Skip to content

perf(relay): count streaming completion tokens incrementally instead of buffering the full response - #5481

Closed
jstar0 wants to merge 6 commits into
QuantumNous:mainfrom
jstar0:feat/streaming-token-counting
Closed

perf(relay): count streaming completion tokens incrementally instead of buffering the full response#5481
jstar0 wants to merge 6 commits into
QuantumNous:mainfrom
jstar0:feat/streaming-token-counting

Conversation

@jstar0

@jstar0 jstar0 commented Jun 13, 2026

Copy link
Copy Markdown

⚠️ 提交说明 / PR Notice

Important

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

📝 变更描述 / Description

Streaming relays currently accumulate the entire streamed completion into a strings.Builder (or a string) only to feed it to ResponseText2Usage once the stream ends. For large-context responses each in-flight request holds the whole output text in memory until completion; under concurrency RSS grows into the GB range and, because Go does not return the heap to the OS, it does not come back down. We hit OOM in production from exactly this.

The fix is based on one observation: EstimateToken in service/token_estimator.go is a per-rune state machine, so the same count can be produced incrementally without ever holding the full text.

What this PR does:

  1. Refactors EstimateToken into a streaming state machine (streamingEstimator) and keeps the old one-shot function as a thin wrapper over it, so existing callers are unchanged. A UsageAccumulator wraps it for the relay layer (service/stream_token_counter.go). Counting is bit-for-bit identical to the old EstimateToken over the concatenated text — verified with an independent reference implementation (a copy of the previous loop) plus hardcoded anchors, so billing output does not change.
  2. Replaces the strings.Builder "accumulate then count" pattern in every streaming handler (openai chat/responses/chat-via-responses, claude + aws, gemini, xai, cohere, dify, tencent, cloudflare, coze) with the accumulator. Upstream-provided usage is still preferred exactly as before; the local estimate is only the fallback. Per-protocol extras are preserved (tool-call compensation, gemini image tokens, dify node tokens, coze context prompt tokens). Claude counts text and thinking separately.
  3. Adds a per-channel trust_upstream_usage setting (default off, so no behavior change for existing channels) for channels whose upstream returns accurate usage, with a toggle in the channel edit drawer and zh/en strings.

Memory only — token numbers are unchanged.

🚀 变更类型 / 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

Memory — local A/B against the same mock upstream streaming ~600k completion tokens, 20 concurrent requests:

Build Peak RSS After completion Returns to OS
before (strings.Builder) ~155 MiB ~95 MiB no
after (streamed counting) ~35 MiB ~28 MiB yes

At 50 concurrent the streamed build stays ~45 MiB instead of scaling with response size.

Correctnessgo test ./service/ ./relay/channel/...:

  • TestEstimateToken_MatchesReferenceImpl / TestEstimateToken_FuzzMatchesReference — refactored estimator equals an independent copy of the original loop over a corpus + 1000 random inputs (incl. multibyte runes split across chunks), per provider.
  • TestEstimateToken_HardcodedAnchors — hand-computed values, independent of the implementation.
  • Per-handler stream tests asserting concrete usage numbers for trust-on (upstream usage) and trust-off (local estimate), including a case built from a real captured upstream Claude SSE stream, plus cache-token preservation, tool-call compensation, multi-choice/empty-delta and truncated streams.

(The three pre-existing TestRequestOpenAI2ClaudeMessage_*File* failures on main are unrelated to this PR — they fail on a clean checkout of main as well.)

Summary by CodeRabbit

  • New Features

    • Added a Trust Upstream Usage channel setting (default: off). When enabled, token billing uses the upstream-reported usage; when disabled, Relay uses a local streaming estimate.
  • Improvements

    • Streaming token accounting was updated to avoid buffering full response text, helping reduce memory usage on large streams.
  • User Interface

    • Added a toggle for Trust Upstream Usage in the channel advanced settings.
  • Localization

    • Added English and Chinese labels/descriptions for the new setting.

jstar0 added 3 commits June 14, 2026 04:16
Refactor EstimateToken into a streaming state machine (streamingEstimator)
that accepts text chunk-by-chunk while keeping O(1) state, and add a
UsageAccumulator that wraps it for use across relay handlers.

The streaming estimator produces bit-for-bit identical results to the
original one-shot EstimateToken for any chunk split (verified against an
independent reference implementation and hardcoded anchors), so billing
output is unchanged. This replaces the previous pattern of buffering the
full streamed response into a strings.Builder before counting tokens,
which made heap residency grow with response size.

UsageAccumulator.Resolve implements the trust-upstream-usage policy:
prefer the upstream-reported completion tokens when trusted and present,
otherwise fall back to the local streamed estimate. Reasoning/thinking
content is counted via a separate estimator (FeedReasoning).
Replace the "accumulate the whole streamed response into a strings.Builder,
then count tokens at the end" pattern with the bounded-memory
UsageAccumulator in every streaming relay handler: openai chat/completions,
openai responses, chat-via-responses, claude (and aws which delegates to it),
gemini, xai, cohere, dify, tencent, cloudflare, coze.

Under large-context streaming responses the old pattern held the entire
output text in memory until the request finished and Go did not return the
heap to the OS, so process RSS grew unbounded under concurrency. The
accumulator keeps only O(1) state per request.

Token output is unchanged when the upstream provides usage (it is used as
before) and matches the previous local estimate when it does not. Each
handler's per-protocol extras are preserved: tool-call compensation
(openai/xai), image token counting (gemini), node tokens (dify), and
context-sourced prompt tokens (coze). Claude counts text and thinking
separately.

Add a per-channel trust_upstream_usage setting (dto.ChannelSettings,
default false) so operators can make channels whose upstream returns
accurate usage rely on it directly.

Add stream-handler tests for every path, including a case built from a
real captured upstream Claude SSE stream, and tests for the trust toggle,
cache-token preservation, tool-call compensation and truncated streams.
Expose the trust_upstream_usage channel setting in the channel edit drawer
with a switch and zh/en localization, alongside the existing channel
settings (force format, pass-through body, etc.).
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5b24fe8f-4095-476b-bee3-d4128de9a478

📥 Commits

Reviewing files that changed from the base of the PR and between 8532fba and df9ba01.

📒 Files selected for processing (1)
  • service/stream_token_counter_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • service/stream_token_counter_test.go

Walkthrough

Replaces buffering full streamed responses with a bounded-memory streaming token estimator (UsageAccumulator), migrates channel stream handlers to feed deltas into the accumulator, adds a per-channel TrustUpstreamUsage setting (default false), and adds tests and UI wiring for the new behavior.

Changes

Streaming Token Accumulation Refactor

Layer / File(s) Summary
Streaming estimator and accumulator core
service/token_estimator.go, service/stream_token_counter.go
Refactors EstimateToken to use an internal streaming estimator that handles UTF-8 rune boundaries correctly when fed chunks; introduces UsageAccumulator with Feed, FeedReasoning, WriteString, LocalCompletionTokens, and Resolve methods for incremental token counting with optional trust of upstream usage.
Estimator equivalence and accumulator tests
service/estimator_reference_test.go, service/stream_token_counter_test.go
Adds reference-based and fuzz tests validating streaming token estimation matches one-shot behavior across providers, chunk splits, UTF-8 boundaries, and reasoning separation; tests UsageAccumulator gold-standard accuracy and trust-aware resolution semantics.
Trust-upstream setting contract and UI wiring
dto/channel_settings.go, web/default/src/features/channels/types.ts, web/default/src/features/channels/lib/channel-form.ts, web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx, web/default/src/i18n/locales/en.json, web/default/src/i18n/locales/zh.json
Adds trust_upstream_usage boolean setting (default false) with DTO field, TypeScript interface, form schema/defaults, UI toggle, and English/Chinese i18n labels.
Claude response accounting migration
relay/channel/claude/relay-claude.go, relay/channel/claude/relay_claude_test.go, relay/channel/claude/stream_handler_test.go, relay/channel/aws/relay-aws.go
Removes ResponseText string builder from ClaudeResponseInfo; uses lazy UsageAccumulator to feed text and thinking deltas; updates HandleStreamFinalResponse to derive completion tokens from local estimate and prompt tokens from relay info; removes obsolete builder initializations; adds tests for upstream trust, local fallback, thinking counting, cache-token preservation, and realistic SSE parsing.
OpenAI streaming handlers migration
relay/channel/openai/helper.go, relay/channel/openai/relay-openai.go, relay/channel/openai/relay_responses.go, relay/channel/openai/chat_via_responses.go, relay/channel/openai/stream_handler_test.go
Replaces buffered text builders with UsageAccumulator feeding; changes ProcessStreamResponse to accept io.StringWriter instead of *strings.Builder; tracks output length via counter for tool-call detection; resolves final tokens with usageAcc.Resolve(upstreamCompletion, trustUpstream); adds tests for upstream trust, local fallback, tool-call compensation, multi-choice streams, and truncated streams.
Other channel handler migrations
relay/channel/cloudflare/*, relay/channel/cohere/*, relay/channel/coze/*, relay/channel/dify/*, relay/channel/gemini/*, relay/channel/tencent/*, relay/channel/xai/*
Each handler initializes UsageAccumulator, feeds streamed deltas instead of buffering text, and computes final usage from estimated prompt tokens via info.GetEstimatePromptTokens() plus local completion tokens via usageAcc.LocalCompletionTokens(); tests added per-channel validating upstream trust and local fallback behavior.

Sequence Diagram(s)

sequenceDiagram
  participant Client as Client
  participant Upstream as Upstream API
  participant Relay as Relay Handler
  participant Acc as UsageAccumulator
  participant Info as RelayInfo

  Client->>Relay: request → start stream
  Relay->>Upstream: proxied request
  Upstream->>Relay: SSE chunk (text delta)
  Relay->>Acc: Feed(delta)
  Acc-->>Acc: estimate rune by rune (O(1) state)
  Upstream->>Relay: SSE chunk (thinking/tool delta)
  Relay->>Acc: FeedReasoning(delta) or Feed(delta)
  Upstream->>Relay: stream end (maybe upstream usage)
  alt trust_upstream_usage = true && upstream provided
    Acc->>Relay: Resolve(...) → upstream tokens
  else fallback to local
    Acc->>Relay: LocalCompletionTokens() → local estimate
  end
  Relay->>Info: GetEstimatePromptTokens()
  Relay->>Client: final response with usage
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • QuantumNous/new-api#4128: Overlaps on Claude HandleStreamFinalResponse final usage-repair logic refactoring.
  • QuantumNous/new-api#3080: Related changes to Claude response structure and ClaudeResponseInfo streaming pipeline.
  • QuantumNous/new-api#2355: Related refactoring of token-estimation logic in service/token_estimator.go feeding downstream usage calculations.

Suggested reviewers

  • seefs001
  • Calcium-Ion

Poem

🐰 I nibble deltas as they come, not hoard,
Tokens tallied, memory kept small and bored,
Upstream tells — I may believe,
Or count myself, a bounded weave,
Hooray — no giant builders stored!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.04% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: refactoring streaming completion token counting to use incremental accumulation instead of buffering the full response.
Linked Issues check ✅ Passed The PR fully implements the core objectives from issue #5480: streaming token accumulator with O(1) memory, incremental counting identical to current implementation, preserved upstream usage preference, and optional per-channel trust setting.
Out of Scope Changes check ✅ Passed All changes are directly related to the objective of replacing full-text buffering with streaming token accumulation. No extraneous modifications detected.

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

✨ 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 and usage tips.

@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: 5

Caution

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

⚠️ Outside diff range comments (2)
web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx (1)

204-223: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Include trust_upstream_usage in advanced-settings detection.

When editing a channel where this is the only advanced flag enabled, hasAdvancedSettingsValues returns false, so the advanced panel can stay collapsed and hide an active non-default setting.

Suggested fix
 function hasAdvancedSettingsValues(values: ChannelFormValues): boolean {
   return Boolean(
@@
     values.pass_through_body_enabled ||
+    values.trust_upstream_usage ||
     values.system_prompt_override ||
@@
   )
 }

Also applies to: 3196-3217

🤖 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
`@web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx`
around lines 204 - 223, Update the hasAdvancedSettingsValues function to include
the trust_upstream_usage field in its truthy checks: add
values.trust_upstream_usage (or values.trust_upstream_usage === true) into the
Boolean(...) OR chain in hasAdvancedSettingsValues (ChannelFormValues) so the
advanced-settings panel detects when trust_upstream_usage is enabled; make the
same addition in the other mirrored occurrence of this function/logic mentioned
in the review.
relay/channel/gemini/relay-gemini.go (1)

1373-1376: ⚠️ Potential issue | 🟠 Major

Apply trust_upstream_usage when resolving streaming token usage (Gemini/XAI)

UsageAccumulator.Resolve(upstreamCompletion, trustUpstream) is the intended gating mechanism for using upstream completion tokens only when TrustUpstreamUsage is enabled, but the Gemini/XAI handlers’ usage assignment logic never routes through Resolve(...) / trust_upstream_usage (no matches found in the handler files for Resolve( or trust_upstream_usage/TrustUpstreamUsage). As a result, upstream token metadata is used whenever present, which bypasses the call-site semantics of trust_upstream_usage.

🤖 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/gemini/relay-gemini.go` around lines 1373 - 1376, The code
assigns upstream token usage directly from geminiResponse via
buildUsageFromGeminiMetadata and bypasses the intended gating; change the
assignment to call UsageAccumulator.Resolve(upstreamCompletion, trustUpstream)
so upstream usage is only applied when TrustUpstreamUsage is enabled: replace
the direct mapping branch that sets *usage = mappedUsage (using
buildUsageFromGeminiMetadata and geminiResponse.UsageMetadata) with logic that
wraps the mapped usage into an upstreamCompletion/usage accumulator and invokes
UsageAccumulator.Resolve(..., trust_upstream_usage / TrustUpstreamUsage) (use
the same upstreamCompletion structure used by other handlers) and assign the
resolved value back to *usage so trust_upstream_usage semantics are respected.
🧹 Nitpick comments (1)
relay/channel/cohere/stream_handler_test.go (1)

65-73: ⚡ Quick win

Add an upstream-usage stream test that asserts TotalTokens.

This test only covers local fallback. Please add a stream-end usage case (with billed input/output tokens) and assert prompt/completion/total together to prevent accounting regressions.

🤖 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/cohere/stream_handler_test.go` around lines 65 - 73, The test
TestCohereStreamHandler_LocalEstimate only covers local fallback and doesn't
assert upstream billing; add a new or extended stream test that sends an SSE
payload including a stream-end event with billed token counts (e.g., finish
event fields for prompt_tokens and completion_tokens or upstream-usage fields)
and call cohereStreamHandler(streamCtx(), streamInfo("command-r-plus"),
sseResp(...)) then assert usage.PromptTokens, usage.CompletionTokens and
usage.TotalTokens (or compute TotalTokens = PromptTokens+CompletionTokens) are
set and > 0 to lock in accounting; update the test to include those specific
stream-end fields and assertions so the handler parses upstream usage into
usage.TotalTokens as well as the individual token fields.
🤖 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 `@dto/channel_settings.go`:
- Around line 10-15: Update the doc comment for the TrustUpstreamUsage field in
dto/channel_settings.go to reflect the current behavior: state that streaming
paths already avoid buffering full response text and that this boolean now
controls whether the relay prefers usage values reported by the upstream over
local streamed token counting; keep note that it defaults to false to preserve
local-counting preference. Reference the TrustUpstreamUsage field name in the
comment so reviewers can find and verify the change.

In `@relay/channel/cohere/relay-cohere.go`:
- Around line 171-175: The returned usage may leave usage.TotalTokens as 0 when
upstream billed usage exists because the fallback block only sets TotalTokens
inside the PromptTokens==0 branch; always set usage.TotalTokens by assigning
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens just before
returning (i.e., after the existing if block) so both the upstream-billed path
and the fallback path produce a consistent TotalTokens value; update the code
around usage, info.GetEstimatePromptTokens(), and
usageAcc.LocalCompletionTokens() to ensure TotalTokens is computed
unconditionally.

In `@relay/channel/dify/relay-dify.go`:
- Around line 259-265: The code finalizes usage.TotalTokens before applying the
nodeToken adjustment, causing undercounting; update the logic in the block that
sets usage.PromptTokens, usage.CompletionTokens and usage.TotalTokens (using
info.GetEstimatePromptTokens() and usageAcc.LocalCompletionTokens()) so that
after adding nodeToken to usage.CompletionTokens you recompute usage.TotalTokens
= usage.PromptTokens + usage.CompletionTokens (i.e., move or repeat the
TotalTokens assignment after the line that does usage.CompletionTokens +=
nodeToken) so TotalTokens reflects the compensated completion tokens.

In `@relay/channel/openai/relay_responses.go`:
- Around line 86-87: The code dereferences info when constructing usageAcc
(usageAcc := service.NewUsageAccumulator(info.UpstreamModelName)) without
checking info for nil; change this to conditionally read UpstreamModelName only
when info != nil (e.g., compute modelName := "" or nil when info is nil, then
call service.NewUsageAccumulator(modelName)) and keep the existing
trustUpstreamUsage assignment (trustUpstreamUsage := info != nil &&
info.ChannelSetting.TrustUpstreamUsage) intact so no nil dereference can occur.

In `@service/token_estimator.go`:
- Around line 175-177: streamingEstimator.result() currently ignores bytes
buffered in e.pending (incomplete UTF-8 suffixes), so update result() to flush
those pending bytes into the token count before computing the final value: if
len(e.pending) > 0, add float64(len(e.pending)) to e.count (to match one-shot
behavior of emitting a RuneError per leftover byte), then compute and return
int(math.Ceil(e.count)) + e.m.BasePad; optionally clear e.pending after
counting. Reference: streamingEstimator.result(), e.pending, e.count, and
e.m.BasePad.

---

Outside diff comments:
In `@relay/channel/gemini/relay-gemini.go`:
- Around line 1373-1376: The code assigns upstream token usage directly from
geminiResponse via buildUsageFromGeminiMetadata and bypasses the intended
gating; change the assignment to call
UsageAccumulator.Resolve(upstreamCompletion, trustUpstream) so upstream usage is
only applied when TrustUpstreamUsage is enabled: replace the direct mapping
branch that sets *usage = mappedUsage (using buildUsageFromGeminiMetadata and
geminiResponse.UsageMetadata) with logic that wraps the mapped usage into an
upstreamCompletion/usage accumulator and invokes UsageAccumulator.Resolve(...,
trust_upstream_usage / TrustUpstreamUsage) (use the same upstreamCompletion
structure used by other handlers) and assign the resolved value back to *usage
so trust_upstream_usage semantics are respected.

In
`@web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx`:
- Around line 204-223: Update the hasAdvancedSettingsValues function to include
the trust_upstream_usage field in its truthy checks: add
values.trust_upstream_usage (or values.trust_upstream_usage === true) into the
Boolean(...) OR chain in hasAdvancedSettingsValues (ChannelFormValues) so the
advanced-settings panel detects when trust_upstream_usage is enabled; make the
same addition in the other mirrored occurrence of this function/logic mentioned
in the review.

---

Nitpick comments:
In `@relay/channel/cohere/stream_handler_test.go`:
- Around line 65-73: The test TestCohereStreamHandler_LocalEstimate only covers
local fallback and doesn't assert upstream billing; add a new or extended stream
test that sends an SSE payload including a stream-end event with billed token
counts (e.g., finish event fields for prompt_tokens and completion_tokens or
upstream-usage fields) and call cohereStreamHandler(streamCtx(),
streamInfo("command-r-plus"), sseResp(...)) then assert usage.PromptTokens,
usage.CompletionTokens and usage.TotalTokens (or compute TotalTokens =
PromptTokens+CompletionTokens) are set and > 0 to lock in accounting; update the
test to include those specific stream-end fields and assertions so the handler
parses upstream usage into usage.TotalTokens as well as the individual token
fields.
🪄 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: 82d9cd19-eb0a-4ee0-884a-b559dd537501

📥 Commits

Reviewing files that changed from the base of the PR and between 1ac0f58 and 9128882.

📒 Files selected for processing (33)
  • dto/channel_settings.go
  • relay/channel/aws/relay-aws.go
  • relay/channel/claude/relay-claude.go
  • relay/channel/claude/relay_claude_test.go
  • relay/channel/claude/stream_handler_test.go
  • relay/channel/cloudflare/relay_cloudflare.go
  • relay/channel/cloudflare/stream_handler_test.go
  • relay/channel/cohere/relay-cohere.go
  • relay/channel/cohere/stream_handler_test.go
  • relay/channel/coze/relay-coze.go
  • relay/channel/coze/stream_handler_test.go
  • relay/channel/dify/relay-dify.go
  • relay/channel/dify/stream_handler_test.go
  • relay/channel/gemini/relay-gemini.go
  • relay/channel/gemini/stream_handler_test.go
  • relay/channel/openai/chat_via_responses.go
  • relay/channel/openai/helper.go
  • relay/channel/openai/relay-openai.go
  • relay/channel/openai/relay_responses.go
  • relay/channel/openai/stream_handler_test.go
  • relay/channel/tencent/relay-tencent.go
  • relay/channel/tencent/stream_handler_test.go
  • relay/channel/xai/stream_handler_test.go
  • relay/channel/xai/text.go
  • service/estimator_reference_test.go
  • service/stream_token_counter.go
  • service/stream_token_counter_test.go
  • service/token_estimator.go
  • web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx
  • web/default/src/features/channels/lib/channel-form.ts
  • web/default/src/features/channels/types.ts
  • web/default/src/i18n/locales/en.json
  • web/default/src/i18n/locales/zh.json
💤 Files with no reviewable changes (1)
  • relay/channel/aws/relay-aws.go

Comment thread dto/channel_settings.go Outdated
Comment thread relay/channel/cohere/relay-cohere.go
Comment thread relay/channel/dify/relay-dify.go
Comment thread relay/channel/openai/relay_responses.go Outdated
Comment thread service/token_estimator.go
jstar0 added 2 commits June 14, 2026 05:45
…ator

The streaming estimator buffered an incomplete trailing UTF-8 sequence in
e.pending but result() ignored it, so a stream ending mid-rune (truncation
or timeout) under-counted versus the one-shot EstimateToken, which decodes
each leftover byte as utf8.RuneError via for-range. Flush pending bytes in
result() the same way to keep streaming and one-shot counts bit-for-bit
identical. Adds reference-impl parity tests for truncated UTF-8 inputs.

Also guard info consistently in OaiResponsesStreamHandler: the model name
and prompt-token fallback are now read only when info is non-nil, matching
the existing nil check on the trust flag.
In the cohere stream handler, TotalTokens was only set inside the local
fallback block, so when upstream billed usage was present it stayed 0 and
the returned usage was inconsistent. In the dify stream handler, nodeToken
was added to CompletionTokens after TotalTokens had been finalized, so the
total under-counted. Compute TotalTokens after all completion adjustments
in both handlers. Adds tests asserting the totals.

Also update the TrustUpstreamUsage doc comment: streaming paths no longer
buffer the full response regardless of this flag; it now only decides
whether upstream usage is preferred over the local streamed count.

@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)
service/estimator_reference_test.go (1)

140-145: Comment-code mismatch and potential test gap.

The comment on line 140 states "feed the residual tail bytes as the last chunk separately" (把残留尾字节单独作为最后一个 chunk 喂入), but the code feeds the entire text at once with e.feed(text).

Additionally, while this test correctly validates truncated UTF-8 at stream end, it doesn't test the chunking behavior where a valid multi-byte UTF-8 sequence is split across multiple feed() calls (e.g., feeding "ab\xe4" then "\xb8\xad" to split "中" across two chunks). This scenario would more thoroughly exercise the streaming estimator's pending buffer logic for handling incomplete runes at chunk boundaries.

📝 Suggested improvements

Clarify the comment and optionally add multi-chunk test coverage:

-// 流式:把残留尾字节单独作为最后一个 chunk 喂入,再 result
+// 流式:喂入被切断的文本(尾部有残留字节),再 result
 e := newStreamingEstimator(p)
 e.feed(text)
 if got := e.result(); got != want {

Optional: Add a separate test case for multi-chunk valid UTF-8 (if not covered elsewhere):

// Test splitting valid UTF-8 across chunks
text := "ab中def"
e := newStreamingEstimator(p)
e.feed("ab\xe4")      // Feed "ab" + first byte of "中"
e.feed("\xb8\xad" + "def")  // Feed remaining bytes of "中" + "def"
if got := e.result(); got != referenceEstimateToken(p, text) {
    t.Errorf("multi-chunk split failed")
}
🤖 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 `@service/estimator_reference_test.go` around lines 140 - 145, The comment
states the test should feed a residual tail byte as a separate chunk but the
code calls e.feed(text) once; update the test around newStreamingEstimator,
e.feed and e.result to exercise chunked streaming: either change the comment to
match the single-feed behavior or (preferable) add/modify a test case that
splits a multi-byte UTF-8 rune across multiple e.feed(...) calls (for example
feed the prefix including the first byte(s) of a multi-byte rune, then feed the
remaining bytes plus the rest of the string) and assert e.result() equals
referenceEstimateToken(provider, fullText); keep the existing truncated-tail
case but add this multi-chunk case to validate the pending-buffer logic of
newStreamingEstimator.
🤖 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 `@service/estimator_reference_test.go`:
- Around line 140-145: The comment states the test should feed a residual tail
byte as a separate chunk but the code calls e.feed(text) once; update the test
around newStreamingEstimator, e.feed and e.result to exercise chunked streaming:
either change the comment to match the single-feed behavior or (preferable)
add/modify a test case that splits a multi-byte UTF-8 rune across multiple
e.feed(...) calls (for example feed the prefix including the first byte(s) of a
multi-byte rune, then feed the remaining bytes plus the rest of the string) and
assert e.result() equals referenceEstimateToken(provider, fullText); keep the
existing truncated-tail case but add this multi-chunk case to validate the
pending-buffer logic of newStreamingEstimator.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e5ee40a1-7cc5-40f9-b1b0-c2f6cc342360

📥 Commits

Reviewing files that changed from the base of the PR and between 9128882 and 8532fba.

📒 Files selected for processing (8)
  • dto/channel_settings.go
  • relay/channel/cohere/relay-cohere.go
  • relay/channel/cohere/stream_handler_test.go
  • relay/channel/dify/relay-dify.go
  • relay/channel/dify/stream_handler_test.go
  • relay/channel/openai/relay_responses.go
  • service/estimator_reference_test.go
  • service/token_estimator.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • dto/channel_settings.go
  • relay/channel/dify/relay-dify.go
  • relay/channel/cohere/relay-cohere.go
  • service/token_estimator.go
  • relay/channel/openai/relay_responses.go

@seefs001 seefs001 self-assigned this Jun 14, 2026
@jstar0

jstar0 commented Jun 15, 2026

Copy link
Copy Markdown
Author

Closing this PR to split the work into narrower, easier-to-review changes.

This PR mixes a channel-level upstream usage trust policy with streaming usage performance changes. Since both touch billing behavior in different ways, they should be reviewed and verified independently.

I’ll follow up with:

  1. a focused PR for the trust_upstream_usage channel policy only;
  2. a separate performance PR with stricter equivalence coverage for local fallback usage counting.

Thanks for the review context so far.

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.

Streaming relays buffer the full response text in memory just to count completion tokens

2 participants