Skip to content

bili drops an OpenAI-compatible stream's usage when it arrives on a non-empty choices frame with an empty delta #4488

Closed

Description

@czm1231

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-work gateway) on 127.0.0.1:7863, which fronts several channels
  • Model that froze: raccoon/sn-deepseek-v4-1-flash-1017, declared context_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:

  1. When the frame has no choices at all — this is the choices: [] 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
}
  1. 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, and tokenCount froze at 182983 while nudge kept 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:

Two independent defects, one symptom. The parser one is cheaper to fix and has no blast radius.


Reproduction

  1. Point bili at a relay whose streaming tail frame is {"choices":[{"delta":{},"index":0}],...,"usage":{...}} (no finish_reason on that frame). OpenAI's own stream_options.include_usage produces exactly this shape, so it is not exotic — it is the reference implementation.
  2. Send a small request first (title generation, /models probe) so a small usage sample becomes the anchor.
  3. Chat past ~200k tokens.
  4. Observe [acp-usage] missing for most/all turns, nudge printing usage=<small>/<window>, growth=0, while the same log's output budget clamped ... (input~<large>) shows the real size and pendingT1 grows without bound.
  5. Switch to a channel that puts usage on the finish_reason frame; 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

  1. ranxianglei commented on Oct 24, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    (ai: vllm-qwen/qwen3.8-27b)

    查重:不重复。 按失败签名(OpenAI SSE usage 只从两种帧形收割 / 非空 choices+空 delta+无 finish_reason 的尾帧被丢弃)检索 open+closed issue 与 PR,无同症状+同机制命中。相关但不同的项:

    标签:bug / zcode(复现客户端)/ scope/wiring(请求管道 wire 解析层)/ p1(核心承诺「自动压缩」整体离线 + 上下文无界增长直到 502,属 owner 2026-10-19 修订的「缓存泄露类/只增不减失控增长」p1)。

    Triage 验证(bug 已核实到代码行级,证据链):

    1. 复现查证成立(origin/master @ 0.1.206,你 204/205/206 三版此区域字节一致)。src/loop/adapter-openai.ts parseStream:

      • !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;于是落到 L647 else if (... Object.keys(delta).length===0 && !finishReason) 被当作 meta 原样转发给客户端,但全程没有 yield 任何 kind:"usage" 事件 → 内部账本(nudge/preflight/面板/cache 记账)永远看不到真实用量。这正是 [acp-usage] 缺失、tokenCount 冻结在 4733、growth=0 的直接原因。
    2. 层次判断:根因在 parser 的帧分类只认两种 usage 载体,不是 nudge 门逻辑、不是配置、不是上游。你的分析打到核心了——补一个「非空 choices 的空 delta 尾帧」收割分支即命中产生层(疏),不是在 nudge 阈值上绕(堵)。

    3. 方案评估:采你验证过的保守分支(选项 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 时 yield kind:"usage"(含 openaiCachedTokens),并对 sawRealToolCall 轮按 #589 同款语义转发原始帧,然后 continue。配套回归测试钉死「该帧形现在会落地 [acp-usage]」+负控(内容帧/finish 帧不受影响)。定向测试过 → push → 开 PR(Closes #4488)→ 八问回复跟进。kernel/ 不动,host 侧单文件改动。

  2. added
    bugSomething isn't working
    zcode改动范围:内核之外的主机侧接线(src/agent 适配器、server 路由、注入拦截)/范围兜底/客户端标签
    on Oct 24, 2026
  3. ranxianglei commented on Oct 24, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    (ai: vllm-qwen/qwen3.8-27b)

    修好了,PR 已开:https://github.com/ranxianglei/billion-context/pull/4494(Closes #4488)。等 CI 全量裁决 + 你审合。

    结构化八问

    1. 是什么问题:某些 OpenAI 兼容中转把 usage 放在一个「choices 非空(单 choice、delta 为空对象)、又不带 finish_reason」的尾帧上——OpenAI 自己 include_usage 的最终帧就是这个形状。bili 的 parser 只认两种 usage 载体(choices:[] 空数组、带 finish_reason 的帧),这个形状两种都不沾,usage 被静默丢掉,内部账本永远拿不到真实样本,nudge 一直骑在上一条陈旧锚点上(growth=0),上下文一路涨到上游 502。

    2. 什么原因导致的:parser 的帧分类把「usage 可能出现在哪些帧」写死成了两种,第三种(非空 choices 的空 delta 尾帧)落在两者缝隙里被漏掉。关键:丢的是内部账本——同一帧其实还照旧透传给了客户端(走空 delta 的 meta 转发),客户端一直看得到真实 usage;坏的是 bili 自己的 settleUsageReport→nudge 这条链。这是产生层缺陷(漏了一个合法载体),不是 nudge 门或配置的问题。

    3. 影响范围与严重程度:触发条件 = 不回传 finish 帧 usage、改用非空 choices 空 delta 尾帧的中转(你那个 raccoon 通道就是)+ include_usage 客户端。严重度 p1 类——自动压缩这一核心承诺整体离线直到外部事件(换模型 / 手动 compress / 上游 502),重启不愈(锚点持久化在 sessions.stats_json)。正常 OpenAI(usage 在 finish 帧或空数组尾帧)不受影响。

    4. 怎么修复的:在 parseStream 加一个守卫分支——帧带 usage、无 finish_reason、delta 是空对象时,yield 一条 kind:"usage" 事件(含 cached_tokens 归一)。刻意不 continue:让这帧继续落到既有的空 delta meta 转发上,客户端可见流逐字节不变,只有内部账本多拿到样本。core.ts 对 usage 是 last-wins 单值,同喂 settleUsageReport(账本)和 emitCompletion(客户端 completion),所以一处收割两处都修正、不会重复计数。

    5. 是否有回归风险:定向审计 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)。

    6. 是否破坏缓存:不涉及。缓存记账 / 重试 / 游标一行没动,只补了一条本该有的 usage 采样进既有账本链。

    7. 是否改了设计方向:没有。host 侧 src/loop/adapter-openai.ts 单文件,kernel/ 未碰,无架构转向。

    8. 是否引入新配置:没有。无新 env/config/CLI 面。

    行为变更披露:客户端可见流零变化(no-continue 落回既有转发,逐字节一致);唯一可观测变化是内部账本现在能拿到它之前漏掉的真实 usage 样本——这正是被修的缺陷本身。无阈值 / 默认 / 时序 / 输出格式漂移。

    与 #4383 / #4413 / #4443 的关系:独立缺陷、同一症状。那三个是读侧兜底(陈旧小锚点降级到本地估算),本 PR 是 parser 侧(让真实样本每轮落地),互补、都要。也不是 #593 的重复——#593 修的是 choices:[] 空数组那种不同形状。

    判定:可提交审合。本地定向绿 + 红绿证据齐,全量回归以推送头部的远程 CI 为准。合并操作留给你(系统规则禁止我 merge)。

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingp1scope/wiringzcode改动范围:内核之外的主机侧接线(src/agent 适配器、server 路由、注入拦截)/范围兜底/客户端标签

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions