Skip to content

fix: compat.roles wire role rewrite for role-rejecting upstreams (#552) - #579

Merged
ranxianglei merged 3 commits into
masterfrom
2026-09-06_compat-role-rewrite
Sep 6, 2026
Merged

ranxianglei merged 3 commits into
masterfrom
2026-09-06_compat-role-rewrite

Conversation

@ranxianglei

@ranxianglei ranxianglei commented Sep 6, 2026 •

Copy link
Copy Markdown
Owner

Follow-up per the maintainer's UX feedback on this PR (thank you!) — the amendment is now implemented on top of 9603e42 as d2b15b3 + 9b6efc2.

Auto-fix: no config needed for the common case

  • Hint direction corrected: the log hint is no longer a global compat block — it prints a per-provider snippet with the real upstream origin (bili knows exactly which provider rejected), ready to copy-paste into the config file only if the user wants the fix to be permanent.
  • Learn is session-scoped, config stays user-owned: on a role-rejection 400 bili rewrites {role: "system"}, retries once, and on success records the mapping in session.metadata.learnedCompatRoles (same precedent as learnedContextLimits). Later requests in the same session apply the learned map up-front and skip the 400 round-trip entirely. The config file is never touched.
  • If the retry also fails, the original 400 passes through verbatim (exactly one retry, no loop).

New e2e coverage: E proves the learn (3 upstream hits: rejected → retried → pre-rewritten), F proves the verbatim 400 + single retry.

Bonus bug found & fixed while testing (9b6efc2)

The new F test exposed a fetch-util timer leak that pins the event loop for 10 minutes: a response body's pending pull can resolve after the consumer already called clearTimer (abandoned body — exactly F's failed retry, but also any client abort racing the last chunk). The late chunk re-armed a fresh UPSTREAM_TIMEOUT_MS timer that nothing would ever clear. rearm() is now a no-op once cleanup() has run, and F asserts _liveUpstreamTimersForTest() === 0 as a regression guard. This class was previously documented at the #411 call site — it could plausibly explain stray long-lived idle procs in the wild.

Pre-flight: typecheck ✓ · targeted 13/13 ✓ · full suite 1080 pass / 2 known plugin-agent env fails (baseline) ✓ · build ✓.

New opt-in compat.roles map (global + per-provider, provider wins per key)
rewrites message roles on the FINAL forwarded openai/responses body at the
forward boundary — covering client-sent roles and bili's own injected
developer-role prompt alike — and the compress-retry loops apply the same
transform to every re-sent body via RequestOptions.wireTransform. Default {}
forwards byte-for-byte unchanged; no kernel changes (kernel already
normalizes developer internally, so session/ACP state is unaffected).
@github-actions

github-actions Bot commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-09-06_compat-role-rewrite (9b6efc2)

Option A — Install from npm PR tag (recommended)

npm install -g billion-context@pr-579

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

Option B — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf billion-context-pr579.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 of PR #579 (fix: compat.roles wire role rewrite, head 9603e42, 1 commit ahead of origin/master 53ce3ba)

Verdict: implementation verified end-to-end, no functional blockers. Three non-blocking findings below — two are description/comment accuracy, one is scope disclosure.

Independently verified

Ran on the checked-out head (clean tree): npm run typecheck ✓, npm run build ✓ (self-contained dist), targeted tests/compat-roles.test.ts 10/10 ✓, full suite 1078/1079 — the single failure is tests/launcher.test.ts "resolveClientCommand: codex/claude resolve to themselves", which fails identically on origin/master (verified via worktree; environmental — no codex/claude binaries in this sandbox). plugin-agent.test.ts passes 35/35 here. Zero PR-related failures.

Design conformance, point by point:

  • Single choke point: all 5 forward() call sites route through the rewrite, including MITM side-prepared (src/server.ts:1360) and passthrough (:1556). Protocol chain prepared?.protocol ?? route?.explicitProtocol ?? inferWireProtocol(req.url) is sound (query stripped, /responses/compact covered, anthropic excluded by the openai/responses check).
  • Content-length consistency claim holds: incoming content-length is not copied into forwarded headers (src/server.ts:241 skips it), so undici computes it from wireBody — re-stringified bytes stay consistent.
  • Default transparency: empty map short-circuits before parsing; applyCompatRoles returns the original string when rewritten === 0 (no re-stringify) — unit-tested and confirmed by e2e C (live proxy, no compat config → developer reaches the wire untouched, i.e. responses path confirmed as billion-context: 400 Bad Request when codex sends role: "developer" messages — request a request-body compat transform hook #552's live surface).
  • Retry loops: wireTransform threaded through both runCompressLoop (src/loop/core.ts) and compressLoopResponsesJson (src/compress-loop-responses.ts) fetch sites — necessary, since the responses-path compress prompt itself carries developer.
  • Hot reload: opts.compat refreshed at both the web Apply path (:795) and __bili/config/reload (:3420).
  • Zero kernel changes: no acp-kernel bump; rewrite happens post-prepare on the serialized body only, so session state / block IDs / ACP tracking are untouched. Per-provider per-key precedence via findRoute longest-prefix matches the compress-settings convention.
  • Docs: README section + CONFIGURATION.md / .zh-CN.md (global + per-provider) present and accurate.

Findings (non-blocking)

1. PR body misstates the chat-path mechanism. It says the chat path "already normalizes developer→system during the rebuild (kernel coreToOpenai)". That is not what happens: acp-kernel 0.0.54's coreToOpenai restores developer — node_modules/acp-kernel/dist/wire/index.js:357: m.originalRole === "developer" ? "developer" : "system" (the same code quoted in issue floor 4). What actually keeps developer off the chat wire is bili's own systemToUser (src/util.ts:201-212), which re-voices both system and developer → user on the rebuilt body. The conclusion stands (chat carries no developer; the boundary still covers whatever does reach the wire), but please correct the mechanism in the PR description.

2. Test B's comment overclaims what it proves (tests/compat-roles.test.ts:190-193). By the time compat runs, the client's developer item has already become user (via systemToUser); what the system→user mapping actually rewrites is bili's injected head system prompt, re-added by injectOpenaiSystem after systemToUser (src/server.ts:~1872). The assertion is still valid — without compat, seen[0][0] would be "system" — so the openai boundary genuinely fires; the comment should just attribute the rewrite to the injected system message rather than "the rebuild normalizing developer".

3. Unmentioned bundled change: acp_status unification in both compress loops. This commit also deletes the stale local handlers — src/loop/core.ts's handleAcpStatus (which read ctx.nudge, a field no caller ever set: the single call site src/server.ts:3137 passes no nudge, so unified-loop acp_status was base+archive-only with a dead field) and src/compress-loop-responses.ts's bare buildStatusReport (base-only, no archive) — replacing both with the shared live-state handleAcpStatus from src/acp-status.js (#389 semantics). Direction is right (loops now match the canonical handler; dead code removed; typecheck/tests green), but it's an agent-visible tool-output change unrelated to compat.roles and absent from the PR body. Please add a sentence describing it (or split it out).

4. Nit (optional): wireTransform mutates the round body in place; with a pathological chained map (e.g. {developer:"system", system:"user"}), repeated application across retries cascades roles beyond one hop (pass 1: developer→system, pass 2 on the same object: system→user). Realistic single-key maps are idempotent, so this is fine given opt-in — snapshotting the original or applying once per round would make it strictly safe if you ever want that guarantee.

LGTM pending the description fixes (#1 required, #3 required, #2/#4 optional). Merging stays with you — I don't merge PRs.

#552)

Learn-on-failure for compat roles: an upstream 400 'Invalid role: ...'
triggers ONE auto-retry with the offending role rewritten to 'system'.
On success the mapping is remembered session-scoped (in-memory on the
session, never written to config) so later requests skip the 400
round-trip; the info log carries the permanent per-provider snippet.
Retry failures pass the original 400 through verbatim. Detection is
conservative (two body patterns + stopword guard).
A response body's pending pull can resolve AFTER the consumer called
clearTimer (abandoned body — a failed retry whose response is never read,
or a client abort racing the last chunk). The old code re-armed a fresh
UPSTREAM_TIMEOUT_MS (10 min) timer in that window, pinning the event loop
for ten minutes per leaked fetch; the new e2e #552 F suite hung exactly
this way. Guard rearm() with a cleared flag set by cleanup().
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