Skip to content

fix(usage): report Qwen's real usage instead of the local estimate - #175

Merged
Rfym21 merged 3 commits into
Rfym21:mainfrom
maxff77:fix/real-upstream-usage
Sep 16, 2026
Merged

Rfym21 merged 3 commits into
Rfym21:mainfrom
maxff77:fix/real-upstream-usage

Conversation

@maxff77

@maxff77 maxff77 commented Sep 16, 2026

Copy link
Copy Markdown

Problem

/v1/chat/completions and /v1/messages never reported Qwen's real token usage. Every response (stream and non-stream) carried a local tiktoken estimate, even though Qwen sends usage on the final frame (stream) / in the body (non-stream). Clients that budget on usage (Claude Code's context-window percentage, dashboards) saw numbers that could be off by ~10 % or by tens of thousands of tokens (the attached-file history is invisible to the local estimate).

Fix

  • Normalizer (precise-tokenizer.js): normalizeUpstreamUsage accepts OpenAI (prompt_tokens/completion_tokens) and DashScope (input_tokens/output_tokens) naming, coerces numeric strings, treats negative / NaN / non-object as absent. mergeUpstreamUsage — last frame wins per field. resolveUsage — per-field fallback to the local estimate only when a field is genuinely absent (an upstream 0 is a real 0).
  • Anthropic (/v1/messages): stream reads Qwen's usage from every frame, emits real input_tokens on message_start and real output_tokens on message_delta; non-stream twin via body.usage. Across retries, the accepted (last) attempt's usage is what gets reported.
  • OpenAI (/v1/chat/completions): stream final chunk and non-stream body carry Qwen's counts; agent runtime reports the accepted attempt's usage.
  • Cache fields (cache_creation_input_tokens, cache_read_input_tokens) are 0, not null: Qwen has no prompt cache.
  • One log line per response: usage source=<upstream|estimated> input=<n> output=<m>.

Tests

tests/upstream-usage.test.js (normalizer / merge / resolve unit cases), tests/anthropic-usage-passthrough.test.js, tests/openai-usage-passthrough.test.js (controllers fed synthetic upstream frames: stream, non-stream, partial usage, all-zero, absent → estimated, several attempts → last wins). tests/expected-counts.json re-blessed: 1115 → 1146 tests, 134 suites.

Live check against real Qwen (qwen3.8-max, hi prompt): input_tokens=651 on both APIs, stream and non-stream, and both containers log usage source=upstream. Not automatable (needs a real account), so not in the suite.

Notes

Rebased onto a947146; no overlap with the SOCKS5 work. Reasoning tokens: Qwen exposes no separate counter, so output_tokens is whatever Qwen reports; the local estimate (fallback only) still counts thinking text.

Verified live on qwen-next (this branch @ 091fea6, real Qwen, qwen3.8-max)

case reported source
A1 /v1/messages non-stream "hi" input 651 / output 11 upstream
A2 /v1/messages stream "hi" 651 / 9 (message_delta) upstream
A3 /v1/messages stream + tool 1620 / 24 upstream
A4 /v1/messages stream + thinking 102 / 235 upstream
O1 /v1/chat/completions non-stream "hi" 651 / 11 upstream
O2 /v1/chat/completions stream "hi" 651 / 11 (last frame) upstream
O3 /v1/chat/completions non-stream + tool 1620 / 24, finish_reason=tool_calls upstream

Every response logged usage source=upstream; the estimate path never fired. Not covered live: "last attempt wins" on agent retries (no retry fired during the probe — unit-tested only).

🤖 Generated with Claude Code

PEDRO LOBATO CARCAMO and others added 3 commits September 15, 2026 19:00
Qwen sends `usage` on every frame under DashScope names
(input_tokens / output_tokens, cumulative). /v1/messages read it
under the OpenAI names (prompt_tokens / completion_tokens), so the
proxy ALWAYS fell back to the tiktoken estimate; /v1/chat/completions
read only the last frame's `usage` and the agent runtime never
surfaced it. Claude Code's context percentage therefore tracked our
estimate, never Qwen's count.

- precise-tokenizer.js: normalizeUpstreamUsage (accepts both naming
  schemes, zero = not reported, all-zero = absent), mergeUpstreamUsage
  (last frame wins), resolveUsage (per-field fallback to the local
  estimate), describeUsageSource (upstream only when both fields came
  from Qwen).
- anthropic.js: stream and non-stream /v1/messages report the
  upstream numbers; cache fields are 0, not null.
- chat.js: stream, non-stream and agent paths report the upstream
  numbers; the agent runtime now returns the accumulated usage of the
  accepted attempt (several attempts -> last attempt's usage, not the
  sum).
- One `info` line per response: `usage source=upstream|estimated
  input=<n> output=<m>`, replacing chat.js's twin tiktoken/upstream
  lines.

Tests: unit tests for the normalizer, and passthrough tests for both
routes (stream, non-stream, agent, partial usage, all-zero usage,
multi-attempt). Not verified here: a live probe against real Qwen
(seam 3 of the spec) — pending on qwen-next.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…o reportUsage

Code review of db70f93 found two things the tests did not cover:

- Anthropic stream and non-stream never reset `upstreamUsage` when the
  agent gate starts a second attempt, so a later attempt that reported
  nothing inherited the first attempt's counters. Spec says the reported
  usage is the last attempt's, for both counters. Reset it per attempt
  (same place the per-attempt text/think buffers are reset) and cover it
  with a two-attempt test for each handler.
- The `resolveUsage` + `logger.info('usage source=...')` pair was copied
  at five call sites. Replace with `reportUsage(acc, estimate, tag)`,
  which resolves, decides upstream/estimated from whether the estimate
  actually ran, logs once and returns the usage. `describeUsageSource`
  goes away with it.

Also drop the leftover `Math.max(0, ...)` clamp and `total_tokens`
recompute in chat.js (resolveUsage already guarantees both), and the
numeric-string coercion in `toReportedCount` (no evidence Qwen sends
strings). Test vocabulary follows CONTEXT.md: "attempt", not "retry".

Gate: 1147 tests / 134 suites (was 1146/134), re-blessed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… 134 suites)

The two usage commits were cherry-picked onto upstream main; only
tests/expected-counts.json clashed. Re-recorded with npm run test:bless
under CI's env (API_KEY=ci-test-key DATA_SAVE_MODE=none ACCOUNTS='').

Co-Authored-By: Claude Fable 5.1 <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