feat: add custom AI provider icons and instance-based model picker grouping (#27026)

This commit is contained in:
Danielle Maywood
2026-07-06 23:00:09 +01:00
committed by GitHub
parent 581f906947
commit d51762440b
53 changed files with 543 additions and 165 deletions
+2
View File
@@ -1785,6 +1785,7 @@ const toChatProviderConfig = (
id: provider.id,
provider: provider.type,
display_name: provider.display_name || provider.type,
icon: provider.icon,
enabled: provider.enabled,
has_api_key: provider.api_keys.length > 0,
central_api_key_enabled: true,
@@ -1824,6 +1825,7 @@ export const userChatProviderConfigs = () => ({
provider_id: config.provider.id,
provider: config.provider.type,
display_name: config.provider.display_name || config.provider.type,
icon: config.provider.icon,
has_user_api_key: config.has_user_api_key,
byok_enabled: config.byok_enabled,
has_central_api_key_fallback: config.has_provider_api_key,
+8
View File
@@ -285,6 +285,7 @@ export interface AIProvider {
readonly type: AIProviderType;
readonly name: string;
readonly display_name: string;
readonly icon: string;
readonly enabled: boolean;
readonly base_url: string;
readonly api_keys: readonly AIProviderKey[];
@@ -440,6 +441,7 @@ export interface AIProviderSummary {
readonly type: AIProviderType;
readonly name: string;
readonly display_name: string;
readonly icon: string;
readonly enabled: boolean;
readonly deleted: boolean;
}
@@ -2826,6 +2828,7 @@ export interface ChatProviderConfig {
readonly id: string;
readonly provider: string;
readonly display_name: string;
readonly icon: string;
readonly enabled: boolean;
readonly has_api_key: boolean;
readonly central_api_key_enabled: boolean;
@@ -3489,6 +3492,7 @@ export interface CreateAIProviderRequest {
readonly type: AIProviderType;
readonly name: string;
readonly display_name?: string;
readonly icon?: string;
readonly enabled: boolean;
readonly base_url: string;
readonly api_keys?: readonly string[];
@@ -3544,6 +3548,7 @@ export interface CreateChatModelConfigRequest {
export interface CreateChatProviderConfigRequest {
readonly provider: string;
readonly display_name?: string;
readonly icon?: string;
readonly api_key?: string;
readonly base_url?: string;
readonly enabled?: boolean;
@@ -8826,6 +8831,7 @@ export interface TransitionStats {
*/
export interface UpdateAIProviderRequest {
readonly display_name?: string;
readonly icon?: string;
readonly enabled?: boolean;
readonly base_url?: string;
readonly api_keys?: AIProviderKeyMutation[];
@@ -8971,6 +8977,7 @@ export interface UpdateChatPlanModeInstructionsRequest {
*/
export interface UpdateChatProviderConfigRequest {
readonly display_name?: string;
readonly icon?: string;
readonly api_key?: string;
readonly base_url?: string;
readonly enabled?: boolean;
@@ -9723,6 +9730,7 @@ export interface UserChatProviderConfig {
readonly provider_id: string;
readonly provider: string;
readonly display_name: string;
readonly icon: string;
readonly has_user_api_key: boolean;
readonly has_central_api_key_fallback: boolean;
readonly byok_enabled: boolean;
@@ -20,7 +20,7 @@ import type * as TypesGen from "#/api/typesGenerated";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { providerTypeByIDFromConfigs } from "#/pages/AgentsPage/utils/modelOptions";
import { providerInfoByIDFromConfigs } from "#/pages/AgentsPage/utils/modelOptions";
import { pageTitle } from "#/utils/page";
import { CoderAgentsPageView } from "./CoderAgentsPageView";
@@ -114,7 +114,7 @@ const CoderAgentsPage: FC = () => {
updateChatComputerUseProvider(queryClient),
);
const providerTypeByID = providerTypeByIDFromConfigs(
const providerInfoByID = providerInfoByIDFromConfigs(
providerConfigsQuery.data,
);
@@ -143,7 +143,7 @@ const CoderAgentsPage: FC = () => {
titleGenerationModelOverrideData={titleGenerationModelQuery.data}
exploreModelOverrideData={exploreModelOverrideQuery.data}
modelConfigsData={modelConfigsQuery.data}
providerTypeByID={providerTypeByID}
providerInfoByID={providerInfoByID}
modelConfigsError={modelConfigsQuery.error}
isLoadingModelConfigs={modelConfigsQuery.isLoading}
isFetchingModelConfigs={modelConfigsQuery.isFetching}
@@ -104,9 +104,12 @@ const allModelConfigs: TypesGen.ChatModelConfig[] = [
exploreDisabledModelConfig,
];
const providerTypeByID = new Map<string, string>([
["provider-1", "openai"],
["provider-anthropic", "anthropic"],
const providerInfoByID = new Map([
["provider-1", { provider: "openai", displayName: "OpenAI", icon: "" }],
[
"provider-anthropic",
{ provider: "anthropic", displayName: "Anthropic", icon: "" },
],
]);
const buildArgs = (
@@ -123,7 +126,7 @@ const buildArgs = (
titleGenerationModelOverrideData: buildTitleGenerationModelOverrideData(),
exploreModelOverrideData: buildOverrideData("explore"),
modelConfigsData: allModelConfigs,
providerTypeByID,
providerInfoByID,
modelConfigsError: undefined,
isLoadingModelConfigs: false,
isFetchingModelConfigs: false,
@@ -8,6 +8,7 @@ import {
} from "#/components/SettingsHeader/SettingsHeader";
import { AdvisorSettings } from "#/pages/AgentsPage/components/AdvisorSettings";
import { VirtualDesktopSettings } from "#/pages/AgentsPage/components/VirtualDesktopSettings";
import type { ProviderInfo } from "#/pages/AgentsPage/utils/modelOptions";
import {
AdminPersonalModelOverridesSettings,
type SavePersonalModelOverridesAdminSetting,
@@ -34,7 +35,7 @@ export interface CoderAgentsPageViewProps {
titleGenerationModelOverrideData?: TypesGen.ChatModelOverrideResponse;
exploreModelOverrideData?: TypesGen.ChatModelOverrideResponse;
modelConfigsData: TypesGen.ChatModelConfig[] | undefined;
providerTypeByID: ReadonlyMap<string, string>;
providerInfoByID: ReadonlyMap<string, ProviderInfo>;
modelConfigsError: unknown;
isLoadingModelConfigs: boolean;
isFetchingModelConfigs: boolean;
@@ -84,7 +85,7 @@ export const CoderAgentsPageView: FC<CoderAgentsPageViewProps> = ({
titleGenerationModelOverrideData,
exploreModelOverrideData,
modelConfigsData,
providerTypeByID,
providerInfoByID,
modelConfigsError,
isLoadingModelConfigs,
isFetchingModelConfigs,
@@ -147,7 +148,7 @@ export const CoderAgentsPageView: FC<CoderAgentsPageViewProps> = ({
description="Used by delegated agents that can edit files or run commands."
modelOverrideData={generalModelOverrideData}
enabledModelConfigs={enabledModelConfigs}
providerTypeByID={providerTypeByID}
providerInfoByID={providerInfoByID}
modelConfigsError={modelConfigsError}
isLoading={isLoadingModelConfigs}
onSaveModelOverride={onSaveGeneralModelOverride}
@@ -161,7 +162,7 @@ export const CoderAgentsPageView: FC<CoderAgentsPageViewProps> = ({
description="Leave unset to use Coder's title default, which prefers fast models from configured providers."
modelOverrideData={titleGenerationModelOverrideData}
enabledModelConfigs={enabledModelConfigs}
providerTypeByID={providerTypeByID}
providerInfoByID={providerInfoByID}
modelConfigsError={modelConfigsError}
isLoading={isLoadingModelConfigs}
onSaveModelOverride={onSaveTitleGenerationModel}
@@ -176,7 +177,7 @@ export const CoderAgentsPageView: FC<CoderAgentsPageViewProps> = ({
description="Used for read-only codebase exploration before work returns to the main agent."
modelOverrideData={exploreModelOverrideData}
enabledModelConfigs={enabledModelConfigs}
providerTypeByID={providerTypeByID}
providerInfoByID={providerInfoByID}
modelConfigsError={modelConfigsError}
isLoading={isLoadingModelConfigs}
onSaveModelOverride={onSaveExploreModelOverride}
@@ -3,9 +3,9 @@ import type { FC, ReactNode } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { useTemporarySavedState } from "#/components/TemporarySavedState/TemporarySavedState";
import type { ModelSelectorOption } from "#/pages/AgentsPage/components/ChatElements/ModelSelector";
import { ModelSelector } from "#/pages/AgentsPage/components/ChatElements/ModelSelector";
import { ModelOverrideAlerts } from "#/pages/AgentsPage/components/ModelOverrideAlerts";
import type { ProviderInfo } from "#/pages/AgentsPage/utils/modelOptions";
import { AgentSettingLayout } from "./AgentSettingLayout";
export interface MutationCallbacks {
@@ -27,7 +27,7 @@ interface SubagentModelOverrideSettingsProps {
description?: ReactNode;
modelOverrideData: ModelOverrideData | undefined;
enabledModelConfigs: readonly TypesGen.ChatModelConfig[];
providerTypeByID: ReadonlyMap<string, string>;
providerInfoByID: ReadonlyMap<string, ProviderInfo>;
modelConfigsError: unknown;
isLoading: boolean;
onSaveModelOverride: (
@@ -42,17 +42,6 @@ interface SubagentModelOverrideSettingsProps {
disabled?: boolean;
}
const toModelSelectorOption = (
modelConfig: TypesGen.ChatModelConfig,
providerTypeByID: ReadonlyMap<string, string>,
): ModelSelectorOption => ({
id: modelConfig.id,
provider: providerTypeByID.get(modelConfig.ai_provider_id) ?? "",
model: modelConfig.model,
displayName: modelConfig.display_name.trim() || modelConfig.model,
contextLimit: modelConfig.context_limit,
});
export const SubagentModelOverrideSettings: FC<
SubagentModelOverrideSettingsProps
> = ({
@@ -60,7 +49,7 @@ export const SubagentModelOverrideSettings: FC<
description,
modelOverrideData,
enabledModelConfigs,
providerTypeByID,
providerInfoByID,
modelConfigsError,
isLoading,
onSaveModelOverride,
@@ -74,9 +63,19 @@ export const SubagentModelOverrideSettings: FC<
const { isSavedVisible, showSavedState } = useTemporarySavedState();
const hasLoadedModelOverride = modelOverrideData !== undefined;
const isMalformedOverride = modelOverrideData?.is_malformed ?? false;
const enabledModelOptions = enabledModelConfigs.map((modelConfig) =>
toModelSelectorOption(modelConfig, providerTypeByID),
);
const enabledModelOptions = enabledModelConfigs.map((modelConfig) => {
const providerInfo = providerInfoByID.get(modelConfig.ai_provider_id);
return {
id: modelConfig.id,
provider: providerInfo?.provider ?? "",
providerId: modelConfig.ai_provider_id,
providerLabel: providerInfo?.displayName,
providerIcon: providerInfo?.icon,
model: modelConfig.model,
displayName: modelConfig.display_name.trim() || modelConfig.model,
contextLimit: modelConfig.context_limit,
};
});
const form = useFormik({
enableReinitialize: true,
@@ -22,7 +22,6 @@ interface IconPickerFieldProps {
placeholder?: string;
disabled?: boolean;
onChange: (value: string) => void;
onPickEmoji: (value: string) => void;
}
export const IconPickerField: FC<IconPickerFieldProps> = ({
@@ -31,7 +30,6 @@ export const IconPickerField: FC<IconPickerFieldProps> = ({
placeholder,
disabled,
onChange,
onPickEmoji,
}) => {
const [open, setOpen] = useState(false);
const hasIcon = value !== "";
@@ -76,12 +74,20 @@ export const IconPickerField: FC<IconPickerFieldProps> = ({
<AnimatedChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" className="w-min">
<PopoverContent
side="bottom"
align="end"
className="w-min"
// The popover is portaled in the DOM but still a React child of
// InputGroupAddon, whose click handler focuses the text input.
// Stop clicks here so the emoji picker keeps focus.
onClick={(event) => event.stopPropagation()}
>
<Suspense fallback={<Loader />}>
<EmojiPicker
onEmojiSelect={(emoji) => {
const picked = emoji.src ?? `/emojis/${emoji.unified}.png`;
onPickEmoji(picked);
onChange(picked);
setOpen(false);
}}
/>
@@ -155,9 +155,6 @@ export const MCPServerFormFields: FC<MCPServerFormFieldsProps> = ({
value={form.values.iconURL}
placeholder="file location"
onChange={(value) => void form.setFieldValue("iconURL", value)}
onPickEmoji={(value) =>
void form.setFieldValue("iconURL", value)
}
disabled={isDisabled}
/>
</Field>
@@ -7,6 +7,7 @@ const MockOpenAIProviderConfig: ChatProviderConfig = {
id: "prov-openai",
provider: "openai",
display_name: "OpenAI",
icon: "",
enabled: true,
has_api_key: true,
central_api_key_enabled: true,
@@ -1,4 +1,5 @@
import { ArrowLeftIcon } from "lucide-react";
import { useState } from "react";
import { useMutation, useQueryClient } from "react-query";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
@@ -25,6 +26,8 @@ const AddProviderPageView: React.FC<AddProviderPageViewProps> = ({
const navigate = useNavigate();
const queryClient = useQueryClient();
const createMutation = useMutation(createAIProviderMutation(queryClient));
const defaultIcon = getProviderIcon(provider.value) ?? "";
const [icon, setIcon] = useState(defaultIcon);
return (
<>
@@ -36,11 +39,7 @@ const AddProviderPageView: React.FC<AddProviderPageViewProps> = ({
</Link>
<div className="flex flex-col gap-6 pt-6">
<div className="flex items-center gap-4 min-w-0">
<Avatar
variant="icon"
size="lg"
src={getProviderIcon(provider.value)}
/>
<Avatar variant="icon" size="lg" src={icon || defaultIcon} />
<SettingsHeaderTitle>{`Add ${indefiniteArticle(
provider.label,
)} ${provider.label} provider`}</SettingsHeaderTitle>
@@ -51,7 +50,8 @@ const AddProviderPageView: React.FC<AddProviderPageViewProps> = ({
<div className="border border-solid p-6 rounded-lg">
<ProviderForm
editing={false}
initialValues={{ type: provider.value }}
initialValues={{ type: provider.value, icon: defaultIcon }}
onIconChange={setIcon}
isLoading={createMutation.isPending}
submitError={createMutation.error}
onSubmit={async (values) => {
@@ -146,7 +146,9 @@ const UpdateProviderPageView: React.FC = () => {
<Avatar
variant="icon"
size="lg"
src={getProviderIcon(getProviderDisplayType(provider))}
src={
provider.icon || getProviderIcon(getProviderDisplayType(provider))
}
/>
<SettingsHeaderTitle>
<span className="block min-w-0 truncate">
@@ -14,6 +14,7 @@ import { Label } from "#/components/Label/Label";
import { Link as DocsLink } from "#/components/Link/Link";
import { Spinner } from "#/components/Spinner/Spinner";
import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt";
import { IconPickerField } from "#/pages/AISettingsPage/MCPServersPage/components/IconPickerField";
import { docs } from "#/utils/docs";
import { getFormHelpers } from "#/utils/formUtils";
import { CredentialField } from "./CredentialField";
@@ -22,6 +23,7 @@ export type ProviderFormValues = {
type: AIProviderType | "";
name: string;
displayName: string;
icon: string;
baseUrl: string;
model: string;
smallFastModel: string;
@@ -64,6 +66,7 @@ const defaultInitialValues: ProviderFormValues = {
type: "anthropic",
name: "",
displayName: "",
icon: "",
baseUrl: "",
model: "",
smallFastModel: "",
@@ -132,6 +135,7 @@ const makeOpenAiAnthropicSchema = (editing: boolean) =>
.required(),
name: makeNameSchema(editing),
displayName: makeDisplayNameSchema(editing),
icon: Yup.string(),
baseUrl: Yup.string()
.url("Endpoint must be a valid URL")
.matches(HTTP_SCHEME_REGEX, "Endpoint must use http or https.")
@@ -162,6 +166,7 @@ const makeBedrockSchema = (editing: boolean) =>
.required(),
name: makeNameSchema(editing),
displayName: makeDisplayNameSchema(editing),
icon: Yup.string(),
baseUrl: Yup.string()
.url("Endpoint must be a valid URL")
.matches(
@@ -199,6 +204,7 @@ const makeCopilotSchema = (editing: boolean) =>
.required(),
name: makeNameSchema(editing),
displayName: makeDisplayNameSchema(editing),
icon: Yup.string(),
baseUrl: Yup.string()
.url("Endpoint must be a valid URL")
.matches(HTTP_SCHEME_REGEX, "Endpoint must use http or https.")
@@ -251,6 +257,8 @@ type ProviderFormProps = {
/** Masked rendering of the saved openai/anthropic key (e.g. `sk-***...ABCD`). Falls back to a generic mask when omitted. */
openAiAnthropicMaskedApiKey?: string;
initialValues?: Partial<ProviderFormValues>;
/** Fires whenever the icon field changes, so page headers can preview it. */
onIconChange?: (icon: string) => void;
onSubmit?: (values: ProviderFormValues) => void;
isLoading?: boolean;
submitError?: unknown;
@@ -279,6 +287,7 @@ export const ProviderForm: FC<ProviderFormProps> = ({
openAiAnthropicSavedApiKey = false,
openAiAnthropicMaskedApiKey,
initialValues,
onIconChange,
onSubmit,
isLoading = false,
submitError,
@@ -322,6 +331,25 @@ export const ProviderForm: FC<ProviderFormProps> = ({
});
const getFieldHelpers = getFormHelpers(form, submitError);
const handleIconChange = (value: string) => {
void form.setFieldValue("icon", value);
onIconChange?.(value);
};
const iconField = (
<div className="flex flex-col gap-2">
<Label htmlFor="icon">Icon</Label>
<div className="text-xs text-content-secondary">
Optional. URL or emoji shown for this provider.
</div>
<IconPickerField
id="icon"
value={form.values.icon}
onChange={handleIconChange}
/>
</div>
);
const typeSelectValue = form.values.type;
// Clears the field once if it's still showing the seeded mask;
@@ -403,6 +431,7 @@ export const ProviderForm: FC<ProviderFormProps> = ({
className="w-full"
/>
</div>
{iconField}
<FormField
required
field={getFieldHelpers("baseUrl")}
@@ -462,6 +491,7 @@ export const ProviderForm: FC<ProviderFormProps> = ({
className="w-full"
/>
</div>
{iconField}
<FormField
required
field={getFieldHelpers("baseUrl")}
@@ -3,6 +3,8 @@ import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
type ProviderIconProps = {
provider: string;
icon?: string;
className?: string;
};
export const getProviderIcon = (provider: string): string | undefined => {
@@ -51,13 +53,20 @@ const getProviderName = (provider: string): string => {
}
};
export const ProviderIcon: React.FC<ProviderIconProps> = ({ provider }) => {
const iconSrc = getProviderIcon(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="size-icon-sm flex-shrink-0" aria-label={name} />
<Building2Icon
className={`${className} flex-shrink-0`}
aria-label={name}
/>
);
}
return <ExternalImage src={iconSrc} alt={name} className="size-icon-sm" />;
return <ExternalImage src={iconSrc} alt={name} className={className} />;
};
@@ -35,7 +35,10 @@ export const ProviderRow: React.FC<ProviderRowProps> = ({
size="lg"
className="flex shrink-0 items-center justify-center"
>
<ProviderIcon provider={getProviderDisplayType(provider)} />
<ProviderIcon
provider={getProviderDisplayType(provider)}
icon={provider.icon}
/>
</Avatar>
}
/>
@@ -25,6 +25,7 @@ const baseOpenAIFormValues: ProviderFormValues = {
type: "openai",
name: "primary-openai",
displayName: "Primary OpenAI",
icon: "",
baseUrl: "https://api.openai.com",
model: "",
smallFastModel: "",
@@ -39,6 +40,7 @@ const baseBedrockFormValues: ProviderFormValues = {
type: "bedrock",
name: "primary-bedrock",
displayName: "Primary Bedrock",
icon: "",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
model: "anthropic.claude-sonnet-4-5",
smallFastModel: "anthropic.claude-haiku-4-5",
@@ -53,6 +55,7 @@ const baseCopilotFormValues: ProviderFormValues = {
type: "copilot",
name: "copilot",
displayName: "GitHub Copilot",
icon: "",
baseUrl: "https://api.business.githubcopilot.com",
model: "",
smallFastModel: "",
@@ -319,6 +322,22 @@ describe("providerFormValuesToCreate", () => {
expect(req.display_name).toBeUndefined();
});
it("trims and sends icon when provided", () => {
const req = providerFormValuesToCreate({
...baseOpenAIFormValues,
icon: " https://example.com/openai.svg ",
});
expect(req.icon).toBe("https://example.com/openai.svg");
});
it("omits icon when blank", () => {
const req = providerFormValuesToCreate({
...baseOpenAIFormValues,
icon: " ",
});
expect(req.icon).toBeUndefined();
});
it("trims whitespace from name and baseUrl", () => {
const req = providerFormValuesToCreate({
...baseOpenAIFormValues,
@@ -543,6 +562,14 @@ describe("providerFormValuesToUpdate", () => {
);
expect(req.api_keys).toEqual([]);
});
it("sends a trimmed icon so blank clears the stored icon", () => {
const req = providerFormValuesToUpdate(
{ ...baseOpenAIFormValues, icon: " " },
MockAIProviderOpenAI,
);
expect(req.icon).toBe("");
});
});
describe("Bedrock", () => {
@@ -664,10 +691,14 @@ describe("providerFormValuesToUpdate", () => {
describe("aiProviderToFormValues", () => {
it("seeds OpenAI form values from a wire provider", () => {
const values = aiProviderToFormValues(MockAIProviderOpenAI);
const values = aiProviderToFormValues({
...MockAIProviderOpenAI,
icon: "https://example.com/openai.svg",
});
expect(values.type).toBe("openai");
expect(values.name).toBe(MockAIProviderOpenAI.name);
expect(values.baseUrl).toBe(MockAIProviderOpenAI.base_url);
expect(values.icon).toBe("https://example.com/openai.svg");
expect(values.apiKey).toBe("");
});
@@ -12,6 +12,7 @@ import {
parseBedrockRegionFromBaseUrl,
SAVED_CREDENTIAL_MASK,
} from "./ProviderForm";
import { getProviderIcon } from "./ProviderIcon";
/** Drop placeholder masks so they don't round-trip back to the API. */
const sanitizeCredential = (
@@ -137,9 +138,11 @@ export const providerFormValuesToCreate = (
values: ProviderFormValues,
): CreateAIProviderRequest => {
const displayName = values.displayName.trim();
const icon = values.icon.trim();
const base: Omit<CreateAIProviderRequest, "type"> = {
name: values.name.trim(),
...(displayName ? { display_name: displayName } : {}),
...(icon ? { icon } : {}),
base_url: values.baseUrl.trim(),
enabled: values.enabled,
};
@@ -188,6 +191,7 @@ export const providerFormValuesToUpdate = (
): UpdateAIProviderRequest => {
const base: UpdateAIProviderRequest = {
display_name: values.displayName.trim(),
icon: values.icon.trim(),
enabled: values.enabled,
base_url: values.baseUrl.trim(),
};
@@ -246,6 +250,7 @@ export const aiProviderToFormValues = (
type: "bedrock",
name: provider.name,
displayName,
icon: provider.icon || (getProviderIcon("bedrock") ?? ""),
baseUrl: provider.base_url,
model: s.model ?? "",
smallFastModel: s.small_fast_model ?? "",
@@ -261,15 +266,18 @@ export const aiProviderToFormValues = (
type: "copilot",
name: provider.name,
displayName,
icon: provider.icon || (getProviderIcon("copilot") ?? ""),
baseUrl: provider.base_url,
enabled: provider.enabled,
};
}
const displayType = getProviderDisplayType(provider);
return {
type: getProviderDisplayType(provider),
type: displayType,
name: provider.name,
displayName,
icon: provider.icon || (getProviderIcon(displayType) ?? ""),
baseUrl: provider.base_url,
apiKey: "",
enabled: provider.enabled,
@@ -821,6 +821,7 @@ const meta: Meta<typeof AgentChatPageLayout> = {
type: "openai",
name: "openai",
display_name: "OpenAI",
icon: "",
enabled: true,
deleted: false,
},
@@ -17,6 +17,7 @@ const createProvider = (
provider_id: overrides.provider_id,
provider: overrides.provider,
display_name: overrides.display_name ?? overrides.provider,
icon: overrides.icon ?? "",
has_user_api_key: overrides.has_user_api_key ?? false,
has_central_api_key_fallback: overrides.has_central_api_key_fallback ?? false,
byok_enabled: overrides.byok_enabled ?? true,
+2 -2
View File
@@ -70,7 +70,7 @@ import {
import { maybePlayChime } from "./utils/chime";
import {
getModelOptionsFromConfigs,
providerTypeByIDFromUserConfigs,
providerInfoByIDFromUserConfigs,
} from "./utils/modelOptions";
import { clearPersistedRightPanelState } from "./utils/rightPanelTabStorage";
import { clearPersistedSidebarTabId } from "./utils/sidebarTabStorage";
@@ -331,7 +331,7 @@ const AgentsPage: FC = () => {
const catalogModelOptions = getModelOptionsFromConfigs(
chatModelConfigsQuery.data,
chatModelsQuery.data,
providerTypeByIDFromUserConfigs(chatProviderConfigsQuery.data),
providerInfoByIDFromUserConfigs(chatProviderConfigsQuery.data),
);
const chatList = chatsQuery.data?.pages.flat() ?? [];
const isArchiving =
@@ -180,7 +180,7 @@ const AgentsRouteElement = () => (
is_malformed: false,
}}
modelConfigsData={[]}
providerTypeByID={new Map()}
providerInfoByID={new Map()}
modelConfigsError={undefined}
isLoadingModelConfigs={false}
isFetchingModelConfigs={false}
@@ -453,6 +453,7 @@ const meta: Meta<typeof AgentsPageView> = {
type: "openai",
name: "openai",
display_name: "OpenAI",
icon: "",
enabled: true,
deleted: false,
},
@@ -128,6 +128,36 @@ export const MultipleProvidersWithCustomLabel: Story = {
},
};
export const MultipleProviderInstances: Story = {
args: {
options: [
...openAIModels,
{
...MockModelSelectorOption,
id: "anthropic-primary/claude-sonnet-4",
provider: "anthropic",
providerId: "provider-anthropic-primary",
providerLabel: "Anthropic",
model: "claude-sonnet-4-20250514",
displayName: "Claude Sonnet 4",
contextLimit: 200_000,
},
{
...MockModelSelectorOption,
id: "anthropic-hyper/claude-opus-4",
provider: "anthropic",
providerId: "provider-anthropic-hyper",
providerLabel: "Hyper",
providerIcon: "/icon/coder.svg",
model: "claude-opus-4-20250514",
displayName: "Claude Opus 4",
contextLimit: 200_000,
},
],
value: "anthropic-primary/claude-sonnet-4",
},
};
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import { screen } from "@testing-library/react";
import { render } from "#/testHelpers/renderHelpers";
import { ModelSelector, type ModelSelectorOption } from "./ModelSelector";
import { MockModelSelectorOption } from "./modelSelectorFixtures";
@@ -15,12 +15,16 @@ import {
PopoverContent,
PopoverTrigger,
} from "#/components/Popover/Popover";
import { ProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon";
import { formatProviderLabel as defaultFormatProviderLabel } from "#/utils/aiProviders";
import { cn } from "#/utils/cn";
export interface ModelSelectorOption {
id: string;
provider: string;
providerId?: string;
providerLabel?: string;
providerIcon?: string;
model: string;
displayName: string;
contextLimit?: number;
@@ -51,6 +55,11 @@ const formatContextLimit = (tokens: number): string => {
return `${k}K`;
};
const getProviderLabel = (
option: ModelSelectorOption,
formatProviderLabel: (provider: string) => string,
) => option.providerLabel?.trim() || formatProviderLabel(option.provider);
const getSearchText = (option: ModelSelectorOption, providerLabel: string) =>
[
providerLabel,
@@ -92,17 +101,18 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
const grouped = new Map<string, ModelSelectorOption[]>();
for (const option of options) {
const providerLabel = formatProviderLabel(option.provider);
const providerLabel = getProviderLabel(option, formatProviderLabel);
if (query && !getSearchText(option, providerLabel).includes(query)) {
continue;
}
const providerOptions = grouped.get(option.provider);
const groupKey = option.providerId?.trim() || option.provider;
const providerOptions = grouped.get(groupKey);
if (providerOptions) {
providerOptions.push(option);
continue;
}
grouped.set(option.provider, [option]);
grouped.set(groupKey, [option]);
}
return Array.from(grouped.entries());
@@ -173,29 +183,45 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
<CommandEmpty className="py-3 text-xs font-normal leading-[18px] text-content-secondary">
{emptyMessage}
</CommandEmpty>
{optionsByProvider.map(([provider, providerOptions], index) => (
<CommandGroup
key={provider}
heading={formatProviderLabel(provider)}
className={cn(
"p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:leading-[18px] [&_[cmdk-group-heading]]:text-content-secondary",
index > 0 &&
"border-0 border-t border-solid border-border-default",
)}
>
{providerOptions.map((option) => (
<ModelOptionItem
key={option.id}
option={option}
isSelected={option.id === value}
onSelect={() => {
onValueChange(option.id);
handleOpenChange(false);
}}
/>
))}
</CommandGroup>
))}
{optionsByProvider.map(([providerKey, providerOptions], index) => {
const firstOption = providerOptions[0];
const providerLabel = getProviderLabel(
firstOption,
formatProviderLabel,
);
return (
<CommandGroup
key={providerKey}
heading={
<span className="flex items-center gap-1.5">
<ProviderIcon
provider={firstOption.provider}
icon={firstOption.providerIcon}
className="size-3.5"
/>
<span>{providerLabel}</span>
</span>
}
className={cn(
"p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:leading-[18px] [&_[cmdk-group-heading]]:text-content-secondary",
index > 0 &&
"border-0 border-t border-solid border-border-default",
)}
>
{providerOptions.map((option) => (
<ModelOptionItem
key={option.id}
option={option}
isSelected={option.id === value}
onSelect={() => {
onValueChange(option.id);
handleOpenChange(false);
}}
/>
))}
</CommandGroup>
);
})}
</CommandList>
</Command>
</PopoverContent>
@@ -2097,8 +2097,10 @@ export const SettingsAPIKeysNonAdmin: Story = {
provider_id: "prov-1",
provider: "openai",
display_name: "OpenAI",
icon: "",
has_user_api_key: false,
has_central_api_key_fallback: false,
byok_enabled: true,
},
],
},
@@ -16,6 +16,8 @@ import {
getUnsupportedProviderNames,
hasConfiguredProviderConfigs,
hasUserFixableProviders,
providerInfoByIDFromConfigs,
providerInfoByIDFromUserConfigs,
providerTypeByIDFromConfigs,
providerTypeByIDFromUserConfigs,
resolveModelOptionId,
@@ -34,10 +36,16 @@ const createConfig = (
...overrides,
});
const providerTypeByID = new Map<string, string>([
["prov-openai", "openai"],
["prov-anthropic", "anthropic"],
["prov-openrouter", "openrouter"],
const providerInfoByID = new Map([
["prov-openai", { provider: "openai", displayName: "OpenAI", icon: "" }],
[
"prov-anthropic",
{ provider: "anthropic", displayName: "Anthropic", icon: "" },
],
[
"prov-openrouter",
{ provider: "openrouter", displayName: "OpenRouter", icon: "" },
],
]);
const createCatalog = (
@@ -240,12 +248,18 @@ describe("resolveModelOptionId", () => {
{
id: "config-1",
provider: "openai",
providerId: "prov-openai",
providerLabel: "OpenAI",
providerIcon: "",
model: "gpt-4o",
displayName: "GPT-4o",
},
{
id: "config-2",
provider: "anthropic",
providerId: "prov-anthropic",
providerLabel: "Anthropic",
providerIcon: "",
model: "claude-sonnet-4-20250514",
displayName: "Claude Sonnet",
},
@@ -293,22 +307,19 @@ describe("getModelOptionsFromConfigs", () => {
]);
expect(
getModelOptionsFromConfigs(configs, catalog, providerTypeByID),
getModelOptionsFromConfigs(configs, catalog, providerInfoByID),
).toEqual([
{
id: "config-1",
provider: "openai",
providerId: "prov-openai",
providerLabel: "OpenAI",
providerIcon: "",
model: "gpt-4o",
displayName: "GPT-4o (Fast)",
contextLimit: 128_000,
},
{
id: "config-2",
provider: "openai",
model: "gpt-4o",
displayName: "GPT-4o (Quality)",
contextLimit: 128_000,
},
expect.objectContaining({ id: "config-2" }),
]);
});
@@ -331,7 +342,7 @@ describe("getModelOptionsFromConfigs", () => {
]);
expect(
getModelOptionsFromConfigs(configs, catalog, providerTypeByID),
getModelOptionsFromConfigs(configs, catalog, providerInfoByID),
).toEqual([]);
});
@@ -362,16 +373,10 @@ describe("getModelOptionsFromConfigs", () => {
]);
expect(
getModelOptionsFromConfigs(configs, catalog, providerTypeByID),
).toEqual([
{
id: "config-2",
provider: "openai",
model: "gpt-4.1",
displayName: "GPT-4.1",
contextLimit: 128_000,
},
]);
getModelOptionsFromConfigs(configs, catalog, providerInfoByID).map(
(option) => option.id,
),
).toEqual(["config-2"]);
});
it("falls back to the model name when display_name is blank", () => {
@@ -393,24 +398,22 @@ describe("getModelOptionsFromConfigs", () => {
]);
expect(
getModelOptionsFromConfigs(configs, catalog, providerTypeByID),
getModelOptionsFromConfigs(configs, catalog, providerInfoByID),
).toEqual([
{
expect.objectContaining({
id: "config-1",
provider: "openai",
model: "gpt-4o",
displayName: "gpt-4o",
contextLimit: 0,
},
}),
]);
});
it("returns an empty array for null and undefined inputs", () => {
expect(getModelOptionsFromConfigs(null, null, providerTypeByID)).toEqual(
expect(getModelOptionsFromConfigs(null, null, providerInfoByID)).toEqual(
[],
);
expect(
getModelOptionsFromConfigs(undefined, undefined, providerTypeByID),
getModelOptionsFromConfigs(undefined, undefined, providerInfoByID),
).toEqual([]);
});
@@ -452,7 +455,7 @@ describe("getModelOptionsFromConfigs", () => {
]);
expect(
getModelOptionsFromConfigs(configs, catalog, providerTypeByID).map(
getModelOptionsFromConfigs(configs, catalog, providerInfoByID).map(
(option) => option.id,
),
).toEqual([
@@ -488,23 +491,10 @@ describe("getModelOptionsFromConfigs", () => {
]);
expect(
getModelOptionsFromConfigs(configs, catalog, providerTypeByID),
).toEqual([
{
id: "config-2",
provider: "openrouter",
model: "anthropic/claude-sonnet-4-20250514",
displayName: "Claude via OpenRouter",
contextLimit: 200_000,
},
{
id: "config-1",
provider: "openrouter",
model: "openai/gpt-4o",
displayName: "GPT-4o via OpenRouter",
contextLimit: 128_000,
},
]);
getModelOptionsFromConfigs(configs, catalog, providerInfoByID).map(
(option) => option.id,
),
).toEqual(["config-2", "config-1"]);
});
it("drops configs whose ai_provider_id is absent from the provider map", () => {
@@ -545,7 +535,9 @@ describe("getModelOptionsFromConfigs", () => {
{ provider: "openai", available: true, models: [] },
{ provider: "anthropic", available: true, models: [] },
]);
const partialMap = new Map<string, string>([["prov-openai", "openai"]]);
const partialMap = new Map([
["prov-openai", { provider: "openai", displayName: "OpenAI", icon: "" }],
]);
expect(
getModelOptionsFromConfigs(configs, catalog, partialMap).map(
@@ -553,6 +545,98 @@ describe("getModelOptionsFromConfigs", () => {
),
).toEqual(["config-openai"]);
});
it("preserves provider instance metadata for same-type providers", () => {
const configs = [
createConfig({
id: "config-primary",
ai_provider_id: "prov-anthropic-primary",
model: "claude-sonnet-4-20250514",
}),
createConfig({
id: "config-hyper",
ai_provider_id: "prov-anthropic-hyper",
model: "claude-opus-4-20250514",
}),
];
const catalog = createCatalog([
{ provider: "anthropic", available: true, models: [] },
]);
const sameTypeProviders = new Map([
[
"prov-anthropic-primary",
{ provider: "anthropic", displayName: "Anthropic", icon: "" },
],
[
"prov-anthropic-hyper",
{
provider: "anthropic",
displayName: "Hyper",
icon: "/icon/coder.svg",
},
],
]);
expect(
getModelOptionsFromConfigs(configs, catalog, sameTypeProviders),
).toEqual([
expect.objectContaining({
id: "config-primary",
providerId: "prov-anthropic-primary",
providerLabel: "Anthropic",
}),
expect.objectContaining({
id: "config-hyper",
providerId: "prov-anthropic-hyper",
providerLabel: "Hyper",
providerIcon: "/icon/coder.svg",
}),
]);
});
});
describe("providerInfoByIDFromConfigs", () => {
it("maps ChatProviderConfig.id to provider metadata", () => {
const map = providerInfoByIDFromConfigs([
{
...MockChatProviderConfig,
id: "prov-openai",
provider: "openai",
display_name: "Primary OpenAI",
icon: "/icon/openai.svg",
},
]);
expect(map.get("prov-openai")).toEqual({
provider: "openai",
displayName: "Primary OpenAI",
icon: "/icon/openai.svg",
});
expect(map.size).toBe(1);
});
});
describe("providerInfoByIDFromUserConfigs", () => {
it("maps UserChatProviderConfig.provider_id to provider metadata", () => {
const map = providerInfoByIDFromUserConfigs([
{
provider_id: "prov-openai",
provider: "openai",
display_name: "Primary OpenAI",
icon: "/icon/openai.svg",
has_user_api_key: false,
has_central_api_key_fallback: true,
byok_enabled: true,
},
]);
expect(map.get("prov-openai")).toEqual({
provider: "openai",
displayName: "Primary OpenAI",
icon: "/icon/openai.svg",
});
expect(map.size).toBe(1);
});
});
describe("providerTypeByIDFromConfigs", () => {
@@ -584,6 +668,7 @@ describe("providerTypeByIDFromUserConfigs", () => {
provider_id: "prov-openai",
provider: "openai",
display_name: "OpenAI",
icon: "",
has_user_api_key: false,
has_central_api_key_fallback: true,
byok_enabled: true,
@@ -660,6 +745,7 @@ describe("resolveModelSelector", () => {
provider_id: "prov-openai",
provider: "openai",
display_name: "OpenAI",
icon: "",
has_user_api_key: false,
has_central_api_key_fallback: true,
byok_enabled: true,
@@ -693,6 +779,9 @@ describe("resolveModelSelector", () => {
{
id: "config-openai",
provider: "openai",
providerId: "prov-openai",
providerLabel: "OpenAI",
providerIcon: "",
model: "gpt-4o",
displayName: "GPT-4o",
contextLimit: 128_000,
+60 -19
View File
@@ -158,18 +158,55 @@ export const resolveModelOptionId = (
return "";
};
// providerTypeByIDFromConfigs and providerTypeByIDFromUserConfigs build
// the ai_provider_id -> provider-type lookup that getModelOptionsFromConfigs
// needs. The admin and user provider endpoints expose the provider id under
// different field names (id vs provider_id), so each source has its own
// helper to bake in the correct field and keep callers from mixing them up.
export type ProviderInfo = {
readonly provider: string;
readonly displayName: string;
readonly icon: string;
};
// providerInfoByIDFromConfigs and providerInfoByIDFromUserConfigs build
// the ai_provider_id -> provider metadata lookup that
// getModelOptionsFromConfigs needs. The admin and user provider endpoints
// expose the provider id under different field names (id vs provider_id), so
// each source has its own helper to bake in the correct field.
export const providerInfoByIDFromConfigs = (
providerConfigs: readonly TypesGen.ChatProviderConfig[] | null | undefined,
): ReadonlyMap<string, ProviderInfo> =>
new Map(
(providerConfigs ?? []).map((providerConfig) => [
providerConfig.id,
{
provider: providerConfig.provider,
displayName: providerConfig.display_name,
icon: providerConfig.icon,
},
]),
);
export const providerInfoByIDFromUserConfigs = (
providerConfigs:
| readonly TypesGen.UserChatProviderConfig[]
| null
| undefined,
): ReadonlyMap<string, ProviderInfo> =>
new Map(
(providerConfigs ?? []).map((providerConfig) => [
providerConfig.provider_id,
{
provider: providerConfig.provider,
displayName: providerConfig.display_name,
icon: providerConfig.icon,
},
]),
);
export const providerTypeByIDFromConfigs = (
providerConfigs: readonly TypesGen.ChatProviderConfig[] | null | undefined,
): ReadonlyMap<string, string> =>
new Map(
(providerConfigs ?? []).map((providerConfig) => [
providerConfig.id,
providerConfig.provider,
Array.from(providerInfoByIDFromConfigs(providerConfigs), ([id, info]) => [
id,
info.provider,
]),
);
@@ -180,16 +217,16 @@ export const providerTypeByIDFromUserConfigs = (
| undefined,
): ReadonlyMap<string, string> =>
new Map(
(providerConfigs ?? []).map((providerConfig) => [
providerConfig.provider_id,
providerConfig.provider,
]),
Array.from(
providerInfoByIDFromUserConfigs(providerConfigs),
([id, info]) => [id, info.provider],
),
);
export const getModelOptionsFromConfigs = (
configs: readonly TypesGen.ChatModelConfig[] | null | undefined,
catalog: TypesGen.ChatModelsResponse | null | undefined,
providerTypeByID: ReadonlyMap<string, string>,
providerInfoByID: ReadonlyMap<string, ProviderInfo>,
): readonly ModelSelectorOption[] => {
if (!configs || !catalog) {
return [];
@@ -204,11 +241,10 @@ export const getModelOptionsFromConfigs = (
}
const configID = config.id.trim();
const provider = asString(providerTypeByID.get(config.ai_provider_id))
.trim()
.toLowerCase();
const providerInfo = providerInfoByID.get(config.ai_provider_id);
const provider = asString(providerInfo?.provider).trim().toLowerCase();
const model = config.model.trim();
if (!configID || !provider || !model) {
if (!configID || !providerInfo || !provider || !model) {
continue;
}
if (!availableProviders.has(provider)) {
@@ -220,6 +256,9 @@ export const getModelOptionsFromConfigs = (
options.push({
id: configID,
provider,
providerId: config.ai_provider_id,
providerLabel: providerInfo.displayName,
providerIcon: providerInfo.icon,
model,
displayName,
...(contextLimit !== undefined ? { contextLimit } : {}),
@@ -227,7 +266,9 @@ export const getModelOptionsFromConfigs = (
}
return options.sort((a, b) => {
const providerCompare = a.provider.localeCompare(b.provider);
const providerCompare = (a.providerLabel ?? a.provider).localeCompare(
b.providerLabel ?? b.provider,
);
if (providerCompare !== 0) {
return providerCompare;
}
@@ -264,7 +305,7 @@ export const resolveModelSelector = (
options: getModelOptionsFromConfigs(
modelConfigs.data,
catalog.data,
providerTypeByIDFromUserConfigs(userProviderConfigs.data),
providerInfoByIDFromUserConfigs(userProviderConfigs.data),
),
isModelCatalogLoading:
modelConfigs.isLoading ||
+1
View File
@@ -22,6 +22,7 @@ export const MockChatProviderConfig: ChatProviderConfig = {
id: "provider-1",
provider: "openai",
display_name: "OpenAI",
icon: "",
enabled: true,
has_api_key: true,
central_api_key_enabled: true,
+4
View File
@@ -5547,6 +5547,7 @@ export const MockAIProviderOpenAI: TypesGen.AIProvider = {
type: "openai",
name: "openai",
display_name: "OpenAI",
icon: "",
base_url: "https://api.openai.com",
enabled: false,
api_keys: [
@@ -5566,6 +5567,7 @@ export const MockAIProviderAnthropic: TypesGen.AIProvider = {
type: "anthropic",
name: "anthropic",
display_name: "Anthropic",
icon: "",
base_url: "https://api.anthropic.com",
enabled: false,
api_keys: [],
@@ -5585,6 +5587,7 @@ export const MockAIProviderBedrock: TypesGen.AIProvider = {
type: "bedrock",
name: "bedrock",
display_name: "Bedrock",
icon: "",
base_url: "https://bedrock-runtime.us-east-2.amazonaws.com",
enabled: true,
api_keys: [],
@@ -5604,6 +5607,7 @@ export const MockAIProviderCopilot: TypesGen.AIProvider = {
type: "copilot",
name: "copilot",
display_name: "GitHub Copilot",
icon: "",
base_url: "https://api.business.githubcopilot.com",
enabled: true,
api_keys: [],