From 614ac4bddfb98d534da28dcaa54a65efe6822f01 Mon Sep 17 00:00:00 2001 From: aurorax-neo <15047150695@163.com> Date: Wed, 23 Sep 2026 21:53:19 +0800 Subject: [PATCH 1/2] fix(composer): display and match models by their complete wire ID The Composer, settings panes, and runtime binding lookups previously used tolerant model-ID matching that could conflate proxy/model with model, borrow another route's metadata, or display a catalog friendly name instead of the configured wire ID. This commit: - Introduces modelWireIdsEqual() for configured-model identity and uses it across Composer selection, advanced settings, image/default model resolution, subagent pins, scheduled picker, context usage, and launch resolver. - Always shows the complete saved wire ID in the Composer chip, model list rows, and settings default summary. - Prevents models.dev catalog lookup from returning ambiguous cross-provider metadata when the endpoint cannot be identified: providerFor() no longer picks the first publisher on a shared API, and findModel() requires a unique candidate when no provider resolves. - Aligns image-generation availability with the runtime's exact-ID check so the UI and the service agree on what can be launched. - Adds regression tests for prefixed/unprefixed coexistence, no cross-borrow of capabilities or context windows, stale defaults, and shared-API ambiguity. --- .../electron/main/models-dev-catalog.ts | 11 +- .../electron/main/runtime/provider-catalog.ts | 5 +- apps/desktop/src/components/Composer.tsx | 18 +-- .../settings/EnhancementModelCard.tsx | 4 +- .../settings/ImageGenerationModelRow.tsx | 4 +- .../components/settings/ModelConfigPage.tsx | 129 +++++++++++++----- .../settings/ModelSelectionPanes.tsx | 19 +-- .../settings/ProviderSetupDialog.tsx | 7 +- .../src/components/settings/default-model.ts | 6 +- .../settings/image-generation-default.ts | 8 +- .../components/settings/subagent-models.ts | 4 +- .../chat/composer/ComposerModelList.tsx | 10 +- .../composer/hooks/useComposerModelMenu.ts | 4 +- .../src/features/chat/composer/model.ts | 6 +- .../scheduled/ScheduledModelPicker.tsx | 10 +- apps/desktop/src/lib/chat-launch-error.ts | 5 +- apps/desktop/src/lib/composer-models.ts | 60 +++----- apps/desktop/src/lib/context-usage.ts | 7 +- .../stores/runtime/session-coordination.ts | 4 +- apps/desktop/src/styles/composer-menus.css | 18 ++- apps/desktop/test/chat-launch-error.test.mjs | 14 +- .../test/composer-chip-descenders.test.mjs | 4 +- .../composer-model-thinking-menu.test.mjs | 15 ++ apps/desktop/test/composer-models.test.mjs | 58 ++++++-- apps/desktop/test/context-usage.test.mjs | 18 ++- .../test/default-model-display.test.mjs | 24 ++-- .../test/image-generation-default.test.mjs | 17 ++- .../test/model-advanced-capabilities.test.mjs | 2 +- apps/desktop/test/models-dev-catalog.test.mjs | 53 +++++++ .../test/provider-catalog-runtime.test.mjs | 37 +++++ .../test/provider-model-config.test.mjs | 49 +++++-- apps/desktop/test/subagent-models.test.mjs | 9 ++ .../host-runtime/src/launch-resolver.test.ts | 28 ++++ packages/host-runtime/src/launch-resolver.ts | 2 +- packages/shared/src/image-generation.ts | 10 +- packages/shared/src/types/models.ts | 6 + 36 files changed, 495 insertions(+), 190 deletions(-) diff --git a/apps/desktop/electron/main/models-dev-catalog.ts b/apps/desktop/electron/main/models-dev-catalog.ts index 11ff335805..c457f5656a 100644 --- a/apps/desktop/electron/main/models-dev-catalog.ts +++ b/apps/desktop/electron/main/models-dev-catalog.ts @@ -974,9 +974,11 @@ export class ModelsDevCatalog { apiMatches(input.baseUrl, provider.api), ); if (apiProviders.length > 0) { + // A shared API endpoint does not identify which publisher owns a custom + // model. Never pick the first provider simply because it was indexed first. return apiProviders.find((provider) => candidates.has(normalizedProviderKey(provider.providerKey)), - ) ?? apiProviders[0]; + ) ?? (apiProviders.length === 1 ? apiProviders[0] : undefined); } const knownKey = Object.entries(KNOWN_PROVIDER_BASE_URLS).find(([, urls]) => urls.some((url) => apiMatches(input.baseUrl, url)), @@ -1028,7 +1030,12 @@ export class ModelsDevCatalog { candidates.sort((left, right) => right.score - left.score || left.model.modelId.length - right.model.modelId.length, ); - const result = candidates[0]?.model; + // An unknown endpoint can use a unique catalog match (including supported + // proxy aliases), but scores are not proof of provider identity. If two + // providers publish the same ID, do not borrow either one's metadata. + const result = preferredProvider || candidates.length === 1 + ? candidates[0]?.model + : undefined; // Cache the result (a miss included) so a repeated miss is also O(1) and // cannot grow the candidate index with query-dependent keys. this.lookupMemo.set(memoKey, result); diff --git a/apps/desktop/electron/main/runtime/provider-catalog.ts b/apps/desktop/electron/main/runtime/provider-catalog.ts index e3d0eccd46..00b510f50b 100644 --- a/apps/desktop/electron/main/runtime/provider-catalog.ts +++ b/apps/desktop/electron/main/runtime/provider-catalog.ts @@ -3,7 +3,6 @@ import { SESSION_THINKING_LEVELS, defaultCommandShellForPlatform, isCommandShellId, - modelIdsMatch, resolveBindingContextWindow, validateNetworkProxy, validateSpeechSettings, @@ -74,7 +73,9 @@ export function createProviderCatalogRuntime({ provider: Pick, modelId: string, ): ModelBinding | undefined => - provider.models?.find((binding) => modelIdsMatch(binding.id, modelId)); + provider.models?.find((binding) => + binding.id.trim().toLowerCase() === modelId.trim().toLowerCase(), + ); const modelsDevModelFor = (provider: RuntimeProvider, modelId: string) => modelsDevCatalog.findModel({ diff --git a/apps/desktop/src/components/Composer.tsx b/apps/desktop/src/components/Composer.tsx index 68325433c5..17f110aaee 100644 --- a/apps/desktop/src/components/Composer.tsx +++ b/apps/desktop/src/components/Composer.tsx @@ -14,7 +14,6 @@ import { initialThinkingLevelForBinding, imageGenerationBindings, isImageGenerationModel, - modelIdsMatch, normalizeLargePasteThreshold, stripInlineComposerFileReferenceTokens, } from "@pi-desktop/shared"; @@ -24,8 +23,7 @@ import { isActivePlanExecution } from "../lib/plan-mode-state"; import { headAsk, queuedAskCount } from "../lib/pending-asks"; import type { QueuedPrompt } from "../lib/queued-prompts"; import { - composerModelDisplayName, - composerModelsForProvider, + sameComposerModelId, } from "../lib/composer-models"; import { providerThinkingLevels, @@ -340,8 +338,7 @@ export function Composer({ const modelId = activeSession?.modelId ?? (!activeSession ? draftConfiguration?.modelId : undefined) ?? - settings?.defaultModelId ?? - provider?.defaultModelId; + (settings?.defaultModelId?.trim() || provider?.models?.[0]?.id || provider?.defaultModelId); const selectedModelCatalog = provider ? providerModels[provider.id] : undefined; const catalogThinkingProvider = thinkingProviderForModel( provider, @@ -355,7 +352,7 @@ export function Composer({ catalogThinkingProvider, }); const selectedBinding = provider?.models.find((candidate) => - modelIdsMatch(candidate.id, modelId ?? ""), + sameComposerModelId(candidate.id, modelId ?? ""), ); // A draft without a session starts at the selected model's stored default // thinking level, clamped onto that binding's enabled ladder. @@ -376,14 +373,7 @@ export function Composer({ configuredThinkingLevel, ); const thinkingLabel = thinkingLevel; - const selectedModel = provider?.id - ? composerModelsForProvider(provider, providerModels[provider.id], imageGenerationCandidates).find( - (model) => modelIdsMatch(model.modelId, modelId ?? ""), - ) - : undefined; - const modelLabel = provider && modelId - ? composerModelDisplayName(provider, modelId, selectedModel?.displayName) - : selectedModel?.displayName || modelId || t("chat.model"); + const modelLabel = modelId || t("chat.model"); const modelMenu = useComposerModelMenu({ configureActiveSession, mode, diff --git a/apps/desktop/src/components/settings/EnhancementModelCard.tsx b/apps/desktop/src/components/settings/EnhancementModelCard.tsx index f0c641e00f..8e6f977a98 100644 --- a/apps/desktop/src/components/settings/EnhancementModelCard.tsx +++ b/apps/desktop/src/components/settings/EnhancementModelCard.tsx @@ -14,7 +14,6 @@ import { useTranslation } from "react-i18next"; import { THINKING_LEVELS, canonicalThinkingLevel, - modelIdsMatch, type AppSettings, type ThinkingLevel, } from "@pi-desktop/shared"; @@ -25,6 +24,7 @@ import { import { useAppStore } from "../../stores/app-store"; import { api } from "../../lib/api"; import { providerDisplayName, providerSearchText } from "../../lib/provider-display"; +import { sameComposerModelId } from "../../lib/composer-models"; import { Button, Input, cx } from "../ui"; import { IconCheck, IconChevronDown, IconSearch } from "../icons"; import { AnchoredMenu } from "./AnchoredMenu"; @@ -255,7 +255,7 @@ export function EnhancementModelCard() { {visible.map(({ provider, modelId }, index) => { const isCurrent = settings.promptEnhancementProviderId === provider.id && - modelIdsMatch(settings.promptEnhancementModelId ?? "", modelId); + sameComposerModelId(settings.promptEnhancementModelId ?? "", modelId); const previous = visible[index - 1]; const startsGroup = !previous || previous.provider.id !== provider.id; return ( diff --git a/apps/desktop/src/components/settings/ImageGenerationModelRow.tsx b/apps/desktop/src/components/settings/ImageGenerationModelRow.tsx index 09f37b6e35..aa69391c28 100644 --- a/apps/desktop/src/components/settings/ImageGenerationModelRow.tsx +++ b/apps/desktop/src/components/settings/ImageGenerationModelRow.tsx @@ -1,11 +1,11 @@ import { useTranslation } from "react-i18next"; import { imageGenerationBindings, - modelIdsMatch, type AppSettings, type ImageGenerationBinding, type ProviderPublic, } from "@pi-desktop/shared"; +import { sameComposerModelId } from "../../lib/composer-models"; import { SettingsMenuSelect } from "./SettingsMenuSelect"; import { imageGenerationBindingAvailable } from "./image-generation-default"; @@ -37,7 +37,7 @@ export function ImageGenerationModelRow({ const activeCandidate = binding ? candidates.find((candidate) => candidate.providerId === binding.providerId && - modelIdsMatch(candidate.modelId, binding.modelId), + sameComposerModelId(candidate.modelId, binding.modelId), ) : undefined; const provider = binding diff --git a/apps/desktop/src/components/settings/ModelConfigPage.tsx b/apps/desktop/src/components/settings/ModelConfigPage.tsx index 3f9a015ac9..04142c7498 100644 --- a/apps/desktop/src/components/settings/ModelConfigPage.tsx +++ b/apps/desktop/src/components/settings/ModelConfigPage.tsx @@ -11,9 +11,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { OAUTH_AUTH_KIND, - imageGenerationBindings, - isImageGenerationModel, - modelIdsMatch, type ImageGenerationBinding, type ModelBinding, type ProviderPublic, @@ -36,12 +33,7 @@ import { IconTrash, } from "../icons"; import { AnchoredMenu } from "./AnchoredMenu"; -import { - defaultModelOptions, - displayedDefaultModelId, - keepsAppDefaultModel, - providerServesChatModels, -} from "./default-model"; +import { providerServesChatModels } from "./default-model"; import { planImageGenerationDefaults } from "./image-generation-default"; import { copyProviderConfiguration, type ProviderCopyDraft } from "./provider-copy"; import { ImageGenerationModelRow } from "./ImageGenerationModelRow"; @@ -71,6 +63,51 @@ function hostFromBaseUrl(baseUrl?: string | null): string { } } +const sameWireId = (left: string, right: string) => left.toLowerCase() === right.toLowerCase(); + +/** Image candidates and chat choices are keyed by complete provider + wire id. */ +function imageCandidates( + candidates: readonly ImageGenerationBinding[] | null | undefined, + active: ImageGenerationBinding | null | undefined, +): ImageGenerationBinding[] { + const source = candidates === undefined ? (active ? [active] : []) : candidates ?? []; + const result: ImageGenerationBinding[] = []; + for (const entry of source) { + if (!result.some((other) => + other.providerId === entry.providerId && sameWireId(other.modelId, entry.modelId) + )) result.push(entry); + } + if (active && !result.some((entry) => + entry.providerId === active.providerId && sameWireId(entry.modelId, active.modelId) + )) result.push(active); + return result; +} + +function isImageCandidate(candidates: readonly ImageGenerationBinding[], providerId: string, modelId: string) { + return candidates.some((entry) => entry.providerId === providerId && sameWireId(entry.modelId, modelId)); +} + +function chatModelOptions(providers: readonly ProviderPublic[], imageModels: readonly ImageGenerationBinding[]) { + return providers.flatMap((provider) => { + const ids = provider.models?.length + ? provider.models.map((model) => model.id) + : [provider.defaultModelId ?? ""]; + return ids.filter((id) => !!id.trim() && !isImageCandidate(imageModels, provider.id, id)) + .map((modelId) => ({ provider, modelId })); + }); +} + + +function displayedChatModelId( + provider: ProviderPublic, + selected: string | undefined, + imageModels: readonly ImageGenerationBinding[], +) { + const configured = chatModelOptions([provider], imageModels).map(({ modelId }) => modelId); + // Never display a different configured model in place of the saved wire ID. + return selected?.trim() || configured[0]; +} + export function ModelConfigPage() { const { t, i18n } = useTranslation(); const providers = useAppStore((s) => s.providers); @@ -118,11 +155,9 @@ export function ModelConfigPage() { }, []); const imageGenerationCandidates = useMemo( - () => imageGenerationBindings(settings?.imageGenerationModels, settings?.imageGeneration), + () => imageCandidates(settings?.imageGenerationModels, settings?.imageGeneration), [settings?.imageGenerationModels, settings?.imageGeneration], ); - // One readiness rule for the picker, the provider rows and the add-provider - // guard: both call `providerServesChatModels`. const providerReady = (provider: ProviderPublic) => providerServesChatModels(provider, imageGenerationCandidates); @@ -132,7 +167,7 @@ export function ModelConfigPage() { ); const reorder = useProviderReorder(aiProviders, busyId !== null || testingId !== null || setupFor !== null); const readyProviders = providers.filter(providerReady); - const defaultModelOptionsList = defaultModelOptions(readyProviders, imageGenerationCandidates); + const defaultModelOptionsList = chatModelOptions(readyProviders, imageGenerationCandidates); const visibleDefaultModelOptions = useMemo(() => { const query = defaultModelQuery.trim().toLowerCase(); if (!query) return defaultModelOptionsList; @@ -148,14 +183,17 @@ export function ModelConfigPage() { providers.find((provider) => provider.id === settings.defaultProviderId) ?? null; const editingProvider = setupFor ? providers.find((provider) => provider.id === setupFor) ?? null : null; + const effectiveDefaultModelId = settings.defaultModelId?.trim() || + defaultProvider?.models?.[0]?.id || defaultProvider?.defaultModelId; const defaultProviderReady = defaultProvider !== null && providerReady(defaultProvider) && - !isImageGenerationModel(imageGenerationCandidates, defaultProvider.id, - displayedDefaultModelId(defaultProvider, settings.defaultModelId)); + chatModelOptions([defaultProvider], imageGenerationCandidates).some( + ({ modelId }) => sameWireId(modelId, effectiveDefaultModelId ?? ""), + ); const setDefaultModel = async (provider: ProviderPublic, modelId: string) => { - if (isImageGenerationModel( - imageGenerationBindings( + if (isImageCandidate( + imageCandidates( useAppStore.getState().settings?.imageGenerationModels, useAppStore.getState().settings?.imageGeneration, ), @@ -191,10 +229,16 @@ export function ModelConfigPage() { models: ModelBinding[], imageModelIds?: string[], ) => { - const firstModelId = models[0]?.id; + const selectedImageIds = imageModelIds ?? imageGenerationCandidates + .filter((entry) => entry.providerId === saved.id) + .map((entry) => entry.modelId); + const firstModelId = models.find((model) => + !selectedImageIds.some((id) => sameWireId(id, model.id)), + )?.id; const replacementChatModelId = settings.defaultProviderId === saved.id && firstModelId && - !models.some((model) => modelIdsMatch(model.id, settings.defaultModelId ?? "")) + !models.some((model) => sameWireId(model.id, settings.defaultModelId ?? "") && + !selectedImageIds.some((id) => sameWireId(id, model.id))) ? firstModelId : undefined; try { @@ -206,7 +250,8 @@ export function ModelConfigPage() { imageModelIds, [...providers.filter((provider) => provider.id !== saved.id), saved], current.imageGeneration?.providerId === saved.id && - !saved.models.some((model) => modelIdsMatch(model.id, current.imageGeneration?.modelId ?? "")), + (!imageModelIds.some((id) => sameWireId(id, current.imageGeneration?.modelId ?? "")) || + !models.some((model) => sameWireId(model.id, current.imageGeneration?.modelId ?? ""))), ); const nextSettings = { ...current, @@ -224,13 +269,12 @@ export function ModelConfigPage() { // A freshly added provider must not take over the app default: whatever // the user already picked keeps running — as long as that default's own // provider is still runnable — until they change it themselves. - const keepsCurrentDefault = keepsAppDefaultModel( - providers, - settings.defaultProviderId, - settings.defaultModelId, - imageGenerationCandidates, - ); - if (!keepsCurrentDefault) { + const currentProvider = providers.find((provider) => provider.id === settings.defaultProviderId); + const keepsCurrentDefault = !!currentProvider && providerReady(currentProvider) && + chatModelOptions([currentProvider], imageGenerationCandidates).some( + ({ modelId }) => sameWireId(modelId, settings.defaultModelId ?? ""), + ); + if (!keepsCurrentDefault && firstModelId) { await api.setSettings({ ...settings, defaultProviderId: saved.id, @@ -258,11 +302,11 @@ export function ModelConfigPage() { setChangingImageModel(true); try { const current = await api.getSettings(); - const candidates = imageGenerationBindings( + const candidates = imageCandidates( current.imageGenerationModels, current.imageGeneration, ); - if (!isImageGenerationModel(candidates, binding.providerId, binding.modelId)) return; + if (!isImageCandidate(candidates, binding.providerId, binding.modelId)) return; const nextSettings = { ...current, imageGeneration: binding }; await api.setSettings(nextSettings); useAppStore.setState({ settings: nextSettings }); @@ -411,17 +455,28 @@ export function ModelConfigPage() { · - {displayedDefaultModelId(defaultProvider, settings.defaultModelId) || + {displayedChatModelId(defaultProvider, effectiveDefaultModelId, imageGenerationCandidates) || t("settings.noModel")} ) : (
- - {readyProviders.length === 0 - ? t("settings.defaultModelNone") - : t("settings.noDefaultProvider")} - + {defaultProvider && settings.defaultModelId ? ( + <> + {providerDisplayName(defaultProvider)} + · + + {settings.defaultModelId} + + {t("settings.noDefaultProvider")} + + ) : ( + + {readyProviders.length === 0 + ? t("settings.defaultModelNone") + : t("settings.noDefaultProvider")} + + )}
)} @@ -468,7 +523,7 @@ export function ModelConfigPage() { {visibleDefaultModelOptions.map(({ provider, modelId }, index) => { const isCurrent = provider.id === settings.defaultProviderId && - modelIdsMatch(settings.defaultModelId ?? "", modelId); + sameWireId(settings.defaultModelId ?? "", modelId); const previous = visibleDefaultModelOptions[index - 1]; const startsGroup = !previous || previous.provider.id !== provider.id; return ( @@ -613,7 +668,7 @@ export function ModelConfigPage() { variant="ghost" disabled={rowBusy || !providerReady(provider)} onClick={() => - void setDefaultModel(provider, defaultModelOptions([provider], imageGenerationCandidates)[0]?.modelId ?? "") + void setDefaultModel(provider, chatModelOptions([provider], imageGenerationCandidates)[0]?.modelId ?? "") } > {t("settings.makeDefault")} diff --git a/apps/desktop/src/components/settings/ModelSelectionPanes.tsx b/apps/desktop/src/components/settings/ModelSelectionPanes.tsx index 3828c2fa52..fd483ec10e 100644 --- a/apps/desktop/src/components/settings/ModelSelectionPanes.tsx +++ b/apps/desktop/src/components/settings/ModelSelectionPanes.tsx @@ -14,9 +14,9 @@ import { THINKING_LEVELS, bindingDefaultThinkingMenuLevels, bindingForCustomModel, + bindingForCustomModelInfo, bindingFromModelInfo, formatTokenCount, - modelIdsMatch, modelMatchesFilter, nativeWebSearchSupportedOn, publishedThinkingLevels, @@ -180,7 +180,7 @@ export function applyVisibleModelSelection( for (const row of visibleRows) { if (selected.has(row.id.toLowerCase())) continue; additions.push( - row.info ? bindingFromModelInfo(row.info) : bindingForCustomModel(row.id), + row.info ? { ...bindingFromModelInfo(row.info), id: row.id } : bindingForCustomModel(row.id), ); } return additions.length === 0 ? current : [...current, ...additions]; @@ -294,9 +294,9 @@ export function ModelSelectionPanes({ ); const reorder = useModelReorder(visibleChosen, setModels, busy); - /** A discovered row arrives enriched; a hand-typed id gets generic limits. */ + /** Keep the wire id of the selected row, even if catalog spelling differs. */ const bindingForRow = (row: ModelRow): ModelBinding => - row.info ? bindingFromModelInfo(row.info) : bindingForCustomModel(row.id); + row.info ? bindingForCustomModelInfo(row.id, row.info) : bindingForCustomModel(row.id); /** * The rule for a model that is being added: a filter is kept while it still @@ -358,6 +358,8 @@ export function ModelSelectionPanes({ } catch { return; } + // A catalog hit for a different wire id is not metadata for this row. + if (info && info.modelId.toLowerCase() !== seed.id.toLowerCase()) return; setModels((current) => applyCustomModelLookup(current, seed, info)); }; @@ -378,10 +380,11 @@ export function ModelSelectionPanes({ return; } const discovered = rows.find((row) => row.id.toLowerCase() === id.toLowerCase()); - const binding = customModelSeedBinding(id, discovered?.info); + const binding = discovered?.info + ? bindingForCustomModelInfo(id, discovered.info) + : customModelSeedBinding(id); setModels((current) => [...current, binding]); - // Expand the row under the id it is stored with: a discovered row keeps the - // service's spelling, which can differ from what the user typed. + // Expand the stored wire id, not a catalog spelling that may differ. setExpandedModelId(binding.id); setCustomModelId(""); setCustomModelError(""); @@ -584,7 +587,7 @@ export function ModelSelectionPanes({ const publishedDocuments = info ? modelMatchesFilter(info, "pdf") : false; const expanded = expandedModelId === binding.id; const imageModelSelected = imageModelIds?.some((modelId) => - modelIdsMatch(modelId, binding.id), + modelId.toLowerCase() === binding.id.toLowerCase(), ) ?? false; const advancedId = `model-advanced-${binding.id}`; return ( diff --git a/apps/desktop/src/components/settings/ProviderSetupDialog.tsx b/apps/desktop/src/components/settings/ProviderSetupDialog.tsx index 4be61202d7..7fd61eb1aa 100644 --- a/apps/desktop/src/components/settings/ProviderSetupDialog.tsx +++ b/apps/desktop/src/components/settings/ProviderSetupDialog.tsx @@ -9,7 +9,6 @@ import { useTranslation } from "react-i18next"; import { NAMED_ENDPOINT_PRESETS, OPENCODE_GO_API_STYLE, - modelIdsMatch, normalizeApiStyle, type CatalogApiStyle, type ModelBinding, @@ -268,7 +267,7 @@ export function ProviderSetupDialog({ // existing save path when the image selection did not change. const imageSelection = imageModelDraft ?? imageModelIds; const remainingImageModels = imageSelection?.filter((imageModelId) => - persisted.some((model) => modelIdsMatch(model.id, imageModelId)), + persisted.some((model) => model.id.toLowerCase() === imageModelId.toLowerCase()), ); const imageModelIdsToSave = imageModelDraft !== undefined || remainingImageModels?.length !== imageSelection?.length @@ -317,9 +316,9 @@ export function ProviderSetupDialog({ setImageModelDraft((current) => { const next = current ?? imageModelIds ?? []; if (selected) { - return next.some((entry) => modelIdsMatch(entry, id)) ? next : [...next, id]; + return next.some((entry) => entry.toLowerCase() === id.toLowerCase()) ? next : [...next, id]; } - return next.filter((entry) => !modelIdsMatch(entry, id)); + return next.filter((entry) => entry.toLowerCase() !== id.toLowerCase()); }); }; diff --git a/apps/desktop/src/components/settings/default-model.ts b/apps/desktop/src/components/settings/default-model.ts index 0252258953..da726755ff 100644 --- a/apps/desktop/src/components/settings/default-model.ts +++ b/apps/desktop/src/components/settings/default-model.ts @@ -7,7 +7,7 @@ * is not configured, so these helpers keep the two notions apart: what a * provider itself offers, and what is safe to display for it. */ -import { isImageGenerationModel, modelIdsMatch, type ImageGenerationBindings, type ProviderPublic } from "@pi-desktop/shared"; +import { isImageGenerationModel, modelWireIdsEqual as sameComposerModelId, type ImageGenerationBindings, type ProviderPublic } from "@pi-desktop/shared"; export type DefaultModelOption = { provider: ProviderPublic; @@ -46,12 +46,12 @@ export function providerOffersModel( ): boolean { if (!modelId) return false; const bindings = provider.models ?? []; - if (bindings.some((binding) => modelIdsMatch(binding.id, modelId))) return true; + if (bindings.some((binding) => sameComposerModelId(binding.id, modelId))) return true; // A legacy/OAuth row may carry only `defaultModelId` with no bindings yet. return ( bindings.length === 0 && !!provider.defaultModelId && - modelIdsMatch(provider.defaultModelId, modelId) + sameComposerModelId(provider.defaultModelId, modelId) ); } diff --git a/apps/desktop/src/components/settings/image-generation-default.ts b/apps/desktop/src/components/settings/image-generation-default.ts index 55b855e919..cb24032282 100644 --- a/apps/desktop/src/components/settings/image-generation-default.ts +++ b/apps/desktop/src/components/settings/image-generation-default.ts @@ -20,9 +20,9 @@ import { MAX_IMAGE_GENERATION_MODELS, imageGenerationBindings, - modelIdsMatch, type ImageGenerationBinding, type ProviderPublic, + modelWireIdsEqual as sameComposerModelId, } from "@pi-desktop/shared"; /** @@ -41,6 +41,8 @@ export function imageGenerationBindingAvailable( provider.authKind !== "oauth" && !!provider.baseUrl && (provider.hasSecret || provider.authKind === "none") && + // Mirror image-generation-service's exact availability guard. A different + // case is a different outbound wire ID for a case-sensitive endpoint. provider.models.some((model) => model.id === modelId) ); } @@ -78,7 +80,7 @@ function cappedImageGenerationCandidates( const activeIndex = active ? candidates.findIndex((candidate) => candidate.providerId === active.providerId && - modelIdsMatch(candidate.modelId, active.modelId), + sameComposerModelId(candidate.modelId, active.modelId), ) : -1; if (activeIndex < MAX_IMAGE_GENERATION_MODELS - 1) { @@ -120,7 +122,7 @@ export function planImageGenerationDefaults( })); const previous = current.imageGeneration ?? null; const active = previous?.providerId === savedProviderId && - !selected.some((binding) => modelIdsMatch(binding.modelId, previous.modelId)) + !selected.some((binding) => sameComposerModelId(binding.modelId, previous.modelId)) ? null : previous; const imageGenerationModels = cappedImageGenerationCandidates( diff --git a/apps/desktop/src/components/settings/subagent-models.ts b/apps/desktop/src/components/settings/subagent-models.ts index ebf4f9b6b2..ecbd11446b 100644 --- a/apps/desktop/src/components/settings/subagent-models.ts +++ b/apps/desktop/src/components/settings/subagent-models.ts @@ -5,7 +5,7 @@ * session). The sheet lists the same configured, runnable models as the * Composer rather than asking the user to type that string. */ -import { modelIdsMatch, type ProviderPublic } from "@pi-desktop/shared"; +import { modelWireIdsEqual as sameComposerModelId, type ProviderPublic } from "@pi-desktop/shared"; import { defaultModelOptions } from "./default-model"; export type SubagentModelChoice = { @@ -79,7 +79,7 @@ export function subagentModelPinParts( export function pinMatchesChoice(pin: string, choice: SubagentModelChoice): boolean { const parts = subagentModelPinParts(pin); if (!parts) return false; - if (!modelIdsMatch(parts.modelId, choice.modelId)) return false; + if (!sameComposerModelId(parts.modelId, choice.modelId)) return false; if (parts.providerPart === choice.providerId) return true; const alias = providerAlias(parts.providerPart); if (!alias) return false; diff --git a/apps/desktop/src/features/chat/composer/ComposerModelList.tsx b/apps/desktop/src/features/chat/composer/ComposerModelList.tsx index 73cafc3664..80ee3c14f1 100644 --- a/apps/desktop/src/features/chat/composer/ComposerModelList.tsx +++ b/apps/desktop/src/features/chat/composer/ComposerModelList.tsx @@ -1,8 +1,8 @@ import type { RefObject } from "react"; import type { TFunction } from "i18next"; -import { formatTokenCount, modelIdsMatch, type ModelInfo, type ProviderPublic } from "@pi-desktop/shared"; +import { formatTokenCount, type ModelInfo, type ProviderPublic } from "@pi-desktop/shared"; import { IconCheck, IconSearch } from "../../../components/icons"; -import { composerModelBadges } from "../../../lib/composer-models"; +import { composerModelBadges, sameComposerModelId } from "../../../lib/composer-models"; export type ComposerModelGroup = { provider: ProviderPublic; @@ -59,8 +59,8 @@ export function ComposerModelList({ const index = flatIndex++; const active = selectedProviderId === group.provider.id && - modelIdsMatch(selectedModelId ?? "", model.modelId); - const optionTitle = model.displayName || model.modelId; + sameComposerModelId(selectedModelId ?? "", model.modelId); + const optionTitle = model.modelId; return (