From b53c4e95cad5294d8ca07e5596ea341f225c6fef Mon Sep 17 00:00:00 2001 From: Angelo Pantano Date: Sun, 6 Sep 2026 10:42:40 -0700 Subject: [PATCH] fix: OpenCode Go/Zen requests are missing the required x-opencode-session header (#137464) * fix: send x-opencode-session header on OpenCode endpoints OpenCode Go/Zen (opencode.ai) enforces an x-opencode-session header from 2026-09-06 and uses it for prompt-cache routing. Add an "opencode" session-affinity mode detected by baseUrl host (covers zen/go/v1 and zen/v1, OpenAI-completions and Anthropic transports). For opencode hosts the header is sent whenever a stable session id is available, without requiring the sendSessionAffinityHeaders opt-in; the flag-gated generic modes are unchanged. Fixes #137165 * fix: emit x-opencode-session regardless of cache retention on OpenCode endpoints Review follow-up: extract isOpencodeEndpoint (trailing-dot safe, parse guarded) shared by both transports; the compliance header now survives cacheRetention none; add Anthropic zen/go and OpenAI zen/v1 regression coverage. * fix(opencode): identify conversations across all model transports * fix(opencode): cover managed transports and preserve configured identity * fix(google): merge model and stream headers case-insensitively * test(opencode): use transport-specific model fixtures --------- Co-authored-by: Ghilteras Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com> --- docs/providers/opencode.md | 5 + packages/ai/src/providers/anthropic.test.ts | 22 ++++ packages/ai/src/providers/anthropic.ts | 3 +- .../src/providers/google-client-auth.test.ts | 14 +++ packages/ai/src/providers/google.ts | 11 +- .../openai-completions.compat.test.ts | 35 ++++-- .../ai/src/providers/openai-completions.ts | 10 +- .../ai/src/providers/openai-responses.test.ts | 14 +++ packages/ai/src/providers/openai-responses.ts | 9 +- .../transports/anthropic-transport-stream.ts | 10 +- .../openai-completions-transport.ts | 13 ++- .../src/transports/openai-transport-params.ts | 5 +- ...provider-transport-session-headers.test.ts | 103 ++++++++++++++++++ .../transports/provider-transport-stream.ts | 13 ++- .../src/transports/session-affinity.test.ts | 62 +++++++++++ .../ai/src/transports/session-affinity.ts | 28 +++++ 16 files changed, 328 insertions(+), 29 deletions(-) create mode 100644 packages/ai/src/transports/provider-transport-session-headers.test.ts create mode 100644 packages/ai/src/transports/session-affinity.test.ts create mode 100644 packages/ai/src/transports/session-affinity.ts diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md index 8407b909f595..1ff48c87c126 100644 --- a/docs/providers/opencode.md +++ b/docs/providers/opencode.md @@ -18,6 +18,11 @@ alias `OPENCODE_ZEN_API_KEY`). Go still requires its own paid subscription; having a Zen key does not by itself grant Go access. OpenClaw keeps the runtime provider ids split so upstream per-model routing stays correct. +OpenClaw sends a stable `x-opencode-session` conversation header on requests to +`https://opencode.ai` across the Anthropic, Gemini, OpenAI Chat Completions, and +OpenAI Responses transports. This header remains enabled when prompt caching is +disabled. Direct SDK callers should supply `sessionId` in their stream options. + ## Getting started diff --git a/packages/ai/src/providers/anthropic.test.ts b/packages/ai/src/providers/anthropic.test.ts index bc4ffb2ca6cf..464e00b86c63 100644 --- a/packages/ai/src/providers/anthropic.test.ts +++ b/packages/ai/src/providers/anthropic.test.ts @@ -273,6 +273,28 @@ describe("Anthropic provider", () => { } }); + it.each(["none", "short", "long"] as const)( + "sends the OpenCode session header with %s cache retention", + async (cacheRetention) => { + streamAnthropic( + makeAnthropicModel({ baseUrl: "https://opencode.ai/zen/go" }), + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { apiKey: "sk-ant-provider", sessionId: "session-123", cacheRetention }, + ); + await vi.waitFor(() => expect(anthropicMockState.configs).toHaveLength(1)); + const config = anthropicMockState.configs[0] as { + defaultHeaders?: Record; + }; + expect( + Object.fromEntries( + Object.entries(config.defaultHeaders ?? {}).filter( + ([key]) => key.startsWith("x-") || key === "session_id", + ), + ), + ).toEqual({ "x-opencode-session": "session-123" }); + }, + ); + it("puts Claude subscription billing identity first for OAuth requests", async () => { const { payload: capturedPayload, result } = await captureSimpleAnthropicPayload( {}, diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 628caa6e3da5..360495a42690 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -29,6 +29,7 @@ import { resolveAnthropicContextManagementBetaHeader, } from "../transports/anthropic-payload-policy.js"; import { consumeAnthropicStream } from "../transports/anthropic-stream-reducer.js"; +import { resolveOpencodeSessionHeaders } from "../transports/session-affinity.js"; import { assignTransportErrorDetails, finalizeTransportStream, @@ -258,7 +259,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicComp requestOptions?.thinkingEnabled === true, requestOptions?.interleavedThinking ?? true, shouldUseFineGrainedToolStreamingBeta(model, requestContext), - requestOptions?.headers, + resolveOpencodeSessionHeaders(model, requestOptions), copilotDynamicHeaders, cacheSessionId, ); diff --git a/packages/ai/src/providers/google-client-auth.test.ts b/packages/ai/src/providers/google-client-auth.test.ts index 599dfe0dea6d..49e1d9a3bb33 100644 --- a/packages/ai/src/providers/google-client-auth.test.ts +++ b/packages/ai/src/providers/google-client-auth.test.ts @@ -70,6 +70,20 @@ describe("Google SDK construction auth", () => { configureAiTransportHost({}); }); + it.each(["none", "short", "long"] as const)( + "identifies OpenCode conversations with %s cache retention", + async (cacheRetention) => { + await streamGoogle({ ...googleModel(), baseUrl: "https://opencode.ai/zen/v1" }, context, { + apiKey: "test", + sessionId: "conversation-123", + cacheRetention, + }).result(); + expect(googleMockState.configs[0]).toMatchObject({ + httpOptions: { headers: { "x-opencode-session": "conversation-123" } }, + }); + }, + ); + it("unwraps Google API-key sentinels immediately before client construction", async () => { const buildModelFetch = vi.fn(); configureAiTransportHost({ diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index 83af236ae34f..5626bc18368f 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -2,6 +2,8 @@ import { type GenerateContentParameters, GoogleGenAI } from "@google/genai"; import { getEnvApiKey } from "../env-api-keys.js"; import { getAiTransportHost, resolveAiTransportHeaderSentinels } from "../host.js"; +import { resolveOpencodeSessionHeaders } from "../transports/session-affinity.js"; +import { mergeTransportHeaders } from "../transports/transport-stream-shared.js"; import type { Context, Model, SimpleStreamOptions, StreamFunction } from "../types.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { @@ -33,7 +35,7 @@ export const streamGoogle: StreamFunction<"google-generative-ai", GoogleOptions> options, createClient: () => { const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; - return createClient(model, apiKey, options?.headers); + return createClient(model, apiKey, resolveOpencodeSessionHeaders(model, options)); }, buildParams: () => buildParams(model, context, options), nextToolCallId: (name) => `${name}_${Date.now()}_${++toolCallCounter}`, @@ -74,10 +76,9 @@ function createClient( httpOptions.apiVersion = ""; // baseUrl already includes version path, don't append } if (model.headers || optionsHeaders) { - httpOptions.headers = resolveAiTransportHeaderSentinels({ - ...model.headers, - ...optionsHeaders, - }); + httpOptions.headers = resolveAiTransportHeaderSentinels( + mergeTransportHeaders(model.headers, optionsHeaders), + ); } // @google/genai exposes RequestInit options but no custom fetch; unwrap at construction. diff --git a/packages/ai/src/providers/openai-completions.compat.test.ts b/packages/ai/src/providers/openai-completions.compat.test.ts index 1fe45fb3948a..5bfa431fb531 100644 --- a/packages/ai/src/providers/openai-completions.compat.test.ts +++ b/packages/ai/src/providers/openai-completions.compat.test.ts @@ -912,17 +912,32 @@ describe("OpenAI-compatible completions compatibility", () => { }), expectedHeaders: { "x-session-id": "session-123" }, }, - ])("sends exact $name session-affinity headers", async ({ model, expectedHeaders }) => { - await streamOpenAICompletions(model, context, { - apiKey: "test", - sessionId: "session-123", - }).result(); + { + name: "OpenCode Go without caching", + cacheRetention: "none" as const, + model: createModel({ baseUrl: "https://opencode.ai/zen/go/v1" }), + expectedHeaders: { "x-opencode-session": "session-123" }, + }, + { + name: "OpenCode Zen", + model: createModel({ baseUrl: "https://opencode.ai/zen/v1" }), + expectedHeaders: { "x-opencode-session": "session-123" }, + }, + ])( + "sends exact $name session-affinity headers", + async ({ model, expectedHeaders, ...testCase }) => { + await streamOpenAICompletions(model, context, { + apiKey: "test", + sessionId: "session-123", + ...testCase, + }).result(); - const clientOptions = mockOpenAI.clientOptions[0] as { - defaultHeaders?: Record; - }; - expect(clientOptions.defaultHeaders).toEqual(expectedHeaders); - }); + const clientOptions = mockOpenAI.clientOptions[0] as { + defaultHeaders?: Record; + }; + expect(clientOptions.defaultHeaders).toEqual(expectedHeaders); + }, + ); it("retains replayed Z.AI thinking when reasoning is enabled", async () => { let payload: unknown; diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 4b5fb4a73b82..33f2457146eb 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -23,6 +23,7 @@ import { isOpenAICompletionsThinkingEnabled, resolveOpenAIClientBaseUrl, } from "../transports/openai-transport-shared.js"; +import { resolveOpencodeSessionHeaders } from "../transports/session-affinity.js"; import { assignTransportErrorDetails, transportAbortError, @@ -122,7 +123,14 @@ export const streamOpenAICompletions: StreamFunction< ); const cacheRetention = resolveCacheRetention(options?.cacheRetention); const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; - const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, compat); + const client = createClient( + model, + context, + apiKey, + resolveOpencodeSessionHeaders(model, options), + cacheSessionId, + compat, + ); let params = buildParams(model, context, options, compat, cacheRetention); const nextParams = await options?.onPayload?.(params, model); if (nextParams !== undefined) { diff --git a/packages/ai/src/providers/openai-responses.test.ts b/packages/ai/src/providers/openai-responses.test.ts index 9b3282d88035..0abdbd359226 100644 --- a/packages/ai/src/providers/openai-responses.test.ts +++ b/packages/ai/src/providers/openai-responses.test.ts @@ -56,6 +56,20 @@ describe("OpenAI Responses provider", () => { configureAiTransportHost({}); }); + it.each(["none", "short", "long"] as const)( + "identifies OpenCode conversations with %s cache retention", + async (cacheRetention) => { + await streamOpenAIResponses(model({ baseUrl: "https://opencode.ai/zen/v1" }), context, { + apiKey: "test", + sessionId: "conversation-123", + cacheRetention, + }).result(); + expect(openAiMockState.configs[0]).toMatchObject({ + defaultHeaders: { "x-opencode-session": "conversation-123" }, + }); + }, + ); + it("constructs the SDK client with the host guarded fetch", async () => { const hostFetch: typeof fetch = async () => new Response(null, { status: 500 }); configureAiTransportHost({ buildModelFetch: () => hostFetch }); diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index b27194adf742..207703fade39 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -7,6 +7,7 @@ import type { BaseOpenAIStreamOptions } from "../provider-options.js"; import type { OpenAIResponsesReplayMode } from "../transports/openai-responses-compaction-replay.js"; import type { OpenAIResponsesRequestParams } from "../transports/openai-responses-contracts.js"; import { resolveOpenAIClientBaseUrl } from "../transports/openai-transport-shared.js"; +import { resolveOpencodeSessionHeaders } from "../transports/session-affinity.js"; import type { Context, Model, @@ -80,7 +81,13 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIRes const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; const cacheRetention = resolveCacheRetention(options?.cacheRetention); const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; - return createClient(model, context, apiKey, options?.headers, cacheSessionId); + return createClient( + model, + context, + apiKey, + resolveOpencodeSessionHeaders(model, options), + cacheSessionId, + ); }, buildParams: (_requestModel, replayMode) => buildParams(model, context, options, replayMode), processStreamOptions: { diff --git a/packages/ai/src/transports/anthropic-transport-stream.ts b/packages/ai/src/transports/anthropic-transport-stream.ts index 55eab84295f4..c4c0439b032b 100644 --- a/packages/ai/src/transports/anthropic-transport-stream.ts +++ b/packages/ai/src/transports/anthropic-transport-stream.ts @@ -69,6 +69,7 @@ import { resolveProviderEndpoint, transformTransportMessages, } from "./host-policy.js"; +import { resolveOpencodeSessionHeaders } from "./session-affinity.js"; import { copyProviderAcceptanceObserver, createEmptyTransportUsage, @@ -440,6 +441,7 @@ function createAnthropicTransportClient(params: { options: AnthropicTransportOptions | undefined; }) { const { model, context, apiKey, options } = params; + const optionHeaders = resolveOpencodeSessionHeaders(model, options); const needsInterleavedBeta = (options?.interleavedThinking ?? true) && !supportsClaudeAdaptiveThinking(model); // Kimi's Anthropic thinking SSE is already well-formed for this parser, but @@ -463,7 +465,7 @@ function createAnthropicTransportClient(params: { }, model.headers, getAiTransportHost().buildCopilotDynamicHeaders(context.messages), - options?.headers, + optionHeaders, ), fetch, }), @@ -484,7 +486,7 @@ function createAnthropicTransportClient(params: { ...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}), }, omitFoundryBearerCredentialHeaders(model.headers), - options?.headers, + optionHeaders, ), fetch, }), @@ -511,7 +513,7 @@ function createAnthropicTransportClient(params: { "x-app": "cli", }, model.headers, - options?.headers, + optionHeaders, ), fetch, }), @@ -529,7 +531,7 @@ function createAnthropicTransportClient(params: { ...(betaHeader ? { "anthropic-beta": betaHeader } : {}), }, model.headers, - options?.headers, + optionHeaders, ); return { client: createAnthropicMessagesClient({ diff --git a/packages/ai/src/transports/openai-completions-transport.ts b/packages/ai/src/transports/openai-completions-transport.ts index 036efa12072c..cf6cae87b13f 100644 --- a/packages/ai/src/transports/openai-completions-transport.ts +++ b/packages/ai/src/transports/openai-completions-transport.ts @@ -36,6 +36,7 @@ import { type MutableAssistantOutput, type OpenAIModeModel, } from "./openai-transport-shared.js"; +import { resolveOpencodeSessionHeaders } from "./session-affinity.js"; import { createWritableTransportEventStream, failTransportStream, @@ -229,9 +230,15 @@ export function createOpenAICompletionsTransportStreamFn(): StreamFn { statusText: response.statusText, }); }; - const client = createOpenAICompletionsClient(model, context, apiKey, options?.headers, { - fetch: doneDetectingFetch, - }); + const client = createOpenAICompletionsClient( + model, + context, + apiKey, + resolveOpencodeSessionHeaders(model, options), + { + fetch: doneDetectingFetch, + }, + ); let params = buildOpenAICompletionsParams( model as OpenAIModeModel, context, diff --git a/packages/ai/src/transports/openai-transport-params.ts b/packages/ai/src/transports/openai-transport-params.ts index f571c0cb1a88..b8bec9427ad1 100644 --- a/packages/ai/src/transports/openai-transport-params.ts +++ b/packages/ai/src/transports/openai-transport-params.ts @@ -13,6 +13,7 @@ import { resolveModelRequestTimeoutMs, resolveProviderRequestPolicyConfig } from import { resolveOpenAICompletionsCompat } from "./openai-completions-compat.js"; import { resolveOpenAIReasoningEffortMap } from "./openai-reasoning-compat.js"; import type { OpenAIModeModel } from "./openai-transport-shared.js"; +import { resolveOpencodeSessionHeaders } from "./session-affinity.js"; import { isCodeModeModelVisibleToolName, sha256Hex } from "./transport-utils.js"; const MAX_OPENAI_STRICT_TOOL_DOWNGRADE_DIAGNOSTIC_KEYS = 256; @@ -382,7 +383,9 @@ export function buildOpenAIClientHeaders( // (companion/btw effects sessions) 400 without this clamp. resolvedHeaders.session_id = clampOpenAIPromptCacheKey(sessionId) ?? sessionId; } - return resolvedHeaders; + return ( + resolveOpencodeSessionHeaders(model, { sessionId, headers: resolvedHeaders }) ?? resolvedHeaders + ); } function resolveOpenAISdkTimeoutMs(model: Model, timeoutMs?: number): number | undefined { diff --git a/packages/ai/src/transports/provider-transport-session-headers.test.ts b/packages/ai/src/transports/provider-transport-session-headers.test.ts new file mode 100644 index 000000000000..506c01319c54 --- /dev/null +++ b/packages/ai/src/transports/provider-transport-session-headers.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { configureAiTransportHost, getAiTransportHost } from "../host.js"; +import { streamSimpleGoogle } from "../providers/google.js"; +import type { Model } from "../types.js"; +import { createBoundaryAwareStreamFnForModel } from "./provider-transport-stream.js"; + +const initialHost = getAiTransportHost(); + +afterEach(() => { + configureAiTransportHost(initialHost); + vi.unstubAllGlobals(); +}); + +describe("managed OpenCode conversation headers at fetch egress", () => { + it.each([ + "openai-completions", + "openai-responses", + "anthropic-messages", + "google-generative-ai", + ] as const)("preserves conversation identity through %s", async (api) => { + const requests: Request[] = []; + const captureFetch: typeof fetch = async (input, init) => { + requests.push(new Request(input, init)); + return new Response(JSON.stringify({ error: { message: "request captured" } }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + }; + vi.stubGlobal("fetch", captureFetch); + configureAiTransportHost({ + ...initialHost, + buildModelFetch: () => captureFetch, + plugin: { + ...initialHost.plugin, + resolveProviderStream: () => (model, context, options) => { + // The plugin boundary must receive identity before any SDK adapter can add it. + if (!model.headers?.["X-OpenCode-Session"]) { + expect(options?.headers?.["x-opencode-session"]).toBe("conversation-a"); + } + return streamSimpleGoogle( + { ...model, api: "google-generative-ai", compat: undefined }, + context, + options, + ); + }, + }, + }); + const baseModel = { + id: "test-model", + name: "Test model", + api, + provider: "opencode", + baseUrl: + api === "anthropic-messages" ? "https://opencode.ai/zen" : "https://opencode.ai/zen/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 128, + } satisfies Model; + + for (const testCase of [ + { cacheRetention: "none", expected: "conversation-a" }, + { cacheRetention: "short", expected: "conversation-a" }, + { + cacheRetention: "none", + modelHeaders: { "X-OpenCode-Session": "model-session" }, + expected: "model-session", + }, + { + cacheRetention: "none", + modelHeaders: { "X-OpenCode-Session": "model-session" }, + headers: { "x-opencode-session": "stream-session" }, + expected: "stream-session", + }, + ] as const) { + const model = { + ...baseModel, + headers: "modelHeaders" in testCase ? testCase.modelHeaders : undefined, + }; + const streamFn = createBoundaryAwareStreamFnForModel(model); + if (!streamFn) { + throw new Error(`No managed transport for ${api}`); + } + requests.length = 0; + const stream = await streamFn( + model, + { + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }, + { + apiKey: "test-key", + sessionId: "conversation-a", + cacheRetention: testCase.cacheRetention, + headers: "headers" in testCase ? testCase.headers : undefined, + }, + ); + await stream.result(); + expect(requests).toHaveLength(1); + expect(requests[0]?.headers.get("x-opencode-session")).toBe(testCase.expected); + } + }); +}); diff --git a/packages/ai/src/transports/provider-transport-stream.ts b/packages/ai/src/transports/provider-transport-stream.ts index c489b209d769..409080dba716 100644 --- a/packages/ai/src/transports/provider-transport-stream.ts +++ b/packages/ai/src/transports/provider-transport-stream.ts @@ -12,6 +12,7 @@ import { createAzureOpenAIResponsesTransportStreamFn, createOpenAIResponsesTransportStreamFn, } from "./openai-responses-transport.js"; +import { resolveOpencodeSessionHeaders } from "./session-affinity.js"; const SUPPORTED_TRANSPORT_APIS = new Set([ "openai-responses", @@ -39,7 +40,7 @@ function createProviderOwnedGoogleTransportStreamFn( model: Model, ctx?: ProviderTransportStreamContext, ): StreamFn | undefined { - return ( + const streamFn = getAiTransportHost().plugin.resolveProviderStream({ provider: model.provider, config: ctx?.cfg, @@ -68,8 +69,14 @@ function createProviderOwnedGoogleTransportStreamFn( model, }, }) ?? - undefined - ); + undefined; + return streamFn + ? (requestModel, context, options) => + streamFn(requestModel, context, { + ...options, + headers: resolveOpencodeSessionHeaders(requestModel, options), + }) + : undefined; } function createSupportedTransportStreamFn( diff --git a/packages/ai/src/transports/session-affinity.test.ts b/packages/ai/src/transports/session-affinity.test.ts new file mode 100644 index 000000000000..3a3179944f5e --- /dev/null +++ b/packages/ai/src/transports/session-affinity.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { resolveOpencodeSessionHeaders } from "./session-affinity.js"; + +describe("OpenCode conversation headers", () => { + it.each([ + "https://proxy.example/zen/v1", + "https://opencode.ai.example/zen/v1", + "https://opencode.ai@proxy.example/zen/v1", + "https://unrelated.opencode.ai/zen/v1", + "http://opencode.ai/zen/v1", + "not a URL", + ])("does not send conversation identity to %s", (baseUrl) => { + const headers = { "X-Custom": "keep" }; + expect(resolveOpencodeSessionHeaders({ baseUrl }, { sessionId: "conversation", headers })).toBe( + headers, + ); + }); + + it("keeps conversation identity stable across requests and distinct across conversations", () => { + const baseUrl = "https://OPENCODE.AI./zen/go/v1"; + const first = resolveOpencodeSessionHeaders({ baseUrl }, { sessionId: "conversation-a" }); + expect(first).toEqual({ "x-opencode-session": "conversation-a" }); + expect(resolveOpencodeSessionHeaders({ baseUrl }, { sessionId: "conversation-a" })).toEqual( + first, + ); + expect(resolveOpencodeSessionHeaders({ baseUrl }, { sessionId: "conversation-b" })).toEqual({ + "x-opencode-session": "conversation-b", + }); + }); + + it("preserves an explicit header case-insensitively and does not mutate caller headers", () => { + const headers = { "X-OpenCode-Session": "configured", "X-Custom": "keep" }; + expect( + resolveOpencodeSessionHeaders( + { baseUrl: "https://opencode.ai/zen/v1" }, + { + sessionId: "conversation", + headers, + }, + ), + ).toBe(headers); + expect(headers).toEqual({ "X-OpenCode-Session": "configured", "X-Custom": "keep" }); + }); + + it("preserves explicit model-level conversation identity", () => { + const model = { + baseUrl: "https://opencode.ai/zen/v1", + headers: { "X-OpenCode-Session": "model-session" }, + }; + const headers = { "X-Custom": "keep" }; + expect(resolveOpencodeSessionHeaders(model, { sessionId: "conversation", headers })).toBe( + headers, + ); + expect(resolveOpencodeSessionHeaders(model, { sessionId: "conversation" })).toBeUndefined(); + }); + + it("does not invent a shared conversation for sessionless requests", () => { + expect( + resolveOpencodeSessionHeaders({ baseUrl: "https://opencode.ai/zen/v1" }), + ).toBeUndefined(); + }); +}); diff --git a/packages/ai/src/transports/session-affinity.ts b/packages/ai/src/transports/session-affinity.ts new file mode 100644 index 000000000000..84e7e4ef8520 --- /dev/null +++ b/packages/ai/src/transports/session-affinity.ts @@ -0,0 +1,28 @@ +import type { Model, StreamOptions } from "../types.js"; + +function isOpencodeEndpoint(baseUrl: string): boolean { + try { + const url = new URL(baseUrl); + return url.protocol === "https:" && url.hostname.replace(/\.$/, "") === "opencode.ai"; + } catch { + return false; + } +} + +/** Required conversation identity is independent of optional prompt caching. */ +export function resolveOpencodeSessionHeaders( + model: Pick, + options?: Pick, +): Record | undefined { + if (!options?.sessionId || !isOpencodeEndpoint(model.baseUrl)) { + return options?.headers; + } + if ( + [model.headers, options.headers].some((headers) => + Object.keys(headers ?? {}).some((name) => name.toLowerCase() === "x-opencode-session"), + ) + ) { + return options.headers; + } + return { ...options.headers, "x-opencode-session": options.sessionId }; +}