Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
24 changes: 16 additions & 8 deletions site/src/modules/aiModels/providerStates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
5 changes: 4 additions & 1 deletion site/src/modules/aiModels/providerStates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -188,6 +189,8 @@ export const deriveProviderStates = (
baseURL: getProviderBaseURL(providerConfigEntry),
};
});

return states.toSorted((a, b) => a.label.localeCompare(b.label));
Comment thread
DanielleMaywood marked this conversation as resolved.
};

export const canManageProviderModels = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
DanielleMaywood marked this conversation as resolved.
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}");
},
};

Expand All @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand All @@ -223,7 +223,10 @@ const ModelsPageView: FC<ModelsPageViewProps> = ({
<SelectItem value={ALL_PROVIDERS_VALUE}>All providers</SelectItem>
Comment thread
DanielleMaywood marked this conversation as resolved.
{providerStates.map((providerState) => (
<SelectItem key={providerState.key} value={providerState.key}>
{providerState.label}
<span className="flex items-center gap-2">
Comment thread
DanielleMaywood marked this conversation as resolved.
<ProviderIcon provider={providerState.provider} />
{providerState.label}
</span>
</SelectItem>
))}
</SelectContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Comment thread
DanielleMaywood marked this conversation as resolved.
await screen.findByRole("option", { name: "Anthropic" });
await expect(screen.getAllByRole("option")).toHaveLength(2);
await expect(
screen.queryByRole("option", { name: /Secondary/ }),
).not.toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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} />;
};
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -23,32 +14,19 @@ 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(
"flex shrink-0 items-center justify-center rounded-full bg-surface-secondary",
className,
)}
>
<ServerIcon className="size-3/5 text-content-secondary" />
{iconPath ? (
<ExternalImage src={iconPath} alt="" className="size-3/5" />
Comment thread
DanielleMaywood marked this conversation as resolved.
) : (
<Building2Icon className="size-3/5 text-content-secondary" />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if there is no provider icon, what are we showing? Do we have a test for this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for context: This appears to only be used in the Coder Agents model compaction personal settings page.

If there is no ProviderIcon we're showing a building icon. Same fallback as used in the AISettingsPage.

When we eventually get round to redesigning the compaction page (it does not look good) we'll likely bin this entire component.

As for test coverage on this, it appears we do not have any coverage but I'm not entirely convinced we need it here.

)}
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 && (
Expand Down
Loading