From ad067a193efa57e75512e92af911ffa9fd3632b1 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:37:25 -0300 Subject: [PATCH] fix(opencode): map x-session-affinity to x-opencode-session for custom providers (#4022) (#4028) --- CHANGELOG.md | 1 + open-sse/executors/opencode.ts | 23 +++++++++++-- tests/unit/opencode-executor.test.ts | 51 ++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8c97e61b0..975b29052a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ ### 🐛 Fixed +- **fix(opencode): forward the OpenCode session id to the upstream regardless of how the user named the provider** — the `OpencodeExecutor` forwarded the `x-opencode-session/request/project/client` headers, but the OpenCode CLI only emits those when the configured `providerID` **starts with** `"opencode"`. A user who adds OmniRoute as a custom provider (e.g. `"omniroute"`) makes the CLI send `x-session-affinity` / `X-Session-Id` instead (both carry the same session id), which the executor never read — so the session-metadata forwarding was effectively dead code for the realistic provider-naming case. The opencode-family executor now falls back to `x-session-affinity` / `X-Session-Id` and maps it onto `x-opencode-session` when the client didn't send the header directly, so session continuity to the `opencode.ai` upstream works for any provider name (a direct `x-opencode-session` still wins). Scoped to this executor only — the generic `DefaultExecutor` intentionally does **not** do this, to avoid leaking the client session id to arbitrary third-party upstreams. ([#4022](https://github.com/diegosouzapw/OmniRoute/issues/4022) — thanks @pizzav-xyz) - **fix(guardrails): Vision Bridge no longer drops the image when the describe call fails (Nvidia NIM "Image unavailable")** — the Vision Bridge is enabled by default and engages for any model whose vision capability OmniRoute can't prove from the registry (`supportsVision !== true`, which includes uncatalogued models that resolve to `null`). When the per-image describe call failed (e.g. no vision model configured), it replaced the image with the literal text `[Image N]: (unavailable)` and dropped the original `image_url` — so a genuinely vision-capable upstream (Nvidia NIM) received text only and answered "Image unavailable. Cannot provide description without visual data." A describe failure is no longer destructive: `replaceImageParts` now receives `null` for failed images and **preserves the original image part** so the upstream can still see it (successful describes still replace the image with the text description; `meta.descriptions` observability is unchanged). ([#4012](https://github.com/diegosouzapw/OmniRoute/issues/4012) — thanks @daniij) - **fix(kiro): preserve `finish_reason: "tool_calls"` on the Kiro streaming path** — streaming tool-call requests through the Kiro (Responses API) provider had their terminal `finish_reason` reported as `"stop"` instead of `"tool_calls"`, so agent clients (Hermes) treated the tool-call turn as a finished turn, never ran the tool, and the next request failed with HTTP 400 on the incomplete tool state. `convertKiroToOpenAI`'s terminal `messageStopEvent`/`done` branch hardcoded `finish_reason: "stop"` regardless of whether the stream had emitted `toolUseEvent`s. The translator now records `state.sawToolUse` when a tool-use chunk is emitted and reports `finish_reason: "tool_calls"` on the terminal chunk (and in `state.finishReason`) whenever the stream produced tool calls. The non-streaming path was already correct. ([#3980](https://github.com/diegosouzapw/OmniRoute/issues/3980) — thanks @lordavadon2) diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index 59681c8b6d..aa46e38bfa 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -77,6 +77,9 @@ export class OpencodeExecutor extends BaseExecutor { } // Forward OpenCode request metadata headers from client + const findClientHeader = (name: string) => + Object.entries(clientHeaders).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1]; + const opencodeHeaderKeys = [ "x-opencode-session", "x-opencode-request", @@ -84,13 +87,27 @@ export class OpencodeExecutor extends BaseExecutor { "x-opencode-client", ]; for (const headerName of opencodeHeaderKeys) { - const value = Object.entries(clientHeaders).find( - ([key]) => key.toLowerCase() === headerName.toLowerCase() - )?.[1]; + const value = findClientHeader(headerName); if (value) { headers[headerName] = value; } } + + // #4022: OpenCode CLI only emits x-opencode-* headers when the provider id + // starts with "opencode". For a custom-named provider (e.g. "omniroute") it + // instead sends x-session-affinity / X-Session-Id, which both carry the same + // OpenCode sessionID. Map that session id onto x-opencode-session so session + // continuity to the opencode.ai upstream works regardless of how the user + // named the provider. Scoped to this executor (opencode.ai/zen upstreams + // only) — the generic DefaultExecutor intentionally does NOT do this, to + // avoid leaking the client session id to arbitrary third-party upstreams. + if (!headers["x-opencode-session"]) { + const sessionAffinity = + findClientHeader("x-session-affinity") || findClientHeader("x-session-id"); + if (sessionAffinity) { + headers["x-opencode-session"] = sessionAffinity; + } + } } void model; diff --git a/tests/unit/opencode-executor.test.ts b/tests/unit/opencode-executor.test.ts index 5f31dd6891..3a354538ec 100644 --- a/tests/unit/opencode-executor.test.ts +++ b/tests/unit/opencode-executor.test.ts @@ -410,6 +410,57 @@ describe("OpencodeExecutor", () => { assert.equal(headers["Authorization"], undefined); }); }); + + // #4022: OpenCode CLI only emits x-opencode-* when the provider id starts with + // "opencode". For a custom-named provider (e.g. "omniroute") it instead sends + // x-session-affinity / X-Session-Id (both carry the same OpenCode sessionID). + // The executor must map that session id onto x-opencode-session so session + // continuity to the opencode.ai upstream works regardless of provider name. + describe("opencode session-affinity fallback (#4022)", () => { + it("maps x-session-affinity to x-opencode-session when no direct x-opencode-session", () => { + const headers = zenExecutor.buildHeaders({ apiKey: "test-key" }, true, { + "x-session-affinity": "sess-aff", + }); + assert.equal(headers["x-opencode-session"], "sess-aff"); + }); + + it("maps X-Session-Id to x-opencode-session when no direct x-opencode-session", () => { + const headers = zenExecutor.buildHeaders({ apiKey: "test-key" }, true, { + "X-Session-Id": "sess-id", + }); + assert.equal(headers["x-opencode-session"], "sess-id"); + }); + + it("prefers a direct x-opencode-session over x-session-affinity (regression guard)", () => { + const headers = zenExecutor.buildHeaders({ apiKey: "test-key" }, true, { + "x-opencode-session": "direct", + "x-session-affinity": "affinity", + "X-Session-Id": "session-id", + }); + assert.equal(headers["x-opencode-session"], "direct"); + }); + + it("does not set x-opencode-session when neither direct nor affinity is present", () => { + const headers = zenExecutor.buildHeaders({ apiKey: "test-key" }, true, { + "some-other-header": "val", + }); + assert.equal(headers["x-opencode-session"], undefined); + }); + + it("matches session-affinity headers case-insensitively", () => { + const headers = zenExecutor.buildHeaders({ apiKey: "test-key" }, true, { + "X-Session-Affinity": "sess-ci", + }); + assert.equal(headers["x-opencode-session"], "sess-ci"); + }); + + it("opencode-go executor also maps session-affinity to x-opencode-session", () => { + const headers = goExecutor.buildHeaders({ apiKey: "test-key" }, true, { + "x-session-affinity": "sess-go-aff", + }); + assert.equal(headers["x-opencode-session"], "sess-go-aff"); + }); + }); }); describe("DefaultExecutor", () => {