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} + @@ -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 // ========================================================================