fix(llm): one-line provider cards, translated custom provider names (#16120)

This commit is contained in:
Raunak Bhagat authored and GitHub committed 2026-10-15 22:25:05 +00:00
1 parent 9d36a5bbd8
commit 8958c9af28
21 files changed
+240 -43

No files matched your search

@@ -7,6 +7,7 @@ import { Divider } from "@opal/components";
import { ContentAction } from "@opal/layouts";
import LLMProviderCard from "@/sections/onboarding/components/LLMProviderCard";
import { getProvider } from "@/lib/languageModels/utils";
import { useProviderNames } from "@/lib/languageModels/useProviderNames";
import { useLLMProviderOptions } from "@/lib/hooks/useLLMProviderOptions";
import {
CRAFT_PROVIDERS,
@@ -25,6 +26,7 @@ const craftKeys: string[] = CRAFT_PROVIDERS;
export default function CraftLlmSetup() {
const t = useTranslations("craft.onboarding.llmSetup");
const providerNames = useProviderNames();
const { openProviderModal } = useOnboarding();
const { llmProviderOptions } = useLLMProviderOptions();
@@ -51,7 +53,7 @@ export default function CraftLlmSetup() {
<Divider />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1 w-full max-h-56 overflow-y-auto [&>*:last-child:nth-child(odd)]:col-span-full">
{providerKeys.map((key) => {
const { productName, companyName } = getProvider(key);
const { productName, companyName } = providerNames(getProvider(key));
return (
<LLMProviderCard
key={key}
+6 -2
View File
@@ -1,5 +1,6 @@
"use client";
import { useProviderNames } from "@/lib/languageModels/useProviderNames";
import { useState, useCallback, useMemo } from "react";
import {
MAX_MODELS,
@@ -48,15 +49,18 @@ export default function useMultiModelChat(
// (e.g. a stale admin/personal default pointing at a hidden model), so
// `currentLlmModel` below can still match it — mirrors ModelSelector's
// `currentModelName` param.
const providerNames = useProviderNames();
const llmOptions = useMemo(
() =>
llmManager.llmProviders
? buildLlmOptions(
llmManager.llmProviders,
llmManager.currentLlm.modelName
llmManager.currentLlm.modelName,
false,
providerNames
)
: [],
[llmManager.llmProviders, llmManager.currentLlm.modelName]
[llmManager.llmProviders, llmManager.currentLlm.modelName, providerNames]
);
// In single-model mode, derive the displayed model directly from
+10
View File
@@ -7417,6 +7417,16 @@
"error": "تعذر تحميل نماذج التوجيه.",
"retry": "إعادة المحاولة"
}
},
"providers": {
"custom": {
"productName": "نماذج مخصصة",
"companyName": "مزوّدون متوافقون مع LiteLLM"
},
"openAICompatible": {
"productName": "نماذج مخصصة",
"companyName": "نقاط نهاية متوافقة مع OpenAI"
}
}
},
"mcpActions": {
+10
View File
@@ -7417,6 +7417,16 @@
"error": "Router-Modelle konnten nicht geladen werden.",
"retry": "Erneut versuchen"
}
},
"providers": {
"custom": {
"productName": "Benutzerdefinierte Modelle",
"companyName": "LiteLLM-kompatible Anbieter"
},
"openAICompatible": {
"productName": "Benutzerdefinierte Modelle",
"companyName": "OpenAI-kompatible Endpunkte"
}
}
},
"mcpActions": {
+10
View File
@@ -7417,6 +7417,16 @@
"error": "Failed to load router models.",
"retry": "Retry"
}
},
"providers": {
"custom": {
"productName": "Custom Models",
"companyName": "LiteLLM-compatible providers"
},
"openAICompatible": {
"productName": "Custom Models",
"companyName": "OpenAI-compatible endpoints"
}
}
},
"mcpActions": {
+10
View File
@@ -7417,6 +7417,16 @@
"error": "No se pudieron cargar los modelos de enrutamiento.",
"retry": "Reintentar"
}
},
"providers": {
"custom": {
"productName": "Modelos personalizados",
"companyName": "Proveedores compatibles con LiteLLM"
},
"openAICompatible": {
"productName": "Modelos personalizados",
"companyName": "Endpoints compatibles con OpenAI"
}
}
},
"mcpActions": {
+10
View File
@@ -7417,6 +7417,16 @@
"error": "Impossible de charger les modèles de routage.",
"retry": "Réessayer"
}
},
"providers": {
"custom": {
"productName": "Modèles personnalisés",
"companyName": "Fournisseurs compatibles LiteLLM"
},
"openAICompatible": {
"productName": "Modèles personnalisés",
"companyName": "Points de terminaison compatibles OpenAI"
}
}
},
"mcpActions": {
+10
View File
@@ -7417,6 +7417,16 @@
"error": "ルーターモデルを読み込めませんでした。",
"retry": "再試行"
}
},
"providers": {
"custom": {
"productName": "カスタムモデル",
"companyName": "LiteLLM 互換プロバイダー"
},
"openAICompatible": {
"productName": "カスタムモデル",
"companyName": "OpenAI 互換エンドポイント"
}
}
},
"mcpActions": {
+10
View File
@@ -7417,6 +7417,16 @@
"error": "라우터 모델을 불러오지 못했습니다.",
"retry": "다시 시도"
}
},
"providers": {
"custom": {
"productName": "사용자 지정 모델",
"companyName": "LiteLLM 호환 공급자"
},
"openAICompatible": {
"productName": "사용자 지정 모델",
"companyName": "OpenAI 호환 엔드포인트"
}
}
},
"mcpActions": {
+10
View File
@@ -7417,6 +7417,16 @@
"error": "Falha ao carregar os modelos de roteamento.",
"retry": "Tentar novamente"
}
},
"providers": {
"custom": {
"productName": "Modelos personalizados",
"companyName": "Provedores compatíveis com LiteLLM"
},
"openAICompatible": {
"productName": "Modelos personalizados",
"companyName": "Endpoints compatíveis com OpenAI"
}
}
},
"mcpActions": {
+10
View File
@@ -7417,6 +7417,16 @@
"error": "无法加载路由器模型。",
"retry": "重试"
}
},
"providers": {
"custom": {
"productName": "自定义模型",
"companyName": "兼容 LiteLLM 的提供商"
},
"openAICompatible": {
"productName": "自定义模型",
"companyName": "兼容 OpenAI 的端点"
}
}
},
"mcpActions": {
@@ -1,5 +1,6 @@
"use client";
import { useProviderNames } from "@/lib/languageModels/useProviderNames";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { InputSingleSelect } from "@opal/components";
@@ -101,8 +102,12 @@ export default function SimpleModelSelector<Nullable extends boolean = false>({
modelPaging,
}: SimpleModelSelectorProps<Nullable>) {
const t = useTranslations("common.modelSelectors");
const providerNames = useProviderNames();
const options = useMemo((): SelectOptions => {
const models = buildModelSelectOptions(providers, { grouped });
const models = buildModelSelectOptions(providers, {
grouped,
providerNames,
});
if (!globalDefault) return models;
return [
{
@@ -113,7 +118,7 @@ export default function SimpleModelSelector<Nullable extends boolean = false>({
},
...models,
];
}, [providers, grouped, globalDefault, t]);
}, [providers, grouped, globalDefault, t, providerNames]);
// Server search only when nothing loaded matches. The query is remembered
// so a merged page, which changes `modelPaging`, does not re-run it.
+6 -4
View File
@@ -110,8 +110,9 @@ export const PROVIDERS: Record<string, ProviderEntry> = {
},
[LLMProviderName.OPENAI_COMPATIBLE]: {
icon: SvgPlug,
productName: "OpenAI-Compatible",
companyName: "OpenAI-Compatible",
productName: LLMProviderName.OPENAI_COMPATIBLE,
companyName: LLMProviderName.OPENAI_COMPATIBLE,
copyKey: "openAICompatible",
Modal: OpenAICompatibleModal,
},
[LLMProviderName.NEBIUS_TOKENFACTORY]: {
@@ -134,8 +135,9 @@ export const PROVIDERS: Record<string, ProviderEntry> = {
},
[LLMProviderName.CUSTOM]: {
icon: SvgServer,
productName: "Custom Models",
companyName: "models from other LiteLLM-compatible providers",
productName: LLMProviderName.CUSTOM,
companyName: LLMProviderName.CUSTOM,
copyKey: "custom",
Modal: CustomModal,
},
};
+25 -4
View File
@@ -6,6 +6,7 @@ import type {
} from "@opal/components";
import type { IconProps } from "@opal/types";
import { getModelIcon, getProvider } from "@/lib/languageModels/utils";
import type { ProviderNames } from "@/lib/languageModels/useProviderNames";
import { AGGREGATOR_PROVIDERS } from "@/lib/languageModels/svc";
import type {
DefaultModel,
@@ -15,6 +16,7 @@ import type {
LLMProviderDescriptor,
ModelOptionProvider,
ReasoningEffortOverride,
ProviderEntry,
} from "@/lib/languageModels/types";
// ---------------------------------------------------------------------------
@@ -66,16 +68,33 @@ export function llmOptionKey(option: {
* selectable model options. Hidden models require an existing selection or
* an explicit admin opt-in.
*/
/** `useProviderNames()`: a provider entry's names as the UI shows them. */
export type ProviderNamesResolver = (entry: ProviderEntry) => ProviderNames;
/**
* Pass `providerNames` wherever the options are shown, so a provider whose
* names are copy (custom, OpenAI-compatible) is labelled in the user's
* language.
*/
export function buildLlmOptions(
llmProviders: ModelOptionProvider[] | undefined,
currentModelName?: string,
includeHiddenModels = false
includeHiddenModels = false,
providerNames?: ProviderNamesResolver
): LLMOption[] {
if (!llmProviders) return [];
const seenKeys = new Set<string>();
const options: LLMOption[] = [];
// The custom provider types share a product name, so an unnamed one is
// labelled by its description to keep its model group apart.
function unnamedProviderLabel(providerKey: string): string {
const entry = getProvider(providerKey);
const names = providerNames ? providerNames(entry) : entry;
return entry.copyKey ? names.companyName : names.productName;
}
llmProviders.forEach((llmProvider) => {
llmProvider.model_configurations
.filter(
@@ -93,7 +112,7 @@ export function buildLlmOptions(
provider: llmProvider.provider,
providerId: llmProvider.id,
providerDisplayName:
llmProvider.name || getProvider(llmProvider.provider).productName,
llmProvider.name || unnamedProviderLabel(llmProvider.provider),
modelName: mc.name,
modelConfigurationId: mc.id ?? null,
displayName: mc.effectiveDisplayName,
@@ -305,6 +324,8 @@ export interface BuildModelSelectOptionsOptions {
* grouping workspace-wide. Defaults to true.
*/
grouped?: boolean;
/** `useProviderNames()`, for the provider group titles. */
providerNames?: ProviderNamesResolver;
}
/**
@@ -314,10 +335,10 @@ export interface BuildModelSelectOptionsOptions {
*/
export function buildModelSelectOptions(
providers: ModelOptionProvider[],
{ grouped = true }: BuildModelSelectOptionsOptions = {}
{ grouped = true, providerNames }: BuildModelSelectOptionsOptions = {}
): SelectOptions {
const dividers: SelectDivider[] = groupLlmOptions(
buildLlmOptions(providers, undefined, true)
buildLlmOptions(providers, undefined, true, providerNames)
)
.map((group) => ({
title: group.displayName,
+9
View File
@@ -467,9 +467,18 @@ export interface CostOverrideUpsert {
cache_read_cost_per_mtok: number | null;
}
/**
* A provider whose names are copy rather than brand names. Its names come from
* `admin.languageModels.providers.<key>`; show them with `useProviderNames`.
*/
export type ProviderCopyKey = "custom" | "openAICompatible";
export interface ProviderEntry {
icon: IconFunctionComponent;
/** A brand name, or the provider key when `copyKey` holds its names. */
productName: string;
/** A brand name, or the provider key when `copyKey` holds its names. */
companyName: string;
copyKey?: ProviderCopyKey;
Modal: React.ComponentType<LLMProviderFormProps>;
}
@@ -0,0 +1,31 @@
"use client";
import { useCallback } from "react";
import { useTranslations } from "next-intl";
import type { ProviderEntry } from "@/lib/languageModels/types";
/** A provider's names as the UI shows them. */
export interface ProviderNames {
productName: string;
companyName: string;
}
/**
* Resolves a provider entry's names for display: brand names as they are, and
* the translated copy for a provider whose names are copy (`copyKey`).
*/
export function useProviderNames(): (
entry: Pick<ProviderEntry, "productName" | "companyName" | "copyKey">
) => ProviderNames {
const t = useTranslations("admin.languageModels.providers");
return useCallback(
(entry) =>
entry.copyKey
? {
productName: t(`${entry.copyKey}.productName`),
companyName: t(`${entry.copyKey}.companyName`),
}
: { productName: entry.productName, companyName: entry.companyName },
[t]
);
}
@@ -47,6 +47,7 @@ import {
import { setDefaultLlmModelAndRefresh } from "@/lib/languageModels/cache";
import { getProvider, modelDisplayName } from "@/lib/languageModels/utils";
import { useAdminLanguageModels } from "@/lib/languageModels/hooks";
import { useProviderNames } from "@/lib/languageModels/useProviderNames";
import { useSWRConfig } from "swr";
import {
SvgArrowExchange,
@@ -1098,11 +1099,10 @@ function ModalWrapperInner({
? t("setup.submitButton.pristineTooltip")
: undefined;
const {
icon: providerIcon,
companyName: providerDisplayName,
productName: providerProductName,
} = getProvider(providerName);
const providerEntry = getProvider(providerName);
const providerIcon = providerEntry.icon;
const { companyName: providerDisplayName, productName: providerProductName } =
useProviderNames()(providerEntry);
const title = llmProvider
? markdown(
@@ -45,6 +45,7 @@ import {
reasoningStopIndex,
} from "@/sections/model-selector/setting-controls";
import { useLanguageModelsForCurrentAgent } from "@/lib/languageModels/hooks";
import { useProviderNames } from "@/lib/languageModels/useProviderNames";
import { useUser } from "@/providers/UserProvider";
import { useSettings } from "@/lib/settings/hooks";
import type {
@@ -457,9 +458,16 @@ export default function ModelSelectorContent({
const internalScrollRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
const providerNames = useProviderNames();
const llmOptions = useMemo(
() => buildLlmOptions(llmProviders, currentModelName, includeHiddenModels),
[llmProviders, currentModelName, includeHiddenModels]
() =>
buildLlmOptions(
llmProviders,
currentModelName,
includeHiddenModels,
providerNames
),
[llmProviders, currentModelName, includeHiddenModels, providerNames]
);
// "Auto" selects the admin-configured router; router rows never list.
@@ -13,6 +13,7 @@ import {
} from "@/interfaces/onboarding";
import { WellKnownLLMProviderDescriptor } from "@/lib/languageModels/types";
import { getProvider } from "@/lib/languageModels/utils";
import { useProviderNames } from "@/lib/languageModels/useProviderNames";
import ProviderSetupModal from "@/sections/modals/languageModels/ProviderSetupModal";
import { Disabled } from "@opal/core";
import { ModelIcon } from "@/lib/languageModels/components";
@@ -94,6 +95,7 @@ const LLMStep = memo(
disabled,
}: LLMStepProps) => {
const t = useTranslations("onboarding");
const providerNames = useProviderNames();
const { appName } = useSettings();
const { llmProviderOptions, isLoading } = useLLMProviderOptions();
const llmDescriptors = llmProviderOptions ?? [];
@@ -188,8 +190,8 @@ const LLMStep = memo(
{/* Render provider cards */}
{llmDescriptors.map((llmDescriptor) => {
const { productName, companyName } = getProvider(
llmDescriptor.name
const { productName, companyName } = providerNames(
getProvider(llmDescriptor.name)
);
return (
<div
+15 -5
View File
@@ -11,6 +11,7 @@ import { SvgCheck, SvgEdit, SvgPlus, SvgTrash, SvgX } from "@opal/icons";
import { markdown } from "@opal/utils";
import { SimpleModelSelector } from "@/lib/languageModels/components";
import { getProvider } from "@/lib/languageModels/utils";
import { useProviderNames } from "@/lib/languageModels/useProviderNames";
import {
filterModelConfigurations,
findLlmOptionById,
@@ -26,12 +27,18 @@ import {
} from "@/lib/languageModels/costOverrides";
import type { CostOverride } from "@/lib/languageModels/types";
/** `allProvidersLabel` is passed in: this module cannot call translation hooks. */
/**
* The label and resolver are passed in: this function cannot call hooks.
* `providerNames` is `useProviderNames()`.
*/
function getProviderDisplayName(
provider: string,
allProvidersLabel: string
allProvidersLabel: string,
providerNames: ReturnType<typeof useProviderNames>
): string {
return provider ? getProvider(provider).companyName : allProvidersLabel;
return provider
? providerNames(getProvider(provider)).companyName
: allProvidersLabel;
}
// Accepts integers/decimals only; "" is allowed mid-edit, validated on submit.
@@ -59,6 +66,7 @@ interface OverrideFormProps {
function OverrideForm({ existing, onDone }: OverrideFormProps) {
const t = useTranslations("admin.costOverrides");
const providerNames = useProviderNames();
const { mutate } = useSWRConfig();
const { llmProviders, modelPaging } = useAdminLanguageModels();
const { hide_provider_grouping: hideProviderGrouping } = useSettings();
@@ -89,7 +97,7 @@ function OverrideForm({ existing, onDone }: OverrideFormProps) {
const parsedCache = parseRate(cacheRate);
const cacheValid = cacheRate.trim() === "" || parsedCache !== null;
const modelLabel = model
? `${getProviderDisplayName(provider, t("allProviders.label"))} · ${model}`
? `${getProviderDisplayName(provider, t("allProviders.label"), providerNames)} · ${model}`
: "";
const canSubmit =
model.trim() !== "" &&
@@ -228,13 +236,15 @@ interface OverrideRowProps {
function OverrideRow({ override }: OverrideRowProps) {
const t = useTranslations("admin.costOverrides");
const providerNames = useProviderNames();
const locale = useLocale();
const { mutate } = useSWRConfig();
const [editing, setEditing] = useState(false);
const [deleting, setDeleting] = useState(false);
const providerDisplayName = getProviderDisplayName(
override.provider,
t("allProviders.label")
t("allProviders.label"),
providerNames
);
async function handleDelete() {
+29 -16
View File
@@ -9,6 +9,7 @@ import {
useAdminLanguageModel,
useAdminLanguageModels,
} from "@/lib/languageModels/hooks";
import { useProviderNames } from "@/lib/languageModels/useProviderNames";
import { PageLoader } from "@opal/loaders";
import { Content, ContentAction, InputHorizontal, toast } from "@opal/layouts";
import {
@@ -55,10 +56,6 @@ import CostOverridesPanel from "@/views/admin/CostOverridesPanel";
const route = ADMIN_ROUTES.LLM_MODELS;
function providerDisplayName(provider: LLMProviderView): string {
return provider.name || getProvider(provider.provider, provider).productName;
}
// ============================================================================
// Provider grouping (keyed by provider name from the API)
// ============================================================================
@@ -133,7 +130,11 @@ function ExistingProviderCard({
}
};
const { icon, companyName, Modal } = getProvider(provider.provider, provider);
const providerEntry = getProvider(provider.provider, provider);
const { icon, Modal } = providerEntry;
const providerNames = useProviderNames()(providerEntry);
const { companyName } = providerNames;
const displayName = provider.name || providerNames.productName;
return (
<>
@@ -144,9 +145,7 @@ function ExistingProviderCard({
{deleteModal.isOpen && (
<ConfirmationModalLayout
icon={SvgTrash}
title={markdown(
t("deleteModal.title", { provider: providerDisplayName(provider) })
)}
title={markdown(t("deleteModal.title", { provider: displayName }))}
onClose={() => deleteModal.toggle(false)}
submit={
<Button
@@ -168,7 +167,7 @@ function ExistingProviderCard({
<Text font="main-ui-body" color="text-03">
{markdown(
t("deleteModal.description", {
provider: providerDisplayName(provider),
provider: displayName,
})
)}
</Text>
@@ -194,13 +193,15 @@ function ExistingProviderCard({
// A name to select the card by. The Edit and Delete buttons inside
// are labelled "Edit <name>" / "Delete <name>", so an exact match on
// the bare name reaches the card alone.
aria-label={providerDisplayName(provider)}
aria-label={displayName}
onClick={() => setIsOpen(true)}
>
<ContentAction
icon={icon}
title={providerDisplayName(provider)}
title={displayName}
description={companyName}
titleMaxLines={1}
descriptionMaxLines={1}
sizePreset="main-ui"
variant="section"
padding={2}
@@ -219,7 +220,7 @@ function ExistingProviderCard({
icon={SvgTrash}
prominence="tertiary"
aria-label={t("providerCard.deleteButton.ariaLabel", {
provider: providerDisplayName(provider),
provider: displayName,
})}
onClick={(e) => {
e.stopPropagation();
@@ -231,7 +232,7 @@ function ExistingProviderCard({
icon={SvgSettings}
prominence="tertiary"
aria-label={t("providerCard.editButton.ariaLabel", {
provider: providerDisplayName(provider),
provider: displayName,
})}
onClick={(e) => {
e.stopPropagation();
@@ -262,7 +263,9 @@ function NewProviderCard({
}: NewProviderCardProps) {
const t = useTranslations("admin.languageModels");
const [isOpen, setIsOpen] = useState(false);
const { icon, productName, companyName, Modal } = getProvider(providerName);
const providerEntry = getProvider(providerName);
const { icon, Modal } = providerEntry;
const { productName, companyName } = useProviderNames()(providerEntry);
return (
<SelectCard
@@ -282,6 +285,8 @@ function NewProviderCard({
icon={icon}
title={productName}
description={companyName}
titleMaxLines={1}
descriptionMaxLines={1}
sizePreset="main-ui"
variant="section"
padding={2}
@@ -294,7 +299,11 @@ function NewProviderCard({
setIsOpen(true);
}}
>
{t("newProviderCard.connectButton.label")}
{/* A provider without a brand (OpenAI-compatible) is set up,
like the custom card, rather than connected. */}
{providerEntry.copyKey
? t("newCustomProviderCard.setUpButton.label")
: t("newProviderCard.connectButton.label")}
</Button>
}
/>
@@ -318,7 +327,9 @@ function NewCustomProviderCard({
}: NewCustomProviderCardProps) {
const t = useTranslations("admin.languageModels");
const [isOpen, setIsOpen] = useState(false);
const { icon, productName, companyName, Modal } = getProvider("custom");
const providerEntry = getProvider("custom");
const { icon, Modal } = providerEntry;
const { productName, companyName } = useProviderNames()(providerEntry);
return (
<>
@@ -336,6 +347,8 @@ function NewCustomProviderCard({
icon={icon}
title={productName}
description={companyName}
titleMaxLines={1}
descriptionMaxLines={1}
sizePreset="main-ui"
variant="section"
padding={2}