Technical specifications for each roadmap feature, in implementation order. Each section covers: what changes, where in the codebase, config schema additions, new API endpoints, test requirements, and dependencies on prior features.
Reference the current architecture in architecture.md and the public roadmap in ROADMAP.md.
Priority: 1 (build first)
Estimated scope: ~400 lines, new file kvpinning.go + integration into router.go
Dependencies: None
When a vLLM cluster has multiple nodes, round-robin routing forces each node to recompute the KV cache for multi-turn conversations. A 100k-token system prompt gets reprocessed on every turn if the request lands on a different node.
Maintain a radix trie in memory, keyed on the SHA-256 prefix of the first N messages in the prompt array (the "stable prefix"). Each leaf stores the backend name that last processed this prefix and a timestamp.
On routing:
- Compute the prefix hash from the request's messages (all messages except the last user message).
- Look up the prefix in the trie.
- If a match exists and the backend is healthy and available, prefer it over the rule-ordered candidate list (insert it at position 0).
- If the pinned backend is down or at capacity, fall through to normal routing.
- After a successful response, update the trie with the backend that handled it.
Entries expire after a configurable TTL (default: 30 minutes, matching typical vLLM KV cache eviction).
| File | Change |
|---|---|
kvpinning.go (new) |
PrefixTrie struct: Insert(prefixHash, backendName), Lookup(prefixHash) -> (backendName, found), evictionLoop(). Thread-safe with sync.RWMutex. |
router.go |
In RouteCandidates(), after rule evaluation produces candidates, call prefixTrie.Lookup(). If a pinned backend is in the candidate list, move it to index 0. If not in candidates but healthy, prepend it. |
proxy.go |
After successful response in handleChatCompletions(), call prefixTrie.Insert(prefixHash, backend.Config.Name). |
config.go |
Add KVPinning field to ServerConfig. |
main.go |
Initialise PrefixTrie, start eviction loop. |
func computePrefixHash(messages []ChatMessage) string {
// Use all messages except the last (which is the new user turn)
if len(messages) <= 1 {
return ""
}
prefix := messages[:len(messages)-1]
data, _ := json.Marshal(prefix)
h := sha256.Sum256(data)
return hex.EncodeToString(h[:16])
}server:
kv_pinning:
enabled: true # Default: false
ttl: 30m # How long to remember prefix-to-backend mapping
max_entries: 10000 # Max trie entries before LRU evictionkronaxis_router_kv_pin_hits_total # Requests routed to a pinned backend
kronaxis_router_kv_pin_misses_total # Prefix not found or pinned backend unavailable
kronaxis_router_kv_pin_entries # Current trie size (gauge)
- Insert and lookup: prefix found, correct backend returned.
- TTL expiry: entry expires after TTL, lookup returns miss.
- Eviction: entries evicted when max_entries exceeded (oldest first).
- Pinned backend down: lookup returns miss, falls through to normal routing.
- Single-message request: no prefix hash computed, no pinning.
- Integration: two requests with same prefix routed to same backend.
- Integration: pinned backend at max_concurrent, falls through.
- The trie does not need to be a true radix trie for v1. A flat
map[string]pinnedEntrykeyed on the prefix hash is sufficient. The "radix trie" name describes the concept (prefix-based routing); the implementation can start simple and optimise later if needed. - Only applies to backends with
type: vllm. Cloud APIs manage their own caching.
Priority: 2 (build second)
Estimated scope: ~500 lines, new file sessions.go + integration into proxy.go
Dependencies: None (but compounds with KV Pinning: session context is always the same prefix, guaranteeing pin hits)
Agentic workflows re-upload the entire conversation context with every HTTP request. A Claude Code session with a 100k-token system prompt sends those 100k tokens 50+ times.
Session creation:
- Client sends a request with
X-Kronaxis-Session-Create: trueheader. - Router processes the request normally.
- Router stores the full messages array (minus the last user message) keyed by a generated session ID.
- Response includes
X-Kronaxis-Session-ID: sess_<uuid>header.
Session hydration:
- Client sends a request with
X-Kronaxis-Session-ID: sess_<uuid>header and only the new message(s). - Router looks up the stored context and prepends it to the request's messages array.
- The hydrated request is forwarded to the backend as if the client had sent the full context.
- Router updates the stored session with the new messages (appends user message + assistant response).
Session lifecycle:
- Sessions expire after a configurable TTL (default: 2 hours).
X-Kronaxis-Session-End: trueheader explicitly destroys a session.- Max sessions configurable (default: 1000), LRU eviction.
| File | Change |
|---|---|
sessions.go (new) |
SessionStore struct: Create(messages) -> sessionID, Hydrate(sessionID, newMessages) -> fullMessages, Update(sessionID, newMessages), Destroy(sessionID), evictionLoop(). Thread-safe. |
proxy.go |
In handleChatCompletions(), before routing: check for session headers. If Session-Create, store after response. If Session-ID, hydrate before routing. If Session-End, destroy. |
config.go |
Add Sessions field to ServerConfig. |
main.go |
Initialise SessionStore, start eviction. |
api.go |
GET /api/sessions (list active), DELETE /api/sessions?id=X (destroy). |
mcp.go |
Add router_sessions and router_create_session tools. |
server:
sessions:
enabled: true # Default: false
ttl: 2h # Session expiry
max_sessions: 1000 # Max concurrent sessions, LRU eviction
max_context_tokens: 500000 # Max tokens stored per session (safety limit)| Header | Direction | Purpose |
|---|---|---|
X-Kronaxis-Session-Create |
Request | true to create a new session from this request's context |
X-Kronaxis-Session-ID |
Request/Response | Session identifier for hydration |
X-Kronaxis-Session-End |
Request | true to destroy the session |
X-Kronaxis-Session-Tokens |
Response | Token count of stored session context |
| Endpoint | Method | Purpose |
|---|---|---|
/api/sessions |
GET | List active sessions (ID, token count, created, last used) |
/api/sessions?id=X |
DELETE | Destroy a session |
kronaxis_router_sessions_active # Current active sessions (gauge)
kronaxis_router_session_hydrations_total # Requests hydrated from session context
kronaxis_router_session_tokens_saved # Tokens not re-transmitted (counter)
- Create session: returns session ID in response header.
- Hydrate: subsequent request with session ID gets full context.
- Update: session context grows with each turn.
- TTL expiry: session expires, hydration returns 404.
- Explicit destroy: session deleted, hydration returns 404.
- Max sessions: oldest session evicted when limit reached.
- Invalid session ID: returns 400, not 500.
- No session header: request processed normally (no regression).
- Integration with KV pinning: session requests pin to same backend.
When a session is hydrated, the prefix hash is computed from the session's stored context. This guarantees the same prefix hash on every turn, which means KV pinning always routes to the same backend. The combination is powerful: zero re-upload AND zero KV recomputation.
Priority: 3
Estimated scope: ~200 lines, new file queueaware.go + integration into backends.go and router.go
Dependencies: None (but complements KV Pinning)
Health checks tell you if a backend is alive, not if it's busy. When one vLLM node has 50 requests queued and another has 2, routing to the first adds unnecessary latency.
Periodically scrape the /metrics endpoint of each vLLM backend. Parse two Prometheus counters:
vllm:num_requests_waiting(queued, not yet processing)vllm:num_requests_running(currently generating)
Store these as QueueDepth and ActiveInference on the Backend struct. In the routing path, when multiple backends are candidates, sort by queue depth (ascending).
| File | Change |
|---|---|
queueaware.go (new) |
QueueScraper: periodic goroutine, HTTP GET to /metrics, parse Prometheus text format for the two counters. Update Backend.QueueDepth and Backend.ActiveInference. |
backends.go |
Add QueueDepth int64 and ActiveInference int64 fields to Backend. Add QueueLoad() int64 method (returns QueueDepth + ActiveInference). Add these to backendStatusInfo JSON. |
router.go |
In resolveAllBackends(), after filtering healthy backends, sort by QueueLoad() ascending. Only sort when queue_aware_routing is enabled. |
config.go |
Add QueueAwareRouting and QueueScrapeInterval to ServerConfig. |
main.go |
Start QueueScraper goroutine if enabled. |
Only need to extract two specific counter values. No need for a full Prometheus parser:
func parseVLLMMetrics(body []byte) (waiting, running int64) {
for _, line := range strings.Split(string(body), "\n") {
if strings.HasPrefix(line, "vllm:num_requests_waiting ") {
fmt.Sscanf(line, "vllm:num_requests_waiting %d", &waiting)
}
if strings.HasPrefix(line, "vllm:num_requests_running ") {
fmt.Sscanf(line, "vllm:num_requests_running %d", &running)
}
}
return
}server:
queue_aware_routing: true # Default: false
queue_scrape_interval: 5s # Default: 5skronaxis_router_backend_queue_depth{backend} # Scraped queue depth per backend (gauge)
kronaxis_router_backend_active_inference{backend} # Active inference count per backend (gauge)
- Parse vLLM metrics: extract waiting and running counts.
- Parse empty/malformed metrics: returns 0, 0 (no crash).
- Sort by queue load: backends sorted correctly.
- Queue-aware disabled: no sorting applied.
- Non-vLLM backends: skipped by scraper (no /metrics probe for cloud APIs).
- Integration: request routed to least-loaded backend.
KV pinning suggests a backend based on cache warmth. Queue-aware LB suggests a backend based on load. When both are active, the logic is:
- KV pinning finds a pinned backend.
- If the pinned backend's queue load is below a threshold (e.g., 2x the average), use it (warm cache is worth some extra wait).
- If the pinned backend is heavily loaded, fall through to queue-aware ordering.
This threshold is configurable: kv_pinning.max_queue_ratio: 2.0 (default).
Priority: 4
Estimated scope: ~200 lines added to qualitygate.go
Dependencies: None
The existing quality gate validates response quality by checking length, JSON validity, and refusal patterns. It does not validate against a user-supplied schema. A cheap model that returns {"name": "foo"} when the schema requires {"name": string, "score": number} passes the current checks but breaks the caller.
Add a new quality check: SchemaValidation. The user supplies a JSON Schema via:
X-Kronaxis-Response-Schemaheader (inline JSON Schema), orresponse_format.json_schemafield in the OpenAI request body (native OpenAI structured output)
If the response fails schema validation, the quality gate retries on the fallback backend.
| File | Change |
|---|---|
qualitygate.go |
Add SchemaJSON string to GateChecks. In passesChecks(), add schema validation step: unmarshal response content, validate against schema. Use Go's encoding/json for basic type/required-field validation (no external JSON Schema library needed for v1). |
proxy.go |
Extract X-Kronaxis-Response-Schema header and response_format.json_schema from request body. Pass schema string to quality gate. |
config.go |
No config changes needed (schema is per-request, not per-rule). |
For v1, validate:
- Response is valid JSON
- All
requiredfields are present - Field types match (
string,number,boolean,array,object) - Nested objects validated recursively
This covers 95% of use cases without adding a JSON Schema library dependency. Full JSON Schema Draft 2020-12 compliance can be added later if needed.
func validateAgainstSchema(content string, schemaJSON string) bool {
var schema map[string]interface{}
if err := json.Unmarshal([]byte(schemaJSON), &schema); err != nil {
return true // Invalid schema = skip validation
}
var data interface{}
if err := json.Unmarshal([]byte(content), &data); err != nil {
return false // Response is not valid JSON
}
return validateNode(data, schema)
}| Header | Direction | Purpose |
|---|---|---|
X-Kronaxis-Response-Schema |
Request | JSON Schema for response validation |
kronaxis_router_schema_validations_total # Requests validated against schema
kronaxis_router_schema_failures_total # Schema validation failures (retried)
- Valid response against schema: passes.
- Missing required field: fails, retries on fallback.
- Wrong type (string where number expected): fails.
- Nested object validation: works recursively.
- No schema provided: quality gate works as before (no regression).
- Invalid schema JSON: skipped gracefully.
- Array response validated correctly.
- Markdown-fenced JSON response: fences stripped before validation.
Priority: 5
Estimated scope: ~80 lines, new file dpo.go + integration into qualitygate.go
Dependencies: Feature 4 (Schema Quality Gate) for schema-based failures, but also works with existing quality gate failures
Every quality gate fallback (cheap model fails, expensive model succeeds) is a free training pair for Direct Preference Optimization. Currently these are logged in the audit log but not in a training-ready format.
When a quality gate fallback fires:
- Log the prompt, the rejected response (cheap model), and the chosen response (expensive model) as a JSONL line.
- Append to a configurable output file.
- Rotate after N entries.
| File | Change |
|---|---|
dpo.go (new) |
DPOLogger struct: LogPair(prompt, rejected, chosen). Writes JSONL. File rotation. Thread-safe. |
qualitygate.go |
After a fallback fires in both GateSequential and GateParallel, call dpoLogger.LogPair() with the prompt, cheap response, and expensive response. |
config.go |
Add DPO field to a new QualityGateConfig sub-struct or top-level. |
main.go |
Initialise DPOLogger if enabled. |
{"prompt": [{"role": "user", "content": "..."}], "chosen": "expensive model output", "rejected": "cheap model output", "timestamp": "2026-04-14T10:30:00Z", "cheap_backend": "local-small", "expensive_backend": "cloud-fast"}quality_gate:
dpo_export:
enabled: true
output_path: "dpo_training_data.jsonl" # Default
max_file_size_mb: 100 # Rotate after 100MB- Pair logged on quality gate fallback.
- JSONL format is valid (each line parses independently).
- No logging when quality gate passes (cheap model succeeded).
- File rotation at max size.
- Disabled: no file created.
Priority: 6
Estimated scope: ~60 lines added to backends.go
Dependencies: None
The current health check interval is 30 seconds. If a provider throws five 503s in 2 seconds, traffic keeps flowing to it until the next probe.
Add a sliding window error tracker per backend. When N errors occur within M seconds, immediately set status to StatusDown. Recovery: on the next scheduled health check, if the probe succeeds, restore to StatusHealthy.
| File | Change |
|---|---|
backends.go |
Add errorTimestamps []time.Time to Backend. Add RecordError() method: appends timestamp, checks if last N timestamps fall within M seconds, trips circuit if so. Add CircuitBreaker config fields to BackendConfig. Modify checkOne() to clear error timestamps on successful probe. |
proxy.go |
After a failed forwardWithRetry(), call backend.RecordError() (currently only increments Failures). |
config.go |
Add CircuitBreaker struct to BackendConfig. |
backends:
- name: cloud-fast
circuit_breaker:
error_threshold: 5 # Default: 5
window_seconds: 10 # Default: 10- Circuit trips after N errors in M seconds.
- Circuit does not trip for N-1 errors.
- Circuit does not trip for N errors spread over >M seconds.
- Circuit recovers on next successful health check.
- No circuit breaker config: existing behaviour unchanged.
Priority: 7
Estimated scope: ~40 lines added to abtest.go and proxy.go
Dependencies: None
How do you prove a cheaper model delivers comparable quality before committing to the switch?
Add a mode field to ABTestConfig: "split" (existing behaviour) or "shadow". In shadow mode:
- 100% of traffic goes to
variant_a(returned to caller). split_pct% is also sent tovariant_b(fire-and-forget, response logged but not returned).- Compare outputs using the existing quality validator similarity metric.
| File | Change |
|---|---|
abtest.go |
Add Mode string field (split or shadow). In SelectVariant(), when mode is shadow, always return variant_a as the primary, but set a flag indicating shadow dispatch is needed. Add ShadowResults stats (similarity scores, cost delta). |
proxy.go |
After response from primary, if shadow flag is set, fire-and-forget goroutine sends the same request to variant_b. Log the comparison. |
ab_tests:
- name: gemini-migration
match: { service: "my-api" }
variant_a: cloud-expensive
variant_b: cloud-cheap
split_pct: 10
mode: shadow # "split" (default) or "shadow"| Endpoint | Method | Purpose |
|---|---|---|
/api/ab-tests |
GET | List A/B tests with stats (including shadow comparison metrics) |
- Shadow mode: caller always gets variant_a response.
- Shadow dispatch: variant_b receives traffic (verify via stats).
- Split mode: unchanged behaviour (no regression).
- Shadow comparison logged: similarity score recorded.
Priority: 8
Estimated scope: ~50 lines added to costs.go and api.go
Dependencies: None
Budget enforcement catches you at the limit. By then it's too late to adjust workloads.
Linear extrapolation from current spend:
- At time T, service S has spent $X since midnight.
- Hours elapsed = T - midnight.
- Projected daily spend = $X * (24 / hours_elapsed).
- Estimated budget exhaustion time = midnight + (daily_limit / hourly_rate) hours.
| File | Change |
|---|---|
costs.go |
Add Forecast(service) -> (projectedDailySpend, exhaustionTime) method. |
api.go |
Add GET /api/costs/forecast endpoint: returns per-service forecast. |
mcp.go |
Add router_cost_forecast tool. |
| Endpoint | Method | Response |
|---|---|---|
/api/costs/forecast |
GET | [{"service": "my-api", "spent_today": 12.50, "projected_daily": 45.00, "budget": 50.00, "exhaustion_time": "2026-04-14T14:15:00Z"}] |
- Forecast at 25% of day with 25% of budget spent: projects hitting limit at EOD.
- Forecast at 10% of day with 50% of budget: projects exhaustion before noon.
- No spend yet: returns null exhaustion time.
- No budget set: returns projected spend without exhaustion time.
Priority: 9
Estimated scope: ~150 lines, new file sla.go + integration into router.go
Dependencies: None (benefits from queue-aware data)
Static rules route to backends that may be experiencing latency spikes. A rule saying "use cloud-fast" doesn't help when cloud-fast has a P95 of 3 seconds.
Track a rolling window (last 100 requests) of TTFT per backend. Expose P50() and P95() methods. In the routing path, when a rule has a max_ttft_ms constraint, filter out backends whose P95 exceeds it.
| File | Change |
|---|---|
sla.go (new) |
LatencyTracker struct per backend: circular buffer of TTFT values, Record(ttft), P50(), P95() methods. |
proxy.go |
After first token received (or full response for non-streaming), record TTFT via latencyTracker.Record(). |
router.go |
In resolveAllBackends(), if rule has max_ttft_ms > 0, filter backends where P95 exceeds the limit. |
config.go |
Add MaxTTFTms to RoutingRule. |
rules:
- name: latency-sensitive
match: { priority_level: interactive }
backends: [local-fast, cloud-fast]
max_ttft_ms: 800 # Filter backends with P95 TTFT above thiskronaxis_router_backend_ttft_p50_ms{backend} # Rolling P50 TTFT (gauge)
kronaxis_router_backend_ttft_p95_ms{backend} # Rolling P95 TTFT (gauge)
- P50/P95 computed correctly from known values.
- Circular buffer wraps: old values replaced.
- Backend filtered when P95 exceeds max_ttft_ms.
- No max_ttft_ms: no filtering applied.
- All backends filtered: falls through to default chain (not empty result).
Priority: 10
Estimated scope: ~100 lines added to mcp.go
Dependencies: Features 2, 8 (session management, cost forecasting)
| Tool | Purpose |
|---|---|
router_sessions |
List active sessions (ID, token count, age) |
router_create_session |
Create a session from provided messages |
router_cost_forecast |
Per-service cost forecast and budget exhaustion estimate |
router_shadow_results |
Shadow routing comparison results |
router_backend_queue |
Queue depth and active inference per backend |
These are thin wrappers around the API endpoints added by the preceding features.
Week 1: Feature 1 (KV Pinning) + Feature 6 (Circuit Breaking)
Week 2: Feature 2 (Session Management) + Feature 5 (DPO Export)
Week 3: Feature 3 (Queue-Aware LB) + Feature 4 (Schema Quality Gate)
Week 4: Feature 7 (Shadow Routing) + Feature 8 (Cost Forecasting)
Week 5: Feature 9 (SLA Routing) + Feature 10 (MCP Expansion)
Features within each week are independent and can be built in parallel.
| File | Feature | Lines (est.) |
|---|---|---|
kvpinning.go |
KV Cache Pinning | ~200 |
kvpinning_test.go |
Tests | ~150 |
sessions.go |
Session Management | ~300 |
sessions_test.go |
Tests | ~200 |
queueaware.go |
Queue-Aware LB | ~120 |
queueaware_test.go |
Tests | ~80 |
dpo.go |
DPO Export | ~60 |
dpo_test.go |
Tests | ~40 |
sla.go |
SLA Routing | ~100 |
sla_test.go |
Tests | ~60 |
Estimated total: ~1,310 new lines + ~200 lines of modifications to existing files.
server:
port: 8050
health_check_interval: 30s
default_timeout: 120s
# New: KV pinning
kv_pinning:
enabled: false
ttl: 30m
max_entries: 10000
max_queue_ratio: 2.0 # Max queue load ratio before ignoring pin
# New: Queue-aware routing
queue_aware_routing: false
queue_scrape_interval: 5s
# New: Session management
sessions:
enabled: false
ttl: 2h
max_sessions: 1000
max_context_tokens: 500000
backends:
- name: my-backend
url: "http://localhost:8000"
type: vllm
# ... existing fields ...
# New: Circuit breaker
circuit_breaker:
error_threshold: 5
window_seconds: 10
rules:
- name: my-rule
# ... existing fields ...
# New: SLA constraint
max_ttft_ms: 0 # 0 = no constraint
# New: Quality gate DPO export
quality_gate:
# ... existing fields ...
dpo_export:
enabled: false
output_path: "dpo_training_data.jsonl"
max_file_size_mb: 100
# New: Shadow mode for A/B tests
ab_tests:
- name: test
mode: split # "split" or "shadow"
# ... existing fields ...| Endpoint | Method | Feature |
|---|---|---|
/api/sessions |
GET | Session Management |
/api/sessions?id=X |
DELETE | Session Management |
/api/costs/forecast |
GET | Cost Forecasting |
/api/ab-tests |
GET | Shadow Routing |
| Header | Feature |
|---|---|
X-Kronaxis-Session-ID |
Session Management |
X-Kronaxis-Session-Tokens |
Session Management |
X-Kronaxis-KV-Pin: HIT |
KV Pinning |
| Variable | Default | Feature |
|---|---|---|
SESSION_MAX_SIZE |
1000 |
Session Management |
SESSION_TTL_SECONDS |
7200 |
Session Management |
DPO_OUTPUT_PATH |
dpo_training_data.jsonl |
DPO Export |