Merge pull request #791 from rowboatlabs/model-selection
Some checks failed
rowboat / apps/x Vitest suites (push) Has been cancelled
rowboat / apps/x Electron package smoke test (push) Has been cancelled

One model-selection experience: providers own catalogs, one Assistant model, split-view picker
This commit is contained in:
Ramnique Singh 2026-07-24 17:45:18 +05:30 committed by GitHub
commit 5c330de3fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 3624 additions and 2511 deletions

View file

@ -0,0 +1,105 @@
import { capture, setPersonProperties } from "./posthog.js";
import type { IModelConfigRepo } from "../models/repo.js";
/**
* Provider-level analytics for model selection.
*
* Privacy rules, encoded here so call sites can't get them wrong:
* - Only provider FLAVORS ever leave the app. Instance ids equal flavor keys
* today, but a future multi-key setup makes ids user-named so every
* surface maps id flavor before capturing.
* - Never credentials: no apiKey, no headers, and no baseURL (local
* endpoints can carry internal hostnames).
* - Model ids are fine (they already ride on llm_usage).
*
* All I/O is lazy (dynamic imports, container resolution at call time) so
* this module stays import-cycle-free models/repo.ts imports it.
*/
const FLAVOR_CACHE_TTL_MS = 10_000;
let flavorCache: { at: number; byId: Map<string, string> } | null = null;
async function resolveRepo(): Promise<IModelConfigRepo> {
const { default: container } = await import("../di/container.js");
return container.resolve<IModelConfigRepo>("modelConfigRepo");
}
async function providerFlavorsById(): Promise<Map<string, string>> {
if (flavorCache && Date.now() - flavorCache.at < FLAVOR_CACHE_TTL_MS) {
return flavorCache.byId;
}
const byId = new Map<string, string>();
try {
const cfg = await (await resolveRepo()).getConfig();
for (const [id, entry] of Object.entries(cfg.providers)) {
byId.set(id, entry.flavor);
}
} catch {
// No config yet — empty map; ids fall through unchanged.
}
flavorCache = { at: Date.now(), byId };
return byId;
}
/**
* Map a provider instance id to its flavor for analytics. Unknown ids fall
* back to the raw value which today always equals the flavor key.
*/
export async function flavorForProviderId(id: string): Promise<string> {
if (id === "rowboat" || id === "codex") return id;
return (await providerFlavorsById()).get(id) ?? id;
}
export function invalidateFlavorCache(): void {
flavorCache = null;
}
/**
* Refresh the person properties describing the user's provider setup:
* `llm_provider_flavors` (sorted, includes rowboat/codex from auth state),
* `llm_provider_count`, and the configured assistant model. Call after any
* provider or assistant change; also called on every app launch so existing
* installs get baselined without waiting for an action.
*/
export async function syncModelProviderPersonProperties(): Promise<void> {
try {
const cfg = await (await resolveRepo()).getConfig().catch(() => null);
const { isSignedIn } = await import("../account/account.js");
const { getChatGPTStatus } = await import("../auth/chatgpt-auth.js");
const flavors = new Set<string>();
for (const entry of Object.values(cfg?.providers ?? {})) {
flavors.add(entry.flavor);
}
if (await isSignedIn().catch(() => false)) flavors.add("rowboat");
const chatgpt = await getChatGPTStatus().catch(() => ({ signedIn: false }));
if (chatgpt.signedIn) flavors.add("codex");
const assistant = cfg?.assistantModel ?? null;
setPersonProperties({
llm_provider_flavors: [...flavors].sort(),
llm_provider_count: flavors.size,
...(assistant
? {
assistant_model: assistant.model,
assistant_model_flavor: await flavorForProviderId(assistant.provider),
}
: {}),
});
} catch (err) {
console.error("[Analytics] provider person-props sync failed:", err);
}
}
/** One provider became connected (any surface: settings, onboarding, sign-in). */
export function captureProviderConnected(flavor: string): void {
capture("llm_provider_connected", { flavor });
invalidateFlavorCache();
void syncModelProviderPersonProperties();
}
/** One provider was disconnected / signed out. */
export function captureProviderDisconnected(flavor: string): void {
capture("llm_provider_disconnected", { flavor });
invalidateFlavorCache();
void syncModelProviderPersonProperties();
}

View file

@ -91,6 +91,21 @@ export function reset(): void {
identifiedUserId = null;
}
/**
* Merge person properties onto the CURRENT identity the rowboat user once
* identified, else the anonymous installation id (identify on the same
* distinctId merges properties without changing identity).
*/
export function setPersonProperties(properties: Record<string, unknown>): void {
const ph = getClient();
if (!ph) return;
try {
ph.identify({ distinctId: activeDistinctId(), properties });
} catch (err) {
console.error('[Analytics] setPersonProperties failed:', err);
}
}
/**
* Evaluate a PostHog feature flag for the current identity (rowboat user id
* once identified, installation id before that). `defaultValue` is returned

View file

@ -21,18 +21,29 @@ export interface CaptureLlmUsageArgs {
}
export function captureLlmUsage(args: CaptureLlmUsageArgs): void {
const usage = args.usage ?? {};
const properties: Record<string, unknown> = {
use_case: args.useCase,
model: args.model,
provider: args.provider,
input_tokens: usage.inputTokens ?? 0,
output_tokens: usage.outputTokens ?? 0,
total_tokens: usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
};
if (args.subUseCase) properties.sub_use_case = args.subUseCase;
if (args.agentName) properties.agent_name = args.agentName;
if (usage.cachedInputTokens != null) properties.cached_input_tokens = usage.cachedInputTokens;
if (usage.reasoningTokens != null) properties.reasoning_tokens = usage.reasoningTokens;
capture('llm_usage', properties);
// Fire-and-forget: callers pass the provider INSTANCE id (ModelRef
// .provider); analytics reports the FLAVOR — ids may one day be
// user-named, and charts must not fracture when "openai-work" appears.
// Today ids equal flavor keys, so the fallback is lossless.
void (async () => {
let provider = args.provider;
try {
const { flavorForProviderId } = await import('./model-providers.js');
provider = await flavorForProviderId(args.provider);
} catch { /* keep the raw value */ }
const usage = args.usage ?? {};
const properties: Record<string, unknown> = {
use_case: args.useCase,
model: args.model,
provider,
input_tokens: usage.inputTokens ?? 0,
output_tokens: usage.outputTokens ?? 0,
total_tokens: usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
};
if (args.subUseCase) properties.sub_use_case = args.subUseCase;
if (args.agentName) properties.agent_name = args.agentName;
if (usage.cachedInputTokens != null) properties.cached_input_tokens = usage.cachedInputTokens;
if (usage.reasoningTokens != null) properties.reasoning_tokens = usage.reasoningTokens;
capture('llm_usage', properties);
})();
}

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

@ -6,9 +6,7 @@ import container from "../di/container.js";
import { WorkDir } from "../config/config.js";
import type { ISessions } from "../runtime/sessions/api.js";
import type { ITurnEventBus } from "../runtime/turns/event-hub.js";
import { isSignedIn } from "../account/account.js";
import { listGatewayModels } from "../models/gateway.js";
import { listOnboardingModels } from "../models/models-dev.js";
import { getModelCatalog, providerDisplayName } from "../models/catalog.js";
import { ChannelBridge, type ModelChoice } from "./bridge.js";
import type { IChannelsConfigRepo } from "./repo.js";
import { TelegramTransport } from "./transports/telegram.js";
@ -82,16 +80,16 @@ export function subscribeChannelsStatus(listener: (status: Status) => void): ()
// Same catalog the desktop model picker uses (models:list IPC).
async function listBridgeModels(): Promise<ModelChoice[]> {
const catalog = (await isSignedIn())
? await listGatewayModels()
: await listOnboardingModels();
return catalog.providers.flatMap((provider) =>
provider.models.map((m) => ({
provider: provider.id,
model: m.id,
label: `${m.name ?? m.id}${provider.name}`,
})),
);
const catalog = await getModelCatalog();
return catalog.providers
.filter((provider) => provider.status === "ok")
.flatMap((provider) =>
provider.models.map((m) => ({
provider: provider.id,
model: m.id,
label: `${m.name ?? m.id}${providerDisplayName(provider.flavor)}`,
})),
);
}
function ensureBridge(): ChannelBridge {

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

@ -0,0 +1,173 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
/**
* The unified model catalog: every provider (rowboat gateway, codex, BYOK,
* local) flows through one function with per-provider status and a
* credential-fingerprinted list cache. These tests pin the policy: who gets
* discovered, which lister serves which flavor, how failures surface, and
* when the cache is (in)validated.
*/
const mocks = vi.hoisted(() => ({
isSignedIn: vi.fn(async () => false),
getChatGPTStatus: vi.fn(async () => ({ signedIn: false })),
listGatewayModels: vi.fn(async () => ({
providers: [{ id: 'rowboat', name: 'Rowboat', models: [{ id: 'google/gemini-3.5-flash', reasoning: true }] }],
})),
listCodexModels: vi.fn(async () => ({
providers: [{ id: 'codex', name: 'OpenAI Codex', models: [{ id: 'gpt-5.6-sol', reasoning: true }] }],
})),
listModelsForProvider: vi.fn(async (_config: unknown) => ['live-model-1']),
listOnboardingModels: vi.fn(async () => ({ providers: [] as Array<{ id: string; name: string; models: Array<{ id: string; name?: string; reasoning?: boolean }> }> })),
getDefaultModelAndProvider: vi.fn(async () => ({ provider: 'openai', model: 'gpt-5.4' })),
getConfig: vi.fn(async (): Promise<unknown> => {
throw new Error('no models.json');
}),
}));
vi.mock('../account/account.js', () => ({ isSignedIn: mocks.isSignedIn }));
vi.mock('../auth/chatgpt-auth.js', () => ({ getChatGPTStatus: mocks.getChatGPTStatus }));
vi.mock('./gateway.js', () => ({ listGatewayModels: mocks.listGatewayModels }));
vi.mock('./codex.js', () => ({ listCodexModels: mocks.listCodexModels }));
vi.mock('./models.js', () => ({ listModelsForProvider: mocks.listModelsForProvider }));
vi.mock('./models-dev.js', () => ({ listOnboardingModels: mocks.listOnboardingModels }));
vi.mock('./defaults.js', () => ({ getDefaultModelAndProvider: mocks.getDefaultModelAndProvider }));
vi.mock('../di/container.js', () => ({
default: { resolve: () => ({ getConfig: mocks.getConfig }) },
}));
import { getModelCatalog, __resetModelCatalogForTests } from './catalog.js';
// 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 () => ({
version: 2,
providers: Object.fromEntries(
Object.entries(providers).map(([id, entry]) => [id, { flavor: id, ...entry }]),
),
...(assistantModel ? { assistantModel } : {}),
}));
}
beforeEach(() => {
vi.clearAllMocks();
__resetModelCatalogForTests();
mocks.isSignedIn.mockResolvedValue(false);
mocks.getChatGPTStatus.mockResolvedValue({ signedIn: false });
mocks.listOnboardingModels.mockResolvedValue({ providers: [] });
mocks.getDefaultModelAndProvider.mockResolvedValue({ provider: 'openai', model: 'gpt-5.4' });
mocks.getConfig.mockRejectedValue(new Error('no models.json'));
});
describe('getModelCatalog', () => {
it('treats rowboat, codex, and BYOK providers as one uniform provider list', async () => {
mocks.isSignedIn.mockResolvedValue(true);
mocks.getChatGPTStatus.mockResolvedValue({ signedIn: true });
serveConfig({
ollama: { baseURL: 'http://localhost:11434' },
});
mocks.listModelsForProvider.mockResolvedValue(['llama3', 'qwen3']);
const catalog = await 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({ flavor: 'ollama', models: [{ id: 'llama3' }, { id: 'qwen3' }] });
expect(catalog.defaultModel).toEqual({ provider: 'openai', model: 'gpt-5.4' });
});
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(['ollama', 'openrouter']);
});
it('serves cloud flavors from the models.dev catalog and only lists live when it is empty', async () => {
serveConfig({ openai: { apiKey: 'sk-a' } });
mocks.listOnboardingModels.mockResolvedValue({
providers: [{ id: 'openai', name: 'OpenAI', models: [{ id: 'gpt-5.4', reasoning: true }] }],
});
const catalog = await getModelCatalog();
expect(catalog.providers[0].models).toEqual([{ id: 'gpt-5.4', reasoning: true }]);
expect(mocks.listModelsForProvider).not.toHaveBeenCalled();
// Empty models.dev cache (fresh offline install) → live listing fallback.
__resetModelCatalogForTests();
mocks.listOnboardingModels.mockResolvedValue({ providers: [] });
mocks.listModelsForProvider.mockResolvedValue(['gpt-5.4-live']);
const fallback = await getModelCatalog();
expect(fallback.providers[0].models).toEqual([{ id: 'gpt-5.4-live' }]);
expect(mocks.listModelsForProvider).toHaveBeenCalledTimes(1);
});
it('reports a failed provider as status error instead of dropping it', async () => {
serveConfig({ ollama: { baseURL: 'http://localhost:11434' } });
mocks.listModelsForProvider.mockRejectedValue(new Error('connection refused'));
const catalog = await getModelCatalog();
expect(catalog.providers[0]).toMatchObject({
id: 'ollama',
status: 'error',
error: 'connection refused',
models: [],
});
});
it('caches successful lists per credential fingerprint and refetches when credentials change', async () => {
serveConfig({ openrouter: { apiKey: 'sk-1' } });
mocks.listModelsForProvider.mockResolvedValue(['a/b']);
await getModelCatalog();
await getModelCatalog();
expect(mocks.listModelsForProvider).toHaveBeenCalledTimes(1);
// Same provider, new key → the fingerprint changes → refetch.
serveConfig({ openrouter: { apiKey: 'sk-2' } });
await getModelCatalog();
expect(mocks.listModelsForProvider).toHaveBeenCalledTimes(2);
});
it('refreshProvider bypasses the cache for that provider only', async () => {
serveConfig({
openrouter: { apiKey: 'sk-1' },
ollama: { baseURL: 'http://localhost:11434' },
});
mocks.listModelsForProvider.mockResolvedValue(['m']);
await getModelCatalog();
expect(mocks.listModelsForProvider).toHaveBeenCalledTimes(2);
await getModelCatalog({ refreshProvider: 'ollama' });
// Only ollama refetched; openrouter served from cache.
expect(mocks.listModelsForProvider).toHaveBeenCalledTimes(3);
const lastCall = mocks.listModelsForProvider.mock.calls.at(-1)?.[0] as { flavor: string };
expect(lastCall.flavor).toBe('ollama');
});
it('caches failures briefly so every catalog build does not re-pay the fetch timeout', async () => {
serveConfig({ ollama: { baseURL: 'http://localhost:11434' } });
mocks.listModelsForProvider.mockRejectedValue(new Error('down'));
await getModelCatalog();
await getModelCatalog();
expect(mocks.listModelsForProvider).toHaveBeenCalledTimes(1);
// …but an explicit refresh always retries.
await getModelCatalog({ refreshProvider: 'ollama' });
expect(mocks.listModelsForProvider).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,283 @@
import z from "zod";
import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js";
import { isSignedIn } from "../account/account.js";
import { getChatGPTStatus } from "../auth/chatgpt-auth.js";
import container from "../di/container.js";
import { IModelConfigRepo } from "./repo.js";
import { listGatewayModels } from "./gateway.js";
import { listCodexModels } from "./codex.js";
import { listModelsForProvider } from "./models.js";
import { listOnboardingModels } from "./models-dev.js";
import { getDefaultModelAndProvider } from "./defaults.js";
/**
* The unified model catalog: one function that answers "which providers are
* connected and what models does each offer", treating every provider the
* same way the Rowboat gateway, the ChatGPT subscription (codex), BYOK
* cloud keys, and local/custom endpoints are all just providers. The
* per-provider listing mechanics (which endpoint, which fallback) live here
* and nowhere else; the renderer consumes this through the single models:list
* IPC call.
*/
export interface CatalogModelEntry {
id: string;
name?: string;
/** models.dev "supports reasoning" flag; absent = unknown. */
reasoning?: boolean;
}
export interface CatalogProviderEntry {
/**
* Provider INSTANCE identifier what ModelRef.provider, assistantModel,
* task overrides, and refreshProvider all reference. Today one instance
* exists per flavor, so id always equals the flavor key ("openai",
* "ollama", "rowboat", ); a future multi-key setup ("openai-work" /
* "openai-personal") would yield two entries with distinct ids sharing
* one flavor, without changing what an id means anywhere.
*/
id: string;
/** Provider TYPE ("openai", "ollama", , "rowboat", "codex") drives
* display naming, listing mechanics, and credential-field UI. */
flavor: string;
/** "error" = the provider is connected but its model list failed to load. */
status: "ok" | "error";
error?: string;
models: CatalogModelEntry[];
}
export interface ModelCatalogResult {
providers: CatalogProviderEntry[];
/** The effective runtime default (what runs when nothing is picked). */
defaultModel: { provider: string; model: string } | null;
}
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
rowboat: "Rowboat",
codex: "OpenAI Codex",
openai: "OpenAI",
anthropic: "Anthropic",
google: "Gemini",
openrouter: "OpenRouter",
aigateway: "AI Gateway",
ollama: "Ollama",
"openai-compatible": "OpenAI-Compatible",
};
/**
* Display name for a provider flavor. Presentation only nothing keys on
* it. (When multi-instance providers arrive, a user-chosen instance label
* would take precedence over this.)
*/
export function providerDisplayName(flavor: string): string {
return PROVIDER_DISPLAY_NAMES[flavor] ?? flavor;
}
// Flavors whose lists come from the models.dev catalog cache (stable ids,
// no per-account variation); the live provider API is only a fallback when
// the cache is empty. Everything else always lists live.
const MODELS_DEV_FLAVORS = new Set(["openai", "anthropic", "google"]);
// listModelsForProvider builds aigateway's URL from baseURL; apply the
// service default here so a keyed-but-URL-less config still lists.
const AIGATEWAY_DEFAULT_BASE_URL = "https://ai-gateway.vercel.sh/v1";
// Successful lists are cached until the provider's credentials change or an
// explicit refresh; failures retry after a short TTL so a temporarily-down
// local server doesn't stay dark, without re-paying the fetch timeout on
// every catalog build in between.
const ERROR_RETRY_MS = 30_000;
interface CacheEntry {
fingerprint: string;
fetchedAt: number;
status: "ok" | "error";
error?: string;
models: CatalogModelEntry[];
}
const cache = new Map<string, CacheEntry>();
const inFlight = new Map<string, Promise<CacheEntry>>();
type ProviderConfig = z.infer<typeof LlmProvider>;
interface DiscoveredProvider {
id: string;
flavor: string;
/** Absent for rowboat/codex — their auth lives outside models.json. */
config?: ProviderConfig;
}
async function readModelConfig(): Promise<z.infer<typeof LlmModelConfig> | null> {
try {
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
return await repo.getConfig();
} catch {
// Signed-in users may have no models.json at all.
return null;
}
}
/**
* Which providers are connected right now. Rowboat and ChatGPT come from
* their auth state; everything else from the models.json providers map
* (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", flavor: "rowboat" });
}
try {
const chatgpt = await getChatGPTStatus();
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 assistantProvider = cfg?.assistantModel?.provider ?? "";
const ids = Object.keys(providersMap)
.sort((a, b) => (a === assistantProvider ? -1 : b === assistantProvider ? 1 : 0));
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: entry.flavor, config });
}
return discovered;
}
/** Cache key input: listing output depends only on flavor + credentials. */
function fingerprintOf(provider: DiscoveredProvider): string {
if (!provider.config) return provider.id;
const { flavor, apiKey, baseURL, headers } = provider.config;
return JSON.stringify({ flavor, apiKey, baseURL, headers });
}
async function fetchProviderEntry(
provider: DiscoveredProvider,
fingerprint: string,
modelsDevByFlavor: Map<string, CatalogModelEntry[]>,
): Promise<CacheEntry> {
try {
let models: CatalogModelEntry[];
if (provider.id === "rowboat") {
const result = await listGatewayModels();
models = result.providers[0]?.models ?? [];
} else if (provider.id === "codex") {
const result = await listCodexModels();
models = result.providers[0]?.models ?? [];
} else if (MODELS_DEV_FLAVORS.has(provider.flavor) && (modelsDevByFlavor.get(provider.flavor)?.length ?? 0) > 0) {
models = modelsDevByFlavor.get(provider.flavor) ?? [];
} else if (!provider.config) {
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).
const ids = await listModelsForProvider(provider.config);
models = ids.map((id) => ({ id }));
}
return { fingerprint, fetchedAt: Date.now(), status: "ok", models };
} catch (err) {
return {
fingerprint,
fetchedAt: Date.now(),
status: "error",
error: err instanceof Error ? err.message : "Failed to list models",
models: [],
};
}
}
async function resolveProviderEntry(
provider: DiscoveredProvider,
modelsDevByFlavor: Map<string, CatalogModelEntry[]>,
forceRefresh: boolean,
): Promise<CacheEntry> {
const fingerprint = fingerprintOf(provider);
const cached = cache.get(provider.id);
if (!forceRefresh && cached && cached.fingerprint === fingerprint) {
const fresh = cached.status === "ok" || Date.now() - cached.fetchedAt < ERROR_RETRY_MS;
if (fresh) return cached;
}
const pending = inFlight.get(provider.id);
if (pending && !forceRefresh) return pending;
const request = fetchProviderEntry(provider, fingerprint, modelsDevByFlavor)
.then((entry) => {
cache.set(provider.id, entry);
return entry;
})
.finally(() => {
if (inFlight.get(provider.id) === request) inFlight.delete(provider.id);
});
inFlight.set(provider.id, request);
return request;
}
export interface GetModelCatalogOptions {
/** Drop this provider's cached list and refetch it (Retry / Refresh models). */
refreshProvider?: string;
}
export async function getModelCatalog(options?: GetModelCatalogOptions): Promise<ModelCatalogResult> {
const discovered = await discoverProviders();
// 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.flavor))) {
try {
const catalog = await listOnboardingModels();
for (const p of catalog.providers) {
modelsDevByFlavor.set(p.id, p.models.map(({ id, name, reasoning }) => ({
id,
...(name ? { name } : {}),
...(reasoning !== undefined ? { reasoning } : {}),
})));
}
} catch {
// Empty map → cloud flavors fall through to live listing.
}
}
const entries = await Promise.all(discovered.map(async (provider) => {
const entry = await resolveProviderEntry(
provider,
modelsDevByFlavor,
options?.refreshProvider === provider.id,
);
const result: CatalogProviderEntry = {
id: provider.id,
flavor: provider.flavor,
status: entry.status,
...(entry.error ? { error: entry.error } : {}),
models: entry.models,
};
return result;
}));
let defaultModel: ModelCatalogResult["defaultModel"] = null;
try {
defaultModel = await getDefaultModelAndProvider();
} catch {
// No default resolvable (no config, signed out) — the picker copes.
}
return { providers: entries, defaultModel };
}
/** Test-only: reset the per-provider list cache. */
export function __resetModelCatalogForTests(): void {
cache.clear();
inFlight.clear();
}

View file

@ -0,0 +1,61 @@
import container from "../di/container.js";
import { IModelConfigRepo } from "./repo.js";
import { listCodexModels } from "./codex.js";
import { getRowboatConfig } from "../config/rowboat.js";
import { selectInitialModel, selectInitialTaskModels } from "./initial-selection.js";
import { normalizeModelRecommendation } from "@x/shared/dist/rowboat-account.js";
import { capture } from "../analytics/posthog.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) {
// Task recommendations ride along the seeding moment (codex has
// none today; the path is uniform across providers).
const taskModels = selectInitialTaskModels("codex", "codex", ids, recommendations, model);
await repo.updateConfig({
assistantModel: { provider: "codex", model },
...(Object.keys(taskModels).length > 0 ? { taskModels } : {}),
});
capture("llm_initial_model_selected", {
flavor: "codex",
model,
recommended: model === normalizeModelRecommendation(recommendations, "codex")?.assistantModel,
task_overrides_seeded: Object.keys(taskModels).length,
source: "sign_in",
});
}
} 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,63 +84,41 @@ 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");
}
/** Model used by the background-task agent + routing classifier. */
export async function getBackgroundTaskAgentModel(): Promise<ModelSelection> {
return getCategoryModel("backgroundTask");
}
/**
* Model used by the background-task agent + routing classifier. Currently
* mirrors `getLiveNoteAgentModel()` both surfaces want a fast, reliable
* agent model. Split into its own getter so a future per-feature override
* doesn't require touching all call sites.
* Explicit subagent model override, or null to inherit the PARENT turn's
* model (spawn-agent's default which is the assistant for a top-level
* chat). Not getCategoryModel: the no-override fallback is the parent, not
* the assistant, and the caller owns that resolution.
*/
export async function getBackgroundTaskAgentModel(): Promise<ModelSelection> {
return getLiveNoteAgentModel();
export async function getSubagentModelOverride(): Promise<ModelSelection | null> {
const cfg = await readConfig();
return cfg?.taskModels?.subagent ?? null;
}

View file

@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { selectInitialModel, selectInitialTaskModels } from './initial-selection.js';
describe('selectInitialModel', () => {
const recommendations = {
openai: 'gpt-5.4',
openrouter: 'anthropic/claude-opus-4.8',
};
it('picks the recommended model when the provider lists it', () => {
expect(selectInitialModel('openai', ['gpt-4.1', 'gpt-5.4', 'gpt-5.4-mini'], recommendations))
.toBe('gpt-5.4');
});
it('falls back to the first listed model when the recommendation is not in the list', () => {
expect(selectInitialModel('openai', ['gpt-4.1', 'gpt-4o'], recommendations))
.toBe('gpt-4.1');
});
it('falls back to the first listed model for flavors with no recommendation', () => {
expect(selectInitialModel('ollama', ['llama3', 'qwen3'], recommendations))
.toBe('llama3');
});
it('falls back to the first listed model when no recommendations map is available', () => {
expect(selectInitialModel('openai', ['gpt-4.1'], undefined)).toBe('gpt-4.1');
});
it('returns null when the provider listed nothing', () => {
expect(selectInitialModel('openai', [], recommendations)).toBeNull();
});
it('accepts the nested { assistantModel, taskModels } wire shape', () => {
const nested = { rowboat: { assistantModel: 'google/gemini-3.5-flash', taskModels: {} } };
expect(selectInitialModel('rowboat', ['a', 'google/gemini-3.5-flash'], nested))
.toBe('google/gemini-3.5-flash');
});
});
describe('selectInitialTaskModels', () => {
const gatewayList = [
'google/gemini-3.5-flash',
'google/gemini-3.1-flash-lite',
'google/gemini-3.5-flash-lite',
];
const nested = {
rowboat: {
assistantModel: 'google/gemini-3.5-flash',
taskModels: {
knowledgeGraph: 'google/gemini-3.1-flash-lite',
chatTitle: 'google/gemini-3.5-flash-lite',
// Equal to the assistant → redundant, inherit produces it.
meetingNotes: 'google/gemini-3.5-flash',
// Not in the provider's list → stale hint, skipped.
liveNoteAgent: 'google/gemini-9-experimental',
// Unknown key → ignored.
somethingNew: 'google/gemini-3.1-flash-lite',
},
},
};
it('writes overrides only for listed recs that differ from the assistant', () => {
expect(selectInitialTaskModels('rowboat', 'rowboat', gatewayList, nested, 'google/gemini-3.5-flash'))
.toEqual({
knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
chatTitle: { provider: 'rowboat', model: 'google/gemini-3.5-flash-lite' },
});
});
it('returns nothing for legacy flat recommendations or absent maps', () => {
expect(selectInitialTaskModels('rowboat', 'rowboat', gatewayList, { rowboat: 'google/gemini-3.5-flash' }, 'x'))
.toEqual({});
expect(selectInitialTaskModels('rowboat', 'rowboat', gatewayList, undefined, 'x')).toEqual({});
});
});

View file

@ -0,0 +1,3 @@
// Pure selection logic lives in @x/shared (the renderer's connect flow uses
// the same implementation); re-exported here for core call sites.
export { selectInitialModel, selectInitialTaskModels } from "@x/shared/dist/initial-selection.js";

View file

@ -0,0 +1,145 @@
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' },
// v1 background tasks mirrored the live-note model.
backgroundTask: { 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' },
backgroundTask: { 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('a v1 live-note override propagates to backgroundTask (v1 bg tasks mirrored live-note)', () => {
const v1 = {
provider: { flavor: 'openai', apiKey: 'sk-a' },
model: 'gpt-5.4',
liveNoteAgentModel: { provider: 'ollama', model: 'qwen3' },
};
expect(migrateModelsConfig(v1, false)?.taskModels).toEqual({
liveNoteAgent: { provider: 'ollama', model: 'qwen3' },
backgroundTask: { 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,171 @@
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 liveNoteEffective = v1Override(raw, "liveNoteAgentModel", signedIn)
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel);
const oldTaskModels: Record<string, Ref | undefined> = {
knowledgeGraph: v1Override(raw, "knowledgeGraphModel", signedIn)
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel),
liveNoteAgent: liveNoteEffective,
// v1 had no backgroundTask key — getBackgroundTaskAgentModel mirrored
// the live-note model (override included). v2 gives it its own slot.
backgroundTask: liveNoteEffective,
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,195 @@
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 { capture } from "../analytics/posthog.js";
import {
captureProviderConnected,
captureProviderDisconnected,
syncModelProviderPersonProperties,
} from "../analytics/model-providers.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);
// One-shot rollout signal for the v1 → v2 schema migration.
capture("models_config_migrated", {
had_assistant: Boolean(migrated.assistantModel),
materialized_overrides: Object.keys(migrated.taskModels ?? {}).length,
provider_count: Object.keys(migrated.providers).length,
});
}
}
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> {
// The credential-less flavors are never stored: their connection IS
// their auth token store (oauth.json / chatgpt-auth.json), and the
// catalog derives their presence from auth state — a providers-map
// entry would double-list them.
if (provider.flavor === "rowboat" || provider.flavor === "codex") {
throw new Error(`Provider flavor '${provider.flavor}' is auth-derived and cannot be stored in models.json`);
}
const config = await this.read();
const isNew = !config.providers[id];
// Merge over an existing entry: replacing a key must not wipe
// hand-tuned connection prefs (contextLength, reasoningEffort).
config.providers[id] = LlmProvider.parse({
...config.providers[id],
...provider,
});
await this.write(config);
// A brand-new entry is a connect; a key rotation is not.
if (isNew) captureProviderConnected(provider.flavor);
}
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
async removeProvider(id: string): Promise<void> {
const config = await this.read();
const removed = config.providers[id];
delete config.providers[id];
if (config.assistantModel?.provider === id) {
delete config.assistantModel;
}
const toWrite = { ...existingSelections, ...config, providers: existingProviders };
await fs.writeFile(this.configPath, JSON.stringify(toWrite, null, 2));
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;
}
await this.write(config);
if (removed) captureProviderDisconnected(removed.flavor);
}
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);
// The assistant person properties track the config.
if (patch.assistantModel !== undefined) {
void syncModelProviderPersonProperties();
}
}
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,67 @@
import container from "../di/container.js";
import { IModelConfigRepo } from "./repo.js";
import { listGatewayModels } from "./gateway.js";
import { getRowboatConfig } from "../config/rowboat.js";
import { selectInitialModel, selectInitialTaskModels } from "./initial-selection.js";
import { normalizeModelRecommendation } from "@x/shared/dist/rowboat-account.js";
import { capture } from "../analytics/posthog.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) {
// Task recommendations ride along the seeding moment: the
// gateway's lite-tier task models become visible overrides so
// always-on background work doesn't run on assistant-class
// models (plan-credit economics).
const taskModels = selectInitialTaskModels("rowboat", "rowboat", ids, recommendations, model);
await repo.updateConfig({
assistantModel: { provider: "rowboat", model },
...(Object.keys(taskModels).length > 0 ? { taskModels } : {}),
});
// Measures recommendation quality: hit = the backend's pick was
// in the gateway list; miss = first-listed fallback.
capture("llm_initial_model_selected", {
flavor: "rowboat",
model,
recommended: model === normalizeModelRecommendation(recommendations, "rowboat")?.assistantModel,
task_overrides_seeded: Object.keys(taskModels).length,
source: "sign_in",
});
}
} catch (error) {
// Best-effort: a failed initial selection must never break sign-in.
// The picker copes with an unset assistant (shows the connect hint).
console.warn("[models] Initial selection after Rowboat sign-in failed:", error);
}
}
export async function clearRowboatSelections(): Promise<void> {
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
try {
// "rowboat" has no providers-map entry; removeProvider still clears
// the assistantModel / task overrides that reference it.
await repo.removeProvider("rowboat");
} catch (error) {
console.warn("[models] Clearing Rowboat selections after sign-out failed:", error);
}
}

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { z } from "zod";
import type { TurnEvent, TurnState } from "@x/shared/dist/turns.js";
import type { ITurnRuntime } from "../turns/api.js";
@ -10,6 +10,16 @@ import type {
} from "./headless.js";
import { runSpawnedAgent } from "./spawn-agent.js";
// spawn-agent reads the configured subagent override lazily from
// models/defaults.js; stub it so tests control the whole precedence chain
// (explicit args > configured override > parent model).
const subagentOverride = vi.hoisted(() => ({
value: null as { provider: string; model: string } | null,
}));
vi.mock("../../models/defaults.js", () => ({
getSubagentModelOverride: async () => subagentOverride.value,
}));
const TS = "2026-07-07T10:00:00Z";
function parentCreated(
@ -95,6 +105,34 @@ function fakeServices(opts: {
const signal = new AbortController().signal;
describe("runSpawnedAgent", () => {
beforeEach(() => {
subagentOverride.value = null;
});
it("uses the configured subagent override when no explicit model is passed", async () => {
subagentOverride.value = { provider: "ollama", model: "qwen3" };
const { services, started } = fakeServices({});
await runSpawnedAgent(
{ task: "t", instructions: "x" },
{ parentTurnId: "parent-1", signal, services },
);
expect(started[0].agent).toMatchObject({
inline: { model: { provider: "ollama", model: "qwen3" } },
});
});
it("explicit per-spawn model args beat the configured override", async () => {
subagentOverride.value = { provider: "ollama", model: "qwen3" };
const { services, started } = fakeServices({});
await runSpawnedAgent(
{ task: "t", instructions: "x", model: "gpt-5.4", provider: "openai" },
{ parentTurnId: "parent-1", signal, services },
);
expect(started[0].agent).toMatchObject({
inline: { model: { provider: "openai", model: "gpt-5.4" } },
});
});
it("runs an inline child on the parent's model and returns the result envelope", async () => {
const { services, started } = fakeServices({});
const progress: unknown[] = [];

View file

@ -135,12 +135,15 @@ export async function runSpawnedAgent(
parentModel = undefined;
}
// Model precedence: explicit per-spawn args > the user's configured
// subagent override (taskModels.subagent) > the parent turn's model.
const subagentOverride = input.model ? null : await readSubagentOverride();
const model: z.infer<typeof ModelDescriptor> | undefined = input.model
? {
provider: input.provider ?? parentModel?.provider ?? "",
model: input.model,
}
: parentModel;
: subagentOverride ?? parentModel;
if (model && !model.provider) {
return spawnError(
"`model` was set but no provider could be determined; pass `provider` too",
@ -232,6 +235,21 @@ export async function runSpawnedAgent(
};
}
// Lazy for the same import-cycle reason as resolveServices; best-effort —
// an unreadable config means "no override", never a failed spawn.
async function readSubagentOverride(): Promise<
z.infer<typeof ModelDescriptor> | null
> {
try {
const { getSubagentModelOverride } = await import(
"../../models/defaults.js"
);
return await getSubagentModelOverride();
} catch {
return null;
}
}
async function resolveServices(): Promise<
NonNullable<SpawnedAgentCallbacks["services"]>
> {

View file

@ -0,0 +1,67 @@
import { z } from "zod";
import { ModelRef, TaskModels } from "./models.js";
import { normalizeModelRecommendation, type ModelRecommendations } from "./rowboat-account.js";
/**
* Initial model selection for a provider being connected for the first time.
*
* Implements the selection order from the provider/model-selection spec:
* 1. If Rowboat's recommended model for this flavor appears in the
* provider's available list, pick it.
* 2. Otherwise pick the first model the provider returned.
* 3. With no list at all, return null the caller offers retry or manual
* entry.
*
* Task-model recommendations ride along the same moment: when (and only
* when) a connect seeds the assistant, the provider's per-task
* recommendations become visible taskModels overrides each validated
* against the live list and skipped when it equals the chosen assistant
* (inheritance already produces it; only differences are written).
*
* This runs ONLY when a provider is first connected and has no saved
* selection. It must never run over an existing choice: after initial setup
* the saved model configuration is the source of truth, and changes to the
* recommendations or to the provider's list order must not silently replace
* what the user picked.
*
* Pure functions by design: callers supply the provider's available models
* (from the unified catalog / a live probe) and the recommendations map
* (from /v1/config via rowboat:getConfig, keyed by provider FLAVOR in each
* provider's native id format). Everything is best-effort an absent map,
* an unknown flavor, or a recommendation the provider doesn't serve all
* degrade gracefully.
*/
const TASK_MODEL_KEYS = Object.keys(TaskModels.shape) as Array<keyof z.infer<typeof TaskModels>>;
export function selectInitialModel(
flavor: string,
availableModelIds: string[],
recommendations: ModelRecommendations | undefined,
): string | null {
const recommended = normalizeModelRecommendation(recommendations, flavor)?.assistantModel;
if (recommended && availableModelIds.includes(recommended)) {
return recommended;
}
return availableModelIds[0] ?? null;
}
export function selectInitialTaskModels(
providerId: string,
flavor: string,
availableModelIds: string[],
recommendations: ModelRecommendations | undefined,
assistantModel: string,
): Partial<Record<keyof z.infer<typeof TaskModels>, z.infer<typeof ModelRef>>> {
const taskRecommendations = normalizeModelRecommendation(recommendations, flavor)?.taskModels;
if (!taskRecommendations) return {};
const overrides: Partial<Record<keyof z.infer<typeof TaskModels>, z.infer<typeof ModelRef>>> = {};
for (const key of TASK_MODEL_KEYS) {
const model = taskRecommendations[key];
// Unknown keys are ignored; a rec equal to the assistant is redundant
// (inherit produces it); an unlisted rec is a stale hint.
if (!model || model === assistantModel || !availableModelIds.includes(model)) continue;
overrides[key] = { provider: providerId, model };
}
return overrides;
}

View file

@ -2,7 +2,7 @@ import { z } from 'zod';
import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, WorkspaceChangeEvent, WriteFileOptions, WriteFileResult, RemoveOptions } from './workspace.js';
import { ListToolsResponse } from './mcp.js';
import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js';
import { LlmModelConfig, LlmProvider, ModelOverride, ModelRef, ReasoningEffort } from './models.js';
import { LlmProvider, ModelRef, ReasoningEffort } from './models.js';
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
import { AgentScheduleState } from './agent-schedule-state.js';
import { ServiceEvent } from './service-events.js';
@ -646,26 +646,44 @@ const ipcSchemas = {
req: BackgroundTaskAgentEvent,
res: z.null(),
},
// The unified model catalog (core/models/catalog.ts): every connected
// provider — Rowboat gateway, ChatGPT subscription (codex), BYOK keys,
// local/custom endpoints — listed the same way, with per-provider status.
'models:list': {
req: z.null(),
req: z.object({
// Drop this provider's cached list and refetch (Retry / Refresh).
refreshProvider: z.string().optional(),
}).nullable(),
res: z.object({
providers: z.array(z.object({
// Provider INSTANCE id — what ModelRef.provider / assistantModel /
// refreshProvider reference. One instance per flavor today, so it
// always equals the flavor key; kept distinct so a future
// multi-instance setup ("openai-work") is additive.
id: z.string(),
name: z.string(),
// Provider TYPE ("openai", "ollama", "rowboat", "codex", …) —
// drives display naming and credential-field UI.
flavor: z.string(),
// 'error' = provider is connected but its model list failed to load.
status: z.enum(['ok', 'error']),
error: z.string().optional(),
models: z.array(z.object({
id: z.string(),
name: z.string().optional(),
release_date: z.string().optional(),
// models.dev "supports reasoning/extended thinking" flag; absent =
// unknown. Gates the composer's reasoning-effort control.
reasoning: z.boolean().optional(),
})),
})),
lastUpdated: z.string().optional(),
// The effective runtime default (what runs when nothing is picked).
defaultModel: ModelRef.nullable(),
}),
},
'models:test': {
req: LlmModelConfig,
req: z.object({
provider: LlmProvider,
model: z.string(),
}),
res: z.object({
success: z.boolean(),
error: z.string().optional(),
@ -709,23 +727,69 @@ const ipcSchemas = {
error: z.string().optional(),
}),
},
'models:saveConfig': {
req: LlmModelConfig,
// Upsert one provider entry (credentials + connection prefs). Model
// choices are NOT part of a provider — set them via models:updateConfig.
'models:setProvider': {
req: z.object({
id: z.string(),
provider: LlmProvider,
}),
res: z.object({
success: z.literal(true),
}),
},
// Partial top-level merge into models.json — used by hybrid (signed-in +
// BYOK) settings to set the default selection / category overrides without
// clobbering the BYOK provider config that saveConfig owns. Omitted keys
// are untouched; null clears a key back to its default.
// Remove a provider entry plus any assistantModel / task override that
// references it (dangling selections would just error at run time).
'models:removeProvider': {
req: z.object({
id: z.string(),
}),
res: z.object({
success: z.literal(true),
}),
},
// Current model selections plus credential-FREE provider metadata (the
// renderer never needs keys to render pickers or provider cards). Null
// assistantModel = not configured yet.
'models:getConfig': {
req: z.null(),
res: z.object({
// Configured BYOK provider entries, secrets stripped: enough to
// render manage/edit surfaces (masked key indicator, endpoint).
providers: z.array(z.object({
id: z.string(),
flavor: z.string(),
baseURL: z.string().optional(),
hasApiKey: z.boolean(),
})),
assistantModel: ModelRef.nullable(),
taskModels: z.object({
knowledgeGraph: ModelRef.nullable(),
meetingNotes: ModelRef.nullable(),
liveNoteAgent: ModelRef.nullable(),
autoPermissionDecision: ModelRef.nullable(),
chatTitle: ModelRef.nullable(),
backgroundTask: ModelRef.nullable(),
subagent: ModelRef.nullable(),
}),
deferBackgroundTasks: z.boolean(),
}),
},
// Partial merge of model selections into models.json. Omitted keys are
// untouched; null clears a key (a cleared task override inherits the
// assistant model again). taskModels merges per-key.
'models:updateConfig': {
req: z.object({
defaultSelection: ModelRef.nullable().optional(),
knowledgeGraphModel: ModelOverride.nullable().optional(),
meetingNotesModel: ModelOverride.nullable().optional(),
liveNoteAgentModel: ModelOverride.nullable().optional(),
autoPermissionDecisionModel: ModelOverride.nullable().optional(),
assistantModel: ModelRef.nullable().optional(),
taskModels: z.object({
knowledgeGraph: ModelRef.nullable().optional(),
meetingNotes: ModelRef.nullable().optional(),
liveNoteAgent: ModelRef.nullable().optional(),
autoPermissionDecision: ModelRef.nullable().optional(),
chatTitle: ModelRef.nullable().optional(),
backgroundTask: ModelRef.nullable().optional(),
subagent: ModelRef.nullable().optional(),
}).optional(),
deferBackgroundTasks: z.boolean().nullable().optional(),
}),
res: z.object({
@ -773,9 +837,16 @@ const ipcSchemas = {
res: z.object({
signedIn: z.boolean(),
accessToken: z.string().nullable(),
config: RowboatApiConfig.nullable(),
}),
},
// The unauthenticated /v1/config bootstrap (service URLs, billing catalog,
// model recommendations). Independent of sign-in state — main caches the
// fetch once per app run; null when the API is unreachable. Renderer
// consumers go through the useRowboatConfig() hook.
'rowboat:getConfig': {
req: z.null(),
res: RowboatApiConfig.nullable(),
},
'oauth:didConnect': {
req: z.object({
provider: z.string(),

View file

@ -8,6 +8,10 @@ import { z } from "zod";
// thinkingLevel, OpenRouter reasoning.effort) is mapped at invoke time.
export const ReasoningEffort = z.enum(["low", "medium", "high"]);
// A provider entry: its TYPE (flavor) plus credentials and connection
// preferences. Deliberately carries NO model fields — model lists are always
// fetched from the provider (core/models/catalog.ts), and model choices live
// in assistantModel / taskModels.
export const LlmProvider = z.object({
// "rowboat" (signed-in gateway) and "codex" (ChatGPT subscription via
// "Sign in with ChatGPT") are credential-less flavors: they never appear
@ -28,51 +32,52 @@ export const LlmProvider = z.object({
reasoningEffort: ReasoningEffort.optional(),
});
// A provider-qualified model reference. `provider` is a provider name as
// understood by resolveProviderConfig — a BYOK flavor ("ollama", "openai",
// …) or "rowboat" for the signed-in gateway.
// A provider-qualified model reference. `provider` is a provider INSTANCE id
// as understood by resolveProviderConfig — a key of the providers map, or
// "rowboat" / "codex" for the credential-less providers. Today one instance
// exists per flavor, so instance ids equal flavor keys.
export const ModelRef = z.object({
provider: z.string(),
model: z.string(),
});
// Category overrides accept either a bare model id (legacy: paired with the
// active default provider) or a provider-qualified ref (hybrid mode: e.g.
// gateway assistant + local Ollama background agents).
export const ModelOverride = z.union([z.string(), ModelRef]);
// The per-task model override slots. Absence = inherit the assistant model
// (except `subagent`, whose default is the PARENT turn's model — which is
// the assistant for a top-level chat).
export const TaskModels = z.object({
knowledgeGraph: ModelRef.optional(),
meetingNotes: ModelRef.optional(),
liveNoteAgent: ModelRef.optional(),
autoPermissionDecision: ModelRef.optional(),
chatTitle: ModelRef.optional(),
backgroundTask: ModelRef.optional(),
subagent: ModelRef.optional(),
});
export type TaskModelKey = keyof z.infer<typeof TaskModels>;
/**
* models.json, version 2.
*
* The design: providers carry credentials only (keyed by instance id, with
* the flavor explicit inside each entry); model choices live in exactly two
* places the required-once-configured `assistantModel`, and optional
* per-task overrides that otherwise inherit from it. Model LISTS are never
* stored: they are fetched live per provider by the unified catalog.
*
* Version 1 (top-level provider/model pair + per-provider model lists +
* defaultSelection + flat category overrides) is migrated on boot by
* core/models/migrate.ts and its schema lives there.
*/
export const LlmModelConfig = z.object({
provider: LlmProvider,
model: z.string(),
models: z.array(z.string()).optional(),
// The user's explicit default assistant model. When set it wins over both
// the signed-in curated default and the legacy top-level provider/model
// pair — this is what lets signed-in users default to a BYOK model.
defaultSelection: ModelRef.optional(),
version: z.literal(2),
providers: z.record(z.string(), LlmProvider),
// The one primary model choice: what runs when nothing more specific was
// picked. Absent only before onboarding / first provider connect.
assistantModel: ModelRef.optional(),
taskModels: TaskModels.optional(),
// When true, background agent runs (knowledge pipeline, live notes,
// background tasks) wait until no chat turn is running before starting.
// Surfaced as a settings checkbox; recommended for local models, where a
// background run competes with the chat for the same hardware.
deferBackgroundTasks: z.boolean().optional(),
providers: z.record(z.string(), z.object({
apiKey: z.string().optional(),
baseURL: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
contextLength: z.number().int().positive().optional(),
reasoningEffort: ReasoningEffort.optional(),
model: z.string().optional(),
models: z.array(z.string()).optional(),
knowledgeGraphModel: z.string().optional(),
meetingNotesModel: z.string().optional(),
liveNoteAgentModel: z.string().optional(),
autoPermissionDecisionModel: z.string().optional(),
})).optional(),
// Per-category model overrides. Honored in both modes: when unset,
// signed-in users get the curated gateway defaults and BYOK users get the
// assistant model. Read by helpers in core/models/defaults.ts.
knowledgeGraphModel: ModelOverride.optional(),
meetingNotesModel: ModelOverride.optional(),
liveNoteAgentModel: ModelOverride.optional(),
autoPermissionDecisionModel: ModelOverride.optional(),
chatTitleModel: ModelOverride.optional(),
});

View file

@ -11,4 +11,43 @@ export const RowboatApiConfig = z.object({
// app keeps working against API deployments that predate it — the rewards
// UI just stays empty until the backend serves the catalog
creditActivations: z.array(CreditActivationCatalogEntrySchema).optional(),
// Recommended models per provider FLAVOR, in each provider's native id
// format: one assistantModel (the primary) plus optional per-task
// taskModels overrides mirroring models.json v2 vocabulary (a missing
// task key = inherit the assistant — task recs exist only where the
// intended model differs; for rowboat they reproduce the pre-v2 curated
// lite-tier task models so plan credits aren't burned by background
// services). Hints for the INITIAL selection when a provider is first
// connected — never a catalog, and never applied over a saved choice
// (see shared/initial-selection.ts). The bare-string form is the legacy
// wire shape, accepted so backend deploy order and rollback are
// non-events. Local/custom flavors are intentionally absent: the API
// can't know which models exist in a user's environment. Optional so
// older API deployments and failed fetches never break parsing —
// recommendations are best-effort by design.
modelRecommendations: z.record(z.string(), z.union([
z.string(),
z.object({
assistantModel: z.string(),
taskModels: z.record(z.string(), z.string()).optional(),
}),
])).optional(),
});
export type ModelRecommendations = NonNullable<z.infer<typeof RowboatApiConfig>['modelRecommendations']>;
export interface NormalizedModelRecommendation {
assistantModel: string;
taskModels: Record<string, string>;
}
/** One provider's recommendation in canonical form; null when absent. */
export function normalizeModelRecommendation(
recommendations: ModelRecommendations | undefined,
flavor: string,
): NormalizedModelRecommendation | null {
const raw = recommendations?.[flavor];
if (!raw) return null;
if (typeof raw === 'string') return { assistantModel: raw, taskModels: {} };
return { assistantModel: raw.assistantModel, taskModels: raw.taskModels ?? {} };
}