Skip to content

fix(gateway): adapt failed requests billing - #3546

Merged
steebchen merged 2 commits into
mainfrom
fix/no-invented-output-tokens
Aug 11, 2026
Merged

steebchen merged 2 commits into
mainfrom
fix/no-invented-output-tokens

Conversation

@steebchen

@steebchen steebchen commented Aug 11, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #3505. That PR stopped charging for failed requests; this one fixes the estimator that produced the charges in the first place, and makes any future estimated charge auditable.

Problem

A customer was charged $17.48 for 1,165,619 output tokens on a log row recording 0 of them. Two smaller charges ($2.76, $2.98) have the same shape.

The stream died before OpenAI sent a usage frame, so calculateCosts fell back to estimating a completion count from the tool-call JSON that had accumulated over an hour of agentic work. Four things went wrong at once:

  1. It estimated from a response that never completed — a guess about output we never saw the end of.
  2. The estimate drove the charge but was never written back to log.completionTokens. Prompt tokens have a reconciliation step in chat.ts; completion tokens had none. So cost and tokens told different stories, and the discrepancy was invisible until a customer noticed.
  3. Nothing bounded the estimate. 1,165,619 tokens is 9.1x gpt-5.6-sol's maxOutput of 128,000 — physically impossible output, billed anyway.
  4. JSON.stringify(toolResult.function.arguments) runs on a value typed arguments: string, re-escaping every quote and newline and inflating the chars/4 estimate on exactly the tool-heavy payloads where it matters most.

The arithmetic confirms it was never upstream-reported usage: input is 83,042 × $5/M × 0.5 (flex) = $0.20760500, matching the log exactly, while output is $17.484285 ÷ ($30/M × 0.5) = 1,165,619 — a number appearing nowhere else in the row.

Approach

Estimation is a fallback for an anomaly, not a revenue path: prepare-request-body.ts sets stream_options: { include_usage: true } across 11 provider branches, so a missing completion count means something broke. Each change below is independently sufficient to have caught this.

  • Don't estimate output for a response that didn't complete. New allowOutputEstimate option on calculateCosts, passed false from the streaming failure path.
  • Write the billed completion count back to the log. A row showing 0 output tokens beside a non-zero output cost is now structurally impossible.
  • Clamp an estimated count to the mapping's maxOutput (declared on 575 mappings). Provider-reported counts are never clamped — those are the provider's own measurement, and rewriting them would hide a real upstream disagreement.
  • Stop re-encoding tool-call arguments.

Auditability

calculateCosts now warns — Billed a request on estimated token counts — whenever a billed request used a count we made up rather than one the provider reported. It lives in the one place every call site goes through, so no path can miss it, and the logger attaches the trace id in production (also stored on the log row), so a hit pivots straight to the request it charged.

Warn rather than info on purpose: this is the class of charge behind the incident, and it should be rare. If it turns out not to be rare, that is the finding.

Writing that warning surfaced a related mislabel: estimatedCost was set on merely entering the estimation branch, so a request whose prompt tokens the provider reported and whose output estimation was declined was recorded as estimated. It is now derived from what was actually estimated, which keeps both the log column and the warning honest.

A second, narrower warning fires when a successful stream reports no completion count and its output ends up billed at zero — that means the provider ignored our include_usage, and the right fix is in the request we send it. The two conditions are mutually exclusive, so one event never logs twice.

Verification

Both new integration tests were confirmed to fail against the pre-fix code:

  • a successful stream logs the completion count it was billed for → expected 0 to be greater than 0 (the row logged 0 output tokens on a billed request).
  • stream truncated after tool calls only is not billed (added in fix(gateway): don't bill upstream failures #3505) → charged $0.02565 on 0 recorded completion tokens.

Unit tests in costs.spec.ts cover the disallowed estimate, the maxOutput clamp, the never-clamp-reported-counts guarantee, the double-encode, and all three warning cases (fires when estimated, silent when measured, silent when the estimate produced no charge).

api.spec.ts (157), fallback.spec.ts (54), responses.spec.ts (73), costs.spec.ts (85), and the four pricing suites (338) pass, run serially. pnpm format and pnpm build clean.

Not included

Surfacing estimatedCost in the UI — it's on the log row and in the generated API types (v1.d.ts:4472) but rendered nowhere, so a user cannot tell an estimated charge from a measured one. Separate UI change.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved cost and token tracking for streaming responses, including Anthropic tool-call responses.
    • Prevented failed streams from receiving guessed completion-token counts.
    • Improved handling when providers omit completion usage information.
    • Added safeguards to keep estimated output tokens within model limits while preserving provider-reported values.
    • Improved handling of tool-call content and added warnings when billing relies on estimated usage.
  • Tests
    • Added coverage for streaming costs, estimation limits, disabled estimation, warnings, and tool-call arguments.

A truncated agentic stream was charged $17.48 for 1,165,619 output tokens
on a log row recording 0 of them. The provider never reported a
completion count — the stream died before any usage frame — so
calculateCosts fell back to estimating one from the accumulated
tool-call JSON. That estimate drove the charge but was never written
back to the log, so cost and tokens told different stories and nobody
noticed until a customer did.

Four changes, each independently sufficient to have caught it:

- Don't estimate output tokens for a response that did not complete.
  A dead stream leaves only partial text to guess from; billing that
  guess is not a measurement. New `allowOutputEstimate` option, passed
  false from the streaming failure path.
- Write the billed completion count back to the log. Prompt tokens
  already had this reconciliation; completion tokens did not, which is
  the only reason the divergence stayed invisible. A row showing 0
  output tokens beside a non-zero output cost is now impossible.
- Clamp an estimated count to the mapping's maxOutput. The incident
  billed 9.1x what the deployment can physically emit. Provider-reported
  counts are never clamped — those are the provider's measurement.
- Stop re-encoding tool-call arguments. `arguments` is already a JSON
  string, so JSON.stringify escaped every quote and inflated the chars/4
  estimate on exactly the tool-heavy payloads where it matters most.

Also warns when a successful stream reports no completion count, since
that means the provider ignored our include_usage and its output is now
billed at 0 rather than at a guess — the fix belongs in the request we
send it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 11, 2026 17:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The gateway adds controlled completion-token estimation for streaming responses. Estimated counts are capped and logged, while provider-reported counts remain unchanged. Failed streams disable estimation, and successful streams synchronize logged usage with billing results.

Changes

Output-token estimation and billing

Layer / File(s) Summary
Cost estimation controls and safeguards
apps/gateway/src/lib/costs.ts, apps/gateway/src/lib/costs.spec.ts
calculateCosts can disable output estimation. Estimated counts are marked and capped at maxOutput. Provider-reported counts remain uncapped. String tool arguments are not encoded again. Tests cover estimation and warning behavior.
Streaming billing integration
apps/gateway/src/chat/chat.ts, apps/gateway/src/api.spec.ts
Failed streams do not estimate output tokens. Successful streams synchronize logged counts with billing and warn when completion usage is missing. Anthropic tool-call streaming coverage verifies estimated billing.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: ratchaw, smakosh

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately identifies the gateway billing fix for failed requests, which is a central part of the changes.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/no-invented-output-tokens

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/gateway/src/chat/chat.ts`:
- Around line 11233-11236: Use zero output billing whenever a stream lacks
provider-reported completion usage: update allowOutputEstimate in the chat
stream handling to disable estimation for that case, retain the warning only
after the billed count is guaranteed to be zero, and update the missing-usage
integration test to assert zero completion tokens and zero output cost. Apply
changes at apps/gateway/src/chat/chat.ts lines 11233-11236 and 11285-11306, and
apps/gateway/src/api.spec.ts lines 8668-8775.

In `@apps/gateway/src/lib/costs.ts`:
- Line 379: Update the completion-token estimation condition in
apps/gateway/src/lib/costs.ts lines 379-379 to estimate only when the reported
completion count is null or undefined, preserving an explicit provider-reported
0. Add a test case in apps/gateway/src/lib/costs.spec.ts lines 2008-2020 with
zero completion tokens plus tool calls or completion content, and assert that
output billing remains zero.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f9f84b39-0be5-4d35-a089-8bf3b8738bec

📥 Commits

Reviewing files that changed from the base of the PR and between 617b4fb and b57318f.

📒 Files selected for processing (4)
  • apps/gateway/src/api.spec.ts
  • apps/gateway/src/chat/chat.ts
  • apps/gateway/src/lib/costs.spec.ts
  • apps/gateway/src/lib/costs.ts

Comment on lines +11233 to +11236
// A stream that died never delivered a usage frame, so
// there is nothing to estimate from but the partial text
// and tool-call JSON that happened to arrive. Don't guess.
allowOutputEstimate: streamingError === null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use one billing rule for successful streams without completion usage. The chat path enables estimation, while its warning and the PR objective state that output must bill as zero. The integration test currently enforces the conflicting estimated-charge behavior.

  • apps/gateway/src/chat/chat.ts#L11233-L11236: disable output estimation when the stream has no provider-reported completion usage.
  • apps/gateway/src/chat/chat.ts#L11285-L11306: retain the warning only after the billed count is guaranteed to be zero.
  • apps/gateway/src/api.spec.ts#L8668-L8775: assert zero completion tokens and zero output cost for the missing-usage stream.
📍 Affects 2 files
  • apps/gateway/src/chat/chat.ts#L11233-L11236 (this comment)
  • apps/gateway/src/chat/chat.ts#L11285-L11306
  • apps/gateway/src/api.spec.ts#L8668-L8775
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gateway/src/chat/chat.ts` around lines 11233 - 11236, Use zero output
billing whenever a stream lacks provider-reported completion usage: update
allowOutputEstimate in the chat stream handling to disable estimation for that
case, retain the warning only after the billed count is guaranteed to be zero,
and update the missing-usage integration test to assert zero completion tokens
and zero output cost. Apply changes at apps/gateway/src/chat/chat.ts lines
11233-11236 and 11285-11306, and apps/gateway/src/api.spec.ts lines 8668-8775.


// Calculate completion tokens
if (!completionTokens && fullOutput) {
if (!completionTokens && fullOutput && allowOutputEstimate) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve reported zero completion counts. A completion count of 0 is a provider-reported value, not an absent value. The current truthiness check can replace it with an estimated, billable count.

  • apps/gateway/src/lib/costs.ts#L379-L379: use explicit null checks before estimating completion tokens.
  • apps/gateway/src/lib/costs.spec.ts#L2008-L2020: add a provider-reported 0 case with tool calls or completion content and assert zero output billing.
📍 Affects 2 files
  • apps/gateway/src/lib/costs.ts#L379-L379 (this comment)
  • apps/gateway/src/lib/costs.spec.ts#L2008-L2020
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gateway/src/lib/costs.ts` at line 379, Update the completion-token
estimation condition in apps/gateway/src/lib/costs.ts lines 379-379 to estimate
only when the reported completion count is null or undefined, preserving an
explicit provider-reported 0. Add a test case in
apps/gateway/src/lib/costs.spec.ts lines 2008-2020 with zero completion tokens
plus tool calls or completion content, and assert that output billing remains
zero.

@steebchen steebchen changed the title fix(gateway): stop inventing output tokens fix(gateway): adapt failed requests billing Aug 11, 2026
Makes an invented charge auditable after the fact. calculateCosts now
warns whenever a billed request used a token count we estimated rather
than one the provider reported, from the one place every call site goes
through. The logger attaches the trace id in production, which is also
stored on the log row, so a hit pivots straight to the request it
charged.

Warn rather than info on purpose: this is the class of charge behind the
phantom-billing incident, and it should be rare since every streaming
provider is asked for usage via include_usage. If it turns out not to be
rare, that is the finding.

Also fixes estimatedCost mislabelling requests that estimated nothing.
The flag was set on merely entering the estimation branch, so a request
whose prompt tokens the provider reported and whose output estimation
was declined was recorded as estimated. It is now derived from what was
actually estimated, which keeps both the log column and the new warning
honest.

The narrower streaming warning added in the previous commit now fires
only when the output ended up billed at zero, so one event never logs
twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@steebchen
steebchen enabled auto-merge (squash) August 11, 2026 18:35
@steebchen
steebchen merged commit f009561 into main Aug 11, 2026
13 checks passed
@steebchen
steebchen deleted the fix/no-invented-output-tokens branch August 11, 2026 18:54
steebchen added a commit that referenced this pull request Aug 12, 2026
## Problem

When investigating "output cost doesn't match output tokens" reports
(e.g. an hour whose blended output rate multiplies out far above any
real tier rate), the phantom rows come from streams that died before the
provider sent usage: the pre-#3546 estimator billed an estimated output
count that was never written back to `completion_tokens`, leaving cost
with no matching tokens. #3546 fixed this going forward but did not
backfill, so historical rows still carry the mismatch and each report
needs an ad-hoc query.

## Approach

Adds `sql/phantom_output_cost_by_project.sql`, a reusable two-part
audit:

1. Per-row detail: every log row for a given account + model where
`output_cost` exceeds `completion_tokens × served-tier output rate ×
1.6`, with `expected_output_cost` / `excess_output_cost` columns and the
diagnostic fields (`canceled`, `estimated_cost`,
`unified_finish_reason`, `pricing_tier`).
2. Totals: phantom row count and the summed excess, i.e. the refund
amount.

Scalability is the point of the shape: the project-id resolution is a
`MATERIALIZED` CTE so it is the first node of the plan, and the log
access is a `LATERAL` join on `project_id` — verified with `EXPLAIN`
that the planner runs a nested loop probing a `project_id`-leading index
(`log_project_id_used_model_idx` locally) instead of scanning `log`. The
`created_at` lower bound keeps `log_project_id_created_at_idx` usable
too. The 1.6× headroom keeps legitimate rows out (discounts only lower
costs; the long-context tier tops out at 1.5× base). Per-row `::numeric`
casts avoid float4 accumulation drift in the sum.

The committed file uses placeholder values (`customer@example.com`,
`openai/gpt-5.6-sol` rates as the example CASE) — fill in account email,
model, rates and date bound before running.

## Verification

- Both statements run clean against the local dev database (seeded
schema).
- `EXPLAIN` confirms the materialized-CTE → nested-loop → index-scan
plan shape.
- `pnpm format` and full `pnpm build` pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added account-scoped audit queries to identify projects with
potentially excessive billed output costs.
* Included detailed results with calculated excess costs and an
aggregated summary of suspect records.
* Supports filtering by model, date range, output cost, and organization
type.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants