mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
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:
parent
b0000a10d9
commit
f39daa9f5d
23 changed files with 938 additions and 539 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
46
apps/x/packages/core/src/models/chatgpt-selection.ts
Normal file
46
apps/x/packages/core/src/models/chatgpt-selection.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
130
apps/x/packages/core/src/models/migrate.test.ts
Normal file
130
apps/x/packages/core/src/models/migrate.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
167
apps/x/packages/core/src/models/migrate.ts
Normal file
167
apps/x/packages/core/src/models/migrate.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
92
apps/x/packages/core/src/models/repo.test.ts
Normal file
92
apps/x/packages/core/src/models/repo.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
apps/x/packages/core/src/models/rowboat-selection.ts
Normal file
48
apps/x/packages/core/src/models/rowboat-selection.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue