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
|
## Common Tasks
|
||||||
|
|
||||||
### LLM configuration (single provider)
|
### LLM configuration
|
||||||
- Config file: `~/.rowboat/config/models.json`
|
- Config file: `~/.rowboat/config/models.json` (v2; v1 files are migrated on boot by `core/models/migrate.ts`)
|
||||||
- Schema: `{ provider: { flavor, apiKey?, baseURL?, headers? }, model: string }`
|
- 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)
|
- Models catalog cache: `~/.rowboat/config/models.dev.json` (OpenAI/Anthropic/Google only)
|
||||||
|
|
||||||
### Add a new shared type
|
### Add a new shared type
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { Server } from 'http';
|
||||||
import { createAuthServer } from './auth-server.js';
|
import { createAuthServer } from './auth-server.js';
|
||||||
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
|
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
|
||||||
import { exchangeChatGPTCode, getChatGPTStatus } from '@x/core/dist/auth/chatgpt-auth.js';
|
import { exchangeChatGPTCode, getChatGPTStatus } from '@x/core/dist/auth/chatgpt-auth.js';
|
||||||
|
import { applyCodexInitialSelection } from '@x/core/dist/models/chatgpt-selection.js';
|
||||||
import {
|
import {
|
||||||
CHATGPT_AUTHORIZE_URL,
|
CHATGPT_AUTHORIZE_URL,
|
||||||
CHATGPT_CALLBACK_PATH,
|
CHATGPT_CALLBACK_PATH,
|
||||||
|
|
@ -65,7 +66,14 @@ export async function signInWithChatGPT(): Promise<ChatGPTSignInResult> {
|
||||||
void attempt.promise.finally(() => {
|
void attempt.promise.finally(() => {
|
||||||
if (activeAttempt === attempt) activeAttempt = null;
|
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}` : ''}`);
|
console.log(`[llm:generate] -> provider=${result.provider ?? '?'} model=${result.model ?? '?'} chars=${result.text?.length ?? 0}${result.error ? ` error=${result.error}` : ''}`);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
'models:saveConfig': async (_event, args) => {
|
'models:setProvider': async (_event, args) => {
|
||||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
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 };
|
return { success: true };
|
||||||
},
|
},
|
||||||
'models:updateConfig': async (_event, args) => {
|
'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 { isSignedIn } from '@x/core/dist/account/account.js';
|
||||||
import { getWebappUrl } from '@x/core/dist/config/remote-config.js';
|
import { getWebappUrl } from '@x/core/dist/config/remote-config.js';
|
||||||
import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.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 {
|
function buildRedirectUri(port: number): string {
|
||||||
return `http://localhost:${port}/oauth/callback`;
|
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.
|
// multiple renderer hooks race to create the user, causing duplicates.
|
||||||
let signedInUserId: string | undefined;
|
let signedInUserId: string | undefined;
|
||||||
if (provider === 'rowboat') {
|
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 {
|
try {
|
||||||
const billing = await getBillingInfo();
|
const billing = await getBillingInfo();
|
||||||
if (billing.userId) {
|
if (billing.userId) {
|
||||||
|
|
@ -530,6 +536,10 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
|
||||||
if (provider === 'rowboat') {
|
if (provider === 'rowboat') {
|
||||||
analyticsCapture('user_signed_out');
|
analyticsCapture('user_signed_out');
|
||||||
analyticsReset();
|
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
|
// Notify renderer so sidebar, voice, and billing re-check state
|
||||||
emitOAuthEvent({ provider, success: false });
|
emitOAuthEvent({ provider, success: false });
|
||||||
|
|
|
||||||
|
|
@ -562,7 +562,7 @@ function ChatInputInner({
|
||||||
if (!model) return
|
if (!model) return
|
||||||
setSelectedModel(model)
|
setSelectedModel(model)
|
||||||
onSelectedModelChange?.(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')) })
|
.then(() => { window.dispatchEvent(new Event('models-config-changed')) })
|
||||||
.catch(() => { toast.error('Failed to save default model') })
|
.catch(() => { toast.error('Failed to save default model') })
|
||||||
}, [lockedModel, onSelectedModelChange])
|
}, [lockedModel, onSelectedModelChange])
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@ let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
|
||||||
function serveTwoProviders(): void {
|
function serveTwoProviders(): void {
|
||||||
handlers['models:list'] = async () => ({
|
handlers['models:list'] = async () => ({
|
||||||
providers: [
|
providers: [
|
||||||
{ id: 'openai', flavor: 'openai', status: 'ok', savedModel: 'gpt-5.4', models: [{ id: 'gpt-5.4' }] },
|
{ id: 'openai', flavor: 'openai', status: 'ok', models: [{ id: 'gpt-5.4' }] },
|
||||||
{ id: 'anthropic', flavor: 'anthropic', status: 'ok', savedModel: 'claude-opus-4-8', models: [{ id: 'claude-opus-4-8' }] },
|
{ id: 'anthropic', flavor: 'anthropic', status: 'ok', models: [{ id: 'claude-opus-4-8' }] },
|
||||||
],
|
],
|
||||||
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
|
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -484,11 +484,15 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
||||||
model = typed
|
model = typed
|
||||||
}
|
}
|
||||||
|
|
||||||
// `models` is the user's curated assistant-model list (shown in Settings),
|
// v2 writes: the provider entry carries credentials only; the resolved
|
||||||
// NOT the full provider catalog. Onboarding seeds it with just the selected
|
// model becomes the assistant model (onboarding's connect doubles as
|
||||||
// model; users add more from Settings. Persisting the whole catalog here
|
// the initial selection).
|
||||||
// rendered every model as a separate assistant-model row.
|
await window.ipc.invoke("models:setProvider", { id: llmProvider, provider })
|
||||||
await window.ipc.invoke("models:saveConfig", { provider, model, models: model ? [model] : [] })
|
if (model) {
|
||||||
|
await window.ipc.invoke("models:updateConfig", {
|
||||||
|
assistantModel: { provider: llmProvider, model },
|
||||||
|
})
|
||||||
|
}
|
||||||
window.dispatchEvent(new Event('models-config-changed'))
|
window.dispatchEvent(new Event('models-config-changed'))
|
||||||
setTestState({ status: "success" })
|
setTestState({ status: "success" })
|
||||||
setConnectedFlavors(prev => new Set(prev).add(llmProvider))
|
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 type { ipc as ipcShared } from "@x/shared"
|
||||||
import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
|
import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
|
||||||
import { useProviderModels } from "@/hooks/use-provider-models"
|
import { useProviderModels } from "@/hooks/use-provider-models"
|
||||||
|
import { useRowboatConfig } from "@/hooks/use-rowboat-config"
|
||||||
import { useChatGPT } from "@/hooks/useChatGPT"
|
import { useChatGPT } from "@/hooks/useChatGPT"
|
||||||
import { ModelSelector, type ModelRef } from "@/components/model-selector"
|
import { ModelSelector, type ModelRef } from "@/components/model-selector"
|
||||||
import { useModels } from "@/hooks/use-models"
|
import { useModels } from "@/hooks/use-models"
|
||||||
|
|
@ -548,6 +549,9 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
||||||
const activeConfig = providerConfigs[provider]
|
const activeConfig = providerConfigs[provider]
|
||||||
// Live per-key model list for the active provider — drives the primary
|
// Live per-key model list for the active provider — drives the primary
|
||||||
// model area. The per-function fields below still use the static catalog.
|
// 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({
|
const providerModels = useProviderModels({
|
||||||
flavor: provider,
|
flavor: provider,
|
||||||
apiKey: activeConfig.apiKey,
|
apiKey: activeConfig.apiKey,
|
||||||
|
|
@ -624,6 +628,11 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
||||||
const result = await window.ipc.invoke("workspace:readFile", {
|
const result = await window.ipc.invoke("workspace:readFile", {
|
||||||
path: "config/models.json",
|
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)
|
const parsed = JSON.parse(result.data)
|
||||||
setDeferBackgroundTasks(parsed?.deferBackgroundTasks === true)
|
setDeferBackgroundTasks(parsed?.deferBackgroundTasks === true)
|
||||||
setDeferExplicit(typeof parsed?.deferBackgroundTasks === "boolean")
|
setDeferExplicit(typeof parsed?.deferBackgroundTasks === "boolean")
|
||||||
|
|
@ -631,51 +640,32 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
||||||
setSavedProviders(new Set(
|
setSavedProviders(new Set(
|
||||||
Object.keys(parsed?.providers ?? {}).filter((k): k is LlmProviderFlavor => knownFlavors.has(k))
|
Object.keys(parsed?.providers ?? {}).filter((k): k is LlmProviderFlavor => knownFlavors.has(k))
|
||||||
))
|
))
|
||||||
if (parsed?.provider?.flavor && parsed?.model) {
|
const assistant = parsed?.assistantModel as { provider?: string; model?: string } | undefined
|
||||||
const flavor = parsed.provider.flavor as LlmProviderFlavor
|
const taskModels = (parsed?.taskModels ?? {}) as Record<string, { provider?: string; model?: string } | undefined>
|
||||||
setProvider(flavor)
|
const taskStringFor = (key: string, providerId: string): string => {
|
||||||
setDefaultProvider(flavor)
|
const ref = taskModels[key]
|
||||||
setProviderConfigs(prev => {
|
return ref?.provider === providerId ? asString(ref.model) : ""
|
||||||
const next = { ...prev };
|
}
|
||||||
// Hydrate all saved providers from the providers map
|
setProviderConfigs(prev => {
|
||||||
if (parsed.providers) {
|
const next = { ...prev };
|
||||||
for (const [key, entry] of Object.entries(parsed.providers)) {
|
for (const [key, entry] of Object.entries(parsed?.providers ?? {})) {
|
||||||
if (key in next) {
|
if (!(key in next)) continue
|
||||||
const e = entry as any;
|
const e = entry as { apiKey?: string; baseURL?: string };
|
||||||
const savedModels: string[] = Array.isArray(e.models) && e.models.length > 0
|
next[key as LlmProviderFlavor] = {
|
||||||
? e.models
|
apiKey: e.apiKey || "",
|
||||||
: e.model ? [e.model] : [""];
|
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
|
||||||
next[key as LlmProviderFlavor] = {
|
models: assistant?.provider === key && assistant.model ? [assistant.model] : [""],
|
||||||
apiKey: e.apiKey || "",
|
knowledgeGraphModel: taskStringFor("knowledgeGraph", key),
|
||||||
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
|
meetingNotesModel: taskStringFor("meetingNotes", key),
|
||||||
models: savedModels,
|
liveNoteAgentModel: taskStringFor("liveNoteAgent", key),
|
||||||
knowledgeGraphModel: asString(e.knowledgeGraphModel),
|
autoPermissionDecisionModel: taskStringFor("autoPermissionDecision", key),
|
||||||
meetingNotesModel: asString(e.meetingNotesModel),
|
};
|
||||||
liveNoteAgentModel: asString(e.liveNoteAgentModel),
|
}
|
||||||
autoPermissionDecisionModel: asString(e.autoPermissionDecisionModel),
|
return next;
|
||||||
};
|
})
|
||||||
}
|
if (assistant?.provider && knownFlavors.has(assistant.provider)) {
|
||||||
}
|
setProvider(assistant.provider as LlmProviderFlavor)
|
||||||
}
|
setDefaultProvider(assistant.provider as LlmProviderFlavor)
|
||||||
// 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;
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// No existing config or parse error - use defaults
|
// 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")
|
toast.error("Enter a model to connect")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The silently resolved model takes the primary slot; the rest of the
|
const providerEntry = {
|
||||||
// saved list is preserved (same semantics setPrimaryModel had).
|
flavor: provider,
|
||||||
const existing = activeConfig.models.map(m => m.trim())
|
apiKey: activeConfig.apiKey.trim() || undefined,
|
||||||
const allModels = [model, ...existing.slice(1).filter(m => m && m !== model)]
|
baseURL: activeConfig.baseURL.trim() || undefined,
|
||||||
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 result = await window.ipc.invoke("models:test", providerConfig)
|
const result = await window.ipc.invoke("models:test", { provider: providerEntry, model })
|
||||||
if (result.success) {
|
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)
|
setDefaultProvider(provider)
|
||||||
setProviderConfigs(prev => ({
|
setProviderConfigs(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[provider]: { ...prev[provider], models: allModels },
|
[provider]: { ...prev[provider], models: [model] },
|
||||||
}))
|
}))
|
||||||
setSavedProviders(prev => new Set(prev).add(provider))
|
setSavedProviders(prev => new Set(prev).add(provider))
|
||||||
setTestState({ status: "success" })
|
setTestState({ status: "success" })
|
||||||
|
|
@ -784,134 +778,63 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
||||||
}
|
}
|
||||||
}, [canTest, resolvedModel, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle])
|
}, [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 handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => {
|
||||||
const config = providerConfigs[prov]
|
const config = providerConfigs[prov]
|
||||||
const allModels = config.models.map(m => m.trim()).filter(Boolean)
|
|
||||||
if (!allModels[0]) return
|
|
||||||
try {
|
try {
|
||||||
await window.ipc.invoke("models:saveConfig", {
|
let model = config.models[0]?.trim() || ""
|
||||||
provider: {
|
if (!model) {
|
||||||
flavor: prov,
|
const listRes = await window.ipc.invoke("models:listForProvider", {
|
||||||
apiKey: config.apiKey.trim() || undefined,
|
provider: {
|
||||||
baseURL: config.baseURL.trim() || undefined,
|
flavor: prov,
|
||||||
},
|
apiKey: config.apiKey.trim() || undefined,
|
||||||
model: allModels[0],
|
baseURL: config.baseURL.trim() || undefined,
|
||||||
models: allModels,
|
},
|
||||||
...(rowboatConnected ? {} : {
|
})
|
||||||
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
|
const list = listRes.success ? listRes.models ?? [] : []
|
||||||
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
|
const recommended = modelRecommendations?.[prov]
|
||||||
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
|
model = (recommended && list.includes(recommended)) ? recommended : (list[0] ?? "")
|
||||||
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
|
}
|
||||||
}),
|
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)
|
setDefaultProvider(prov)
|
||||||
|
setProviderConfigs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[prov]: { ...prev[prov], models: [model] },
|
||||||
|
}))
|
||||||
window.dispatchEvent(new Event('models-config-changed'))
|
window.dispatchEvent(new Event('models-config-changed'))
|
||||||
toast.success("Default provider updated")
|
toast.success("Default provider updated")
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to set default provider")
|
toast.error("Failed to set default provider")
|
||||||
}
|
}
|
||||||
}, [providerConfigs, rowboatConnected])
|
}, [providerConfigs, modelRecommendations])
|
||||||
|
|
||||||
const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => {
|
const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => {
|
||||||
const isDefaultProv = defaultProvider === prov
|
const isDefaultProv = defaultProvider === prov
|
||||||
try {
|
try {
|
||||||
const result = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
|
// Removes the entry AND any assistant/task selection referencing it
|
||||||
let parsed = JSON.parse(result.data)
|
// (repo-side dangling cleanup).
|
||||||
|
await window.ipc.invoke("models:removeProvider", { id: prov })
|
||||||
|
|
||||||
// Disconnecting the default provider: silently promote another
|
// Disconnecting the assistant's provider: promote another connected
|
||||||
// connected provider first — the connect-only UI has no usable
|
// provider — the connect-only UI has no usable set-as-default step to
|
||||||
// set-as-default step to send the user to. Prefer the provider the
|
// send the user to. Best-effort; with none left the composer shows
|
||||||
// user's explicit defaultSelection points at; promotion goes through
|
// its connect hint.
|
||||||
// the same models:saveConfig path Set-as-default uses, so the repo
|
|
||||||
// writes a valid top-level provider/model.
|
|
||||||
let promoted: LlmProviderFlavor | null = null
|
let promoted: LlmProviderFlavor | null = null
|
||||||
if (isDefaultProv) {
|
if (isDefaultProv) {
|
||||||
const selProvider = parsed?.defaultSelection?.provider
|
const candidate = [...savedProviders].find((p): p is LlmProviderFlavor => p !== prov)
|
||||||
const candidates = Object.keys(parsed?.providers ?? {})
|
if (candidate) {
|
||||||
.filter((k): k is LlmProviderFlavor => k !== prov && k in providerConfigs)
|
await handleSetDefault(candidate)
|
||||||
.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,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
promoted = 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 => ({
|
setProviderConfigs(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
|
|
@ -933,7 +856,7 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to remove provider")
|
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 renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }) => {
|
||||||
const isDefault = defaultProvider === p.id
|
const isDefault = defaultProvider === p.id
|
||||||
|
|
@ -1611,23 +1534,18 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
parsed = JSON.parse(configResult.data)
|
parsed = JSON.parse(configResult.data)
|
||||||
} catch { /* no config yet */ }
|
} catch { /* no config yet */ }
|
||||||
|
|
||||||
// Current selections. Legacy string overrides pair with the BYOK
|
// models.json v2: assistantModel + taskModels refs.
|
||||||
// top-level flavor (mirrors core/models/defaults.ts).
|
|
||||||
const legacyFlavor = (parsed.provider as Record<string, unknown> | undefined)?.flavor
|
|
||||||
const toRef = (value: unknown): ModelRef | null => {
|
const toRef = (value: unknown): ModelRef | null => {
|
||||||
if (!value) return null
|
const ref = value as { provider?: unknown; model?: unknown } | null | undefined
|
||||||
if (typeof value === "string") {
|
return ref && typeof ref.provider === "string" && typeof ref.model === "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"
|
|
||||||
? { provider: ref.provider, model: ref.model }
|
? { provider: ref.provider, model: ref.model }
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
setSelectedDefault(toRef(parsed.defaultSelection))
|
const taskModels = (parsed.taskModels ?? {}) as Record<string, unknown>
|
||||||
setSelectedKg(toRef(parsed.knowledgeGraphModel))
|
setSelectedDefault(toRef(parsed.assistantModel))
|
||||||
setSelectedLiveNote(toRef(parsed.liveNoteAgentModel))
|
setSelectedKg(toRef(taskModels.knowledgeGraph))
|
||||||
setSelectedAutoPermission(toRef(parsed.autoPermissionDecisionModel))
|
setSelectedLiveNote(toRef(taskModels.liveNoteAgent))
|
||||||
|
setSelectedAutoPermission(toRef(taskModels.autoPermissionDecision))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to load models")
|
toast.error("Failed to load models")
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1642,10 +1560,16 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await window.ipc.invoke("models:updateConfig", {
|
await window.ipc.invoke("models:updateConfig", {
|
||||||
defaultSelection: selectedDefault,
|
// The "Rowboat default" sentinel no longer maps to a hidden curated
|
||||||
knowledgeGraphModel: selectedKg,
|
// default — a null task ref simply inherits the assistant model. The
|
||||||
liveNoteAgentModel: selectedLiveNote,
|
// assistant itself is only written when explicitly picked (clearing
|
||||||
autoPermissionDecisionModel: selectedAutoPermission,
|
// 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"))
|
window.dispatchEvent(new Event("models-config-changed"))
|
||||||
toast.success("Model configuration saved")
|
toast.success("Model configuration saved")
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ function serveCatalog(catalog: {
|
||||||
flavor?: string
|
flavor?: string
|
||||||
status?: 'ok' | 'error'
|
status?: 'ok' | 'error'
|
||||||
error?: string
|
error?: string
|
||||||
savedModel?: string
|
|
||||||
models: Array<{ id: string; reasoning?: boolean }>
|
models: Array<{ id: string; reasoning?: boolean }>
|
||||||
}>
|
}>
|
||||||
defaultModel: { provider: string; model: string } | null
|
defaultModel: { provider: string; model: string } | null
|
||||||
|
|
@ -99,32 +98,27 @@ describe('useModels', () => {
|
||||||
expect(invokeCounts['models:list']).toBe(1)
|
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({
|
serveCatalog({
|
||||||
providers: [
|
providers: [
|
||||||
{ id: 'openai', savedModel: 'gpt-4.1', models: [{ id: 'gpt-5.4' }, { id: 'gpt-4.1' }] },
|
{ id: 'ollama', status: 'error', error: 'connection refused', models: [] },
|
||||||
{
|
{ id: 'openai', models: [{ id: 'gpt-4.1' }, { id: 'gpt-5.4' }] },
|
||||||
id: 'ollama',
|
|
||||||
status: 'error',
|
|
||||||
error: 'connection refused',
|
|
||||||
savedModel: 'llama3',
|
|
||||||
models: [],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
defaultModel: { provider: 'ollama', model: 'llama3' },
|
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const { result } = renderHook(() => useModels())
|
const { result } = renderHook(() => useModels())
|
||||||
await waitFor(() => expect(result.current.groups.length).toBe(2))
|
await waitFor(() => expect(result.current.groups.length).toBe(2))
|
||||||
|
|
||||||
// The default's group leads (despite arriving second) with its saved
|
// The default's group leads (despite arriving second) and the default
|
||||||
// model still pickable; the failed status and error travel with it.
|
// model leads within it.
|
||||||
expect(result.current.groups[0]).toEqual({
|
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({
|
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({
|
serveCatalog({
|
||||||
providers: [
|
providers: [
|
||||||
{ id: 'openai', models: [{ id: 'gpt-5.4' }] },
|
{ id: 'openai', models: [{ id: 'gpt-5.4' }] },
|
||||||
{ id: 'ollama', savedModel: 'llama3', models: [{ id: 'llama3' }] },
|
{ id: 'ollama', models: [{ id: 'llama3' }] },
|
||||||
],
|
],
|
||||||
defaultModel: { provider: 'ollama', model: 'llama3' },
|
defaultModel: { provider: 'ollama', model: 'llama3' },
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -83,17 +83,10 @@ async function buildSnapshot(refreshProvider?: string): Promise<ModelsSnapshot>
|
||||||
reasoningByKey[`${p.id}/${m.id}`] = m.reasoning
|
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({
|
groups.push({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
flavor: p.flavor,
|
flavor: p.flavor,
|
||||||
models,
|
models: ids,
|
||||||
status: p.status,
|
status: p.status,
|
||||||
...(p.error ? { error: p.error } : {}),
|
...(p.error ? { error: p.error } : {}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -385,5 +385,10 @@ export async function signOutChatGPT(): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await clearStore();
|
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');
|
console.log('[ChatGPTAuth] Signed out');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,7 @@ import { CronExpressionParser } from 'cron-parser';
|
||||||
import { generateText } from 'ai';
|
import { generateText } from 'ai';
|
||||||
import { WorkDir } from '../config/config.js';
|
import { WorkDir } from '../config/config.js';
|
||||||
import { runWhenPossible } from '../runtime/assembly/headless-app.js';
|
import { runWhenPossible } from '../runtime/assembly/headless-app.js';
|
||||||
import { getKgModel } from '../models/defaults.js';
|
import { getKgModel, resolveProviderConfig } from '../models/defaults.js';
|
||||||
import container from '../di/container.js';
|
|
||||||
import type { IModelConfigRepo } from '../models/repo.js';
|
|
||||||
import { createLanguageModel } from '../models/models.js';
|
import { createLanguageModel } from '../models/models.js';
|
||||||
import { inlineTask } from '@x/shared';
|
import { inlineTask } from '@x/shared';
|
||||||
import { captureLlmUsage } from '../analytics/usage.js';
|
import { captureLlmUsage } from '../analytics/usage.js';
|
||||||
|
|
@ -611,9 +609,9 @@ export async function processRowboatInstruction(
|
||||||
* Returns a schedule object or null for one-time tasks.
|
* Returns a schedule object or null for one-time tasks.
|
||||||
*/
|
*/
|
||||||
export async function classifySchedule(instruction: string): Promise<InlineTaskSchedule | null> {
|
export async function classifySchedule(instruction: string): Promise<InlineTaskSchedule | null> {
|
||||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
const selection = await getKgModel();
|
||||||
const config = await repo.getConfig();
|
const providerConfig = await resolveProviderConfig(selection.provider);
|
||||||
const model = createLanguageModel(config.provider, config.model);
|
const model = createLanguageModel(providerConfig, selection.model);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const defaultEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
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({
|
captureLlmUsage({
|
||||||
useCase: 'knowledge_sync',
|
useCase: 'knowledge_sync',
|
||||||
subUseCase: 'inline_task_classify',
|
subUseCase: 'inline_task_classify',
|
||||||
model: config.model,
|
model: selection.model,
|
||||||
provider: config.provider.flavor,
|
provider: selection.provider,
|
||||||
usage: result.usage,
|
usage: result.usage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,18 @@ vi.mock('../di/container.js', () => ({
|
||||||
|
|
||||||
import { getModelCatalog, __resetModelCatalogForTests } from './catalog.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 () => ({
|
mocks.getConfig.mockImplementation(async () => ({
|
||||||
provider: { flavor: defaultFlavor },
|
version: 2,
|
||||||
model: 'gpt-5.4',
|
providers: Object.fromEntries(
|
||||||
providers,
|
Object.entries(providers).map(([id, entry]) => [id, { flavor: id, ...entry }]),
|
||||||
|
),
|
||||||
|
...(assistantModel ? { assistantModel } : {}),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,7 +68,7 @@ describe('getModelCatalog', () => {
|
||||||
mocks.isSignedIn.mockResolvedValue(true);
|
mocks.isSignedIn.mockResolvedValue(true);
|
||||||
mocks.getChatGPTStatus.mockResolvedValue({ signedIn: true });
|
mocks.getChatGPTStatus.mockResolvedValue({ signedIn: true });
|
||||||
serveConfig({
|
serveConfig({
|
||||||
ollama: { baseURL: 'http://localhost:11434', model: 'llama3' },
|
ollama: { baseURL: 'http://localhost:11434' },
|
||||||
});
|
});
|
||||||
mocks.listModelsForProvider.mockResolvedValue(['llama3', 'qwen3']);
|
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.map((p) => p.id)).toEqual(['rowboat', 'codex', 'ollama']);
|
||||||
expect(catalog.providers.every((p) => p.status === 'ok')).toBe(true);
|
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[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' });
|
expect(catalog.defaultModel).toEqual({ provider: 'openai', model: 'gpt-5.4' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips providers-map entries that carry no credential', async () => {
|
it('orders the assistant model provider first among configured providers', async () => {
|
||||||
serveConfig({
|
serveConfig(
|
||||||
openai: { model: 'gpt-5.4' }, // no key — not connected
|
{
|
||||||
anthropic: { apiKey: 'sk-b' },
|
openrouter: { apiKey: 'sk-1' },
|
||||||
});
|
ollama: { baseURL: 'http://localhost:11434' },
|
||||||
mocks.listModelsForProvider.mockResolvedValue(['claude-opus-4-8']);
|
},
|
||||||
|
{ provider: 'ollama', model: 'llama3' },
|
||||||
|
);
|
||||||
|
mocks.listModelsForProvider.mockResolvedValue(['m']);
|
||||||
|
|
||||||
const catalog = await getModelCatalog();
|
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 () => {
|
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 () => {
|
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'));
|
mocks.listModelsForProvider.mockRejectedValue(new Error('connection refused'));
|
||||||
|
|
||||||
const catalog = await getModelCatalog();
|
const catalog = await getModelCatalog();
|
||||||
|
|
@ -113,7 +123,6 @@ describe('getModelCatalog', () => {
|
||||||
id: 'ollama',
|
id: 'ollama',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: 'connection refused',
|
error: 'connection refused',
|
||||||
savedModel: 'llama3',
|
|
||||||
models: [],
|
models: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,6 @@ export interface CatalogProviderEntry {
|
||||||
/** "error" = the provider is connected but its model list failed to load. */
|
/** "error" = the provider is connected but its model list failed to load. */
|
||||||
status: "ok" | "error";
|
status: "ok" | "error";
|
||||||
error?: string;
|
error?: string;
|
||||||
/** The provider's saved default model from models.json, if any. */
|
|
||||||
savedModel?: string;
|
|
||||||
models: CatalogModelEntry[];
|
models: CatalogModelEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,14 +103,9 @@ type ProviderConfig = z.infer<typeof LlmProvider>;
|
||||||
|
|
||||||
interface DiscoveredProvider {
|
interface DiscoveredProvider {
|
||||||
id: string;
|
id: string;
|
||||||
|
flavor: string;
|
||||||
/** Absent for rowboat/codex — their auth lives outside models.json. */
|
/** Absent for rowboat/codex — their auth lives outside models.json. */
|
||||||
config?: ProviderConfig;
|
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> {
|
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
|
* Which providers are connected right now. Rowboat and ChatGPT come from
|
||||||
* their auth state; everything else from the models.json providers map
|
* their auth state; everything else from the models.json providers map
|
||||||
* (an entry counts as connected once it carries some credential). The
|
* (entries carry credentials by construction in v2). The assistant model's
|
||||||
* default provider's entry leads, matching picker ordering.
|
* provider leads, matching picker ordering.
|
||||||
*/
|
*/
|
||||||
async function discoverProviders(): Promise<DiscoveredProvider[]> {
|
async function discoverProviders(): Promise<DiscoveredProvider[]> {
|
||||||
const discovered: DiscoveredProvider[] = [];
|
const discovered: DiscoveredProvider[] = [];
|
||||||
|
|
||||||
if (await isSignedIn().catch(() => false)) {
|
if (await isSignedIn().catch(() => false)) {
|
||||||
discovered.push({ id: "rowboat" });
|
discovered.push({ id: "rowboat", flavor: "rowboat" });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const chatgpt = await getChatGPTStatus();
|
const chatgpt = await getChatGPTStatus();
|
||||||
if (chatgpt.signedIn) discovered.push({ id: "codex" });
|
if (chatgpt.signedIn) discovered.push({ id: "codex", flavor: "codex" });
|
||||||
} catch {
|
} catch {
|
||||||
// ChatGPT status failures must never break the main list.
|
// ChatGPT status failures must never break the main list.
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = await readModelConfig();
|
const cfg = await readModelConfig();
|
||||||
const providersMap = cfg?.providers ?? {};
|
const providersMap = cfg?.providers ?? {};
|
||||||
const defaultFlavor = cfg?.provider.flavor ?? "";
|
const assistantProvider = cfg?.assistantModel?.provider ?? "";
|
||||||
const flavors = Object.keys(providersMap)
|
const ids = Object.keys(providersMap)
|
||||||
.sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0));
|
.sort((a, b) => (a === assistantProvider ? -1 : b === assistantProvider ? 1 : 0));
|
||||||
|
|
||||||
for (const flavor of flavors) {
|
for (const id of ids) {
|
||||||
const entry = providersMap[flavor] ?? {};
|
const entry = providersMap[id];
|
||||||
const apiKey = entry.apiKey?.trim() ?? "";
|
if (!entry) continue;
|
||||||
const baseURL = entry.baseURL?.trim() ?? "";
|
const config = { ...entry };
|
||||||
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;
|
|
||||||
if (config.flavor === "aigateway" && !config.baseURL) {
|
if (config.flavor === "aigateway" && !config.baseURL) {
|
||||||
config.baseURL = AIGATEWAY_DEFAULT_BASE_URL;
|
config.baseURL = AIGATEWAY_DEFAULT_BASE_URL;
|
||||||
}
|
}
|
||||||
discovered.push({ id: flavor, config, savedModel });
|
discovered.push({ id, flavor: entry.flavor, config });
|
||||||
}
|
}
|
||||||
|
|
||||||
return discovered;
|
return discovered;
|
||||||
|
|
@ -192,10 +176,10 @@ async function fetchProviderEntry(
|
||||||
} else if (provider.id === "codex") {
|
} else if (provider.id === "codex") {
|
||||||
const result = await listCodexModels();
|
const result = await listCodexModels();
|
||||||
models = result.providers[0]?.models ?? [];
|
models = result.providers[0]?.models ?? [];
|
||||||
} else if (MODELS_DEV_FLAVORS.has(provider.id) && (modelsDevByFlavor.get(provider.id)?.length ?? 0) > 0) {
|
} else if (MODELS_DEV_FLAVORS.has(provider.flavor) && (modelsDevByFlavor.get(provider.flavor)?.length ?? 0) > 0) {
|
||||||
models = modelsDevByFlavor.get(provider.id) ?? [];
|
models = modelsDevByFlavor.get(provider.flavor) ?? [];
|
||||||
} else if (!provider.config) {
|
} else if (!provider.config) {
|
||||||
models = (provider.savedModels ?? []).map((id) => ({ id }));
|
throw new Error(`Provider '${provider.id}' has no configuration to list models with`);
|
||||||
} else {
|
} else {
|
||||||
// Live listing: local/custom flavors always, cloud flavors only
|
// Live listing: local/custom flavors always, cloud flavors only
|
||||||
// when the models.dev cache is empty (offline fresh install).
|
// 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,
|
// One models.dev read serves every cloud flavor in the build (disk cache,
|
||||||
// no network — refreshed by its own background loop).
|
// no network — refreshed by its own background loop).
|
||||||
const modelsDevByFlavor = new Map<string, CatalogModelEntry[]>();
|
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 {
|
try {
|
||||||
const catalog = await listOnboardingModels();
|
const catalog = await listOnboardingModels();
|
||||||
for (const p of catalog.providers) {
|
for (const p of catalog.providers) {
|
||||||
|
|
@ -274,11 +258,9 @@ export async function getModelCatalog(options?: GetModelCatalogOptions): Promise
|
||||||
);
|
);
|
||||||
const result: CatalogProviderEntry = {
|
const result: CatalogProviderEntry = {
|
||||||
id: provider.id,
|
id: provider.id,
|
||||||
// One instance per flavor today, so the id IS the flavor key.
|
flavor: provider.flavor,
|
||||||
flavor: provider.config?.flavor ?? provider.id,
|
|
||||||
status: entry.status,
|
status: entry.status,
|
||||||
...(entry.error ? { error: entry.error } : {}),
|
...(entry.error ? { error: entry.error } : {}),
|
||||||
...(provider.savedModel ? { savedModel: provider.savedModel } : {}),
|
|
||||||
models: entry.models,
|
models: entry.models,
|
||||||
};
|
};
|
||||||
return result;
|
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 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 { IModelConfigRepo } from "./repo.js";
|
||||||
import { isSignedIn } from "../account/account.js";
|
|
||||||
import container from "../di/container.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>;
|
export type ModelSelection = z.infer<typeof ModelRef>;
|
||||||
|
|
||||||
async function readConfig(): Promise<z.infer<typeof LlmModelConfig> | null> {
|
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");
|
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||||
return await repo.getConfig();
|
return await repo.getConfig();
|
||||||
} catch {
|
} catch {
|
||||||
// Signed-in users may have no models.json at all.
|
// Fresh install before ensureConfig ran, or an unreadable file.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The single source of truth for "what model+provider should we use when
|
* The single source of truth for "what model+provider should we use when
|
||||||
* the caller didn't specify and the agent didn't declare".
|
* the caller didn't specify and the agent didn't declare": the config's
|
||||||
*
|
* assistantModel, period. It is written by onboarding / provider connect
|
||||||
* Resolution order (hybrid mode):
|
* (via initial selection) and by every model pick in the UI; hidden
|
||||||
* 1. `defaultSelection` — the user's explicit choice; may point at the
|
* fallback defaults were removed with the v2 config migration.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export async function getDefaultModelAndProvider(): Promise<{ model: string; provider: string }> {
|
export async function getDefaultModelAndProvider(): Promise<{ model: string; provider: string }> {
|
||||||
const signedIn = await isSignedIn();
|
|
||||||
const cfg = await readConfig();
|
const cfg = await readConfig();
|
||||||
const selection = cfg?.defaultSelection;
|
const assistant = cfg?.assistantModel;
|
||||||
if (selection && (selection.provider !== "rowboat" || signedIn)) {
|
if (!assistant) {
|
||||||
return { model: selection.model, provider: selection.provider };
|
throw new Error("No assistant model configured (connect a provider or sign in)");
|
||||||
}
|
}
|
||||||
if (signedIn) {
|
return { model: assistant.model, provider: assistant.provider };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -68,69 +42,39 @@ export async function shouldDeferBackgroundTasks(): Promise<boolean> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a provider name (as stored on a run, an agent, or returned by
|
* Resolve a provider instance id (as stored on a run, an agent, or returned
|
||||||
* getDefaultModelAndProvider) into the full LlmProvider config that
|
* by getDefaultModelAndProvider) into the LlmProvider entry that
|
||||||
* createProvider expects (apiKey/baseURL/headers).
|
* createProvider expects.
|
||||||
*
|
*
|
||||||
* - "rowboat" → gateway provider (auth via OAuth bearer; no creds field).
|
* - "rowboat" → gateway provider (auth via OAuth bearer; no creds field).
|
||||||
* - other names → look up models.json's `providers[name]` map.
|
* - "codex" → ChatGPT subscription (auth in chatgpt-auth.json).
|
||||||
* - fallback: if the name matches the active default's flavor (legacy
|
* - other ids → the models.json providers map.
|
||||||
* single-provider configs that didn't write to the providers map yet).
|
|
||||||
*/
|
*/
|
||||||
export async function resolveProviderConfig(name: string): Promise<z.infer<typeof LlmProvider>> {
|
export async function resolveProviderConfig(name: string): Promise<z.infer<typeof LlmProvider>> {
|
||||||
if (name === "rowboat") {
|
if (name === "rowboat") {
|
||||||
return { flavor: "rowboat" };
|
return { flavor: "rowboat" };
|
||||||
}
|
}
|
||||||
if (name === "codex") {
|
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" };
|
return { flavor: "codex" };
|
||||||
}
|
}
|
||||||
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
const cfg = await readConfig();
|
||||||
const cfg = await repo.getConfig();
|
const entry = cfg?.providers[name];
|
||||||
const entry = cfg.providers?.[name];
|
if (!entry) {
|
||||||
if (entry) {
|
throw new Error(`Provider '${name}' is referenced but not configured`);
|
||||||
return LlmProvider.parse({
|
|
||||||
flavor: name,
|
|
||||||
apiKey: entry.apiKey,
|
|
||||||
baseURL: entry.baseURL,
|
|
||||||
headers: entry.headers,
|
|
||||||
contextLength: entry.contextLength,
|
|
||||||
reasoningEffort: entry.reasoningEffort,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (cfg.provider.flavor === name) {
|
return entry;
|
||||||
return cfg.provider;
|
|
||||||
}
|
|
||||||
throw new Error(`Provider '${name}' is referenced but not configured`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-category model resolution (hybrid mode):
|
/**
|
||||||
// 1. An explicit override wins in BOTH modes. Provider-qualified refs are
|
* Per-task model resolution: the explicit taskModels override wins, else the
|
||||||
// used as-is (a "rowboat" ref is skipped while signed out); legacy string
|
* assistant model. No hidden per-task defaults — the v2 migration
|
||||||
// overrides pair with the BYOK provider they were configured against
|
* materialized the historical curated models as visible overrides.
|
||||||
// (the top-level flavor), NOT the dynamic default — so a signed-in user's
|
*/
|
||||||
// local-model overrides keep routing to their local server.
|
async function getCategoryModel(category: TaskModelKey): Promise<ModelSelection> {
|
||||||
// 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();
|
|
||||||
const cfg = await readConfig();
|
const cfg = await readConfig();
|
||||||
const override = cfg?.[category];
|
const override = cfg?.taskModels?.[category];
|
||||||
if (override) {
|
if (override) {
|
||||||
if (typeof override === "string") {
|
return { model: override.model, provider: override.provider };
|
||||||
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 getDefaultModelAndProvider();
|
return getDefaultModelAndProvider();
|
||||||
}
|
}
|
||||||
|
|
@ -140,55 +84,27 @@ async function getCategoryModel(
|
||||||
* etc.) when they're the top-level of a run.
|
* etc.) when they're the top-level of a run.
|
||||||
*/
|
*/
|
||||||
export async function getKgModel(): Promise<ModelSelection> {
|
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. */
|
/** Model used by the live-note agent + routing classifier. */
|
||||||
export async function getLiveNoteAgentModel(): Promise<ModelSelection> {
|
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. */
|
/** Model used by the auto-permission classifier. */
|
||||||
export async function getAutoPermissionDecisionModel(): Promise<ModelSelection> {
|
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. */
|
||||||
* Model used by the meeting-notes summarizer. No special signed-in curated
|
|
||||||
* model — historically meetings used the assistant model.
|
|
||||||
*/
|
|
||||||
export async function getMeetingNotesModel(): Promise<ModelSelection> {
|
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. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export async function getChatTitleModel(): Promise<ModelSelection> {
|
export async function getChatTitleModel(): Promise<ModelSelection> {
|
||||||
const signedIn = await isSignedIn();
|
return getCategoryModel("chatTitle");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
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 { WorkDir } from "../config/config.js";
|
||||||
|
import { isSignedIn } from "../account/account.js";
|
||||||
|
import { migrateModelsConfig } from "./migrate.js";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import z from "zod";
|
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 = {
|
export type ModelConfigPatch = {
|
||||||
[K in
|
assistantModel?: Ref | null;
|
||||||
| "defaultSelection"
|
taskModels?: TaskModelPatch;
|
||||||
| "knowledgeGraphModel"
|
deferBackgroundTasks?: boolean | null;
|
||||||
| "meetingNotesModel"
|
|
||||||
| "liveNoteAgentModel"
|
|
||||||
| "autoPermissionDecisionModel"
|
|
||||||
| "deferBackgroundTasks"]?: z.infer<typeof ModelConfig>[K] | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IModelConfigRepo {
|
export interface IModelConfigRepo {
|
||||||
|
/** Create the file if missing; migrate v1 → v2 in place if needed. */
|
||||||
ensureConfig(): Promise<void>;
|
ensureConfig(): Promise<void>;
|
||||||
getConfig(): Promise<z.infer<typeof ModelConfig>>;
|
getConfig(): Promise<Config>;
|
||||||
setConfig(config: z.infer<typeof ModelConfig>): Promise<void>;
|
/** Upsert one provider entry (credentials + connection prefs). */
|
||||||
// Merge the given top-level keys into the existing file without touching
|
setProvider(id: string, provider: z.infer<typeof LlmProvider>): Promise<void>;
|
||||||
// provider credentials — hybrid settings (default selection, category
|
/**
|
||||||
// overrides) save through this. Omitted keys are untouched; an explicit
|
* Remove a provider entry and every model selection that references it
|
||||||
// null clears the key back to its default.
|
* (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>;
|
updateConfig(patch: ModelConfigPatch): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: z.infer<typeof ModelConfig> = {
|
const emptyConfig: Config = {
|
||||||
provider: {
|
version: 2,
|
||||||
flavor: "openai",
|
providers: {},
|
||||||
},
|
|
||||||
model: "gpt-5.4",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isEnoent(err: unknown): boolean {
|
||||||
|
return (err as NodeJS.ErrnoException | null)?.code === "ENOENT";
|
||||||
|
}
|
||||||
|
|
||||||
export class FSModelConfigRepo implements IModelConfigRepo {
|
export class FSModelConfigRepo implements IModelConfigRepo {
|
||||||
private readonly configPath = path.join(WorkDir, "config", "models.json");
|
private readonly configPath = path.join(WorkDir, "config", "models.json");
|
||||||
|
|
||||||
async ensureConfig(): Promise<void> {
|
async ensureConfig(): Promise<void> {
|
||||||
|
let rawText: string;
|
||||||
try {
|
try {
|
||||||
await fs.access(this.configPath);
|
rawText = await fs.readFile(this.configPath, "utf8");
|
||||||
} catch {
|
} catch (err) {
|
||||||
await fs.writeFile(this.configPath, JSON.stringify(defaultConfig, null, 2));
|
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");
|
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> {
|
async setProvider(id: string, provider: z.infer<typeof LlmProvider>): Promise<void> {
|
||||||
let existingProviders: Record<string, Record<string, unknown>> = {};
|
const config = await this.read();
|
||||||
try {
|
config.providers[id] = LlmProvider.parse(provider);
|
||||||
const raw = await fs.readFile(this.configPath, "utf8");
|
await this.write(config);
|
||||||
const existing = JSON.parse(raw);
|
}
|
||||||
existingProviders = existing.providers || {};
|
|
||||||
} catch {
|
async removeProvider(id: string): Promise<void> {
|
||||||
// No existing config
|
const config = await this.read();
|
||||||
|
delete config.providers[id];
|
||||||
|
if (config.assistantModel?.provider === id) {
|
||||||
|
delete config.assistantModel;
|
||||||
}
|
}
|
||||||
|
if (config.taskModels) {
|
||||||
existingProviders[config.provider.flavor] = {
|
for (const key of Object.keys(config.taskModels) as Array<keyof NonNullable<Config["taskModels"]>>) {
|
||||||
...existingProviders[config.provider.flavor],
|
if (config.taskModels[key]?.provider === id) {
|
||||||
apiKey: config.provider.apiKey,
|
delete config.taskModels[key];
|
||||||
baseURL: config.provider.baseURL,
|
}
|
||||||
headers: config.provider.headers,
|
}
|
||||||
// Preserve hand-edited local-model tuning unless the caller sets it.
|
if (Object.keys(config.taskModels).length === 0) delete config.taskModels;
|
||||||
...(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
|
|
||||||
}
|
}
|
||||||
|
await this.write(config);
|
||||||
const toWrite = { ...existingSelections, ...config, providers: existingProviders };
|
|
||||||
await fs.writeFile(this.configPath, JSON.stringify(toWrite, null, 2));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateConfig(patch: ModelConfigPatch): Promise<void> {
|
async updateConfig(patch: ModelConfigPatch): Promise<void> {
|
||||||
const raw = await fs.readFile(this.configPath, "utf8");
|
const config = await this.read();
|
||||||
const existing = JSON.parse(raw) as Record<string, unknown>;
|
if (patch.assistantModel !== undefined) {
|
||||||
for (const [key, value] of Object.entries(patch)) {
|
if (patch.assistantModel === null) delete config.assistantModel;
|
||||||
if (value === undefined || value === null) {
|
else config.assistantModel = patch.assistantModel;
|
||||||
delete existing[key];
|
|
||||||
} else {
|
|
||||||
existing[key] = value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ModelConfig.parse(existing);
|
if (patch.taskModels !== undefined) {
|
||||||
await fs.writeFile(this.configPath, JSON.stringify(existing, null, 2));
|
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 { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, WorkspaceChangeEvent, WriteFileOptions, WriteFileResult, RemoveOptions } from './workspace.js';
|
||||||
import { ListToolsResponse } from './mcp.js';
|
import { ListToolsResponse } from './mcp.js';
|
||||||
import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.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 { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
|
||||||
import { AgentScheduleState } from './agent-schedule-state.js';
|
import { AgentScheduleState } from './agent-schedule-state.js';
|
||||||
import { ServiceEvent } from './service-events.js';
|
import { ServiceEvent } from './service-events.js';
|
||||||
|
|
@ -667,8 +667,6 @@ const ipcSchemas = {
|
||||||
// 'error' = provider is connected but its model list failed to load.
|
// 'error' = provider is connected but its model list failed to load.
|
||||||
status: z.enum(['ok', 'error']),
|
status: z.enum(['ok', 'error']),
|
||||||
error: z.string().optional(),
|
error: z.string().optional(),
|
||||||
// The provider's saved default model from models.json, if any.
|
|
||||||
savedModel: z.string().optional(),
|
|
||||||
models: z.array(z.object({
|
models: z.array(z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
|
@ -682,7 +680,10 @@ const ipcSchemas = {
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
'models:test': {
|
'models:test': {
|
||||||
req: LlmModelConfig,
|
req: z.object({
|
||||||
|
provider: LlmProvider,
|
||||||
|
model: z.string(),
|
||||||
|
}),
|
||||||
res: z.object({
|
res: z.object({
|
||||||
success: z.boolean(),
|
success: z.boolean(),
|
||||||
error: z.string().optional(),
|
error: z.string().optional(),
|
||||||
|
|
@ -726,23 +727,40 @@ const ipcSchemas = {
|
||||||
error: z.string().optional(),
|
error: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
'models:saveConfig': {
|
// Upsert one provider entry (credentials + connection prefs). Model
|
||||||
req: LlmModelConfig,
|
// 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({
|
res: z.object({
|
||||||
success: z.literal(true),
|
success: z.literal(true),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
// Partial top-level merge into models.json — used by hybrid (signed-in +
|
// Remove a provider entry plus any assistantModel / task override that
|
||||||
// BYOK) settings to set the default selection / category overrides without
|
// references it (dangling selections would just error at run time).
|
||||||
// clobbering the BYOK provider config that saveConfig owns. Omitted keys
|
'models:removeProvider': {
|
||||||
// are untouched; null clears a key back to its default.
|
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': {
|
'models:updateConfig': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
defaultSelection: ModelRef.nullable().optional(),
|
assistantModel: ModelRef.nullable().optional(),
|
||||||
knowledgeGraphModel: ModelOverride.nullable().optional(),
|
taskModels: z.object({
|
||||||
meetingNotesModel: ModelOverride.nullable().optional(),
|
knowledgeGraph: ModelRef.nullable().optional(),
|
||||||
liveNoteAgentModel: ModelOverride.nullable().optional(),
|
meetingNotes: ModelRef.nullable().optional(),
|
||||||
autoPermissionDecisionModel: ModelOverride.nullable().optional(),
|
liveNoteAgent: ModelRef.nullable().optional(),
|
||||||
|
autoPermissionDecision: ModelRef.nullable().optional(),
|
||||||
|
chatTitle: ModelRef.nullable().optional(),
|
||||||
|
}).optional(),
|
||||||
deferBackgroundTasks: z.boolean().nullable().optional(),
|
deferBackgroundTasks: z.boolean().nullable().optional(),
|
||||||
}),
|
}),
|
||||||
res: z.object({
|
res: z.object({
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ import { z } from "zod";
|
||||||
// thinkingLevel, OpenRouter reasoning.effort) is mapped at invoke time.
|
// thinkingLevel, OpenRouter reasoning.effort) is mapped at invoke time.
|
||||||
export const ReasoningEffort = z.enum(["low", "medium", "high"]);
|
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({
|
export const LlmProvider = z.object({
|
||||||
// "rowboat" (signed-in gateway) and "codex" (ChatGPT subscription via
|
// "rowboat" (signed-in gateway) and "codex" (ChatGPT subscription via
|
||||||
// "Sign in with ChatGPT") are credential-less flavors: they never appear
|
// "Sign in with ChatGPT") are credential-less flavors: they never appear
|
||||||
|
|
@ -28,51 +32,48 @@ export const LlmProvider = z.object({
|
||||||
reasoningEffort: ReasoningEffort.optional(),
|
reasoningEffort: ReasoningEffort.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// A provider-qualified model reference. `provider` is a provider name as
|
// A provider-qualified model reference. `provider` is a provider INSTANCE id
|
||||||
// understood by resolveProviderConfig — a BYOK flavor ("ollama", "openai",
|
// as understood by resolveProviderConfig — a key of the providers map, or
|
||||||
// …) or "rowboat" for the signed-in gateway.
|
// "rowboat" / "codex" for the credential-less providers. Today one instance
|
||||||
|
// exists per flavor, so instance ids equal flavor keys.
|
||||||
export const ModelRef = z.object({
|
export const ModelRef = z.object({
|
||||||
provider: z.string(),
|
provider: z.string(),
|
||||||
model: z.string(),
|
model: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Category overrides accept either a bare model id (legacy: paired with the
|
// The per-task model override slots. Absence = inherit the assistant model.
|
||||||
// active default provider) or a provider-qualified ref (hybrid mode: e.g.
|
export const TaskModels = z.object({
|
||||||
// gateway assistant + local Ollama background agents).
|
knowledgeGraph: ModelRef.optional(),
|
||||||
export const ModelOverride = z.union([z.string(), ModelRef]);
|
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({
|
export const LlmModelConfig = z.object({
|
||||||
provider: LlmProvider,
|
version: z.literal(2),
|
||||||
model: z.string(),
|
providers: z.record(z.string(), LlmProvider),
|
||||||
models: z.array(z.string()).optional(),
|
// The one primary model choice: what runs when nothing more specific was
|
||||||
// The user's explicit default assistant model. When set it wins over both
|
// picked. Absent only before onboarding / first provider connect.
|
||||||
// the signed-in curated default and the legacy top-level provider/model
|
assistantModel: ModelRef.optional(),
|
||||||
// pair — this is what lets signed-in users default to a BYOK model.
|
taskModels: TaskModels.optional(),
|
||||||
defaultSelection: ModelRef.optional(),
|
|
||||||
// When true, background agent runs (knowledge pipeline, live notes,
|
// When true, background agent runs (knowledge pipeline, live notes,
|
||||||
// background tasks) wait until no chat turn is running before starting.
|
// background tasks) wait until no chat turn is running before starting.
|
||||||
// Surfaced as a settings checkbox; recommended for local models, where a
|
// Surfaced as a settings checkbox; recommended for local models, where a
|
||||||
// background run competes with the chat for the same hardware.
|
// background run competes with the chat for the same hardware.
|
||||||
deferBackgroundTasks: z.boolean().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: 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