fix(site): align AI Settings model provider dropdowns (#27391)

This commit is contained in:
Danielle Maywood
2026-07-22 14:13:31 +01:00
committed by GitHub
parent f17d488479
commit ff71bb3850
9 changed files with 70 additions and 89 deletions
@@ -27,30 +27,38 @@ const baseProviderState: ProviderState = {
};
describe("deriveProviderStates", () => {
it("orders provider configs first, then catalog-only providers", () => {
it("orders providers alphabetically by display label", () => {
const providerConfigs = [
{
...MockChatProviderConfig,
id: "prov-anthropic",
provider: "anthropic",
display_name: "Anthropic",
id: "prov-openai",
provider: "openai",
display_name: "OpenAI",
},
];
const catalog: TypesGen.ChatModelsResponse = {
providers: [
{ ...MockChatModelProvider, provider: "anthropic" },
{ ...MockChatModelProvider, provider: "google" },
{ ...MockChatModelProvider, provider: "anthropic" },
],
unsupported_providers: [],
};
const states = deriveProviderStates([], providerConfigs, catalog);
expect(states.map((s) => s.provider)).toEqual(["anthropic", "google"]);
expect(states[0].key).toBe("prov-anthropic");
expect(states[1].key).toBe("google");
expect(states.map((s) => s.provider)).toEqual([
"anthropic",
"google",
"openai",
]);
expect(states.map((s) => s.label)).toEqual([
"Anthropic",
"Google",
"OpenAI",
]);
expect(states[0].hasEffectiveAPIKey).toBe(true);
expect(states[1].hasEffectiveAPIKey).toBe(true);
expect(states[2].hasEffectiveAPIKey).toBe(true);
});
it("matches model configs to provider configs by ai_provider_id", () => {
+4 -1
View File
@@ -84,6 +84,7 @@ type ProviderEntry = {
provider: string;
};
// Returns provider states ordered alphabetically by display label.
export const deriveProviderStates = (
modelConfigs: readonly TypesGen.ChatModelConfig[],
providerConfigs: TypesGen.ChatProviderConfig[] | null | undefined,
@@ -144,7 +145,7 @@ export const deriveProviderStates = (
}
}
return orderedEntries.map(({ key, provider }) => {
const states = orderedEntries.map(({ key, provider }) => {
const providerConfigEntry = providerConfigsByKey.get(key);
const providerConfigSource = providerConfigEntry?.source;
const providerConfig = isDatabaseProviderConfig(
@@ -188,6 +189,8 @@ export const deriveProviderStates = (
baseURL: getProviderBaseURL(providerConfigEntry),
};
});
return states.toSorted((a, b) => a.label.localeCompare(b.label));
};
export const canManageProviderModels = (
@@ -68,11 +68,18 @@ export const Default: Story = {
canvas.getByText("Claude Sonnet 4.5 (Bedrock)"),
).toBeInTheDocument();
await expect(canvas.getByText("AWS Bedrock")).toBeInTheDocument();
// The Bedrock model config should render the Bedrock provider icon.
await expect(canvas.getByAltText("AWS Bedrock")).toBeInTheDocument();
// The provider icon is decorative (alt=""), so its name comes from the
// visible label asserted above rather than the image alt text.
await expect(canvas.getAllByText("Enabled").length).toBeGreaterThan(0);
await expect(canvas.getByText("Default")).toBeInTheDocument();
await expect(canvas.getByText("Disabled")).toBeInTheDocument();
// The Add model menu lists each provider by exact accessible name; a
// regressed icon would turn a name into "Anthropic Anthropic".
await userEvent.click(canvas.getByRole("button", { name: /add model/i }));
const menu = await within(document.body).findByRole("menu");
await within(menu).findByRole("menuitem", { name: "Anthropic" });
await userEvent.keyboard("{Escape}");
},
};
@@ -94,7 +101,8 @@ export const FilterByProvider: Story = {
name: /filter by provider/i,
});
await userEvent.click(providerFilter);
const anthropicOption = await within(document.body).findByRole("option", {
const listbox = await within(document.body).findByRole("listbox");
const anthropicOption = await within(listbox).findByRole("option", {
name: "Anthropic",
});
await userEvent.click(anthropicOption);
@@ -214,7 +214,7 @@ const ModelsPageView: FC<ModelsPageViewProps> = ({
</div>
<Select value={providerFilter} onValueChange={handleProviderChange}>
<SelectTrigger
className="w-full sm:w-60"
className="w-full shadow-none sm:w-60"
aria-label="Filter by provider"
>
<SelectValue placeholder="All providers" />
@@ -223,7 +223,10 @@ const ModelsPageView: FC<ModelsPageViewProps> = ({
<SelectItem value={ALL_PROVIDERS_VALUE}>All providers</SelectItem>
{providerStates.map((providerState) => (
<SelectItem key={providerState.key} value={providerState.key}>
{providerState.label}
<span className="flex items-center gap-2">
<ProviderIcon provider={providerState.provider} />
{providerState.label}
</span>
</SelectItem>
))}
</SelectContent>
@@ -139,11 +139,11 @@ export const AddHidesDisabledProviders: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("combobox", { name: /provider/i }));
// Option names include the provider icon alt text, so match loosely.
const optionNames = screen
.getAllByRole("option")
.map((option) => option.textContent?.trim());
await expect(optionNames).toEqual(["OpenAI", "Anthropic"]);
// Exact accessible-name matches guard the aria-hidden icon fix: a
// regressed icon would turn an option's name into "OpenAI OpenAI".
await screen.findByRole("option", { name: "OpenAI" });
await screen.findByRole("option", { name: "Anthropic" });
await expect(screen.getAllByRole("option")).toHaveLength(2);
await expect(
screen.queryByRole("option", { name: /Secondary/ }),
).not.toBeInTheDocument();
@@ -23,50 +23,21 @@ export const getProviderIcon = (provider: string): string | undefined => {
return "/icon/google.svg";
case "vercel":
return "/icon/vercel.svg";
case "gemini":
return "/icon/gemini.svg";
default:
return undefined;
}
};
const getProviderName = (provider: string): string => {
switch (provider) {
case "openai":
return "OpenAI";
case "anthropic":
return "Anthropic";
case "bedrock":
return "AWS Bedrock";
case "azure":
return "Azure OpenAI";
case "copilot":
return "GitHub Copilot";
case "google":
return "Google";
case "openai-compat":
return "OpenAI-compatible";
case "openrouter":
return "OpenRouter";
case "vercel":
return "Vercel";
default:
return provider || "Unknown provider";
}
};
export const ProviderIcon: React.FC<ProviderIconProps> = ({
provider,
icon,
className = "size-icon-sm",
}) => {
const iconSrc = icon || getProviderIcon(provider);
const name = getProviderName(provider);
if (iconSrc === undefined) {
return (
<Building2Icon
className={`${className} flex-shrink-0`}
aria-label={name}
/>
);
return <Building2Icon className={`${className} flex-shrink-0`} />;
}
return <ExternalImage src={iconSrc} alt={name} className={className} />;
return <ExternalImage src={iconSrc} alt="" className={className} />;
};
@@ -1,18 +1,9 @@
import { ServerIcon } from "lucide-react";
import { Building2Icon } from "lucide-react";
import type { FC } from "react";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import { normalizeProvider } from "#/modules/aiModels/helpers";
import { getProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon";
import { cn } from "#/utils/cn";
import { formatProviderLabel } from "../../utils/modelOptions";
const providerIconMap: Record<string, string> = {
openai: "/icon/openai.svg",
anthropic: "/icon/anthropic.svg",
azure: "/icon/azure.svg",
bedrock: "/icon/aws.svg",
google: "/icon/google.svg",
gemini: "/icon/gemini.svg",
};
interface ProviderIconProps {
provider: string;
@@ -23,24 +14,7 @@ export const ProviderIcon: FC<ProviderIconProps> = ({
provider,
className,
}) => {
const normalized = normalizeProvider(provider);
const iconPath = providerIconMap[normalized];
if (iconPath) {
return (
<div
className={cn(
"flex shrink-0 items-center justify-center rounded-full bg-surface-secondary",
className,
)}
>
<ExternalImage
src={iconPath}
alt={`${formatProviderLabel(provider)} logo`}
className="size-3/5"
/>
</div>
);
}
const iconPath = getProviderIcon(normalizeProvider(provider));
return (
<div
className={cn(
@@ -48,7 +22,11 @@ export const ProviderIcon: FC<ProviderIconProps> = ({
className,
)}
>
<ServerIcon className="size-3/5 text-content-secondary" />
{iconPath ? (
<ExternalImage src={iconPath} alt="" className="size-3/5" />
) : (
<Building2Icon className="size-3/5 text-content-secondary" />
)}
</div>
);
};
@@ -78,6 +78,12 @@ export const Default: Story = {
expect(canvas.getByText("Claude Sonnet")).toBeInTheDocument();
expect(canvas.queryByText("GPT-3.5 (Disabled)")).not.toBeInTheDocument();
// Each badge announces provider + model (the icon itself is decorative).
expect(canvas.getByLabelText("OpenAI GPT-4o")).toBeInTheDocument();
expect(
canvas.getByLabelText("Anthropic Claude Sonnet"),
).toBeInTheDocument();
// No footer visible when nothing is dirty
expect(
canvas.queryByRole("button", { name: /Save/i }),
@@ -24,6 +24,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { formatProviderLabel } from "#/utils/aiProviders";
import { cn } from "#/utils/cn";
import { ProviderIcon } from "./ChatModelAdminPanel/ProviderIcon";
@@ -278,17 +279,20 @@ export const UserCompactionThresholdSettings: FC<
draftValue === "100" && drafts[modelConfig.id] !== undefined;
const rowError = rowErrors[modelConfig.id];
const modelName = modelConfig.display_name || modelConfig.model;
const provider =
providerTypeByID.get(modelConfig.ai_provider_id) ?? "";
const providerLabel = formatProviderLabel(provider);
return (
<TableRow key={modelConfig.id}>
<TableCell className="text-sm font-medium text-content-primary">
<Badge size="sm" variant="default" className="w-fit">
<ProviderIcon
provider={
providerTypeByID.get(modelConfig.ai_provider_id) ?? ""
}
className="size-4"
/>
<Badge
size="sm"
variant="default"
className="w-fit"
aria-label={`${providerLabel} ${modelName}`}
>
<ProviderIcon provider={provider} className="size-4" />
{modelName}
</Badge>
{rowError && (