mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add custom AI provider icons and instance-based model picker grouping (#27026)
This commit is contained in:
@@ -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,
|
||||
|
||||
Generated
+8
@@ -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}
|
||||
|
||||
+16
-17
@@ -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) => {
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user