Skip to content

Commit 8913953

Browse files
fix(latency): strip case-variant spend-logs spoofs, stamp selection on pre-built responses
1 parent ccb0331 commit 8913953

8 files changed

Lines changed: 147 additions & 28 deletions

switchyard/lib/backends/latency_service_llm_backend.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,15 @@ def _pick_by_latency(
368368
# -- Request processing (hot path — no Latency Service call) ------------
369369

370370
async def call(self, ctx: ProxyContext, request: ChatRequest) -> ChatResponse:
371+
"""Serve *request* on the best healthy endpoint, failing over on error.
372+
373+
Candidates are ordered by health and observed latency (with optional
374+
session affinity); every upstream attempt is stamped with the
375+
spend-logs metadata header, and the attempt that succeeds records its
376+
route selection on *ctx* (see :data:`CTX_ROUTE_SELECTION`). Exhausting
377+
all candidates re-raises the last upstream error, annotated for the
378+
client-facing error envelope.
379+
"""
371380
# This backend records its own per-attempt ``outcome_metrics`` counters
372381
# below (one per failover attempt). Claim attempt accounting for this
373382
# request so the endpoint-layer fallback in ``dispatch`` /
@@ -675,21 +684,33 @@ async def _call_endpoint(
675684
body: dict[str, object],
676685
extra_headers: dict[str, str],
677686
) -> 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+
"""Make one upstream SDK call with the protected *extra_headers* stamped on.
688+
689+
``extra_headers`` rides the client wrapper's ``**kwargs`` into the
690+
OpenAI SDK's per-request header merge (over ``default_headers``). A
691+
passthrough body may itself carry an SDK-style ``extra_headers`` field
692+
(shape-preserving translation keeps unknown keys); it is still
693+
forwarded — it used to ride ``**body`` into the same SDK parameter —
694+
but the protected headers must win the merge so callers can't spoof
695+
them, and header names are case-insensitive on the wire while dict
696+
keys are not, so every case variant of a protected name is stripped
697+
from the client mapping (a client-cased duplicate would otherwise
698+
reach the wire alongside ours and win first-match parsing on the
699+
receiving proxy). A non-mapping value is dropped: it could only ever
700+
have broken the SDK call.
701+
"""
687702
client_extra = body.pop("extra_headers", None)
703+
protected = {name.lower() for name in extra_headers}
688704
headers: dict[str, object] = (
689-
{**client_extra, **extra_headers}
705+
{
706+
name: value
707+
for name, value in client_extra.items()
708+
if not (isinstance(name, str) and name.lower() in protected)
709+
}
690710
if isinstance(client_extra, Mapping)
691-
else dict(extra_headers)
711+
else {}
692712
)
713+
headers.update(extra_headers)
693714
if target_request_type == ChatRequestType.OPENAI_RESPONSES:
694715
return await self._clients[model_id].aresponses(
695716
api_key=api_key,

switchyard/lib/endpoints/anthropic_messages_endpoint.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ async def anthropic_messages(
7474
request: Request,
7575
body: Annotated[dict[str, Any], Body(...)],
7676
) -> Response:
77+
"""Anthropic-compatible Messages endpoint."""
7778
obj = request.app.state.switchyard
7879
_strip_unsupported_output_config(body)
7980
model = str(body.get("model", "<none>"))

switchyard/lib/endpoints/dispatch.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,16 +101,22 @@ def serialize_chain_result(
101101
) -> Response:
102102
"""Serialize a chain result to the appropriate HTTP response.
103103
104-
Returns the result as-is if it is already a ``Response``, wraps it in a
104+
Returns the result itself if it is already a ``Response``, wraps it in a
105105
``StreamingResponse`` when streaming is requested, or JSON-serializes it.
106106
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.
107+
response headers on every branch, pre-built responses included (streaming
108+
too — the backend call completed before the response object is built, so
109+
the selection is final). ``ctx`` is required so a new endpoint cannot
110+
silently opt out of spend attribution.
110111
"""
112+
headers = route_selection_headers(ctx)
111113
if isinstance(result, Response):
114+
# Merge rather than pass through untouched: no current chain path
115+
# yields a pre-built Response after a billed upstream success, but if
116+
# one ever does, dropping the recorded selection here would silently
117+
# break spend attribution.
118+
result.headers.update(headers)
112119
return result
113-
headers = route_selection_headers(ctx)
114120
if stream and hasattr(result, "__aiter__"):
115121
return StreamingResponse(
116122
sse_iter(result), media_type="text/event-stream", headers=headers

switchyard/lib/endpoints/openai_chat_endpoint.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ async def chat_completions(
7373
request: Request,
7474
body: Annotated[dict[str, Any], Body(...)],
7575
) -> Response:
76+
"""OpenAI-compatible Chat Completions endpoint."""
7677
obj = request.app.state.switchyard
7778
chat_request = ChatRequest.openai_chat(body)
7879
# Reject semantically invalid input (e.g. empty messages) at the

switchyard/lib/endpoints/responses_endpoint.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ async def responses(
5757
request: Request,
5858
body: Annotated[dict[str, Any], Body(...)],
5959
) -> Response:
60+
"""OpenAI-compatible Responses endpoint."""
6061
obj = request.app.state.switchyard
6162
model = str(body.get("model", "<none>"))
6263
stream = bool(body.get("stream"))

tests/_chain_test_helpers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ class Handler(BaseHTTPRequestHandler):
151151
protocol_version = "HTTP/1.1"
152152

153153
def do_POST(self) -> None:
154+
"""Record the request (path, body, headers) and pop the queued reply."""
154155
length = int(self.headers.get("content-length", "0"))
155156
raw = self.rfile.read(length)
156157
body = json.loads(raw.decode("utf-8"))

tests/test_latency_service_llm_backend.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,6 +1012,7 @@ class TestRouteSelectionSpendLogs:
10121012
"""
10131013

10141014
async def test_outbound_header_carries_route_selection(self):
1015+
"""The outbound header records the full selection for the chosen endpoint."""
10151016
config = LatencyServiceBackendConfig(
10161017
latency_service_url=LATENCY_SERVICE_URL,
10171018
endpoints=[
@@ -1040,6 +1041,7 @@ async def test_outbound_header_carries_route_selection(self):
10401041
uuid.UUID(str(payload["router_correlation_id"]))
10411042

10421043
async def test_ctx_records_selection_matching_outbound_header(self):
1044+
"""ctx records exactly the payload the wire header carried."""
10431045
backend = _make_backend(_config("model-A"))
10441046
backend._clients["model-A"].acompletion = AsyncMock(
10451047
return_value=_make_completion()
@@ -1055,6 +1057,7 @@ async def test_ctx_records_selection_matching_outbound_header(self):
10551057
assert payload["router_selected_provider"] == "model-A"
10561058

10571059
async def test_failover_restamps_selection_and_keeps_correlation_id(self):
1060+
"""Each attempt is stamped with its own endpoint under one correlation id."""
10581061
backend = _make_backend(_config("model-A", "model-B"))
10591062
_set_health(
10601063
backend,
@@ -1082,6 +1085,7 @@ async def test_failover_restamps_selection_and_keeps_correlation_id(self):
10821085
assert ctx.metadata[CTX_ROUTE_SELECTION] == second
10831086

10841087
async def test_correlation_id_is_fresh_per_request(self):
1088+
"""Two client requests never share a correlation id."""
10851089
backend = _make_backend(_config("model-A"))
10861090
mock = AsyncMock(return_value=_make_completion())
10871091
backend._clients["model-A"].acompletion = mock
@@ -1094,6 +1098,7 @@ async def test_correlation_id_is_fresh_per_request(self):
10941098
assert first["router_correlation_id"] != second["router_correlation_id"]
10951099

10961100
async def test_router_model_falls_back_to_configured_route_model(self):
1101+
"""A model-less client body attributes to the configured route id."""
10971102
backend = _make_backend(
10981103
_config("model-A", route_model="nvidia/switchyard/gpt-5.5")
10991104
)
@@ -1108,6 +1113,7 @@ async def test_router_model_falls_back_to_configured_route_model(self):
11081113
assert payload["router_model"] == "nvidia/switchyard/gpt-5.5"
11091114

11101115
async def test_no_selection_recorded_when_all_attempts_fail(self):
1116+
"""No billed success → no selection recorded on ctx."""
11111117
backend = _make_backend(_config("model-A"))
11121118
backend._clients["model-A"].acompletion = AsyncMock(
11131119
side_effect=_api_status_error(401),
@@ -1120,6 +1126,7 @@ async def test_no_selection_recorded_when_all_attempts_fail(self):
11201126
assert CTX_ROUTE_SELECTION not in ctx.metadata
11211127

11221128
async def test_responses_surface_is_stamped_too(self):
1129+
"""The Responses-API surface is stamped like the Chat surface."""
11231130
backend = _make_backend(_config("model-A", request_type="openai_responses"))
11241131
backend._clients["model-A"].aresponses = AsyncMock(
11251132
return_value={"id": "resp-test", "object": "response", "output": []}
@@ -1134,6 +1141,34 @@ async def test_responses_surface_is_stamped_too(self):
11341141
assert payload["router_selected_endpoint"] == "model-A"
11351142
assert payload["router_strategy"] == "latency"
11361143

1144+
async def test_cased_spoof_of_spend_logs_header_is_stripped(self):
1145+
"""A differently-cased spoof key cannot ride client extra_headers to the wire.
1146+
1147+
Header names are case-insensitive on the wire while dict merges are
1148+
not: exactly one instance of the protected header — ours — may reach
1149+
the SDK, while benign client headers still pass through.
1150+
"""
1151+
backend = _make_backend(_config("model-A"))
1152+
mock = AsyncMock(return_value=_make_completion())
1153+
backend._clients["model-A"].acompletion = mock
1154+
1155+
await backend.call(
1156+
ProxyContext(),
1157+
_openai_request(
1158+
extra_headers={
1159+
"X-LiteLLM-Spend-Logs-Metadata": "spoofed",
1160+
"x-client-tag": "42",
1161+
},
1162+
),
1163+
)
1164+
1165+
headers = mock.call_args.kwargs["extra_headers"]
1166+
spoof_keys = [k for k in headers if k.lower() == SPEND_LOGS_METADATA_HEADER]
1167+
assert spoof_keys == [SPEND_LOGS_METADATA_HEADER]
1168+
payload = _spend_logs_payload(mock)
1169+
assert payload["router_selected_endpoint"] == "model-A"
1170+
assert headers["x-client-tag"] == "42"
1171+
11371172

11381173
# ---------------------------------------------------------------------------
11391174
# Credential policy

0 commit comments

Comments
 (0)