Skip to content

fix(openai-shim): keep terminal usage from a repeated finish_reason - #2252

Merged
kevincodex1 merged 1 commit into
Twigpine:mainfrom
serafkul:fix/openai-shim-final-usage
Oct 6, 2026
Merged

kevincodex1 merged 1 commit into
Twigpine:mainfrom
serafkul:fix/openai-shim-final-usage

Conversation

@serafkul

@serafkul serafkul commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • The final-usage fallback in openaiStreamToAnthropic no longer requires an empty choices array; hasEmittedFinalUsage stays as the single guard against a double emit.
  • Some OpenAI-compatible providers end a stream twice: one chunk carrying the finish_reason, then a second, identical but for the usage, with choices still populated. The streaming loop ignores a repeated finish_reason by design, and the fallback skipped that chunk because choices was not empty, so its usage was dropped and every assistant message from such a provider stored usage of all zeros.

Impact

  • user-facing impact: anything reading the size or the cost of a conversation reads it from stored usage — tokenCountWithEstimation walks back to the newest message carrying usage and trusts it. With zeros there, automatic compaction on size could not fire, the context warning never warned, and cost tracking recorded nothing. A measured session reached 1763 messages with preTokens: 183 recorded at its compaction boundary. After this change those read real numbers.
  • developer/maintainer impact: one condition relaxed in one branch. The two shapes already handled are unchanged: usage on the same chunk as the finish_reason (handled in the loop) and usage on a final empty-choices chunk (handled by this fallback). Both keep their existing tests, including emits terminal usage once when both terminal chunks report it, which stays passing because hasEmittedFinalUsage is already set by the time its third chunk arrives.

Testing

  • I ran the required local preflight, with one documented exception below.

  • exact commands and results:

    • bun install --frozen-lockfile — ok
    • bun run lint:any-budget — ok (current=778, baseline=779)
    • bun run smoke — ok (CLI + SDK bundles, reports 0.31.0)
    • bun run deadcode — ok (knip: configuration hints only)
    • bun run typecheck — ok
    • bun run typecheck:type-tests — ok
    • node bin/openclaude --version — ok
    • NODE_DISABLE_COMPILE_CACHE=1 node bin/openclaude --version — ok
    • bun run test:provider — 1703 pass / 1 fail. The failure is pre-existing: Claude stream watchdog > falls back when the top-level stream iterator never settles, which fails identically on clean main 88a2286b (1702 pass / 1 fail). The pass count differs by exactly +1, which is the test added here.
    • npm run test:provider-recommendation — ok
    • git fetch https://github.com/Twigpine/openclaude.git main then bun run security:pr-scan -- --base FETCH_HEAD --head HEAD — ok
  • focused tests: bun test ./src/services/api/openaiShim/streamConversion.test.ts — 22 pass, 0 fail. Negative control: with streamConversion.ts reverted to main and the new test kept, exactly that test fails (21 pass, 1 fail) and the emitted stream shows a message_delta carrying no usage at all.

  • documented skipped checks, platform limitations, or verified pre-existing failures:

    bun run check could not be run to completion, and the cause is in main, not in this PR. Its test:full step stops making progress and spins a single core indefinitely. Two runs on a clean checkout of main at 88a2286b with no modifications were left for 4h30m and 2h53m (16512s and 14864s of CPU) and never finished. bun test --feature=UNATTENDED_RETRY --timeout 15000 over the whole suite also never finished and recorded zero timed-out tests, which is consistent with a synchronous spin reached only after state accumulates across test files rather than one slow test. Every segment of the suite passes on its own, and all segments together take about nine minutes, so the stalled runs consumed roughly 27x the suite's entire work without completing. I did not isolate the file: the stall occurs where bun test writes only to stdout, which is block-buffered when redirected, so the last visible output is unrelated to where execution stopped. Happy to open this as a separate issue with the measurements.

    The steps inside check that do finish — lint:any-budget, smoke, deadcode — were run individually and pass (above). The unit suite was covered by running it in segments and comparing failure sets against the base:

    tree unique failing tests
    clean main 88a2286b 104
    this branch 104

    The two sets differ by two entries, both explained and neither caused by this change:

    • GitHub 429 stops when every pooled credential is cooling down appears on this branch but not on the base run. It is flaky: running the src/services segment three times on clean main with no changes, it passed twice and failed on the third run. In isolation it passes 3/3.
    • open build source does not reintroduce Ant employee gate helpers appears on the base run but not here. It reads the built bundle in dist/, and it passes on all three trees once each has a current bun run build.

    For context on the 104: they are dominated by environment-dependent tests rather than real breakage — CLAUDE_CONFIG_DIR overrides, expandTilde on win32, fastMode gates, Anthropic attribution, and the four scripts/openclaude-bin-compile-cache.test.ts launcher failures. All of them reproduce on the clean base above. Unrelated, but worth flagging: some tests write into the real user config directory (~/.openclaude/backups/) instead of a temporary one.

Notes

  • provider/model path tested: the OpenAI-compatible streaming shim. The wire shape was measured against OpenRouter — two chunks carrying a finish_reason, usage on the later of the two. The added test reproduces that shape with the file's existing fake-SSE helpers, so it needs no network and no provider key.
  • screenshots attached (if UI changed): n/a, no UI change.
  • follow-up work or known limitations: none known. Providers that send usage only on an empty-choices chunk and providers that send it alongside the finish_reason both keep their existing path.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed streaming responses so final token usage is reported when providers repeat a stop reason in a usage chunk, including chunks that also contain choices.

Some OpenAI-compatible providers end a stream twice: one chunk carrying
the finish_reason, then a second, identical but for the usage, with
choices still populated. The final-usage fallback required an EMPTY
choices array, so that second chunk was skipped and its usage dropped.

The streaming loop ignores a repeated finish_reason by design, so nothing
else picked it up: every assistant message from such a provider stored
usage of all zeros. Anything reading the size or the cost of a
conversation reads it from there - tokenCountWithEstimation walks back to
the newest message carrying usage and trusts it - so automatic compaction
on size could not fire, the context warning never warned, and cost
tracking recorded nothing. A measured session reached 1763 messages with
preTokens: 183 recorded at its compaction boundary.

Dropping the empty-choices requirement leaves hasEmittedFinalUsage as the
single guard against a double emit, which the existing empty-choices and
both-terminal-chunks tests already cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: Twigpine/openclaude/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 59462694-d672-4c86-ac93-6da33fe86a15
📥 Commits

Reviewing files that changed from the base of the PR and between 88a2286 and 51adb93.

📒 Files selected for processing (2)
  • src/services/api/openaiShim/streamConversion.test.ts
  • src/services/api/openaiShim/streamConversion.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

📜 Recent review details
🧰 Additional context used
📓 Path-based instructions (3)
Review provider routing, model selection, env precedence, auth/token handling, OpenAI-compatible shims, retries, proxy behavior, and outbound HTTP behavior with high scrutiny.

⚙️ CodeRabbit configuration file

Files:

  • src/services/api/openaiShim/streamConversion.test.ts
  • src/services/api/openaiShim/streamConversion.ts
Review tests for meaningful coverage of the changed behavior, isolation of global/env/config state, async cleanup, fake timers, provider profile leaks, and Windows-compatible assumptions.

⚙️ CodeRabbit configuration file

Files:

  • src/services/api/openaiShim/streamConversion.test.ts
Apply the OpenClaude maintainer review rubric from AGENTS.md.

⚙️ CodeRabbit configuration file

Files:

  • src/services/api/openaiShim/streamConversion.test.ts
  • src/services/api/openaiShim/streamConversion.ts
🔇 Additional comments (2)
src/services/api/openaiShim/streamConversion.ts (1)

1119-1119: LGTM!

src/services/api/openaiShim/streamConversion.test.ts (1)

327-366: LGTM!


📝 Walkthrough

Walkthrough

The stream converter now emits final usage when a chunk has populated choices, if a stop reason is known and final usage has not already been emitted. A new test checks that repeated stop information produces one terminal usage event with the expected token counts.

Changes

OpenAI stream usage emission

Layer / File(s) Summary
Emit final usage from repeated-stop chunks
src/services/api/openaiShim/streamConversion.ts, src/services/api/openaiShim/streamConversion.test.ts
The final-usage fallback no longer requires an empty choices array. The test verifies one terminal message_delta with 84 input tokens, 1 output token, and zero cache token counts.

Priority: ➖ Normal

Estimated code review effort: 2 (Simple) | ~8 minutes

Change: Bug fix

Suggested reviewers: chioarub

Merge Risk: ⚪ Minimal · up to 51adb

This change allows terminal usage from repeated-stop chunks to be recorded without duplicate emission. No merge-blocking risk was identified.

Architecture Summary

Architecture risk: 🔵 Low · up to 51adb

The change affects 1 system.

Changed systems: src

Architecture concerns
No architecture-level concerns identified.

Review details

Systems and components

  • observed — src (api) was modified; 2 changed files map to changed impact.

Before / after behavior

  • observed — Modified behavior in src/services/api/openaiShim/streamConversion.test.ts: Adds a test where an OpenAI stream first reports stop, then repeats stop in a chunk with populated choices and usage. It expects one terminal message_delta with 84 input tokens, 1 output token, and zero cache token counts.
  • observed — Modified behavior in src/services/api/openaiShim/streamConversion.ts: The final-usage fallback no longer requires an empty choices array. It emits usage when chunkUsage exists, lastStopReason is set, and final usage has not already been emitted.
🚥 Pre-merge checks | ✅ 7
✅ Passed checks (7 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise, scoped, and accurately describes the fix for terminal usage when a provider repeats finish_reason.
Description check ✅ Passed The description covers the change, its user and maintainer impact, testing results, the incomplete full check and its reported cause, and provider-path notes. It follows the repository template and do…
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2…
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.
Risk Surface Disclosed ✅ Passed The PR changes only OpenAI stream conversion and its test. The converter consumes an already-selected Response; the provider selection logic in clientDispatch.ts is unchanged. The patch does not c…
No Hidden Policy Change ✅ Passed The diff changes only terminal-usage handling in the OpenAI stream converter and adds a regression test. It emits provider-supplied usage after a known stop reason when no usage was emitted earlier. T…
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@euxaristia euxaristia 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.

Verified on head 51adb93. I ran the focused file (22 pass) and the negative control against main's streamConversion.ts (exactly the new test fails, 21 pass), so the test is red-first.

The mechanism holds together:

  • finish_reason is processed once (src/services/api/openaiShim/streamConversion.ts:778), so a repeated terminal chunk never emits a second message_delta from the loop.
  • The fallback sits outside that once-guard and is gated on lastStopReason !== null, only set after a processed finish_reason (line 1079). Usage on a pre-stop chunk cannot fire it.
  • hasEmittedFinalUsage is set in both emit paths (lines 1087 and 1126), so the loop-served shapes keep their single-emit guarantee, and the existing both-terminal-chunks test still passes.

One edge worth a thought, non-blocking: the first post-stop chunk with any truthy usage object now wins, and buildAnthropicUsageFromRawUsage (src/services/api/cacheMetrics.ts:397) normalizes an empty or all-zero usage into a zeros object. A provider that echoes usage: {} on a post-stop chunk would store zeros and set the guard, where the old code stored nothing. Recorded zeros defeat the estimation fallback in tokenCountWithEstimation (src/utils/tokens.ts:572), so that provider's size and cost tracking reads zero. Requiring a non-zero token count in the fallback condition would close it. Not a blocker: the shapes in the wild carry real numbers, as the OpenRouter measurement shows.

@kevincodex1 kevincodex1 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

@kevincodex1
kevincodex1 merged commit 10254f4 into Twigpine:main Oct 6, 2026
6 checks passed
rayss868 pushed a commit to rayss868/openclaude that referenced this pull request Oct 9, 2026
…wigpine#2252)

Some OpenAI-compatible providers end a stream twice: one chunk carrying
the finish_reason, then a second, identical but for the usage, with
choices still populated. The final-usage fallback required an EMPTY
choices array, so that second chunk was skipped and its usage dropped.

The streaming loop ignores a repeated finish_reason by design, so nothing
else picked it up: every assistant message from such a provider stored
usage of all zeros. Anything reading the size or the cost of a
conversation reads it from there - tokenCountWithEstimation walks back to
the newest message carrying usage and trusts it - so automatic compaction
on size could not fire, the context warning never warned, and cost
tracking recorded nothing. A measured session reached 1763 messages with
preTokens: 183 recorded at its compaction boundary.

Dropping the empty-choices requirement leaves hasEmittedFinalUsage as the
single guard against a double emit, which the existing empty-choices and
both-terminal-chunks tests already cover.

Co-authored-by: Claude Opus 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.

3 participants