fix(ai): record token usage on Responses early-abort (#100954)#101608
fix(ai): record token usage on Responses early-abort (#100954)#101608Yigtwxx wants to merge 7 commits into
Conversation
5ddb3cb to
ffc98ec
Compare
|
Codex review: needs real behavior proof before merge. Reviewed July 16, 2026, 4:21 PM ET / 20:21 UTC. Summary PR surface: Source +91, Tests +104. Total +195 across 7 files. Reproducibility: no. for the linked report: there is no high-confidence current-main reproduction of user cancellation or first-byte timeout. The distinct completed-only handling of a server-generated Review metrics: none identified. Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Proof guidance:
Risk before merge
Maintainer options:
Next step before merge
Maintainer decision needed
Security Review findings
Review detailsBest possible solution: Treat this as a narrowly scoped server-incomplete accounting fix, keep #100954 open for the distinct client-abort behavior, and validate the narrowed change with a redacted real OpenAI or Azure truncation run before merge. Do we have a high-confidence way to reproduce the issue? No for the linked report: there is no high-confidence current-main reproduction of user cancellation or first-byte timeout. The distinct completed-only handling of a server-generated Is this the best way to solve the issue? No as currently scoped: the shared mapper is a maintainable fix for server-incomplete responses, but it should not close a report about client aborts unless that lifecycle is reproduced and shown to reach this branch. Full review comments:
Overall correctness: patch is incorrect AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 8f74762f4dbb. Label changesLabel justifications:
Evidence reviewedPR surface: Source +91, Tests +104. Total +195 across 7 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
Review history (10 earlier review cycles; latest 8 shown)
|
|
@clawsweeper re-review The prior review was against an earlier commit. Since then this PR:
The PR body has been updated with the reasoning and evidence. |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
Thanks for taking this @vincentkoc. ClawSweeper re-reviewed at head ( I don't have access to an OpenAI or Azure provider key, so I can't capture that transcript myself. Rather than leave it stalled, here's the exact repro if anyone on the team wants to run it — it's a single call and costs about a cent:
That before/after pair is precisely the behavior the review asks for, and it also closes the caveat in the review itself ("I did not establish a live Azure/OpenAI early-abort reproduction"). Endpoint, key and deployment name would need redacting before posting. If capturing that isn't worth a maintainer's time, the alternative is to merge on patch quality with human judgment — the diff is confined to two terminal stream branches, CI is green, and security was cleared. Happy either way; just let me know which and I'll rebase (the branch is currently behind |
56d48ba to
a0655cf
Compare
|
Rebased onto current The conflict came from the module splits that landed since (#105894, #106006, #106354): Local verification: both affected suites pass ( |
7e9e007 to
2386323
Compare
|
CI status after the rebase: 65 checks green, including The one remaining red, |
2386323 to
c22256d
Compare
|
Rebased onto current The |
c22256d to
b41c88d
Compare
|
CI is now fully green at head For the record: the brief red burst earlier today was inherited from Nothing outstanding on my side — the proof-question framed in the review above remains the only open item. |
Azure/OpenAI Responses streams that end with response.incomplete (e.g. max_output_tokens, content filter) never sent response.completed, so output.usage was left at zero and cache-hit and other billed tokens were dropped from telemetry and cost accounting. Extract the usage split into a mapResponsesUsage helper and record usage on the response.incomplete event, keeping cost, service-tier, and stop-reason mapping specific to response.completed. Closes openclaw#100954
…paths The initial fix recorded token usage on response.incomplete but reused a usage object whose cost fields are all zero and never ran calculateCost, so session accounting (which sums usage.cost.total) still under-reported cost for early-terminated streams. Route response.incomplete through the same terminal usage+cost path as response.completed via a shared recordResponsesTerminalUsage helper, and apply the same fix to the sibling Responses processor in src/agents/openai-transport-stream.ts, which had the identical completed-only handling. Regression tests now assert nonzero cost.
The TypeScript LOC ratchet forbids growth in oversized legacy files, and adding recordResponsesTerminalUsage inline grew both Responses processors past their base line counts. Extract the helper (and mapResponsesUsage on the packages/ai side) into small dedicated modules; both legacy files now end up below their pre-change size. No behavior change. Co-Authored-By: Claude Fable 5 <[email protected]>
ClawSweeper flagged two parallel terminal usage/cost implementations (packages/ai and src/agents) as a billing source of truth that could drift. Move the priced bucket split, calculateCost, and service-tier pricing into a single exported mapResponsesTerminalUsage in the package and re-export it through the internal barrel. The agent helper becomes a thin adapter that only layers on the agent-local reasoningTokens field, which the normalized Usage type does not model. This removes the duplicated billing invariant and the agent-side as-never casts; behavior is preserved (both processor suites stay green). Co-Authored-By: Claude Opus 4.8 <[email protected]>
b41c88d to
ac75a64
Compare
|
Addressed the [P1] "use one canonical terminal-usage helper" finding, and rebased onto current The billing invariant — priced bucket split, On the one field that stays agent-local: the agent transport keeps a thin adapter that adds only Local verification after the rebase:
The remaining open item is unchanged: the proof-override vs. live-transcript decision for the @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
CI note: the red The failing assertion is Evidence it is a base issue, not mine:
Nothing to fix on this branch — hand-editing the locale catalogs would be out of scope for a billing change and would collide with |
|
@vincentkoc — the re-review at To keep the call low-friction, the two options are unchanged:
Happy either way — just say which and I'll act. On CI: the current reds are inherited from |
…arly-abort-usage # Conflicts: # packages/ai/src/internal/openai.ts # packages/ai/src/providers/openai-responses-shared.ts
|
CI note: the current red checks are not from this diff. Every failing test shard reports the same root cause: That table was added to the canonical schema by #108543 ( The remaining failure ("OpenGrep — PR Diff") is an infra error ("Failed to fetch available versions from GitHub" while downloading the scanner), unrelated to code. I'll update the branch once main picks up the STRICT fix so this goes back to green (it was fully green at the previous round). |
|
Follow-up on the CI note above: the schema fix landed on main as #108850 ( |
|
Update: no maintainer re-run needed — both infra flakes cleared on their own. I merged current On the "Merge conflicts" banner that showed up earlier: there was no real conflict. GitHub's No code changes in this update — the diff is still the same usage mapping plus its regression tests, and the targeted suites pass locally (65/65). Original note, kept for context: the two reds were Install opengrep ( |
|
Closing this in favor of #109904, which fixes what is left of #100954 in a much narrower change. Why: #109615 (merged) rebuilt the package-side processor to finalize What #109615 did not touch is the agent-side processor. On current Rather than rebase 342 commits of drift and then delete half the diff, #109904 is a fresh, smaller change One note that may be useful for the @vincentkoc unassigning yourself is fine — no action needed on this one. |
What Problem This Solves
Azure/OpenAI Responses streams that end early with
response.incomplete(e.g.max_output_tokensreached, content filter) never sendresponse.completed. Both Responses stream processors onmainassignoutput.usageonly inside theresponse.completedbranch, so these early-terminated streams leave usage — and its cost — at zero, dropping the reported token counts (including partial cache-hit tokens) and their cost from telemetry and session accounting. This is the drift described in #100954.Why This Change Was Made
The
response.incompleteevent carries a fullResponseobject with the sameusageshape (input_tokens/output_tokens/total_tokens+input_tokens_details) asresponse.completed. The fix records that usage — and computes its cost — when the stream terminates early. Both Responses processors had the identical completed-only defect, so both are fixed:packages/ai/src/providers/openai-responses-shared.tssrc/agents/openai-responses-transport.tsBoth
response.completedand the newresponse.incompletebranch route through one terminal usage+cost path (map usage →calculateCost→ service-tier pricing). Cost must run for the incomplete path too because session accounting sumsusage.cost.total(src/agents/sessions/agent-session.ts); recording tokens without cost would still under-report. Stop-reason mapping and completed-only backfill stay specific toresponse.completed. The abort lifecycle already forwards a populatedoutput.usageinto the emitted error event, so no changes to the abort path or the genericnormalizeUsagewere needed.Single Billing Source of Truth
Per review feedback ([P1] "use one canonical terminal-usage helper"), the terminal usage/cost invariant now lives in one shared mapper —
mapResponsesTerminalUsageinpackages/ai/src/providers/openai-responses-terminal-usage.ts, re-exported via@openclaw/ai/internal/openai. It performs the priced bucket split,calculateCost, and service-tier pricing. Both processors call it:recordResponsesTerminalUsagewrites the priced usage onto the packageAssistantMessage.reasoningTokensfield, which the normalizedUsagetype (packages/llm-core/src/types.ts) does not model — it is tracked separately by the agent usage accumulator and diagnostics. The shared mapper returns aUsage; the agent layers reasoning on top.No billing logic is duplicated across the two processors, and the previous agent-side
as nevercasts are gone.User Impact
Token usage and cost telemetry stay accurate when a Responses stream is cut short, instead of silently reporting zero. No change to normal (
response.completed) streams or to provider defaults.Evidence
Regression tests drive a real
response.incompleteevent (carrying nonzero tokens, including cache reads/writes) through the actual stream processors with noresponse.completed, then assert both the token buckets and a nonzero computed cost:packages/ai/src/providers/openai-responses-shared.test.ts→ 52 passed (early-abort test assertsusage.cost.total > 0and exact per-bucket cost)src/agents/openai-transport-stream.test.ts→ 331 passed (same regression added for the sibling processor)src/agents/usage.normalization.test.ts→ 10 passedtsgocore) → clean on the touched files; LOC ratchet clean.Note on live proof: a redacted real-Azure early-abort transcript would require forcing
max_output_tokens/content-filter termination against a live deployment, which I can't capture here. The regression tests instead feed the exact SDKresponse.incompleteevent shape (per the OpenAI NodeResponseIncompleteEvent→Response.usagecontract) through the unmodified processors, so they exercise the real terminal code path rather than a mock.Closes #100954
This is AI-assisted work.