Repository navigation
billion-context: 400 Bad Request when codex sends role: "developer" messages — request a request-body compat transform hook #552
Description
Environment
- billion-context (bili): v0.1.85
- Client: codex CLI 0.153.2 (Codex TUI), using the
codex//v1/responsesprotocol - Upstream: an OpenAI-compatible relay (
api.b.ai, whose backend is Tencent Cloud Hunyuan) viabili's/bili/<upstream>wrapping - Observer: bili forwards the request body verbatim (only base_url is rewritten via
httpRewrites); a local OpenAI-compatible gateway converts the Responses API body to Chat Completions before reaching the relay
Problem
Since codex 0.153.x, the Codex TUI sends its built-in agent instructions as a normal message item with role: "developer" inside the Responses API input array, e.g.:
{
"model": "gpt-5.6-sol",
"input": [
{
"type": "message",
"role": "developer",
"content": [
{
"type": "input_text",
"text": "# Portable Codex agent instructions\n\nThese instructions..."
}
]
},
{ "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "Hello" }] }
],
"instructions": null
}Every downstream OpenAI-compatible server that converts to the Chat Completions shape (or natively accepts messages) has no developer role — the standard mapping is developer → system (OpenAI's own chat protocol exposes only system, and OpenAI's docs state codex instructions map to the system role). When the message with role: "developer" is forwarded unchanged, upstream validation fails:
400 {"error":{"message":"The request is invalid: invoke model error: ... forward bad request, [HTTP 400] {\"error\":{\"message\":\"Invalid role: developer\", ...}}}}
(observed variant through a multi-hop backend: http client codec StatusCode: Bad Request, body rejected during JSON re-serialization at the second hop — Tencent Cloud Hunyuan)
This is not a hypothetical: it breaks every reque it with codex ≥ 0.153 as client + a chat-completions/converting upstream, which is the most common self-hosted/relay setup this tool targets.
Root cause
bili is a transparent proxy: it rewrites the base URL (httpRewrites) and adds session identity, but it has no hooks to adjust the request body. The developer-role message therefore passes through untouched and reaches upstreams that reject the role. Workarounds currently require an extra middleware hop (a local gateway doing developer → system), which defeats the "wrap in one command" design.
How to reproduce (minimal)
- Run any OpenAI-compatible upstream that rejects
role: "developer"(e.g. a one-api/new-api relay in front of Tencent Hunyuan, or any server that validates roles strictly). - Start bili wrapping that upstream:
bili start --port 8787 --upstream http://127.0.0.1:9001(or via launcher). - Send a Responses API request through the wrap with a
developerrole message:
curl -s http://127.0.0.1:8787/bili/http://127.0.0.1:9001/v1/responses \
-H "Content-Type: application/json" \
-H "x-session-id: test" \
-d '{
"model": "gpt-5.6-sol",
"input": [
{"type":"message","role":"developer","content":[{"type":"input_text","text":"be concise"}]},
{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}
]
}'Expected: 2xx with a response object.
Actual: 400 with an invalid-role error from upstream.
Requested change: a request-body transform hook (function-style compat)
Please add an optional, opt-in body transform hook so downstream protocol incompatibilities like this one can be fixed by the user without an extra proxy hop. Suggested shapes (either is fine):
- Config-driven built-in compat: a flag in the launcher/config, e.g.
compat.chatRoles: "system"— when set, bili rewrites anyinput[].role == "developer"torole: "system"before forwarding Requests-API bodies (and, symmetrically,messages[].role == "developer"→systemfor chat-style bodies). - User function hook:
onRequestBody(body, {url})returning the possibly-mutated body, loaded from a user script path in config (plugin/transform).
Design notes so the maintainer can pick the right size:
- Must be opt-in (default behavior stays transparent; today's users rely on byte-for-byte fidelity).
- The conversion is lossless for and only needs to handle
developer→system; keep the message position (codex sends it first, which matches OpenAI's "system must be first" rule). - Apply to both
/v1/responsesinputitems and/v1/chat/completionsmessagesfor symmetric coverage. - Session-identity and ACP-compression logic should run on the transformed body so compressed output keeps working.
Impact if not addressed
- Every codex ≥ 0.153 user pointing at a converting/non-
developerupstream is stuck with a permanently failing first message of each session. - The documented workaround (extra gateway hop) adds a second failure surface (SSE re-formatting issues previously reported, e.g. data-only SSE streams) for users who only needed one hop.
References
- OpenAI Responses API:
inputitems may carryrole: "developer"; chat protocol has no such role (maps tosystem). - Codex 0.153.x change: built-in agent instructions moved from
instructionsfield into aninputmessage withrole: "developer". - Related: this repo's SSE parser issue (data-only streams) — same theme of "transparent wrapping needs a small amount of protocol tolerance".
Activity
Juna9969 commentedon Sep 5, 2026
另需,横向更多匹配 role 参数
ranxianglei commentedon Sep 5, 2026
[bot] 🏷 Acknowledged — triaging now. Preliminary finding before full verification: your root-cause analysis is directionally right, but the bug runs deeper than reported.
bili does not forward the body verbatim — it rebuilds it through the acp-kernel pipeline, and that pipeline itself emits
role: "developer"messages:/v1/responsesrequest as a developer message (injectResponsesDeveloperMessage,src/server.ts:1948/1956).coreToOpenairestores the client's original role:m.originalRole === "developer" ? "developer" : "system"(acp-kernel wire) — so any client-sent developer message round-trips back out as developer.src/compress-loop-responses.ts:213).Consequence: on an upstream that rejects
developer, every turn 400s, not just codex's first message — even for a client that never sends a developer message itself. That means the transform must cover all emission sites, which points to applying it at the single final forward boundary rather than patching only the client's inbound items.Verifying with a repro against a mock upstream that rejects
developer; will follow up with a concrete design recommendation (config shape + scope) before implementing.ranxianglei commentedon Sep 5, 2026
[bot] 🏷 Got it — folding that into the design. Instead of a single
developer→systemswitch, the compat transform will be a configurable role-rewrite map, e.g.:{ "compat": { "roles": { "developer": "system" } } }applied uniformly to
messages[].role(chat completions) andinput[].roleon message items (responses). That way additional alias roles (e.g. legacyfunction) can be mapped without a code change, and each entry stays individually opt-in. Default remains off (byte-for-byte transparent).Finishing the repro against a mock rejecting upstream now; full design recommendation (hook location, scope, exact config shape) follows.
ranxianglei commentedon Sep 5, 2026
[bot] 🏷 Verification done — reproduced end-to-end against a mock upstream that rejects
developer(bili master build,/bili/<upstream>wrap). Results:/v1/responses, client sendsinput[]item withrole:"developer"input:developer, input:user/v1/responses, clean client (user-only), normal token budgetinput:developer, input:usersrc/server.ts:1948/1956)/v1/chat/completions, client sends adevelopermessagemessages:system, messages:developer, messages:usercoreToOpenairestores the original roleSo confirmed: on any upstream that rejects
developer, every turn fails in proxy mode, even for clients that never send a developer message themselves. The transform must cover all emission sites (client items + bili's injected compress prompt + instructions hoisting at:2041+ compress-loop items atsrc/compress-loop-responses.ts:213), not just inbound client items.Recommendation: config-driven role-rewrite map, applied at the final forward boundary
Your shape 1 over shape 2, generalized per your follow-up:
{ "compat": { "roles": { "developer": "system" } } }"function") are added by config, no code change; each entry individually opt-in. Default{}= byte-for-byte transparent, as your design notes require.Mechanics
Where: top of
forward()(src/server.ts:2529), before headers/dumps are built. When the map is non-empty and protocol is openai/responses: parseprepared.bodyonce and rewrite exact-match roles inmessages[].roleinput[]items wheretype === "message"→.rolePosition and content untouched otherwise; re-stringify after the rewrite so content-length derives from the final bytes.
Why the forward boundary and not before
prepare*: bili injects its own developer message after parsing (scenarios B/C above prove it). Only the final boundary sees every emission site — one choke point, zero acp-kernel changes.Session/ACP impact: none. The kernel already normalizes developer→system internally (tracks
originalRole); session identity, block IDs, and compression state are computed from pre-wire core messages. Only outbound bytes change, deterministically per request → prefix-cache stays stable. This satisfies your "compression must keep working on the transformed body" note without re-running the pipeline.Anthropic path: no-op (that wire has no per-message roles).
Cost: one extra JSON round-trip per request, only when opt-in.
Why not the user-function hook (shape 2): loading arbitrary code from a config path adds attack surface and lifecycle complexity for what is fundamentally a one-line need; the map covers the whole role-alias class without an eval surface. If you want a generic
onRequestBodyextension point, I'd scope it as a separate follow-up issue rather than bundling it here.Test plan: unit tests for the rewriter (both wire shapes, default no-op, multi-entry map) + a regression test mirroring scenarios A/B/C against a rejecting mock upstream.
If you approve this shape (map at forward boundary, global+provider scope), I'll implement on a branch and open the PR.
ranxianglei commentedon Sep 6, 2026
Implemented per the design agreed above: PR #579 — #579
compat.rolesmap, global + per-provider (provider wins per key), applied at the forward boundary — the single choke point that sees client-sent roles AND bili's own injected developer-role compress prompt — pluswireTransformon the compress-retry loops so re-sent bodies carry the same rewrite (no mid-stream 400 on retry).{}stays byte-for-byte transparent; anthropic untouched; zero kernel changes (kernel already normalizes developer internally).developerreaches the wire untouched there when compat is off. The fix covers both anyway.Example:
{ "compat": { "roles": { "developer": "system" } }, "providers": { "https://picky.example.com": { "compat": { "roles": { "developer": "user" } } } } }ranxianglei commentedon Sep 6, 2026
[bot] 🏷 Verified PR #579 against the agreed design — it matches:
2026-09-06_compat-role-rewrite→ master; 9 files (+485/−13): newsrc/compat-roles.ts(parse/resolve/apply) +tests/compat-roles.test.ts, forward-boundary hook insrc/server.ts,wireTransformthreaded throughsrc/loop/core.ts+src/compress-loop-responses.ts, config merge insrc/config.ts, README + CONFIGURATION (EN/zh-CN).Two notes for the record:
Correction to my earlier scenario C: that chat-path 400 was invalid evidence — requests with
max_tokens ≤ 200take the side-request path (side request (max_tokens<=200) → passthrough + tag strip only), so that 400 was verbatim passthrough, not kernel rebuild. Re-ran the processed chat path (max_tokens=2048): a leading system+developer prefix arrived upstream as a singlesystemmessage, and a mid-conversation developer arrived asuser(systemToUser re-voicing) — no developer role reached the wire, 200 OK. So your e2e finding stands as stated: the live surface is the responses path (client items + the injected compress prompt — both confirmed there under full-pipeline retest). Covering both paths anyway remains right as defense-in-depth (e.g. side-request passthrough still forwards client bytes verbatim).Side finding from the repro runs, filed separately: starting a locally-built
dist/index.jsfrom a git checkout makes auto-update resolve the checkout itself as its install dir (findInstallDirwalks up to the matching package.json) and merge-copy the published tarball over it — my working tree was silently rewritten 3× during repro (version field, acp-kernel pin, READMEs). Filed as auto-update overwrites tracked files when run from a git source checkout (findInstallDir resolves to repo root) #580 with root cause + suggested fix.Merge stays human-only; once CI is green and merged, the PR's
Closes #552will close this thread.feat: auto-retry role-rejecting upstreams with learned session mapping (
fix: late body chunk must not re-arm the idle timer after clearTimer
ranxianglei commentedon Sep 6, 2026
Update (per feedback above): PR #579 now handles the common case with zero configuration.
On a role-rejection 400, bili automatically rewrites
developer → system, retries once, and — if that succeeds — remembers the mapping for this session only (config file is never modified; same mechanism aslearnedContextLimits). Subsequent requests skip the 400 round-trip entirely. The one-time log hint prints a copy-paste per-provider snippet (real upstream origin) for users who want to make it permanent in their config.If the auto-retry still fails, the original 400 is passed through verbatim.
The manual
compat.rolesconfig remains for users who prefer explicit control (global + per-provider), documented in README/CONFIGURATION (EN + zh-CN).ranxianglei commentedon Sep 6, 2026
[bot] 🏷 Verified the update against the live PR state — everything checks out:
PR #579 @
9b6efc2— 3 commits, 10 files (+717/−13),mergeable_state: clean, CI fully green (ubuntu+windows × node 22/24, version-guard, build-artifact).Reviewed the full diff; the mechanism matches your description point for point:
systemand retries exactly once. Detection is conservative by design (detectRoleRejection: status must be 400, pattern match, stopword filter so "Invalid role must be one of…" prose doesn't misfire, and Pydantic-style errors where the role name isn't in the text correctly don't detect). On success the mapping lands onsession.metadata.learnedCompatRoles(session-scoped,markDirty, config file untouched); same-request re-sends pick it up immediately via thewireTransformclosure reading the map at call time.developer(rejected) →system(retry) →system(learned, rewritten pre-fetch).compat.rolesremains (global + per-provider, provider wins per key), documented in README + CONFIGURATION (EN + zh-CN).RequestOptions.wireTransformcontract — no mid-stream developer-role 400 on truncation/degraded retries.One thing worth calling out from the third commit: it fixes a separate latent bug found during the e2e work —
fetch-util.tscould re-arm the 10-minute idle timer afterclearTimer()when a late body chunk resolved post-cleanup (abandoned response body), leaking the timer and pinning the event loop (this was the "e2e hang"). Fixed with aclearedflag + a regression assertion (_liveUpstreamTimersForTest() === 0). Small, well-isolated, and the right place to land it.Nothing outstanding from my side — ready whenever you want to merge (that part's yours, not mine 🙂).
Merge pull request #579 from ranxianglei/2026-09-06_compat-role-rewrite
ranxianglei commentedon Sep 6, 2026
已在 master 修复(PR #579,branch 2026-09-06_compat-role-rewrite):compat.roles 配置重写 + 首次 400 自动学习重试 + fetch-util timer race 修复。将随下个 release 发布。
fix: compat.roles wire role rewrite for role-rejecting upstreams (ran…
fix: add second-chance developer→user compat learn (ranxianglei#583)
fix(ranxianglei#684): reasoning-drop self-disables on strict-echo ups…
fix: derive fake-completion hinted retry from accepted wire bytes (ra…
fix(ranxianglei#2645): probe-and-remember prompt_cache_key at the egr…