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
@@ -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<string | null>(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<OpencodeProviderOption[]>([]);
const [loadingProviders, setLoadingProviders] = useState(false);
const [providerSearch, setProviderSearch] = useState('');
const [addingProviderId, setAddingProviderId] = useState<string | null>(null);
const [newProviderKey, setNewProviderKey] = useState('');
const [providerBusyId, setProviderBusyId] = useState<string | null>(null);
const [providerActionError, setProviderActionError] = useState<string | null>(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() {
</span>
</SettingItem>
)}
<SettingItem
label="Providers"
description="Add credentials for any provider OpenCode supports — a key entered here is stored by OpenCode itself, not by this app. Providers that only offer a browser sign-in (OAuth) aren't manageable here yet; use the opencode CLI for those."
>
<Button
variant="outline" size="sm"
onClick={() => {
const next = !showProviderManager;
setShowProviderManager(next);
if (next && opencodeProviders.length === 0) void refreshOpencodeProviders();
}}
>
{showProviderManager ? 'Hide' : 'Manage providers'}
</Button>
</SettingItem>
{showProviderManager && (
<div className="px-4 pb-4 space-y-3">
{providerActionError && (
<p className="flex items-start gap-1.5 text-sm text-destructive">
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-0.5" /> {providerActionError}
</p>
)}
<div className="flex items-center gap-2">
<input
type="text"
value={providerSearch}
onChange={(e) => setProviderSearch(e.target.value)}
placeholder="Search providers (e.g. anthropic, openai, groq)…"
spellCheck={false}
className={inputClass}
/>
<Button variant="outline" size="sm" onClick={refreshOpencodeProviders} disabled={loadingProviders}>
<RefreshCw className={`w-3.5 h-3.5 me-1.5 ${loadingProviders ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{opencodeProviders.length === 0 && !loadingProviders && (
<p className="text-xs text-muted-foreground">No providers loaded yet click Refresh.</p>
)}
<div className="max-h-72 overflow-y-auto space-y-1.5">
{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) => (
<div key={p.id} className="flex items-center gap-2 rounded-md border border-border px-3 py-2">
<div className="flex-1 min-w-0">
<span className="text-sm">{p.name}</span>
<span className="ms-1.5 text-xs text-muted-foreground">{p.id}</span>
</div>
{p.connected ? (
<>
<span className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-500">
<CheckCircle className="w-3.5 h-3.5" /> Connected
</span>
<Button
variant="outline" size="sm"
onClick={() => handleRemoveProvider(p.id)}
disabled={providerBusyId === p.id}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</>
) : p.supportsApiKey ? (
addingProviderId === p.id ? (
<div className="flex items-center gap-1.5">
<input
type="password"
value={newProviderKey}
onChange={(e) => setNewProviderKey(e.target.value)}
placeholder="API key"
autoFocus
className="px-2 py-1 text-xs rounded-md bg-muted border border-border w-36"
/>
<Button size="sm" onClick={() => handleAddProvider(p.id)} disabled={providerBusyId === p.id || !newProviderKey.trim()}>
Save
</Button>
<Button variant="outline" size="sm" onClick={() => { setAddingProviderId(null); setNewProviderKey(''); }}>
<X className="w-3.5 h-3.5" />
</Button>
</div>
) : (
<Button variant="outline" size="sm" onClick={() => { setAddingProviderId(p.id); setNewProviderKey(''); }}>
<Plus className="w-3.5 h-3.5 me-1" /> Add key
</Button>
)
) : (
<span className="text-xs text-muted-foreground">Browser sign-in only</span>
)}
</div>
))}
</div>
</div>
)}
</SettingsSection>
)}