From 5729e21d770a3e0df32962c5c11a2a559961d40d Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:42:40 +0530 Subject: [PATCH 1/6] refactor(x): unify model listing behind one catalog pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One function in core (getModelCatalog) now answers "which providers are connected and what models does each offer", treating every provider the same way — Rowboat gateway, ChatGPT subscription (codex), BYOK keys, and local endpoints. Each entry carries { id, flavor, status, savedModel, models }: id is the provider *instance* (what ModelRef.provider joins on), flavor is the provider *type* (display naming, listing mechanics) — one instance per flavor today, so id === flavor key, but a future multi-key setup stays additive. - models:list reshaped to serve the catalog plus the effective default; req gains optional refreshProvider (Retry / Refresh models) - main-side list cache keyed on a credential fingerprint (auto-invalidates on key change; failures retry after 30s) — the gateway is no longer hit on every snapshot rebuild - useModels collapses from 4 IPC calls to 1: no more renderer-side flavor sets, sign-in awareness, or raw models.json reads - ModelSelector drops the live-group fetch machinery; groups render uniformly with an inline error row + Retry. The only renderer-side fetch left is probing typed-but-unsaved credentials (settings forms) - channels bridge lists from the same catalog, so WhatsApp/Telegram pickers now match the desktop picker Co-Authored-By: Claude Fable 5 --- apps/x/apps/main/src/ipc.ts | 22 +- .../src/components/model-selector.test.tsx | 17 +- .../src/components/model-selector.tsx | 143 +++++---- .../renderer/src/hooks/use-models.test.tsx | 143 ++++++--- apps/x/apps/renderer/src/hooks/use-models.ts | 215 +++++-------- apps/x/packages/core/src/channels/service.ts | 24 +- .../packages/core/src/models/catalog.test.ts | 164 ++++++++++ apps/x/packages/core/src/models/catalog.ts | 301 ++++++++++++++++++ apps/x/packages/shared/src/ipc.ts | 25 +- 9 files changed, 767 insertions(+), 287 deletions(-) create mode 100644 apps/x/packages/core/src/models/catalog.test.ts create mode 100644 apps/x/packages/core/src/models/catalog.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index cd8c3551..a4f1f9d9 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -33,15 +33,13 @@ 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 { 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 +1247,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); diff --git a/apps/x/apps/renderer/src/components/model-selector.test.tsx b/apps/x/apps/renderer/src/components/model-selector.test.tsx index dba96921..ca718b8b 100644 --- a/apps/x/apps/renderer/src/components/model-selector.test.tsx +++ b/apps/x/apps/renderer/src/components/model-selector.test.tsx @@ -24,23 +24,12 @@ let handlers: Record Promise> = {} } 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', savedModel: 'gpt-5.4', models: [{ id: 'gpt-5.4' }] }, + { id: 'anthropic', flavor: 'anthropic', status: 'ok', savedModel: 'claude-opus-4-8', models: [{ id: 'claude-opus-4-8' }] }, ], - }) - 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' }, }) } diff --git a/apps/x/apps/renderer/src/components/model-selector.tsx b/apps/x/apps/renderer/src/components/model-selector.tsx index 4946c0a3..768765b2 100644 --- a/apps/x/apps/renderer/src/components/model-selector.tsx +++ b/apps/x/apps/renderer/src/components/model-selector.tsx @@ -47,42 +47,42 @@ 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 one remaining renderer-side fetch: probing a provider that is being +// configured RIGHT NOW (credentials typed into a form but not yet saved). +// Connected providers all come pre-listed through the unified catalog +// (useModels); this path exists only because unsaved credentials, by +// definition, aren't in that catalog. Pinned models (the app default) render +// first so the model that actually runs stays pickable while the fetch is +// pending or failed. Probe-fetched ids carry no reasoning metadata, so the +// effort control stays hidden for them. // // 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 +function ProbeProviderGroupItems({ flavor, apiKey, baseURL, label, pinnedModels, filter, onModelRowsChange }: { + flavor: ProviderModelsFlavor + apiKey: string + baseURL: string 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 { status, models, error, refetch } = useProviderModels({ flavor, apiKey, 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]) + onModelRowsChange(flavor, hasModelRows) + }, [flavor, hasModelRows, onModelRowsChange]) if (!hasModelRows && !showStatus) return null return ( <> {label} {visible.map((m) => { - const key = `${group.flavor}/${m}` + const key = `${flavor}/${m}` return ( {m} @@ -238,54 +238,55 @@ 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) + // Probe mode: the form's typed credentials trump anything saved — an + // unsaved provider isn't in the catalog, so its list is probe-fetched. const liveFlavor = liveCredentials?.flavor const liveApiKey = liveCredentials?.apiKey.trim() ?? '' const liveBaseURL = liveCredentials?.baseURL.trim() ?? '' + const probeActive = Boolean(providerFilter && liveFlavor === providerFilter && (liveApiKey || liveBaseURL)) + const groups = useMemo(() => { + if (probeActive) return [] 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, probeActive]) // 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 + // matching is a case-insensitive substring test on the model id. The probe + // group filters itself and reports whether it still has rows, so the // parent can render the global "No models match" row. const [modelFilter, setModelFilter] = useState('') const modelFilterInputRef = useRef(null) - const [liveGroupHasRows, setLiveGroupHasRows] = useState>({}) + const [probeHasRows, setProbeHasRows] = useState(true) const modelFilterValue = modelFilter.trim().toLowerCase() - const handleLiveGroupRows = useCallback((flavor: string, hasRows: boolean) => { - setLiveGroupHasRows((prev) => (prev[flavor] === hasRows ? prev : { ...prev, [flavor]: hasRows })) + const handleProbeRows = useCallback((_flavor: string, hasRows: boolean) => { + setProbeHasRows(hasRows) }, []) // 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-scoped picker only shows it when it belongs to that provider. + // provider's list failed, or its provider was removed from config) — the + // picker must never be missing the model that actually runs. The probe + // group pins the default itself, so it opts out here. A provider-scoped + // picker only shows it when it belongs to that provider. const standaloneDefault = useMemo(() => { - if (!defaultModel) return null + if (!defaultModel || probeActive) 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]) + }, [groups, defaultModel, providerFilter, probeActive]) const standaloneVisible = standaloneDefault !== null && (!modelFilterValue || standaloneDefault.model.toLowerCase().includes(modelFilterValue)) @@ -297,15 +298,14 @@ export function ModelSelector({ (o.label ?? o.id).toLowerCase().includes(modelFilterValue) || o.id.toLowerCase().includes(modelFilterValue)) }, [staticOptions, modelFilterValue]) 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 + // Nothing matches anywhere → "No models match". A probe that hasn't + // reported yet (first render after opening) counts as having rows so the // empty row never flashes. 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) => g.models.some((m) => m.toLowerCase().includes(modelFilterValue))) + || (probeActive && probeHasRows) const handleModelChange = useCallback((key: string) => { if (lockedModel) return @@ -397,7 +397,7 @@ export function ModelSelector({ // open-focus (DropdownMenu.Content has no onOpenAutoFocus). if (open) { setModelFilter('') - setLiveGroupHasRows({}) + setProbeHasRows(true) setTimeout(() => modelFilterInputRef.current?.focus(), 0) } }} @@ -444,7 +444,7 @@ export function ModelSelector({ align={variant === 'field' ? 'start' : 'end'} className={cn('p-0 overflow-hidden', variant === 'field' && 'min-w-[var(--radix-dropdown-menu-trigger-width)]')} > - {!staticOptions && groups.length === 0 && !standaloneDefault && !sentinel && !allowCustom ? ( + {!staticOptions && !probeActive && groups.length === 0 && !standaloneDefault && !sentinel && !allowCustom ? (
Connect a provider in Settings
@@ -503,44 +503,53 @@ export function ModelSelector({ )} {!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) - return ( - - ) - } const visibleModels = modelFilterValue ? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue)) : g.models - if (visibleModels.length === 0) return null + // 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 ( - + {label} {visibleModels.map((m) => { - const key = `${g.flavor}/${m}` + const key = `${g.id}/${m}` return ( {m} ) })} + {showError && ( + { + e.preventDefault() + refresh(g.id) + }} + className="text-xs" + > + {g.error || 'Failed to load models'} + Retry + + )} ) })} + {probeActive && liveFlavor && ( + + )} {modelFilterValue && !anyModelRowVisible && ( allowCustom ? ( // Escape hatch for ids the lists don't carry (local diff --git a/apps/x/apps/renderer/src/hooks/use-models.test.tsx b/apps/x/apps/renderer/src/hooks/use-models.test.tsx index a1819c68..48194c44 100644 --- a/apps/x/apps/renderer/src/hooks/use-models.test.tsx +++ b/apps/x/apps/renderer/src/hooks/use-models.test.tsx @@ -8,6 +8,7 @@ import { __resetModelsForTests, useModels } from './use-models' // captures listeners so tests can fire main-process broadcasts. let handlers: Record Promise> = {} let invokeCounts: Record = {} +let invokeArgs: Record = {} let ipcListeners: Record void>> = {} ;(window as unknown as { ipc: unknown }).ipc = { @@ -19,21 +20,32 @@ let ipcListeners: Record 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): 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 + savedModel?: 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 +53,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 +73,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 +99,52 @@ 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('pins the saved model, orders the default group first, and passes error status through', async () => { + serveCatalog({ + providers: [ + { id: 'openai', savedModel: 'gpt-4.1', models: [{ id: 'gpt-5.4' }, { id: 'gpt-4.1' }] }, + { + id: 'ollama', + status: 'error', + error: 'connection refused', + savedModel: 'llama3', + models: [], + }, + ], + defaultModel: { provider: 'ollama', model: 'llama3' }, }) - 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) with its saved + // model still pickable; the failed status and error travel with it. + expect(result.current.groups[0]).toEqual({ + id: 'ollama', flavor: 'ollama', models: ['llama3'], status: 'error', error: 'connection refused', + }) + // Saved model leads its group ahead of the fetched list order. + expect(result.current.groups[1]).toEqual({ + id: 'openai', flavor: 'openai', models: ['gpt-4.1', 'gpt-5.4'], status: 'ok', + }) + }) + + 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 +153,51 @@ 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', savedModel: 'llama3', 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)) + + act(() => result.current.refresh('ollama')) + await waitFor(() => expect(invokeCounts['models:list']).toBe(2)) + expect(invokeArgs['models:list'][1]).toEqual({ refreshProvider: 'ollama' }) }) }) diff --git a/apps/x/apps/renderer/src/hooks/use-models.ts b/apps/x/apps/renderer/src/hooks/use-models.ts index a525d714..fd23839b 100644 --- a/apps/x/apps/renderer/src/hooks/use-models.ts +++ b/apps/x/apps/renderer/src/hooks/use-models.ts @@ -1,45 +1,48 @@ 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(['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(['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 // 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 } 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). + */ + refresh: (providerId?: string) => void } const EMPTY_SNAPSHOT: ModelsSnapshot = { @@ -61,122 +64,72 @@ 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 { - 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 { + 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 = {} + const catalogByProvider: Record = {} + const groups: ModelPickerGroup[] = [] - // Full catalog per provider (gateway + models.dev cloud providers). - const catalog: Record = {} - 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'] }) + // The provider's saved default model leads its group; it stays pickable + // even when the fetched list doesn't carry it (or failed entirely). + const models: string[] = [] + if (p.savedModel) models.push(p.savedModel) + for (const id of ids) { + if (!models.includes(id)) models.push(id) + } + groups.push({ + id: p.id, + flavor: p.flavor, + models, + 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 - 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): 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() + void buildSnapshot(refreshProvider) .then((next) => { if (seq !== fetchSeq) return snapshot = next @@ -192,8 +145,8 @@ function startFetch(): void { }) } -function refreshModels(): void { - startFetch() +function refreshModels(providerId?: string): void { + startFetch(typeof providerId === 'string' ? providerId : undefined) } function ensureLoaded(): void { @@ -203,17 +156,19 @@ function ensureLoaded(): void { function wireGlobalEvents(): void { if (wired) return wired = true + // Event payloads must not leak into startFetch's refreshProvider arg. + const refetch = () => 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), ] } diff --git a/apps/x/packages/core/src/channels/service.ts b/apps/x/packages/core/src/channels/service.ts index 946a2abc..7a55e52b 100644 --- a/apps/x/packages/core/src/channels/service.ts +++ b/apps/x/packages/core/src/channels/service.ts @@ -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 { - 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 { diff --git a/apps/x/packages/core/src/models/catalog.test.ts b/apps/x/packages/core/src/models/catalog.test.ts new file mode 100644 index 00000000..c2b71de6 --- /dev/null +++ b/apps/x/packages/core/src/models/catalog.test.ts @@ -0,0 +1,164 @@ +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 => { + 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'; + +function serveConfig(providers: Record, defaultFlavor = 'openai'): void { + mocks.getConfig.mockImplementation(async () => ({ + provider: { flavor: defaultFlavor }, + model: 'gpt-5.4', + providers, + })); +} + +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', model: 'llama3' }, + }); + 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({ savedModel: 'llama3', models: [{ id: 'llama3' }, { id: 'qwen3' }] }); + expect(catalog.defaultModel).toEqual({ provider: 'openai', model: 'gpt-5.4' }); + }); + + it('skips providers-map entries that carry no credential', async () => { + serveConfig({ + openai: { model: 'gpt-5.4' }, // no key — not connected + anthropic: { apiKey: 'sk-b' }, + }); + mocks.listModelsForProvider.mockResolvedValue(['claude-opus-4-8']); + + const catalog = await getModelCatalog(); + expect(catalog.providers.map((p) => p.id)).toEqual(['anthropic']); + }); + + 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', model: 'llama3' } }); + mocks.listModelsForProvider.mockRejectedValue(new Error('connection refused')); + + const catalog = await getModelCatalog(); + expect(catalog.providers[0]).toMatchObject({ + id: 'ollama', + status: 'error', + error: 'connection refused', + savedModel: 'llama3', + 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); + }); +}); diff --git a/apps/x/packages/core/src/models/catalog.ts b/apps/x/packages/core/src/models/catalog.ts new file mode 100644 index 00000000..0b9ca46b --- /dev/null +++ b/apps/x/packages/core/src/models/catalog.ts @@ -0,0 +1,301 @@ +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, defaultSelection, + * 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; + /** The provider's saved default model from models.json, if any. */ + savedModel?: 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 = { + 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(); +const inFlight = new Map>(); + +type ProviderConfig = z.infer; + +interface DiscoveredProvider { + id: string; + /** Absent for rowboat/codex — their auth lives outside models.json. */ + config?: ProviderConfig; + savedModel?: string; + /** + * Saved models[] from config — the list of last resort for flavors the + * live fetch doesn't support (an unknown flavor in the providers map). + */ + savedModels?: string[]; +} + +async function readModelConfig(): Promise | null> { + try { + const repo = container.resolve("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 + * (an entry counts as connected once it carries some credential). The + * default provider's entry leads, matching picker ordering. + */ +async function discoverProviders(): Promise { + const discovered: DiscoveredProvider[] = []; + + if (await isSignedIn().catch(() => false)) { + discovered.push({ id: "rowboat" }); + } + try { + const chatgpt = await getChatGPTStatus(); + if (chatgpt.signedIn) discovered.push({ id: "codex" }); + } catch { + // ChatGPT status failures must never break the main list. + } + + const cfg = await readModelConfig(); + const providersMap = cfg?.providers ?? {}; + const defaultFlavor = cfg?.provider.flavor ?? ""; + const flavors = Object.keys(providersMap) + .sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0)); + + for (const flavor of flavors) { + const entry = providersMap[flavor] ?? {}; + const apiKey = entry.apiKey?.trim() ?? ""; + const baseURL = entry.baseURL?.trim() ?? ""; + if (!apiKey && !baseURL) continue; // provider not configured + const savedModel = entry.model || undefined; + const parsed = LlmProvider.safeParse({ ...entry, flavor }); + if (!parsed.success) { + // Unknown flavor: not live-listable — serve the saved list. + discovered.push({ id: flavor, savedModel, savedModels: entry.models ?? [] }); + continue; + } + const config = parsed.data; + if (config.flavor === "aigateway" && !config.baseURL) { + config.baseURL = AIGATEWAY_DEFAULT_BASE_URL; + } + discovered.push({ id: flavor, config, savedModel }); + } + + 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, +): Promise { + 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.id) && (modelsDevByFlavor.get(provider.id)?.length ?? 0) > 0) { + models = modelsDevByFlavor.get(provider.id) ?? []; + } else if (!provider.config) { + models = (provider.savedModels ?? []).map((id) => ({ id })); + } 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, + forceRefresh: boolean, +): Promise { + 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 { + 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(); + if (discovered.some((p) => MODELS_DEV_FLAVORS.has(p.id))) { + 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, + // One instance per flavor today, so the id IS the flavor key. + flavor: provider.config?.flavor ?? provider.id, + status: entry.status, + ...(entry.error ? { error: entry.error } : {}), + ...(provider.savedModel ? { savedModel: provider.savedModel } : {}), + 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(); +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 55daa37f..c3489a92 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -646,22 +646,39 @@ 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 / defaultSelection / + // 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(), + // The provider's saved default model from models.json, if any. + savedModel: z.string().optional(), models: z.array(z.object({ id: z.string(), name: z.string().optional(), - 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': { From b0000a10d9bd3a4b5493d4e3848d6de1f7ff66f9 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:36:20 +0530 Subject: [PATCH 2/6] feat(x): rowboat config IPC + model recommendations plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RowboatApiConfig gains optional modelRecommendations (one model per provider FLAVOR, native id format) matching the merged backend payload; optional so older API deployments and failed fetches never break parsing - new rowboat:getConfig IPC serves the unauthenticated /v1/config bootstrap independent of sign-in (signed-out BYOK users need recommendations when connecting a provider); account:getRowboat is back to pure account state (signedIn/accessToken) — config is no longer a property of the account - renderer useRowboatConfig() hook: one shared store for all config consumers (appUrl dialogs/sidebar/settings), plus imperative fetchRowboatConfig() for event-time consumers (voice, meeting) that must await the value when starting a session - selectInitialModel(flavor, models, recommendations): pure spec-ordered initial pick (recommendation if listed, else first, else null) — runs only when a provider is first connected, never over a saved choice Inert plumbing: nothing calls selectInitialModel yet. Co-Authored-By: Claude Fable 5 --- apps/x/apps/main/src/ipc.ts | 15 ++-- .../src/components/billing-error-dialog.tsx | 19 +----- .../components/settings/account-settings.tsx | 11 +-- .../src/components/sidebar-content.tsx | 9 +-- .../src/hooks/use-rowboat-config.test.tsx | 68 +++++++++++++++++++ .../renderer/src/hooks/use-rowboat-config.ts | 67 ++++++++++++++++++ .../src/hooks/useMeetingTranscription.ts | 12 +++- .../renderer/src/hooks/useRowboatAccount.ts | 11 ++- .../x/apps/renderer/src/hooks/useVoiceMode.ts | 13 +++- .../core/src/models/initial-selection.test.ts | 32 +++++++++ .../core/src/models/initial-selection.ts | 34 ++++++++++ apps/x/packages/shared/src/ipc.ts | 9 ++- apps/x/packages/shared/src/rowboat-account.ts | 9 +++ 13 files changed, 257 insertions(+), 52 deletions(-) create mode 100644 apps/x/apps/renderer/src/hooks/use-rowboat-config.test.tsx create mode 100644 apps/x/apps/renderer/src/hooks/use-rowboat-config.ts create mode 100644 apps/x/packages/core/src/models/initial-selection.test.ts create mode 100644 apps/x/packages/core/src/models/initial-selection.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index a4f1f9d9..0a1e4956 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1327,18 +1327,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('granolaConfigRepo'); const config = await repo.getConfig(); diff --git a/apps/x/apps/renderer/src/components/billing-error-dialog.tsx b/apps/x/apps/renderer/src/components/billing-error-dialog.tsx index e0bf2521..da26f38e 100644 --- a/apps/x/apps/renderer/src/components/billing-error-dialog.tsx +++ b/apps/x/apps/renderer/src/components/billing-error-dialog.tsx @@ -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(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) diff --git a/apps/x/apps/renderer/src/components/settings/account-settings.tsx b/apps/x/apps/renderer/src/components/settings/account-settings.tsx index 9833d50b..be6a40f9 100644 --- a/apps/x/apps/renderer/src/components/settings/account-settings.tsx +++ b/apps/x/apps/renderer/src/components/settings/account-settings.tsx @@ -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(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') { diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index c873b1bc..85182e0d 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -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(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) { diff --git a/apps/x/apps/renderer/src/hooks/use-rowboat-config.test.tsx b/apps/x/apps/renderer/src/hooks/use-rowboat-config.test.tsx new file mode 100644 index 00000000..f06abf4f --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/use-rowboat-config.test.tsx @@ -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 Promise> = {} +let invokeCounts: Record = {} + +;(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() + }) +}) diff --git a/apps/x/apps/renderer/src/hooks/use-rowboat-config.ts b/apps/x/apps/renderer/src/hooks/use-rowboat-config.ts new file mode 100644 index 00000000..c9f76139 --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/use-rowboat-config.ts @@ -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 + +// 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 | 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 { + 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() +} diff --git a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts index 4e5a73d5..3511a639 100644 --- a/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts +++ b/apps/x/apps/renderer/src/hooks/useMeetingTranscription.ts @@ -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 { diff --git a/apps/x/apps/renderer/src/hooks/useRowboatAccount.ts b/apps/x/apps/renderer/src/hooks/useRowboatAccount.ts index 1cab2414..c0f7985f 100644 --- a/apps/x/apps/renderer/src/hooks/useRowboatAccount.ts +++ b/apps/x/apps/renderer/src/hooks/useRowboatAccount.ts @@ -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 | 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, }; diff --git a/apps/x/apps/renderer/src/hooks/useVoiceMode.ts b/apps/x/apps/renderer/src/hooks/useVoiceMode.ts index d8c49098..2c5d6c45 100644 --- a/apps/x/apps/renderer/src/hooks/useVoiceMode.ts +++ b/apps/x/apps/renderer/src/hooks/useVoiceMode.ts @@ -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) { diff --git a/apps/x/packages/core/src/models/initial-selection.test.ts b/apps/x/packages/core/src/models/initial-selection.test.ts new file mode 100644 index 00000000..44ca5fc0 --- /dev/null +++ b/apps/x/packages/core/src/models/initial-selection.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { selectInitialModel } 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(); + }); +}); diff --git a/apps/x/packages/core/src/models/initial-selection.ts b/apps/x/packages/core/src/models/initial-selection.ts new file mode 100644 index 00000000..de6ad1d0 --- /dev/null +++ b/apps/x/packages/core/src/models/initial-selection.ts @@ -0,0 +1,34 @@ +/** + * 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. + * + * 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 function by design: the caller supplies 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). Recommendations are best-effort — an + * absent map, an unknown flavor, or a recommendation the provider doesn't + * serve all degrade to "first available model". + */ +export function selectInitialModel( + flavor: string, + availableModelIds: string[], + recommendations: Record | undefined, +): string | null { + const recommended = recommendations?.[flavor]; + if (recommended && availableModelIds.includes(recommended)) { + return recommended; + } + return availableModelIds[0] ?? null; +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index c3489a92..b22c3270 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -790,9 +790,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(), diff --git a/apps/x/packages/shared/src/rowboat-account.ts b/apps/x/packages/shared/src/rowboat-account.ts index efa484e3..526adf30 100644 --- a/apps/x/packages/shared/src/rowboat-account.ts +++ b/apps/x/packages/shared/src/rowboat-account.ts @@ -11,4 +11,13 @@ 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(), + // One recommended model id per provider FLAVOR (e.g. { openai: "gpt-5.4", + // openrouter: "anthropic/claude-opus-4.8" }), in each provider's native id + // format. A hint for the INITIAL selection when a provider is first + // connected — never a catalog, and never applied over a saved choice + // (see core/models/initial-selection.ts). 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.string()).optional(), }); From f39daa9f5d2f4e4115b403557469a2f4d72c1fe7 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:51:58 +0530 Subject: [PATCH 3/6] =?UTF-8?q?refactor(x)!:=20models.json=20v2=20?= =?UTF-8?q?=E2=80=94=20providers=20carry=20credentials,=20models=20live=20?= =?UTF-8?q?in=20assistantModel/taskModels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1 config fossilized three schema generations: a top-level provider/model pair (single-provider era), a providers map duplicating credentials AND caching model lists (pre-dynamic-listing era), plus defaultSelection and flat category overrides (hybrid era) — while several effective models existed only as hidden curated branches in code. v2 is the intended shape: { version: 2, providers: { : { flavor, apiKey?, baseURL?, … } }, assistantModel: { provider, model }, taskModels: { knowledgeGraph?, meetingNotes?, liveNoteAgent?, autoPermissionDecision?, chatTitle? }, deferBackgroundTasks? } - one-time boot migration (core/models/migrate.ts, invoked from ensureConfig): evaluates the v1 resolution rules — including the curated signed-in defaults — one last time and writes the answers explicitly, so the simplified resolvers produce identical effective models; task overrides are written only where the old effective model differs from inherit-from-assistant; curated ids survive solely as frozen literals in the migration - defaults.ts: getDefaultModelAndProvider reads assistantModel, period; getCategoryModel/getChatTitleModel are "override else assistant"; all SIGNED_IN_* constants and the repo's gpt-5.4 bootstrap deleted - rowboat sign-in = connecting a provider: with no saved assistant, the initial model is picked via selectInitialModel (backend recommendation if the gateway lists it, else first listed) and saved; sign-out clears rowboat-referencing selections (same dangling-ref cleanup as removing any provider). A saved assistant is never replaced — signing in no longer hijacks a BYOK selection - IPC: models:saveConfig replaced by models:setProvider/removeProvider; models:updateConfig speaks v2 keys; models:test takes {provider, model} - renderer call sites (settings dialog, onboarding, composer) translated; the settings dialog's delete-provider path collapses from ~100 lines of top-level-pair juggling to removeProvider + best-effort promotion - migration dry-run verified against a real-world signed-in config Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 7 +- apps/x/apps/main/src/chatgpt-signin.ts | 10 +- apps/x/apps/main/src/ipc.ts | 9 +- apps/x/apps/main/src/oauth-handler.ts | 10 + .../components/chat-input-with-mentions.tsx | 2 +- .../src/components/model-selector.test.tsx | 4 +- .../onboarding/use-onboarding-state.ts | 14 +- .../src/components/settings-dialog.tsx | 308 +++++++----------- .../renderer/src/hooks/use-models.test.tsx | 28 +- apps/x/apps/renderer/src/hooks/use-models.ts | 9 +- apps/x/packages/core/src/auth/chatgpt-auth.ts | 5 + .../core/src/knowledge/inline_tasks.ts | 14 +- .../packages/core/src/models/catalog.test.ts | 39 ++- apps/x/packages/core/src/models/catalog.ts | 54 +-- .../core/src/models/chatgpt-selection.ts | 46 +++ apps/x/packages/core/src/models/defaults.ts | 154 ++------- .../packages/core/src/models/migrate.test.ts | 130 ++++++++ apps/x/packages/core/src/models/migrate.ts | 167 ++++++++++ apps/x/packages/core/src/models/repo.test.ts | 92 ++++++ apps/x/packages/core/src/models/repo.ts | 208 +++++++----- .../core/src/models/rowboat-selection.ts | 48 +++ apps/x/packages/shared/src/ipc.ts | 48 ++- apps/x/packages/shared/src/models.ts | 71 ++-- 23 files changed, 938 insertions(+), 539 deletions(-) create mode 100644 apps/x/packages/core/src/models/chatgpt-selection.ts create mode 100644 apps/x/packages/core/src/models/migrate.test.ts create mode 100644 apps/x/packages/core/src/models/migrate.ts create mode 100644 apps/x/packages/core/src/models/repo.test.ts create mode 100644 apps/x/packages/core/src/models/rowboat-selection.ts diff --git a/CLAUDE.md b/CLAUDE.md index a5fe24c8..dd8b2d5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: { : { 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 diff --git a/apps/x/apps/main/src/chatgpt-signin.ts b/apps/x/apps/main/src/chatgpt-signin.ts index 81e789d6..49cbf18e 100644 --- a/apps/x/apps/main/src/chatgpt-signin.ts +++ b/apps/x/apps/main/src/chatgpt-signin.ts @@ -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 { 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; } /** diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 0a1e4956..1007f93d 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1271,9 +1271,14 @@ export function setupIpcHandlers() { console.log(`[llm:generate] -> provider=${result.provider ?? '?'} model=${result.model ?? '?'} chars=${result.text?.length ?? 0}${result.error ? ` error=${result.error}` : ''}`); return result; }, - 'models:saveConfig': async (_event, args) => { + 'models:setProvider': async (_event, args) => { const repo = container.resolve('modelConfigRepo'); - await repo.setConfig(args); + await repo.setProvider(args.id, args.provider); + return { success: true }; + }, + 'models:removeProvider': async (_event, args) => { + const repo = container.resolve('modelConfigRepo'); + await repo.removeProvider(args.id); return { success: true }; }, 'models:updateConfig': async (_event, args) => { diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index e0d4b2fc..78d6c4d1 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -17,6 +17,7 @@ import { capture as analyticsCapture, identify as analyticsIdentify, reset as an import { isSignedIn } from '@x/core/dist/account/account.js'; import { getWebappUrl } from '@x/core/dist/config/remote-config.js'; import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js'; +import { applyRowboatInitialSelection, clearRowboatSelections } from '@x/core/dist/models/rowboat-selection.js'; function buildRedirectUri(port: number): string { return `http://localhost:${port}/oauth/callback`; @@ -339,6 +340,11 @@ export async function connectProvider(provider: string, credentials?: { clientId // multiple renderer hooks race to create the user, causing duplicates. let signedInUserId: string | undefined; if (provider === 'rowboat') { + // Signing in connects the rowboat provider: if no assistant + // model is saved yet, pick the initial one (recommendation if + // the gateway lists it, else first listed). Never replaces a + // saved choice; best-effort by design. + await applyRowboatInitialSelection(); try { const billing = await getBillingInfo(); if (billing.userId) { @@ -530,6 +536,10 @@ export async function disconnectProvider(provider: string): Promise<{ success: b if (provider === 'rowboat') { analyticsCapture('user_signed_out'); analyticsReset(); + // Signing out disconnects the rowboat provider: drop the model + // selections that reference it (same dangling-ref cleanup as removing + // any provider). The composer prompts for a new pick. + await clearRowboatSelections(); } // Notify renderer so sidebar, voice, and billing re-check state emitOAuthEvent({ provider, success: false }); diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 80bcd318..de7d7004 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -562,7 +562,7 @@ function ChatInputInner({ if (!model) return setSelectedModel(model) onSelectedModelChange?.(model) - void window.ipc.invoke('models:updateConfig', { defaultSelection: { provider: model.provider, model: model.model } }) + void window.ipc.invoke('models:updateConfig', { assistantModel: { provider: model.provider, model: model.model } }) .then(() => { window.dispatchEvent(new Event('models-config-changed')) }) .catch(() => { toast.error('Failed to save default model') }) }, [lockedModel, onSelectedModelChange]) diff --git a/apps/x/apps/renderer/src/components/model-selector.test.tsx b/apps/x/apps/renderer/src/components/model-selector.test.tsx index ca718b8b..7fea033f 100644 --- a/apps/x/apps/renderer/src/components/model-selector.test.tsx +++ b/apps/x/apps/renderer/src/components/model-selector.test.tsx @@ -26,8 +26,8 @@ let handlers: Record Promise> = {} function serveTwoProviders(): void { handlers['models:list'] = async () => ({ providers: [ - { id: 'openai', flavor: 'openai', status: 'ok', savedModel: 'gpt-5.4', models: [{ id: 'gpt-5.4' }] }, - { id: 'anthropic', flavor: 'anthropic', status: 'ok', savedModel: 'claude-opus-4-8', models: [{ id: 'claude-opus-4-8' }] }, + { id: 'openai', flavor: 'openai', status: 'ok', models: [{ id: 'gpt-5.4' }] }, + { id: 'anthropic', flavor: 'anthropic', status: 'ok', models: [{ id: 'claude-opus-4-8' }] }, ], defaultModel: { provider: 'openai', model: 'gpt-5.4' }, }) diff --git a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts index ee52c7d2..e95427b8 100644 --- a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts +++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts @@ -484,11 +484,15 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou model = typed } - // `models` is the user's curated assistant-model list (shown in Settings), - // NOT the full provider catalog. Onboarding seeds it with just the selected - // model; users add more from Settings. Persisting the whole catalog here - // rendered every model as a separate assistant-model row. - await window.ipc.invoke("models:saveConfig", { provider, model, models: model ? [model] : [] }) + // v2 writes: the provider entry carries credentials only; the resolved + // model becomes the assistant model (onboarding's connect doubles as + // the initial selection). + await window.ipc.invoke("models:setProvider", { id: llmProvider, provider }) + if (model) { + await window.ipc.invoke("models:updateConfig", { + assistantModel: { provider: llmProvider, model }, + }) + } window.dispatchEvent(new Event('models-config-changed')) setTestState({ status: "success" }) setConnectedFlavors(prev => new Set(prev).add(llmProvider)) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index db65997c..927aaff1 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -33,6 +33,7 @@ import { DEFAULT_TURN_LIMITS_SETTINGS } from "@x/shared/src/turn-limits.js" import type { ipc as ipcShared } from "@x/shared" import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning" import { useProviderModels } from "@/hooks/use-provider-models" +import { useRowboatConfig } from "@/hooks/use-rowboat-config" import { useChatGPT } from "@/hooks/useChatGPT" import { ModelSelector, type ModelRef } from "@/components/model-selector" import { useModels } from "@/hooks/use-models" @@ -548,6 +549,9 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b const activeConfig = providerConfigs[provider] // Live per-key model list for the active provider — drives the primary // model area. The per-function fields below still use the static catalog. + // Backend model recommendations (flavor-keyed) — used when a provider + // becomes the assistant without an explicit model pick. + const modelRecommendations = useRowboatConfig()?.modelRecommendations const providerModels = useProviderModels({ flavor: provider, apiKey: activeConfig.apiKey, @@ -624,6 +628,11 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b const result = await window.ipc.invoke("workspace:readFile", { path: "config/models.json", }) + // models.json v2: providers carry credentials only; the assistant + // model and per-task overrides live at the top level. This screen's + // per-provider "model" state is a UI concept — hydrated from + // assistantModel for its provider, empty for the rest (the picker + // lists come from the catalog / live probe, not from config). const parsed = JSON.parse(result.data) setDeferBackgroundTasks(parsed?.deferBackgroundTasks === true) setDeferExplicit(typeof parsed?.deferBackgroundTasks === "boolean") @@ -631,51 +640,32 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b setSavedProviders(new Set( Object.keys(parsed?.providers ?? {}).filter((k): k is LlmProviderFlavor => knownFlavors.has(k)) )) - if (parsed?.provider?.flavor && parsed?.model) { - const flavor = parsed.provider.flavor as LlmProviderFlavor - setProvider(flavor) - setDefaultProvider(flavor) - setProviderConfigs(prev => { - const next = { ...prev }; - // Hydrate all saved providers from the providers map - if (parsed.providers) { - for (const [key, entry] of Object.entries(parsed.providers)) { - if (key in next) { - const e = entry as any; - const savedModels: string[] = Array.isArray(e.models) && e.models.length > 0 - ? e.models - : e.model ? [e.model] : [""]; - next[key as LlmProviderFlavor] = { - apiKey: e.apiKey || "", - baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""), - models: savedModels, - knowledgeGraphModel: asString(e.knowledgeGraphModel), - meetingNotesModel: asString(e.meetingNotesModel), - liveNoteAgentModel: asString(e.liveNoteAgentModel), - autoPermissionDecisionModel: asString(e.autoPermissionDecisionModel), - }; - } - } - } - // Active provider takes precedence from top-level config, - // but only if it exists in the providers map (wasn't deleted) - if (parsed.providers?.[flavor]) { - const existingModels = next[flavor].models; - const activeModels = existingModels[0] === parsed.model - ? existingModels - : [parsed.model, ...existingModels.filter((m: string) => m && m !== parsed.model)]; - next[flavor] = { - apiKey: parsed.provider.apiKey || "", - baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""), - models: activeModels.length > 0 ? activeModels : [""], - knowledgeGraphModel: asString(parsed.knowledgeGraphModel), - meetingNotesModel: asString(parsed.meetingNotesModel), - liveNoteAgentModel: asString(parsed.liveNoteAgentModel), - autoPermissionDecisionModel: asString(parsed.autoPermissionDecisionModel), - }; - } - return next; - }) + const assistant = parsed?.assistantModel as { provider?: string; model?: string } | undefined + const taskModels = (parsed?.taskModels ?? {}) as Record + const taskStringFor = (key: string, providerId: string): string => { + const ref = taskModels[key] + return ref?.provider === providerId ? asString(ref.model) : "" + } + setProviderConfigs(prev => { + const next = { ...prev }; + for (const [key, entry] of Object.entries(parsed?.providers ?? {})) { + if (!(key in next)) continue + const e = entry as { apiKey?: string; baseURL?: string }; + next[key as LlmProviderFlavor] = { + apiKey: e.apiKey || "", + baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""), + models: assistant?.provider === key && assistant.model ? [assistant.model] : [""], + knowledgeGraphModel: taskStringFor("knowledgeGraph", key), + meetingNotesModel: taskStringFor("meetingNotes", key), + liveNoteAgentModel: taskStringFor("liveNoteAgent", key), + autoPermissionDecisionModel: taskStringFor("autoPermissionDecision", key), + }; + } + return next; + }) + if (assistant?.provider && knownFlavors.has(assistant.provider)) { + setProvider(assistant.provider as LlmProviderFlavor) + setDefaultProvider(assistant.provider as LlmProviderFlavor) } } catch { // No existing config or parse error - use defaults @@ -727,32 +717,36 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b toast.error("Enter a model to connect") return } - // The silently resolved model takes the primary slot; the rest of the - // saved list is preserved (same semantics setPrimaryModel had). - const existing = activeConfig.models.map(m => m.trim()) - const allModels = [model, ...existing.slice(1).filter(m => m && m !== model)] - const providerConfig = { - provider: { - flavor: provider, - apiKey: activeConfig.apiKey.trim() || undefined, - baseURL: activeConfig.baseURL.trim() || undefined, - }, - model: allModels[0] || "", - models: allModels, - ...(rowboatConnected ? {} : { - knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined, - meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined, - liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined, - autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined, - }), + const providerEntry = { + flavor: provider, + apiKey: activeConfig.apiKey.trim() || undefined, + baseURL: activeConfig.baseURL.trim() || undefined, } - const result = await window.ipc.invoke("models:test", providerConfig) + const result = await window.ipc.invoke("models:test", { provider: providerEntry, model }) if (result.success) { - await window.ipc.invoke("models:saveConfig", providerConfig) + // v2 writes: the provider entry carries credentials only; the model + // choice becomes the assistant model, and the BYOK per-task strings + // become task overrides scoped to this provider ('' clears → inherit). + const taskRef = (value: string) => { + const trimmed = value.trim() + return trimmed ? { provider, model: trimmed } : null + } + await window.ipc.invoke("models:setProvider", { id: provider, provider: providerEntry }) + await window.ipc.invoke("models:updateConfig", { + assistantModel: { provider, model }, + ...(rowboatConnected ? {} : { + taskModels: { + knowledgeGraph: taskRef(activeConfig.knowledgeGraphModel), + meetingNotes: taskRef(activeConfig.meetingNotesModel), + liveNoteAgent: taskRef(activeConfig.liveNoteAgentModel), + autoPermissionDecision: taskRef(activeConfig.autoPermissionDecisionModel), + }, + }), + }) setDefaultProvider(provider) setProviderConfigs(prev => ({ ...prev, - [provider]: { ...prev[provider], models: allModels }, + [provider]: { ...prev[provider], models: [model] }, })) setSavedProviders(prev => new Set(prev).add(provider)) setTestState({ status: "success" }) @@ -784,134 +778,63 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b } }, [canTest, resolvedModel, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle]) + // "Set as default" = choose this provider's model as the assistant model. + // The UI state only knows a model for the provider that was already the + // assistant, so other providers resolve one the same way a first connect + // does: recommendation if the provider lists it, else the first listed. const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => { const config = providerConfigs[prov] - const allModels = config.models.map(m => m.trim()).filter(Boolean) - if (!allModels[0]) return try { - await window.ipc.invoke("models:saveConfig", { - provider: { - flavor: prov, - apiKey: config.apiKey.trim() || undefined, - baseURL: config.baseURL.trim() || undefined, - }, - model: allModels[0], - models: allModels, - ...(rowboatConnected ? {} : { - knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined, - meetingNotesModel: config.meetingNotesModel.trim() || undefined, - liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined, - autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined, - }), - }) + let model = config.models[0]?.trim() || "" + if (!model) { + const listRes = await window.ipc.invoke("models:listForProvider", { + provider: { + flavor: prov, + apiKey: config.apiKey.trim() || undefined, + baseURL: config.baseURL.trim() || undefined, + }, + }) + const list = listRes.success ? listRes.models ?? [] : [] + const recommended = modelRecommendations?.[prov] + model = (recommended && list.includes(recommended)) ? recommended : (list[0] ?? "") + } + if (!model) { + toast.error("Couldn't determine a model for this provider") + return + } + await window.ipc.invoke("models:updateConfig", { assistantModel: { provider: prov, model } }) setDefaultProvider(prov) + setProviderConfigs(prev => ({ + ...prev, + [prov]: { ...prev[prov], models: [model] }, + })) window.dispatchEvent(new Event('models-config-changed')) toast.success("Default provider updated") } catch { toast.error("Failed to set default provider") } - }, [providerConfigs, rowboatConnected]) + }, [providerConfigs, modelRecommendations]) const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => { const isDefaultProv = defaultProvider === prov try { - const result = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" }) - let parsed = JSON.parse(result.data) + // Removes the entry AND any assistant/task selection referencing it + // (repo-side dangling cleanup). + await window.ipc.invoke("models:removeProvider", { id: prov }) - // Disconnecting the default provider: silently promote another - // connected provider first — the connect-only UI has no usable - // set-as-default step to send the user to. Prefer the provider the - // user's explicit defaultSelection points at; promotion goes through - // the same models:saveConfig path Set-as-default uses, so the repo - // writes a valid top-level provider/model. + // Disconnecting the assistant's provider: promote another connected + // provider — the connect-only UI has no usable set-as-default step to + // send the user to. Best-effort; with none left the composer shows + // its connect hint. let promoted: LlmProviderFlavor | null = null if (isDefaultProv) { - const selProvider = parsed?.defaultSelection?.provider - const candidates = Object.keys(parsed?.providers ?? {}) - .filter((k): k is LlmProviderFlavor => k !== prov && k in providerConfigs) - .sort((a, b) => (a === selProvider ? -1 : b === selProvider ? 1 : 0)) - for (const candidate of candidates) { - const config = providerConfigs[candidate] - const allModels = config.models.map(m => m.trim()).filter(Boolean) - // Same silent precedence as connect, minus the live list we don't - // have here: the provider's saved model, else its preferred default. - const model = allModels[0] || preferredDefaults[candidate] || "" - if (!model) continue - await window.ipc.invoke("models:saveConfig", { - provider: { - flavor: candidate, - apiKey: config.apiKey.trim() || undefined, - baseURL: config.baseURL.trim() || undefined, - }, - model, - models: allModels.length > 0 ? allModels : [model], - ...(rowboatConnected ? {} : { - knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined, - meetingNotesModel: config.meetingNotesModel.trim() || undefined, - liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined, - autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined, - }), - }) + const candidate = [...savedProviders].find((p): p is LlmProviderFlavor => p !== prov) + if (candidate) { + await handleSetDefault(candidate) promoted = candidate - break - } - if (promoted) { - // saveConfig rewrote top-level and the providers map — re-read so - // the deletion write below doesn't clobber the promotion. - const fresh = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" }) - parsed = JSON.parse(fresh.data) } } - if (parsed?.providers?.[prov]) { - delete parsed.providers[prov] - } - // A defaultSelection pointing at the removed provider is dangling — - // drop it so llm:getDefaultModel falls back cleanly. - if (parsed?.defaultSelection?.provider === prov) { - delete parsed.defaultSelection - } - // If the deleted provider is the current top-level active one, - // switch top-level config to the current default provider - if (parsed?.provider?.flavor === prov && defaultProvider && defaultProvider !== prov) { - const defConfig = providerConfigs[defaultProvider] - const defModels = defConfig.models.map(m => m.trim()).filter(Boolean) - parsed.provider = { - flavor: defaultProvider, - apiKey: defConfig.apiKey.trim() || undefined, - baseURL: defConfig.baseURL.trim() || undefined, - } - parsed.model = defModels[0] || "" - parsed.models = defModels - if (!rowboatConnected) { - parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined - parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined - parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined - parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined - } - } else if (parsed?.provider?.flavor === prov) { - // Removing the last connected provider: drop the top-level pair - // entirely. The schema requires it, so core's readConfig() treats - // the file as "no config" — signed-in falls back to the curated - // gateway default and signed-out llm:getDefaultModel rejects, which - // the composer already handles (it shows the connect hint). - delete parsed.provider - delete parsed.model - delete parsed.models - delete parsed.knowledgeGraphModel - delete parsed.meetingNotesModel - delete parsed.liveNoteAgentModel - delete parsed.autoPermissionDecisionModel - // With no BYOK providers left, any non-gateway selection is dangling. - if (parsed?.defaultSelection && parsed.defaultSelection.provider !== "rowboat" - && Object.keys(parsed?.providers ?? {}).length === 0) { - delete parsed.defaultSelection - } - } - await window.ipc.invoke("workspace:writeFile", { - path: "config/models.json", - data: JSON.stringify(parsed, null, 2), - }) setProviderConfigs(prev => ({ ...prev, [prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" }, @@ -933,7 +856,7 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b } catch { toast.error("Failed to remove provider") } - }, [defaultProvider, providerConfigs, rowboatConnected]) + }, [defaultProvider, savedProviders, handleSetDefault]) const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }) => { const isDefault = defaultProvider === p.id @@ -1611,23 +1534,18 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) { parsed = JSON.parse(configResult.data) } catch { /* no config yet */ } - // Current selections. Legacy string overrides pair with the BYOK - // top-level flavor (mirrors core/models/defaults.ts). - const legacyFlavor = (parsed.provider as Record | undefined)?.flavor + // models.json v2: assistantModel + taskModels refs. const toRef = (value: unknown): ModelRef | null => { - if (!value) return null - if (typeof value === "string") { - return typeof legacyFlavor === "string" ? { provider: legacyFlavor, model: value } : null - } - const ref = value as { provider?: unknown; model?: unknown } - return typeof ref.provider === "string" && typeof ref.model === "string" + const ref = value as { provider?: unknown; model?: unknown } | null | undefined + return ref && typeof ref.provider === "string" && typeof ref.model === "string" ? { provider: ref.provider, model: ref.model } : null } - setSelectedDefault(toRef(parsed.defaultSelection)) - setSelectedKg(toRef(parsed.knowledgeGraphModel)) - setSelectedLiveNote(toRef(parsed.liveNoteAgentModel)) - setSelectedAutoPermission(toRef(parsed.autoPermissionDecisionModel)) + const taskModels = (parsed.taskModels ?? {}) as Record + setSelectedDefault(toRef(parsed.assistantModel)) + setSelectedKg(toRef(taskModels.knowledgeGraph)) + setSelectedLiveNote(toRef(taskModels.liveNoteAgent)) + setSelectedAutoPermission(toRef(taskModels.autoPermissionDecision)) } catch { toast.error("Failed to load models") } finally { @@ -1642,10 +1560,16 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) { setSaving(true) try { await window.ipc.invoke("models:updateConfig", { - defaultSelection: selectedDefault, - knowledgeGraphModel: selectedKg, - liveNoteAgentModel: selectedLiveNote, - autoPermissionDecisionModel: selectedAutoPermission, + // The "Rowboat default" sentinel no longer maps to a hidden curated + // default — a null task ref simply inherits the assistant model. The + // assistant itself is only written when explicitly picked (clearing + // it would leave the app with no model at all). + ...(selectedDefault ? { assistantModel: selectedDefault } : {}), + taskModels: { + knowledgeGraph: selectedKg, + liveNoteAgent: selectedLiveNote, + autoPermissionDecision: selectedAutoPermission, + }, }) window.dispatchEvent(new Event("models-config-changed")) toast.success("Model configuration saved") diff --git a/apps/x/apps/renderer/src/hooks/use-models.test.tsx b/apps/x/apps/renderer/src/hooks/use-models.test.tsx index 48194c44..f3b6b2ff 100644 --- a/apps/x/apps/renderer/src/hooks/use-models.test.tsx +++ b/apps/x/apps/renderer/src/hooks/use-models.test.tsx @@ -34,7 +34,6 @@ function serveCatalog(catalog: { flavor?: string status?: 'ok' | 'error' error?: string - savedModel?: string models: Array<{ id: string; reasoning?: boolean }> }> defaultModel: { provider: string; model: string } | null @@ -99,32 +98,27 @@ describe('useModels', () => { expect(invokeCounts['models:list']).toBe(1) }) - it('pins the saved model, orders the default group first, and passes error status through', async () => { + it('orders the default group and model first and passes error status through', async () => { serveCatalog({ providers: [ - { id: 'openai', savedModel: 'gpt-4.1', models: [{ id: 'gpt-5.4' }, { id: 'gpt-4.1' }] }, - { - id: 'ollama', - status: 'error', - error: 'connection refused', - savedModel: 'llama3', - models: [], - }, + { id: 'ollama', status: 'error', error: 'connection refused', models: [] }, + { id: 'openai', models: [{ id: 'gpt-4.1' }, { id: 'gpt-5.4' }] }, ], - defaultModel: { provider: 'ollama', model: 'llama3' }, + defaultModel: { provider: 'openai', model: 'gpt-5.4' }, }) const { result } = renderHook(() => useModels()) await waitFor(() => expect(result.current.groups.length).toBe(2)) - // The default's group leads (despite arriving second) with its saved - // model still pickable; the failed status and error travel with it. + // The default's group leads (despite arriving second) and the default + // model leads within it. expect(result.current.groups[0]).toEqual({ - id: 'ollama', flavor: 'ollama', models: ['llama3'], status: 'error', error: 'connection refused', + id: 'openai', flavor: 'openai', models: ['gpt-5.4', 'gpt-4.1'], status: 'ok', }) - // Saved model leads its group ahead of the fetched list order. + // A failed provider keeps its group, with the error travelling along + // (ModelSelector renders it as an inline error row + Retry). expect(result.current.groups[1]).toEqual({ - id: 'openai', flavor: 'openai', models: ['gpt-4.1', 'gpt-5.4'], status: 'ok', + id: 'ollama', flavor: 'ollama', models: [], status: 'error', error: 'connection refused', }) }) @@ -171,7 +165,7 @@ describe('useModels', () => { serveCatalog({ providers: [ { id: 'openai', models: [{ id: 'gpt-5.4' }] }, - { id: 'ollama', savedModel: 'llama3', models: [{ id: 'llama3' }] }, + { id: 'ollama', models: [{ id: 'llama3' }] }, ], defaultModel: { provider: 'ollama', model: 'llama3' }, }) diff --git a/apps/x/apps/renderer/src/hooks/use-models.ts b/apps/x/apps/renderer/src/hooks/use-models.ts index fd23839b..e29238cf 100644 --- a/apps/x/apps/renderer/src/hooks/use-models.ts +++ b/apps/x/apps/renderer/src/hooks/use-models.ts @@ -83,17 +83,10 @@ async function buildSnapshot(refreshProvider?: string): Promise reasoningByKey[`${p.id}/${m.id}`] = m.reasoning } } - // The provider's saved default model leads its group; it stays pickable - // even when the fetched list doesn't carry it (or failed entirely). - const models: string[] = [] - if (p.savedModel) models.push(p.savedModel) - for (const id of ids) { - if (!models.includes(id)) models.push(id) - } groups.push({ id: p.id, flavor: p.flavor, - models, + models: ids, status: p.status, ...(p.error ? { error: p.error } : {}), }) diff --git a/apps/x/packages/core/src/auth/chatgpt-auth.ts b/apps/x/packages/core/src/auth/chatgpt-auth.ts index 0106d976..2283be83 100644 --- a/apps/x/packages/core/src/auth/chatgpt-auth.ts +++ b/apps/x/packages/core/src/auth/chatgpt-auth.ts @@ -385,5 +385,10 @@ export async function signOutChatGPT(): Promise { } } 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'); } diff --git a/apps/x/packages/core/src/knowledge/inline_tasks.ts b/apps/x/packages/core/src/knowledge/inline_tasks.ts index c5ba742f..a7524a12 100644 --- a/apps/x/packages/core/src/knowledge/inline_tasks.ts +++ b/apps/x/packages/core/src/knowledge/inline_tasks.ts @@ -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 { - const repo = container.resolve('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, }); diff --git a/apps/x/packages/core/src/models/catalog.test.ts b/apps/x/packages/core/src/models/catalog.test.ts index c2b71de6..75196504 100644 --- a/apps/x/packages/core/src/models/catalog.test.ts +++ b/apps/x/packages/core/src/models/catalog.test.ts @@ -38,11 +38,18 @@ vi.mock('../di/container.js', () => ({ import { getModelCatalog, __resetModelCatalogForTests } from './catalog.js'; -function serveConfig(providers: Record, defaultFlavor = 'openai'): void { +// v2 config: providers keyed by instance id, flavor explicit inside (the +// helper defaults flavor to the key — one instance per flavor today). +function serveConfig( + providers: Record>, + assistantModel?: { provider: string; model: string }, +): void { mocks.getConfig.mockImplementation(async () => ({ - provider: { flavor: defaultFlavor }, - model: 'gpt-5.4', - providers, + version: 2, + providers: Object.fromEntries( + Object.entries(providers).map(([id, entry]) => [id, { flavor: id, ...entry }]), + ), + ...(assistantModel ? { assistantModel } : {}), })); } @@ -61,7 +68,7 @@ describe('getModelCatalog', () => { mocks.isSignedIn.mockResolvedValue(true); mocks.getChatGPTStatus.mockResolvedValue({ signedIn: true }); serveConfig({ - ollama: { baseURL: 'http://localhost:11434', model: 'llama3' }, + ollama: { baseURL: 'http://localhost:11434' }, }); mocks.listModelsForProvider.mockResolvedValue(['llama3', 'qwen3']); @@ -70,19 +77,22 @@ describe('getModelCatalog', () => { expect(catalog.providers.map((p) => p.id)).toEqual(['rowboat', 'codex', 'ollama']); expect(catalog.providers.every((p) => p.status === 'ok')).toBe(true); expect(catalog.providers[0].models).toEqual([{ id: 'google/gemini-3.5-flash', reasoning: true }]); - expect(catalog.providers[2]).toMatchObject({ savedModel: 'llama3', models: [{ id: 'llama3' }, { id: 'qwen3' }] }); + expect(catalog.providers[2]).toMatchObject({ flavor: 'ollama', models: [{ id: 'llama3' }, { id: 'qwen3' }] }); expect(catalog.defaultModel).toEqual({ provider: 'openai', model: 'gpt-5.4' }); }); - it('skips providers-map entries that carry no credential', async () => { - serveConfig({ - openai: { model: 'gpt-5.4' }, // no key — not connected - anthropic: { apiKey: 'sk-b' }, - }); - mocks.listModelsForProvider.mockResolvedValue(['claude-opus-4-8']); + it('orders the assistant model provider first among configured providers', async () => { + serveConfig( + { + openrouter: { apiKey: 'sk-1' }, + ollama: { baseURL: 'http://localhost:11434' }, + }, + { provider: 'ollama', model: 'llama3' }, + ); + mocks.listModelsForProvider.mockResolvedValue(['m']); const catalog = await getModelCatalog(); - expect(catalog.providers.map((p) => p.id)).toEqual(['anthropic']); + expect(catalog.providers.map((p) => p.id)).toEqual(['ollama', 'openrouter']); }); it('serves cloud flavors from the models.dev catalog and only lists live when it is empty', async () => { @@ -105,7 +115,7 @@ describe('getModelCatalog', () => { }); it('reports a failed provider as status error instead of dropping it', async () => { - serveConfig({ ollama: { baseURL: 'http://localhost:11434', model: 'llama3' } }); + serveConfig({ ollama: { baseURL: 'http://localhost:11434' } }); mocks.listModelsForProvider.mockRejectedValue(new Error('connection refused')); const catalog = await getModelCatalog(); @@ -113,7 +123,6 @@ describe('getModelCatalog', () => { id: 'ollama', status: 'error', error: 'connection refused', - savedModel: 'llama3', models: [], }); }); diff --git a/apps/x/packages/core/src/models/catalog.ts b/apps/x/packages/core/src/models/catalog.ts index 0b9ca46b..1f1ecea4 100644 --- a/apps/x/packages/core/src/models/catalog.ts +++ b/apps/x/packages/core/src/models/catalog.ts @@ -43,8 +43,6 @@ export interface CatalogProviderEntry { /** "error" = the provider is connected but its model list failed to load. */ status: "ok" | "error"; error?: string; - /** The provider's saved default model from models.json, if any. */ - savedModel?: string; models: CatalogModelEntry[]; } @@ -105,14 +103,9 @@ type ProviderConfig = z.infer; interface DiscoveredProvider { id: string; + flavor: string; /** Absent for rowboat/codex — their auth lives outside models.json. */ config?: ProviderConfig; - savedModel?: string; - /** - * Saved models[] from config — the list of last resort for flavors the - * live fetch doesn't support (an unknown flavor in the providers map). - */ - savedModels?: string[]; } async function readModelConfig(): Promise | null> { @@ -128,45 +121,36 @@ async function readModelConfig(): Promise | null> /** * Which providers are connected right now. Rowboat and ChatGPT come from * their auth state; everything else from the models.json providers map - * (an entry counts as connected once it carries some credential). The - * default provider's entry leads, matching picker ordering. + * (entries carry credentials by construction in v2). The assistant model's + * provider leads, matching picker ordering. */ async function discoverProviders(): Promise { const discovered: DiscoveredProvider[] = []; if (await isSignedIn().catch(() => false)) { - discovered.push({ id: "rowboat" }); + discovered.push({ id: "rowboat", flavor: "rowboat" }); } try { const chatgpt = await getChatGPTStatus(); - if (chatgpt.signedIn) discovered.push({ id: "codex" }); + if (chatgpt.signedIn) discovered.push({ id: "codex", flavor: "codex" }); } catch { // ChatGPT status failures must never break the main list. } const cfg = await readModelConfig(); const providersMap = cfg?.providers ?? {}; - const defaultFlavor = cfg?.provider.flavor ?? ""; - const flavors = Object.keys(providersMap) - .sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0)); + const assistantProvider = cfg?.assistantModel?.provider ?? ""; + const ids = Object.keys(providersMap) + .sort((a, b) => (a === assistantProvider ? -1 : b === assistantProvider ? 1 : 0)); - for (const flavor of flavors) { - const entry = providersMap[flavor] ?? {}; - const apiKey = entry.apiKey?.trim() ?? ""; - const baseURL = entry.baseURL?.trim() ?? ""; - if (!apiKey && !baseURL) continue; // provider not configured - const savedModel = entry.model || undefined; - const parsed = LlmProvider.safeParse({ ...entry, flavor }); - if (!parsed.success) { - // Unknown flavor: not live-listable — serve the saved list. - discovered.push({ id: flavor, savedModel, savedModels: entry.models ?? [] }); - continue; - } - const config = parsed.data; + for (const id of ids) { + const entry = providersMap[id]; + if (!entry) continue; + const config = { ...entry }; if (config.flavor === "aigateway" && !config.baseURL) { config.baseURL = AIGATEWAY_DEFAULT_BASE_URL; } - discovered.push({ id: flavor, config, savedModel }); + discovered.push({ id, flavor: entry.flavor, config }); } return discovered; @@ -192,10 +176,10 @@ async function fetchProviderEntry( } else if (provider.id === "codex") { const result = await listCodexModels(); models = result.providers[0]?.models ?? []; - } else if (MODELS_DEV_FLAVORS.has(provider.id) && (modelsDevByFlavor.get(provider.id)?.length ?? 0) > 0) { - models = modelsDevByFlavor.get(provider.id) ?? []; + } else if (MODELS_DEV_FLAVORS.has(provider.flavor) && (modelsDevByFlavor.get(provider.flavor)?.length ?? 0) > 0) { + models = modelsDevByFlavor.get(provider.flavor) ?? []; } else if (!provider.config) { - models = (provider.savedModels ?? []).map((id) => ({ id })); + throw new Error(`Provider '${provider.id}' has no configuration to list models with`); } else { // Live listing: local/custom flavors always, cloud flavors only // when the models.dev cache is empty (offline fresh install). @@ -251,7 +235,7 @@ export async function getModelCatalog(options?: GetModelCatalogOptions): Promise // One models.dev read serves every cloud flavor in the build (disk cache, // no network — refreshed by its own background loop). const modelsDevByFlavor = new Map(); - if (discovered.some((p) => MODELS_DEV_FLAVORS.has(p.id))) { + if (discovered.some((p) => MODELS_DEV_FLAVORS.has(p.flavor))) { try { const catalog = await listOnboardingModels(); for (const p of catalog.providers) { @@ -274,11 +258,9 @@ export async function getModelCatalog(options?: GetModelCatalogOptions): Promise ); const result: CatalogProviderEntry = { id: provider.id, - // One instance per flavor today, so the id IS the flavor key. - flavor: provider.config?.flavor ?? provider.id, + flavor: provider.flavor, status: entry.status, ...(entry.error ? { error: entry.error } : {}), - ...(provider.savedModel ? { savedModel: provider.savedModel } : {}), models: entry.models, }; return result; diff --git a/apps/x/packages/core/src/models/chatgpt-selection.ts b/apps/x/packages/core/src/models/chatgpt-selection.ts new file mode 100644 index 00000000..36d04005 --- /dev/null +++ b/apps/x/packages/core/src/models/chatgpt-selection.ts @@ -0,0 +1,46 @@ +import container from "../di/container.js"; +import { IModelConfigRepo } from "./repo.js"; +import { listCodexModels } from "./codex.js"; +import { getRowboatConfig } from "../config/rowboat.js"; +import { selectInitialModel } from "./initial-selection.js"; + +/** + * Model-selection hooks for the ChatGPT-subscription (codex) sign-in + * lifecycle. ChatGPT is a provider like any other: signing in connects it, + * so it follows the same rules — + * + * - Connect with no saved assistant → pick an initial model (backend + * recommendation if the subscription lists it, else the first listed + * model) and save it. A saved assistant is NEVER replaced. + * - Disconnect → drop the selections that reference the provider (same + * dangling-ref cleanup as removing any provider). + */ + +export async function applyCodexInitialSelection(): Promise { + const repo = container.resolve("modelConfigRepo"); + try { + const cfg = await repo.getConfig().catch(() => null); + if (cfg?.assistantModel) return; // saved choice — never replaced + const catalog = await listCodexModels(); + const ids = catalog.providers[0]?.models.map((m) => m.id) ?? []; + const recommendations = (await getRowboatConfig().catch(() => null))?.modelRecommendations; + const model = selectInitialModel("codex", ids, recommendations); + if (model) { + await repo.updateConfig({ assistantModel: { provider: "codex", model } }); + } + } catch (error) { + // Best-effort: a failed initial selection must never break sign-in. + console.warn("[models] Initial selection after ChatGPT sign-in failed:", error); + } +} + +export async function clearCodexSelections(): Promise { + const repo = container.resolve("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); + } +} diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts index 78ea2470..2b35a541 100644 --- a/apps/x/packages/core/src/models/defaults.ts +++ b/apps/x/packages/core/src/models/defaults.ts @@ -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; async function readConfig(): Promise | null> { @@ -25,36 +10,25 @@ async function readConfig(): Promise | null> { const repo = container.resolve("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 { } /** - * 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> { 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("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 { - 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 { const cfg = await readConfig(); - const override = cfg?.[category]; + const override = cfg?.taskModels?.[category]; if (override) { - if (typeof override === "string") { - if (cfg) { - return { model: override, provider: cfg.provider.flavor }; - } - } else if (override.provider !== "rowboat" || signedIn) { - return { model: override.model, provider: override.provider }; - } - } - if (signedIn) { - return { model: curatedModel, provider: SIGNED_IN_DEFAULT_PROVIDER }; + return { model: override.model, provider: override.provider }; } return getDefaultModelAndProvider(); } @@ -140,55 +84,27 @@ async function getCategoryModel( * etc.) when they're the top-level of a run. */ export async function getKgModel(): Promise { - return getCategoryModel("knowledgeGraphModel", SIGNED_IN_KG_MODEL); + return getCategoryModel("knowledgeGraph"); } /** Model used by the live-note agent + routing classifier. */ export async function getLiveNoteAgentModel(): Promise { - return getCategoryModel("liveNoteAgentModel", SIGNED_IN_LIVE_NOTE_AGENT_MODEL); + return getCategoryModel("liveNoteAgent"); } /** Model used by the auto-permission classifier. */ export async function getAutoPermissionDecisionModel(): Promise { - 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 { - 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 { - 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"); } /** diff --git a/apps/x/packages/core/src/models/migrate.test.ts b/apps/x/packages/core/src/models/migrate.test.ts new file mode 100644 index 00000000..9c97e1e1 --- /dev/null +++ b/apps/x/packages/core/src/models/migrate.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import { migrateModelsConfig } from './migrate.js'; + +/** + * The migration contract: evaluate the v1 resolution rules (including the + * curated signed-in defaults that lived only in code) one last time and + * write their answers explicitly, so v2's simple "override else assistant" + * rules produce identical effective models. Overrides are written ONLY + * where the old effective model differs from inherit-from-assistant. + */ + +describe('migrateModelsConfig', () => { + it('returns null for a config that is already v2', () => { + expect(migrateModelsConfig({ version: 2, providers: {} }, false)).toBeNull(); + }); + + it('signed-out BYOK: adopts the top-level pair as assistant, writes no task overrides', () => { + const v1 = { + provider: { flavor: 'openai', apiKey: 'sk-a' }, + model: 'gpt-5.4', + providers: { openai: { apiKey: 'sk-a', model: 'gpt-5.4', models: ['gpt-5.4'] } }, + }; + expect(migrateModelsConfig(v1, false)).toEqual({ + version: 2, + providers: { openai: { flavor: 'openai', apiKey: 'sk-a' } }, + assistantModel: { provider: 'openai', model: 'gpt-5.4' }, + }); + }); + + it('signed-in with untouched bootstrap config: materializes the curated defaults', () => { + // The classic signed-in models.json — the bootstrap file that nothing + // ever wrote to; every effective model lived in code branches. + const v1 = { provider: { flavor: 'openai' }, model: 'gpt-5.4' }; + expect(migrateModelsConfig(v1, true)).toEqual({ + version: 2, + providers: {}, // bootstrap top-level pair had no credentials + assistantModel: { provider: 'rowboat', model: 'google/gemini-3.5-flash' }, + taskModels: { + knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' }, + liveNoteAgent: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' }, + autoPermissionDecision: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' }, + // chat titles used flash-lite because the assistant routes + // through the gateway; meeting notes used the curated default + // which EQUALS the assistant → no override written for it. + chatTitle: { provider: 'rowboat', model: 'google/gemini-3.5-flash-lite' }, + }, + }); + }); + + it('signed-in user with a BYOK defaultSelection: keeps it, materializes the differing task models', () => { + const v1 = { + provider: { flavor: 'ollama', baseURL: 'http://localhost:11434' }, + model: 'llama3', + providers: { ollama: { baseURL: 'http://localhost:11434', model: 'llama3' } }, + defaultSelection: { provider: 'ollama', model: 'llama3' }, + }; + const v2 = migrateModelsConfig(v1, true); + expect(v2?.assistantModel).toEqual({ provider: 'ollama', model: 'llama3' }); + expect(v2?.taskModels).toEqual({ + knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' }, + liveNoteAgent: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' }, + autoPermissionDecision: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' }, + // Meeting notes used the curated gateway default, which now + // differs from the (BYOK) assistant — preserved explicitly. + meetingNotes: { provider: 'rowboat', model: 'google/gemini-3.5-flash' }, + // Chat titles followed the assistant whenever it was NOT the + // gateway — inherit reproduces that, so no override. + }); + }); + + it('explicit v1 overrides survive: legacy strings pair with the top-level flavor, refs pass through', () => { + const v1 = { + provider: { flavor: 'openai', apiKey: 'sk-a' }, + model: 'gpt-5.4', + providers: { openai: { apiKey: 'sk-a' } }, + knowledgeGraphModel: 'gpt-5.4-mini', // legacy string form + meetingNotesModel: { provider: 'ollama', model: 'qwen3' }, // ref form + }; + const v2 = migrateModelsConfig(v1, false); + expect(v2?.taskModels).toEqual({ + knowledgeGraph: { provider: 'openai', model: 'gpt-5.4-mini' }, + meetingNotes: { provider: 'ollama', model: 'qwen3' }, + }); + }); + + it('an override equal to the assistant is dropped (inherit produces the same model)', () => { + const v1 = { + provider: { flavor: 'openai', apiKey: 'sk-a' }, + model: 'gpt-5.4', + knowledgeGraphModel: 'gpt-5.4', + }; + expect(migrateModelsConfig(v1, false)?.taskModels).toBeUndefined(); + }); + + it('a rowboat defaultSelection is skipped while signed out (needs auth), like v1 resolution did', () => { + const v1 = { + provider: { flavor: 'openai', apiKey: 'sk-a' }, + model: 'gpt-5.4', + defaultSelection: { provider: 'rowboat', model: 'google/gemini-3.5-flash' }, + }; + expect(migrateModelsConfig(v1, false)?.assistantModel) + .toEqual({ provider: 'openai', model: 'gpt-5.4' }); + }); + + it('providers without credentials are dropped; connection prefs survive', () => { + const v1 = { + provider: { flavor: 'openai', apiKey: 'sk-a' }, + model: 'gpt-5.4', + providers: { + openai: { apiKey: 'sk-a', models: ['gpt-5.4'] }, + anthropic: { model: 'claude-opus-4-8' }, // no key: never connected + ollama: { baseURL: 'http://localhost:11434', contextLength: 32768, reasoningEffort: 'low' }, + }, + }; + expect(migrateModelsConfig(v1, false)?.providers).toEqual({ + openai: { flavor: 'openai', apiKey: 'sk-a' }, + ollama: { flavor: 'ollama', baseURL: 'http://localhost:11434', contextLength: 32768, reasoningEffort: 'low' }, + }); + }); + + it('degrades gracefully on garbage input: empty v2 config', () => { + expect(migrateModelsConfig('not an object', false)).toEqual({ version: 2, providers: {} }); + expect(migrateModelsConfig({}, false)).toEqual({ version: 2, providers: {} }); + }); + + it('deferBackgroundTasks is carried over', () => { + const v1 = { provider: { flavor: 'openai', apiKey: 'sk-a' }, model: 'gpt-5.4', deferBackgroundTasks: true }; + expect(migrateModelsConfig(v1, false)?.deferBackgroundTasks).toBe(true); + }); +}); diff --git a/apps/x/packages/core/src/models/migrate.ts b/apps/x/packages/core/src/models/migrate.ts new file mode 100644 index 00000000..99d19e8f --- /dev/null +++ b/apps/x/packages/core/src/models/migrate.ts @@ -0,0 +1,167 @@ +import { z } from "zod"; +import { LlmModelConfig, LlmProvider, ModelRef } from "@x/shared/dist/models.js"; + +/** + * One-time migration of models.json from version 1 to version 2. + * + * v1 accreted three generations of schema: a top-level provider/model pair + * (the original single-provider config), a providers map whose entries + * duplicated credentials AND carried saved model lists (pre-dynamic-listing + * picker caches), plus `defaultSelection` and flat category overrides bolted + * on for hybrid mode. On top of that, several effective models existed only + * as hidden branches in code (the signed-in curated defaults). + * + * v2 stores providers as credentials-only entries and model choices in + * exactly two places: `assistantModel` and `taskModels`. This migration + * evaluates the OLD resolution rules one last time and writes their answers + * down explicitly, so the simplified v2 resolvers produce identical + * effective models for every existing user. Task overrides are written ONLY + * where the old effective model differs from plain inherit-from-assistant. + * + * The curated model ids below are FROZEN COPIES of the v1 constants that + * lived in defaults.ts (deleted with this migration). They are historical + * data, not live configuration — do not update them when recommendations + * change. + */ + +const V1_SIGNED_IN_ASSISTANT: z.infer = { 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; +type Ref = z.infer; + +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, key: string, signedIn: boolean): Ref | undefined { + const value = raw[key]; + if (typeof value === "string" && value) { + const flavor = (raw.provider as Record | 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, 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 | 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; + 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, + ); + const topLevel = raw.provider as Record | 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; + const apiKey = typeof entry.apiKey === "string" ? entry.apiKey.trim() : ""; + const baseURL = typeof entry.baseURL === "string" ? entry.baseURL.trim() : ""; + if (!apiKey && !baseURL) continue; // never connected + const parsed = LlmProvider.safeParse({ ...entry, flavor: id }); + if (parsed.success) providers[id] = parsed.data; + } + + const assistantModel = v1EffectiveAssistant(raw, signedIn); + + // Old effective model per task, via the deleted v1 rules. + const oldTaskModels: Record = { + knowledgeGraph: v1Override(raw, "knowledgeGraphModel", signedIn) + ?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel), + liveNoteAgent: v1Override(raw, "liveNoteAgentModel", signedIn) + ?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel), + autoPermissionDecision: v1Override(raw, "autoPermissionDecisionModel", signedIn) + ?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel), + meetingNotes: v1Override(raw, "meetingNotesModel", signedIn) + ?? (signedIn ? V1_SIGNED_IN_ASSISTANT : assistantModel), + // Chat titles used the curated lite model ONLY when the assistant itself + // routed through the gateway (the exhausted-gateway safeguard). + chatTitle: v1Override(raw, "chatTitleModel", signedIn) + ?? (assistantModel?.provider === "rowboat" + ? { provider: "rowboat", model: V1_CURATED_CHAT_TITLE } + : assistantModel), + }; + + // Write an override only where the old effective model differs from what + // v2 inheritance (assistant) would produce. + const taskModels: NonNullable = {}; + 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 } : {}), + }; +} diff --git a/apps/x/packages/core/src/models/repo.test.ts b/apps/x/packages/core/src/models/repo.test.ts new file mode 100644 index 00000000..c849de44 --- /dev/null +++ b/apps/x/packages/core/src/models/repo.test.ts @@ -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'); + }); +}); diff --git a/apps/x/packages/core/src/models/repo.ts b/apps/x/packages/core/src/models/repo.ts index 3a3a5b77..a3c200c2 100644 --- a/apps/x/packages/core/src/models/repo.ts +++ b/apps/x/packages/core/src/models/repo.ts @@ -1,114 +1,162 @@ -import { ModelConfig } from "./models.js"; +import { LlmModelConfig, LlmProvider, ModelRef, TaskModels } from "@x/shared/dist/models.js"; import { WorkDir } from "../config/config.js"; +import { isSignedIn } from "../account/account.js"; +import { migrateModelsConfig } from "./migrate.js"; import fs from "fs/promises"; import path from "path"; import z from "zod"; +type Config = z.infer; +type Ref = z.infer; +type TaskModelPatch = { [K in keyof z.infer]?: 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[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; - getConfig(): Promise>; - setConfig(config: z.infer): Promise; - // 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; + /** Upsert one provider entry (credentials + connection prefs). */ + setProvider(id: string, provider: z.infer): Promise; + /** + * 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; updateConfig(patch: ModelConfigPatch): Promise; } -const defaultConfig: z.infer = { - 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 { + let rawText: string; try { - await fs.access(this.configPath); - } catch { - await fs.writeFile(this.configPath, JSON.stringify(defaultConfig, null, 2)); + rawText = await fs.readFile(this.configPath, "utf8"); + } catch (err) { + if (isEnoent(err)) { + await this.write(emptyConfig); + } else { + // Transient read failure (permissions, I/O): NEVER overwrite + // the existing file — it holds the user's API keys. Leave it + // for the next boot; per-call getConfig errors degrade + // features without destroying data. + console.error("[models] Could not read models.json; leaving it untouched:", err); + } + return; + } + let raw: unknown; + try { + raw = JSON.parse(rawText); + } catch (err) { + // Corrupt JSON (e.g. a crash mid-write): quarantine the file for + // manual recovery instead of overwriting the only copy of the + // user's credentials. + const quarantinePath = `${this.configPath}.corrupt-${Date.now()}`; + await fs.rename(this.configPath, quarantinePath).catch(() => {}); + console.error(`[models] models.json is corrupt; preserved at ${quarantinePath}:`, err); + await this.write(emptyConfig); + return; + } + const signedIn = await isSignedIn().catch(() => false); + const migrated = migrateModelsConfig(raw, signedIn); + if (migrated) { + // Keep the v1 original recoverable — the migration is the only + // record of the old selections once it runs. + await fs.writeFile(`${this.configPath}.v1.bak`, rawText).catch(() => {}); + await this.write(migrated); } } - async getConfig(): Promise> { + async getConfig(): Promise { 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): Promise { - let existingProviders: Record> = {}; - 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): Promise { + const config = await this.read(); + config.providers[id] = LlmProvider.parse(provider); + await this.write(config); + } + + async removeProvider(id: string): Promise { + const config = await this.read(); + delete config.providers[id]; + if (config.assistantModel?.provider === id) { + delete config.assistantModel; } - - existingProviders[config.provider.flavor] = { - ...existingProviders[config.provider.flavor], - apiKey: config.provider.apiKey, - baseURL: config.provider.baseURL, - headers: config.provider.headers, - // Preserve hand-edited local-model tuning unless the caller sets it. - ...(config.provider.contextLength !== undefined - ? { contextLength: config.provider.contextLength } - : {}), - ...(config.provider.reasoningEffort !== undefined - ? { reasoningEffort: config.provider.reasoningEffort } - : {}), - model: config.model, - models: config.models, - knowledgeGraphModel: config.knowledgeGraphModel, - meetingNotesModel: config.meetingNotesModel, - liveNoteAgentModel: config.liveNoteAgentModel, - autoPermissionDecisionModel: config.autoPermissionDecisionModel, - }; - - // saveConfig owns provider credentials/model lists; the hybrid-mode - // selections are owned by updateConfig — carry them over when the - // incoming config doesn't set them. - let existingSelections: Record = {}; - 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)[key] === undefined) - .map((key) => [key, existing[key]]), - ); - } catch { - // No existing config + if (config.taskModels) { + for (const key of Object.keys(config.taskModels) as Array>) { + if (config.taskModels[key]?.provider === id) { + delete config.taskModels[key]; + } + } + if (Object.keys(config.taskModels).length === 0) delete config.taskModels; } - - const toWrite = { ...existingSelections, ...config, providers: existingProviders }; - await fs.writeFile(this.configPath, JSON.stringify(toWrite, null, 2)); + await this.write(config); } async updateConfig(patch: ModelConfigPatch): Promise { - const raw = await fs.readFile(this.configPath, "utf8"); - const existing = JSON.parse(raw) as Record; - for (const [key, value] of Object.entries(patch)) { - if (value === undefined || value === null) { - delete existing[key]; - } else { - existing[key] = value; - } + const config = await this.read(); + if (patch.assistantModel !== undefined) { + if (patch.assistantModel === null) delete config.assistantModel; + else config.assistantModel = patch.assistantModel; } - ModelConfig.parse(existing); - await fs.writeFile(this.configPath, JSON.stringify(existing, null, 2)); + if (patch.taskModels !== undefined) { + const merged = { ...(config.taskModels ?? {}) }; + for (const [key, value] of Object.entries(patch.taskModels)) { + if (value === undefined) continue; + if (value === null) delete merged[key as keyof typeof merged]; + else merged[key as keyof typeof merged] = value; + } + if (Object.keys(merged).length > 0) config.taskModels = merged; + else delete config.taskModels; + } + if (patch.deferBackgroundTasks !== undefined) { + if (patch.deferBackgroundTasks === null) delete config.deferBackgroundTasks; + else config.deferBackgroundTasks = patch.deferBackgroundTasks; + } + await this.write(config); + } + + private async read(): Promise { + 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 { + 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); } } diff --git a/apps/x/packages/core/src/models/rowboat-selection.ts b/apps/x/packages/core/src/models/rowboat-selection.ts new file mode 100644 index 00000000..7e3c94e0 --- /dev/null +++ b/apps/x/packages/core/src/models/rowboat-selection.ts @@ -0,0 +1,48 @@ +import container from "../di/container.js"; +import { IModelConfigRepo } from "./repo.js"; +import { listGatewayModels } from "./gateway.js"; +import { getRowboatConfig } from "../config/rowboat.js"; +import { selectInitialModel } from "./initial-selection.js"; + +/** + * Model-selection hooks for the Rowboat sign-in lifecycle. Signing in is + * "connecting the rowboat provider", so it follows the same rules as any + * provider connect: + * + * - Connect with no saved assistant → pick an initial model (backend + * recommendation if the gateway lists it, else the first listed model) + * and save it. A saved assistant is NEVER replaced — recommendations only + * ever choose the initial model. + * - Disconnect → drop the selections that reference the provider (same + * dangling-reference cleanup as removing any provider). + */ + +export async function applyRowboatInitialSelection(): Promise { + const repo = container.resolve("modelConfigRepo"); + try { + const cfg = await repo.getConfig().catch(() => null); + if (cfg?.assistantModel) return; // saved choice — never replaced + const catalog = await listGatewayModels(); + const ids = catalog.providers[0]?.models.map((m) => m.id) ?? []; + const recommendations = (await getRowboatConfig().catch(() => null))?.modelRecommendations; + const model = selectInitialModel("rowboat", ids, recommendations); + if (model) { + await repo.updateConfig({ assistantModel: { provider: "rowboat", model } }); + } + } catch (error) { + // Best-effort: a failed initial selection must never break sign-in. + // The picker copes with an unset assistant (shows the connect hint). + console.warn("[models] Initial selection after Rowboat sign-in failed:", error); + } +} + +export async function clearRowboatSelections(): Promise { + const repo = container.resolve("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); + } +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index b22c3270..7baa6285 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, WorkspaceChangeEvent, WriteFileOptions, WriteFileResult, RemoveOptions } from './workspace.js'; import { ListToolsResponse } from './mcp.js'; import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js'; -import { LlmModelConfig, LlmProvider, ModelOverride, ModelRef, ReasoningEffort } from './models.js'; +import { LlmProvider, ModelRef, ReasoningEffort } from './models.js'; import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; import { AgentScheduleState } from './agent-schedule-state.js'; import { ServiceEvent } from './service-events.js'; @@ -667,8 +667,6 @@ const ipcSchemas = { // 'error' = provider is connected but its model list failed to load. status: z.enum(['ok', 'error']), error: z.string().optional(), - // The provider's saved default model from models.json, if any. - savedModel: z.string().optional(), models: z.array(z.object({ id: z.string(), name: z.string().optional(), @@ -682,7 +680,10 @@ const ipcSchemas = { }), }, 'models:test': { - req: LlmModelConfig, + req: z.object({ + provider: LlmProvider, + model: z.string(), + }), res: z.object({ success: z.boolean(), error: z.string().optional(), @@ -726,23 +727,40 @@ const ipcSchemas = { error: z.string().optional(), }), }, - 'models:saveConfig': { - req: LlmModelConfig, + // Upsert one provider entry (credentials + connection prefs). Model + // choices are NOT part of a provider — set them via models:updateConfig. + 'models:setProvider': { + req: z.object({ + id: z.string(), + provider: LlmProvider, + }), res: z.object({ success: z.literal(true), }), }, - // Partial top-level merge into models.json — used by hybrid (signed-in + - // BYOK) settings to set the default selection / category overrides without - // clobbering the BYOK provider config that saveConfig owns. Omitted keys - // are untouched; null clears a key back to its default. + // Remove a provider entry plus any assistantModel / task override that + // references it (dangling selections would just error at run time). + 'models:removeProvider': { + req: z.object({ + id: z.string(), + }), + res: z.object({ + success: z.literal(true), + }), + }, + // Partial merge of model selections into models.json. Omitted keys are + // untouched; null clears a key (a cleared task override inherits the + // assistant model again). taskModels merges per-key. 'models:updateConfig': { req: z.object({ - defaultSelection: ModelRef.nullable().optional(), - knowledgeGraphModel: ModelOverride.nullable().optional(), - meetingNotesModel: ModelOverride.nullable().optional(), - liveNoteAgentModel: ModelOverride.nullable().optional(), - autoPermissionDecisionModel: ModelOverride.nullable().optional(), + assistantModel: ModelRef.nullable().optional(), + taskModels: z.object({ + knowledgeGraph: ModelRef.nullable().optional(), + meetingNotes: ModelRef.nullable().optional(), + liveNoteAgent: ModelRef.nullable().optional(), + autoPermissionDecision: ModelRef.nullable().optional(), + chatTitle: ModelRef.nullable().optional(), + }).optional(), deferBackgroundTasks: z.boolean().nullable().optional(), }), res: z.object({ diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index 9fd79a4d..e376bee4 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -8,6 +8,10 @@ import { z } from "zod"; // thinkingLevel, OpenRouter reasoning.effort) is mapped at invoke time. export const ReasoningEffort = z.enum(["low", "medium", "high"]); +// A provider entry: its TYPE (flavor) plus credentials and connection +// preferences. Deliberately carries NO model fields — model lists are always +// fetched from the provider (core/models/catalog.ts), and model choices live +// in assistantModel / taskModels. export const LlmProvider = z.object({ // "rowboat" (signed-in gateway) and "codex" (ChatGPT subscription via // "Sign in with ChatGPT") are credential-less flavors: they never appear @@ -28,51 +32,48 @@ export const LlmProvider = z.object({ reasoningEffort: ReasoningEffort.optional(), }); -// A provider-qualified model reference. `provider` is a provider name as -// understood by resolveProviderConfig — a BYOK flavor ("ollama", "openai", -// …) or "rowboat" for the signed-in gateway. +// A provider-qualified model reference. `provider` is a provider INSTANCE id +// as understood by resolveProviderConfig — a key of the providers map, or +// "rowboat" / "codex" for the credential-less providers. Today one instance +// exists per flavor, so instance ids equal flavor keys. export const ModelRef = z.object({ provider: z.string(), model: z.string(), }); -// Category overrides accept either a bare model id (legacy: paired with the -// active default provider) or a provider-qualified ref (hybrid mode: e.g. -// gateway assistant + local Ollama background agents). -export const ModelOverride = z.union([z.string(), ModelRef]); +// The per-task model override slots. Absence = inherit the assistant model. +export const TaskModels = z.object({ + knowledgeGraph: ModelRef.optional(), + meetingNotes: ModelRef.optional(), + liveNoteAgent: ModelRef.optional(), + autoPermissionDecision: ModelRef.optional(), + chatTitle: ModelRef.optional(), +}); +export type TaskModelKey = keyof z.infer; +/** + * 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(), }); From c695ddf1c15b2d189a09ac7f374ca30ae0de2b30 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:16:21 +0530 Subject: [PATCH 4/6] =?UTF-8?q?feat(x):=20one=20model-selection=20experien?= =?UTF-8?q?ce=20=E2=80=94=20unified=20settings,=20provider=20lifecycle,=20?= =?UTF-8?q?split-view=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task slots (config + runtime): - taskModels gains backgroundTask and subagent; getBackgroundTaskAgentModel resolves its own override instead of mirroring live-notes (migration materializes the old mirror rule so effective models are unchanged) - spawn-agent precedence: explicit per-spawn args > configured subagent override > parent turn's model - repo.setProvider rejects auth-derived flavors (rowboat/codex) and merges over existing entries so key rotation keeps contextLength/reasoningEffort Models settings, rebuilt: - ModelSelectionSection: ONE Assistant model picker (with an Unavailable badge when the saved model drops off its provider's live list) + all seven tasks defaulting to "Same as Assistant" with resolve subtext and a clear-override link. Replaces both the signed-in and BYOK screens; the "Auto (recommended)" sentinel and both hardcoded preferredDefaults maps are gone — connect-time resolution uses backend recommendations - ProvidersSection replaces the old ModelSettings card grid: status cards (Connected · N models / error + Retry), an add-provider dialog (choose → creds → loading → first-vs-more result states → error with manual-model fallback), and a manage dialog (masked key + Replace, endpoint, Refresh models, Used-by list, disconnect with consequences spelled out). Rowboat and ChatGPT are cards in the same list - connecting a provider seeds the assistant ONLY when none is configured — never replaces a saved choice - models:getConfig IPC serves selections + credential-free provider metadata; the probe machinery (use-provider-models, liveCredentials) is deleted — the add flow validates credentials click-driven Onboarding: - screen 2 is the same ProvidersSection (onboarding variant) for BOTH paths: "Add more providers" after sign-in, "Connect a model provider" otherwise; Continue gates on an assistant model existing. The tile grid, per-provider key forms, and ~350 lines of duplicate state are deleted; one step sequence replaces the two path-dependent indicators Picker: - split-view browse (Popover + cmdk Command): providers left with counts and error dots, selected provider's models right, ←/→ switches provider, ↑/↓ navigates, typing collapses to a flat cross-provider list that matches provider NAMES as well as model ids (searching "rowboat" now works). Scoped/static pickers stay flat; public API unchanged Co-Authored-By: Claude Fable 5 --- apps/x/apps/main/src/ipc.ts | 24 + .../components/chat-input-with-mentions.tsx | 14 +- .../src/components/model-selector.test.tsx | 90 +- .../src/components/model-selector.tsx | 597 ++++++------ .../src/components/onboarding/index.tsx | 5 +- .../components/onboarding/step-indicator.tsx | 21 +- .../onboarding/steps/llm-setup-step.tsx | 308 +------ .../onboarding/use-onboarding-state.ts | 256 +----- .../src/components/settings-dialog.tsx | 867 +----------------- .../settings/model-selection-section.test.tsx | 90 ++ .../settings/model-selection-section.tsx | 169 ++++ .../components/settings/providers-section.tsx | 747 +++++++++++++++ .../renderer/src/hooks/use-models.test.tsx | 8 +- apps/x/apps/renderer/src/hooks/use-models.ts | 17 +- .../renderer/src/hooks/use-provider-models.ts | 147 --- apps/x/packages/core/src/models/catalog.ts | 2 +- apps/x/packages/core/src/models/defaults.ts | 20 +- .../packages/core/src/models/migrate.test.ts | 15 + apps/x/packages/core/src/models/migrate.ts | 8 +- apps/x/packages/core/src/models/repo.ts | 14 +- .../src/runtime/assembly/spawn-agent.test.ts | 40 +- .../core/src/runtime/assembly/spawn-agent.ts | 20 +- apps/x/packages/shared/src/ipc.ts | 31 +- apps/x/packages/shared/src/models.ts | 6 +- 24 files changed, 1599 insertions(+), 1917 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/settings/model-selection-section.test.tsx create mode 100644 apps/x/apps/renderer/src/components/settings/model-selection-section.tsx create mode 100644 apps/x/apps/renderer/src/components/settings/providers-section.tsx delete mode 100644 apps/x/apps/renderer/src/hooks/use-provider-models.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 1007f93d..cd8e586f 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1271,6 +1271,30 @@ 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:getConfig': async () => { + const repo = container.resolve('modelConfigRepo'); + 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('modelConfigRepo'); await repo.setProvider(args.id, args.provider); diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index de7d7004..464648b6 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -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', { assistantModel: { provider: model.provider, model: model.model } }) - .then(() => { window.dispatchEvent(new Event('models-config-changed')) }) - .catch(() => { toast.error('Failed to save default model') }) }, [lockedModel, onSelectedModelChange]) // Effort is per-turn and unpersisted; ModelSelector reports '' when the diff --git a/apps/x/apps/renderer/src/components/model-selector.test.tsx b/apps/x/apps/renderer/src/components/model-selector.test.tsx index 7fea033f..9381eb99 100644 --- a/apps/x/apps/renderer/src/components/model-selector.test.tsx +++ b/apps/x/apps/renderer/src/components/model-selector.test.tsx @@ -34,11 +34,8 @@ function serveTwoProviders(): void { } async function openMenu(): Promise { - 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(() => { @@ -124,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"')) @@ -144,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() + 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( - , + {}} 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( {}} 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 () => { @@ -198,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) @@ -206,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) }) @@ -224,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' }) diff --git a/apps/x/apps/renderer/src/components/model-selector.tsx b/apps/x/apps/renderer/src/components/model-selector.tsx index 768765b2..cb63dd7a 100644 --- a/apps/x/apps/renderer/src/components/model-selector.tsx +++ b/apps/x/apps/renderer/src/components/model-selector.tsx @@ -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 = { +export const providerDisplayNames: Record = { openai: 'OpenAI', anthropic: 'Anthropic', google: 'Gemini', @@ -47,94 +52,34 @@ function getModelDisplayName(model: string) { return model.split('/').pop() || model } -// The one remaining renderer-side fetch: probing a provider that is being -// configured RIGHT NOW (credentials typed into a form but not yet saved). -// Connected providers all come pre-listed through the unified catalog -// (useModels); this path exists only because unsaved credentials, by -// definition, aren't in that catalog. Pinned models (the app default) render -// first so the model that actually runs stays pickable while the fetch is -// pending or failed. Probe-fetched ids carry no reasoning metadata, so the -// effort control stays hidden for them. -// -// 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 ProbeProviderGroupItems({ flavor, apiKey, baseURL, label, pinnedModels, filter, onModelRowsChange }: { - flavor: ProviderModelsFlavor - apiKey: string - baseURL: string - label: string - pinnedModels: string[] - filter: string - onModelRowsChange: (flavor: string, hasModelRows: boolean) => void -}) { - const { status, models, error, refetch } = useProviderModels({ flavor, apiKey, 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(flavor, hasModelRows) - }, [flavor, hasModelRows, onModelRowsChange]) - if (!hasModelRows && !showStatus) return null - return ( - <> - {label} - {visible.map((m) => { - const key = `${flavor}/${m}` - return ( - - {m} - - ) - })} - {status === 'loading' && ( -
- - Loading models… -
- )} - {status === 'error' && ( - { - e.preventDefault() - refetch() - }} - className="text-xs" - > - {error || 'Failed to load models'} - Retry - - )} - - ) -} - // 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 ""` 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, @@ -245,15 +175,7 @@ export function ModelSelector({ const sentinel = defaultOption ?? inheritDefault const sentinelMuted = !defaultOption && Boolean(inheritDefault) - // Probe mode: the form's typed credentials trump anything saved — an - // unsaved provider isn't in the catalog, so its list is probe-fetched. - const liveFlavor = liveCredentials?.flavor - const liveApiKey = liveCredentials?.apiKey.trim() ?? '' - const liveBaseURL = liveCredentials?.baseURL.trim() ?? '' - const probeActive = Boolean(providerFilter && liveFlavor === providerFilter && (liveApiKey || liveBaseURL)) - const groups = useMemo(() => { - if (probeActive) return [] if (!providerFilter) return allGroups const scoped = allGroups.filter((g) => g.id === providerFilter) if (scoped.length > 0) return scoped @@ -261,68 +183,97 @@ export function ModelSelector({ return catalogModels.length > 0 ? [{ id: providerFilter, flavor: providerFilter, models: catalogModels, status: 'ok' }] : [] - }, [allGroups, providerFilter, catalogByProvider, probeActive]) + }, [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. The probe - // group filters itself and reports whether it still has rows, so the - // parent can render the global "No models match" row. - const [modelFilter, setModelFilter] = useState('') - const modelFilterInputRef = useRef(null) - const [probeHasRows, setProbeHasRows] = useState(true) - const modelFilterValue = modelFilter.trim().toLowerCase() - const handleProbeRows = useCallback((_flavor: string, hasRows: boolean) => { - setProbeHasRows(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(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 // provider's list failed, or its provider was removed from config) — the - // picker must never be missing the model that actually runs. The probe - // group pins the default itself, so it opts out here. A provider-scoped - // picker only shows it when it belongs to that provider. + // 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(() => { - if (!defaultModel || probeActive) return null + if (!defaultModel) return null if (providerFilter && defaultModel.provider !== providerFilter) return null const covered = groups.some((g) => g.id === defaultModel.provider && g.models.includes(defaultModel.model)) return covered ? null : defaultModel - }, [groups, defaultModel, providerFilter, probeActive]) + }, [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". A probe that hasn't - // reported yet (first render after opening) counts 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.models.some((m) => m.toLowerCase().includes(modelFilterValue))) - || (probeActive && probeHasRows) + || 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 ( + select({ provider: providerId, model })} + > + + {model} + {secondary && {secondary}} + + ) + } + + const renderSentinelItem = () => sentinel && ( + select(null)}> + + {sentinel.label} + + ) + + const renderErrorItem = (g: ModelPickerGroup) => ( + refresh(g.id)} + className="text-xs" + > + {g.error || 'Failed to load models'} + Retry + + ) + return ( <> {reasoningAvailable && onEffortChange && ( @@ -390,19 +377,14 @@ export function ModelSelector({ ) : ( - { - // 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('') - setProbeHasRows(true) - setTimeout(() => modelFilterInputRef.current?.focus(), 0) - } - }} - > - + // 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). + + {variant === 'field' ? ( // Styled after ui/select's SelectTrigger so it sits naturally // in forms next to real Select fields. @@ -439,139 +421,154 @@ export function ModelSelector({ )} - - + - {!staticOptions && !probeActive && groups.length === 0 && !standaloneDefault && !sentinel && !allowCustom ? ( -
- Connect a provider in Settings -
- ) : ( - <> - {/* 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. */} -
- 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" - /> -
-
- - {sentinel && ( - - {sentinel.label} - - )} - {staticVisible?.map((o) => ( - - {o.label ?? o.id} - {o.label && o.label !== o.id && ( - {o.id} - )} - - ))} - {!staticOptions && standaloneDefault && standaloneVisible && ( - - {standaloneDefault.model} - - {providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider} - - - )} - {!staticOptions && groups.map((g) => { - const label = providerDisplayNames[g.flavor] || g.flavor - const visibleModels = modelFilterValue - ? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue)) - : 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 ( - - - {label} - - {visibleModels.map((m) => { - const key = `${g.id}/${m}` - return ( - - {m} - - ) - })} - {showError && ( - { - e.preventDefault() - refresh(g.id) - }} - className="text-xs" - > - {g.error || 'Failed to load models'} - Retry - - )} - - ) - })} - {probeActive && liveFlavor && ( - - )} - {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. - onChange(parseCustomModel(modelFilter.trim(), providerFilter, defaultModel))} - > - Use "{modelFilter.trim()}" - - ) : ( -
No models match
- ) - )} -
-
- + 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 ? ( +
Connect a provider in Settings
+ ) : ( + { + // 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]) + }} + > + + {splitMode && activeGroup ? ( +
+ {/* Provider column — tab-like: click or ←/→. */} +
+ {groups.map((g) => ( + + ))} +
+ + + {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 && ( +
No models reported
+ )} +
+
+
+ ) : ( + + {sentinel && !queryValue && ( + {renderSentinelItem()} + )} + {staticVisible && staticVisible.length > 0 && ( + + {staticVisible.map((o) => ( + select({ provider: '', model: o.id })}> + + {o.label ?? o.id} + {o.label && o.label !== o.id && ( + {o.id} + )} + + ))} + + )} + {!staticOptions && standaloneDefault && standaloneVisible && ( + + {renderModelItem( + standaloneDefault.provider, + standaloneDefault.model, + providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider, + )} + + )} + {!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 ( + + {visibleModels.map((m) => renderModelItem(g.id, m))} + {showError && renderErrorItem(g)} + + ) + })} + {queryValue && !anyModelRowVisible && ( + allowCustom ? ( + // Escape hatch for ids the lists don't carry (local + // servers, brand-new models): select exactly what was + // typed. + + select(parseCustomModel(query.trim(), providerFilter, defaultModel))} + > + Use "{query.trim()}" + + + ) : ( +
No models match
+ ) + )} +
+ )} +
+ )} + + )} ) diff --git a/apps/x/apps/renderer/src/components/onboarding/index.tsx b/apps/x/apps/renderer/src/components/onboarding/index.tsx index ae83e4fa..38c9c519 100644 --- a/apps/x/apps/renderer/src/components/onboarding/index.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/index.tsx @@ -62,10 +62,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { onEscapeKeyDown={(e) => e.preventDefault()} >
- + s.step === currentStep) return ( diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx index fa7ee93c..898fbc84 100644 --- a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx @@ -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: }, - { id: "anthropic", name: "Anthropic", description: "Claude models", color: "bg-orange-500/10 text-orange-600 dark:text-orange-400", icon: }, - { id: "google", name: "Gemini", description: "Google AI Studio", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400", icon: }, - { id: "ollama", name: "Ollama", description: "Local models", color: "bg-purple-500/10 text-purple-600 dark:text-purple-400", icon: }, -] - -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: }, - { id: "aigateway", name: "AI Gateway", description: "Vercel AI Gateway", color: "bg-sky-500/10 text-sky-600 dark:text-sky-400", icon: }, - { id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom endpoint", color: "bg-gray-500/10 text-gray-600 dark:text-gray-400", icon: }, -] - +// 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 ( - { - 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" - )} - > -
-
- {provider.icon} -
-
-
{provider.name}
-
{provider.description}
-
- {connectedFlavors.has(provider.id) && ( - - )} -
-
- ) - } + const { handleNext, handleBack } = state + const { defaultModel, isRowboatConnected } = useModels() + const hasAssistant = defaultModel !== null return (
{/* Title */}

- Choose your provider + {isRowboatConnected ? "Add more providers" : "Connect a model provider"}

- 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."}

- {/* Inline Rowboat upsell callout */} - {!upsellDismissed && ( - - -
-

- Tip: 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. -

- -
- -
- )} - - {/* Provider selection */} -
- Provider -
- {primaryProviders.map((p, i) => renderProviderCard(p, i))} -
- {(showMoreProviders || isMoreProvider) ? ( -
- {moreProviders.map((p, i) => renderProviderCard(p, i + 4))} -
- ) : ( - - )} -
- - {/* Separator */} -
- - {/* Provider configuration */} -
- {/* 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 && ( -
- - {modelsLoading ? ( -
- - Loading... -
- ) : ( - updateProviderConfig(llmProvider, { model: e.target.value })} - placeholder="Model ID (leave empty to auto-select)" - /> - )} - {modelsError && ( -
{modelsError}
- )} -
- )} - - {showApiKey && ( -
- - updateProviderConfig(llmProvider, { apiKey: e.target.value })} - placeholder="Paste your API key" - className="font-mono" - /> -
- )} - - {/* 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" && ( -
- - {chatgpt.status.signedIn ? ( -
-
- - - Connected as {chatgpt.status.email ?? "your ChatGPT account"} - -
- -
- ) : chatgpt.isSigningIn ? ( -
-
- - Waiting for browser… -
- -
- ) : ( -
- - Use your ChatGPT Plus/Pro subscription - - -
- )} -
- )} - - {showBaseURL && ( -
- - 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" - /> -
- )} -
+ {/* Footer */}
@@ -270,44 +39,9 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) { Back - -
- {testState.status === "success" && ( - - - Connected - - )} - {testState.status === "error" && ( - - {testState.error} - - )} - - -
+
) diff --git a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts index e95427b8..ce3f9dac 100644 --- a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts +++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts @@ -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(0) const [onboardingPath, setOnboardingPath] = useState(null) - // LLM setup state - const [llmProvider, setLlmProvider] = useState("openai") - const [modelsCatalog, setModelsCatalog] = useState>({}) - const [modelsLoading, setModelsLoading] = useState(false) - const [modelsError, setModelsError] = useState(null) - const [providerConfigs, setProviderConfigs] = useState>({ - 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>(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([]) 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(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 = {} - 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> = { - 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,103 +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 => { - 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 - } - - // v2 writes: the provider entry carries credentials only; the resolved - // model becomes the assistant model (onboarding's connect doubles as - // the initial selection). - await window.ipc.invoke("models:setProvider", { id: llmProvider, provider }) - if (model) { - await window.ipc.invoke("models:updateConfig", { - assistantModel: { provider: llmProvider, model }, - }) - } - window.dispatchEvent(new Event('models-config-changed')) - setTestState({ status: "success" }) - setConnectedFlavors(prev => new Set(prev).add(llmProvider)) - 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() @@ -681,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, @@ -694,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, @@ -750,10 +503,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou handleSlackSaveWorkspaces, handleSlackDisable, - // Upsell - upsellDismissed, - setUpsellDismissed, - // Composio/Gmail state useComposioForGoogle, gmailConnected, @@ -777,7 +526,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou handleBack, handleComplete, handleCompleteWithTour, - handleSwitchToRowboat, } } diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 927aaff1..c09496ee 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -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,10 +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 { useRowboatConfig } from "@/hooks/use-rowboat-config" -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" @@ -484,708 +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> = { - openai: "gpt-5.4", - anthropic: "claude-opus-4-8", -} - -const defaultBaseURLs: Partial> = { - 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("openai") - const [defaultProvider, setDefaultProvider] = useState(null) - // Flavors present in the saved providers map — drives each card's - // "Connected" indicator, independent of which card is active. - const [savedProviders, setSavedProviders] = useState>(new Set()) - const [providerConfigs, setProviderConfigs] = useState>({ - 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. - // Backend model recommendations (flavor-keyed) — used when a provider - // becomes the assistant without an explicit model pick. - const modelRecommendations = useRowboatConfig()?.modelRecommendations - const providerModels = useProviderModels({ - flavor: provider, - apiKey: activeConfig.apiKey, - 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) => { - 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", - }) - // models.json v2: providers carry credentials only; the assistant - // model and per-task overrides live at the top level. This screen's - // per-provider "model" state is a UI concept — hydrated from - // assistantModel for its provider, empty for the rest (the picker - // lists come from the catalog / live probe, not from config). - const parsed = JSON.parse(result.data) - setDeferBackgroundTasks(parsed?.deferBackgroundTasks === true) - setDeferExplicit(typeof parsed?.deferBackgroundTasks === "boolean") - const knownFlavors = new Set([...primaryProviders, ...moreProviders].map(p => p.id)) - setSavedProviders(new Set( - Object.keys(parsed?.providers ?? {}).filter((k): k is LlmProviderFlavor => knownFlavors.has(k)) - )) - const assistant = parsed?.assistantModel as { provider?: string; model?: string } | undefined - const taskModels = (parsed?.taskModels ?? {}) as Record - const taskStringFor = (key: string, providerId: string): string => { - const ref = taskModels[key] - return ref?.provider === providerId ? asString(ref.model) : "" - } - setProviderConfigs(prev => { - const next = { ...prev }; - for (const [key, entry] of Object.entries(parsed?.providers ?? {})) { - if (!(key in next)) continue - const e = entry as { apiKey?: string; baseURL?: string }; - next[key as LlmProviderFlavor] = { - apiKey: e.apiKey || "", - baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""), - models: assistant?.provider === key && assistant.model ? [assistant.model] : [""], - knowledgeGraphModel: taskStringFor("knowledgeGraph", key), - meetingNotesModel: taskStringFor("meetingNotes", key), - liveNoteAgentModel: taskStringFor("liveNoteAgent", key), - autoPermissionDecisionModel: taskStringFor("autoPermissionDecision", key), - }; - } - return next; - }) - if (assistant?.provider && knownFlavors.has(assistant.provider)) { - setProvider(assistant.provider as LlmProviderFlavor) - setDefaultProvider(assistant.provider as LlmProviderFlavor) - } - } catch { - // No existing config or parse error - use defaults - } 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 - } - const providerEntry = { - flavor: provider, - apiKey: activeConfig.apiKey.trim() || undefined, - baseURL: activeConfig.baseURL.trim() || undefined, - } - const result = await window.ipc.invoke("models:test", { provider: providerEntry, model }) - if (result.success) { - // v2 writes: the provider entry carries credentials only; the model - // choice becomes the assistant model, and the BYOK per-task strings - // become task overrides scoped to this provider ('' clears → inherit). - const taskRef = (value: string) => { - const trimmed = value.trim() - return trimmed ? { provider, model: trimmed } : null - } - await window.ipc.invoke("models:setProvider", { id: provider, provider: providerEntry }) - await window.ipc.invoke("models:updateConfig", { - assistantModel: { provider, model }, - ...(rowboatConnected ? {} : { - taskModels: { - knowledgeGraph: taskRef(activeConfig.knowledgeGraphModel), - meetingNotes: taskRef(activeConfig.meetingNotesModel), - liveNoteAgent: taskRef(activeConfig.liveNoteAgentModel), - autoPermissionDecision: taskRef(activeConfig.autoPermissionDecisionModel), - }, - }), - }) - setDefaultProvider(provider) - setProviderConfigs(prev => ({ - ...prev, - [provider]: { ...prev[provider], models: [model] }, - })) - 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]) - - // "Set as default" = choose this provider's model as the assistant model. - // The UI state only knows a model for the provider that was already the - // assistant, so other providers resolve one the same way a first connect - // does: recommendation if the provider lists it, else the first listed. - const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => { - const config = providerConfigs[prov] - try { - let model = config.models[0]?.trim() || "" - if (!model) { - const listRes = await window.ipc.invoke("models:listForProvider", { - provider: { - flavor: prov, - apiKey: config.apiKey.trim() || undefined, - baseURL: config.baseURL.trim() || undefined, - }, - }) - const list = listRes.success ? listRes.models ?? [] : [] - const recommended = modelRecommendations?.[prov] - model = (recommended && list.includes(recommended)) ? recommended : (list[0] ?? "") - } - if (!model) { - toast.error("Couldn't determine a model for this provider") - return - } - await window.ipc.invoke("models:updateConfig", { assistantModel: { provider: prov, model } }) - setDefaultProvider(prov) - setProviderConfigs(prev => ({ - ...prev, - [prov]: { ...prev[prov], models: [model] }, - })) - window.dispatchEvent(new Event('models-config-changed')) - toast.success("Default provider updated") - } catch { - toast.error("Failed to set default provider") - } - }, [providerConfigs, modelRecommendations]) - - const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => { - const isDefaultProv = defaultProvider === prov - try { - // Removes the entry AND any assistant/task selection referencing it - // (repo-side dangling cleanup). - await window.ipc.invoke("models:removeProvider", { id: prov }) - - // Disconnecting the assistant's provider: promote another connected - // provider — the connect-only UI has no usable set-as-default step to - // send the user to. Best-effort; with none left the composer shows - // its connect hint. - let promoted: LlmProviderFlavor | null = null - if (isDefaultProv) { - const candidate = [...savedProviders].find((p): p is LlmProviderFlavor => p !== prov) - if (candidate) { - await handleSetDefault(candidate) - promoted = candidate - } - } - - 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, savedProviders, handleSetDefault]) - - 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 ( - - ) - } - - if (configLoading) { - return ( -
- - Loading... -
- ) - } - - return ( -
- {/* Provider selection */} -
- Provider -
- {primaryProviders.map(renderProviderCard)} -
- {(showMoreProviders || isMoreProvider) ? ( -
- {moreProviders.map(renderProviderCard)} -
- ) : ( - - )} -
- - {/* API Key — key-first: the model list is fetched from it */} - {showApiKey && ( -
- - {provider === "openai-compatible" ? "API Key (optional)" : "API Key"} - - updateConfig(provider, { apiKey: e.target.value })} - onBlur={() => providerModels.refetch()} - placeholder="Paste your API key" - /> -
- )} - - {/* 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" && ( -
- - ChatGPT Subscription - - {chatgpt.status.signedIn ? ( -
-
- - - Connected as {chatgpt.status.email ?? "your ChatGPT account"} - -
- -
- ) : chatgpt.isSigningIn ? ( -
-
- - Waiting for browser… -
- -
- ) : ( -
- - Use your ChatGPT Plus/Pro subscription - - -
- )} -
- )} - - {/* Base URL */} - {showBaseURL && ( -
- Base URL - 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" - } - /> -
- )} - - {/* Connection status — the model itself is resolved silently on save */} -
- {providerModels.status === "idle" ? ( -
- {isLocalProvider - ? "Enter your base URL to connect" - : "Enter your API key to connect"} -
- ) : providerModels.status === "loading" ? ( -
- - Checking connection… -
- ) : providerModels.status === "error" ? ( -
-
- {providerModels.error || "Connection check failed"} -
- - {provider !== "openai-compatible" && ( - setPrimaryModel(provider, e.target.value)} - placeholder="Enter a model to connect anyway" - /> - )} -
- ) : providerModels.models.length === 0 && provider !== "openai-compatible" ? ( -
-
- Connected, but the provider reported no models — enter one manually -
- setPrimaryModel(provider, e.target.value)} - placeholder="Enter model" - /> -
- ) : ( -
- - Connected · {providerModels.models.length} model{providerModels.models.length === 1 ? "" : "s"} available -
- )} - {savedProviders.has(provider) && ( - - )} -
- - {/* 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. */} -
- - {rowboatConnected ? "Model" : "Assistant model"} - - setPrimaryModel(provider, ref ? ref.model : "")} - /> -
- - {/* 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). */} -
- {!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 }) => ( -
- {label} - updateConfig(provider, { [field]: ref ? ref.model : "" })} - /> -
- ))} - )} -
- - {/* Test status */} - {testState.status === "error" && ( -
- {testState.error || "Connection test failed"} -
- )} - {testState.status === "success" && ( -
- - Connected and saved -
- )} - - {/* Defer background tasks while chatting */} -
-
-
Defer background tasks while chatting
-
- Background agents (knowledge sync, live notes, tasks) wait until your chat finishes. Recommended for local models. -
-
- -
- - {/* Test & Save button */} - -
- ) -} - -// --- Tools Library Settings --- interface ToolkitInfo { slug: string @@ -1504,129 +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(null) - const [selectedKg, setSelectedKg] = useState(null) - const [selectedLiveNote, setSelectedLiveNote] = useState(null) - const [selectedAutoPermission, setSelectedAutoPermission] = useState(null) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - - useEffect(() => { - if (!dialogOpen) return - - async function load() { - setLoading(true) - try { - let parsed: Record = {} - try { - const configResult = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" }) - parsed = JSON.parse(configResult.data) - } catch { /* no config yet */ } - - // models.json v2: assistantModel + taskModels refs. - const toRef = (value: unknown): ModelRef | null => { - const ref = value as { provider?: unknown; model?: unknown } | null | undefined - return ref && typeof ref.provider === "string" && typeof ref.model === "string" - ? { provider: ref.provider, model: ref.model } - : null - } - const taskModels = (parsed.taskModels ?? {}) as Record - setSelectedDefault(toRef(parsed.assistantModel)) - setSelectedKg(toRef(taskModels.knowledgeGraph)) - setSelectedLiveNote(toRef(taskModels.liveNoteAgent)) - setSelectedAutoPermission(toRef(taskModels.autoPermissionDecision)) - } 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", { - // The "Rowboat default" sentinel no longer maps to a hidden curated - // default — a null task ref simply inherits the assistant model. The - // assistant itself is only written when explicitly picked (clearing - // it would leave the app with no model at all). - ...(selectedDefault ? { assistantModel: selectedDefault } : {}), - taskModels: { - knowledgeGraph: selectedKg, - liveNoteAgent: selectedLiveNote, - autoPermissionDecision: selectedAutoPermission, - }, - }) - window.dispatchEvent(new Event("models-config-changed")) - toast.success("Model configuration saved") - } 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, - ) => ( -
- - -
- ) - - if (loading) { - return ( -
- -
- ) - } - - return ( -
-

- Select the models Rowboat uses. Rowboat models are provided through your account; models from your own providers route through your keys or local runtimes. -

- - {renderModelField("Assistant model", selectedDefault, setSelectedDefault)} - {renderModelField("Knowledge graph model", selectedKg, setSelectedKg)} - {renderModelField("Background agents model", selectedLiveNote, setSelectedLiveNote)} - {renderModelField("Permission checks model", selectedAutoPermission, setSelectedAutoPermission)} - - {/* Save */} - -
- ) -} - // --- Note Tagging Settings --- interface TagDef { @@ -2719,8 +1893,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control

{activeTabConfig.label}

- {activeTab === "models" && rowboatConnected - ? "Select your default models" + {activeTab === "models" + ? "Choose the models Rowboat uses for chat and background work." : activeTabConfig.description}

@@ -2747,21 +1921,22 @@ export function SettingsDialog({ children, defaultTab = "account", open: control ) : activeTab === "mobile" ? ( ) : activeTab === "models" ? ( - rowboatConnected - ? ( -
- - -
-

Your own providers

-

- 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. -

- -
-
- ) - : + // ONE model-selection surface for signed-in and BYOK alike: + // the Assistant model + per-task overrides, then provider + // (credential) management below. +
+ + +
+

{rowboatConnected ? "Your own providers" : "Providers"}

+

+ {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."} +

+ +
+
) : activeTab === "note-tagging" ? ( ) : activeTab === "appearance" ? ( diff --git a/apps/x/apps/renderer/src/components/settings/model-selection-section.test.tsx b/apps/x/apps/renderer/src/components/settings/model-selection-section.test.tsx new file mode 100644 index 00000000..065bf18e --- /dev/null +++ b/apps/x/apps/renderer/src/components/settings/model-selection-section.test.tsx @@ -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 Promise> = {} +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 +}): 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() + + // 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() + + 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()) + }) +}) diff --git a/apps/x/apps/renderer/src/components/settings/model-selection-section.tsx b/apps/x/apps/renderer/src/components/settings/model-selection-section.tsx new file mode 100644 index 00000000..627093a8 --- /dev/null +++ b/apps/x/apps/renderer/src/components/settings/model-selection-section.tsx @@ -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>>({}) + + // 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 ( +
+ {/* Assistant model — the one required primary selection. */} +
+
+

+ Assistant model + {assistantUnavailable && ( + + Unavailable + + )} +

+

+ Used for chat and for any task without its own model selection. +

+
+ + {assistantUnavailable && defaultModel && ( +

+ This model is no longer listed by {providerDisplayNames[defaultModel.provider] || defaultModel.provider}. Choose another model to continue. +

+ )} +
+ + {/* Per-task overrides — inherit the assistant unless picked. */} +
+
+

Models for other tasks

+

+ These tasks use the Assistant model unless you choose a different one. +

+
+
+ {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 ( +
+
+ {label} + {override && ( + + )} +
+ void setTask(key, ref)} + triggerTitle={label} + /> +

+ {override ? "Uses a different model from the Assistant" : inheritText} +

+

{description}

+
+ ) + })} +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/settings/providers-section.tsx b/apps/x/apps/renderer/src/components/settings/providers-section.tsx new file mode 100644 index 00000000..508df12f --- /dev/null +++ b/apps/x/apps/renderer/src/components/settings/providers-section.tsx @@ -0,0 +1,747 @@ +import { useCallback, useEffect, useMemo, useState } from "react" +import { toast } from "sonner" +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 { 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 +} + +const TASK_LABELS: Record = { + 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> = { + 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([]) + const [selections, setSelections] = useState({ assistantModel: null, taskModels: {} }) + const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false) + const [addOpen, setAddOpen] = useState(false) + const [manageId, setManageId] = useState(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 ( +
+
+ {cards.length === 0 && ( +
+ Connect Rowboat, use your own API key, or choose a local provider to start using the Assistant. +
+ )} + {cards.map((c) => ( +
+
+
+ {c.name} +
+
+ + {c.status === "ok" + ? `Connected · ${c.modelCount} model${c.modelCount === 1 ? "" : "s"} available` + : (c.error || "Could not load models")} +
+
+ {c.status === "error" && ( + + )} + {variant === "settings" && ( + + )} +
+ ))} +
+ + + + {/* Defer background tasks while chatting */} + {variant === "settings" && ( +
+
+
Defer background tasks while chatting
+
+ Background agents wait until no chat is running. Recommended for local models. +
+
+ +
+ )} + + c.id)} + isRowboatConnected={isRowboatConnected} + chatgptSignedIn={chatgpt.status.signedIn} + onChatGPTSignIn={chatgpt.signIn} + hadAssistant={selections.assistantModel !== null} + modelRecommendations={modelRecommendations} + /> + + {manageCard && ( + setManageId(null)} + onRefreshModels={async () => { await refresh(manageCard.id) }} + /> + )} +
+ ) +} + +// ---------- 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 }: { + open: boolean + onOpenChange: (open: boolean) => void + connectedIds: string[] + isRowboatConnected: boolean + chatgptSignedIn: boolean + onChatGPTSignIn: () => Promise | void + hadAssistant: boolean + modelRecommendations: Record | undefined +}) { + const [step, setStep] = useState({ 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() + let model = typed + if (!model) { + const recommended = modelRecommendations?.[flavor] + model = recommended && list.includes(recommended) ? recommended : (list[0] ?? "") + } + 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) { + await window.ipc.invoke("models:updateConfig", { assistantModel: { provider: flavor, model } }) + } + 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 ( + + + + + {step.kind === "creds" && credsMeta ? `Connect ${credsMeta.name}` : "Add provider"} + + + + {step.kind === "choose" && ( +
+

+ Connect Rowboat, add your own API key, or run models locally. Each provider's models appear alongside the others in every picker. +

+
+ {chooseEntries.map((e) => ( + + ))} +
+
+ )} + + {step.kind === "creds" && credsMeta && ( +
+ + {credsMeta.needsKey && ( +
+ + API key{credsMeta.optionalKey ? " (optional)" : ""} + + setApiKey(e.target.value)} + placeholder="Paste your API key" + /> +
+ )} + {credsMeta.needsEndpoint && ( +
+ Endpoint URL + setBaseURL(e.target.value)} + placeholder={DEFAULT_BASE_URLS[credsMeta.flavor] ?? "https://api.example.com/v1"} + /> +
+ )} + {credsMeta.manualModel && ( +
+ Model id (optional) + setManualModel(e.target.value)} + placeholder="Leave empty to auto-select" + /> +
+ )} + +
+ )} + + {step.kind === "authwait" && ( +
+ +
Complete sign-in in your browser…
+ +
+ )} + + {step.kind === "loading" && ( +
+ +
Loading available models…
+
Validating the connection and fetching the model list.
+
+ )} + + {step.kind === "result" && ( +
+
+ + {step.name} connected +
+ {step.first && step.pickedModel && ( +

+ We selected {step.pickedModel} as your Assistant model to get you started. You can change it any time above. +

+ )} + {step.first && !step.pickedModel && ( +

Your Assistant model has been set. You can change it any time above.

+ )} + {!step.first && ( +

+ {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. +

+ )} +
+ +
+
+ )} + + {step.kind === "error" && credsMeta && ( +
+
Couldn't connect
+

{step.message}

+ {credsMeta.manualModel && ( +
+ Model id + setManualModel(e.target.value)} + placeholder="Enter a model id to connect anyway" + /> +
+ )} +
+ + +
+
+ )} +
+
+ ) +} + +// ---------- 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 +}) { + 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 ( + { if (!o) onClose() }}> + + + {card.name} + +
+
+ + {card.status === "ok" + ? `Connected · ${card.modelCount} model${card.modelCount === 1 ? "" : "s"} available` + : (card.error || "Could not load models")} +
+ + {!isAuthDerived && meta?.needsKey && ( +
+ API key + {replacingKey ? ( +
+ setNewKey(e.target.value)} + placeholder="Paste the new API key" + /> + +
+ ) : ( +
+ + +
+ )} +
+ )} + + {!isAuthDerived && meta?.needsEndpoint && ( +
+ Endpoint URL +
+ setEndpoint(e.target.value)} /> + +
+
+ )} + +
+
Refresh to pull the latest models from {card.name}.
+ +
+ +
+ Used by + {usedBy.length === 0 ? ( +
No model selections currently use this provider.
+ ) : ( +
+ {usedBy.map((u) => ( +
+ {u.label} + {u.model} +
+ ))} +
+ )} +
+ +
+ {!confirmDisconnect ? ( +
+ + {card.flavor === "rowboat" + ? "Sign out of your Rowboat account." + : `Remove ${card.name} and its models from Rowboat.`} + + +
+ ) : ( +
+
Disconnect {card.name}?
+

+ {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."} +

+
+ + +
+
+ )} +
+
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/hooks/use-models.test.tsx b/apps/x/apps/renderer/src/hooks/use-models.test.tsx index f3b6b2ff..03670c1e 100644 --- a/apps/x/apps/renderer/src/hooks/use-models.test.tsx +++ b/apps/x/apps/renderer/src/hooks/use-models.test.tsx @@ -190,8 +190,12 @@ describe('useModels', () => { const { result } = renderHook(() => useModels()) await waitFor(() => expect(result.current.groups.length).toBe(1)) - act(() => result.current.refresh('ollama')) - await waitFor(() => expect(invokeCounts['models:list']).toBe(2)) + // 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' }) }) }) diff --git a/apps/x/apps/renderer/src/hooks/use-models.ts b/apps/x/apps/renderer/src/hooks/use-models.ts index e29238cf..6728cb10 100644 --- a/apps/x/apps/renderer/src/hooks/use-models.ts +++ b/apps/x/apps/renderer/src/hooks/use-models.ts @@ -40,9 +40,10 @@ export interface UseModelsResult extends ModelsSnapshot { /** * 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). + * (the error-row Retry, Manage's "Refresh models"). Resolves once the + * snapshot has been updated, so callers can render progress. */ - refresh: (providerId?: string) => void + refresh: (providerId?: string) => Promise } const EMPTY_SNAPSHOT: ModelsSnapshot = { @@ -116,13 +117,13 @@ async function buildSnapshot(refreshProvider?: string): Promise } } -function startFetch(refreshProvider?: string): void { +function startFetch(refreshProvider?: string): Promise { // 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(refreshProvider) + return buildSnapshot(refreshProvider) .then((next) => { if (seq !== fetchSeq) return snapshot = next @@ -138,19 +139,19 @@ function startFetch(refreshProvider?: string): void { }) } -function refreshModels(providerId?: string): void { - startFetch(typeof providerId === 'string' ? providerId : undefined) +function refreshModels(providerId?: string): Promise { + 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 = () => startFetch() + 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', refetch) diff --git a/apps/x/apps/renderer/src/hooks/use-provider-models.ts b/apps/x/apps/renderer/src/hooks/use-provider-models.ts deleted file mode 100644 index 4b737442..00000000 --- a/apps/x/apps/renderer/src/hooks/use-provider-models.ts +++ /dev/null @@ -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() -// De-dupes concurrent requests for the same key (debounce firing + field blur). -const inFlight = new Map>() - -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 { - 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 } -} diff --git a/apps/x/packages/core/src/models/catalog.ts b/apps/x/packages/core/src/models/catalog.ts index 1f1ecea4..7f61258f 100644 --- a/apps/x/packages/core/src/models/catalog.ts +++ b/apps/x/packages/core/src/models/catalog.ts @@ -29,7 +29,7 @@ export interface CatalogModelEntry { export interface CatalogProviderEntry { /** - * Provider INSTANCE identifier — what ModelRef.provider, defaultSelection, + * 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" / diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts index 2b35a541..c2b97f53 100644 --- a/apps/x/packages/core/src/models/defaults.ts +++ b/apps/x/packages/core/src/models/defaults.ts @@ -107,12 +107,18 @@ export async function getChatTitleModel(): Promise { return getCategoryModel("chatTitle"); } -/** - * 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. - */ +/** Model used by the background-task agent + routing classifier. */ export async function getBackgroundTaskAgentModel(): Promise { - return getLiveNoteAgentModel(); + return getCategoryModel("backgroundTask"); +} + +/** + * 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 getSubagentModelOverride(): Promise { + const cfg = await readConfig(); + return cfg?.taskModels?.subagent ?? null; } diff --git a/apps/x/packages/core/src/models/migrate.test.ts b/apps/x/packages/core/src/models/migrate.test.ts index 9c97e1e1..a8a8dce4 100644 --- a/apps/x/packages/core/src/models/migrate.test.ts +++ b/apps/x/packages/core/src/models/migrate.test.ts @@ -38,6 +38,8 @@ describe('migrateModelsConfig', () => { 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 @@ -59,6 +61,7 @@ describe('migrateModelsConfig', () => { 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. @@ -83,6 +86,18 @@ describe('migrateModelsConfig', () => { }); }); + 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' }, diff --git a/apps/x/packages/core/src/models/migrate.ts b/apps/x/packages/core/src/models/migrate.ts index 99d19e8f..2a03a7ef 100644 --- a/apps/x/packages/core/src/models/migrate.ts +++ b/apps/x/packages/core/src/models/migrate.ts @@ -131,11 +131,15 @@ export function migrateModelsConfig(rawInput: unknown, signedIn: boolean): V2 | 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 = { knowledgeGraph: v1Override(raw, "knowledgeGraphModel", signedIn) ?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel), - liveNoteAgent: v1Override(raw, "liveNoteAgentModel", signedIn) - ?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel), + 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) diff --git a/apps/x/packages/core/src/models/repo.ts b/apps/x/packages/core/src/models/repo.ts index a3c200c2..6936c447 100644 --- a/apps/x/packages/core/src/models/repo.ts +++ b/apps/x/packages/core/src/models/repo.ts @@ -90,8 +90,20 @@ export class FSModelConfigRepo implements IModelConfigRepo { } async setProvider(id: string, provider: z.infer): Promise { + // 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(); - config.providers[id] = LlmProvider.parse(provider); + // 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); } diff --git a/apps/x/packages/core/src/runtime/assembly/spawn-agent.test.ts b/apps/x/packages/core/src/runtime/assembly/spawn-agent.test.ts index 7b61abd4..194d71e2 100644 --- a/apps/x/packages/core/src/runtime/assembly/spawn-agent.test.ts +++ b/apps/x/packages/core/src/runtime/assembly/spawn-agent.test.ts @@ -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[] = []; diff --git a/apps/x/packages/core/src/runtime/assembly/spawn-agent.ts b/apps/x/packages/core/src/runtime/assembly/spawn-agent.ts index 2b7ad77d..24b25b4b 100644 --- a/apps/x/packages/core/src/runtime/assembly/spawn-agent.ts +++ b/apps/x/packages/core/src/runtime/assembly/spawn-agent.ts @@ -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 | 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 | null +> { + try { + const { getSubagentModelOverride } = await import( + "../../models/defaults.js" + ); + return await getSubagentModelOverride(); + } catch { + return null; + } +} + async function resolveServices(): Promise< NonNullable > { diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 7baa6285..5661a75f 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -656,7 +656,7 @@ const ipcSchemas = { }).nullable(), res: z.object({ providers: z.array(z.object({ - // Provider INSTANCE id — what ModelRef.provider / defaultSelection / + // 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. @@ -748,6 +748,33 @@ const ipcSchemas = { 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. @@ -760,6 +787,8 @@ const ipcSchemas = { 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(), }), diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index e376bee4..9fa56f5a 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -41,13 +41,17 @@ export const ModelRef = z.object({ model: z.string(), }); -// The per-task model override slots. Absence = inherit the assistant model. +// 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; From d9317f3ca1e154c3f4270d4ed12bc5aaf1240808 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:30:03 +0530 Subject: [PATCH 5/6] feat(x): PostHog instrumentation for provider/model selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Privacy rules encoded in one place (analytics/model-providers.ts): only provider FLAVORS leave the app — never instance ids (future-proofing for user-named instances), never apiKey/headers, never baseURL (local endpoints can carry internal hostnames). Model ids allowed. - llm_usage.provider now reports the flavor: captureLlmUsage maps the instance id centrally (lazy, cached, falls back to the raw value — which today equals the flavor, so history stays continuous) - llm_provider_connected / _disconnected {flavor}: one event family across all surfaces — BYOK at the repo choke point (new entries only; key rotation is not a connect), rowboat at OAuth sign-in/out, codex at ChatGPT sign-in/out - person properties synced on every launch and after provider/assistant changes: llm_provider_flavors (sorted, auth-derived included), llm_provider_count, assistant_model + assistant_model_flavor - llm_initial_model_selected {flavor, model, recommended, source}: recommendation hit-rate for the initial-selection flow (connect / onboarding / sign_in) - models_config_migrated {had_assistant, materialized_overrides, provider_count}: one-shot rollout health for the v2 migration - ANALYTICS.md updated (llm_usage provider row, new lifecycle section, person-properties table) Co-Authored-By: Claude Fable 5 --- apps/x/ANALYTICS.md | 13 ++- apps/x/apps/main/src/ipc.ts | 3 + apps/x/apps/main/src/main.ts | 6 + apps/x/apps/main/src/oauth-handler.ts | 3 + .../components/settings/providers-section.tsx | 11 +- apps/x/apps/renderer/src/lib/analytics.ts | 13 +++ .../core/src/analytics/model-providers.ts | 105 ++++++++++++++++++ apps/x/packages/core/src/analytics/posthog.ts | 15 +++ apps/x/packages/core/src/analytics/usage.ts | 39 ++++--- .../core/src/models/chatgpt-selection.ts | 7 ++ apps/x/packages/core/src/models/repo.ts | 21 ++++ .../core/src/models/rowboat-selection.ts | 9 ++ 12 files changed, 229 insertions(+), 16 deletions(-) create mode 100644 apps/x/packages/core/src/analytics/model-providers.ts diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 59183479..cf3a0790 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -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, 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. Emit points: `apps/renderer/src/components/settings/providers-section.tsx` (connect/onboarding) and `packages/core/src/models/rowboat-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 diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index cd8e586f..cc9d177c 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -35,6 +35,7 @@ import type { ITurnEventBus } from '@x/core/dist/runtime/turns/event-hub.js'; import container from '@x/core/dist/di/container.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 type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; @@ -1336,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; }, @@ -1347,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); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index e442cba3..8b627ca2 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -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)); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 78d6c4d1..a5c458ba 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -18,6 +18,7 @@ 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`; @@ -345,6 +346,7 @@ export async function connectProvider(provider: string, credentials?: { clientId // 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) { @@ -540,6 +542,7 @@ export async function disconnectProvider(provider: string): Promise<{ success: b // 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 }); diff --git a/apps/x/apps/renderer/src/components/settings/providers-section.tsx b/apps/x/apps/renderer/src/components/settings/providers-section.tsx index 508df12f..0eb15424 100644 --- a/apps/x/apps/renderer/src/components/settings/providers-section.tsx +++ b/apps/x/apps/renderer/src/components/settings/providers-section.tsx @@ -1,5 +1,6 @@ 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" @@ -225,6 +226,7 @@ export function ProvidersSection({ dialogOpen, variant = "settings" }: { onChatGPTSignIn={chatgpt.signIn} hadAssistant={selections.assistantModel !== null} modelRecommendations={modelRecommendations} + analyticsSource={variant === "onboarding" ? "onboarding" : "connect"} /> {manageCard && ( @@ -249,7 +251,7 @@ type AddStep = | { 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 }: { +function AddProviderDialog({ open, onOpenChange, connectedIds, isRowboatConnected, chatgptSignedIn, onChatGPTSignIn, hadAssistant, modelRecommendations, analyticsSource }: { open: boolean onOpenChange: (open: boolean) => void connectedIds: string[] @@ -258,6 +260,7 @@ function AddProviderDialog({ open, onOpenChange, connectedIds, isRowboatConnecte onChatGPTSignIn: () => Promise | void hadAssistant: boolean modelRecommendations: Record | undefined + analyticsSource: 'connect' | 'onboarding' }) { const [step, setStep] = useState({ kind: "choose" }) const [apiKey, setApiKey] = useState("") @@ -392,6 +395,12 @@ function AddProviderDialog({ open, onOpenChange, connectedIds, isRowboatConnecte const hasAssistantNow = cfgNow ? cfgNow.assistantModel !== null : hadAssistant if (!hasAssistantNow) { await window.ipc.invoke("models:updateConfig", { assistantModel: { provider: flavor, model } }) + analytics.llmInitialModelSelected({ + flavor, + model, + recommended: model === modelRecommendations?.[flavor], + source: analyticsSource, + }) } for (const warning of testRes.warnings ?? []) { toast.warning(warning, { duration: 12000 }) diff --git a/apps/x/apps/renderer/src/lib/analytics.ts b/apps/x/apps/renderer/src/lib/analytics.ts index 0774e574..621c637b 100644 --- a/apps/x/apps/renderer/src/lib/analytics.ts +++ b/apps/x/apps/renderer/src/lib/analytics.ts @@ -336,3 +336,16 @@ 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 + source: 'connect' | 'onboarding' +}) { + posthog.capture('llm_initial_model_selected', { ...props }) +} diff --git a/apps/x/packages/core/src/analytics/model-providers.ts b/apps/x/packages/core/src/analytics/model-providers.ts new file mode 100644 index 00000000..3dc86d2f --- /dev/null +++ b/apps/x/packages/core/src/analytics/model-providers.ts @@ -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 } | null = null; + +async function resolveRepo(): Promise { + const { default: container } = await import("../di/container.js"); + return container.resolve("modelConfigRepo"); +} + +async function providerFlavorsById(): Promise> { + if (flavorCache && Date.now() - flavorCache.at < FLAVOR_CACHE_TTL_MS) { + return flavorCache.byId; + } + const byId = new Map(); + 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 { + 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 { + 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(); + 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(); +} diff --git a/apps/x/packages/core/src/analytics/posthog.ts b/apps/x/packages/core/src/analytics/posthog.ts index 81e24eb8..3b93091c 100644 --- a/apps/x/packages/core/src/analytics/posthog.ts +++ b/apps/x/packages/core/src/analytics/posthog.ts @@ -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): 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 diff --git a/apps/x/packages/core/src/analytics/usage.ts b/apps/x/packages/core/src/analytics/usage.ts index 31b703dc..a5421f33 100644 --- a/apps/x/packages/core/src/analytics/usage.ts +++ b/apps/x/packages/core/src/analytics/usage.ts @@ -21,18 +21,29 @@ export interface CaptureLlmUsageArgs { } export function captureLlmUsage(args: CaptureLlmUsageArgs): void { - const usage = args.usage ?? {}; - const properties: Record = { - 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 = { + 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); + })(); } diff --git a/apps/x/packages/core/src/models/chatgpt-selection.ts b/apps/x/packages/core/src/models/chatgpt-selection.ts index 36d04005..1f1a57c4 100644 --- a/apps/x/packages/core/src/models/chatgpt-selection.ts +++ b/apps/x/packages/core/src/models/chatgpt-selection.ts @@ -3,6 +3,7 @@ import { IModelConfigRepo } from "./repo.js"; import { listCodexModels } from "./codex.js"; import { getRowboatConfig } from "../config/rowboat.js"; import { selectInitialModel } from "./initial-selection.js"; +import { capture } from "../analytics/posthog.js"; /** * Model-selection hooks for the ChatGPT-subscription (codex) sign-in @@ -27,6 +28,12 @@ export async function applyCodexInitialSelection(): Promise { const model = selectInitialModel("codex", ids, recommendations); if (model) { await repo.updateConfig({ assistantModel: { provider: "codex", model } }); + capture("llm_initial_model_selected", { + flavor: "codex", + model, + recommended: model === recommendations?.["codex"], + source: "sign_in", + }); } } catch (error) { // Best-effort: a failed initial selection must never break sign-in. diff --git a/apps/x/packages/core/src/models/repo.ts b/apps/x/packages/core/src/models/repo.ts index 6936c447..0311c5eb 100644 --- a/apps/x/packages/core/src/models/repo.ts +++ b/apps/x/packages/core/src/models/repo.ts @@ -1,6 +1,12 @@ 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"; @@ -81,6 +87,12 @@ export class FSModelConfigRepo implements IModelConfigRepo { // 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, + }); } } @@ -98,6 +110,7 @@ export class FSModelConfigRepo implements IModelConfigRepo { 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({ @@ -105,10 +118,13 @@ export class FSModelConfigRepo implements IModelConfigRepo { ...provider, }); await this.write(config); + // A brand-new entry is a connect; a key rotation is not. + if (isNew) captureProviderConnected(provider.flavor); } async removeProvider(id: string): Promise { const config = await this.read(); + const removed = config.providers[id]; delete config.providers[id]; if (config.assistantModel?.provider === id) { delete config.assistantModel; @@ -122,6 +138,7 @@ export class FSModelConfigRepo implements IModelConfigRepo { if (Object.keys(config.taskModels).length === 0) delete config.taskModels; } await this.write(config); + if (removed) captureProviderDisconnected(removed.flavor); } async updateConfig(patch: ModelConfigPatch): Promise { @@ -145,6 +162,10 @@ export class FSModelConfigRepo implements IModelConfigRepo { 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 { diff --git a/apps/x/packages/core/src/models/rowboat-selection.ts b/apps/x/packages/core/src/models/rowboat-selection.ts index 7e3c94e0..7a6bc1dc 100644 --- a/apps/x/packages/core/src/models/rowboat-selection.ts +++ b/apps/x/packages/core/src/models/rowboat-selection.ts @@ -3,6 +3,7 @@ import { IModelConfigRepo } from "./repo.js"; import { listGatewayModels } from "./gateway.js"; import { getRowboatConfig } from "../config/rowboat.js"; import { selectInitialModel } from "./initial-selection.js"; +import { capture } from "../analytics/posthog.js"; /** * Model-selection hooks for the Rowboat sign-in lifecycle. Signing in is @@ -28,6 +29,14 @@ export async function applyRowboatInitialSelection(): Promise { const model = selectInitialModel("rowboat", ids, recommendations); if (model) { await repo.updateConfig({ assistantModel: { provider: "rowboat", model } }); + // 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 === recommendations?.["rowboat"], + source: "sign_in", + }); } } catch (error) { // Best-effort: a failed initial selection must never break sign-in. From 72e71e0ab5daf7a9954f6bc24eb83b3ca7d023ac Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:33:59 +0530 Subject: [PATCH 6/6] feat(x): apply per-task model recommendations at initial selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend's modelRecommendations entries are now { assistantModel, taskModels? } (rowboatx-backend#18), mirroring models.json v2 vocabulary. When a provider connect/sign-in seeds the assistant (and ONLY then), its per-task recommendations become visible taskModels overrides — each validated against the live list and skipped when equal to the chosen assistant (only differences are written, same principle as the migration). This restores pre-v2 economics for signed-in task models without hidden defaults: fresh Rowboat sign-ins, new devices, and sign-out→sign-in round trips all land with the lite-tier task models the app used to hardcode — server-controlled, updateable without a release. Saved choices are still never touched: no seeding happens when an assistant already exists. - selection logic moves to @x/shared/initial-selection (core re-exports; the renderer connect flow uses the same implementation) - RowboatApiConfig accepts both the legacy flat shape and the nested one (normalizeModelRecommendation), so backend deploy order and rollback are non-events - llm_initial_model_selected gains task_overrides_seeded Co-Authored-By: Claude Fable 5 --- apps/x/ANALYTICS.md | 2 +- .../components/settings/providers-section.tsx | 21 +++--- apps/x/apps/renderer/src/lib/analytics.ts | 4 +- .../core/src/models/chatgpt-selection.ts | 14 +++- .../core/src/models/initial-selection.test.ts | 45 ++++++++++++- .../core/src/models/initial-selection.ts | 37 +--------- .../core/src/models/rowboat-selection.ts | 16 ++++- .../packages/shared/src/initial-selection.ts | 67 +++++++++++++++++++ apps/x/packages/shared/src/rowboat-account.ts | 46 ++++++++++--- 9 files changed, 193 insertions(+), 59 deletions(-) create mode 100644 apps/x/packages/shared/src/initial-selection.ts diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index cf3a0790..f016c066 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -89,7 +89,7 @@ Emit points: `apps/main/src/oauth-handler.ts:369` and `apps/renderer/src/hooks/u 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, 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. Emit points: `apps/renderer/src/components/settings/providers-section.tsx` (connect/onboarding) and `packages/core/src/models/rowboat-selection.ts` (sign-in). +- `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) diff --git a/apps/x/apps/renderer/src/components/settings/providers-section.tsx b/apps/x/apps/renderer/src/components/settings/providers-section.tsx index 0eb15424..b46f140e 100644 --- a/apps/x/apps/renderer/src/components/settings/providers-section.tsx +++ b/apps/x/apps/renderer/src/components/settings/providers-section.tsx @@ -8,6 +8,8 @@ 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" @@ -259,7 +261,7 @@ function AddProviderDialog({ open, onOpenChange, connectedIds, isRowboatConnecte chatgptSignedIn: boolean onChatGPTSignIn: () => Promise | void hadAssistant: boolean - modelRecommendations: Record | undefined + modelRecommendations: ModelRecommendations | undefined analyticsSource: 'connect' | 'onboarding' }) { const [step, setStep] = useState({ kind: "choose" }) @@ -368,11 +370,7 @@ function AddProviderDialog({ open, onOpenChange, connectedIds, isRowboatConnecte const listRes = await window.ipc.invoke("models:listForProvider", { provider: providerEntry }) const list = listRes.success ? listRes.models ?? [] : [] const typed = manualModel.trim() - let model = typed - if (!model) { - const recommended = modelRecommendations?.[flavor] - model = recommended && list.includes(recommended) ? recommended : (list[0] ?? "") - } + 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 @@ -394,11 +392,18 @@ function AddProviderDialog({ open, onOpenChange, connectedIds, isRowboatConnecte const cfgNow = await window.ipc.invoke("models:getConfig", null).catch(() => null) const hasAssistantNow = cfgNow ? cfgNow.assistantModel !== null : hadAssistant if (!hasAssistantNow) { - await window.ipc.invoke("models:updateConfig", { assistantModel: { provider: flavor, model } }) + // 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 === modelRecommendations?.[flavor], + recommended: model === normalizeModelRecommendation(modelRecommendations, flavor)?.assistantModel, + taskOverridesSeeded: Object.keys(taskModels).length, source: analyticsSource, }) } diff --git a/apps/x/apps/renderer/src/lib/analytics.ts b/apps/x/apps/renderer/src/lib/analytics.ts index 621c637b..d0c2fa56 100644 --- a/apps/x/apps/renderer/src/lib/analytics.ts +++ b/apps/x/apps/renderer/src/lib/analytics.ts @@ -345,7 +345,9 @@ export function llmInitialModelSelected(props: { flavor: string model: string recommended: boolean + taskOverridesSeeded: number source: 'connect' | 'onboarding' }) { - posthog.capture('llm_initial_model_selected', { ...props }) + const { taskOverridesSeeded, ...rest } = props + posthog.capture('llm_initial_model_selected', { ...rest, task_overrides_seeded: taskOverridesSeeded }) } diff --git a/apps/x/packages/core/src/models/chatgpt-selection.ts b/apps/x/packages/core/src/models/chatgpt-selection.ts index 1f1a57c4..b78788ea 100644 --- a/apps/x/packages/core/src/models/chatgpt-selection.ts +++ b/apps/x/packages/core/src/models/chatgpt-selection.ts @@ -2,7 +2,8 @@ import container from "../di/container.js"; import { IModelConfigRepo } from "./repo.js"; import { listCodexModels } from "./codex.js"; import { getRowboatConfig } from "../config/rowboat.js"; -import { selectInitialModel } from "./initial-selection.js"; +import { selectInitialModel, selectInitialTaskModels } from "./initial-selection.js"; +import { normalizeModelRecommendation } from "@x/shared/dist/rowboat-account.js"; import { capture } from "../analytics/posthog.js"; /** @@ -27,11 +28,18 @@ export async function applyCodexInitialSelection(): Promise { const recommendations = (await getRowboatConfig().catch(() => null))?.modelRecommendations; const model = selectInitialModel("codex", ids, recommendations); if (model) { - await repo.updateConfig({ assistantModel: { provider: "codex", 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 === recommendations?.["codex"], + recommended: model === normalizeModelRecommendation(recommendations, "codex")?.assistantModel, + task_overrides_seeded: Object.keys(taskModels).length, source: "sign_in", }); } diff --git a/apps/x/packages/core/src/models/initial-selection.test.ts b/apps/x/packages/core/src/models/initial-selection.test.ts index 44ca5fc0..26ebad1c 100644 --- a/apps/x/packages/core/src/models/initial-selection.test.ts +++ b/apps/x/packages/core/src/models/initial-selection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { selectInitialModel } from './initial-selection.js'; +import { selectInitialModel, selectInitialTaskModels } from './initial-selection.js'; describe('selectInitialModel', () => { const recommendations = { @@ -29,4 +29,47 @@ describe('selectInitialModel', () => { 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({}); + }); }); diff --git a/apps/x/packages/core/src/models/initial-selection.ts b/apps/x/packages/core/src/models/initial-selection.ts index de6ad1d0..d894390e 100644 --- a/apps/x/packages/core/src/models/initial-selection.ts +++ b/apps/x/packages/core/src/models/initial-selection.ts @@ -1,34 +1,3 @@ -/** - * 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. - * - * 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 function by design: the caller supplies 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). Recommendations are best-effort — an - * absent map, an unknown flavor, or a recommendation the provider doesn't - * serve all degrade to "first available model". - */ -export function selectInitialModel( - flavor: string, - availableModelIds: string[], - recommendations: Record | undefined, -): string | null { - const recommended = recommendations?.[flavor]; - if (recommended && availableModelIds.includes(recommended)) { - return recommended; - } - return availableModelIds[0] ?? null; -} +// 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"; diff --git a/apps/x/packages/core/src/models/rowboat-selection.ts b/apps/x/packages/core/src/models/rowboat-selection.ts index 7a6bc1dc..23d4aa40 100644 --- a/apps/x/packages/core/src/models/rowboat-selection.ts +++ b/apps/x/packages/core/src/models/rowboat-selection.ts @@ -2,7 +2,8 @@ import container from "../di/container.js"; import { IModelConfigRepo } from "./repo.js"; import { listGatewayModels } from "./gateway.js"; import { getRowboatConfig } from "../config/rowboat.js"; -import { selectInitialModel } from "./initial-selection.js"; +import { selectInitialModel, selectInitialTaskModels } from "./initial-selection.js"; +import { normalizeModelRecommendation } from "@x/shared/dist/rowboat-account.js"; import { capture } from "../analytics/posthog.js"; /** @@ -28,13 +29,22 @@ export async function applyRowboatInitialSelection(): Promise { const recommendations = (await getRowboatConfig().catch(() => null))?.modelRecommendations; const model = selectInitialModel("rowboat", ids, recommendations); if (model) { - await repo.updateConfig({ assistantModel: { provider: "rowboat", 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 === recommendations?.["rowboat"], + recommended: model === normalizeModelRecommendation(recommendations, "rowboat")?.assistantModel, + task_overrides_seeded: Object.keys(taskModels).length, source: "sign_in", }); } diff --git a/apps/x/packages/shared/src/initial-selection.ts b/apps/x/packages/shared/src/initial-selection.ts new file mode 100644 index 00000000..11bb17c3 --- /dev/null +++ b/apps/x/packages/shared/src/initial-selection.ts @@ -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>; + +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, z.infer>> { + const taskRecommendations = normalizeModelRecommendation(recommendations, flavor)?.taskModels; + if (!taskRecommendations) return {}; + const overrides: Partial, z.infer>> = {}; + 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; +} diff --git a/apps/x/packages/shared/src/rowboat-account.ts b/apps/x/packages/shared/src/rowboat-account.ts index 526adf30..9c4d457e 100644 --- a/apps/x/packages/shared/src/rowboat-account.ts +++ b/apps/x/packages/shared/src/rowboat-account.ts @@ -11,13 +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(), - // One recommended model id per provider FLAVOR (e.g. { openai: "gpt-5.4", - // openrouter: "anthropic/claude-opus-4.8" }), in each provider's native id - // format. A hint for the INITIAL selection when a provider is first + // 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 core/models/initial-selection.ts). 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.string()).optional(), + // (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['modelRecommendations']>; + +export interface NormalizedModelRecommendation { + assistantModel: string; + taskModels: Record; +} + +/** 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 ?? {} }; +}