Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions apps/desktop/electron/main/models-dev-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/electron/main/runtime/provider-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
SESSION_THINKING_LEVELS,
defaultCommandShellForPlatform,
isCommandShellId,
modelIdsMatch,
resolveBindingContextWindow,
validateNetworkProxy,
validateSpeechSettings,
Expand Down Expand Up @@ -74,7 +73,9 @@ export function createProviderCatalogRuntime({
provider: Pick<RuntimeProvider, "models">,
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({
Expand Down
25 changes: 9 additions & 16 deletions apps/desktop/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
initialThinkingLevelForBinding,
imageGenerationBindings,
isImageGenerationModel,
modelIdsMatch,
normalizeLargePasteThreshold,
stripInlineComposerFileReferenceTokens,
} from "@pi-desktop/shared";
Expand All @@ -23,10 +22,7 @@ import { latestTurnContextInspector } from "../lib/latest-turn-context";
import { isActivePlanExecution } from "../lib/plan-mode-state";
import { headAsk, queuedAskCount } from "../lib/pending-asks";
import type { QueuedPrompt } from "../lib/queued-prompts";
import {
composerModelDisplayName,
composerModelsForProvider,
} from "../lib/composer-models";
import { composerModelDisplayName, sameComposerModelId } from "../lib/composer-models";
import {
providerThinkingLevels,
resolveComposerThinkingProvider,
Expand Down Expand Up @@ -340,8 +336,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,
Expand All @@ -355,7 +350,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.
Expand All @@ -376,14 +371,12 @@ 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 selectedModelInfo = selectedModelCatalog?.find((candidate) =>
sameComposerModelId(candidate.modelId, modelId ?? ""),
);
const modelLabel = modelId
? composerModelDisplayName(provider, modelId, selectedModelInfo?.displayName)
: t("chat.model");
const modelMenu = useComposerModelMenu({
configureActiveSession,
mode,
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/components/settings/EnhancementModelCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { useTranslation } from "react-i18next";
import {
THINKING_LEVELS,
canonicalThinkingLevel,
modelIdsMatch,
type AppSettings,
type ThinkingLevel,
} from "@pi-desktop/shared";
Expand All @@ -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";
Expand Down Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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
Expand Down
129 changes: 92 additions & 37 deletions apps/desktop/src/components/settings/ModelConfigPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand All @@ -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;
Expand All @@ -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,
),
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -411,17 +455,28 @@ export function ModelConfigPage() {
·
</span>
<span className="model-default-model font-mono">
{displayedDefaultModelId(defaultProvider, settings.defaultModelId) ||
{displayedChatModelId(defaultProvider, effectiveDefaultModelId, imageGenerationCandidates) ||
t("settings.noModel")}
</span>
</div>
) : (
<div className="settings-row-detail model-default-value">
<span className="model-default-empty">
{readyProviders.length === 0
? t("settings.defaultModelNone")
: t("settings.noDefaultProvider")}
</span>
{defaultProvider && settings.defaultModelId ? (
<>
<span className="model-default-provider">{providerDisplayName(defaultProvider)}</span>
<span className="model-default-sep" aria-hidden>·</span>
<span className="model-default-model font-mono" title={t("settings.noDefaultProvider")}>
{settings.defaultModelId}
</span>
<span className="model-default-empty">{t("settings.noDefaultProvider")}</span>
</>
) : (
<span className="model-default-empty">
{readyProviders.length === 0
? t("settings.defaultModelNone")
: t("settings.noDefaultProvider")}
</span>
)}
</div>
)}
</div>
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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")}
Expand Down
Loading
Loading