From 467ca9a79660efa410fcb74fb3eea894d0081544 Mon Sep 17 00:00:00 2001 From: Agung Maulana Date: Wed, 15 Jul 2026 12:52:06 +0700 Subject: [PATCH] feat(opencode): support custom OpenAI-compatible provider CRUD in UI Add full UI for adding, editing, and managing custom OpenAI-compatible providers (manual or auto-discovered models, context/output limits, aliases). Update backend with updateProvider, fetch-models routes, and dynamic config injection. Improves local LLM/bespoke endpoint support and DX. --- .gitignore | 4 - backend/auth/permissions.ts | 2 + backend/database/queries/engine-queries.ts | 12 + backend/engine/adapters/opencode/config.ts | 9 + backend/engine/adapters/opencode/models.ts | 11 +- backend/engine/adapters/opencode/server.ts | 82 +- backend/ws/engine/opencode/providers.ts | 49 ++ .../settings/engines/AIEnginesSettings.svelte | 795 +++++++++++++++--- .../features/opencode-providers.svelte.ts | 9 + 9 files changed, 852 insertions(+), 121 deletions(-) diff --git a/.gitignore b/.gitignore index 77c7cee5..660a2107 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,3 @@ temp-videos/ # Terminal output cache .terminal-output-cache/ - -/okegas -/okegas (1) -/okegas (2) diff --git a/backend/auth/permissions.ts b/backend/auth/permissions.ts index 9de2d847..409c5078 100644 --- a/backend/auth/permissions.ts +++ b/backend/auth/permissions.ts @@ -74,7 +74,9 @@ export const ADMIN_ONLY_ROUTES = new Set([ 'engine:opencode-provider-add', 'engine:opencode-provider-remove', 'engine:opencode-provider-toggle', + 'engine:opencode-provider-update', 'engine:opencode-provider-update-options', + 'engine:opencode-provider-fetch-models', 'engine:opencode-account-add', 'engine:opencode-account-switch', 'engine:opencode-account-delete', diff --git a/backend/database/queries/engine-queries.ts b/backend/database/queries/engine-queries.ts index ee6e98bd..205b719c 100644 --- a/backend/database/queries/engine-queries.ts +++ b/backend/database/queries/engine-queries.ts @@ -115,6 +115,18 @@ export const engineQueries = { return db.prepare(`SELECT * FROM engine_providers WHERE id = ?`).get(id) as EngineProvider; }, + updateProvider(id: number, data: { name?: string; apiUrl?: string; options?: string }): void { + const db = getDatabase(); + const sets: string[] = []; + const vals: (string | number)[] = []; + if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); } + if (data.apiUrl !== undefined) { sets.push('api_url = ?'); vals.push(data.apiUrl); } + if (data.options !== undefined) { sets.push('options = ?'); vals.push(data.options); } + if (sets.length === 0) return; + vals.push(id); + db.prepare(`UPDATE engine_providers SET ${sets.join(', ')} WHERE id = ?`).run(...vals); + }, + updateProviderOptions(id: number, options: string): void { const db = getDatabase(); db.prepare(`UPDATE engine_providers SET options = ? WHERE id = ?`).run(options, id); diff --git a/backend/engine/adapters/opencode/config.ts b/backend/engine/adapters/opencode/config.ts index 7ca3641e..00eb10e6 100644 --- a/backend/engine/adapters/opencode/config.ts +++ b/backend/engine/adapters/opencode/config.ts @@ -67,6 +67,15 @@ export function generateOpenCodeProviderConfig(): OpenCodeProviderConfigResult { for (const provider of providers) { const activeAccount = engineQueries.getActiveAccount(provider.id); + + // Custom providers (api_url set) are OpenAI-compatible endpoints that + // don't need env-var management — the config injection in server.ts + // handles npm + baseURL + model discovery from /v1/models. + if (provider.api_url) { + enabledProviders.push(provider.slug); + continue; + } + if (!activeAccount) continue; enabledProviders.push(provider.slug); diff --git a/backend/engine/adapters/opencode/models.ts b/backend/engine/adapters/opencode/models.ts index a7a2675f..dedff66f 100644 --- a/backend/engine/adapters/opencode/models.ts +++ b/backend/engine/adapters/opencode/models.ts @@ -22,8 +22,17 @@ export async function fetchOpenCodeModels(client: OpencodeClient): Promise = provider.models ?? {}; + const modelOrder = (provider.options._modelOrder as string[]) ?? []; - for (const [, model] of Object.entries(providerModels)) { + const entries = modelOrder.length > 0 + ? [...Object.entries(providerModels)].sort((a, b) => { + const ai = modelOrder.indexOf(a[0]); + const bi = modelOrder.indexOf(b[0]); + return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi); + }) + : Object.entries(providerModels); + + for (const [, model] of entries) { models.push({ engine: { type: 'opencode', diff --git a/backend/engine/adapters/opencode/server.ts b/backend/engine/adapters/opencode/server.ts index 5dc56ead..8ab5529b 100644 --- a/backend/engine/adapters/opencode/server.ts +++ b/backend/engine/adapters/opencode/server.ts @@ -25,7 +25,7 @@ import { join } from 'path'; import type { OpencodeClient } from '@opencode-ai/sdk'; import type { Subprocess } from 'bun'; import { getOpenCodeMcpConfig } from '../../../mcp'; -import { settingsQueries } from '../../../database/queries'; +import { engineQueries, settingsQueries } from '../../../database/queries'; import { generateOpenCodeProviderConfig } from './config'; import type { OpenCodeInlineAgent } from '$backend/subagents'; import { resolveBinaryWithRefresh } from '$backend/utils/cli'; @@ -195,6 +195,86 @@ async function spawnServer(key: string, spec: ServerConfigSpec): Promise 0) mergedConfig.mcp = mcpConfig; if (providerConfig.enabledProviders.length > 0) mergedConfig.enabled_providers = providerConfig.enabledProviders; if (spec.inlineAgents && Object.keys(spec.inlineAgents).length > 0) mergedConfig.agent = spec.inlineAgents; + + // Inject provider definitions for custom OpenAI-compatible providers + // (those with api_url set). Unlike models.dev catalog providers, custom + // providers need their npm + baseURL passed explicitly so the opencode + // server can discover models from their /v1/models endpoint. + const providerSection: Record = {}; + const opencodeProviders = engineQueries.getEnabledProviders('opencode'); + for (const provider of opencodeProviders) { + if (!provider.api_url) continue; + + const models: Record = {}; + let defaultContext = 128000; + let defaultOutput = 16384; + let modelNames: Record = {}; + let hiddenSet = new Set(); + try { + const opts = JSON.parse(provider.options || '{}') as { + models?: string[]; + modelNames?: Record; + hiddenModels?: string[]; + contextLimit?: number; + outputLimit?: number; + }; + if (opts.contextLimit) defaultContext = opts.contextLimit; + if (opts.outputLimit) defaultOutput = opts.outputLimit; + if (opts.modelNames) modelNames = opts.modelNames; + if (opts.hiddenModels) hiddenSet = new Set(opts.hiddenModels); + } catch { + // malformed options — proceed to auto-discover + } + + const addModel = (id: string) => { + models[id] = { name: modelNames[id] || id, limit: { context: defaultContext, output: defaultOutput } }; + }; + + try { + const opts = JSON.parse(provider.options || '{}') as { models?: string[] }; + if (opts.models && opts.models.length > 0) { + for (const id of opts.models) { + if (!hiddenSet.has(id)) addModel(id); + } + } + } catch { + // malformed options — proceed to auto-discover + } + + // Auto-discover models from /v1/models if none stored in options + if (Object.keys(models).length === 0) { + try { + const baseUrl = provider.api_url.replace(/\/+$/, ''); + const res = await fetch(`${baseUrl}/models`, { + signal: AbortSignal.timeout(3000), + }); + if (res.ok) { + const body = await res.json() as { data?: { id: string }[] }; + for (const m of body.data ?? []) { + addModel(m.id); + } + } + } catch { + // Provider may not be running — models will be empty. + } + } + + let modelOrder: string[] | undefined; + try { + const o = JSON.parse(provider.options || '{}') as { models?: string[] }; + if (o.models && o.models.length > 0) modelOrder = o.models.filter(id => !hiddenSet.has(id)); + } catch { /* ignore */ } + const providerOptions: Record = { baseURL: provider.api_url }; + if (modelOrder) providerOptions._modelOrder = modelOrder; + providerSection[provider.slug] = { + npm: provider.npm || '@ai-sdk/openai-compatible', + name: provider.name, + options: providerOptions, + models: models, + }; + } + if (Object.keys(providerSection).length > 0) mergedConfig.provider = providerSection; + const configContent = Object.keys(mergedConfig).length > 0 ? JSON.stringify(mergedConfig) : '{}'; const proc = Bun.spawn(args, { diff --git a/backend/ws/engine/opencode/providers.ts b/backend/ws/engine/opencode/providers.ts index c9e1505f..7081c19d 100644 --- a/backend/ws/engine/opencode/providers.ts +++ b/backend/ws/engine/opencode/providers.ts @@ -133,6 +133,26 @@ export const openCodeProviderHandler = createRouter() return { success: true }; }) + .http('engine:opencode-provider-update', { + data: t.Object({ + id: t.Number(), + name: t.Optional(t.String()), + apiUrl: t.Optional(t.String()), + options: t.Optional(t.String()), + }), + response: t.Object({ success: t.Boolean() }) + }, async ({ data }) => { + if (data.options !== undefined) { + try { JSON.parse(data.options); } catch { throw new Error('Invalid JSON for options'); } + } + engineQueries.updateProvider(data.id, { + name: data.name, + apiUrl: data.apiUrl, + options: data.options, + }); + return { success: true }; + }) + .http('engine:opencode-provider-toggle', { data: t.Object({ id: t.Number(), enabled: t.Boolean() }), response: t.Object({ success: t.Boolean() }) @@ -250,6 +270,35 @@ export const openCodeProviderHandler = createRouter() return { catalog, cachedAt: new Date().toISOString() }; }) + // ═══════════════════════════════════════ + // Model Discovery + // ═══════════════════════════════════════ + + .http('engine:opencode-provider-fetch-models', { + data: t.Object({ id: t.Number() }), + response: t.Object({ + models: t.Array(t.Object({ id: t.String(), name: t.Optional(t.String()) })) + }) + }, async ({ data }) => { + const providers = engineQueries.getProvidersWithAccounts('opencode'); + const provider = providers.find(p => p.id === data.id); + if (!provider) throw new Error('Provider not found'); + if (!provider.api_url) throw new Error('Provider has no API URL'); + + const baseUrl = provider.api_url.replace(/\/+$/, ''); + const activeAccount = provider.accounts.find(a => a.is_active === 1) || provider.accounts[0]; + const headers: Record = { 'Content-Type': 'application/json' }; + if (activeAccount?.credential) { + headers['Authorization'] = `Bearer ${activeAccount.credential}`; + } + + const res = await fetch(`${baseUrl}/models`, { headers, signal: AbortSignal.timeout(5000) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json() as { data?: { id: string; name?: string }[] }; + const models = (body.data || []).map(m => ({ id: m.id, name: m.name })); + return { models }; + }) + // ═══════════════════════════════════════ // Server Restart // ═══════════════════════════════════════ diff --git a/frontend/components/settings/engines/AIEnginesSettings.svelte b/frontend/components/settings/engines/AIEnginesSettings.svelte index f5d395ab..97b53316 100644 --- a/frontend/components/settings/engines/AIEnginesSettings.svelte +++ b/frontend/components/settings/engines/AIEnginesSettings.svelte @@ -5,6 +5,7 @@ import Dialog from '$frontend/components/common/overlay/Dialog.svelte'; import ws from '$frontend/utils/ws'; import { isDarkMode } from '$frontend/utils/theme'; + import { debug } from '$shared/utils/logger'; import { setActiveSection, settingsModalState, clearEngineFocus } from '$frontend/stores/ui/settings-modal.svelte'; import { ENGINES } from '$shared/constants/engines'; import type { EngineType, QwenProviderPresetId } from '$shared/types/unified'; @@ -32,6 +33,7 @@ import { qwenPresetsStore } from '$frontend/stores/features/qwen-presets.svelte'; import { opencodeProvidersStore, type OpenCodeProviderItem, type ModelsDevProviderItem } from '$frontend/stores/features/opencode-providers.svelte'; import { modelStore } from '$frontend/stores/features/models.svelte'; + import { settings, togglePinnedModel } from '$frontend/stores/features/settings.svelte'; import { showSuccess } from '$frontend/stores/ui/notification.svelte'; import type { TerminalViewerHandle } from '@myrialabs/ptykit/client'; @@ -190,7 +192,7 @@ const ocCatalog = $derived(opencodeProvidersStore.catalog); // Add provider flow - type OCAddStep = 'idle' | 'picking' | 'configuring' | 'saving' | 'success' | 'error'; + type OCAddStep = 'idle' | 'picking' | 'configuring' | 'custom' | 'saving' | 'success' | 'error'; let ocAddStep = $state('idle'); let ocAddError = $state(''); let ocCatalogSearch = $state(''); @@ -200,6 +202,32 @@ let ocAddOptions = $state>({}); let ocCatalogRefreshing = $state(false); + // Custom provider form + let ocCustomName = $state(''); + let ocCustomSlug = $state(''); + let ocCustomBaseUrl = $state(''); + let ocCustomApiKey = $state(''); + let ocCustomModelRows = $state<{ code: string; alias: string; hidden: boolean }[]>([]); + let ocCustomContextLimit = $state(128000); + let ocCustomOutputLimit = $state(16384); + let ocCustomFetching = $state(false); + let ocEditingProviderId = $state(null); + + // Edit form (reuses custom provider form fields) + let ocEditName = $state(''); + let ocEditBaseUrl = $state(''); + let ocEditApiKey = $state(''); + let ocEditModelRows = $state<{ code: string; alias: string; hidden: boolean }[]>([]); + let ocEditContextLimit = $state(128000); + let ocEditOutputLimit = $state(16384); + let ocEditFetching = $state(false); + let ocDragIndex = $state(null); + + // Derived: split providers + const ocCatalogSlugs = $derived(new Set(ocCatalog.map(c => c.id))); + const ocCatalogProviders = $derived(ocProviders.filter(p => ocCatalogSlugs.has(p.slug))); + const ocCustomProviders = $derived(ocProviders.filter(p => p.apiUrl && !ocCatalogSlugs.has(p.slug))); + // Provider account management let ocRenamingAccountId = $state(null); let ocRenameValue = $state(''); @@ -813,6 +841,52 @@ // ── OpenCode Provider Management ── + // ── Row-based model helpers ── + + function modelRowsToOptions(rows: { code: string; alias: string; hidden: boolean }[]): string { + const modelIds: string[] = []; + const modelNames: Record = {}; + const hiddenModels: string[] = []; + for (const row of rows) { + const code = row.code.trim(); + if (!code) continue; + modelIds.push(code); + const alias = row.alias.trim(); + if (alias) modelNames[code] = alias; + if (row.hidden) hiddenModels.push(code); + } + return JSON.stringify({ + models: modelIds, + modelNames: Object.keys(modelNames).length > 0 ? modelNames : undefined, + hiddenModels: hiddenModels.length > 0 ? hiddenModels : undefined, + contextLimit: ocCustomContextLimit, + outputLimit: ocCustomOutputLimit, + }); + } + + function editModelRowsToOptions(rows: { code: string; alias: string; hidden: boolean }[], ctx?: number, out?: number): string { + const modelIds: string[] = []; + const modelNames: Record = {}; + const hiddenModels: string[] = []; + for (const row of rows) { + const code = row.code.trim(); + if (!code) continue; + modelIds.push(code); + const alias = row.alias.trim(); + if (alias) modelNames[code] = alias; + if (row.hidden) hiddenModels.push(code); + } + return JSON.stringify({ + models: modelIds, + modelNames: Object.keys(modelNames).length > 0 ? modelNames : undefined, + hiddenModels: hiddenModels.length > 0 ? hiddenModels : undefined, + contextLimit: ctx ?? ocEditContextLimit, + outputLimit: out ?? ocEditOutputLimit, + }); + } + + // ── Custom Provider CRUD ── + function startAddProvider() { ocAddStep = 'picking'; ocAddError = ''; @@ -821,6 +895,150 @@ ocAddAccountName = ''; ocAddApiKey = ''; ocAddOptions = {}; + ocCustomName = ''; + ocCustomSlug = ''; + ocCustomBaseUrl = ''; + ocCustomApiKey = ''; + ocCustomModelRows = []; + ocCustomFetching = false; + } + + function startCustomProvider() { + ocAddStep = 'custom'; + ocAddError = ''; + ocCustomName = ''; + ocCustomSlug = ''; + ocCustomBaseUrl = ''; + ocCustomApiKey = ''; + ocCustomModelRows = [{ code: '', alias: '', hidden: false }]; + ocCustomFetching = false; + } + + function autoGenerateSlug(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'custom'; + } + + function addCustomModelRow() { + ocCustomModelRows = [...ocCustomModelRows, { code: '', alias: '', hidden: false }]; + } + + function removeCustomModelRow(index: number) { + ocCustomModelRows = ocCustomModelRows.filter((_, i) => i !== index); + } + + async function fetchCustomModels() { + if (!ocCustomBaseUrl.trim()) return; + ocCustomFetching = true; + ocAddError = ''; + try { + const baseUrl = ocCustomBaseUrl.trim().replace(/\/+$/, ''); + const res = await fetch(`${baseUrl}/models`, { signal: AbortSignal.timeout(5000) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json() as { data?: { id: string }[] }; + const ids = (body.data || []).map(m => m.id); + if (ids.length === 0) throw new Error('No models returned'); + ocCustomModelRows = ids.map(id => ({ code: id, alias: '', hidden: false })); + } catch (err: any) { + ocAddError = `Failed to fetch models: ${err?.message || 'unknown error'}`; + } finally { + ocCustomFetching = false; + } + } + + function isCustomProviderValid(): boolean { + return !!ocCustomName.trim() && !!ocCustomSlug.trim() && !!ocCustomBaseUrl.trim() && ocCustomModelRows.some(r => r.code.trim()); + } + + async function submitCustomProvider() { + if (!isCustomProviderValid()) return; + ocAddStep = 'saving'; + ocAddError = ''; + try { + const options = modelRowsToOptions(ocCustomModelRows); + await opencodeProvidersStore.addProvider({ + slug: ocCustomSlug.trim(), + name: ocCustomName.trim(), + npm: '@ai-sdk/openai-compatible', + apiUrl: ocCustomBaseUrl.trim().replace(/\/+$/, ''), + options, + accountName: ocCustomApiKey.trim() ? `${ocCustomName.trim()} key` : `${ocCustomName.trim()} (no key)`, + credential: ocCustomApiKey.trim() || '', + }); + ocAddStep = 'success'; + } catch (error: any) { + ocAddError = error?.message || 'Failed to add custom provider'; + ocAddStep = 'error'; + } + } + + // ── Edit Custom Provider ── + + function startEditCustomProvider(provider: OpenCodeProviderItem) { + ocEditingProviderId = provider.id; + ocEditName = provider.name; + ocEditBaseUrl = provider.apiUrl || ''; + ocEditApiKey = ''; + const opts = JSON.parse(provider.options || '{}') as { + models?: string[]; + modelNames?: Record; + hiddenModels?: string[]; + contextLimit?: number; + outputLimit?: number; + }; + const hiddenSet = new Set(opts.hiddenModels || []); + ocEditModelRows = (opts.models || []).map(id => ({ + code: id, + alias: opts.modelNames?.[id] || '', + hidden: hiddenSet.has(id), + })); + ocEditContextLimit = opts.contextLimit || 128000; + ocEditOutputLimit = opts.outputLimit || 16384; + if (ocEditModelRows.length === 0 && provider.apiUrl) { + fetchEditModels(); + } + } + + function cancelEditCustomProvider() { + ocEditingProviderId = null; + } + + function addEditModelRow() { + ocEditModelRows = [...ocEditModelRows, { code: '', alias: '', hidden: false }]; + } + + function removeEditModelRow(index: number) { + ocEditModelRows = ocEditModelRows.filter((_, i) => i !== index); + } + + async function fetchEditModels() { + if (!ocEditingProviderId || !ocEditBaseUrl.trim()) return; + ocEditFetching = true; + try { + const result = await ws.http('engine:opencode-provider-fetch-models', { + id: ocEditingProviderId, + }) as { models: { id: string; name?: string }[] }; + if (result.models.length === 0) throw new Error('No models returned'); + ocEditModelRows = result.models.map(m => ({ code: m.id, alias: '', hidden: false })); + } catch { + // silent — user can type manually + } finally { + ocEditFetching = false; + } + } + + async function submitEditCustomProvider() { + if (!ocEditingProviderId || !ocEditName.trim() || !ocEditBaseUrl.trim()) return; + const options = editModelRowsToOptions(ocEditModelRows); + try { + await opencodeProvidersStore.updateProvider(ocEditingProviderId, { + name: ocEditName.trim(), + apiUrl: ocEditBaseUrl.trim().replace(/\/+$/, ''), + options, + }); + ocEditingProviderId = null; + } catch (error: any) { + debug.error('settings', 'Failed to update custom provider:', error); + } } function selectCatalogProvider(provider: ModelsDevProviderItem) { @@ -2413,137 +2631,329 @@ - {#each ocProviders as provider (provider.id)} -
- -
-
- {provider.name} - {provider.slug} - {#if !provider.isEnabled} - Disabled + + {#if ocCatalogProviders.length > 0} +
+
Catalog
+
+
+ {#each ocCatalogProviders as provider (provider.id)} +
+
+
+ {provider.name} + {provider.slug} + {#if !provider.isEnabled} + Disabled + {/if} +
+
+ + +
+
+
+ {#if provider.accounts.length === 0} +

No accounts

+ {:else} + {#each provider.accounts as account (account.id)} +
+
+ + {#if ocRenamingAccountId === account.id} +
+ +
+ + +
+
+ {:else} + {account.name} + {#if account.isActive} + Active + {/if} + {/if} +
+ {#if ocRenamingAccountId !== account.id} +
+ {#if !account.isActive} + + {/if} + + +
+ {/if} +
+ {/each} + {/if} + {#if ocAddingAccountForProvider === provider.id} + {@const envNames = getProviderEnvNames(provider.slug)} + {@const primaryEnv = envNames[0] || 'API Key'} +
+ + + {#each envNames.slice(1) as envVar (envVar)} + { ocNewAccountOptions = { ...ocNewAccountOptions, [envVar]: (e.target as HTMLInputElement).value }; }} placeholder={envVar} class="w-full px-3 py-1.5 text-xs rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-violet-500 font-mono" /> + {/each} +
+ + +
+
+ {:else} + {/if}
-
- +
+ {#if ocEditingProviderId === provider.id} +
+
+ + Edit Provider — {ocEditName} +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+ + +
+
+
+ {#each ocEditModelRows as row, i (i)} +
{ ocDragIndex = i; e.dataTransfer!.effectAllowed = 'move'; }} + ondragover={(e) => e.preventDefault()} + ondrop={(e) => { e.preventDefault(); if (ocDragIndex !== null && ocDragIndex !== i) { const a = [...ocEditModelRows]; const [m] = a.splice(ocDragIndex, 1); a.splice(i, 0, m); ocEditModelRows = a; } ocDragIndex = null; }} + ondragend={() => { ocDragIndex = null; }} + > + + + + + + +
+ {/each} + {#if ocEditModelRows.length === 0} +

No models. Click Add row or Fetch.

+ {/if} +
+
+
+ + +
- - -
- {#if provider.accounts.length === 0} -

No accounts

- {:else} - {#each provider.accounts as account (account.id)} -
-
- - {#if ocRenamingAccountId === account.id} -
- + {/if} + {/each} + {/if} + + + {#if ocCustomProviders.length > 0} +
+
Custom
+
+
+ {#each ocCustomProviders as provider (provider.id)} + +
+
+
+ {provider.name} + Custom + {provider.slug} + {#if !provider.isEnabled} + Disabled + {/if} +
+
+ + +
+
+
+ {#if provider.accounts.length === 0} +

No accounts

+ {:else} + {#each provider.accounts as account (account.id)} +
+
+ + {#if ocRenamingAccountId === account.id} +
+ +
+ + +
+
+ {:else} + {account.name} + {#if account.isActive} + Active + {/if} + {/if} +
+ {#if ocRenamingAccountId !== account.id}
- - + {#if !account.isActive} + + {/if} + +
-
- {:else} - {account.name} - {#if account.isActive} - Active {/if} - {/if} +
+ {/each} + {/if} + {#if ocAddingAccountForProvider === provider.id} + {@const envNames = getProviderEnvNames(provider.slug)} + {@const primaryEnv = envNames[0] || 'API Key'} +
+ + + {#each envNames.slice(1) as envVar (envVar)} + { ocNewAccountOptions = { ...ocNewAccountOptions, [envVar]: (e.target as HTMLInputElement).value }; }} placeholder={envVar} class="w-full px-3 py-1.5 text-xs rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-violet-500 font-mono" /> + {/each} +
+ + +
- - {#if ocRenamingAccountId !== account.id} -
- {#if !account.isActive} - - {/if} - + {/if} +
+
+ {#if ocEditingProviderId === provider.id} +
+
+ + Edit Provider — {ocEditName} +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+ -
- {/if} +
+
+ {#each ocEditModelRows as row, i (i)} +
{ ocDragIndex = i; e.dataTransfer!.effectAllowed = 'move'; }} + ondragover={(e) => e.preventDefault()} + ondrop={(e) => { e.preventDefault(); if (ocDragIndex !== null && ocDragIndex !== i) { const a = [...ocEditModelRows]; const [m] = a.splice(ocDragIndex, 1); a.splice(i, 0, m); ocEditModelRows = a; } ocDragIndex = null; }} + ondragend={() => { ocDragIndex = null; }} + > + + + + + + +
+ {/each} + {#if ocEditModelRows.length === 0} +

No models. Click Add row or Fetch.

+ {/if} +
- {/each} - {/if} - - - {#if ocAddingAccountForProvider === provider.id} - {@const envNames = getProviderEnvNames(provider.slug)} - {@const primaryEnv = envNames[0] || 'API Key'} -
- - - {#each envNames.slice(1) as envVar (envVar)} - { ocNewAccountOptions = { ...ocNewAccountOptions, [envVar]: (e.target as HTMLInputElement).value }; }} - placeholder={envVar} - class="w-full px-3 py-1.5 text-xs rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-violet-500 font-mono" - /> - {/each}
- - +
- {:else} - +
{/if} -
-
- {/each} + {/each} + {/if}
@@ -2602,6 +3012,18 @@ {/if}
+
+
+
+
+
+ or +
+
+
{:else if ocAddStep === 'configuring' && ocSelectedCatalogProvider} @@ -2653,6 +3075,149 @@
+ {:else if ocAddStep === 'custom'} +
+
+ + Custom Provider +
+

Add any OpenAI-compatible API endpoint as a provider.

+ +
+ + { ocCustomSlug = autoGenerateSlug(ocCustomName); }} + placeholder="e.g. My Local LLM" + class="w-full px-3 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-violet-500/40" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + +
+
+
+ {#each ocCustomModelRows as row, i (i)} +
{ ocDragIndex = i; e.dataTransfer!.effectAllowed = 'move'; }} + ondragover={(e) => e.preventDefault()} + ondrop={(e) => { e.preventDefault(); if (ocDragIndex !== null && ocDragIndex !== i) { const a = [...ocCustomModelRows]; const [m] = a.splice(ocDragIndex, 1); a.splice(i, 0, m); ocCustomModelRows = a; } ocDragIndex = null; }} + ondragend={() => { ocDragIndex = null; }} + > + + + + + + +
+ {/each} + {#if ocCustomModelRows.length === 0} +

No models. Click Add row or Fetch.

+ {/if} +
+
+ + {#if ocAddError} +
+ + {ocAddError} +
+ {/if} + +
+ + +
+
{:else if ocAddStep === 'saving'}
diff --git a/frontend/stores/features/opencode-providers.svelte.ts b/frontend/stores/features/opencode-providers.svelte.ts index bad273dd..9248e955 100644 --- a/frontend/stores/features/opencode-providers.svelte.ts +++ b/frontend/stores/features/opencode-providers.svelte.ts @@ -100,6 +100,15 @@ export const opencodeProvidersStore = { await this.refreshProviders(); }, + async updateProvider(id: number, data: { + name?: string; + apiUrl?: string; + options?: string; + }): Promise { + await ws.http('engine:opencode-provider-update', { id, ...data }); + await this.refreshProviders(); + }, + async updateProviderOptions(id: number, options: string): Promise { await ws.http('engine:opencode-provider-update-options', { id, options }); await this.refreshProviders();