// Shared server-side helpers for the OpenCode AI class. // // OpenCode (github.com/sst/opencode) runs as a local headless server // (`opencode serve`) — the same runtime Paperclip drives as an agent adapter. // Here it is used only as a one-shot chat backend for the mail assistant, so // its HTTP surface is enough and no subprocess needs spawning from this app. // // IT IS NOT OpenAI-COMPATIBLE, despite `/v1/models` and `/v1/chat/completions` // both answering 200: opencode serves a web UI from the same port with a // catch-all route, so ANY unknown path returns the SPA's index.html with a 200. // Checking `res.ok` alone therefore "verifies" endpoints that do not exist — // verified the hard way, by believing exactly that before reading a body. // Every probe here validates the parsed SHAPE, never the status code alone. // // The real API (from the server's own /doc OpenAPI spec): // GET /api/model -> { data: [{ id, providerID, name, ... }] } // POST /session -> { id: "ses_..." } // POST /session/{id}/message -> { info, parts: [{ type: 'text', text }, ...] } // // Address resolution is deliberately narrow: loopback only. This class exists // to reach a runtime on the user's OWN machine — pointing it at a remote host // would silently turn "local, no keys, nothing leaves the device" into the // opposite, so a non-loopback OPENCODE_BASE_URL is refused rather than honoured. const DEFAULT_BASE_URLS = ['http://127.0.0.1:4096', 'http://localhost:4096']; const PROBE_TIMEOUT_MS = 2500; const PROMPT_TIMEOUT_MS = 120_000; function isLoopback(raw: string): boolean { try { const url = new URL(raw); return url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '::1'; } catch { return false; } } /** Candidate addresses, honouring an explicit OPENCODE_BASE_URL when it is * loopback. `opencode serve` defaults to a RANDOM port (`--port 0`), so the * conventional 4096 only finds a server deliberately started there; the env * var is how someone on another port points us at it. */ export function opencodeBaseUrls(): string[] { const configured = process.env.OPENCODE_BASE_URL?.trim(); if (configured) { if (!isLoopback(configured)) { console.error('[opencode] ignoring non-loopback OPENCODE_BASE_URL:', configured); return DEFAULT_BASE_URLS; } return [configured.replace(/\/+$/, ''), ...DEFAULT_BASE_URLS]; } return DEFAULT_BASE_URLS; } interface OpencodeModelListResponse { data?: Array<{ id?: string; providerID?: string; name?: string }>; } export interface OpencodeModel { /** "providerID/modelID" — the reference shown in the picker and stored in * settings, matching how opencode itself names models on the CLI. */ ref: string; providerID: string; modelID: string; label: string; } /** Splits the stored "providerID/modelID" reference back into the pair the * message API wants. Returns null for anything malformed rather than * guessing, so a corrupted setting surfaces as a clear error. */ export function parseModelRef(ref: string): { providerID: string; modelID: string } | null { const slash = ref.indexOf('/'); if (slash <= 0 || slash === ref.length - 1) return null; return { providerID: ref.slice(0, slash), modelID: ref.slice(slash + 1) }; } /** * Auth header for a password-protected server. * * HTTP Basic with the username EXACTLY `opencode` — verified against 1.18.14: * an empty username, an arbitrary one, a Bearer token and every plausible * custom header all 401 with the correct password. Its own OpenAPI spec * declares no securitySchemes at all, so this is only knowable by trying it. * Absent password = an unsecured server (the desktop shell always sets one; * a hand-started `opencode serve` typically has none). */ function authHeaders(): Record { const password = process.env.OPENCODE_SERVER_PASSWORD; if (!password) return {}; return { Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString('base64')}` }; } async function fetchJson(url: string, init: RequestInit, timeoutMs: number): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { ...init, headers: { ...authHeaders(), ...(init.headers as Record | undefined) }, signal: controller.signal, }); if (!res.ok) return null; // The SPA catch-all returns HTML with a 200 for unknown paths — see the // module header. Content-type is what actually distinguishes a real API // response from the web UI. const contentType = res.headers.get('content-type') ?? ''; if (!contentType.includes('application/json')) return null; return await res.json(); } catch { return null; } finally { clearTimeout(timer); } } /** First reachable candidate that answers /api/model with a real model list. */ export async function findOpencodeServer(): Promise<{ baseUrl: string; models: OpencodeModel[] } | null> { for (const baseUrl of opencodeBaseUrls()) { const body = (await fetchJson(`${baseUrl}/api/model`, {}, PROBE_TIMEOUT_MS)) as OpencodeModelListResponse | null; if (!body || !Array.isArray(body.data)) continue; const models: OpencodeModel[] = body.data .filter((m): m is { id: string; providerID: string; name?: string } => typeof m?.id === 'string' && !!m.id && typeof m?.providerID === 'string' && !!m.providerID) .map((m) => ({ ref: `${m.providerID}/${m.id}`, providerID: m.providerID, modelID: m.id, label: m.name ? `${m.name} (${m.providerID})` : `${m.providerID}/${m.id}`, })); if (models.length > 0) return { baseUrl, models }; } return null; } // ── Provider management ────────────────────────────────────────────────── // // Without this, "any LLM OpenCode supports" was only true for whatever the // user had already authenticated via its own CLI (`opencode auth login`) — // this app could pick a model, never add a provider. `GET /provider` lists // every provider opencode KNOWS about (180 on a real run) with a `connected` // array naming which ones actually have credentials; `GET /provider/auth` // says which auth METHODS each one accepts. // // Scoped to API-key auth only for now, deliberately. `PUT /auth/{id}` with // `{type:'api', key}` is one HTTP call with a schema-verified shape (tested // live: 200, and the key round-trips into opencode's own auth.json). OAuth // entries in `/provider/auth` (`{type:'oauth', label, prompts?}`) need a // browser redirect + callback this app has no page for yet, and some carry // interactive prompts (GitHub Copilot's deployment-type picker) beyond a // single form — real scope for later, not something to half-build tonight. // Providers offering only OAuth are still LISTED, just marked unsupported // here, so the picker is honest about what it can and can't do. export interface OpencodeProviderInfo { id: string; name: string; connected: boolean; /** Whether this app can authenticate it — see the module note above. */ supportsApiKey: boolean; } interface ProviderListResponse { all?: Array<{ id?: string; name?: string }>; connected?: string[]; } type ProviderAuthMethod = { type?: string }; type ProviderAuthResponse = Record; export async function listOpencodeProviders(baseUrl: string): Promise { const [providers, authMethods] = await Promise.all([ fetchJson(`${baseUrl}/provider`, {}, PROBE_TIMEOUT_MS) as Promise, fetchJson(`${baseUrl}/provider/auth`, {}, PROBE_TIMEOUT_MS) as Promise, ]); if (!providers || !Array.isArray(providers.all)) return []; const connected = new Set(providers.connected ?? []); return providers.all .filter((p): p is { id: string; name?: string } => typeof p?.id === 'string' && !!p.id) .map((p) => ({ id: p.id, name: p.name || p.id, connected: connected.has(p.id), supportsApiKey: (authMethods?.[p.id] ?? []).some((m) => m.type === 'api'), })) .sort((a, b) => (a.connected === b.connected ? a.name.localeCompare(b.name) : a.connected ? -1 : 1)); } /** Stores an API key for a provider. Throws with opencode's own status on * failure rather than returning a boolean, so the route can pass a real * error back instead of a bare "didn't work". */ export async function setOpencodeProviderKey(baseUrl: string, providerID: string, key: string): Promise { const res = await fetch(`${baseUrl}/auth/${encodeURIComponent(providerID)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ type: 'api', key }), }); if (!res.ok) throw new Error(`OpenCode rejected the credential (HTTP ${res.status})`); } export async function removeOpencodeProvider(baseUrl: string, providerID: string): Promise { const res = await fetch(`${baseUrl}/auth/${encodeURIComponent(providerID)}`, { method: 'DELETE', headers: authHeaders(), }); if (!res.ok) throw new Error(`OpenCode could not remove the credential (HTTP ${res.status})`); } interface OpencodeMessageResponse { parts?: Array<{ type?: string; text?: string }>; } /** * One prompt, one answer. Creates a throwaway session per question — this is * a stateless "ask about my mail" box, not a running conversation, and a fresh * session keeps one question's context from leaking into the next. * * `system` is passed as opencode's own system field rather than as a message * part, so the retrieved-mail prompt keeps the same shape it has for every * other provider class (see buildPrompt in lib/ai/local-client.ts). */ export async function opencodePrompt( baseUrl: string, model: { providerID: string; modelID: string }, system: string | undefined, userText: string, ): Promise<{ ok: true; answer: string } | { ok: false; error: string }> { const session = (await fetchJson( `${baseUrl}/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }, PROBE_TIMEOUT_MS, )) as { id?: string } | null; if (!session?.id) return { ok: false, error: 'OpenCode would not start a session' }; const body = (await fetchJson( `${baseUrl}/session/${encodeURIComponent(session.id)}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, ...(system ? { system } : {}), parts: [{ type: 'text', text: userText }], }), }, PROMPT_TIMEOUT_MS, )) as OpencodeMessageResponse | null; if (!body) return { ok: false, error: 'OpenCode returned no usable response' }; // A reply carries several parts (step-start / reasoning / text / step-finish). // Only the `text` parts are the answer; `reasoning` is the model's private // chain of thought and must not be shown as the reply. const answer = (body.parts ?? []) .filter((p) => p.type === 'text' && typeof p.text === 'string' && p.text.trim()) .map((p) => (p.text as string).trim()) .join('\n\n'); if (!answer) return { ok: false, error: 'OpenCode returned no message content' }; return { ok: true, answer }; }