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.
This commit is contained in:
Bernd Rodler
2026-08-07 12:09:14 +02:00
parent 35ed6a2858
commit 62b0455388
5 changed files with 460 additions and 0 deletions
+73
View File
@@ -131,6 +131,79 @@ export async function findOpencodeServer(): Promise<{ baseUrl: string; models: O
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 }>;
}