From b6f2bd85feafcfca4aff708b07f8be0e2f3fafef Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Mon, 6 Jul 2026 16:53:43 -0300 Subject: [PATCH] fix(executors): strip client context_management on 400 (port from 9router#1468) Claude Code always sends a top-level `context_management` field. Strict anthropic-compatible gateways reject it with 400 "context_management: Extra inputs are not permitted". The dedicated context-editing 400-fallback in base.ts only fires when OmniRoute's own `contextEditing` feature is enabled (default off), so a client-sent field passed through untouched and 400'd with no recovery. Add `context_management` to KNOWN_OFFENDING_FIELDS so the generic reactive 400 field-downgrade strips-and-retries it once, independent of the feature flag (the generic path already re-signs for claude-compatible relays). Regression guard: tests/unit/provider-field-strips.test.ts. Reported-by: ohahe52-dot (https://github.com/decolua/9router/issues/1468) --- CHANGELOG.md | 2 ++ open-sse/config/providerFieldStrips.ts | 6 ++++++ tests/unit/provider-field-strips.test.ts | 6 ++++++ 3 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e0f34140e..bc00868198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ ### 🐛 Bug Fixes +- **fix(executors):** recover from a strict gateway's `context_management: Extra inputs are not permitted` 400. **Claude Code** always sends a top-level `context_management` field; strict anthropic-compatible gateways reject it. The dedicated context-editing 400-fallback only fired when OmniRoute's own `contextEditing` feature was enabled (default off), so a client-sent field passed through untouched and 400'd. `context_management` is now in the generic reactive field-strip list, so it's stripped-and-retried once regardless of the feature flag (with correct request re-signing for claude-compatible relays). Regression guard: `tests/unit/provider-field-strips.test.ts`. (thanks @ohahe52-dot) + - **fix(live-ws):** the Live Dashboard WebSocket server now **rejects on bind failure** (e.g. `EADDRINUSE` when the API bridge already holds the port) instead of letting the error surface as an unhandled `error` event that crash-loops the process — the `error` listener is attached to `wss` (not `server`) and releases the EventBus subscription on a failed start ([#6324](https://github.com/diegosouzapw/OmniRoute/issues/6324)). Regression guard: `tests/unit/live-ws-eaddrinuse-6324.test.ts`. (thanks @vinayakkulkarni) - **fix(dashboard):** the Home provider-topology widget now trusts the live provider-metrics snapshot — it uses `topology.errorProvider` and live `activeRequests` directly instead of re-deriving state from a stale `lastErrorAt` or applying a frontend timeout filter, so the topology reflects real-time provider health. Regression guard: `tests/unit/home-provider-topology-live-state.test.ts`. (thanks @xz-dev) - **fix(sse):** strip zero-width markers from streamed **tool-call arguments** — a follow-up to [#5857](https://github.com/diegosouzapw/OmniRoute/pull/5857). That PR removed injected zero-width joiners (U+200D) from streamed assistant text/reasoning but deliberately left tool-call argument JSON byte-exact. The request-side obfuscation (`open-sse/services/claudeCodeObfuscation.ts`) injects ZWJ into agent words — including the temp path inside the Bash tool description — and Claude models copy that verbatim into generated commands, which are delivered as tool-call arguments rather than assistant text. As a result the ZWJ survived and corrupted code blocks (e.g. a temp path rendered with an invisible joiner). Now `open-sse/handlers/responseSanitizer.ts` strips zero-width code points from tool-call argument strings at every emit site (OpenAI non-stream/stream chat `tool_calls` + legacy `function_call`, native Responses `function_call` items, the OpenAI→Responses conversion, and the native Responses streaming `response.function_call_arguments.delta/.done` events). Only zero-width code points are removed; JSON structure and all other bytes stay identical (no parse/restringify), so normal arguments remain byte-exact. Regression guard: 6 new cases in `tests/unit/response-sanitizer.test.ts` (suite 50/50). diff --git a/open-sse/config/providerFieldStrips.ts b/open-sse/config/providerFieldStrips.ts index 0cb5f3f8f5..5a342cc9c1 100644 --- a/open-sse/config/providerFieldStrips.ts +++ b/open-sse/config/providerFieldStrips.ts @@ -1,10 +1,16 @@ // Fields that, when literally named in an upstream 400 body, are safe to strip and // retry once (FCC NIM-style recovery). Mirrors the existing context_management 400 // fallback in base.ts, generalized to these OpenAI-compat / NIM reasoning fields. +// `context_management` (9router#1468): Claude Code sends it top-level; strict +// anthropic-compatible gateways 400 with "context_management: Extra inputs are not +// permitted". The dedicated base.ts fallback only fires when OmniRoute's own +// contextEditing feature is enabled, so a client-sent field passed through +// untouched when the feature is off — this generic strip covers that case. export const KNOWN_OFFENDING_FIELDS: readonly string[] = [ "reasoning_budget", "chat_template", "reasoning_content", + "context_management", ]; /** Return the first known-offending field literally named in a 400 body, or null. */ diff --git a/tests/unit/provider-field-strips.test.ts b/tests/unit/provider-field-strips.test.ts index 7d4b6aa3d5..9e57ca60af 100644 --- a/tests/unit/provider-field-strips.test.ts +++ b/tests/unit/provider-field-strips.test.ts @@ -9,6 +9,12 @@ test("findOffendingField matches known field names in a 400 body", () => { assert.equal(findOffendingField("Invalid argument: reasoning_budget not supported"), "reasoning_budget"); assert.equal(findOffendingField("unexpected field chat_template"), "chat_template"); assert.equal(findOffendingField("reasoning_content is not allowed"), "reasoning_content"); + // #1468: Claude Code's top-level context_management field rejected by strict + // anthropic-compatible gateways → strip + retry regardless of the contextEditing flag. + assert.equal( + findOffendingField("context_management: Extra inputs are not permitted"), + "context_management" + ); assert.equal(findOffendingField("all good"), null); assert.equal(findOffendingField(""), null); });