mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da82657052 | ||
|
|
f4ede21fdd |
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;
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user