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
+86
View File
@@ -180,3 +180,89 @@ describe('opencodePrompt', () => {
expect(result).toEqual({ ok: false, error: 'OpenCode returned no message content' });
});
});
describe('listOpencodeProviders', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it('merges /provider and /provider/auth into one list, connected first', async () => {
const fetchMock = vi.fn((url: string) => {
if (url.endsWith('/provider')) {
return Promise.resolve(jsonResponse({
all: [{ id: 'anthropic', name: 'Anthropic' }, { id: 'deepseek', name: 'DeepSeek' }, { id: 'github-copilot', name: 'GitHub Copilot' }],
connected: ['deepseek'],
}));
}
if (url.endsWith('/provider/auth')) {
return Promise.resolve(jsonResponse({
anthropic: [{ type: 'api' }],
deepseek: [{ type: 'api' }],
'github-copilot': [{ type: 'oauth' }],
}));
}
return Promise.resolve(HTML_CATCHALL);
});
global.fetch = fetchMock as unknown as typeof fetch;
const { listOpencodeProviders } = await import('../opencode');
const result = await listOpencodeProviders('http://127.0.0.1:4096');
expect(result).toHaveLength(3);
// Connected providers sort first regardless of name.
expect(result[0]).toMatchObject({ id: 'deepseek', connected: true, supportsApiKey: true });
const anthropic = result.find((p) => p.id === 'anthropic');
expect(anthropic).toMatchObject({ connected: false, supportsApiKey: true });
const copilot = result.find((p) => p.id === 'github-copilot');
// OAuth-only provider: listed, but honestly marked as not addable here.
expect(copilot).toMatchObject({ connected: false, supportsApiKey: false });
});
it('returns an empty list rather than throwing when /provider is unreachable', async () => {
global.fetch = vi.fn().mockResolvedValue(HTML_CATCHALL) as unknown as typeof fetch;
const { listOpencodeProviders } = await import('../opencode');
expect(await listOpencodeProviders('http://127.0.0.1:4096')).toEqual([]);
});
});
describe('setOpencodeProviderKey / removeOpencodeProvider', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it('PUTs the exact schema OpenCode requires: {type:"api", key}', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
global.fetch = fetchMock as unknown as typeof fetch;
const { setOpencodeProviderKey } = await import('../opencode');
await setOpencodeProviderKey('http://127.0.0.1:4096', 'anthropic', 'sk-real-key');
expect(fetchMock).toHaveBeenCalledWith(
'http://127.0.0.1:4096/auth/anthropic',
expect.objectContaining({ method: 'PUT', body: JSON.stringify({ type: 'api', key: 'sk-real-key' }) }),
);
});
it('throws with the upstream status when OpenCode rejects the credential', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 400 }) as unknown as typeof fetch;
const { setOpencodeProviderKey } = await import('../opencode');
await expect(setOpencodeProviderKey('http://127.0.0.1:4096', 'anthropic', 'bad')).rejects.toThrow(/400/);
});
it('DELETEs by provider id and encodes it in the path', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
global.fetch = fetchMock as unknown as typeof fetch;
const { removeOpencodeProvider } = await import('../opencode');
await removeOpencodeProvider('http://127.0.0.1:4096', 'weird id/with slash');
expect(fetchMock).toHaveBeenCalledWith(
'http://127.0.0.1:4096/auth/weird%20id%2Fwith%20slash',
expect.objectContaining({ method: 'DELETE' }),
);
});
});
+39
View File
@@ -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',
+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 }>;
}