Repository navigation
bili drops an OpenAI-compatible stream's usage when it arrives on a non-empty choices frame with an empty delta #4488
Description
TL;DR — On a relay that does return usage on every single request, billion-context 0.1.205/0.1.206 still recorded no usage for 4 hours, so nudge never saw growth and never folded. The reason is not the relay, and not only the statusInputBaseline fallback tracked in #4383: the OpenAI SSE parser only harvests usage from two frame shapes (no choices at all, or a frame carrying finish_reason). A usage-only frame that still carries choices:[{delta:{}}] with no finish_reason falls through both branches and the usage is silently discarded. I verified this against the upstream with curl and with a one-line patch to dist/index.js that fixed the frozen session.
Related: #4383 (the reported freeze), #4413 / #4443 (read-side fixes for it), #593 (an earlier, different frame shape with the same symptom).
Environment
- billion-context 0.1.204 → 0.1.205 → 0.1.206 (all three exhibit the bug; 0.1.206's parser is byte-identical to 0.1.205's in this area)
- Windows Server 2022, Git Bash
- Client: ZCode via an OpenAI-compatible endpoint served by bili on
127.0.0.1:8787 - Upstream: a local relay (
wild-workgateway) on127.0.0.1:7863, which fronts several channels - Model that froze:
raccoon/sn-deepseek-v4-1-flash-1017, declaredcontext_length: 1000000,max_output_tokens: 100000; bili resolves an effective window of 750000 after output headroom - Model that recovered it:
workbuddy/deepseek-v4.1-flash, same declared window
Symptom
Session a5ea1121-b895-4ee3-bfdc-e0a967642270 sat frozen for 4h07m (00:31:59Z → 04:39:20Z). During that window every nudge verdict was idle with growth=0, while pendingT1 climbed to 284030 tokens:
02:xx nudge idle: usage=1% (4733/750000), growth=0/22500 (ref=4733, interval=50000), pendingT1=170128/50000 model=raccoon/sn-deepseek-v4-1-flash-1017
...
04:37 nudge idle: usage=1% (4733/750000), growth=0/22500 (ref=4733, interval=50000), pendingT1=284030/50000 model=raccoon/sn-deepseek-v4-1-flash-1017
The same requests were carrying ~300k tokens of input — bili's own budget line proves it knows:
04:37:31 output budget clamped 384000 -> 100000 (input~303092.92940082756, window=1000000); prevents input+output overflow (#453)
So one code path measures 303093 tokens and another reports 4733, with no new usage sample arriving to correct it. Switching the model (no restart, no config change) fixed it instantly:
04:39:21 [acp-usage] round 1 input=252154 cached=22400 (cache hit 9%)
04:39:25 nudge INJECT T1: ... growth=247421/22500 (ref=4733, interval=50000), pendingT1=285786/50000
04:39:49 [acp-compress-obs] shrink 84% (~245156/293216 tok) foldPoints=m00391 blocks=5 ... postCtx≈48060
This is the freeze already filed as #4383.
What #4383 does not capture: the upstream is reporting usage
The premise in #4383's triage — and in maintainer comment 2 and 4 — is that this relay "从不回传 usage" (never returns usage), so the anchor can only go stale. That is not what happens here. I queried the relay directly and it returns usage on every request, streaming or not:
Non-streaming (POST http://127.0.0.1:7863/v1/chat/completions, model: raccoon/sn-deepseek-v4-1-flash-1017):
"usage": {"completion_tokens": 12, "prompt_tokens": 33, "total_tokens": 45}Streaming, tail of the SSE body — this is the shape that matters:
data: {"choices":[{"delta":{},"finish_reason":"stop","index":0}],...,"model":"raccoon/sn-deepseek-v4-1-flash-1017",...}
data: {"choices":[{"delta":{},"index":0}],...,"model":"raccoon/sn-deepseek-v4-1-flash-1017","usage":{"completion_tokens":11,"prompt_tokens":33,"total_tokens":44}}
data: [DONE]
The usage rides on a separate trailing frame that keeps choices non-empty ([{"delta":{},"index":0}]) and carries no finish_reason.
For contrast, workbuddy/deepseek-v4.1-flash on the same relay puts usage on the frame that does carry finish_reason:
data: {"choices":[{"delta":{"role":"assistant"},"finish_reason":"stop","index":0}],...,"usage":{"cache_read_input_tokens":0,...,"prompt_tokens":7,...}}
Same relay, same request shape, two different placements. One is readable by bili, the other is not.
The parser
In the shipped 0.1.205 bundle (dist/index.js, line 6102, OpenAI SSE parseStream), usage is harvested in exactly two places:
- When the frame has no
choicesat all — this is thechoices: []shape fixed by fix #589: forward trailing usage-only frame on raw tool-call rounds #593:
const choices=parsed.choices;
const choice=choices?.[0];
if(!choice){
if(parsed.usage){
const u2=parsed.usage;
yield{kind:"usage",inputTokens:...,outputTokens:...,cachedTokens:openaiCachedTokens(u2)};
if(sawRealToolCall){yield{kind:"meta",chunk:rawBuf}}
}
continue
}- When the frame carries
finish_reason:
const delta=choice.delta;
const finishReason=typeof choice.finish_reason==="string"?choice.finish_reason:void 0;
...
if(finishReason){
...
const u2=parsed.usage;
yield{kind:"usage",inputTokens:typeof u2?.prompt_tokens==="number"?u2.prompt_tokens:void 0,...}A frame with choices:[{"delta":{},"index":0}] plus usage satisfies neither: choice exists (so branch 1 is skipped) and finish_reason is undefined (so branch 2 is skipped). delta is {}, truthy, so execution walks all the way to the bottom and the usage field is never read. No warning, no diagnostic — the same silence that made #4383 hard to attribute.
#593 fixed the choices: [] variant of this in 0.1.x. This is the non-empty-choices variant, and it is still open in 0.1.206.
Empirical confirmation from the same session
The freeze and the recovery happened without a restart, so the parser and the session state were constant across both. What changed was which channel produced the tail frame.
- Frozen window (00:31:59Z → 04:39:20Z, 153 main requests on raccoon): exactly one
[acp-usage]line in the entire window. Not one per request. - After the model switch to workbuddy:
[acp-usage]appears on essentially every request (224 lines total, 220 of them after 04:39:20). - Later, back on raccoon in a fresh session:
[acp-usage]stopped again — 06:00Z → 06:19Z had zero[acp-usage]lines despite successful turns, andtokenCountfroze at 182983 whilenudgekept reading that stale value.
So the relay reports usage on every request, and bili records it only when the channel happens to use the finish_reason placement. That is a parser defect, not a relay defect.
Fix (minimal, verified locally)
Read usage from any frame that carries it, not only the two legacy shapes. A conservative placement that cannot disturb the other channels: only fire when the frame has usage, no finish_reason, and an empty delta object (i.e. the frame carries no content of its own):
if(parsed.usage&&typeof choice.finish_reason!=="string"&&choice.delta&&typeof choice.delta==="object"&&Object.keys(choice.delta).length===0){
const biliUsageFix=parsed.usage;
yield{kind:"usage",
inputTokens:typeof biliUsageFix.prompt_tokens==="number"?biliUsageFix.prompt_tokens:void 0,
outputTokens:typeof biliUsageFix.completion_tokens==="number"?biliUsageFix.completion_tokens:void 0,
cachedTokens:openaiCachedTokens(biliUsageFix)}
}inserted immediately before const delta=choice.delta;const finishReason=... in dist/index.js (line 6102 in the 0.1.205 bundle). Frames that carry content, and frames that carry finish_reason (the workbuddy shape), never enter the branch, so existing behavior is untouched.
Verification after patch + restart, same session, same raccoon model, no other change:
06:21:34 [acp-usage] round 1 input=91392 cached=90624 (cache hit 99%)
06:22:28 nudge idle: usage=12% (92313/750000), growth=3325/22500 (ref=88988, interval=50000), pendingT1=40875/50000
06:22:43 [acp-usage] round 1 input=94748 cached=92288 (cache hit 97%)
06:23:17 [acp-usage] round 1 input=96483 cached=94720 (cache hit 98%)
Usage now lands on every request, growth accumulates normally, and the nudge gate returns to its designed behavior (idle because max compressible 45239 < threshold 50000, which is the intended reason — not the growth 0 < floor 22500 deadlock).
Why this should be fixed in addition to #4413 / #4443
The read-side fallback in #4413/#4443 degrades a stale anchor to the calibrated local estimate. That is the right safety net, and it would have broken this freeze. But with the parser defect left in place:
- every request from this relay still loses its real usage, so
nudge, pressure band, preflight, panel figures and cache accounting all ride an estimate permanently, instead of the number the upstream actually billed; - the estimate is explicitly less trustworthy: in [Bug] statusInputBaseline falls back to lastUsageGradeTokens instead of the calibrated local estimate — so one 4.7k side-request sample blocks folding for 4 hours | #1595 承诺的"改用本地估算"兜底在 statusInputBaseline 里没有实现,一次 4.7k 侧请求样本即可挡住折叠 4 小时 #4383's own measurements the calibrated estimate ran ~0.7355 of true usage, and
localInputEstimateexceeded usage by 4.2×; - it makes the failure look like "this relay never reports usage", which sends the fix to the wrong layer and leaves every other client of the same relay permanently mis-metered.
Two independent defects, one symptom. The parser one is cheaper to fix and has no blast radius.
Reproduction
- Point bili at a relay whose streaming tail frame is
{"choices":[{"delta":{},"index":0}],...,"usage":{...}}(nofinish_reasonon that frame). OpenAI's ownstream_options.include_usageproduces exactly this shape, so it is not exotic — it is the reference implementation. - Send a small request first (title generation,
/modelsprobe) so a small usage sample becomes the anchor. - Chat past ~200k tokens.
- Observe
[acp-usage]missing for most/all turns,nudgeprintingusage=<small>/<window>, growth=0, while the same log'soutput budget clamped ... (input~<large>)shows the real size andpendingT1grows without bound. - Switch to a channel that puts usage on the
finish_reasonframe; the freeze clears on the first response.
No special configuration needed, compress left at defaults.
Suggested fix location
parseStream in the OpenAI-compatible SSE lane. Either harvest parsed.usage from every frame after the two existing branches (guarding against duplicate applyUsageSample calls), or add the empty-delta trailing-frame branch as above. A one-line #593-style regression test with a choices:[{delta:{}}] usage frame would cover it.
中文摘要
上游 relay 每次请求都回传了 usage,但 bili 的 OpenAI SSE 解析器只在两种帧里读取它:choices 为空数组的帧,以及带 finish_reason 的帧。而该渠道把 usage 放在一个独立尾帧里——choices 非空([{"delta":{},"index":0}])、delta 为空对象、没有 finish_reason。这种帧两个分支都进不去,usage 被静默丢弃,#593 当年只修了 choices: [] 那一种。
后果:acp-usage 长时间不出现,settleUsageReport 不再更新,tokenCount 停在旧的 4733,growth 恒为 0,nudge 永不触发,上下文一路涨到 30 万、110 万后才被 502 撞停。换到 usage 贴在 finish_reason 帧上的渠道立刻恢复(input=252154 → 当轮 INJECT → 折叠 84%)。
这与 #4383 是两个独立缺陷:#4383 是"读侧把陈旧样本当成当前值",本条是"真实样本根本没被读出来"。#4413/#4443 的读侧兜底能救症状,但治不了这个——修好之后每次请求的真实 usage 依然丢失,所有计量永久骑在精度差得多的本地估算上。
已在 0.1.205 的 dist/index.js 第 6102 行打补丁验证:补丁后同一会话、同一 raccoon 模型,每个请求都有 [acp-usage],growth 正常累积。
Activity
ranxianglei commentedon Oct 24, 2026
(ai: vllm-qwen/qwen3.8-27b)
查重:不重复。 按失败签名(OpenAI SSE
usage只从两种帧形收割 / 非空choices+空delta+无finish_reason的尾帧被丢弃)检索 open+closed issue 与 PR,无同症状+同机制命中。相关但不同的项:choices: [](空数组)尾帧被吞——不同帧形,不是本问题。它的回归测试在tests/issue589-usage-frame.test.ts。本问题是choices:[{delta:{}}](非空、单 choice、空 delta)这一种,恰好落在 fix #589: forward trailing usage-only frame on raw tool-call rounds #593 的!choice分支和finishReason分支两者的缝隙里。statusInputBaseline/effectiveTokenCount把陈旧小锚点当当前负载真值),与本问题同一症状、独立机制。正如你正文说的「两个独立缺陷,一个症状」:[Bug] statusInputBaseline falls back to lastUsageGradeTokens instead of the calibrated local estimate — so one 4.7k side-request sample blocks folding for 4 hours | #1595 承诺的"改用本地估算"兜底在 statusInputBaseline 里没有实现,一次 4.7k 侧请求样本即可挡住折叠 4 小时 #4383 让陈旧锚点降级到本地估算兜底;本 bili drops an OpenAI-compatible stream's usage when it arrives on a non-emptychoicesframe with an empty delta #4488 让真实 usage 每轮真正落地。两者都要——parser 修复保证样本进来,读侧修复是无样本时的安全网。不重复。标签:bug / zcode(复现客户端)/ scope/wiring(请求管道 wire 解析层)/ p1(核心承诺「自动压缩」整体离线 + 上下文无界增长直到 502,属 owner 2026-10-19 修订的「缓存泄露类/只增不减失控增长」p1)。
Triage 验证(bug 已核实到代码行级,证据链):
复现查证成立(origin/master @ 0.1.206,你 204/205/206 三版此区域字节一致)。
src/loop/adapter-openai.tsparseStream:!choice分支(L507-525,fix #589: forward trailing usage-only frame on raw tool-call rounds #593)只接choices为空的帧;if (finishReason)分支(L539-572)才从parsed.usage收割 usage(L543-549)。{"choices":[{"delta":{},"index":0}],"usage":{...}}无 finish_reason:choice 存在 → 跳过!choice;无 finish_reason → 跳过 L539;于是落到 L647else if (... Object.keys(delta).length===0 && !finishReason)被当作 meta 原样转发给客户端,但全程没有 yield 任何kind:"usage"事件 → 内部账本(nudge/preflight/面板/cache 记账)永远看不到真实用量。这正是[acp-usage]缺失、tokenCount冻结在 4733、growth=0的直接原因。层次判断:根因在 parser 的帧分类只认两种 usage 载体,不是 nudge 门逻辑、不是配置、不是上游。你的分析打到核心了——补一个「非空 choices 的空 delta 尾帧」收割分支即命中产生层(疏),不是在 nudge 阈值上绕(堵)。
方案评估:采你验证过的保守分支(选项 B),理由:core.ts L813-819 对
kind:"usage"是 last-wins 整对象替换(usage = {...}),且内部账本与客户端可见 completion 读的是同一个 last-wins 值——所以尾帧收割后两者同时修正、无重复计数风险;但反过来,若改成「每帧都收割」(选项 A),一旦某帧带部分字段的 usage(只有 completion_tokens)就会用 last-wins 把前面完整的那条 usage 冲掉。选项 B 只触发在「纯 usage 尾帧」这一个形状上,爆炸半径最小,也是你本地实测通过的版本。计划(无决策依赖,直接开修):在
parseStream的!choice分支之后、finishReason分支之前加一个守卫分支——parsed.usage && !finishReason && delta && Object.keys(delta).length===0时 yieldkind:"usage"(含openaiCachedTokens),并对sawRealToolCall轮按 #589 同款语义转发原始帧,然后continue。配套回归测试钉死「该帧形现在会落地[acp-usage]」+负控(内容帧/finish 帧不受影响)。定向测试过 → push → 开 PR(Closes #4488)→ 八问回复跟进。kernel/ 不动,host 侧单文件改动。ranxianglei commentedon Oct 24, 2026
(ai: vllm-qwen/qwen3.8-27b)
修好了,PR 已开:https://github.com/ranxianglei/billion-context/pull/4494(Closes #4488)。等 CI 全量裁决 + 你审合。
结构化八问
是什么问题:某些 OpenAI 兼容中转把 usage 放在一个「choices 非空(单 choice、delta 为空对象)、又不带 finish_reason」的尾帧上——OpenAI 自己
include_usage的最终帧就是这个形状。bili 的 parser 只认两种 usage 载体(choices:[]空数组、带 finish_reason 的帧),这个形状两种都不沾,usage 被静默丢掉,内部账本永远拿不到真实样本,nudge 一直骑在上一条陈旧锚点上(growth=0),上下文一路涨到上游 502。什么原因导致的:parser 的帧分类把「usage 可能出现在哪些帧」写死成了两种,第三种(非空 choices 的空 delta 尾帧)落在两者缝隙里被漏掉。关键:丢的是内部账本——同一帧其实还照旧透传给了客户端(走空 delta 的 meta 转发),客户端一直看得到真实 usage;坏的是 bili 自己的
settleUsageReport→nudge 这条链。这是产生层缺陷(漏了一个合法载体),不是 nudge 门或配置的问题。影响范围与严重程度:触发条件 = 不回传 finish 帧 usage、改用非空 choices 空 delta 尾帧的中转(你那个 raccoon 通道就是)+ include_usage 客户端。严重度 p1 类——自动压缩这一核心承诺整体离线直到外部事件(换模型 / 手动 compress / 上游 502),重启不愈(锚点持久化在 sessions.stats_json)。正常 OpenAI(usage 在 finish 帧或空数组尾帧)不受影响。
怎么修复的:在 parseStream 加一个守卫分支——帧带 usage、无 finish_reason、delta 是空对象时,yield 一条
kind:"usage"事件(含 cached_tokens 归一)。刻意不 continue:让这帧继续落到既有的空 delta meta 转发上,客户端可见流逐字节不变,只有内部账本多拿到样本。core.ts 对 usage 是 last-wins 单值,同喂settleUsageReport(账本)和emitCompletion(客户端 completion),所以一处收割两处都修正、不会重复计数。是否有回归风险:定向审计 OpenAI adapter / usage / terminal / SSE 一整簇(含 OpenAI adapter drops all content when upstream sends an empty tool_calls array #4466、bili dsh 原版那个 圆圈, 显示当前上下文大小的. 经常显示只有几百token #589)167/167 过;typecheck 干净;全量交远程 CI。红绿验证:撤掉修复时三条收割断言转红、负控(无 usage 帧不造假事件 / finish 帧路径不变 / 客户端流不变)双向都绿。高危模式逐项过:无阈值漂移、无输出格式变化、无 fail-fast 变吞错、客户端可见流逐字节未变(有测试专门钉死 no-continue)。
是否破坏缓存:不涉及。缓存记账 / 重试 / 游标一行没动,只补了一条本该有的 usage 采样进既有账本链。
是否改了设计方向:没有。host 侧 src/loop/adapter-openai.ts 单文件,kernel/ 未碰,无架构转向。
是否引入新配置:没有。无新 env/config/CLI 面。
行为变更披露:客户端可见流零变化(no-continue 落回既有转发,逐字节一致);唯一可观测变化是内部账本现在能拿到它之前漏掉的真实 usage 样本——这正是被修的缺陷本身。无阈值 / 默认 / 时序 / 输出格式漂移。
与 #4383 / #4413 / #4443 的关系:独立缺陷、同一症状。那三个是读侧兜底(陈旧小锚点降级到本地估算),本 PR 是 parser 侧(让真实样本每轮落地),互补、都要。也不是 #593 的重复——#593 修的是
choices:[]空数组那种不同形状。判定:可提交审合。本地定向绿 + 红绿证据齐,全量回归以推送头部的远程 CI 为准。合并操作留给你(系统规则禁止我 merge)。
fix: harvest usage from an OpenAI trailing frame whose choices stay n…