From 8f9f31d188f8555d71e77d50fe82c717ae3b791a Mon Sep 17 00:00:00 2001 From: ofyyyo <2582741+ofyyyo@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:40:23 -0400 Subject: [PATCH] feat(ai): add OpenCode Go provider - register the opencode provider (https://opencode.ai/zen/go/v1) in provider configs, types and desktop/mobile provider pickers - identify the client with a ReadAny user agent - send a stable x-opencode-session per conversation (thread id), falling back to a per-request id for connection tests and model listing --- .../components/onboarding/steps/AIPage.tsx | 1 + .../screens/settings/ai/EndpointEditor.tsx | 1 + .../components/onboarding/steps/AIPage.tsx | 1 + .../src/components/settings/AISettings.tsx | 1 + .../src/ai/__tests__/llm-provider.test.ts | 53 ++++++++++++++++++ packages/core/src/ai/agents/reading-agent.ts | 4 ++ packages/core/src/ai/llm-provider.ts | 55 ++++++++++++++++++- packages/core/src/ai/streaming.ts | 1 + packages/core/src/types/chat.ts | 1 + packages/core/src/utils/api.ts | 9 +++ 10 files changed, 125 insertions(+), 2 deletions(-) diff --git a/packages/app-expo/src/components/onboarding/steps/AIPage.tsx b/packages/app-expo/src/components/onboarding/steps/AIPage.tsx index d16844d7b..8bebedb2e 100644 --- a/packages/app-expo/src/components/onboarding/steps/AIPage.tsx +++ b/packages/app-expo/src/components/onboarding/steps/AIPage.tsx @@ -37,6 +37,7 @@ const PROVIDER_OPTIONS: { id: AIProviderType; name: string }[] = [ { id: "lmstudio", name: "LM Studio" }, { id: "openrouter", name: "OpenRouter" }, { id: "siliconflow", name: "SiliconFlow" }, + { id: "opencode", name: "OpenCode Go" }, { id: "custom", name: "Custom" }, ]; diff --git a/packages/app-expo/src/screens/settings/ai/EndpointEditor.tsx b/packages/app-expo/src/screens/settings/ai/EndpointEditor.tsx index 07b5e6472..b8d476c7d 100644 --- a/packages/app-expo/src/screens/settings/ai/EndpointEditor.tsx +++ b/packages/app-expo/src/screens/settings/ai/EndpointEditor.tsx @@ -36,6 +36,7 @@ const PROVIDERS: { id: AIProviderType; label: string }[] = [ { id: "moonshot", label: "Moonshot" }, { id: "zhipu", label: "智谱 GLM" }, { id: "aliyun", label: "阿里云通义" }, + { id: "opencode", label: "OpenCode Go" }, { id: "custom", label: "Custom" }, ]; diff --git a/packages/app/src/components/onboarding/steps/AIPage.tsx b/packages/app/src/components/onboarding/steps/AIPage.tsx index 6e31f59d8..210a2cba2 100644 --- a/packages/app/src/components/onboarding/steps/AIPage.tsx +++ b/packages/app/src/components/onboarding/steps/AIPage.tsx @@ -29,6 +29,7 @@ const PROVIDER_OPTIONS: { id: AIProviderType; name: string }[] = [ { id: "lmstudio", name: "LM Studio" }, { id: "openrouter", name: "OpenRouter" }, { id: "siliconflow", name: "SiliconFlow" }, + { id: "opencode", name: "OpenCode Go" }, { id: "custom", name: "Custom" }, ]; diff --git a/packages/app/src/components/settings/AISettings.tsx b/packages/app/src/components/settings/AISettings.tsx index f406c09af..ad411b62a 100644 --- a/packages/app/src/components/settings/AISettings.tsx +++ b/packages/app/src/components/settings/AISettings.tsx @@ -45,6 +45,7 @@ function useProviderOptions(): { value: AIProviderType; label: string }[] { { value: "moonshot", label: "Moonshot (Kimi)" }, { value: "zhipu", label: t("settings.ai_provider_zhipu") }, { value: "aliyun", label: t("settings.ai_provider_aliyun") }, + { value: "opencode", label: "OpenCode Go" }, { value: "custom", label: t("settings.ai_provider_custom") }, ], [t]); } diff --git a/packages/core/src/ai/__tests__/llm-provider.test.ts b/packages/core/src/ai/__tests__/llm-provider.test.ts index 459532bcd..6ceed4796 100644 --- a/packages/core/src/ai/__tests__/llm-provider.test.ts +++ b/packages/core/src/ai/__tests__/llm-provider.test.ts @@ -142,6 +142,59 @@ describe("getEndpointFetch Gemini thought signatures", () => { }); }); +describe("getEndpointFetch OpenCode Go headers", () => { + it("identifies the client and forwards the conversation session id", async () => { + let capturedHeaders: Headers | undefined; + globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + capturedHeaders = input instanceof Request ? input.headers : new Headers(init?.headers); + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const endpoint = makeEndpoint({ + provider: "opencode", + baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", + useExactRequestUrl: false, + }); + const endpointFetch = getEndpointFetch(endpoint, "some-model", "thread-123"); + await endpointFetch(endpoint.baseUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ messages: [] }), + }); + + expect(capturedHeaders?.get("user-agent")).toBe("ReadAny/1.0"); + expect(capturedHeaders?.get("x-opencode-session")).toBe("thread-123"); + }); + + it("sends a fallback session id for non-conversation requests", async () => { + let capturedHeaders: Headers | undefined; + globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + capturedHeaders = input instanceof Request ? input.headers : new Headers(init?.headers); + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const endpoint = makeEndpoint({ + provider: "opencode", + baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", + useExactRequestUrl: false, + }); + const endpointFetch = getEndpointFetch(endpoint, "some-model"); + await endpointFetch(endpoint.baseUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ messages: [] }), + }); + + expect(capturedHeaders?.get("x-opencode-session")).toMatch(/^readany-/); + }); +}); + describe("getEndpointFetch custom endpoint compatibility", () => { it("retries without token limit when a custom endpoint rejects max_completion_tokens over 100", async () => { const requestBodies: Record[] = []; diff --git a/packages/core/src/ai/agents/reading-agent.ts b/packages/core/src/ai/agents/reading-agent.ts index 37bf5f654..89db7f219 100644 --- a/packages/core/src/ai/agents/reading-agent.ts +++ b/packages/core/src/ai/agents/reading-agent.ts @@ -592,6 +592,8 @@ export interface ReadingAgentOptions { }) => ToolDefinition[]; /** Abort signal for immediate cancellation */ signal?: AbortSignal; + /** Stable conversation/session id, forwarded to the provider (e.g. OpenCode Go routing). */ + sessionId?: string; /** Maximum time a single tool may run before returning an error result. */ toolTimeoutMs?: number; } @@ -818,6 +820,7 @@ export async function* streamReadingAgent( memorySummary, getAvailableTools, signal, + sessionId, toolTimeoutMs = DEFAULT_TOOL_TIMEOUT_MS, } = options; @@ -863,6 +866,7 @@ export async function* streamReadingAgent( maxTokens: aiConfig.maxTokens, streaming: true, deepThinking, + sessionId, }); // Check abort after async operation diff --git a/packages/core/src/ai/llm-provider.ts b/packages/core/src/ai/llm-provider.ts index 03b4ab2fd..2f4c78aa3 100644 --- a/packages/core/src/ai/llm-provider.ts +++ b/packages/core/src/ai/llm-provider.ts @@ -33,6 +33,42 @@ function shouldSanitizeCustomHeaders(endpoint: AIEndpoint): boolean { return endpoint.provider === "custom"; } +/** + * OpenCode Go monitors traffic and asks clients to identify themselves with a + * dedicated user agent (not a generic SDK/HTTP library name) and to send a + * stable session id per conversation so it can optimize routing/prompt caching. + */ +const OPENCODE_PROVIDER_ID = "opencode"; +const READANY_CLIENT_USER_AGENT = "ReadAny/1.0"; + +/** + * OpenCode Go expects the x-opencode-session header on every request, including + * non-conversation calls like connection tests and model listing. Fall back to a + * per-fetch-instance id when no conversation id was provided. + */ +function createOpencodeSessionId(): string { + return `readany-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +function applyOpencodeHeaders( + input: RequestInfo | URL, + init: RequestInit | undefined, + sessionId?: string, +): { input: RequestInfo | URL; init?: RequestInit } { + const headers = mergeRequestHeaders(input, init) ?? new Headers(); + headers.set("user-agent", READANY_CLIENT_USER_AGENT); + if (sessionId) headers.set("x-opencode-session", sessionId); + + if (isRequestLike(input)) { + return { + input: new Request(input, { headers }), + init: init ? { ...init, headers } : undefined, + }; + } + + return { input, init: { ...(init ?? {}), headers } }; +} + function mergeRequestHeaders(input: RequestInfo | URL, init?: RequestInit): Headers | undefined { const merged = new Headers(); let hasHeaders = false; @@ -375,9 +411,15 @@ async function patchGeminiThoughtSignatureRequest( }; } -export function getEndpointFetch(endpoint: AIEndpoint, model?: string): typeof globalThis.fetch { +export function getEndpointFetch( + endpoint: AIEndpoint, + model?: string, + sessionId?: string, +): typeof globalThis.fetch { const exactUrl = endpoint.useExactRequestUrl ? endpoint.baseUrl?.trim() : ""; const baseFetch = (_streamingFetch ?? globalThis.fetch).bind(globalThis); + const opencodeSessionId = + endpoint.provider === OPENCODE_PROVIDER_ID ? (sessionId ?? createOpencodeSessionId()) : undefined; return (async (input: RequestInfo | URL, init?: RequestInit) => { const finalInput = isRequestLike(input) @@ -403,6 +445,12 @@ export function getEndpointFetch(endpoint: AIEndpoint, model?: string): typeof g } } + if (endpoint.provider === OPENCODE_PROVIDER_ID) { + const withOpencodeHeaders = applyOpencodeHeaders(requestInput, requestInit, opencodeSessionId); + requestInput = withOpencodeHeaders.input; + requestInit = withOpencodeHeaders.init; + } + if ( requestMethod.toUpperCase() === "POST" && shouldPatchGeminiThoughtSignatures(endpoint, model, requestUrl) @@ -542,6 +590,8 @@ export interface LLMOptions { maxTokens?: number; streaming?: boolean; deepThinking?: boolean; + /** Stable conversation/session id (sent as x-opencode-session for OpenCode Go). */ + sessionId?: string; } export function resolveActiveEndpoint(config: AIConfig): { @@ -581,6 +631,7 @@ export async function createChatModel( maxTokens: options.maxTokens ?? config.maxTokens, streaming: options.streaming, deepThinking: options.deepThinking, + sessionId: options.sessionId, }); } @@ -601,7 +652,7 @@ export async function createChatModelFromEndpoint( const temperature = options.temperature ?? 0.7; const maxTokens = options.maxTokens ?? 8192; const streaming = options.streaming ?? true; - const endpointFetch = getEndpointFetch(endpoint, model); + const endpointFetch = getEndpointFetch(endpoint, model, options.sessionId); switch (endpoint.provider) { case "anthropic": { diff --git a/packages/core/src/ai/streaming.ts b/packages/core/src/ai/streaming.ts index dab64149b..a3110d0d6 100644 --- a/packages/core/src/ai/streaming.ts +++ b/packages/core/src/ai/streaming.ts @@ -113,6 +113,7 @@ export class StreamingChat { memorySummary: options.thread.memorySummary, getAvailableTools: options.getAvailableTools, signal, + sessionId: options.thread.id, }, userInput, history, diff --git a/packages/core/src/types/chat.ts b/packages/core/src/types/chat.ts index 22c1668ee..dd7acceb3 100644 --- a/packages/core/src/types/chat.ts +++ b/packages/core/src/types/chat.ts @@ -145,6 +145,7 @@ export type AIProviderType = | "mistral" | "perplexity" | "aihubmix" + | "opencode" | "custom"; export interface AIEndpoint { diff --git a/packages/core/src/utils/api.ts b/packages/core/src/utils/api.ts index 26d4d1142..46f09d3ec 100644 --- a/packages/core/src/utils/api.ts +++ b/packages/core/src/utils/api.ts @@ -201,6 +201,14 @@ export const PROVIDER_CONFIGS: Record = { placeholder: "https://aihubmix.com", keyPlaceholder: "sk-...", }, + opencode: { + id: "opencode", + name: "OpenCode Go", + defaultBaseUrl: "https://opencode.ai/zen/go/v1", + needsV1Suffix: false, + placeholder: "https://opencode.ai/zen/go/v1", + keyPlaceholder: "sk-...", + }, custom: { id: "custom", name: "Custom (OpenAI Compatible)", @@ -566,6 +574,7 @@ export function detectProviderFromUrl(url: string): string { if (urlLower.includes("mistral.ai")) return "mistral"; if (urlLower.includes("perplexity.ai")) return "perplexity"; if (urlLower.includes("aihubmix.com")) return "aihubmix"; + if (urlLower.includes("opencode.ai")) return "opencode"; return "custom"; }