Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];

Expand Down
1 change: 1 addition & 0 deletions packages/app/src/components/onboarding/steps/AIPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];

Expand Down
1 change: 1 addition & 0 deletions packages/app/src/components/settings/AISettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
Expand Down
53 changes: 53 additions & 0 deletions packages/core/src/ai/__tests__/llm-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>[] = [];
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/ai/agents/reading-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -818,6 +820,7 @@ export async function* streamReadingAgent(
memorySummary,
getAvailableTools,
signal,
sessionId,
toolTimeoutMs = DEFAULT_TOOL_TIMEOUT_MS,
} = options;

Expand Down Expand Up @@ -863,6 +866,7 @@ export async function* streamReadingAgent(
maxTokens: aiConfig.maxTokens,
streaming: true,
deepThinking,
sessionId,
});

// Check abort after async operation
Expand Down
55 changes: 53 additions & 2 deletions packages/core/src/ai/llm-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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): {
Expand Down Expand Up @@ -581,6 +631,7 @@ export async function createChatModel(
maxTokens: options.maxTokens ?? config.maxTokens,
streaming: options.streaming,
deepThinking: options.deepThinking,
sessionId: options.sessionId,
});
}

Expand All @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/ai/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export class StreamingChat {
memorySummary: options.thread.memorySummary,
getAvailableTools: options.getAvailableTools,
signal,
sessionId: options.thread.id,
},
userInput,
history,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export type AIProviderType =
| "mistral"
| "perplexity"
| "aihubmix"
| "opencode"
| "custom";

export interface AIEndpoint {
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,14 @@ export const PROVIDER_CONFIGS: Record<string, ProviderConfig> = {
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)",
Expand Down Expand Up @@ -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";
}
Expand Down