fix(executors): synthesize x-opencode-request for custom-named OpenCode providers (#4465) (#4476)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-21 02:43:04 -03:00
committed by GitHub
parent 92afce6d09
commit b4dbf7b235
3 changed files with 81 additions and 0 deletions

View File

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

View File

@@ -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();
}
}
}
}

View File

@@ -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", () => {