Skip to content

Commit 536c949

Browse files
fix(proxy/openai): propagate provider usage on the Responses WS->HTTP fallback (#2988)
## Description When Codex uses the OpenAI Responses WebSocket endpoint through Headroom and the upstream WebSocket is rejected, Headroom falls back to HTTPS POST/SSE. On that fallback the dashboard reported zero or tiny input tokens for a large request, and invalid savings: ```json { "input_tokens_original": 3, "input_tokens_optimized": 0, "output_tokens": 246, "tokens_saved": 31052, "savings_percent": 33233.33 } ``` ## Root cause `_ws_http_fallback` (openai.py) relays the SSE `data:` events to the client but never parses the terminal `response.completed` event for usage. The non-fallback WS path accumulates `_extract_responses_usage(event)` into the session totals on every `response.completed` frame (openai.py ~8182); the fallback path did not. So `ws_input_tokens_total` stayed at the small local count, and the session-end RequestLog computed `optimized_tokens = residual_input_tokens = 0`, leaving `tokens_saved > input_tokens_original` and `savings_percent` far above 100%. ## Fix `_ws_http_fallback` now parses each relayed `response.completed` line with the existing `_extract_responses_usage` and returns the accumulated `(input, output, cache_read, cache_write, uncached)` provider usage. The caller folds it into the WS session totals, so the session-end outcome uses the authoritative provider wire-token count -- bringing the fallback to parity with the non-fallback WS path. SSE relay behaviour is otherwise unchanged. Fixes #2957 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature - [ ] Breaking change - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/proxy/handlers/openai.py` (`_ws_http_fallback`): accumulate usage from `response.completed` SSE lines (both the main relay loop and the buffer flush) and return the `(input, output, cache_read, cache_write, uncached)` tuple from every exit path; the WS handler caller adds it to `ws_input_tokens_total` / `ws_output_tokens_total` / cache / uncached totals before the session-end RequestLog. - `tests/test_ws_http_fallback.py`: the fallback returns the provider usage from a `response.completed` event (input/output/cache_read/uncached), and returns all-zeros when no completed event arrives. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check`) - [x] Type checking passes (`mypy`) - [x] New tests added ### Test Output ```text tests/test_ws_http_fallback.py 13 passed (11 existing + 2 new) # uvx ruff@0.15.22 check -> All checks passed! # uvx mypy@1.20.2 headroom/proxy/handlers/openai.py -> Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1, ruff 0.15.22 and mypy 1.20.2 via uvx. - Exact command / steps: drove `_ws_http_fallback` with the existing WS/stream mocks, feeding an SSE `response.completed` carrying `usage.input_tokens=31055`, `output_tokens=246`, `input_tokens_details.cached_tokens=20000`. The method now returns `(31055, 246, 20000, ..., 11055)`; a stream with no completed event returns all zeros. The existing 11 relay/routing/retry tests are unchanged (they ignore the new return value). - Observed result: the fallback surfaces the provider's real input usage, so the WS session-end outcome records the actual input tokens instead of 0, and savings percentages stay within a meaningful range. - Not tested: a live Codex WS session that triggers the upstream-WS rejection and HTTP fallback end to end (needs a real upstream refusing the WS). The usage-propagation contract is verified at the fallback boundary with the same mocks the existing fallback tests use. ## Runtime Rollout Safety - Rollout-managed feature(s): none. The OpenAI Responses WS-to-HTTP fallback is always-on transport behavior, not rollout-channel-gated. - Minimum rollout channel: N/A (no rollout-managed behavior). - Stable/default behavior changed: yes, as a bug fix. On the WS-to-HTTP fallback the session-end outcome now records the provider's real input/output/cache usage from `response.completed` instead of leaving `ws_input_tokens_total` at 0 (which produced >100% savings). SSE relay to the client is unchanged. - Kill switch / disable path: N/A. This corrects accounting only; there is no behavioral toggle and no user-facing surface beyond the recorded outcome numbers. - Unsafe override required: no. - Qualification impact: fallback-path token accounting now matches the non-fallback WS path and the HTTP Responses path (all three use `_extract_responses_usage`); savings percentages return to a valid range. - Rollback path: revert this PR; the fallback returns to reporting zero input usage on this path. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md`: it is generated by release-please from my Conventional Commit PR title ## Additional Notes The fix reuses the already-present `_extract_responses_usage` (same parser the non-fallback WS path and HTTP Responses path use), so cache-read/write and uncached accounting stay consistent across all three transports. Co-authored-by: JD Davis <mxjerrett@gmail.com>
1 parent a06a51e commit 536c949

2 files changed

Lines changed: 86 additions & 5 deletions

File tree

headroom/proxy/handlers/openai.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8602,9 +8602,23 @@ async def _record_ws_response_metrics() -> None:
86028602
f"[{request_id}] WS upstream failed ({_ws_detail}), "
86038603
f"falling back to HTTP POST streaming"
86048604
)
8605-
await self._ws_http_fallback(
8605+
(
8606+
fb_input_tokens,
8607+
fb_output_tokens,
8608+
fb_cache_read_tokens,
8609+
fb_cache_write_tokens,
8610+
fb_uncached_tokens,
8611+
) = await self._ws_http_fallback(
86068612
websocket, body, first_msg_raw, upstream_headers, request_id
86078613
)
8614+
# Fold the fallback's provider usage into the session totals so
8615+
# the WS session-end outcome records the authoritative wire-token
8616+
# count instead of 0 (#2957).
8617+
ws_input_tokens_total += fb_input_tokens
8618+
ws_output_tokens_total += fb_output_tokens
8619+
ws_cache_read_tokens_total += fb_cache_read_tokens
8620+
ws_cache_write_tokens_total += fb_cache_write_tokens
8621+
ws_uncached_input_tokens_total += fb_uncached_tokens
86088622

86098623
# ── WS session-end metric + RequestLog ──────────────────
86108624
#
@@ -8850,14 +8864,31 @@ async def _ws_http_fallback(
88508864
first_msg_raw: str,
88518865
upstream_headers: dict[str, str],
88528866
request_id: str,
8853-
) -> None:
8867+
) -> tuple[int, int, int, int, int]:
88548868
"""Fall back to HTTP POST streaming when upstream WS fails.
88558869
88568870
Converts the WS ``response.create`` message to an HTTP POST to
88578871
``/v1/responses?stream=true``, reads SSE events, and relays each
88588872
``data:`` line as a WS text message to the client. This makes
88598873
Codex work immediately instead of exhausting its WS retry budget.
8874+
8875+
Returns ``(input, output, cache_read, cache_write, uncached)`` provider
8876+
usage parsed from the ``response.completed`` SSE event. The caller folds
8877+
it into the session totals so the WS session-end outcome uses the
8878+
authoritative wire-token count; otherwise a fallback recorded
8879+
``input_tokens=0`` and savings percentages blew past 100 (#2957).
88608880
"""
8881+
fallback_usage = [0, 0, 0, 0, 0]
8882+
8883+
def _accumulate_usage(data_str: str) -> None:
8884+
try:
8885+
event = json.loads(data_str)
8886+
except (json.JSONDecodeError, TypeError):
8887+
return
8888+
if isinstance(event, dict) and event.get("type") == "response.completed":
8889+
for i, value in enumerate(_extract_responses_usage(event)):
8890+
fallback_usage[i] += value
8891+
88618892
# Route to correct endpoint based on auth mode
88628893
is_chatgpt_fallback = has_chatgpt_account_header(upstream_headers)
88638894
if is_chatgpt_fallback:
@@ -8963,7 +8994,7 @@ async def _ws_http_fallback(
89638994
},
89648995
}
89658996
await websocket.send_text(json.dumps(error_event))
8966-
return
8997+
return tuple(fallback_usage) # type: ignore[return-value]
89678998

89688999
# Refresh Codex /stats from the fallback response
89699000
# headers. We can't forward them onto the client 101
@@ -8989,10 +9020,11 @@ async def _ws_http_fallback(
89899020
data = line[6:]
89909021
if data == "[DONE]":
89919022
continue
9023+
_accumulate_usage(data)
89929024
try:
89939025
await websocket.send_text(data)
89949026
except Exception:
8995-
return
9027+
return tuple(fallback_usage) # type: ignore[return-value]
89969028
elif line.startswith("event: "):
89979029
# SSE event type — skip, the data line contains the type
89989030
continue
@@ -9001,9 +9033,10 @@ async def _ws_http_fallback(
90019033
for line in buffer.strip().splitlines():
90029034
line = line.strip()
90039035
if line.startswith("data: ") and line[6:] != "[DONE]":
9036+
_accumulate_usage(line[6:])
90049037
with contextlib.suppress(Exception):
90059038
await websocket.send_text(line[6:])
9006-
return
9039+
return tuple(fallback_usage) # type: ignore[return-value]
90079040
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as http_err:
90089041
if http_attempt >= retry_attempts - 1:
90099042
raise
@@ -9034,6 +9067,7 @@ async def _ws_http_fallback(
90349067
finally:
90359068
with contextlib.suppress(Exception):
90369069
await websocket.close()
9070+
return tuple(fallback_usage) # type: ignore[return-value]
90379071

90389072
def _derived_compress_pipeline(self, key: str, **overrides: Any) -> Any:
90399073
"""Cached ``/v1/compress`` pipeline derived from the live OpenAI router.

tests/test_ws_http_fallback.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,53 @@ def stream(self, method, url, **kwargs):
310310

311311
assert "api.openai.com" in captured_url["url"]
312312

313+
def test_fallback_returns_provider_usage_from_completed_event(self):
314+
"""The fallback must surface the provider's input usage (#2957).
315+
316+
Otherwise the WS session-end outcome records input_tokens=0 for a large
317+
request and savings percentages blow past 100.
318+
"""
319+
handler = _make_handler()
320+
ws = FakeWebSocket()
321+
completed = {
322+
"type": "response.completed",
323+
"response": {
324+
"usage": {
325+
"input_tokens": 31055,
326+
"output_tokens": 246,
327+
"input_tokens_details": {"cached_tokens": 20000},
328+
}
329+
},
330+
}
331+
sse_lines = [
332+
'data: {"type":"response.created","response":{"id":"r1"}}\n\n',
333+
f"data: {json.dumps(completed)}\n\n",
334+
"data: [DONE]\n\n",
335+
]
336+
handler.http_client = FakeHttpClient(FakeStreamResponse(200, sse_lines))
337+
338+
body = {"model": "gpt-5.4", "input": "big context"}
339+
usage = asyncio.run(handler._ws_http_fallback(ws, body, json.dumps(body), {}, "req_usage"))
340+
341+
input_tokens, output_tokens, cache_read, _cache_write, uncached = usage
342+
assert input_tokens == 31055
343+
assert output_tokens == 246
344+
assert cache_read == 20000
345+
assert uncached == 31055 - 20000
346+
347+
def test_fallback_returns_zero_usage_without_completed_event(self):
348+
handler = _make_handler()
349+
ws = FakeWebSocket()
350+
handler.http_client = FakeHttpClient(
351+
FakeStreamResponse(200, ['data: {"type":"response.created"}\n\n', "data: [DONE]\n\n"])
352+
)
353+
usage = asyncio.run(
354+
handler._ws_http_fallback(
355+
ws, {"model": "gpt-5.4", "input": "hi"}, json.dumps({"input": "hi"}), {}, "req_none"
356+
)
357+
)
358+
assert usage == (0, 0, 0, 0, 0)
359+
313360
def test_fallback_refreshes_codex_rate_limit_state(self, monkeypatch):
314361
"""A successful fallback refreshes Codex /stats from response headers.
315362

0 commit comments

Comments
 (0)