Persist provider model display names

This commit is contained in:
musistudio 2026-06-29 20:45:08 +08:00
parent c583f69666
commit 7cfe854336
12 changed files with 309 additions and 64 deletions

View file

@ -1083,6 +1083,7 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
const models = Array.isArray(item.models)
? item.models.map((model) => readString(model)).filter((model): model is string => Boolean(model))
: [];
const modelDisplayNames = parseModelDisplayNames(item.modelDisplayNames ?? item.model_display_names, models);
if (!name) {
return undefined;
@ -1103,6 +1104,7 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
extraHeaders: item.extraHeaders,
icon: readString(item.icon),
id: readString(item.id),
modelDisplayNames,
models,
name,
provider: readString(item.provider),
@ -1116,6 +1118,22 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
return withProviderIds(providers);
}
function parseModelDisplayNames(value: unknown, models: string[]): Record<string, string> | undefined {
if (!isObject(value)) {
return undefined;
}
const modelIds = new Set(models);
const entries = Object.entries(value)
.map(([rawModel, rawDisplayName]) => [rawModel.trim(), readString(rawDisplayName)] as const)
.filter((entry): entry is [string, string] => {
const [model, displayName] = entry;
return Boolean(model && displayName && modelIds.has(model) && model !== displayName);
});
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
function withProviderIds(providers: GatewayProviderConfig[]): GatewayProviderConfig[] {
const counts = new Map<string, number>();
return providers.map((provider) => {

View file

@ -7,7 +7,9 @@ import type {
ProviderAccountMappingConfig
} from "../../shared/app";
import {
isRecord,
missingCandidate,
modelDisplayNamesForModels,
providerInternalNamePlaceholder,
providerNamePlaceholder,
providerNameSlugPlaceholder,
@ -17,7 +19,6 @@ import {
readString,
uniqueProviderName,
uniqueStrings,
isRecord,
type OAuthTokenSet
} from "./shared";
@ -26,6 +27,11 @@ export const codexDefaultBaseUrl = "https://chatgpt.com/backend-api/codex";
const codexAccountBaseUrl = "https://chatgpt.com/backend-api";
const codexDefaultModels = ["gpt-5-codex"];
type LocalAgentModelCatalog = {
modelDisplayNames?: Record<string, string>;
models: string[];
};
const codexAccountRateLimitMapping: ProviderAccountMappingConfig = {
meters: [
{
@ -93,21 +99,22 @@ const codexAccountTokenUsageMapping: ProviderAccountMappingConfig = {
export function codexCandidate(): LocalAgentProviderCandidate {
const auth = readCodexAuth();
const models = readCodexModels();
const catalog = readCodexModelCatalog();
if (auth?.refreshToken || auth?.accessToken) {
return {
detail: "ChatGPT login detected. Click Import to add it as a gateway provider.",
id: "codex-api",
importable: true,
kind: "codex",
models,
modelDisplayNames: catalog.modelDisplayNames,
models: catalog.models,
name: "Codex API",
protocol: "openai_responses",
sourceFile: auth.sourceFile,
status: "available"
};
}
return missingCandidate("codex", "codex-api", "Codex API", "openai_responses", models);
return missingCandidate("codex", "codex-api", "Codex API", "openai_responses", catalog.models, catalog.modelDisplayNames);
}
export function importCodexProvider(candidate: LocalAgentProviderCandidate, providerNames: string[]): LocalAgentProviderImportResult {
@ -197,13 +204,31 @@ function codexBackendRequestTransform(): Record<string, unknown> {
};
}
function readCodexModels(): string[] {
function readCodexModelCatalog(): LocalAgentModelCatalog {
const modelsFile = path.join(os.homedir(), ".codex", "models_cache.json");
const record = readJsonRecord(modelsFile);
const models = Array.isArray(record?.models)
? record.models.map((model) => isRecord(model) ? readString(model.slug) || readString(model.id) || readString(model.name) : readString(model))
: [];
return uniqueStrings([...models, ...codexDefaultModels]);
const models: string[] = [];
const modelDisplayNames: Record<string, string> = {};
for (const item of Array.isArray(record?.models) ? record.models : []) {
const model = isRecord(item)
? readString(item.slug) || readString(item.id) || readString(item.name)
: readString(item);
if (!model) {
continue;
}
models.push(model);
if (isRecord(item)) {
const displayName = readString(item.display_name) || readString(item.displayName) || readString(item.label) || readString(item.name);
if (displayName && displayName !== model) {
modelDisplayNames[model] = displayName;
}
}
}
const uniqueModels = uniqueStrings([...models, ...codexDefaultModels]);
return {
modelDisplayNames: modelDisplayNamesForModels(modelDisplayNames, uniqueModels),
models: uniqueModels
};
}
function readCodexIdTokenClaims(idToken: string | undefined): { accountId?: string; isFedrampAccount?: boolean } {

View file

@ -30,13 +30,15 @@ export function missingCandidate(
id: string,
name: string,
protocol: GatewayProviderProtocol,
models: string[]
models: string[],
modelDisplayNames?: Record<string, string>
): LocalAgentProviderCandidate {
return {
detail: "No local login state was found for this agent.",
id,
importable: false,
kind,
modelDisplayNames: modelDisplayNamesForModels(modelDisplayNames, models),
models,
name,
protocol,
@ -50,16 +52,29 @@ export function providerPayload(
baseUrl: string,
account?: ProviderAccountConfig
): ProviderDeepLinkPayload {
const models = uniqueStrings(candidate.models).slice(0, 24);
return {
account,
apiKey: localAgentProviderApiKey,
baseUrl,
models: uniqueStrings(candidate.models).slice(0, 24),
modelDisplayNames: modelDisplayNamesForModels(candidate.modelDisplayNames, models),
models,
name,
protocol: candidate.protocol
};
}
export function modelDisplayNamesForModels(
value: Record<string, string> | undefined,
models: string[]
): Record<string, string> | undefined {
const modelIds = new Set(models);
const entries = Object.entries(value ?? {})
.map(([rawModel, rawDisplayName]) => [rawModel.trim(), rawDisplayName.trim()] as const)
.filter(([model, displayName]) => model && displayName && model !== displayName && modelIds.has(model));
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
export function bearerAuthPlugin(
suffix: string,
token: string,

View file

@ -13,6 +13,7 @@ import {
isLoopbackUrl,
isRecord,
missingCandidate,
modelDisplayNamesForModels,
providerInternalNamePlaceholder,
providerPayload,
readJsonRecord,
@ -25,12 +26,18 @@ import {
type ZcodeConfiguredProvider = {
apiKey: string;
baseUrl: string;
modelDisplayNames?: Record<string, string>;
models: string[];
name: string;
providerId: string;
sourceFile: string;
};
type LocalAgentModelCatalog = {
modelDisplayNames?: Record<string, string>;
models: string[];
};
const zcodeDefaultModels = ["GLM-5.2", "GLM-5-Turbo"];
const zcodeDefaultBaseUrl = "https://zcode.z.ai/api/v1/zcode-plan/anthropic";
@ -40,12 +47,16 @@ export function zcodeCandidate(): LocalAgentProviderCandidate {
const models = configuredProvider?.models.length
? configuredProvider.models
: zcodeRuntime.models.length > 0 ? zcodeRuntime.models : zcodeDefaultModels;
const modelDisplayNames = configuredProvider?.models.length
? configuredProvider.modelDisplayNames
: zcodeRuntime.modelDisplayNames;
if (configuredProvider) {
return {
detail: "ZCode provider API key detected in local ZCode config. Click Import to add it as a gateway provider.",
id: "zcode-api",
importable: true,
kind: "zcode",
modelDisplayNames,
models,
name: "ZCode API",
protocol: "anthropic_messages",
@ -61,6 +72,7 @@ export function zcodeCandidate(): LocalAgentProviderCandidate {
id: "zcode-api",
importable: false,
kind: "zcode",
modelDisplayNames,
models,
name: "ZCode API",
protocol: "anthropic_messages",
@ -68,7 +80,7 @@ export function zcodeCandidate(): LocalAgentProviderCandidate {
status: "locked"
};
}
return missingCandidate("zcode", "zcode-api", "ZCode API", "anthropic_messages", models);
return missingCandidate("zcode", "zcode-api", "ZCode API", "anthropic_messages", models, modelDisplayNames);
}
export function importZcodeProvider(candidate: LocalAgentProviderCandidate, providerNames: string[]): LocalAgentProviderImportResult {
@ -77,7 +89,11 @@ export function importZcodeProvider(candidate: LocalAgentProviderCandidate, prov
throw new Error("ZCode provider API key was not found in ZCode config.");
}
const provider = providerPayload(
{ ...candidate, models: configuredProvider.models.length > 0 ? configuredProvider.models : candidate.models },
{
...candidate,
modelDisplayNames: configuredProvider.models.length > 0 ? configuredProvider.modelDisplayNames : candidate.modelDisplayNames,
models: configuredProvider.models.length > 0 ? configuredProvider.models : candidate.models
},
uniqueProviderName(providerNames, "ZCode API"),
configuredProvider.baseUrl,
zcodeProviderAccountConfig(configuredProvider.baseUrl)
@ -152,7 +168,7 @@ function readZcodeConfiguredProviders(sourceFile: string): ZcodeConfiguredProvid
return [{
apiKey,
baseUrl,
models: zcodeProviderModels(value),
...zcodeProviderModelCatalog(value),
name: readString(value.name) || providerId,
providerId,
sourceFile
@ -160,7 +176,7 @@ function readZcodeConfiguredProviders(sourceFile: string): ZcodeConfiguredProvid
});
}
function readZcodeRuntime(): { baseUrl: string; models: string[] } {
function readZcodeRuntime(): { baseUrl: string } & LocalAgentModelCatalog {
const cache = readJsonRecord(path.join(os.homedir(), ".zcode", "v2", "bots-model-cache.v2.json"));
const providers = Array.isArray(cache?.providers)
? cache.providers.filter((provider): provider is Record<string, unknown> => isRecord(provider))
@ -174,23 +190,63 @@ function readZcodeRuntime(): { baseUrl: string; models: string[] } {
return text.includes("zcode") || text.includes("z.ai") || text.includes("bigmodel");
});
const baseUrl = readString(isRecord(provider?.endpoints) ? provider?.endpoints.baseURL : undefined) || zcodeDefaultBaseUrl;
const models = Array.isArray(provider?.models)
? provider.models.map((model) => isRecord(model) ? readString(model.id) || readString(model.name) : readString(model))
: [];
const catalog = zcodeProviderModelCatalog(provider ?? {});
const models = uniqueStrings([...catalog.models, ...zcodeDefaultModels]);
return {
baseUrl,
models: uniqueStrings([...models, ...zcodeDefaultModels])
modelDisplayNames: modelDisplayNamesForModels(catalog.modelDisplayNames, models),
models
};
}
function zcodeProviderModels(provider: Record<string, unknown>): string[] {
function zcodeProviderModelCatalog(provider: Record<string, unknown>): LocalAgentModelCatalog {
const models: string[] = [];
const modelDisplayNames: Record<string, string> = {};
if (Array.isArray(provider.models)) {
return uniqueStrings(provider.models.map((model) => isRecord(model) ? readString(model.id) || readString(model.name) : readString(model)));
for (const item of provider.models) {
const model = isRecord(item)
? readString(item.id) || readString(item.name)
: readString(item);
if (!model) {
continue;
}
models.push(model);
if (isRecord(item)) {
const displayName = readString(item.displayName) || readString(item.display_name) || readString(item.label) || readString(item.name);
if (displayName && displayName !== model) {
modelDisplayNames[model] = displayName;
}
}
}
const uniqueModels = uniqueStrings(models);
return {
modelDisplayNames: modelDisplayNamesForModels(modelDisplayNames, uniqueModels),
models: uniqueModels
};
}
if (isRecord(provider.models)) {
return uniqueStrings(Object.entries(provider.models).map(([key, value]) => isRecord(value) ? readString(value.id) || key : key));
for (const [key, value] of Object.entries(provider.models)) {
const model = isRecord(value) ? readString(value.id) || key : key;
if (!model) {
continue;
}
models.push(model);
if (isRecord(value)) {
const displayName = readString(value.displayName) || readString(value.display_name) || readString(value.label) || readString(value.name);
if (displayName && displayName !== model) {
modelDisplayNames[model] = displayName;
}
}
}
const uniqueModels = uniqueStrings(models);
return {
modelDisplayNames: modelDisplayNamesForModels(modelDisplayNames, uniqueModels),
models: uniqueModels
};
}
return [];
return {
models: []
};
}
function isZcodeModelProvider(providerId: string, provider: Record<string, unknown>): boolean {

View file

@ -17,7 +17,7 @@ import {
isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady,
isTraySupportedPlatform,
isRoutingRewriteDraftRowValid,
LayoutGroup, mergeModelDisplayNames, mergeProviderCapabilities, mergeProviderModelLists,
LayoutGroup, mergeModelDisplayNames, mergeProviderCapabilities, mergeProviderModelLists, modelDisplayNamesForModels,
navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeObservabilityConfig, normalizeOverviewWidgets,
normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference,
normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder,
@ -1349,6 +1349,7 @@ function App() {
}
const protocolsToSave = selectedProtocols.length > 0 ? selectedProtocols : [fallbackProtocol];
const modelDisplayNames = modelDisplayNamesForModels(providerDraft.modelDisplayNames, models);
const selectedProtocolSet = new Set(protocolsToSave);
const capabilityCandidates = mergeProviderCapabilities(
presetCapabilitiesFromDraft(providerDraft),
@ -1416,6 +1417,7 @@ function App() {
account: accountConfig,
credentials: credentials.length > 0 ? credentials : undefined,
icon: providerDraft.icon.trim() || undefined,
modelDisplayNames,
models,
name: providerName,
type: protocol

View file

@ -5,7 +5,7 @@ import {
DialogTitle, Field, GatewayProviderConfig, Info, Input, KeyValueRowsControl, LoaderCircle, motion,
normalizeProfileScope, normalizeProfileSurface, parseProfileModelValue, Pencil, Plus, PopoverContent,
profileAgentLabel, profileAgentOptions, ProfileConfig, profileModelDisplayValue, profileModelMatchesQuery, profileModelProviderMatchesQuery,
profileModelProviderOptions, profileOpenSurfaces, profileScopeLabel, profileScopeOptions, profileSummaryItems, profileSurfaceLabel, profileSurfaceOptions,
profileModelOptionDisplayName, profileModelProviderOptions, profileOpenSurfaces, profileScopeLabel, profileScopeOptions, profileSummaryItems, profileSurfaceLabel, profileSurfaceOptions,
Play, Power, RefreshCw, Search, Select, SelectControl, Terminal, Toggle, translateOptions, Trash2, useAppErrorText, useAppText, type ProfileOpenSurface, type ProfileRuntimeStatus, type ReactNode, type VirtualModelProfileConfig,
copyTextToClipboard,
useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, X
@ -516,7 +516,7 @@ function ProfileModelSelector({
filteredProviders.find((provider) => provider.name === parsedValue.provider) ??
filteredProviders[0];
const filteredModels = activeProvider
? activeProvider.models.filter((model) => profileModelMatchesQuery(activeProvider.name, model, query))
? activeProvider.models.filter((model) => profileModelMatchesQuery(activeProvider.name, model, query, profileModelOptionDisplayName(activeProvider, model)))
: [];
const displayValue = profileModelDisplayValue(value, parsedValue, providers, placeholder, virtualModelProfiles);
@ -729,6 +729,7 @@ function ProfileModelSelector({
) : null}
{activeProvider && filteredModels.map((model) => {
const selected = parsedValue.provider === activeProvider.name && parsedValue.model === model;
const displayName = profileModelOptionDisplayName(activeProvider, model);
return (
<button
className={cn(
@ -739,7 +740,7 @@ function ProfileModelSelector({
onClick={() => chooseModel(activeProvider.name, model)}
type="button"
>
<span className="min-w-0 flex-1 truncate font-mono">{model}</span>
<span className="min-w-0 flex-1 truncate" title={displayName}>{displayName}</span>
{selected ? <Check className="h-3.5 w-3.5 shrink-0" /> : null}
</button>
);

View file

@ -12,7 +12,7 @@ import {
providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath,
ProviderAccountTestResult, providerBaseUrl, providerCapabilitiesSummary, ProviderCredentialDraft, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerDraftSafetyIssue, providerCredentialDraftPatchFromJson, providerHttpJsonConnectorFromDraft,
ProviderConnectivityCheckReport, providerDeepLinkDisplayIcon, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol,
providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl,
providerModelDisplayName, providerModelDisplayTitle, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl,
resolveProviderDeepLinkPreset, ShieldCheck, splitLines, splitModelTagInput, Switch, Textarea, translatedProviderProtocolLabel, translateOptions,
translateProbeProtocolMessage, Trash2, uniqueProviderName, uniqueProviderProtocols, useAppErrorText, useAppText, useEffect, useMemo,
useRef, useState, X, isPlainRecord
@ -216,16 +216,17 @@ export function ProvidersView({ accountSnapshots, addProvider, editProvider, not
<div className="flex flex-wrap gap-2">
{provider.models.map((model) => {
const modelKey = `${itemKey}:${model}`;
const displayName = providerModelDisplayName(provider, model);
return (
<button
aria-label={`${t("Double click to copy")} ${model}`}
className="inline-flex max-w-full items-center rounded-full border border-border bg-background px-2.5 py-1 font-mono text-[11px] leading-4 text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
aria-label={`${t("Double click to copy")} ${displayName}`}
className="inline-flex max-w-full items-center rounded-full border border-border bg-background px-2.5 py-1 text-[11px] leading-4 text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
key={modelKey}
onDoubleClick={() => void copyModel(model)}
title={t("Double click to copy")}
title={`${providerModelDisplayTitle(provider, model)} · ${t("Double click to copy")}`}
type="button"
>
<span className="min-w-0 truncate">{model}</span>
<span className="min-w-0 truncate">{displayName}</span>
</button>
);
})}
@ -308,8 +309,8 @@ export function ModelsView({ config }: { config: AppConfig }) {
key={row.key}
>
<div className="min-w-0">
<div className="truncate font-mono text-[12px] font-semibold text-foreground" title={row.model}>
{row.model}
<div className="truncate text-[12px] font-semibold text-foreground" title={row.displayName ?? row.model}>
{row.displayName ?? row.model}
</div>
</div>
</AnimatedListItem>
@ -521,7 +522,9 @@ export function ProviderDeepLinkDialog({
<div className="flex flex-wrap gap-2">
{modelPreview.map((model) => (
<Badge key={model} variant="outline">
<span className="max-w-[210px] truncate font-mono">{model}</span>
<span className="max-w-[210px] truncate" title={provider.modelDisplayNames?.[model] ?? model}>
{provider.modelDisplayNames?.[model] ?? model}
</span>
</Badge>
))}
{provider.models.length > modelPreview.length ? (
@ -943,6 +946,7 @@ function LocalAgentProviderImportPanel({
baseUrl: result.provider.baseUrl,
credentials: [],
icon: result.provider.icon ?? "",
modelDisplayNames: result.provider.modelDisplayNames,
modelSearch: "",
modelsText: result.provider.models.join("\n"),
name: result.provider.name?.trim() || inferProviderNameFromBaseUrl(result.provider.baseUrl),
@ -2321,22 +2325,25 @@ function ModelTagInput({
/>
{models.length > 0 ? (
<div className="flex max-h-[120px] flex-wrap gap-1.5 overflow-auto">
{models.map((model) => (
<Badge className="max-w-full pr-1" key={model} variant="secondary">
<span className="min-w-0 max-w-[260px] truncate" title={model}>
{displayNames?.[model] ?? model}
</span>
<button
aria-label={`${t("Remove model")} ${model}`}
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/25"
onClick={() => removeModel(model)}
title={t("Remove model")}
type="button"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
{models.map((model) => {
const displayName = displayNames?.[model] ?? model;
return (
<Badge className="max-w-full pr-1" key={model} variant="secondary">
<span className="min-w-0 max-w-[260px] truncate" title={displayName}>
{displayName}
</span>
<button
aria-label={`${t("Remove model")} ${displayName}`}
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/25"
onClick={() => removeModel(model)}
title={t("Remove model")}
type="button"
>
<X className="h-3 w-3" />
</button>
</Badge>
);
})}
</div>
) : null}
</>
@ -2401,7 +2408,7 @@ function ModelMultiSelect({
checked && "border-primary bg-accent"
)}
key={model}
title={displayName ? `${displayName} (${model})` : model}
title={displayName ?? model}
>
<Checkbox checked={checked} onCheckedChange={() => toggleModel(model)} />
<span className="min-w-0 flex-1 truncate">{displayName ?? model}</span>

View file

@ -415,6 +415,7 @@ export function normalizeProfileClientModel(value: string | undefined): string {
}
export type ProfileModelProviderOption = {
modelDisplayNames?: Record<string, string>;
models: string[];
name: string;
};
@ -433,6 +434,7 @@ export function profileModelProviderOptions(
const providerOptions = providers
.filter((provider) => provider.name?.trim() && Array.isArray(provider.models))
.map((provider) => ({
modelDisplayNames: profileModelDisplayNamesForModels(provider.modelDisplayNames, provider.models),
models: uniqueStrings(provider.models.filter(Boolean)),
name: provider.name.trim()
}))
@ -485,10 +487,11 @@ export function profileModelDisplayValue(
}
const normalized = normalizeProfileClientModel(value);
if (parsedValue.provider && parsedValue.model) {
return `${parsedValue.provider}/${parsedValue.model}`;
const provider = profileModelProviderOptions(providers, virtualModelProfiles).find((item) => item.name === parsedValue.provider);
return `${parsedValue.provider}/${profileModelOptionDisplayName(provider, parsedValue.model)}`;
}
const provider = profileModelProviderOptions(providers, virtualModelProfiles).find((item) => item.models.includes(normalized));
return provider ? `${provider.name}/${normalized}` : normalized;
return provider ? `${provider.name}/${profileModelOptionDisplayName(provider, normalized)}` : normalized;
}
export function profileModelProviderMatchesQuery(provider: ProfileModelProviderOption, query: string): boolean {
@ -498,16 +501,36 @@ export function profileModelProviderMatchesQuery(provider: ProfileModelProviderO
}
return (
provider.name.toLowerCase().includes(normalizedQuery) ||
provider.models.some((model) => model.toLowerCase().includes(normalizedQuery))
provider.models.some((model) =>
model.toLowerCase().includes(normalizedQuery) ||
profileModelOptionDisplayName(provider, model).toLowerCase().includes(normalizedQuery)
)
);
}
export function profileModelMatchesQuery(providerName: string, model: string, query: string): boolean {
export function profileModelMatchesQuery(providerName: string, model: string, query: string, displayName?: string): boolean {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return true;
}
return providerName.toLowerCase().includes(normalizedQuery) || model.toLowerCase().includes(normalizedQuery);
return providerName.toLowerCase().includes(normalizedQuery) ||
model.toLowerCase().includes(normalizedQuery) ||
(displayName ?? "").toLowerCase().includes(normalizedQuery);
}
export function profileModelOptionDisplayName(provider: ProfileModelProviderOption | undefined, model: string): string {
return provider?.modelDisplayNames?.[model]?.trim() || model;
}
function profileModelDisplayNamesForModels(
value: Record<string, string> | undefined,
models: string[]
): Record<string, string> | undefined {
const modelIds = new Set(models);
const entries = Object.entries(value ?? {})
.map(([rawModel, rawDisplayName]) => [rawModel.trim(), rawDisplayName.trim()] as const)
.filter(([model, displayName]) => model && displayName && model !== displayName && modelIds.has(model));
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
export type BotGatewayAuthInputType = "text" | "password";

View file

@ -390,12 +390,23 @@ export const localAgentProviderIconUrls: Record<LocalAgentProviderKind, string>
};
export function createModelCatalogItems(config: AppConfig): ModelCatalogItem[] {
const providerModels = config.Providers.flatMap((provider) => mergeProviderModelLists(provider.models));
const providerModels: string[] = [];
const displayNames: Record<string, string> = {};
for (const provider of config.Providers) {
for (const model of mergeProviderModelLists(provider.models)) {
providerModels.push(model);
const displayName = provider.modelDisplayNames?.[model]?.trim();
if (displayName && !displayNames[model]) {
displayNames[model] = displayName;
}
}
}
const virtualModels = (config.virtualModelProfiles ?? [])
.filter(virtualModelIsCatalogVisible)
.flatMap(virtualModelCatalogNames);
return uniqueStrings([...providerModels, ...virtualModels]).map((model, index) => ({
...(displayNames[model] ? { displayName: displayNames[model] } : {}),
key: `model:${index}:${model}`,
model
}));
@ -451,7 +462,7 @@ export function modelCatalogItemMatchesQuery(row: ModelCatalogItem, query: strin
return true;
}
return row.model.toLowerCase().includes(query);
return row.model.toLowerCase().includes(query) || (row.displayName ?? "").toLowerCase().includes(query);
}
export function createRouteModelOptions(providers: GatewayProviderConfig[]): Array<{ label: string; value: string }> {
@ -462,7 +473,7 @@ export function createRouteModelOptions(providers: GatewayProviderConfig[]): Arr
return provider.models
.filter(Boolean)
.map((model) => ({
label: `${provider.name}, ${model}`,
label: `${provider.name}, ${providerModelDisplayName(provider, model)}`,
value: `${provider.name},${model}`
}));
});
@ -867,7 +878,10 @@ export function createProviderDraftFromDeepLinkPayload(
baseUrl,
credentials: [],
icon: payload.icon?.trim() || "",
modelDisplayNames: providerPresetModelDisplayNames(preset),
modelDisplayNames: modelDisplayNamesForModels(
mergeModelDisplayNames(providerPresetModelDisplayNames(preset), payload.modelDisplayNames),
models
),
modelSearch: "",
modelsText: models.join("\n"),
name: uniqueProviderName(providers, baseName),
@ -926,6 +940,7 @@ export function createProviderConfigFromDeepLink(
api_key: apiKey,
capabilities: capabilities.length > 0 ? capabilities : undefined,
icon: payload.icon?.trim() || undefined,
modelDisplayNames: modelDisplayNamesForModels(mergeModelDisplayNames(payload.modelDisplayNames, probe?.modelDisplayNames), models),
models,
name,
type: protocol
@ -973,7 +988,10 @@ export function createProviderDraftFromProvider(provider: GatewayProviderConfig)
baseUrl,
credentials: (provider.credentials ?? []).map(providerCredentialDraftFromConfig),
icon: provider.icon ?? "",
modelDisplayNames: providerPresetModelDisplayNames(preset),
modelDisplayNames: modelDisplayNamesForModels(
mergeModelDisplayNames(providerPresetModelDisplayNames(preset), provider.modelDisplayNames),
provider.models
),
modelSearch: "",
modelsText: provider.models.join("\n"),
name: provider.name,
@ -1519,10 +1537,12 @@ export function createProviderInstallLinkFromDraft(draft: AddProviderDraft, prob
return accountKeySafetyIssue.message;
}
const modelDisplayNames = modelDisplayNamesForModels(draft.modelDisplayNames, models);
const payload: ProviderDeepLinkPayload = {
...(account ? { account } : {}),
baseUrl,
...(draft.icon.trim() ? { icon: draft.icon.trim() } : {}),
...(modelDisplayNames ? { modelDisplayNames } : {}),
models,
name: providerName,
protocol
@ -1870,6 +1890,25 @@ export function mergeModelDisplayNames(
return Object.keys(merged).length > 0 ? merged : undefined;
}
export function modelDisplayNamesForModels(
value: Record<string, string> | undefined,
models: string[]
): Record<string, string> | undefined {
const modelIds = new Set(models);
const entries = Object.entries(value ?? {})
.map(([rawModel, rawDisplayName]) => [rawModel.trim(), rawDisplayName.trim()] as const)
.filter(([model, displayName]) => model && displayName && model !== displayName && modelIds.has(model));
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
export function providerModelDisplayName(provider: GatewayProviderConfig, model: string): string {
return provider.modelDisplayNames?.[model]?.trim() || model;
}
export function providerModelDisplayTitle(provider: GatewayProviderConfig, model: string): string {
return providerModelDisplayName(provider, model);
}
export function pickRecommendedProviderModels(models: string[], protocol?: GatewayProviderProtocol): string[] {
const candidates = mergeProviderModelLists(models);
if (candidates.length === 0) {
@ -1965,7 +2004,8 @@ export function providerMatchesQuery(provider: GatewayProviderConfig, query: str
providerBaseUrl(provider),
providerCapabilitiesSummary(provider),
...(provider.capabilities ?? []).map((capability) => capability.baseUrl),
...provider.models
...provider.models,
...provider.models.map((model) => providerModelDisplayName(provider, model))
]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(query));

View file

@ -694,6 +694,7 @@ export type ExtensionListItem = {
};
export type ModelCatalogItem = {
displayName?: string;
key: string;
model: string;
};

View file

@ -78,6 +78,7 @@ export type GatewayProviderConfig = {
extraHeaders?: unknown;
icon?: string;
id?: string;
modelDisplayNames?: Record<string, string>;
models: string[];
name: string;
provider?: string;
@ -221,6 +222,7 @@ export type ProviderDeepLinkPayload = {
apiKey?: string;
baseUrl: string;
icon?: string;
modelDisplayNames?: Record<string, string>;
models: string[];
name?: string;
protocol?: GatewayProviderProtocol;
@ -250,6 +252,7 @@ export type LocalAgentProviderCandidate = {
id: string;
importable: boolean;
kind: LocalAgentProviderKind;
modelDisplayNames?: Record<string, string>;
models: string[];
name: string;
protocol: GatewayProviderProtocol;

View file

@ -147,6 +147,7 @@ export function parseProviderDeepLinkPayload(rawUrl: string): ProviderDeepLinkPa
firstStringParam(params, ["protocol"]) ?? firstPayloadString(payload, ["protocol"])
);
const models = readDeepLinkModels(params, payload);
const modelDisplayNames = readDeepLinkModelDisplayNames(params, payload, models);
const account = readDeepLinkAccount(params, payload);
const source = boundedString(
firstStringParam(params, ["source"]) ??
@ -159,6 +160,7 @@ export function parseProviderDeepLinkPayload(rawUrl: string): ProviderDeepLinkPa
...(apiKey ? { apiKey } : {}),
baseUrl,
...(icon ? { icon } : {}),
...(modelDisplayNames ? { modelDisplayNames } : {}),
models,
...(name ? { name } : {}),
...(protocol ? { protocol } : {}),
@ -216,6 +218,7 @@ function parseProviderPayloadFields(
firstStringParam(params, ["protocol"]) ?? firstPayloadString(payload, ["protocol"])
);
const models = readDeepLinkModels(params, payload);
const modelDisplayNames = readDeepLinkModelDisplayNames(params, payload, models);
const account = readDeepLinkAccount(params, payload);
const source = boundedString(
firstStringParam(params, ["source"]) ??
@ -230,6 +233,7 @@ function parseProviderPayloadFields(
...(apiKey ? { apiKey } : {}),
baseUrl,
...(icon ? { icon } : {}),
...(modelDisplayNames ? { modelDisplayNames } : {}),
models,
...(name ? { name } : {}),
...(protocol ? { protocol } : {}),
@ -515,11 +519,61 @@ function payloadModels(payload: Record<string, unknown> | undefined): string[] {
}
const value = payload.models;
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === "string");
return value
.map((item) => typeof item === "string" ? item : readPayloadModelId(item))
.filter((item): item is string => Boolean(item));
}
return typeof value === "string" ? [value] : [];
}
function readDeepLinkModelDisplayNames(
params: URLSearchParams,
payload: Record<string, unknown> | undefined,
models: string[]
): Record<string, string> | undefined {
const modelIds = new Set(models);
const displayNames: Record<string, string> = {};
const addDisplayName = (rawModel: unknown, rawDisplayName: unknown) => {
const model = typeof rawModel === "string" ? rawModel.trim() : "";
const displayName = typeof rawDisplayName === "string" ? rawDisplayName.trim() : "";
if (!model || !displayName || model === displayName || !modelIds.has(model)) {
return;
}
if (displayName.length > maxModelLength) {
throw new Error("Model display name is too long.");
}
displayNames[model] = displayName;
};
const explicit = parseJsonValueParam(params, payload, ["modelDisplayNames", "model_display_names"]);
if (isRecord(explicit)) {
for (const [model, displayName] of Object.entries(explicit)) {
addDisplayName(model, displayName);
}
}
const payloadModelList = Array.isArray(payload?.models) ? payload.models : [];
for (const item of payloadModelList) {
if (!isRecord(item)) {
continue;
}
addDisplayName(readPayloadModelId(item), readPayloadModelDisplayName(item));
}
return Object.keys(displayNames).length > 0 ? displayNames : undefined;
}
function readPayloadModelId(value: unknown): string | undefined {
if (!isRecord(value)) {
return undefined;
}
return firstPayloadString(value, ["id", "slug", "model", "name"]);
}
function readPayloadModelDisplayName(value: Record<string, unknown>): string | undefined {
return firstPayloadString(value, ["display_name", "displayName", "label", "name"]);
}
function splitModelValue(value: string): string[] {
return value
.split(/[\n,]+/)