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:
@@ -181,6 +181,45 @@ export async function listOpencodeModels(): Promise<OpencodeModelOption[]> {
|
||||
return (body?.models ?? []) as OpencodeModelOption[];
|
||||
}
|
||||
|
||||
export interface OpencodeProviderOption {
|
||||
id: string;
|
||||
name: string;
|
||||
connected: boolean;
|
||||
supportsApiKey: boolean;
|
||||
}
|
||||
|
||||
/** Every provider OpenCode knows about, not just ones already authenticated —
|
||||
* this is what lets "add any LLM OpenCode supports" mean something from
|
||||
* inside this app instead of only whatever its CLI already set up. */
|
||||
export async function listOpencodeProviders(): Promise<OpencodeProviderOption[]> {
|
||||
const res = await fetch('/api/ai/opencode/providers');
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body?.error || `OpenCode returned ${res.status}`);
|
||||
return (body?.providers ?? []) as OpencodeProviderOption[];
|
||||
}
|
||||
|
||||
/** The key is relayed to OpenCode's own credential store, never held by this
|
||||
* app — same reasoning as the module note above, extended to provider setup. */
|
||||
export async function addOpencodeProvider(providerID: string, key: string): Promise<void> {
|
||||
const res = await fetch('/api/ai/opencode/providers', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ providerID, key }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
// A 200 with `ok: false` means the route VERIFIED the write and the
|
||||
// provider still isn't connected (some need more than one credential
|
||||
// field — see the route's own comment) - that is as much a failure as a
|
||||
// non-2xx status and must not be swallowed as success.
|
||||
if (!res.ok || body?.ok === false) throw new Error(body?.error || `OpenCode returned ${res.status}`);
|
||||
}
|
||||
|
||||
export async function removeOpencodeProvider(providerID: string): Promise<void> {
|
||||
const res = await fetch(`/api/ai/opencode/providers?providerID=${encodeURIComponent(providerID)}`, { method: 'DELETE' });
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body?.error || `OpenCode returned ${res.status}`);
|
||||
}
|
||||
|
||||
export async function chatOpencode(model: string, messages: ChatMessage[]): Promise<string> {
|
||||
const res = await fetch('/api/ai/opencode/chat', {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user