Skip to content

fix(cloudflare): preserve upstream reasoning token usage#6266

Open
nelsonie wants to merge 1 commit into
QuantumNous:mainfrom
nelsonie:fix/cloudflare-reasoning-token-usage
Open

fix(cloudflare): preserve upstream reasoning token usage#6266
nelsonie wants to merge 1 commit into
QuantumNous:mainfrom
nelsonie:fix/cloudflare-reasoning-token-usage

Conversation

@nelsonie

@nelsonie nelsonie commented Jul 17, 2026

Copy link
Copy Markdown

⚠️ 提交说明 / PR Notice

Important

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

📝 变更描述 / Description

Cloudflare Chat Completions 可能在 completion_tokens 中包含 Reasoning Token,但原处理逻辑会根据输出文本重新估算用量,导致本地消费记录与 Cloudflare AI Gateway Usage 不一致。

本次修改:

  • 流式与非流式响应优先采用有效的上游 Usage,保留 completion_tokens_details.reasoning_tokens
  • 流式请求在渠道支持时始终向上游请求 Usage,但仍遵循客户端的 include_usage 返回偏好。
  • 将全零 Usage 视为无效数据,回退本地计算,避免成功请求零扣费。
  • 本地回退计算同时包含可见内容和 Reasoning Content。
  • 当客户端关闭 Usage 时,内部仍保留 Usage 用于计费,但不转发 usage-only 空 Choice chunk。
  • 增加非流式、流式、全零 Usage 和下游过滤场景的回归测试。

AI-assisted disclosure: 此变更由 AI 协助实现、测试和整理,提交者已要求按仓库流程提交。

🚀 变更类型 / Type of change

  • 🐛 Bug 修复 (Bug fix) - 请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug
  • ✨ 新功能 (New feature) - 重大特性建议先通过 Issue 沟通
  • ⚡ 性能优化 / 重构 (Refactor)
  • 📝 文档更新 (Documentation)

🔗 关联任务 / Related Issue

✅ 提交前检查项 / Checklist

  • 人工确认: 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
  • 非重复提交: 我已搜索现有的 IssuesPRs,确认不是重复提交。
  • Bug fix 说明: 若此 PR 标记为 Bug fix,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。
  • 变更理解: 我已理解这些更改的工作原理及可能影响。
  • 范围聚焦: 本 PR 未包含任何与当前任务无关的代码改动。
  • 本地验证: 已在本地运行并通过测试或手动验证,维护者可以据此复核结果。
  • 安全合规: 代码中无敏感凭据,且符合项目代码规范。

📸 运行证明 / Proof of Work

本地自动化测试:

go test ./relay/channel/cloudflare ./service
ok github.com/QuantumNous/new-api/relay/channel/cloudflare
ok github.com/QuantumNous/new-api/service

Cloudflare AI Gateway 实际流式请求验证:

prompt_tokens: 126
completion_tokens: 495
reasoning_tokens: 477
total_tokens: 621

new-api 本地消费日志记录 prompt_tokens=126completion_tokens=495,与上游 Usage 一致,且计费路径为 usage_billing_path=upstream

Summary by CodeRabbit

  • Bug Fixes
    • Improved token usage reporting for Cloudflare-backed chat and streaming responses.
    • Preserves valid upstream prompt, completion, and reasoning token counts when available.
    • Falls back to locally estimated usage when upstream counts are missing or invalid.
    • Prevents unnecessary usage-only streaming events when usage reporting is not requested.
    • Streaming requests now consistently request usage details from supported providers.

Use valid Cloudflare-reported usage for streaming and non-streaming chat completions so reasoning tokens remain part of the billed completion total. Treat all-zero usage as missing to prevent successful token-priced requests from settling at zero, and fall back to local counting with both visible and reasoning content.

Request stream usage from the upstream provider whenever stream options are supported, while preserving the client's downstream preference. Retain usage internally for billing but suppress usage-only empty-choice chunks when the client disables usage.

Add regression coverage for reasoning token details, zero-usage fallback, streamed final usage, downstream usage filtering, and forced upstream usage collection.
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Cloudflare relay handlers now preserve valid upstream token usage, fall back to local counting for zero or missing usage, include reasoning content in fallback calculations, and force upstream usage for supported streaming requests.

Changes

Cloudflare usage accounting

Layer / File(s) Summary
Streaming usage propagation
relay/channel/cloudflare/adaptor.go, relay/channel/cloudflare/relay_cloudflare.go, relay/channel/cloudflare/relay_cloudflare_test.go
Streaming requests enable upstream usage reporting; valid chunk usage is retained, fallback usage includes accumulated reasoning and content, and usage-only chunks are filtered when not requested.
Non-stream usage and serialization
relay/channel/cloudflare/relay_cloudflare.go, relay/channel/cloudflare/relay_cloudflare_test.go
Non-stream responses preserve valid upstream usage or compute fallback usage, while JSON handling uses the common helpers and tests cover both paths.

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

Possibly related issues

  • Issue 6217 — Streaming usage propagation and preference for valid upstream usage address the reported missing cache and inflated token accounting concerns.

Poem

A rabbit hops through Cloudflare’s stream,
Counting each token, bright as a dream.
Reasoning carrots join content’s trail,
True upstream numbers now never fail.
Empty usage chunks quietly flee—
“Accurate accounting!” cheers bunny with glee.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: preserving upstream reasoning token usage in Cloudflare.
Linked Issues check ✅ Passed The changes address #6265 by using valid upstream usage, falling back on zero usage, and preserving include_usage behavior in streams.
Out of Scope Changes check ✅ Passed The PR stays focused on Cloudflare usage accounting fixes and related regression tests, with no clear unrelated additions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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.

Warning

⚠️ This pull request shows signs of AI-generated slop (ai_padded_prose). It has been flagged by CodeRabbit slop detection and should be reviewed carefully.

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
relay/channel/cloudflare/relay_cloudflare.go (1)

122-137: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Remove redundant usageEnvelope unmarshaling.

dto.TextResponse already unmarshals the usage field into response.Usage. Since service.ValidUsage considers both missing usage (which leaves response.Usage zero-valued) and explicitly zeroed usage as invalid, a second JSON parse to check for a nil pointer is unnecessary. You can pass &response.Usage directly.

♻️ Proposed fix
-	var usageEnvelope struct {
-		Usage *dto.Usage `json:"usage"`
-	}
-	err = common.Unmarshal(responseBody, &usageEnvelope)
-	if err != nil {
-		return types.NewError(err, types.ErrorCodeBadResponseBody), nil
-	}
-	usage := usageEnvelope.Usage
-	if !service.ValidUsage(usage) {
+	usage := &response.Usage
+	if !service.ValidUsage(usage) {
 		var responseText strings.Builder
 		for _, choice := range response.Choices {
 			responseText.WriteString(choice.Message.StringContent())
 			responseText.WriteString(choice.Message.GetReasoningContent())
 		}
 		usage = service.ResponseText2Usage(c, responseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
 	}
🤖 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/cloudflare/relay_cloudflare.go` around lines 122 - 137, Remove
the redundant usageEnvelope declaration and common.Unmarshal call in the
response usage handling. Use response.Usage directly when calling
service.ValidUsage, passing its address to preserve the existing invalid-usage
fallback through service.ResponseText2Usage.
relay/channel/cloudflare/relay_cloudflare_test.go (1)

23-204: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Refactor duplicate tests into table-driven tests.

The test functions for cfHandler and cfStreamHandler contain significant boilerplate duplication, sharing identical setup and mock HTTP request/response handling. As per coding guidelines, prefer deterministic table tests with explicit inputs and exact outputs to improve maintainability and make it easier to add new test cases cleanly.

🤖 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/cloudflare/relay_cloudflare_test.go` around lines 23 - 204,
Refactor the duplicated cfHandler and cfStreamHandler tests into deterministic
table-driven tests, consolidating shared Gin context, RelayInfo, HTTP response,
invocation, and assertion setup. Preserve each existing scenario’s exact body,
usage expectations, fallback behavior, context flag, and streamed output checks,
using distinct test-case inputs and expected results for the cfHandler and
cfStreamHandler test functions.

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.

Inline comments:
In `@relay/channel/cloudflare/adaptor.go`:
- Around line 60-62: Preserve existing StreamOptions fields in
relay/channel/cloudflare/adaptor.go:60-62 by initializing request.StreamOptions
only when nil, then setting IncludeUsage = true on the existing struct. Update
relay/channel/cloudflare/relay_cloudflare_test.go:206-226 to provide
IncludeObfuscation: true in the input StreamOptions and assert it remains true
after conversion.

In `@relay/channel/cloudflare/relay_cloudflare.go`:
- Around line 69-73: Update the loop over response.Choices so it iterates by
index and assigns response.Choices[index].Delta.Role, ensuring the role mutation
affects the actual response elements while preserving the existing content and
reasoning writes.

---

Nitpick comments:
In `@relay/channel/cloudflare/relay_cloudflare_test.go`:
- Around line 23-204: Refactor the duplicated cfHandler and cfStreamHandler
tests into deterministic table-driven tests, consolidating shared Gin context,
RelayInfo, HTTP response, invocation, and assertion setup. Preserve each
existing scenario’s exact body, usage expectations, fallback behavior, context
flag, and streamed output checks, using distinct test-case inputs and expected
results for the cfHandler and cfStreamHandler test functions.

In `@relay/channel/cloudflare/relay_cloudflare.go`:
- Around line 122-137: Remove the redundant usageEnvelope declaration and
common.Unmarshal call in the response usage handling. Use response.Usage
directly when calling service.ValidUsage, passing its address to preserve the
existing invalid-usage fallback through service.ResponseText2Usage.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b6a243f6-08b1-402e-8bf0-de05160ef9a7

📥 Commits

Reviewing files that changed from the base of the PR and between a63364d and 166e41d.

📒 Files selected for processing (3)
  • relay/channel/cloudflare/adaptor.go
  • relay/channel/cloudflare/relay_cloudflare.go
  • relay/channel/cloudflare/relay_cloudflare_test.go

Comment on lines +60 to +62
if info.RelayMode == constant.RelayModeChatCompletions && info.SupportStreamOptions && info.IsStream {
request.StreamOptions = &dto.StreamOptions{IncludeUsage: true}
}

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve existing StreamOptions fields.

Assigning a completely new &dto.StreamOptions{} overwrites any existing options the client might have provided (such as IncludeObfuscation). Update the code to modify the existing struct if present, and update the test to verify this preservation.

  • relay/channel/cloudflare/adaptor.go#L60-L62: instantiate request.StreamOptions only if it is nil, then set IncludeUsage = true.
  • relay/channel/cloudflare/relay_cloudflare_test.go#L206-L226: add an existing field like IncludeObfuscation: true to the input StreamOptions mock and assert that it remains true after conversion.
🐛 Proposed fixes

relay/channel/cloudflare/adaptor.go

 	if info.RelayMode == constant.RelayModeChatCompletions && info.SupportStreamOptions && info.IsStream {
-		request.StreamOptions = &dto.StreamOptions{IncludeUsage: true}
+		if request.StreamOptions == nil {
+			request.StreamOptions = &dto.StreamOptions{}
+		}
+		request.StreamOptions.IncludeUsage = true
 	}

relay/channel/cloudflare/relay_cloudflare_test.go

 	request := &dto.GeneralOpenAIRequest{
 		Stream:        common.GetPointer(true),
-		StreamOptions: &dto.StreamOptions{IncludeUsage: false},
+		StreamOptions: &dto.StreamOptions{IncludeUsage: false, IncludeObfuscation: true},
 	}
 // ...
 	require.Same(t, request, converted)
 	require.NotNil(t, request.StreamOptions)
 	assert.True(t, request.StreamOptions.IncludeUsage)
+	assert.True(t, request.StreamOptions.IncludeObfuscation)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if info.RelayMode == constant.RelayModeChatCompletions && info.SupportStreamOptions && info.IsStream {
request.StreamOptions = &dto.StreamOptions{IncludeUsage: true}
}
if info.RelayMode == constant.RelayModeChatCompletions && info.SupportStreamOptions && info.IsStream {
if request.StreamOptions == nil {
request.StreamOptions = &dto.StreamOptions{}
}
request.StreamOptions.IncludeUsage = true
}
Suggested change
if info.RelayMode == constant.RelayModeChatCompletions && info.SupportStreamOptions && info.IsStream {
request.StreamOptions = &dto.StreamOptions{IncludeUsage: true}
}
request := &dto.GeneralOpenAIRequest{
Stream: common.GetPointer(true),
StreamOptions: &dto.StreamOptions{IncludeUsage: false, IncludeObfuscation: true},
}
// ...
require.Same(t, request, converted)
require.NotNil(t, request.StreamOptions)
assert.True(t, request.StreamOptions.IncludeUsage)
assert.True(t, request.StreamOptions.IncludeObfuscation)
📍 Affects 2 files
  • relay/channel/cloudflare/adaptor.go#L60-L62 (this comment)
  • relay/channel/cloudflare/relay_cloudflare_test.go#L206-L226
🤖 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/cloudflare/adaptor.go` around lines 60 - 62, Preserve existing
StreamOptions fields in relay/channel/cloudflare/adaptor.go:60-62 by
initializing request.StreamOptions only when nil, then setting IncludeUsage =
true on the existing struct. Update
relay/channel/cloudflare/relay_cloudflare_test.go:206-226 to provide
IncludeObfuscation: true in the input StreamOptions and assert it remains true
after conversion.

Comment on lines 69 to 73
for _, choice := range response.Choices {
choice.Delta.Role = "assistant"
responseText += choice.Delta.GetContentString()
responseText.WriteString(choice.Delta.GetContentString())
responseText.WriteString(choice.Delta.GetReasoningContent())
}

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Ensure mutation of Role modifies the actual response.

In Go, choice is a copy of the slice element. If Delta is a value type within the choice struct, assigning choice.Delta.Role = "assistant" only modifies the copy, and the updated role will not be included when response is emitted to the client later. Update the loop to use the slice index to ensure the mutation takes effect.

🐛 Proposed fix
-		for _, choice := range response.Choices {
-			choice.Delta.Role = "assistant"
+		for i, choice := range response.Choices {
+			response.Choices[i].Delta.Role = "assistant"
 			responseText.WriteString(choice.Delta.GetContentString())
 			responseText.WriteString(choice.Delta.GetReasoningContent())
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for _, choice := range response.Choices {
choice.Delta.Role = "assistant"
responseText += choice.Delta.GetContentString()
responseText.WriteString(choice.Delta.GetContentString())
responseText.WriteString(choice.Delta.GetReasoningContent())
}
for i, choice := range response.Choices {
response.Choices[i].Delta.Role = "assistant"
responseText.WriteString(choice.Delta.GetContentString())
responseText.WriteString(choice.Delta.GetReasoningContent())
}
🤖 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/cloudflare/relay_cloudflare.go` around lines 69 - 73, Update
the loop over response.Choices so it iterates by index and assigns
response.Choices[index].Delta.Role, ensuring the role mutation affects the
actual response elements while preserving the existing content and reasoning
writes.

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.

Cloudflare 渠道未使用有效上游 Usage 导致 Reasoning Token 漏算

1 participant