Skip to content

fix(openai): fix stop_reason null and zero usage fields in stream adapter#226

Open
guunergooner wants to merge 1 commit intoclaude-code-best:mainfrom
guunergooner:fix/openai-stop-reason-usage
Open

fix(openai): fix stop_reason null and zero usage fields in stream adapter#226
guunergooner wants to merge 1 commit intoclaude-code-best:mainfrom
guunergooner:fix/openai-stop-reason-usage

Conversation

@guunergooner
Copy link
Copy Markdown
Contributor

@guunergooner guunergooner commented Apr 9, 2026

Summary

  • Fix stop_reason always being null in assembled AssistantMessage — the value captured from message_delta was never applied to the partial message
  • Fix partialMessage not being reset to null after message_stop, causing a safety fallback that would yield a second identical AssistantMessage (doubled content in next API request)
  • Fix zero usage fields in stream adapter output
  • Add comprehensive unit tests for queryModelOpenAI and streamAdapter

Test plan

  • Run bun test src/services/api/openai/__tests__/ to verify new tests pass
  • Test with an OpenAI-compatible endpoint (e.g. Ollama) and verify stop_reason is correctly populated
  • Verify no duplicate messages appear in conversation turns

Summary by CodeRabbit

  • Bug Fixes
    • Improved OpenAI model handling with better token limit tracking and truncation error signaling.
    • Enhanced streaming event handling with improved token usage tracking and robust finish-event handling.
    • Fixed timestamp formatting to use consistent locale across different system environments.

…pter

Signed-off-by: guunergooner <tongchao0923@gmail.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 9, 2026

📝 Walkthrough

Walkthrough

Refactors OpenAI stream handling to consolidate message assembly into a single AssistantMessage at message_stop instead of per-block emission, defers finish reason emission to capture trailing usage chunks, improves usage tracking with comprehensive Anthropic field mapping (including cache-read tokens), adds stop_reason propagation and max_tokens truncation signaling, and implements corresponding test coverage for validation.

Changes

Cohort / File(s) Summary
OpenAI Stream Processing
src/services/api/openai/streamAdapter.ts, src/services/api/openai/index.ts
Updated usage tracking to map OpenAI usage fields to Anthropic format (including cache_read_input_tokens from cached_tokens). Deferred finish reason emission until after stream loop to capture trailing usage. Consolidated message assembly to yield a single AssistantMessage at message_stop with all blocks and usage. Added stopReason tracking and max_tokens truncation signaling with apiError: 'max_output_tokens'. Reset partialMessage after emission to prevent duplicates.
OpenAI Stream Tests
src/services/api/openai/__tests__/queryModelOpenAI.test.ts, src/services/api/openai/__tests__/streamAdapter.test.ts
Added comprehensive test suites for stream-to-message assembly, validating stop_reason propagation (end_turn, tool_use, max_tokens), duplicate prevention, event forwarding, trailing usage chunk handling with correct token mapping (including cache-read and DeepSeek-V3 compatibility), and event ordering (message_delta before message_stop).
Date/Time Utilities
src/utils/formatBriefTimestamp.ts, src/utils/__tests__/formatBriefTimestamp.test.ts
Modified startOfDay to use UTC components instead of local-time components for accurate day-boundary calculations. Added global test setup/teardown to enforce consistent locale (en_US.UTF-8) for reproducible timestamp formatting assertions.

Sequence Diagram(s)

sequenceDiagram
    participant Stream as OpenAI Stream
    participant Adapter as streamAdapter
    participant QueryModel as queryModelOpenAI
    participant Assembler as Message Assembly

    rect rgba(100, 200, 150, 0.5)
    Note over Stream,Assembler: Stream Processing (New Flow)
    
    Stream->>Adapter: chunk (content_block_start)
    Adapter->>QueryModel: yield content_block_start
    
    Stream->>Adapter: chunk (content_block_delta)
    Adapter->>QueryModel: yield content_block_delta
    
    Stream->>Adapter: chunk (finish_reason, usage)
    Adapter->>Adapter: set pendingFinishReason
    Adapter->>Adapter: defer message_delta/message_stop
    
    alt Trailing Usage Chunk
    Stream->>Adapter: chunk (choices: [], usage)
    Adapter->>Adapter: accumulate usage tokens
    Adapter->>Adapter: update cached_tokens
    end
    
    Adapter->>Adapter: stream completes
    Adapter->>Adapter: emit deferred message_delta<br/>(with full usage)
    Adapter->>QueryModel: yield message_delta
    Adapter->>QueryModel: yield message_stop
    end
    
    rect rgba(150, 150, 200, 0.5)
    Note over QueryModel,Assembler: Message Assembly (New Flow)
    
    QueryModel->>Assembler: collect all content_block events
    QueryModel->>Assembler: receive message_stop
    Assembler->>Assembler: consolidate all blocks
    Assembler->>Assembler: inject usage & stop_reason
    QueryModel->>QueryModel: yield single AssistantMessage
    QueryModel->>QueryModel: reset partialMessage
    
    alt Max Tokens Detected
    QueryModel->>QueryModel: yield apiError: 'max_output_tokens'
    end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • PR #206: Modifies src/services/api/openai/index.ts queryModelOpenAI with thinking-mode request-building, intersecting with this PR's stream assembly and stop_reason handling changes.
  • PR #149: Updates streamAdapter stop_reason logic to force tool_use when tool calls are present, directly overlapping with this PR's tool_use override logic.
  • PR #199: Modifies index.ts stream/message assembly and partialMessage lifecycle management, sharing core concerns with this PR's consolidation refactor.

Poem

🐰 Streams now dance as one grand song,
Events deferred till rightful time,
Trailing tokens counted strong,
Messages at last sublime,
No more fragments left behind

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and specifically describes the main changes: fixing null stop_reason values and zeroing usage fields in the stream adapter, which aligns with the core objectives and code modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/services/api/openai/index.ts (1)

385-392: ⚠️ Potential issue | 🟠 Major

Duplicate cost tracking — this block executes twice for message_stop.

The cost tracking at lines 385-392 duplicates the logic already executed inside the message_stop case handler at lines 364-367. When event.type === 'message_stop', addToTotalSessionCost is called twice with the same values, inflating the reported session cost.

🐛 Proposed fix: Remove the duplicate block
-      // Track cost and token usage (matching the Anthropic path in claude.ts)
-      if (
-        event.type === 'message_stop' &&
-        usage.input_tokens + usage.output_tokens > 0
-      ) {
-        const costUSD = calculateUSDCost(openaiModel, usage as any)
-        addToTotalSessionCost(costUSD, usage as any, options.model)
-      }
-
       // Also yield as StreamEvent for real-time display (matching Anthropic path)
       yield {
         type: 'stream_event',
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/api/openai/index.ts` around lines 385 - 392, There is a
duplicated cost-tracking block that runs when event.type === 'message_stop'
causing addToTotalSessionCost to be called twice; remove the redundant block
(the one that checks event.type === 'message_stop' and calls calculateUSDCost +
addToTotalSessionCost) so that cost is only recorded by the existing logic
inside the 'message_stop' case handler; ensure calculateUSDCost,
addToTotalSessionCost, usage and options.model are still used exactly once for
that event type.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/utils/formatBriefTimestamp.ts`:
- Around line 79-81: The day-boundary calculation in startOfDay() uses UTC
getters (getUTCFullYear/getUTCMonth/getUTCDate) while formatting uses local
timezone (toLocaleTimeString/toLocaleString), causing inconsistent day
classification vs display; update startOfDay() to use local date components
(getFullYear/getMonth/getDate) so boundaries match local formatting, or
alternatively make the display calls use UTC consistently (e.g., pass timeZone:
'UTC' to toLocaleString/toLocaleTimeString) if UTC-based boundaries are
intentional; if keeping UTC behavior, add a clear comment in startOfDay()
explaining the trade-off and test determinism.

---

Outside diff comments:
In `@src/services/api/openai/index.ts`:
- Around line 385-392: There is a duplicated cost-tracking block that runs when
event.type === 'message_stop' causing addToTotalSessionCost to be called twice;
remove the redundant block (the one that checks event.type === 'message_stop'
and calls calculateUSDCost + addToTotalSessionCost) so that cost is only
recorded by the existing logic inside the 'message_stop' case handler; ensure
calculateUSDCost, addToTotalSessionCost, usage and options.model are still used
exactly once for that event type.
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8febc2f7-9823-4844-9baa-a8e3065b9ad5

📥 Commits

Reviewing files that changed from the base of the PR and between e6affc7 and b23bead.

📒 Files selected for processing (6)
  • src/services/api/openai/__tests__/queryModelOpenAI.test.ts
  • src/services/api/openai/__tests__/streamAdapter.test.ts
  • src/services/api/openai/index.ts
  • src/services/api/openai/streamAdapter.ts
  • src/utils/__tests__/formatBriefTimestamp.test.ts
  • src/utils/formatBriefTimestamp.ts

Comment on lines 79 to 81
function startOfDay(d: Date): number {
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
}
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.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there are any comments or documentation about UTC being intentional
rg -n -C3 "UTC|startOfDay" src/utils/formatBriefTimestamp.ts

# Check for related discussion in PR or commit messages
git log --oneline -10 --all -- src/utils/formatBriefTimestamp.ts 2>/dev/null || echo "Git history not available"

Repository: claude-code-best/claude-code

Length of output: 493


🏁 Script executed:

cat -n src/utils/formatBriefTimestamp.ts

Repository: claude-code-best/claude-code

Length of output: 2858


Day boundary calculation uses UTC, but display formatting uses local timezone.

The startOfDay() function calculates day boundaries using UTC components (getUTCFullYear(), getUTCMonth(), getUTCDate()), but the subsequent toLocaleTimeString() and toLocaleString() calls (lines 30, 37, 44) format the timestamp in local timezone. This causes a mismatch for users in timezones far from UTC:

  • A timestamp at 2026-04-01T22:00:00Z is categorized as April 1 UTC (same day threshold), but displays as April 2 in UTC+10.

If this UTC-based boundary is intentional for test determinism, add a comment explaining the trade-off. Otherwise, use consistent timezone handling for both day boundary calculation and display.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/formatBriefTimestamp.ts` around lines 79 - 81, The day-boundary
calculation in startOfDay() uses UTC getters
(getUTCFullYear/getUTCMonth/getUTCDate) while formatting uses local timezone
(toLocaleTimeString/toLocaleString), causing inconsistent day classification vs
display; update startOfDay() to use local date components
(getFullYear/getMonth/getDate) so boundaries match local formatting, or
alternatively make the display calls use UTC consistently (e.g., pass timeZone:
'UTC' to toLocaleString/toLocaleTimeString) if UTC-based boundaries are
intentional; if keeping UTC behavior, add a clear comment in startOfDay()
explaining the trade-off and test determinism.

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.

1 participant