Repository navigation
[Bug][claude-surface] message_start reports usage.input_tokens: 0 — third-party clients reading the first frame under-report context usage (Paseo) #4857
Description
Client or integration
Claude Code
Area
Streaming
Summary
The Anthropic Messages stream emitted by opencodex reports usage: { input_tokens: 0, output_tokens: 0 } in the message_start frame. The real input size (including cache read/creation) only arrives in the terminal message_delta.usage.
Real Anthropic populates message_start.message.usage.input_tokens with the actual prompt size at the start of the turn. Clients that follow the documented contract and read usage from the first frame — e.g. Paseo's context-window meter (packages/server/src/server/agent/providers/claude/agent.ts in getpaseo/paseo: readStreamRequestInputTokens reads message_start, readStreamRequestOutputTokens reads message_delta, and the sum drives contextWindowUsedTokens) — therefore see only the output tokens of the previous turn and show a nearly-empty context ring (e.g. "a few hundred tokens used" of 1M) while /context in Claude Code correctly shows ~97k.
Claude Code itself does not rely on the first frame, so compaction and cost accounting are unaffected — this is a display-contract gap for third-party clients only.
Reproduction
- Start opencodex (
ocx start) with any provider behind the Claude surface (reproduced with anopenai-chatadapter provider; version 2.56.0, macOS, bun runtime). - Send a small streaming request directly through the proxy:
curl -sN http://127.0.0.1:10100/v1/messages \
-H "Authorization: Bearer <token>" \
-H "anthropic-version: 2023-06-01" \
-H "Content-Type: application/json" \
-d '{"model":"<provider>/<model>","max_tokens":16,"stream":true,"messages":[{"role":"user","content":"reply with one word"}]}'Observed:
event: message_start
data: {"type":"message_start","message":{...,"usage":{"input_tokens":0,"output_tokens":0}}}
...
event: message_delta
data: {"type":"message_delta","delta":{...},"usage":{"input_tokens":17,"output_tokens":147,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}
Expected (real Anthropic): message_start.message.usage.input_tokens already carries the full prompt size for this turn (and cache_read_input_tokens / cache_creation_input_tokens when caching applies).
- In a real session,
~/.opencodex/usage.jsonlconfirms the terminal usage is correct — latest turn recordedinputTokens: 87567, cachedInputTokens: 57920— while Paseo's meter showed only a few hundred tokens for the same session. So the data exists; only the first frame is zero.
Root cause (2.56.0 source)
src/claude/outbound.ts:
messageSnapshot(model)(L192) hardcodesusage: { input_tokens: 0, output_tokens: 0 }.responsesSseToAnthropicSseemitsmessage_startwith that snapshot (L283) as soon as the stream starts, long before upstream usage is known.- The real usage only reaches the client in
finish()viaanthropicUsage()onmessage_delta(L355).
I understand the upstream Responses stream may not provide prompt usage until the end, so a faithful fix may not always be possible at message_start time. But two intermediate options would already unblock first-frame readers:
- If the adapter knows the prompt size early (e.g. from an estimate or an early upstream usage frame), emit it in
message_start. - Alternatively, emit a synthetic
message_deltawith a usage update as soon as input usage becomes known, since Paseo-style readers summessage_startinput + latestmessage_deltaoutput.
Related prior art: #508 (Kiro under-reporting fixed in v2.7.41 via contextTotalTokens) — same user-visible symptom class, different code path.
Version
2.56.0
Operating system
macOS 15.5 (Darwin 25.5.0)
Provider and model
tianyi / glm-5.3-oc (openai-chat adapter, surface: claude)
Logs or error output
event: message_start
data: {"type":"message_start","message":{"id":"msg_7629b195d7e044088b9fbda77ff5e9c4","type":"message","role":"assistant","content":[],"model":"tianyi/glm-5.3-oc","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":17,"output_tokens":147,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}
Checks
- I searched existing issues and documentation.
- I removed secrets, tokens, account details, request credentials, and personal data.
Activity
github-actions commentedon Sep 17, 2026
Issue reopened
The report now contains the information required by the automated check. Thanks for updating it.
lidge-jun commentedon Sep 17, 2026
리뷰 · 우선순위 74 / 80
설명
이 이슈는 Claude surface SSE에서
message_start프레임의usage.input_tokens가 항상 0으로 나가서, 첫 프레임만 읽는 서드파티 클라이언트(예: Paseo context meter)가 컨텍스트 사용량을 거의 비어 있다고 보여주는 버그 보고다. 보고자 말대로 진짜 Anthropic은message_start.message.usage.input_tokens에 이번 턴 prompt 크기를 넣고,message_delta.usage는 누적/최종 쪽이다. Claude Code 본체는 첫 프레임에 의존하지 않아서 compaction·비용 계산은 안 깨지고, 표시 계약 갭이다.현재
devcheckout(7a29e7b6630652d5228b207b1b3244312ec336de, package 2.58.0)의src/claude/outbound.ts를 그대로 읽어 보면 보고의 원인 지점이 아직 그대로다.messageSnapshot(model)(약 L192–201)이usage: { input_tokens: 0, output_tokens: 0 }을 하드코딩하고,responsesSseToAnthropicSse의ensureStarted()(약 L282–286)가 스트림이 의미 출력을 내는 순간 그 스냅샷으로message_start를 먼저 보낸다. 실제 usage는finish()(약 L352–361)에서response.completed/ incomplete 경로의response.usage를anthropicUsage()(L90–105, cache read/write를 Anthropic 계약에 맞게 빼는 변환)로 넣어message_delta에만 실어 보낸다. upstream Responses가 prompt usage를 끝까지 안 주는 경우가 많아서 시작 시점에 항상 채울 수는 없다는 보고자의 이해도 코드와 맞다.관련 테스트
tests/claude-integration/claude-outbound.test.ts도message_start의 role/model/stop_reason은 보고, usage 숫자는 주로message_delta쪽에서 assert한다(예: cache 매핑 케이스에서md.usage의 input/cache 필드). 즉 지금 suite는 첫 프레임 input_tokens=0을 허용하는 상태이고, Paseo처럼message_startinput +message_deltaoutput을 합치는 독자는 문서 계약을 따라가도 빈 링을 본다. 증상 계급은 닫힌#508(Kiro under-report /contextTotalTokens) 와 비슷하지만 코드 경로는 Claude outbound translator 쪽이다 별개다.왜 지금 고칠 가치가 있나: opencodex가 Anthropic Messages 표면을 흉내 내는 한, 서드파티가 공개 계약을 믿을 수 있어야 한다. 데이터는 이미 터미널 프레임과
~/.opencodex/usage.jsonl에 있다(보고 재현). 완벽한 Anthropic 동치가 어렵다면, 보고자가 제안한 중간안 — (a) adapter가 일찍 아는 prompt size/추정치를message_start에 넣기, 또는 (b) input usage가 생기는 즉시 usage-onlymessage_delta를 한 번 더 내기 — 가 first-frame reader를 풀어 준다. 최근 tip 방향(#4860 behaviour oracle)과도 맞다: ‘0이 아닌 숫자 문자열’이 아니라 언제 어떤 프레임에 usage가 채워지는지를 테스트해야 한다.라인 단위 / 경로 단위 문제
src/claude/outbound.ts L192–201 messageSnapshot - usage를 항상 0으로 고정해서 Anthropic message_start 계약을 충족하지 못한다
src/claude/outbound.ts L282–286 ensureStarted - upstream usage를 알기 전에 message_start를 내보내므로, 나중에 알게 된 input usage를 첫 프레임에 되돌릴 경로가 없다
src/claude/outbound.ts L352–361 finish / L618+ response.completed - usage가 여기서만 anthropicUsage로 나가 message_delta에 실림; 중간 usage-only delta 경로가 없음
src/claude/outbound.ts L90–105 anthropicUsage - 변환 자체(캐시 차감)는 올바르고 terminal 쪽 정확도의 근거다. 문제는 변환 시점이 너무 늦다는 것
tests/claude-integration/claude-outbound.test.ts - message_start.usage.input_tokens 기대를 거의 안 걸어서 회귀가 suite에 안 잡힌다. 0 고정을 ‘맞는 동작’처럼 고정하면 안 된다
재현 버전 2.56.0 vs 현재 2.58.0 - 해당 하드코딩은 tip에도 남아 있어 version drift로 사라지지 않았다
메인테이너의 판단이 필요한 지점
너의 추천
고치는 쪽으로 진행(keep open). 중복 close 대상 아님(#508은 다른 경로). 선호 순서: (1) upstream/adapter가 input usage를 일찍 주면
message_start에anthropicUsage결과를 넣고, (2) 늦게 알면 usage-bearingmessage_delta를 stop_reason 없이(또는 문서화된 누적 규칙으로) 한 번 더 emit, (3)tests/claude-integration/claude-outbound.test.ts에 ‘첫 프레임 또는 early delta에 input_tokens>0’ behaviour oracle 추가 — 0 하드코딩을 영구 정답으로 assert하지 말 것. 점수 74/80: 재현·원인·영향 범위가 tip 코드와 정확히 맞고, 고치면 서드파티 표시 계약이 바로 좋아진다.이 댓글은 grok-bot이 작성했습니다
lidge-jun commentedon Sep 17, 2026
Partially fixed, and deliberately left open with a narrower scope.
#4891 (
6b6d6e6294, in 2.58.0) puts a real input-token count onmessage_startwhen the upstream sends one early — onresponse.createdorresponse.in_progress. That covers the destinations that report usage up front.It does not cover the path this report came from. The internal bridge attaches
usage: nullto its lifecycle frames (src/bridge/sse.ts), so atianyi/glm-5.3-ocrequest through theopenai-chatadapter on the Claude surface still shows zero at the start of the turn. #4891's own description says this is by design rather than an oversight.Closing this now would tell the reporter their symptom was fixed when it was not. What remains is the option this issue already proposed: emit a synthetic
message_deltacarrying input usage at the moment it becomes known, for paths where it is not available atmessage_start. Scope is therefore narrowed to the internal bridge lifecycle frames; the early-usage destinations are done.fix(claude): report the prompt this proxy counted on message_start (#…
lidge-jun commentedon Sep 18, 2026
Fixed on
devby #5057.message_startnow carries this proxy's own count of the prompt it forwarded, whenever the upstream reported no usage before the first content frame. #4891 had covered the destinations that report early; the internal bridge attachesusage: nullto its lifecycle frames, so the path your report came from had nothing to publish and kept sending zero. The floor comes fromestimateClaudeRequestTokens, the same estimate the usage log already trusts, computed once per request.Ordering is explicit and tested: confirmed early upstream usage always outranks the floor, and
message_deltaremains the authoritative count. The floor is never a claim about the upstream tokenizer.This narrowed a decision the repository had recorded, and the test comment now says so rather than having been quietly deleted. The pinned case read that zero is the honest placeholder and an estimate must not replace it. Its second half stands — the first content frame is still never delayed to await usage. The first half does not: zero is not honest about a prompt that exists, it asserts an empty one, and because the Anthropic schema makes
usagerequired onmessage_start, "absent" was never available. The real choice was between a false measurement and a true measurement of the request side.A floor that measures nothing is still not published: zero, negative, and NaN all fall back to the previous zeros, so a caller that cannot count cannot turn that into a number on the wire. Your Paseo-style reader should now see the prompt size at the start of the turn instead of a nearly-empty ring.
Ingwannu commentedon Sep 18, 2026
Reopening because #5057 fixes the translated streaming path but current
devstill emits zero input usage for the JSON-upstream-to-streaming fallback.responsesJsonToAnthropicMessage()preserves knownbody.usageon the synthesized message. Inclaude-messages.ts, the fallback then replaces that known usage inmessage_startwith{ input_tokens: 0, output_tokens: 0 }and exposes the real usage only later inmessage_delta. Clients that read the first frame still under-report exactly as this issue describes.Preserve the known input/cache usage fields in
message_startand set onlyoutput_tokensto zero for stream start. Add a regression with a JSON upstream carrying nonzero input and cache usage, provingmessage_startcontains those known values andmessage_deltaretains final output usage. The estimate added by #5057 should remain a fallback only when confirmed usage is unavailable.