refactor(x)!: models.json v2 — providers carry credentials, models live in assistantModel/taskModels

The v1 config fossilized three schema generations: a top-level
provider/model pair (single-provider era), a providers map duplicating
credentials AND caching model lists (pre-dynamic-listing era), plus
defaultSelection and flat category overrides (hybrid era) — while several
effective models existed only as hidden curated branches in code.

v2 is the intended shape:
  { version: 2,
    providers: { <instance-id>: { flavor, apiKey?, baseURL?, … } },
    assistantModel: { provider, model },
    taskModels: { knowledgeGraph?, meetingNotes?, liveNoteAgent?,
                  autoPermissionDecision?, chatTitle? },
    deferBackgroundTasks? }

- one-time boot migration (core/models/migrate.ts, invoked from
  ensureConfig): evaluates the v1 resolution rules — including the curated
  signed-in defaults — one last time and writes the answers explicitly, so
  the simplified resolvers produce identical effective models; task
  overrides are written only where the old effective model differs from
  inherit-from-assistant; curated ids survive solely as frozen literals in
  the migration
- defaults.ts: getDefaultModelAndProvider reads assistantModel, period;
  getCategoryModel/getChatTitleModel are "override else assistant"; all
  SIGNED_IN_* constants and the repo's gpt-5.4 bootstrap deleted
- rowboat sign-in = connecting a provider: with no saved assistant, the
  initial model is picked via selectInitialModel (backend recommendation
  if the gateway lists it, else first listed) and saved; sign-out clears
  rowboat-referencing selections (same dangling-ref cleanup as removing
  any provider). A saved assistant is never replaced — signing in no
  longer hijacks a BYOK selection
- IPC: models:saveConfig replaced by models:setProvider/removeProvider;
  models:updateConfig speaks v2 keys; models:test takes {provider, model}
- renderer call sites (settings dialog, onboarding, composer) translated;
  the settings dialog's delete-provider path collapses from ~100 lines of
  top-level-pair juggling to removeProvider + best-effort promotion
- migration dry-run verified against a real-world signed-in config

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-24 13:51:58 +05:30
parent b0000a10d9
commit f39daa9f5d
23 changed files with 938 additions and 539 deletions

View file

@ -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: { <id>: { 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

View file

@ -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<ChatGPTSignInResult> {
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;
}
/**

View file

@ -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<IModelConfigRepo>('modelConfigRepo');
await repo.setConfig(args);
await repo.setProvider(args.id, args.provider);
return { success: true };
},
'models:removeProvider': async (_event, args) => {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
await repo.removeProvider(args.id);
return { success: true };
},
'models:updateConfig': async (_event, args) => {

View file

@ -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 });

View file

@ -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])

View file

@ -26,8 +26,8 @@ let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
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' },
})

View file

@ -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))

View file

@ -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<string, { provider?: string; model?: string } | undefined>
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<string, unknown> | 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<string, unknown>
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")

View file

@ -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' },
})

View file

@ -83,17 +83,10 @@ async function buildSnapshot(refreshProvider?: string): Promise<ModelsSnapshot>
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 } : {}),
})

View file

@ -385,5 +385,10 @@ export async function signOutChatGPT(): Promise<void> {
}
}
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');
}

View file

@ -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<InlineTaskSchedule | null> {
const repo = container.resolve<IModelConfigRepo>('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,
});

View file

@ -38,11 +38,18 @@ vi.mock('../di/container.js', () => ({
import { getModelCatalog, __resetModelCatalogForTests } from './catalog.js';
function serveConfig(providers: Record<string, unknown>, 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<string, Record<string, unknown>>,
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: [],
});
});

View file

@ -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<typeof LlmProvider>;
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<z.infer<typeof LlmModelConfig> | null> {
@ -128,45 +121,36 @@ async function readModelConfig(): Promise<z.infer<typeof LlmModelConfig> | 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<DiscoveredProvider[]> {
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<string, CatalogModelEntry[]>();
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;

View file

@ -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<void> {
const repo = container.resolve<IModelConfigRepo>("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<void> {
const repo = container.resolve<IModelConfigRepo>("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);
}
}

View file

@ -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<typeof ModelRef>;
async function readConfig(): Promise<z.infer<typeof LlmModelConfig> | null> {
@ -25,36 +10,25 @@ async function readConfig(): Promise<z.infer<typeof LlmModelConfig> | null> {
const repo = container.resolve<IModelConfigRepo>("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<boolean> {
}
/**
* 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<z.infer<typeof LlmProvider>> {
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<IModelConfigRepo>("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<ModelSelection> {
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<ModelSelection> {
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<ModelSelection> {
return getCategoryModel("knowledgeGraphModel", SIGNED_IN_KG_MODEL);
return getCategoryModel("knowledgeGraph");
}
/** Model used by the live-note agent + routing classifier. */
export async function getLiveNoteAgentModel(): Promise<ModelSelection> {
return getCategoryModel("liveNoteAgentModel", SIGNED_IN_LIVE_NOTE_AGENT_MODEL);
return getCategoryModel("liveNoteAgent");
}
/** Model used by the auto-permission classifier. */
export async function getAutoPermissionDecisionModel(): Promise<ModelSelection> {
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<ModelSelection> {
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<ModelSelection> {
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");
}
/**

View file

@ -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);
});
});

View file

@ -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<typeof ModelRef> = { 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<typeof LlmModelConfig>;
type Ref = z.infer<typeof ModelRef>;
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<string, unknown>, key: string, signedIn: boolean): Ref | undefined {
const value = raw[key];
if (typeof value === "string" && value) {
const flavor = (raw.provider as Record<string, unknown> | 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<string, unknown>, 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<string, unknown> | 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<string, unknown>;
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<string, unknown>,
);
const topLevel = raw.provider as Record<string, unknown> | 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<string, unknown>;
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<string, Ref | undefined> = {
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<V2["taskModels"]> = {};
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 } : {}),
};
}

View file

@ -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');
});
});

View file

@ -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<typeof LlmModelConfig>;
type Ref = z.infer<typeof ModelRef>;
type TaskModelPatch = { [K in keyof z.infer<typeof TaskModels>]?: 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<typeof ModelConfig>[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<void>;
getConfig(): Promise<z.infer<typeof ModelConfig>>;
setConfig(config: z.infer<typeof ModelConfig>): Promise<void>;
// 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<Config>;
/** Upsert one provider entry (credentials + connection prefs). */
setProvider(id: string, provider: z.infer<typeof LlmProvider>): Promise<void>;
/**
* 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<void>;
updateConfig(patch: ModelConfigPatch): Promise<void>;
}
const defaultConfig: z.infer<typeof ModelConfig> = {
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<void> {
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<z.infer<typeof ModelConfig>> {
async getConfig(): Promise<Config> {
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<typeof ModelConfig>): Promise<void> {
let existingProviders: Record<string, Record<string, unknown>> = {};
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<typeof LlmProvider>): Promise<void> {
const config = await this.read();
config.providers[id] = LlmProvider.parse(provider);
await this.write(config);
}
async removeProvider(id: string): Promise<void> {
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<string, unknown> = {};
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<string, unknown>)[key] === undefined)
.map((key) => [key, existing[key]]),
);
} catch {
// No existing config
if (config.taskModels) {
for (const key of Object.keys(config.taskModels) as Array<keyof NonNullable<Config["taskModels"]>>) {
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<void> {
const raw = await fs.readFile(this.configPath, "utf8");
const existing = JSON.parse(raw) as Record<string, unknown>;
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<Config> {
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<void> {
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);
}
}

View file

@ -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<void> {
const repo = container.resolve<IModelConfigRepo>("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<void> {
const repo = container.resolve<IModelConfigRepo>("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);
}
}

View file

@ -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({

View file

@ -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<typeof TaskModels>;
/**
* 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(),
});