From 62b0455388fdf5d5c36bc41a07dc66bd8dcd528b Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Fri, 7 Aug 2026 12:09:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(ai):=20OpenCode=20provider=20management=20?= =?UTF-8?q?(B4)=20=E2=80=94=20"any=20LLM=20OpenCode=20supports",=20from=20?= =?UTF-8?q?inside=20VNCmail+?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/api/ai/opencode/providers/route.ts | 103 ++++++++++++ components/settings/ai-assistant-settings.tsx | 159 ++++++++++++++++++ lib/ai/__tests__/opencode.test.ts | 86 ++++++++++ lib/ai/local-client.ts | 39 +++++ lib/ai/opencode.ts | 73 ++++++++ 5 files changed, 460 insertions(+) create mode 100644 app/api/ai/opencode/providers/route.ts diff --git a/app/api/ai/opencode/providers/route.ts b/app/api/ai/opencode/providers/route.ts new file mode 100644 index 00000000..88014b8f --- /dev/null +++ b/app/api/ai/opencode/providers/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { configManager } from '@/lib/admin/config-manager'; +import { + findOpencodeServer, listOpencodeProviders, setOpencodeProviderKey, removeOpencodeProvider, +} from '@/lib/ai/opencode'; +import { logger } from '@/lib/logger'; + +export const runtime = 'nodejs'; + +const SETUP_ERROR = + 'No local OpenCode server is running. The desktop app starts one automatically when the opencode CLI is installed — install it from opencode.ai, then restart VNCmail+.'; + +async function requireOpencode(request: NextRequest) { + const auth = await getStalwartCredentials(request); + if (!auth) return { error: NextResponse.json({ error: 'not authenticated' }, { status: 401 }) } as const; + + await configManager.ensureLoaded(); + if (configManager.getAiConsoleConfig().classesEnabled.opencode === false) { + return { error: NextResponse.json({ error: 'the OpenCode class is disabled by admin policy' }, { status: 403 }) } as const; + } + + const found = await findOpencodeServer(); + if (!found) return { error: NextResponse.json({ error: SETUP_ERROR }, { status: 503 }) } as const; + return { baseUrl: found.baseUrl } as const; +} + +/** + * GET/PUT/DELETE /api/ai/opencode/providers — lets a user add "any LLM + * OpenCode supports" from inside this app, rather than only whatever was + * already authenticated via its own CLI. See lib/ai/opencode.ts's module + * note on why this only covers API-key providers for now, not OAuth ones. + */ +export async function GET(request: NextRequest) { + const result = await requireOpencode(request); + if ('error' in result) return result.error; + try { + const providers = await listOpencodeProviders(result.baseUrl); + return NextResponse.json({ providers }, { headers: { 'Cache-Control': 'no-store' } }); + } catch (cause) { + logger.error('opencode providers list failed', { error: cause instanceof Error ? cause.message : String(cause) }); + return NextResponse.json({ error: 'Could not list OpenCode providers' }, { status: 502 }); + } +} + +export async function PUT(request: NextRequest) { + const result = await requireOpencode(request); + if ('error' in result) return result.error; + + let body: { providerID?: unknown; key?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 }); + } + const providerID = typeof body.providerID === 'string' ? body.providerID.trim() : ''; + const key = typeof body.key === 'string' ? body.key.trim() : ''; + if (!providerID || !key) { + return NextResponse.json({ error: 'providerID and key are required' }, { status: 400 }); + } + + try { + await setOpencodeProviderKey(result.baseUrl, providerID, key); + // VERIFY rather than trust the 200: OpenCode accepts a bare API key for + // every provider (confirmed live), but does not consider every provider + // "connected" from that alone - Snowflake Cortex, for one real example, + // needs SNOWFLAKE_ACCOUNT alongside its token, and a single key field + // silently leaves it unconnected with no error from the PUT itself. The + // provider's own `env` array length does NOT predict this reliably either + // (Azure needs two env vars and DOES connect from one key) - the only + // honest source of truth is asking OpenCode again. + const after = await listOpencodeProviders(result.baseUrl); + const nowConnected = after.find((p) => p.id === providerID)?.connected === true; + if (!nowConnected) { + return NextResponse.json({ + ok: false, + error: `OpenCode stored the key but does not show ${providerID} as connected — it likely needs more than one credential field (check its requirements with the opencode CLI: opencode auth login ${providerID}).`, + }, { status: 200 }); + } + return NextResponse.json({ ok: true }); + } catch (cause) { + logger.error('opencode provider auth failed', { providerID, error: cause instanceof Error ? cause.message : String(cause) }); + return NextResponse.json({ error: cause instanceof Error ? cause.message : 'Could not add the provider' }, { status: 502 }); + } +} + +export async function DELETE(request: NextRequest) { + const result = await requireOpencode(request); + if ('error' in result) return result.error; + + const providerID = request.nextUrl.searchParams.get('providerID')?.trim(); + if (!providerID) { + return NextResponse.json({ error: 'providerID is required' }, { status: 400 }); + } + + try { + await removeOpencodeProvider(result.baseUrl, providerID); + return NextResponse.json({ ok: true }); + } catch (cause) { + logger.error('opencode provider removal failed', { providerID, error: cause instanceof Error ? cause.message : String(cause) }); + return NextResponse.json({ error: cause instanceof Error ? cause.message : 'Could not remove the provider' }, { status: 502 }); + } +} diff --git a/components/settings/ai-assistant-settings.tsx b/components/settings/ai-assistant-settings.tsx index e4465ecb..d5fc3c8d 100644 --- a/components/settings/ai-assistant-settings.tsx +++ b/components/settings/ai-assistant-settings.tsx @@ -23,6 +23,10 @@ import { listLocalModels, listServerModels, listOpencodeModels, + listOpencodeProviders, + addOpencodeProvider, + removeOpencodeProvider, + type OpencodeProviderOption, type OpencodeModelOption, testLocalConnection, type AskResult, @@ -154,6 +158,58 @@ export function AiAssistantSettings() { const [refreshingOpencode, setRefreshingOpencode] = useState(false); const [opencodeError, setOpencodeError] = useState(null); + // ── OpenCode provider management — "add any LLM OpenCode supports" from + // inside this app, not only whatever its own CLI already authenticated. ── + const [opencodeProviders, setOpencodeProviders] = useState([]); + const [loadingProviders, setLoadingProviders] = useState(false); + const [providerSearch, setProviderSearch] = useState(''); + const [addingProviderId, setAddingProviderId] = useState(null); + const [newProviderKey, setNewProviderKey] = useState(''); + const [providerBusyId, setProviderBusyId] = useState(null); + const [providerActionError, setProviderActionError] = useState(null); + const [showProviderManager, setShowProviderManager] = useState(false); + + const refreshOpencodeProviders = useCallback(async () => { + setLoadingProviders(true); + setProviderActionError(null); + try { + setOpencodeProviders(await listOpencodeProviders()); + } catch (err) { + setProviderActionError(err instanceof Error ? err.message : String(err)); + } finally { + setLoadingProviders(false); + } + }, []); + + const handleAddProvider = useCallback(async (providerId: string) => { + if (!newProviderKey.trim()) return; + setProviderBusyId(providerId); + setProviderActionError(null); + try { + await addOpencodeProvider(providerId, newProviderKey.trim()); + setAddingProviderId(null); + setNewProviderKey(''); + await refreshOpencodeProviders(); + } catch (err) { + setProviderActionError(err instanceof Error ? err.message : String(err)); + } finally { + setProviderBusyId(null); + } + }, [newProviderKey, refreshOpencodeProviders]); + + const handleRemoveProvider = useCallback(async (providerId: string) => { + setProviderBusyId(providerId); + setProviderActionError(null); + try { + await removeOpencodeProvider(providerId); + await refreshOpencodeProviders(); + } catch (err) { + setProviderActionError(err instanceof Error ? err.message : String(err)); + } finally { + setProviderBusyId(null); + } + }, [refreshOpencodeProviders]); + const refreshOpencodeModels = useCallback(async () => { setRefreshingOpencode(true); setOpencodeError(null); @@ -423,6 +479,109 @@ export function AiAssistantSettings() { )} + + + + + + {showProviderManager && ( +
+ {providerActionError && ( +

+ {providerActionError} +

+ )} + +
+ setProviderSearch(e.target.value)} + placeholder="Search providers (e.g. anthropic, openai, groq)…" + spellCheck={false} + className={inputClass} + /> + +
+ + {opencodeProviders.length === 0 && !loadingProviders && ( +

No providers loaded yet — click Refresh.

+ )} + +
+ {opencodeProviders + .filter((p) => { + const q = providerSearch.trim().toLowerCase(); + return !q || p.id.toLowerCase().includes(q) || p.name.toLowerCase().includes(q); + }) + // Connected first (already sorted server-side), then cap what + // renders — 180 providers in one scroll box is noise, not choice. + .slice(0, providerSearch.trim() ? 40 : 20) + .map((p) => ( +
+
+ {p.name} + {p.id} +
+ {p.connected ? ( + <> + + Connected + + + + ) : p.supportsApiKey ? ( + addingProviderId === p.id ? ( +
+ setNewProviderKey(e.target.value)} + placeholder="API key" + autoFocus + className="px-2 py-1 text-xs rounded-md bg-muted border border-border w-36" + /> + + +
+ ) : ( + + ) + ) : ( + Browser sign-in only + )} +
+ ))} +
+
+ )} )} diff --git a/lib/ai/__tests__/opencode.test.ts b/lib/ai/__tests__/opencode.test.ts index 4ac676a0..4af77a54 100644 --- a/lib/ai/__tests__/opencode.test.ts +++ b/lib/ai/__tests__/opencode.test.ts @@ -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' }), + ); + }); +}); diff --git a/lib/ai/local-client.ts b/lib/ai/local-client.ts index fd769053..37420720 100644 --- a/lib/ai/local-client.ts +++ b/lib/ai/local-client.ts @@ -181,6 +181,45 @@ export async function listOpencodeModels(): Promise { 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 { + 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 { + 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 { + 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 { const res = await fetch('/api/ai/opencode/chat', { method: 'POST', diff --git a/lib/ai/opencode.ts b/lib/ai/opencode.ts index 1546f1b9..a3d62f40 100644 --- a/lib/ai/opencode.ts +++ b/lib/ai/opencode.ts @@ -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; + +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 }>; }