fix(executors): strip client_metadata on the OpenCode path (port from 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 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)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-06 16:47:20 -03:00
parent fb3da7ce98
commit d7d08f4f2a
3 changed files with 67 additions and 0 deletions

View File

@@ -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).

View File

@@ -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<string, unknown>).client_metadata;
}
if (
modifiedBody &&
typeof modifiedBody === "object" &&

View File

@@ -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<string, unknown>;
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<string, unknown>).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));
});
});