Skip to content

fix(#728): proactive nudge for upstreams that never report usage - #731

Merged
ranxianglei merged 1 commit into
masterfrom
2026-09-12_silent-usage-nudge-fallback
Sep 18, 2026
Merged

ranxianglei merged 1 commit into
masterfrom
2026-09-12_silent-usage-nudge-fallback

Conversation

@ranxianglei

@ranxianglei ranxianglei commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the refined option 1 agreed in #728 triage (host-side fallback, no kernel change; option 2 rejected, option 3 deferred per triage; codex source cross-check confirmed the server-observed-first + local-estimator-fallback design shape).

When an upstream NEVER reports usage (ChatGPT-login backends omit usage.input_tokens entirely), lastInputTokens stays 0 all session and decideNudge is structurally unfireable at tokenCount == 0 — contexts grew unbounded until the hard-limit emergency preflight took over (#726: ~1.32M tokens → ~630K-token emergency fold → 502 loop).

Changes

  • src/session.ts: new stat localInputEstimate (persisted with stats); cleared by resetSessionCompression on native-compaction boundary reset.
  • src/server.ts (prepareAnthropic / prepareOpenai / prepareResponses): each records the char-count upper bound of that turn's outbound payload — post-fold processed messages + system/tools overhead + image tokens — before returning (title-gen and compaction-trigger requests skip; failed transform falls back to the unprocessed projection so the fallback isn't blinded).
  • src/server.ts (effectiveTokenCount): precedence unchanged for real usage (always wins) and for anonymous-prefix-affinity forks (raw inbound upper bound, fork 会话 lastInputTokens=0 时 preflight 退化为字符估算,大载荷被低估后不压缩直接裸转上游 #553 branch untouched); explicit-identity zero-baseline now returns localInputEstimate capped by this request's inbound upper bound (a stale-high reading can't outlive a client-side shrink), falling back to 0 as before when nothing was measured — turn 1 behavior byte-identical.

Same three invariants as #604's armFailureShrink exception: upper-bound estimator only errs early (compress earlier, never later); active only while lastInputTokens == 0; any real usage report takes precedence immediately. Self-corrects after every fold (post-fold outbound payload shrinks → next reading drops), so proxy clients re-sending full raw history don't inflate it.

Out of scope per triage: one-shot handoff paths (prepareCountTokens / prepareResponsesCompact) still pass tokenCount directly — those requests ARE the compaction mechanism.

Tests

New tests/silent-backend-nudge.test.ts (e2e, Anthropic wire, explicit identity):

  • A: silent backend (usage field absent, not zero) multi-turn — turn 1 idle (pre-fix first-turn behavior), turn 2 nudges once the recorded estimate crosses thresholds, preflight silent, nothing folded;
  • B: same conversation with reported input_tokens: 2000 every turn → NO nudge anywhere (real usage beats the ~90% payload estimate);
  • C: stale-high cap — after A's nudged turn, a shrunk-history turn does NOT re-trigger.

Full suite green: 1428 tests, 1426 pass, 2 skipped, 0 fail (includes existing anonymous/explicit regime pins unchanged).

Closes #728.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 Review complete — verified end-to-end on branch head c899546. Verdict: can merge (merge itself stays human-only per AGENTS.md). Details below.

Duplicate screen

No duplicate. Searched open+closed issues/PRs for the silent-backend/nudge-fallback topic — this is the only implementation of the refined option 1 agreed in #728 triage. Relation to neighbors: #726 is the incident, #727 fixed the downstream symptom (empty-summary diagnosis/retry/cooldown) — complementary layers, no overlap with this growth-source fix.

Pre-flight (all run on the PR branch)

Check Result
npm run typecheck clean
npm test 1428 tests, 1426 pass, 0 fail, 2 skipped — exactly matches the PR's claimed numbers
npm run build success, dist/index.js 2.76 MB
new tests/silent-backend-nudge.test.ts 3/3 pass in isolation
E2E_CHECK=1 codex e2e preflight pass (codex binary + dist present). Full 4-phase e2e NOT run here — the local sglang upstream isn't reachable from this environment; CI manual-dispatch covers that gate

Code review — every claim checked against source

  1. Precedence chain intact (src/server.ts:1947-1960): lastInputTokens > 0 → real usage (untouched); anonymousPrefixAffinity → raw inbound upper bound (the fork 会话 lastInputTokens=0 时 preflight 退化为字符估算,大载荷被低估后不压缩直接裸转上游 #553 branch is byte-for-byte the same early-return, just reordered); explicit-identity zero-baseline → localInputEstimate, returning 0 when nothing was measured. Turn 1 of a fresh explicit session still feeds 0 → first-turn behavior byte-identical, as claimed.
  2. Recording points: all three prepare* record estimateCoreMessagesUpper(post-fold msgs) + countSystemAndToolsTokens(...) + imageTokensInParsedBody(...) right before return (server.ts:2094 / 2362 / 2628). Skips verified: title-gen (isTitleGen, OpenAI path) and compaction-trigger (Responses path) don't record; Anthropic path has no such side requests. Transform-failure fallback verified: the catch sets processedMessages = [] and the forwarded body is the unprocessed projection, so measuring originalMessages there keeps the fallback from being blinded — comment in code matches actual control flow.
  3. Reset: resetSessionCompression zeroes localInputEstimate at the native-compaction boundary (src/session.ts:316) — correct, since the pre-compaction wire payload is gone and a stale-high reading would blind the fallback early. Stat is persisted with the rest of stats; a post-restart stale value is harmless because it's capped by the current request's inbound bound.
  4. Out-of-scope claim confirmed: prepareCountTokens (server.ts:2662) and prepareResponsesCompact (server.ts:2703) never touch localInputEstimate — those one-shot handoff requests stay on direct lastInputTokens, as stated.
  5. arm emergency shrink on upstream failure to break relay-5xx deadlock #604 invariant alignment holds: the estimator is an upper bound (only errs early → compresses earlier, never later), is active only while lastInputTokens == 0, and any real report takes precedence immediately — the same three properties as the armFailureShrink exception. Self-correction after folds is structural (post-fold outbound shrinks → next reading drops), so proxy clients re-sending full raw history don't inflate it; the stale-high cap additionally bounds a client-side shrink.
  6. Both compression modes considered: the change feeds tokenCount into processTurn, which drives the nudge decision in BOTH modes (nudge injection is proxy-side in both per 0.1.69 回归: plugin 模式 nudge 注入被 !pluginMode 静默禁用 — INJECT 日志照打但从不投递, 自动压缩只剩 preflight 应急 #451); carriers are untouched. Plugin-mode agents (pi/omp) benefit identically since the proxy-side nudge is their proactive trigger.
  7. Tests: proper e2e through the real server with a mock upstream emitting the exact ChatGPT-login wire shape (usage field absent, not zero): A = silent multi-turn (turn 1 idle, turn 2 nudges, preflight silent, nothing folded), B = reported usage beats the ~90% payload estimate, C = stale-high cap. Anonymous-regression pin lives in tests/fork-nudge-trigger.test.ts (present, passing).

Minor observations (non-blocking, no changes requested)

  • Mixed units in the sum: message portion is char-count (upper bound), system/tools is chars/4, images use their own cost model. This is the identical combination preflight/estimateInputTokens already use, so it's consistent with house convention rather than a defect.
  • Cap corner case: Math.min(est, raw) clips the overhead terms whenever outbound tags/system/tools push the estimate above the message-only inbound bound — i.e. in that corner the fallback slightly underestimates total context and could err marginally late. The delay is bounded by tag chars + system/tools overhead, preflight remains the independent hard backstop (it uses its own estimate, not this value), and the clipping makes the explicit-fallback behave like the established fork 会话 lastInputTokens=0 时 preflight 退化为字符估算,大载荷被低估后不压缩直接裸转上游 #553 anonymous regime in the corner. Deliberate trade-off, documented in-code — fine as-is.

Diff hygiene: single commit, 4 files, every line on-topic; no version bump, no lockfile drift, CHANGELOG entry follows the existing format.

中文摘要: 修复了上游从不报告 usage(ChatGPT 登录态后端)时 nudge 结构性永不触发、上下文无界增长直到硬限紧急折叠的问题——host 侧以上一轮本地 post-fold 外发载荷上界估算在 lastInputTokens==0 时兜底 tokenCount,真实 usage 永远优先,与 #604 既有例外完全对齐;已验证 typecheck/全套 1428 测试/构建/e2e 预检全绿且与源码逐条核对无误,diff 干净,可以合并。

@ranxianglei

Copy link
Copy Markdown
Owner Author

冲突

@ranxianglei
ranxianglei force-pushed the 2026-09-12_silent-usage-nudge-fallback branch from c899546 to 7eb92e8 Compare September 15, 2026 01:24
@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-09-12_silent-usage-nudge-fallback (f879c46)

Option A — Install from npm PR tag (recommended)

npm install -g billion-context@pr-731

Each push to this PR publishes a new version under the pr-731 npm tag.

Option B — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf billion-context-pr731.tgz
npm install -g package

Install instructions are refreshed on each push.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 Conflict resolved — rebased onto latest master, re-verified green.

Cause. Master moved past the PR's base (01731056fff170). The conflict came from #787 (server-split phase 1), which relocated countSystemAndToolsTokens / estimateInputTokens / estimateWireOverhead / clampOutputBudget / emergencyNudge / clampOutgoingOutput out of src/server.ts into src/server/budget.ts — colliding with this PR's hunk that sits right below those helpers — plus the prompt-pack work changing the prepare* return field (promptsprompts, surface). Exactly one conflict region, in prepareAnthropic; the other three hunks (effectiveTokenCount, prepareOpenai, prepareResponses) auto-merged cleanly.

Fix. Rebased the single commit onto master: new head 7eb92e8 (was c899546). Resolution kept master's layout: the #728 recording block stays in place before the return, adopting the new surface return signature; the duplicated helper block was dropped (it now lives in src/server/budget.ts, already imported by server.ts). No logic changes beyond the collision itself.

Re-verified on the new base:

One minor note from the rebase: master's #767 gave imageTokensInParsedBody an optional third billing param (default "bytes"); the three recording sites keep their 2-arg form and thus use byte-based image counting while adjacent call sites pass imageBillingFor(...). Compiles fine and matches the tested/reviewed behavior — for a conservative upper-bound estimator the difference is negligible, but easy to align later if you want pixel-billing consistency there.

PR is now mergeable (state "blocked" = pending CI/approval only).


中文摘要:冲突来自 master 侧 #787 把 token 估算辅助函数挪进 src/server/budget.ts(外加 prompt-pack 改了返回签名),已把本 PR rebase 到最新 master(新 head 7eb92e8),只保留 #728 的计量逻辑、丢弃重复的辅助函数块,typecheck/全量测试/build/e2e 预检全部通过,可以合并。

@ranxianglei

Copy link
Copy Markdown
Owner Author

解决冲突 另外看看是否还需要解决这个问题?是否已经有类似修复

…e upstreams never report usage

Silent backends (ChatGPT-login style) omit usage.input_tokens, so lastInputTokens stays 0 and decideNudge can never fire: context grows until the preflight hard limit forces an emergency fold (#726 shape). Record each turn's outbound payload upper bound as session.stats.localInputEstimate (cleared at native-compaction boundaries) and let effectiveTokenCount fall back to min(estimate, this request's inbound upper bound) only while no real usage has ever been reported.

Rebased onto master 7bf7cde. The rebase exposed an interaction with #817's per-body preflight dead-end cooldown key (sha256 of the rebuilt wire body): once the fallback arms the nudge on these silent backends, the nudge text injected into the system prompt changes the rebuilt body between two identical client retries, the cooldown key misses, and every retry re-burns the doomed summarization walk — defeating #726's quota protection exactly where it was built for. Key the cooldown on the client's raw inbound body instead of the proxy-rebuilt wire body so proxy-side injections (nudge/system/tools/tags) cannot defeat it.
@ranxianglei
ranxianglei force-pushed the 2026-09-12_silent-usage-nudge-fallback branch from 7eb92e8 to f879c46 Compare September 18, 2026 01:07
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

已完成 rebase + 查重 + 一个 rebase 暴露的真实回归修复。分支已更新:f879c46(单一线性提交,基于当前 master 7bf7cde / v0.1.118)。

1. 冲突解决(2 处)

2. 是否还需要 / 是否已有类似修复

**仍然需要,master 上没有类似修复。**证据:

3. Rebase 暴露的新回归(已在同一提交内修复)

Rebase 后 tests/preflight-empty-summary.test.ts 有 2 个测试失败(clean master 上全部通过),定位到的因果链:

  1. Responses tool images falsely overflow preflight; transient summary failures lock the session #817[Bug] Codex 额度中断后恢复长会话,preflight SSE 摘要为空并持续 502 #726 的 preflight dead-end cooldown 键从 model\0limit 改为 model\0limit\0sha256(prepared.body)(代理重建后的 wire body)。
  2. 本 PR 的 fallback 让 nudge 首次在这些沉默后端上触发;nudge 文本注入 system prompt 且每轮都变(token 数/usage%)。
  3. 于是两次完全相同的客户端重试之间,rebuilt body 哈希不同 → cooldown 键 miss → 每次重试都重跑注定失败的 summarization walk(repro 实测每次重试多烧 7 个上游调用)——恰好在本修复目标人群(从不报 usage 的 ChatGPT-login 类后端)上废掉了 [Bug] Codex 额度中断后恢复长会话,preflight SSE 摘要为空并持续 502 #726 的配额保护,还让缓存诊断消息无法字节级重放。

这是 #817 的潜在缺陷(任何逐轮变化的注入都能破掉 body-hash 身份),此前被掩盖——沉默后端上 nudge 本来就不触发。修复:cooldown 身份改为哈希客户端原始入站请求体(解码后的 raw bytes),代理侧注入(nudge/system/tools/tags)不再能击穿它;键格式不变(model\0limit\0hex64),现有测试断言全部兼容。改动 ~9 行,与本 PR 行为强耦合(不修则本 PR 让 master 测试变红),故并入同一提交;如你希望拆成独立 PR 也可以说一声。

4. 验证


一句话中文摘要:已 rebase 到最新 master 并解决 CHANGELOG 与 session.ts 两处冲突;确认 master 无类似修复、问题仍然存在;同时修复了 rebase 暴露的与 #817 dead-end cooldown 键的交互回归(cooldown 改按客户端原始请求体哈希,nudge 注入不再使冷却失效、重试不再重复烧上游配额),typecheck/全量测试/build 全绿,等待人工合并。

@ranxianglei
ranxianglei merged commit 0cfde7a into master Sep 18, 2026
8 checks passed
This was referenced Sep 18, 2026
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.

[Bug] 上游从不报告 usage 时 nudge 永不触发——上下文无界增长直到超窗(ChatGPT 登录态后端)

1 participant