From d1167fdff70c91f97e47f8dc36307b1e81a8953c Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:56:58 +0300 Subject: [PATCH] fix(desktop): match custom: providers in the settings and bot model pickers model.info and saved profiles report a user-defined provider as custom:, 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: 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. --- .../src/app/settings/model-settings.test.tsx | 35 +++++++++++++++++++ .../src/app/settings/model-settings.tsx | 23 ++++++------ apps/desktop/src/lib/model-options.ts | 14 ++++++-- .../plugins/hermes-bots/model-picker.test.tsx | 19 +++++++++- .../src/plugins/hermes-bots/model-picker.tsx | 11 ++++-- apps/desktop/src/sdk/index.ts | 3 ++ .../developer-guide/desktop-plugin-sdk.md | 2 +- 7 files changed, 89 insertions(+), 18 deletions(-) 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`.