Sapphire is the LLM gateway for the Luna Protocol ecosystem. It sits between Emerald (the brain) and Krystal (llama.cpp), handling session management, few-shot example injection, emotion classification, degenerate response detection, and prompt construction.
Architecture:
Emerald → Sapphire (HTTP) → Krystal (llama.cpp)
- Emerald sends user messages to Sapphire's
/v1/respondHTTP endpoint - Sapphire classifies the message using fastembed + BAAI-bge-small-en-v1.5 centroid embeddings (FUTILE/INTERESSANT + emotional valence/arousal)
- Sapphire retrieves or creates a conversation session with per-channel history
- Sapphire injects few-shot examples from YAML files into the conversation (right after system prompt)
- Sapphire constructs the prompt with system message, few-shot examples, and conversation history
- Sapphire calls Krystal's
/v1/chat/completionswith emotion-aware sampling parameters - Sapphire checks the response for degenerate patterns and retries if needed (non-streaming)
- Sapphire returns the response text (and optionally debug stats)
Main endpoint called by Emerald. Accepts a text message and session ID, returns a generated response.
Request:
{
"username": "User",
"text": "hello how are you?",
"session_id": "jade:123456",
"stream": false,
"debug": false
}Response (non-streaming):
{
"text": "Hey! I'm good, how about you?",
"label": "FUTILE",
"backend": "http://127.0.0.1:3124",
"valence": 0.12,
"arousal": -0.04
}Response (debug mode):
{
"text": "Hey! I'm good, how about you?",
"label": "FUTILE",
"backend": "http://127.0.0.1:3124",
"valence": 0.12,
"arousal": -0.04,
"debug_prompt_tokens": 450,
"debug_completion_tokens": 12,
"debug_time_ms": 3200,
"debug_tokens_per_second": 3.75,
"debug_emotion_state_valence": 0.6,
"debug_emotion_state_arousal": 0.3,
"debug_classification_confidence": 0.85
}When stream: true is set, Sapphire returns an SSE (Server-Sent Events) stream:
data: Hey
data: !
data: I'm
data: good
data: , how
data: about
data: you
data: ?
data: {"text":"Hey! I'm good, how about you?","label":"FUTILE","backend":"http://127.0.0.1:3124","valence":0.12,"arousal":-0.04,"time_ms":3200,"tokens_per_second":3.8}
data: [DONE]
The stream contains:
- One
data: <token>line per content token - A final
data: {json}line with complete metadata (includes debug fields whendebug: true) - A
data: [DONE]terminator
If the output is degenerate, the stream stops early — no metadata, just data: [DONE]. The session is not updated with degenerate content.
- POST
/v1/reset— Reset a session (or all sessions) - POST
/classify— Classify text without generating a response - GET
/emotion/{conv_key}— Current emotional state for a conversation - GET
/health— Health check with system status
Each message is scored on two continuous axes:
- Valence (−1 to +1): negative to positive sentiment
- Arousal (−1 to +1): calm to aroused
Centroids are computed from examples_emotion.yml (83 positive, 79 negative, 85 high-arousal, 116 low-arousal samples).
When calling Krystal, Sapphire dynamically adjusts sampling parameters:
- Temperature:
clamp(0.7 + arousal * 0.3, 0.4, 1.0)— higher arousal = more randomness - Repeat penalty:
clamp(1.15 - valence * 0.1, 1.0, 1.3)— higher valence = less penalty - Mirostat entropy:
clamp(6.0 + arousal * 2.0, 3.0, 8.0)— higher arousal = more entropy - Emotion deadzone: 0.005 — per-message scores below this threshold don't update the running state
Per-channel/user conversation history with configurable parameters:
LLM_MAX_HISTORY(default: 20) — max messages keptLLM_SESSION_TTL(default: 600s) — session expiryLLM_N_SLOTS(default: 1) — concurrent slots
Up to 5 example exchanges from few_shot_examples.yml are injected after the system prompt and before conversation history. Username prefix is prepended to user examples (e.g., "User: hello").
Regex-based detection of empty, too-short, or character-repetitive outputs. In non-streaming mode, Sapphire retries up to LLM_MAX_RETRIES (default: 2) times. In streaming mode, degenerate output is discarded after buffering — the client never receives it.
FUTILE messages route to KRYSTAL_GENERIC_URL and INTERESSANT messages route to KRYSTAL_SEMANTIC_URL. By default these point to different ports (:3124 / :3125) — set both to the same URL to run a single backend.
The system prompt is configurable via SAPPHIRE_SYSTEM_PROMPT and includes:
- Bot name (from
SAPPHIRE_BOT_NAME, default: "Luna") - Personality description
- Explicit "never repeat or echo" instruction
Key environment variables:
| Variable | Default | Description |
|---|---|---|
SAPPHIRE_PORT |
3123 | HTTP port |
KRYSTAL_GENERIC_URL |
http://127.0.0.1:3124 |
Backend for FUTILE messages |
KRYSTAL_SEMANTIC_URL |
http://127.0.0.1:3125 |
Backend for INTERESSANT messages |
SAPPHIRE_BOT_NAME |
"Luna" | Bot persona name |
SAPPHIRE_EMOTION_DEADZONE |
0.005 | Emotion update threshold |
SAPPHIRE_EMOTION_DECAY |
0.85 | Emotion decay factor |
SAPPHIRE_FEW_SHOT_ENABLED |
true | Enable few-shot injection |
SAPPHIRE_LLM_MAX_RETRIES |
2 | Degenerate retry count |
SAPPHIRE_MIROSTAT_ENABLED |
true | Enable mirostat sampling |
# Install
pip install -r requirements.txt
# Development
python server.py
# Production (PM2)
pm2 start