fix(relay): bill Responses stream usage on incomplete/cancelled/failed terminal events - #7242
fix(relay): bill Responses stream usage on incomplete/cancelled/failed terminal events#7242txgo wants to merge 1 commit into
Conversation
…d terminal events `OaiResponsesStreamHandler` only read `response.usage` in the `response.completed` / `response.done` branch. The other terminal events (`response.incomplete`, `response.cancelled`, `response.canceled`, `response.failed`) also carry a response object with upstream usage, but the handler discarded it and only reset the image counter. The non-streaming path does not have this gap: `OaiResponsesHandler` calls `NormalizeResponsesUsage(responsesResponse.Usage)` unconditionally, so the same truncated request is billed correctly when `stream=false`. The streaming path was silently inconsistent with it. The output-text fallback at the end of the handler does not cover the gap. It only triggers when `response.output_text.delta` events were seen, so a reasoning model truncated by `max_output_tokens`, or any response truncated before the first text token, ends with zero prompt and completion tokens even though the terminal event reported real counts. Fix: read usage in the terminal-event branch through the same `NormalizeResponsesUsage` + `MergeUsageNonZero` pair the completed branch uses. Image-generation call counting is unchanged: those events still reset the counter, because the image call did not complete. Tests: four cases in relay_responses_billing_test.go covering incomplete without a text delta, incomplete with a text delta (upstream numbers must beat the local estimate), cancelled, and a terminal event carrying no usage at all (must stay zero). Without the fix the first three fail and the fourth passes; with the fix all four pass. The shared helper initializes the token encoders so the fallback path reports an assertion failure instead of a nil-pointer panic.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review. WalkthroughThe streaming Responses handler now reads usage from terminal response events and merges it into accumulated usage. Tests cover incomplete and cancelled events, upstream usage precedence, and terminal events without usage. ChangesStreaming usage billing
Estimated code review effort: 2 (Simple) | ~10 minutes Merge Risk: ⚪ Minimal · up to Streaming Responses requests now retain upstream token usage from terminal incomplete, cancelled, canceled, and failed events for billing while preserving existing image-count behavior. The covered terminal billing paths are ready to merge. Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
CI 说明:
|
Agent
Links
switch,改的是completed分支的sr.Done(),与本 PR 不冲突)· Bug: upstream TCP reset leaves Responses SSE without a terminal event #7059 · fix(relay): emit terminal error event when upstream stream fails mid-flight #7066(均为「终止事件缺失」类,与本 PR 互补)User request
用户原话:「rc.30 计费缺口:流式
/v1/responses以response.incomplete结束时不读 usage,整条按 0 计」,并要求「向上游提交 issue 和 PR……PR 更是要遵从规范,让 PR 能够真正被接受」。Out of scope — refuse
Kind
Issue facts
/v1/responses以response.incomplete结束(如incomplete_details.reason = max_output_tokens)时,该请求quota = 0,日志记「上游没有返回计费信息,无法扣费」,而该终止事件体内带有usage。glm-5.338 ·glm-5.3-flash35 ·doubao-seed-evolving2,涉 6 个用户)。usage.output_tokens(64 / 1500,与请求的max_output_tokens一致);② 同上游、同模型、同截断条件下非流式请求计费正常,差异只在stream;③ 源码分支可直接解释该差异(见 Change)。Change
OaiResponsesStreamHandler只在response.completed/response.done分支读取Response.Usage;response.failed/response.incomplete/response.cancelled/response.canceled这一分支只复位图像计数器,不读 usage。本 PR 在该终止分支复用
completed分支已在用的同一对函数读取 usage:为什么这样能生效,三点都落在实际改到的代码上:
OaiResponsesHandler第 42 行usage := relayconvert.NormalizeResponsesUsage(responsesResponse.Usage)是无条件的,所以stream = false时同一截断请求计费正确。流式路径此前与它不一致,本 PR 消除这个分叉。response.output_text.delta;推理型模型在产出首个文本 token 前被截断时responseTextBuilder为空,兜底不触发,prompt 与 completion 均归零。这解释了生产命中集中在推理模型。IsNonBillableResponsesStatus只服务于图像生成调用计数,终止分支仍照旧Reset()后Commit();本 PR 不触碰这段。token 已经产生,与图像调用是否成立是两件事。Research
Duplicate / prior art
response.incomplete、incomplete usage、responses 计费、stream usage 0、responses not billed、responses usage、incomplete billingclient_gone,改的是completed/done分支加sr.Done()。与本 PR 同文件同switch但不同分支、不同目的,可并存。Docs and code
zh/docs/guide/feature-guide/user/pricing。未记载流式 Responses 的 usage 提取,也未记载终止事件的计费处理;无可配置开关。relay/channel/openai/relay_responses.go:91-96—completed/done分支读 usage 的现成写法,本 PR 直接复用。relay/channel/openai/relay_responses.go:115-120— 终止分支,本 PR 唯一改动点。relay/channel/openai/relay_responses.go:42— 非流式无条件读 usage,是「应有行为」的项目内参照。relay/channel/openai/relay_responses.go:142-150— 文本兜底,说明为何缺陷未被现有机制掩盖。relay/common/tool_usage.go:180—IsNonBillableResponsesStatus的适用范围仅为图像计数,故不能拿它否定 token 计费。Alternatives considered
completed分支统一处理。 会一并把图像计数的Observe逻辑套到终止事件上,改变现有图像计费语义,超出修复范围。NormalizeResponsesUsage+MergeUsageNonZero,不新增状态、不改变图像计数、不引入新概念,并直接消除与非流式路径的不一致。Files
relay/channel/openai/relay_responses.goResponse.Usage(+11 行,含注释)relay/channel/openai/relay_responses_billing_test.goBehavior
/v1/responses以incomplete/cancelled/canceled/failed结束时,终止事件里的 usage 被丢弃;若流中又没有过文本增量,该请求最终按 0 计费。stream = false的同条件请求一致;若终止事件本身不带 usage,行为与此前完全相同。client_gone误标(fix(relay): mark Responses streams done on terminal event to avoid client_gone mislabel #6904);不改动图像生成调用计数;不改动计费口径与倍率。Verification
Commands and results:
gofmt -l relay/→ 无输出go vet ./...→ 通过make test→ EXIT=0,全部包通过(50 个包报ok)go test ./relay/channel/openai/→okManual steps and observed result: 缺陷本身在自托管网关上以真实上游复现(
stream = true,max_output_tokens = 64与1500,推理型模型),两次均以response.incomplete结束、事件体带usage、日志quota = 0。详见 Bug: streaming /v1/responses discards usage on response.incomplete and bills the request as zero #7241。UI: screenshot or recording (or why none): 无 UI 改动。
Tests added or updated: 新增 4 个用例。每个用例都做了阳性对照(临时移除修复后单独运行):
...BillsUsageOnIncompleteWithoutTextDelta...PrefersUpstreamUsageOnIncomplete...BillsUsageOnCancelled...IncompleteWithoutUsageStaysZero共享 helper 里调用了
service.InitTokenEncoders():文本兜底路径会解引用默认编码器,不初始化时该路径以空指针 panic 收场而非给出断言失败。初始化后为纯内存操作(实测 0.01s,不联网)。Databases / providers / platforms exercised: 复现发生在 mysql + 原生 Responses 上游(
converter = none);单元测试不触及数据库。Not verified: 未做「直连上游 vs 经 new-api」逐字对照;未在 sqlite / postgres 上复现(该路径不含数据库分支);未验证
response.failed在真实上游下是否总带 usage(实测只覆盖incomplete);经转换器的 Responses 路径未验证。Risks
completed分支早已被无条件采用、非流式路径也无条件采用,本 PR 不引入新的信任面。client_gone误标(fix(relay): mark Responses streams done on terminal event to avoid client_gone mislabel #6904)是另外两类,不在本 PR 内。Scope check
Summary by CodeRabbit