Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down
15 changes: 15 additions & 0 deletions open-sse/executors/opencode.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from "crypto";
import {
BaseExecutor,
setUserAgentHeader,
Expand Down Expand Up @@ -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();
}
}
}
}
Expand Down
65 changes: 65 additions & 0 deletions tests/unit/opencode-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading