Skip to content

feat(proxy+analytics): routing header injector and LiteLLM metadata capture - #513

Merged
yanaSelin merged 3 commits into
codemie-ai:EPMCDME-14083from
BolotnyaBoss:EPMCDME-14083
Aug 28, 2026
Merged

feat(proxy+analytics): routing header injector and LiteLLM metadata capture#513
yanaSelin merged 3 commits into
codemie-ai:EPMCDME-14083from
BolotnyaBoss:EPMCDME-14083

Conversation

@BolotnyaBoss

Copy link
Copy Markdown
Contributor

Continuation of PR #503

Adds two components to complete the routing decisions capture and reporting:

feat(proxy): add routing header injector plugin (037da99)

  • Proxy plugin (priority 55) that injects routing headers into response bodies
  • Supports both x-litellm-* (LiteLLM) and x-codemie-* (Switchyard) header families
  • Handles JSON (buffers and merges) and SSE (stores in metadata, injects into message_start)
  • Headers captured by prefix matching — new fields automatically recorded
  • 20 unit tests, all passing

feat(analytics): capture LiteLLM routing metadata (c5c1627)

  • Extends usage-readers.ts to extract 6 previously-dropped LiteLLM headers:
    • x-litellm-router-score, x-litellm-model-name, routed model, classifier model, router type, requested model
  • Introduces routingFamily discriminator ('switchyard' | 'litellm')
  • Session-level routingCostKnown flag: true when all routed turns came from a family that reports cost
  • Report surfaces routing cost as 'unmeasured' for LiteLLM instead of absent
  • KPI panel counts both measured and unmeasured routed sessions

Result

Completes the routing decisions flow: headers → plugin body injection → analytics extraction → report timeline and KPIs.
Both LiteLLM and Switchyard deployments now render equivalent routing views.

Sviatoslav Likhtarchyk and others added 3 commits August 27, 2026 23:26
…isions in transcripts

The upstream router (Switchyard or LiteLLM) reports routing decisions in HTTP
response headers: tier selection, decision rationale, classifier model, cost, etc.
Headers are forwarded downstream but agents do not persist them, so the decision
is lost when the turn ends.

This plugin captures routing headers and injects them into the response body at
the top level (JSON) or into the first `message_start` SSE event's `message`
object (streaming). The agent then persists these fields in its native transcript
alongside `usage`, making the routing metadata auditable and queryable.

Routing header families supported:
  - x-litellm-*: LiteLLM router complexity tier, classifier model, routed model, score
  - x-codemie-*: Switchyard tier, decision source, confidence, judge cost and tokens

Headers are captured by prefix matching, so new fields in either family are
automatically recorded without code changes.

Priority: 55 (after logging at 50, before session-sync at 100)
- JSON path: buffers and merges fields at top level
- SSE path: stores headers in context.metadata, injects into first message_start chunk
- Malformed inputs (parse errors, split events, non-JSON) are passed through unchanged

Tested against real session data from both Switchyard and LiteLLM deployments.
Verified: both families' vocabularies captured correctly, authorization headers
not leaked, other SSE events preserved, robustness against edge cases.

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
…ting cost observability

Extend the analytics pipeline to fully capture LiteLLM router headers alongside
Switchyard ones, treating both families equally in the cost and timeline views.

Changes to cost/usage-readers.ts:
- Hoist parseOptInt and parseOptFloat to module scope (used by LiteLLM extraction)
- Add ClaudeRawMessage fields for the 6 previously-undeclared LiteLLM headers:
  x-litellm-router-score, x-litellm-model-name (the actual deployment target)
- Introduce routingFamily discriminator ('switchyard' | 'litellm') so consumers can
  distinguish between a zero routing cost (Switchyard reports it explicitly) and an
  unmeasurable one (LiteLLM never emits cost headers despite running classifiers)
- Extract routingTierRaw to preserve both 2-tier (efficient/capable) and 4-tier
  (SIMPLE/MEDIUM/COMPLEX/REASONING) vocabularies before normalization, allowing
  future refinement of the mapping without data loss
- Populate routedModel (LiteLLM: router-routed-model), classifierModel (which LLM
  decided), routerType (e.g. 'complexity'), routerScore, and requestedModel
  now falls back to x-litellm-router-model-name when Switchyard isn't present

Changes to cost-enricher.ts:
- Pass the new fields through to ModelTimelinePoint
- Track routingCostKnown at session level: true when all routed turns came from a
  family that reports cost (Switchyard), false when any turn used LiteLLM
  (reading an absent judgeCostUSD then means 'unmeasured', not '/bin/zsh')

Changes to report types and payload-builder:
- Carry routingCostKnown through to the rendered report

Changes to report/client/app.js:
- Routing (judge) cost row now shows '— unmeasured (LiteLLM)' instead of being
  absent when cost is unknown
- KPI panel includes both measured and unmeasured routed sessions, surfacing the
  count and noting where cost data is incomplete

Result: LiteLLM and Switchyard deployments now render equivalent routing timelines
and KPI views, with the caveat that LiteLLM routing overhead is visible in tier
distribution and decision source but not in cost totals (missing upstream headers).

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
…se body and headers

Show the model the LiteLLM router or CodeMie Switchyard actually dispatched to,
not just the nominal model from Claude Code's configuration. Detect routing via:
1. Explicit routing headers (x_codemie_routing_capable_model, x-litellm-router-routed-model)
2. Response body's own model field, normalized to strip Bedrock qualifiers

Use a normalized comparison (family matching: opus/sonnet/haiku/fable) to avoid
false-positive arrows when aliases resolve to their concrete provider snapshots.
Display format when routing differs: [Claude Sonnet 5 → claude-opus-5]

- Export normalizeModelId() for use in display path (was private)
- Add modelId to extractBasicInfo() to compare against candidate
- Layers response-body detection on top of header detection
- Never throws on missing/unreadable transcript; gracefully degrades

Fixes: claude-only-expensive-no-aff showing spurious arrows to bedrock backend ids
@yanaSelin
yanaSelin merged commit d25469e into codemie-ai:EPMCDME-14083 Aug 28, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants