fix(gateway): meter OpenAI usage from the shapes the wire actually sends - #262
Conversation
The gateway parsed OpenAI usage with Responses-only, top-level names, so Chat Completions (prompt_tokens/completion_tokens) and streamed Responses (usage nested in the response.completed envelope - the wire Codex speaks) both metered to zero: hard budgets never accumulated for Codex runs. The stubs encoded the same wrong shapes, so the assertions could not catch it (theam#232). Both stubs now emit the wire truth - with those stubs alone, tests 3 and 3b fail against the old parser at "expected +0 to be 600000/750000". usageFromJson accepts both naming schemes, the stream path also reads the response envelope, and cached tokens are subtracted from input: OpenAI reports input INCLUSIVE of cached while costCents sums buckets additively in the Anthropic convention, so the old accounting billed cached tokens at both the input and cache-read rates. Metering assertions now pin the full bucket math (600k/400k cached at 216 cents; 750k/250k/200k at 987.5). Closes theam#232 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f7b2d46 to
b8fe764
Compare
usage.test.ts is the unit suite for this module and covered Anthropic only: five cases including split frames and malformed ones, and nothing for OpenAI. So the parsing this branch adds had integration coverage but no unit coverage, and CLAUDE.md asks for both on a change to billing. That gap matters more than the rule alone suggests. The integration test that does cover these shapes lives in gateway.test.ts, which needs Postgres and skips silently when it is unreachable - a local run reports "4 passed" with 32 tests skipped, so on a developer machine nothing exercises this at all. These cases run everywhere. Three cases, one per shape the branch teaches the parser: - Chat Completions naming, with prompt_tokens inclusive of cached, so 1000 reported with 400 cached meters 600 input and 400 cache-read. Reading only input_tokens/output_tokens meters the stream at zero, which is the hard-budget bypass; leaving input at 1000 bills the cached tokens at the input rate on top of the cache-read rate. - The response.completed envelope Codex streams, where usage never appears at the frame's top level. The preceding response.created frame carries no usage and must not erase the total. - A non-streamed body using the chat naming, asserting input stays as reported when no cached details accompany it. All three fail against the previous parser with inputTokens 0. The five Anthropic cases are untouched and still pass. Addresses: review finding - services/gateway/test/usage.test.ts Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyXT6m6npUH46VBuY1mPGM
|
Reviewed this and pushed one commit to the branch ( What I verified independently, driving the old and new parser directly rather than trusting the description:
Both streaming paths meter to exactly zero on The one gap: no unit coverage. That gap is worse than the rule alone implies. The integration test that does cover these shapes needs Postgres and skips silently when it is unreachable: a local run here reported So Gateway suite 28 passed / 1 skipped, Two things I looked at and deliberately left alone: |
|
Thank you — this is the kind of review a branch dreams about: an independent verification table, a commit that closes a real gap, and explicit notes on what you chose not to touch. The unit-coverage point lands especially well. The silently-skipping integration suite is a masked failure of exactly the class this fix targets: On the two things you left alone, I read them the same way: no OpenAI wire shape reports a cache-creation count, so the asymmetric naming on From my side the branch is exactly as you left it — |
|
Thanks — and agreed on leaving both out. On the clamp: I'd go one step further and say a debug log is the version I'd argue against, precisely for the reason you spotted. If that state is worth knowing about, the useful version is louder and lives elsewhere: metered usage that cannot be reconciled against what the provider reported is a signal for whatever already watches spend, not a line in the parser. That is a design question rather than a line change, and it belongs to whoever owns budget reconciliation — adjacent to the #248/#251/#255 work you flagged as out of scope here. Happy to open an issue framed that way if you want it tracked; I'd rather not file "add a debug log" as a ticket, since that is the kind that sits open forever. Branch confirmed as you describe: One thing worth flagging for whoever merges: |
Closes #232 — and full credit to @Julian-Genuario, whose diagnosis was exact down to the sentence "the tests encode the wrong shapes." I claimed this in the issue thread with right of way offered; the courtesy window has passed, so here is the fix, built the way #181 was.
Stubs first, so the tests bite (the #181 pattern)
Both OpenAI stubs now emit what the wire actually sends — Chat Completions speaks
prompt_tokens/completion_tokens(withprompt_tokens_details.cached_tokenson the final include_usage chunk), and the/responsesstub streams the real envelope: usage nested inside{"type":"response.completed","response":{...}}, never at the frame's top level. With the corrected stubs alone, tests 3 and 3b fail against the old parser exactly as the issue predicted:Then the parser
usageFromJsonaccepts both naming schemes; the stream path also reads theresponse.completedenvelope (the wire Codex speaks) alongside top-level usage.costCentssums buckets additively in the Anthropic convention — so cached tokens are subtracted from input, or they get billed at both the input and cache-read rates.Pinned money math
The assertions now check the full buckets, not just presence: 1M prompt with 400k cached + 1M completion on gpt-5.5-mini = 216¢ (was silently 225 with cached double-billed and names lucky-matched); the streamed Codex path (1M/250k cached/200k out on gpt-5.6-sol) = 987.5¢ — previously zero, which is the hard-budget bypass in the issue title.
Gateway suite: 57 tests, 0 failures, tsc clean. Adjacent, not overlapping: #248/#251/#255 handle incomplete usage reconciliation; this handles usage that arrives complete but was unparseable.