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] 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': {