feat: move model settings page to ai settings

This commit is contained in:
Danielle Maywood
2026-06-23 12:33:28 +01:00
committed by GitHub
parent e044a4265d
commit ecfff8a7db
53 changed files with 3166 additions and 5445 deletions
+2
View File
@@ -23,6 +23,8 @@ export interface FieldSchema {
enum?: string[];
/** If true, this field should not be rendered in admin UI forms. */
hidden?: boolean;
visible_when?: string;
conflicts_with?: string[];
}
/**
+10 -4
View File
@@ -145,7 +145,9 @@
"description": "Restrict web search to these domains (cannot be used with blocked_domains)",
"label": "Web Search: Allowed Domains",
"required": false,
"input_type": "json"
"input_type": "json",
"visible_when": "web_search_enabled",
"conflicts_with": ["blocked_domains"]
},
{
"json_name": "blocked_domains",
@@ -154,7 +156,9 @@
"description": "Block web search on these domains (cannot be used with allowed_domains)",
"label": "Web Search: Blocked Domains",
"required": false,
"input_type": "json"
"input_type": "json",
"visible_when": "web_search_enabled",
"conflicts_with": ["allowed_domains"]
}
]
},
@@ -404,7 +408,8 @@
"description": "Amount of search context to use",
"required": false,
"enum": ["low", "medium", "high"],
"input_type": "select"
"input_type": "select",
"visible_when": "web_search_enabled"
},
{
"json_name": "allowed_domains",
@@ -413,7 +418,8 @@
"description": "Restrict web search to these domains",
"label": "Web Search: Allowed Domains",
"required": false,
"input_type": "json"
"input_type": "json",
"visible_when": "web_search_enabled"
}
]
},
-78
View File
@@ -10,9 +10,7 @@ import {
type CreateChatMessageRequestWithClearablePlanMode,
} from "#/api/api";
import type * as TypesGen from "#/api/typesGenerated";
import { type AIProviderType, AIProviderTypes } from "#/api/typesGenerated";
import type { UsePaginatedQueryOptions } from "#/hooks/usePaginatedQuery";
import { formatProviderLabel } from "#/utils/aiProviders";
import {
projectEditedConversationIntoCache,
reconcileEditedMessageInCache,
@@ -1797,82 +1795,6 @@ const invalidateChatConfigurationQueries = async (queryClient: QueryClient) => {
]);
};
const generatedAIProviderName = (provider: string): string => {
const suffix =
globalThis.crypto?.randomUUID?.() ??
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
return `${provider}-${suffix}`;
};
const normalizeAIProviderType = (provider: string): AIProviderType => {
const normalized = provider.trim().toLowerCase();
const aliased =
normalized === "openai-compatible" || normalized === "openai_compatible"
? "openai-compat"
: normalized;
const providerType = AIProviderTypes.find(
(candidate) => candidate === aliased,
);
if (!providerType) {
throw new Error(`Unsupported AI provider type "${provider}".`);
}
return providerType;
};
export const createChatProviderConfig = (queryClient: QueryClient) => ({
mutationFn: (req: TypesGen.CreateChatProviderConfigRequest) => {
const providerType = normalizeAIProviderType(req.provider);
const apiKey = req.api_key;
return API.experimental.createAIProvider({
type: providerType,
name: generatedAIProviderName(providerType),
display_name: req.display_name || formatProviderLabel(providerType),
base_url: req.base_url ?? "",
enabled: req.enabled ?? true,
api_keys: apiKey ? [apiKey] : undefined,
});
},
onSuccess: async () => {
await invalidateChatConfigurationQueries(queryClient);
},
});
type UpdateChatProviderConfigMutationArgs = {
providerConfigId: string;
req: TypesGen.UpdateChatProviderConfigRequest;
};
export const updateChatProviderConfig = (queryClient: QueryClient) => ({
mutationFn: async ({
providerConfigId,
req,
}: UpdateChatProviderConfigMutationArgs) => {
const apiKey = req.api_key;
return API.experimental.updateAIProvider(providerConfigId, {
display_name: req.display_name,
base_url: req.base_url,
enabled: req.enabled,
api_keys:
req.api_key === undefined
? undefined
: apiKey
? [{ api_key: apiKey }]
: [],
});
},
onSuccess: async () => {
await invalidateChatConfigurationQueries(queryClient);
},
});
export const deleteChatProviderConfig = (queryClient: QueryClient) => ({
mutationFn: (providerConfigId: string) =>
API.experimental.deleteAIProvider(providerConfigId),
onSuccess: async () => {
await invalidateChatConfigurationQueries(queryClient);
},
});
export const createChatModelConfig = (queryClient: QueryClient) => ({
mutationFn: (req: TypesGen.CreateChatModelConfigRequest) =>
API.experimental.createChatModelConfig(req),
+15
View File
@@ -0,0 +1,15 @@
export function normalizeProvider(provider: string): string {
return provider.trim().toLowerCase();
}
const canonicalProviderBaseURLs: Record<string, string> = {
anthropic: "https://api.anthropic.com",
google: "https://generativelanguage.googleapis.com/v1beta",
openai: "https://api.openai.com/v1",
openrouter: "https://openrouter.ai/api/v1",
vercel: "https://ai-gateway.vercel.sh/v1",
};
export function getDefaultProviderBaseURL(provider: string): string {
return canonicalProviderBaseURLs[normalizeProvider(provider)] ?? "";
}
@@ -0,0 +1,282 @@
import { describe, expect, it } from "vitest";
import type * as TypesGen from "#/api/typesGenerated";
import {
MockChatModelConfig,
MockChatModelProvider,
MockChatProviderConfig,
} from "#/testHelpers/chatModels";
import {
canManageProviderModels,
deriveProviderStates,
type ProviderState,
resolveModelProviderKey,
} from "./providerStates";
const baseProviderState: ProviderState = {
key: "prov-openai",
provider: "openai",
label: "OpenAI",
providerConfig: MockChatProviderConfig,
modelConfigs: [],
catalogModelCount: 0,
hasManagedAPIKey: true,
hasCatalogAPIKey: false,
hasEffectiveAPIKey: true,
allowUserAPIKey: false,
isEnvPreset: false,
baseURL: "",
};
describe("deriveProviderStates", () => {
it("orders provider configs first, then catalog-only, then model-only providers", () => {
const providerConfigs = [
{
...MockChatProviderConfig,
id: "prov-anthropic",
provider: "anthropic",
display_name: "Anthropic",
},
];
const catalog: TypesGen.ChatModelsResponse = {
providers: [
{ ...MockChatModelProvider, provider: "anthropic" },
{ ...MockChatModelProvider, provider: "google" },
],
};
const modelConfigs = [
{ ...MockChatModelConfig, id: "m-vercel", provider: "vercel" },
];
const states = deriveProviderStates(modelConfigs, providerConfigs, catalog);
expect(states.map((s) => s.provider)).toEqual([
"anthropic",
"google",
"vercel",
]);
expect(states[0].key).toBe("prov-anthropic");
expect(states[1].key).toBe("google");
expect(states[2].key).toBe("vercel");
expect(states[0].hasEffectiveAPIKey).toBe(true);
expect(states[1].hasEffectiveAPIKey).toBe(true);
expect(states[2].hasEffectiveAPIKey).toBe(false);
});
it("matches model configs to provider configs by ai_provider_id", () => {
const providerConfigs = [
{ ...MockChatProviderConfig, id: "prov-openai", provider: "openai" },
];
const modelConfigs = [
{
...MockChatModelConfig,
id: "m1",
provider: "openai",
ai_provider_id: "prov-openai",
},
{ ...MockChatModelConfig, id: "m2", provider: "openai" },
];
const states = deriveProviderStates(modelConfigs, providerConfigs, null);
expect(states).toHaveLength(1);
expect(states[0].key).toBe("prov-openai");
expect(states[0].modelConfigs.map((m) => m.id)).toEqual(["m1", "m2"]);
});
it("treats bedrock with central_api_key_enabled as having an effective key", () => {
const providerConfigs = [
{
...MockChatProviderConfig,
id: "prov-bedrock",
provider: "bedrock",
has_api_key: false,
central_api_key_enabled: true,
},
];
const states = deriveProviderStates([], providerConfigs, null);
expect(states[0].hasEffectiveAPIKey).toBe(true);
});
it("drops models without ai_provider_id when multiple configs exist for the same provider", () => {
const providerConfigs = [
{ ...MockChatProviderConfig, id: "prov-a", provider: "openai" },
{ ...MockChatProviderConfig, id: "prov-b", provider: "openai" },
];
const modelConfigs = [
{ ...MockChatModelConfig, id: "m1", provider: "openai" },
];
const states = deriveProviderStates(modelConfigs, providerConfigs, null);
expect(states.flatMap((s) => s.modelConfigs)).toHaveLength(0);
});
it("detects env-preset providers from the catalog when no config exists", () => {
const catalog: TypesGen.ChatModelsResponse = {
providers: [
{ ...MockChatModelProvider, provider: "openai", available: true },
],
};
const states = deriveProviderStates([], null, catalog);
expect(states).toHaveLength(1);
expect(states[0].provider).toBe("openai");
expect(states[0].isEnvPreset).toBe(true);
expect(states[0].hasCatalogAPIKey).toBe(true);
});
it("flags env-preset providers via the provider config source", () => {
const providerConfigs = [
{
...MockChatProviderConfig,
id: "prov-openai",
provider: "openai",
source: "env_preset" as const,
},
];
const states = deriveProviderStates([], providerConfigs, null);
expect(states[0].isEnvPreset).toBe(true);
expect(states[0].providerConfig).toBeUndefined();
});
it("treats an unavailable catalog provider as having a key unless the api key is missing", () => {
const catalog: TypesGen.ChatModelsResponse = {
providers: [
{
...MockChatModelProvider,
provider: "openai",
available: false,
unavailable_reason: "fetch_failed",
},
],
};
const states = deriveProviderStates([], null, catalog);
expect(states[0].hasCatalogAPIKey).toBe(true);
});
it("derives label, catalogModelCount, and baseURL from the inputs", () => {
const providerConfigs = [
{
...MockChatProviderConfig,
id: "prov-openai",
provider: "openai",
display_name: "Custom OpenAI",
base_url: "https://custom.example.com/v1",
},
];
const catalog: TypesGen.ChatModelsResponse = {
providers: [
{
...MockChatModelProvider,
provider: "openai",
models: [
{
id: "gpt-x",
provider: "openai",
model: "gpt-x",
display_name: "GPT-X",
},
],
},
],
};
const states = deriveProviderStates([], providerConfigs, catalog);
expect(states[0].label).toBe("Custom OpenAI");
expect(states[0].catalogModelCount).toBe(1);
expect(states[0].baseURL).toBe("https://custom.example.com/v1");
});
});
describe("canManageProviderModels", () => {
const baseState = baseProviderState;
it("returns false without a managed provider config", () => {
expect(
canManageProviderModels({ ...baseState, providerConfig: undefined }),
).toBe(false);
});
it("returns true when the provider has an effective API key", () => {
expect(canManageProviderModels(baseState)).toBe(true);
});
it("returns true when user-supplied API keys are allowed", () => {
expect(
canManageProviderModels({
...baseState,
hasEffectiveAPIKey: false,
providerConfig: { ...MockChatProviderConfig, allow_user_api_key: true },
}),
).toBe(true);
});
it("returns false with no key and user keys disallowed", () => {
expect(
canManageProviderModels({
...baseState,
hasEffectiveAPIKey: false,
providerConfig: {
...MockChatProviderConfig,
allow_user_api_key: false,
},
}),
).toBe(false);
});
it("returns false for undefined provider state", () => {
expect(canManageProviderModels(undefined)).toBe(false);
});
});
describe("resolveModelProviderKey", () => {
const states: ProviderState[] = [
{ ...baseProviderState, key: "prov-a", provider: "openai" },
{ ...baseProviderState, key: "prov-b", provider: "openai" },
{ ...baseProviderState, key: "prov-anthropic", provider: "anthropic" },
];
it("prefers ai_provider_id when present", () => {
expect(
resolveModelProviderKey(
{ ...MockChatModelConfig, ai_provider_id: "prov-explicit" },
states,
),
).toBe("prov-explicit");
});
it("falls back to the single matching provider state key", () => {
expect(
resolveModelProviderKey(
{ ...MockChatModelConfig, provider: "anthropic" },
states,
),
).toBe("prov-anthropic");
});
it("returns an empty key when multiple states match the provider", () => {
expect(
resolveModelProviderKey(
{ ...MockChatModelConfig, provider: "openai" },
states,
),
).toBe("");
});
it("falls back to the provider name when no states match", () => {
expect(
resolveModelProviderKey(
{ ...MockChatModelConfig, provider: "google" },
states,
),
).toBe("google");
});
});
+237
View File
@@ -0,0 +1,237 @@
import type * as TypesGen from "#/api/typesGenerated";
import {
getDefaultProviderBaseURL,
normalizeProvider,
} from "#/modules/aiModels/helpers";
import { formatProviderLabel } from "#/utils/aiProviders";
export type ProviderState = {
key: string;
provider: string;
label: string;
providerConfig: TypesGen.ChatProviderConfig | undefined;
modelConfigs: readonly TypesGen.ChatModelConfig[];
catalogModelCount: number;
hasManagedAPIKey: boolean;
hasCatalogAPIKey: boolean;
hasEffectiveAPIKey: boolean;
allowUserAPIKey: boolean;
isEnvPreset: boolean;
baseURL: string;
};
type CatalogProvider = TypesGen.ChatModelsResponse["providers"][number];
const envPresetProviders = new Set(["openai", "anthropic"]);
const readOptionalString = (value: unknown): string | undefined => {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed || undefined;
};
const isDatabaseProviderConfig = (
providerConfig: TypesGen.ChatProviderConfig | undefined,
source: TypesGen.ChatProviderConfigSource | undefined,
): providerConfig is TypesGen.ChatProviderConfig => {
if (!providerConfig) return false;
if (providerConfig.id === "00000000-0000-0000-0000-000000000000") {
return false;
}
return source === undefined || source === "database";
};
const getCatalogProviders = (
catalog: TypesGen.ChatModelsResponse | null | undefined,
): readonly CatalogProvider[] => {
const providers = catalog?.providers;
return Array.isArray(providers) ? providers : [];
};
const providerHasCatalogAPIKey = (provider: CatalogProvider): boolean =>
provider.available ||
(Boolean(provider.unavailable_reason) &&
provider.unavailable_reason !== "missing_api_key");
const getProviderModels = (
provider: CatalogProvider | undefined,
): readonly CatalogProvider["models"][number][] => {
const models = provider?.models;
return Array.isArray(models) ? models : [];
};
const getProviderBaseURL = (
providerConfig: TypesGen.ChatProviderConfig | undefined,
): string => {
return (
readOptionalString(providerConfig?.base_url) ??
getDefaultProviderBaseURL(providerConfig?.provider ?? "")
);
};
const providerConfigStateKey = (
providerConfig: TypesGen.ChatProviderConfig,
): string => {
const providerID = readOptionalString(providerConfig.id);
if (providerID && providerID !== "00000000-0000-0000-0000-000000000000") {
return providerID;
}
return normalizeProvider(providerConfig.provider);
};
type ProviderEntry = {
key: string;
provider: string;
};
export const deriveProviderStates = (
modelConfigs: readonly TypesGen.ChatModelConfig[],
providerConfigs: TypesGen.ChatProviderConfig[] | null | undefined,
catalog: TypesGen.ChatModelsResponse | null | undefined,
): readonly ProviderState[] => {
const orderedEntries: ProviderEntry[] = [];
const seenEntries = new Set<string>();
const includeEntry = (keyValue: string, providerValue: string) => {
const key = readOptionalString(keyValue);
const provider = normalizeProvider(providerValue);
if (!key || !provider || seenEntries.has(key)) return;
seenEntries.add(key);
orderedEntries.push({ key, provider });
};
const catalogProviders = getCatalogProviders(catalog);
const catalogProvidersByProvider = new Map<string, CatalogProvider>();
for (const cp of catalogProviders) {
const provider = normalizeProvider(cp.provider);
if (!provider) continue;
catalogProvidersByProvider.set(provider, cp);
}
const providerConfigKeysByProvider = new Map<string, string[]>();
const providerTypesWithConfigs = new Set<string>();
for (const pc of providerConfigs ?? []) {
const provider = normalizeProvider(pc.provider);
if (!provider) continue;
const key = providerConfigStateKey(pc);
providerTypesWithConfigs.add(provider);
providerConfigKeysByProvider.set(provider, [
...(providerConfigKeysByProvider.get(provider) ?? []),
key,
]);
includeEntry(key, provider);
}
const modelStateKey = (modelConfig: TypesGen.ChatModelConfig): string => {
const aiProviderID = readOptionalString(modelConfig.ai_provider_id);
if (aiProviderID) {
return aiProviderID;
}
const provider = normalizeProvider(modelConfig.provider);
const providerConfigKeys = providerConfigKeysByProvider.get(provider) ?? [];
if (providerConfigKeys.length === 1) {
return providerConfigKeys[0];
}
return providerConfigKeys.length === 0 ? provider : "";
};
for (const cp of catalogProviders) {
const provider = normalizeProvider(cp.provider);
if (!provider || providerTypesWithConfigs.has(provider)) continue;
includeEntry(provider, provider);
}
for (const mc of modelConfigs) {
includeEntry(modelStateKey(mc), mc.provider);
}
const providerConfigsByKey = new Map<string, TypesGen.ChatProviderConfig>();
for (const pc of providerConfigs ?? []) {
const key = providerConfigStateKey(pc);
if (!key) continue;
providerConfigsByKey.set(key, pc);
}
const modelConfigsByKey = new Map<string, TypesGen.ChatModelConfig[]>();
for (const mc of modelConfigs) {
const key = modelStateKey(mc);
if (!key) continue;
const existing = modelConfigsByKey.get(key);
if (existing) {
existing.push(mc);
} else {
modelConfigsByKey.set(key, [mc]);
}
}
return orderedEntries.map(({ key, provider }) => {
const providerConfigEntry = providerConfigsByKey.get(key);
const providerConfigSource = providerConfigEntry?.source;
const providerConfig = isDatabaseProviderConfig(
providerConfigEntry,
providerConfigSource,
)
? providerConfigEntry
: undefined;
const catalogProvider = catalogProvidersByProvider.get(provider);
const hasManagedAPIKey = providerConfig?.has_api_key ?? false;
const hasProviderEntryAPIKey = providerConfigEntry?.has_api_key ?? false;
const hasCatalogAPIKey = catalogProvider
? providerHasCatalogAPIKey(catalogProvider)
: false;
const label =
readOptionalString(providerConfigEntry?.display_name) ??
formatProviderLabel(provider);
const hasBedrockAmbientCredentials =
provider === "bedrock" &&
providerConfig?.central_api_key_enabled === true;
const modelConfigsForProvider = modelConfigsByKey.get(key) ?? [];
const isCatalogEnvPreset =
!providerConfig && envPresetProviders.has(provider) && hasCatalogAPIKey;
const isEnvPreset =
providerConfigSource === "env_preset" || isCatalogEnvPreset;
return {
key,
provider,
label,
providerConfig,
modelConfigs: modelConfigsForProvider,
catalogModelCount: getProviderModels(catalogProvider).length,
hasManagedAPIKey,
hasCatalogAPIKey,
hasEffectiveAPIKey: providerConfigEntry
? hasProviderEntryAPIKey || hasBedrockAmbientCredentials
: hasManagedAPIKey || hasCatalogAPIKey,
allowUserAPIKey: providerConfigEntry?.allow_user_api_key ?? true,
isEnvPreset,
baseURL: getProviderBaseURL(providerConfigEntry),
};
});
};
export const canManageProviderModels = (
providerState: ProviderState | undefined,
): boolean => {
return Boolean(
providerState?.providerConfig &&
(providerState.hasEffectiveAPIKey ||
providerState.providerConfig.allow_user_api_key),
);
};
export const resolveModelProviderKey = (
modelConfig: TypesGen.ChatModelConfig,
providerStates: readonly ProviderState[],
): string => {
const providerID = readOptionalString(modelConfig.ai_provider_id);
if (providerID) {
return providerID;
}
const provider = normalizeProvider(modelConfig.provider);
const matches = providerStates.filter((s) => s.provider === provider);
if (matches.length === 1) {
return matches[0].key;
}
if (matches.length > 1) {
return "";
}
return provider;
};
@@ -32,6 +32,9 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
AI Gateway Keys
</SidebarNavItem>
)}
{permissions.editDeploymentConfig && (
<SidebarNavItem href="/ai/settings/models">Models</SidebarNavItem>
)}
{permissions.editDeploymentConfig && (
<SidebarNavItem href="/agents/settings/agents">
<div className="flex flex-row items-center gap-1">
@@ -0,0 +1,92 @@
import { type FC, useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate, useSearchParams } from "react-router";
import { toast } from "sonner";
import { getErrorMessage } from "#/api/errors";
import {
chatModelConfigs,
chatModels,
chatProviderConfigs,
createChatModelConfig,
} from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import {
canManageProviderModels,
deriveProviderStates,
} from "#/modules/aiModels/providerStates";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { pageTitle } from "#/utils/page";
import AddModelPageView from "./AddModelPageView";
const AddModelPage: FC = () => {
const { permissions } = useAuthenticated();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const providerKey = searchParams.get("provider") ?? "";
const duplicateId = searchParams.get("duplicate");
const providerConfigsQuery = useQuery(chatProviderConfigs());
const modelConfigsQuery = useQuery(chatModelConfigs());
const modelCatalogQuery = useQuery(chatModels());
const createMutation = useMutation(createChatModelConfig(queryClient));
const providerStates = useMemo(
() =>
deriveProviderStates(
modelConfigsQuery.data ?? [],
providerConfigsQuery.data,
modelCatalogQuery.data,
),
[modelConfigsQuery.data, providerConfigsQuery.data, modelCatalogQuery.data],
);
const isLoading =
providerConfigsQuery.isLoading ||
modelConfigsQuery.isLoading ||
modelCatalogQuery.isLoading;
const selectedProviderState = providerKey
? (providerStates.find((ps) => ps.key === providerKey) ?? null)
: (providerStates.find(canManageProviderModels) ?? null);
const duplicateSourceModel = duplicateId
? modelConfigsQuery.data?.find((m) => m.id === duplicateId)
: undefined;
const currentDefaultModel = modelConfigsQuery.data?.find((m) => m.is_default);
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<title>{pageTitle("Add model", "AI Settings")}</title>
<AddModelPageView
isLoading={isLoading}
providerStates={providerStates}
selectedProviderState={selectedProviderState}
duplicateSourceModel={duplicateSourceModel}
currentDefaultModel={currentDefaultModel}
isSaving={createMutation.isPending}
onProviderChange={(key) => {
const next = new URLSearchParams(searchParams);
next.set("provider", key);
void navigate(`/ai/settings/models/add?${next.toString()}`, {
replace: true,
});
}}
onCreateModel={async (req) => {
try {
const created = await createMutation.mutateAsync(req);
toast.success(
`Model "${created.display_name || created.model}" added.`,
);
await navigate(`/ai/settings/models/${created.id}`);
} catch (error) {
toast.error(getErrorMessage(error, "Failed to add model."));
}
}}
/>
</RequirePermission>
);
};
export default AddModelPage;
@@ -0,0 +1,67 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { withToaster } from "#/testHelpers/storybook";
import {
MockAnthropicProviderState,
MockOpenAIProviderState,
} from "../testFixtures";
import AddModelPageView from "./AddModelPageView";
const meta: Meta<typeof AddModelPageView> = {
title: "pages/AISettingsPage/ModelsPage/AddModelPageView",
component: AddModelPageView,
decorators: [withToaster],
args: {
isLoading: false,
providerStates: [MockOpenAIProviderState, MockAnthropicProviderState],
selectedProviderState: MockOpenAIProviderState,
isSaving: false,
onProviderChange: fn(),
onCreateModel: fn(async () => undefined),
},
};
export default meta;
type Story = StoryObj<typeof AddModelPageView>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("heading", { name: /add an? OpenAI model/i }),
).toBeInTheDocument();
},
};
export const WebSearchDependentFields: Story = {
args: { selectedProviderState: MockAnthropicProviderState },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByRole("button", { name: /provider configuration/i }),
);
expect(canvas.queryByLabelText(/allowed domains/i)).not.toBeInTheDocument();
const webSearchField = canvas.getByRole("radiogroup", {
name: /web search enabled/i,
});
await userEvent.click(
within(webSearchField).getByRole("radio", { name: /on/i }),
);
const allowed = await canvas.findByLabelText(/allowed domains/i);
const blocked = await canvas.findByLabelText(/blocked domains/i);
await userEvent.type(allowed, "example.com");
expect(blocked).toBeDisabled();
},
};
export const ProviderNotFound: Story = {
args: { selectedProviderState: null },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Provider not found")).toBeInTheDocument();
},
};
export const Loading: Story = {
args: { isLoading: true },
};
@@ -0,0 +1,73 @@
import { ArrowLeftIcon } from "lucide-react";
import type { FC } from "react";
import { Link } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import { Loader } from "#/components/Loader/Loader";
import type { ProviderState } from "#/modules/aiModels/providerStates";
import { ModelForm } from "../components/ModelForm";
interface AddModelPageViewProps {
isLoading: boolean;
providerStates: readonly ProviderState[];
selectedProviderState: ProviderState | null;
duplicateSourceModel?: TypesGen.ChatModelConfig;
currentDefaultModel?: TypesGen.ChatModelConfig;
isSaving: boolean;
onProviderChange: (providerKey: string) => void;
onCreateModel: (
req: TypesGen.CreateChatModelConfigRequest,
) => Promise<unknown>;
}
const AddModelPageView: FC<AddModelPageViewProps> = ({
isLoading,
providerStates,
selectedProviderState,
duplicateSourceModel,
currentDefaultModel,
isSaving,
onProviderChange,
onCreateModel,
}) => {
if (isLoading) {
return <Loader fullscreen />;
}
if (!selectedProviderState) {
return (
<div className="flex flex-col items-start gap-4">
<Link to="/ai/settings/models" className="-ml-3">
<Button variant="subtle">
<ArrowLeftIcon />
<span>Back to models</span>
</Button>
</Link>
<Alert severity="warning">
<AlertTitle>Provider not found</AlertTitle>
<AlertDescription>
The provider you are trying to add a model for is not available.
Please try again.
</AlertDescription>
</Alert>
</div>
);
}
return (
<ModelForm
duplicateSourceModel={duplicateSourceModel}
currentDefaultModel={currentDefaultModel}
providerStates={providerStates}
selectedProviderState={selectedProviderState}
onProviderChange={onProviderChange}
isSaving={isSaving}
isDeleting={false}
onCreateModel={onCreateModel}
onUpdateModel={async () => {}}
/>
);
};
export default AddModelPageView;
@@ -0,0 +1,56 @@
import type { FC } from "react";
import { useQuery } from "react-query";
import {
chatModelConfigs,
chatModels,
chatProviderConfigs,
} from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { deriveProviderStates } from "#/modules/aiModels/providerStates";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { pageTitle } from "#/utils/page";
import ModelsPageView from "./ModelsPageView";
const ModelsPage: FC = () => {
const { permissions } = useAuthenticated();
const providerConfigsQuery = useQuery({
...chatProviderConfigs(),
enabled: permissions.editDeploymentConfig,
});
const modelConfigsQuery = useQuery(chatModelConfigs());
const modelCatalogQuery = useQuery(chatModels());
const models = (modelConfigsQuery.data ?? []).slice().sort((a, b) => {
const cmp = a.provider.localeCompare(b.provider);
return cmp !== 0 ? cmp : a.model.localeCompare(b.model);
});
const providerStates = deriveProviderStates(
models,
providerConfigsQuery.data,
modelCatalogQuery.data,
);
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<title>{pageTitle("Models", "AI Settings")}</title>
<ModelsPageView
isLoading={
providerConfigsQuery.isLoading ||
modelConfigsQuery.isLoading ||
modelCatalogQuery.isLoading
}
error={
providerConfigsQuery.error ??
modelConfigsQuery.error ??
modelCatalogQuery.error
}
models={models}
providerStates={providerStates}
/>
</RequirePermission>
);
};
export default ModelsPage;
@@ -0,0 +1,99 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import type { ChatModelConfig } from "#/api/typesGenerated";
import ModelsPageView from "./ModelsPageView";
import {
MockAnthropicProviderState,
MockOpenAIProviderState,
mockClaude,
mockDisabledModel,
mockGPT5,
} from "./testFixtures";
const meta: Meta<typeof ModelsPageView> = {
title: "pages/AISettingsPage/ModelsPage/ModelsPageView",
component: ModelsPageView,
args: {
isLoading: false,
error: null,
models: [mockGPT5, mockClaude, mockDisabledModel],
providerStates: [MockOpenAIProviderState, MockAnthropicProviderState],
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/ai/settings/models" },
routing: [
{ path: "/ai/settings/models", useStoryElement: true },
{ path: "/ai/settings/models/add", useStoryElement: true },
{ path: "/ai/settings/models/:modelId", useStoryElement: true },
],
}),
},
};
export default meta;
type Story = StoryObj<typeof ModelsPageView>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("button", { name: /add model/i }),
).toBeInTheDocument();
await expect(canvas.getByText("GPT-5")).toBeInTheDocument();
await expect(canvas.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
await expect(canvas.getAllByText("OpenAI").length).toBeGreaterThan(0);
await expect(canvas.getByText("Anthropic")).toBeInTheDocument();
await expect(canvas.getAllByText("Enabled").length).toBeGreaterThan(0);
await expect(canvas.getByText("Default")).toBeInTheDocument();
await expect(canvas.getByText("Disabled")).toBeInTheDocument();
},
};
export const Loading: Story = {
args: {
isLoading: true,
models: [],
},
};
export const Empty: Story = {
args: {
models: [],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("No models configured")).toBeInTheDocument();
},
};
export const LoadError: Story = {
args: {
error: new Error("Failed to load models"),
models: [],
},
};
const manyModels: ChatModelConfig[] = Array.from({ length: 23 }, (_, i) => ({
...mockClaude,
id: `model-${i}`,
model: `model-${i}`,
display_name: `Model ${i}`,
is_default: false,
}));
export const Paginated: Story = {
args: {
models: manyModels,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Model 0")).toBeInTheDocument();
await expect(canvas.queryByText("Model 10")).not.toBeInTheDocument();
await expect(canvas.getByText(/Showing/)).toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: /next page/i }));
await expect(canvas.getByText("Model 10")).toBeInTheDocument();
await expect(canvas.queryByText("Model 0")).not.toBeInTheDocument();
},
};
@@ -0,0 +1,178 @@
import { ChevronDownIcon, PlusIcon } from "lucide-react";
import { type FC, useState } from "react";
import { useNavigate } from "react-router";
import type { ChatModelConfig } from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "#/components/Table/Table";
import { TableEmpty } from "#/components/TableEmpty/TableEmpty";
import { TableLoader } from "#/components/TableLoader/TableLoader";
import {
canManageProviderModels,
type ProviderState,
} from "#/modules/aiModels/providerStates";
import { ProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon";
import { paginateItems } from "#/utils/paginateItems";
import { ModelRow } from "./components/ModelRow";
const MODELS_PAGE_SIZE = 10;
const AddModelDropdown: FC<{ providerStates: readonly ProviderState[] }> = ({
providerStates,
}) => {
const navigate = useNavigate();
const manageableProviderStates = providerStates.filter(
canManageProviderModels,
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<PlusIcon />
<span>Add model</span>
<ChevronDownIcon className="ml-1 size-icon-xs" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-56">
<div className="px-2 py-1.5 text-xs font-medium text-content-secondary">
Select a provider
</div>
{manageableProviderStates.length === 0 ? (
<DropdownMenuItem disabled>No providers available</DropdownMenuItem>
) : (
manageableProviderStates.map((providerState) => (
<DropdownMenuItem
key={providerState.key}
onSelect={() =>
void navigate(
`/ai/settings/models/add?provider=${encodeURIComponent(
providerState.key,
)}`,
)
}
>
<ProviderIcon provider={providerState.provider} />
<span>{providerState.label}</span>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
);
};
interface ModelsPageViewProps {
isLoading: boolean;
error: unknown;
models: readonly ChatModelConfig[];
providerStates: readonly ProviderState[];
}
const ModelsPageView: FC<ModelsPageViewProps> = ({
isLoading,
error,
models,
providerStates,
}) => {
const navigate = useNavigate();
const [page, setPage] = useState(1);
const { pagedItems, clampedPage, hasPreviousPage, hasNextPage } =
paginateItems(models, MODELS_PAGE_SIZE, page);
const providerLabelByModelId = new Map<string, string>();
for (const providerState of providerStates) {
for (const providerModel of providerState.modelConfigs) {
providerLabelByModelId.set(providerModel.id, providerState.label);
}
}
return (
<div>
<SettingsHeader
actions={<AddModelDropdown providerStates={providerStates} />}
>
<SettingsHeaderTitle>Models</SettingsHeaderTitle>
<SettingsHeaderDescription>
Choose which models from your configured providers are available for
users to select. You can set a default and adjust context limits.
</SettingsHeaderDescription>
</SettingsHeader>
{Boolean(error) && (
<div className="mb-4">
<ErrorAlert error={error} />
</div>
)}
<Table className="table-fixed" aria-label="Models">
<TableHeader>
<TableRow>
<TableHead className="w-1/3">Name</TableHead>
<TableHead className="w-1/4">Provider</TableHead>
<TableHead className="w-1/4">Context limit</TableHead>
<TableHead className="w-40">Status</TableHead>
<TableHead className="w-12">
<span className="sr-only">Open model</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableLoader />
) : models.length === 0 ? (
<TableEmpty
message="No models configured"
description="Configured models will appear here."
/>
) : (
pagedItems.map((model) => (
<ModelRow
key={model.id}
model={model}
providerLabel={providerLabelByModelId.get(model.id) ?? ""}
onClick={() => void navigate(`/ai/settings/models/${model.id}`)}
/>
))
)}
</TableBody>
</Table>
{models.length > 0 && (
<div className="flex items-center justify-between pt-4">
<div className="flex-1">
<PaginationWidgetBase
currentPage={clampedPage}
pageSize={MODELS_PAGE_SIZE}
totalRecords={models.length}
onPageChange={setPage}
hasPreviousPage={hasPreviousPage}
hasNextPage={hasNextPage}
/>
</div>
<span className="text-xs text-content-secondary">
Showing <strong className="font-medium">{pagedItems.length}</strong>{" "}
of <strong className="font-medium">{models.length}</strong> models
</span>
</div>
)}
</div>
);
};
export default ModelsPageView;
@@ -0,0 +1,140 @@
import { type FC, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Navigate, useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import { getErrorMessage } from "#/api/errors";
import {
chatModelConfigs,
chatModels,
chatProviderConfigs,
deleteChatModelConfig,
updateChatModelConfig,
} from "#/api/queries/chats";
import { Loader } from "#/components/Loader/Loader";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { deriveProviderStates } from "#/modules/aiModels/providerStates";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { pageTitle } from "#/utils/page";
import UpdateModelPageView from "./UpdateModelPageView";
const UpdateModelPage: FC = () => {
const { permissions } = useAuthenticated();
const { modelId } = useParams<{ modelId: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const providerConfigsQuery = useQuery(chatProviderConfigs());
const modelConfigsQuery = useQuery(chatModelConfigs());
const modelCatalogQuery = useQuery(chatModels());
const updateMutation = useMutation(updateChatModelConfig(queryClient));
const deleteMutation = useMutation(deleteChatModelConfig(queryClient));
const providerStates = useMemo(
() =>
deriveProviderStates(
modelConfigsQuery.data ?? [],
providerConfigsQuery.data,
modelCatalogQuery.data,
),
[modelConfigsQuery.data, providerConfigsQuery.data, modelCatalogQuery.data],
);
const isLoading =
providerConfigsQuery.isLoading ||
modelConfigsQuery.isLoading ||
modelCatalogQuery.isLoading;
const model = modelConfigsQuery.data?.find((m) => m.id === modelId);
const currentDefaultModel = modelConfigsQuery.data?.find((m) => m.is_default);
const [providerKeyOverride, setProviderKeyOverride] = useState<string | null>(
null,
);
const selectedProviderState =
(providerKeyOverride
? providerStates.find((ps) => ps.key === providerKeyOverride)
: undefined) ??
providerStates.find((ps) =>
ps.modelConfigs.some((m) => m.id === modelId),
) ??
null;
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
{!modelId ? (
<Navigate to="/ai/settings/models" replace />
) : isLoading ? (
<>
<title>{pageTitle("Loading...", "AI Settings")}</title>
<Loader fullscreen />
</>
) : !model ? (
<Navigate to="/ai/settings/models" replace />
) : (
<UpdateModelPageView
model={model}
currentDefaultModel={currentDefaultModel}
providerStates={providerStates}
selectedProviderState={selectedProviderState}
onProviderChange={setProviderKeyOverride}
isSaving={updateMutation.isPending}
isDeleting={deleteMutation.isPending}
onUpdateModel={async (id, req) => {
try {
const updated = await updateMutation.mutateAsync({
modelConfigId: id,
req,
});
toast.success(
`Model "${updated.display_name || updated.model}" updated.`,
);
await navigate("/ai/settings/models");
} catch (error) {
toast.error(getErrorMessage(error, "Failed to update model."));
}
}}
onDeleteModel={async (id) => {
try {
await deleteMutation.mutateAsync(id);
toast.success(
`Model "${model.display_name || model.model}" deleted.`,
);
await navigate("/ai/settings/models", { replace: true });
} catch (error) {
toast.error(getErrorMessage(error, "Failed to delete model."));
}
}}
onDuplicate={() => {
if (!selectedProviderState) return;
void navigate(
`/ai/settings/models/add?provider=${encodeURIComponent(
selectedProviderState.key,
)}&duplicate=${encodeURIComponent(model.id)}`,
);
}}
onToggleEnabled={(enabled) => {
updateMutation.mutate(
{ modelConfigId: model.id, req: { enabled } },
{
onSuccess: () => {
toast.success(
`Model "${model.display_name || model.model}" ${
enabled ? "enabled" : "disabled"
}.`,
);
},
onError: (error) => {
toast.error(
getErrorMessage(error, "Failed to update model."),
);
},
},
);
}}
/>
)}
</RequirePermission>
);
};
export default UpdateModelPage;
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, within } from "storybook/test";
import { withToaster } from "#/testHelpers/storybook";
import {
MockAnthropicProviderState,
MockOpenAIProviderState,
mockGPT5,
} from "../testFixtures";
import UpdateModelPageView from "./UpdateModelPageView";
const meta: Meta<typeof UpdateModelPageView> = {
title: "pages/AISettingsPage/ModelsPage/UpdateModelPageView",
component: UpdateModelPageView,
decorators: [withToaster],
args: {
model: mockGPT5,
providerStates: [MockOpenAIProviderState, MockAnthropicProviderState],
selectedProviderState: MockOpenAIProviderState,
onProviderChange: fn(),
isSaving: false,
isDeleting: false,
onUpdateModel: fn(async () => undefined),
onDeleteModel: fn(async () => undefined),
onDuplicate: fn(),
onToggleEnabled: fn(),
},
};
export default meta;
type Story = StoryObj<typeof UpdateModelPageView>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("button", { name: /^update model$/i }),
).toBeVisible();
await expect(canvas.getByLabelText(/model identifier/i)).toBeEnabled();
},
};
@@ -0,0 +1,61 @@
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import type { ProviderState } from "#/modules/aiModels/providerStates";
import { pageTitle } from "#/utils/page";
import { ModelForm } from "../components/ModelForm";
interface UpdateModelPageViewProps {
model: TypesGen.ChatModelConfig;
currentDefaultModel?: TypesGen.ChatModelConfig;
providerStates: readonly ProviderState[];
selectedProviderState: ProviderState | null;
onProviderChange: (providerKey: string) => void;
isSaving: boolean;
isDeleting: boolean;
onUpdateModel: (
modelConfigId: string,
req: TypesGen.UpdateChatModelConfigRequest,
) => Promise<unknown>;
onDeleteModel: (modelConfigId: string) => Promise<void>;
onDuplicate: () => void;
onToggleEnabled: (enabled: boolean) => void;
}
const UpdateModelPageView: FC<UpdateModelPageViewProps> = ({
model,
currentDefaultModel,
providerStates,
selectedProviderState,
onProviderChange,
isSaving,
isDeleting,
onUpdateModel,
onDeleteModel,
onDuplicate,
onToggleEnabled,
}) => {
return (
<>
<title>
{pageTitle(model.display_name || model.model, "AI Settings")}
</title>
<ModelForm
key={model.id}
editingModel={model}
currentDefaultModel={currentDefaultModel}
providerStates={providerStates}
selectedProviderState={selectedProviderState}
onProviderChange={onProviderChange}
isSaving={isSaving}
isDeleting={isDeleting}
onCreateModel={async () => {}}
onUpdateModel={onUpdateModel}
onDeleteModel={onDeleteModel}
onDuplicate={onDuplicate}
onToggleEnabled={onToggleEnabled}
/>
</>
);
};
export default UpdateModelPageView;
@@ -0,0 +1,201 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, screen, userEvent, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { withToaster } from "#/testHelpers/storybook";
import {
MockAnthropicProviderState,
MockOpenAIProviderState,
mockGPT5,
} from "../testFixtures";
import { ModelForm } from "./ModelForm";
const meta: Meta<typeof ModelForm> = {
title: "pages/AISettingsPage/ModelsPage/ModelForm",
component: ModelForm,
decorators: [withToaster],
args: {
providerStates: [MockOpenAIProviderState, MockAnthropicProviderState],
selectedProviderState: MockOpenAIProviderState,
onProviderChange: fn(),
isSaving: false,
isDeleting: false,
onCreateModel: fn(async () => undefined),
onUpdateModel: fn(async () => undefined),
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/ai/settings/models/add" },
routing: [
{ path: "/ai/settings/models/add", useStoryElement: true },
{ path: "/ai/settings/models", element: <div>Models</div> },
],
}),
},
};
export default meta;
type Story = StoryObj<typeof ModelForm>;
export const Add: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("heading", { name: /add an? OpenAI model/i }),
).toBeInTheDocument();
await expect(
canvas.getByRole("checkbox", { name: /set as default model/i }),
).toBeInTheDocument();
const submit = canvas.getByRole("button", { name: /add model/i });
await expect(submit).toBeDisabled();
},
};
export const AddValidSubmit: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const modelInput = canvas.getByLabelText(/model identifier/i);
await userEvent.type(modelInput, "gpt-5");
const contextLimit = canvas.getByLabelText(/context limit/i);
await userEvent.type(contextLimit, "200000");
const submit = canvas.getByRole("button", { name: /add model/i });
await expect(submit).toBeEnabled();
await userEvent.click(submit);
await expect(args.onCreateModel).toHaveBeenCalledTimes(1);
},
};
export const AddSetAsDefault: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText(/model identifier/i), "gpt-5");
await userEvent.type(canvas.getByLabelText(/context limit/i), "200000");
await userEvent.click(
canvas.getByRole("checkbox", { name: /set as default model/i }),
);
await userEvent.click(canvas.getByRole("button", { name: /add model/i }));
await expect(args.onCreateModel).toHaveBeenCalledTimes(1);
await expect(args.onCreateModel).toHaveBeenCalledWith(
expect.objectContaining({ is_default: true }),
);
},
};
export const LeaveWithUnsavedChanges: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText(/model identifier/i), "gpt-5");
await userEvent.click(
canvas.getByRole("link", { name: /back to models/i }),
);
const dialog = await screen.findByRole("dialog", {
name: /unsaved changes/i,
});
await expect(dialog).toBeInTheDocument();
},
};
export const ReplaceDefaultWarning: Story = {
args: {
currentDefaultModel: { ...mockGPT5, is_default: true },
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.type(
canvas.getByLabelText(/model identifier/i),
"gpt-5-mini",
);
await userEvent.type(canvas.getByLabelText(/context limit/i), "200000");
await userEvent.click(
canvas.getByRole("checkbox", { name: /set as default model/i }),
);
await userEvent.click(canvas.getByRole("button", { name: /add model/i }));
await expect(
await screen.findByText(/replace default model/i),
).toBeInTheDocument();
await expect(args.onCreateModel).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: /^confirm$/i }));
await expect(args.onCreateModel).toHaveBeenCalledWith(
expect.objectContaining({ is_default: true }),
);
},
};
export const Edit: Story = {
args: {
editingModel: mockGPT5,
onDeleteModel: fn(async () => undefined),
onDuplicate: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("button", { name: /^update model$/i }),
).toBeVisible();
await expect(
canvas.getByRole("button", { name: /^cancel$/i }),
).toBeVisible();
await expect(
canvas.getByRole("checkbox", { name: /set as default model/i }),
).toBeInTheDocument();
await expect(canvas.getByLabelText(/model identifier/i)).toBeEnabled();
await expect(
canvas.getByRole("combobox", { name: /provider/i }),
).toBeEnabled();
await userEvent.click(
canvas.getByRole("button", { name: /model actions/i }),
);
await expect(
screen.getByRole("menuitem", { name: /duplicate model/i }),
).toBeInTheDocument();
await expect(
screen.getByRole("menuitem", { name: /delete/i }),
).toBeInTheDocument();
},
};
export const EditDefaultBadge: Story = {
args: {
editingModel: { ...mockGPT5, is_default: true, enabled: true },
onDeleteModel: fn(async () => undefined),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText(/^default$/i)).toBeInTheDocument();
},
};
export const EditDisabledBadge: Story = {
args: {
editingModel: { ...mockGPT5, is_default: false, enabled: false },
onDeleteModel: fn(async () => undefined),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText(/^disabled$/i)).toBeInTheDocument();
},
};
export const EditSaveSubmits: Story = {
args: {
editingModel: mockGPT5,
onDeleteModel: fn(async () => undefined),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const save = canvas.getByRole("button", { name: /^update model$/i });
await userEvent.click(save);
await expect(args.onUpdateModel).toHaveBeenCalledTimes(1);
},
};
export const CostTrackingExpanded: Story = {
args: {
editingModel: mockGPT5,
onDeleteModel: fn(async () => undefined),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = canvas.getByRole("button", { name: /cost tracking/i });
await userEvent.click(toggle);
},
};
@@ -0,0 +1,349 @@
import { useFormik } from "formik";
import { type FC, useRef, useState } from "react";
import * as Yup from "yup";
import type * as TypesGen from "#/api/typesGenerated";
import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader";
import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt";
import {
canManageProviderModels,
type ProviderState,
} from "#/modules/aiModels/providerStates";
import {
buildInitialModelFormValues,
buildModelConfigFromForm,
type ModelFormValues,
parsePositiveInteger,
parseThresholdInteger,
} from "#/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic";
import { getFormHelpers } from "#/utils/formUtils";
import { ModelFormDialogs } from "./ModelFormDialogs";
import { ModelFormFields } from "./ModelFormFields";
import { ModelFormBackLink, ModelFormHeader } from "./ModelFormHeader";
import { ModelFormProviderSelect } from "./ModelFormProviderSelect";
const indefiniteArticle = (word: string): string =>
/^[aeiou]/i.test(word) ? "an" : "a";
const validationSchema = Yup.object({
model: Yup.string().trim().required("Model ID is required."),
displayName: Yup.string(),
enabled: Yup.boolean(),
contextLimit: Yup.string()
.required("Context limit is required.")
.test(
"positive-integer",
"Context limit must be a positive integer.",
(value) => !value?.trim() || parsePositiveInteger(value) !== null,
),
compressionThreshold: Yup.string().test(
"threshold-range",
"Compression threshold must be a number between 0 and 100.",
(value) => !value?.trim() || parseThresholdInteger(value) !== null,
),
isDefault: Yup.boolean(),
});
interface ModelFormProps {
editingModel?: TypesGen.ChatModelConfig;
duplicateSourceModel?: TypesGen.ChatModelConfig;
providerStates: readonly ProviderState[];
selectedProviderState: ProviderState | null;
onProviderChange: (providerKey: string) => void;
isSaving: boolean;
isDeleting: boolean;
onCreateModel: (
req: TypesGen.CreateChatModelConfigRequest,
) => Promise<unknown>;
onUpdateModel: (
modelConfigId: string,
req: TypesGen.UpdateChatModelConfigRequest,
) => Promise<unknown>;
onDeleteModel?: (modelConfigId: string) => Promise<void>;
currentDefaultModel?: TypesGen.ChatModelConfig;
onSetDefault?: () => void;
onDuplicate?: () => void;
onToggleEnabled?: (enabled: boolean) => void;
}
export const ModelForm: FC<ModelFormProps> = ({
editingModel,
duplicateSourceModel,
providerStates,
selectedProviderState,
onProviderChange,
isSaving,
isDeleting,
onCreateModel,
onUpdateModel,
onDeleteModel,
onDuplicate,
currentDefaultModel,
onToggleEnabled,
}) => {
const initialModel = editingModel ?? duplicateSourceModel;
const isEditing = Boolean(editingModel);
const isDuplicating = Boolean(duplicateSourceModel) && !isEditing;
const initialValues = {
...buildInitialModelFormValues(initialModel),
...(isDuplicating && { isDefault: false }),
};
const [showAdvanced, setShowAdvanced] = useState(false);
const [showPricing, setShowPricing] = useState(false);
const [showProviderConfig, setShowProviderConfig] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [confirmingReplaceDefault, setConfirmingReplaceDefault] =
useState(false);
const replaceDefaultConfirmedRef = useRef(false);
const canAddModelForSelectedProvider = canManageProviderModels(
selectedProviderState ?? undefined,
);
const mode: "add" | "edit" | "duplicate" = isEditing
? "edit"
: isDuplicating
? "duplicate"
: "add";
const selectedProviderType =
selectedProviderState?.provider ?? selectedProviderState?.key ?? "";
const selectedProviderKey = selectedProviderState?.key ?? "";
const form = useFormik<ModelFormValues>({
initialValues,
validationSchema,
validateOnMount: true,
validateOnBlur: false,
onSubmit: async (values) => {
if (isSaving) return;
const replacingDefault =
values.isDefault &&
currentDefaultModel != null &&
currentDefaultModel.id !== editingModel?.id;
if (replacingDefault && !replaceDefaultConfirmedRef.current) {
setConfirmingReplaceDefault(true);
return;
}
replaceDefaultConfirmedRef.current = false;
const trimmedModel = values.model.trim();
if (!trimmedModel) return;
const parsedContextLimit = parsePositiveInteger(values.contextLimit);
const parsedCompressionThreshold = parseThresholdInteger(
values.compressionThreshold,
);
const buildResult = buildModelConfigFromForm(
selectedProviderType,
values.config,
);
if (Object.keys(buildResult.fieldErrors).length > 0) return;
const trimmedDisplayName = values.displayName.trim();
const builtModelConfig = buildResult.modelConfig;
const selectedProviderConfigID =
selectedProviderState?.providerConfig?.id;
const editingProviderConfigID =
editingModel?.ai_provider_id?.trim() ?? "";
if (isEditing && editingModel) {
const req: TypesGen.UpdateChatModelConfigRequest = {
...(selectedProviderConfigID &&
selectedProviderConfigID !== editingProviderConfigID && {
provider: selectedProviderState.provider,
ai_provider_id: selectedProviderConfigID,
}),
...(trimmedModel !== editingModel.model && {
model: trimmedModel,
}),
...(trimmedDisplayName !== (editingModel.display_name ?? "") && {
display_name: trimmedDisplayName,
}),
...(parsedContextLimit !== null &&
parsedContextLimit !== editingModel.context_limit && {
context_limit: parsedContextLimit,
}),
...(parsedCompressionThreshold !== null &&
parsedCompressionThreshold !==
editingModel.compression_threshold && {
compression_threshold: parsedCompressionThreshold,
}),
...(values.isDefault !== editingModel.is_default && {
is_default: values.isDefault,
}),
model_config: builtModelConfig,
};
await onUpdateModel(editingModel.id, req);
} else {
if (!selectedProviderState?.providerConfig) return;
const req: TypesGen.CreateChatModelConfigRequest = {
provider: selectedProviderState.provider,
ai_provider_id: selectedProviderState.providerConfig.id,
model: trimmedModel,
enabled: values.enabled,
is_default: values.isDefault,
...(parsedContextLimit !== null && {
context_limit: parsedContextLimit,
}),
...(parsedCompressionThreshold !== null && {
compression_threshold: parsedCompressionThreshold,
}),
...(trimmedDisplayName && {
display_name: trimmedDisplayName,
}),
...(builtModelConfig && {
model_config: builtModelConfig,
}),
};
await onCreateModel(req);
}
},
});
const getFieldHelpers = getFormHelpers(form);
const unsavedChanges = useUnsavedChangesPrompt(
form.dirty && !form.isSubmitting,
);
const modelConfigFormBuildResult = buildModelConfigFromForm(
selectedProviderType,
form.values.config,
);
const hasFieldErrors =
Object.keys(modelConfigFormBuildResult.fieldErrors).length > 0;
const enabledToggleDisabled =
isSaving ||
(editingModel?.is_default === true && editingModel.enabled === true);
const setDefaultDisabled =
isSaving ||
(isEditing &&
(editingModel?.is_default === true || editingModel?.enabled === false));
const contextLimitValid =
parsePositiveInteger(form.values.contextLimit) !== null;
const compressionThresholdValid =
!form.values.compressionThreshold.trim() ||
parseThresholdInteger(form.values.compressionThreshold) !== null;
const canSubmit =
!isSaving &&
!hasFieldErrors &&
form.values.model.trim().length > 0 &&
contextLimitValid &&
compressionThresholdValid;
const handleConfirmReplaceDefault = () => {
replaceDefaultConfirmedRef.current = true;
setConfirmingReplaceDefault(false);
void form.submitForm();
};
if (
!selectedProviderState ||
(!canAddModelForSelectedProvider && !isEditing)
) {
return (
<>
<ModelFormBackLink />
<div className="flex flex-col gap-6 pt-6">
<SettingsHeaderTitle>Add model</SettingsHeaderTitle>
<div className="border border-solid p-6 rounded-lg">
<div className="space-y-3">
<ModelFormProviderSelect
providerStates={providerStates}
selectedProviderKey={selectedProviderKey}
onProviderChange={onProviderChange}
disabled={isDuplicating || providerStates.length === 0}
/>
{selectedProviderState && (
<p className="text-sm text-content-secondary m-0">
{!selectedProviderState.providerConfig
? "Create a managed provider before adding models."
: "Set an API key for this provider before adding models."}
</p>
)}
</div>
</div>
</div>
</>
);
}
const modelField = getFieldHelpers("model");
const contextLimitField = getFieldHelpers("contextLimit");
const compressionThresholdField = getFieldHelpers("compressionThreshold");
const displayNameField = getFieldHelpers("displayName");
const providerLabel = selectedProviderState.label;
const title = isEditing
? editingModel
? editingModel.display_name || editingModel.model
: "Edit model"
: isDuplicating
? `Duplicate ${providerLabel} model`
: `Add ${indefiniteArticle(providerLabel)} ${providerLabel} model`;
return (
<>
<ModelFormHeader
title={title}
selectedProviderState={selectedProviderState}
isEditing={isEditing}
editingModel={editingModel}
onDeleteModel={onDeleteModel}
onDuplicate={onDuplicate}
onToggleEnabled={onToggleEnabled}
isSaving={isSaving}
enabledToggleDisabled={enabledToggleDisabled}
onRequestDelete={() => setConfirmingDelete(true)}
/>
<div className="flex flex-col gap-6 pt-6">
<ModelFormFields
form={form}
mode={mode}
providerStates={providerStates}
selectedProviderState={selectedProviderState}
selectedProviderKey={selectedProviderKey}
selectedProviderType={selectedProviderType}
onProviderChange={onProviderChange}
isDuplicating={isDuplicating}
isEditing={isEditing}
isSaving={isSaving}
canSubmit={canSubmit}
initialModel={initialModel}
modelField={modelField}
contextLimitField={contextLimitField}
compressionThresholdField={compressionThresholdField}
displayNameField={displayNameField}
setDefaultDisabled={setDefaultDisabled}
modelConfigFormBuildResult={modelConfigFormBuildResult}
showPricing={showPricing}
setShowPricing={setShowPricing}
showProviderConfig={showProviderConfig}
setShowProviderConfig={setShowProviderConfig}
showAdvanced={showAdvanced}
setShowAdvanced={setShowAdvanced}
/>
</div>
<ModelFormDialogs
editingModel={editingModel}
onDeleteModel={onDeleteModel}
isDeleting={isDeleting}
confirmingDelete={confirmingDelete}
setConfirmingDelete={setConfirmingDelete}
resetForm={(values) => form.resetForm({ values })}
formValues={form.values}
unsavedChanges={unsavedChanges}
confirmingReplaceDefault={confirmingReplaceDefault}
setConfirmingReplaceDefault={setConfirmingReplaceDefault}
currentDefaultModel={currentDefaultModel}
onConfirmReplaceDefault={handleConfirmReplaceDefault}
/>
</>
);
};
@@ -0,0 +1,121 @@
import { TriangleAlertIcon } from "lucide-react";
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "#/components/Dialog/Dialog";
import type { ModelFormValues } from "#/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic";
import { ConfirmDeleteDialog } from "#/pages/AgentsPage/components/ConfirmDeleteDialog";
export const ModelFormDialogs: FC<{
editingModel?: TypesGen.ChatModelConfig;
onDeleteModel?: (modelConfigId: string) => Promise<void>;
isDeleting: boolean;
confirmingDelete: boolean;
setConfirmingDelete: (open: boolean) => void;
resetForm: (values: ModelFormValues) => void;
formValues: ModelFormValues;
unsavedChanges: {
isOpen: boolean;
onCancel: () => void;
onConfirm: () => void;
};
confirmingReplaceDefault: boolean;
setConfirmingReplaceDefault: (open: boolean) => void;
currentDefaultModel?: TypesGen.ChatModelConfig;
onConfirmReplaceDefault: () => void;
}> = ({
editingModel,
onDeleteModel,
isDeleting,
confirmingDelete,
setConfirmingDelete,
resetForm,
formValues,
unsavedChanges,
confirmingReplaceDefault,
setConfirmingReplaceDefault,
currentDefaultModel,
onConfirmReplaceDefault,
}) => {
return (
<>
{editingModel && onDeleteModel && (
<ConfirmDeleteDialog
entity="model"
isPending={isDeleting}
open={confirmingDelete}
onOpenChange={(open) => !open && setConfirmingDelete(false)}
onConfirm={() => {
resetForm(formValues);
void onDeleteModel(editingModel.id);
}}
/>
)}
<Dialog
open={unsavedChanges.isOpen}
onOpenChange={(open) => !open && unsavedChanges.onCancel()}
>
<DialogContent className="border-border-warning">
<DialogHeader>
<DialogTitle>Unsaved changes</DialogTitle>
<DialogDescription className="flex items-start gap-3">
<TriangleAlertIcon className="size-icon-sm mt-1 shrink-0 text-content-primary" />
<span>Your updates haven't been saved. Leave anyway?</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
type="button"
onClick={unsavedChanges.onCancel}
>
Cancel
</Button>
<Button type="button" onClick={unsavedChanges.onConfirm}>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={confirmingReplaceDefault}
onOpenChange={(open) => !open && setConfirmingReplaceDefault(false)}
>
<DialogContent className="border-border-warning">
<DialogHeader>
<DialogTitle>Replace default model</DialogTitle>
<DialogDescription className="flex items-center gap-2">
<TriangleAlertIcon className="size-icon-sm shrink-0 text-content-primary" />
<span>
<strong className="text-content-primary">
{currentDefaultModel?.display_name ||
currentDefaultModel?.model}
</strong>{" "}
is currently the default. Replace it?
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
type="button"
onClick={() => setConfirmingReplaceDefault(false)}
>
Cancel
</Button>
<Button type="button" onClick={onConfirmReplaceDefault}>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -0,0 +1,349 @@
import type { FormikContextType } from "formik";
import { ChevronDownIcon, ChevronRightIcon, InfoIcon } from "lucide-react";
import type { FC, ReactNode } from "react";
import { Link } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { Checkbox } from "#/components/Checkbox/Checkbox";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "#/components/Collapsible/Collapsible";
import { Input } from "#/components/Input/Input";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "#/components/InputGroup/InputGroup";
import { Label } from "#/components/Label/Label";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import type { ProviderState } from "#/modules/aiModels/providerStates";
import {
GeneralModelConfigFields,
ModelConfigFields,
PricingModelConfigFields,
} from "#/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields";
import { ModelIdentifierField } from "#/pages/AgentsPage/components/ChatModelAdminPanel/ModelIdentifierField";
import type {
ModelConfigFormBuildResult,
ModelFormValues,
} from "#/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic";
import { cn } from "#/utils/cn";
import type { FormHelpers } from "#/utils/formUtils";
import { ModelFormProviderSelect } from "./ModelFormProviderSelect";
const CollapsibleSection: FC<{
title: string;
description: string;
open: boolean;
onOpenChange: (open: boolean) => void;
className?: string;
contentClassName?: string;
children: ReactNode;
}> = ({
title,
description,
open,
onOpenChange,
className,
contentClassName,
children,
}) => {
return (
<Collapsible
open={open}
onOpenChange={onOpenChange}
className={cn("p-4", className)}
>
<CollapsibleTrigger className="flex w-full cursor-pointer items-start gap-2 border-0 bg-transparent p-0 text-left transition-colors hover:text-content-primary">
{open ? (
<ChevronDownIcon className="mt-0.5 size-4 shrink-0 text-content-secondary" />
) : (
<ChevronRightIcon className="mt-0.5 size-4 shrink-0 text-content-secondary" />
)}
<div>
<h3 className="m-0 text-sm font-medium text-content-primary">
{title}
</h3>
<p className="m-0 text-xs text-content-secondary">{description}</p>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className={contentClassName}>{children}</div>
</CollapsibleContent>
</Collapsible>
);
};
export const ModelFormFields: FC<{
form: FormikContextType<ModelFormValues>;
mode: "add" | "edit" | "duplicate";
providerStates: readonly ProviderState[];
selectedProviderState: ProviderState;
selectedProviderKey: string;
selectedProviderType: string;
onProviderChange: (providerKey: string) => void;
isDuplicating: boolean;
isEditing: boolean;
isSaving: boolean;
canSubmit: boolean;
initialModel?: TypesGen.ChatModelConfig;
modelField: FormHelpers;
contextLimitField: FormHelpers;
compressionThresholdField: FormHelpers;
displayNameField: FormHelpers;
setDefaultDisabled: boolean;
modelConfigFormBuildResult: ModelConfigFormBuildResult;
showPricing: boolean;
setShowPricing: (open: boolean) => void;
showProviderConfig: boolean;
setShowProviderConfig: (open: boolean) => void;
showAdvanced: boolean;
setShowAdvanced: (open: boolean) => void;
}> = ({
form,
mode,
providerStates,
selectedProviderState,
selectedProviderKey,
selectedProviderType,
onProviderChange,
isDuplicating,
isEditing,
isSaving,
canSubmit,
initialModel,
modelField,
contextLimitField,
compressionThresholdField,
displayNameField,
setDefaultDisabled,
modelConfigFormBuildResult,
showPricing,
setShowPricing,
showProviderConfig,
setShowProviderConfig,
showAdvanced,
setShowAdvanced,
}) => {
return (
<div className="border border-solid p-6 rounded-lg">
<form
onSubmit={form.handleSubmit}
spellCheck={false}
autoComplete="off"
className="flex flex-col gap-6"
>
<div className="grid items-start gap-4 sm:grid-cols-2">
<ModelFormProviderSelect
providerStates={providerStates}
selectedProviderKey={selectedProviderKey}
onProviderChange={onProviderChange}
disabled={isDuplicating || providerStates.length === 0}
/>
<div className="flex flex-col gap-1">
<ModelIdentifierField
form={form}
modelField={modelField}
mode={mode}
selectedProvider={selectedProviderType}
disabled={isSaving}
controlClassName="shadow-none"
/>
<label
htmlFor="isDefault"
className="flex w-fit cursor-pointer items-center gap-2 font-normal text-sm leading-6 text-content-secondary"
>
<Checkbox
id="isDefault"
checked={form.values.isDefault}
onCheckedChange={(checked) =>
form.setFieldValue("isDefault", checked === true)
}
disabled={setDefaultDisabled}
/>
Set as default model
</label>
</div>
<div className="grid gap-1.5">
<Label
htmlFor={displayNameField.id}
className="flex items-center gap-1 leading-6 text-content-primary"
>
Display name{" "}
<span className="text-xs font-bold text-content-destructive">
*
</span>
</Label>
<p className="m-0 text-xs text-content-secondary">
Friendly name. Defaults to identifier if blank.
</p>
<Input
id={displayNameField.id}
name={displayNameField.name}
className="placeholder:text-content-disabled shadow-none"
placeholder={initialModel?.model ?? "Model name"}
value={displayNameField.value}
onChange={displayNameField.onChange}
onBlur={displayNameField.onBlur}
disabled={isSaving}
/>
</div>
<div className="grid gap-1.5">
<Label
htmlFor={contextLimitField.id}
className="flex items-center gap-1 leading-6 text-content-primary"
>
Context limit{" "}
<span className="text-xs font-bold text-content-destructive">
*
</span>
</Label>
{contextLimitField.error ? (
<p className="m-0 text-xs text-content-destructive">
{contextLimitField.helperText}
</p>
) : (
<p className="m-0 text-xs text-content-secondary">
Max tokens in the context window.
</p>
)}
<InputGroup
className={cn(
contextLimitField.error && "border-border-destructive",
)}
>
<InputGroupInput
id={contextLimitField.id}
name={contextLimitField.name}
className="min-w-0 placeholder:text-content-disabled"
placeholder="200000"
value={contextLimitField.value}
onChange={contextLimitField.onChange}
onBlur={contextLimitField.onBlur}
disabled={isSaving}
aria-invalid={contextLimitField.error}
/>
<InputGroupAddon align="inline-end">
<span className="text-xs text-content-disabled">Tokens</span>
</InputGroupAddon>
</InputGroup>
</div>
</div>
<div className="overflow-hidden rounded-lg border border-solid border-border">
<CollapsibleSection
title="Cost tracking"
description="Set per-token pricing so Coder can track costs and enforce spending limits."
open={showPricing}
onOpenChange={setShowPricing}
contentClassName="grid grid-cols-2 gap-3 pt-3 pl-6 sm:grid-cols-4"
>
<PricingModelConfigFields
provider={selectedProviderState.provider}
form={form}
fieldErrors={modelConfigFormBuildResult.fieldErrors}
disabled={isSaving}
/>
</CollapsibleSection>
<CollapsibleSection
title="Provider configuration"
description="Tune provider-specific behavior like reasoning, tool calling, and web search."
open={showProviderConfig}
onOpenChange={setShowProviderConfig}
className="border-0 border-t border-solid border-border"
contentClassName="pt-3 pl-6"
>
<ModelConfigFields
provider={selectedProviderState.provider}
form={form}
fieldErrors={modelConfigFormBuildResult.fieldErrors}
disabled={isSaving}
/>
</CollapsibleSection>
<CollapsibleSection
title="Advanced"
description="Low-level parameters like temperature and penalties. Rarely need changing."
open={showAdvanced}
onOpenChange={setShowAdvanced}
className="border-0 border-t border-solid border-border"
contentClassName="grid grid-cols-2 gap-3 pt-3 pl-6 sm:grid-cols-3"
>
<GeneralModelConfigFields
provider={selectedProviderState.provider}
form={form}
fieldErrors={modelConfigFormBuildResult.fieldErrors}
disabled={isSaving}
/>
<div className="flex min-w-0 flex-col gap-1.5">
<Label
htmlFor={compressionThresholdField.id}
className="flex items-center gap-1 leading-6 text-content-primary"
>
Compression threshold
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="size-3 text-content-secondary" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
Percentage at which context is compressed.
</TooltipContent>
</Tooltip>
</Label>
<InputGroup
className={cn(
compressionThresholdField.error &&
"border-border-destructive",
)}
>
<InputGroupInput
id={compressionThresholdField.id}
name={compressionThresholdField.name}
className="placeholder:text-content-disabled"
placeholder="70"
value={compressionThresholdField.value}
onChange={compressionThresholdField.onChange}
onBlur={compressionThresholdField.onBlur}
disabled={isSaving}
aria-invalid={compressionThresholdField.error}
/>
<InputGroupAddon align="inline-end">
<span className="text-xs text-content-disabled">%</span>
</InputGroupAddon>
</InputGroup>
{compressionThresholdField.error && (
<p className="m-0 text-xs text-content-destructive">
{compressionThresholdField.helperText}
</p>
)}
</div>
</CollapsibleSection>
</div>
<div className="flex items-center justify-end gap-3">
<Link to="/ai/settings/models">
<Button variant="outline" type="button">
Cancel
</Button>
</Link>
<Button type="submit" disabled={!canSubmit}>
{isSaving && <Spinner loading />}
{isEditing
? "Update model"
: isDuplicating
? "Create duplicate"
: "Add Model"}
</Button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,153 @@
import {
ArrowLeftIcon,
CopyIcon,
EllipsisVerticalIcon,
TrashIcon,
} from "lucide-react";
import type { FC } from "react";
import { Link } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { Avatar } from "#/components/Avatar/Avatar";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader";
import { Switch } from "#/components/Switch/Switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import type { ProviderState } from "#/modules/aiModels/providerStates";
import { getProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon";
import { cn } from "#/utils/cn";
export const ModelFormBackLink: FC = () => {
return (
<Link to="/ai/settings/models" className="-ml-3">
<Button variant="subtle" type="button">
<ArrowLeftIcon />
<span>Back to models</span>
</Button>
</Link>
);
};
export const ModelFormHeader: FC<{
title: string;
selectedProviderState: ProviderState;
isEditing: boolean;
editingModel?: TypesGen.ChatModelConfig;
onDeleteModel?: (modelConfigId: string) => Promise<void>;
onDuplicate?: () => void;
onToggleEnabled?: (enabled: boolean) => void;
isSaving: boolean;
enabledToggleDisabled: boolean;
onRequestDelete: () => void;
}> = ({
title,
selectedProviderState,
isEditing,
editingModel,
onDeleteModel,
onDuplicate,
onToggleEnabled,
isSaving,
enabledToggleDisabled,
onRequestDelete,
}) => {
return (
<>
<div className="flex items-center justify-between">
<ModelFormBackLink />
{isEditing && editingModel && onDeleteModel && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="subtle"
size="icon"
type="button"
disabled={isSaving}
aria-label="Model actions"
>
<EllipsisVerticalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onDuplicate && (
<DropdownMenuItem onClick={onDuplicate}>
<CopyIcon className="size-icon-sm" />
Duplicate model
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={onRequestDelete}
>
<TrashIcon />
Delete…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4 min-w-0">
<Avatar
variant="icon"
size="lg"
src={getProviderIcon(selectedProviderState.provider)}
/>
<SettingsHeaderTitle>
<span
className={cn(
"block min-w-0 truncate",
editingModel?.enabled === false && "text-content-secondary",
)}
>
{title}
</span>
</SettingsHeaderTitle>
{isEditing && editingModel?.is_default && (
<Badge variant="default">Default</Badge>
)}
{isEditing &&
editingModel &&
!editingModel.is_default &&
!editingModel.enabled && <Badge variant="default">Disabled</Badge>}
</div>
{isEditing && editingModel && (
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Switch
checked={editingModel.enabled}
onCheckedChange={(checked) => onToggleEnabled?.(checked)}
disabled={enabledToggleDisabled}
aria-label="Model enabled"
/>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{editingModel.is_default && editingModel.enabled
? "Default model cannot be disabled. Set another model as default first."
: editingModel.enabled
? "Disable this model. It will be hidden from users."
: "Enable this model. It will be visible to users."}
</TooltipContent>
</Tooltip>
<span className="text-sm">Enable</span>
</div>
)}
</div>
</>
);
};
@@ -0,0 +1,55 @@
import type { FC } from "react";
import { Label } from "#/components/Label/Label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "#/components/Select/Select";
import type { ProviderState } from "#/modules/aiModels/providerStates";
import { ProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon";
export const ModelFormProviderSelect: FC<{
providerStates: readonly ProviderState[];
selectedProviderKey: string;
onProviderChange: (providerKey: string) => void;
disabled: boolean;
}> = ({ providerStates, selectedProviderKey, onProviderChange, disabled }) => {
return (
<div className="grid gap-1.5">
<Label
htmlFor="providerSelect"
className="flex items-center gap-1 leading-6 text-content-primary"
>
Provider{" "}
<span className="text-xs font-bold text-content-destructive">*</span>
</Label>
<p className="m-0 text-xs text-content-secondary">
The provider this model belongs to.
</p>
<Select
value={selectedProviderKey}
onValueChange={onProviderChange}
disabled={disabled}
>
<SelectTrigger
id="providerSelect"
className="text-content-primary shadow-none"
>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{providerStates.map((ps) => (
<SelectItem key={ps.key} value={ps.key}>
<span className="flex items-center gap-2">
<ProviderIcon provider={ps.provider} />
{ps.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
@@ -0,0 +1,84 @@
import { ChevronRightIcon } from "lucide-react";
import type { FC } from "react";
import type { ChatModelConfig } from "#/api/typesGenerated";
import { Avatar } from "#/components/Avatar/Avatar";
import { Badge } from "#/components/Badge/Badge";
import { TableCell, TableRow } from "#/components/Table/Table";
import { useClickableTableRow } from "#/hooks/useClickableTableRow";
import { ProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon";
import { cn } from "#/utils/cn";
type ModelRowProps = {
model: ChatModelConfig;
providerLabel: string;
onClick: () => void;
};
const formatContextLimit = (contextLimit: number): string => {
if (!Number.isFinite(contextLimit)) {
return "N/A";
}
return `${contextLimit.toLocaleString("en-US")} tokens`;
};
export const ModelRow: FC<ModelRowProps> = ({
model,
providerLabel,
onClick,
}) => {
const clickableProps = useClickableTableRow({ onClick });
const displayName = model.display_name || model.model;
return (
<TableRow
{...clickableProps}
className={cn(clickableProps.className, "h-[72px]")}
>
<TableCell className="min-w-0 p-4">
<div className="flex min-w-0 items-center gap-4">
<Avatar
size="lg"
className="flex shrink-0 items-center justify-center"
>
<ProviderIcon provider={model.provider} />
</Avatar>
<span
className="truncate text-sm font-medium leading-6 text-content-primary"
title={displayName}
>
{displayName}
</span>
</div>
</TableCell>
<TableCell className="min-w-0">
<span
className="block truncate text-sm font-medium leading-6 text-content-secondary"
title={providerLabel}
>
{providerLabel || "N/A"}
</span>
</TableCell>
<TableCell className="min-w-0">
<span className="block truncate text-sm font-medium leading-6 text-content-secondary">
{formatContextLimit(model.context_limit)}
</span>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-2">
{model.is_default && <Badge variant="default">Default</Badge>}
<Badge variant="default">
{model.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</TableCell>
<TableCell className="w-10 text-center">
<div className="flex justify-end items-center gap-8 pr-4">
<ChevronRightIcon
aria-hidden
className="size-icon-md text-content-primary flex-shrink-0"
/>
</div>
</TableCell>
</TableRow>
);
};
@@ -0,0 +1,84 @@
import type { ChatModelConfig, ChatProviderConfig } from "#/api/typesGenerated";
import type { ProviderState } from "#/modules/aiModels/providerStates";
const now = "2026-02-18T12:00:00.000Z";
const MockOpenAIProviderConfig: ChatProviderConfig = {
id: "prov-openai",
provider: "openai",
display_name: "OpenAI",
enabled: true,
has_api_key: true,
central_api_key_enabled: true,
allow_user_api_key: false,
allow_central_api_key_fallback: true,
base_url: "",
source: "database",
created_at: now,
updated_at: now,
};
const MockAnthropicProviderConfig: ChatProviderConfig = {
...MockOpenAIProviderConfig,
id: "prov-anthropic",
provider: "anthropic",
display_name: "Anthropic",
};
export const mockGPT5: ChatModelConfig = {
id: "model-gpt5",
provider: "openai",
ai_provider_id: "prov-openai",
model: "gpt-5",
display_name: "GPT-5",
enabled: true,
is_default: true,
context_limit: 200000,
compression_threshold: 70,
created_at: now,
updated_at: now,
};
export const mockClaude: ChatModelConfig = {
...mockGPT5,
id: "model-claude",
provider: "anthropic",
ai_provider_id: "prov-anthropic",
model: "claude-sonnet-4-5",
display_name: "Claude Sonnet 4.5",
is_default: false,
};
export const mockDisabledModel: ChatModelConfig = {
...mockGPT5,
id: "model-disabled",
model: "gpt-4o-mini",
display_name: "GPT-4o mini",
is_default: false,
enabled: false,
context_limit: 128000,
};
export const MockOpenAIProviderState: ProviderState = {
key: "prov-openai",
provider: "openai",
label: "OpenAI",
providerConfig: MockOpenAIProviderConfig,
modelConfigs: [mockGPT5, mockDisabledModel],
catalogModelCount: 0,
hasManagedAPIKey: true,
hasCatalogAPIKey: true,
hasEffectiveAPIKey: true,
allowUserAPIKey: false,
isEnvPreset: false,
baseURL: "",
};
export const MockAnthropicProviderState: ProviderState = {
...MockOpenAIProviderState,
key: "prov-anthropic",
provider: "anthropic",
label: "Anthropic",
providerConfig: MockAnthropicProviderConfig,
modelConfigs: [mockClaude],
};
@@ -158,7 +158,7 @@ const UpdateProviderPageView: React.FC = () => {
<p className="text-sm text-content-secondary m-0">
Add or update models for this provider.{" "}
<a
href="/agents/settings/models"
href="/ai/settings/models"
className="text-content-link no-underline hover:underline"
>
Model settings
@@ -548,7 +548,7 @@ export const MissingProviderAndModelSetup: Story = {
);
expect(canvas.getByRole("link", { name: "model" })).toHaveAttribute(
"href",
"/agents/settings/models",
"/ai/settings/models",
);
},
};
@@ -579,7 +579,7 @@ export const MissingModelSetup: Story = {
});
expect(canvas.getByRole("link", { name: "model" })).toHaveAttribute(
"href",
"/agents/settings/models",
"/ai/settings/models",
);
},
};
@@ -1,101 +0,0 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatModelConfigs,
chatModels,
chatProviderConfigs,
createChatModelConfig,
createChatProviderConfig,
deleteChatModelConfig,
deleteChatProviderConfig,
updateChatModelConfig,
updateChatProviderConfig,
} from "#/api/queries/chats";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { ChatModelAdminPanel } from "./components/ChatModelAdminPanel/ChatModelAdminPanel";
const AgentSettingsModelsPage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
// Queries.
const providerConfigsQuery = useQuery({
...chatProviderConfigs(),
enabled: permissions.editDeploymentConfig,
});
const modelConfigsQuery = useQuery(chatModelConfigs());
const modelCatalogQuery = useQuery(chatModels());
// Mutations.
const createProviderMutation = useMutation(
createChatProviderConfig(queryClient),
);
const updateProviderMutation = useMutation(
updateChatProviderConfig(queryClient),
);
const deleteProviderMutation = useMutation(
deleteChatProviderConfig(queryClient),
);
const createModelMutation = useMutation(createChatModelConfig(queryClient));
const updateModelMutation = useMutation(updateChatModelConfig(queryClient));
const deleteModelMutation = useMutation(deleteChatModelConfig(queryClient));
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<ChatModelAdminPanel
section="models"
sectionLabel="Models"
sectionDescription="Choose which models from your configured providers are available for Coder Agents. Set a default and adjust context limits."
providerConfigsData={providerConfigsQuery.data}
modelConfigsData={modelConfigsQuery.data}
modelCatalogData={modelCatalogQuery.data}
isLoading={
providerConfigsQuery.isLoading ||
modelConfigsQuery.isLoading ||
modelCatalogQuery.isLoading
}
providerConfigsError={
providerConfigsQuery.isError ? providerConfigsQuery.error : null
}
modelConfigsError={
modelConfigsQuery.isError ? modelConfigsQuery.error : null
}
modelCatalogError={
modelCatalogQuery.isError ? modelCatalogQuery.error : null
}
onCreateProvider={(req) => createProviderMutation.mutateAsync(req)}
onUpdateProvider={(providerConfigId, req) =>
updateProviderMutation.mutateAsync({ providerConfigId, req })
}
onDeleteProvider={(id) => deleteProviderMutation.mutateAsync(id)}
isProviderMutationPending={
createProviderMutation.isPending ||
updateProviderMutation.isPending ||
deleteProviderMutation.isPending
}
providerMutationError={
createProviderMutation.error ??
updateProviderMutation.error ??
deleteProviderMutation.error
}
onCreateModel={(req) => createModelMutation.mutateAsync(req)}
onUpdateModel={(modelConfigId, req) =>
updateModelMutation.mutateAsync({ modelConfigId, req })
}
onDeleteModel={(id) => deleteModelMutation.mutateAsync(id)}
isCreatingModel={createModelMutation.isPending}
isUpdatingModel={updateModelMutation.isPending}
isDeletingModel={deleteModelMutation.isPending}
modelMutationError={
createModelMutation.error ??
updateModelMutation.error ??
deleteModelMutation.error
}
/>
</RequirePermission>
);
};
export default AgentSettingsModelsPage;
@@ -494,7 +494,7 @@ export const MissingProviderAndModelSetup: Story = {
);
expect(canvas.getByRole("link", { name: "model" })).toHaveAttribute(
"href",
"/agents/settings/models",
"/ai/settings/models",
);
},
};
@@ -45,7 +45,7 @@ export const AgentSetupNotice: FC<AgentSetupNoticeProps> = ({
{" "}
then add a{" "}
<Link
to="/agents/settings/models"
to="/ai/settings/models"
className="text-content-link transition-colors hover:text-content-link/80"
>
model
@@ -62,7 +62,7 @@ export const AgentSetupNotice: FC<AgentSetupNoticeProps> = ({
<NoticeContainer>
To chat with Coder Agents, set up a{" "}
<Link
to="/agents/settings/models"
to="/ai/settings/models"
className="text-content-link transition-colors hover:text-content-link/80"
>
model
@@ -1,373 +0,0 @@
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Spinner } from "#/components/Spinner/Spinner";
import { cn } from "#/utils/cn";
import { formatProviderLabel } from "../../utils/modelOptions";
import {
getDefaultProviderBaseURL,
normalizeProvider,
readOptionalString,
} from "./helpers";
import { ModelsSection } from "./ModelsSection";
import { ProvidersSection } from "./ProvidersSection";
export type CreateProviderResult = { id: string };
export type ProviderState = {
key: string;
provider: string;
label: string;
providerConfig: TypesGen.ChatProviderConfig | undefined;
modelConfigs: readonly TypesGen.ChatModelConfig[];
catalogModelCount: number;
hasManagedAPIKey: boolean;
hasCatalogAPIKey: boolean;
hasEffectiveAPIKey: boolean;
allowUserAPIKey: boolean;
isEnvPreset: boolean;
baseURL: string;
};
export type ChatModelAdminSection = "providers" | "models";
type CatalogProvider = TypesGen.ChatModelsResponse["providers"][number];
const nilUUID = "00000000-0000-0000-0000-000000000000";
const envPresetProviders = new Set(["openai", "anthropic"]);
const hasProviderAPIKey = (
providerConfig: TypesGen.ChatProviderConfig | undefined,
): boolean => {
if (!providerConfig) return false;
return providerConfig.has_api_key;
};
const getProviderConfigSource = (
providerConfig: TypesGen.ChatProviderConfig | undefined,
): TypesGen.ChatProviderConfigSource | undefined => {
return providerConfig?.source;
};
const isDatabaseProviderConfig = (
providerConfig: TypesGen.ChatProviderConfig | undefined,
source: TypesGen.ChatProviderConfigSource | undefined,
): providerConfig is TypesGen.ChatProviderConfig => {
if (!providerConfig) return false;
if (providerConfig.id === nilUUID) return false;
return source === undefined || source === "database";
};
const getCatalogProviders = (
catalog: TypesGen.ChatModelsResponse | null | undefined,
): readonly CatalogProvider[] => {
const providers = catalog?.providers;
return Array.isArray(providers) ? providers : [];
};
const providerHasCatalogAPIKey = (provider: CatalogProvider): boolean =>
provider.available ||
(Boolean(provider.unavailable_reason) &&
provider.unavailable_reason !== "missing_api_key");
const getProviderModels = (
provider: CatalogProvider | undefined,
): readonly CatalogProvider["models"][number][] => {
const models = provider?.models;
return Array.isArray(models) ? models : [];
};
const getProviderBaseURL = (
providerConfig: TypesGen.ChatProviderConfig | undefined,
): string => {
return (
readOptionalString(providerConfig?.base_url) ??
getDefaultProviderBaseURL(providerConfig?.provider ?? "")
);
};
const providerConfigStateKey = (
providerConfig: TypesGen.ChatProviderConfig,
): string => {
const providerID = readOptionalString(providerConfig.id);
if (providerID && providerID !== nilUUID) {
return providerID;
}
return normalizeProvider(providerConfig.provider);
};
type ProviderEntry = {
key: string;
provider: string;
};
const useProviderStates = (
modelConfigs: readonly TypesGen.ChatModelConfig[],
providerConfigsData: TypesGen.ChatProviderConfig[] | null | undefined,
catalogData: TypesGen.ChatModelsResponse | null | undefined,
): readonly ProviderState[] => {
const orderedEntries: ProviderEntry[] = [];
const seenEntries = new Set<string>();
const includeEntry = (keyValue: string, providerValue: string) => {
const key = readOptionalString(keyValue);
const provider = normalizeProvider(providerValue);
if (!key || !provider || seenEntries.has(key)) return;
seenEntries.add(key);
orderedEntries.push({ key, provider });
};
const catalogProviders = getCatalogProviders(catalogData);
const catalogProvidersByProvider = new Map<string, CatalogProvider>();
for (const cp of catalogProviders) {
const provider = normalizeProvider(cp.provider);
if (!provider) continue;
catalogProvidersByProvider.set(provider, cp);
}
const providerConfigKeysByProvider = new Map<string, string[]>();
const providerTypesWithConfigs = new Set<string>();
for (const pc of providerConfigsData ?? []) {
const provider = normalizeProvider(pc.provider);
if (!provider) continue;
const key = providerConfigStateKey(pc);
providerTypesWithConfigs.add(provider);
providerConfigKeysByProvider.set(provider, [
...(providerConfigKeysByProvider.get(provider) ?? []),
key,
]);
includeEntry(key, provider);
}
const modelStateKey = (modelConfig: TypesGen.ChatModelConfig): string => {
const aiProviderID = readOptionalString(modelConfig.ai_provider_id);
if (aiProviderID) {
return aiProviderID;
}
const provider = normalizeProvider(modelConfig.provider);
const providerConfigKeys = providerConfigKeysByProvider.get(provider) ?? [];
if (providerConfigKeys.length === 1) {
return providerConfigKeys[0];
}
return providerConfigKeys.length === 0 ? provider : "";
};
for (const cp of catalogProviders) {
const provider = normalizeProvider(cp.provider);
if (!provider || providerTypesWithConfigs.has(provider)) continue;
includeEntry(provider, provider);
}
for (const mc of modelConfigs) {
includeEntry(modelStateKey(mc), mc.provider);
}
const providerConfigsByKey = new Map<string, TypesGen.ChatProviderConfig>();
for (const pc of providerConfigsData ?? []) {
const key = providerConfigStateKey(pc);
if (!key) continue;
providerConfigsByKey.set(key, pc);
}
const modelConfigsByKey = new Map<string, TypesGen.ChatModelConfig[]>();
for (const mc of modelConfigs) {
const key = modelStateKey(mc);
if (!key) continue;
const existing = modelConfigsByKey.get(key);
if (existing) {
existing.push(mc);
} else {
modelConfigsByKey.set(key, [mc]);
}
}
return orderedEntries.map(({ key, provider }) => {
const providerConfigEntry = providerConfigsByKey.get(key);
const providerConfigSource = getProviderConfigSource(providerConfigEntry);
const providerConfig = isDatabaseProviderConfig(
providerConfigEntry,
providerConfigSource,
)
? providerConfigEntry
: undefined;
const catalogProvider = catalogProvidersByProvider.get(provider);
const hasManagedAPIKey = hasProviderAPIKey(providerConfig);
const hasProviderEntryAPIKey = hasProviderAPIKey(providerConfigEntry);
const hasCatalogAPIKey = catalogProvider
? providerHasCatalogAPIKey(catalogProvider)
: false;
const label =
readOptionalString(providerConfigEntry?.display_name) ??
formatProviderLabel(provider);
const hasBedrockAmbientCredentials =
provider === "bedrock" &&
providerConfig?.central_api_key_enabled === true;
const modelConfigsForProvider = modelConfigsByKey.get(key) ?? [];
const isCatalogEnvPreset =
!providerConfig && envPresetProviders.has(provider) && hasCatalogAPIKey;
const isEnvPreset =
providerConfigSource === "env_preset" || isCatalogEnvPreset;
return {
key,
provider,
label,
providerConfig,
modelConfigs: modelConfigsForProvider,
catalogModelCount: getProviderModels(catalogProvider).length,
hasManagedAPIKey,
hasCatalogAPIKey,
hasEffectiveAPIKey: providerConfigEntry
? hasProviderEntryAPIKey || hasBedrockAmbientCredentials
: hasManagedAPIKey || hasCatalogAPIKey,
allowUserAPIKey: providerConfigEntry?.allow_user_api_key ?? true,
isEnvPreset,
baseURL: getProviderBaseURL(providerConfigEntry),
};
});
};
interface ChatModelAdminPanelProps {
className?: string;
section?: ChatModelAdminSection;
sectionLabel?: string;
sectionDescription?: string;
// Data from queries.
providerConfigsData: TypesGen.ChatProviderConfig[] | undefined;
modelConfigsData: TypesGen.ChatModelConfig[] | undefined;
modelCatalogData: TypesGen.ChatModelsResponse | undefined;
isLoading: boolean;
// Query error states.
providerConfigsError: Error | null;
modelConfigsError: Error | null;
modelCatalogError: Error | null;
// Provider mutation handlers.
onCreateProvider: (
req: TypesGen.CreateChatProviderConfigRequest,
) => Promise<CreateProviderResult>;
onUpdateProvider: (
providerConfigId: string,
req: TypesGen.UpdateChatProviderConfigRequest,
) => Promise<unknown>;
onDeleteProvider: (providerConfigId: string) => Promise<void>;
isProviderMutationPending: boolean;
providerMutationError: Error | null;
// Model mutation handlers.
onCreateModel: (
req: TypesGen.CreateChatModelConfigRequest,
) => Promise<unknown>;
onUpdateModel: (
modelConfigId: string,
req: TypesGen.UpdateChatModelConfigRequest,
) => Promise<unknown>;
onDeleteModel: (modelConfigId: string) => Promise<void>;
isCreatingModel: boolean;
isUpdatingModel: boolean;
isDeletingModel: boolean;
modelMutationError: Error | null;
}
export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
className,
section = "providers",
sectionLabel,
sectionDescription,
providerConfigsData,
modelConfigsData,
modelCatalogData,
isLoading,
providerConfigsError,
modelConfigsError,
modelCatalogError,
onCreateProvider,
onUpdateProvider,
onDeleteProvider,
isProviderMutationPending,
providerMutationError,
onCreateModel,
onUpdateModel,
onDeleteModel,
isCreatingModel,
isUpdatingModel,
isDeletingModel,
modelMutationError,
}) => {
const modelConfigs = (modelConfigsData ?? []).slice().sort((a, b) => {
const cmp = a.provider.localeCompare(b.provider);
return cmp !== 0 ? cmp : a.model.localeCompare(b.model);
});
const providerStates = useProviderStates(
modelConfigs,
providerConfigsData,
modelCatalogData,
);
const providerConfigsUnavailable = providerConfigsData === null;
const modelConfigsUnavailable = modelConfigsData === null;
return (
<div className={cn("flex min-h-full flex-col", className)}>
{isLoading && (
<div className="flex items-center gap-1.5 text-xs text-content-secondary">
<Spinner className="h-4 w-4" loading />
Loading
</div>
)}
<div className="flex flex-1 flex-col gap-8">
{section === "providers" ? (
<ProvidersSection
sectionLabel={sectionLabel}
sectionDescription={sectionDescription}
providerStates={providerStates}
providerConfigsUnavailable={providerConfigsUnavailable}
isProviderMutationPending={isProviderMutationPending}
onCreateProvider={onCreateProvider}
onUpdateProvider={onUpdateProvider}
onDeleteProvider={onDeleteProvider}
/>
) : (
<ModelsSection
sectionLabel={sectionLabel}
sectionDescription={sectionDescription}
providerStates={providerStates}
modelConfigs={modelConfigs}
modelConfigsUnavailable={modelConfigsUnavailable}
isCreating={isCreatingModel}
isUpdating={isUpdatingModel}
isDeleting={isDeletingModel}
onCreateModel={onCreateModel}
onUpdateModel={onUpdateModel}
onDeleteModel={onDeleteModel}
/>
)}
</div>
{providerConfigsError && <ErrorAlert error={providerConfigsError} />}
{modelConfigsError && <ErrorAlert error={modelConfigsError} />}
{modelCatalogError && <ErrorAlert error={modelCatalogError} />}
{providerMutationError && <ErrorAlert error={providerMutationError} />}
{modelMutationError && <ErrorAlert error={modelMutationError} />}
{providerConfigsUnavailable && (
<Alert severity="info">
<AlertTitle>
Chat provider admin API is unavailable on this deployment.
</AlertTitle>
<AlertDescription>
/api/v2/chats/providers is missing.
</AlertDescription>
</Alert>
)}
{modelConfigsUnavailable && (
<Alert severity="info">
<AlertTitle>
Chat model admin API is unavailable on this deployment.
</AlertTitle>
<AlertDescription>
/api/v2/chats/model-configs is missing.
</AlertDescription>
</Alert>
)}
</div>
);
};
@@ -29,17 +29,24 @@ import {
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { normalizeProvider } from "#/modules/aiModels/helpers";
import { cn } from "#/utils/cn";
import { normalizeProvider } from "./helpers";
import type {
ModelConfigFormBuildResult,
ModelFormValues,
import {
isFieldConflictDisabled,
isVisibleWhenSatisfied,
type ModelConfigFormBuildResult,
type ModelFormValues,
} from "./modelConfigFormLogic";
import {
getPricingPlaceholderForField,
pricingFieldNames,
} from "./pricingFields";
const booleanFieldOptions = [
{ label: "On", value: "true" },
{ label: "Off", value: "false" },
] as const;
/** Sentinel value for Select components to represent "no selection". */
const unsetSelectValue = "__unset__";
@@ -143,7 +150,7 @@ const FieldLabel: FC<{
}> = ({ htmlFor, label, description }) => (
<Label
htmlFor={htmlFor}
className="inline-flex items-center gap-1 text-[13px] font-medium text-content-primary"
className="flex items-center gap-1 leading-6 text-content-primary"
>
{label}
{description && (
@@ -184,12 +191,10 @@ const InputField: FC<
const fieldProps = form.getFieldProps(fieldKey);
const inputEl = suffix ? (
<InputGroup
className={cn("h-9", fieldError && "border-border-destructive")}
>
<InputGroup className={cn(fieldError && "border-border-destructive")}>
<InputGroupInput
id={fieldKey}
className="h-9 min-w-0 text-[13px] placeholder:text-content-disabled"
className="min-w-0 placeholder:text-content-disabled"
placeholder={placeholder}
{...fieldProps}
disabled={disabled}
@@ -204,7 +209,7 @@ const InputField: FC<
<Input
id={fieldKey}
className={cn(
"h-9 min-w-0 text-[13px] placeholder:text-content-disabled",
"min-w-0 placeholder:text-content-disabled",
fieldError && "border-content-destructive",
)}
placeholder={placeholder}
@@ -264,10 +269,7 @@ const SelectField: FC<
>
<SelectTrigger
id={fieldKey}
className={cn(
"h-9 min-w-0 text-[13px]",
fieldError && "border-content-destructive",
)}
className={cn("min-w-0", fieldError && "border-content-destructive")}
aria-invalid={Boolean(fieldError)}
aria-describedby={fieldError ? errorId : undefined}
>
@@ -277,7 +279,7 @@ const SelectField: FC<
<SelectItem value={unsetSelectValue}>Default</SelectItem>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
{capitalize(option)}
</SelectItem>
))}
</SelectContent>
@@ -379,6 +381,10 @@ const JSONField: FC<
const errorId = `${fieldKey}-error`;
const fieldError = fieldErrors[errorKey ?? fieldKey];
const fieldProps = form.getFieldProps(fieldKey);
// Only surface the error once the field has been blurred, so a partially
// typed array like "[" doesn't complain mid-edit.
const showError =
Boolean(fieldError) && Boolean(getIn(form.touched, fieldKey));
return (
<div className="flex min-w-0 flex-col gap-1.5">
<FieldLabel htmlFor={fieldKey} label={label} description={description} />
@@ -387,15 +393,15 @@ const JSONField: FC<
rows={1}
className={cn(
"min-h-0 resize-y font-mono text-xs leading-tight placeholder:text-content-disabled",
fieldError && "border-content-destructive",
showError && "border-content-destructive",
)}
placeholder={placeholder}
{...fieldProps}
disabled={disabled}
aria-invalid={Boolean(fieldError)}
aria-describedby={fieldError ? errorId : undefined}
aria-invalid={showError}
aria-describedby={showError ? errorId : undefined}
/>
{fieldError && (
{showError && (
<p id={errorId} className="m-0 text-xs text-content-destructive">
{fieldError}
</p>
@@ -451,10 +457,7 @@ const SchemaField: FC<SchemaFieldProps> = ({
errorKey={errorKey}
label={label}
description={field.description}
options={[
{ label: "On", value: "true" },
{ label: "Off", value: "false" },
]}
options={booleanFieldOptions}
/>
);
}
@@ -468,7 +471,10 @@ const SchemaField: FC<SchemaFieldProps> = ({
errorKey={errorKey}
label={label}
description={field.description}
options={options.map((v) => ({ label: capitalize(v), value: v }))}
options={options.map((value) => ({
label: capitalize(value),
value,
}))}
/>
);
}
@@ -503,8 +509,8 @@ const SchemaField: FC<SchemaFieldProps> = ({
/**
* How many grid columns a field should span in the 3-col layout.
* 1 = default (inputs, booleans, small enums ≤3)
* 3 = full-width (4+ option enums, json textareas)
* 1 = default (inputs, booleans, small enums)
* 3 = full-width (large enums, json textareas)
*/
function colSpan(field: FieldSchema): 1 | 3 {
if (field.input_type === "json") {
@@ -553,11 +559,17 @@ export const ModelConfigFields: FC<ModelConfigFieldsProps> = ({
return null;
}
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
const fieldValueByName = (jsonName: string): unknown =>
getIn(form.values, `config.${toFormFieldKey(resolved, jsonName)}`);
const isFieldVisible = (field: FieldSchema): boolean =>
isVisibleWhenSatisfied(field, fieldValueByName);
// Sort wider fields to the end so compact fields fill the
// grid first, keeping the layout dense.
const sorted = [...fields].sort((a, b) => colSpan(a) - colSpan(b));
const sorted = [...fields]
.filter(isFieldVisible)
.sort((a, b) => colSpan(a) - colSpan(b));
return (
<div className="grid min-w-0 gap-3 sm:grid-cols-3">
@@ -570,7 +582,11 @@ export const ModelConfigFields: FC<ModelConfigFieldsProps> = ({
field={field}
fieldKey={fieldKey}
errorKey={errorKey}
{...ctx}
form={form}
fieldErrors={fieldErrors}
disabled={
disabled || isFieldConflictDisabled(field, fieldValueByName)
}
/>
</div>
);
@@ -579,50 +595,6 @@ export const ModelConfigFields: FC<ModelConfigFieldsProps> = ({
);
};
/**
* Shared renderer for general model config fields backed by the
* top-level ChatModelCallConfig schema.
*/
const GeneralFieldsGroup: FC<
ModelConfigFieldsProps & {
fields: FieldSchema[];
suppressDescriptions?: boolean;
}
> = ({ form, fieldErrors, disabled, fields, suppressDescriptions }) => {
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
return (
<>
{fields.map((field) => {
// General field keys support nested json_name values, such as
// cost.input_price_per_million_tokens.
const camelName = field.json_name
.split(".")
.map(snakeToCamel)
.join(".");
const fieldKey = `config.${camelName}`;
const label = snakeToPrettyLabel(field);
return (
<InputField
key={fieldKey}
{...ctx}
fieldKey={fieldKey}
errorKey={camelName}
label={label}
description={suppressDescriptions ? undefined : field.description}
placeholder={
placeholderOverrides[field.json_name] ??
placeholderForField(field)
}
suffix={fieldSuffix[field.json_name]}
/>
);
})}
</>
);
};
/**
* Pricing fields rendered with $ prefix and /1M suffix using
* InputGroup for a compact, readable layout.
@@ -653,12 +625,12 @@ export const PricingModelConfigFields: FC<ModelConfigFieldsProps> = ({
<div key={fieldKey} className="flex min-w-0 flex-col gap-1.5">
<FieldLabel htmlFor={fieldKey} label={label} />
<InputGroup
className={cn("h-9", fieldError && "border-border-destructive")}
className={cn(fieldError && "border-border-destructive")}
>
<InputGroupAddon align="inline-start">$</InputGroupAddon>
<InputGroupInput
id={fieldKey}
className="h-9 min-w-0 text-[13px] placeholder:text-content-disabled"
className="min-w-0 placeholder:text-content-disabled"
placeholder="0"
{...fieldProps}
disabled={disabled}
@@ -691,20 +663,43 @@ export const PricingModelConfigFields: FC<ModelConfigFieldsProps> = ({
* `api/chatModelOptions`.
*/
export const GeneralModelConfigFields: FC<ModelConfigFieldsProps> = ({
provider,
form,
fieldErrors,
disabled,
}) => {
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
const fields = getVisibleGeneralFields().filter(
({ json_name }) => !pricingFieldNames.has(json_name),
);
return (
<GeneralFieldsGroup
provider={provider}
form={form}
fieldErrors={fieldErrors}
disabled={disabled}
fields={getVisibleGeneralFields().filter(
({ json_name }) => !pricingFieldNames.has(json_name),
)}
/>
<>
{fields.map((field) => {
// General field keys support nested json_name values, such as
// cost.input_price_per_million_tokens.
const camelName = field.json_name
.split(".")
.map(snakeToCamel)
.join(".");
const fieldKey = `config.${camelName}`;
const label = snakeToPrettyLabel(field);
return (
<InputField
key={fieldKey}
{...ctx}
fieldKey={fieldKey}
errorKey={camelName}
label={label}
description={field.description}
placeholder={
placeholderOverrides[field.json_name] ??
placeholderForField(field)
}
suffix={fieldSuffix[field.json_name]}
/>
);
})}
</>
);
};
@@ -1,662 +0,0 @@
import { useFormik } from "formik";
import {
ChevronDownIcon,
ChevronRightIcon,
InfoIcon,
PencilIcon,
} from "lucide-react";
import { type FC, useState } from "react";
import * as Yup from "yup";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "#/components/InputGroup/InputGroup";
import { Label } from "#/components/Label/Label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "#/components/Select/Select";
import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { getFormHelpers } from "#/utils/formUtils";
import { BackButton } from "../BackButton";
import { ConfirmDeleteDialog } from "../ConfirmDeleteDialog";
import type { ProviderState } from "./ChatModelAdminPanel";
import { readOptionalString } from "./helpers";
import {
GeneralModelConfigFields,
ModelConfigFields,
PricingModelConfigFields,
} from "./ModelConfigFields";
import { ModelIdentifierField } from "./ModelIdentifierField";
import {
buildInitialModelFormValues,
buildModelConfigFromForm,
type ModelFormValues,
parsePositiveInteger,
parseThresholdInteger,
} from "./modelConfigFormLogic";
import { ProviderIcon } from "./ProviderIcon";
// ── Validation ──────────────────────────────────────────────────
const validationSchema = Yup.object({
model: Yup.string().trim().required("Model ID is required."),
displayName: Yup.string(),
enabled: Yup.boolean(),
contextLimit: Yup.string()
.required("Context limit is required.")
.test(
"positive-integer",
"Context limit must be a positive integer.",
(value) => !value?.trim() || parsePositiveInteger(value) !== null,
),
compressionThreshold: Yup.string().test(
"threshold-range",
"Compression threshold must be a number between 0 and 100.",
(value) => !value?.trim() || parseThresholdInteger(value) !== null,
),
isDefault: Yup.boolean(),
});
// ── Component ──────────────────────────────────────────────────
interface ModelFormProps {
/** When set, the form is in "edit" mode for the given model. */
editingModel?: TypesGen.ChatModelConfig;
/** When set without editingModel, the form creates from this model. */
duplicateSourceModel?: TypesGen.ChatModelConfig;
providerStates: readonly ProviderState[];
selectedProvider: string | null;
selectedProviderState: ProviderState | null;
onSelectedProviderChange: (provider: string) => void;
modelConfigsUnavailable: boolean;
isSaving: boolean;
isDeleting: boolean;
onCreateModel: (
req: TypesGen.CreateChatModelConfigRequest,
) => Promise<unknown>;
onUpdateModel: (
modelConfigId: string,
req: TypesGen.UpdateChatModelConfigRequest,
) => Promise<unknown>;
onCancel: () => void;
onDeleteModel?: (modelConfigId: string) => Promise<void>;
}
export const ModelForm: FC<ModelFormProps> = ({
editingModel,
duplicateSourceModel,
providerStates,
selectedProvider,
selectedProviderState,
onSelectedProviderChange,
modelConfigsUnavailable,
isSaving,
isDeleting,
onCreateModel,
onUpdateModel,
onCancel,
onDeleteModel,
}) => {
const initialModel = editingModel ?? duplicateSourceModel;
const isEditing = Boolean(editingModel);
const isDuplicating = Boolean(duplicateSourceModel) && !isEditing;
const initialValues = {
...buildInitialModelFormValues(initialModel),
...(isDuplicating && { isDefault: false }),
};
const [showAdvanced, setShowAdvanced] = useState(false);
const [showPricing, setShowPricing] = useState(false);
const [showProviderConfig, setShowProviderConfig] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const canManageModels = Boolean(
selectedProviderState?.providerConfig &&
(selectedProviderState.hasEffectiveAPIKey ||
selectedProviderState.providerConfig.allow_user_api_key),
);
const formTitle = isEditing
? "Edit model"
: isDuplicating
? "Duplicate model"
: "Add model";
const formDescription = isDuplicating
? "Review the copied settings, then save to create a new model."
: undefined;
const mode: "add" | "edit" | "duplicate" = (() => {
if (isEditing) return "edit";
if (isDuplicating) return "duplicate";
return "add";
})();
const selectedProviderType =
selectedProviderState?.provider ?? selectedProvider;
const form = useFormik<ModelFormValues>({
initialValues,
validationSchema,
validateOnMount: true,
validateOnBlur: false,
onSubmit: async (values) => {
if (isSaving) return;
const trimmedModel = values.model.trim();
if (!trimmedModel) return;
const parsedContextLimit = parsePositiveInteger(values.contextLimit);
const parsedCompressionThreshold = parseThresholdInteger(
values.compressionThreshold,
);
const buildResult = buildModelConfigFromForm(
selectedProviderType,
values.config,
);
if (Object.keys(buildResult.fieldErrors).length > 0) return;
const trimmedDisplayName = values.displayName.trim();
const builtModelConfig = buildResult.modelConfig;
const selectedProviderConfigID =
selectedProviderState?.providerConfig?.id;
if (isEditing && editingModel) {
const req: TypesGen.UpdateChatModelConfigRequest = {
...(selectedProviderConfigID &&
selectedProviderConfigID !==
readOptionalString(editingModel.ai_provider_id) && {
provider: selectedProviderState.provider,
ai_provider_id: selectedProviderConfigID,
}),
...(trimmedModel !== editingModel.model && {
model: trimmedModel,
}),
...(trimmedDisplayName !== (editingModel.display_name ?? "") && {
display_name: trimmedDisplayName,
}),
...(values.enabled !== editingModel.enabled && {
enabled: values.enabled,
}),
...(parsedContextLimit !== null &&
parsedContextLimit !== editingModel.context_limit && {
context_limit: parsedContextLimit,
}),
...(parsedCompressionThreshold !== null &&
parsedCompressionThreshold !==
editingModel.compression_threshold && {
compression_threshold: parsedCompressionThreshold,
}),
...(values.isDefault !== editingModel.is_default && {
is_default: values.isDefault,
}),
// Always send model_config so it can be cleared or updated.
model_config: builtModelConfig,
};
await onUpdateModel(editingModel.id, req);
} else {
if (!selectedProvider || !selectedProviderState?.providerConfig) return;
const req: TypesGen.CreateChatModelConfigRequest = {
provider: selectedProviderState.provider,
ai_provider_id: selectedProviderState.providerConfig.id,
model: trimmedModel,
enabled: values.enabled,
is_default: values.isDefault,
...(parsedContextLimit !== null && {
context_limit: parsedContextLimit,
}),
...(parsedCompressionThreshold !== null && {
compression_threshold: parsedCompressionThreshold,
}),
...(trimmedDisplayName && {
display_name: trimmedDisplayName,
}),
...(builtModelConfig && {
model_config: builtModelConfig,
}),
};
await onCreateModel(req);
}
// Navigation is handled by the parent (ModelsSection) after
// the mutation promise resolves, so we do not call onCancel()
// here to avoid a double view-transition.
},
});
const getFieldHelpers = getFormHelpers(form);
const modelConfigFormBuildResult = buildModelConfigFromForm(
selectedProviderType,
form.values.config,
);
const hasFieldErrors =
Object.keys(modelConfigFormBuildResult.fieldErrors).length > 0;
const defaultModelDisableGuard =
isEditing && form.values.isDefault && form.values.enabled;
// ── Provider select (shared across all form states) ───────
const providerSelect = (
<div className="grid gap-1.5">
<Label
htmlFor="providerSelect"
className="text-[13px] font-medium text-content-primary"
>
Provider
</Label>
<Select
value={selectedProvider ?? ""}
onValueChange={onSelectedProviderChange}
disabled={
((isEditing || isDuplicating) && selectedProviderState !== null) ||
providerStates.length === 0
}
>
<SelectTrigger
id="providerSelect"
className="h-10 max-w-[240px] text-[13px]"
>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{providerStates.map((ps) => (
<SelectItem key={ps.key} value={ps.key}>
<span className="flex items-center gap-2">
<ProviderIcon provider={ps.provider} className="size-4" />
{ps.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
// No provider selected or configs unavailable.
if (!selectedProviderState || modelConfigsUnavailable) {
return (
<div>
<BackButton onClick={onCancel} />
<h2 className="m-0 text-lg font-medium text-content-primary">
{formTitle}
</h2>
<hr className="my-4 border-0 border-t border-solid border-border" />
<div className="space-y-3">{providerSelect}</div>
</div>
);
}
// Provider can't manage models.
if (!canManageModels && !isEditing) {
return (
<div>
<BackButton onClick={onCancel} />
<h2 className="m-0 text-lg font-medium text-content-primary">
{formTitle}
</h2>
<hr className="my-4 border-0 border-t border-solid border-border" />
<div className="space-y-3">
{providerSelect}
<p className="text-sm text-content-secondary">
{!selectedProviderState.providerConfig
? "Create a managed provider config on the Providers tab before managing models."
: "Set an API key for this provider on the Providers tab before managing models."}
</p>
</div>
</div>
);
}
// ── Full form ─────────────────────────────────────────────
const modelField = getFieldHelpers("model");
const contextLimitField = getFieldHelpers("contextLimit");
const compressionThresholdField = getFieldHelpers("compressionThreshold");
return (
<div className="flex min-h-full flex-col">
{/* Back */}
<BackButton onClick={onCancel} />
<div className="mb-4">
<h2 className="m-0 text-lg font-medium text-content-primary">
{formTitle}
</h2>
{formDescription && (
<p className="m-0 mt-1 text-sm text-content-secondary">
{formDescription}
</p>
)}
</div>
{/* Header - editable display name */}
<div className="flex items-center gap-3">
{selectedProviderState && (
<ProviderIcon
provider={selectedProviderState.provider}
className="size-8"
/>
)}
<div className="inline-flex items-center gap-1">
<div className="relative inline-grid">
<span
className="invisible col-start-1 row-start-1 whitespace-pre text-lg font-medium"
aria-hidden="true"
>
{form.values.displayName || initialModel?.model || "Model name"}
</span>
<input
type="text"
{...form.getFieldProps("displayName")}
disabled={isSaving}
spellCheck={false}
className="col-start-1 row-start-1 m-0 min-w-0 border-0 bg-transparent p-0 text-lg font-medium text-content-primary outline-none placeholder:text-content-secondary focus:ring-0"
placeholder={initialModel?.model ?? "Model name"}
/>
</div>
<PencilIcon className="size-3.5 shrink-0 text-content-secondary" />
</div>{" "}
{initialModel && (
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-auto inline-flex">
<Switch
checked={form.values.enabled}
onCheckedChange={(v) => {
form.setFieldValue("enabled", v);
}}
aria-label="Enabled"
disabled={isSaving || defaultModelDisableGuard}
/>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{defaultModelDisableGuard
? "Default model cannot be disabled. Remove default status first."
: form.values.enabled
? "Disable this model. It will be hidden from users."
: "Enable this model. It will be visible to users."}
</TooltipContent>
</Tooltip>
)}
</div>
<hr className="my-4 border-0 border-t border-solid border-border" />
{/* Form body */}
<form
className="flex flex-1 flex-col"
onSubmit={form.handleSubmit}
spellCheck={false}
autoComplete="off"
>
<div className="space-y-6">
{/* Model ID + Context limit + Pricing */}
<div className="space-y-4">
<div className="grid items-start gap-4 sm:grid-cols-2">
{" "}
<ModelIdentifierField
form={form}
modelField={modelField}
mode={mode}
selectedProvider={selectedProviderType}
disabled={isSaving}
/>
<div className="grid gap-1.5">
<Label
htmlFor={contextLimitField.id}
className="inline-flex items-center gap-1 text-sm font-medium text-content-primary"
>
Context limit{" "}
<span className="text-xs font-bold text-content-destructive">
*
</span>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="size-3 text-content-secondary" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
Max tokens in the context window.
</TooltipContent>
</Tooltip>
</Label>
<InputGroup
className={cn(
"h-9",
contextLimitField.error && "border-border-destructive",
)}
>
<InputGroupInput
id={contextLimitField.id}
name={contextLimitField.name}
className="h-9 min-w-0 text-[13px] placeholder:text-content-disabled"
placeholder="200000"
value={contextLimitField.value}
onChange={contextLimitField.onChange}
onBlur={contextLimitField.onBlur}
disabled={isSaving}
aria-invalid={contextLimitField.error}
/>
<InputGroupAddon align="inline-end">
<span className="text-xs text-content-disabled">
tokens
</span>
</InputGroupAddon>
</InputGroup>{" "}
{contextLimitField.error && (
<p className="m-0 text-xs text-content-destructive">
{contextLimitField.helperText}
</p>
)}
</div>
</div>
</div>
{/* Cost tracking */}
<div className="border-0 border-t border-solid border-border pt-4">
<button
type="button"
onClick={() => setShowPricing((v) => !v)}
className="flex w-full cursor-pointer items-start justify-between border-0 bg-transparent p-0 text-left transition-colors hover:text-content-primary"
>
<div>
<h3 className="m-0 text-sm font-medium text-content-primary">
Cost tracking
</h3>
<p className="m-0 text-xs text-content-secondary">
Set per-token pricing so Coder can track costs and enforce
spending limits.
</p>
</div>
{showPricing ? (
<ChevronDownIcon className="mt-0.5 size-4 shrink-0 text-content-secondary" />
) : (
<ChevronRightIcon className="mt-0.5 size-4 shrink-0 text-content-secondary" />
)}
</button>
{showPricing && (
<div className="grid grid-cols-2 gap-3 pt-3 sm:grid-cols-4">
<PricingModelConfigFields
provider={selectedProviderState.provider}
form={form}
fieldErrors={modelConfigFormBuildResult.fieldErrors}
disabled={isSaving}
/>
</div>
)}
</div>
{/* Provider configuration */}
<div className="border-0 border-t border-solid border-border pt-4">
<button
type="button"
onClick={() => setShowProviderConfig((v) => !v)}
className="flex w-full cursor-pointer items-start justify-between border-0 bg-transparent p-0 text-left transition-colors hover:text-content-primary"
>
<div>
<h3 className="m-0 text-sm font-medium text-content-primary">
Provider configuration
</h3>
<p className="m-0 text-xs text-content-secondary">
Tune provider-specific behavior like reasoning, tool calling,
and web search.
</p>
</div>
{showProviderConfig ? (
<ChevronDownIcon className="mt-0.5 size-4 shrink-0 text-content-secondary" />
) : (
<ChevronRightIcon className="mt-0.5 size-4 shrink-0 text-content-secondary" />
)}
</button>
{showProviderConfig && (
<div className="pt-3">
<ModelConfigFields
provider={selectedProviderState.provider}
form={form}
fieldErrors={modelConfigFormBuildResult.fieldErrors}
disabled={isSaving}
/>
</div>
)}
</div>
{/* Advanced */}
<div className="border-0 border-t border-solid border-border pt-4">
<button
type="button"
onClick={() => setShowAdvanced((v) => !v)}
className="flex w-full cursor-pointer items-start justify-between border-0 bg-transparent p-0 text-left transition-colors hover:text-content-primary"
>
<div>
<h3 className="m-0 text-sm font-medium text-content-primary">
Advanced
</h3>
<p className="m-0 text-xs text-content-secondary">
Low-level parameters like temperature and penalties. Rarely
need changing.
</p>
</div>
{showAdvanced ? (
<ChevronDownIcon className="mt-0.5 size-4 shrink-0 text-content-secondary" />
) : (
<ChevronRightIcon className="mt-0.5 size-4 shrink-0 text-content-secondary" />
)}
</button>
{showAdvanced && (
<div className="grid grid-cols-2 gap-3 pt-3 sm:grid-cols-3">
<GeneralModelConfigFields
provider={selectedProviderState.provider}
form={form}
fieldErrors={modelConfigFormBuildResult.fieldErrors}
disabled={isSaving}
/>
<div className="flex min-w-0 flex-col gap-1.5">
<Label
htmlFor={compressionThresholdField.id}
className="inline-flex items-center gap-1 text-[13px] font-medium text-content-primary"
>
Compression threshold
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="size-3 text-content-secondary" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
Percentage at which context is compressed.
</TooltipContent>
</Tooltip>
</Label>
<InputGroup
className={cn(
"h-9",
compressionThresholdField.error &&
"border-border-destructive",
)}
>
<InputGroupInput
id={compressionThresholdField.id}
name={compressionThresholdField.name}
className="h-9 text-[13px] placeholder:text-content-disabled"
placeholder="70"
value={compressionThresholdField.value}
onChange={compressionThresholdField.onChange}
onBlur={compressionThresholdField.onBlur}
disabled={isSaving}
aria-invalid={compressionThresholdField.error}
/>
<InputGroupAddon align="inline-end">
<span className="text-xs text-content-disabled">%</span>
</InputGroupAddon>
</InputGroup>
{compressionThresholdField.error && (
<p className="m-0 text-xs text-content-destructive">
{compressionThresholdField.helperText}
</p>
)}
</div>
</div>
)}
</div>
</div>
<div className="mt-auto py-6">
<hr className="mb-4 border-0 border-t border-solid border-border" />
<div className="flex items-center justify-between">
{isEditing && editingModel && onDeleteModel ? (
<Button
variant="outline"
size="lg"
type="button"
className="text-content-secondary hover:text-content-destructive hover:border-border-destructive"
disabled={isSaving}
onClick={() => setConfirmingDelete(true)}
>
Delete
</Button>
) : (
<Button
variant="outline"
size="lg"
type="button"
onClick={onCancel}
>
Cancel
</Button>
)}
<Button
size="lg"
type="submit"
disabled={isSaving || !form.isValid || hasFieldErrors}
>
{isSaving && <Spinner className="h-4 w-4" loading />}{" "}
{isEditing
? "Save"
: isDuplicating
? "Create duplicate"
: "Add model"}
</Button>
</div>
</div>
</form>
{editingModel && onDeleteModel && (
<ConfirmDeleteDialog
entity="model"
onConfirm={() => void onDeleteModel(editingModel.id)}
isPending={isDeleting}
open={confirmingDelete}
onOpenChange={(open) => !open && setConfirmingDelete(false)}
/>
)}{" "}
</div>
);
};
@@ -1,5 +1,5 @@
import type { FormikContextType } from "formik";
import { CheckIcon, InfoIcon } from "lucide-react";
import { CheckIcon } from "lucide-react";
import {
type FocusEvent,
type KeyboardEvent,
@@ -10,14 +10,9 @@ import {
import { Autocomplete } from "#/components/Autocomplete/Autocomplete";
import { Input } from "#/components/Input/Input";
import { Label } from "#/components/Label/Label";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { normalizeProvider } from "#/modules/aiModels/helpers";
import { cn } from "#/utils/cn";
import type { FormHelpers } from "#/utils/formUtils";
import { normalizeProvider } from "./helpers";
import {
findKnownModelByCanonicalId,
findKnownModelByExactAlias,
@@ -37,6 +32,7 @@ type ModelIdentifierFieldProps = {
mode: ModelFormMode;
selectedProvider: string | null;
disabled: boolean;
controlClassName?: string;
};
type ModelIdentifierOption = {
@@ -69,6 +65,7 @@ export const ModelIdentifierField = ({
mode,
selectedProvider,
disabled,
controlClassName,
}: ModelIdentifierFieldProps) => {
const [initialFormValues] = useState(() => form.initialValues);
const [open, setOpen] = useState(false);
@@ -383,7 +380,8 @@ export const ModelIdentifierField = ({
id={modelField.id}
name={modelField.name}
className={cn(
"h-9 text-[13px] placeholder:text-content-disabled",
"placeholder:text-content-disabled",
controlClassName,
hasError && "border-content-destructive",
)}
placeholder="e.g. gpt-5, claude-sonnet-4-5"
@@ -437,7 +435,8 @@ export const ModelIdentifierField = ({
onEnterEmpty={() => handleOpenChange(false)}
placeholder="e.g. gpt-5, claude-sonnet-4-5"
className={cn(
"h-9 text-[13px] placeholder:text-content-disabled",
"placeholder:text-content-disabled",
controlClassName,
hasError && "border-content-destructive",
)}
triggerAriaInvalid={hasError}
@@ -457,19 +456,14 @@ export const ModelIdentifierField = ({
>
<Label
htmlFor={modelField.id}
className="inline-flex items-center gap-1 text-sm font-medium text-content-primary"
className="flex items-center gap-1 leading-6 text-content-primary"
>
Model identifier{" "}
<span className="text-xs font-bold text-content-destructive">*</span>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="size-3 text-content-secondary" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
The model identifier sent to the provider API.
</TooltipContent>
</Tooltip>
</Label>
<p className="m-0 text-xs text-content-secondary">
The model identifier sent to the provider API.
</p>
{renderControl()}
{hasError && (
<p id={errorId} className="m-0 text-xs text-content-destructive">
@@ -1,471 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { TooltipProvider } from "#/components/Tooltip/Tooltip";
import {
MockChatModelConfig,
MockChatProviderConfig,
} from "#/testHelpers/chatModels";
import type { ProviderState } from "./ChatModelAdminPanel";
import { ModelsSection } from "./ModelsSection";
const providerState: ProviderState = {
key: "provider-config-id",
provider: "openai",
label: "OpenAI",
providerConfig: {
...MockChatProviderConfig,
id: "provider-config-id",
allow_central_api_key_fallback: false,
base_url: undefined,
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
},
modelConfigs: [],
catalogModelCount: 0,
hasManagedAPIKey: true,
hasCatalogAPIKey: true,
hasEffectiveAPIKey: true,
allowUserAPIKey: false,
isEnvPreset: false,
baseURL: "",
};
const providerStateWithoutAPIKey: ProviderState = {
...providerState,
providerConfig: {
...providerState.providerConfig!,
has_api_key: false,
central_api_key_enabled: false,
allow_user_api_key: false,
},
hasManagedAPIKey: false,
hasCatalogAPIKey: false,
hasEffectiveAPIKey: false,
allowUserAPIKey: false,
};
const baseModelConfig: TypesGen.ChatModelConfig = {
...MockChatModelConfig,
id: "model-config-id",
model: "gpt-4.1",
display_name: "GPT-4.1",
context_limit: 128000,
compression_threshold: 80,
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
const disabledModelConfig: TypesGen.ChatModelConfig = {
...baseModelConfig,
id: "disabled-model-config-id",
model: "gpt-4.1-disabled",
display_name: "GPT-4.1 Disabled",
enabled: false,
};
const defaultModelConfig: TypesGen.ChatModelConfig = {
...baseModelConfig,
id: "default-model-config-id",
model: "gpt-4o",
display_name: "GPT-4o",
is_default: true,
};
const duplicateSourceModel: TypesGen.ChatModelConfig = {
...baseModelConfig,
id: "duplicate-source-model-id",
model: "gpt-4.1-default",
display_name: "GPT-4.1 Default",
is_default: true,
context_limit: 200000,
compression_threshold: 65,
model_config: {
max_output_tokens: 4096,
provider_options: {
openai: {
max_tool_calls: 4,
reasoning_effort: "high",
},
},
},
};
const meta: Meta<typeof ModelsSection> = {
title: "pages/AgentsPage/ChatModelAdminPanel/ModelsSection",
component: ModelsSection,
args: {
sectionLabel: "Models",
providerStates: [providerState],
modelConfigs: [baseModelConfig],
modelConfigsUnavailable: false,
isCreating: false,
isUpdating: false,
isDeleting: false,
onCreateModel: fn(async () => undefined),
onUpdateModel: fn(async () => undefined),
onDeleteModel: fn(async () => undefined),
},
decorators: [
(Story) => (
<TooltipProvider>
<Story />
</TooltipProvider>
),
],
};
export default meta;
type Story = StoryObj<typeof ModelsSection>;
export const ShowsPricingWarning: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByText("Model pricing is not defined"),
).toBeInTheDocument();
},
};
export const HidesPricingWarningForExplicitZeroPricing: Story = {
args: {
modelConfigs: [
{
...baseModelConfig,
id: "model-config-id-zero-pricing",
model_config: {
cost: {
output_price_per_million_tokens: "0",
},
},
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.queryByText("Model pricing is not defined"),
).not.toBeInTheDocument();
},
};
export const LinksToProvidersFromEmptyState: Story = {
args: {
providerStates: [providerStateWithoutAPIKey],
modelConfigs: [],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const providerLink = canvas.getByRole("link", { name: /provider/i });
await expect(canvas.getByText("No models configured yet.")).toBeVisible();
await expect(providerLink).toBeVisible();
expect(providerLink).toHaveAttribute("href", "/ai/settings");
},
};
export const ShowsExplicitRowActions: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const user = userEvent.setup();
const rowButton = canvas.getByRole("button", {
name: "Open model: GPT-4.1",
});
const starButton = canvas.getByRole("button", {
name: "Set as default model: GPT-4.1",
});
const editButton = canvas.getByRole("button", {
name: "Edit model: GPT-4.1",
});
const copyButton = canvas.getByRole("button", {
name: "Duplicate model: GPT-4.1",
});
await expect(starButton).toBeVisible();
await expect(editButton).toBeVisible();
await expect(copyButton).toBeVisible();
rowButton.focus();
await expect(rowButton).toHaveFocus();
await user.tab();
await expect(starButton).toHaveFocus();
await user.tab();
await expect(editButton).toHaveFocus();
await user.tab();
await expect(copyButton).toHaveFocus();
},
};
export const OpensDuplicateFormWithoutCreating: Story = {
args: {
modelConfigs: [duplicateSourceModel],
onCreateModel: fn(async () => undefined),
onUpdateModel: fn(async () => undefined),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", {
name: "Duplicate model: GPT-4.1 Default",
}),
);
await expect(canvas.findByText("Duplicate model")).resolves.toBeVisible();
expect(args.onCreateModel).not.toHaveBeenCalled();
expect(args.onUpdateModel).not.toHaveBeenCalled();
expect(canvas.getByDisplayValue("GPT-4.1 Default")).toBeVisible();
expect(canvas.getByLabelText(/Model identifier/)).toHaveValue(
"gpt-4.1-default",
);
expect(canvas.getByLabelText(/Context limit/)).toHaveValue("200000");
const enabledSwitch = canvas.getByRole("switch", { name: "Enabled" });
expect(enabledSwitch).toBeChecked();
expect(enabledSwitch).toBeEnabled();
await userEvent.click(canvas.getByRole("button", { name: /Advanced/ }));
expect(canvas.getByLabelText(/Compression threshold/)).toHaveValue("65");
await userEvent.click(
canvas.getByRole("button", { name: /Provider configuration/ }),
);
expect(canvas.getByLabelText("Max Tool Calls")).toHaveValue("4");
},
};
export const AbandonsDuplicateWithoutSaving: Story = {
args: {
modelConfigs: [duplicateSourceModel],
onCreateModel: fn(async () => undefined),
onUpdateModel: fn(async () => undefined),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const copyButtonName = "Duplicate model: GPT-4.1 Default";
await userEvent.click(canvas.getByRole("button", { name: copyButtonName }));
await expect(canvas.findByText("Duplicate model")).resolves.toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Cancel" }));
await expect(
canvas.findByRole("button", { name: copyButtonName }),
).resolves.toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: copyButtonName }));
await expect(canvas.findByText("Duplicate model")).resolves.toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Back" }));
await expect(
canvas.findByRole("button", { name: copyButtonName }),
).resolves.toBeVisible();
expect(args.onCreateModel).not.toHaveBeenCalled();
expect(args.onUpdateModel).not.toHaveBeenCalled();
},
};
export const SavesDuplicateAsCreateRequest: Story = {
args: {
modelConfigs: [duplicateSourceModel],
onCreateModel: fn(async () => undefined),
onUpdateModel: fn(async () => undefined),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", {
name: "Duplicate model: GPT-4.1 Default",
}),
);
await expect(canvas.findByText("Duplicate model")).resolves.toBeVisible();
const modelInput = canvas.getByLabelText(/Model identifier/);
await userEvent.clear(modelInput);
await userEvent.type(modelInput, "gpt-4.1-copy");
const displayNameInput = canvas.getByDisplayValue("GPT-4.1 Default");
await userEvent.clear(displayNameInput);
await userEvent.type(displayNameInput, "GPT-4.1 Copy");
await userEvent.click(
canvas.getByRole("button", { name: "Create duplicate" }),
);
await waitFor(() => expect(args.onCreateModel).toHaveBeenCalledTimes(1));
expect(args.onUpdateModel).not.toHaveBeenCalled();
const createModelMock = args.onCreateModel as ReturnType<typeof fn>;
const createReq = createModelMock.mock.calls[0]?.[0];
if (!createReq) {
throw new Error("Expected create request.");
}
expect(createReq).toEqual({
ai_provider_id: "provider-config-id",
provider: "openai",
model: "gpt-4.1-copy",
display_name: "GPT-4.1 Copy",
enabled: true,
is_default: false,
context_limit: 200000,
compression_threshold: 65,
model_config: {
max_output_tokens: 4096,
provider_options: {
openai: {
max_tool_calls: 4,
reasoning_effort: "high",
},
},
},
});
},
};
export const SavesNonDefaultDuplicateWithEditableEnabled: Story = {
args: {
modelConfigs: [baseModelConfig],
onCreateModel: fn(async () => undefined),
onUpdateModel: fn(async () => undefined),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Duplicate model: GPT-4.1" }),
);
await expect(canvas.findByText("Duplicate model")).resolves.toBeVisible();
const enabledSwitch = canvas.getByRole("switch", { name: "Enabled" });
expect(enabledSwitch).toBeChecked();
expect(enabledSwitch).toBeEnabled();
await userEvent.click(enabledSwitch);
const modelInput = canvas.getByLabelText(/Model identifier/);
await userEvent.clear(modelInput);
await userEvent.type(modelInput, "gpt-4.1-copy");
await userEvent.click(
canvas.getByRole("button", { name: "Create duplicate" }),
);
await waitFor(() => expect(args.onCreateModel).toHaveBeenCalledTimes(1));
expect(args.onUpdateModel).not.toHaveBeenCalled();
const createModelMock = args.onCreateModel as ReturnType<typeof fn>;
expect(createModelMock.mock.calls[0]?.[0]).toEqual({
ai_provider_id: "provider-config-id",
provider: "openai",
model: "gpt-4.1-copy",
display_name: "GPT-4.1",
enabled: false,
is_default: false,
context_limit: 128000,
compression_threshold: 80,
});
},
};
export const SavesDisabledDuplicateWithEditableEnabled: Story = {
args: {
modelConfigs: [disabledModelConfig],
onCreateModel: fn(async () => undefined),
onUpdateModel: fn(async () => undefined),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", {
name: "Duplicate model: GPT-4.1 Disabled",
}),
);
await expect(canvas.findByText("Duplicate model")).resolves.toBeVisible();
const enabledSwitch = canvas.getByRole("switch", { name: "Enabled" });
expect(enabledSwitch).not.toBeChecked();
expect(enabledSwitch).toBeEnabled();
await userEvent.click(enabledSwitch);
const modelInput = canvas.getByLabelText(/Model identifier/);
await userEvent.clear(modelInput);
await userEvent.type(modelInput, "gpt-4.1-disabled-copy");
await userEvent.click(
canvas.getByRole("button", { name: "Create duplicate" }),
);
await waitFor(() => expect(args.onCreateModel).toHaveBeenCalledTimes(1));
const createModelMock = args.onCreateModel as ReturnType<typeof fn>;
expect(createModelMock.mock.calls[0]?.[0]).toEqual({
ai_provider_id: "provider-config-id",
provider: "openai",
model: "gpt-4.1-disabled-copy",
display_name: "GPT-4.1 Disabled",
enabled: true,
is_default: false,
context_limit: 128000,
compression_threshold: 80,
});
},
};
export const DisablesDuplicateWhenProviderCannotManageModels: Story = {
args: {
providerStates: [providerStateWithoutAPIKey],
modelConfigs: [baseModelConfig],
onCreateModel: fn(async () => undefined),
onUpdateModel: fn(async () => undefined),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const duplicateButton = canvas.getByRole("button", {
name: "Duplicate model: GPT-4.1",
});
expect(duplicateButton).toHaveAttribute("aria-disabled", "true");
await userEvent.click(duplicateButton);
expect(canvas.queryByText("Duplicate model")).not.toBeInTheDocument();
expect(args.onCreateModel).not.toHaveBeenCalled();
},
};
export const RowActionsDoNotOpenRowBody: Story = {
args: {
modelConfigs: [baseModelConfig, defaultModelConfig, disabledModelConfig],
onCreateModel: fn(async () => undefined),
onUpdateModel: fn(async () => undefined),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const updateModelMock = args.onUpdateModel as ReturnType<typeof fn>;
await userEvent.click(
canvas.getByRole("button", {
name: "Set as default model: GPT-4.1",
}),
);
await waitFor(() => expect(args.onUpdateModel).toHaveBeenCalledTimes(1));
expect(updateModelMock.mock.calls[0]).toEqual([
"model-config-id",
{ is_default: true },
]);
expect(canvas.queryByText("Edit model")).not.toBeInTheDocument();
await userEvent.click(
canvas.getByRole("button", { name: "Default model: GPT-4o" }),
);
expect(args.onUpdateModel).toHaveBeenCalledTimes(1);
expect(canvas.queryByText("Edit model")).not.toBeInTheDocument();
const disabledStarButton = canvas.getByRole("button", {
name: "Set as default model: GPT-4.1 Disabled",
});
expect(disabledStarButton).toHaveAttribute("aria-disabled", "true");
await userEvent.click(disabledStarButton);
expect(args.onUpdateModel).toHaveBeenCalledTimes(1);
expect(canvas.queryByText("Edit model")).not.toBeInTheDocument();
await userEvent.click(
canvas.getByRole("button", { name: "Duplicate model: GPT-4.1" }),
);
await expect(canvas.findByText("Duplicate model")).resolves.toBeVisible();
expect(args.onCreateModel).not.toHaveBeenCalled();
expect(args.onUpdateModel).toHaveBeenCalledTimes(1);
expect(canvas.queryByText("Edit model")).not.toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Back" }));
await userEvent.click(
await canvas.findByRole("button", { name: "Edit model: GPT-4.1" }),
);
await expect(canvas.findByText("Edit model")).resolves.toBeVisible();
},
};
@@ -1,471 +0,0 @@
import {
ChevronDownIcon,
CopyIcon,
PencilIcon,
PlusIcon,
StarIcon,
TriangleAlertIcon,
} from "lucide-react";
import { type FC, useEffect, useState } from "react";
import { Link, useLocation, useSearchParams } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { SectionHeader } from "../SectionHeader";
import type { ProviderState } from "./ChatModelAdminPanel";
import { normalizeProvider, readOptionalString } from "./helpers";
import { ModelForm } from "./ModelForm";
import { ProviderIcon } from "./ProviderIcon";
import { hasCustomPricing } from "./pricingFields";
type ModelView =
| { mode: "list" }
| { mode: "add"; provider: string }
| { mode: "edit"; model: TypesGen.ChatModelConfig }
| { mode: "duplicate"; sourceModel: TypesGen.ChatModelConfig };
const MODEL_VIEW_PARAMS = ["model", "newModel", "duplicate"] as const;
type ModelViewParam = (typeof MODEL_VIEW_PARAMS)[number];
const clearModelViewParams = (params: URLSearchParams) => {
for (const param of MODEL_VIEW_PARAMS) {
params.delete(param);
}
};
const modelConfigProviderKey = (
modelConfig: TypesGen.ChatModelConfig,
providerStates: readonly ProviderState[],
): string => {
const providerID = readOptionalString(modelConfig.ai_provider_id);
if (providerID) {
return providerID;
}
const provider = normalizeProvider(modelConfig.provider);
const providerMatches = providerStates.filter(
(providerState) => providerState.provider === provider,
);
if (providerMatches.length === 1) {
return providerMatches[0].key;
}
if (providerMatches.length > 1) {
return "";
}
return provider;
};
const canManageProviderModels = (providerState: ProviderState | undefined) => {
return Boolean(
providerState?.providerConfig &&
(providerState.hasEffectiveAPIKey ||
providerState.providerConfig.allow_user_api_key),
);
};
interface ModelsSectionProps {
sectionLabel?: string;
sectionDescription?: string;
providerStates: readonly ProviderState[];
modelConfigs: readonly TypesGen.ChatModelConfig[];
modelConfigsUnavailable: boolean;
isCreating: boolean;
isUpdating: boolean;
isDeleting: boolean;
onCreateModel: (
req: TypesGen.CreateChatModelConfigRequest,
) => Promise<unknown>;
onUpdateModel: (
modelConfigId: string,
req: TypesGen.UpdateChatModelConfigRequest,
) => Promise<unknown>;
onDeleteModel: (modelConfigId: string) => Promise<void>;
}
export const ModelsSection: FC<ModelsSectionProps> = ({
sectionLabel,
sectionDescription,
providerStates,
modelConfigs,
modelConfigsUnavailable,
isCreating,
isUpdating,
isDeleting,
onCreateModel,
onUpdateModel,
onDeleteModel,
}) => {
const [searchParams, setSearchParams] = useSearchParams();
const [selectedProviderOverride, setSelectedProviderOverride] = useState<
string | null
>(null);
const location = useLocation();
// Derive the current view from URL search params so that
// browser back/forward navigation works as expected.
const view: ModelView = (() => {
const editModelId = searchParams.get("model");
if (editModelId) {
const model = modelConfigs.find((m) => m.id === editModelId);
return model ? { mode: "edit", model } : { mode: "list" };
}
const duplicateModelId = searchParams.get("duplicate");
if (duplicateModelId) {
const sourceModel = modelConfigs.find((m) => m.id === duplicateModelId);
return sourceModel
? { mode: "duplicate", sourceModel }
: { mode: "list" };
}
const addProvider = searchParams.get("newModel");
if (addProvider) {
return { mode: "add", provider: addProvider };
}
return { mode: "list" };
})();
const setModelViewParam = (
param: ModelViewParam,
value: string,
options?: { replace?: boolean },
) => {
const nextParams = new URLSearchParams(searchParams);
clearModelViewParams(nextParams);
nextParams.set(param, value);
setSearchParams(nextParams, {
replace: options?.replace,
state: options?.replace ? location.state : { pushed: true },
});
};
const modelViewIdentity = (() => {
switch (view.mode) {
case "add":
return `add:${view.provider}`;
case "edit":
return `edit:${view.model.id}`;
case "duplicate":
return `duplicate:${view.sourceModel.id}`;
default:
return "list";
}
})();
useEffect(() => {
void modelViewIdentity;
setSelectedProviderOverride(null);
}, [modelViewIdentity]);
// Clear model-related search params and return to the list.
const clearModelView = () => {
setSelectedProviderOverride(null);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
clearModelViewParams(next);
return next;
});
};
const exitModelView = () => {
setSelectedProviderOverride(null);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
clearModelViewParams(next);
return next;
},
{ replace: true },
);
};
// When the form is open it takes over the full panel.
if (
view.mode === "add" ||
view.mode === "edit" ||
view.mode === "duplicate"
) {
const editingModel = view.mode === "edit" ? view.model : undefined;
const duplicateSourceModel =
view.mode === "duplicate" ? view.sourceModel : undefined;
const effectiveProvider =
selectedProviderOverride ??
(view.mode === "edit"
? modelConfigProviderKey(view.model, providerStates)
: view.mode === "duplicate"
? modelConfigProviderKey(view.sourceModel, providerStates)
: view.provider);
const effectiveProviderState =
providerStates.find((ps) => ps.key === effectiveProvider) ?? null;
const formKey =
view.mode === "edit"
? `edit:${view.model.id}`
: view.mode === "duplicate"
? `duplicate:${view.sourceModel.id}`
: `add:${view.provider}`;
return (
<ModelForm
key={formKey}
editingModel={editingModel}
duplicateSourceModel={duplicateSourceModel}
providerStates={providerStates}
selectedProvider={effectiveProvider}
selectedProviderState={effectiveProviderState}
onSelectedProviderChange={(provider) => {
if (view.mode === "add") {
setModelViewParam("newModel", provider, { replace: true });
return;
}
setSelectedProviderOverride(provider);
}}
modelConfigsUnavailable={modelConfigsUnavailable}
isSaving={isCreating || isUpdating}
isDeleting={isDeleting}
onCreateModel={async (req) => {
await onCreateModel(req);
exitModelView();
}}
onUpdateModel={async (id, req) => {
await onUpdateModel(id, req);
clearModelView();
}}
onCancel={clearModelView}
onDeleteModel={
editingModel
? async (id) => {
await onDeleteModel(id);
exitModelView();
}
: undefined
}
/>
);
}
// ── List view ──────────────────────────────────────────────
// Only show providers that have a deployment key configured or allow
// end users to bring their own key.
const addableProviders = providerStates.filter(canManageProviderModels);
const addButton = addableProviders.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" className="gap-1.5" aria-label="Add model">
<PlusIcon className="size-4" />
Add
<ChevronDownIcon className="size-3.5 text-content-secondary" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{addableProviders.map((ps) => (
<DropdownMenuItem
key={ps.key}
onClick={() => {
setModelViewParam("newModel", ps.key);
}}
className="gap-2"
>
<ProviderIcon provider={ps.provider} className="size-5" />
{ps.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
const handleSetDefault = (modelConfig: TypesGen.ChatModelConfig) => {
if (isUpdating || modelConfig.is_default || !modelConfig.enabled) return;
void onUpdateModel(modelConfig.id, { is_default: true });
};
return (
<>
{sectionLabel && (
<SectionHeader
label={sectionLabel}
description={
sectionDescription ?? "Manage models available to Agents."
}
action={addButton || undefined}
/>
)}
{modelConfigs.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<p className="m-0 text-sm text-content-secondary">
No models configured yet.
</p>
{addableProviders.length > 0 && addButton}
{addableProviders.length === 0 && (
<p className="m-0 text-xs text-content-secondary">
Connect a{" "}
<Link
to="/ai/settings"
className="underline transition-colors hover:text-content-primary"
>
provider
</Link>{" "}
first to add models.
</p>
)}
</div>
) : (
<div>
{modelConfigs.map((modelConfig, i) => {
const showPricingWarning = !hasCustomPricing(
modelConfig.model_config,
);
const modelName = modelConfig.display_name || modelConfig.model;
const starLabel = modelConfig.is_default
? `Default model: ${modelName}`
: `Set as default model: ${modelName}`;
const starUnavailable =
isUpdating || modelConfig.is_default || !modelConfig.enabled;
const providerState = providerStates.find(
(ps) =>
ps.key === modelConfigProviderKey(modelConfig, providerStates),
);
const duplicateUnavailable = Boolean(
providerState && !canManageProviderModels(providerState),
);
return (
<div
key={modelConfig.id}
className={cn(
"flex items-center gap-3.5 px-3 py-3 transition-colors hover:bg-surface-secondary/30",
i > 0 && "border-0 border-t border-solid border-border/50",
)}
>
<button
type="button"
onClick={() => setModelViewParam("model", modelConfig.id)}
aria-label={`Open model: ${modelName}`}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3.5 border-0 bg-transparent p-0 text-left"
>
<ProviderIcon
provider={modelConfig.provider}
className="size-8 shrink-0"
/>
<div className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[15px] font-medium",
modelConfig.enabled === false
? "text-content-secondary"
: "text-content-primary",
)}
>
{modelName}
</span>
{showPricingWarning && (
<span className="mt-1 flex items-center gap-1 text-xs text-content-warning">
<TriangleAlertIcon className="size-3.5 shrink-0" />
Model pricing is not defined
</span>
)}
</div>
{modelConfig.enabled === false && (
<Badge size="xs" variant="warning">
disabled
</Badge>
)}
</button>
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="subtle"
onClick={(event) => {
event.stopPropagation();
handleSetDefault(modelConfig);
}}
aria-disabled={starUnavailable}
aria-label={starLabel}
className={cn(
"hover:bg-surface-secondary",
starUnavailable &&
"cursor-not-allowed text-content-secondary/40 hover:bg-transparent hover:text-content-secondary/40",
modelConfig.is_default && "text-content-primary",
)}
>
<StarIcon
className={cn(
modelConfig.is_default && "fill-current",
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{!modelConfig.enabled
? "Cannot set a disabled model as default"
: modelConfig.is_default
? "Default for new conversations"
: "Set as default for new conversations"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="subtle"
onClick={(event) => {
event.stopPropagation();
setModelViewParam("model", modelConfig.id);
}}
aria-label={`Edit model: ${modelName}`}
className="hover:bg-surface-secondary"
>
<PencilIcon />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit model</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="subtle"
onClick={(event) => {
event.stopPropagation();
if (duplicateUnavailable) return;
setModelViewParam("duplicate", modelConfig.id);
}}
aria-disabled={duplicateUnavailable}
aria-label={`Duplicate model: ${modelName}`}
className={cn(
"hover:bg-surface-secondary",
duplicateUnavailable &&
"cursor-not-allowed text-content-secondary/40 hover:bg-transparent hover:text-content-secondary/40",
)}
>
<CopyIcon />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{duplicateUnavailable
? "Set an API key for this provider before duplicating models"
: "Duplicate model"}
</TooltipContent>
</Tooltip>
</div>
</div>
);
})}
</div>
)}
</>
);
};
@@ -0,0 +1,35 @@
import type { FC, ReactNode } from "react";
interface ProviderFieldProps {
label: string;
htmlFor?: string;
required?: boolean;
description?: string;
children: ReactNode;
}
export const ProviderField: FC<ProviderFieldProps> = ({
label,
htmlFor,
required,
description,
children,
}) => (
<div className="grid gap-1.5">
<div className="flex items-baseline gap-1.5">
<label
htmlFor={htmlFor}
className="text-sm font-medium text-content-primary"
>
{label}
</label>
{required && (
<span className="text-xs font-bold text-content-destructive">*</span>
)}
</div>
{description && (
<p className="m-0 text-xs text-content-secondary">{description}</p>
)}
{children}
</div>
);
@@ -1,413 +0,0 @@
import { InfoIcon } from "lucide-react";
import {
type FC,
type FormEvent,
type ReactNode,
useId,
useState,
} from "react";
import { useNavigate } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import { Input } from "#/components/Input/Input";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { formatProviderLabel } from "../../utils/modelOptions";
import { BackButton } from "../BackButton";
import { ConfirmDeleteDialog } from "../ConfirmDeleteDialog";
import type {
CreateProviderResult,
ProviderState,
} from "./ChatModelAdminPanel";
import { getProviderBaseURLPlaceholder, readOptionalString } from "./helpers";
import { ProviderIcon } from "./ProviderIcon";
// Sentinel value used to represent an existing API key that the
// backend will not reveal. If the user has not touched the field,
// we know nothing changed.
const API_KEY_PLACEHOLDER = "••••••••••••••••";
interface ProviderFormProps {
providerState: ProviderState;
providerConfigsUnavailable: boolean;
isProviderMutationPending: boolean;
onCreateProvider: (
req: TypesGen.CreateChatProviderConfigRequest,
) => Promise<CreateProviderResult>;
onUpdateProvider: (
providerConfigId: string,
req: TypesGen.UpdateChatProviderConfigRequest,
) => Promise<unknown>;
onDeleteProvider: (providerConfigId: string) => Promise<void>;
onBack: () => void;
}
export const ProviderForm: FC<ProviderFormProps> = ({
providerState,
providerConfigsUnavailable,
isProviderMutationPending,
onCreateProvider,
onUpdateProvider,
onDeleteProvider,
onBack,
}) => {
const navigate = useNavigate();
const { provider, providerConfig, baseURL, isEnvPreset } = providerState;
const apiKeyInputId = useId();
const baseURLInputId = useId();
const baseURLPlaceholder = getProviderBaseURLPlaceholder(provider);
// Initial values are snapshotted when the provider config changes
// so we can detect dirty state.
const [initialValues] = useState(() => ({
displayName: readOptionalString(providerConfig?.display_name) ?? "",
baseURL,
}));
const [displayName, setDisplayName] = useState(initialValues.displayName);
const [apiKey, setApiKey] = useState(
providerState.hasManagedAPIKey ? API_KEY_PLACEHOLDER : "",
);
const [apiKeyTouched, setApiKeyTouched] = useState(false);
const [apiKeyModified, setApiKeyModified] = useState(false);
const [baseURLValue, setBaseURLValue] = useState(initialValues.baseURL);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const isBedrockProvider = provider === "bedrock";
const isAPIKeyEnvManaged = isEnvPreset && !providerConfig;
const requiresAPIKey =
!providerState.allowUserAPIKey &&
!isBedrockProvider &&
!providerState.hasManagedAPIKey;
const effectiveApiKey =
apiKeyTouched && apiKey !== API_KEY_PLACEHOLDER ? apiKey : "";
const hasTypedAPIKey = effectiveApiKey.length > 0;
const hasAPIKeyWhitespace =
hasTypedAPIKey && effectiveApiKey.trim() !== effectiveApiKey;
// Clearing a saved provider-scoped key switches the provider to
// BYOK-only behavior, or ambient AWS credentials for Bedrock.
const isClearingAPIKey =
providerState.hasManagedAPIKey && apiKeyModified && effectiveApiKey === "";
const hasPendingAPIKeyChange = hasTypedAPIKey || isClearingAPIKey;
const shouldCreateAPIKey = hasTypedAPIKey;
const apiKeyDescription = isBedrockProvider
? "Bearer token for Bedrock authentication. Leave empty to use ambient AWS credentials."
: "Secret key used to authenticate requests to this provider.";
const baseURLDescription = isBedrockProvider
? "Bedrock runtime endpoint. Use the AWS region for the models this provider should call."
: "Endpoint used to call this provider.";
const apiKeyPlaceholder = isBedrockProvider ? "Enter bearer token" : "sk-...";
const deleteProviderDescription =
"Are you sure you want to delete this provider? The provider will be " +
"disabled and hidden from new model configuration. Existing model " +
"configs that reference it remain saved but cannot run until updated.";
const hasNewProviderConfiguration = !providerConfig;
const isDirty =
displayName.trim() !== initialValues.displayName ||
hasPendingAPIKeyChange ||
baseURLValue.trim() !== initialValues.baseURL.trim() ||
hasNewProviderConfiguration;
const hasBaseURL = baseURLValue.trim().length > 0;
const canSave =
!providerConfigsUnavailable &&
!isProviderMutationPending &&
!isAPIKeyEnvManaged &&
isDirty &&
hasBaseURL &&
!hasAPIKeyWhitespace &&
(!requiresAPIKey || hasTypedAPIKey);
const canAddModel =
Boolean(providerConfig) &&
(providerState.hasEffectiveAPIKey ||
providerConfig?.allow_user_api_key === true);
const handleAddModel = () => {
const params = new URLSearchParams({ newModel: providerState.key });
navigate(`/agents/settings/models?${params.toString()}`, {
state: { pushed: true },
});
};
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
if (
providerConfigsUnavailable ||
isProviderMutationPending ||
isAPIKeyEnvManaged ||
!hasBaseURL ||
hasAPIKeyWhitespace
) {
return;
}
if (requiresAPIKey && !hasTypedAPIKey) {
return;
}
const trimmedDisplayName = displayName.trim();
const trimmedBaseURL = baseURLValue.trim();
if (providerConfig) {
const currentDisplayName =
readOptionalString(providerConfig.display_name) ?? "";
const currentBaseURL = baseURL.trim();
const req: TypesGen.UpdateChatProviderConfigRequest = {
...(trimmedDisplayName !== currentDisplayName && {
display_name: trimmedDisplayName,
}),
...(hasPendingAPIKeyChange && { api_key: effectiveApiKey }),
...(trimmedBaseURL !== currentBaseURL && {
base_url: trimmedBaseURL,
}),
};
if (Object.keys(req).length === 0) {
return;
}
try {
await onUpdateProvider(providerConfig.id, req);
} catch {
return;
}
} else {
const req: TypesGen.CreateChatProviderConfigRequest = {
provider,
base_url: trimmedBaseURL,
...(shouldCreateAPIKey && { api_key: effectiveApiKey }),
...(trimmedDisplayName && {
display_name: trimmedDisplayName,
}),
};
try {
await onCreateProvider(req);
} catch {
return;
}
}
setApiKeyTouched(false);
setApiKeyModified(false);
setApiKey(API_KEY_PLACEHOLDER);
};
const handleApiKeyFocus = () => {
// Clear the placeholder on first focus so the user starts
// with a blank field and Chrome does not try to autofill.
if (!apiKeyTouched && apiKey === API_KEY_PLACEHOLDER) {
setApiKey("");
setApiKeyTouched(true);
}
};
const isDisabled = providerConfigsUnavailable || isProviderMutationPending;
return (
<div className="flex min-h-full flex-col">
<BackButton onClick={onBack} />
<div className="flex items-center gap-3">
<ProviderIcon provider={provider} className="size-8" />
<div className="min-w-0 flex-1">
<input
type="text"
value={displayName || formatProviderLabel(provider)}
onChange={(event) => setDisplayName(event.target.value)}
disabled={isDisabled || isAPIKeyEnvManaged}
className="m-0 w-full border-0 bg-transparent p-0 text-lg font-medium text-content-primary outline-none placeholder:text-content-secondary focus:ring-0"
placeholder={formatProviderLabel(provider)}
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="size-4 shrink-0 cursor-help text-content-secondary" />
</TooltipTrigger>
<TooltipContent>
Uses the {formatProviderLabel(provider)} API specification
</TooltipContent>
</Tooltip>
</div>
<hr className="my-4 border-0 border-t border-solid border-border" />
{isAPIKeyEnvManaged ? (
<Alert severity="info">
<AlertTitle>API key managed by environment variable</AlertTitle>
<AlertDescription>
This provider key is configured from deployment environment settings
and cannot be edited in this UI.
</AlertDescription>
</Alert>
) : (
<form
className="flex flex-1 flex-col"
onSubmit={(event) => void handleSubmit(event)}
autoComplete="off"
data-form-type="other"
>
<div className="space-y-5">
<ProviderField
label="API Key"
htmlFor={apiKeyInputId}
required={requiresAPIKey}
description={apiKeyDescription}
>
<div className="space-y-1.5">
<Input
id={apiKeyInputId}
name="provider_api_token"
type="password"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
className="h-9 font-mono text-[13px] [-webkit-text-security:disc]"
placeholder={apiKeyPlaceholder}
required={requiresAPIKey}
value={apiKey}
onFocus={handleApiKeyFocus}
onChange={(event) => {
setApiKey(event.target.value);
setApiKeyTouched(true);
setApiKeyModified(true);
}}
disabled={isDisabled}
/>
{hasAPIKeyWhitespace && (
<p className="m-0 text-xs text-content-destructive">
API key must not contain leading or trailing whitespace.
</p>
)}
{isBedrockProvider &&
providerState.hasManagedAPIKey &&
!isDisabled &&
(!apiKeyModified || apiKey !== "") && (
<div className="flex justify-end">
<button
type="button"
className="appearance-none border-0 bg-transparent p-0 text-xs text-content-link hover:cursor-pointer hover:underline"
onClick={() => {
setApiKey("");
setApiKeyTouched(true);
setApiKeyModified(true);
}}
>
Clear stored token
</button>
</div>
)}
</div>
</ProviderField>
<ProviderField
label="Base URL"
htmlFor={baseURLInputId}
description={baseURLDescription}
>
<Input
id={baseURLInputId}
name="provider_base_url"
className="h-9 text-[13px]"
placeholder={baseURLPlaceholder}
required
autoComplete="off"
value={baseURLValue}
onChange={(event) => setBaseURLValue(event.target.value)}
disabled={isDisabled}
/>
</ProviderField>
</div>
<div className="mt-auto pt-6">
<hr className="mb-4 border-0 border-t border-solid border-border" />
<div className="flex items-center justify-between">
{providerConfig ? (
<Button
variant="outline"
size="lg"
type="button"
className="text-content-secondary hover:text-content-destructive hover:border-border-destructive"
disabled={isDisabled}
onClick={() => setConfirmingDelete(true)}
>
Delete
</Button>
) : (
<div />
)}
<div className="flex items-center gap-2">
{canAddModel && (
<Button size="lg" type="button" onClick={handleAddModel}>
Add model
</Button>
)}
<Button
size="lg"
type="submit"
variant={canAddModel ? "outline" : undefined}
disabled={!canSave}
>
{isProviderMutationPending && (
<Spinner className="h-4 w-4" loading />
)}
{providerConfig ? "Save changes" : "Create provider config"}
</Button>
</div>
</div>
</div>
</form>
)}
{providerConfig && (
<ConfirmDeleteDialog
entity="provider"
description={deleteProviderDescription}
onConfirm={() => void onDeleteProvider(providerConfig.id)}
isPending={isProviderMutationPending}
open={confirmingDelete}
onOpenChange={(open) => !open && setConfirmingDelete(false)}
/>
)}
</div>
);
};
interface ProviderFieldProps {
label: string;
htmlFor?: string;
required?: boolean;
description?: string;
children: ReactNode;
}
export const ProviderField: FC<ProviderFieldProps> = ({
label,
htmlFor,
required,
description,
children,
}) => (
<div className="grid gap-1.5">
<div className="flex items-baseline gap-1.5">
<label
htmlFor={htmlFor}
className="text-sm font-medium text-content-primary"
>
{label}
</label>
{required && (
<span className="text-xs font-bold text-content-destructive">*</span>
)}
</div>
{description && (
<p className="m-0 text-xs text-content-secondary">{description}</p>
)}
{children}
</div>
);
@@ -1,9 +1,9 @@
import { ServerIcon } from "lucide-react";
import type { FC } from "react";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import { normalizeProvider } from "#/modules/aiModels/helpers";
import { cn } from "#/utils/cn";
import { formatProviderLabel } from "../../utils/modelOptions";
import { normalizeProvider } from "./helpers";
const providerIconMap: Record<string, string> = {
openai: "/icon/openai.svg",
@@ -1,296 +0,0 @@
import {
CheckCircleIcon,
ChevronRightIcon,
CircleIcon,
PlusIcon,
} from "lucide-react";
import type { FC } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router";
import {
type AIProviderType,
AIProviderTypes,
type CreateChatProviderConfigRequest,
type UpdateChatProviderConfigRequest,
} from "#/api/typesGenerated";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { cn } from "#/utils/cn";
import { formatProviderLabel } from "../../utils/modelOptions";
import { SectionHeader } from "../SectionHeader";
import type {
CreateProviderResult,
ProviderState,
} from "./ChatModelAdminPanel";
import { getDefaultProviderBaseURL } from "./helpers";
import { ProviderForm } from "./ProviderForm";
import { ProviderIcon } from "./ProviderIcon";
type ProviderView =
| { mode: "list" }
| { mode: "detail"; provider: string }
| { mode: "new"; providerType: AIProviderType };
const providerTypeOptions = AIProviderTypes.map((providerType) => ({
providerType,
label: formatProviderLabel(providerType),
})).sort((a, b) => a.label.localeCompare(b.label));
const getAIProviderType = (
value: string | null,
): AIProviderType | undefined => {
if (!value) {
return undefined;
}
return AIProviderTypes.find((providerType) => providerType === value);
};
const newProviderState = (providerType: AIProviderType): ProviderState => ({
key: `new:${providerType}`,
provider: providerType,
label: formatProviderLabel(providerType),
providerConfig: undefined,
modelConfigs: [],
catalogModelCount: 0,
hasManagedAPIKey: false,
hasCatalogAPIKey: false,
hasEffectiveAPIKey: false,
allowUserAPIKey: true,
isEnvPreset: false,
baseURL: getDefaultProviderBaseURL(providerType),
});
interface ProvidersSectionProps {
sectionLabel?: string;
sectionDescription?: string;
providerStates: readonly ProviderState[];
providerConfigsUnavailable: boolean;
isProviderMutationPending: boolean;
onCreateProvider: (
req: CreateChatProviderConfigRequest,
) => Promise<CreateProviderResult>;
onUpdateProvider: (
providerConfigId: string,
req: UpdateChatProviderConfigRequest,
) => Promise<unknown>;
onDeleteProvider: (providerConfigId: string) => Promise<void>;
}
export const ProvidersSection: FC<ProvidersSectionProps> = ({
sectionLabel,
sectionDescription,
providerStates,
providerConfigsUnavailable,
isProviderMutationPending,
onCreateProvider,
onUpdateProvider,
onDeleteProvider,
}) => {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
const canGoBack =
(location.state as { pushed?: boolean } | null)?.pushed === true;
// Derive the current view from URL search params so that
// browser back/forward navigation works as expected.
const view: ProviderView = (() => {
const providerParam = searchParams.get("provider");
if (providerParam) {
const exists = providerStates.some((ps) => ps.key === providerParam);
return exists
? { mode: "detail", provider: providerParam }
: { mode: "list" };
}
const newProviderType = getAIProviderType(searchParams.get("newProvider"));
if (newProviderType) {
return { mode: "new", providerType: newProviderType };
}
return { mode: "list" };
})();
// Clear provider search param and return to the list.
const clearProviderView = () => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete("provider");
next.delete("newProvider");
return next;
});
};
const openNewProviderView = (providerType: AIProviderType) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete("provider");
next.set("newProvider", providerType);
return next;
},
{ state: { pushed: true } },
);
};
// Detail view.
const detailProvider =
view.mode === "detail"
? providerStates.find((ps) => ps.key === view.provider)
: view.mode === "new"
? newProviderState(view.providerType)
: undefined;
if ((view.mode === "detail" || view.mode === "new") && detailProvider) {
const providerFormKey = [
detailProvider.provider,
detailProvider.providerConfig?.id ?? "new",
detailProvider.providerConfig?.display_name ?? "",
detailProvider.providerConfig?.base_url ?? detailProvider.baseURL,
detailProvider.providerConfig?.central_api_key_enabled ?? true,
detailProvider.providerConfig?.allow_user_api_key ?? false,
detailProvider.providerConfig?.allow_central_api_key_fallback ?? false,
detailProvider.providerConfig?.has_api_key ??
detailProvider.hasManagedAPIKey,
detailProvider.providerConfig?.updated_at ?? "",
].join("|");
return (
<ProviderForm
key={providerFormKey}
providerState={detailProvider}
providerConfigsUnavailable={providerConfigsUnavailable}
isProviderMutationPending={isProviderMutationPending}
onCreateProvider={async (req) => {
const createdProvider = await onCreateProvider(req);
if (createdProvider.id) {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("provider", createdProvider.id);
next.delete("newProvider");
return next;
},
{ replace: true, state: location.state },
);
}
return createdProvider;
}}
onUpdateProvider={onUpdateProvider}
onDeleteProvider={async (id) => {
await onDeleteProvider(id);
if (canGoBack) {
navigate(-1);
} else {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete("provider");
next.delete("newProvider");
return next;
},
{ replace: true },
);
}
}}
onBack={clearProviderView}
/>
);
}
// List view.
const addProviderAction = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={providerConfigsUnavailable || isProviderMutationPending}
>
<PlusIcon className="size-4" />
Add provider
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{providerTypeOptions.map(({ providerType, label }) => (
<DropdownMenuItem
key={providerType}
className="gap-2"
onSelect={() => openNewProviderView(providerType)}
>
<ProviderIcon provider={providerType} className="size-5" />
<span>{label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
const header = sectionLabel ? (
<SectionHeader
label={sectionLabel}
description={
sectionDescription ?? "Configure AI providers to use with Agents."
}
action={addProviderAction}
/>
) : null;
return (
<>
{header}
{providerStates.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-surface-primary p-6 text-center text-[13px] text-content-secondary">
No providers have been added yet.
</div>
) : (
<div>
{providerStates.map((providerState, i) => (
<button
type="button"
key={providerState.key}
aria-label={providerState.label}
onClick={() => {
setSearchParams(
{ provider: providerState.key },
{ state: { pushed: true } },
);
}}
className={cn(
"flex w-full cursor-pointer items-center gap-3.5 border-0 bg-transparent p-0 px-3 py-3 text-left transition-colors hover:bg-surface-secondary/30",
i > 0 && "border-0 border-t border-solid border-border/50",
)}
>
<ProviderIcon
provider={providerState.provider}
className="size-8 shrink-0"
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 truncate text-[15px] font-medium text-content-primary text-left">
{providerState.label}
</span>
{providerState.providerConfig?.allow_user_api_key && (
<Badge size="xs" className="text-content-secondary">
User keys enabled
</Badge>
)}
</div>
</div>
{providerState.hasEffectiveAPIKey ? (
<CheckCircleIcon className="size-4 shrink-0 text-content-success" />
) : (
<CircleIcon className="size-4 shrink-0 text-content-secondary opacity-40" />
)}
<ChevronRightIcon className="size-5 shrink-0 text-content-secondary" />
</button>
))}
</div>
)}
</>
);
};
@@ -1,41 +0,0 @@
/**
* Reads a value as a non-empty string, returning undefined for
* empty strings, null, or undefined values.
*/
export function readOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
/**
* Normalizes a provider name for case-insensitive comparison.
*/
export function normalizeProvider(provider: string): string {
return provider.trim().toLowerCase();
}
const canonicalProviderBaseURLs: Record<string, string> = {
anthropic: "https://api.anthropic.com",
google: "https://generativelanguage.googleapis.com/v1beta",
openai: "https://api.openai.com/v1",
openrouter: "https://openrouter.ai/api/v1",
vercel: "https://ai-gateway.vercel.sh/v1",
};
export function getDefaultProviderBaseURL(provider: string): string {
return canonicalProviderBaseURLs[normalizeProvider(provider)] ?? "";
}
export function getProviderBaseURLPlaceholder(provider: string): string {
switch (normalizeProvider(provider)) {
case "azure":
return "https://<resource-name>.openai.azure.com";
case "bedrock":
return "https://bedrock-runtime.<region>.amazonaws.com";
case "openai-compat":
return "https://api.example.com/v1";
default:
return getDefaultProviderBaseURL(provider) || "https://api.example.com";
}
}
@@ -1,4 +1,4 @@
import { normalizeProvider } from "../helpers";
import { normalizeProvider } from "#/modules/aiModels/helpers";
import { anthropicKnownModels } from "./anthropic";
import { openAIKnownModels } from "./openai";
import type { KnownModel, KnownModelSourceMetadata } from "./types";
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import type { FieldSchema } from "#/api/chatModelOptions";
import type * as TypesGen from "#/api/typesGenerated";
import { MockChatModelConfig } from "#/testHelpers/chatModels";
import {
@@ -6,6 +7,8 @@ import {
buildModelConfigFromForm,
emptyModelConfigFormState,
extractModelConfigFormState,
hasFieldValue,
isFieldConflictDisabled,
type ModelConfigFormState,
parsePositiveInteger,
parseThresholdInteger,
@@ -694,6 +697,50 @@ describe("buildModelConfigFromForm", () => {
});
});
describe("visible_when gating", () => {
it("drops gated sub-fields when the gating field is off", () => {
const result = buildModelConfigFromForm(
"openai",
formWith({
openai: {
webSearchEnabled: "false",
searchContextSize: "high",
allowedDomains: '["example.com"]',
},
}),
);
expect(result.fieldErrors).toEqual({});
const openai = result.modelConfig?.provider_options?.openai as Record<
string,
unknown
>;
expect(openai).not.toHaveProperty("search_context_size");
expect(openai).not.toHaveProperty("allowed_domains");
expect(openai.web_search_enabled).toBe(false);
});
it("keeps gated sub-fields when the gating field is on", () => {
const result = buildModelConfigFromForm(
"openai",
formWith({
openai: {
webSearchEnabled: "true",
searchContextSize: "high",
allowedDomains: '["example.com"]',
},
}),
);
expect(result.fieldErrors).toEqual({});
const openai = result.modelConfig?.provider_options?.openai as Record<
string,
unknown
>;
expect(openai.web_search_enabled).toBe(true);
expect(openai.search_context_size).toBe("high");
expect(openai.allowed_domains).toEqual(["example.com"]);
});
});
describe("Anthropic / Bedrock provider", () => {
it("builds Anthropic provider options with effort", () => {
const result = buildModelConfigFromForm(
@@ -1177,3 +1224,92 @@ describe("buildModelConfigFromForm", () => {
});
});
});
// ── hasFieldValue ─────────────────────────────────────────────
describe("hasFieldValue", () => {
it("returns false for an empty string", () => {
expect(hasFieldValue("")).toBe(false);
});
it("returns false for whitespace-only string", () => {
expect(hasFieldValue(" ")).toBe(false);
expect(hasFieldValue("\t\n")).toBe(false);
});
it("returns false for the empty JSON array sentinel '[]'", () => {
expect(hasFieldValue("[]")).toBe(false);
});
it("returns false for '[]' with surrounding whitespace", () => {
expect(hasFieldValue(" [] ")).toBe(false);
});
it("returns true for a non-empty string", () => {
expect(hasFieldValue("hello")).toBe(true);
expect(hasFieldValue(" hello ")).toBe(true);
});
it("returns true for a non-empty JSON array", () => {
expect(hasFieldValue('["example.com"]')).toBe(true);
});
it("returns false for non-string values", () => {
expect(hasFieldValue(undefined)).toBe(false);
expect(hasFieldValue(null)).toBe(false);
expect(hasFieldValue(42)).toBe(false);
expect(hasFieldValue(true)).toBe(false);
expect(hasFieldValue({})).toBe(false);
});
});
// ── isFieldConflictDisabled ──────────────────────────────────
describe("isFieldConflictDisabled", () => {
const makeReader =
(values: Record<string, unknown>): ((jsonName: string) => unknown) =>
(jsonName: string): unknown =>
values[jsonName];
const field = (overrides: Partial<FieldSchema> = {}): FieldSchema => ({
json_name: "field_a",
go_name: "FieldA",
type: "string",
required: false,
input_type: "input",
conflicts_with: ["field_b"],
...overrides,
});
it("disables the field when a sibling has a value and this field is empty", () => {
const reader = makeReader({ field_a: "", field_b: "some-value" });
expect(isFieldConflictDisabled(field(), reader)).toBe(true);
});
it("does not disable when the field has its own value, even if a sibling is set", () => {
const reader = makeReader({ field_a: "my-value", field_b: "some-value" });
expect(isFieldConflictDisabled(field(), reader)).toBe(false);
});
it("does not disable when the field has no conflicts_with", () => {
const reader = makeReader({ field_a: "", field_b: "some-value" });
expect(
isFieldConflictDisabled(field({ conflicts_with: undefined }), reader),
).toBe(false);
});
it("does not disable when the sibling value is empty", () => {
const reader = makeReader({ field_a: "", field_b: "" });
expect(isFieldConflictDisabled(field(), reader)).toBe(false);
});
it("does not disable when the sibling value is whitespace-only", () => {
const reader = makeReader({ field_a: "", field_b: " " });
expect(isFieldConflictDisabled(field(), reader)).toBe(false);
});
it("does not disable when the sibling value is the empty array sentinel '[]'", () => {
const reader = makeReader({ field_a: "", field_b: "[]" });
expect(isFieldConflictDisabled(field(), reader)).toBe(false);
});
});
@@ -98,6 +98,37 @@ export function deepGet(obj: unknown, path: string[]): unknown {
const hasObjectKeys = (value: Record<string, unknown>): boolean =>
Object.keys(value).length > 0;
export const isVisibleWhenSatisfied = (
field: FieldSchema,
readSiblingValue: (jsonName: string) => unknown,
): boolean =>
!field.visible_when || readSiblingValue(field.visible_when) === "true";
// A field counts as "set" when it holds a non-empty value. JSON array fields
// serialize to "[]" when empty, so that is treated as unset too.
export const hasFieldValue = (raw: unknown): boolean => {
if (typeof raw !== "string") {
return false;
}
const trimmed = raw.trim();
return trimmed.length > 0 && trimmed !== "[]";
};
/**
* conflicts_with: disable the field while a mutually exclusive sibling holds
* a value, unless this field also has one so a both-set state stays
* recoverable.
*/
export const isFieldConflictDisabled = (
field: FieldSchema,
readSiblingValue: (jsonName: string) => unknown,
): boolean =>
Boolean(field.conflicts_with) &&
!hasFieldValue(readSiblingValue(field.json_name)) &&
(field.conflicts_with ?? []).some((sibling) =>
hasFieldValue(readSiblingValue(sibling)),
);
/**
* Convert a form string value to its API representation based on
* the field schema type. Empty strings yield `undefined` so
@@ -507,7 +538,14 @@ export const buildModelConfigFromForm = (
if (providerFormState && typeof providerFormState === "object") {
const providerPayload: Record<string, unknown> = {};
const readProviderValue = (jsonName: string): unknown =>
deepGet(providerFormState, jsonName.split(".").map(snakeToCamel));
for (const field of getProviderFields(resolved)) {
// Skip fields hidden by an unsatisfied `visible_when` gate so
// stale values left in form state are not serialized.
if (!isVisibleWhenSatisfied(field, readProviderValue)) continue;
// Read the form value from the nested camelCase structure.
const camelSegments = field.json_name.split(".").map(snakeToCamel);
const formValue = deepGet(providerFormState, camelSegments);
@@ -173,9 +173,9 @@ export const SettingsPanel: FC<SettingsPanelProps> = ({
<SettingsNavItem
icon={BoxesIcon}
label="Models"
active={settingsSection === "models"}
to="/agents/settings/models"
state={location.state}
active={false}
to="/ai/settings/models"
trailingIcon={ArrowUpRightIcon}
/>
<SettingsNavItem
icon={ServerIcon}
@@ -51,7 +51,7 @@ import {
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { BackButton } from "./BackButton";
import { ProviderField as Field } from "./ChatModelAdminPanel/ProviderForm";
import { ProviderField as Field } from "./ChatModelAdminPanel/ProviderField";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { SectionHeader } from "./SectionHeader";
+20 -4
View File
@@ -391,9 +391,6 @@ const AgentSettingsPersonalSkillsPage = lazy(
const AgentSettingsAPIKeysPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsAPIKeysPage"),
);
const AgentSettingsModelsPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsModelsPage"),
);
const AgentSettingsMCPServersPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsMCPServersPage"),
);
@@ -450,6 +447,16 @@ const AISettingsAddProviderPage = lazy(
const AISettingsGatewayKeysPage = lazy(
() => import("./pages/AISettingsPage/GatewayKeysPage/GatewayKeysPage"),
);
const AISettingsModelsPage = lazy(
() => import("./pages/AISettingsPage/ModelsPage/ModelsPage"),
);
const AISettingsAddModelPage = lazy(
() => import("./pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPage"),
);
const AISettingsUpdateModelPage = lazy(
() =>
import("./pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPage"),
);
const AISettingsIndexPage = () => {
const { permissions } = useAuthenticated();
@@ -723,6 +730,12 @@ export const router = createBrowserRouter(
element={<AISettingsGatewayKeysPage />}
/>
<Route index element={<AISettingsIndexPage />} />
<Route path="models" element={<AISettingsModelsPage />} />
<Route path="models/add" element={<AISettingsAddModelPage />} />
<Route
path="models/:modelId"
element={<AISettingsUpdateModelPage />}
/>
<Route path="add" element={<AISettingsAddProviderPage />} />
<Route
path=":providerId"
@@ -812,7 +825,10 @@ export const router = createBrowserRouter(
path="providers"
element={<Navigate to="/ai/settings" replace />}
/>
<Route path="models" element={<AgentSettingsModelsPage />} />
<Route
path="models"
element={<Navigate to="/ai/settings/models" replace />}
/>
<Route
path="mcp-servers"
element={<AgentSettingsMCPServersPage />}
+11 -1
View File
@@ -1,4 +1,8 @@
import type { ChatModelConfig, ChatProviderConfig } from "#/api/typesGenerated";
import type {
ChatModelConfig,
ChatModelProvider,
ChatProviderConfig,
} from "#/api/typesGenerated";
import { MOCK_TIMESTAMP } from "./chatEntities";
export const MockChatModelConfig: ChatModelConfig = {
@@ -28,3 +32,9 @@ export const MockChatProviderConfig: ChatProviderConfig = {
created_at: MOCK_TIMESTAMP,
updated_at: MOCK_TIMESTAMP,
};
export const MockChatModelProvider: ChatModelProvider = {
provider: "openai",
available: true,
models: [],
};