Files
SRCmail/lib/ai/opencode.ts
T
Bernd Rodler 62b0455388 feat(ai): OpenCode provider management (B4) — "any LLM OpenCode supports", from inside VNCmail+
Before this, the OpenCode class could only use providers already authenticated
via its own CLI (opencode auth login) — this app could pick a MODEL, never add
a PROVIDER. That is the one thing standing between "OpenCode integration" and
the actual ask: any LLM it supports, added from here.

New GET/PUT/DELETE /api/ai/opencode/providers, backed by GET /provider (every
provider OpenCode knows — 180 on a real run) and GET /provider/auth (which
auth method each accepts). New "Manage providers" panel in the OpenCode
settings section: search, add a key, remove one.

Scoped to API-key auth only, deliberately — recorded in lib/ai/opencode.ts's
module comment. `PUT /auth/{id}` with `{type:'api', key}` is one HTTP call
with a schema-verified shape. OAuth entries in /provider/auth need a browser
redirect + callback this app has no page for, and some carry interactive
prompts beyond a single form (GitHub Copilot's deployment-type picker) — real
scope for later, not something to half-build. OAuth-only providers are still
LISTED, just marked "Browser sign-in only" rather than hidden, so the picker
stays honest about what it can't do here.

A real finding from testing this against opencode's actual behaviour rather
than trusting a 200: NOT EVERY PROVIDER BECOMES CONNECTED FROM A BARE API KEY.
Snowflake Cortex needs SNOWFLAKE_ACCOUNT alongside its token; a single key
field silently leaves it stored-but-unconnected with no error from the PUT
itself. Worse, the provider's own `env` array length does not predict this —
Azure also needs two env vars and DOES connect from one key. There is no
reliable way to know in advance, so the route now VERIFIES by re-listing
providers after the write and reports plainly when a key was accepted but the
provider still isn't connected, rather than reporting the PUT's own success.

Verified live end-to-end, twice: once confirming a simple single-field
provider connects and can be removed cleanly, once confirming the honest
"stored but not connected" case is real and detected, not theoretical.
Cleaned up every throwaway credential from this machine's real opencode
config afterwards (checked auth.json directly, not just this app's view of it).

17 new/updated unit tests. Gate: tsc clean, eslint clean, 2527/2527 tests, build clean.
2026-08-07 12:09:14 +02:00

258 lines
11 KiB
TypeScript

// 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<string, string> {
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<unknown | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
...init,
headers: { ...authHeaders(), ...(init.headers as Record<string, string> | 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<string, ProviderAuthMethod[]>;
export async function listOpencodeProviders(baseUrl: string): Promise<OpencodeProviderInfo[]> {
const [providers, authMethods] = await Promise.all([
fetchJson(`${baseUrl}/provider`, {}, PROBE_TIMEOUT_MS) as Promise<ProviderListResponse | null>,
fetchJson(`${baseUrl}/provider/auth`, {}, PROBE_TIMEOUT_MS) as Promise<ProviderAuthResponse | null>,
]);
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<void> {
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<void> {
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 };
}