mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
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:
parent
f0e8e51a0e
commit
5729e21d77
9 changed files with 767 additions and 287 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
164
apps/x/packages/core/src/models/catalog.test.ts
Normal file
164
apps/x/packages/core/src/models/catalog.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
301
apps/x/packages/core/src/models/catalog.ts
Normal file
301
apps/x/packages/core/src/models/catalog.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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': {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue