Skip to content

fix: guard side requests against over-window payloads (#554) - #557

Merged
ranxianglei merged 2 commits into
masterfrom
2026-09-05_side-request-payload-guard
Sep 6, 2026
Merged

ranxianglei merged 2 commits into
masterfrom
2026-09-05_side-request-payload-guard

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

What

Side requests (max_tokens<=200, title-gen/probes) bypass preflight by design (#388) and were forwarded verbatim regardless of payload size. A full-history side request over the upstream window is a guaranteed upstream 400 — and because title-gen/probe clients re-issue such requests, it becomes a persistent 400 storm that deadlocks large-context sessions (#554).

The learned self-heal could never fix this from the side path either — three independent failures:

  1. exceed_context_size_error (llama.cpp family) was not in CONTEXT_OVERFLOW_PATTERNS, so inspectContextOverflow never even classified it as an overflow;
  2. even if recognized, the fallback estimate ran on prepared.processedMessages, which is empty on the side path → estimate ≈ 0 < 1000 → learning skipped;
  3. even if learned, the side gate fires before the self-heal read and never consults reqConfig.modelContextLimit.

Changes

  • src/server.ts — sideRequestGuard + gate in handle(): before forwarding a side request, estimate the RAW body (CJK-aware defaultCountTokens over every string leaf via new estimateRawBodyTokens, plus image tokens from imageTokensInParsedBody) against the effective window = min(resolved, learned) minus the output reservation on non-Anthropic wires. Over-window → local fail-fast 413 {error:{code:"side_request_payload_too_large", retryable:false}}, NOT forwarded (mirrors the fix: honor anthropic-beta context-1m to size the context window #301 preflight fail-fast precedent). Unknown window (limit ≤ 0) → forward as before. No history truncation: the client owns its view; truncation has no verifiable semantics.
  • src/util.ts: added /exceed[_\s]?context[_\s]?size/i to CONTEXT_OVERFLOW_PATTERNS; parseOverflowWindow now recognizes (A / B > W) and takes W (the limit after > inside the parens), e.g. 150,528 from exceed_context_size_error (198,277 / 198,661 > 150,528).
  • src/preflight.ts: new exported estimateRawBodyTokens(parsed) — protocol-agnostic walk of string leaves, excluding binary-carrying keys (data/url/b64_json) which image-tokens charges separately; slight overcount of structural strings is deliberate (conservative for a fail-closed guard).
  • src/server.ts — forward() overflow branch: hoisted parsedBody; when processedMessages is empty (side path) the conservative-window fallback now estimates from the raw body — exactly what was forwarded verbatim.

Net effect: over-window side requests are blocked locally with a structured error; if an under-estimate ever lets one through and the upstream reports its real window in the overflow body, the proxy learns it and blocks the next identical request locally (covered by test).

Not done (deliberate): no config switch to disable side passthrough — once guarded, passthrough is safe; the switch adds config surface for no gain.

Tests

tests/side-request-isolation.test.ts (+5 tests):

  • unit: estimateRawBodyTokens (string leaves / binary-key exclusion / CJK per-char / non-object bodies); inspectContextOverflow (new marker + (A/B>W) window parse + status gate + existing-marker regression); sideRequestGuard (unknown window, boundary, learned-shrink-only, headroom reservation on OpenAI wire, image cost at boundary);
  • e2e: oversized side request → local 413, structured code, upstream never hit, kernel state untouched; fitting side request still passes through verbatim; overflow-400-on-side-request learns the real window (150,528) + arms emergency shrink, second identical request blocked locally with no second upstream hit.

Rig gained _resetSessionsForTest() per start (clean session map), upstream hit-counting, a sideErrorStatus/sideErrorBody hook, and a compressModelContextLimit option (operator tuning is the only way to pin the effective window below the registry/static-table resolution — see comment in the test file).

Pre-flight

  • npm run typecheck ✅
  • npm test: 1051/1052 pass; the single failure (launcher.test.ts: resolveClientCommand codex/claude resolve to themselves) is pre-existing and environmental — verified failing identically on clean master (e710eb0): this sandbox has /usr/bin/codex on PATH so the resolver returns the absolute path while the test expects the bare name.
  • npm run build ✅

E2E codex suite (ACP_TEST_E2E) not run here — requires a reachable Responses-compatible upstream; the change touches the side-gate path and the forward() error branch, both covered by the unit/e2e tests above.

Side requests (max_tokens<=200) bypass preflight by design (#388) and were forwarded verbatim regardless of payload size — a full-history side request over the upstream window is a guaranteed upstream 400 that the learned self-heal can never fix from that path (the gate fires before the self-heal read, the overflow marker was unrecognized, and the fallback estimate ran on an empty kernel view).

- sideRequestGuard: estimate the raw body (CJK-aware text + image tokens) against min(resolved, learned) window minus output headroom on non-Anthropic wires; block locally with 413 side_request_payload_too_large instead of forwarding

- overflow learning on side requests: recognize the llama.cpp-family exceed_context_size_error marker and parse its '(A / B > W)' window form; when the kernel view is empty (side path), fall back to the raw-body estimate so the real window is learned and the next identical request is blocked locally
@github-actions

github-actions Bot commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-09-05_side-request-payload-guard (0bdb325)

Option A — Install from npm PR tag (recommended)

npm install -g billion-context@pr-557

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

Option B — Download artifact

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

This comment is automatically updated on each push.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · qwen3.8-27b

[bot] 🏷 Review started on PR #554 (2d78bf2, branch 2026-09-05_side-request-payload-guard). Per triage rules I'm verifying the claimed defect on master first (side path bypasses preflight + the three self-heal failures), then auditing the diff against both compression modes, then running typecheck/test/build. Full verdict to follow shortly.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · qwen3.8-27b

[bot] 🏷 Review 完成:LGTM,可合并。 缺陷三层全部在 master 上独立复核成立,实现与方案一致,pre-flight 通过。

1. 缺陷核实(master @ e710eb0,逐条对码)

层 master 证据 结论
① 溢出未识别 CONTEXT_OVERFLOW_PATTERNS(src/util.ts:107-123)无 exceed_context_size_error;且 parseOverflowWindow 不认 (A / B > W) 形式 ✅ 该 400 根本不进学习路径
② 兜底估算落空 forward() 溢出分支(src/server.ts:2847)payloadEstimate = estimateCoreMessages(prepared.processedMessages);side 路径 processedMessages 恒为 [] → 估算 0 < 1000 → 学习跳过 ✅(注:body 带可解析窗口时走 info.window 分支不受影响——但 ① 已使其不可达;本 PR 两条都修了)
③ 学到也不用 side gate(src/server.ts:1346)在 self-heal 块(L1362+,读取 learned limits 重定 reqConfig.modelContextLimit)之前 early-return,且从不引用任何 limit ✅ 死锁闭环确认

层次判断:根因是 side gate 把「小输出预算(max_tokens≤200)」与「可跳过所有输入侧护栏」混为一谈(#388 的隔离设计本身正确,但透传面缺少体积维度);①②③ 是阻止自纠的次级缺陷。PR 同时打掉根因(guard)+ 三个次级缺陷,层次到位。

2. 实现评估

  • sideRequestGuard(src/server.ts:681):min(resolved, learned)(learned 只缩不放)→ 非 Anthropic wire 预留输出 headroom(shouldReserveOutputHeadroom/reserveOutputHeadroom,Anthropic 独立 output budget 豁免,与主路径一致)→ estimateRawBodyTokens + imageTokensInParsedBody,estimate >= limit 拦截、limit≤0(窗口未知)行为不变。fail-closed 偏保守(结构化字符串略多计)方向正确;不截断历史的取舍正确——截断无可验证语义,客户端拥有自己的 view。
  • estimateRawBodyTokens(src/preflight.ts):排除键 data/url/b64_json 与 image-tokens.ts:42 的计费范围核对过,无双重计数(anthropic base64/url、openai image_url、responses input_image 三种形态均被 image 模块单独计价)。
  • util.ts:新模式 /exceed[_\s]?context[_\s]?size/i 特异性足够(不会误伤 Bedrock throttle 短语);(A / B > W) 取 > 后的 W,issue 示例正确提取 150,528。
  • forward():processedMessages 为空时改用 raw body 估算,学习闸门(>=1000、只缩不放)不变——即使 guard 因低估漏过一次,上游报窗后下一次即本地拦截,自愈闭环成立。
  • 两种压缩模式(AGENTS.md §6):guard 位于模式分支之前,纯 wire 层、mode-agnostic;plugin/proxy 客户端的 side 请求共用同一路径,估算对象就是将被转发的那个 body,两模式下均正确。restoreOutputBudget([bug] bili dsh 长会话中 max_output_tokens 被压到 ≤200,主请求被误判为 side request 并绕过压缩 #546)在 gate 前运行但对 side 请求是 no-op(要求带 tools),无交互问题。
  • 错误响应:413 + side_request_payload_too_large、retryable:false。一点说明:fix: honor anthropic-beta context-1m to size the context window #301 preflight fail-fast 实际用的是 502(服务端压缩失败语义);此处用 413 更贴切——这是客户端载荷条件、代理未做任何处理,属有意改进而非不一致,不构成阻塞。

3. 次要观察(非阻塞,记录在案)

  • Anthropic PDF document 块({type:"document", source:{type:"base64", data}})两个估算器都不计(raw 排除 data 键,image 模块只计 image part)→ 超大 PDF 的 side 请求可能漏过一次;但 forward() 学习通道会在下一次拦截,且标题生成类 side 请求携带大 PDF 属罕见场景。若日后有真实案例再补 document 计费即可。

4. Pre-flight(本环境实测)

  • npm run typecheck ✅
  • npm test:1051/1052,唯一失败 tests/launcher.test.ts「resolveClientCommand: codex/claude resolve to themselves」——已在干净 master(e710eb0)worktree 复现同样失败(98 pass/1 fail),系本沙箱 PATH 存在 /usr/bin/codex 的环境性问题,与本变更无关 ✅
  • npm run build ✅(dist/index.js 2.63 MB,acp-kernel 内联正常)
  • E2E codex 套件:本沙箱无可达上游,无法运行(PR body 已注明);新增的 e2e 用例(本地 413/upstream 零命中/kernel state 不变/learn→block 循环)已在全量测试中通过。

结论:审核通过,建议合并。合并操作按规则由人工执行:#554

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · qwen3.8-27b

[bot] 🏷 更正上一条回复末尾的链接笔误:PR 编号是 #557(标题中的 #554 指原始 bug report)。合并入口:#557 2d78bf2,已核实与本次 review 的分支一致)

defaultCountTokens counts CJK per-char (~1.6x real tokenizer cost), and
the guard is the only consumer where that bias hard-denies (413,
retryable:false) a payload that really fits. Block at estimate >= 1.15x
the effective window instead: borderline payloads forward, and a real
upstream overflow 400 still teaches the learned limit. Also skip
file_data (Responses input_file data-URL) like other binary carriers.
@ranxianglei
ranxianglei merged commit e992d5e into master Sep 6, 2026
6 checks passed
@ranxianglei ranxianglei mentioned this pull request Sep 6, 2026
@ranxianglei
ranxianglei deleted the 2026-09-05_side-request-payload-guard branch October 15, 2026 05:32
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.

1 participant