Merge pull request #791 from rowboatlabs/model-selection
Some checks are pending
rowboat / apps/x Vitest suites (push) Waiting to run
rowboat / apps/x Electron package smoke test (push) Waiting to run

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

@ -115,9 +115,10 @@ Long-form docs for specific features. Read the relevant file before making chang
## Common Tasks
### LLM configuration (single provider)
- Config file: `~/.rowboat/config/models.json`
- Schema: `{ provider: { flavor, apiKey?, baseURL?, headers? }, model: string }`
### LLM configuration
- Config file: `~/.rowboat/config/models.json` (v2; v1 files are migrated on boot by `core/models/migrate.ts`)
- Schema: `{ version: 2, providers: { <id>: { flavor, apiKey?, baseURL?, … } }, assistantModel?: { provider, model }, taskModels?: { knowledgeGraph?, meetingNotes?, liveNoteAgent?, autoPermissionDecision?, chatTitle? }, deferBackgroundTasks? }`
- Providers carry credentials only (no model fields) — model lists are always fetched live via the unified catalog (`core/models/catalog.ts`, `models:list` IPC). Model choices live in `assistantModel` (the one primary) and `taskModels` (optional overrides that otherwise inherit the assistant).
- Models catalog cache: `~/.rowboat/config/models.dev.json` (OpenAI/Anthropic/Google only)
### Add a new shared type

View file

@ -28,7 +28,7 @@ Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run
| `sub_use_case` | string? | Refines `use_case` — see taxonomy table below |
| `agent_name` | string? | Present when the call goes through an agent run (`createRun`); omitted for direct `generateText`/`generateObject` |
| `model` | string | e.g. `claude-sonnet-4-6` |
| `provider` | string | `rowboat` = cloud LLM gateway; otherwise the BYOK provider (`openai`, `anthropic`, `ollama`, etc.) |
| `provider` | string | The provider FLAVOR: `rowboat` = cloud LLM gateway, `codex` = ChatGPT subscription, else the BYOK flavor (`openai`, `anthropic`, `ollama`, …). Call sites pass instance ids; `captureLlmUsage` maps id → flavor so charts never fracture if user-named provider instances ship (ids never leave the app) |
| `input_tokens` | number | |
| `output_tokens` | number | |
| `total_tokens` | number | |
@ -84,6 +84,14 @@ Emitted on rowboat disconnect. No properties. Followed immediately by `posthog.r
Emit points: `apps/main/src/oauth-handler.ts:369` and `apps/renderer/src/hooks/useAnalyticsIdentity.ts:82`.
### Model-provider lifecycle
Privacy rules (enforced in `packages/core/src/analytics/model-providers.ts`): only provider **flavors** are captured — never instance ids (future-proofing for user-named instances), never `apiKey`/`headers`, and never `baseURL` (local endpoints can carry internal hostnames). Model ids are allowed.
- `llm_provider_connected` / `llm_provider_disconnected``{ flavor }` — one event family across every surface. BYOK fires from `FSModelConfigRepo.setProvider` (new entries only — key rotation is not a connect) / `removeProvider`; `rowboat` from sign-in/out (`apps/main/src/oauth-handler.ts`); `codex` from ChatGPT sign-in/out (`apps/main/src/ipc.ts`).
- `llm_initial_model_selected``{ flavor, model, recommended, task_overrides_seeded, source: 'connect' | 'onboarding' | 'sign_in' }` — a connect seeded the assistant model (only when none was configured). `recommended: false` = first-listed fallback; the hit rate measures backend recommendation quality. `task_overrides_seeded` counts the per-task recommendations written alongside (the server-controlled lite-tier task models — 0 when the provider has none). Emit points: `apps/renderer/src/components/settings/providers-section.tsx` (connect/onboarding) and `packages/core/src/models/rowboat-selection.ts` / `chatgpt-selection.ts` (sign-in).
- `models_config_migrated``{ had_assistant, materialized_overrides, provider_count }` — one-shot per install at the models.json v1 → v2 boot migration (`FSModelConfigRepo.ensureConfig`); rollout health for the schema change.
### Other events (pre-existing, not added by the LLM-usage work)
All in `apps/renderer/src/lib/analytics.ts`:
@ -210,6 +218,9 @@ Persistent across sessions for the same user. Set via `posthog.people.set` or as
| `has_used_search`, `has_used_voice` | renderer | One-shot first-use flags |
| `has_used_email`, `has_used_meetings`, `has_used_live_notes`, `has_used_bg_agents`, `has_used_apps`, `has_used_code` | renderer (`view_opened`) | One-shot first-use flags per feature view |
| `has_created_bg_agent` | renderer | One-shot: user set up a background agent |
| `llm_provider_flavors` | main | Sorted array of connected provider flavors incl. `rowboat`/`codex` from auth state (e.g. `["openai","openrouter","rowboat"]`). Synced on every launch and after any provider/assistant change (`packages/core/src/analytics/model-providers.ts`) |
| `llm_provider_count` | main | Size of `llm_provider_flavors` |
| `assistant_model`, `assistant_model_flavor` | main | The configured primary model (complements `llm_usage`, which reports actual usage). Absent until an assistant is configured |
## How to add a new event

View file

@ -3,6 +3,7 @@ import type { Server } from 'http';
import { createAuthServer } from './auth-server.js';
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
import { exchangeChatGPTCode, getChatGPTStatus } from '@x/core/dist/auth/chatgpt-auth.js';
import { applyCodexInitialSelection } from '@x/core/dist/models/chatgpt-selection.js';
import {
CHATGPT_AUTHORIZE_URL,
CHATGPT_CALLBACK_PATH,
@ -65,7 +66,14 @@ export async function signInWithChatGPT(): Promise<ChatGPTSignInResult> {
void attempt.promise.finally(() => {
if (activeAttempt === attempt) activeAttempt = null;
});
return attempt.promise;
const result = await attempt.promise;
if (result.signedIn) {
// Signing in connects the codex provider: if no assistant model is
// saved yet, pick the initial one (recommendation if the subscription
// lists it, else first listed). Never replaces a saved choice.
await applyCodexInitialSelection();
}
return result;
}
/**

View file

@ -33,15 +33,14 @@ import { isDurableTurnEvent } from '@x/shared/dist/turns.js';
import type { ISessions, EmitterSessionBus } from '@x/core/dist/runtime/sessions/index.js';
import type { ITurnEventBus } from '@x/core/dist/runtime/turns/event-hub.js';
import container from '@x/core/dist/di/container.js';
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js';
import { getModelCatalog } from '@x/core/dist/models/catalog.js';
import { captureProviderConnected, captureProviderDisconnected } from '@x/core/dist/analytics/model-providers.js';
import { getDefaultModelAndProvider } from '@x/core/dist/models/defaults.js';
import { isSignedIn } from '@x/core/dist/account/account.js';
import { listGatewayModels } from '@x/core/dist/models/gateway.js';
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
import { getChatGPTStatus, signOutChatGPT } from '@x/core/dist/auth/chatgpt-auth.js';
import { listCodexModels } from '@x/core/dist/models/codex.js';
import { signInWithChatGPT, cancelChatGPTSignIn } from './chatgpt-signin.js';
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
@ -1249,22 +1248,8 @@ export function setupIpcHandlers() {
return { success: false, error: message };
}
},
'models:list': async () => {
const base = (await isSignedIn())
? await listGatewayModels()
: await listOnboardingModels();
// ChatGPT-subscription (codex) models are additive and independent of
// Rowboat sign-in; their failure must never break the main list.
try {
const chatgpt = await getChatGPTStatus();
if (chatgpt.signedIn) {
const codex = await listCodexModels();
return { providers: [...base.providers, ...codex.providers] };
}
} catch (error) {
console.warn('[Codex] Listing subscription models failed:', error);
}
return base;
'models:list': async (_event, args) => {
return await getModelCatalog({ refreshProvider: args?.refreshProvider });
},
'models:test': async (_event, args) => {
return await testModelConnection(args.provider, args.model);
@ -1287,9 +1272,38 @@ export function setupIpcHandlers() {
console.log(`[llm:generate] -> provider=${result.provider ?? '?'} model=${result.model ?? '?'} chars=${result.text?.length ?? 0}${result.error ? ` error=${result.error}` : ''}`);
return result;
},
'models:saveConfig': async (_event, args) => {
'models:getConfig': async () => {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
await repo.setConfig(args);
const cfg = await repo.getConfig().catch(() => null);
const tasks = cfg?.taskModels ?? {};
return {
providers: Object.entries(cfg?.providers ?? {}).map(([id, entry]) => ({
id,
flavor: entry.flavor,
...(entry.baseURL ? { baseURL: entry.baseURL } : {}),
hasApiKey: Boolean(entry.apiKey),
})),
assistantModel: cfg?.assistantModel ?? null,
taskModels: {
knowledgeGraph: tasks.knowledgeGraph ?? null,
meetingNotes: tasks.meetingNotes ?? null,
liveNoteAgent: tasks.liveNoteAgent ?? null,
autoPermissionDecision: tasks.autoPermissionDecision ?? null,
chatTitle: tasks.chatTitle ?? null,
backgroundTask: tasks.backgroundTask ?? null,
subagent: tasks.subagent ?? null,
},
deferBackgroundTasks: cfg?.deferBackgroundTasks === true,
};
},
'models:setProvider': async (_event, args) => {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
await repo.setProvider(args.id, args.provider);
return { success: true };
},
'models:removeProvider': async (_event, args) => {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
await repo.removeProvider(args.id);
return { success: true };
},
'models:updateConfig': async (_event, args) => {
@ -1323,6 +1337,7 @@ export function setupIpcHandlers() {
// Model lists gate on sign-in state (composer picker, models:list) —
// push the change so they refresh without polling.
broadcastToWindows('chatgpt:statusChanged', { signedIn: true });
captureProviderConnected('codex');
}
return result;
},
@ -1334,6 +1349,7 @@ export function setupIpcHandlers() {
try {
await signOutChatGPT();
broadcastToWindows('chatgpt:statusChanged', { signedIn: false });
captureProviderDisconnected('codex');
return { success: true };
} catch (error) {
console.error('[ChatGPTAuth] Sign-out failed:', error);
@ -1343,18 +1359,21 @@ export function setupIpcHandlers() {
'account:getRowboat': async () => {
const signedIn = await isSignedIn();
if (!signedIn) {
return { signedIn: false, accessToken: null, config: null };
return { signedIn: false, accessToken: null };
}
const config = await getRowboatConfig();
try {
const accessToken = await getAccessToken();
return { signedIn: true, accessToken, config };
return { signedIn: true, accessToken };
} catch {
return { signedIn: true, accessToken: null, config };
return { signedIn: true, accessToken: null };
}
},
// Unauthenticated /v1/config bootstrap, independent of sign-in (signed-out
// BYOK users need its model recommendations when connecting a provider).
// getRowboatConfig caches once per app run; best-effort null on failure.
'rowboat:getConfig': async () => {
return await getRowboatConfig().catch(() => null);
},
'granola:getConfig': async () => {
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
const config = await repo.getConfig();

View file

@ -43,6 +43,7 @@ import { setTokenCipher as setGithubTokenCipher } from "@x/core/dist/apps/github
import { setTokenCipher as setChatGPTTokenCipher } from "@x/core/dist/auth/chatgpt-auth.js";
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
import { syncModelProviderPersonProperties } from "@x/core/dist/analytics/model-providers.js";
import { migrateRuns } from "@x/core/dist/migrations/runs/migrate.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
@ -511,6 +512,11 @@ app.whenReady().then(async () => {
identifyIfSignedIn().catch((error) => {
console.error('[Analytics] Failed to identify on startup:', error);
});
// Baseline the provider person properties (llm_provider_flavors et al) on
// every launch — existing installs get them without any provider action.
syncModelProviderPersonProperties().catch((error) => {
console.error('[Analytics] Failed to sync provider properties:', error);
});
registerBrowserControlService(new ElectronBrowserControlService());
registerNotificationService(new ElectronNotificationService(APP_LAUNCHED_AT));

View file

@ -17,6 +17,8 @@ import { capture as analyticsCapture, identify as analyticsIdentify, reset as an
import { isSignedIn } from '@x/core/dist/account/account.js';
import { getWebappUrl } from '@x/core/dist/config/remote-config.js';
import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js';
import { applyRowboatInitialSelection, clearRowboatSelections } from '@x/core/dist/models/rowboat-selection.js';
import { captureProviderConnected, captureProviderDisconnected } from '@x/core/dist/analytics/model-providers.js';
function buildRedirectUri(port: number): string {
return `http://localhost:${port}/oauth/callback`;
@ -339,6 +341,12 @@ export async function connectProvider(provider: string, credentials?: { clientId
// multiple renderer hooks race to create the user, causing duplicates.
let signedInUserId: string | undefined;
if (provider === 'rowboat') {
// Signing in connects the rowboat provider: if no assistant
// model is saved yet, pick the initial one (recommendation if
// the gateway lists it, else first listed). Never replaces a
// saved choice; best-effort by design.
await applyRowboatInitialSelection();
captureProviderConnected('rowboat');
try {
const billing = await getBillingInfo();
if (billing.userId) {
@ -530,6 +538,11 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
if (provider === 'rowboat') {
analyticsCapture('user_signed_out');
analyticsReset();
// Signing out disconnects the rowboat provider: drop the model
// selections that reference it (same dangling-ref cleanup as removing
// any provider). The composer prompts for a new pick.
await clearRowboatSelections();
captureProviderDisconnected('rowboat');
}
// Notify renderer so sidebar, voice, and billing re-check state
emitOAuthEvent({ provider, success: false });

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"
import { useEffect } from "react"
import {
Dialog,
DialogContent,
@ -10,12 +10,7 @@ import {
import { Button } from "@/components/ui/button"
import type { BillingErrorMatch } from "@/lib/billing-error"
import * as analytics from "@/lib/analytics"
interface BillingRowboatAccount {
config?: {
appUrl?: string | null
} | null
}
import { useRowboatConfig } from "@/hooks/use-rowboat-config"
interface BillingErrorDialogProps {
open: boolean
@ -24,15 +19,7 @@ interface BillingErrorDialogProps {
}
export function BillingErrorDialog({ open, match, onOpenChange }: BillingErrorDialogProps) {
const [appUrl, setAppUrl] = useState<string | null>(null)
useEffect(() => {
if (!open) return
window.ipc
.invoke('account:getRowboat', null)
.then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null))
.catch(() => {})
}, [open])
const appUrl = useRowboatConfig()?.appUrl ?? null
useEffect(() => {
if (open && match) analytics.billingErrorShown(match.kind)

View file

@ -549,12 +549,11 @@ function ChatInputInner({
checkSearch()
}, [isActive, isRowboatConnected])
// Selecting a model affects the *next* run created from this tab (frozen
// once a run exists) AND persists as the app default so background agents
// and new tabs follow the last pick. The models-config-changed dispatch
// re-fetches the shared store too — that re-read lands on the same
// selection (selectedModel is untouched, the live lists come from the
// useProviderModels cache), so it's visually a no-op.
// Selecting a model here is PER-CHAT: it affects the next run created
// from this tab (frozen once a run exists) and nothing else. The config's
// assistantModel is the durable default — new tabs and background work
// always start from it, and only the settings Assistant picker (or a
// provider connect's initial selection) writes it.
const handleModelChange = useCallback((model: SelectedModel | null) => {
if (lockedModel) return
// null = the sentinel row, which the composer never renders (no
@ -562,9 +561,6 @@ function ChatInputInner({
if (!model) return
setSelectedModel(model)
onSelectedModelChange?.(model)
void window.ipc.invoke('models:updateConfig', { defaultSelection: { provider: model.provider, model: model.model } })
.then(() => { window.dispatchEvent(new Event('models-config-changed')) })
.catch(() => { toast.error('Failed to save default model') })
}, [lockedModel, onSelectedModelChange])
// Effort is per-turn and unpersisted; ModelSelector reports '' when the

View file

@ -24,32 +24,18 @@ let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
}
function serveTwoProviders(): void {
handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: false } } })
handlers['llm:getDefaultModel'] = async () => ({ provider: 'openai', model: 'gpt-5.4' })
handlers['models:list'] = async () => ({
providers: [
{ id: 'openai', name: 'OpenAI', models: [{ id: 'gpt-5.4' }] },
{ id: 'anthropic', name: 'Anthropic', models: [{ id: 'claude-opus-4-8' }] },
{ id: 'openai', flavor: 'openai', status: 'ok', models: [{ id: 'gpt-5.4' }] },
{ id: 'anthropic', flavor: 'anthropic', status: 'ok', models: [{ id: 'claude-opus-4-8' }] },
],
})
handlers['workspace:readFile'] = async () => ({
data: JSON.stringify({
provider: { flavor: 'openai' },
model: 'gpt-5.4',
providers: {
openai: { apiKey: 'sk-a', model: 'gpt-5.4' },
anthropic: { apiKey: 'sk-b', model: 'claude-opus-4-8' },
},
}),
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
})
}
async function openMenu(): Promise<void> {
const trigger = screen.getByRole('button')
// Radix opens the menu from the trigger's keydown handler in jsdom
// (pointerdown would ALSO toggle — one gesture only).
fireEvent.keyDown(trigger, { key: 'Enter' })
await waitFor(() => expect(document.querySelector('[role="menu"]')).not.toBeNull())
fireEvent.click(screen.getByRole('button'))
await waitFor(() => expect(document.querySelector('[cmdk-root]')).not.toBeNull())
}
beforeEach(() => {
@ -135,7 +121,7 @@ describe('ModelSelector', () => {
/>,
)
await openMenu()
fireEvent.change(screen.getByPlaceholderText('Search models…'), {
fireEvent.change(screen.getByPlaceholderText('Search models and providers…'), {
target: { value: 'openrouter/meituan/longcat-2.0' },
})
fireEvent.click(await screen.findByText('Use "openrouter/meituan/longcat-2.0"'))
@ -155,39 +141,68 @@ describe('ModelSelector', () => {
/>,
)
await openMenu()
// Wait for the store snapshot (the default provider comes from it).
await screen.findByText('claude-opus-4-8')
fireEvent.change(screen.getByPlaceholderText('Search models…'), { target: { value: 'my-local-model' } })
// Wait for the store snapshot (split mode opens on the default
// provider's group, so its model is what renders first).
await screen.findByText('gpt-5.4')
fireEvent.change(screen.getByPlaceholderText('Search models and providers…'), { target: { value: 'my-local-model' } })
fireEvent.click(await screen.findByText('Use "my-local-model"'))
expect(onChange).toHaveBeenCalledWith({ provider: 'openai', model: 'my-local-model' })
})
it('liveCredentials live-fetches a provider that is not saved anywhere', async () => {
it('split view: ArrowRight switches provider and Enter selects from the new group', async () => {
serveTwoProviders()
// openrouter is absent from models.json AND the static catalog — only
// the form's typed credentials can produce its list.
handlers['models:listForProvider'] = async () => ({
success: true,
models: ['meituan/longcat-2.0', 'qwen/qwen-3'],
})
const onChange = vi.fn()
render(<ModelSelector variant="field" value={null} onChange={onChange} defaultOption={{ label: 'x' }} />)
await openMenu()
await screen.findByText('gpt-5.4')
// Regression: swapping the provider column used to orphan cmdk's
// internal highlight, making Enter a no-op until ↑/↓ was pressed.
const input = screen.getByPlaceholderText('Search models and providers…')
fireEvent.keyDown(input, { key: 'ArrowRight' })
await screen.findByText('claude-opus-4-8')
fireEvent.keyDown(input, { key: 'Enter' })
expect(onChange).toHaveBeenCalledWith({ provider: 'anthropic', model: 'claude-opus-4-8' })
})
it('search matches provider names and shows that provider\'s whole group', async () => {
serveTwoProviders()
render(
<ModelSelector
variant="field"
value={null}
onChange={onChange}
providerFilter="openrouter"
liveCredentials={{ flavor: 'openrouter', apiKey: 'sk-or-typed', baseURL: '' }}
allowCustom
defaultOption={{ label: 'Auto (recommended)' }}
/>,
<ModelSelector variant="field" value={null} onChange={() => {}} defaultOption={{ label: 'x' }} />,
)
await openMenu()
// 600ms debounce in useProviderModels before the fetch fires.
const row = await screen.findByText('meituan/longcat-2.0', undefined, { timeout: 3000 })
await screen.findByText('gpt-5.4')
// "anthropic" matches no model id — but it matches the provider, so its
// group stays visible in full while others filter away.
fireEvent.change(screen.getByPlaceholderText('Search models and providers…'), { target: { value: 'anthropic' } })
await screen.findByText('claude-opus-4-8')
expect(screen.queryByText('gpt-5.4')).toBeNull()
fireEvent.click(row)
expect(onChange).toHaveBeenCalledWith({ provider: 'openrouter', model: 'meituan/longcat-2.0' })
expect(screen.queryByText('No models match')).toBeNull()
})
it('renders a failed provider group as an error row with Retry (refreshing that provider)', async () => {
handlers['models:list'] = async (args) => {
const refreshed = (args as { refreshProvider?: string } | null)?.refreshProvider === 'ollama'
return {
providers: [
{ id: 'openai', flavor: 'openai', status: 'ok', models: [{ id: 'gpt-5.4' }] },
refreshed
? { id: 'ollama', flavor: 'ollama', status: 'ok', models: [{ id: 'llama3' }] }
: { id: 'ollama', flavor: 'ollama', status: 'error', error: 'connection refused', models: [] },
],
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
}
}
render(<ModelSelector variant="field" value={null} onChange={() => {}} defaultOption={{ label: 'x' }} />)
await openMenu()
// Split mode opens on the default provider (openai); the failed
// provider shows an error dot in the column — select it to see the row.
fireEvent.click(await screen.findByRole('tab', { name: /Ollama/ }))
const retry = await screen.findByText('Retry')
expect(screen.getByText('connection refused')).toBeInTheDocument()
fireEvent.click(retry)
// The retried fetch recovers the group's models.
await screen.findByText('llama3')
})
it('staticOptions renders only the supplied rows and round-trips ids and null', async () => {
@ -209,7 +224,7 @@ describe('ModelSelector', () => {
await openMenu()
// Only the caller's rows — nothing from the shared catalog store.
expect(screen.queryByText('gpt-5.4')).toBeNull()
expect(screen.queryByText('claude-opus-4-8', { selector: '[role="menuitemradio"] span' })).not.toBeNull()
expect(screen.queryByText('claude-opus-4-8', { selector: '[role="option"] span' })).not.toBeNull()
// Colliding "Opus" labels are disambiguated by their raw id.
expect(screen.getAllByText('Opus')).toHaveLength(2)
@ -217,7 +232,7 @@ describe('ModelSelector', () => {
expect(onChange).toHaveBeenCalledWith({ provider: '', model: 'sonnet' })
await openMenu()
fireEvent.click(screen.getByText('Default (recommended)', { selector: '[role="menuitemradio"] span' }))
fireEvent.click(screen.getByText('Default (recommended)', { selector: '[role="option"] span' }))
expect(onChange).toHaveBeenCalledWith(null)
})
@ -235,7 +250,7 @@ describe('ModelSelector', () => {
/>,
)
await openMenu()
fireEvent.change(screen.getByPlaceholderText('Search models…'), { target: { value: 'my-custom-model' } })
fireEvent.change(screen.getByPlaceholderText('Search models and providers…'), { target: { value: 'my-custom-model' } })
const custom = await screen.findByText('Use "my-custom-model"')
fireEvent.click(custom)
expect(onChange).toHaveBeenCalledWith({ provider: 'anthropic', model: 'my-custom-model' })

View file

@ -1,18 +1,23 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Brain, ChevronDown, LoaderIcon } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Brain, Check, ChevronDown } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { useModels, type ModelPickerGroup, type ModelRef } from '@/hooks/use-models'
import { useProviderModels, type ProviderModelsFlavor } from '@/hooks/use-provider-models'
import { cn } from '@/lib/utils'
export type { ModelRef } from '@/hooks/use-models'
@ -21,7 +26,7 @@ export type ReasoningEffortLevel = 'low' | 'medium' | 'high'
const TOOLTIP_DELAY_MS = 1000
const providerDisplayNames: Record<string, string> = {
export const providerDisplayNames: Record<string, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
google: 'Gemini',
@ -47,94 +52,34 @@ function getModelDisplayName(model: string) {
return model.split('/').pop() || model
}
// Rendered inside the dropdown's radio group: each live provider fetches its
// own list, so groups load and fail independently. Pinned models (the saved
// default / app default) render first — the model that actually runs is
// always pickable even while the fetch is pending or failed. Live-fetched
// ids carry no reasoning metadata, so the effort control stays hidden for
// them (reasoningByKey lookup misses default to off).
//
// The group owns its header so it can hide itself when the search filter
// matches none of its rows. Loading/error rows are status, not models — they
// render (with the header) regardless of the filter, and don't count toward
// the parent's "No models match" check (which is what gets reported up).
function LiveProviderGroupItems({ group, label, pinnedModels, filter, onModelRowsChange }: {
group: Extract<ModelPickerGroup, { kind: 'live' }>
label: string
pinnedModels: string[]
filter: string
onModelRowsChange: (flavor: string, hasModelRows: boolean) => void
}) {
const { status, models, error, refetch } = useProviderModels({
flavor: group.flavor,
apiKey: group.apiKey,
baseURL: group.baseURL,
})
const items = [...pinnedModels, ...models.filter((m) => !pinnedModels.includes(m))]
const visible = filter ? items.filter((m) => m.toLowerCase().includes(filter)) : items
const showStatus = status === 'loading' || status === 'error'
const hasModelRows = visible.length > 0
useEffect(() => {
onModelRowsChange(group.flavor, hasModelRows)
}, [group.flavor, hasModelRows, onModelRowsChange])
if (!hasModelRows && !showStatus) return null
return (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">{label}</DropdownMenuLabel>
{visible.map((m) => {
const key = `${group.flavor}/${m}`
return (
<DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m}</span>
</DropdownMenuRadioItem>
)
})}
{status === 'loading' && (
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground">
<LoaderIcon className="h-3 w-3 animate-spin" />
Loading models
</div>
)}
{status === 'error' && (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
refetch()
}}
className="text-xs"
>
<span className="truncate text-destructive">{error || 'Failed to load models'}</span>
<span className="ml-auto shrink-0 text-muted-foreground">Retry</span>
</DropdownMenuItem>
)}
</>
)
}
// The standardized model picker (model-selection consolidation), mounted
// everywhere models are chosen. One controlled value/onChange contract with
// per-surface modes layered on as optional props: the composer's full
// catalog picker (provider groups, live fetches, search, reasoning effort),
// settings' default sentinel / field trigger / provider scoping / typed-id
// escape hatch, per-task "(global default)" inheritance, and caller-supplied
// restricted lists (coding-agent options).
// everywhere models are chosen — the composer pill, every settings field,
// per-task overrides, and the coding-agent restricted lists. One controlled
// value/onChange contract with per-surface modes layered on as optional
// props.
//
// The dropdown is a Popover + cmdk Command. With the search empty and more
// than one provider connected, it browses as a SPLIT VIEW: providers on the
// left (the assistant's provider pre-selected), the chosen provider's models
// on the right — ←/→ switches provider, ↑/↓ navigates models. Typing
// collapses to one flat list filtered across ALL providers (model ids and
// provider names both match). Scoped/static pickers stay flat.
export interface ModelSelectorProps {
/** Current selection; null follows the app default / the sentinel. */
value: ModelRef | null
/** null only ever fires when defaultOption is set (sentinel picked). */
onChange: (value: ModelRef | null) => void
/**
* Pinned top entry ("Rowboat default", "Same as assistant") that selects
* null. When set, a null value renders this label instead of the app
* default model.
* Pinned top entry ("Same as Assistant") that selects null. When set, a
* null value renders this label instead of the app default model.
*/
defaultOption?: { label: string }
/**
* Inheritance flavor of defaultOption for per-task overrides
* ("(global default)"): same sentinel row and null semantics, but null
* means "inherit the global default at runtime" and the trigger renders
* the label muted, so an un-overridden field reads like a placeholder.
* Mutually exclusive with defaultOption (defaultOption wins).
* Inheritance flavor of defaultOption for per-task overrides: same
* sentinel row and null semantics, but null means "inherit at runtime"
* and the trigger renders the label muted, so an un-overridden field
* reads like a placeholder. Mutually exclusive with defaultOption
* (defaultOption wins).
*/
inheritDefault?: { label: string }
/**
@ -143,22 +88,9 @@ export interface ModelSelectorProps {
*/
variant?: 'pill' | 'field'
/**
* Restrict the picker to one provider's group (catalog or live). Providers
* not configured in models.json fall back to their static models:list
* catalog so a provider mid-setup still lists models.
* Restrict the picker to one connected provider's group.
*/
providerFilter?: string
/**
* Live-fetch credentials for a provider being configured right now (typed
* but possibly unsaved). Store groups only exist for providers saved in
* models.json and the catalog fallback only covers openai/anthropic/
* google this synthesizes the scoped live group from the form's current
* inputs instead (and wins over a saved group, whose stored key may be
* stale). Requires providerFilter set to the same flavor; ignored until
* some credential is typed. useProviderModels debounces + caches, so
* keystrokes don't spray fetches.
*/
liveCredentials?: { flavor: ProviderModelsFlavor; apiKey: string; baseURL: string }
/**
* When the search text matches no rows, offer a `Use "<text>"` row that
* selects the typed id arbitrary ids for ollama / openai-compatible.
@ -171,12 +103,11 @@ export interface ModelSelectorProps {
/**
* Caller-supplied restricted list (e.g. a coding agent's own model
* options): the picker renders ONLY these rows plus the defaultOption
* sentinel no catalog groups, no live fetches. Entries are opaque
* engine ids, not provider/model pairs, so the selected ref is
* {provider: '', model: id} the id travels in .model and provider is
* meaningless. Search filters on label and id; rows whose label differs
* from their id show the id as secondary text (labels can collide, e.g.
* Claude lists both the 'opus' alias and the concrete id as "Opus").
* sentinel no catalog groups. Entries are opaque engine ids, not
* provider/model pairs, so the selected ref is {provider: '', model: id}.
* Search filters on label and id; rows whose label differs from their id
* show the id as secondary text (labels can collide, e.g. Claude lists
* both the 'opus' alias and the concrete id as "Opus").
*/
staticOptions?: Array<{ id: string; label?: string }>
/** Optional title attribute for the trigger button (header tooltips). */
@ -193,8 +124,8 @@ export interface ModelSelectorProps {
onEffortChange?: (effort: '' | ReasoningEffortLevel) => void
}
// Radio value for the defaultOption sentinel row. Never a valid model key
// (real keys always contain "provider/").
// cmdk item value for the defaultOption sentinel row. Never a valid model
// key (real keys always contain "provider/").
const DEFAULT_OPTION_KEY = '__default__'
// Un-scoped custom entries can't know their provider, so the rule is:
@ -230,7 +161,6 @@ export function ModelSelector({
inheritDefault,
variant = 'pill',
providerFilter,
liveCredentials,
allowCustom = false,
staticOptions,
triggerTitle,
@ -238,91 +168,112 @@ export function ModelSelector({
effort = '',
onEffortChange,
}: ModelSelectorProps) {
const { groups: allGroups, reasoningByKey, defaultModel, catalogByProvider } = useModels()
const { groups: allGroups, reasoningByKey, defaultModel, catalogByProvider, refresh } = useModels()
// inheritDefault is defaultOption with placeholder styling — one sentinel
// code path, not two.
const sentinel = defaultOption ?? inheritDefault
const sentinelMuted = !defaultOption && Boolean(inheritDefault)
const liveFlavor = liveCredentials?.flavor
const liveApiKey = liveCredentials?.apiKey.trim() ?? ''
const liveBaseURL = liveCredentials?.baseURL.trim() ?? ''
const groups = useMemo<ModelPickerGroup[]>(() => {
if (!providerFilter) return allGroups
// The form's typed credentials trump anything saved — same "configured"
// bar as the store (some credential present).
if (liveFlavor === providerFilter && (liveApiKey || liveBaseURL)) {
return [{ kind: 'live', flavor: liveFlavor, apiKey: liveApiKey, baseURL: liveBaseURL, savedModel: '' }]
}
const scoped = allGroups.filter((g) => g.flavor === providerFilter)
const scoped = allGroups.filter((g) => g.id === providerFilter)
if (scoped.length > 0) return scoped
const catalogModels = catalogByProvider[providerFilter] || []
return catalogModels.length > 0 ? [{ kind: 'catalog', flavor: providerFilter, models: catalogModels }] : []
}, [allGroups, providerFilter, catalogByProvider, liveFlavor, liveApiKey, liveBaseURL])
return catalogModels.length > 0
? [{ id: providerFilter, flavor: providerFilter, models: catalogModels, status: 'ok' }]
: []
}, [allGroups, providerFilter, catalogByProvider])
// Search filter for the model dropdown. Reset each time the menu opens;
// matching is a case-insensitive substring test on the model id. Live
// groups filter themselves and report whether they still have rows, so the
// parent can render the global "No models match" row.
const [modelFilter, setModelFilter] = useState('')
const modelFilterInputRef = useRef<HTMLInputElement>(null)
const [liveGroupHasRows, setLiveGroupHasRows] = useState<Record<string, boolean>>({})
const modelFilterValue = modelFilter.trim().toLowerCase()
const handleLiveGroupRows = useCallback((flavor: string, hasRows: boolean) => {
setLiveGroupHasRows((prev) => (prev[flavor] === hasRows ? prev : { ...prev, [flavor]: hasRows }))
}, [])
const [open, setOpen] = useState(false)
// cmdk's highlighted-item value, controlled: when the split view swaps the
// provider column, the previous group's items unmount and cmdk's internal
// highlight is left pointing at a value with no item — ↵ becomes a no-op.
// Driving the value ourselves re-anchors the highlight on the new group.
const [commandValue, setCommandValue] = useState('')
// Search text; case-insensitive substring test on the model id AND the
// provider name — typing "rowboat" surfaces the whole Rowboat group.
const [query, setQuery] = useState('')
const queryValue = query.trim().toLowerCase()
const groupMatchesFilter = useCallback((g: ModelPickerGroup) =>
(providerDisplayNames[g.flavor] || g.flavor).toLowerCase().includes(queryValue)
|| g.id.toLowerCase().includes(queryValue), [queryValue])
// Split view only where browsing across providers is meaningful.
const splitMode = !staticOptions && !providerFilter && !queryValue && groups.length > 1
const [activeProviderId, setActiveProviderId] = useState<string | null>(null)
const activeGroup = splitMode
? (groups.find((g) => g.id === activeProviderId) ?? groups[0])
: null
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next)
if (next) {
// Per-opening state: fresh search, provider column on the selection's
// (else the assistant's) provider — groups[0] is already the
// assistant's group by store ordering. Empty commandValue lets cmdk
// highlight the first rendered item itself.
setQuery('')
setCommandValue('')
setActiveProviderId(value?.provider ?? defaultModel?.provider ?? null)
}
}, [value, defaultModel])
// Switch the split view's provider column and re-anchor the keyboard
// highlight on the new group's first row (see commandValue above).
const switchProvider = useCallback((g: ModelPickerGroup) => {
setActiveProviderId(g.id)
setCommandValue(
g.models.length > 0
? `${g.id}/${g.models[0]}`
: g.status === 'error'
? `__retry__:${g.id}`
: sentinel ? DEFAULT_OPTION_KEY : '',
)
}, [sentinel])
// The effective default always renders even when no group carries it (the
// gateway list failed, or its provider was removed from config) — the
// picker must never be missing the model that actually runs. Live groups
// pin the default themselves, so a flavor match is enough there. A
// provider's list failed, or its provider was removed from config) — the
// picker must never be missing the model that actually runs. A
// provider-scoped picker only shows it when it belongs to that provider.
const standaloneDefault = useMemo<ModelRef | null>(() => {
if (!defaultModel) return null
if (providerFilter && defaultModel.provider !== providerFilter) return null
const covered = groups.some((g) =>
g.flavor === defaultModel.provider &&
(g.kind === 'live' || g.models.includes(defaultModel.model)))
g.id === defaultModel.provider && g.models.includes(defaultModel.model))
return covered ? null : defaultModel
}, [groups, defaultModel, providerFilter])
const standaloneVisible = standaloneDefault !== null &&
(!modelFilterValue || standaloneDefault.model.toLowerCase().includes(modelFilterValue))
(!queryValue || standaloneDefault.model.toLowerCase().includes(queryValue))
// Static mode replaces all store-driven rows with the caller's list.
const staticVisible = useMemo(() => {
if (!staticOptions) return null
if (!modelFilterValue) return staticOptions
if (!queryValue) return staticOptions
return staticOptions.filter((o) =>
(o.label ?? o.id).toLowerCase().includes(modelFilterValue) || o.id.toLowerCase().includes(modelFilterValue))
}, [staticOptions, modelFilterValue])
(o.label ?? o.id).toLowerCase().includes(queryValue) || o.id.toLowerCase().includes(queryValue))
}, [staticOptions, queryValue])
const staticLabelFor = (id: string) => staticOptions?.find((o) => o.id === id)?.label ?? id
// Nothing matches anywhere → "No models match". Live groups that haven't
// reported yet (first render after opening) count as having rows so the
// empty row never flashes.
// Nothing matches anywhere → "No models match".
const anyModelRowVisible = staticVisible
? staticVisible.length > 0
: standaloneVisible || groups.some((g) =>
g.kind === 'catalog'
? g.models.some((m) => m.toLowerCase().includes(modelFilterValue))
: liveGroupHasRows[g.flavor] !== false)
: standaloneVisible
|| groups.some((g) =>
groupMatchesFilter(g) ? g.models.length > 0
: g.models.some((m) => m.toLowerCase().includes(queryValue)))
const handleModelChange = useCallback((key: string) => {
// The cmdk value of the current selection, for check indicators.
const selectedKey = value
? (staticOptions ? value.model : `${value.provider}/${value.model}`)
: sentinel
? DEFAULT_OPTION_KEY
: (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')
const select = useCallback((ref: ModelRef | null) => {
if (lockedModel) return
if (key === DEFAULT_OPTION_KEY) {
onChange(null)
return
}
// Static keys are opaque engine ids — no provider/model split (ids may
// themselves contain slashes).
if (staticOptions) {
onChange({ provider: '', model: key })
return
}
const slash = key.indexOf('/')
if (slash <= 0 || slash === key.length - 1) return
onChange({ provider: key.slice(0, slash), model: key.slice(slash + 1) })
}, [lockedModel, onChange, staticOptions])
setOpen(false)
onChange(ref)
}, [lockedModel, onChange])
// Reasoning effort applies to the model the next message will actually use:
// the frozen model when locked, else the picker selection, else the app
@ -345,6 +296,42 @@ export function ModelSelector({
}
}, [reasoningAvailable, effort, onEffortChange])
const renderModelItem = (providerId: string, model: string, secondary?: string) => {
const key = `${providerId}/${model}`
return (
<CommandItem
key={key}
value={key}
onSelect={() => select({ provider: providerId, model })}
>
<Check className={cn('size-3.5 shrink-0', selectedKey === key ? 'opacity-100' : 'opacity-0')} />
<span className="truncate">{model}</span>
{secondary && <span className="ml-auto shrink-0 text-xs text-muted-foreground">{secondary}</span>}
</CommandItem>
)
}
const renderSentinelItem = () => sentinel && (
<CommandItem value={DEFAULT_OPTION_KEY} onSelect={() => select(null)}>
<Check className={cn('size-3.5 shrink-0', selectedKey === DEFAULT_OPTION_KEY ? 'opacity-100' : 'opacity-0')} />
<span className="truncate">{sentinel.label}</span>
</CommandItem>
)
const renderErrorItem = (g: ModelPickerGroup) => (
<CommandItem
key={`__retry__:${g.id}`}
value={`__retry__:${g.id}`}
// Retry refreshes in place — the popover stays open and the group
// re-renders when the store updates.
onSelect={() => refresh(g.id)}
className="text-xs"
>
<span className="truncate text-destructive">{g.error || 'Failed to load models'}</span>
<span className="ml-auto shrink-0 text-muted-foreground">Retry</span>
</CommandItem>
)
return (
<>
{reasoningAvailable && onEffortChange && (
@ -390,19 +377,14 @@ export function ModelSelector({
</TooltipContent>
</Tooltip>
) : (
<DropdownMenu
onOpenChange={(open) => {
// The filter is per-opening, never sticky. Focus the search
// input once the content has mounted and Radix has run its own
// open-focus (DropdownMenu.Content has no onOpenAutoFocus).
if (open) {
setModelFilter('')
setLiveGroupHasRows({})
setTimeout(() => modelFilterInputRef.current?.focus(), 0)
}
}}
>
<DropdownMenuTrigger asChild>
// modal: the settings Dialog's scroll-lock cancels wheel events over
// content portalled outside its subtree — a modal popover brings its
// own lock layer that permits scrolling within (Radix's supported
// fix for popover-inside-dialog; matches the old DropdownMenu's
// modality). Keyboard scrolling was never affected (cmdk uses
// programmatic scrollIntoView).
<Popover open={open} onOpenChange={handleOpenChange} modal>
<PopoverTrigger asChild>
{variant === 'field' ? (
// Styled after ui/select's SelectTrigger so it sits naturally
// in forms next to real Select fields.
@ -439,130 +421,154 @@ export function ModelSelector({
<ChevronDown className="h-3 w-3 shrink-0" />
</button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
</PopoverTrigger>
<PopoverContent
align={variant === 'field' ? 'start' : 'end'}
className={cn('p-0 overflow-hidden', variant === 'field' && 'min-w-[var(--radix-dropdown-menu-trigger-width)]')}
className={cn(
'p-0 overflow-hidden',
splitMode
? 'w-[480px]'
: variant === 'field'
? 'w-[var(--radix-popover-trigger-width)] min-w-[300px]'
: 'w-[320px]',
)}
>
{!staticOptions && groups.length === 0 && !standaloneDefault && !sentinel && !allowCustom ? (
<div className="p-1">
<DropdownMenuItem disabled>Connect a provider in Settings</DropdownMenuItem>
</div>
<div className="px-3 py-2 text-sm text-muted-foreground">Connect a provider in Settings</div>
) : (
<>
{/* Fixed search header lives OUTSIDE the scroll area (the
inner div below scrolls), so it's flush at the very top
and always visible without any scroll. */}
<div className="bg-popover p-1">
<input
ref={modelFilterInputRef}
value={modelFilter}
onChange={(e) => setModelFilter(e.target.value)}
onKeyDown={(e) => {
// Printable keys belong to the input, not the menu's
// typeahead; arrows and Escape stay with the menu.
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Escape') {
e.stopPropagation()
}
}}
placeholder="Search models…"
className="h-7 w-full rounded-sm border border-input bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-80 overflow-y-auto p-1 pt-0">
<DropdownMenuRadioGroup
value={
value
? (staticOptions ? value.model : `${value.provider}/${value.model}`)
: sentinel
? DEFAULT_OPTION_KEY
: (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')
}
onValueChange={handleModelChange}
>
{sentinel && (
<DropdownMenuRadioItem value={DEFAULT_OPTION_KEY}>
<span className="truncate">{sentinel.label}</span>
</DropdownMenuRadioItem>
)}
{staticVisible?.map((o) => (
<DropdownMenuRadioItem key={o.id} value={o.id}>
<span className="truncate">{o.label ?? o.id}</span>
{o.label && o.label !== o.id && (
<span className="ml-2 shrink-0 text-xs text-muted-foreground">{o.id}</span>
)}
</DropdownMenuRadioItem>
))}
{!staticOptions && standaloneDefault && standaloneVisible && (
<DropdownMenuRadioItem value={`${standaloneDefault.provider}/${standaloneDefault.model}`}>
<span className="truncate">{standaloneDefault.model}</span>
<span className="ml-2 text-xs text-muted-foreground">
{providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider}
</span>
</DropdownMenuRadioItem>
)}
{!staticOptions && groups.map((g) => {
const label = providerDisplayNames[g.flavor] || g.flavor
if (g.kind === 'live') {
// The app default leads its live group; the group's
// own saved model follows (both stay pickable through
// fetch loading/failure).
const pinned: string[] = []
if (defaultModel && defaultModel.provider === g.flavor) pinned.push(defaultModel.model)
if (g.savedModel && !pinned.includes(g.savedModel)) pinned.push(g.savedModel)
<Command
// Filtering is ours (provider-name matching, the custom-id
// escape hatch, split-mode layout) — cmdk only does keyboard
// navigation and selection over what we render, with the
// highlighted value controlled (see commandValue).
shouldFilter={false}
value={commandValue}
onValueChange={setCommandValue}
onKeyDown={(e) => {
// Split mode: ←/→ cycles the provider column (tabs
// semantics); ↑/↓ stays on the model list via cmdk.
if (!splitMode || groups.length === 0) return
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
e.preventDefault()
const index = groups.findIndex((g) => g.id === (activeGroup?.id ?? ''))
const next = e.key === 'ArrowRight'
? (index + 1) % groups.length
: (index - 1 + groups.length) % groups.length
switchProvider(groups[next])
}}
>
<CommandInput
autoFocus
value={query}
onValueChange={setQuery}
placeholder="Search models and providers…"
/>
{splitMode && activeGroup ? (
<div className="flex">
{/* Provider column — tab-like: click or ←/→. */}
<div className="w-40 shrink-0 border-r max-h-80 overflow-y-auto p-1" role="tablist" aria-label="Providers">
{groups.map((g) => (
<button
key={g.id}
type="button"
role="tab"
aria-selected={g.id === activeGroup.id}
tabIndex={-1}
onClick={() => switchProvider(g)}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm',
g.id === activeGroup.id ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50',
)}
>
<span className="min-w-0 flex-1 truncate">{providerDisplayNames[g.flavor] || g.flavor}</span>
{g.status === 'error' ? (
<span className="size-2 shrink-0 rounded-full bg-destructive" />
) : (
<span className="shrink-0 text-[10px] text-muted-foreground">{g.models.length}</span>
)}
</button>
))}
</div>
<CommandList className="max-h-80 flex-1">
<CommandGroup>
{renderSentinelItem()}
{standaloneDefault && standaloneDefault.provider === activeGroup.id &&
renderModelItem(standaloneDefault.provider, standaloneDefault.model)}
{activeGroup.models.map((m) => renderModelItem(activeGroup.id, m))}
{activeGroup.status === 'error' && renderErrorItem(activeGroup)}
{activeGroup.status === 'ok' && activeGroup.models.length === 0 && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models reported</div>
)}
</CommandGroup>
</CommandList>
</div>
) : (
<CommandList className="max-h-80">
{sentinel && !queryValue && (
<CommandGroup>{renderSentinelItem()}</CommandGroup>
)}
{staticVisible && staticVisible.length > 0 && (
<CommandGroup>
{staticVisible.map((o) => (
<CommandItem key={o.id} value={o.id} onSelect={() => select({ provider: '', model: o.id })}>
<Check className={cn('size-3.5 shrink-0', selectedKey === o.id ? 'opacity-100' : 'opacity-0')} />
<span className="truncate">{o.label ?? o.id}</span>
{o.label && o.label !== o.id && (
<span className="ml-2 shrink-0 text-xs text-muted-foreground">{o.id}</span>
)}
</CommandItem>
))}
</CommandGroup>
)}
{!staticOptions && standaloneDefault && standaloneVisible && (
<CommandGroup>
{renderModelItem(
standaloneDefault.provider,
standaloneDefault.model,
providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider,
)}
</CommandGroup>
)}
{!staticOptions && groups.map((g) => {
// A provider-name match shows the whole group.
const visibleModels = queryValue && !groupMatchesFilter(g)
? g.models.filter((m) => m.toLowerCase().includes(queryValue))
: g.models
// Error rows are status, not models: they render (with
// the header) regardless of the filter and don't count
// toward "No models match".
const showError = g.status === 'error'
if (visibleModels.length === 0 && !showError) return null
return (
<LiveProviderGroupItems
key={g.flavor}
group={g}
label={label}
pinnedModels={pinned}
filter={modelFilterValue}
onModelRowsChange={handleLiveGroupRows}
/>
<CommandGroup key={g.id} heading={providerDisplayNames[g.flavor] || g.flavor}>
{visibleModels.map((m) => renderModelItem(g.id, m))}
{showError && renderErrorItem(g)}
</CommandGroup>
)
}
const visibleModels = modelFilterValue
? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue))
: g.models
if (visibleModels.length === 0) return null
return (
<Fragment key={g.flavor}>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{label}
</DropdownMenuLabel>
{visibleModels.map((m) => {
const key = `${g.flavor}/${m}`
return (
<DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m}</span>
</DropdownMenuRadioItem>
)
})}
</Fragment>
)
})}
{modelFilterValue && !anyModelRowVisible && (
allowCustom ? (
// Escape hatch for ids the lists don't carry (local
// servers, brand-new models): select exactly what was
// typed. Scoped pickers attach it to their provider;
// un-scoped ones split "provider/model" on the first
// slash, else pair the text with the default provider.
<DropdownMenuItem
onSelect={() => onChange(parseCustomModel(modelFilter.trim(), providerFilter, defaultModel))}
>
<span className="truncate">Use &quot;{modelFilter.trim()}&quot;</span>
</DropdownMenuItem>
) : (
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models match</div>
)
)}
</DropdownMenuRadioGroup>
</div>
</>
})}
{queryValue && !anyModelRowVisible && (
allowCustom ? (
// Escape hatch for ids the lists don't carry (local
// servers, brand-new models): select exactly what was
// typed.
<CommandGroup>
<CommandItem
value="__custom__"
onSelect={() => select(parseCustomModel(query.trim(), providerFilter, defaultModel))}
>
<span className="truncate">Use &quot;{query.trim()}&quot;</span>
</CommandItem>
</CommandGroup>
) : (
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models match</div>
)
)}
</CommandList>
)}
</Command>
)}
</DropdownMenuContent>
</DropdownMenu>
</PopoverContent>
</Popover>
)}
</>
)

View file

@ -62,10 +62,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
onEscapeKeyDown={(e) => e.preventDefault()}
>
<div className="flex flex-col h-full max-h-[85vh] overflow-y-auto p-8 md:p-10">
<StepIndicator
currentStep={state.currentStep}
path={state.onboardingPath}
/>
<StepIndicator currentStep={state.currentStep} />
<AnimatePresence mode="wait">
<motion.div
key={state.currentStep}

View file

@ -1,18 +1,14 @@
import * as React from "react"
import { CheckCircle2 } from "lucide-react"
import { cn } from "@/lib/utils"
import type { Step, OnboardingPath } from "./use-onboarding-state"
import type { Step } from "./use-onboarding-state"
const ROWBOAT_STEPS = [
// Both paths share one sequence now: screen 1 handles Rowboat sign-in, and
// screen 2 ("Providers") serves everyone — first provider for key users,
// optional extras for signed-in users.
const STEPS = [
{ step: 0 as Step, label: "Welcome" },
{ step: 2 as Step, label: "Connect" },
{ step: 3 as Step, label: "Code" },
{ step: 4 as Step, label: "Done" },
]
const BYOK_STEPS = [
{ step: 0 as Step, label: "Welcome" },
{ step: 1 as Step, label: "Model" },
{ step: 1 as Step, label: "Providers" },
{ step: 2 as Step, label: "Connect" },
{ step: 3 as Step, label: "Code" },
{ step: 4 as Step, label: "Done" },
@ -20,11 +16,10 @@ const BYOK_STEPS = [
interface StepIndicatorProps {
currentStep: Step
path: OnboardingPath
}
export function StepIndicator({ currentStep, path }: StepIndicatorProps) {
const steps = path === 'byok' ? BYOK_STEPS : ROWBOAT_STEPS
export function StepIndicator({ currentStep }: StepIndicatorProps) {
const steps = STEPS
const currentIndex = steps.findIndex(s => s.step === currentStep)
return (

View file

@ -1,268 +1,37 @@
import { Loader2, CheckCircle2, ArrowLeft, X, Lightbulb } from "lucide-react"
import { motion } from "motion/react"
import { ArrowLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import {
OpenAIIcon,
AnthropicIcon,
GoogleIcon,
OllamaIcon,
OpenRouterIcon,
VercelIcon,
GenericApiIcon,
} from "../provider-icons"
import type { OnboardingState, LlmProviderFlavor } from "../use-onboarding-state"
import { ProvidersSection } from "@/components/settings/providers-section"
import { useModels } from "@/hooks/use-models"
import type { OnboardingState } from "../use-onboarding-state"
interface LlmSetupStepProps {
state: OnboardingState
}
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; color: string; icon: React.ReactNode }> = [
{ id: "openai", name: "OpenAI", description: "GPT models", color: "bg-green-500/10 text-green-600 dark:text-green-400", icon: <OpenAIIcon /> },
{ id: "anthropic", name: "Anthropic", description: "Claude models", color: "bg-orange-500/10 text-orange-600 dark:text-orange-400", icon: <AnthropicIcon /> },
{ id: "google", name: "Gemini", description: "Google AI Studio", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400", icon: <GoogleIcon /> },
{ id: "ollama", name: "Ollama", description: "Local models", color: "bg-purple-500/10 text-purple-600 dark:text-purple-400", icon: <OllamaIcon /> },
]
const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; color: string; icon: React.ReactNode }> = [
{ id: "openrouter", name: "OpenRouter", description: "Multiple models, one key", color: "bg-pink-500/10 text-pink-600 dark:text-pink-400", icon: <OpenRouterIcon /> },
{ id: "aigateway", name: "AI Gateway", description: "Vercel AI Gateway", color: "bg-sky-500/10 text-sky-600 dark:text-sky-400", icon: <VercelIcon /> },
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom endpoint", color: "bg-gray-500/10 text-gray-600 dark:text-gray-400", icon: <GenericApiIcon /> },
]
// Screen 2 of onboarding: the SAME provider surface as Settings (connected
// list + add-provider flow), reframed for first-run. Users who signed in on
// screen 1 already have a working assistant (initial selection runs at
// sign-in) and see "Add more providers"; users who skipped sign-in connect
// their first provider here. Continue gates on an assistant model existing —
// the one thing chat can't run without.
export function LlmSetupStep({ state }: LlmSetupStepProps) {
const {
llmProvider, setLlmProvider, modelsLoading, modelsError,
activeConfig, testState, setTestState, showApiKey,
showBaseURL, canTest, showMoreProviders, setShowMoreProviders,
updateProviderConfig, handleTestAndSaveLlmConfig, handleTestAndAddAnother,
connectedFlavors, handleNext, handleBack,
upsellDismissed, setUpsellDismissed, handleSwitchToRowboat,
chatgpt,
} = state
const isMoreProvider = moreProviders.some(p => p.id === llmProvider)
// "Sign in with ChatGPT" (below the OpenAI card) is an alternative to a key:
// signing in counts as a connected provider alongside any saved BYOK one,
// which is what unlocks Continue when no API key was entered.
const hasConnectedProvider = connectedFlavors.size > 0 || chatgpt.status.signedIn
// Connect-only, mirroring Settings: entering a key (or base URL) is enough
// and the model is resolved silently at save. openai-compatible is the sole
// exception with a visible Model field — its /models endpoint often doesn't
// exist, so a typed value must be able to win.
const showModelInput = llmProvider === "openai-compatible"
const renderProviderCard = (provider: typeof primaryProviders[0], index: number) => {
const isSelected = llmProvider === provider.id
return (
<motion.button
key={provider.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
onClick={() => {
setLlmProvider(provider.id)
setTestState({ status: "idle" })
}}
className={cn(
"rounded-xl border-2 p-4 text-left transition-all",
isSelected
? "border-primary bg-primary/5 shadow-sm"
: "border-transparent bg-muted/50 hover:bg-muted"
)}
>
<div className="flex items-center gap-3">
<div className={cn("size-10 rounded-lg flex items-center justify-center shrink-0", provider.color)}>
{provider.icon}
</div>
<div>
<div className="text-sm font-semibold">{provider.name}</div>
<div className="text-xs text-muted-foreground">{provider.description}</div>
</div>
{connectedFlavors.has(provider.id) && (
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400 ml-auto shrink-0" />
)}
</div>
</motion.button>
)
}
const { handleNext, handleBack } = state
const { defaultModel, isRowboatConnected } = useModels()
const hasAssistant = defaultModel !== null
return (
<div className="flex flex-col flex-1">
{/* Title */}
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
Choose your provider
{isRowboatConnected ? "Add more providers" : "Connect a model provider"}
</h2>
<p className="text-base text-muted-foreground text-center mb-6">
Select a provider and configure your API key
{isRowboatConnected
? "Rowboat is ready to use. Optionally connect your own API keys or local models — their models appear alongside your Rowboat models."
: "Connect an API key or a local model to power the Assistant."}
</p>
{/* Inline Rowboat upsell callout */}
{!upsellDismissed && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, height: 0 }}
className="rounded-xl bg-primary/5 border border-primary/20 p-4 mb-6 flex items-start gap-3"
>
<Lightbulb className="size-5 text-primary shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground">
<span className="font-medium">Tip:</span> Hosted models recommended. Locally run LLMs can struggle with Rowboat's parallel background agents. Bring your own API keys below, or sign in for instant access.
</p>
<button
onClick={handleSwitchToRowboat}
className="text-sm text-primary font-medium hover:underline mt-1 inline-block"
>
Sign in instead
</button>
</div>
<button
onClick={() => setUpsellDismissed(true)}
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<X className="size-4" />
</button>
</motion.div>
)}
{/* Provider selection */}
<div className="space-y-3 mb-4">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Provider</span>
<div className="grid gap-2 sm:grid-cols-2">
{primaryProviders.map((p, i) => renderProviderCard(p, i))}
</div>
{(showMoreProviders || isMoreProvider) ? (
<div className="grid gap-2 sm:grid-cols-2 mt-2">
{moreProviders.map((p, i) => renderProviderCard(p, i + 4))}
</div>
) : (
<button
onClick={() => setShowMoreProviders(true)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
>
More providers...
</button>
)}
</div>
{/* Separator */}
<div className="h-px bg-border my-4" />
{/* Provider configuration */}
<div className="space-y-4">
{/* Every provider resolves its model silently at save. openai-compatible
alone keeps this field, since its /models is unreliable and a typed
value must win; leaving it blank auto-selects from the fetched list. */}
{showModelInput && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Model
</label>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : (
<Input
value={activeConfig.model}
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
placeholder="Model ID (leave empty to auto-select)"
/>
)}
{modelsError && (
<div className="text-xs text-destructive">{modelsError}</div>
)}
</div>
)}
{showApiKey && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
API Key {!state.requiresApiKey && "(optional)"}
</label>
<Input
type="password"
value={activeConfig.apiKey}
onChange={(e) => updateProviderConfig(llmProvider, { apiKey: e.target.value })}
placeholder="Paste your API key"
className="font-mono"
/>
</div>
)}
{/* ChatGPT subscription OAuth sign-in shown under the OpenAI card,
mirroring Settings (settings-dialog.tsx, gated on provider ===
"openai"). Alternative to an API key: signs in via the shared
chatgpt:* path, independent of models.json (the Codex model client
consumes the token in core). */}
{llmProvider === "openai" && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
ChatGPT Subscription
</label>
{chatgpt.status.signedIn ? (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400 min-w-0">
<CheckCircle2 className="size-4 shrink-0" />
<span className="truncate">
Connected as {chatgpt.status.email ?? "your ChatGPT account"}
</span>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={chatgpt.signOut}
>
Sign Out
</Button>
</div>
) : chatgpt.isSigningIn ? (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Waiting for browser
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.cancelSignIn}>
Cancel
</Button>
</div>
) : (
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-muted-foreground">
Use your ChatGPT Plus/Pro subscription
</span>
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.signIn}>
Sign In
</Button>
</div>
)}
</div>
)}
{showBaseURL && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Base URL
</label>
<Input
value={activeConfig.baseURL}
onChange={(e) => updateProviderConfig(llmProvider, { baseURL: e.target.value })}
placeholder={
llmProvider === "ollama"
? "http://localhost:11434"
: llmProvider === "openai-compatible"
? "http://localhost:1234/v1"
: "https://ai-gateway.vercel.sh/v1"
}
className="font-mono"
/>
</div>
)}
</div>
<ProvidersSection dialogOpen variant="onboarding" />
{/* Footer */}
<div className="flex items-center justify-between mt-6 pt-4 border-t">
@ -270,44 +39,9 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
<ArrowLeft className="size-4" />
Back
</Button>
<div className="flex items-center gap-3">
{testState.status === "success" && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400"
>
<CheckCircle2 className="size-4" />
Connected
</motion.div>
)}
{testState.status === "error" && (
<span className="text-sm text-destructive max-w-[200px] truncate">
{testState.error}
</span>
)}
<Button
variant="outline"
onClick={handleTestAndAddAnother}
disabled={!canTest || testState.status === "testing"}
>
Save & add another
</Button>
<Button
onClick={canTest ? handleTestAndSaveLlmConfig : handleNext}
disabled={testState.status === "testing" || (!canTest && !hasConnectedProvider)}
className="min-w-[140px]"
>
{testState.status === "testing" ? (
<><Loader2 className="size-4 animate-spin mr-2" />Testing...</>
) : (canTest || !hasConnectedProvider) ? (
"Test & Continue"
) : (
"Continue"
)}
</Button>
</div>
<Button onClick={handleNext} disabled={!hasAssistant} className="min-w-[140px]">
Continue
</Button>
</div>
</div>
)

View file

@ -1,6 +1,5 @@
import { useState, useEffect, useCallback } from "react"
import { setGoogleCredentials } from "@/lib/google-credentials-store"
import { useChatGPT } from "@/hooks/useChatGPT"
import { toast } from "sonner"
export interface ProviderState {
@ -13,45 +12,10 @@ export type Step = 0 | 1 | 2 | 3 | 4
export type OnboardingPath = 'rowboat' | 'byok' | null
export type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
export interface LlmModelOption {
id: string
name?: string
release_date?: string
}
export function useOnboardingState(open: boolean, onComplete: (opts?: { startTour?: boolean }) => void) {
const [currentStep, setCurrentStep] = useState<Step>(0)
const [onboardingPath, setOnboardingPath] = useState<OnboardingPath>(null)
// LLM setup state
const [llmProvider, setLlmProvider] = useState<LlmProviderFlavor>("openai")
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState<string | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
})
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
status: "idle",
})
const [connectedFlavors, setConnectedFlavors] = useState<Set<LlmProviderFlavor>>(new Set())
const [showMoreProviders, setShowMoreProviders] = useState(false)
// "Sign in with ChatGPT" (subscription OAuth) is offered below the OpenAI
// card, mirroring Settings. It isn't a LlmProviderFlavor — no API key, no
// models.json entry — it signs in via the dedicated chatgpt:* IPC (same
// path Settings uses) and the Codex model client consumes the token in
// core, leaving the BYOK config machinery untouched.
const chatgpt = useChatGPT()
// OAuth provider states
const [providers, setProviders] = useState<string[]>([])
const [providersLoading, setProvidersLoading] = useState(true)
@ -72,9 +36,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
const [slackDiscovering, setSlackDiscovering] = useState(false)
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
// Inline upsell callout dismissed
const [upsellDismissed, setUpsellDismissed] = useState(false)
// Composio Gmail/Calendar sync was removed — flags are seeded false and
// never flipped. Kept here so legacy gating expressions still type-check.
const [useComposioForGoogle] = useState(false)
@ -89,27 +50,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
const updateProviderConfig = useCallback(
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
setProviderConfigs(prev => ({
...prev,
[provider]: { ...prev[provider], ...updates },
}))
setTestState({ status: "idle" })
},
[]
)
const activeConfig = providerConfigs[llmProvider]
const showApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway" || llmProvider === "openai-compatible"
const requiresApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway"
const requiresBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible"
const showBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" || llmProvider === "aigateway"
const isLocalProvider = llmProvider === "ollama" || llmProvider === "openai-compatible"
const canTest =
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
// Track connected providers for the completion step
const connectedProviders = Object.entries(providerStates)
.filter(([, state]) => state.isConnected)
@ -135,56 +75,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
loadProviders()
}, [open])
// Load LLM models catalog on open
useEffect(() => {
if (!open) return
async function loadModels() {
try {
setModelsLoading(true)
setModelsError(null)
const result = await window.ipc.invoke("models:list", null)
const catalog: Record<string, LlmModelOption[]> = {}
for (const provider of result.providers || []) {
catalog[provider.id] = provider.models || []
}
setModelsCatalog(catalog)
} catch (error) {
console.error("Failed to load models catalog:", error)
setModelsError("Failed to load models list")
setModelsCatalog({})
} finally {
setModelsLoading(false)
}
}
loadModels()
}, [open])
// Preferred default models for each provider
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
openai: "gpt-5.4",
anthropic: "claude-opus-4-8",
}
// Initialize default models from catalog
useEffect(() => {
if (Object.keys(modelsCatalog).length === 0) return
setProviderConfigs(prev => {
const next = { ...prev }
const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
for (const provider of cloudProviders) {
const models = modelsCatalog[provider]
if (models?.length && !next[provider].model) {
const preferredModel = preferredDefaults[provider]
const hasPreferred = preferredModel && models.some(m => m.id === preferredModel)
next[provider] = { ...next[provider], model: hasPreferred ? preferredModel : (models[0]?.id || "") }
}
}
return next
})
}, [modelsCatalog])
// Load Granola config
const refreshGranolaConfig = useCallback(async () => {
try {
@ -389,11 +279,7 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
// BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (code mode) → 4 (done)
const handleNext = useCallback(() => {
if (currentStep === 0) {
if (onboardingPath === 'byok') {
setCurrentStep(1)
} else {
setCurrentStep(2)
}
setCurrentStep(1)
} else if (currentStep === 1) {
setCurrentStep(2)
} else if (currentStep === 2) {
@ -408,11 +294,7 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
setCurrentStep(0)
setOnboardingPath(null)
} else if (currentStep === 2) {
if (onboardingPath === 'rowboat') {
setCurrentStep(0)
} else {
setCurrentStep(1)
}
setCurrentStep(1)
} else if (currentStep === 3) {
setCurrentStep(2)
}
@ -429,99 +311,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
onComplete({ startTour: true })
}, [onComplete])
// Test the active provider's credentials and persist its config. Returns
// whether it succeeded so callers can decide whether to advance or stay.
const testAndSaveActiveProvider = useCallback(async (): Promise<boolean> => {
if (!canTest) return false
setTestState({ status: "testing" })
try {
const apiKey = activeConfig.apiKey.trim() || undefined
const baseURL = activeConfig.baseURL.trim() || undefined
const provider = { flavor: llmProvider, apiKey, baseURL }
// Fetch the provider's models from the key — this both validates the
// credentials and gives us the list to populate the chat picker.
const result = await window.ipc.invoke("models:listForProvider", { provider })
if (!result.success) {
setTestState({ status: "error", error: result.error })
toast.error(result.error || "Connection test failed")
return false
}
const catalog: string[] = result.models ?? []
const typed = activeConfig.model.trim()
// Hosted providers hide the model field (it holds an auto-seeded
// default), so only treat it as user intent where the field is shown —
// mirrors showModelInput in llm-setup-step.
const hostedProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
const modelInputShown = !hostedProviders.includes(llmProvider)
if (modelInputShown && typed && llmProvider === "ollama" && catalog.length > 0 && !catalog.includes(typed)) {
// Ollama's tag list is authoritative: an unlisted model isn't pulled,
// so saving it would break chat at runtime with no obvious cause.
const error = `Model '${typed}' is not available on this Ollama server. Pull it first (ollama pull ${typed}) or pick one of: ${catalog.slice(0, 5).join(", ")}${catalog.length > 5 ? ", …" : ""}`
setTestState({ status: "error", error })
toast.error(error)
return false
}
// Resolve the model silently — same precedence as the settings dialog's
// resolvedModel, so onboarding no longer asks users to type one.
// openai-compatible keeps a visible Model field (its /models is
// unreliable), so a typed value there wins; otherwise the typed/saved
// model if the fetched list still has it, else the flavor's preferred
// default, else the first fetched id. An empty list falls back to
// whatever was typed/seeded.
let model: string
if (llmProvider === "openai-compatible" && typed) {
model = typed
} else if (catalog.length > 0) {
const preferred = preferredDefaults[llmProvider]
model = (typed && catalog.includes(typed))
? typed
: ((preferred && catalog.includes(preferred)) ? preferred : catalog[0])
} else {
model = typed
}
// `models` is the user's curated assistant-model list (shown in Settings),
// NOT the full provider catalog. Onboarding seeds it with just the selected
// model; users add more from Settings. Persisting the whole catalog here
// rendered every model as a separate assistant-model row.
await window.ipc.invoke("models:saveConfig", { provider, model, models: model ? [model] : [] })
window.dispatchEvent(new Event('models-config-changed'))
setTestState({ status: "success" })
setConnectedFlavors(prev => new Set(prev).add(llmProvider))
return true
} catch (error) {
console.error("Connection test failed:", error)
setTestState({ status: "error", error: "Connection test failed" })
toast.error("Connection test failed")
return false
}
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, canTest, llmProvider])
// Save the active provider and advance to the next step.
const handleTestAndSaveLlmConfig = useCallback(async () => {
const ok = await testAndSaveActiveProvider()
if (ok) handleNext()
}, [testAndSaveActiveProvider, handleNext])
// Save the active provider but stay on the step. Switch to the next provider the
// user hasn't connected yet so the form is fresh and the buttons re-enable once
// they enter that key. (Clearing the current field instead left the buttons
// disabled on an empty form with no clear next step.)
const handleTestAndAddAnother = useCallback(async () => {
const ok = await testAndSaveActiveProvider()
if (!ok) return
// setConnectedFlavors is async, so include the just-saved provider here.
const connectedNow = new Set(connectedFlavors).add(llmProvider)
const order: LlmProviderFlavor[] = ["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible"]
const next = order.find(p => !connectedNow.has(p))
if (next) setLlmProvider(next)
setTestState({ status: "idle" })
}, [testAndSaveActiveProvider, connectedFlavors, llmProvider])
// Check connection status for all providers
const refreshAllStatuses = useCallback(async () => {
refreshGranolaConfig()
@ -677,12 +466,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
startConnect('google', { clientId, clientSecret })
}, [startConnect])
// Switch to rowboat path from BYOK inline callout
const handleSwitchToRowboat = useCallback(() => {
setOnboardingPath('rowboat')
setCurrentStep(0)
}, [])
return {
// Step state
currentStep,
@ -690,32 +473,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
onboardingPath,
setOnboardingPath,
// LLM state
llmProvider,
setLlmProvider,
modelsCatalog,
modelsLoading,
modelsError,
providerConfigs,
activeConfig,
testState,
setTestState,
showApiKey,
requiresApiKey,
requiresBaseURL,
showBaseURL,
isLocalProvider,
canTest,
connectedFlavors,
showMoreProviders,
setShowMoreProviders,
updateProviderConfig,
handleTestAndSaveLlmConfig,
handleTestAndAddAnother,
// ChatGPT subscription sign-in (shown below the OpenAI card)
chatgpt,
// OAuth state
providers,
providersLoading,
@ -746,10 +503,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
handleSlackSaveWorkspaces,
handleSlackDisable,
// Upsell
upsellDismissed,
setUpsellDismissed,
// Composio/Gmail state
useComposioForGoogle,
gmailConnected,
@ -773,7 +526,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
handleBack,
handleComplete,
handleCompleteWithTour,
handleSwitchToRowboat,
}
}

View file

@ -24,7 +24,7 @@ import { cn } from "@/lib/utils"
import * as analytics from "@/lib/analytics"
import { useTheme } from "@/contexts/theme-context"
import { toast } from "sonner"
import { AnthropicIcon, DiscordIcon, GenericApiIcon, GitHubIcon, GoogleIcon, OllamaIcon, OpenAIIcon, OpenRouterIcon, VercelIcon } from "@/components/onboarding/provider-icons"
import { AnthropicIcon, DiscordIcon, GitHubIcon, OpenAIIcon } from "@/components/onboarding/provider-icons"
import { AccountSettings } from "@/components/settings/account-settings"
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
import { MobileChannelsSettings } from "@/components/settings/mobile-channels-settings"
@ -32,9 +32,8 @@ import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
import { DEFAULT_TURN_LIMITS_SETTINGS } from "@x/shared/src/turn-limits.js"
import type { ipc as ipcShared } from "@x/shared"
import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
import { useProviderModels } from "@/hooks/use-provider-models"
import { useChatGPT } from "@/hooks/useChatGPT"
import { ModelSelector, type ModelRef } from "@/components/model-selector"
import { ModelSelectionSection } from "@/components/settings/model-selection-section"
import { ProvidersSection } from "@/components/settings/providers-section"
import { useModels } from "@/hooks/use-models"
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "advanced" | "help"
@ -483,786 +482,7 @@ function AppearanceSettings() {
)
}
// --- Model Settings UI ---
type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }> = [
{ id: "openai", name: "OpenAI", description: "GPT models", icon: OpenAIIcon },
{ id: "anthropic", name: "Anthropic", description: "Claude models", icon: AnthropicIcon },
{ id: "google", name: "Gemini", description: "Google AI Studio", icon: GoogleIcon },
{ id: "ollama", name: "Ollama (Local)", description: "Run models locally", icon: OllamaIcon },
]
const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }> = [
{ id: "openrouter", name: "OpenRouter", description: "Multiple models, one key", icon: OpenRouterIcon },
{ id: "aigateway", name: "AI Gateway (Vercel)", description: "Vercel's AI Gateway", icon: VercelIcon },
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom OpenAI-compatible API", icon: GenericApiIcon },
]
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
openai: "gpt-5.4",
anthropic: "claude-opus-4-8",
}
const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
ollama: "http://localhost:11434",
"openai-compatible": "http://localhost:1234/v1",
aigateway: "https://ai-gateway.vercel.sh/v1",
}
type ProviderModelConfig = {
apiKey: string
baseURL: string
models: string[]
knowledgeGraphModel: string
meetingNotesModel: string
liveNoteAgentModel: string
autoPermissionDecisionModel: string
}
function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: boolean; rowboatConnected?: boolean }) {
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
// Flavors present in the saved providers map — drives each card's
// "Connected" indicator, independent of which card is active.
const [savedProviders, setSavedProviders] = useState<Set<LlmProviderFlavor>>(new Set())
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
aigateway: { apiKey: "", baseURL: "https://ai-gateway.vercel.sh/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
})
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" })
const [configLoading, setConfigLoading] = useState(true)
const [showMoreProviders, setShowMoreProviders] = useState(false)
// "Defer background tasks while a chat is running" — a top-level
// models.json flag. deferExplicit tracks whether the user (or the Ollama
// auto-enable) has ever set it, so we only auto-enable once.
const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false)
const [deferExplicit, setDeferExplicit] = useState(false)
const activeConfig = providerConfigs[provider]
// Live per-key model list for the active provider — drives the primary
// model area. The per-function fields below still use the static catalog.
const providerModels = useProviderModels({
flavor: provider,
apiKey: activeConfig.apiKey,
baseURL: activeConfig.baseURL,
})
// "Sign in with ChatGPT" subscription state — only rendered on the OpenAI
// card; independent of the API-key providerConfigs state above.
const chatgpt = useChatGPT()
const showApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" || provider === "openai-compatible"
const requiresApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway"
const showBaseURL = provider === "ollama" || provider === "openai-compatible" || provider === "aigateway"
const requiresBaseURL = provider === "ollama" || provider === "openai-compatible"
const isLocalProvider = provider === "ollama" || provider === "openai-compatible"
const isMoreProvider = moreProviders.some(p => p.id === provider)
const primaryModel = activeConfig.models[0] || ""
// What "Auto" resolves to right now: the flavor's preferred default when
// the fetched list carries it, else the first fetched id. Empty while the
// live list hasn't settled — the sentinel label and handleTestAndSave's
// on-demand fetch cover that window.
const autoResolvePreview = (() => {
if (providerModels.status !== "loaded" || providerModels.models.length === 0) return ""
const preferred = preferredDefaults[provider]
return preferred && providerModels.models.includes(preferred) ? preferred : providerModels.models[0]
})()
// The model chats run on. An explicit pick (the Assistant model field,
// including its typed-id escape hatch and the offline manual inputs)
// lives in models[0] and always wins — connecting must never silently
// swap a model the user chose; models:test reports honestly if it's bad.
// Empty ("Auto") keeps the silent resolve this card has always done.
const resolvedModel = primaryModel.trim() || autoResolvePreview
// Gate Connect on credentials only, NOT on resolvedModel — that derives
// from the live fetch, which hasn't settled the instant a key is pasted, so
// gating on it made the first click a no-op (the model is resolved, fetching
// on demand if needed, inside handleTestAndSave).
const canTest =
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
const updateConfig = useCallback(
(prov: LlmProviderFlavor, updates: Partial<ProviderModelConfig>) => {
setProviderConfigs(prev => ({
...prev,
[prov]: { ...prev[prov], ...updates },
}))
setTestState({ status: "idle" })
},
[]
)
// All primary-model writes go through here: the old `models: [value]` form
// silently dropped every saved model after the first.
const setPrimaryModel = useCallback((prov: LlmProviderFlavor, value: string) => {
setProviderConfigs(prev => {
const existing = prev[prov].models
return {
...prev,
[prov]: { ...prev[prov], models: [value, ...existing.slice(1).filter(m => m && m !== value)] },
}
})
setTestState({ status: "idle" })
}, [])
// Load current config from file
useEffect(() => {
if (!dialogOpen) return
const asString = (v: unknown): string => (typeof v === "string" ? v : "")
async function loadCurrentConfig() {
try {
setConfigLoading(true)
const result = await window.ipc.invoke("workspace:readFile", {
path: "config/models.json",
})
const parsed = JSON.parse(result.data)
setDeferBackgroundTasks(parsed?.deferBackgroundTasks === true)
setDeferExplicit(typeof parsed?.deferBackgroundTasks === "boolean")
const knownFlavors = new Set<string>([...primaryProviders, ...moreProviders].map(p => p.id))
setSavedProviders(new Set(
Object.keys(parsed?.providers ?? {}).filter((k): k is LlmProviderFlavor => knownFlavors.has(k))
))
if (parsed?.provider?.flavor && parsed?.model) {
const flavor = parsed.provider.flavor as LlmProviderFlavor
setProvider(flavor)
setDefaultProvider(flavor)
setProviderConfigs(prev => {
const next = { ...prev };
// Hydrate all saved providers from the providers map
if (parsed.providers) {
for (const [key, entry] of Object.entries(parsed.providers)) {
if (key in next) {
const e = entry as any;
const savedModels: string[] = Array.isArray(e.models) && e.models.length > 0
? e.models
: e.model ? [e.model] : [""];
next[key as LlmProviderFlavor] = {
apiKey: e.apiKey || "",
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
models: savedModels,
knowledgeGraphModel: asString(e.knowledgeGraphModel),
meetingNotesModel: asString(e.meetingNotesModel),
liveNoteAgentModel: asString(e.liveNoteAgentModel),
autoPermissionDecisionModel: asString(e.autoPermissionDecisionModel),
};
}
}
}
// Active provider takes precedence from top-level config,
// but only if it exists in the providers map (wasn't deleted)
if (parsed.providers?.[flavor]) {
const existingModels = next[flavor].models;
const activeModels = existingModels[0] === parsed.model
? existingModels
: [parsed.model, ...existingModels.filter((m: string) => m && m !== parsed.model)];
next[flavor] = {
apiKey: parsed.provider.apiKey || "",
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
models: activeModels.length > 0 ? activeModels : [""],
knowledgeGraphModel: asString(parsed.knowledgeGraphModel),
meetingNotesModel: asString(parsed.meetingNotesModel),
liveNoteAgentModel: asString(parsed.liveNoteAgentModel),
autoPermissionDecisionModel: asString(parsed.autoPermissionDecisionModel),
};
}
return next;
})
}
} catch {
// No existing config or parse error - use defaults
} finally {
setConfigLoading(false)
}
}
loadCurrentConfig()
}, [dialogOpen])
const handleDeferToggle = useCallback(async (value: boolean) => {
setDeferBackgroundTasks(value)
setDeferExplicit(true)
try {
await window.ipc.invoke("models:updateConfig", { deferBackgroundTasks: value })
window.dispatchEvent(new Event("models-config-changed"))
} catch {
toast.error("Failed to save setting")
}
}, [])
const handleTestAndSave = useCallback(async () => {
if (!canTest) return
setTestState({ status: "testing" })
try {
// Normally providerModels has loaded by click time and resolvedModel is
// set. But a Connect click right after pasting a key can beat the
// debounced key-change fetch — so when nothing is resolved yet, fetch
// the list on demand (one fetch, then save) instead of forcing a second
// click. Same silent precedence as the Auto path (this branch can only
// run when no explicit model is set — an explicit pick IS resolved).
let model = resolvedModel
if (!model) {
const listRes = await window.ipc.invoke("models:listForProvider", {
provider: {
flavor: provider,
apiKey: activeConfig.apiKey.trim() || undefined,
baseURL: activeConfig.baseURL.trim() || undefined,
},
})
if (listRes.success && listRes.models && listRes.models.length > 0) {
const preferred = preferredDefaults[provider]
model = preferred && listRes.models.includes(preferred) ? preferred : listRes.models[0]
}
}
if (!model) {
setTestState({ status: "error", error: "Enter a model to connect" })
toast.error("Enter a model to connect")
return
}
// The silently resolved model takes the primary slot; the rest of the
// saved list is preserved (same semantics setPrimaryModel had).
const existing = activeConfig.models.map(m => m.trim())
const allModels = [model, ...existing.slice(1).filter(m => m && m !== model)]
const providerConfig = {
provider: {
flavor: provider,
apiKey: activeConfig.apiKey.trim() || undefined,
baseURL: activeConfig.baseURL.trim() || undefined,
},
model: allModels[0] || "",
models: allModels,
...(rowboatConnected ? {} : {
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined,
}),
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
await window.ipc.invoke("models:saveConfig", providerConfig)
setDefaultProvider(provider)
setProviderConfigs(prev => ({
...prev,
[provider]: { ...prev[provider], models: allModels },
}))
setSavedProviders(prev => new Set(prev).add(provider))
setTestState({ status: "success" })
window.dispatchEvent(new Event('models-config-changed'))
// Local models compete with background agents for the same hardware:
// when the user connects Ollama and has never touched the defer
// flag, enable it for them (they can switch it off below).
if (provider === "ollama" && !deferExplicit && !deferBackgroundTasks) {
void handleDeferToggle(true)
}
// Capability probe caveats (local models): saved, but the user should
// know when the model can't do tools or has a too-small context.
const warnings: string[] = result.warnings ?? []
if (warnings.length > 0) {
for (const warning of warnings) {
toast.warning(warning, { duration: 12000 })
}
toast.success("Model configuration saved (with warnings)")
} else {
toast.success("Model configuration saved")
}
} else {
setTestState({ status: "error", error: result.error })
toast.error(result.error || "Connection test failed")
}
} catch {
setTestState({ status: "error", error: "Connection test failed" })
toast.error("Connection test failed")
}
}, [canTest, resolvedModel, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle])
const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => {
const config = providerConfigs[prov]
const allModels = config.models.map(m => m.trim()).filter(Boolean)
if (!allModels[0]) return
try {
await window.ipc.invoke("models:saveConfig", {
provider: {
flavor: prov,
apiKey: config.apiKey.trim() || undefined,
baseURL: config.baseURL.trim() || undefined,
},
model: allModels[0],
models: allModels,
...(rowboatConnected ? {} : {
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
}),
})
setDefaultProvider(prov)
window.dispatchEvent(new Event('models-config-changed'))
toast.success("Default provider updated")
} catch {
toast.error("Failed to set default provider")
}
}, [providerConfigs, rowboatConnected])
const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => {
const isDefaultProv = defaultProvider === prov
try {
const result = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
let parsed = JSON.parse(result.data)
// Disconnecting the default provider: silently promote another
// connected provider first — the connect-only UI has no usable
// set-as-default step to send the user to. Prefer the provider the
// user's explicit defaultSelection points at; promotion goes through
// the same models:saveConfig path Set-as-default uses, so the repo
// writes a valid top-level provider/model.
let promoted: LlmProviderFlavor | null = null
if (isDefaultProv) {
const selProvider = parsed?.defaultSelection?.provider
const candidates = Object.keys(parsed?.providers ?? {})
.filter((k): k is LlmProviderFlavor => k !== prov && k in providerConfigs)
.sort((a, b) => (a === selProvider ? -1 : b === selProvider ? 1 : 0))
for (const candidate of candidates) {
const config = providerConfigs[candidate]
const allModels = config.models.map(m => m.trim()).filter(Boolean)
// Same silent precedence as connect, minus the live list we don't
// have here: the provider's saved model, else its preferred default.
const model = allModels[0] || preferredDefaults[candidate] || ""
if (!model) continue
await window.ipc.invoke("models:saveConfig", {
provider: {
flavor: candidate,
apiKey: config.apiKey.trim() || undefined,
baseURL: config.baseURL.trim() || undefined,
},
model,
models: allModels.length > 0 ? allModels : [model],
...(rowboatConnected ? {} : {
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
}),
})
promoted = candidate
break
}
if (promoted) {
// saveConfig rewrote top-level and the providers map — re-read so
// the deletion write below doesn't clobber the promotion.
const fresh = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
parsed = JSON.parse(fresh.data)
}
}
if (parsed?.providers?.[prov]) {
delete parsed.providers[prov]
}
// A defaultSelection pointing at the removed provider is dangling —
// drop it so llm:getDefaultModel falls back cleanly.
if (parsed?.defaultSelection?.provider === prov) {
delete parsed.defaultSelection
}
// If the deleted provider is the current top-level active one,
// switch top-level config to the current default provider
if (parsed?.provider?.flavor === prov && defaultProvider && defaultProvider !== prov) {
const defConfig = providerConfigs[defaultProvider]
const defModels = defConfig.models.map(m => m.trim()).filter(Boolean)
parsed.provider = {
flavor: defaultProvider,
apiKey: defConfig.apiKey.trim() || undefined,
baseURL: defConfig.baseURL.trim() || undefined,
}
parsed.model = defModels[0] || ""
parsed.models = defModels
if (!rowboatConnected) {
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined
}
} else if (parsed?.provider?.flavor === prov) {
// Removing the last connected provider: drop the top-level pair
// entirely. The schema requires it, so core's readConfig() treats
// the file as "no config" — signed-in falls back to the curated
// gateway default and signed-out llm:getDefaultModel rejects, which
// the composer already handles (it shows the connect hint).
delete parsed.provider
delete parsed.model
delete parsed.models
delete parsed.knowledgeGraphModel
delete parsed.meetingNotesModel
delete parsed.liveNoteAgentModel
delete parsed.autoPermissionDecisionModel
// With no BYOK providers left, any non-gateway selection is dangling.
if (parsed?.defaultSelection && parsed.defaultSelection.provider !== "rowboat"
&& Object.keys(parsed?.providers ?? {}).length === 0) {
delete parsed.defaultSelection
}
}
await window.ipc.invoke("workspace:writeFile", {
path: "config/models.json",
data: JSON.stringify(parsed, null, 2),
})
setProviderConfigs(prev => ({
...prev,
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
}))
setSavedProviders(prev => {
const next = new Set(prev)
next.delete(prov)
return next
})
if (isDefaultProv) setDefaultProvider(promoted)
setTestState({ status: "idle" })
window.dispatchEvent(new Event('models-config-changed'))
if (promoted) {
const promotedName = [...primaryProviders, ...moreProviders].find(p => p.id === promoted)?.name || promoted
toast.success(`Disconnected · ${promotedName} is now the default`)
} else {
toast.success("Provider configuration removed")
}
} catch {
toast.error("Failed to remove provider")
}
}, [defaultProvider, providerConfigs, rowboatConnected])
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }) => {
const isDefault = defaultProvider === p.id
const isSelected = provider === p.id
const isConnected = savedProviders.has(p.id)
const hasModel = providerConfigs[p.id].models[0]?.trim().length > 0
return (
<button
key={p.id}
onClick={() => {
setProvider(p.id)
setTestState({ status: "idle" })
}}
className={cn(
"rounded-md border px-3 py-2.5 text-left transition-colors relative",
isSelected
? "border-primary bg-primary/5"
: "border-border hover:bg-accent"
)}
>
<div className="flex items-center gap-2">
<p.icon className="size-4 shrink-0" />
<span className="text-sm font-medium">{p.name}</span>
{isDefault && !rowboatConnected && (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary">
Default
</span>
)}
{isConnected && (
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
Connected
</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div>
{!isDefault && hasModel && isSelected && (
<div className="mt-1.5 flex items-center gap-3">
{!rowboatConnected && (
<span
role="button"
onClick={(e) => {
e.stopPropagation()
handleSetDefault(p.id)
}}
className="inline-flex text-[11px] text-muted-foreground hover:text-primary transition-colors cursor-pointer"
>
Set as default
</span>
)}
<span
role="button"
onClick={(e) => {
e.stopPropagation()
handleDeleteProvider(p.id)
}}
className="inline-flex text-[11px] text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
>
Remove
</span>
</div>
)}
</button>
)
}
if (configLoading) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin mr-2" />
Loading...
</div>
)
}
return (
<div className="space-y-4">
{/* Provider selection */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Provider</span>
<div className="grid gap-2 grid-cols-2">
{primaryProviders.map(renderProviderCard)}
</div>
{(showMoreProviders || isMoreProvider) ? (
<div className="grid gap-2 grid-cols-2 mt-2">
{moreProviders.map(renderProviderCard)}
</div>
) : (
<button
onClick={() => setShowMoreProviders(true)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
>
More providers...
</button>
)}
</div>
{/* API Key — key-first: the model list is fetched from it */}
{showApiKey && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{provider === "openai-compatible" ? "API Key (optional)" : "API Key"}
</span>
<Input
type="password"
value={activeConfig.apiKey}
onChange={(e) => updateConfig(provider, { apiKey: e.target.value })}
onBlur={() => providerModels.refetch()}
placeholder="Paste your API key"
/>
</div>
)}
{/* ChatGPT subscription OAuth sign-in, independent of the API key and
of models.json (the Codex model client consumes the token via core) */}
{provider === "openai" && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
ChatGPT Subscription
</span>
{chatgpt.status.signedIn ? (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-sm text-green-600 min-w-0">
<CheckCircle2 className="size-4 shrink-0" />
<span className="truncate">
Connected as {chatgpt.status.email ?? "your ChatGPT account"}
</span>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={chatgpt.signOut}
>
Sign Out
</Button>
</div>
) : chatgpt.isSigningIn ? (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Waiting for browser
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.cancelSignIn}>
Cancel
</Button>
</div>
) : (
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-muted-foreground">
Use your ChatGPT Plus/Pro subscription
</span>
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.signIn}>
Sign In
</Button>
</div>
)}
</div>
)}
{/* Base URL */}
{showBaseURL && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Base URL</span>
<Input
value={activeConfig.baseURL}
onChange={(e) => updateConfig(provider, { baseURL: e.target.value })}
onBlur={() => providerModels.refetch()}
placeholder={
provider === "ollama"
? "http://localhost:11434"
: provider === "openai-compatible"
? "http://localhost:1234/v1"
: "https://ai-gateway.vercel.sh/v1"
}
/>
</div>
)}
{/* Connection status — the model itself is resolved silently on save */}
<div className="space-y-2">
{providerModels.status === "idle" ? (
<div className="text-sm text-muted-foreground">
{isLocalProvider
? "Enter your base URL to connect"
: "Enter your API key to connect"}
</div>
) : providerModels.status === "loading" ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Checking connection
</div>
) : providerModels.status === "error" ? (
<div className="space-y-2">
<div className="text-xs text-destructive break-words">
{providerModels.error || "Connection check failed"}
</div>
<Button variant="outline" size="sm" onClick={() => providerModels.refetch()}>
Retry
</Button>
{provider !== "openai-compatible" && (
<Input
value={primaryModel}
onChange={(e) => setPrimaryModel(provider, e.target.value)}
placeholder="Enter a model to connect anyway"
/>
)}
</div>
) : providerModels.models.length === 0 && provider !== "openai-compatible" ? (
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
Connected, but the provider reported no models enter one manually
</div>
<Input
value={primaryModel}
onChange={(e) => setPrimaryModel(provider, e.target.value)}
placeholder="Enter model"
/>
</div>
) : (
<div className="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" />
Connected · {providerModels.models.length} model{providerModels.models.length === 1 ? "" : "s"} available
</div>
)}
{savedProviders.has(provider) && (
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => handleDeleteProvider(provider)}
>
Disconnect
</Button>
)}
</div>
{/* The model chats run on the anchor the category fields' "Same as
assistant" points at. Auto keeps the silent resolve; the picker
live-fetches with the card's CURRENT inputs (typed, maybe unsaved),
and allowCustom subsumes the old openai-compatible free-text field
(its /models often doesn't exist type the id in the search).
Explicit picks land in models[0] via setPrimaryModel (preserving
models[1..]); the offline manual inputs above write the same slot. */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{rowboatConnected ? "Model" : "Assistant model"}
</span>
<ModelSelector
variant="field"
providerFilter={provider}
liveCredentials={{ flavor: provider, apiKey: activeConfig.apiKey, baseURL: activeConfig.baseURL }}
allowCustom
defaultOption={{ label: autoResolvePreview ? `Auto (currently ${autoResolvePreview})` : "Auto (recommended)" }}
value={primaryModel.trim() ? { provider, model: primaryModel.trim() } : null}
onChange={(ref) => setPrimaryModel(provider, ref ? ref.model : "")}
/>
</div>
{/* Per-function model overrides. Persisted as bare model-id strings
inside providers[flavor] ('' = "Same as assistant"), so the
ModelRef picker value is adapted at this boundary: string
{provider, model} with the active card's flavor. allowCustom keeps
arbitrary ids typeable (local servers, unlisted models). */}
<div className="grid grid-cols-2 gap-3">
{!rowboatConnected && (<>
{(
[
{ label: "Knowledge graph model", field: "knowledgeGraphModel" },
{ label: "Meeting notes model", field: "meetingNotesModel" },
{ label: "Track block model", field: "liveNoteAgentModel" },
{ label: "Auto-permission model", field: "autoPermissionDecisionModel" },
] as const
).map(({ label, field }) => (
<div key={field} className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
<ModelSelector
variant="field"
providerFilter={provider}
allowCustom
defaultOption={{ label: "Same as assistant" }}
value={activeConfig[field] ? { provider, model: activeConfig[field] } : null}
onChange={(ref) => updateConfig(provider, { [field]: ref ? ref.model : "" })}
/>
</div>
))}
</>)}
</div>
{/* Test status */}
{testState.status === "error" && (
<div className="text-sm text-destructive">
{testState.error || "Connection test failed"}
</div>
)}
{testState.status === "success" && (
<div className="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" />
Connected and saved
</div>
)}
{/* Defer background tasks while chatting */}
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div className="min-w-0">
<div className="text-sm font-medium">Defer background tasks while chatting</div>
<div className="text-xs text-muted-foreground mt-0.5">
Background agents (knowledge sync, live notes, tasks) wait until your chat finishes. Recommended for local models.
</div>
</div>
<Switch checked={deferBackgroundTasks} onCheckedChange={handleDeferToggle} />
</div>
{/* Test & Save button */}
<Button
onClick={handleTestAndSave}
disabled={!canTest || testState.status === "testing"}
className="w-full"
>
{testState.status === "testing" ? (
<><Loader2 className="size-4 animate-spin mr-2" />Connecting...</>
) : (
"Connect"
)}
</Button>
</div>
)
}
// --- Tools Library Settings ---
interface ToolkitInfo {
slug: string
@ -1581,128 +801,6 @@ function ToolsLibrarySettings({ dialogOpen, rowboatConnected }: { dialogOpen: bo
)
}
// --- Rowboat Model Settings (when signed in via Rowboat) ---
//
// Hybrid mode: every picker lists the gateway catalog PLUS any BYOK
// providers configured below (ModelSelector renders the shared store's
// groups, live-fetching list-less providers inside the open dropdown).
// Saved via models:updateConfig as {provider, model} refs, so a signed-in
// user can e.g. keep the gateway assistant while running background agents
// on a local Ollama model. Selections stay local until Save — unlike the
// composer, picking here must not write anything by itself.
function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [selectedDefault, setSelectedDefault] = useState<ModelRef | null>(null)
const [selectedKg, setSelectedKg] = useState<ModelRef | null>(null)
const [selectedLiveNote, setSelectedLiveNote] = useState<ModelRef | null>(null)
const [selectedAutoPermission, setSelectedAutoPermission] = useState<ModelRef | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!dialogOpen) return
async function load() {
setLoading(true)
try {
let parsed: Record<string, unknown> = {}
try {
const configResult = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
parsed = JSON.parse(configResult.data)
} catch { /* no config yet */ }
// Current selections. Legacy string overrides pair with the BYOK
// top-level flavor (mirrors core/models/defaults.ts).
const legacyFlavor = (parsed.provider as Record<string, unknown> | undefined)?.flavor
const toRef = (value: unknown): ModelRef | null => {
if (!value) return null
if (typeof value === "string") {
return typeof legacyFlavor === "string" ? { provider: legacyFlavor, model: value } : null
}
const ref = value as { provider?: unknown; model?: unknown }
return typeof ref.provider === "string" && typeof ref.model === "string"
? { provider: ref.provider, model: ref.model }
: null
}
setSelectedDefault(toRef(parsed.defaultSelection))
setSelectedKg(toRef(parsed.knowledgeGraphModel))
setSelectedLiveNote(toRef(parsed.liveNoteAgentModel))
setSelectedAutoPermission(toRef(parsed.autoPermissionDecisionModel))
} catch {
toast.error("Failed to load models")
} finally {
setLoading(false)
}
}
load()
}, [dialogOpen])
const handleSave = useCallback(async () => {
setSaving(true)
try {
await window.ipc.invoke("models:updateConfig", {
defaultSelection: selectedDefault,
knowledgeGraphModel: selectedKg,
liveNoteAgentModel: selectedLiveNote,
autoPermissionDecisionModel: selectedAutoPermission,
})
window.dispatchEvent(new Event("models-config-changed"))
toast.success("Model configuration saved")
} catch {
toast.error("Failed to save model configuration")
} finally {
setSaving(false)
}
}, [selectedDefault, selectedKg, selectedLiveNote, selectedAutoPermission])
const renderModelField = (
label: string,
value: ModelRef | null,
onChange: (v: ModelRef | null) => void,
) => (
<div className="space-y-2">
<label className="text-sm font-medium">{label}</label>
<ModelSelector
variant="field"
value={value}
onChange={onChange}
defaultOption={{ label: "Rowboat default" }}
/>
</div>
)
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
)
}
return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
Select the models Rowboat uses. Rowboat models are provided through your account; models from your own providers route through your keys or local runtimes.
</p>
{renderModelField("Assistant model", selectedDefault, setSelectedDefault)}
{renderModelField("Knowledge graph model", selectedKg, setSelectedKg)}
{renderModelField("Background agents model", selectedLiveNote, setSelectedLiveNote)}
{renderModelField("Permission checks model", selectedAutoPermission, setSelectedAutoPermission)}
{/* Save */}
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<><Loader2 className="size-4 animate-spin mr-2" />Saving...</>
) : (
"Save"
)}
</Button>
</div>
)
}
// --- Note Tagging Settings ---
interface TagDef {
@ -2795,8 +1893,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
<div className="px-6 pb-4 pt-5">
<h3 className="text-lg font-semibold tracking-tight">{activeTabConfig.label}</h3>
<p className="text-sm text-muted-foreground mt-1">
{activeTab === "models" && rowboatConnected
? "Select your default models"
{activeTab === "models"
? "Choose the models Rowboat uses for chat and background work."
: activeTabConfig.description}
</p>
</div>
@ -2823,21 +1921,22 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
) : activeTab === "mobile" ? (
<MobileChannelsSettings dialogOpen={open} />
) : activeTab === "models" ? (
rowboatConnected
? (
<div className="space-y-8">
<RowboatModelSettings dialogOpen={open} />
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold">Your own providers</h4>
<p className="text-xs text-muted-foreground">
Connect your own API keys or local runtimes (Ollama, LM Studio). Their models appear in the model pickers above and alongside your Rowboat models, and are billed to you directly.
</p>
<ModelSettings dialogOpen={open} rowboatConnected />
</div>
</div>
)
: <ModelSettings dialogOpen={open} />
// ONE model-selection surface for signed-in and BYOK alike:
// the Assistant model + per-task overrides, then provider
// (credential) management below.
<div className="space-y-8">
<ModelSelectionSection dialogOpen={open} />
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold">{rowboatConnected ? "Your own providers" : "Providers"}</h4>
<p className="text-xs text-muted-foreground">
{rowboatConnected
? "Connect your own API keys or local runtimes (Ollama, LM Studio). Their models appear in every picker alongside your Rowboat models, and are billed to you directly."
: "Connect API keys or local runtimes (Ollama, LM Studio). Every connected provider's models appear in the pickers above."}
</p>
<ProvidersSection dialogOpen={open} />
</div>
</div>
) : activeTab === "note-tagging" ? (
<NoteTaggingSettings dialogOpen={open} />
) : activeTab === "appearance" ? (

View file

@ -16,6 +16,7 @@ import {
} from "@/components/ui/alert-dialog"
import { Separator } from "@/components/ui/separator"
import { useBilling } from "@/hooks/useBilling"
import { useRowboatConfig } from "@/hooks/use-rowboat-config"
import { CreditRewards } from "@/components/settings/credit-rewards"
import { toast } from "sonner"
import { getBillingPlanData, type BillingUsageBucket } from "@x/shared/dist/billing.js"
@ -56,7 +57,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
const [connectionLoading, setConnectionLoading] = useState(true)
const [disconnecting, setDisconnecting] = useState(false)
const [connecting, setConnecting] = useState(false)
const [appUrl, setAppUrl] = useState<string | null>(null)
const appUrl = useRowboatConfig()?.appUrl ?? null
const { billing, isLoading: billingLoading, refresh: refreshBilling } = useBilling(isRowboatConnected)
const currentPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
const hasPaidSubscription = currentPlan?.category === 'starter' || currentPlan?.category === 'pro'
@ -80,14 +81,6 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
}
}, [dialogOpen, checkConnection])
useEffect(() => {
if (isRowboatConnected) {
window.ipc.invoke('account:getRowboat', null)
.then((account) => setAppUrl(account.config?.appUrl ?? null))
.catch(() => {})
}
}, [isRowboatConnected])
useEffect(() => {
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
if (event.provider === 'rowboat') {

View file

@ -0,0 +1,90 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { __resetModelsForTests } from '@/hooks/use-models'
import { ModelSelectionSection } from './model-selection-section'
// Same preload stub pattern as use-models.test.tsx.
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
let updateCalls: unknown[] = []
;(window as unknown as { ipc: unknown }).ipc = {
on: () => () => undefined,
invoke: (channel: string, args: unknown) => {
if (channel === 'models:updateConfig') updateCalls.push(args)
const handler = handlers[channel]
return handler ? handler(args) : Promise.reject(new Error(`no handler: ${channel}`))
},
}
const EMPTY_TASKS = {
knowledgeGraph: null,
meetingNotes: null,
liveNoteAgent: null,
autoPermissionDecision: null,
chatTitle: null,
backgroundTask: null,
subagent: null,
}
function serve(opts: {
assistant?: { provider: string; model: string } | null
taskModels?: Record<string, { provider: string; model: string } | null>
}): void {
handlers['models:list'] = async () => ({
providers: [
{ id: 'rowboat', flavor: 'rowboat', status: 'ok', models: [{ id: 'google/gemini-3.5-flash' }] },
],
defaultModel: opts.assistant ?? null,
})
handlers['models:getConfig'] = async () => ({
assistantModel: opts.assistant ?? null,
taskModels: { ...EMPTY_TASKS, ...(opts.taskModels ?? {}) },
deferBackgroundTasks: false,
})
handlers['models:updateConfig'] = async () => ({ success: true })
}
beforeEach(() => {
__resetModelsForTests()
handlers = {}
updateCalls = []
})
afterEach(cleanup)
describe('ModelSelectionSection', () => {
it('shows the effective assistant model and "Same as Assistant" for un-overridden tasks', async () => {
serve({ assistant: { provider: 'rowboat', model: 'google/gemini-3.5-flash' } })
render(<ModelSelectionSection dialogOpen />)
// Assistant trigger shows the actual model — no "Auto" anywhere.
await waitFor(() => expect(screen.getByTitle('Assistant model')).toHaveTextContent('google/gemini-3.5-flash'))
// The old sentinel labels are gone for good.
expect(screen.queryByText(/Auto \(/)).toBeNull()
expect(screen.queryByText('Rowboat default')).toBeNull()
// All seven tasks render, inheriting.
for (const label of ['Background agents', 'Subagents', 'Knowledge graph', 'Meeting notes', 'Live notes', 'Permission checks', 'Chat titles']) {
expect(screen.getByText(label)).toBeInTheDocument()
}
expect(screen.getAllByText('Same as Assistant').length).toBe(7)
// Inherit subtext names the resolved assistant.
expect(screen.getAllByText('Currently uses Rowboat · google/gemini-3.5-flash').length).toBeGreaterThan(0)
})
it('an overridden task shows "Use Assistant model" and clicking it clears the override', async () => {
serve({
assistant: { provider: 'rowboat', model: 'google/gemini-3.5-flash' },
taskModels: { knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' } },
})
render(<ModelSelectionSection dialogOpen />)
const clear = await screen.findByText('Use Assistant model')
fireEvent.click(clear)
await waitFor(() => expect(updateCalls).toEqual([
{ taskModels: { knowledgeGraph: null } },
]))
// Back to inheriting.
await waitFor(() => expect(screen.queryByText('Use Assistant model')).toBeNull())
})
})

View file

@ -0,0 +1,169 @@
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { ModelSelector, providerDisplayNames, type ModelRef } from "@/components/model-selector"
import { useModels } from "@/hooks/use-models"
// The unified model-selection surface (signed-in and BYOK alike): ONE
// required Assistant model plus per-task overrides that default to
// "Same as Assistant". No "Auto" rows — every choice is an explicit model;
// recommendation logic only ever picks INITIAL models at provider-connect
// time, never appears as a dropdown option.
type TaskKey =
| "backgroundTask"
| "subagent"
| "knowledgeGraph"
| "meetingNotes"
| "liveNoteAgent"
| "autoPermissionDecision"
| "chatTitle"
const TASKS: Array<{ key: TaskKey; label: string; description: string }> = [
{ key: "backgroundTask", label: "Background agents", description: "Scheduled and event-driven agents that run without a chat" },
{ key: "subagent", label: "Subagents", description: "Workers the assistant spawns during a chat" },
{ key: "knowledgeGraph", label: "Knowledge graph", description: "Note creation, email classification, knowledge sync" },
{ key: "meetingNotes", label: "Meeting notes", description: "Meeting summaries and prep briefs" },
{ key: "liveNoteAgent", label: "Live notes", description: "Self-updating notes and their routing" },
{ key: "autoPermissionDecision", label: "Permission checks", description: "Auto-approval of safe tool calls" },
{ key: "chatTitle", label: "Chat titles", description: "Naming chats from the first message" },
]
function refLabel(ref: ModelRef): string {
return `${providerDisplayNames[ref.provider] || ref.provider} · ${ref.model}`
}
export function ModelSelectionSection({ dialogOpen }: { dialogOpen: boolean }) {
// The effective assistant model — the same value every picker shows.
const { defaultModel, groups } = useModels()
const [taskModels, setTaskModels] = useState<Partial<Record<TaskKey, ModelRef | null>>>({})
// Retired-model detection: the saved assistant no longer appears in its
// provider's live list. Only trusted lists count — a failed fetch or an
// openai-compatible endpoint (whose /models is often unreliable) must not
// flag a working model.
const assistantUnavailable = (() => {
if (!defaultModel) return false
const group = groups.find((g) => g.id === defaultModel.provider)
if (!group || group.status !== "ok" || group.models.length === 0) return false
if (group.flavor === "openai-compatible") return false
return !group.models.includes(defaultModel.model)
})()
const load = useCallback(async () => {
try {
const cfg = await window.ipc.invoke("models:getConfig", null)
setTaskModels(cfg.taskModels)
} catch {
// Fresh install — everything inherits.
setTaskModels({})
}
}, [])
useEffect(() => {
if (dialogOpen) void load()
}, [dialogOpen, load])
const setAssistant = useCallback(async (ref: ModelRef | null) => {
// No sentinel row on the assistant picker, so ref is never null — the
// assistant is the one required selection.
if (!ref) return
try {
await window.ipc.invoke("models:updateConfig", { assistantModel: ref })
window.dispatchEvent(new Event("models-config-changed"))
} catch {
toast.error("Failed to save the Assistant model")
}
}, [])
const setTask = useCallback(async (key: TaskKey, ref: ModelRef | null) => {
const previous = taskModels
setTaskModels((prev) => ({ ...prev, [key]: ref }))
try {
await window.ipc.invoke("models:updateConfig", { taskModels: { [key]: ref } })
window.dispatchEvent(new Event("models-config-changed"))
} catch {
toast.error("Failed to save the model")
setTaskModels(previous)
}
}, [taskModels])
return (
<div className="space-y-6">
{/* Assistant model — the one required primary selection. */}
<div className="space-y-2">
<div>
<h4 className="text-sm font-semibold flex items-center gap-2">
Assistant model
{assistantUnavailable && (
<span className="rounded-full bg-destructive/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-destructive">
Unavailable
</span>
)}
</h4>
<p className="text-xs text-muted-foreground mt-0.5">
Used for chat and for any task without its own model selection.
</p>
</div>
<ModelSelector
variant="field"
value={defaultModel}
onChange={setAssistant}
triggerTitle="Assistant model"
/>
{assistantUnavailable && defaultModel && (
<p className="text-xs text-destructive">
This model is no longer listed by {providerDisplayNames[defaultModel.provider] || defaultModel.provider}. Choose another model to continue.
</p>
)}
</div>
{/* Per-task overrides — inherit the assistant unless picked. */}
<div className="space-y-3">
<div>
<h4 className="text-sm font-semibold">Models for other tasks</h4>
<p className="text-xs text-muted-foreground mt-0.5">
These tasks use the Assistant model unless you choose a different one.
</p>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-4">
{TASKS.map(({ key, label, description }) => {
const override = taskModels[key] ?? null
const inheritText = key === "subagent"
? "Uses the spawning chat's model"
: defaultModel
? `Currently uses ${refLabel(defaultModel)}`
: "Uses the Assistant model"
return (
<div key={key} className="space-y-1.5 min-w-0">
<div className="flex items-baseline justify-between gap-2">
<span className="text-xs font-medium">{label}</span>
{override && (
<button
type="button"
className="text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground shrink-0"
onClick={() => void setTask(key, null)}
>
Use Assistant model
</button>
)}
</div>
<ModelSelector
variant="field"
allowCustom
inheritDefault={{ label: "Same as Assistant" }}
value={override}
onChange={(ref) => void setTask(key, ref)}
triggerTitle={label}
/>
<p className="text-[11px] text-muted-foreground truncate" title={override ? refLabel(override) : inheritText}>
{override ? "Uses a different model from the Assistant" : inheritText}
</p>
<p className="sr-only">{description}</p>
</div>
)
})}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,761 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import * as analytics from "@/lib/analytics"
import { ArrowLeft, CheckCircle2, Loader2, Plus, RefreshCw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { providerDisplayNames, type ModelRef } from "@/components/model-selector"
import { selectInitialModel, selectInitialTaskModels } from "@x/shared/dist/initial-selection.js"
import { normalizeModelRecommendation, type ModelRecommendations } from "@x/shared/dist/rowboat-account.js"
import { useModels } from "@/hooks/use-models"
import { useRowboatConfig } from "@/hooks/use-rowboat-config"
import { useChatGPT } from "@/hooks/useChatGPT"
import {
AnthropicIcon,
GenericApiIcon,
GoogleIcon,
OllamaIcon,
OpenAIIcon,
OpenRouterIcon,
VercelIcon,
} from "@/components/onboarding/provider-icons"
// Provider lifecycle: connected-provider cards (status + model counts), the
// add-provider flow, per-provider manage (replace key / endpoint / refresh
// models / used-by), and disconnect with its consequences spelled out.
// Providers manage CREDENTIALS only — model choices live in
// ModelSelectionSection above this section.
type ByokFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
interface ProviderMeta {
id: string
flavor: string
baseURL?: string
hasApiKey: boolean
}
interface Selections {
assistantModel: ModelRef | null
taskModels: Record<string, ModelRef | null>
}
const TASK_LABELS: Record<string, string> = {
backgroundTask: "Background agents",
subagent: "Subagents",
knowledgeGraph: "Knowledge graph",
meetingNotes: "Meeting notes",
liveNoteAgent: "Live notes",
autoPermissionDecision: "Permission checks",
chatTitle: "Chat titles",
}
const BYOK_CATALOG: Array<{ flavor: ByokFlavor; name: string; tagline: string; icon: React.ElementType; needsKey: boolean; needsEndpoint: boolean; optionalKey?: boolean; manualModel?: boolean }> = [
{ flavor: "openai", name: "OpenAI", tagline: "GPT models", icon: OpenAIIcon, needsKey: true, needsEndpoint: false },
{ flavor: "anthropic", name: "Anthropic", tagline: "Claude models", icon: AnthropicIcon, needsKey: true, needsEndpoint: false },
{ flavor: "google", name: "Gemini", tagline: "Google AI Studio", icon: GoogleIcon, needsKey: true, needsEndpoint: false },
{ flavor: "ollama", name: "Ollama", tagline: "Run models locally", icon: OllamaIcon, needsKey: false, needsEndpoint: true },
{ flavor: "openrouter", name: "OpenRouter", tagline: "One key, many models", icon: OpenRouterIcon, needsKey: true, needsEndpoint: false },
{ flavor: "aigateway", name: "AI Gateway (Vercel)", tagline: "Vercel's AI Gateway", icon: VercelIcon, needsKey: true, needsEndpoint: false },
{ flavor: "openai-compatible", name: "OpenAI-Compatible", tagline: "Custom OpenAI-compatible endpoint", icon: GenericApiIcon, needsKey: true, optionalKey: true, needsEndpoint: true, manualModel: true },
]
const DEFAULT_BASE_URLS: Partial<Record<ByokFlavor, string>> = {
ollama: "http://localhost:11434",
"openai-compatible": "http://localhost:1234/v1",
aigateway: "https://ai-gateway.vercel.sh/v1",
}
function flavorMeta(flavor: string) {
return BYOK_CATALOG.find((c) => c.flavor === flavor)
}
export function ProvidersSection({ dialogOpen, variant = "settings" }: {
dialogOpen: boolean
/**
* "onboarding" renders the same connected-provider list + add flow but
* without the settings-only chrome (Manage buttons, defer toggle) the
* onboarding step supplies its own framing and navigation.
*/
variant?: "settings" | "onboarding"
}) {
const { groups, isRowboatConnected, refresh } = useModels()
const chatgpt = useChatGPT()
const modelRecommendations = useRowboatConfig()?.modelRecommendations
const [providersMeta, setProvidersMeta] = useState<ProviderMeta[]>([])
const [selections, setSelections] = useState<Selections>({ assistantModel: null, taskModels: {} })
const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [manageId, setManageId] = useState<string | null>(null)
const load = useCallback(async () => {
try {
const cfg = await window.ipc.invoke("models:getConfig", null)
setProvidersMeta(cfg.providers)
setSelections({ assistantModel: cfg.assistantModel, taskModels: cfg.taskModels })
setDeferBackgroundTasks(cfg.deferBackgroundTasks)
} catch { /* fresh install */ }
}, [])
useEffect(() => {
if (dialogOpen) void load()
}, [dialogOpen, load])
useEffect(() => {
const handler = () => void load()
window.addEventListener("models-config-changed", handler)
// Main-side config writes (Rowboat sign-in seeding the assistant,
// sign-out clearing selections, ChatGPT state) announce themselves on
// the auth broadcasts, not the window event — reload on those too.
const cleanups = [
window.ipc.on("oauth:didConnect", handler),
window.ipc.on("chatgpt:statusChanged", handler),
]
return () => {
window.removeEventListener("models-config-changed", handler)
for (const cleanup of cleanups) cleanup()
}
}, [load])
// One card per connected provider, in catalog order (assistant's first).
const cards = useMemo(() => {
return groups.map((g) => {
const meta = providersMeta.find((p) => p.id === g.id)
return {
id: g.id,
flavor: g.flavor,
name: providerDisplayNames[g.flavor] || g.flavor,
status: g.status,
error: g.error,
modelCount: g.models.length,
meta,
}
})
}, [groups, providersMeta])
const usedBy = useCallback((providerId: string): Array<{ label: string; model: string }> => {
const rows: Array<{ label: string; model: string }> = []
if (selections.assistantModel?.provider === providerId) {
rows.push({ label: "Assistant model", model: selections.assistantModel.model })
}
for (const [key, ref] of Object.entries(selections.taskModels)) {
if (ref?.provider === providerId) {
rows.push({ label: TASK_LABELS[key] ?? key, model: ref.model })
}
}
return rows
}, [selections])
const handleDeferToggle = useCallback(async (value: boolean) => {
setDeferBackgroundTasks(value)
try {
await window.ipc.invoke("models:updateConfig", { deferBackgroundTasks: value })
window.dispatchEvent(new Event("models-config-changed"))
} catch {
toast.error("Failed to save setting")
}
}, [])
const manageCard = manageId ? cards.find((c) => c.id === manageId) ?? null : null
return (
<div className="space-y-4">
<div className="space-y-2">
{cards.length === 0 && (
<div className="rounded-md border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
Connect Rowboat, use your own API key, or choose a local provider to start using the Assistant.
</div>
)}
{cards.map((c) => (
<div key={c.id} className="flex items-center gap-3 rounded-md border px-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{c.name}</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
<span
className={cn(
"size-2 rounded-full shrink-0",
c.status === "ok" ? "bg-green-500" : "bg-destructive",
)}
/>
{c.status === "ok"
? `Connected · ${c.modelCount} model${c.modelCount === 1 ? "" : "s"} available`
: (c.error || "Could not load models")}
</div>
</div>
{c.status === "error" && (
<Button variant="ghost" size="sm" onClick={() => refresh(c.id)}>
Retry
</Button>
)}
{variant === "settings" && (
<Button variant="outline" size="sm" onClick={() => setManageId(c.id)}>
Manage
</Button>
)}
</div>
))}
</div>
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)}>
<Plus className="size-4 mr-1" />
Add provider
</Button>
{/* Defer background tasks while chatting */}
{variant === "settings" && (
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div className="min-w-0">
<div className="text-sm font-medium">Defer background tasks while chatting</div>
<div className="text-xs text-muted-foreground mt-0.5">
Background agents wait until no chat is running. Recommended for local models.
</div>
</div>
<Switch checked={deferBackgroundTasks} onCheckedChange={handleDeferToggle} />
</div>
)}
<AddProviderDialog
open={addOpen}
onOpenChange={setAddOpen}
connectedIds={cards.map((c) => c.id)}
isRowboatConnected={isRowboatConnected}
chatgptSignedIn={chatgpt.status.signedIn}
onChatGPTSignIn={chatgpt.signIn}
hadAssistant={selections.assistantModel !== null}
modelRecommendations={modelRecommendations}
analyticsSource={variant === "onboarding" ? "onboarding" : "connect"}
/>
{manageCard && (
<ManageProviderDialog
card={manageCard}
usedBy={usedBy(manageCard.id)}
onClose={() => setManageId(null)}
onRefreshModels={async () => { await refresh(manageCard.id) }}
/>
)}
</div>
)
}
// ---------- Add provider ----------
type AddStep =
| { kind: "choose" }
| { kind: "creds"; flavor: ByokFlavor }
| { kind: "authwait"; which: "rowboat" | "chatgpt" }
| { kind: "loading"; flavor: ByokFlavor }
| { kind: "result"; name: string; first: boolean; pickedModel: string | null; modelCount: number | null }
| { kind: "error"; flavor: ByokFlavor; message: string }
function AddProviderDialog({ open, onOpenChange, connectedIds, isRowboatConnected, chatgptSignedIn, onChatGPTSignIn, hadAssistant, modelRecommendations, analyticsSource }: {
open: boolean
onOpenChange: (open: boolean) => void
connectedIds: string[]
isRowboatConnected: boolean
chatgptSignedIn: boolean
onChatGPTSignIn: () => Promise<unknown> | void
hadAssistant: boolean
modelRecommendations: ModelRecommendations | undefined
analyticsSource: 'connect' | 'onboarding'
}) {
const [step, setStep] = useState<AddStep>({ kind: "choose" })
const [apiKey, setApiKey] = useState("")
const [baseURL, setBaseURL] = useState("")
const [manualModel, setManualModel] = useState("")
useEffect(() => {
if (open) {
setStep({ kind: "choose" })
setApiKey("")
setBaseURL("")
setManualModel("")
}
}, [open])
// Rowboat / ChatGPT sign-in completes out-of-band (browser); the shared
// store refreshes on the auth broadcasts, so the provider appearing in
// connectedIds is the completion signal.
useEffect(() => {
if (step.kind !== "authwait") return
const id = step.which === "rowboat" ? "rowboat" : "codex"
if (connectedIds.includes(id)) {
setStep({
kind: "result",
name: providerDisplayNames[id] || id,
// Both sign-ins seed the assistant when none was set (main-side
// initial selection) — first-provider copy applies to either.
first: !hadAssistant,
pickedModel: null,
modelCount: null,
})
}
}, [step, connectedIds, hadAssistant])
const chooseEntries = useMemo(() => {
const entries: Array<{ id: string; name: string; tagline: string; icon: React.ElementType | null; onChoose: () => void }> = []
if (!isRowboatConnected) {
entries.push({
id: "rowboat",
name: "Rowboat",
tagline: "Included with your plan",
icon: null,
onChoose: () => {
setStep({ kind: "authwait", which: "rowboat" })
void window.ipc.invoke("oauth:connect", { provider: "rowboat" }).catch(() => {
setStep({ kind: "choose" })
toast.error("Sign-in failed to start")
})
},
})
}
if (!chatgptSignedIn) {
entries.push({
id: "codex",
name: "ChatGPT",
tagline: "Use your Plus/Pro subscription",
icon: OpenAIIcon,
onChoose: () => {
setStep({ kind: "authwait", which: "chatgpt" })
void Promise.resolve(onChatGPTSignIn()).catch(() => {
setStep({ kind: "choose" })
toast.error("Sign-in failed to start")
})
},
})
}
for (const c of BYOK_CATALOG) {
if (connectedIds.includes(c.flavor)) continue
entries.push({
id: c.flavor,
name: c.name,
tagline: c.tagline,
icon: c.icon,
onChoose: () => {
setApiKey("")
setBaseURL(DEFAULT_BASE_URLS[c.flavor] ?? "")
setManualModel("")
setStep({ kind: "creds", flavor: c.flavor })
},
})
}
return entries
}, [isRowboatConnected, chatgptSignedIn, connectedIds, onChatGPTSignIn])
const connect = useCallback(async (flavor: ByokFlavor) => {
const meta = flavorMeta(flavor)
if (!meta) return
const key = apiKey.trim()
const url = baseURL.trim()
if (meta.needsKey && !meta.optionalKey && !key) {
toast.error("Enter an API key")
return
}
if (meta.needsEndpoint && !url) {
toast.error("Enter the endpoint URL")
return
}
setStep({ kind: "loading", flavor })
const providerEntry = {
flavor,
apiKey: key || undefined,
baseURL: url || undefined,
}
try {
const listRes = await window.ipc.invoke("models:listForProvider", { provider: providerEntry })
const list = listRes.success ? listRes.models ?? [] : []
const typed = manualModel.trim()
const model = typed || (selectInitialModel(flavor, list, modelRecommendations) ?? "")
if (!listRes.success && !model) {
setStep({ kind: "error", flavor, message: listRes.error || "Could not load the provider's model list." })
return
}
if (!model) {
setStep({ kind: "error", flavor, message: "The provider reported no models. Enter a model id manually and retry." })
return
}
const testRes = await window.ipc.invoke("models:test", { provider: providerEntry, model })
if (!testRes.success) {
setStep({ kind: "error", flavor, message: testRes.error || "Connection test failed" })
return
}
await window.ipc.invoke("models:setProvider", { id: flavor, provider: providerEntry })
// Initial selection only — a saved assistant is never replaced. The
// prop can be stale (a Rowboat sign-in moments ago seeds the
// assistant MAIN-side), so re-read the authoritative config at the
// moment of decision instead of trusting render-time state.
const cfgNow = await window.ipc.invoke("models:getConfig", null).catch(() => null)
const hasAssistantNow = cfgNow ? cfgNow.assistantModel !== null : hadAssistant
if (!hasAssistantNow) {
// Task recommendations ride along the seeding moment as visible
// overrides (validated against the live list; only differences).
const taskModels = selectInitialTaskModels(flavor, flavor, list, modelRecommendations, model)
await window.ipc.invoke("models:updateConfig", {
assistantModel: { provider: flavor, model },
...(Object.keys(taskModels).length > 0 ? { taskModels } : {}),
})
analytics.llmInitialModelSelected({
flavor,
model,
recommended: model === normalizeModelRecommendation(modelRecommendations, flavor)?.assistantModel,
taskOverridesSeeded: Object.keys(taskModels).length,
source: analyticsSource,
})
}
for (const warning of testRes.warnings ?? []) {
toast.warning(warning, { duration: 12000 })
}
window.dispatchEvent(new Event("models-config-changed"))
setStep({
kind: "result",
name: meta.name,
first: !hasAssistantNow,
pickedModel: !hasAssistantNow ? model : null,
modelCount: list.length > 0 ? list.length : null,
})
} catch {
setStep({ kind: "error", flavor, message: "Connection failed" })
}
}, [apiKey, baseURL, manualModel, modelRecommendations, hadAssistant])
const credsMeta = step.kind === "creds" || step.kind === "error" ? flavorMeta(step.flavor) : null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{step.kind === "creds" && credsMeta ? `Connect ${credsMeta.name}` : "Add provider"}
</DialogTitle>
</DialogHeader>
{step.kind === "choose" && (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Connect Rowboat, add your own API key, or run models locally. Each provider&apos;s models appear alongside the others in every picker.
</p>
<div className="grid grid-cols-2 gap-2">
{chooseEntries.map((e) => (
<button
key={e.id}
type="button"
onClick={e.onChoose}
className="rounded-md border px-3 py-2.5 text-left transition-colors hover:bg-accent"
>
<div className="flex items-center gap-2">
{e.icon && <e.icon className="size-4 shrink-0" />}
<span className="text-sm font-medium">{e.name}</span>
</div>
<div className="text-xs text-muted-foreground mt-0.5">{e.tagline}</div>
</button>
))}
</div>
</div>
)}
{step.kind === "creds" && credsMeta && (
<div className="space-y-3">
<button
type="button"
onClick={() => setStep({ kind: "choose" })}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3.5" />
All providers
</button>
{credsMeta.needsKey && (
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
API key{credsMeta.optionalKey ? " (optional)" : ""}
</span>
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Paste your API key"
/>
</div>
)}
{credsMeta.needsEndpoint && (
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Endpoint URL</span>
<Input
value={baseURL}
onChange={(e) => setBaseURL(e.target.value)}
placeholder={DEFAULT_BASE_URLS[credsMeta.flavor] ?? "https://api.example.com/v1"}
/>
</div>
)}
{credsMeta.manualModel && (
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model id (optional)</span>
<Input
value={manualModel}
onChange={(e) => setManualModel(e.target.value)}
placeholder="Leave empty to auto-select"
/>
</div>
)}
<Button className="w-full" onClick={() => void connect(credsMeta.flavor)}>
Connect
</Button>
</div>
)}
{step.kind === "authwait" && (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
<div className="text-sm font-medium">Complete sign-in in your browser</div>
<Button variant="ghost" size="sm" onClick={() => setStep({ kind: "choose" })}>
Cancel
</Button>
</div>
)}
{step.kind === "loading" && (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
<div className="text-sm font-medium">Loading available models</div>
<div className="text-xs text-muted-foreground">Validating the connection and fetching the model list.</div>
</div>
)}
{step.kind === "result" && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-green-600">
<CheckCircle2 className="size-4" />
{step.name} connected
</div>
{step.first && step.pickedModel && (
<p className="text-xs text-muted-foreground">
We selected <span className="font-medium text-foreground">{step.pickedModel}</span> as your Assistant model to get you started. You can change it any time above.
</p>
)}
{step.first && !step.pickedModel && (
<p className="text-xs text-muted-foreground">Your Assistant model has been set. You can change it any time above.</p>
)}
{!step.first && (
<p className="text-xs text-muted-foreground">
{step.modelCount ? `${step.modelCount} models are now available. ` : ""}Your Assistant model is unchanged pick a model from {step.name} for any task whenever you like.
</p>
)}
<div className="flex justify-end">
<Button onClick={() => onOpenChange(false)}>Done</Button>
</div>
</div>
)}
{step.kind === "error" && credsMeta && (
<div className="space-y-3">
<div className="text-sm font-medium text-destructive">Couldn&apos;t connect</div>
<p className="text-xs text-muted-foreground break-words">{step.message}</p>
{credsMeta.manualModel && (
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model id</span>
<Input
value={manualModel}
onChange={(e) => setManualModel(e.target.value)}
placeholder="Enter a model id to connect anyway"
/>
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setStep({ kind: "creds", flavor: step.flavor })}>
Review connection
</Button>
<Button onClick={() => void connect(step.flavor)}>Retry</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}
// ---------- Manage provider ----------
function ManageProviderDialog({ card, usedBy, onClose, onRefreshModels }: {
card: { id: string; flavor: string; name: string; status: "ok" | "error"; error?: string; modelCount: number; meta?: ProviderMeta }
usedBy: Array<{ label: string; model: string }>
onClose: () => void
onRefreshModels: () => Promise<void>
}) {
const isAuthDerived = card.flavor === "rowboat" || card.flavor === "codex"
const meta = flavorMeta(card.flavor)
const chatgpt = useChatGPT()
const [replacingKey, setReplacingKey] = useState(false)
const [newKey, setNewKey] = useState("")
const [endpoint, setEndpoint] = useState(card.meta?.baseURL ?? "")
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const handleRefreshModels = useCallback(async () => {
setRefreshing(true)
try {
await onRefreshModels()
toast.success("Models refreshed")
} finally {
setRefreshing(false)
}
}, [onRefreshModels])
const saveCredentials = useCallback(async (updates: { apiKey?: string; baseURL?: string }) => {
try {
await window.ipc.invoke("models:setProvider", {
id: card.id,
provider: {
flavor: card.flavor as ByokFlavor,
...(updates.apiKey !== undefined ? { apiKey: updates.apiKey } : {}),
...(updates.baseURL !== undefined ? { baseURL: updates.baseURL } : {}),
},
})
window.dispatchEvent(new Event("models-config-changed"))
toast.success("Provider updated")
setReplacingKey(false)
setNewKey("")
} catch {
toast.error("Failed to update provider")
}
}, [card.id, card.flavor])
const disconnect = useCallback(async () => {
try {
if (card.flavor === "rowboat") {
await window.ipc.invoke("oauth:disconnect", { provider: "rowboat" })
} else if (card.flavor === "codex") {
await chatgpt.signOut()
} else {
await window.ipc.invoke("models:removeProvider", { id: card.id })
}
window.dispatchEvent(new Event("models-config-changed"))
toast.success(`${card.name} disconnected`)
onClose()
} catch {
toast.error("Failed to disconnect")
}
}, [card, chatgpt, onClose])
const assistantAffected = usedBy.some((u) => u.label === "Assistant model")
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose() }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{card.name}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className={cn("size-2 rounded-full shrink-0", card.status === "ok" ? "bg-green-500" : "bg-destructive")} />
{card.status === "ok"
? `Connected · ${card.modelCount} model${card.modelCount === 1 ? "" : "s"} available`
: (card.error || "Could not load models")}
</div>
{!isAuthDerived && meta?.needsKey && (
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">API key</span>
{replacingKey ? (
<div className="flex gap-2">
<Input
type="password"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
placeholder="Paste the new API key"
/>
<Button size="sm" disabled={!newKey.trim()} onClick={() => void saveCredentials({ apiKey: newKey.trim() })}>
Save
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Input readOnly value={card.meta?.hasApiKey ? "••••••••••••••••" : "No key saved"} className="font-mono text-xs" />
<Button variant="outline" size="sm" onClick={() => setReplacingKey(true)}>
Replace
</Button>
</div>
)}
</div>
)}
{!isAuthDerived && meta?.needsEndpoint && (
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Endpoint URL</span>
<div className="flex gap-2">
<Input value={endpoint} onChange={(e) => setEndpoint(e.target.value)} />
<Button
size="sm"
variant="outline"
disabled={!endpoint.trim() || endpoint.trim() === (card.meta?.baseURL ?? "")}
onClick={() => void saveCredentials({ baseURL: endpoint.trim() })}
>
Save
</Button>
</div>
</div>
)}
<div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2.5">
<div className="text-xs text-muted-foreground">Refresh to pull the latest models from {card.name}.</div>
<Button variant="outline" size="sm" disabled={refreshing} onClick={() => void handleRefreshModels()}>
<RefreshCw className={cn("size-3.5 mr-1", refreshing && "animate-spin")} />
{refreshing ? "Refreshing…" : "Refresh models"}
</Button>
</div>
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Used by</span>
{usedBy.length === 0 ? (
<div className="text-xs text-muted-foreground">No model selections currently use this provider.</div>
) : (
<div className="space-y-1">
{usedBy.map((u) => (
<div key={u.label} className="flex items-center justify-between text-xs">
<span className="font-medium">{u.label}</span>
<span className="text-muted-foreground truncate ml-3">{u.model}</span>
</div>
))}
</div>
)}
</div>
<div className="border-t pt-3">
{!confirmDisconnect ? (
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">
{card.flavor === "rowboat"
? "Sign out of your Rowboat account."
: `Remove ${card.name} and its models from Rowboat.`}
</span>
<Button variant="outline" size="sm" className="text-destructive" onClick={() => setConfirmDisconnect(true)}>
Disconnect
</Button>
</div>
) : (
<div className="space-y-2">
<div className="text-sm font-medium">Disconnect {card.name}?</div>
<p className="text-xs text-muted-foreground">
{usedBy.length > 0
? `${usedBy.length} model selection${usedBy.length === 1 ? "" : "s"} use${usedBy.length === 1 ? "s" : ""} this provider. Task overrides will reset to the Assistant model${assistantAffected ? ", and you'll need to pick a new Assistant model" : ""}.`
: "Its models will no longer be available in Rowboat."}
</p>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setConfirmDisconnect(false)}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={() => void disconnect()}>
Disconnect
</Button>
</div>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -87,6 +87,7 @@ import { SidebarCreditRewards } from "@/components/sidebar-credit-rewards"
import { MascotFaceIcon } from "@/components/talking-head"
import { extractConferenceLink } from "@/lib/calendar-event"
import { useBilling } from "@/hooks/useBilling"
import { useRowboatConfig } from "@/hooks/use-rowboat-config"
import { toast } from "@/lib/toast"
import { getBillingPlanData } from "@x/shared/dist/billing.js"
import { ServiceEvent } from "@x/shared/src/service-events.js"
@ -477,7 +478,7 @@ export function SidebarContentPanel({
const outOfCreditsRef = useRef(false)
const creditPopoverAutoShownRef = useRef(false)
const [loggingIn, setLoggingIn] = useState(false)
const [appUrl, setAppUrl] = useState<string | null>(null)
const appUrl = useRowboatConfig()?.appUrl ?? null
const { billing, refresh: refreshBilling } = useBilling(isRowboatConnected)
const currentBillingPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
@ -734,12 +735,6 @@ export function SidebarContentPanel({
setShowOauthAlert(true)
}
}
if (connected && mounted) {
try {
const account = await window.ipc.invoke('account:getRowboat', null)
if (mounted) setAppUrl(account.config?.appUrl ?? null)
} catch { /* ignore */ }
}
} catch (error) {
console.error('Failed to fetch OAuth state:', error)
if (mounted) {

View file

@ -8,6 +8,7 @@ import { __resetModelsForTests, useModels } from './use-models'
// captures listeners so tests can fire main-process broadcasts.
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
let invokeCounts: Record<string, number> = {}
let invokeArgs: Record<string, unknown[]> = {}
let ipcListeners: Record<string, Array<(payload: unknown) => void>> = {}
;(window as unknown as { ipc: unknown }).ipc = {
@ -19,21 +20,31 @@ let ipcListeners: Record<string, Array<(payload: unknown) => void>> = {}
},
invoke: (channel: string, args: unknown) => {
invokeCounts[channel] = (invokeCounts[channel] ?? 0) + 1
;(invokeArgs[channel] ??= []).push(args)
const handler = handlers[channel]
return handler ? handler(args) : Promise.reject(new Error(`no handler: ${channel}`))
},
}
function serveConfig(providers: Record<string, unknown>): void {
handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: false } } })
handlers['llm:getDefaultModel'] = async () => ({ provider: 'openai', model: 'gpt-5.4' })
// One catalog response serves the whole snapshot — the unified pipeline's
// single IPC call.
function serveCatalog(catalog: {
providers: Array<{
id: string
flavor?: string
status?: 'ok' | 'error'
error?: string
models: Array<{ id: string; reasoning?: boolean }>
}>
defaultModel: { provider: string; model: string } | null
}): void {
handlers['models:list'] = async () => ({
providers: [
{ id: 'openai', name: 'OpenAI', models: [{ id: 'gpt-5.4', reasoning: true }, { id: 'gpt-5.4-mini' }] },
],
})
handlers['workspace:readFile'] = async () => ({
data: JSON.stringify({ provider: { flavor: 'openai' }, model: 'gpt-5.4', providers }),
providers: catalog.providers.map((p) => ({
flavor: p.id, // one instance per flavor today: id === flavor key
status: 'ok' as const,
...p,
})),
defaultModel: catalog.defaultModel,
})
}
@ -41,12 +52,18 @@ beforeEach(() => {
__resetModelsForTests()
handlers = {}
invokeCounts = {}
invokeArgs = {}
ipcListeners = {}
})
describe('useModels', () => {
it('shares one fetch across concurrently mounted consumers', async () => {
serveConfig({ openai: { apiKey: 'sk-test', model: 'gpt-5.4' } })
serveCatalog({
providers: [
{ id: 'openai', models: [{ id: 'gpt-5.4', reasoning: true }, { id: 'gpt-5.4-mini' }] },
],
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
})
const first = renderHook(() => useModels())
const second = renderHook(() => useModels())
@ -55,21 +72,22 @@ describe('useModels', () => {
await waitFor(() => expect(second.result.current.groups.length).toBeGreaterThan(0))
expect(invokeCounts['models:list']).toBe(1)
expect(invokeCounts['workspace:readFile']).toBe(1)
expect(first.result.current.groups).toEqual([
{ kind: 'catalog', flavor: 'openai', models: ['gpt-5.4', 'gpt-5.4-mini'] },
{ id: 'openai', flavor: 'openai', models: ['gpt-5.4', 'gpt-5.4-mini'], status: 'ok' },
])
expect(first.result.current.reasoningByKey).toEqual({ 'openai/gpt-5.4': true })
expect(first.result.current.defaultModel).toEqual({ provider: 'openai', model: 'gpt-5.4' })
// Raw catalog is exposed for provider-scoped pickers (unconfigured
// providers have no group but may have a catalog).
// Raw catalog is exposed for provider-scoped pickers.
expect(first.result.current.catalogByProvider).toEqual({ openai: ['gpt-5.4', 'gpt-5.4-mini'] })
// Both consumers see the same store snapshot, not copies.
expect(second.result.current.groups).toBe(first.result.current.groups)
})
it('serves the cache to late mounts without refetching', async () => {
serveConfig({ openai: { apiKey: 'sk-test', model: 'gpt-5.4' } })
serveCatalog({
providers: [{ id: 'openai', models: [{ id: 'gpt-5.4' }] }],
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
})
const first = renderHook(() => useModels())
await waitFor(() => expect(first.result.current.groups.length).toBeGreaterThan(0))
@ -80,27 +98,47 @@ describe('useModels', () => {
expect(invokeCounts['models:list']).toBe(1)
})
it('sign-out via the oauth:didConnect broadcast flips isRowboatConnected and drops the rowboat group', async () => {
// Signed in: gateway catalog present.
handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: true } } })
handlers['llm:getDefaultModel'] = async () => ({ provider: 'rowboat', model: 'claude-opus-4-8' })
handlers['models:list'] = async () => ({
providers: [{ id: 'rowboat', name: 'Rowboat', models: [{ id: 'claude-opus-4-8' }] }],
it('orders the default group and model first and passes error status through', async () => {
serveCatalog({
providers: [
{ id: 'ollama', status: 'error', error: 'connection refused', models: [] },
{ id: 'openai', models: [{ id: 'gpt-4.1' }, { id: 'gpt-5.4' }] },
],
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
})
handlers['workspace:readFile'] = async () => ({
data: JSON.stringify({ provider: { flavor: 'openai' }, model: 'gpt-5.4', providers: {} }),
const { result } = renderHook(() => useModels())
await waitFor(() => expect(result.current.groups.length).toBe(2))
// The default's group leads (despite arriving second) and the default
// model leads within it.
expect(result.current.groups[0]).toEqual({
id: 'openai', flavor: 'openai', models: ['gpt-5.4', 'gpt-4.1'], status: 'ok',
})
// A failed provider keeps its group, with the error travelling along
// (ModelSelector renders it as an inline error row + Retry).
expect(result.current.groups[1]).toEqual({
id: 'ollama', flavor: 'ollama', models: [], status: 'error', error: 'connection refused',
})
})
it('sign-out via the oauth:didConnect broadcast flips isRowboatConnected and drops the rowboat group', async () => {
serveCatalog({
providers: [{ id: 'rowboat', models: [{ id: 'claude-opus-4-8' }] }],
defaultModel: { provider: 'rowboat', model: 'claude-opus-4-8' },
})
const { result } = renderHook(() => useModels())
await waitFor(() => expect(result.current.isRowboatConnected).toBe(true))
expect(result.current.groups).toEqual([{ kind: 'catalog', flavor: 'rowboat', models: ['claude-opus-4-8'] }])
expect(result.current.groups).toEqual([
{ id: 'rowboat', flavor: 'rowboat', models: ['claude-opus-4-8'], status: 'ok' },
])
// Sign out: main broadcasts oauth:didConnect with success:false
// (disconnectProvider's emitOAuthEvent) — same channel as connect.
handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: false } } })
handlers['llm:getDefaultModel'] = async () => ({ provider: 'openai', model: 'gpt-5.4' })
handlers['models:list'] = async () => ({
providers: [{ id: 'openai', name: 'OpenAI', models: [{ id: 'gpt-5.4' }] }],
serveCatalog({
providers: [{ id: 'openai', models: [{ id: 'gpt-5.4' }] }],
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
})
act(() => {
for (const listener of ipcListeners['oauth:didConnect'] ?? []) {
@ -109,32 +147,55 @@ describe('useModels', () => {
})
await waitFor(() => expect(result.current.isRowboatConnected).toBe(false))
expect(result.current.groups.some((g) => g.flavor === 'rowboat')).toBe(false)
expect(result.current.groups.some((g) => g.id === 'rowboat')).toBe(false)
})
it('refetches on models-config-changed and updates every consumer', async () => {
serveConfig({ openai: { apiKey: 'sk-test', model: 'gpt-5.4' } })
serveCatalog({
providers: [{ id: 'openai', models: [{ id: 'gpt-5.4' }] }],
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
})
const { result } = renderHook(() => useModels())
await waitFor(() => expect(result.current.groups.length).toBe(1))
serveConfig({
openai: { apiKey: 'sk-test', model: 'gpt-5.4' },
ollama: { baseURL: 'http://localhost:11434' },
// The settings Save path: the config write lands first, then the event
// fires — the refetch must see the new provider set and default (this is
// what moves a fresh composer tab's trigger label without a restart).
serveCatalog({
providers: [
{ id: 'openai', models: [{ id: 'gpt-5.4' }] },
{ id: 'ollama', models: [{ id: 'llama3' }] },
],
defaultModel: { provider: 'ollama', model: 'llama3' },
})
// The settings Save path: models:updateConfig lands first, then the
// event fires — the refetch must see the new default (this is what
// moves a fresh composer tab's trigger label without a restart).
handlers['llm:getDefaultModel'] = async () => ({ provider: 'ollama', model: 'llama3' })
act(() => {
window.dispatchEvent(new Event('models-config-changed'))
})
await waitFor(() => expect(result.current.groups.length).toBe(2))
expect(result.current.groups[1]).toEqual({
kind: 'live', flavor: 'ollama', apiKey: '', baseURL: 'http://localhost:11434', savedModel: '',
})
expect(result.current.defaultModel).toEqual({ provider: 'ollama', model: 'llama3' })
expect(invokeCounts['models:list']).toBe(2)
// Event-driven refetches are plain rebuilds, not forced provider
// refreshes — the Event object must never leak in as a provider id.
expect(invokeArgs['models:list']).toEqual([null, null])
})
it('refresh(providerId) asks main to drop that provider\'s cached list', async () => {
serveCatalog({
providers: [{ id: 'ollama', status: 'error', error: 'down', models: [] }],
defaultModel: null,
})
const { result } = renderHook(() => useModels())
await waitFor(() => expect(result.current.groups.length).toBe(1))
// Promise-returning so callers (Manage's "Refresh models") can render
// progress and confirm completion.
await act(async () => {
await result.current.refresh('ollama')
})
expect(invokeCounts['models:list']).toBe(2)
expect(invokeArgs['models:list'][1]).toEqual({ refreshProvider: 'ollama' })
})
})

View file

@ -1,45 +1,49 @@
import { useMemo, useSyncExternalStore } from 'react'
import type { ProviderModelsFlavor } from './use-provider-models'
export interface ModelRef {
provider: string
model: string
}
// One picker group per connected provider. Catalog groups carry a resolved
// model list (models:list / saved config); live groups carry credentials and
// fetch their list from the provider inside the dropdown via
// useProviderModels (models:listForProvider).
export type ModelPickerGroup =
| { kind: 'catalog'; flavor: string; models: string[] }
| { kind: 'live'; flavor: ProviderModelsFlavor; apiKey: string; baseURL: string; savedModel: string }
const LIVE_PICKER_FLAVORS = new Set<string>(['openrouter', 'aigateway', 'ollama', 'openai-compatible'])
// Catalog-preferred flavors that degrade to a live fetch when models:list has
// no catalog for them (signed-in mode returns only the rowboat provider, or
// the models.dev cache is empty).
const LIVE_FALLBACK_FLAVORS = new Set<string>(['openai', 'anthropic', 'google'])
// One picker group per connected provider, straight from the unified model
// catalog (models:list → core/models/catalog.ts). Every provider — Rowboat
// gateway, ChatGPT subscription (codex), BYOK keys, local endpoints — comes
// through the same pipeline with a resolved list and a status; there is no
// renderer-side fetching or per-flavor special casing.
export interface ModelPickerGroup {
/** Provider instance id — what ModelRef.provider joins on. */
id: string
/** Provider type ("openai", "ollama", "rowboat", …) — display naming. */
flavor: string
models: string[]
/** 'error' = provider is connected but its model list failed to load. */
status: 'ok' | 'error'
error?: string
}
export interface ModelsSnapshot {
groups: ModelPickerGroup[]
// Per-model reasoning capability ("provider/model" → flag) from models:list.
// Live-fetched ids carry no reasoning metadata, so lookups miss → treated
// as non-reasoning.
// Per-model reasoning capability ("provider/model" → flag) from the
// catalog. Ids without metadata miss → treated as non-reasoning.
reasoningByKey: Record<string, boolean>
// The effective runtime default (what a run actually uses when the user
// hasn't picked a model) — shown in pickers instead of guessing from list
// order, which can disagree with the real default.
defaultModel: ModelRef | null
isRowboatConnected: boolean
// Raw models:list catalog per provider id. Groups only cover providers
// configured in models.json; provider-scoped pickers fall back to this so
// a provider mid-setup (key typed, not saved) still lists its catalog.
// Raw catalog model ids per provider id, unpinned — for provider-scoped
// pickers that need a provider's list without group ordering applied.
catalogByProvider: Record<string, string[]>
}
export interface UseModelsResult extends ModelsSnapshot {
/** Force a refetch now (e.g. a composer tab becoming active). */
refresh: () => void
/**
* Force a refetch now (e.g. a composer tab becoming active). With a
* provider id, that provider's cached list is dropped and refetched
* (the error-row Retry, Manage's "Refresh models"). Resolves once the
* snapshot has been updated, so callers can render progress.
*/
refresh: (providerId?: string) => Promise<void>
}
const EMPTY_SNAPSHOT: ModelsSnapshot = {
@ -61,122 +65,65 @@ let wired = false
let wiredCleanups: Array<() => void> = []
const subscribers = new Set<() => void>()
// Hybrid mode: signed-in users get the gateway list AND every BYOK provider
// configured in models.json (selecting a BYOK model routes that message
// through the user's own key / local server). Signed-out users get BYOK only.
async function buildSnapshot(): Promise<ModelsSnapshot> {
let isRowboatConnected = false
try {
const state = await window.ipc.invoke('oauth:getState', null)
isRowboatConnected = state.config?.rowboat?.connected ?? false
} catch { /* treat as signed out */ }
async function buildSnapshot(refreshProvider?: string): Promise<ModelsSnapshot> {
const catalog = await window.ipc.invoke(
'models:list',
refreshProvider ? { refreshProvider } : null,
)
let defaultModel: ModelRef | null = null
try {
const def = await window.ipc.invoke('llm:getDefaultModel', null)
defaultModel = { provider: def.provider, model: def.model }
} catch { /* no default resolvable */ }
const groups: ModelPickerGroup[] = []
const defaultModel: ModelRef | null = catalog.defaultModel
const reasoningByKey: Record<string, boolean> = {}
const catalogByProvider: Record<string, string[]> = {}
const groups: ModelPickerGroup[] = []
// Full catalog per provider (gateway + models.dev cloud providers).
const catalog: Record<string, string[]> = {}
try {
const listResult = await window.ipc.invoke('models:list', null)
for (const p of listResult.providers || []) {
catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id)
for (const m of p.models || []) {
if (typeof m.reasoning === 'boolean') {
reasoningByKey[`${p.id}/${m.id}`] = m.reasoning
}
for (const p of catalog.providers) {
const ids = p.models.map((m) => m.id)
catalogByProvider[p.id] = ids
for (const m of p.models) {
if (typeof m.reasoning === 'boolean') {
reasoningByKey[`${p.id}/${m.id}`] = m.reasoning
}
}
} catch { /* offline / no catalog — groups fall back to saved config below */ }
if (isRowboatConnected && (catalog['rowboat'] || []).length > 0) {
groups.push({ kind: 'catalog', flavor: 'rowboat', models: catalog['rowboat'] })
groups.push({
id: p.id,
flavor: p.flavor,
models: ids,
status: p.status,
...(p.error ? { error: p.error } : {}),
})
}
// ChatGPT subscription (codex): models:list only carries this catalog
// while signed in with ChatGPT, so presence is the gate.
if ((catalog['codex'] || []).length > 0) {
groups.push({ kind: 'catalog', flavor: 'codex', models: catalog['codex'] })
// The effective default leads the picker: its group first and, within the
// group, the model itself first.
if (defaultModel) {
const index = groups.findIndex((g) => g.id === defaultModel.provider)
if (index >= 0) {
const [group] = groups.splice(index, 1)
groups.unshift(group)
const mi = group.models.indexOf(defaultModel.model)
if (mi > 0) {
group.models.splice(mi, 1)
group.models.unshift(defaultModel.model)
}
}
}
try {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
const parsed = JSON.parse(result.data)
// List the default provider's group first.
const defaultFlavor = typeof parsed?.provider?.flavor === 'string' ? parsed.provider.flavor : ''
const flavors = Object.keys(parsed?.providers || {})
.sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0))
for (const flavor of flavors) {
const e = (parsed.providers[flavor] || {}) as Record<string, unknown>
const apiKey = typeof e.apiKey === 'string' ? e.apiKey.trim() : ''
const baseURL = typeof e.baseURL === 'string' ? e.baseURL.trim() : ''
if (!apiKey && !baseURL) continue // provider not configured
const savedModel = typeof e.model === 'string' ? e.model : ''
// Live flavors fetch their list from the provider inside the
// dropdown, with the credentials saved in config. Catalog flavors
// degrade to the same live fetch when models:list carried no
// catalog for them (signed in, or empty models.dev cache).
const catalogModels = catalog[flavor] || []
if (LIVE_PICKER_FLAVORS.has(flavor) || (catalogModels.length === 0 && LIVE_FALLBACK_FLAVORS.has(flavor))) {
groups.push({ kind: 'live', flavor: flavor as ProviderModelsFlavor, apiKey, baseURL, savedModel })
continue
}
// Catalog group: the saved default model leads, then the catalog.
// Saved models[] survives as the fallback for unknown flavors the
// live fetch doesn't support.
const models: string[] = []
const push = (model: string) => {
if (model && !models.includes(model)) models.push(model)
}
push(savedModel)
if (catalogModels.length > 0) {
for (const m of catalogModels) push(m)
} else {
const saved = Array.isArray(e.models) ? e.models as string[] : []
for (const m of saved) push(m)
}
groups.push({ kind: 'catalog', flavor, models })
}
// The user's explicit default selection leads the picker: its group
// first and, within a catalog group, the model itself first. (Live
// groups pin the default at the top themselves.)
const sel = parsed?.defaultSelection
if (sel && typeof sel.provider === 'string' && typeof sel.model === 'string') {
const index = groups.findIndex((g) => g.flavor === sel.provider)
if (index >= 0) {
const [group] = groups.splice(index, 1)
groups.unshift(group)
if (group.kind === 'catalog') {
const mi = group.models.indexOf(sel.model)
if (mi > 0) {
group.models.splice(mi, 1)
group.models.unshift(sel.model)
}
}
}
}
} catch { /* no BYOK config yet */ }
return { groups, reasoningByKey, defaultModel, isRowboatConnected, catalogByProvider: catalog }
return {
groups,
reasoningByKey,
defaultModel,
isRowboatConnected: catalog.providers.some((p) => p.id === 'rowboat'),
catalogByProvider,
}
}
function startFetch(): void {
function startFetch(refreshProvider?: string): Promise<void> {
// Concurrent fetches race (an event can fire while one is in flight) —
// only the newest run may write the snapshot, else a slow stale run can
// clobber the fresh list.
const seq = ++fetchSeq
fetching = true
void buildSnapshot()
return buildSnapshot(refreshProvider)
.then((next) => {
if (seq !== fetchSeq) return
snapshot = next
@ -192,28 +139,30 @@ function startFetch(): void {
})
}
function refreshModels(): void {
startFetch()
function refreshModels(providerId?: string): Promise<void> {
return startFetch(typeof providerId === 'string' ? providerId : undefined)
}
function ensureLoaded(): void {
if (!loaded && !fetching) startFetch()
if (!loaded && !fetching) void startFetch()
}
function wireGlobalEvents(): void {
if (wired) return
wired = true
// Event payloads must not leak into startFetch's refreshProvider arg.
const refetch = () => void startFetch()
// Config edits anywhere in the app (settings dialog, composer pick,
// onboarding) announce themselves on this window event.
window.addEventListener('models-config-changed', refreshModels)
window.addEventListener('models-config-changed', refetch)
wiredCleanups = [
() => window.removeEventListener('models-config-changed', refreshModels),
// Rowboat sign-in/out swaps the whole hybrid list. Despite the name,
// main broadcasts this channel on every OAuth state change — including
() => window.removeEventListener('models-config-changed', refetch),
// Rowboat sign-in/out swaps the provider set. Despite the name, main
// broadcasts this channel on every OAuth state change — including
// disconnect (disconnectProvider emits { provider, success: false }).
window.ipc.on('oauth:didConnect', refreshModels),
window.ipc.on('oauth:didConnect', refetch),
// ChatGPT subscription models appear/disappear with the ChatGPT session.
window.ipc.on('chatgpt:statusChanged', refreshModels),
window.ipc.on('chatgpt:statusChanged', refetch),
]
}

View file

@ -1,147 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react"
// Flavors the live model-list fetch (models:listForProvider) supports.
// "rowboat" (the signed-in gateway) is deliberately absent — its catalog
// comes from models:list, and core throws on the flavor.
export type ProviderModelsFlavor =
| "openai"
| "anthropic"
| "google"
| "openrouter"
| "aigateway"
| "ollama"
| "openai-compatible"
export type ProviderModelsStatus = "idle" | "loading" | "loaded" | "error"
export interface UseProviderModelsResult {
/** idle = credentials are insufficient to attempt a fetch. */
status: ProviderModelsStatus
models: string[]
error: string | null
/** Bypass the cache and fetch now (key-field blur / Retry). No-op while idle. */
refetch: () => void
}
const AIGATEWAY_DEFAULT_BASE_URL = "https://ai-gateway.vercel.sh/v1"
// The automatic fetch fires only once the credential inputs stop changing —
// never per keystroke, which would spray partial API keys at the provider.
const FETCH_DEBOUNCE_MS = 600
// Module-level so provider switches and dialog reopens don't refetch.
// Successful results only, keyed on `${flavor}|${apiKey}|${baseURL}`.
const listCache = new Map<string, string[]>()
// De-dupes concurrent requests for the same key (debounce firing + field blur).
const inFlight = new Map<string, Promise<string[]>>()
function credentialsSufficient(flavor: ProviderModelsFlavor, apiKey: string, baseURL: string): boolean {
if (flavor === "ollama" || flavor === "openai-compatible") return baseURL.length > 0
return apiKey.length > 0
}
function fetchProviderModels(
cacheKey: string,
provider: { flavor: ProviderModelsFlavor; apiKey?: string; baseURL?: string },
): Promise<string[]> {
const pending = inFlight.get(cacheKey)
if (pending) return pending
const request = window.ipc
.invoke("models:listForProvider", { provider })
.then((result) => {
if (!result.success) throw new Error(result.error || "Failed to list models")
const models = result.models ?? []
listCache.set(cacheKey, models)
return models
})
.finally(() => {
inFlight.delete(cacheKey)
})
inFlight.set(cacheKey, request)
return request
}
export function useProviderModels(input: {
flavor: ProviderModelsFlavor
apiKey: string
baseURL: string
}): UseProviderModelsResult {
const { flavor } = input
const apiKey = input.apiKey.trim()
const baseURL = input.baseURL.trim() || (flavor === "aigateway" ? AIGATEWAY_DEFAULT_BASE_URL : "")
const cacheKey = `${flavor}|${apiKey}|${baseURL}`
const sufficient = credentialsSufficient(flavor, apiKey, baseURL)
const [state, setState] = useState<{
key: string
status: ProviderModelsStatus
models: string[]
error: string | null
}>({ key: "", status: "idle", models: [], error: null })
// Bumped whenever the inputs change (and on unmount) so completions of
// superseded fetches never write state.
const epochRef = useRef(0)
const startFetch = useCallback(() => {
const epoch = ++epochRef.current
setState({ key: cacheKey, status: "loading", models: [], error: null })
fetchProviderModels(cacheKey, {
flavor,
apiKey: apiKey || undefined,
baseURL: baseURL || undefined,
})
.then((models) => {
if (epochRef.current !== epoch) return
setState({ key: cacheKey, status: "loaded", models, error: null })
})
.catch((err: unknown) => {
if (epochRef.current !== epoch) return
const message = err instanceof Error ? err.message : "Failed to list models"
setState({ key: cacheKey, status: "error", models: [], error: message })
})
}, [cacheKey, flavor, apiKey, baseURL])
useEffect(() => {
epochRef.current++
if (!sufficient) {
setState({ key: cacheKey, status: "idle", models: [], error: null })
return
}
const cached = listCache.get(cacheKey)
if (cached) {
setState({ key: cacheKey, status: "loaded", models: cached, error: null })
return
}
setState({ key: cacheKey, status: "loading", models: [], error: null })
const timer = setTimeout(() => {
// A blur-triggered refetch may have already filled the cache while the
// debounce was pending — don't fetch the same key twice.
const nowCached = listCache.get(cacheKey)
if (nowCached) {
setState({ key: cacheKey, status: "loaded", models: nowCached, error: null })
return
}
startFetch()
}, FETCH_DEBOUNCE_MS)
return () => clearTimeout(timer)
}, [cacheKey, sufficient, startFetch])
useEffect(() => () => {
epochRef.current++
}, [])
const refetch = useCallback(() => {
if (!sufficient) return
listCache.delete(cacheKey)
startFetch()
}, [sufficient, cacheKey, startFetch])
// State lags the inputs by one render (the effect above reconciles), so
// derive the answer for the *current* inputs — a provider switch must never
// flash the previous provider's list.
if (state.key !== cacheKey) {
const cached = sufficient ? listCache.get(cacheKey) : undefined
if (cached) return { status: "loaded", models: cached, error: null, refetch }
return { status: sufficient ? "loading" : "idle", models: [], error: null, refetch }
}
return { status: state.status, models: state.models, error: state.error, refetch }
}

View file

@ -0,0 +1,68 @@
import { renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { __resetRowboatConfigForTests, fetchRowboatConfig, useRowboatConfig } from './use-rowboat-config'
// Same preload stub pattern as use-models.test.tsx: invoke routes by channel
// through a per-test handler map, with per-channel call counts to observe the
// store's fetch dedupe.
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
let invokeCounts: Record<string, number> = {}
;(window as unknown as { ipc: unknown }).ipc = {
on: () => () => undefined,
invoke: (channel: string, args: unknown) => {
invokeCounts[channel] = (invokeCounts[channel] ?? 0) + 1
const handler = handlers[channel]
return handler ? handler(args) : Promise.reject(new Error(`no handler: ${channel}`))
},
}
const CONFIG = {
appUrl: 'https://app.example.com',
websocketApiUrl: 'https://ws.example.com',
supabaseUrl: 'https://supabase.example.com',
billing: { plans: [] },
modelRecommendations: { openai: 'gpt-5.4' },
}
beforeEach(() => {
__resetRowboatConfigForTests()
handlers = {}
invokeCounts = {}
})
describe('useRowboatConfig', () => {
it('serves every consumer from one shared fetch', async () => {
handlers['rowboat:getConfig'] = async () => CONFIG
const first = renderHook(() => useRowboatConfig())
const second = renderHook(() => useRowboatConfig())
await waitFor(() => expect(first.result.current?.appUrl).toBe('https://app.example.com'))
await waitFor(() => expect(second.result.current?.modelRecommendations).toEqual({ openai: 'gpt-5.4' }))
expect(invokeCounts['rowboat:getConfig']).toBe(1)
// Imperative reads share the same cache — no extra IPC.
await expect(fetchRowboatConfig()).resolves.toEqual(CONFIG)
expect(invokeCounts['rowboat:getConfig']).toBe(1)
})
it('retries after an unreachable API instead of caching null forever', async () => {
handlers['rowboat:getConfig'] = async () => null
await expect(fetchRowboatConfig()).resolves.toBeNull()
handlers['rowboat:getConfig'] = async () => CONFIG
await expect(fetchRowboatConfig()).resolves.toEqual(CONFIG)
expect(invokeCounts['rowboat:getConfig']).toBe(2)
})
it('returns null while loading and to consumers when the fetch rejects', async () => {
handlers['rowboat:getConfig'] = async () => {
throw new Error('ipc down')
}
const { result } = renderHook(() => useRowboatConfig())
expect(result.current).toBeNull()
await expect(fetchRowboatConfig()).resolves.toBeNull()
expect(result.current).toBeNull()
})
})

View file

@ -0,0 +1,67 @@
import { z } from 'zod'
import { useSyncExternalStore } from 'react'
import { RowboatApiConfig } from '@x/shared/dist/rowboat-account.js'
export type RowboatConfig = z.infer<typeof RowboatApiConfig>
// The single renderer-side reader of the /v1/config bootstrap (service URLs,
// billing catalog, model recommendations) via rowboat:getConfig. The config
// is static for the lifetime of the app run (main caches the fetch), so one
// module-level store serves every consumer: the first subscriber triggers
// the fetch, everyone shares the result, and a failed fetch retries on the
// next consumer instead of caching null forever.
let cached: RowboatConfig | null = null
let pending: Promise<RowboatConfig | null> | null = null
const subscribers = new Set<() => void>()
/**
* Imperative access for non-hook call paths (e.g. useRowboatAccount's
* refresh) same shared cache as the hook.
*/
export function fetchRowboatConfig(): Promise<RowboatConfig | null> {
if (cached) return Promise.resolve(cached)
if (!pending) {
pending = window.ipc
.invoke('rowboat:getConfig', null)
.then((config) => {
if (config) {
cached = config
for (const notify of subscribers) notify()
} else {
// Main returned null (API unreachable) — leave the cache empty so
// a later consumer retries.
pending = null
}
return config
})
.catch(() => {
pending = null
return null
})
}
return pending
}
function subscribe(onStoreChange: () => void): () => void {
subscribers.add(onStoreChange)
void fetchRowboatConfig()
return () => {
subscribers.delete(onStoreChange)
}
}
function getSnapshot(): RowboatConfig | null {
return cached
}
/** The Rowboat bootstrap config, or null while loading / when unreachable. */
export function useRowboatConfig(): RowboatConfig | null {
return useSyncExternalStore(subscribe, getSnapshot)
}
// Test-only: drop the shared cache so each test starts cold.
export function __resetRowboatConfigForTests(): void {
cached = null
pending = null
subscribers.clear()
}

View file

@ -3,6 +3,7 @@ import { toast } from 'sonner';
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
import { finalizeDeepgramStream } from '@/lib/deepgram-finalize';
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
import { fetchRowboatConfig } from '@/hooks/use-rowboat-config';
export type MeetingTranscriptionState = 'idle' | 'connecting' | 'recording' | 'stopping';
@ -258,14 +259,19 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
detectHeadphones(),
// 2. Set up Deepgram WebSocket (account refresh + connect + wait for open)
(async () => {
const account = await refreshRowboatAccount();
// Token from account refresh; websocket URL from the
// sign-in-independent bootstrap config store.
const [account, rowboatConfig] = await Promise.all([
refreshRowboatAccount(),
fetchRowboatConfig(),
]);
let ws: WebSocket;
if (
account?.signedIn &&
account.accessToken &&
account.config?.websocketApiUrl
rowboatConfig?.websocketApiUrl
) {
const listenUrl = buildDeepgramListenUrl(account.config.websocketApiUrl, DEEPGRAM_PARAMS);
const listenUrl = buildDeepgramListenUrl(rowboatConfig.websocketApiUrl, DEEPGRAM_PARAMS);
console.log('[meeting] Using Rowboat WebSocket');
ws = new WebSocket(listenUrl, ['bearer', account.accessToken]);
} else {

View file

@ -1,12 +1,12 @@
import { z } from 'zod';
import { useCallback, useEffect, useState } from 'react';
import { RowboatApiConfig } from '@x/shared/dist/rowboat-account.js';
// Account state only — sign-in status and the access token. The bootstrap
// config (/v1/config: service URLs, billing catalog, model recommendations)
// is deliberately NOT part of this snapshot: it's unauthenticated and
// sign-in independent, so consumers read it from use-rowboat-config instead.
interface RowboatAccountState {
signedIn: boolean;
accessToken: string | null;
config: z.infer<typeof RowboatApiConfig> | null;
}
export type RowboatAccountSnapshot = RowboatAccountState;
@ -14,7 +14,6 @@ export type RowboatAccountSnapshot = RowboatAccountState;
const DEFAULT_STATE: RowboatAccountState = {
signedIn: false,
accessToken: null,
config: null,
};
export function useRowboatAccount() {
@ -28,7 +27,6 @@ export function useRowboatAccount() {
const next: RowboatAccountSnapshot = {
signedIn: result.signedIn,
accessToken: result.accessToken,
config: result.config,
};
setState(next);
return next;
@ -58,7 +56,6 @@ export function useRowboatAccount() {
return {
signedIn: state.signedIn,
accessToken: state.accessToken,
config: state.config,
isLoading,
refresh,
};

View file

@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from 'react';
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
import { finalizeDeepgramStream } from '@/lib/deepgram-finalize';
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
import { fetchRowboatConfig } from '@/hooks/use-rowboat-config';
import posthog from 'posthog-js';
import * as analytics from '@/lib/analytics';
@ -91,13 +92,19 @@ export function useVoiceMode() {
// Refresh cached auth details (called on warmup, not on mic click)
const refreshAuth = useCallback(async () => {
const account = await refreshRowboatAccount();
// Auth (account) and the websocket URL (bootstrap config) are
// separate concerns: the token comes from account refresh, the URL
// from the sign-in-independent config store.
const [account, rowboatConfig] = await Promise.all([
refreshRowboatAccount(),
fetchRowboatConfig(),
]);
if (
account?.signedIn &&
account.accessToken &&
account.config?.websocketApiUrl
rowboatConfig?.websocketApiUrl
) {
cachedAuth = { type: 'rowboat', url: account.config.websocketApiUrl, token: account.accessToken };
cachedAuth = { type: 'rowboat', url: rowboatConfig.websocketApiUrl, token: account.accessToken };
} else {
const config = await window.ipc.invoke('voice:getConfig', null);
if (config?.deepgram) {

View file

@ -336,3 +336,18 @@ export function settingsTabChanged(tab: string) {
export function onboardingCompleted() {
posthog.capture('onboarding_completed')
}
// A provider connect seeded the assistant model (only happens when none was
// configured). `recommended` = the backend's flavor recommendation was in
// the provider's live list; false = first-listed fallback. Flavor only —
// never provider instance ids, keys, or endpoints.
export function llmInitialModelSelected(props: {
flavor: string
model: string
recommended: boolean
taskOverridesSeeded: number
source: 'connect' | 'onboarding'
}) {
const { taskOverridesSeeded, ...rest } = props
posthog.capture('llm_initial_model_selected', { ...rest, task_overrides_seeded: taskOverridesSeeded })
}

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 ?? {} };
}