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

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

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

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

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

View file

@ -385,5 +385,10 @@ export async function signOutChatGPT(): Promise<void> {
}
}
await clearStore();
// Signing out disconnects the codex provider: drop the model selections
// that reference it (same dangling-ref cleanup as removing any
// provider). Lazy import — models/catalog imports this module.
const { clearCodexSelections } = await import('../models/chatgpt-selection.js');
await clearCodexSelections();
console.log('[ChatGPTAuth] Signed out');
}

View file

@ -4,9 +4,7 @@ import { CronExpressionParser } from 'cron-parser';
import { generateText } from 'ai';
import { WorkDir } from '../config/config.js';
import { runWhenPossible } from '../runtime/assembly/headless-app.js';
import { getKgModel } from '../models/defaults.js';
import container from '../di/container.js';
import type { IModelConfigRepo } from '../models/repo.js';
import { getKgModel, resolveProviderConfig } from '../models/defaults.js';
import { createLanguageModel } from '../models/models.js';
import { inlineTask } from '@x/shared';
import { captureLlmUsage } from '../analytics/usage.js';
@ -611,9 +609,9 @@ export async function processRowboatInstruction(
* Returns a schedule object or null for one-time tasks.
*/
export async function classifySchedule(instruction: string): Promise<InlineTaskSchedule | null> {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
const config = await repo.getConfig();
const model = createLanguageModel(config.provider, config.model);
const selection = await getKgModel();
const providerConfig = await resolveProviderConfig(selection.provider);
const model = createLanguageModel(providerConfig, selection.model);
const now = new Date();
const defaultEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
@ -660,8 +658,8 @@ Respond with ONLY valid JSON: either a schedule object or null. No other text.`;
captureLlmUsage({
useCase: 'knowledge_sync',
subUseCase: 'inline_task_classify',
model: config.model,
provider: config.provider.flavor,
model: selection.model,
provider: selection.provider,
usage: result.usage,
});

View file

@ -38,11 +38,18 @@ vi.mock('../di/container.js', () => ({
import { getModelCatalog, __resetModelCatalogForTests } from './catalog.js';
function serveConfig(providers: Record<string, unknown>, defaultFlavor = 'openai'): void {
// v2 config: providers keyed by instance id, flavor explicit inside (the
// helper defaults flavor to the key — one instance per flavor today).
function serveConfig(
providers: Record<string, Record<string, unknown>>,
assistantModel?: { provider: string; model: string },
): void {
mocks.getConfig.mockImplementation(async () => ({
provider: { flavor: defaultFlavor },
model: 'gpt-5.4',
providers,
version: 2,
providers: Object.fromEntries(
Object.entries(providers).map(([id, entry]) => [id, { flavor: id, ...entry }]),
),
...(assistantModel ? { assistantModel } : {}),
}));
}
@ -61,7 +68,7 @@ describe('getModelCatalog', () => {
mocks.isSignedIn.mockResolvedValue(true);
mocks.getChatGPTStatus.mockResolvedValue({ signedIn: true });
serveConfig({
ollama: { baseURL: 'http://localhost:11434', model: 'llama3' },
ollama: { baseURL: 'http://localhost:11434' },
});
mocks.listModelsForProvider.mockResolvedValue(['llama3', 'qwen3']);
@ -70,19 +77,22 @@ describe('getModelCatalog', () => {
expect(catalog.providers.map((p) => p.id)).toEqual(['rowboat', 'codex', 'ollama']);
expect(catalog.providers.every((p) => p.status === 'ok')).toBe(true);
expect(catalog.providers[0].models).toEqual([{ id: 'google/gemini-3.5-flash', reasoning: true }]);
expect(catalog.providers[2]).toMatchObject({ savedModel: 'llama3', models: [{ id: 'llama3' }, { id: 'qwen3' }] });
expect(catalog.providers[2]).toMatchObject({ flavor: 'ollama', models: [{ id: 'llama3' }, { id: 'qwen3' }] });
expect(catalog.defaultModel).toEqual({ provider: 'openai', model: 'gpt-5.4' });
});
it('skips providers-map entries that carry no credential', async () => {
serveConfig({
openai: { model: 'gpt-5.4' }, // no key — not connected
anthropic: { apiKey: 'sk-b' },
});
mocks.listModelsForProvider.mockResolvedValue(['claude-opus-4-8']);
it('orders the assistant model provider first among configured providers', async () => {
serveConfig(
{
openrouter: { apiKey: 'sk-1' },
ollama: { baseURL: 'http://localhost:11434' },
},
{ provider: 'ollama', model: 'llama3' },
);
mocks.listModelsForProvider.mockResolvedValue(['m']);
const catalog = await getModelCatalog();
expect(catalog.providers.map((p) => p.id)).toEqual(['anthropic']);
expect(catalog.providers.map((p) => p.id)).toEqual(['ollama', 'openrouter']);
});
it('serves cloud flavors from the models.dev catalog and only lists live when it is empty', async () => {
@ -105,7 +115,7 @@ describe('getModelCatalog', () => {
});
it('reports a failed provider as status error instead of dropping it', async () => {
serveConfig({ ollama: { baseURL: 'http://localhost:11434', model: 'llama3' } });
serveConfig({ ollama: { baseURL: 'http://localhost:11434' } });
mocks.listModelsForProvider.mockRejectedValue(new Error('connection refused'));
const catalog = await getModelCatalog();
@ -113,7 +123,6 @@ describe('getModelCatalog', () => {
id: 'ollama',
status: 'error',
error: 'connection refused',
savedModel: 'llama3',
models: [],
});
});

View file

@ -43,8 +43,6 @@ export interface CatalogProviderEntry {
/** "error" = the provider is connected but its model list failed to load. */
status: "ok" | "error";
error?: string;
/** The provider's saved default model from models.json, if any. */
savedModel?: string;
models: CatalogModelEntry[];
}
@ -105,14 +103,9 @@ type ProviderConfig = z.infer<typeof LlmProvider>;
interface DiscoveredProvider {
id: string;
flavor: string;
/** Absent for rowboat/codex — their auth lives outside models.json. */
config?: ProviderConfig;
savedModel?: string;
/**
* Saved models[] from config the list of last resort for flavors the
* live fetch doesn't support (an unknown flavor in the providers map).
*/
savedModels?: string[];
}
async function readModelConfig(): Promise<z.infer<typeof LlmModelConfig> | null> {
@ -128,45 +121,36 @@ async function readModelConfig(): Promise<z.infer<typeof LlmModelConfig> | null>
/**
* Which providers are connected right now. Rowboat and ChatGPT come from
* their auth state; everything else from the models.json providers map
* (an entry counts as connected once it carries some credential). The
* default provider's entry leads, matching picker ordering.
* (entries carry credentials by construction in v2). The assistant model's
* provider leads, matching picker ordering.
*/
async function discoverProviders(): Promise<DiscoveredProvider[]> {
const discovered: DiscoveredProvider[] = [];
if (await isSignedIn().catch(() => false)) {
discovered.push({ id: "rowboat" });
discovered.push({ id: "rowboat", flavor: "rowboat" });
}
try {
const chatgpt = await getChatGPTStatus();
if (chatgpt.signedIn) discovered.push({ id: "codex" });
if (chatgpt.signedIn) discovered.push({ id: "codex", flavor: "codex" });
} catch {
// ChatGPT status failures must never break the main list.
}
const cfg = await readModelConfig();
const providersMap = cfg?.providers ?? {};
const defaultFlavor = cfg?.provider.flavor ?? "";
const flavors = Object.keys(providersMap)
.sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0));
const assistantProvider = cfg?.assistantModel?.provider ?? "";
const ids = Object.keys(providersMap)
.sort((a, b) => (a === assistantProvider ? -1 : b === assistantProvider ? 1 : 0));
for (const flavor of flavors) {
const entry = providersMap[flavor] ?? {};
const apiKey = entry.apiKey?.trim() ?? "";
const baseURL = entry.baseURL?.trim() ?? "";
if (!apiKey && !baseURL) continue; // provider not configured
const savedModel = entry.model || undefined;
const parsed = LlmProvider.safeParse({ ...entry, flavor });
if (!parsed.success) {
// Unknown flavor: not live-listable — serve the saved list.
discovered.push({ id: flavor, savedModel, savedModels: entry.models ?? [] });
continue;
}
const config = parsed.data;
for (const id of ids) {
const entry = providersMap[id];
if (!entry) continue;
const config = { ...entry };
if (config.flavor === "aigateway" && !config.baseURL) {
config.baseURL = AIGATEWAY_DEFAULT_BASE_URL;
}
discovered.push({ id: flavor, config, savedModel });
discovered.push({ id, flavor: entry.flavor, config });
}
return discovered;
@ -192,10 +176,10 @@ async function fetchProviderEntry(
} else if (provider.id === "codex") {
const result = await listCodexModels();
models = result.providers[0]?.models ?? [];
} else if (MODELS_DEV_FLAVORS.has(provider.id) && (modelsDevByFlavor.get(provider.id)?.length ?? 0) > 0) {
models = modelsDevByFlavor.get(provider.id) ?? [];
} else if (MODELS_DEV_FLAVORS.has(provider.flavor) && (modelsDevByFlavor.get(provider.flavor)?.length ?? 0) > 0) {
models = modelsDevByFlavor.get(provider.flavor) ?? [];
} else if (!provider.config) {
models = (provider.savedModels ?? []).map((id) => ({ id }));
throw new Error(`Provider '${provider.id}' has no configuration to list models with`);
} else {
// Live listing: local/custom flavors always, cloud flavors only
// when the models.dev cache is empty (offline fresh install).
@ -251,7 +235,7 @@ export async function getModelCatalog(options?: GetModelCatalogOptions): Promise
// One models.dev read serves every cloud flavor in the build (disk cache,
// no network — refreshed by its own background loop).
const modelsDevByFlavor = new Map<string, CatalogModelEntry[]>();
if (discovered.some((p) => MODELS_DEV_FLAVORS.has(p.id))) {
if (discovered.some((p) => MODELS_DEV_FLAVORS.has(p.flavor))) {
try {
const catalog = await listOnboardingModels();
for (const p of catalog.providers) {
@ -274,11 +258,9 @@ export async function getModelCatalog(options?: GetModelCatalogOptions): Promise
);
const result: CatalogProviderEntry = {
id: provider.id,
// One instance per flavor today, so the id IS the flavor key.
flavor: provider.config?.flavor ?? provider.id,
flavor: provider.flavor,
status: entry.status,
...(entry.error ? { error: entry.error } : {}),
...(provider.savedModel ? { savedModel: provider.savedModel } : {}),
models: entry.models,
};
return result;

View file

@ -0,0 +1,46 @@
import container from "../di/container.js";
import { IModelConfigRepo } from "./repo.js";
import { listCodexModels } from "./codex.js";
import { getRowboatConfig } from "../config/rowboat.js";
import { selectInitialModel } from "./initial-selection.js";
/**
* Model-selection hooks for the ChatGPT-subscription (codex) sign-in
* lifecycle. ChatGPT is a provider like any other: signing in connects it,
* so it follows the same rules
*
* - Connect with no saved assistant pick an initial model (backend
* recommendation if the subscription lists it, else the first listed
* model) and save it. A saved assistant is NEVER replaced.
* - Disconnect drop the selections that reference the provider (same
* dangling-ref cleanup as removing any provider).
*/
export async function applyCodexInitialSelection(): Promise<void> {
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
try {
const cfg = await repo.getConfig().catch(() => null);
if (cfg?.assistantModel) return; // saved choice — never replaced
const catalog = await listCodexModels();
const ids = catalog.providers[0]?.models.map((m) => m.id) ?? [];
const recommendations = (await getRowboatConfig().catch(() => null))?.modelRecommendations;
const model = selectInitialModel("codex", ids, recommendations);
if (model) {
await repo.updateConfig({ assistantModel: { provider: "codex", model } });
}
} catch (error) {
// Best-effort: a failed initial selection must never break sign-in.
console.warn("[models] Initial selection after ChatGPT sign-in failed:", error);
}
}
export async function clearCodexSelections(): Promise<void> {
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
try {
// "codex" has no providers-map entry; removeProvider still clears
// the assistantModel / task overrides that reference it.
await repo.removeProvider("codex");
} catch (error) {
console.warn("[models] Clearing codex selections after sign-out failed:", error);
}
}

View file

@ -1,23 +1,8 @@
import z from "zod";
import { LlmModelConfig, LlmProvider, ModelRef } from "@x/shared/dist/models.js";
import { LlmModelConfig, LlmProvider, ModelRef, type TaskModelKey } from "@x/shared/dist/models.js";
import { IModelConfigRepo } from "./repo.js";
import { isSignedIn } from "../account/account.js";
import container from "../di/container.js";
const SIGNED_IN_DEFAULT_MODEL = "google/gemini-3.5-flash";
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
// KG note-creation historically failed on identity (self-notes, perspective
// flips, misread outbound email) — root cause was the owner block never being
// injected, not the model tier. With identity injected + the NON-NEGOTIABLE
// RULES checklist + the end-of-message owner reminder, the lite tier is
// serviceable and 6x cheaper than full flash for this always-on service.
const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite";
const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite";
const SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL = "google/gemini-3.1-flash-lite";
// Must be on the gateway's server-side allowlist or title calls 403
// "Model not allowed" (and silently keep the placeholder title).
const SIGNED_IN_CHAT_TITLE_MODEL = "google/gemini-3.5-flash-lite";
export type ModelSelection = z.infer<typeof ModelRef>;
async function readConfig(): Promise<z.infer<typeof LlmModelConfig> | null> {
@ -25,36 +10,25 @@ async function readConfig(): Promise<z.infer<typeof LlmModelConfig> | null> {
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
return await repo.getConfig();
} catch {
// Signed-in users may have no models.json at all.
// Fresh install before ensureConfig ran, or an unreadable file.
return null;
}
}
/**
* The single source of truth for "what model+provider should we use when
* the caller didn't specify and the agent didn't declare".
*
* Resolution order (hybrid mode):
* 1. `defaultSelection` the user's explicit choice; may point at the
* gateway ("rowboat") or any BYOK provider, and is honored in both modes
* (a "rowboat" selection is skipped while signed out it needs auth).
* 2. Signed in the curated gateway default.
* 3. BYOK the legacy top-level provider/model pair.
* the caller didn't specify and the agent didn't declare": the config's
* assistantModel, period. It is written by onboarding / provider connect
* (via initial selection) and by every model pick in the UI; hidden
* fallback defaults were removed with the v2 config migration.
*/
export async function getDefaultModelAndProvider(): Promise<{ model: string; provider: string }> {
const signedIn = await isSignedIn();
const cfg = await readConfig();
const selection = cfg?.defaultSelection;
if (selection && (selection.provider !== "rowboat" || signedIn)) {
return { model: selection.model, provider: selection.provider };
const assistant = cfg?.assistantModel;
if (!assistant) {
throw new Error("No assistant model configured (connect a provider or sign in)");
}
if (signedIn) {
return { model: SIGNED_IN_DEFAULT_MODEL, provider: SIGNED_IN_DEFAULT_PROVIDER };
}
if (!cfg) {
throw new Error("No model configuration found (models.json missing and not signed in)");
}
return { model: cfg.model, provider: cfg.provider.flavor };
return { model: assistant.model, provider: assistant.provider };
}
/**
@ -68,69 +42,39 @@ export async function shouldDeferBackgroundTasks(): Promise<boolean> {
}
/**
* Resolve a provider name (as stored on a run, an agent, or returned by
* getDefaultModelAndProvider) into the full LlmProvider config that
* createProvider expects (apiKey/baseURL/headers).
* Resolve a provider instance id (as stored on a run, an agent, or returned
* by getDefaultModelAndProvider) into the LlmProvider entry that
* createProvider expects.
*
* - "rowboat" gateway provider (auth via OAuth bearer; no creds field).
* - other names look up models.json's `providers[name]` map.
* - fallback: if the name matches the active default's flavor (legacy
* single-provider configs that didn't write to the providers map yet).
* - "codex" ChatGPT subscription (auth in chatgpt-auth.json).
* - other ids the models.json providers map.
*/
export async function resolveProviderConfig(name: string): Promise<z.infer<typeof LlmProvider>> {
if (name === "rowboat") {
return { flavor: "rowboat" };
}
if (name === "codex") {
// ChatGPT subscription: auth lives in chatgpt-auth.json (core auth
// layer), never in models.json — which may not exist at all.
return { flavor: "codex" };
}
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
const cfg = await repo.getConfig();
const entry = cfg.providers?.[name];
if (entry) {
return LlmProvider.parse({
flavor: name,
apiKey: entry.apiKey,
baseURL: entry.baseURL,
headers: entry.headers,
contextLength: entry.contextLength,
reasoningEffort: entry.reasoningEffort,
});
const cfg = await readConfig();
const entry = cfg?.providers[name];
if (!entry) {
throw new Error(`Provider '${name}' is referenced but not configured`);
}
if (cfg.provider.flavor === name) {
return cfg.provider;
}
throw new Error(`Provider '${name}' is referenced but not configured`);
return entry;
}
// Per-category model resolution (hybrid mode):
// 1. An explicit override wins in BOTH modes. Provider-qualified refs are
// used as-is (a "rowboat" ref is skipped while signed out); legacy string
// overrides pair with the BYOK provider they were configured against
// (the top-level flavor), NOT the dynamic default — so a signed-in user's
// local-model overrides keep routing to their local server.
// 2. No override, signed in → the curated gateway model.
// 3. No override, BYOK → the assistant default.
async function getCategoryModel(
category: "knowledgeGraphModel" | "meetingNotesModel" | "liveNoteAgentModel" | "autoPermissionDecisionModel",
curatedModel: string,
): Promise<ModelSelection> {
const signedIn = await isSignedIn();
/**
* Per-task model resolution: the explicit taskModels override wins, else the
* assistant model. No hidden per-task defaults the v2 migration
* materialized the historical curated models as visible overrides.
*/
async function getCategoryModel(category: TaskModelKey): Promise<ModelSelection> {
const cfg = await readConfig();
const override = cfg?.[category];
const override = cfg?.taskModels?.[category];
if (override) {
if (typeof override === "string") {
if (cfg) {
return { model: override, provider: cfg.provider.flavor };
}
} else if (override.provider !== "rowboat" || signedIn) {
return { model: override.model, provider: override.provider };
}
}
if (signedIn) {
return { model: curatedModel, provider: SIGNED_IN_DEFAULT_PROVIDER };
return { model: override.model, provider: override.provider };
}
return getDefaultModelAndProvider();
}
@ -140,55 +84,27 @@ async function getCategoryModel(
* etc.) when they're the top-level of a run.
*/
export async function getKgModel(): Promise<ModelSelection> {
return getCategoryModel("knowledgeGraphModel", SIGNED_IN_KG_MODEL);
return getCategoryModel("knowledgeGraph");
}
/** Model used by the live-note agent + routing classifier. */
export async function getLiveNoteAgentModel(): Promise<ModelSelection> {
return getCategoryModel("liveNoteAgentModel", SIGNED_IN_LIVE_NOTE_AGENT_MODEL);
return getCategoryModel("liveNoteAgent");
}
/** Model used by the auto-permission classifier. */
export async function getAutoPermissionDecisionModel(): Promise<ModelSelection> {
return getCategoryModel("autoPermissionDecisionModel", SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL);
return getCategoryModel("autoPermissionDecision");
}
/**
* Model used by the meeting-notes summarizer. No special signed-in curated
* model historically meetings used the assistant model.
*/
/** Model used by the meeting-notes summarizer. */
export async function getMeetingNotesModel(): Promise<ModelSelection> {
return getCategoryModel("meetingNotesModel", SIGNED_IN_DEFAULT_MODEL);
return getCategoryModel("meetingNotes");
}
/**
* Model used to auto-name chat sessions from the first user message.
*
* Deliberately NOT getCategoryModel: that helper routes every signed-in user
* to the gateway, even one whose assistant default is a BYOK provider (the
* common "gateway limits exhausted, switched to own key" case) and a title
* call against an exhausted gateway fails silently forever. Instead the
* curated model applies only when the assistant default itself routes
* through the gateway; otherwise titles follow the assistant provider.
*/
/** Model used to auto-name chat sessions from the first user message. */
export async function getChatTitleModel(): Promise<ModelSelection> {
const signedIn = await isSignedIn();
const cfg = await readConfig();
const override = cfg?.chatTitleModel;
if (override) {
if (typeof override === "string") {
if (cfg) {
return { model: override, provider: cfg.provider.flavor };
}
} else if (override.provider !== "rowboat" || signedIn) {
return { model: override.model, provider: override.provider };
}
}
const dflt = await getDefaultModelAndProvider();
if (dflt.provider === SIGNED_IN_DEFAULT_PROVIDER) {
return { model: SIGNED_IN_CHAT_TITLE_MODEL, provider: SIGNED_IN_DEFAULT_PROVIDER };
}
return dflt;
return getCategoryModel("chatTitle");
}
/**

View file

@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest';
import { migrateModelsConfig } from './migrate.js';
/**
* The migration contract: evaluate the v1 resolution rules (including the
* curated signed-in defaults that lived only in code) one last time and
* write their answers explicitly, so v2's simple "override else assistant"
* rules produce identical effective models. Overrides are written ONLY
* where the old effective model differs from inherit-from-assistant.
*/
describe('migrateModelsConfig', () => {
it('returns null for a config that is already v2', () => {
expect(migrateModelsConfig({ version: 2, providers: {} }, false)).toBeNull();
});
it('signed-out BYOK: adopts the top-level pair as assistant, writes no task overrides', () => {
const v1 = {
provider: { flavor: 'openai', apiKey: 'sk-a' },
model: 'gpt-5.4',
providers: { openai: { apiKey: 'sk-a', model: 'gpt-5.4', models: ['gpt-5.4'] } },
};
expect(migrateModelsConfig(v1, false)).toEqual({
version: 2,
providers: { openai: { flavor: 'openai', apiKey: 'sk-a' } },
assistantModel: { provider: 'openai', model: 'gpt-5.4' },
});
});
it('signed-in with untouched bootstrap config: materializes the curated defaults', () => {
// The classic signed-in models.json — the bootstrap file that nothing
// ever wrote to; every effective model lived in code branches.
const v1 = { provider: { flavor: 'openai' }, model: 'gpt-5.4' };
expect(migrateModelsConfig(v1, true)).toEqual({
version: 2,
providers: {}, // bootstrap top-level pair had no credentials
assistantModel: { provider: 'rowboat', model: 'google/gemini-3.5-flash' },
taskModels: {
knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
liveNoteAgent: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
autoPermissionDecision: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
// chat titles used flash-lite because the assistant routes
// through the gateway; meeting notes used the curated default
// which EQUALS the assistant → no override written for it.
chatTitle: { provider: 'rowboat', model: 'google/gemini-3.5-flash-lite' },
},
});
});
it('signed-in user with a BYOK defaultSelection: keeps it, materializes the differing task models', () => {
const v1 = {
provider: { flavor: 'ollama', baseURL: 'http://localhost:11434' },
model: 'llama3',
providers: { ollama: { baseURL: 'http://localhost:11434', model: 'llama3' } },
defaultSelection: { provider: 'ollama', model: 'llama3' },
};
const v2 = migrateModelsConfig(v1, true);
expect(v2?.assistantModel).toEqual({ provider: 'ollama', model: 'llama3' });
expect(v2?.taskModels).toEqual({
knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
liveNoteAgent: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
autoPermissionDecision: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
// Meeting notes used the curated gateway default, which now
// differs from the (BYOK) assistant — preserved explicitly.
meetingNotes: { provider: 'rowboat', model: 'google/gemini-3.5-flash' },
// Chat titles followed the assistant whenever it was NOT the
// gateway — inherit reproduces that, so no override.
});
});
it('explicit v1 overrides survive: legacy strings pair with the top-level flavor, refs pass through', () => {
const v1 = {
provider: { flavor: 'openai', apiKey: 'sk-a' },
model: 'gpt-5.4',
providers: { openai: { apiKey: 'sk-a' } },
knowledgeGraphModel: 'gpt-5.4-mini', // legacy string form
meetingNotesModel: { provider: 'ollama', model: 'qwen3' }, // ref form
};
const v2 = migrateModelsConfig(v1, false);
expect(v2?.taskModels).toEqual({
knowledgeGraph: { provider: 'openai', model: 'gpt-5.4-mini' },
meetingNotes: { provider: 'ollama', model: 'qwen3' },
});
});
it('an override equal to the assistant is dropped (inherit produces the same model)', () => {
const v1 = {
provider: { flavor: 'openai', apiKey: 'sk-a' },
model: 'gpt-5.4',
knowledgeGraphModel: 'gpt-5.4',
};
expect(migrateModelsConfig(v1, false)?.taskModels).toBeUndefined();
});
it('a rowboat defaultSelection is skipped while signed out (needs auth), like v1 resolution did', () => {
const v1 = {
provider: { flavor: 'openai', apiKey: 'sk-a' },
model: 'gpt-5.4',
defaultSelection: { provider: 'rowboat', model: 'google/gemini-3.5-flash' },
};
expect(migrateModelsConfig(v1, false)?.assistantModel)
.toEqual({ provider: 'openai', model: 'gpt-5.4' });
});
it('providers without credentials are dropped; connection prefs survive', () => {
const v1 = {
provider: { flavor: 'openai', apiKey: 'sk-a' },
model: 'gpt-5.4',
providers: {
openai: { apiKey: 'sk-a', models: ['gpt-5.4'] },
anthropic: { model: 'claude-opus-4-8' }, // no key: never connected
ollama: { baseURL: 'http://localhost:11434', contextLength: 32768, reasoningEffort: 'low' },
},
};
expect(migrateModelsConfig(v1, false)?.providers).toEqual({
openai: { flavor: 'openai', apiKey: 'sk-a' },
ollama: { flavor: 'ollama', baseURL: 'http://localhost:11434', contextLength: 32768, reasoningEffort: 'low' },
});
});
it('degrades gracefully on garbage input: empty v2 config', () => {
expect(migrateModelsConfig('not an object', false)).toEqual({ version: 2, providers: {} });
expect(migrateModelsConfig({}, false)).toEqual({ version: 2, providers: {} });
});
it('deferBackgroundTasks is carried over', () => {
const v1 = { provider: { flavor: 'openai', apiKey: 'sk-a' }, model: 'gpt-5.4', deferBackgroundTasks: true };
expect(migrateModelsConfig(v1, false)?.deferBackgroundTasks).toBe(true);
});
});

View file

@ -0,0 +1,167 @@
import { z } from "zod";
import { LlmModelConfig, LlmProvider, ModelRef } from "@x/shared/dist/models.js";
/**
* One-time migration of models.json from version 1 to version 2.
*
* v1 accreted three generations of schema: a top-level provider/model pair
* (the original single-provider config), a providers map whose entries
* duplicated credentials AND carried saved model lists (pre-dynamic-listing
* picker caches), plus `defaultSelection` and flat category overrides bolted
* on for hybrid mode. On top of that, several effective models existed only
* as hidden branches in code (the signed-in curated defaults).
*
* v2 stores providers as credentials-only entries and model choices in
* exactly two places: `assistantModel` and `taskModels`. This migration
* evaluates the OLD resolution rules one last time and writes their answers
* down explicitly, so the simplified v2 resolvers produce identical
* effective models for every existing user. Task overrides are written ONLY
* where the old effective model differs from plain inherit-from-assistant.
*
* The curated model ids below are FROZEN COPIES of the v1 constants that
* lived in defaults.ts (deleted with this migration). They are historical
* data, not live configuration do not update them when recommendations
* change.
*/
const V1_SIGNED_IN_ASSISTANT: z.infer<typeof ModelRef> = { provider: "rowboat", model: "google/gemini-3.5-flash" };
const V1_CURATED_LITE = "google/gemini-3.1-flash-lite";
const V1_CURATED_CHAT_TITLE = "google/gemini-3.5-flash-lite";
// v1 schema — kept here, and only here, for the migration reader.
const ModelOverrideV1 = z.union([z.string(), ModelRef]);
export const LlmModelConfigV1 = z.object({
provider: LlmProvider,
model: z.string(),
models: z.array(z.string()).optional(),
defaultSelection: ModelRef.optional(),
deferBackgroundTasks: z.boolean().optional(),
providers: z.record(z.string(), z.object({
apiKey: z.string().optional(),
baseURL: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
contextLength: z.number().int().positive().optional(),
reasoningEffort: z.enum(["low", "medium", "high"]).optional(),
model: z.string().optional(),
models: z.array(z.string()).optional(),
knowledgeGraphModel: z.string().optional(),
meetingNotesModel: z.string().optional(),
liveNoteAgentModel: z.string().optional(),
autoPermissionDecisionModel: z.string().optional(),
})).optional(),
knowledgeGraphModel: ModelOverrideV1.optional(),
meetingNotesModel: ModelOverrideV1.optional(),
liveNoteAgentModel: ModelOverrideV1.optional(),
autoPermissionDecisionModel: ModelOverrideV1.optional(),
chatTitleModel: ModelOverrideV1.optional(),
});
type V2 = z.infer<typeof LlmModelConfig>;
type Ref = z.infer<typeof ModelRef>;
function sameRef(a: Ref | undefined, b: Ref | undefined): boolean {
return !!a && !!b && a.provider === b.provider && a.model === b.model;
}
function asRef(value: unknown): Ref | undefined {
const parsed = ModelRef.safeParse(value);
return parsed.success && parsed.data.model ? parsed.data : undefined;
}
/**
* Resolve a v1 category override the way defaults.ts used to: a bare string
* pairs with the top-level provider flavor; a ref is used as-is except a
* "rowboat" ref while signed out (needs auth was skipped).
*/
function v1Override(raw: Record<string, unknown>, key: string, signedIn: boolean): Ref | undefined {
const value = raw[key];
if (typeof value === "string" && value) {
const flavor = (raw.provider as Record<string, unknown> | undefined)?.flavor;
return typeof flavor === "string" && flavor ? { provider: flavor, model: value } : undefined;
}
const ref = asRef(value);
if (ref && (ref.provider !== "rowboat" || signedIn)) return ref;
return undefined;
}
/** The old effective assistant model (defaults.ts resolution order). */
function v1EffectiveAssistant(raw: Record<string, unknown>, signedIn: boolean): Ref | undefined {
const selection = asRef(raw.defaultSelection);
if (selection && (selection.provider !== "rowboat" || signedIn)) return selection;
if (signedIn) return V1_SIGNED_IN_ASSISTANT;
const flavor = (raw.provider as Record<string, unknown> | undefined)?.flavor;
const model = raw.model;
if (typeof flavor === "string" && flavor && typeof model === "string" && model) {
return { provider: flavor, model };
}
return undefined;
}
/**
* Migrate a raw parsed models.json (any shape) to v2. Returns null when the
* input is already v2 nothing to do.
*
* Pure: sign-in state is an input; no I/O.
*/
export function migrateModelsConfig(rawInput: unknown, signedIn: boolean): V2 | null {
const raw = (rawInput && typeof rawInput === "object" ? rawInput : {}) as Record<string, unknown>;
if (raw.version === 2) return null;
// Providers: map entries with some credential survive, stripped to
// credentials + connection prefs. The top-level pair is merged in for
// very old configs that predate the providers map.
const providers: V2["providers"] = {};
const candidateEntries: Array<[string, unknown]> = Object.entries(
(raw.providers && typeof raw.providers === "object" ? raw.providers : {}) as Record<string, unknown>,
);
const topLevel = raw.provider as Record<string, unknown> | undefined;
if (topLevel && typeof topLevel.flavor === "string" && !candidateEntries.some(([k]) => k === topLevel.flavor)) {
candidateEntries.push([topLevel.flavor, topLevel]);
}
for (const [id, value] of candidateEntries) {
if (!value || typeof value !== "object") continue;
const entry = value as Record<string, unknown>;
const apiKey = typeof entry.apiKey === "string" ? entry.apiKey.trim() : "";
const baseURL = typeof entry.baseURL === "string" ? entry.baseURL.trim() : "";
if (!apiKey && !baseURL) continue; // never connected
const parsed = LlmProvider.safeParse({ ...entry, flavor: id });
if (parsed.success) providers[id] = parsed.data;
}
const assistantModel = v1EffectiveAssistant(raw, signedIn);
// Old effective model per task, via the deleted v1 rules.
const oldTaskModels: Record<string, Ref | undefined> = {
knowledgeGraph: v1Override(raw, "knowledgeGraphModel", signedIn)
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel),
liveNoteAgent: v1Override(raw, "liveNoteAgentModel", signedIn)
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel),
autoPermissionDecision: v1Override(raw, "autoPermissionDecisionModel", signedIn)
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel),
meetingNotes: v1Override(raw, "meetingNotesModel", signedIn)
?? (signedIn ? V1_SIGNED_IN_ASSISTANT : assistantModel),
// Chat titles used the curated lite model ONLY when the assistant itself
// routed through the gateway (the exhausted-gateway safeguard).
chatTitle: v1Override(raw, "chatTitleModel", signedIn)
?? (assistantModel?.provider === "rowboat"
? { provider: "rowboat", model: V1_CURATED_CHAT_TITLE }
: assistantModel),
};
// Write an override only where the old effective model differs from what
// v2 inheritance (assistant) would produce.
const taskModels: NonNullable<V2["taskModels"]> = {};
for (const [key, ref] of Object.entries(oldTaskModels)) {
if (ref && !sameRef(ref, assistantModel)) {
taskModels[key as keyof typeof taskModels] = ref;
}
}
return {
version: 2,
providers,
...(assistantModel ? { assistantModel } : {}),
...(Object.keys(taskModels).length > 0 ? { taskModels } : {}),
...(raw.deferBackgroundTasks === true ? { deferBackgroundTasks: true } : {}),
};
}

View file

@ -0,0 +1,92 @@
import { rmSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The repo guards the only copy of the user's API keys. These tests pin the
// data-loss behaviors: corrupt or invalid files must never be silently
// overwritten by boot or by an unrelated write.
// vi.mock factories are hoisted above module code — the temp path must be
// computable inside vi.hoisted without imports (created in beforeEach).
const workDir = vi.hoisted(() =>
`${process.env.TMPDIR?.replace(/\/$/, '') ?? '/tmp'}/models-repo-test-${process.pid}-${Math.random().toString(36).slice(2)}`,
);
vi.mock('../config/config.js', () => ({ WorkDir: workDir }));
vi.mock('../account/account.js', () => ({ isSignedIn: async () => false }));
vi.mock('../analytics/posthog.js', () => ({ capture: () => {} }));
vi.mock('../analytics/model-providers.js', () => ({
captureProviderConnected: () => {},
captureProviderDisconnected: () => {},
syncModelProviderPersonProperties: async () => {},
}));
import { FSModelConfigRepo } from './repo.js';
const configDir = path.join(workDir, 'config');
const configPath = path.join(configDir, 'models.json');
beforeEach(async () => {
await fs.mkdir(configDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(configDir, { recursive: true, force: true });
});
process.on('exit', () => {
try { rmSync(workDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
});
describe('FSModelConfigRepo data safety', () => {
it('creates an empty v2 config when the file is missing', async () => {
await new FSModelConfigRepo().ensureConfig();
expect(JSON.parse(await fs.readFile(configPath, 'utf8'))).toEqual({ version: 2, providers: {} });
});
it('quarantines corrupt JSON instead of overwriting the only copy of the keys', async () => {
await fs.writeFile(configPath, '{"version":2,"providers":{"openai":{"flavor":"op'); // truncated
await new FSModelConfigRepo().ensureConfig();
const entries = await fs.readdir(configDir);
const quarantined = entries.find((f) => f.startsWith('models.json.corrupt-'));
expect(quarantined).toBeDefined();
expect(await fs.readFile(path.join(configDir, quarantined as string), 'utf8')).toContain('"op');
expect(JSON.parse(await fs.readFile(configPath, 'utf8'))).toEqual({ version: 2, providers: {} });
});
it('migration keeps the v1 original as models.json.v1.bak', async () => {
const v1 = { provider: { flavor: 'openai', apiKey: 'sk-a' }, model: 'gpt-5.4' };
await fs.writeFile(configPath, JSON.stringify(v1));
await new FSModelConfigRepo().ensureConfig();
expect(JSON.parse(await fs.readFile(`${configPath}.v1.bak`, 'utf8'))).toEqual(v1);
const migrated = JSON.parse(await fs.readFile(configPath, 'utf8'));
expect(migrated.version).toBe(2);
expect(migrated.providers.openai.apiKey).toBe('sk-a');
});
it('a schema-invalid file makes writes FAIL instead of clobbering stored credentials', async () => {
// Parses as JSON, fails zod (bad flavor) — the pre-fix behavior was
// to fall back to an empty config and overwrite everything on the
// next unrelated write.
await fs.writeFile(configPath, JSON.stringify({
version: 2,
providers: { weird: { flavor: 'not-a-flavor', apiKey: 'sk-precious' } },
}));
const repo = new FSModelConfigRepo();
await expect(repo.updateConfig({ deferBackgroundTasks: true })).rejects.toThrow();
// The file is untouched — the key survives.
expect((await fs.readFile(configPath, 'utf8'))).toContain('sk-precious');
});
it('writes land atomically via temp + rename (no lingering temp file)', async () => {
const repo = new FSModelConfigRepo();
await repo.ensureConfig();
await repo.setProvider('openai', { flavor: 'openai', apiKey: 'sk-a' });
const entries = await fs.readdir(configDir);
expect(entries).not.toContain('models.json.tmp');
expect(JSON.parse(await fs.readFile(configPath, 'utf8')).providers.openai.apiKey).toBe('sk-a');
});
});

View file

@ -1,114 +1,162 @@
import { ModelConfig } from "./models.js";
import { LlmModelConfig, LlmProvider, ModelRef, TaskModels } from "@x/shared/dist/models.js";
import { WorkDir } from "../config/config.js";
import { isSignedIn } from "../account/account.js";
import { migrateModelsConfig } from "./migrate.js";
import fs from "fs/promises";
import path from "path";
import z from "zod";
type Config = z.infer<typeof LlmModelConfig>;
type Ref = z.infer<typeof ModelRef>;
type TaskModelPatch = { [K in keyof z.infer<typeof TaskModels>]?: Ref | null };
// Top-level merge patch: omitted keys are untouched; an explicit null clears
// a key. taskModels merges per-key (null clears that task's override).
export type ModelConfigPatch = {
[K in
| "defaultSelection"
| "knowledgeGraphModel"
| "meetingNotesModel"
| "liveNoteAgentModel"
| "autoPermissionDecisionModel"
| "deferBackgroundTasks"]?: z.infer<typeof ModelConfig>[K] | null;
assistantModel?: Ref | null;
taskModels?: TaskModelPatch;
deferBackgroundTasks?: boolean | null;
};
export interface IModelConfigRepo {
/** Create the file if missing; migrate v1 → v2 in place if needed. */
ensureConfig(): Promise<void>;
getConfig(): Promise<z.infer<typeof ModelConfig>>;
setConfig(config: z.infer<typeof ModelConfig>): Promise<void>;
// Merge the given top-level keys into the existing file without touching
// provider credentials — hybrid settings (default selection, category
// overrides) save through this. Omitted keys are untouched; an explicit
// null clears the key back to its default.
getConfig(): Promise<Config>;
/** Upsert one provider entry (credentials + connection prefs). */
setProvider(id: string, provider: z.infer<typeof LlmProvider>): Promise<void>;
/**
* Remove a provider entry and every model selection that references it
* (a dangling assistantModel / task override would just error at run
* time dropping them lets resolution fall back cleanly).
*/
removeProvider(id: string): Promise<void>;
updateConfig(patch: ModelConfigPatch): Promise<void>;
}
const defaultConfig: z.infer<typeof ModelConfig> = {
provider: {
flavor: "openai",
},
model: "gpt-5.4",
const emptyConfig: Config = {
version: 2,
providers: {},
};
function isEnoent(err: unknown): boolean {
return (err as NodeJS.ErrnoException | null)?.code === "ENOENT";
}
export class FSModelConfigRepo implements IModelConfigRepo {
private readonly configPath = path.join(WorkDir, "config", "models.json");
async ensureConfig(): Promise<void> {
let rawText: string;
try {
await fs.access(this.configPath);
} catch {
await fs.writeFile(this.configPath, JSON.stringify(defaultConfig, null, 2));
rawText = await fs.readFile(this.configPath, "utf8");
} catch (err) {
if (isEnoent(err)) {
await this.write(emptyConfig);
} else {
// Transient read failure (permissions, I/O): NEVER overwrite
// the existing file — it holds the user's API keys. Leave it
// for the next boot; per-call getConfig errors degrade
// features without destroying data.
console.error("[models] Could not read models.json; leaving it untouched:", err);
}
return;
}
let raw: unknown;
try {
raw = JSON.parse(rawText);
} catch (err) {
// Corrupt JSON (e.g. a crash mid-write): quarantine the file for
// manual recovery instead of overwriting the only copy of the
// user's credentials.
const quarantinePath = `${this.configPath}.corrupt-${Date.now()}`;
await fs.rename(this.configPath, quarantinePath).catch(() => {});
console.error(`[models] models.json is corrupt; preserved at ${quarantinePath}:`, err);
await this.write(emptyConfig);
return;
}
const signedIn = await isSignedIn().catch(() => false);
const migrated = migrateModelsConfig(raw, signedIn);
if (migrated) {
// Keep the v1 original recoverable — the migration is the only
// record of the old selections once it runs.
await fs.writeFile(`${this.configPath}.v1.bak`, rawText).catch(() => {});
await this.write(migrated);
}
}
async getConfig(): Promise<z.infer<typeof ModelConfig>> {
async getConfig(): Promise<Config> {
const config = await fs.readFile(this.configPath, "utf8");
return ModelConfig.parse(JSON.parse(config));
return LlmModelConfig.parse(JSON.parse(config));
}
async setConfig(config: z.infer<typeof ModelConfig>): Promise<void> {
let existingProviders: Record<string, Record<string, unknown>> = {};
try {
const raw = await fs.readFile(this.configPath, "utf8");
const existing = JSON.parse(raw);
existingProviders = existing.providers || {};
} catch {
// No existing config
async setProvider(id: string, provider: z.infer<typeof LlmProvider>): Promise<void> {
const config = await this.read();
config.providers[id] = LlmProvider.parse(provider);
await this.write(config);
}
async removeProvider(id: string): Promise<void> {
const config = await this.read();
delete config.providers[id];
if (config.assistantModel?.provider === id) {
delete config.assistantModel;
}
existingProviders[config.provider.flavor] = {
...existingProviders[config.provider.flavor],
apiKey: config.provider.apiKey,
baseURL: config.provider.baseURL,
headers: config.provider.headers,
// Preserve hand-edited local-model tuning unless the caller sets it.
...(config.provider.contextLength !== undefined
? { contextLength: config.provider.contextLength }
: {}),
...(config.provider.reasoningEffort !== undefined
? { reasoningEffort: config.provider.reasoningEffort }
: {}),
model: config.model,
models: config.models,
knowledgeGraphModel: config.knowledgeGraphModel,
meetingNotesModel: config.meetingNotesModel,
liveNoteAgentModel: config.liveNoteAgentModel,
autoPermissionDecisionModel: config.autoPermissionDecisionModel,
};
// saveConfig owns provider credentials/model lists; the hybrid-mode
// selections are owned by updateConfig — carry them over when the
// incoming config doesn't set them.
let existingSelections: Record<string, unknown> = {};
try {
const raw = await fs.readFile(this.configPath, "utf8");
const existing = JSON.parse(raw);
existingSelections = Object.fromEntries(
["defaultSelection", "knowledgeGraphModel", "meetingNotesModel", "liveNoteAgentModel", "autoPermissionDecisionModel", "deferBackgroundTasks"]
.filter((key) => existing[key] !== undefined && (config as Record<string, unknown>)[key] === undefined)
.map((key) => [key, existing[key]]),
);
} catch {
// No existing config
if (config.taskModels) {
for (const key of Object.keys(config.taskModels) as Array<keyof NonNullable<Config["taskModels"]>>) {
if (config.taskModels[key]?.provider === id) {
delete config.taskModels[key];
}
}
if (Object.keys(config.taskModels).length === 0) delete config.taskModels;
}
const toWrite = { ...existingSelections, ...config, providers: existingProviders };
await fs.writeFile(this.configPath, JSON.stringify(toWrite, null, 2));
await this.write(config);
}
async updateConfig(patch: ModelConfigPatch): Promise<void> {
const raw = await fs.readFile(this.configPath, "utf8");
const existing = JSON.parse(raw) as Record<string, unknown>;
for (const [key, value] of Object.entries(patch)) {
if (value === undefined || value === null) {
delete existing[key];
} else {
existing[key] = value;
}
const config = await this.read();
if (patch.assistantModel !== undefined) {
if (patch.assistantModel === null) delete config.assistantModel;
else config.assistantModel = patch.assistantModel;
}
ModelConfig.parse(existing);
await fs.writeFile(this.configPath, JSON.stringify(existing, null, 2));
if (patch.taskModels !== undefined) {
const merged = { ...(config.taskModels ?? {}) };
for (const [key, value] of Object.entries(patch.taskModels)) {
if (value === undefined) continue;
if (value === null) delete merged[key as keyof typeof merged];
else merged[key as keyof typeof merged] = value;
}
if (Object.keys(merged).length > 0) config.taskModels = merged;
else delete config.taskModels;
}
if (patch.deferBackgroundTasks !== undefined) {
if (patch.deferBackgroundTasks === null) delete config.deferBackgroundTasks;
else config.deferBackgroundTasks = patch.deferBackgroundTasks;
}
await this.write(config);
}
private async read(): Promise<Config> {
try {
return await this.getConfig();
} catch (err) {
// ONLY a missing file falls back to empty (writes can arrive
// before ensureConfig on a fresh install). Any other failure —
// unreadable file, schema-invalid content — must propagate:
// read-modify-write on an empty fallback would clobber the
// user's stored credentials.
if (isEnoent(err)) {
return structuredClone(emptyConfig);
}
throw err;
}
}
// Atomic write (temp + rename): a crash mid-write must never leave a
// truncated models.json — that file is the only copy of the user's keys.
private async write(config: Config): Promise<void> {
const data = JSON.stringify(LlmModelConfig.parse(config), null, 2);
const tmpPath = `${this.configPath}.tmp`;
await fs.writeFile(tmpPath, data);
await fs.rename(tmpPath, this.configPath);
}
}

View file

@ -0,0 +1,48 @@
import container from "../di/container.js";
import { IModelConfigRepo } from "./repo.js";
import { listGatewayModels } from "./gateway.js";
import { getRowboatConfig } from "../config/rowboat.js";
import { selectInitialModel } from "./initial-selection.js";
/**
* Model-selection hooks for the Rowboat sign-in lifecycle. Signing in is
* "connecting the rowboat provider", so it follows the same rules as any
* provider connect:
*
* - Connect with no saved assistant pick an initial model (backend
* recommendation if the gateway lists it, else the first listed model)
* and save it. A saved assistant is NEVER replaced recommendations only
* ever choose the initial model.
* - Disconnect drop the selections that reference the provider (same
* dangling-reference cleanup as removing any provider).
*/
export async function applyRowboatInitialSelection(): Promise<void> {
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
try {
const cfg = await repo.getConfig().catch(() => null);
if (cfg?.assistantModel) return; // saved choice — never replaced
const catalog = await listGatewayModels();
const ids = catalog.providers[0]?.models.map((m) => m.id) ?? [];
const recommendations = (await getRowboatConfig().catch(() => null))?.modelRecommendations;
const model = selectInitialModel("rowboat", ids, recommendations);
if (model) {
await repo.updateConfig({ assistantModel: { provider: "rowboat", model } });
}
} catch (error) {
// Best-effort: a failed initial selection must never break sign-in.
// The picker copes with an unset assistant (shows the connect hint).
console.warn("[models] Initial selection after Rowboat sign-in failed:", error);
}
}
export async function clearRowboatSelections(): Promise<void> {
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
try {
// "rowboat" has no providers-map entry; removeProvider still clears
// the assistantModel / task overrides that reference it.
await repo.removeProvider("rowboat");
} catch (error) {
console.warn("[models] Clearing Rowboat selections after sign-out failed:", error);
}
}