From d7d08f4f2a2c20f95d5d38dcef8adf8dff3e91bd Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Mon, 6 Jul 2026 16:47:20 -0300 Subject: [PATCH] fix(executors): strip client_metadata on the OpenCode path (port from 9router#1442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode upstreams (e.g. kimi-k2.6 via opencode-go) return 400 "Extra inputs are not permitted, field: 'client_metadata'" — an OpenAI-Codex/Claude-CLI passthrough field with no equivalent upstream. The DefaultExecutor strip only covers cerebras/mistral, and OpencodeExecutor extends BaseExecutor directly, so nothing removed it on this path. Strip it in OpencodeExecutor.transformRequest. Regression guard: tests/unit/opencode-strip-client-metadata-1442.test.ts. Reported-by: yanpaing007 (https://github.com/decolua/9router/issues/1442) --- CHANGELOG.md | 2 + open-sse/executors/opencode.ts | 13 +++++ ...pencode-strip-client-metadata-1442.test.ts | 52 +++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 tests/unit/opencode-strip-client-metadata-1442.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e0f34140e..88a97db9ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ ### 🐛 Bug Fixes +- **fix(executors):** strip the `client_metadata` passthrough field on the **OpenCode** path. OpenCode upstreams (e.g. `kimi-k2.6` via opencode-go) reject it with `400 "Extra inputs are not permitted, field: 'client_metadata'"`; the DefaultExecutor strip only covered cerebras/mistral and `OpencodeExecutor` extends `BaseExecutor` directly, so nothing removed it there. Regression guard: `tests/unit/opencode-strip-client-metadata-1442.test.ts`. (thanks @yanpaing007) + - **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/executors/opencode.ts b/open-sse/executors/opencode.ts index a7e885103f..60fe0f7951 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -292,6 +292,19 @@ export class OpencodeExecutor extends BaseExecutor { credentials: ProviderCredentials ): any { let modifiedBody = super.transformRequest(model, body, stream, credentials); + // 9router#1442: OpenCode upstreams (e.g. kimi-k2.6 via opencode-go) return + // 400 "Extra inputs are not permitted, field: 'client_metadata'" — an + // OpenAI-Codex/Claude-CLI passthrough field with no equivalent here. The + // DefaultExecutor strip only covers cerebras/mistral, and OpencodeExecutor + // extends BaseExecutor directly, so nothing removed it on this path. + if ( + modifiedBody && + typeof modifiedBody === "object" && + !Array.isArray(modifiedBody) && + Object.prototype.hasOwnProperty.call(modifiedBody, "client_metadata") + ) { + delete (modifiedBody as Record).client_metadata; + } if ( modifiedBody && typeof modifiedBody === "object" && diff --git a/tests/unit/opencode-strip-client-metadata-1442.test.ts b/tests/unit/opencode-strip-client-metadata-1442.test.ts new file mode 100644 index 0000000000..ef6fe7e49e --- /dev/null +++ b/tests/unit/opencode-strip-client-metadata-1442.test.ts @@ -0,0 +1,52 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +const { OpencodeExecutor } = await import("../../open-sse/executors/opencode.ts"); + +/** + * Regression test for upstream decolua/9router#1442: + * + * OpenCode upstreams (e.g. kimi-k2.6 via opencode-go) reject the + * `client_metadata` passthrough field (an OpenAI-Codex/Claude-CLI artifact) + * with 400 "Extra inputs are not permitted, field: 'client_metadata'". + * DefaultExecutor strips it only for cerebras/mistral, and OpencodeExecutor + * extends BaseExecutor directly, so nothing removed it on the opencode path. + * OpencodeExecutor.transformRequest must strip it. + */ +describe("OpencodeExecutor — strips client_metadata (#1442)", () => { + const executor = new OpencodeExecutor("opencode-go"); + const CREDENTIALS = { apiKey: "k" } as Record; + + function body() { + return { + model: "oc/kimi-k2.6", + stream: true, + client_metadata: { user_id: "abc" }, + messages: [{ role: "user", content: "hi" }], + }; + } + + it("removes client_metadata from the forwarded body", () => { + const out = executor.transformRequest("oc/kimi-k2.6", body(), true, CREDENTIALS) as Record< + string, + unknown + >; + assert.equal( + Object.prototype.hasOwnProperty.call(out, "client_metadata"), + false, + "opencode forward body must not carry client_metadata" + ); + assert.ok(Array.isArray(out.messages), "messages preserved"); + }); + + it("is a no-op when client_metadata is absent", () => { + const b = body(); + delete (b as Record).client_metadata; + const out = executor.transformRequest("oc/kimi-k2.6", b, true, CREDENTIALS) as Record< + string, + unknown + >; + assert.equal("client_metadata" in out, false); + assert.ok(Array.isArray(out.messages)); + }); +});