diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index 5a53812bf0..93c43972d3 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -281,6 +281,41 @@ describe('ModelSettings', () => { ) }) + it('matches a saved custom: main provider to its catalog row', async () => { + // model.info reports a user-defined provider as `custom:`, 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 diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index a38272feb9..5b396afcdb 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -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

{m.appliesDesc}

- @@ -1099,7 +1100,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile, subpage }: Mod
, @@ -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: alias', async () => { + // model.options reports a user-defined provider as `custom:`, 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() + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/model-picker.tsx b/apps/desktop/src/plugins/hermes-bots/model-picker.tsx index 59510a6b63..abe8afb7e6 100644 --- a/apps/desktop/src/plugins/hermes-bots/model-picker.tsx +++ b/apps/desktop/src/plugins/hermes-bots/model-picker.tsx @@ -8,6 +8,7 @@ import { Button, + catalogProviderMatches, GlyphSpinner, Input, Select, @@ -63,6 +64,7 @@ function boundedModelOptionsFetch(fetch: Promise, 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 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} > diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index b67078b705..a10b90d794 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -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:` 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 diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md index 790b60d946..9a3122cc35 100644 --- a/website/docs/developer-guide/desktop-plugin-sdk.md +++ b/website/docs/developer-guide/desktop-plugin-sdk.md @@ -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`.