From e8a6d7e269535553bfb88119e7375fb53e42c26a Mon Sep 17 00:00:00 2001 From: jxngrx Date: Tue, 30 Jun 2026 00:04:16 +0530 Subject: [PATCH 1/2] feat(ai): add OpenCode Zen provider support and update related types and handlers --- server/ai/drivers/index.ts | 2 + server/ai/drivers/opencode.ts | 189 +++++++++++++++++++++++ server/ai/drivers/types.ts | 3 +- server/ai/handlers/credentials.ts | 1 + server/ai/handlers/models.ts | 2 +- server/ai/runtime/types.ts | 6 +- src/__tests__/ai/opencodeMapping.test.ts | 144 +++++++++++++++++ src/admin/ai/api.ts | 7 +- src/admin/pages/ai/tabs/ProvidersTab.tsx | 5 +- 9 files changed, 350 insertions(+), 9 deletions(-) create mode 100644 server/ai/drivers/opencode.ts create mode 100644 src/__tests__/ai/opencodeMapping.test.ts diff --git a/server/ai/drivers/index.ts b/server/ai/drivers/index.ts index fc2097e61..436aebaf3 100644 --- a/server/ai/drivers/index.ts +++ b/server/ai/drivers/index.ts @@ -12,12 +12,14 @@ import { anthropicDriver } from './anthropic' import { openaiDriver } from './openai' import { ollamaDriver } from './ollama' import { openrouterDriver } from './openrouter' +import { opencodeDriver } from './opencode' const DRIVERS: Record = { anthropic: anthropicDriver, openai: openaiDriver, ollama: ollamaDriver, openrouter: openrouterDriver, + opencode: opencodeDriver, } /** Returns the driver for a provider id, or throws if unknown. */ diff --git a/server/ai/drivers/opencode.ts b/server/ai/drivers/opencode.ts new file mode 100644 index 000000000..71ced263a --- /dev/null +++ b/server/ai/drivers/opencode.ts @@ -0,0 +1,189 @@ +/** + * OpenCode driver — direct HTTP against OpenCode Zen. + * + * Talks to `POST https://opencode.ai/zen/v1/chat/completions` with no SDK. + * OpenCode Zen exposes an OpenAI-compatible chat/completions API, so this + * driver reuses the same chat-completions mapping + SSE translator as Ollama. + * + * Tools are sent with their canonical TypeBox `inputSchema` as JSON Schema + * parameters directly — no Zod bridge. + */ + +import { Type, parseValue } from '@core/utils/typeboxHelpers' +import type { + AiAuthMode, + AiProviderId, + AiStreamEvent, + AiToolOutput, +} from '../runtime/types' +import type { + AiProvider, + AiProviderCapabilities, + AiProviderModel, + AiResolvedCredential, + AiStreamRequest, +} from './types' +import { runToolLoop, type ProviderAdapter } from './http/toolLoop' +import { + ChatCompletionsTurnTranslator, + mapChatHistory, + type ChatMessage, +} from './ollama' + +const SUPPORTED_AUTH_MODES: AiAuthMode[] = ['apiKey'] + +const OPENCODE_BASE_URL = 'https://opencode.ai/zen/v1' +const OPENCODE_CHAT_ENDPOINT = `${OPENCODE_BASE_URL}/chat/completions` +const OPENCODE_MODELS_ENDPOINT = `${OPENCODE_BASE_URL}/models` + +const DEFAULT_CAPABILITIES: AiProviderCapabilities = { + toolCalling: true, + visionInput: false, + promptCache: false, + streaming: true, +} + +const OpenCodeModelSchema = Type.Object( + { + id: Type.String(), + name: Type.Optional(Type.String()), + capabilities: Type.Optional( + Type.Object( + { + toolcall: Type.Optional(Type.Boolean()), + input: Type.Optional( + Type.Object({ image: Type.Optional(Type.Boolean()) }, { additionalProperties: true }), + ), + }, + { additionalProperties: true }, + ), + ), + limit: Type.Optional( + Type.Object({ context: Type.Optional(Type.Number()) }, { additionalProperties: true }), + ), + cost: Type.Optional( + Type.Object( + { input: Type.Optional(Type.Number()), output: Type.Optional(Type.Number()) }, + { additionalProperties: true }, + ), + ), + }, + { additionalProperties: true }, +) + +const OpenCodeModelsResponseSchema = Type.Object({ + data: Type.Optional(Type.Array(OpenCodeModelSchema)), +}) + +const opencodeAdapter: ProviderAdapter = { + label: 'OpenCode', + endpoint: OPENCODE_CHAT_ENDPOINT, + + buildHeaders(req) { + return { + Authorization: `Bearer ${req.credentials.apiKey!}`, + 'content-type': 'application/json', + } + }, + + mapHistory(req) { + return mapChatHistory(req.systemPrompt, req.messages) + }, + + buildRequestBody(messages, req) { + const body: Record = { + model: req.modelId, + messages: messages.flat(), + stream: true, + stream_options: { include_usage: true }, + } + if (req.tools.length > 0) { + body.tools = req.tools.map((t) => ({ + type: 'function', + function: { + name: t.name, + description: t.description, + parameters: t.inputSchema, + }, + })) + } + return body + }, + + buildToolResultMessage(results) { + return results.map((r) => ({ + role: 'tool' as const, + tool_call_id: r.id, + content: toolOutputToString(r.output), + })) + }, + + createTurnTranslator() { + return new ChatCompletionsTurnTranslator() + }, +} + +export const opencodeDriver: AiProvider = { + id: 'opencode' as AiProviderId, + label: 'OpenCode', + supportedAuthModes: SUPPORTED_AUTH_MODES, + + capabilities(_modelId: string) { + return DEFAULT_CAPABILITIES + }, + + async listModels(creds: AiResolvedCredential) { + if (creds.authMode !== 'apiKey' || !creds.apiKey) return [] + return fetchOpenCodeModels(creds) + }, + + async *stream(req: AiStreamRequest): AsyncIterable { + if (req.credentials.authMode !== 'apiKey' || !req.credentials.apiKey) { + yield { + type: 'error', + message: + 'OpenCode requires an API key. Add an API-key credential in /admin/ai/providers and pick it for the site default.', + } + return + } + yield* runToolLoop(opencodeAdapter, req) + }, +} + +async function fetchOpenCodeModels(creds: AiResolvedCredential): Promise { + const res = await fetch(OPENCODE_MODELS_ENDPOINT, { + headers: { Authorization: `Bearer ${creds.apiKey!}` }, + }) + if (!res.ok) { + throw new Error(`[ai/opencode] models request failed: ${res.status} ${res.statusText}`) + } + + const parsed = parseValue(OpenCodeModelsResponseSchema, await res.json()) + return (parsed.data ?? []).map((model) => { + const caps = model.capabilities + return { + id: model.id, + label: model.name ?? model.id, + catalogueSource: 'live' as const, + capabilities: { + toolCalling: caps?.toolcall ?? true, + visionInput: caps?.input?.image ?? false, + promptCache: false, + streaming: true, + }, + ...(typeof model.cost?.input === 'number' && typeof model.cost?.output === 'number' + ? { pricing: { inputPerMTok: model.cost.input, outputPerMTok: model.cost.output } } + : {}), + ...(typeof model.limit?.context === 'number' ? { contextWindow: model.limit.context } : {}), + } + }) +} + +function toolOutputToString(output: AiToolOutput): string { + if (!output.ok) return output.error ?? 'Tool call failed.' + const text = JSON.stringify(output.data ?? { ok: true }) + if (output.images && output.images.length > 0) { + return `${text}\n\n[${output.images.length} screenshot(s) omitted: this provider delivers tool results as text only.]` + } + return text +} diff --git a/server/ai/drivers/types.ts b/server/ai/drivers/types.ts index 58af3ecc9..b7443e001 100644 --- a/server/ai/drivers/types.ts +++ b/server/ai/drivers/types.ts @@ -168,7 +168,8 @@ export interface AiProvider { * anthropic → ['apiKey'] * openai → ['apiKey'] * openrouter → ['apiKey'] - * ollama → ['baseUrl'] + * opencode → ['apiKey'] + * ollama → ['baseUrl'] */ readonly supportedAuthModes: readonly AiAuthMode[] diff --git a/server/ai/handlers/credentials.ts b/server/ai/handlers/credentials.ts index d105541d0..9b906cf9c 100644 --- a/server/ai/handlers/credentials.ts +++ b/server/ai/handlers/credentials.ts @@ -35,6 +35,7 @@ const ProviderId = Type.Union([ Type.Literal('openai'), Type.Literal('ollama'), Type.Literal('openrouter'), + Type.Literal('opencode'), ]) const CreateBodySchema = Type.Union([ diff --git a/server/ai/handlers/models.ts b/server/ai/handlers/models.ts index 11384e100..b848dfc4b 100644 --- a/server/ai/handlers/models.ts +++ b/server/ai/handlers/models.ts @@ -19,7 +19,7 @@ import { getModelCatalogue, pricingKey } from '../pricing' import type { AiProviderModel } from '../drivers/types' import type { AiProviderId } from '../runtime/types' -const VALID_PROVIDERS: AiProviderId[] = ['anthropic', 'openai', 'ollama', 'openrouter'] +const VALID_PROVIDERS: AiProviderId[] = ['anthropic', 'openai', 'ollama', 'openrouter', 'opencode'] export function tryHandleAiModels( req: Request, diff --git a/server/ai/runtime/types.ts b/server/ai/runtime/types.ts index 29f90acca..3105fdf1c 100644 --- a/server/ai/runtime/types.ts +++ b/server/ai/runtime/types.ts @@ -24,11 +24,12 @@ export type { AiContentBlock, AiToolImage, AiToolOutput } from '@core/ai' // Provider identity + auth modes // --------------------------------------------------------------------------- -export type AiProviderId = 'anthropic' | 'openai' | 'ollama' | 'openrouter' +export type AiProviderId = 'anthropic' | 'openai' | 'ollama' | 'openrouter' | 'opencode' /** * Credential auth modes. * - * - `apiKey` — encrypted user-supplied key (Anthropic, OpenAI, OpenRouter). + * - `apiKey` — encrypted user-supplied key (Anthropic, OpenAI, OpenRouter, + * OpenCode Zen). * - `baseUrl` — OpenAI-compatible local endpoint (Ollama). Optional * bearer token may be stored alongside the URL. */ @@ -206,4 +207,3 @@ export interface AiBrowserBridge { // Aggregated usage — drivers report token counts so the handler can persist // per-message + per-conversation totals and compute cost from pricing.ts. // --------------------------------------------------------------------------- - diff --git a/src/__tests__/ai/opencodeMapping.test.ts b/src/__tests__/ai/opencodeMapping.test.ts new file mode 100644 index 000000000..0f9db7292 --- /dev/null +++ b/src/__tests__/ai/opencodeMapping.test.ts @@ -0,0 +1,144 @@ +import { describe, test, expect, afterEach } from 'bun:test' +import { Type } from '@core/utils/typeboxHelpers' +import { opencodeDriver } from '../../../server/ai/drivers/opencode' +import type { AiStreamRequest } from '../../../server/ai/drivers/types' +import type { AiBrowserBridge, AiStreamEvent, AiTool } from '../../../server/ai/runtime/types' + +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +describe('opencodeDriver', () => { + test('lists OpenCode Zen models with catalogue pricing, context, and capabilities', async () => { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toBe('https://opencode.ai/zen/v1/models') + expect(init?.headers).toEqual({ Authorization: 'Bearer oc-test' }) + return new Response( + JSON.stringify({ + data: [ + { + id: 'mimo-v2.5-free', + name: 'MiMo V2.5 Free', + capabilities: { toolcall: true, input: { image: true } }, + cost: { input: 0, output: 0 }, + limit: { context: 200000 }, + }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + }) as typeof fetch + + const models = await opencodeDriver.listModels({ + id: 'cred', + providerId: 'opencode', + authMode: 'apiKey', + apiKey: 'oc-test', + baseUrl: null, + }) + + expect(models).toEqual([ + { + id: 'mimo-v2.5-free', + label: 'MiMo V2.5 Free', + catalogueSource: 'live', + capabilities: { toolCalling: true, visionInput: true, promptCache: false, streaming: true }, + pricing: { inputPerMTok: 0, outputPerMTok: 0 }, + contextWindow: 200000, + }, + ]) + }) + + test('streams via OpenCode Zen chat/completions and sends tools as JSON Schema', async () => { + const calls: unknown[] = [] + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ input: String(input), init }) + expect(String(input)).toBe('https://opencode.ai/zen/v1/chat/completions') + return new Response( + [ + 'data: {"choices":[{"delta":{"content":"zen reply"},"finish_reason":"stop"}]}', + 'data: {"choices":[],"usage":{"prompt_tokens":11,"completion_tokens":3}}', + 'data: [DONE]', + '', + ].join('\n\n'), + { status: 200, headers: { 'content-type': 'text/event-stream' } }, + ) + }) as typeof fetch + + const tool: AiTool = { + name: 'insertHtml', + description: 'Insert HTML', + scope: 'site', + execution: 'server', + inputSchema: Type.Object({ html: Type.String() }), + } + const bridge: AiBrowserBridge = { callBrowser: async () => ({ ok: true, data: {} }) } + const req: AiStreamRequest = { + systemPrompt: ['SYS'], + messages: [{ role: 'user', content: [{ kind: 'text', text: 'hi' }] }], + tools: [tool], + modelId: 'mimo-v2.5-free', + modelCapabilities: { + toolCalling: true, + visionInput: false, + promptCache: false, + streaming: true, + }, + credentials: { + id: 'cred', + providerId: 'opencode', + authMode: 'apiKey', + apiKey: 'oc-test', + baseUrl: null, + }, + signal: new AbortController().signal, + bridge, + toolContextBase: { + db: {} as AiStreamRequest['toolContextBase']['db'], + userId: 'u1', + capabilities: [], + scope: 'site', + conversationId: 'c1', + snapshot: null, + }, + } + + const events: AiStreamEvent[] = [] + for await (const ev of opencodeDriver.stream(req)) events.push(ev) + + expect(events).toEqual([ + { type: 'text', text: 'zen reply' }, + { type: 'context', promptTokens: 11, cacheReadTokens: undefined, cacheCreationTokens: undefined }, + { + type: 'usage', + promptTokens: 11, + completionTokens: 3, + costUsd: undefined, + cacheReadTokens: undefined, + cacheCreationTokens: undefined, + }, + ]) + expect(calls).toHaveLength(1) + const body = JSON.parse((calls[0] as { init: RequestInit }).init.body as string) + expect(body).toMatchObject({ + model: 'mimo-v2.5-free', + stream: true, + messages: [ + { role: 'system', content: 'SYS' }, + { role: 'user', content: 'hi' }, + ], + tools: [ + { + type: 'function', + function: { + name: 'insertHtml', + description: 'Insert HTML', + parameters: { type: 'object' }, + }, + }, + ], + }) + }) +}) diff --git a/src/admin/ai/api.ts b/src/admin/ai/api.ts index 13b77655f..f30fd423e 100644 --- a/src/admin/ai/api.ts +++ b/src/admin/ai/api.ts @@ -27,6 +27,7 @@ const ProviderId = Type.Union([ Type.Literal('openai'), Type.Literal('ollama'), Type.Literal('openrouter'), + Type.Literal('opencode'), ]) const AuthMode = Type.Union([ @@ -170,13 +171,13 @@ export async function listCredentials(signal?: AbortSignal): Promise { // --------------------------------------------------------------------------- export async function listModels( - providerId: 'anthropic' | 'openai' | 'ollama' | 'openrouter', + providerId: 'anthropic' | 'openai' | 'ollama' | 'openrouter' | 'opencode', credentialId?: string, ): Promise { const body = await apiRequest(`/admin/api/ai/providers/${providerId}/models`, { diff --git a/src/admin/pages/ai/tabs/ProvidersTab.tsx b/src/admin/pages/ai/tabs/ProvidersTab.tsx index fa15b3eb8..d997867f4 100644 --- a/src/admin/pages/ai/tabs/ProvidersTab.tsx +++ b/src/admin/pages/ai/tabs/ProvidersTab.tsx @@ -27,7 +27,7 @@ import { ApiError } from '@core/http' import styles from '../AiPage.module.css' import { getErrorMessage } from '@core/utils/errorMessage' -type ProviderId = 'anthropic' | 'openai' | 'ollama' | 'openrouter' +type ProviderId = 'anthropic' | 'openai' | 'ollama' | 'openrouter' | 'opencode' type AuthMode = 'apiKey' | 'baseUrl' // Each provider has exactly one credential shape; the UI derives it instead @@ -36,6 +36,7 @@ const PROVIDERS: Array<{ id: ProviderId; label: string; authMode: AuthMode }> = { id: 'anthropic', label: 'Anthropic (Claude)', authMode: 'apiKey' }, { id: 'openai', label: 'OpenAI', authMode: 'apiKey' }, { id: 'openrouter', label: 'OpenRouter', authMode: 'apiKey' }, + { id: 'opencode', label: 'OpenCode', authMode: 'apiKey' }, { id: 'ollama', label: 'Ollama (local)', authMode: 'baseUrl' }, ] @@ -49,12 +50,14 @@ const PROVIDER_LABEL: Record = { openai: 'OpenAI', openrouter: 'OpenRouter', ollama: 'Ollama', + opencode: 'OpenCode', } // Hint text for the API-key field, per provider key prefix. const API_KEY_PLACEHOLDER: Partial> = { anthropic: 'sk-ant-...', openrouter: 'sk-or-...', + opencode: 'sk-.....', } async function deleteCredentialAction( From 31ed9d455b7bb1cf6f3c7bf344cdd49bc76a7114 Mon Sep 17 00:00:00 2001 From: jxngrx Date: Tue, 30 Jun 2026 00:15:28 +0530 Subject: [PATCH 2/2] feat(docs): update documentation to include OpenCode support in AI agent and clarify INSTATIC_SECRET_KEY usage --- docs/deployment/docker-image.md | 2 +- docs/features/agent.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/deployment/docker-image.md b/docs/deployment/docker-image.md index b4092d239..aca5be570 100644 --- a/docs/deployment/docker-image.md +++ b/docs/deployment/docker-image.md @@ -148,7 +148,7 @@ Managed platforms usually inject `PORT`. Do not hard-code a different listen por Managed HTTPS platforms often terminate TLS before forwarding HTTP to the container, so the container sees plain HTTP. Set `PUBLIC_ORIGIN` to the site's public origin for those deployments so the CSRF origin check compares against the real public origin instead of the container-local request URL. Render and Railway are auto-detected (`RENDER_EXTERNAL_URL` / `RAILWAY_PUBLIC_DOMAIN`), so a one-click deploy needs no manual value; set `PUBLIC_ORIGIN` explicitly when you add a custom domain (append it as a second comma-separated entry). -`INSTATIC_SECRET_KEY` is the stable AES master key for reversible server secrets, including Anthropic, OpenAI, and OpenRouter credentials and TOTP MFA seeds. If it is missing in production, adding a credential or enabling TOTP MFA fails. If it is rotated or lost, existing stored credentials must be re-entered and TOTP MFA must be re-enrolled. +`INSTATIC_SECRET_KEY` is the stable AES master key for reversible server secrets, including Anthropic, OpenAI, OpenRouter, and OpenCode credentials and TOTP MFA seeds. If it is missing in production, adding a credential or enabling TOTP MFA fails. If it is rotated or lost, existing stored credentials must be re-entered and TOTP MFA must be re-enrolled. ## Health Check diff --git a/docs/features/agent.md b/docs/features/agent.md index 9b1c3c93c..465ab887d 100644 --- a/docs/features/agent.md +++ b/docs/features/agent.md @@ -2,7 +2,7 @@ The AI Agent is a model-powered assistant integrated into the visual editor. The user types a request in the Agent Panel; the agent reads the current page snapshot, plans a sequence of edits, and executes them by calling tools. Structure is written as semantic HTML (`insertHtml` / `replaceNodeHtml`); styling is written as CSS — a `