mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
fix(site): align AI Settings model provider dropdowns (#27391)
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user