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 }>; }