diff --git a/CLAUDE.md b/CLAUDE.md index a5fe24c8..dd8b2d5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,9 +115,10 @@ Long-form docs for specific features. Read the relevant file before making chang ## Common Tasks -### LLM configuration (single provider) -- Config file: `~/.rowboat/config/models.json` -- Schema: `{ provider: { flavor, apiKey?, baseURL?, headers? }, model: string }` +### LLM configuration +- Config file: `~/.rowboat/config/models.json` (v2; v1 files are migrated on boot by `core/models/migrate.ts`) +- Schema: `{ version: 2, providers: { : { flavor, apiKey?, baseURL?, … } }, assistantModel?: { provider, model }, taskModels?: { knowledgeGraph?, meetingNotes?, liveNoteAgent?, autoPermissionDecision?, chatTitle? }, deferBackgroundTasks? }` +- Providers carry credentials only (no model fields) — model lists are always fetched live via the unified catalog (`core/models/catalog.ts`, `models:list` IPC). Model choices live in `assistantModel` (the one primary) and `taskModels` (optional overrides that otherwise inherit the assistant). - Models catalog cache: `~/.rowboat/config/models.dev.json` (OpenAI/Anthropic/Google only) ### Add a new shared type diff --git a/apps/x/apps/main/src/chatgpt-signin.ts b/apps/x/apps/main/src/chatgpt-signin.ts index 81e789d6..49cbf18e 100644 --- a/apps/x/apps/main/src/chatgpt-signin.ts +++ b/apps/x/apps/main/src/chatgpt-signin.ts @@ -3,6 +3,7 @@ import type { Server } from 'http'; import { createAuthServer } from './auth-server.js'; import * as oauthClient from '@x/core/dist/auth/oauth-client.js'; import { exchangeChatGPTCode, getChatGPTStatus } from '@x/core/dist/auth/chatgpt-auth.js'; +import { applyCodexInitialSelection } from '@x/core/dist/models/chatgpt-selection.js'; import { CHATGPT_AUTHORIZE_URL, CHATGPT_CALLBACK_PATH, @@ -65,7 +66,14 @@ export async function signInWithChatGPT(): Promise { void attempt.promise.finally(() => { if (activeAttempt === attempt) activeAttempt = null; }); - return attempt.promise; + const result = await attempt.promise; + if (result.signedIn) { + // Signing in connects the codex provider: if no assistant model is + // saved yet, pick the initial one (recommendation if the subscription + // lists it, else first listed). Never replaces a saved choice. + await applyCodexInitialSelection(); + } + return result; } /** diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 0a1e4956..1007f93d 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1271,9 +1271,14 @@ export function setupIpcHandlers() { console.log(`[llm:generate] -> provider=${result.provider ?? '?'} model=${result.model ?? '?'} chars=${result.text?.length ?? 0}${result.error ? ` error=${result.error}` : ''}`); return result; }, - 'models:saveConfig': async (_event, args) => { + 'models:setProvider': async (_event, args) => { const repo = container.resolve('modelConfigRepo'); - await repo.setConfig(args); + await repo.setProvider(args.id, args.provider); + return { success: true }; + }, + 'models:removeProvider': async (_event, args) => { + const repo = container.resolve('modelConfigRepo'); + await repo.removeProvider(args.id); return { success: true }; }, 'models:updateConfig': async (_event, args) => { diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index e0d4b2fc..78d6c4d1 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -17,6 +17,7 @@ import { capture as analyticsCapture, identify as analyticsIdentify, reset as an import { isSignedIn } from '@x/core/dist/account/account.js'; import { getWebappUrl } from '@x/core/dist/config/remote-config.js'; import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js'; +import { applyRowboatInitialSelection, clearRowboatSelections } from '@x/core/dist/models/rowboat-selection.js'; function buildRedirectUri(port: number): string { return `http://localhost:${port}/oauth/callback`; @@ -339,6 +340,11 @@ export async function connectProvider(provider: string, credentials?: { clientId // multiple renderer hooks race to create the user, causing duplicates. let signedInUserId: string | undefined; if (provider === 'rowboat') { + // Signing in connects the rowboat provider: if no assistant + // model is saved yet, pick the initial one (recommendation if + // the gateway lists it, else first listed). Never replaces a + // saved choice; best-effort by design. + await applyRowboatInitialSelection(); try { const billing = await getBillingInfo(); if (billing.userId) { @@ -530,6 +536,10 @@ export async function disconnectProvider(provider: string): Promise<{ success: b if (provider === 'rowboat') { analyticsCapture('user_signed_out'); analyticsReset(); + // Signing out disconnects the rowboat provider: drop the model + // selections that reference it (same dangling-ref cleanup as removing + // any provider). The composer prompts for a new pick. + await clearRowboatSelections(); } // Notify renderer so sidebar, voice, and billing re-check state emitOAuthEvent({ provider, success: false }); diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 80bcd318..de7d7004 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -562,7 +562,7 @@ function ChatInputInner({ if (!model) return setSelectedModel(model) onSelectedModelChange?.(model) - void window.ipc.invoke('models:updateConfig', { defaultSelection: { provider: model.provider, model: model.model } }) + void window.ipc.invoke('models:updateConfig', { assistantModel: { provider: model.provider, model: model.model } }) .then(() => { window.dispatchEvent(new Event('models-config-changed')) }) .catch(() => { toast.error('Failed to save default model') }) }, [lockedModel, onSelectedModelChange]) diff --git a/apps/x/apps/renderer/src/components/model-selector.test.tsx b/apps/x/apps/renderer/src/components/model-selector.test.tsx index ca718b8b..7fea033f 100644 --- a/apps/x/apps/renderer/src/components/model-selector.test.tsx +++ b/apps/x/apps/renderer/src/components/model-selector.test.tsx @@ -26,8 +26,8 @@ let handlers: Record Promise> = {} function serveTwoProviders(): void { handlers['models:list'] = async () => ({ providers: [ - { id: 'openai', flavor: 'openai', status: 'ok', savedModel: 'gpt-5.4', models: [{ id: 'gpt-5.4' }] }, - { id: 'anthropic', flavor: 'anthropic', status: 'ok', savedModel: 'claude-opus-4-8', models: [{ id: 'claude-opus-4-8' }] }, + { id: 'openai', flavor: 'openai', status: 'ok', models: [{ id: 'gpt-5.4' }] }, + { id: 'anthropic', flavor: 'anthropic', status: 'ok', models: [{ id: 'claude-opus-4-8' }] }, ], defaultModel: { provider: 'openai', model: 'gpt-5.4' }, }) diff --git a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts index ee52c7d2..e95427b8 100644 --- a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts +++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts @@ -484,11 +484,15 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou model = typed } - // `models` is the user's curated assistant-model list (shown in Settings), - // NOT the full provider catalog. Onboarding seeds it with just the selected - // model; users add more from Settings. Persisting the whole catalog here - // rendered every model as a separate assistant-model row. - await window.ipc.invoke("models:saveConfig", { provider, model, models: model ? [model] : [] }) + // v2 writes: the provider entry carries credentials only; the resolved + // model becomes the assistant model (onboarding's connect doubles as + // the initial selection). + await window.ipc.invoke("models:setProvider", { id: llmProvider, provider }) + if (model) { + await window.ipc.invoke("models:updateConfig", { + assistantModel: { provider: llmProvider, model }, + }) + } window.dispatchEvent(new Event('models-config-changed')) setTestState({ status: "success" }) setConnectedFlavors(prev => new Set(prev).add(llmProvider)) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index db65997c..927aaff1 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -33,6 +33,7 @@ import { DEFAULT_TURN_LIMITS_SETTINGS } from "@x/shared/src/turn-limits.js" import type { ipc as ipcShared } from "@x/shared" import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning" import { useProviderModels } from "@/hooks/use-provider-models" +import { useRowboatConfig } from "@/hooks/use-rowboat-config" import { useChatGPT } from "@/hooks/useChatGPT" import { ModelSelector, type ModelRef } from "@/components/model-selector" import { useModels } from "@/hooks/use-models" @@ -548,6 +549,9 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b const activeConfig = providerConfigs[provider] // Live per-key model list for the active provider — drives the primary // model area. The per-function fields below still use the static catalog. + // Backend model recommendations (flavor-keyed) — used when a provider + // becomes the assistant without an explicit model pick. + const modelRecommendations = useRowboatConfig()?.modelRecommendations const providerModels = useProviderModels({ flavor: provider, apiKey: activeConfig.apiKey, @@ -624,6 +628,11 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b const result = await window.ipc.invoke("workspace:readFile", { path: "config/models.json", }) + // models.json v2: providers carry credentials only; the assistant + // model and per-task overrides live at the top level. This screen's + // per-provider "model" state is a UI concept — hydrated from + // assistantModel for its provider, empty for the rest (the picker + // lists come from the catalog / live probe, not from config). const parsed = JSON.parse(result.data) setDeferBackgroundTasks(parsed?.deferBackgroundTasks === true) setDeferExplicit(typeof parsed?.deferBackgroundTasks === "boolean") @@ -631,51 +640,32 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b setSavedProviders(new Set( Object.keys(parsed?.providers ?? {}).filter((k): k is LlmProviderFlavor => knownFlavors.has(k)) )) - if (parsed?.provider?.flavor && parsed?.model) { - const flavor = parsed.provider.flavor as LlmProviderFlavor - setProvider(flavor) - setDefaultProvider(flavor) - setProviderConfigs(prev => { - const next = { ...prev }; - // Hydrate all saved providers from the providers map - if (parsed.providers) { - for (const [key, entry] of Object.entries(parsed.providers)) { - if (key in next) { - const e = entry as any; - const savedModels: string[] = Array.isArray(e.models) && e.models.length > 0 - ? e.models - : e.model ? [e.model] : [""]; - next[key as LlmProviderFlavor] = { - apiKey: e.apiKey || "", - baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""), - models: savedModels, - knowledgeGraphModel: asString(e.knowledgeGraphModel), - meetingNotesModel: asString(e.meetingNotesModel), - liveNoteAgentModel: asString(e.liveNoteAgentModel), - autoPermissionDecisionModel: asString(e.autoPermissionDecisionModel), - }; - } - } - } - // Active provider takes precedence from top-level config, - // but only if it exists in the providers map (wasn't deleted) - if (parsed.providers?.[flavor]) { - const existingModels = next[flavor].models; - const activeModels = existingModels[0] === parsed.model - ? existingModels - : [parsed.model, ...existingModels.filter((m: string) => m && m !== parsed.model)]; - next[flavor] = { - apiKey: parsed.provider.apiKey || "", - baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""), - models: activeModels.length > 0 ? activeModels : [""], - knowledgeGraphModel: asString(parsed.knowledgeGraphModel), - meetingNotesModel: asString(parsed.meetingNotesModel), - liveNoteAgentModel: asString(parsed.liveNoteAgentModel), - autoPermissionDecisionModel: asString(parsed.autoPermissionDecisionModel), - }; - } - return next; - }) + const assistant = parsed?.assistantModel as { provider?: string; model?: string } | undefined + const taskModels = (parsed?.taskModels ?? {}) as Record + const taskStringFor = (key: string, providerId: string): string => { + const ref = taskModels[key] + return ref?.provider === providerId ? asString(ref.model) : "" + } + setProviderConfigs(prev => { + const next = { ...prev }; + for (const [key, entry] of Object.entries(parsed?.providers ?? {})) { + if (!(key in next)) continue + const e = entry as { apiKey?: string; baseURL?: string }; + next[key as LlmProviderFlavor] = { + apiKey: e.apiKey || "", + baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""), + models: assistant?.provider === key && assistant.model ? [assistant.model] : [""], + knowledgeGraphModel: taskStringFor("knowledgeGraph", key), + meetingNotesModel: taskStringFor("meetingNotes", key), + liveNoteAgentModel: taskStringFor("liveNoteAgent", key), + autoPermissionDecisionModel: taskStringFor("autoPermissionDecision", key), + }; + } + return next; + }) + if (assistant?.provider && knownFlavors.has(assistant.provider)) { + setProvider(assistant.provider as LlmProviderFlavor) + setDefaultProvider(assistant.provider as LlmProviderFlavor) } } catch { // No existing config or parse error - use defaults @@ -727,32 +717,36 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b toast.error("Enter a model to connect") return } - // The silently resolved model takes the primary slot; the rest of the - // saved list is preserved (same semantics setPrimaryModel had). - const existing = activeConfig.models.map(m => m.trim()) - const allModels = [model, ...existing.slice(1).filter(m => m && m !== model)] - const providerConfig = { - provider: { - flavor: provider, - apiKey: activeConfig.apiKey.trim() || undefined, - baseURL: activeConfig.baseURL.trim() || undefined, - }, - model: allModels[0] || "", - models: allModels, - ...(rowboatConnected ? {} : { - knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined, - meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined, - liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined, - autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined, - }), + const providerEntry = { + flavor: provider, + apiKey: activeConfig.apiKey.trim() || undefined, + baseURL: activeConfig.baseURL.trim() || undefined, } - const result = await window.ipc.invoke("models:test", providerConfig) + const result = await window.ipc.invoke("models:test", { provider: providerEntry, model }) if (result.success) { - await window.ipc.invoke("models:saveConfig", providerConfig) + // v2 writes: the provider entry carries credentials only; the model + // choice becomes the assistant model, and the BYOK per-task strings + // become task overrides scoped to this provider ('' clears → inherit). + const taskRef = (value: string) => { + const trimmed = value.trim() + return trimmed ? { provider, model: trimmed } : null + } + await window.ipc.invoke("models:setProvider", { id: provider, provider: providerEntry }) + await window.ipc.invoke("models:updateConfig", { + assistantModel: { provider, model }, + ...(rowboatConnected ? {} : { + taskModels: { + knowledgeGraph: taskRef(activeConfig.knowledgeGraphModel), + meetingNotes: taskRef(activeConfig.meetingNotesModel), + liveNoteAgent: taskRef(activeConfig.liveNoteAgentModel), + autoPermissionDecision: taskRef(activeConfig.autoPermissionDecisionModel), + }, + }), + }) setDefaultProvider(provider) setProviderConfigs(prev => ({ ...prev, - [provider]: { ...prev[provider], models: allModels }, + [provider]: { ...prev[provider], models: [model] }, })) setSavedProviders(prev => new Set(prev).add(provider)) setTestState({ status: "success" }) @@ -784,134 +778,63 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b } }, [canTest, resolvedModel, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle]) + // "Set as default" = choose this provider's model as the assistant model. + // The UI state only knows a model for the provider that was already the + // assistant, so other providers resolve one the same way a first connect + // does: recommendation if the provider lists it, else the first listed. const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => { const config = providerConfigs[prov] - const allModels = config.models.map(m => m.trim()).filter(Boolean) - if (!allModels[0]) return try { - await window.ipc.invoke("models:saveConfig", { - provider: { - flavor: prov, - apiKey: config.apiKey.trim() || undefined, - baseURL: config.baseURL.trim() || undefined, - }, - model: allModels[0], - models: allModels, - ...(rowboatConnected ? {} : { - knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined, - meetingNotesModel: config.meetingNotesModel.trim() || undefined, - liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined, - autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined, - }), - }) + let model = config.models[0]?.trim() || "" + if (!model) { + const listRes = await window.ipc.invoke("models:listForProvider", { + provider: { + flavor: prov, + apiKey: config.apiKey.trim() || undefined, + baseURL: config.baseURL.trim() || undefined, + }, + }) + const list = listRes.success ? listRes.models ?? [] : [] + const recommended = modelRecommendations?.[prov] + model = (recommended && list.includes(recommended)) ? recommended : (list[0] ?? "") + } + if (!model) { + toast.error("Couldn't determine a model for this provider") + return + } + await window.ipc.invoke("models:updateConfig", { assistantModel: { provider: prov, model } }) setDefaultProvider(prov) + setProviderConfigs(prev => ({ + ...prev, + [prov]: { ...prev[prov], models: [model] }, + })) window.dispatchEvent(new Event('models-config-changed')) toast.success("Default provider updated") } catch { toast.error("Failed to set default provider") } - }, [providerConfigs, rowboatConnected]) + }, [providerConfigs, modelRecommendations]) const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => { const isDefaultProv = defaultProvider === prov try { - const result = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" }) - let parsed = JSON.parse(result.data) + // Removes the entry AND any assistant/task selection referencing it + // (repo-side dangling cleanup). + await window.ipc.invoke("models:removeProvider", { id: prov }) - // Disconnecting the default provider: silently promote another - // connected provider first — the connect-only UI has no usable - // set-as-default step to send the user to. Prefer the provider the - // user's explicit defaultSelection points at; promotion goes through - // the same models:saveConfig path Set-as-default uses, so the repo - // writes a valid top-level provider/model. + // Disconnecting the assistant's provider: promote another connected + // provider — the connect-only UI has no usable set-as-default step to + // send the user to. Best-effort; with none left the composer shows + // its connect hint. let promoted: LlmProviderFlavor | null = null if (isDefaultProv) { - const selProvider = parsed?.defaultSelection?.provider - const candidates = Object.keys(parsed?.providers ?? {}) - .filter((k): k is LlmProviderFlavor => k !== prov && k in providerConfigs) - .sort((a, b) => (a === selProvider ? -1 : b === selProvider ? 1 : 0)) - for (const candidate of candidates) { - const config = providerConfigs[candidate] - const allModels = config.models.map(m => m.trim()).filter(Boolean) - // Same silent precedence as connect, minus the live list we don't - // have here: the provider's saved model, else its preferred default. - const model = allModels[0] || preferredDefaults[candidate] || "" - if (!model) continue - await window.ipc.invoke("models:saveConfig", { - provider: { - flavor: candidate, - apiKey: config.apiKey.trim() || undefined, - baseURL: config.baseURL.trim() || undefined, - }, - model, - models: allModels.length > 0 ? allModels : [model], - ...(rowboatConnected ? {} : { - knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined, - meetingNotesModel: config.meetingNotesModel.trim() || undefined, - liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined, - autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined, - }), - }) + const candidate = [...savedProviders].find((p): p is LlmProviderFlavor => p !== prov) + if (candidate) { + await handleSetDefault(candidate) promoted = candidate - break - } - if (promoted) { - // saveConfig rewrote top-level and the providers map — re-read so - // the deletion write below doesn't clobber the promotion. - const fresh = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" }) - parsed = JSON.parse(fresh.data) } } - if (parsed?.providers?.[prov]) { - delete parsed.providers[prov] - } - // A defaultSelection pointing at the removed provider is dangling — - // drop it so llm:getDefaultModel falls back cleanly. - if (parsed?.defaultSelection?.provider === prov) { - delete parsed.defaultSelection - } - // If the deleted provider is the current top-level active one, - // switch top-level config to the current default provider - if (parsed?.provider?.flavor === prov && defaultProvider && defaultProvider !== prov) { - const defConfig = providerConfigs[defaultProvider] - const defModels = defConfig.models.map(m => m.trim()).filter(Boolean) - parsed.provider = { - flavor: defaultProvider, - apiKey: defConfig.apiKey.trim() || undefined, - baseURL: defConfig.baseURL.trim() || undefined, - } - parsed.model = defModels[0] || "" - parsed.models = defModels - if (!rowboatConnected) { - parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined - parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined - parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined - parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined - } - } else if (parsed?.provider?.flavor === prov) { - // Removing the last connected provider: drop the top-level pair - // entirely. The schema requires it, so core's readConfig() treats - // the file as "no config" — signed-in falls back to the curated - // gateway default and signed-out llm:getDefaultModel rejects, which - // the composer already handles (it shows the connect hint). - delete parsed.provider - delete parsed.model - delete parsed.models - delete parsed.knowledgeGraphModel - delete parsed.meetingNotesModel - delete parsed.liveNoteAgentModel - delete parsed.autoPermissionDecisionModel - // With no BYOK providers left, any non-gateway selection is dangling. - if (parsed?.defaultSelection && parsed.defaultSelection.provider !== "rowboat" - && Object.keys(parsed?.providers ?? {}).length === 0) { - delete parsed.defaultSelection - } - } - await window.ipc.invoke("workspace:writeFile", { - path: "config/models.json", - data: JSON.stringify(parsed, null, 2), - }) setProviderConfigs(prev => ({ ...prev, [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, @@ -933,7 +856,7 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b } catch { toast.error("Failed to remove provider") } - }, [defaultProvider, providerConfigs, rowboatConnected]) + }, [defaultProvider, savedProviders, handleSetDefault]) const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }) => { const isDefault = defaultProvider === p.id @@ -1611,23 +1534,18 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) { parsed = JSON.parse(configResult.data) } catch { /* no config yet */ } - // Current selections. Legacy string overrides pair with the BYOK - // top-level flavor (mirrors core/models/defaults.ts). - const legacyFlavor = (parsed.provider as Record | undefined)?.flavor + // models.json v2: assistantModel + taskModels refs. const toRef = (value: unknown): ModelRef | null => { - if (!value) return null - if (typeof value === "string") { - return typeof legacyFlavor === "string" ? { provider: legacyFlavor, model: value } : null - } - const ref = value as { provider?: unknown; model?: unknown } - return typeof ref.provider === "string" && typeof ref.model === "string" + const ref = value as { provider?: unknown; model?: unknown } | null | undefined + return ref && typeof ref.provider === "string" && typeof ref.model === "string" ? { provider: ref.provider, model: ref.model } : null } - setSelectedDefault(toRef(parsed.defaultSelection)) - setSelectedKg(toRef(parsed.knowledgeGraphModel)) - setSelectedLiveNote(toRef(parsed.liveNoteAgentModel)) - setSelectedAutoPermission(toRef(parsed.autoPermissionDecisionModel)) + const taskModels = (parsed.taskModels ?? {}) as Record + setSelectedDefault(toRef(parsed.assistantModel)) + setSelectedKg(toRef(taskModels.knowledgeGraph)) + setSelectedLiveNote(toRef(taskModels.liveNoteAgent)) + setSelectedAutoPermission(toRef(taskModels.autoPermissionDecision)) } catch { toast.error("Failed to load models") } finally { @@ -1642,10 +1560,16 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) { setSaving(true) try { await window.ipc.invoke("models:updateConfig", { - defaultSelection: selectedDefault, - knowledgeGraphModel: selectedKg, - liveNoteAgentModel: selectedLiveNote, - autoPermissionDecisionModel: selectedAutoPermission, + // The "Rowboat default" sentinel no longer maps to a hidden curated + // default — a null task ref simply inherits the assistant model. The + // assistant itself is only written when explicitly picked (clearing + // it would leave the app with no model at all). + ...(selectedDefault ? { assistantModel: selectedDefault } : {}), + taskModels: { + knowledgeGraph: selectedKg, + liveNoteAgent: selectedLiveNote, + autoPermissionDecision: selectedAutoPermission, + }, }) window.dispatchEvent(new Event("models-config-changed")) toast.success("Model configuration saved") diff --git a/apps/x/apps/renderer/src/hooks/use-models.test.tsx b/apps/x/apps/renderer/src/hooks/use-models.test.tsx index 48194c44..f3b6b2ff 100644 --- a/apps/x/apps/renderer/src/hooks/use-models.test.tsx +++ b/apps/x/apps/renderer/src/hooks/use-models.test.tsx @@ -34,7 +34,6 @@ function serveCatalog(catalog: { flavor?: string status?: 'ok' | 'error' error?: string - savedModel?: string models: Array<{ id: string; reasoning?: boolean }> }> defaultModel: { provider: string; model: string } | null @@ -99,32 +98,27 @@ describe('useModels', () => { expect(invokeCounts['models:list']).toBe(1) }) - it('pins the saved model, orders the default group first, and passes error status through', async () => { + it('orders the default group and model first and passes error status through', async () => { serveCatalog({ providers: [ - { id: 'openai', savedModel: 'gpt-4.1', models: [{ id: 'gpt-5.4' }, { id: 'gpt-4.1' }] }, - { - id: 'ollama', - status: 'error', - error: 'connection refused', - savedModel: 'llama3', - models: [], - }, + { id: 'ollama', status: 'error', error: 'connection refused', models: [] }, + { id: 'openai', models: [{ id: 'gpt-4.1' }, { id: 'gpt-5.4' }] }, ], - defaultModel: { provider: 'ollama', model: 'llama3' }, + defaultModel: { provider: 'openai', model: 'gpt-5.4' }, }) const { result } = renderHook(() => useModels()) await waitFor(() => expect(result.current.groups.length).toBe(2)) - // The default's group leads (despite arriving second) with its saved - // model still pickable; the failed status and error travel with it. + // The default's group leads (despite arriving second) and the default + // model leads within it. expect(result.current.groups[0]).toEqual({ - id: 'ollama', flavor: 'ollama', models: ['llama3'], status: 'error', error: 'connection refused', + id: 'openai', flavor: 'openai', models: ['gpt-5.4', 'gpt-4.1'], status: 'ok', }) - // Saved model leads its group ahead of the fetched list order. + // A failed provider keeps its group, with the error travelling along + // (ModelSelector renders it as an inline error row + Retry). expect(result.current.groups[1]).toEqual({ - id: 'openai', flavor: 'openai', models: ['gpt-4.1', 'gpt-5.4'], status: 'ok', + id: 'ollama', flavor: 'ollama', models: [], status: 'error', error: 'connection refused', }) }) @@ -171,7 +165,7 @@ describe('useModels', () => { serveCatalog({ providers: [ { id: 'openai', models: [{ id: 'gpt-5.4' }] }, - { id: 'ollama', savedModel: 'llama3', models: [{ id: 'llama3' }] }, + { id: 'ollama', models: [{ id: 'llama3' }] }, ], defaultModel: { provider: 'ollama', model: 'llama3' }, }) diff --git a/apps/x/apps/renderer/src/hooks/use-models.ts b/apps/x/apps/renderer/src/hooks/use-models.ts index fd23839b..e29238cf 100644 --- a/apps/x/apps/renderer/src/hooks/use-models.ts +++ b/apps/x/apps/renderer/src/hooks/use-models.ts @@ -83,17 +83,10 @@ async function buildSnapshot(refreshProvider?: string): Promise reasoningByKey[`${p.id}/${m.id}`] = m.reasoning } } - // The provider's saved default model leads its group; it stays pickable - // even when the fetched list doesn't carry it (or failed entirely). - const models: string[] = [] - if (p.savedModel) models.push(p.savedModel) - for (const id of ids) { - if (!models.includes(id)) models.push(id) - } groups.push({ id: p.id, flavor: p.flavor, - models, + models: ids, status: p.status, ...(p.error ? { error: p.error } : {}), }) diff --git a/apps/x/packages/core/src/auth/chatgpt-auth.ts b/apps/x/packages/core/src/auth/chatgpt-auth.ts index 0106d976..2283be83 100644 --- a/apps/x/packages/core/src/auth/chatgpt-auth.ts +++ b/apps/x/packages/core/src/auth/chatgpt-auth.ts @@ -385,5 +385,10 @@ export async function signOutChatGPT(): Promise { } } await clearStore(); + // Signing out disconnects the codex provider: drop the model selections + // that reference it (same dangling-ref cleanup as removing any + // provider). Lazy import — models/catalog imports this module. + const { clearCodexSelections } = await import('../models/chatgpt-selection.js'); + await clearCodexSelections(); console.log('[ChatGPTAuth] Signed out'); } diff --git a/apps/x/packages/core/src/knowledge/inline_tasks.ts b/apps/x/packages/core/src/knowledge/inline_tasks.ts index c5ba742f..a7524a12 100644 --- a/apps/x/packages/core/src/knowledge/inline_tasks.ts +++ b/apps/x/packages/core/src/knowledge/inline_tasks.ts @@ -4,9 +4,7 @@ import { CronExpressionParser } from 'cron-parser'; import { generateText } from 'ai'; import { WorkDir } from '../config/config.js'; import { runWhenPossible } from '../runtime/assembly/headless-app.js'; -import { getKgModel } from '../models/defaults.js'; -import container from '../di/container.js'; -import type { IModelConfigRepo } from '../models/repo.js'; +import { getKgModel, resolveProviderConfig } from '../models/defaults.js'; import { createLanguageModel } from '../models/models.js'; import { inlineTask } from '@x/shared'; import { captureLlmUsage } from '../analytics/usage.js'; @@ -611,9 +609,9 @@ export async function processRowboatInstruction( * Returns a schedule object or null for one-time tasks. */ export async function classifySchedule(instruction: string): Promise { - const repo = container.resolve('modelConfigRepo'); - const config = await repo.getConfig(); - const model = createLanguageModel(config.provider, config.model); + const selection = await getKgModel(); + const providerConfig = await resolveProviderConfig(selection.provider); + const model = createLanguageModel(providerConfig, selection.model); const now = new Date(); const defaultEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); @@ -660,8 +658,8 @@ Respond with ONLY valid JSON: either a schedule object or null. No other text.`; captureLlmUsage({ useCase: 'knowledge_sync', subUseCase: 'inline_task_classify', - model: config.model, - provider: config.provider.flavor, + model: selection.model, + provider: selection.provider, usage: result.usage, }); diff --git a/apps/x/packages/core/src/models/catalog.test.ts b/apps/x/packages/core/src/models/catalog.test.ts index c2b71de6..75196504 100644 --- a/apps/x/packages/core/src/models/catalog.test.ts +++ b/apps/x/packages/core/src/models/catalog.test.ts @@ -38,11 +38,18 @@ vi.mock('../di/container.js', () => ({ import { getModelCatalog, __resetModelCatalogForTests } from './catalog.js'; -function serveConfig(providers: Record, defaultFlavor = 'openai'): void { +// v2 config: providers keyed by instance id, flavor explicit inside (the +// helper defaults flavor to the key — one instance per flavor today). +function serveConfig( + providers: Record>, + assistantModel?: { provider: string; model: string }, +): void { mocks.getConfig.mockImplementation(async () => ({ - provider: { flavor: defaultFlavor }, - model: 'gpt-5.4', - providers, + version: 2, + providers: Object.fromEntries( + Object.entries(providers).map(([id, entry]) => [id, { flavor: id, ...entry }]), + ), + ...(assistantModel ? { assistantModel } : {}), })); } @@ -61,7 +68,7 @@ describe('getModelCatalog', () => { mocks.isSignedIn.mockResolvedValue(true); mocks.getChatGPTStatus.mockResolvedValue({ signedIn: true }); serveConfig({ - ollama: { baseURL: 'http://localhost:11434', model: 'llama3' }, + ollama: { baseURL: 'http://localhost:11434' }, }); mocks.listModelsForProvider.mockResolvedValue(['llama3', 'qwen3']); @@ -70,19 +77,22 @@ describe('getModelCatalog', () => { expect(catalog.providers.map((p) => p.id)).toEqual(['rowboat', 'codex', 'ollama']); expect(catalog.providers.every((p) => p.status === 'ok')).toBe(true); expect(catalog.providers[0].models).toEqual([{ id: 'google/gemini-3.5-flash', reasoning: true }]); - expect(catalog.providers[2]).toMatchObject({ savedModel: 'llama3', models: [{ id: 'llama3' }, { id: 'qwen3' }] }); + expect(catalog.providers[2]).toMatchObject({ flavor: 'ollama', models: [{ id: 'llama3' }, { id: 'qwen3' }] }); expect(catalog.defaultModel).toEqual({ provider: 'openai', model: 'gpt-5.4' }); }); - it('skips providers-map entries that carry no credential', async () => { - serveConfig({ - openai: { model: 'gpt-5.4' }, // no key — not connected - anthropic: { apiKey: 'sk-b' }, - }); - mocks.listModelsForProvider.mockResolvedValue(['claude-opus-4-8']); + it('orders the assistant model provider first among configured providers', async () => { + serveConfig( + { + openrouter: { apiKey: 'sk-1' }, + ollama: { baseURL: 'http://localhost:11434' }, + }, + { provider: 'ollama', model: 'llama3' }, + ); + mocks.listModelsForProvider.mockResolvedValue(['m']); const catalog = await getModelCatalog(); - expect(catalog.providers.map((p) => p.id)).toEqual(['anthropic']); + expect(catalog.providers.map((p) => p.id)).toEqual(['ollama', 'openrouter']); }); it('serves cloud flavors from the models.dev catalog and only lists live when it is empty', async () => { @@ -105,7 +115,7 @@ describe('getModelCatalog', () => { }); it('reports a failed provider as status error instead of dropping it', async () => { - serveConfig({ ollama: { baseURL: 'http://localhost:11434', model: 'llama3' } }); + serveConfig({ ollama: { baseURL: 'http://localhost:11434' } }); mocks.listModelsForProvider.mockRejectedValue(new Error('connection refused')); const catalog = await getModelCatalog(); @@ -113,7 +123,6 @@ describe('getModelCatalog', () => { id: 'ollama', status: 'error', error: 'connection refused', - savedModel: 'llama3', models: [], }); }); diff --git a/apps/x/packages/core/src/models/catalog.ts b/apps/x/packages/core/src/models/catalog.ts index 0b9ca46b..1f1ecea4 100644 --- a/apps/x/packages/core/src/models/catalog.ts +++ b/apps/x/packages/core/src/models/catalog.ts @@ -43,8 +43,6 @@ export interface CatalogProviderEntry { /** "error" = the provider is connected but its model list failed to load. */ status: "ok" | "error"; error?: string; - /** The provider's saved default model from models.json, if any. */ - savedModel?: string; models: CatalogModelEntry[]; } @@ -105,14 +103,9 @@ type ProviderConfig = z.infer; interface DiscoveredProvider { id: string; + flavor: string; /** Absent for rowboat/codex — their auth lives outside models.json. */ config?: ProviderConfig; - savedModel?: string; - /** - * Saved models[] from config — the list of last resort for flavors the - * live fetch doesn't support (an unknown flavor in the providers map). - */ - savedModels?: string[]; } async function readModelConfig(): Promise | null> { @@ -128,45 +121,36 @@ async function readModelConfig(): Promise | null> /** * Which providers are connected right now. Rowboat and ChatGPT come from * their auth state; everything else from the models.json providers map - * (an entry counts as connected once it carries some credential). The - * default provider's entry leads, matching picker ordering. + * (entries carry credentials by construction in v2). The assistant model's + * provider leads, matching picker ordering. */ async function discoverProviders(): Promise { const discovered: DiscoveredProvider[] = []; if (await isSignedIn().catch(() => false)) { - discovered.push({ id: "rowboat" }); + discovered.push({ id: "rowboat", flavor: "rowboat" }); } try { const chatgpt = await getChatGPTStatus(); - if (chatgpt.signedIn) discovered.push({ id: "codex" }); + if (chatgpt.signedIn) discovered.push({ id: "codex", flavor: "codex" }); } catch { // ChatGPT status failures must never break the main list. } const cfg = await readModelConfig(); const providersMap = cfg?.providers ?? {}; - const defaultFlavor = cfg?.provider.flavor ?? ""; - const flavors = Object.keys(providersMap) - .sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0)); + const assistantProvider = cfg?.assistantModel?.provider ?? ""; + const ids = Object.keys(providersMap) + .sort((a, b) => (a === assistantProvider ? -1 : b === assistantProvider ? 1 : 0)); - for (const flavor of flavors) { - const entry = providersMap[flavor] ?? {}; - const apiKey = entry.apiKey?.trim() ?? ""; - const baseURL = entry.baseURL?.trim() ?? ""; - if (!apiKey && !baseURL) continue; // provider not configured - const savedModel = entry.model || undefined; - const parsed = LlmProvider.safeParse({ ...entry, flavor }); - if (!parsed.success) { - // Unknown flavor: not live-listable — serve the saved list. - discovered.push({ id: flavor, savedModel, savedModels: entry.models ?? [] }); - continue; - } - const config = parsed.data; + for (const id of ids) { + const entry = providersMap[id]; + if (!entry) continue; + const config = { ...entry }; if (config.flavor === "aigateway" && !config.baseURL) { config.baseURL = AIGATEWAY_DEFAULT_BASE_URL; } - discovered.push({ id: flavor, config, savedModel }); + discovered.push({ id, flavor: entry.flavor, config }); } return discovered; @@ -192,10 +176,10 @@ async function fetchProviderEntry( } else if (provider.id === "codex") { const result = await listCodexModels(); models = result.providers[0]?.models ?? []; - } else if (MODELS_DEV_FLAVORS.has(provider.id) && (modelsDevByFlavor.get(provider.id)?.length ?? 0) > 0) { - models = modelsDevByFlavor.get(provider.id) ?? []; + } else if (MODELS_DEV_FLAVORS.has(provider.flavor) && (modelsDevByFlavor.get(provider.flavor)?.length ?? 0) > 0) { + models = modelsDevByFlavor.get(provider.flavor) ?? []; } else if (!provider.config) { - models = (provider.savedModels ?? []).map((id) => ({ id })); + throw new Error(`Provider '${provider.id}' has no configuration to list models with`); } else { // Live listing: local/custom flavors always, cloud flavors only // when the models.dev cache is empty (offline fresh install). @@ -251,7 +235,7 @@ export async function getModelCatalog(options?: GetModelCatalogOptions): Promise // One models.dev read serves every cloud flavor in the build (disk cache, // no network — refreshed by its own background loop). const modelsDevByFlavor = new Map(); - if (discovered.some((p) => MODELS_DEV_FLAVORS.has(p.id))) { + if (discovered.some((p) => MODELS_DEV_FLAVORS.has(p.flavor))) { try { const catalog = await listOnboardingModels(); for (const p of catalog.providers) { @@ -274,11 +258,9 @@ export async function getModelCatalog(options?: GetModelCatalogOptions): Promise ); const result: CatalogProviderEntry = { id: provider.id, - // One instance per flavor today, so the id IS the flavor key. - flavor: provider.config?.flavor ?? provider.id, + flavor: provider.flavor, status: entry.status, ...(entry.error ? { error: entry.error } : {}), - ...(provider.savedModel ? { savedModel: provider.savedModel } : {}), models: entry.models, }; return result; diff --git a/apps/x/packages/core/src/models/chatgpt-selection.ts b/apps/x/packages/core/src/models/chatgpt-selection.ts new file mode 100644 index 00000000..36d04005 --- /dev/null +++ b/apps/x/packages/core/src/models/chatgpt-selection.ts @@ -0,0 +1,46 @@ +import container from "../di/container.js"; +import { IModelConfigRepo } from "./repo.js"; +import { listCodexModels } from "./codex.js"; +import { getRowboatConfig } from "../config/rowboat.js"; +import { selectInitialModel } from "./initial-selection.js"; + +/** + * Model-selection hooks for the ChatGPT-subscription (codex) sign-in + * lifecycle. ChatGPT is a provider like any other: signing in connects it, + * so it follows the same rules — + * + * - Connect with no saved assistant → pick an initial model (backend + * recommendation if the subscription lists it, else the first listed + * model) and save it. A saved assistant is NEVER replaced. + * - Disconnect → drop the selections that reference the provider (same + * dangling-ref cleanup as removing any provider). + */ + +export async function applyCodexInitialSelection(): Promise { + const repo = container.resolve("modelConfigRepo"); + try { + const cfg = await repo.getConfig().catch(() => null); + if (cfg?.assistantModel) return; // saved choice — never replaced + const catalog = await listCodexModels(); + const ids = catalog.providers[0]?.models.map((m) => m.id) ?? []; + const recommendations = (await getRowboatConfig().catch(() => null))?.modelRecommendations; + const model = selectInitialModel("codex", ids, recommendations); + if (model) { + await repo.updateConfig({ assistantModel: { provider: "codex", model } }); + } + } catch (error) { + // Best-effort: a failed initial selection must never break sign-in. + console.warn("[models] Initial selection after ChatGPT sign-in failed:", error); + } +} + +export async function clearCodexSelections(): Promise { + const repo = container.resolve("modelConfigRepo"); + try { + // "codex" has no providers-map entry; removeProvider still clears + // the assistantModel / task overrides that reference it. + await repo.removeProvider("codex"); + } catch (error) { + console.warn("[models] Clearing codex selections after sign-out failed:", error); + } +} diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts index 78ea2470..2b35a541 100644 --- a/apps/x/packages/core/src/models/defaults.ts +++ b/apps/x/packages/core/src/models/defaults.ts @@ -1,23 +1,8 @@ import z from "zod"; -import { LlmModelConfig, LlmProvider, ModelRef } from "@x/shared/dist/models.js"; +import { LlmModelConfig, LlmProvider, ModelRef, type TaskModelKey } from "@x/shared/dist/models.js"; import { IModelConfigRepo } from "./repo.js"; -import { isSignedIn } from "../account/account.js"; import container from "../di/container.js"; -const SIGNED_IN_DEFAULT_MODEL = "google/gemini-3.5-flash"; -const SIGNED_IN_DEFAULT_PROVIDER = "rowboat"; -// KG note-creation historically failed on identity (self-notes, perspective -// flips, misread outbound email) — root cause was the owner block never being -// injected, not the model tier. With identity injected + the NON-NEGOTIABLE -// RULES checklist + the end-of-message owner reminder, the lite tier is -// serviceable and 6x cheaper than full flash for this always-on service. -const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite"; -const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite"; -const SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL = "google/gemini-3.1-flash-lite"; -// Must be on the gateway's server-side allowlist or title calls 403 -// "Model not allowed" (and silently keep the placeholder title). -const SIGNED_IN_CHAT_TITLE_MODEL = "google/gemini-3.5-flash-lite"; - export type ModelSelection = z.infer; async function readConfig(): Promise | null> { @@ -25,36 +10,25 @@ async function readConfig(): Promise | null> { const repo = container.resolve("modelConfigRepo"); return await repo.getConfig(); } catch { - // Signed-in users may have no models.json at all. + // Fresh install before ensureConfig ran, or an unreadable file. return null; } } /** * The single source of truth for "what model+provider should we use when - * the caller didn't specify and the agent didn't declare". - * - * Resolution order (hybrid mode): - * 1. `defaultSelection` — the user's explicit choice; may point at the - * gateway ("rowboat") or any BYOK provider, and is honored in both modes - * (a "rowboat" selection is skipped while signed out — it needs auth). - * 2. Signed in → the curated gateway default. - * 3. BYOK → the legacy top-level provider/model pair. + * the caller didn't specify and the agent didn't declare": the config's + * assistantModel, period. It is written by onboarding / provider connect + * (via initial selection) and by every model pick in the UI; hidden + * fallback defaults were removed with the v2 config migration. */ export async function getDefaultModelAndProvider(): Promise<{ model: string; provider: string }> { - const signedIn = await isSignedIn(); const cfg = await readConfig(); - const selection = cfg?.defaultSelection; - if (selection && (selection.provider !== "rowboat" || signedIn)) { - return { model: selection.model, provider: selection.provider }; + const assistant = cfg?.assistantModel; + if (!assistant) { + throw new Error("No assistant model configured (connect a provider or sign in)"); } - if (signedIn) { - return { model: SIGNED_IN_DEFAULT_MODEL, provider: SIGNED_IN_DEFAULT_PROVIDER }; - } - if (!cfg) { - throw new Error("No model configuration found (models.json missing and not signed in)"); - } - return { model: cfg.model, provider: cfg.provider.flavor }; + return { model: assistant.model, provider: assistant.provider }; } /** @@ -68,69 +42,39 @@ export async function shouldDeferBackgroundTasks(): Promise { } /** - * Resolve a provider name (as stored on a run, an agent, or returned by - * getDefaultModelAndProvider) into the full LlmProvider config that - * createProvider expects (apiKey/baseURL/headers). + * Resolve a provider instance id (as stored on a run, an agent, or returned + * by getDefaultModelAndProvider) into the LlmProvider entry that + * createProvider expects. * * - "rowboat" → gateway provider (auth via OAuth bearer; no creds field). - * - other names → look up models.json's `providers[name]` map. - * - fallback: if the name matches the active default's flavor (legacy - * single-provider configs that didn't write to the providers map yet). + * - "codex" → ChatGPT subscription (auth in chatgpt-auth.json). + * - other ids → the models.json providers map. */ export async function resolveProviderConfig(name: string): Promise> { if (name === "rowboat") { return { flavor: "rowboat" }; } if (name === "codex") { - // ChatGPT subscription: auth lives in chatgpt-auth.json (core auth - // layer), never in models.json — which may not exist at all. return { flavor: "codex" }; } - const repo = container.resolve("modelConfigRepo"); - const cfg = await repo.getConfig(); - const entry = cfg.providers?.[name]; - if (entry) { - return LlmProvider.parse({ - flavor: name, - apiKey: entry.apiKey, - baseURL: entry.baseURL, - headers: entry.headers, - contextLength: entry.contextLength, - reasoningEffort: entry.reasoningEffort, - }); + const cfg = await readConfig(); + const entry = cfg?.providers[name]; + if (!entry) { + throw new Error(`Provider '${name}' is referenced but not configured`); } - if (cfg.provider.flavor === name) { - return cfg.provider; - } - throw new Error(`Provider '${name}' is referenced but not configured`); + return entry; } -// Per-category model resolution (hybrid mode): -// 1. An explicit override wins in BOTH modes. Provider-qualified refs are -// used as-is (a "rowboat" ref is skipped while signed out); legacy string -// overrides pair with the BYOK provider they were configured against -// (the top-level flavor), NOT the dynamic default — so a signed-in user's -// local-model overrides keep routing to their local server. -// 2. No override, signed in → the curated gateway model. -// 3. No override, BYOK → the assistant default. -async function getCategoryModel( - category: "knowledgeGraphModel" | "meetingNotesModel" | "liveNoteAgentModel" | "autoPermissionDecisionModel", - curatedModel: string, -): Promise { - const signedIn = await isSignedIn(); +/** + * Per-task model resolution: the explicit taskModels override wins, else the + * assistant model. No hidden per-task defaults — the v2 migration + * materialized the historical curated models as visible overrides. + */ +async function getCategoryModel(category: TaskModelKey): Promise { const cfg = await readConfig(); - const override = cfg?.[category]; + const override = cfg?.taskModels?.[category]; if (override) { - if (typeof override === "string") { - if (cfg) { - return { model: override, provider: cfg.provider.flavor }; - } - } else if (override.provider !== "rowboat" || signedIn) { - return { model: override.model, provider: override.provider }; - } - } - if (signedIn) { - return { model: curatedModel, provider: SIGNED_IN_DEFAULT_PROVIDER }; + return { model: override.model, provider: override.provider }; } return getDefaultModelAndProvider(); } @@ -140,55 +84,27 @@ async function getCategoryModel( * etc.) when they're the top-level of a run. */ export async function getKgModel(): Promise { - return getCategoryModel("knowledgeGraphModel", SIGNED_IN_KG_MODEL); + return getCategoryModel("knowledgeGraph"); } /** Model used by the live-note agent + routing classifier. */ export async function getLiveNoteAgentModel(): Promise { - return getCategoryModel("liveNoteAgentModel", SIGNED_IN_LIVE_NOTE_AGENT_MODEL); + return getCategoryModel("liveNoteAgent"); } /** Model used by the auto-permission classifier. */ export async function getAutoPermissionDecisionModel(): Promise { - return getCategoryModel("autoPermissionDecisionModel", SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL); + return getCategoryModel("autoPermissionDecision"); } -/** - * Model used by the meeting-notes summarizer. No special signed-in curated - * model — historically meetings used the assistant model. - */ +/** Model used by the meeting-notes summarizer. */ export async function getMeetingNotesModel(): Promise { - return getCategoryModel("meetingNotesModel", SIGNED_IN_DEFAULT_MODEL); + return getCategoryModel("meetingNotes"); } -/** - * Model used to auto-name chat sessions from the first user message. - * - * Deliberately NOT getCategoryModel: that helper routes every signed-in user - * to the gateway, even one whose assistant default is a BYOK provider (the - * common "gateway limits exhausted, switched to own key" case) — and a title - * call against an exhausted gateway fails silently forever. Instead the - * curated model applies only when the assistant default itself routes - * through the gateway; otherwise titles follow the assistant provider. - */ +/** Model used to auto-name chat sessions from the first user message. */ export async function getChatTitleModel(): Promise { - const signedIn = await isSignedIn(); - const cfg = await readConfig(); - const override = cfg?.chatTitleModel; - if (override) { - if (typeof override === "string") { - if (cfg) { - return { model: override, provider: cfg.provider.flavor }; - } - } else if (override.provider !== "rowboat" || signedIn) { - return { model: override.model, provider: override.provider }; - } - } - const dflt = await getDefaultModelAndProvider(); - if (dflt.provider === SIGNED_IN_DEFAULT_PROVIDER) { - return { model: SIGNED_IN_CHAT_TITLE_MODEL, provider: SIGNED_IN_DEFAULT_PROVIDER }; - } - return dflt; + return getCategoryModel("chatTitle"); } /** diff --git a/apps/x/packages/core/src/models/migrate.test.ts b/apps/x/packages/core/src/models/migrate.test.ts new file mode 100644 index 00000000..9c97e1e1 --- /dev/null +++ b/apps/x/packages/core/src/models/migrate.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import { migrateModelsConfig } from './migrate.js'; + +/** + * The migration contract: evaluate the v1 resolution rules (including the + * curated signed-in defaults that lived only in code) one last time and + * write their answers explicitly, so v2's simple "override else assistant" + * rules produce identical effective models. Overrides are written ONLY + * where the old effective model differs from inherit-from-assistant. + */ + +describe('migrateModelsConfig', () => { + it('returns null for a config that is already v2', () => { + expect(migrateModelsConfig({ version: 2, providers: {} }, false)).toBeNull(); + }); + + it('signed-out BYOK: adopts the top-level pair as assistant, writes no task overrides', () => { + const v1 = { + provider: { flavor: 'openai', apiKey: 'sk-a' }, + model: 'gpt-5.4', + providers: { openai: { apiKey: 'sk-a', model: 'gpt-5.4', models: ['gpt-5.4'] } }, + }; + expect(migrateModelsConfig(v1, false)).toEqual({ + version: 2, + providers: { openai: { flavor: 'openai', apiKey: 'sk-a' } }, + assistantModel: { provider: 'openai', model: 'gpt-5.4' }, + }); + }); + + it('signed-in with untouched bootstrap config: materializes the curated defaults', () => { + // The classic signed-in models.json — the bootstrap file that nothing + // ever wrote to; every effective model lived in code branches. + const v1 = { provider: { flavor: 'openai' }, model: 'gpt-5.4' }; + expect(migrateModelsConfig(v1, true)).toEqual({ + version: 2, + providers: {}, // bootstrap top-level pair had no credentials + assistantModel: { provider: 'rowboat', model: 'google/gemini-3.5-flash' }, + taskModels: { + knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' }, + liveNoteAgent: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' }, + autoPermissionDecision: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' }, + // chat titles used flash-lite because the assistant routes + // through the gateway; meeting notes used the curated default + // which EQUALS the assistant → no override written for it. + chatTitle: { provider: 'rowboat', model: 'google/gemini-3.5-flash-lite' }, + }, + }); + }); + + it('signed-in user with a BYOK defaultSelection: keeps it, materializes the differing task models', () => { + const v1 = { + provider: { flavor: 'ollama', baseURL: 'http://localhost:11434' }, + model: 'llama3', + providers: { ollama: { baseURL: 'http://localhost:11434', model: 'llama3' } }, + defaultSelection: { provider: 'ollama', model: 'llama3' }, + }; + const v2 = migrateModelsConfig(v1, true); + expect(v2?.assistantModel).toEqual({ provider: 'ollama', model: 'llama3' }); + expect(v2?.taskModels).toEqual({ + knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' }, + liveNoteAgent: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' }, + autoPermissionDecision: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' }, + // Meeting notes used the curated gateway default, which now + // differs from the (BYOK) assistant — preserved explicitly. + meetingNotes: { provider: 'rowboat', model: 'google/gemini-3.5-flash' }, + // Chat titles followed the assistant whenever it was NOT the + // gateway — inherit reproduces that, so no override. + }); + }); + + it('explicit v1 overrides survive: legacy strings pair with the top-level flavor, refs pass through', () => { + const v1 = { + provider: { flavor: 'openai', apiKey: 'sk-a' }, + model: 'gpt-5.4', + providers: { openai: { apiKey: 'sk-a' } }, + knowledgeGraphModel: 'gpt-5.4-mini', // legacy string form + meetingNotesModel: { provider: 'ollama', model: 'qwen3' }, // ref form + }; + const v2 = migrateModelsConfig(v1, false); + expect(v2?.taskModels).toEqual({ + knowledgeGraph: { provider: 'openai', model: 'gpt-5.4-mini' }, + meetingNotes: { provider: 'ollama', model: 'qwen3' }, + }); + }); + + it('an override equal to the assistant is dropped (inherit produces the same model)', () => { + const v1 = { + provider: { flavor: 'openai', apiKey: 'sk-a' }, + model: 'gpt-5.4', + knowledgeGraphModel: 'gpt-5.4', + }; + expect(migrateModelsConfig(v1, false)?.taskModels).toBeUndefined(); + }); + + it('a rowboat defaultSelection is skipped while signed out (needs auth), like v1 resolution did', () => { + const v1 = { + provider: { flavor: 'openai', apiKey: 'sk-a' }, + model: 'gpt-5.4', + defaultSelection: { provider: 'rowboat', model: 'google/gemini-3.5-flash' }, + }; + expect(migrateModelsConfig(v1, false)?.assistantModel) + .toEqual({ provider: 'openai', model: 'gpt-5.4' }); + }); + + it('providers without credentials are dropped; connection prefs survive', () => { + const v1 = { + provider: { flavor: 'openai', apiKey: 'sk-a' }, + model: 'gpt-5.4', + providers: { + openai: { apiKey: 'sk-a', models: ['gpt-5.4'] }, + anthropic: { model: 'claude-opus-4-8' }, // no key: never connected + ollama: { baseURL: 'http://localhost:11434', contextLength: 32768, reasoningEffort: 'low' }, + }, + }; + expect(migrateModelsConfig(v1, false)?.providers).toEqual({ + openai: { flavor: 'openai', apiKey: 'sk-a' }, + ollama: { flavor: 'ollama', baseURL: 'http://localhost:11434', contextLength: 32768, reasoningEffort: 'low' }, + }); + }); + + it('degrades gracefully on garbage input: empty v2 config', () => { + expect(migrateModelsConfig('not an object', false)).toEqual({ version: 2, providers: {} }); + expect(migrateModelsConfig({}, false)).toEqual({ version: 2, providers: {} }); + }); + + it('deferBackgroundTasks is carried over', () => { + const v1 = { provider: { flavor: 'openai', apiKey: 'sk-a' }, model: 'gpt-5.4', deferBackgroundTasks: true }; + expect(migrateModelsConfig(v1, false)?.deferBackgroundTasks).toBe(true); + }); +}); diff --git a/apps/x/packages/core/src/models/migrate.ts b/apps/x/packages/core/src/models/migrate.ts new file mode 100644 index 00000000..99d19e8f --- /dev/null +++ b/apps/x/packages/core/src/models/migrate.ts @@ -0,0 +1,167 @@ +import { z } from "zod"; +import { LlmModelConfig, LlmProvider, ModelRef } from "@x/shared/dist/models.js"; + +/** + * One-time migration of models.json from version 1 to version 2. + * + * v1 accreted three generations of schema: a top-level provider/model pair + * (the original single-provider config), a providers map whose entries + * duplicated credentials AND carried saved model lists (pre-dynamic-listing + * picker caches), plus `defaultSelection` and flat category overrides bolted + * on for hybrid mode. On top of that, several effective models existed only + * as hidden branches in code (the signed-in curated defaults). + * + * v2 stores providers as credentials-only entries and model choices in + * exactly two places: `assistantModel` and `taskModels`. This migration + * evaluates the OLD resolution rules one last time and writes their answers + * down explicitly, so the simplified v2 resolvers produce identical + * effective models for every existing user. Task overrides are written ONLY + * where the old effective model differs from plain inherit-from-assistant. + * + * The curated model ids below are FROZEN COPIES of the v1 constants that + * lived in defaults.ts (deleted with this migration). They are historical + * data, not live configuration — do not update them when recommendations + * change. + */ + +const V1_SIGNED_IN_ASSISTANT: z.infer = { provider: "rowboat", model: "google/gemini-3.5-flash" }; +const V1_CURATED_LITE = "google/gemini-3.1-flash-lite"; +const V1_CURATED_CHAT_TITLE = "google/gemini-3.5-flash-lite"; + +// v1 schema — kept here, and only here, for the migration reader. +const ModelOverrideV1 = z.union([z.string(), ModelRef]); +export const LlmModelConfigV1 = z.object({ + provider: LlmProvider, + model: z.string(), + models: z.array(z.string()).optional(), + defaultSelection: ModelRef.optional(), + deferBackgroundTasks: z.boolean().optional(), + providers: z.record(z.string(), z.object({ + apiKey: z.string().optional(), + baseURL: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + contextLength: z.number().int().positive().optional(), + reasoningEffort: z.enum(["low", "medium", "high"]).optional(), + model: z.string().optional(), + models: z.array(z.string()).optional(), + knowledgeGraphModel: z.string().optional(), + meetingNotesModel: z.string().optional(), + liveNoteAgentModel: z.string().optional(), + autoPermissionDecisionModel: z.string().optional(), + })).optional(), + knowledgeGraphModel: ModelOverrideV1.optional(), + meetingNotesModel: ModelOverrideV1.optional(), + liveNoteAgentModel: ModelOverrideV1.optional(), + autoPermissionDecisionModel: ModelOverrideV1.optional(), + chatTitleModel: ModelOverrideV1.optional(), +}); + +type V2 = z.infer; +type Ref = z.infer; + +function sameRef(a: Ref | undefined, b: Ref | undefined): boolean { + return !!a && !!b && a.provider === b.provider && a.model === b.model; +} + +function asRef(value: unknown): Ref | undefined { + const parsed = ModelRef.safeParse(value); + return parsed.success && parsed.data.model ? parsed.data : undefined; +} + +/** + * Resolve a v1 category override the way defaults.ts used to: a bare string + * pairs with the top-level provider flavor; a ref is used as-is except a + * "rowboat" ref while signed out (needs auth → was skipped). + */ +function v1Override(raw: Record, key: string, signedIn: boolean): Ref | undefined { + const value = raw[key]; + if (typeof value === "string" && value) { + const flavor = (raw.provider as Record | undefined)?.flavor; + return typeof flavor === "string" && flavor ? { provider: flavor, model: value } : undefined; + } + const ref = asRef(value); + if (ref && (ref.provider !== "rowboat" || signedIn)) return ref; + return undefined; +} + +/** The old effective assistant model (defaults.ts resolution order). */ +function v1EffectiveAssistant(raw: Record, signedIn: boolean): Ref | undefined { + const selection = asRef(raw.defaultSelection); + if (selection && (selection.provider !== "rowboat" || signedIn)) return selection; + if (signedIn) return V1_SIGNED_IN_ASSISTANT; + const flavor = (raw.provider as Record | undefined)?.flavor; + const model = raw.model; + if (typeof flavor === "string" && flavor && typeof model === "string" && model) { + return { provider: flavor, model }; + } + return undefined; +} + +/** + * Migrate a raw parsed models.json (any shape) to v2. Returns null when the + * input is already v2 — nothing to do. + * + * Pure: sign-in state is an input; no I/O. + */ +export function migrateModelsConfig(rawInput: unknown, signedIn: boolean): V2 | null { + const raw = (rawInput && typeof rawInput === "object" ? rawInput : {}) as Record; + if (raw.version === 2) return null; + + // Providers: map entries with some credential survive, stripped to + // credentials + connection prefs. The top-level pair is merged in for + // very old configs that predate the providers map. + const providers: V2["providers"] = {}; + const candidateEntries: Array<[string, unknown]> = Object.entries( + (raw.providers && typeof raw.providers === "object" ? raw.providers : {}) as Record, + ); + const topLevel = raw.provider as Record | undefined; + if (topLevel && typeof topLevel.flavor === "string" && !candidateEntries.some(([k]) => k === topLevel.flavor)) { + candidateEntries.push([topLevel.flavor, topLevel]); + } + for (const [id, value] of candidateEntries) { + if (!value || typeof value !== "object") continue; + const entry = value as Record; + const apiKey = typeof entry.apiKey === "string" ? entry.apiKey.trim() : ""; + const baseURL = typeof entry.baseURL === "string" ? entry.baseURL.trim() : ""; + if (!apiKey && !baseURL) continue; // never connected + const parsed = LlmProvider.safeParse({ ...entry, flavor: id }); + if (parsed.success) providers[id] = parsed.data; + } + + const assistantModel = v1EffectiveAssistant(raw, signedIn); + + // Old effective model per task, via the deleted v1 rules. + const oldTaskModels: Record = { + knowledgeGraph: v1Override(raw, "knowledgeGraphModel", signedIn) + ?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel), + liveNoteAgent: v1Override(raw, "liveNoteAgentModel", signedIn) + ?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel), + autoPermissionDecision: v1Override(raw, "autoPermissionDecisionModel", signedIn) + ?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel), + meetingNotes: v1Override(raw, "meetingNotesModel", signedIn) + ?? (signedIn ? V1_SIGNED_IN_ASSISTANT : assistantModel), + // Chat titles used the curated lite model ONLY when the assistant itself + // routed through the gateway (the exhausted-gateway safeguard). + chatTitle: v1Override(raw, "chatTitleModel", signedIn) + ?? (assistantModel?.provider === "rowboat" + ? { provider: "rowboat", model: V1_CURATED_CHAT_TITLE } + : assistantModel), + }; + + // Write an override only where the old effective model differs from what + // v2 inheritance (assistant) would produce. + const taskModels: NonNullable = {}; + for (const [key, ref] of Object.entries(oldTaskModels)) { + if (ref && !sameRef(ref, assistantModel)) { + taskModels[key as keyof typeof taskModels] = ref; + } + } + + return { + version: 2, + providers, + ...(assistantModel ? { assistantModel } : {}), + ...(Object.keys(taskModels).length > 0 ? { taskModels } : {}), + ...(raw.deferBackgroundTasks === true ? { deferBackgroundTasks: true } : {}), + }; +} diff --git a/apps/x/packages/core/src/models/repo.test.ts b/apps/x/packages/core/src/models/repo.test.ts new file mode 100644 index 00000000..c849de44 --- /dev/null +++ b/apps/x/packages/core/src/models/repo.test.ts @@ -0,0 +1,92 @@ +import { rmSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The repo guards the only copy of the user's API keys. These tests pin the +// data-loss behaviors: corrupt or invalid files must never be silently +// overwritten by boot or by an unrelated write. + +// vi.mock factories are hoisted above module code — the temp path must be +// computable inside vi.hoisted without imports (created in beforeEach). +const workDir = vi.hoisted(() => + `${process.env.TMPDIR?.replace(/\/$/, '') ?? '/tmp'}/models-repo-test-${process.pid}-${Math.random().toString(36).slice(2)}`, +); + +vi.mock('../config/config.js', () => ({ WorkDir: workDir })); +vi.mock('../account/account.js', () => ({ isSignedIn: async () => false })); +vi.mock('../analytics/posthog.js', () => ({ capture: () => {} })); +vi.mock('../analytics/model-providers.js', () => ({ + captureProviderConnected: () => {}, + captureProviderDisconnected: () => {}, + syncModelProviderPersonProperties: async () => {}, +})); + +import { FSModelConfigRepo } from './repo.js'; + +const configDir = path.join(workDir, 'config'); +const configPath = path.join(configDir, 'models.json'); + +beforeEach(async () => { + await fs.mkdir(configDir, { recursive: true }); +}); + +afterEach(async () => { + await fs.rm(configDir, { recursive: true, force: true }); +}); + +process.on('exit', () => { + try { rmSync(workDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } +}); + +describe('FSModelConfigRepo data safety', () => { + it('creates an empty v2 config when the file is missing', async () => { + await new FSModelConfigRepo().ensureConfig(); + expect(JSON.parse(await fs.readFile(configPath, 'utf8'))).toEqual({ version: 2, providers: {} }); + }); + + it('quarantines corrupt JSON instead of overwriting the only copy of the keys', async () => { + await fs.writeFile(configPath, '{"version":2,"providers":{"openai":{"flavor":"op'); // truncated + await new FSModelConfigRepo().ensureConfig(); + + const entries = await fs.readdir(configDir); + const quarantined = entries.find((f) => f.startsWith('models.json.corrupt-')); + expect(quarantined).toBeDefined(); + expect(await fs.readFile(path.join(configDir, quarantined as string), 'utf8')).toContain('"op'); + expect(JSON.parse(await fs.readFile(configPath, 'utf8'))).toEqual({ version: 2, providers: {} }); + }); + + it('migration keeps the v1 original as models.json.v1.bak', async () => { + const v1 = { provider: { flavor: 'openai', apiKey: 'sk-a' }, model: 'gpt-5.4' }; + await fs.writeFile(configPath, JSON.stringify(v1)); + await new FSModelConfigRepo().ensureConfig(); + + expect(JSON.parse(await fs.readFile(`${configPath}.v1.bak`, 'utf8'))).toEqual(v1); + const migrated = JSON.parse(await fs.readFile(configPath, 'utf8')); + expect(migrated.version).toBe(2); + expect(migrated.providers.openai.apiKey).toBe('sk-a'); + }); + + it('a schema-invalid file makes writes FAIL instead of clobbering stored credentials', async () => { + // Parses as JSON, fails zod (bad flavor) — the pre-fix behavior was + // to fall back to an empty config and overwrite everything on the + // next unrelated write. + await fs.writeFile(configPath, JSON.stringify({ + version: 2, + providers: { weird: { flavor: 'not-a-flavor', apiKey: 'sk-precious' } }, + })); + const repo = new FSModelConfigRepo(); + await expect(repo.updateConfig({ deferBackgroundTasks: true })).rejects.toThrow(); + // The file is untouched — the key survives. + expect((await fs.readFile(configPath, 'utf8'))).toContain('sk-precious'); + }); + + it('writes land atomically via temp + rename (no lingering temp file)', async () => { + const repo = new FSModelConfigRepo(); + await repo.ensureConfig(); + await repo.setProvider('openai', { flavor: 'openai', apiKey: 'sk-a' }); + const entries = await fs.readdir(configDir); + expect(entries).not.toContain('models.json.tmp'); + expect(JSON.parse(await fs.readFile(configPath, 'utf8')).providers.openai.apiKey).toBe('sk-a'); + }); +}); diff --git a/apps/x/packages/core/src/models/repo.ts b/apps/x/packages/core/src/models/repo.ts index 3a3a5b77..a3c200c2 100644 --- a/apps/x/packages/core/src/models/repo.ts +++ b/apps/x/packages/core/src/models/repo.ts @@ -1,114 +1,162 @@ -import { ModelConfig } from "./models.js"; +import { LlmModelConfig, LlmProvider, ModelRef, TaskModels } from "@x/shared/dist/models.js"; import { WorkDir } from "../config/config.js"; +import { isSignedIn } from "../account/account.js"; +import { migrateModelsConfig } from "./migrate.js"; import fs from "fs/promises"; import path from "path"; import z from "zod"; +type Config = z.infer; +type Ref = z.infer; +type TaskModelPatch = { [K in keyof z.infer]?: Ref | null }; + +// Top-level merge patch: omitted keys are untouched; an explicit null clears +// a key. taskModels merges per-key (null clears that task's override). export type ModelConfigPatch = { - [K in - | "defaultSelection" - | "knowledgeGraphModel" - | "meetingNotesModel" - | "liveNoteAgentModel" - | "autoPermissionDecisionModel" - | "deferBackgroundTasks"]?: z.infer[K] | null; + assistantModel?: Ref | null; + taskModels?: TaskModelPatch; + deferBackgroundTasks?: boolean | null; }; export interface IModelConfigRepo { + /** Create the file if missing; migrate v1 → v2 in place if needed. */ ensureConfig(): Promise; - getConfig(): Promise>; - setConfig(config: z.infer): Promise; - // Merge the given top-level keys into the existing file without touching - // provider credentials — hybrid settings (default selection, category - // overrides) save through this. Omitted keys are untouched; an explicit - // null clears the key back to its default. + getConfig(): Promise; + /** Upsert one provider entry (credentials + connection prefs). */ + setProvider(id: string, provider: z.infer): Promise; + /** + * Remove a provider entry and every model selection that references it + * (a dangling assistantModel / task override would just error at run + * time — dropping them lets resolution fall back cleanly). + */ + removeProvider(id: string): Promise; updateConfig(patch: ModelConfigPatch): Promise; } -const defaultConfig: z.infer = { - provider: { - flavor: "openai", - }, - model: "gpt-5.4", +const emptyConfig: Config = { + version: 2, + providers: {}, }; +function isEnoent(err: unknown): boolean { + return (err as NodeJS.ErrnoException | null)?.code === "ENOENT"; +} + export class FSModelConfigRepo implements IModelConfigRepo { private readonly configPath = path.join(WorkDir, "config", "models.json"); async ensureConfig(): Promise { + let rawText: string; try { - await fs.access(this.configPath); - } catch { - await fs.writeFile(this.configPath, JSON.stringify(defaultConfig, null, 2)); + rawText = await fs.readFile(this.configPath, "utf8"); + } catch (err) { + if (isEnoent(err)) { + await this.write(emptyConfig); + } else { + // Transient read failure (permissions, I/O): NEVER overwrite + // the existing file — it holds the user's API keys. Leave it + // for the next boot; per-call getConfig errors degrade + // features without destroying data. + console.error("[models] Could not read models.json; leaving it untouched:", err); + } + return; + } + let raw: unknown; + try { + raw = JSON.parse(rawText); + } catch (err) { + // Corrupt JSON (e.g. a crash mid-write): quarantine the file for + // manual recovery instead of overwriting the only copy of the + // user's credentials. + const quarantinePath = `${this.configPath}.corrupt-${Date.now()}`; + await fs.rename(this.configPath, quarantinePath).catch(() => {}); + console.error(`[models] models.json is corrupt; preserved at ${quarantinePath}:`, err); + await this.write(emptyConfig); + return; + } + const signedIn = await isSignedIn().catch(() => false); + const migrated = migrateModelsConfig(raw, signedIn); + if (migrated) { + // Keep the v1 original recoverable — the migration is the only + // record of the old selections once it runs. + await fs.writeFile(`${this.configPath}.v1.bak`, rawText).catch(() => {}); + await this.write(migrated); } } - async getConfig(): Promise> { + async getConfig(): Promise { const config = await fs.readFile(this.configPath, "utf8"); - return ModelConfig.parse(JSON.parse(config)); + return LlmModelConfig.parse(JSON.parse(config)); } - async setConfig(config: z.infer): Promise { - let existingProviders: Record> = {}; - try { - const raw = await fs.readFile(this.configPath, "utf8"); - const existing = JSON.parse(raw); - existingProviders = existing.providers || {}; - } catch { - // No existing config + async setProvider(id: string, provider: z.infer): Promise { + const config = await this.read(); + config.providers[id] = LlmProvider.parse(provider); + await this.write(config); + } + + async removeProvider(id: string): Promise { + const config = await this.read(); + delete config.providers[id]; + if (config.assistantModel?.provider === id) { + delete config.assistantModel; } - - existingProviders[config.provider.flavor] = { - ...existingProviders[config.provider.flavor], - apiKey: config.provider.apiKey, - baseURL: config.provider.baseURL, - headers: config.provider.headers, - // Preserve hand-edited local-model tuning unless the caller sets it. - ...(config.provider.contextLength !== undefined - ? { contextLength: config.provider.contextLength } - : {}), - ...(config.provider.reasoningEffort !== undefined - ? { reasoningEffort: config.provider.reasoningEffort } - : {}), - model: config.model, - models: config.models, - knowledgeGraphModel: config.knowledgeGraphModel, - meetingNotesModel: config.meetingNotesModel, - liveNoteAgentModel: config.liveNoteAgentModel, - autoPermissionDecisionModel: config.autoPermissionDecisionModel, - }; - - // saveConfig owns provider credentials/model lists; the hybrid-mode - // selections are owned by updateConfig — carry them over when the - // incoming config doesn't set them. - let existingSelections: Record = {}; - try { - const raw = await fs.readFile(this.configPath, "utf8"); - const existing = JSON.parse(raw); - existingSelections = Object.fromEntries( - ["defaultSelection", "knowledgeGraphModel", "meetingNotesModel", "liveNoteAgentModel", "autoPermissionDecisionModel", "deferBackgroundTasks"] - .filter((key) => existing[key] !== undefined && (config as Record)[key] === undefined) - .map((key) => [key, existing[key]]), - ); - } catch { - // No existing config + if (config.taskModels) { + for (const key of Object.keys(config.taskModels) as Array>) { + if (config.taskModels[key]?.provider === id) { + delete config.taskModels[key]; + } + } + if (Object.keys(config.taskModels).length === 0) delete config.taskModels; } - - const toWrite = { ...existingSelections, ...config, providers: existingProviders }; - await fs.writeFile(this.configPath, JSON.stringify(toWrite, null, 2)); + await this.write(config); } async updateConfig(patch: ModelConfigPatch): Promise { - const raw = await fs.readFile(this.configPath, "utf8"); - const existing = JSON.parse(raw) as Record; - for (const [key, value] of Object.entries(patch)) { - if (value === undefined || value === null) { - delete existing[key]; - } else { - existing[key] = value; - } + const config = await this.read(); + if (patch.assistantModel !== undefined) { + if (patch.assistantModel === null) delete config.assistantModel; + else config.assistantModel = patch.assistantModel; } - ModelConfig.parse(existing); - await fs.writeFile(this.configPath, JSON.stringify(existing, null, 2)); + if (patch.taskModels !== undefined) { + const merged = { ...(config.taskModels ?? {}) }; + for (const [key, value] of Object.entries(patch.taskModels)) { + if (value === undefined) continue; + if (value === null) delete merged[key as keyof typeof merged]; + else merged[key as keyof typeof merged] = value; + } + if (Object.keys(merged).length > 0) config.taskModels = merged; + else delete config.taskModels; + } + if (patch.deferBackgroundTasks !== undefined) { + if (patch.deferBackgroundTasks === null) delete config.deferBackgroundTasks; + else config.deferBackgroundTasks = patch.deferBackgroundTasks; + } + await this.write(config); + } + + private async read(): Promise { + try { + return await this.getConfig(); + } catch (err) { + // ONLY a missing file falls back to empty (writes can arrive + // before ensureConfig on a fresh install). Any other failure — + // unreadable file, schema-invalid content — must propagate: + // read-modify-write on an empty fallback would clobber the + // user's stored credentials. + if (isEnoent(err)) { + return structuredClone(emptyConfig); + } + throw err; + } + } + + // Atomic write (temp + rename): a crash mid-write must never leave a + // truncated models.json — that file is the only copy of the user's keys. + private async write(config: Config): Promise { + const data = JSON.stringify(LlmModelConfig.parse(config), null, 2); + const tmpPath = `${this.configPath}.tmp`; + await fs.writeFile(tmpPath, data); + await fs.rename(tmpPath, this.configPath); } } diff --git a/apps/x/packages/core/src/models/rowboat-selection.ts b/apps/x/packages/core/src/models/rowboat-selection.ts new file mode 100644 index 00000000..7e3c94e0 --- /dev/null +++ b/apps/x/packages/core/src/models/rowboat-selection.ts @@ -0,0 +1,48 @@ +import container from "../di/container.js"; +import { IModelConfigRepo } from "./repo.js"; +import { listGatewayModels } from "./gateway.js"; +import { getRowboatConfig } from "../config/rowboat.js"; +import { selectInitialModel } from "./initial-selection.js"; + +/** + * Model-selection hooks for the Rowboat sign-in lifecycle. Signing in is + * "connecting the rowboat provider", so it follows the same rules as any + * provider connect: + * + * - Connect with no saved assistant → pick an initial model (backend + * recommendation if the gateway lists it, else the first listed model) + * and save it. A saved assistant is NEVER replaced — recommendations only + * ever choose the initial model. + * - Disconnect → drop the selections that reference the provider (same + * dangling-reference cleanup as removing any provider). + */ + +export async function applyRowboatInitialSelection(): Promise { + const repo = container.resolve("modelConfigRepo"); + try { + const cfg = await repo.getConfig().catch(() => null); + if (cfg?.assistantModel) return; // saved choice — never replaced + const catalog = await listGatewayModels(); + const ids = catalog.providers[0]?.models.map((m) => m.id) ?? []; + const recommendations = (await getRowboatConfig().catch(() => null))?.modelRecommendations; + const model = selectInitialModel("rowboat", ids, recommendations); + if (model) { + await repo.updateConfig({ assistantModel: { provider: "rowboat", model } }); + } + } catch (error) { + // Best-effort: a failed initial selection must never break sign-in. + // The picker copes with an unset assistant (shows the connect hint). + console.warn("[models] Initial selection after Rowboat sign-in failed:", error); + } +} + +export async function clearRowboatSelections(): Promise { + const repo = container.resolve("modelConfigRepo"); + try { + // "rowboat" has no providers-map entry; removeProvider still clears + // the assistantModel / task overrides that reference it. + await repo.removeProvider("rowboat"); + } catch (error) { + console.warn("[models] Clearing Rowboat selections after sign-out failed:", error); + } +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index b22c3270..7baa6285 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, WorkspaceChangeEvent, WriteFileOptions, WriteFileResult, RemoveOptions } from './workspace.js'; import { ListToolsResponse } from './mcp.js'; import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js'; -import { LlmModelConfig, LlmProvider, ModelOverride, ModelRef, ReasoningEffort } from './models.js'; +import { LlmProvider, ModelRef, ReasoningEffort } from './models.js'; import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; import { AgentScheduleState } from './agent-schedule-state.js'; import { ServiceEvent } from './service-events.js'; @@ -667,8 +667,6 @@ const ipcSchemas = { // 'error' = provider is connected but its model list failed to load. status: z.enum(['ok', 'error']), error: z.string().optional(), - // The provider's saved default model from models.json, if any. - savedModel: z.string().optional(), models: z.array(z.object({ id: z.string(), name: z.string().optional(), @@ -682,7 +680,10 @@ const ipcSchemas = { }), }, 'models:test': { - req: LlmModelConfig, + req: z.object({ + provider: LlmProvider, + model: z.string(), + }), res: z.object({ success: z.boolean(), error: z.string().optional(), @@ -726,23 +727,40 @@ const ipcSchemas = { error: z.string().optional(), }), }, - 'models:saveConfig': { - req: LlmModelConfig, + // Upsert one provider entry (credentials + connection prefs). Model + // choices are NOT part of a provider — set them via models:updateConfig. + 'models:setProvider': { + req: z.object({ + id: z.string(), + provider: LlmProvider, + }), res: z.object({ success: z.literal(true), }), }, - // Partial top-level merge into models.json — used by hybrid (signed-in + - // BYOK) settings to set the default selection / category overrides without - // clobbering the BYOK provider config that saveConfig owns. Omitted keys - // are untouched; null clears a key back to its default. + // Remove a provider entry plus any assistantModel / task override that + // references it (dangling selections would just error at run time). + 'models:removeProvider': { + req: z.object({ + id: z.string(), + }), + res: z.object({ + success: z.literal(true), + }), + }, + // Partial merge of model selections into models.json. Omitted keys are + // untouched; null clears a key (a cleared task override inherits the + // assistant model again). taskModels merges per-key. 'models:updateConfig': { req: z.object({ - defaultSelection: ModelRef.nullable().optional(), - knowledgeGraphModel: ModelOverride.nullable().optional(), - meetingNotesModel: ModelOverride.nullable().optional(), - liveNoteAgentModel: ModelOverride.nullable().optional(), - autoPermissionDecisionModel: ModelOverride.nullable().optional(), + assistantModel: ModelRef.nullable().optional(), + taskModels: z.object({ + knowledgeGraph: ModelRef.nullable().optional(), + meetingNotes: ModelRef.nullable().optional(), + liveNoteAgent: ModelRef.nullable().optional(), + autoPermissionDecision: ModelRef.nullable().optional(), + chatTitle: ModelRef.nullable().optional(), + }).optional(), deferBackgroundTasks: z.boolean().nullable().optional(), }), res: z.object({ diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index 9fd79a4d..e376bee4 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -8,6 +8,10 @@ import { z } from "zod"; // thinkingLevel, OpenRouter reasoning.effort) is mapped at invoke time. export const ReasoningEffort = z.enum(["low", "medium", "high"]); +// A provider entry: its TYPE (flavor) plus credentials and connection +// preferences. Deliberately carries NO model fields — model lists are always +// fetched from the provider (core/models/catalog.ts), and model choices live +// in assistantModel / taskModels. export const LlmProvider = z.object({ // "rowboat" (signed-in gateway) and "codex" (ChatGPT subscription via // "Sign in with ChatGPT") are credential-less flavors: they never appear @@ -28,51 +32,48 @@ export const LlmProvider = z.object({ reasoningEffort: ReasoningEffort.optional(), }); -// A provider-qualified model reference. `provider` is a provider name as -// understood by resolveProviderConfig — a BYOK flavor ("ollama", "openai", -// …) or "rowboat" for the signed-in gateway. +// A provider-qualified model reference. `provider` is a provider INSTANCE id +// as understood by resolveProviderConfig — a key of the providers map, or +// "rowboat" / "codex" for the credential-less providers. Today one instance +// exists per flavor, so instance ids equal flavor keys. export const ModelRef = z.object({ provider: z.string(), model: z.string(), }); -// Category overrides accept either a bare model id (legacy: paired with the -// active default provider) or a provider-qualified ref (hybrid mode: e.g. -// gateway assistant + local Ollama background agents). -export const ModelOverride = z.union([z.string(), ModelRef]); +// The per-task model override slots. Absence = inherit the assistant model. +export const TaskModels = z.object({ + knowledgeGraph: ModelRef.optional(), + meetingNotes: ModelRef.optional(), + liveNoteAgent: ModelRef.optional(), + autoPermissionDecision: ModelRef.optional(), + chatTitle: ModelRef.optional(), +}); +export type TaskModelKey = keyof z.infer; +/** + * models.json, version 2. + * + * The design: providers carry credentials only (keyed by instance id, with + * the flavor explicit inside each entry); model choices live in exactly two + * places — the required-once-configured `assistantModel`, and optional + * per-task overrides that otherwise inherit from it. Model LISTS are never + * stored: they are fetched live per provider by the unified catalog. + * + * Version 1 (top-level provider/model pair + per-provider model lists + + * defaultSelection + flat category overrides) is migrated on boot by + * core/models/migrate.ts and its schema lives there. + */ export const LlmModelConfig = z.object({ - provider: LlmProvider, - model: z.string(), - models: z.array(z.string()).optional(), - // The user's explicit default assistant model. When set it wins over both - // the signed-in curated default and the legacy top-level provider/model - // pair — this is what lets signed-in users default to a BYOK model. - defaultSelection: ModelRef.optional(), + version: z.literal(2), + providers: z.record(z.string(), LlmProvider), + // The one primary model choice: what runs when nothing more specific was + // picked. Absent only before onboarding / first provider connect. + assistantModel: ModelRef.optional(), + taskModels: TaskModels.optional(), // When true, background agent runs (knowledge pipeline, live notes, // background tasks) wait until no chat turn is running before starting. // Surfaced as a settings checkbox; recommended for local models, where a // background run competes with the chat for the same hardware. deferBackgroundTasks: z.boolean().optional(), - providers: z.record(z.string(), z.object({ - apiKey: z.string().optional(), - baseURL: z.string().optional(), - headers: z.record(z.string(), z.string()).optional(), - contextLength: z.number().int().positive().optional(), - reasoningEffort: ReasoningEffort.optional(), - model: z.string().optional(), - models: z.array(z.string()).optional(), - knowledgeGraphModel: z.string().optional(), - meetingNotesModel: z.string().optional(), - liveNoteAgentModel: z.string().optional(), - autoPermissionDecisionModel: z.string().optional(), - })).optional(), - // Per-category model overrides. Honored in both modes: when unset, - // signed-in users get the curated gateway defaults and BYOK users get the - // assistant model. Read by helpers in core/models/defaults.ts. - knowledgeGraphModel: ModelOverride.optional(), - meetingNotesModel: ModelOverride.optional(), - liveNoteAgentModel: ModelOverride.optional(), - autoPermissionDecisionModel: ModelOverride.optional(), - chatTitleModel: ModelOverride.optional(), });