mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
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:
parent
fb16bc4a98
commit
b53c4e95ca
16 changed files with 328 additions and 29 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
62
packages/ai/src/transports/session-affinity.test.ts
Normal file
62
packages/ai/src/transports/session-affinity.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
28
packages/ai/src/transports/session-affinity.ts
Normal file
28
packages/ai/src/transports/session-affinity.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue