Skip to content

billion-context: 400 Bad Request when codex sends role: "developer" messages — request a request-body compat transform hook #552

Closed

Description

@Juna9969

Environment

  • billion-context (bili): v0.1.85
  • Client: codex CLI 0.153.2 (Codex TUI), using the codex / /v1/responses protocol
  • Upstream: an OpenAI-compatible relay (api.b.ai, whose backend is Tencent Cloud Hunyuan) via bili'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)

  1. 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).
  2. Start bili wrapping that upstream: bili start --port 8787 --upstream http://127.0.0.1:9001 (or via launcher).
  3. Send a Responses API request through the wrap with a developer role 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):

  1. Config-driven built-in compat: a flag in the launcher/config, e.g. compat.chatRoles: "system" — when set, bili rewrites any input[].role == "developer" to role: "system" before forwarding Requests-API bodies (and, symmetrically, messages[].role == "developer" → system for chat-style bodies).
  2. 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/responses input items and /v1/chat/completions messages for 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-developer upstream 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: input items may carry role: "developer"; chat protocol has no such role (maps to system).
  • Codex 0.153.x change: built-in agent instructions moved from instructions field into an input message with role: "developer".
  • Related: this repo's SSE parser issue (data-only streams) — same theme of "transparent wrapping needs a small amount of protocol tolerance".

Activity

  1. Juna9969 commented on Sep 5, 2026

    @Juna9969
    Author

    另需,横向更多匹配 role 参数

  2. ranxianglei commented on Sep 5, 2026

    @ranxianglei
    Owner

    🤖 ework agent · qwen3.8-27b

    [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:

    1. In proxy mode, bili injects its own compress-prompt into every forwarded /v1/responses request as a developer message (injectResponsesDeveloperMessage, src/server.ts:1948/1956).
    2. On the chat-completions path, coreToOpenai restores 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.
    3. The server-side compress loop also generates developer items (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.

  3. ranxianglei commented on Sep 5, 2026

    @ranxianglei
    Owner

    🤖 ework agent · qwen3.8-27b

    [bot] 🏷 Got it — folding that into the design. Instead of a single developer→system switch, the compat transform will be a configurable role-rewrite map, e.g.:

    {
      "compat": {
        "roles": { "developer": "system" }
      }
    }

    applied uniformly to messages[].role (chat completions) and input[].role on message items (responses). That way additional alias roles (e.g. legacy function) 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.

  4. ranxianglei commented on Sep 5, 2026

    @ranxianglei
    Owner

    🤖 ework agent · qwen3.8-27b

    [bot] 🏷 Verification done — reproduced end-to-end against a mock upstream that rejects developer (bili master build, /bili/<upstream> wrap). Results:

    # Request What upstream actually received Result
    A /v1/responses, client sends input[] item with role:"developer" input:developer, input:user 400 Invalid role — the reported bug
    B /v1/responses, clean client (user-only), normal token budget input:developer, input:user 400 — the developer item is bili's own compress prompt (src/server.ts:1948/1956)
    C /v1/chat/completions, client sends a developer message messages:system, messages:developer, messages:user 400 — coreToOpenai restores the original role

    So 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 at src/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" } } }
    • Extensible map, not a single switch: extra alias roles (e.g. legacy "function") are added by config, no code change; each entry individually opt-in. Default {} = byte-for-byte transparent, as your design notes require.
    • Scope: global + per-provider route override, merged with the same mechanism as the compress settings (role compat is an upstream property, not a model property).

    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: parse prepared.body once and rewrite exact-match roles in

      • chat: messages[].role
      • responses: input[] items where type === "message" → .role

      Position 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 onRequestBody extension 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.

  5. ranxianglei commented on Sep 6, 2026

    @ranxianglei
    Owner

    Implemented per the design agreed above: PR #579 — #579

    • compat.roles map, 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 — plus wireTransform on the compress-retry loops so re-sent bodies carry the same rewrite (no mid-stream 400 on retry).
    • Default {} stays byte-for-byte transparent; anthropic untouched; zero kernel changes (kernel already normalizes developer internally).
    • One finding from the e2e tests: the chat path already normalizes developer→system during rebuild (kernel coreToOpenai), so the live surface of this bug is the responses path — confirmed developer reaches the wire untouched there when compat is off. The fix covers both anyway.
    • 10 new tests (units + 4 e2e through a live proxy); full suite 1077/1079 (2 known plugin-agent env fails); typecheck + build green. README + CONFIGURATION (EN/zh-CN) documented.

    Example:

    {
      "compat": { "roles": { "developer": "system" } },
      "providers": {
        "https://picky.example.com": { "compat": { "roles": { "developer": "user" } } }
      }
    }
  6. ranxianglei commented on Sep 6, 2026

    @ranxianglei
    Owner

    🤖 ework agent · qwen3.8-27b

    [bot] 🏷 Verified PR #579 against the agreed design — it matches:

    • Open, 2026-09-06_compat-role-rewrite → master; 9 files (+485/−13): new src/compat-roles.ts (parse/resolve/apply) + tests/compat-roles.test.ts, forward-boundary hook in src/server.ts, wireTransform threaded through src/loop/core.ts + src/compress-loop-responses.ts, config merge in src/config.ts, README + CONFIGURATION (EN/zh-CN).
    • CI at check time: test matrix green on ubuntu+windows node 24 and ubuntu node 22 (windows node 22 still running), version-guard + build-artifact green.

    Two notes for the record:

    1. Correction to my earlier scenario C: that chat-path 400 was invalid evidence — requests with max_tokens ≤ 200 take 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 single system message, and a mid-conversation developer arrived as user (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).

    2. Side finding from the repro runs, filed separately: starting a locally-built dist/index.js from a git checkout makes auto-update resolve the checkout itself as its install dir (findInstallDir walks 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 #552 will close this thread.

  7. ranxianglei commented on Sep 6, 2026

    @ranxianglei
    Owner

    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 as learnedContextLimits). 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.roles config remains for users who prefer explicit control (global + per-provider), documented in README/CONFIGURATION (EN + zh-CN).

  8. ranxianglei commented on Sep 6, 2026

    @ranxianglei
    Owner

    🤖 ework agent · qwen3.8-27b

    [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:

    • Auto-retry: on a 400 whose body is detected as a role rejection, bili rewrites the offending role → system and 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 on session.metadata.learnedCompatRoles (session-scoped, markDirty, config file untouched); same-request re-sends pick it up immediately via the wireTransform closure reading the map at call time.
    • Round-trip skip: e2e test E asserts exactly 3 upstream hits across two client requests — developer (rejected) → system (retry) → system (learned, rewritten pre-fetch).
    • Failure path: if the retry still fails or the 400 isn't a role rejection, the original response continues downstream verbatim (test F: body byte-preserved, exactly one retry, no loop).
    • Log hint: the info line carries the copy-paste per-provider snippet with the real upstream origin.
    • Manual compat.roles remains (global + per-provider, provider wins per key), documented in README + CONFIGURATION (EN + zh-CN).
    • Retry bodies from both compress loops carry the rewrite via the new RequestOptions.wireTransform contract — 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.ts could re-arm the 10-minute idle timer after clearTimer() 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 a cleared flag + 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 🙂).

  9. added a commit that references this issue on Sep 6, 2026
  10. ranxianglei commented on Sep 6, 2026

    @ranxianglei
    Owner

    已在 master 修复(PR #579,branch 2026-09-06_compat-role-rewrite):compat.roles 配置重写 + 首次 400 自动学习重试 + fetch-util timer race 修复。将随下个 release 发布。

  11. added a commit that references this issue on Oct 19, 2026
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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions