refactor(x): unify model listing behind one catalog pipeline

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 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-24 12:42:40 +05:30
parent f0e8e51a0e
commit 5729e21d77
9 changed files with 767 additions and 287 deletions

View file

@ -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);

View file

@ -24,23 +24,12 @@ let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
}
function serveTwoProviders(): void {
handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: false } } })
handlers['llm:getDefaultModel'] = async () => ({ provider: 'openai', model: 'gpt-5.4' })
handlers['models:list'] = async () => ({
providers: [
{ id: 'openai', name: 'OpenAI', models: [{ id: 'gpt-5.4' }] },
{ id: 'anthropic', name: 'Anthropic', models: [{ id: 'claude-opus-4-8' }] },
{ id: 'openai', flavor: 'openai', status: 'ok', 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' },
})
}

View file

@ -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<ModelPickerGroup, { kind: 'live' }>
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 (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">{label}</DropdownMenuLabel>
{visible.map((m) => {
const key = `${group.flavor}/${m}`
const key = `${flavor}/${m}`
return (
<DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m}</span>
@ -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<ModelPickerGroup[]>(() => {
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<HTMLInputElement>(null)
const [liveGroupHasRows, setLiveGroupHasRows] = useState<Record<string, boolean>>({})
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<ModelRef | null>(() => {
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 ? (
<div className="p-1">
<DropdownMenuItem disabled>Connect a provider in Settings</DropdownMenuItem>
</div>
@ -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 (
<LiveProviderGroupItems
key={g.flavor}
group={g}
label={label}
pinnedModels={pinned}
filter={modelFilterValue}
onModelRowsChange={handleLiveGroupRows}
/>
)
}
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 (
<Fragment key={g.flavor}>
<Fragment key={g.id}>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{label}
</DropdownMenuLabel>
{visibleModels.map((m) => {
const key = `${g.flavor}/${m}`
const key = `${g.id}/${m}`
return (
<DropdownMenuRadioItem key={key} value={key}>
<span className="truncate">{m}</span>
</DropdownMenuRadioItem>
)
})}
{showError && (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
refresh(g.id)
}}
className="text-xs"
>
<span className="truncate text-destructive">{g.error || 'Failed to load models'}</span>
<span className="ml-auto shrink-0 text-muted-foreground">Retry</span>
</DropdownMenuItem>
)}
</Fragment>
)
})}
{probeActive && liveFlavor && (
<ProbeProviderGroupItems
flavor={liveFlavor}
apiKey={liveApiKey}
baseURL={liveBaseURL}
label={providerDisplayNames[liveFlavor] || liveFlavor}
pinnedModels={defaultModel && defaultModel.provider === liveFlavor ? [defaultModel.model] : []}
filter={modelFilterValue}
onModelRowsChange={handleProbeRows}
/>
)}
{modelFilterValue && !anyModelRowVisible && (
allowCustom ? (
// Escape hatch for ids the lists don't carry (local

View file

@ -8,6 +8,7 @@ import { __resetModelsForTests, useModels } from './use-models'
// captures listeners so tests can fire main-process broadcasts.
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
let invokeCounts: Record<string, number> = {}
let invokeArgs: Record<string, unknown[]> = {}
let ipcListeners: Record<string, Array<(payload: unknown) => void>> = {}
;(window as unknown as { ipc: unknown }).ipc = {
@ -19,21 +20,32 @@ let ipcListeners: Record<string, Array<(payload: unknown) => void>> = {}
},
invoke: (channel: string, args: unknown) => {
invokeCounts[channel] = (invokeCounts[channel] ?? 0) + 1
;(invokeArgs[channel] ??= []).push(args)
const handler = handlers[channel]
return handler ? handler(args) : Promise.reject(new Error(`no handler: ${channel}`))
},
}
function serveConfig(providers: Record<string, unknown>): void {
handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: false } } })
handlers['llm:getDefaultModel'] = async () => ({ provider: 'openai', model: 'gpt-5.4' })
// One catalog response serves the whole snapshot — the unified pipeline's
// single IPC call.
function serveCatalog(catalog: {
providers: Array<{
id: string
flavor?: string
status?: 'ok' | 'error'
error?: string
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' })
})
})

View file

@ -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<string>(['openrouter', 'aigateway', 'ollama', 'openai-compatible'])
// Catalog-preferred flavors that degrade to a live fetch when models:list has
// no catalog for them (signed-in mode returns only the rowboat provider, or
// the models.dev cache is empty).
const LIVE_FALLBACK_FLAVORS = new Set<string>(['openai', 'anthropic', 'google'])
// One picker group per connected provider, straight from the unified model
// catalog (models:list → core/models/catalog.ts). Every provider — Rowboat
// gateway, ChatGPT subscription (codex), BYOK keys, local endpoints — comes
// through the same pipeline with a resolved list and a status; there is no
// renderer-side fetching or per-flavor special casing.
export interface ModelPickerGroup {
/** Provider instance id — what ModelRef.provider joins on. */
id: string
/** Provider type ("openai", "ollama", "rowboat", …) — display naming. */
flavor: string
models: string[]
/** 'error' = provider is connected but its model list failed to load. */
status: 'ok' | 'error'
error?: string
}
export interface ModelsSnapshot {
groups: ModelPickerGroup[]
// Per-model reasoning capability ("provider/model" → flag) from models:list.
// Live-fetched ids carry no reasoning metadata, so lookups miss → treated
// as non-reasoning.
// Per-model reasoning capability ("provider/model" → flag) from the
// catalog. Ids without metadata miss → treated as non-reasoning.
reasoningByKey: Record<string, boolean>
// The effective runtime default (what a run actually uses when the user
// hasn't picked a model) — shown in pickers instead of guessing from list
// order, which can disagree with the real default.
defaultModel: ModelRef | null
isRowboatConnected: boolean
// Raw models:list catalog per provider id. Groups only cover providers
// configured in models.json; provider-scoped pickers fall back to this so
// a provider mid-setup (key typed, not saved) still lists its catalog.
// Raw catalog model ids per provider id, unpinned — for provider-scoped
// pickers that need a provider's list without group ordering applied.
catalogByProvider: Record<string, string[]>
}
export interface UseModelsResult extends ModelsSnapshot {
/** Force a refetch now (e.g. a composer tab becoming active). */
refresh: () => void
/**
* Force a refetch now (e.g. a composer tab becoming active). With a
* provider id, that provider's cached list is dropped and refetched
* (the error-row Retry).
*/
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<ModelsSnapshot> {
let isRowboatConnected = false
try {
const state = await window.ipc.invoke('oauth:getState', null)
isRowboatConnected = state.config?.rowboat?.connected ?? false
} catch { /* treat as signed out */ }
async function buildSnapshot(refreshProvider?: string): Promise<ModelsSnapshot> {
const catalog = await window.ipc.invoke(
'models:list',
refreshProvider ? { refreshProvider } : null,
)
let defaultModel: ModelRef | null = null
try {
const def = await window.ipc.invoke('llm:getDefaultModel', null)
defaultModel = { provider: def.provider, model: def.model }
} catch { /* no default resolvable */ }
const groups: ModelPickerGroup[] = []
const defaultModel: ModelRef | null = catalog.defaultModel
const reasoningByKey: Record<string, boolean> = {}
const catalogByProvider: Record<string, string[]> = {}
const groups: ModelPickerGroup[] = []
// Full catalog per provider (gateway + models.dev cloud providers).
const catalog: Record<string, string[]> = {}
try {
const listResult = await window.ipc.invoke('models:list', null)
for (const p of listResult.providers || []) {
catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id)
for (const m of p.models || []) {
if (typeof m.reasoning === 'boolean') {
reasoningByKey[`${p.id}/${m.id}`] = m.reasoning
}
for (const p of catalog.providers) {
const ids = p.models.map((m) => m.id)
catalogByProvider[p.id] = ids
for (const m of p.models) {
if (typeof m.reasoning === 'boolean') {
reasoningByKey[`${p.id}/${m.id}`] = m.reasoning
}
}
} catch { /* offline / no catalog — groups fall back to saved config below */ }
if (isRowboatConnected && (catalog['rowboat'] || []).length > 0) {
groups.push({ kind: 'catalog', flavor: 'rowboat', models: catalog['rowboat'] })
// 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<string, unknown>
const apiKey = typeof e.apiKey === 'string' ? e.apiKey.trim() : ''
const baseURL = typeof e.baseURL === 'string' ? e.baseURL.trim() : ''
if (!apiKey && !baseURL) continue // provider not configured
const savedModel = typeof e.model === 'string' ? e.model : ''
// Live flavors fetch their list from the provider inside the
// dropdown, with the credentials saved in config. Catalog flavors
// degrade to the same live fetch when models:list carried no
// catalog for them (signed in, or empty models.dev cache).
const catalogModels = catalog[flavor] || []
if (LIVE_PICKER_FLAVORS.has(flavor) || (catalogModels.length === 0 && LIVE_FALLBACK_FLAVORS.has(flavor))) {
groups.push({ kind: 'live', flavor: flavor as ProviderModelsFlavor, apiKey, baseURL, savedModel })
continue
}
// Catalog group: the saved default model leads, then the catalog.
// Saved models[] survives as the fallback for unknown flavors the
// live fetch doesn't support.
const models: string[] = []
const push = (model: string) => {
if (model && !models.includes(model)) models.push(model)
}
push(savedModel)
if (catalogModels.length > 0) {
for (const m of catalogModels) push(m)
} else {
const saved = Array.isArray(e.models) ? e.models as string[] : []
for (const m of saved) push(m)
}
groups.push({ kind: 'catalog', flavor, models })
}
// The user's explicit default selection leads the picker: its group
// first and, within a catalog group, the model itself first. (Live
// groups pin the default at the top themselves.)
const sel = parsed?.defaultSelection
if (sel && typeof sel.provider === 'string' && typeof sel.model === 'string') {
const index = groups.findIndex((g) => g.flavor === sel.provider)
if (index >= 0) {
const [group] = groups.splice(index, 1)
groups.unshift(group)
if (group.kind === 'catalog') {
const mi = group.models.indexOf(sel.model)
if (mi > 0) {
group.models.splice(mi, 1)
group.models.unshift(sel.model)
}
}
}
}
} catch { /* no BYOK config yet */ }
return { groups, reasoningByKey, defaultModel, isRowboatConnected, catalogByProvider: catalog }
return {
groups,
reasoningByKey,
defaultModel,
isRowboatConnected: catalog.providers.some((p) => p.id === 'rowboat'),
catalogByProvider,
}
}
function startFetch(): void {
function startFetch(refreshProvider?: string): 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),
]
}

View file

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

View file

@ -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<unknown> => {
throw new Error('no models.json');
}),
}));
vi.mock('../account/account.js', () => ({ isSignedIn: mocks.isSignedIn }));
vi.mock('../auth/chatgpt-auth.js', () => ({ getChatGPTStatus: mocks.getChatGPTStatus }));
vi.mock('./gateway.js', () => ({ listGatewayModels: mocks.listGatewayModels }));
vi.mock('./codex.js', () => ({ listCodexModels: mocks.listCodexModels }));
vi.mock('./models.js', () => ({ listModelsForProvider: mocks.listModelsForProvider }));
vi.mock('./models-dev.js', () => ({ listOnboardingModels: mocks.listOnboardingModels }));
vi.mock('./defaults.js', () => ({ getDefaultModelAndProvider: mocks.getDefaultModelAndProvider }));
vi.mock('../di/container.js', () => ({
default: { resolve: () => ({ getConfig: mocks.getConfig }) },
}));
import { getModelCatalog, __resetModelCatalogForTests } from './catalog.js';
function serveConfig(providers: Record<string, unknown>, 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);
});
});

View file

@ -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<string, string> = {
rowboat: "Rowboat",
codex: "OpenAI Codex",
openai: "OpenAI",
anthropic: "Anthropic",
google: "Gemini",
openrouter: "OpenRouter",
aigateway: "AI Gateway",
ollama: "Ollama",
"openai-compatible": "OpenAI-Compatible",
};
/**
* Display name for a provider flavor. Presentation only nothing keys on
* it. (When multi-instance providers arrive, a user-chosen instance label
* would take precedence over this.)
*/
export function providerDisplayName(flavor: string): string {
return PROVIDER_DISPLAY_NAMES[flavor] ?? flavor;
}
// Flavors whose lists come from the models.dev catalog cache (stable ids,
// no per-account variation); the live provider API is only a fallback when
// the cache is empty. Everything else always lists live.
const MODELS_DEV_FLAVORS = new Set(["openai", "anthropic", "google"]);
// listModelsForProvider builds aigateway's URL from baseURL; apply the
// service default here so a keyed-but-URL-less config still lists.
const AIGATEWAY_DEFAULT_BASE_URL = "https://ai-gateway.vercel.sh/v1";
// Successful lists are cached until the provider's credentials change or an
// explicit refresh; failures retry after a short TTL so a temporarily-down
// local server doesn't stay dark, without re-paying the fetch timeout on
// every catalog build in between.
const ERROR_RETRY_MS = 30_000;
interface CacheEntry {
fingerprint: string;
fetchedAt: number;
status: "ok" | "error";
error?: string;
models: CatalogModelEntry[];
}
const cache = new Map<string, CacheEntry>();
const inFlight = new Map<string, Promise<CacheEntry>>();
type ProviderConfig = z.infer<typeof LlmProvider>;
interface DiscoveredProvider {
id: string;
/** Absent for rowboat/codex — their auth lives outside models.json. */
config?: ProviderConfig;
savedModel?: string;
/**
* Saved models[] from config the list of last resort for flavors the
* live fetch doesn't support (an unknown flavor in the providers map).
*/
savedModels?: string[];
}
async function readModelConfig(): Promise<z.infer<typeof LlmModelConfig> | null> {
try {
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
return await repo.getConfig();
} catch {
// Signed-in users may have no models.json at all.
return null;
}
}
/**
* Which providers are connected right now. Rowboat and ChatGPT come from
* their auth state; everything else from the models.json providers map
* (an entry counts as connected once it carries some credential). The
* default provider's entry leads, matching picker ordering.
*/
async function discoverProviders(): Promise<DiscoveredProvider[]> {
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<string, CatalogModelEntry[]>,
): Promise<CacheEntry> {
try {
let models: CatalogModelEntry[];
if (provider.id === "rowboat") {
const result = await listGatewayModels();
models = result.providers[0]?.models ?? [];
} else if (provider.id === "codex") {
const result = await listCodexModels();
models = result.providers[0]?.models ?? [];
} else if (MODELS_DEV_FLAVORS.has(provider.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<string, CatalogModelEntry[]>,
forceRefresh: boolean,
): Promise<CacheEntry> {
const fingerprint = fingerprintOf(provider);
const cached = cache.get(provider.id);
if (!forceRefresh && cached && cached.fingerprint === fingerprint) {
const fresh = cached.status === "ok" || Date.now() - cached.fetchedAt < ERROR_RETRY_MS;
if (fresh) return cached;
}
const pending = inFlight.get(provider.id);
if (pending && !forceRefresh) return pending;
const request = fetchProviderEntry(provider, fingerprint, modelsDevByFlavor)
.then((entry) => {
cache.set(provider.id, entry);
return entry;
})
.finally(() => {
if (inFlight.get(provider.id) === request) inFlight.delete(provider.id);
});
inFlight.set(provider.id, request);
return request;
}
export interface GetModelCatalogOptions {
/** Drop this provider's cached list and refetch it (Retry / Refresh models). */
refreshProvider?: string;
}
export async function getModelCatalog(options?: GetModelCatalogOptions): Promise<ModelCatalogResult> {
const discovered = await discoverProviders();
// One models.dev read serves every cloud flavor in the build (disk cache,
// no network — refreshed by its own background loop).
const modelsDevByFlavor = new Map<string, CatalogModelEntry[]>();
if (discovered.some((p) => MODELS_DEV_FLAVORS.has(p.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();
}

View file

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