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 <Ghilteras@users.noreply.github.com>
Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
This commit is contained in:
Angelo Pantano 2026-09-06 10:42:40 -07:00 • committed by GitHub
parent fb16bc4a98
commit b53c4e95ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 328 additions and 29 deletions

View file

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

View file

@ -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<string, string | null>;
};
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(
{},

View file

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

View file

@ -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({

View file

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

View file

@ -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<string, string>;
};
expect(clientOptions.defaultHeaders).toEqual(expectedHeaders);
});
const clientOptions = mockOpenAI.clientOptions[0] as {
defaultHeaders?: Record<string, string>;
};
expect(clientOptions.defaultHeaders).toEqual(expectedHeaders);
},
);
it("retains replayed Z.AI thinking when reasoning is enabled", async () => {
let payload: unknown;

View file

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

View file

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

View file

@ -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: {

View file

@ -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({

View file

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

View file

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

View file

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

View file

@ -12,6 +12,7 @@ import {
createAzureOpenAIResponsesTransportStreamFn,
createOpenAIResponsesTransportStreamFn,
} from "./openai-responses-transport.js";
import { resolveOpencodeSessionHeaders } from "./session-affinity.js";
const SUPPORTED_TRANSPORT_APIS = new Set<Api>([
"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(

View file

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

View file

@ -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<Model, "baseUrl" | "headers">,
options?: Pick<StreamOptions, "sessionId" | "headers">,
): Record<string, string> | 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 };
}