Compare commits

...
Author SHA1 Message Date
bincxz da82657052 test(ai): verify provider headers at SDK request boundary 2026-09-12 10:27:43 +08:00
netcatty-bot f4ede21fdd fix(#3349): automated AI fix 2026-09-10 03:20:20 +00:00
5 changed files with 152 additions and 0 deletions

No files matched your search

@@ -247,6 +247,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
{
getOpenAIChatAssistantFields: () => continuationContext.openAIChatAssistantFields,
streamIdleTimeoutMs: responseIdleTimeoutMs,
chatSessionId: sessionId,
},
);
} catch (e) {
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
OPENCODE_SESSION_HEADER,
buildSdkRequestHeaders,
isOpencodeEndpoint,
} from "./providerRequestHeaders";
test("isOpencodeEndpoint matches opencode.ai hosts", () => {
assert.equal(isOpencodeEndpoint("https://opencode.ai/zen/go/v1"), true);
assert.equal(isOpencodeEndpoint("https://opencode.ai/zen/v1"), true);
assert.equal(isOpencodeEndpoint("https://gateway.opencode.ai/v1"), true);
assert.equal(isOpencodeEndpoint("https://opencode.ai"), true);
assert.equal(isOpencodeEndpoint(undefined), false);
assert.equal(isOpencodeEndpoint(""), false);
assert.equal(isOpencodeEndpoint("https://api.openai.com/v1"), false);
assert.equal(isOpencodeEndpoint("https://notopencode.ai/v1"), false);
assert.equal(isOpencodeEndpoint("http://localhost:11434/v1"), false);
assert.equal(isOpencodeEndpoint("not a url"), false);
});
test("buildSdkRequestHeaders passes customHeaders through verbatim", () => {
const headers = buildSdkRequestHeaders({
baseURL: "https://api.example.com/v1",
customHeaders: { "X-Custom": "a", Authorization: "Bearer x" },
});
assert.deepEqual(headers, { "X-Custom": "a", Authorization: "Bearer x" });
});
test("buildSdkRequestHeaders adds the OpenCode session header for opencode endpoints", () => {
const headers = buildSdkRequestHeaders(
{ baseURL: "https://opencode.ai/zen/go/v1" },
"chat-session-1",
);
assert.deepEqual(headers, { [OPENCODE_SESSION_HEADER]: "chat-session-1" });
});
test("buildSdkRequestHeaders adds no session header off opencode or without a session id", () => {
assert.deepEqual(
buildSdkRequestHeaders({ baseURL: "https://api.deepseek.com/v1" }, "chat-session-1"),
{},
);
assert.deepEqual(buildSdkRequestHeaders({ baseURL: "https://opencode.ai/zen/go/v1" }), {});
assert.deepEqual(buildSdkRequestHeaders({ baseURL: undefined }, "chat-session-1"), {});
});
test("buildSdkRequestHeaders lets a user-supplied session header win", () => {
const headers = buildSdkRequestHeaders(
{
baseURL: "https://opencode.ai/zen/go/v1",
customHeaders: { "X-OpenCode-Session": "my-own-session" },
},
"chat-session-1",
);
assert.deepEqual(headers, { "X-OpenCode-Session": "my-own-session" });
});
@@ -0,0 +1,43 @@
import type { ProviderConfig } from "./types";
/**
* OpenCode (Zen / Go, https://opencode.ai) requires a stable
* per-conversation session id on every request for routing and prompt-cache
* affinity. Without it the server rejects the call with HTTP 400:
* "Request is missing x-opencode-session and cannot be routed efficiently."
*/
export const OPENCODE_SESSION_HEADER = "x-opencode-session";
/** Match the opencode.ai API hosts (Zen `.../zen/v1`, Go `.../zen/go/v1`). */
export function isOpencodeEndpoint(baseURL: string | undefined): boolean {
if (!baseURL) return false;
try {
const hostname = new URL(baseURL).hostname;
return hostname === "opencode.ai" || hostname.endsWith(".opencode.ai");
} catch {
return false;
}
}
/**
* Build the extra SDK request headers for a provider config.
*
* - User-configured `customHeaders` always win verbatim (they were previously
* collected in settings but never applied).
* - OpenCode endpoints get an automatic `x-opencode-session` header carrying
* the chat session id, so OpenCode Go can route the conversation. A
* user-supplied session header takes precedence over the automatic one.
*/
export function buildSdkRequestHeaders(
config: Pick<ProviderConfig, "baseURL" | "customHeaders">,
chatSessionId?: string,
): Record<string, string> {
const headers: Record<string, string> = { ...(config.customHeaders ?? {}) };
const hasSessionHeader = Object.keys(headers).some(
(key) => key.toLowerCase() === OPENCODE_SESSION_HEADER,
);
if (!hasSessionHeader && chatSessionId && isOpencodeEndpoint(config.baseURL)) {
headers[OPENCODE_SESSION_HEADER] = chatSessionId;
}
return headers;
}
+7
View File
@@ -4,6 +4,7 @@ import { createGoogle } from '@ai-sdk/google';
import type { ProviderConfig, ProviderStyle } from '../types';
import { resolveOpenAIApi, resolveProviderStyle } from '../types';
import { normalizeAnthropicSdkBaseURL } from '../anthropicCompatBaseUrl';
import { buildSdkRequestHeaders } from '../providerRequestHeaders';
import { normalizeOllamaSdkBaseURL } from '../ollamaCompatBaseUrl';
export { normalizeOllamaSdkBaseURL };
@@ -19,6 +20,8 @@ import {
export interface ProviderRequestContext {
getOpenAIChatAssistantFields?: () => Array<OpenAIChatAssistantFields | undefined>;
streamIdleTimeoutMs?: number;
/** Stable per-conversation id; used e.g. for OpenCode's x-opencode-session header. */
chatSessionId?: string;
}
/**
@@ -669,6 +672,7 @@ export function createModelFromConfig(
const modelId = config.defaultModel || '';
const style = resolveProviderStyle(config);
const { baseURL, apiKey } = resolveProviderEndpoint(config, style, safeApiKey);
const sdkHeaders = buildSdkRequestHeaders(config, requestContext?.chatSessionId);
switch (style) {
case 'openai': {
@@ -676,6 +680,7 @@ export function createModelFromConfig(
apiKey,
baseURL,
fetch: customFetch,
headers: sdkHeaders,
});
// Chat Completions stays the default so OpenAI-compatible proxies keep
// working. Responses is opt-in for relays that cache better on /v1/responses.
@@ -689,6 +694,7 @@ export function createModelFromConfig(
apiKey,
baseURL,
fetch: customFetch,
headers: sdkHeaders,
})(modelId);
case 'google':
@@ -696,6 +702,7 @@ export function createModelFromConfig(
apiKey,
baseURL,
fetch: customFetch,
headers: sdkHeaders,
})(modelId);
default: {
+44
View File
@@ -236,3 +236,47 @@ test("resolveProviderEndpoint adds /v1 to a bare ollama.com Cloud host", () => {
"https://ollama.com/v1",
);
});
// Exercise the real SDK request builders through the Electron fetch boundary.
// A rejected request is sufficient here: headers are inspected before parsing.
test("SDK requests forward custom headers and scope OpenCode sessions", async (t) => {
const browserGlobal = globalThis as typeof globalThis & { window?: unknown };
const originalWindow = browserGlobal.window;
t.after(() => { browserGlobal.window = originalWindow; });
const requests: Array<{ url: string; headers: Headers }> = [];
browserGlobal.window = {
netcatty: {
aiFetch: async (url: string, _method: string, headers: Record<string, string>) => {
requests.push({ url, headers: new Headers(headers) });
return { ok: false, status: 400, data: '{"error":{"message":"test response"}}' };
},
},
};
for (const style of ["openai", "anthropic", "google"] as const) {
for (const openaiApi of style === "openai" ? ["chat", "responses"] as const : ["chat"] as const) {
for (const sessionId of ["conversation-a", "conversation-a", "conversation-b"]) {
const model = createModelFromConfig(makeConfig({
style,
openaiApi,
apiKey: "test-key",
baseURL: "https://opencode.ai/zen/go/v1",
customHeaders: { "X-Test-Header": "configured" },
}), { chatSessionId: sessionId });
await assert.rejects(model.doGenerate({ prompt: [{ role: "user", content: [{ type: "text", text: "hello" }] }] }));
const request = requests.at(-1)!;
assert.equal(request.headers.get("x-opencode-session"), sessionId);
assert.equal(request.headers.get("x-test-header"), "configured");
}
}
}
for (const baseURL of ["https://opencode.ai/zen/go/v1", "https://example.test/v1"]) {
for (const customHeaders of [undefined, { "X-OpenCode-Session": "override" }]) {
const model = createModelFromConfig(makeConfig({ baseURL, apiKey: "test-key", customHeaders }), { chatSessionId: "automatic" });
await assert.rejects(model.doGenerate({ prompt: [{ role: "user", content: [{ type: "text", text: "hello" }] }] }));
assert.equal(requests.at(-1)!.headers.get("x-opencode-session"),
customHeaders ? "override" : baseURL.includes("opencode.ai") ? "automatic" : null);
}
}
assert.equal(requests.length, 16);
});