diff --git a/CHANGELOG.md b/CHANGELOG.md index c7d81b4432..ead3475bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(pricing): align Claude Code (`cc`) pricing with current Anthropic per-MTok rates** — the `cc` provider block in the default pricing table had stale numbers across every Claude 4.x family entry — most visibly, `claude-opus-4-5-20251101` was billed at the deprecated Opus 4.1 rate (`input $15` / `output $75`), and `claude-haiku-4-5-20251001` was at half the current Haiku 4.5 rate. The `cached` (cache hit) and `cache_creation` (5-minute cache write) multipliers were also off across Opus 4.6/4.7/4.8, Sonnet 4.5/4.6, Haiku 4.5, and Fable 5. All eight entries now match the rates Anthropic publishes (input, 5m cache write at 1.25x input, cache hit at 0.1x input, output; reasoning billed at the output rate), so cost accounting on the dashboard and per-request usage events stop under- or over-reporting Claude Code spend. (thanks @chulanpro5) - **fix(executors): sanitize Anthropic-shape content parts before GitHub Copilot `/chat/completions`** — Claude models on GitHub Copilot driven from clients like Cursor IDE (e.g. `gh/claude-sonnet-4.6`) failed with `Provider returned error: type has to be either 'image_url' or 'text' (reset after 30s)` because the client passed through Anthropic-shape content parts (`tool_use`, `tool_result`, `thinking`) untouched, and the Copilot chat-completions endpoint only accepts `text`/`image_url`. `GithubExecutor.transformRequest` now serializes any unsupported part type as `text` (preserving the model's context), drops empty parts, and collapses to `null` when an assistant message's only content was tool_calls — `tool_calls` ride alongside untouched. Codex-family models still route through `/responses` unchanged. (thanks @cngznNN) - **fix(sse):** refactor stall detection to reduce false positives on slow but progressing streams. (thanks @zakirkun) +- **fix(executors): synthesize `x-opencode-request` for custom-named OpenCode providers** — the OpenCode CLI only emits the `x-opencode-*` header set when the provider id starts with `opencode`; a custom-named provider (e.g. `omniroute`) instead sends `x-session-affinity` / `x-session-id` (mapped to `x-opencode-session` since #4022) but no request-correlation id, so `x-opencode-request` was silently dropped. `OpencodeExecutor` now synthesizes a fresh `x-opencode-request` on that session-affinity fallback path so custom-named providers are not disadvantaged on the opencode.ai upstream. `x-opencode-client` / `x-opencode-project` are intentionally **not** fabricated (no valid client source — an invented value risks upstream rejection) and remain forward-only; `DefaultExecutor` is untouched. ([#4465](https://github.com/diegosouzapw/OmniRoute/issues/4465) — thanks @pizzav-xyz) --- diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index aa46e38bfa..0088d2a4e9 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "crypto"; import { BaseExecutor, setUserAgentHeader, @@ -106,6 +107,20 @@ export class OpencodeExecutor extends BaseExecutor { findClientHeader("x-session-affinity") || findClientHeader("x-session-id"); if (sessionAffinity) { headers["x-opencode-session"] = sessionAffinity; + + // #4465: a custom-named provider only reaches this fallback because the + // OpenCode CLI did NOT emit the x-opencode-* set (it only does so when the + // provider id starts with "opencode"). It therefore also dropped + // x-opencode-request, a per-request correlation id. Synthesize one so these + // users are not disadvantaged versus opencode-prefixed providers on the + // opencode.ai upstream. x-opencode-client / x-opencode-project are NOT + // fabricated: their valid values are opencode-internal and inventing them + // could be rejected upstream — they remain forward-only above. Scoped to this + // executor (opencode.ai/zen) and only to the fallback path, so the direct + // OpenCode CLI flow (which controls its own request id) is untouched. + if (!headers["x-opencode-request"]) { + headers["x-opencode-request"] = randomUUID(); + } } } } diff --git a/tests/unit/opencode-executor.test.ts b/tests/unit/opencode-executor.test.ts index 3a354538ec..f57c9ef4b8 100644 --- a/tests/unit/opencode-executor.test.ts +++ b/tests/unit/opencode-executor.test.ts @@ -461,6 +461,71 @@ describe("OpencodeExecutor", () => { assert.equal(headers["x-opencode-session"], "sess-go-aff"); }); }); + + // #4465: custom-named providers reach the session-affinity fallback above, but the + // OpenCode CLI never emits x-opencode-request for them (it only emits x-opencode-* + // when the provider id starts with "opencode"). Synthesize a request correlation id + // so these users are not disadvantaged on the opencode.ai upstream. x-opencode-client + // / x-opencode-project are NOT fabricated: their valid values are opencode-internal + // and inventing them risks upstream rejection — they stay forward-only. + describe("opencode request-id synthesis for custom-named providers (#4465)", () => { + it("synthesizes x-opencode-request when only session-affinity is present", () => { + const headers = zenExecutor.buildHeaders({ apiKey: "test-key" }, true, { + "x-session-affinity": "sess-aff", + }); + assert.equal(headers["x-opencode-session"], "sess-aff"); + assert.ok( + typeof headers["x-opencode-request"] === "string" && + headers["x-opencode-request"].length > 0, + "expected a synthesized x-opencode-request id" + ); + }); + + it("synthesizes a unique x-opencode-request per call", () => { + const a = zenExecutor.buildHeaders({ apiKey: "k" }, true, { + "x-session-affinity": "sess-aff", + }); + const b = zenExecutor.buildHeaders({ apiKey: "k" }, true, { + "x-session-affinity": "sess-aff", + }); + assert.notEqual(a["x-opencode-request"], b["x-opencode-request"]); + }); + + it("prefers a client-sent x-opencode-request over the synthesized one", () => { + const headers = zenExecutor.buildHeaders({ apiKey: "test-key" }, true, { + "x-session-affinity": "sess-aff", + "x-opencode-request": "req-real", + }); + assert.equal(headers["x-opencode-request"], "req-real"); + }); + + it("does not fabricate x-opencode-client / x-opencode-project (no client source)", () => { + const headers = zenExecutor.buildHeaders({ apiKey: "test-key" }, true, { + "x-session-affinity": "sess-aff", + }); + assert.equal(headers["x-opencode-client"], undefined); + assert.equal(headers["x-opencode-project"], undefined); + }); + + it("does not synthesize x-opencode-request on the direct opencode-session path", () => { + // opencode CLI (provider id starts with "opencode") sends its own x-opencode-* + // set; we must not override/inject when it controls the request id itself. + const headers = zenExecutor.buildHeaders({ apiKey: "test-key" }, true, { + "x-opencode-session": "direct", + }); + assert.equal(headers["x-opencode-request"], undefined); + }); + + it("opencode-go executor also synthesizes x-opencode-request on the fallback path", () => { + const headers = goExecutor.buildHeaders({ apiKey: "test-key" }, true, { + "x-session-affinity": "sess-go-aff", + }); + assert.ok( + typeof headers["x-opencode-request"] === "string" && + headers["x-opencode-request"].length > 0 + ); + }); + }); }); describe("DefaultExecutor", () => {