Skip to content

fix(openai): preserve first valid usage in stream when cost metadata frames follow - #6328

Closed
xiaoyuyu6420 wants to merge 1 commit into
QuantumNous:mainfrom
xiaoyuyu6420:fix/openai-stream-usage
Closed

fix(openai): preserve first valid usage in stream when cost metadata frames follow#6328
xiaoyuyu6420 wants to merge 1 commit into
QuantumNous:mainfrom
xiaoyuyu6420:fix/openai-stream-usage

Conversation

@xiaoyuyu6420

@xiaoyuyu6420 xiaoyuyu6420 commented Jul 20, 2026

Copy link
Copy Markdown

⚠️ 提交说明 / PR Notice

Important

  • 请提供人工撰写的简洁摘要,避免直接粘贴未经整理的 AI 输出。

📝 变更描述 / Description

部分 openai 兼容上游(如 opencode zen / opencode go)的 SSE 流中,含 usage 的帧之后会追加一个或多个无 usage 的 cost 元数据帧。原 OaiStreamHandler 只从最后一帧(lastStreamData)提取 usage:

  • handleLastResponseUsage == null 时不会把 containStreamUsage 置为 true
  • 因此 usage 最终落到 ResponseText2Usage 估算路径
  • 结果:prompt_tokens / completion_tokens 通常变成 0,缓存命中统计和计费异常

修复:在 StreamScannerHandler 的循环里,对 Chat Completions 模式且尚未拿到 usage 的情况,扫描每一帧的 usage 字段(用 strings.Contains 预过滤避免每帧都做完整 JSON 解析),一旦命中 service.ValidUsage 的帧就保存。后续无 usage 的 cost 帧不会覆盖;最后一帧若仍带 usage,handleLastResponse 会照常更新(已有逻辑不变)。已有的音频模型 secondLastStreamData 机制也保持不变。

🚀 变更类型 / Type of change

  • 🐛 Bug 修复 (Bug fix)
  • ✨ 新功能 (New feature)
  • ⚡ 性能优化 / 重构 (Refactor)
  • 📝 文档更新 (Documentation)

🔗 关联任务 / Related Issue

✅ 提交前检查项 / Checklist

📸 运行证明 / Proof of Work

新增回归测试 3 个(relay/channel/openai/relay_openai_stream_usage_test.go):

```
=== RUN TestOaiStreamHandler_UsageExtractedWhenCostMetadataFrameFollows
--- PASS: TestOaiStreamHandler_UsageExtractedWhenCostMetadataFrameFollows (0.00s)
=== RUN TestOaiStreamHandler_UsageFromLastChunkWhenNoCostFrame
--- PASS: TestOaiStreamHandler_UsageFromLastChunkWhenNoCostFrame (0.00s)
=== RUN TestOaiStreamHandler_NoUsageAnywhereUsesEstimation
--- PASS: TestOaiStreamHandler_NoUsageAnywhereUsesEstimation (0.00s)
PASS
ok github.com/QuantumNous/new-api/relay/channel/openai
```

验证修复前的失败行为(临时 stash 修复代码后跑同一测试):

```
TestOaiStreamHandler_UsageExtractedWhenCostMetadataFrameFollows
expected: 42 actual: 0
Messages: prompt_tokens should come from the chunk that carries real usage
--- FAIL
```

修复前 prompt_tokens=0(被估算路径覆盖),修复后正确拿到上游声明的 42。整个 relay/channel/openai 包测试也全过。

Summary by CodeRabbit

  • Bug Fixes

    • Improved usage tracking for OpenAI streaming responses.
    • Preserved accurate usage data when later stream frames contain empty usage details.
    • Continued supporting usage estimation when upstream responses provide no usage information.
  • Tests

    • Added coverage for usage extraction, cost metadata frames, and fallback estimation scenarios.

…frames follow

部分 openai 兼容提供商(如 opencode zen / opencode go)在含 usage 的 SSE
帧之后会追加无 usage 的 cost 元数据帧。原 OaiStreamHandler 只从 lastStreamData
(最后一帧)提取 usage,而 handleLastResponse 在 usage 为 null 时不更新
containStreamUsage,最终 usage 落到 ResponseText2Usage 估算路径,
prompt_tokens / completion_tokens 通常变成 0,缓存命中与计费因此异常。

修复:在 SSE 循环里,对 Chat Completions 模式且尚未拿到 usage 的情况下,
扫描每一帧的 usage 字段(用 substring 预过滤避免每帧都做完整 JSON 解析),
一旦命中 service.ValidUsage 的帧就保存,后续无 usage 的帧不会覆盖。
最后一帧若仍带 usage,handleLastResponse 会照常更新(已有的音频模型
secondLastStreamData 机制保持不变)。

回归测试覆盖三种场景:
- usage 帧后跟 cost 元数据帧(核心 bug 场景,修复前 prompt_tokens=0)
- 标准上游 usage 在最后一帧(行为保持)
- 上游不返回 usage(走估算路径,行为保持)

Closes QuantumNous#6272
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

OaiStreamHandler now captures valid usage from intermediate Chat Completions SSE frames without allowing later empty cost metadata frames to overwrite it. Tests cover intermediate usage, final-chunk usage, and estimation when usage is absent.

Changes

OpenAI streaming usage handling

Layer / File(s) Summary
Capture valid intermediate usage
relay/channel/openai/relay-openai.go
Chat Completions stream scanning validates and stores usage as soon as a valid usage frame is encountered.
Validate streaming usage scenarios
relay/channel/openai/relay_openai_stream_usage_test.go
SSE test helpers and cases verify preservation after cost metadata, final-chunk extraction, and estimation without upstream usage.

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

Sequence Diagram(s)

sequenceDiagram
  participant UpstreamSSE
  participant OaiStreamHandler
  participant UsageState
  UpstreamSSE->>OaiStreamHandler: sends SSE frame containing usage
  OaiStreamHandler->>UsageState: validates and stores usage
  UpstreamSSE->>OaiStreamHandler: sends later cost metadata frame
  OaiStreamHandler-->>UsageState: preserves stored usage
Loading

Possibly related PRs

Suggested reviewers: calcium-ion

Poem

I’m a rabbit guarding tokens bright,
Catching usage frames mid-flight.
Cost clouds come hopping through,
But true counts stay safe and true.
SSE streams now end just right!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes match issue #6272 by retaining the first valid usage frame so later usage-less cost frames do not overwrite it.
Out of Scope Changes check ✅ Passed The diff appears limited to the streaming usage fix and related regression tests, with no unrelated code changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main fix: preserving the first valid OpenAI stream usage when later cost metadata frames appear.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

🧹 Nitpick comments (1)
relay/channel/openai/relay_openai_stream_usage_test.go (1)

89-91: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use assert for non-fatal value checks.

As per coding guidelines, "New or substantially rewritten Go backend tests must use testify/require for setup and fatal assertions and testify/assert for non-fatal value checks." Both of these locations use require.Equal for final value assertions; please switch them to assert.Equal (and make sure to add the github.com/stretchr/testify/assert import).

  • relay/channel/openai/relay_openai_stream_usage_test.go#L89-L91: replace require.Equal with assert.Equal for token assertions.
  • relay/channel/openai/relay_openai_stream_usage_test.go#L115-L116: replace require.Equal with assert.Equal for token assertions.
🤖 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 `@relay/channel/openai/relay_openai_stream_usage_test.go` around lines 89 - 91,
Replace the final token-value require.Equal assertions with assert.Equal in
relay/channel/openai/relay_openai_stream_usage_test.go at lines 89-91 and
115-116, and add the github.com/stretchr/testify/assert import; retain require
for setup and fatal assertions.

Source: Coding guidelines

🤖 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.

Nitpick comments:
In `@relay/channel/openai/relay_openai_stream_usage_test.go`:
- Around line 89-91: Replace the final token-value require.Equal assertions with
assert.Equal in relay/channel/openai/relay_openai_stream_usage_test.go at lines
89-91 and 115-116, and add the github.com/stretchr/testify/assert import; retain
require for setup and fatal assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 682b1475-ca44-4a64-be0b-865f72b97e15

📥 Commits

Reviewing files that changed from the base of the PR and between 5a6c53d and f14fae3.

📒 Files selected for processing (2)
  • relay/channel/openai/relay-openai.go
  • relay/channel/openai/relay_openai_stream_usage_test.go

@xiaoyuyu6420
xiaoyuyu6420 marked this pull request as draft July 20, 2026 17:50
@xiaoyuyu6420
xiaoyuyu6420 marked this pull request as ready for review July 21, 2026 01:08
@xiaoyuyu6420

Copy link
Copy Markdown
Author

Closing in favor of #6070, which addresses the same root cause (first valid usage chunk overwritten by subsequent non-standard frames like OpenCode's x-opencode-type: cost) and was opened earlier.

On closer review, #6070 also covers the applyUsagePostProcessing body swap (passing the usage-bearing frame instead of lastStreamData), which this PR missed. Without that, channels that re-extract cached_tokens from the body (DeepSeek, Zhipu, Moonshot) would still not be fully fixed here, so #6070 is the more complete fix.

Redirecting review effort there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant