From 467ca9a79660efa410fcb74fb3eea894d0081544 Mon Sep 17 00:00:00 2001 From: Agung Maulana Date: Wed, 15 Jul 2026 12:52:06 +0700 Subject: [PATCH 1/2] 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(); From c6bba120941ee10437817d5981a16b197c718976 Mon Sep 17 00:00:00 2001 From: Arga Fairuz Date: Fri, 17 Jul 2026 00:52:34 +0700 Subject: [PATCH 2/2] feat(engines): editable accounts across engines + OpenCode provider overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let every engine account be edited beyond its name, rework the OpenCode provider UI, and fix authentication for OpenAI-compatible providers whose endpoint needs injected credentials. OpenCode providers: - Add custom OpenAI-compatible providers with an optional (blank) API key, edit them (name, slug, base URL, models) in a modal — restricted to custom providers since catalog entries are defined upstream. - Manage credentials at the account level: each account edits the provider's real secret fields (e.g. Cloudflare Workers AI shows CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_KEY), all-optional but filled together; add multiple accounts per provider (catalog and custom). - Move context/output limits onto each model row instead of one provider-wide default (legacy provider-level limits still honored as a fallback). - Fix 401s for api_url providers: inject the active account's apiKey into the provider config and substitute ${VAR} placeholders (e.g. the account id in Cloudflare's base URL), authenticating model discovery too. - UI polish: brighter "Fetch from /v1/models" button, normal-font inputs, Ollama/9router placeholders. Per-engine account editing: - Copilot (token), Qwen (API key + preset), and Codex (API key) accounts can update their credential in place, not just rename. - OAuth accounts (Claude Code, Codex ChatGPT) gain a Re-authenticate action that reruns the login flow and updates the existing account in place via a new optional reauthAccountId on the setup flow. --- backend/database/queries/engine-queries.ts | 3 +- backend/engine/adapters/opencode/server.ts | 36 +- backend/ws/engine/claude/accounts.ts | 50 +- backend/ws/engine/codex/accounts.ts | 40 +- backend/ws/engine/copilot/accounts.ts | 12 + backend/ws/engine/opencode/providers.ts | 24 +- backend/ws/engine/qwen/accounts.ts | 22 + .../settings/engines/AIEnginesSettings.svelte | 528 +++++++++--------- .../features/opencode-providers.svelte.ts | 6 + 9 files changed, 422 insertions(+), 299 deletions(-) diff --git a/backend/database/queries/engine-queries.ts b/backend/database/queries/engine-queries.ts index 205b719c..8ff468af 100644 --- a/backend/database/queries/engine-queries.ts +++ b/backend/database/queries/engine-queries.ts @@ -115,10 +115,11 @@ 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 { + updateProvider(id: number, data: { slug?: string; name?: string; apiUrl?: string; options?: string }): void { const db = getDatabase(); const sets: string[] = []; const vals: (string | number)[] = []; + if (data.slug !== undefined) { sets.push('slug = ?'); vals.push(data.slug); } 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); } diff --git a/backend/engine/adapters/opencode/server.ts b/backend/engine/adapters/opencode/server.ts index 8ab5529b..eccffb22 100644 --- a/backend/engine/adapters/opencode/server.ts +++ b/backend/engine/adapters/opencode/server.ts @@ -26,7 +26,7 @@ import type { OpencodeClient } from '@opencode-ai/sdk'; import type { Subprocess } from 'bun'; import { getOpenCodeMcpConfig } from '../../../mcp'; import { engineQueries, settingsQueries } from '../../../database/queries'; -import { generateOpenCodeProviderConfig } from './config'; +import { generateOpenCodeProviderConfig, parseCredentialMap } from './config'; import type { OpenCodeInlineAgent } from '$backend/subagents'; import { resolveBinaryWithRefresh } from '$backend/utils/cli'; import { getEngineUserConfigDir } from '$backend/utils/paths'; @@ -205,9 +205,32 @@ async function spawnServer(key: string, spec: ServerConfigSpec): Promise credMap[name] ?? ''); + const keys = Object.keys(credMap); + const tokenKey = keys.find(k => /API[_-]?(?:KEY|TOKEN)$/i.test(k)) ?? keys.find(k => /(?:KEY|TOKEN)$/i.test(k)); + if (tokenKey) apiKey = credMap[tokenKey]; + } else { + apiKey = activeAccount.credential; + } + } + const models: Record = {}; + // Per-model limits live in `modelLimits`; legacy provider-level + // contextLimit/outputLimit remain a fallback for older rows. let defaultContext = 128000; let defaultOutput = 16384; + let modelLimits: Record = {}; let modelNames: Record = {}; let hiddenSet = new Set(); try { @@ -215,11 +238,13 @@ async function spawnServer(key: string, spec: ServerConfigSpec): Promise; hiddenModels?: string[]; + modelLimits?: Record; contextLimit?: number; outputLimit?: number; }; if (opts.contextLimit) defaultContext = opts.contextLimit; if (opts.outputLimit) defaultOutput = opts.outputLimit; + if (opts.modelLimits) modelLimits = opts.modelLimits; if (opts.modelNames) modelNames = opts.modelNames; if (opts.hiddenModels) hiddenSet = new Set(opts.hiddenModels); } catch { @@ -227,7 +252,8 @@ async function spawnServer(key: string, spec: ServerConfigSpec): Promise { - models[id] = { name: modelNames[id] || id, limit: { context: defaultContext, output: defaultOutput } }; + const lim = modelLimits[id]; + models[id] = { name: modelNames[id] || id, limit: { context: lim?.context || defaultContext, output: lim?.output || defaultOutput } }; }; try { @@ -244,8 +270,9 @@ async function spawnServer(key: string, spec: ServerConfigSpec): Promise 0) modelOrder = o.models.filter(id => !hiddenSet.has(id)); } catch { /* ignore */ } - const providerOptions: Record = { baseURL: provider.api_url }; + const providerOptions: Record = { baseURL }; + if (apiKey) providerOptions.apiKey = apiKey; if (modelOrder) providerOptions._modelOrder = modelOrder; providerSection[provider.slug] = { npm: provider.npm || '@ai-sdk/openai-compatible', diff --git a/backend/ws/engine/claude/accounts.ts b/backend/ws/engine/claude/accounts.ts index 6bdd50aa..c22416b8 100644 --- a/backend/ws/engine/claude/accounts.ts +++ b/backend/ws/engine/claude/accounts.ts @@ -90,9 +90,34 @@ interface SetupProcess { disposed: boolean; phase: SetupPhase; accountName: string; + /** When set, re-authenticate this existing account in place instead of creating a new one. */ + reauthAccountId: number | null; urlEmitted: boolean; } +/** + * Persist a freshly captured OAuth token — either updating an existing account + * in place (re-authentication) or creating a new one. Always resets the Claude + * environment so the next stream reads the new credential. + */ +function persistClaudeAccount(entry: SetupProcess, token: string): { ok: true; accountId: number } | { ok: false; error: string } { + const provider = engineQueries.getProviderBySlug('claude-code', 'anthropic'); + if (!provider) return { ok: false, error: 'Anthropic provider not found in DB' }; + + if (entry.reauthAccountId != null) { + const existing = engineQueries.getAccount(entry.reauthAccountId); + if (!existing) return { ok: false, error: 'Account to re-authenticate not found' }; + engineQueries.updateAccountCredential(entry.reauthAccountId, token); + if (entry.accountName) engineQueries.renameAccount(entry.reauthAccountId, entry.accountName); + resetEnvironment(); + return { ok: true, accountId: entry.reauthAccountId }; + } + + const account = engineQueries.createAccount(provider.id, entry.accountName, token); + resetEnvironment(); + return { ok: true, accountId: account.id }; +} + const setupProcesses = new Map(); const userSetups = new Map(); @@ -226,6 +251,7 @@ export const accountsHandler = createRouter() disposed: false, phase: 'waiting-url', accountName: '', + reauthAccountId: null, urlEmitted: false }; setupProcesses.set(setupId, entry); @@ -262,21 +288,19 @@ export const accountsHandler = createRouter() entry.phase = 'done'; debug.log('engine', `[${setupId}] Token captured`); - const provider = engineQueries.getProviderBySlug('claude-code', 'anthropic'); - if (!provider) { + const result = persistClaudeAccount(entry, token); + if (!result.ok) { ws.emit.user(userId, 'engine:claude-account-setup-error', { setupId, - message: 'Anthropic provider not found in DB' + message: result.error }); cleanupSetup(setupId); return; } - const account = engineQueries.createAccount(provider.id, entry.accountName, token); - resetEnvironment(); ws.emit.user(userId, 'engine:claude-account-setup-complete', { setupId, - accountId: account.id + accountId: result.accountId }); cleanupSetup(setupId); @@ -316,20 +340,18 @@ export const accountsHandler = createRouter() const token = extractOAuthToken(clean); if (token) { debug.log('engine', `[${setupId}] Token found in final buffer`); - const provider = engineQueries.getProviderBySlug('claude-code', 'anthropic'); - if (!provider) { + const result = persistClaudeAccount(entry, token); + if (!result.ok) { ws.emit.user(userId, 'engine:claude-account-setup-error', { setupId, - message: 'Anthropic provider not found in DB' + message: result.error }); cleanupSetup(setupId); return; } - const account = engineQueries.createAccount(provider.id, entry.accountName, token); - resetEnvironment(); ws.emit.user(userId, 'engine:claude-account-setup-complete', { setupId, - accountId: account.id + accountId: result.accountId }); } else { // Try to extract a meaningful error message @@ -350,7 +372,8 @@ export const accountsHandler = createRouter() data: t.Object({ setupId: t.String(), code: t.String(), - name: t.String({ minLength: 1 }) + name: t.String({ minLength: 1 }), + reauthAccountId: t.Optional(t.Number()) }) }, async ({ data, conn }) => { const userId = ws.getUserId(conn); @@ -371,6 +394,7 @@ export const accountsHandler = createRouter() entry.buffer = ''; entry.phase = 'waiting-token'; entry.accountName = data.name; + entry.reauthAccountId = data.reauthAccountId ?? null; // Write auth code to PTY, then press Enter after a short delay // The delay ensures the PTY input buffer has processed the code before receiving Enter diff --git a/backend/ws/engine/codex/accounts.ts b/backend/ws/engine/codex/accounts.ts index f700c0cb..09422d03 100644 --- a/backend/ws/engine/codex/accounts.ts +++ b/backend/ws/engine/codex/accounts.ts @@ -93,6 +93,8 @@ interface CodexLoginProcess { userId: string; accountName: string; deviceAuth: boolean; + /** When set, re-authenticate this existing account in place instead of creating a new one. */ + reauthAccountId: number | null; timer: ReturnType; } @@ -157,7 +159,7 @@ function isLoginSuccess(buffer: string): boolean { return /Successfully logged in/i.test(buffer); } -function persistChatGptLoginResult(accountName: string): { ok: true; accountId: number } | { ok: false; error: string } { +function persistChatGptLoginResult(accountName: string, reauthAccountId?: number | null): { ok: true; accountId: number } | { ok: false; error: string } { const provider = engineQueries.getProviderBySlug('codex', 'openai'); if (!provider) return { ok: false, error: 'OpenAI Codex provider not found in DB' }; @@ -167,6 +169,17 @@ function persistChatGptLoginResult(accountName: string): { ok: true; accountId: } const credential = serializeCodexCredential({ kind: 'chatgpt', authJson }); + + // Re-authentication: replace the existing account's blob in place so its + // name and active state survive. + if (reauthAccountId != null) { + const existing = engineQueries.getAccount(reauthAccountId); + if (!existing) return { ok: false, error: 'Account to re-authenticate not found' }; + engineQueries.updateAccountCredential(reauthAccountId, credential); + if (accountName) engineQueries.renameAccount(reauthAccountId, accountName); + return { ok: true, accountId: reauthAccountId }; + } + const account = engineQueries.createAccount(provider.id, accountName, credential); return { ok: true, accountId: account.id }; } @@ -285,6 +298,23 @@ export const codexAccountsHandler = createRouter() return { success: true }; }) + // Replace the API key for an api_key-mode account. ChatGPT accounts have no + // editable key — they re-authenticate via the login flow instead. + .http('engine:codex-accounts-update-api-key', { + data: t.Object({ id: t.Number(), apiKey: t.String({ minLength: 1 }) }), + response: t.Object({ success: t.Boolean() }) + }, async ({ data }) => { + const account = engineQueries.getAccount(data.id); + if (!account) throw new Error('Account not found'); + if (authModeOf(account) !== 'api_key') { + throw new Error('Only API-key accounts can have their key edited; ChatGPT accounts re-authenticate instead'); + } + engineQueries.updateAccountCredential(data.id, serializeCodexCredential({ kind: 'api_key', apiKey: data.apiKey.trim() })); + const active = engineQueries.getActiveAccountForEngine('codex'); + if (active?.id === data.id) await disposeCodexEngines(); + return { success: true }; + }) + // ─── ChatGPT browser OAuth flow ─── // // Spawn `codex login` in a PTY (the CLI silently no-ops when stdout @@ -294,7 +324,8 @@ export const codexAccountsHandler = createRouter() .on('engine:codex-account-setup-start', { data: t.Object({ name: t.String({ minLength: 1 }), - deviceAuth: t.Optional(t.Boolean()) + deviceAuth: t.Optional(t.Boolean()), + reauthAccountId: t.Optional(t.Number()) }) }, async ({ data, conn }) => { const userId = ws.getUserId(conn); @@ -363,6 +394,7 @@ export const codexAccountsHandler = createRouter() userId, accountName: data.name.trim(), deviceAuth: !!data.deviceAuth, + reauthAccountId: data.reauthAccountId ?? null, timer, }; setupProcesses.set(setupId, entry); @@ -409,7 +441,7 @@ export const codexAccountsHandler = createRouter() if (isLoginSuccess(clean) && !entry.completed) { entry.completed = true; debug.log('engine', `[${setupId}] Codex login success — persisting auth.json`); - const result = persistChatGptLoginResult(entry.accountName); + const result = persistChatGptLoginResult(entry.accountName, entry.reauthAccountId); if (result.ok) { disposeCodexEngines().finally(() => { ws.emit.user(userId, 'engine:codex-account-setup-complete', { @@ -438,7 +470,7 @@ export const codexAccountsHandler = createRouter() // in the final chunk right before exit. if (isLoginSuccess(clean)) { entry.completed = true; - const result = persistChatGptLoginResult(entry.accountName); + const result = persistChatGptLoginResult(entry.accountName, entry.reauthAccountId); if (result.ok) { disposeCodexEngines().finally(() => { ws.emit.user(userId, 'engine:codex-account-setup-complete', { diff --git a/backend/ws/engine/copilot/accounts.ts b/backend/ws/engine/copilot/accounts.ts index 0c8ade42..8a9ff3a4 100644 --- a/backend/ws/engine/copilot/accounts.ts +++ b/backend/ws/engine/copilot/accounts.ts @@ -111,6 +111,18 @@ export const copilotAccountsHandler = createRouter() return { success: true }; }) + // Replace the stored PAT for an account. When the edited account is the + // active one, drop engine instances so the next stream picks up the token. + .http('engine:copilot-accounts-update-token', { + data: t.Object({ id: t.Number(), token: t.String({ minLength: 1 }) }), + response: t.Object({ success: t.Boolean() }) + }, async ({ data }) => { + engineQueries.updateAccountCredential(data.id, data.token.trim()); + const active = engineQueries.getActiveAccountForEngine('copilot'); + if (active?.id === data.id) await disposeCopilotEngines(); + return { success: true }; + }) + // Restart all Copilot engine instances. Use after changing the active token // so subsequent models:list / chat calls re-initialise with fresh credentials. .http('engine:copilot-restart', { diff --git a/backend/ws/engine/opencode/providers.ts b/backend/ws/engine/opencode/providers.ts index 7081c19d..0a07a6e6 100644 --- a/backend/ws/engine/opencode/providers.ts +++ b/backend/ws/engine/opencode/providers.ts @@ -77,7 +77,10 @@ export const openCodeProviderHandler = createRouter() apiUrl: t.Optional(t.String()), options: t.Optional(t.String()), accountName: t.String({ minLength: 1 }), - credential: t.String({ minLength: 1 }), + // Empty string allowed: custom OpenAI-compatible endpoints (e.g. a + // local LLM) often need no API key. The frontend still requires a + // credential for catalog providers via its own validation. + credential: t.String(), }), response: t.Object({ provider: ProviderSchema @@ -136,6 +139,7 @@ export const openCodeProviderHandler = createRouter() .http('engine:opencode-provider-update', { data: t.Object({ id: t.Number(), + slug: t.Optional(t.String({ minLength: 1 })), name: t.Optional(t.String()), apiUrl: t.Optional(t.String()), options: t.Optional(t.String()), @@ -145,7 +149,16 @@ export const openCodeProviderHandler = createRouter() if (data.options !== undefined) { try { JSON.parse(data.options); } catch { throw new Error('Invalid JSON for options'); } } + // Changing a provider's slug rewrites its identity in the generated + // opencode config — reject collisions so two providers can't share one. + if (data.slug !== undefined) { + const existing = engineQueries.getProviderBySlug('opencode', data.slug); + if (existing && existing.id !== data.id) { + throw new Error(`Provider "${data.slug}" already configured`); + } + } engineQueries.updateProvider(data.id, { + slug: data.slug, name: data.name, apiUrl: data.apiUrl, options: data.options, @@ -221,6 +234,15 @@ export const openCodeProviderHandler = createRouter() return { success: true }; }) + .http('engine:opencode-account-update-credential', { + // Empty string allowed — a keyless custom endpoint may clear its key. + data: t.Object({ accountId: t.Number(), credential: t.String() }), + response: t.Object({ success: t.Boolean() }) + }, async ({ data }) => { + engineQueries.updateAccountCredential(data.accountId, data.credential); + return { success: true }; + }) + // ═══════════════════════════════════════ // Models.dev Catalog // ═══════════════════════════════════════ diff --git a/backend/ws/engine/qwen/accounts.ts b/backend/ws/engine/qwen/accounts.ts index b6f4168b..eade4857 100644 --- a/backend/ws/engine/qwen/accounts.ts +++ b/backend/ws/engine/qwen/accounts.ts @@ -134,4 +134,26 @@ export const qwenAccountsHandler = createRouter() }, async ({ data }) => { engineQueries.renameAccount(data.id, data.name.trim()); return { success: true }; + }) + + // Update an account's API key and/or preset. Both are optional — an omitted + // (or blank) apiKey keeps the stored one, so the user can re-point the + // preset without re-entering the secret, and vice versa. + .http('engine:qwen-accounts-update', { + data: t.Object({ + id: t.Number(), + apiKey: t.Optional(t.String()), + preset: t.Optional(PRESET_LITERALS), + }), + response: t.Object({ success: t.Boolean() }) + }, async ({ data }) => { + const account = engineQueries.getAccount(data.id); + if (!account) throw new Error('Account not found'); + const current = parseQwenCredential(account.credential); + const apiKey = data.apiKey?.trim() ? data.apiKey.trim() : current.apiKey; + const preset = data.preset ?? current.preset; + engineQueries.updateAccountCredential(data.id, serializeQwenCredential({ apiKey, preset })); + const active = engineQueries.getActiveAccountForEngine('qwen'); + if (active?.id === data.id) await disposeQwenEngines(); + return { success: true }; }); diff --git a/frontend/components/settings/engines/AIEnginesSettings.svelte b/frontend/components/settings/engines/AIEnginesSettings.svelte index 97b53316..8cc3b861 100644 --- a/frontend/components/settings/engines/AIEnginesSettings.svelte +++ b/frontend/components/settings/engines/AIEnginesSettings.svelte @@ -31,7 +31,7 @@ import { codexAccountsStore, type CodexAccountItem } from '$frontend/stores/features/codex-accounts.svelte'; import { qwenAccountsStore, type QwenAccountItem } from '$frontend/stores/features/qwen-accounts.svelte'; import { qwenPresetsStore } from '$frontend/stores/features/qwen-presets.svelte'; - import { opencodeProvidersStore, type OpenCodeProviderItem, type ModelsDevProviderItem } from '$frontend/stores/features/opencode-providers.svelte'; + import { opencodeProvidersStore, type OpenCodeProviderItem, type OpenCodeAccountItem, 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'; @@ -85,9 +85,10 @@ let copilotAddToken = $state(''); let copilotAddError = $state(''); - // Copilot rename + // Copilot rename / edit (name + optional new token) let copilotRenamingId = $state(null); let copilotRenameValue = $state(''); + let copilotRenameToken = $state(''); // Copilot delete confirmation let copilotDeleteDialogOpen = $state(false); @@ -136,6 +137,9 @@ // Codex rename / delete / restart let codexRenamingId = $state(null); let codexRenameValue = $state(''); + let codexRenameApiKey = $state(''); + // When re-authenticating an existing ChatGPT account, the login flow targets it in place. + let codexReauthAccountId = $state(null); let codexDeleteDialogOpen = $state(false); let codexDeleteTargetId = $state(null); let codexRestarting = $state(false); @@ -163,6 +167,8 @@ // Qwen rename / delete let qwenRenamingId = $state(null); let qwenRenameValue = $state(''); + let qwenRenameApiKey = $state(''); + let qwenRenamePreset = $state('dashscope-intl'); let qwenDeleteDialogOpen = $state(false); let qwenDeleteTargetId = $state(null); @@ -178,6 +184,8 @@ // Rename let claudeCodeRenamingId = $state(null); let claudeCodeRenameValue = $state(''); + // When re-authenticating an existing account, the setup-token flow updates it in place. + let claudeCodeReauthAccountId = $state(null); // Delete confirmation dialog let claudeCodeDeleteDialogOpen = $state(false); @@ -202,24 +210,23 @@ let ocAddOptions = $state>({}); let ocCatalogRefreshing = $state(false); + // Model row: per-model context/output limits (null = fall back to defaults). + type OcModelRow = { code: string; alias: string; hidden: boolean; context: number | null; output: number | null }; + // 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 ocCustomModelRows = $state([]); let ocCustomFetching = $state(false); let ocEditingProviderId = $state(null); // Edit form (reuses custom provider form fields) let ocEditName = $state(''); + let ocEditSlug = $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 ocEditModelRows = $state([]); let ocEditFetching = $state(false); let ocDragIndex = $state(null); @@ -231,6 +238,17 @@ // Provider account management let ocRenamingAccountId = $state(null); let ocRenameValue = $state(''); + // Credential fields shown while editing an account, keyed by the provider's + // env-var names (e.g. CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_KEY). All are + // optional but must be filled together — blank across the board keeps the + // stored secret. Custom providers fall back to a single 'API Key' field. + let ocRenameEnvNames = $state([]); + let ocRenameEnvValues = $state>({}); + // Valid when the credential fields are either all blank or all filled. + const ocRenameCredentialComplete = $derived.by(() => { + const vals = ocRenameEnvNames.map(n => (ocRenameEnvValues[n] ?? '').trim()); + return vals.every(v => !v) || vals.every(v => v); + }); let ocAddingAccountForProvider = $state(null); let ocNewAccountName = $state(''); let ocNewAccountApiKey = $state(''); @@ -453,6 +471,7 @@ claudeCodeSetupError = ''; claudeCodeAuthCode = ''; claudeCodeAccountName = ''; + claudeCodeReauthAccountId = null; claudeDebugPhase = ''; claudeDebugBufferLen = 0; hasClaudeDebugData = false; @@ -465,6 +484,14 @@ ws.emit('engine:claude-account-setup-start', {}); } + // Re-authenticate an existing account in place: run the setup-token flow but + // carry the account id (+ prefill its name) so completion updates it. + function startClaudeCodeReauth(account: ClaudeCodeAccountItem) { + startClaudeCodeSetup(); + claudeCodeReauthAccountId = account.id; + claudeCodeAccountName = account.name; + } + function submitClaudeCodeAuth() { if (!claudeCodeSetupId || !claudeCodeAuthCode.trim() || !claudeCodeAccountName.trim()) return; @@ -475,7 +502,8 @@ ws.emit('engine:claude-account-setup-submit', { setupId: claudeCodeSetupId, code: claudeCodeAuthCode.trim(), - name: claudeCodeAccountName.trim() + name: claudeCodeAccountName.trim(), + ...(claudeCodeReauthAccountId != null ? { reauthAccountId: claudeCodeReauthAccountId } : {}) }); } @@ -493,6 +521,7 @@ claudeCodeAuthCode = ''; claudeCodeAccountName = ''; claudeCodeSetupError = ''; + claudeCodeReauthAccountId = null; } async function switchClaudeCodeAccount(id: number) { @@ -633,14 +662,19 @@ function startCopilotRename(account: CopilotAccountItem) { copilotRenamingId = account.id; copilotRenameValue = account.name; + copilotRenameToken = ''; } async function submitCopilotRename() { if (copilotRenamingId === null || !copilotRenameValue.trim()) return; + const id = copilotRenamingId; try { - await ws.http('engine:copilot-accounts-rename', { id: copilotRenamingId, name: copilotRenameValue.trim() }); + await ws.http('engine:copilot-accounts-rename', { id, name: copilotRenameValue.trim() }); + const token = copilotRenameToken.trim(); + if (token) await ws.http('engine:copilot-accounts-update-token', { id, token }); copilotRenamingId = null; copilotRenameValue = ''; + copilotRenameToken = ''; await copilotAccountsStore.refresh(); } catch { // Ignore @@ -650,6 +684,7 @@ function cancelCopilotRename() { copilotRenamingId = null; copilotRenameValue = ''; + copilotRenameToken = ''; } async function handleCopilotRestart() { @@ -689,6 +724,7 @@ codexDeviceCode = null; codexUrlCopied = false; codexCodeCopied = false; + codexReauthAccountId = null; } function proceedCodexMode() { @@ -740,6 +776,7 @@ ws.emit('engine:codex-account-setup-start', { name: codexAddName.trim(), deviceAuth: true, + ...(codexReauthAccountId != null ? { reauthAccountId: codexReauthAccountId } : {}), }); } @@ -756,6 +793,7 @@ codexAddError = ''; codexUrlCopied = false; codexCodeCopied = false; + codexReauthAccountId = null; } async function copyCodexVerificationUrl() { @@ -806,14 +844,20 @@ function startCodexRename(account: CodexAccountItem) { codexRenamingId = account.id; codexRenameValue = account.name; + codexRenameApiKey = ''; } async function submitCodexRename() { if (codexRenamingId === null || !codexRenameValue.trim()) return; + const id = codexRenamingId; try { - await ws.http('engine:codex-accounts-rename', { id: codexRenamingId, name: codexRenameValue.trim() }); + await ws.http('engine:codex-accounts-rename', { id, name: codexRenameValue.trim() }); + // api_key accounts may also replace their key; blank keeps the stored one. + const apiKey = codexRenameApiKey.trim(); + if (apiKey) await ws.http('engine:codex-accounts-update-api-key', { id, apiKey }); codexRenamingId = null; codexRenameValue = ''; + codexRenameApiKey = ''; await codexAccountsStore.refresh(); } catch { // Ignore @@ -823,6 +867,16 @@ function cancelCodexRename() { codexRenamingId = null; codexRenameValue = ''; + codexRenameApiKey = ''; + } + + // Re-authenticate an existing ChatGPT account in place by re-running the + // device-code login flow, reusing the same add-account UI. + function startCodexReauth(account: CodexAccountItem) { + codexReauthAccountId = account.id; + codexAddName = account.name; + codexAddMode = 'chatgpt'; + startCodexChatGptLogin(); } async function handleCodexRestart() { @@ -843,31 +897,11 @@ // ── 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 { + function modelRowsToOptions(rows: OcModelRow[]): string { const modelIds: string[] = []; const modelNames: Record = {}; const hiddenModels: string[] = []; + const modelLimits: Record = {}; for (const row of rows) { const code = row.code.trim(); if (!code) continue; @@ -875,13 +909,16 @@ const alias = row.alias.trim(); if (alias) modelNames[code] = alias; if (row.hidden) hiddenModels.push(code); + // Store a per-model limit only when the user set at least one value. + if (row.context || row.output) { + modelLimits[code] = { context: row.context || 128000, output: row.output || 16384 }; + } } 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, + modelLimits: Object.keys(modelLimits).length > 0 ? modelLimits : undefined, }); } @@ -910,7 +947,7 @@ ocCustomSlug = ''; ocCustomBaseUrl = ''; ocCustomApiKey = ''; - ocCustomModelRows = [{ code: '', alias: '', hidden: false }]; + ocCustomModelRows = [{ code: '', alias: '', hidden: false, context: null, output: null }]; ocCustomFetching = false; } @@ -919,7 +956,7 @@ } function addCustomModelRow() { - ocCustomModelRows = [...ocCustomModelRows, { code: '', alias: '', hidden: false }]; + ocCustomModelRows = [...ocCustomModelRows, { code: '', alias: '', hidden: false, context: null, output: null }]; } function removeCustomModelRow(index: number) { @@ -937,7 +974,7 @@ 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 })); + ocCustomModelRows = ids.map(id => ({ code: id, alias: '', hidden: false, context: null, output: null })); } catch (err: any) { ocAddError = `Failed to fetch models: ${err?.message || 'unknown error'}`; } finally { @@ -976,23 +1013,26 @@ function startEditCustomProvider(provider: OpenCodeProviderItem) { ocEditingProviderId = provider.id; ocEditName = provider.name; + ocEditSlug = provider.slug; ocEditBaseUrl = provider.apiUrl || ''; - ocEditApiKey = ''; const opts = JSON.parse(provider.options || '{}') as { models?: string[]; modelNames?: Record; hiddenModels?: string[]; + modelLimits?: Record; contextLimit?: number; outputLimit?: number; }; const hiddenSet = new Set(opts.hiddenModels || []); + // Seed each row from its per-model limit, falling back to a legacy + // provider-level limit (older data) so nothing is silently dropped. ocEditModelRows = (opts.models || []).map(id => ({ code: id, alias: opts.modelNames?.[id] || '', hidden: hiddenSet.has(id), + context: opts.modelLimits?.[id]?.context ?? opts.contextLimit ?? null, + output: opts.modelLimits?.[id]?.output ?? opts.outputLimit ?? null, })); - ocEditContextLimit = opts.contextLimit || 128000; - ocEditOutputLimit = opts.outputLimit || 16384; if (ocEditModelRows.length === 0 && provider.apiUrl) { fetchEditModels(); } @@ -1003,7 +1043,7 @@ } function addEditModelRow() { - ocEditModelRows = [...ocEditModelRows, { code: '', alias: '', hidden: false }]; + ocEditModelRows = [...ocEditModelRows, { code: '', alias: '', hidden: false, context: null, output: null }]; } function removeEditModelRow(index: number) { @@ -1018,7 +1058,7 @@ 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 })); + ocEditModelRows = result.models.map(m => ({ code: m.id, alias: '', hidden: false, context: null, output: null })); } catch { // silent — user can type manually } finally { @@ -1027,10 +1067,11 @@ } async function submitEditCustomProvider() { - if (!ocEditingProviderId || !ocEditName.trim() || !ocEditBaseUrl.trim()) return; - const options = editModelRowsToOptions(ocEditModelRows); + if (!ocEditingProviderId || !ocEditName.trim() || !ocEditSlug.trim() || !ocEditBaseUrl.trim()) return; + const options = modelRowsToOptions(ocEditModelRows); try { await opencodeProvidersStore.updateProvider(ocEditingProviderId, { + slug: ocEditSlug.trim(), name: ocEditName.trim(), apiUrl: ocEditBaseUrl.trim().replace(/\/+$/, ''), options, @@ -1156,21 +1197,38 @@ await opencodeProvidersStore.switchAccount(accountId); } - function startOCRename(accountId: number, currentName: string) { - ocRenamingAccountId = accountId; - ocRenameValue = currentName; + function startOCRename(account: OpenCodeAccountItem, provider: OpenCodeProviderItem) { + ocRenamingAccountId = account.id; + ocRenameValue = account.name; + // Catalog providers expose their real env-var names; custom (api_url-only) + // providers have none, so present a single generic API key field. + const envNames = getProviderEnvNames(provider.slug); + ocRenameEnvNames = envNames.length > 0 ? envNames : ['API Key']; + ocRenameEnvValues = {}; } async function submitOCRename() { - if (ocRenamingAccountId === null || !ocRenameValue.trim()) return; - await opencodeProvidersStore.renameAccount(ocRenamingAccountId, ocRenameValue.trim()); + if (ocRenamingAccountId === null || !ocRenameValue.trim() || !ocRenameCredentialComplete) return; + const accountId = ocRenamingAccountId; + await opencodeProvidersStore.renameAccount(accountId, ocRenameValue.trim()); + // Update credentials only when every field is filled; all-blank keeps the + // stored secret. buildAccountCredential returns a raw string for a single + // field (custom providers) or a JSON bundle for multi-secret providers. + const values = ocRenameEnvNames.map(n => (ocRenameEnvValues[n] ?? '').trim()); + if (values.length > 0 && values.every(v => v)) { + const primary = (ocRenameEnvValues[ocRenameEnvNames[0]] ?? '').trim(); + const credential = buildAccountCredential(ocRenameEnvNames, primary, ocRenameEnvValues); + await opencodeProvidersStore.updateAccountCredential(accountId, credential); + } ocRenamingAccountId = null; ocRenameValue = ''; + ocRenameEnvValues = {}; } function cancelOCRename() { ocRenamingAccountId = null; ocRenameValue = ''; + ocRenameEnvValues = {}; } function confirmDeleteOCProvider(provider: OpenCodeProviderItem) { @@ -1324,14 +1382,21 @@ function startQwenRename(account: QwenAccountItem) { qwenRenamingId = account.id; qwenRenameValue = account.name; + qwenRenameApiKey = ''; + qwenRenamePreset = account.preset; } async function submitQwenRename() { if (qwenRenamingId === null || !qwenRenameValue.trim()) return; + const id = qwenRenamingId; try { - await ws.http('engine:qwen-accounts-rename', { id: qwenRenamingId, name: qwenRenameValue.trim() }); + await ws.http('engine:qwen-accounts-rename', { id, name: qwenRenameValue.trim() }); + // Update key/preset in one call — omit apiKey when blank to keep the stored one. + const apiKey = qwenRenameApiKey.trim(); + await ws.http('engine:qwen-accounts-update', { id, preset: qwenRenamePreset, ...(apiKey ? { apiKey } : {}) }); qwenRenamingId = null; qwenRenameValue = ''; + qwenRenameApiKey = ''; await qwenAccountsStore.refresh(); } catch { // Ignore @@ -1341,6 +1406,7 @@ function cancelQwenRename() { qwenRenamingId = null; qwenRenameValue = ''; + qwenRenameApiKey = ''; } async function copyClaudeCodeAuthUrl() { @@ -1464,7 +1530,7 @@
Anthropic - anthropic + anthropic Built-in
@@ -1526,6 +1592,7 @@ {/if} +
@@ -1782,7 +1849,7 @@
GitHub - github + github Built-in
@@ -1798,11 +1865,10 @@ {#if copilotRenamingId === account.id}
- +
+ + +
@@ -2031,7 +2097,7 @@
OpenAI - openai + openai Built-in
@@ -2046,11 +2112,12 @@ {#if codexRenamingId === account.id}
- +
+ + {#if account.authMode !== 'chatgpt'} + + {/if} +
{/if} - + {/if} + @@ -2642,15 +2716,12 @@
{provider.name} - {provider.slug} + {provider.slug} {#if !provider.isEnabled} Disabled {/if}
- @@ -2666,9 +2737,9 @@ {#if ocRenamingAccountId === account.id}
- +
{#each ocRenameEnvNames as envName (envName)} { ocRenameEnvValues = { ...ocRenameEnvValues, [envName]: (e.target as HTMLInputElement).value }; }} placeholder={`${envName} (leave blank to keep)`} class="w-full px-2 py-0.5 text-xs rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-violet-500" />{/each}
- +
@@ -2684,7 +2755,7 @@ {#if !account.isActive} {/if} - +
{/if} @@ -2696,9 +2767,9 @@ {@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" /> + { 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" /> {/each}
@@ -2712,83 +2783,6 @@ {/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} {/each} {/if} @@ -2805,7 +2799,7 @@
{provider.name} Custom - {provider.slug} + {provider.slug} {#if !provider.isEnabled} Disabled {/if} @@ -2829,9 +2823,9 @@ {#if ocRenamingAccountId === account.id}
- +
{#each ocRenameEnvNames as envName (envName)} { ocRenameEnvValues = { ...ocRenameEnvValues, [envName]: (e.target as HTMLInputElement).value }; }} placeholder={`${envName} (leave blank to keep)`} class="w-full px-2 py-0.5 text-xs rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-violet-500" />{/each}
- +
@@ -2847,7 +2841,7 @@ {#if !account.isActive} {/if} - +
{/if} @@ -2859,9 +2853,9 @@ {@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" /> + { 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" /> {/each}
@@ -2875,83 +2869,6 @@ {/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} {/each} {/if} @@ -2976,7 +2893,7 @@
@@ -3089,7 +3006,7 @@ type="text" bind:value={ocCustomName} oninput={() => { ocCustomSlug = autoGenerateSlug(ocCustomName); }} - placeholder="e.g. My Local LLM" + placeholder="e.g. Ollama (local), 9router" 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" />
@@ -3099,8 +3016,8 @@
@@ -3109,8 +3026,8 @@
@@ -3119,37 +3036,17 @@
-
-
- - -
-
- - -
-
- @@ -3173,7 +3070,7 @@ type="text" bind:value={row.code} placeholder="model-id" - class="flex-[3] px-2.5 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-2 focus:ring-violet-500/40 font-mono" + class="flex-[3] px-2.5 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-2 focus:ring-violet-500/40" /> + + @@ -3253,6 +3152,83 @@
+ + +
+
+ + Edit Provider — {ocEditName} +
+ +
+ + +
+
+ + +

Changing the slug rewrites this provider's identity — restart the server afterwards.

+
+
+ + +
+ +
+
+ +
+ + +
+
+
+ {#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} +
+
+ +
+ + +
+
+
+ { claudeCodeDeleteDialogOpen = false; claudeCodeDeleteTargetId = null; }} diff --git a/frontend/stores/features/opencode-providers.svelte.ts b/frontend/stores/features/opencode-providers.svelte.ts index 9248e955..aa32b300 100644 --- a/frontend/stores/features/opencode-providers.svelte.ts +++ b/frontend/stores/features/opencode-providers.svelte.ts @@ -101,6 +101,7 @@ export const opencodeProvidersStore = { }, async updateProvider(id: number, data: { + slug?: string; name?: string; apiUrl?: string; options?: string; @@ -138,6 +139,11 @@ export const opencodeProvidersStore = { await this.refreshProviders(); }, + async updateAccountCredential(accountId: number, credential: string): Promise { + await ws.http('engine:opencode-account-update-credential', { accountId, credential }); + await this.refreshProviders(); + }, + // ======================================================================== // Models.dev Catalog // ========================================================================