fix(desktop): match custom:<key> providers in the settings and bot model pickers
model.info and saved profiles report a user-defined provider as custom:<key>, but the catalog row uses the bare key as its slug. Settings > Model and the Bot Mode picker compared the two with ===, so a saved custom provider never found its row. Settings showed a duplicate custom:<key> entry and a Set up provider button, and the bot editor fell back to the manual form. Both now match rows with catalogProviderMatches, like the composer picker already does. Settings uses a small findCatalogProvider helper for every row lookup, including the aux and MoA slots and the endpoint passed on Set to main. catalogProviderMatches is now exported through the plugin SDK so the bot picker can use it.
This commit is contained in:
@@ -281,6 +281,41 @@ describe('ModelSettings', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('matches a saved custom:<key> main provider to its catalog row', async () => {
|
||||
// model.info reports a user-defined provider as `custom:<key>`, while the
|
||||
// catalog row carries the bare key as its slug plus the alias list.
|
||||
getGlobalModelInfo.mockResolvedValueOnce({ provider: 'custom:lab', model: 'lab-large' })
|
||||
getGlobalModelOptions.mockResolvedValueOnce({
|
||||
providers: [
|
||||
{
|
||||
name: 'Lab',
|
||||
slug: 'lab',
|
||||
aliases: ['custom:lab', 'lab'],
|
||||
models: ['lab-small', 'lab-large'],
|
||||
authenticated: true,
|
||||
is_user_defined: true,
|
||||
api_url: 'http://lab.local/v1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
renderModelSettings()
|
||||
|
||||
await waitFor(() => expect(screen.getAllByRole('combobox')[0].textContent).toBe('Lab'))
|
||||
expect(screen.queryByRole('button', { name: 'Set up provider' })).toBeNull()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Apply' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setModelAssignment).toHaveBeenCalledWith({
|
||||
model: 'lab-large',
|
||||
provider: 'custom:lab',
|
||||
scope: 'main',
|
||||
base_url: 'http://lab.local/v1'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('writes the profile default speed (service_tier) as a sparse patch, never the cached snapshot', async () => {
|
||||
// The cached record is a default-expanded snapshot; a CLI pin made after it
|
||||
// loaded is not in it. Echoing the whole record back would reset that
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useI18n } from '@/i18n'
|
||||
import { isCodeSkewRestartRequired } from '@/lib/code-skew-error'
|
||||
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
|
||||
import { isSubmitEnter } from '@/lib/ime'
|
||||
import { findCatalogProvider } from '@/lib/model-options'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $customModels, withCustomModels } from '@/store/custom-models'
|
||||
import { setMainModelAssignment } from '@/store/model-assignment'
|
||||
@@ -394,7 +395,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile, subpage }: Mod
|
||||
// leaving it out of the real inventory used for readiness/setup metadata.
|
||||
const mainProviderOptions = useMemo(
|
||||
() =>
|
||||
selectedProvider && !providers.some(provider => provider.slug === selectedProvider)
|
||||
selectedProvider && !findCatalogProvider(providers, selectedProvider)
|
||||
? [{ name: selectedProvider, slug: selectedProvider, models: [] }, ...providers]
|
||||
: providerOptions,
|
||||
[providerOptions, providers, selectedProvider]
|
||||
@@ -406,7 +407,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile, subpage }: Mod
|
||||
const moaSlotProviderOptions = providerOptions.filter(provider => (provider.slug || '').toLowerCase() !== 'moa')
|
||||
|
||||
const selectedProviderRow = useMemo(
|
||||
() => providers.find(provider => provider.slug === selectedProvider),
|
||||
() => findCatalogProvider(providers, selectedProvider),
|
||||
[providers, selectedProvider]
|
||||
)
|
||||
|
||||
@@ -424,12 +425,12 @@ export function ModelSettings({ onMainModelChanged, scopeProfile, subpage }: Mod
|
||||
}, [selectedProvider])
|
||||
|
||||
const auxDraftProviderModels = useMemo(
|
||||
() => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [],
|
||||
() => findCatalogProvider(providers, auxDraft.provider)?.models ?? [],
|
||||
[auxDraft.provider, providers]
|
||||
)
|
||||
|
||||
const modelsForProvider = useCallback(
|
||||
(provider: string) => providers.find(row => row.slug === provider)?.models ?? [],
|
||||
(provider: string) => findCatalogProvider(providers, provider)?.models ?? [],
|
||||
[providers]
|
||||
)
|
||||
|
||||
@@ -585,7 +586,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile, subpage }: Mod
|
||||
// reasoning/speed controls the same way the composer picker gates per-model
|
||||
// edits (reasoning defaults on, fast defaults off when unreported).
|
||||
const mainCaps = useMemo(() => {
|
||||
const row = providers.find(provider => provider.slug === mainModel?.provider)
|
||||
const row = mainModel ? findCatalogProvider(providers, mainModel.provider) : undefined
|
||||
|
||||
return mainModel ? row?.capabilities?.[mainModel.model] : undefined
|
||||
}, [providers, mainModel])
|
||||
@@ -761,7 +762,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile, subpage }: Mod
|
||||
// main endpoint.
|
||||
const endpointForProvider = useCallback(
|
||||
(provider: string) => {
|
||||
const row = providers.find(entry => entry.slug === provider)
|
||||
const row = findCatalogProvider(providers, provider)
|
||||
|
||||
return row?.api_url ? { base_url: row.api_url } : {}
|
||||
},
|
||||
@@ -916,7 +917,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile, subpage }: Mod
|
||||
<section>
|
||||
<p className="mb-3 text-xs text-muted-foreground">{m.appliesDesc}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select onValueChange={setSelectedProvider} value={selectedProvider}>
|
||||
<Select onValueChange={setSelectedProvider} value={selectedProviderRow?.slug ?? selectedProvider}>
|
||||
<SelectTrigger className={cn('min-w-40', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder={m.provider} />
|
||||
</SelectTrigger>
|
||||
@@ -1099,7 +1100,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile, subpage }: Mod
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
onValueChange={value => setAuxDraft(prev => ({ ...prev, provider: value, model: '' }))}
|
||||
value={auxDraft.provider}
|
||||
value={findCatalogProvider(providers, auxDraft.provider)?.slug ?? auxDraft.provider}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={`${copy.label} provider`}
|
||||
@@ -1120,7 +1121,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile, subpage }: Mod
|
||||
className="min-w-48"
|
||||
models={auxDraftProviderModels}
|
||||
onValueChange={value => setAuxDraft(prev => ({ ...prev, model: value }))}
|
||||
provider={providers.find(row => row.slug === auxDraft.provider)}
|
||||
provider={findCatalogProvider(providers, auxDraft.provider)}
|
||||
providerSlug={auxDraft.provider}
|
||||
value={auxDraft.model}
|
||||
/>
|
||||
@@ -1352,7 +1353,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile, subpage }: Mod
|
||||
)
|
||||
}))
|
||||
}
|
||||
provider={providers.find(row => row.slug === slot.provider)}
|
||||
provider={findCatalogProvider(providers, slot.provider)}
|
||||
providerSlug={slot.provider}
|
||||
value={slot.model}
|
||||
/>
|
||||
@@ -1438,7 +1439,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile, subpage }: Mod
|
||||
aggregator: updateMoaSlot(prev.aggregator, { model: value })
|
||||
}))
|
||||
}
|
||||
provider={providers.find(row => row.slug === currentMoaPreset.aggregator.provider)}
|
||||
provider={findCatalogProvider(providers, currentMoaPreset.aggregator.provider)}
|
||||
providerSlug={currentMoaPreset.aggregator.provider}
|
||||
value={currentMoaPreset.aggregator.model}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { ModelCapabilities, ModelOptionProvider, ModelOptionsResult } from
|
||||
|
||||
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
|
||||
|
||||
type CatalogProviderIdentity = Pick<ModelOptionProvider, 'aliases' | 'name' | 'slug'>
|
||||
type CatalogProviderIdentity = Partial<Pick<ModelOptionProvider, 'aliases' | 'name'>> &
|
||||
Pick<ModelOptionProvider, 'slug'>
|
||||
|
||||
/** True when `currentProvider` is this catalog row — slug, display name, or
|
||||
* a custom-provider alias (`custom:<key>` vs the bare config key, #87035). */
|
||||
@@ -18,6 +19,15 @@ export function catalogProviderMatches(provider: CatalogProviderIdentity, curren
|
||||
)
|
||||
}
|
||||
|
||||
/** The catalog row for `currentProvider`, matched the same way as
|
||||
* `catalogProviderMatches` (so a saved `custom:<key>` finds its row). */
|
||||
export function findCatalogProvider<T extends CatalogProviderIdentity>(
|
||||
providers: readonly T[],
|
||||
currentProvider: string
|
||||
): T | undefined {
|
||||
return providers.find(row => catalogProviderMatches(row, currentProvider))
|
||||
}
|
||||
|
||||
/** The catalog's option support for the current pick, or undefined while the
|
||||
* catalog is loading / doesn't say. Callers treat undefined as "assume
|
||||
* reasoning" so controls never flicker away during the fetch. */
|
||||
@@ -26,7 +36,7 @@ export function currentModelCapabilities(
|
||||
provider: string,
|
||||
model: string
|
||||
): ModelCapabilities | undefined {
|
||||
return options?.providers?.find(row => catalogProviderMatches(row, provider))?.capabilities?.[model]
|
||||
return findCatalogProvider(options?.providers ?? [], provider)?.capabilities?.[model]
|
||||
}
|
||||
|
||||
// A picked (provider, model) pair is never retargeted from catalog membership.
|
||||
|
||||
@@ -38,10 +38,11 @@ const { hostMock } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('@hermes/plugin-sdk', async () => {
|
||||
const { useQuery } = await import('@tanstack/react-query')
|
||||
const { useI18n } = await vi.importActual<typeof HermesSdk>('@hermes/plugin-sdk')
|
||||
const { catalogProviderMatches, useI18n } = await vi.importActual<typeof HermesSdk>('@hermes/plugin-sdk')
|
||||
|
||||
return {
|
||||
Button: (props: React.ComponentProps<'button'>) => <button {...props} />,
|
||||
catalogProviderMatches,
|
||||
GlyphSpinner: () => <span data-testid="spinner" />,
|
||||
host: hostMock,
|
||||
Input: (props: React.ComponentProps<'input'>) => <input {...props} />,
|
||||
@@ -211,3 +212,19 @@ describe('the manual-entry form vs the dropdowns (#121875)', () => {
|
||||
await waitFor(() => expect(isFreeText(container)).toBe(false))
|
||||
})
|
||||
})
|
||||
|
||||
describe('a saved custom provider', () => {
|
||||
it('matches its catalog row through the custom:<key> alias', async () => {
|
||||
// model.options reports a user-defined provider as `custom:<key>`, while
|
||||
// the catalog row carries the bare key as its slug plus the alias list.
|
||||
hostMock.requestProfile.mockResolvedValue({
|
||||
providers: [{ aliases: ['custom:lab', 'lab'], models: ['lab-small', 'lab-large'], name: 'Lab', slug: 'lab' }]
|
||||
})
|
||||
|
||||
const { container } = mountWithSelection(remoteBot, { model: 'lab-large', provider: 'custom:lab' })
|
||||
|
||||
await waitFor(() => expect(container.querySelector('[data-testid="spinner"]')).toBeNull())
|
||||
expect(isFreeText(container)).toBe(false)
|
||||
expect(screen.getByText('lab-small')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import {
|
||||
Button,
|
||||
catalogProviderMatches,
|
||||
GlyphSpinner,
|
||||
Input,
|
||||
Select,
|
||||
@@ -63,6 +64,7 @@ function boundedModelOptionsFetch<T>(fetch: Promise<T>, settleMs = MODEL_OPTIONS
|
||||
/** One provider row of the gateway's `model.options` inventory. Entries in
|
||||
* `models` are bare slugs on current gateways and objects on older ones. */
|
||||
interface ModelProviderOption {
|
||||
aliases?: null | string[]
|
||||
models?: Array<string | { id?: string; name?: string }>
|
||||
name?: string
|
||||
slug: string
|
||||
@@ -128,7 +130,10 @@ export function ModelPicker({ bot = null, value, onChange, placeholderModel }: M
|
||||
const NONE = '__default__'
|
||||
const CUSTOM = '__custom__'
|
||||
const providers = (data?.providers || []).filter(p => p && p.slug)
|
||||
const isKnown = !value.provider || value.provider === NONE || providers.some(p => p.slug === value.provider)
|
||||
|
||||
const isKnown =
|
||||
!value.provider || value.provider === NONE || providers.some(p => catalogProviderMatches(p, value.provider))
|
||||
|
||||
// The manual-entry latch is the USER's choice only. Seeding it from
|
||||
// `isKnown` froze whatever the catalog state was at first paint: on the
|
||||
// first open the async read had not resolved yet, so a configured provider
|
||||
@@ -221,7 +226,7 @@ export function ModelPicker({ bot = null, value, onChange, placeholderModel }: M
|
||||
)
|
||||
}
|
||||
|
||||
const activeProvider = providers.find(p => p.slug === value.provider) || null
|
||||
const activeProvider = providers.find(p => catalogProviderMatches(p, value.provider)) || null
|
||||
|
||||
const models = activeProvider
|
||||
? (activeProvider.models || []).map(m => (typeof m === 'string' ? m : m.id || m.name || ''))
|
||||
@@ -250,7 +255,7 @@ export function ModelPicker({ bot = null, value, onChange, placeholderModel }: M
|
||||
})
|
||||
}
|
||||
}}
|
||||
value={value.provider || NONE}
|
||||
value={activeProvider?.slug || value.provider || NONE}
|
||||
>
|
||||
<SelectTrigger className="h-8 rounded-md">
|
||||
<SelectValue />
|
||||
|
||||
@@ -1930,6 +1930,9 @@ export { formatModifierToken } from '@/lib/keybinds/combo'
|
||||
export { LruCache } from '@/lib/lru-cache'
|
||||
/** Capture a gateway file download alongside a REST read (see the SDK guide). */
|
||||
export { captureGatewayFileDownload } from '@/lib/media'
|
||||
/** True when a saved provider id names this `model.options` row: its slug,
|
||||
* display name, or a custom-provider alias (`custom:<key>` vs the bare key). */
|
||||
export { catalogProviderMatches } from '@/lib/model-options'
|
||||
/** The app's deterministic identity color for a name (profiles, assignees,
|
||||
* authors), its translucent tag fill, and the curated picker swatches — so
|
||||
* plugin-rendered identities read the same hue as everywhere else. The
|
||||
|
||||
@@ -1554,7 +1554,7 @@ pipeline as a trust boundary.
|
||||
| React / state | `useValue`, `atom`, `computed`, `useQuery`, `useMutation`, `useQueryClient`, `queryClient`, `Contribute` |
|
||||
| Theming | `useTheme`, `requestTheme`, `setAccentOverride`, `$accentOverride`, `retintTheme`, `themeHue`, `DesktopTheme`, `DesktopThemeColors`, plus OKLCH math (`hexToOklch`, `oklchToHex`, `oklchToSrgb255`, `mixOklab`, `maxChroma`, `hueDelta`, `normalizeHex`) and sRGB measures (`contrastRatio` — `number | null`, null for unparseable input — `readableOn`) |
|
||||
| UI kit | `Button`, `Input`, `Textarea`, `Select*`, `Switch`, `Checkbox`, `SegmentedControl`, `Tabs*`, `Dialog*`, `ConfirmDialog`, `DropdownMenu*`, `ContextMenu*`, `Popover*`, `Tip`/`Tooltip*`, `Badge`, `Kbd`/`KbdGroup`, `SearchField`, `ScrollArea`, `Separator`, `Skeleton`, `GlyphSpinner`, `Loader`, `EmptyState`, `ErrorState`, `CopyButton`, `StatusDot`, `LogView`, `Codicon`, `DecodeText`, `SandboxedFrame` |
|
||||
| Helpers | `cn`, `icons`, `haptic`, `useI18n`, `profileColor`, `profileColorSoft`, `relativeTime`, `fmtDateTime`, `fmtDayTime`, `coarseElapsed`, `evaluateRuntimeReadiness` |
|
||||
| Helpers | `cn`, `icons`, `haptic`, `useI18n`, `profileColor`, `profileColorSoft`, `relativeTime`, `fmtDateTime`, `fmtDayTime`, `coarseElapsed`, `evaluateRuntimeReadiness`, `catalogProviderMatches` |
|
||||
|
||||
The canonical, always-current export list is `apps/desktop/src/sdk/index.ts`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user