Skip to content

Commit ccb0331

Browse files
feat(latency): pass route-selection spend metadata to LiteLLM
1 parent a2a939d commit ccb0331

13 files changed

Lines changed: 853 additions & 16 deletions

.agents/skills/switchyard-lib-core/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ right validation set. If the change is driven by a launcher need, also read
4343
| Stats / telemetry | Reuse `StatsRequestProcessor`, `StatsResponseProcessor`, `StatsLlmBackend`, and `StatsAccumulator`. A profile config should thread one accumulator through all three when stats are enabled. Do not write a parallel collector. |
4444
| A fixed-path endpoint contributed by per-route components | Set `Endpoint.register_once = True`; `build_switchyard_app(...)` mounts the first instance while still running every component's lifecycle. Leave the default `False` for configurable endpoint classes that may mount distinct instances. |
4545
| Per-endpoint attribution on `/metrics` for a Python backend that can't be wrapped by `StatsLlmBackend` | Set `ctx.selected_model = endpoint_id` before returning the response. Also set `ctx.backend_call_latency_ms = upstream_call_ms` so the response processor can compute routing overhead. `LatencyServiceLLMBackend.call` is the reference. |
46+
| Spend/tokenomics attribution when a front proxy (LiteLLM) sits above Switchyard | Stamp each upstream attempt with the `x-litellm-spend-logs-metadata` request header (selection re-stamped per failover attempt, one `router_correlation_id` uuid per client request) and record the successful attempt's selection in `ctx.metadata[CTX_ROUTE_SELECTION]`; the endpoint layer maps it to the `x-switchyard-*` response headers via `route_selection_headers` in `lib/endpoints/route_selection.py` — stamped by `dispatch.serialize_chain_result` on success and by `upstream_error.handle_chain_exception` when a failure follows a billed upstream success. Header values are sanitized there (client-controlled `router_model` must stay legal header material). `LatencyServiceLLMBackend.call` is the reference; the payload contract lives on `CTX_ROUTE_SELECTION` in `switchyard/lib/proxy_context.py`. |
4647
| State metrics on `/metrics` | Register a `PrometheusEmitter` via `switchyard.lib.endpoints.prometheus_emitter.register(...)` and unregister on `shutdown()`. This is for backend-owned state, not request-flow counters. |
4748
| Error-rate / retry-recovery counters | Use `switchyard.lib.endpoints.outcome_metrics`. FastAPI middleware records client outcomes. A retrying Python backend records each upstream attempt itself (and `record_retry_recovered()`) and sets `CTX_UPSTREAM_ATTEMPTS_RECORDED` so the endpoint skips its fallback — `LatencyServiceLLMBackend.call` is the reference. Single-attempt backends (Rust native / passthrough / multi) record nothing themselves; the endpoint fallback (`record_upstream_attempt_success` / `record_upstream_attempt_failure` in `upstream_error.py`, called from `dispatch_chat_request` and `handle_chain_exception`) counts their one attempt. Don't add a `model` label — these counters are layer-aggregate. Keep labels bounded. For a per-model error breakdown, emit a backend-owned counter via the `PrometheusEmitter` instead (labels bounded to config-derived ids: the route id `config.route_model` + endpoint ids, else the `other` sentinel) — `LatencyServiceLLMBackend._render_prometheus_lines` exposes `switchyard_latency_upstream_attempts_total{requested_model,upstream_model,outcome,code}` next to the aggregate, leaving the shared counter model-free. |
4849
| Per-event error log | Use `switchyard.lib.endpoints.upstream_error_log.log_upstream_attempt_failure(...)` on the failure path. Events belong in logs/traces, not Prometheus sample timestamps. |

docs/internal/latency_service_routing.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,52 @@ front of Switchyard (e.g. LiteLLM) should tag its own failures the same way and
431431
propagate these headers from below. Mid-stream failures after HTTP 200 has been
432432
committed cannot carry headers and are not annotated today.
433433

434+
## Route-selection spend attribution
435+
436+
For tokenomics reporting, a LiteLLM front proxy needs to tie its provider spend-log
437+
rows back to the Switchyard logical route that selected them. The backend stamps
438+
**every upstream attempt** with a spend-logs metadata request header (LiteLLM copies
439+
its JSON value into the provider spend-log DB row):
440+
441+
```text
442+
x-litellm-spend-logs-metadata: {"router_model": "<client-facing route id>",
443+
"router_strategy": "latency",
444+
"router_selected_endpoint": "<endpoint id>",
445+
"router_selected_model": "<upstream model>",
446+
"router_selected_provider": "<upstream model's leading path segment>",
447+
"router_correlation_id": "<uuid4>"}
448+
```
449+
450+
One correlation id is generated per client request and shared by every failover
451+
attempt; the selection fields are re-stamped per attempt, so each provider row
452+
records the endpoint it actually hit. `router_model` is the model the client asked
453+
for, falling back to the configured `route_model` when the body carried no model.
454+
455+
**Successful** responses then return the selection that served the request, so the
456+
front proxy can enrich its own (parent) spend-log row with the same correlation id
457+
the provider row received:
458+
459+
| Header | Value |
460+
|---|---|
461+
| `x-switchyard-router-model` | `router_model` from the selection |
462+
| `x-switchyard-selected-model` | `router_selected_model` |
463+
| `x-switchyard-selected-provider` | `router_selected_provider` |
464+
| `x-switchyard-router-correlation-id` | `router_correlation_id` |
465+
466+
Streaming responses carry the same headers (the upstream call completes before the
467+
SSE response is committed). Error responses carry the failure-source headers above;
468+
the selection headers join them only when an upstream call already **succeeded**
469+
before the failure (e.g. a response-translation error after a billed 200), so the
470+
provider row's correlation id stays joinable — a request that never reached a
471+
successful upstream claims no selection. Because the client controls `router_model`,
472+
header values are re-validated as header material: a value that is not
473+
latin-1-encodable or contains control characters is omitted rather than breaking
474+
(or splitting) the response; the outbound JSON header is immune (`json.dumps`
475+
escapes it) and still records the raw value. The cross-component contract is
476+
`CTX_ROUTE_SELECTION` in `switchyard/lib/proxy_context.py` (written by the backend,
477+
read by the endpoint layer via `switchyard/lib/endpoints/route_selection.py`);
478+
wire-level proofs live in `tests/test_route_selection_headers.py`.
479+
434480
## Observability
435481

436482
When `enable_stats` is `true` (the default), `LatencyServiceProfileConfig` wires a

switchyard/lib/backends/latency_service_llm_backend.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,13 @@
2020
Responses-mode endpoints receive the OpenAI Responses API natively.
2121
"""
2222

23+
import json
2324
import logging
2425
import random
2526
import threading
2627
import time
28+
import uuid
29+
from collections.abc import Mapping
2730
from dataclasses import dataclass
2831

2932
from openai import APIStatusError, AsyncStream
@@ -46,6 +49,7 @@
4649
from switchyard.lib.proxy_context import (
4750
CTX_CALLER_API_KEY,
4851
CTX_ERROR_SOURCE,
52+
CTX_ROUTE_SELECTION,
4953
CTX_UPSTREAM_ATTEMPTS_RECORDED,
5054
CTX_UPSTREAM_HTTP_BODY,
5155
CTX_UPSTREAM_HTTP_STATUS,
@@ -63,6 +67,14 @@
6367

6468
log = logging.getLogger(__name__)
6569

70+
#: Request header read by a LiteLLM front proxy; its JSON value is copied into
71+
#: the provider spend-log DB row, tying that row back to the Switchyard route
72+
#: that selected it (see :data:`CTX_ROUTE_SELECTION` for the payload contract).
73+
SPEND_LOGS_METADATA_HEADER = "x-litellm-spend-logs-metadata"
74+
75+
#: ``router_strategy`` value identifying this backend's selection algorithm.
76+
ROUTER_STRATEGY_LATENCY = "latency"
77+
6678

6779
@dataclass(frozen=True)
6880
class _RouteDecision:
@@ -380,6 +392,10 @@ async def call(self, ctx: ProxyContext, request: ChatRequest) -> ChatResponse:
380392
# Captured before the per-attempt ``body["model"]`` override so the span
381393
# records the model the client asked for, not the selected endpoint.
382394
incoming_model = request.model
395+
# One correlation id per client request, shared by every failover
396+
# attempt's spend-logs header and by the response headers — the join
397+
# key between a front proxy's spend-log row and the provider rows.
398+
correlation_id = str(uuid.uuid4())
383399
# Resolve the session-affinity pin once (keyed on the stable conversation
384400
# prefix). ``None`` when affinity is disabled or the conversation isn't
385401
# pinned yet, so every attempt routes purely by health + latency.
@@ -426,6 +442,15 @@ async def call(self, ctx: ProxyContext, request: ChatRequest) -> ChatResponse:
426442
target_request_type = self._request_types[model_id]
427443
body = self._body_for_endpoint_request_type(ctx, request, target_request_type)
428444
body["model"] = upstream_model
445+
# Per-attempt route-selection record: each upstream call is stamped
446+
# with the endpoint it actually hits, so a failover retry's
447+
# spend-log row doesn't inherit the failed attempt's selection.
448+
route_selection = self._route_selection(
449+
incoming_model=incoming_model,
450+
model_id=model_id,
451+
upstream_model=upstream_model,
452+
correlation_id=correlation_id,
453+
)
429454
log.debug(
430455
"LatencyServiceLLMBackend: attempt=%d model=%s upstream=%s "
431456
"request_type=%s stream=%s",
@@ -450,6 +475,9 @@ async def call(self, ctx: ProxyContext, request: ChatRequest) -> ChatResponse:
450475
target_request_type,
451476
api_key=api_key_override,
452477
body=body,
478+
extra_headers={
479+
SPEND_LOGS_METADATA_HEADER: json.dumps(route_selection),
480+
},
453481
)
454482
except APIStatusError as exc:
455483
set_tags(attempt_span, {
@@ -547,6 +575,12 @@ async def call(self, ctx: ProxyContext, request: ChatRequest) -> ChatResponse:
547575
# Rust ``StatsLlmBackend`` that normally publishes this signal.
548576
ctx.backend_call_latency_ms = backend_latency_ms
549577

578+
# The selection that actually served the request, surfaced to
579+
# the endpoint layer as ``x-switchyard-*`` response headers so
580+
# a front proxy can enrich its own spend-log row with the same
581+
# correlation id the provider row received.
582+
ctx.metadata[CTX_ROUTE_SELECTION] = route_selection
583+
550584
# Pin this conversation to the endpoint that served it so later
551585
# turns reuse it (warm cache). Re-pinning on every success also
552586
# follows a recovery: if the previous pin degraded and we
@@ -605,21 +639,66 @@ def _body_for_endpoint_request_type(
605639
)
606640
return dict(normalized.body)
607641

642+
def _route_selection(
643+
self,
644+
*,
645+
incoming_model: str | None,
646+
model_id: str,
647+
upstream_model: str,
648+
correlation_id: str,
649+
) -> dict[str, str | None]:
650+
"""Build the route-selection payload for one upstream attempt.
651+
652+
This is the JSON value of the outbound ``x-litellm-spend-logs-metadata``
653+
header and, for the attempt that succeeds, the
654+
:data:`CTX_ROUTE_SELECTION` record behind the ``x-switchyard-*``
655+
response headers. ``router_model`` falls back to the configured route id
656+
when the client body carried no model; ``router_selected_provider`` is
657+
the upstream model's leading path segment (IH/LiteLLM naming, e.g.
658+
``"openai/openai/gpt-5.4"`` → ``"openai"``).
659+
"""
660+
return {
661+
"router_model": incoming_model or self._config.route_model,
662+
"router_strategy": ROUTER_STRATEGY_LATENCY,
663+
"router_selected_endpoint": model_id,
664+
"router_selected_model": upstream_model,
665+
"router_selected_provider": upstream_model.split("/", 1)[0],
666+
"router_correlation_id": correlation_id,
667+
}
668+
608669
async def _call_endpoint(
609670
self,
610671
model_id: str,
611672
target_request_type: ChatRequestType,
612673
*,
613674
api_key: str | None,
614675
body: dict[str, object],
676+
extra_headers: dict[str, str],
615677
) -> object:
678+
# ``extra_headers`` rides the client wrapper's ``**kwargs`` into the
679+
# OpenAI SDK's per-request header merge (over ``default_headers``).
680+
# A passthrough body may itself carry an SDK-style ``extra_headers``
681+
# field (shape-preserving translation keeps unknown keys); merge it
682+
# under ours rather than letting it collide with the keyword — it used
683+
# to ride ``**body`` into the same SDK parameter, and the spend-logs
684+
# header must win a name conflict so callers can't spoof it. A
685+
# non-mapping value is dropped: it could only ever have broken the
686+
# SDK call.
687+
client_extra = body.pop("extra_headers", None)
688+
headers: dict[str, object] = (
689+
{**client_extra, **extra_headers}
690+
if isinstance(client_extra, Mapping)
691+
else dict(extra_headers)
692+
)
616693
if target_request_type == ChatRequestType.OPENAI_RESPONSES:
617694
return await self._clients[model_id].aresponses(
618695
api_key=api_key,
696+
extra_headers=headers,
619697
**body,
620698
)
621699
return await self._clients[model_id].acompletion(
622700
api_key=api_key,
701+
extra_headers=headers,
623702
**body,
624703
)
625704

switchyard/lib/endpoints/anthropic_messages_endpoint.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,9 @@ async def anthropic_messages(
106106
stream,
107107
type(result).__name__,
108108
)
109-
return serialize_chain_result(result, stream=stream, sse_iter=iter_anthropic_sse)
109+
return serialize_chain_result(
110+
result, stream=stream, sse_iter=iter_anthropic_sse, ctx=ctx
111+
)
110112
except (SwitchyardContextPoolExhaustedError, SwitchyardContextWindowExceededError) as exc:
111113
return context_exhausted_response(exc, inbound="anthropic")
112114
except Exception as exc:

switchyard/lib/endpoints/dispatch.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from fastapi.responses import JSONResponse, Response, StreamingResponse
1010

1111
from switchyard.lib.endpoints.error_envelope import error_response
12+
from switchyard.lib.endpoints.route_selection import route_selection_headers
1213
from switchyard.lib.endpoints.upstream_error import record_upstream_attempt_success
1314
from switchyard.lib.proxy_context import ProxyContext
1415
from switchyard.lib.roles import TranslatedResponse
@@ -96,16 +97,24 @@ def serialize_chain_result(
9697
*,
9798
stream: bool,
9899
sse_iter: Callable[[Any], AsyncIterator[str]],
100+
ctx: ProxyContext,
99101
) -> Response:
100102
"""Serialize a chain result to the appropriate HTTP response.
101103
102104
Returns the result as-is if it is already a ``Response``, wraps it in a
103105
``StreamingResponse`` when streaming is requested, or JSON-serializes it.
106+
Any route selection recorded on *ctx* is stamped as ``x-switchyard-*``
107+
response headers (streaming included — the backend call completed before
108+
the response object is built, so the selection is final). ``ctx`` is
109+
required so a new endpoint cannot silently opt out of spend attribution.
104110
"""
105111
if isinstance(result, Response):
106112
return result
113+
headers = route_selection_headers(ctx)
107114
if stream and hasattr(result, "__aiter__"):
108-
return StreamingResponse(sse_iter(result), media_type="text/event-stream")
115+
return StreamingResponse(
116+
sse_iter(result), media_type="text/event-stream", headers=headers
117+
)
109118
if hasattr(result, "model_dump"):
110-
return JSONResponse(content=result.model_dump())
111-
return JSONResponse(content=result)
119+
return JSONResponse(content=result.model_dump(), headers=headers)
120+
return JSONResponse(content=result, headers=headers)

switchyard/lib/endpoints/openai_chat_endpoint.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ async def chat_completions(
8989
try:
9090
result: Any = await dispatch_chat_request(obj, chat_request, ctx)
9191
return serialize_chain_result(
92-
result, stream=stream, sse_iter=iter_chat_completion_sse
92+
result, stream=stream, sse_iter=iter_chat_completion_sse, ctx=ctx
9393
)
9494
except (SwitchyardContextPoolExhaustedError, SwitchyardContextWindowExceededError) as exc:
9595
return context_exhausted_response(exc, inbound="openai")

switchyard/lib/endpoints/responses_endpoint.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,9 @@ async def responses(
8484
stream,
8585
type(result).__name__,
8686
)
87-
return serialize_chain_result(result, stream=stream, sse_iter=iter_preframed_sse)
87+
return serialize_chain_result(
88+
result, stream=stream, sse_iter=iter_preframed_sse, ctx=ctx
89+
)
8890
except (SwitchyardContextPoolExhaustedError, SwitchyardContextWindowExceededError) as exc:
8991
return context_exhausted_response(exc, inbound="openai-responses")
9092
except Exception as exc:
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Route-selection response headers for spend/tokenomics attribution.
5+
6+
Maps the :data:`CTX_ROUTE_SELECTION` record a routing backend stored on
7+
``ctx`` (see :mod:`switchyard.lib.proxy_context`) to the ``x-switchyard-*``
8+
response headers a front proxy such as LiteLLM copies into its parent
9+
spend-log row. Shared by the success serializer (``dispatch``) and the
10+
error path (``upstream_error``) — a failure that happens *after* a billed
11+
upstream success must still expose the selection, or the provider spend-log
12+
row's correlation id becomes unjoinable.
13+
"""
14+
15+
from collections.abc import Mapping
16+
17+
from switchyard.lib.proxy_context import CTX_ROUTE_SELECTION, ProxyContext
18+
19+
#: Response headers exposing the route selection behind an upstream call,
20+
#: carrying the same correlation id the provider row received via the
21+
#: outbound ``x-litellm-spend-logs-metadata`` header.
22+
ROUTER_MODEL_HEADER = "x-switchyard-router-model"
23+
SELECTED_MODEL_HEADER = "x-switchyard-selected-model"
24+
SELECTED_PROVIDER_HEADER = "x-switchyard-selected-provider"
25+
ROUTER_CORRELATION_ID_HEADER = "x-switchyard-router-correlation-id"
26+
27+
_ROUTE_SELECTION_RESPONSE_HEADERS = (
28+
(ROUTER_MODEL_HEADER, "router_model"),
29+
(SELECTED_MODEL_HEADER, "router_selected_model"),
30+
(SELECTED_PROVIDER_HEADER, "router_selected_provider"),
31+
(ROUTER_CORRELATION_ID_HEADER, "router_correlation_id"),
32+
)
33+
34+
35+
def _is_header_value_safe(value: str) -> bool:
36+
"""Whether *value* can be emitted as an HTTP/1.1 response-header value.
37+
38+
``router_model`` echoes the client-supplied model string, so it must be
39+
re-validated as header material: Starlette encodes response-header values
40+
as latin-1 (a non-encodable value would fail response construction after
41+
the upstream call already succeeded and was billed), and CTL characters —
42+
CR/LF above all — would be a response-splitting vector on permissive
43+
ASGI stacks.
44+
"""
45+
try:
46+
value.encode("latin-1")
47+
except UnicodeEncodeError:
48+
return False
49+
return not any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in value)
50+
51+
52+
def route_selection_headers(ctx: ProxyContext) -> dict[str, str]:
53+
"""Response headers for the route selection recorded on *ctx*, if any.
54+
55+
Empty when no routing backend recorded a selection (passthrough chains,
56+
failures before any upstream success). A recorded field that is absent or
57+
not emittable as a header value is skipped — headers never carry
58+
placeholder or unsafe values.
59+
"""
60+
selection = ctx.metadata.get(CTX_ROUTE_SELECTION)
61+
if not isinstance(selection, Mapping):
62+
return {}
63+
headers: dict[str, str] = {}
64+
for header_name, selection_key in _ROUTE_SELECTION_RESPONSE_HEADERS:
65+
value = selection.get(selection_key)
66+
if isinstance(value, str) and value and _is_header_value_safe(value):
67+
headers[header_name] = value
68+
return headers

0 commit comments

Comments
 (0)