feat(site): provider key policy frontend UI (#23781)

Frontend for provider key policies (backend in #23751).

## Changes

**Admin provider form**: Three policy toggles (central API key, user API
keys, central fallback) with cross-field validation and conditional
visibility. Form resets properly after save.

**User settings page**: New `/settings/providers` route for personal API
key management. Conditional sidebar item (visible only when providers
allow user keys). Status badges, masked key input, save/remove actions
with confirmation. Read-only model list per provider. Gated behind
`agents` experiment flag.

**Model selector**: Distinguishes user-fixable (`user_api_key_required`)
from admin-fixable (`missing_api_key`) empty states. Links to
`/settings/providers` when user action is needed. Applied to both chat
detail and agent create flows.

**API client**: Query/mutation hooks for user provider configs. Cache
invalidation across provider configs and model catalog.
This commit is contained in:
Michael Suchacz
2026-04-02 22:05:19 +02:00
committed by GitHub
parent 8d08885792
commit b0a6802d12
24 changed files with 2046 additions and 179 deletions
+30
View File
@@ -416,6 +416,8 @@ export type DeploymentConfig = Readonly<{
const chatProviderConfigsPath = "/api/experimental/chats/providers";
const chatModelConfigsPath = "/api/experimental/chats/model-configs";
const userChatProviderConfigsPath =
"/api/experimental/chats/user-provider-configs";
const mcpServerConfigsPath = "/api/experimental/mcp/servers";
type ChatCostDateParams = {
@@ -3399,6 +3401,34 @@ class ExperimentalApiMethods {
);
};
getUserChatProviderConfigs = async (): Promise<
TypesGen.UserChatProviderConfig[]
> => {
const response = await this.axios.get<TypesGen.UserChatProviderConfig[]>(
userChatProviderConfigsPath,
);
return response.data;
};
upsertUserChatProviderKey = async (
providerConfigId: string,
req: TypesGen.CreateUserChatProviderKeyRequest,
): Promise<TypesGen.UserChatProviderConfig> => {
const response = await this.axios.put<TypesGen.UserChatProviderConfig>(
`${userChatProviderConfigsPath}/${encodeURIComponent(providerConfigId)}`,
req,
);
return response.data;
};
deleteUserChatProviderKey = async (
providerConfigId: string,
): Promise<void> => {
await this.axios.delete(
`${userChatProviderConfigsPath}/${encodeURIComponent(providerConfigId)}`,
);
};
getMCPServerConfigs = async (): Promise<TypesGen.MCPServerConfig[]> => {
const response =
await this.axios.get<TypesGen.MCPServerConfig[]>(mcpServerConfigsPath);
+41
View File
@@ -861,6 +861,47 @@ export const chatModelConfigs = () => ({
API.experimental.getChatModelConfigs(),
});
export const userChatProviderConfigsKey = [
"user-chat-provider-configs",
] as const;
export const userChatProviderConfigs = () => ({
queryKey: userChatProviderConfigsKey,
queryFn: (): Promise<TypesGen.UserChatProviderConfig[]> =>
API.experimental.getUserChatProviderConfigs(),
});
type UpsertUserChatProviderKeyArgs = {
providerConfigId: string;
req: TypesGen.CreateUserChatProviderKeyRequest;
};
export const upsertUserChatProviderKey = (queryClient: QueryClient) => ({
mutationFn: ({ providerConfigId, req }: UpsertUserChatProviderKeyArgs) =>
API.experimental.upsertUserChatProviderKey(providerConfigId, req),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: userChatProviderConfigsKey,
}),
queryClient.invalidateQueries({ queryKey: chatModelsKey }),
]);
},
});
export const deleteUserChatProviderKey = (queryClient: QueryClient) => ({
mutationFn: (providerConfigId: string) =>
API.experimental.deleteUserChatProviderKey(providerConfigId),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: userChatProviderConfigsKey,
}),
queryClient.invalidateQueries({ queryKey: chatModelsKey }),
]);
},
});
const invalidateChatConfigurationQueries = async (queryClient: QueryClient) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: chatProviderConfigsKey }),
@@ -61,12 +61,14 @@ import {
getSavedMCPSelection,
saveMCPSelection,
} from "./components/MCPServerPicker";
import { getModelSelectorHelp } from "./components/ModelSelectorHelp";
import { useGitWatcher } from "./hooks/useGitWatcher";
import { type ParsedDraft, parseStoredDraft } from "./utils/draftStorage";
import {
getModelOptionsFromConfigs,
getModelSelectorPlaceholder,
hasConfiguredModelsInCatalog,
hasUserFixableProviders,
resolveModelOptionId,
} from "./utils/modelOptions";
import { parsePullRequestUrl } from "./utils/pullRequest";
@@ -732,11 +734,19 @@ const AgentChatPage: FC = () => {
);
const hasModelOptions = modelOptions.length > 0;
const hasConfiguredModels = hasConfiguredModelsInCatalog(modelCatalog);
const hasUserFixableModelProviders = hasUserFixableProviders(modelCatalog);
const modelSelectorPlaceholder = getModelSelectorPlaceholder(
modelOptions,
isModelCatalogLoading,
hasConfiguredModels,
modelCatalog,
);
const modelSelectorHelp = getModelSelectorHelp({
isModelCatalogLoading,
hasModelOptions,
hasConfiguredModels,
hasUserFixableModelProviders,
});
const isSubmissionPending =
sendMutation.isPending ||
editMutation.isPending ||
@@ -1117,6 +1127,7 @@ const AgentChatPage: FC = () => {
setSelectedModel={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
modelSelectorHelp={modelSelectorHelp}
hasModelOptions={hasModelOptions}
isModelCatalogLoading={isModelCatalogLoading}
compressionThreshold={compressionThreshold}
@@ -6,7 +6,13 @@ import {
MonitorXIcon,
} from "lucide-react";
import { type FC, type RefObject, useRef, useState } from "react";
import {
type FC,
type ReactNode,
type RefObject,
useRef,
useState,
} from "react";
import { useQueryClient } from "react-query";
import type { UrlTransform } from "streamdown";
import { chatDiffContentsKey } from "#/api/queries/chats";
@@ -94,6 +100,7 @@ interface AgentChatPageViewProps {
setSelectedModel: (model: string) => void;
modelOptions: readonly ModelSelectorOption[];
modelSelectorPlaceholder: string;
modelSelectorHelp?: ReactNode;
hasModelOptions: boolean;
isModelCatalogLoading?: boolean;
compressionThreshold: number | undefined;
@@ -179,6 +186,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
setSelectedModel,
modelOptions,
modelSelectorPlaceholder,
modelSelectorHelp,
hasModelOptions,
isModelCatalogLoading = false,
compressionThreshold,
@@ -403,6 +411,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
modelSelectorHelp={modelSelectorHelp}
isModelCatalogLoading={isModelCatalogLoading}
inputRef={editing.chatInputRef}
initialValue={editing.editorInitialValue}
@@ -0,0 +1,376 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type {
ChatModelConfig,
UserChatProviderConfig,
} from "#/api/typesGenerated";
import {
AgentSettingsAPIKeysPageView,
type AgentSettingsAPIKeysPageViewProps,
} from "./AgentSettingsAPIKeysPageView";
const createProvider = (
overrides: Partial<UserChatProviderConfig> &
Pick<UserChatProviderConfig, "provider_id" | "provider">,
): UserChatProviderConfig => ({
provider_id: overrides.provider_id,
provider: overrides.provider,
display_name: overrides.display_name ?? overrides.provider,
has_user_api_key: overrides.has_user_api_key ?? false,
has_central_api_key_fallback: overrides.has_central_api_key_fallback ?? false,
});
const createModel = (
overrides: Partial<ChatModelConfig> &
Pick<ChatModelConfig, "id" | "provider" | "model">,
): ChatModelConfig => ({
id: overrides.id,
provider: overrides.provider,
display_name: overrides.display_name ?? overrides.model,
model: overrides.model,
enabled: overrides.enabled ?? true,
is_default: overrides.is_default ?? false,
context_limit: overrides.context_limit ?? 200000,
compression_threshold: overrides.compression_threshold ?? 70,
model_config: overrides.model_config,
created_at: overrides.created_at ?? "2026-03-01T00:00:00.000Z",
updated_at: overrides.updated_at ?? "2026-03-01T00:00:00.000Z",
});
const baseProvider = createProvider({
provider_id: "prov-1",
provider: "openai",
display_name: "OpenAI",
});
const baseModel = createModel({
id: "model-1",
provider: "openai",
display_name: "GPT-4o",
model: "gpt-4o",
});
const baseModels = [baseModel];
const createProviderItems = (
providers: readonly UserChatProviderConfig[],
): AgentSettingsAPIKeysPageViewProps["providerItems"] => {
return providers.map((provider) => ({
provider,
renderKey: `${provider.provider_id}-${provider.has_user_api_key}`,
isSaving: false,
isRemoving: false,
}));
};
const meta = {
title: "pages/AgentsPage/AgentSettingsAPIKeysPageView",
component: AgentSettingsAPIKeysPageView,
args: {
error: undefined,
isLoading: false,
providerItems: createProviderItems([baseProvider]),
models: baseModels,
isModelsLoading: false,
areModelsUnavailable: false,
onSave: fn(),
onRemove: fn(),
},
} satisfies Meta<typeof AgentSettingsAPIKeysPageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsAPIKeysPageView>;
export const Default: Story = {};
export const WithSavedKey: Story = {
args: {
providerItems: createProviderItems([
createProvider({
provider_id: "prov-1",
provider: "openai",
display_name: "OpenAI",
has_user_api_key: true,
has_central_api_key_fallback: true,
}),
]),
models: baseModels,
},
};
export const MasksApiKeyInput: Story = {
args: {
providerItems: createProviderItems([
createProvider({
provider_id: "prov-1",
provider: "openai",
display_name: "OpenAI",
has_user_api_key: true,
}),
]),
models: [],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByLabelText(/API Key/i)).toHaveAttribute(
"type",
"password",
);
},
};
export const WithFallback: Story = {
args: {
providerItems: createProviderItems([
createProvider({
provider_id: "prov-1",
provider: "anthropic",
display_name: "Anthropic",
has_central_api_key_fallback: true,
}),
]),
models: [
createModel({
id: "model-1",
provider: "anthropic",
display_name: "Claude Sonnet 4",
model: "claude-sonnet-4-20250514",
}),
],
},
};
export const MultipleProviders: Story = {
args: {
providerItems: createProviderItems([
createProvider({
provider_id: "prov-openai",
provider: "openai",
display_name: "OpenAI",
has_user_api_key: true,
has_central_api_key_fallback: true,
}),
createProvider({
provider_id: "prov-anthropic",
provider: "anthropic",
display_name: "Anthropic",
has_central_api_key_fallback: true,
}),
createProvider({
provider_id: "prov-google",
provider: "google",
display_name: "Google",
}),
]),
models: [
createModel({
id: "model-openai-1",
provider: "openai",
display_name: "GPT-4o",
model: "gpt-4o",
}),
createModel({
id: "model-anthropic-1",
provider: "anthropic",
display_name: "Claude Sonnet 4",
model: "claude-sonnet-4-20250514",
}),
createModel({
id: "model-anthropic-2",
provider: "anthropic",
display_name: "Claude Opus 4",
model: "claude-opus-4-20250514",
}),
],
},
};
export const Empty: Story = {
args: {
providerItems: [],
models: [],
},
};
export const Loading: Story = {
args: {
isLoading: true,
providerItems: [],
models: [],
},
};
export const ModelsUnavailable: Story = {
args: {
areModelsUnavailable: true,
models: [],
},
};
export const SavingSingleProvider: Story = {
args: {
providerItems: [
{
provider: baseProvider,
renderKey: `${baseProvider.provider_id}-${baseProvider.has_user_api_key}`,
isSaving: true,
isRemoving: false,
},
{
provider: createProvider({
provider_id: "prov-2",
provider: "anthropic",
display_name: "Anthropic",
}),
renderKey: "prov-2-false",
isSaving: false,
isRemoving: false,
},
],
models: [
...baseModels,
createModel({
id: "model-2",
provider: "anthropic",
display_name: "Claude Sonnet 4",
model: "claude-sonnet-4-20250514",
}),
],
},
};
export const SavesProviderKey: Story = {
args: {
onSave: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const apiKeyInput = await canvas.findByLabelText("API Key");
await userEvent.type(apiKeyInput, "sk-test-key");
await userEvent.click(canvas.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(args.onSave).toHaveBeenCalledWith("prov-1", "sk-test-key");
});
},
};
export const RemovesProviderKey: Story = {
args: {
providerItems: createProviderItems([
createProvider({
provider_id: "prov-1",
provider: "openai",
display_name: "OpenAI",
has_user_api_key: true,
}),
]),
onRemove: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const removeButton = await canvas.findByRole("button", { name: "Remove" });
await userEvent.click(removeButton);
const body = within(canvasElement.ownerDocument.body);
await waitFor(() =>
expect(body.getByText("Remove API key?")).toBeVisible(),
);
const dialog = await body.findByRole("dialog");
await userEvent.click(
within(dialog).getByRole("button", { name: "Remove" }),
);
await waitFor(() => {
expect(args.onRemove).toHaveBeenCalledWith("prov-1");
});
},
};
export const ClearsMaskedApiKeyOnFocus: Story = {
args: {
providerItems: createProviderItems([
createProvider({
provider_id: "prov-1",
provider: "openai",
display_name: "OpenAI",
has_user_api_key: true,
}),
]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const apiKeyInput = await canvas.findByLabelText("API Key");
await expect(apiKeyInput).toHaveValue("••••••••••••••••");
await userEvent.click(apiKeyInput);
await expect(apiKeyInput).toHaveValue("");
},
};
export const ShowsProviderStatuses: Story = {
args: {
providerItems: createProviderItems([
createProvider({
provider_id: "prov-openai",
provider: "openai",
display_name: "OpenAI",
has_user_api_key: true,
has_central_api_key_fallback: false,
}),
createProvider({
provider_id: "prov-anthropic",
provider: "anthropic",
display_name: "Anthropic",
has_user_api_key: false,
has_central_api_key_fallback: true,
}),
createProvider({
provider_id: "prov-google",
provider: "google",
display_name: "Google",
has_user_api_key: false,
has_central_api_key_fallback: false,
}),
]),
models: [
createModel({
id: "model-openai-1",
provider: "openai",
display_name: "GPT-4o",
model: "gpt-4o",
}),
createModel({
id: "model-anthropic-1",
provider: "anthropic",
display_name: "Claude Sonnet 4",
model: "claude-sonnet-4-20250514",
}),
createModel({
id: "model-google-1",
provider: "google",
display_name: "Gemini 2.5 Pro",
model: "gemini-2.5-pro",
}),
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText("Key saved")).toBeVisible();
await expect(canvas.getByText("Using shared key")).toBeVisible();
await expect(canvas.getByText("No key")).toBeVisible();
await expect(
canvas.getByText(
"The shared deployment key is being used. Add a personal key to use your own.",
),
).toBeVisible();
await expect(
canvas.getByText("You must add a personal API key to use this provider."),
).toBeVisible();
},
};
export const WithError: Story = {
args: {
error: new Error("Failed to load provider configurations"),
},
};
@@ -0,0 +1,97 @@
import type { FC } from "react";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { toast } from "sonner";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import {
chatModelConfigs,
deleteUserChatProviderKey,
upsertUserChatProviderKey,
userChatProviderConfigs,
} from "#/api/queries/chats";
import { AgentSettingsAPIKeysPageView } from "./AgentSettingsAPIKeysPageView";
const incrementResetToken = (
current: Record<string, number>,
providerConfigId: string,
) => ({
...current,
[providerConfigId]: (current[providerConfigId] ?? 0) + 1,
});
const AgentSettingsAPIKeysPage: FC = () => {
const queryClient = useQueryClient();
const [providerPanelResetTokens, setProviderPanelResetTokens] = useState<
Record<string, number>
>({});
const providersQuery = useQuery(userChatProviderConfigs());
const modelsQuery = useQuery(chatModelConfigs());
const upsertMutationOptions = upsertUserChatProviderKey(queryClient);
const upsertMutation = useMutation({
...upsertMutationOptions,
onSuccess: async (_data, variables) => {
await upsertMutationOptions.onSuccess?.();
setProviderPanelResetTokens((current) =>
incrementResetToken(current, variables.providerConfigId),
);
toast.success("API key saved.");
},
onError: (mutationError) => {
toast.error(getErrorMessage(mutationError, "Error saving API key."), {
description: getErrorDetail(mutationError),
});
},
});
const deleteMutationOptions = deleteUserChatProviderKey(queryClient);
const deleteMutation = useMutation({
...deleteMutationOptions,
onSuccess: async (_data, variables) => {
await deleteMutationOptions.onSuccess?.();
setProviderPanelResetTokens((current) =>
incrementResetToken(current, variables),
);
toast.success("API key removed.");
},
onError: (mutationError) => {
toast.error(getErrorMessage(mutationError, "Error removing API key."), {
description: getErrorDetail(mutationError),
});
},
});
const providerItems = (providersQuery.data ?? []).map((provider) => ({
provider,
renderKey: `${provider.provider_id}-${provider.has_user_api_key}-${providerPanelResetTokens[provider.provider_id] ?? 0}`,
isSaving:
upsertMutation.isPending &&
upsertMutation.variables?.providerConfigId === provider.provider_id,
isRemoving:
deleteMutation.isPending &&
deleteMutation.variables === provider.provider_id,
}));
return (
<AgentSettingsAPIKeysPageView
error={providersQuery.error}
isLoading={providersQuery.isLoading}
providerItems={providerItems}
models={modelsQuery.data ?? []}
isModelsLoading={modelsQuery.isLoading}
areModelsUnavailable={Boolean(modelsQuery.error)}
onSave={(providerConfigId, apiKey) => {
upsertMutation.mutate({
providerConfigId,
req: { api_key: apiKey },
});
}}
onRemove={(providerConfigId) => {
deleteMutation.mutate(providerConfigId);
}}
/>
);
};
export default AgentSettingsAPIKeysPage;
@@ -0,0 +1,286 @@
import type { FC, FormEvent } from "react";
import { useId, useState } from "react";
import type {
ChatModelConfig,
UserChatProviderConfig,
} from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
import { EmptyState } from "#/components/EmptyState/EmptyState";
import { Input } from "#/components/Input/Input";
import { Loader } from "#/components/Loader/Loader";
import { SectionHeader } from "./components/SectionHeader";
const API_KEY_PLACEHOLDER = "••••••••••••••••";
type ProviderStatus = {
label: string;
variant: "default" | "green" | "warning";
note?: string;
};
const getProviderStatus = (
provider: UserChatProviderConfig,
): ProviderStatus => {
if (provider.has_user_api_key) {
return {
label: "Key saved",
variant: "green",
};
}
if (provider.has_central_api_key_fallback) {
return {
label: "Using shared key",
variant: "default",
note: "The shared deployment key is being used. Add a personal key to use your own.",
};
}
return {
label: "No key",
variant: "warning",
note: "You must add a personal API key to use this provider.",
};
};
interface ProviderKeyPanelProps {
provider: UserChatProviderConfig;
models: readonly ChatModelConfig[];
isModelsLoading: boolean;
areModelsUnavailable: boolean;
isSaving: boolean;
isRemoving: boolean;
onSave: (providerConfigId: string, apiKey: string) => void;
onRemove: (providerConfigId: string) => void;
}
const ProviderKeyPanel: FC<ProviderKeyPanelProps> = ({
provider,
models,
isModelsLoading,
areModelsUnavailable,
isSaving,
isRemoving,
onSave,
onRemove,
}) => {
const apiKeyInputId = useId();
const [apiKey, setApiKey] = useState(
provider.has_user_api_key ? API_KEY_PLACEHOLDER : "",
);
const [apiKeyTouched, setApiKeyTouched] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const status = getProviderStatus(provider);
const enabledModels = models.filter((model) => {
return model.enabled && model.provider === provider.provider;
});
const trimmedApiKey = apiKey.trim();
const saveDisabled =
trimmedApiKey.length === 0 ||
apiKey === API_KEY_PLACEHOLDER ||
isSaving ||
isRemoving;
const inputDisabled = isSaving || isRemoving;
const providerName = provider.display_name || provider.provider;
const handleApiKeyFocus = () => {
if (!apiKeyTouched && apiKey === API_KEY_PLACEHOLDER) {
setApiKey("");
setApiKeyTouched(true);
}
};
const handleSave = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (saveDisabled) {
return;
}
onSave(provider.provider_id, trimmedApiKey);
};
const handleRemoveKey = () => {
onRemove(provider.provider_id);
};
const deleteDescription = provider.has_central_api_key_fallback
? "This will remove your personal API key. Requests will fall back to the shared deployment key for this provider."
: "This will remove your personal API key. You will need to add a new key before you can use this provider again.";
return (
<article className="rounded-lg border border-solid border-border p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-2">
<h5 className="m-0 text-lg font-medium text-content-primary">
{providerName}
</h5>
{status.note && (
<p className="m-0 text-sm text-content-secondary">{status.note}</p>
)}
</div>
<Badge size="sm" variant={status.variant} className="w-fit">
{status.label}
</Badge>
</div>
<form className="mt-6 flex flex-col gap-3" onSubmit={handleSave}>
<label
htmlFor={apiKeyInputId}
className="text-sm font-medium text-content-primary"
>
API Key
</label>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start">
<Input
id={apiKeyInputId}
name={`provider-api-key-${provider.provider_id}`}
type="password"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
className="h-9 font-mono text-[13px] lg:flex-1"
placeholder="sk-..."
value={apiKey}
onFocus={handleApiKeyFocus}
onChange={(event) => {
setApiKey(event.target.value);
setApiKeyTouched(true);
}}
disabled={inputDisabled}
/>
<div className="flex items-center gap-2">
<Button type="submit" size="sm" disabled={saveDisabled}>
Save
</Button>
{provider.has_user_api_key && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsDeleteDialogOpen(true)}
disabled={inputDisabled}
>
Remove
</Button>
)}
</div>
</div>
</form>
<div className="mt-6 flex flex-col gap-2">
<p className="m-0 text-sm font-medium text-content-primary">
Enabled models
</p>
{areModelsUnavailable ? (
<p className="m-0 text-sm text-content-secondary">
Enabled model badges are temporarily unavailable.
</p>
) : isModelsLoading ? (
<p className="m-0 text-sm text-content-secondary">
Loading models...
</p>
) : enabledModels.length > 0 ? (
<div className="flex flex-wrap gap-2">
{enabledModels.map((model) => (
<Badge key={model.id} size="xs" variant="default">
{model.display_name || model.model}
</Badge>
))}
</div>
) : (
<p className="m-0 text-sm text-content-secondary">
No enabled models configured.
</p>
)}
</div>
<ConfirmDialog
open={isDeleteDialogOpen}
onClose={() => setIsDeleteDialogOpen(false)}
onConfirm={handleRemoveKey}
title="Remove API key?"
description={deleteDescription}
confirmText="Remove"
confirmLoading={isRemoving}
type="delete"
/>
</article>
);
};
interface AgentSettingsAPIKeysProviderItem {
provider: UserChatProviderConfig;
renderKey: string;
isSaving: boolean;
isRemoving: boolean;
}
export interface AgentSettingsAPIKeysPageViewProps {
error: unknown;
isLoading: boolean;
providerItems: readonly AgentSettingsAPIKeysProviderItem[];
models: readonly ChatModelConfig[];
isModelsLoading: boolean;
areModelsUnavailable: boolean;
onSave: (providerConfigId: string, apiKey: string) => void;
onRemove: (providerConfigId: string) => void;
}
export const AgentSettingsAPIKeysPageView: FC<
AgentSettingsAPIKeysPageViewProps
> = ({
error,
isLoading,
providerItems,
models,
isModelsLoading,
areModelsUnavailable,
onSave,
onRemove,
}) => {
return (
<div className="flex flex-col gap-8">
<section>
<SectionHeader
label="Personal API Keys"
description="Add a personal API key for each provider. Your personal key takes precedence over the shared deployment key when both are available."
/>
<div className="mt-4">
{error ? (
<ErrorAlert error={error} />
) : isLoading ? (
<Loader />
) : providerItems.length === 0 ? (
<EmptyState
message="No providers allow personal API keys."
description="Ask your administrator to enable personal API keys for at least one provider."
/>
) : (
<div className="flex flex-col gap-4">
{providerItems.map((item) => (
<ProviderKeyPanel
key={item.renderKey}
provider={item.provider}
models={models}
isModelsLoading={isModelsLoading}
areModelsUnavailable={areModelsUnavailable}
isSaving={item.isSaving}
isRemoving={item.isRemoving}
onSave={onSave}
onRemove={onRemove}
/>
))}
</div>
)}
</div>
</section>
</div>
);
};
@@ -1,10 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
MonitorDotIcon,
MonitorIcon,
MonitorPauseIcon,
MonitorXIcon,
} from "lucide-react";
import { useEffect, useRef } from "react";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
@@ -734,49 +728,3 @@ export const ContextNearLimit: Story = {
},
},
};
const iconCls = "size-3";
export const AttachedWorkspaceRunning: Story = {
args: {
attachedWorkspace: {
name: "my-workspace",
route: "/@admin/my-workspace",
statusIcon: <MonitorIcon className={iconCls} />,
statusLabel: "Workspace running",
},
},
};
export const AttachedWorkspaceStopped: Story = {
args: {
attachedWorkspace: {
name: "my-workspace",
route: "/@admin/my-workspace",
statusIcon: <MonitorPauseIcon className={iconCls} />,
statusLabel: "Workspace stopped",
},
},
};
export const AttachedWorkspaceStarting: Story = {
args: {
attachedWorkspace: {
name: "my-workspace",
route: "/@admin/my-workspace",
statusIcon: <MonitorDotIcon className={iconCls} />,
statusLabel: "Workspace starting",
},
},
};
export const AttachedWorkspaceError: Story = {
args: {
attachedWorkspace: {
name: "my-workspace",
route: "/@admin/my-workspace",
statusIcon: <MonitorXIcon className={iconCls} />,
statusLabel: "Workspace failed",
},
},
};
@@ -13,6 +13,7 @@ import { parseStoredDraft } from "../utils/draftStorage";
import {
getModelSelectorPlaceholder,
hasConfiguredModelsInCatalog,
hasUserFixableProviders,
} from "../utils/modelOptions";
import {
formatUsageLimitMessage,
@@ -26,6 +27,7 @@ import {
getSavedMCPSelection,
saveMCPSelection,
} from "./MCPServerPicker";
import { getModelSelectorHelp } from "./ModelSelectorHelp";
/** @internal Exported for testing. */
export const emptyInputStorageKey = "agents.empty-input";
@@ -187,11 +189,19 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
);
const hasModelOptions = modelOptions.length > 0;
const hasConfiguredModels = hasConfiguredModelsInCatalog(modelCatalog);
const hasUserFixableModelProviders = hasUserFixableProviders(modelCatalog);
const modelSelectorPlaceholder = getModelSelectorPlaceholder(
modelOptions,
isModelCatalogLoading,
hasConfiguredModels,
modelCatalog,
);
const modelSelectorHelp = getModelSelectorHelp({
isModelCatalogLoading,
hasModelOptions,
hasConfiguredModels,
hasUserFixableModelProviders,
});
useEffect(() => {
if (!initialLastModelConfigID) {
return;
@@ -367,6 +377,11 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
onWorkspaceChange={handleWorkspaceChange}
isWorkspaceLoading={isWorkspacesLoading}
/>
{modelSelectorHelp ? (
<div className="px-3 pt-1 text-2xs text-content-secondary">
{modelSelectorHelp}
</div>
) : null}
<p className="mt-1 text-center text-xs text-content-secondary/50">
Coder Agents is available via{" "}
<a
@@ -1,5 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { type ComponentProps, useState } from "react";
import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test";
import { API } from "#/api/api";
import type * as TypesGen from "#/api/typesGenerated";
import {
ChatModelAdminPanel,
@@ -47,6 +49,159 @@ const createModelConfig = (
updated_at: overrides.updated_at ?? now,
});
type ChatModelAdminPanelStoryProps = ComponentProps<typeof ChatModelAdminPanel>;
/**
* Set up spies for all chat admin API methods. The mutable `state`
* object lets mutation spies update what queries return on refetch,
* mimicking the real server round-trip.
*/
const setupChatSpies = (state: {
providerConfigs: TypesGen.ChatProviderConfig[];
modelConfigs: TypesGen.ChatModelConfig[];
modelCatalog: TypesGen.ChatModelsResponse;
}) => {
spyOn(API.experimental, "getChatProviderConfigs").mockImplementation(
async () => {
return state.providerConfigs;
},
);
spyOn(API.experimental, "getChatModelConfigs").mockImplementation(
async () => {
return state.modelConfigs;
},
);
spyOn(API.experimental, "getChatModels").mockImplementation(async () => {
return state.modelCatalog;
});
spyOn(API.experimental, "createChatProviderConfig").mockImplementation(
async (req) => {
const created = createProviderConfig({
id: `provider-${Date.now()}`,
provider: req.provider,
display_name: req.display_name ?? "",
has_api_key: (req.api_key ?? "").trim().length > 0,
central_api_key_enabled: req.central_api_key_enabled ?? true,
allow_user_api_key: req.allow_user_api_key ?? false,
allow_central_api_key_fallback:
req.allow_central_api_key_fallback ?? false,
base_url: req.base_url ?? "",
source: "database",
});
state.providerConfigs = [
...state.providerConfigs.filter((p) => p.provider !== req.provider),
created,
];
return created;
},
);
spyOn(API.experimental, "updateChatProviderConfig").mockImplementation(
async (providerConfigId, req) => {
const idx = state.providerConfigs.findIndex(
(p) => p.id === providerConfigId,
);
if (idx < 0) {
throw new Error("Provider config not found.");
}
const current = state.providerConfigs[idx];
const updated: TypesGen.ChatProviderConfig = {
...current,
display_name:
typeof req.display_name === "string"
? req.display_name
: current.display_name,
has_api_key:
typeof req.api_key === "string"
? req.api_key.trim().length > 0
: current.has_api_key,
central_api_key_enabled:
typeof req.central_api_key_enabled === "boolean"
? req.central_api_key_enabled
: current.central_api_key_enabled,
allow_user_api_key:
typeof req.allow_user_api_key === "boolean"
? req.allow_user_api_key
: current.allow_user_api_key,
allow_central_api_key_fallback:
typeof req.allow_central_api_key_fallback === "boolean"
? req.allow_central_api_key_fallback
: current.allow_central_api_key_fallback,
base_url:
typeof req.base_url === "string" ? req.base_url : current.base_url,
updated_at: now,
};
state.providerConfigs = state.providerConfigs.map((p, i) =>
i === idx ? updated : p,
);
return updated;
},
);
spyOn(API.experimental, "createChatModelConfig").mockImplementation(
async (req) => {
const created = createModelConfig({
id: `model-${state.modelConfigs.length + 1}`,
provider: req.provider,
model: req.model,
display_name: req.display_name || req.model,
enabled: req.enabled ?? true,
context_limit:
typeof req.context_limit === "number" &&
Number.isFinite(req.context_limit)
? req.context_limit
: 200000,
compression_threshold:
typeof req.compression_threshold === "number" &&
Number.isFinite(req.compression_threshold)
? req.compression_threshold
: 70,
model_config: req.model_config,
});
state.modelConfigs = [...state.modelConfigs, created];
return created;
},
);
spyOn(API.experimental, "deleteChatModelConfig").mockImplementation(
async (modelConfigId) => {
state.modelConfigs = state.modelConfigs.filter(
(m) => m.id !== modelConfigId,
);
},
);
// Unused but mock to avoid errors.
spyOn(API.experimental, "deleteChatProviderConfig").mockResolvedValue(
undefined,
);
spyOn(API.experimental, "updateChatModelConfig").mockImplementation(
async (modelConfigId, req) => {
const idx = state.modelConfigs.findIndex((m) => m.id === modelConfigId);
if (idx < 0) {
throw new Error("Model config not found.");
}
const current = state.modelConfigs[idx];
const updated = createModelConfig({
...current,
...req,
id: current.id,
provider: current.provider,
model: current.model,
updated_at: now,
});
state.modelConfigs = state.modelConfigs.map((modelConfig, i) =>
i === idx ? updated : modelConfig,
);
return updated;
},
);
};
// ── Meta ───────────────────────────────────────────────────────
const meta: Meta<typeof ChatModelAdminPanel> = {
@@ -147,7 +302,7 @@ export const EnvPresetProviders: Story = {
),
).toBeVisible();
// No API key input or create button should be present.
expect(body.queryByLabelText(/API key/i)).not.toBeInTheDocument();
expect(body.queryByLabelText(/^API Key$/i)).not.toBeInTheDocument();
expect(
body.queryByRole("button", {
name: "Create provider config",
@@ -173,6 +328,85 @@ export const EnvPresetProviders: Story = {
};
export const CreateAndUpdateProvider: Story = {
render: function CreateAndUpdateProvider(args) {
const [providerConfigsData, setProviderConfigsData] = useState(
args.providerConfigsData,
);
const handleCreateProvider: ChatModelAdminPanelStoryProps["onCreateProvider"] =
async (req) => {
const result = await args.onCreateProvider(req);
const created = createProviderConfig({
id: `provider-${Date.now()}`,
provider: req.provider,
display_name: req.display_name ?? "",
has_api_key: (req.api_key ?? "").trim().length > 0,
central_api_key_enabled: req.central_api_key_enabled ?? true,
allow_user_api_key: req.allow_user_api_key ?? false,
allow_central_api_key_fallback:
req.allow_central_api_key_fallback ?? false,
base_url: req.base_url ?? "",
source: "database",
});
setProviderConfigsData((current) => [
...(current ?? []).filter((p) => p.provider !== req.provider),
created,
]);
return result;
};
const handleUpdateProvider: ChatModelAdminPanelStoryProps["onUpdateProvider"] =
async (providerConfigId, req) => {
const result = await args.onUpdateProvider(providerConfigId, req);
setProviderConfigsData((current) => {
if (!current) {
return current;
}
return current.map((providerConfig) =>
providerConfig.id === providerConfigId
? {
...providerConfig,
display_name:
typeof req.display_name === "string"
? req.display_name
: providerConfig.display_name,
has_api_key:
typeof req.api_key === "string"
? req.api_key.trim().length > 0
: providerConfig.has_api_key,
central_api_key_enabled:
typeof req.central_api_key_enabled === "boolean"
? req.central_api_key_enabled
: providerConfig.central_api_key_enabled,
allow_user_api_key:
typeof req.allow_user_api_key === "boolean"
? req.allow_user_api_key
: providerConfig.allow_user_api_key,
allow_central_api_key_fallback:
typeof req.allow_central_api_key_fallback === "boolean"
? req.allow_central_api_key_fallback
: providerConfig.allow_central_api_key_fallback,
base_url:
typeof req.base_url === "string"
? req.base_url
: providerConfig.base_url,
updated_at: "2026-02-18T12:00:00.000Z",
}
: providerConfig,
);
});
return result;
};
return (
<ChatModelAdminPanel
{...args}
providerConfigsData={providerConfigsData}
onCreateProvider={handleCreateProvider}
onUpdateProvider={handleUpdateProvider}
/>
);
},
args: {
section: "providers" as ChatModelAdminSection,
providerConfigsData: [
@@ -199,12 +433,20 @@ export const CreateAndUpdateProvider: Story = {
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
// Navigate to the OpenAI detail view.
await userEvent.click(await body.findByRole("button", { name: /OpenAI/i }));
// Fill in form to create a provider config.
await expect(
body.getByRole("switch", { name: "Central API key" }),
).toBeChecked();
expect(
body.getByRole("switch", { name: "Allow user API keys" }),
).not.toBeChecked();
expect(
body.queryByRole("switch", { name: "Use central key as fallback" }),
).not.toBeInTheDocument();
await userEvent.type(
await body.findByLabelText(/API key/i),
await body.findByLabelText(/^API Key$/i),
"sk-provider-key",
);
await userEvent.type(
@@ -215,7 +457,6 @@ export const CreateAndUpdateProvider: Story = {
body.getByRole("button", { name: "Create provider config" }),
);
// The create callback should have been called.
await waitFor(() => {
expect(args.onCreateProvider).toHaveBeenCalledTimes(1);
});
@@ -224,51 +465,26 @@ export const CreateAndUpdateProvider: Story = {
provider: "openai",
api_key: "sk-provider-key",
base_url: "https://proxy.example.com/v1",
central_api_key_enabled: true,
allow_user_api_key: false,
allow_central_api_key_fallback: false,
}),
);
},
};
/**
* Update an existing provider config: clear and re-type the API key
* and base URL, then save.
*/
export const UpdateProvider: Story = {
args: {
section: "providers" as ChatModelAdminSection,
providerConfigsData: [
createProviderConfig({
id: "provider-openai",
provider: "openai",
display_name: "OpenAI",
source: "database",
has_api_key: true,
base_url: "https://proxy.example.com/v1",
}),
],
modelCatalogData: {
providers: [
{
provider: "openai",
available: true,
models: [],
},
],
},
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await waitFor(() => {
expect(
body.getByRole("button", { name: "Save changes" }),
).toBeInTheDocument();
});
// Navigate to the OpenAI detail view.
await userEvent.click(await body.findByRole("button", { name: /OpenAI/i }));
await userEvent.click(
body.getByRole("switch", { name: "Allow user API keys" }),
);
await userEvent.click(
await body.findByRole("switch", { name: "Use central key as fallback" }),
);
// The form should be in edit mode with "Save changes".
await expect(
body.findByRole("button", { name: "Save changes" }),
).resolves.toBeInTheDocument();
// Update the API key and base URL.
const apiKeyInput = body.getByLabelText(/API key/i);
const apiKeyInput = body.getByLabelText(/^API Key$/i);
await userEvent.clear(apiKeyInput);
await userEvent.type(apiKeyInput, "sk-updated-provider-key");
const baseURLInput = body.getByLabelText("Base URL");
@@ -280,22 +496,318 @@ export const UpdateProvider: Story = {
expect(args.onUpdateProvider).toHaveBeenCalledTimes(1);
});
expect(args.onUpdateProvider).toHaveBeenCalledWith(
"provider-openai",
expect.any(String),
expect.objectContaining({
api_key: "sk-updated-provider-key",
base_url: "https://internal-proxy.example.com/v2",
allow_user_api_key: true,
allow_central_api_key_fallback: true,
}),
);
},
};
// ── Models section stories ─────────────────────────────────────
export const ProviderWithUserKeysEnabled: Story = {
args: {
section: "providers" as ChatModelAdminSection,
providerConfigsData: [
createProviderConfig({
id: "provider-openai-user-keys",
provider: "openai",
display_name: "OpenAI",
has_api_key: true,
central_api_key_enabled: true,
allow_user_api_key: true,
allow_central_api_key_fallback: false,
}),
],
modelCatalogData: { providers: [] },
},
beforeEach: () => {
setupChatSpies({
providerConfigs: [
createProviderConfig({
id: "provider-openai-user-keys",
provider: "openai",
display_name: "OpenAI",
has_api_key: true,
central_api_key_enabled: true,
allow_user_api_key: true,
allow_central_api_key_fallback: false,
}),
],
modelConfigs: [],
modelCatalog: { providers: [] },
});
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await expect(
await body.findByText("User keys enabled"),
).toBeInTheDocument();
await userEvent.click(body.getByRole("button", { name: /OpenAI/i }));
await expect(
body.getByRole("switch", { name: "Allow user API keys" }),
).toBeChecked();
await expect(
await body.findByRole("switch", {
name: "Use central key as fallback",
}),
).not.toBeChecked();
},
};
export const ProviderWithCentralFallback: Story = {
args: {
section: "providers" as ChatModelAdminSection,
providerConfigsData: [
createProviderConfig({
id: "provider-openrouter-fallback",
provider: "openrouter",
display_name: "OpenRouter",
has_api_key: true,
central_api_key_enabled: true,
allow_user_api_key: true,
allow_central_api_key_fallback: true,
}),
],
modelCatalogData: { providers: [] },
},
beforeEach: () => {
setupChatSpies({
providerConfigs: [
createProviderConfig({
id: "provider-openrouter-fallback",
provider: "openrouter",
display_name: "OpenRouter",
has_api_key: true,
central_api_key_enabled: true,
allow_user_api_key: true,
allow_central_api_key_fallback: true,
}),
],
modelConfigs: [],
modelCatalog: { providers: [] },
});
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: /OpenRouter/i }),
);
await expect(
await body.findByRole("switch", {
name: "Use central key as fallback",
}),
).toBeChecked();
},
};
export const ProviderWithUserKeysOnly: Story = {
args: {
section: "providers" as ChatModelAdminSection,
providerConfigsData: [
createProviderConfig({
id: "provider-google-user-only",
provider: "google",
display_name: "Google",
has_api_key: false,
central_api_key_enabled: false,
allow_user_api_key: true,
allow_central_api_key_fallback: false,
}),
],
modelCatalogData: { providers: [] },
},
beforeEach: () => {
setupChatSpies({
providerConfigs: [
createProviderConfig({
id: "provider-google-user-only",
provider: "google",
display_name: "Google",
has_api_key: false,
central_api_key_enabled: false,
allow_user_api_key: true,
allow_central_api_key_fallback: false,
}),
],
modelConfigs: [],
modelCatalog: { providers: [] },
});
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(await body.findByRole("button", { name: /Google/i }));
await expect(
body.getByRole("switch", { name: "Central API key" }),
).not.toBeChecked();
await expect(
body.getByRole("switch", { name: "Allow user API keys" }),
).toBeChecked();
expect(body.queryByLabelText(/^API Key$/i)).not.toBeInTheDocument();
expect(
body.queryByRole("switch", { name: "Use central key as fallback" }),
).not.toBeInTheDocument();
const saveButton = body.getByRole("button", { name: "Save changes" });
await userEvent.click(
body.getByRole("switch", { name: "Central API key" }),
);
await expect(await body.findByLabelText(/^API Key$/i)).toBeRequired();
expect(saveButton).toBeDisabled();
await userEvent.type(
body.getByLabelText(/^API Key$/i),
"sk-google-central-key",
);
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onUpdateProvider).toHaveBeenCalledTimes(1);
});
expect(args.onUpdateProvider).toHaveBeenCalledWith(
"provider-google-user-only",
expect.objectContaining({
api_key: "sk-google-central-key",
central_api_key_enabled: true,
}),
);
},
};
export const ProviderApiKeyInputMasked: Story = {
args: {
section: "providers" as ChatModelAdminSection,
providerConfigsData: [
createProviderConfig({
id: "provider-openai-mask",
provider: "openai",
display_name: "OpenAI",
has_api_key: true,
}),
],
modelCatalogData: { providers: [] },
},
beforeEach: () => {
setupChatSpies({
providerConfigs: [
createProviderConfig({
id: "provider-openai-mask",
provider: "openai",
display_name: "OpenAI",
has_api_key: true,
}),
],
modelConfigs: [],
modelCatalog: { providers: [] },
});
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(await body.findByRole("button", { name: /OpenAI/i }));
await expect(await body.findByLabelText(/^API Key$/i)).toHaveAttribute(
"type",
"password",
);
},
};
export const ModelFormUserKeyOnlyProvider: Story = {
args: {
section: "models" as ChatModelAdminSection,
providerConfigsData: [
createProviderConfig({
id: "provider-google-user-only-models",
provider: "google",
display_name: "Google",
has_api_key: false,
central_api_key_enabled: false,
allow_user_api_key: true,
allow_central_api_key_fallback: false,
}),
],
modelCatalogData: { providers: [] },
},
beforeEach: () => {
setupChatSpies({
providerConfigs: [
createProviderConfig({
id: "provider-google-user-only-models",
provider: "google",
display_name: "Google",
has_api_key: false,
central_api_key_enabled: false,
allow_user_api_key: true,
allow_central_api_key_fallback: false,
}),
],
modelConfigs: [],
modelCatalog: { providers: [] },
});
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "Google");
await expect(
await body.findByLabelText(/Model Identifier/i),
).toBeInTheDocument();
expect(
body.queryByText(
"Set an API key for this provider on the Providers tab before adding models.",
),
).not.toBeInTheDocument();
},
};
export const ProviderInvalidCredentialState: Story = {
args: {
section: "providers" as ChatModelAdminSection,
providerConfigsData: [
createProviderConfig({
id: "provider-bedrock-invalid",
provider: "bedrock",
display_name: "Bedrock",
has_api_key: false,
central_api_key_enabled: false,
allow_user_api_key: false,
allow_central_api_key_fallback: false,
}),
],
modelCatalogData: { providers: [] },
},
beforeEach: () => {
setupChatSpies({
providerConfigs: [
createProviderConfig({
id: "provider-bedrock-invalid",
provider: "bedrock",
display_name: "Bedrock",
has_api_key: false,
central_api_key_enabled: false,
allow_user_api_key: false,
allow_central_api_key_fallback: false,
}),
],
modelConfigs: [],
modelCatalog: { providers: [] },
});
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: /Bedrock/i }),
);
await expect(
body.findByText("At least one credential source must be enabled"),
).resolves.toBeInTheDocument();
expect(body.getByRole("button", { name: "Save changes" })).toBeDisabled();
},
};
/**
* Helper to open the "Add model" dropdown and select a provider.
* The "Add model" button is a DropdownMenuTrigger. Clicking it opens
* a dropdown of addable providers. We then select the given provider.
*/
const openAddModelForm = async (
body: ReturnType<typeof within>,
providerLabel: string,
@@ -656,7 +1168,7 @@ export const ModelDeleteConfirmation: Story = {
// Click Delete to show the confirmation dialog.
await userEvent.click(deleteButton);
// The confirmation dialog should appear — leave it visible
// The confirmation dialog should appear - leave it visible
// so the Chromatic snapshot captures this state.
await expect(
await body.findByText(/Are you sure you want to delete this model/i),
@@ -774,7 +1286,7 @@ export const ProviderDeleteConfirmation: Story = {
const deleteButton = await body.findByRole("button", { name: "Delete" });
await userEvent.click(deleteButton);
// The confirmation dialog should appear — leave it visible
// The confirmation dialog should appear - leave it visible
// so the Chromatic snapshot captures this state.
await expect(
await body.findByText(/Are you sure you want to delete this provider/i),
@@ -115,7 +115,8 @@ export const ModelForm: FC<ModelFormProps> = ({
const canManageModels = Boolean(
selectedProviderState?.providerConfig &&
selectedProviderState.hasEffectiveAPIKey,
(selectedProviderState.hasEffectiveAPIKey ||
selectedProviderState.providerConfig.allow_user_api_key),
);
const form = useFormik<ModelFormValues>({
@@ -315,7 +316,7 @@ export const ModelForm: FC<ModelFormProps> = ({
<ChevronLeftIcon className="h-4 w-4" />
Back
</button>
{/* Header — editable display name */}
{/* Header - editable display name */}
<div className="flex items-center gap-3">
{selectedProviderState && (
<ProviderIcon
@@ -447,7 +448,7 @@ export const ModelForm: FC<ModelFormProps> = ({
/>
<div className="space-y-5">
{/* Pricing — toggle */}
{/* Pricing - toggle */}
<div>
<button
type="button"
@@ -482,7 +483,7 @@ export const ModelForm: FC<ModelFormProps> = ({
)}
</div>
{/* Advanced — toggle */}
{/* Advanced - toggle */}
<div>
<button
type="button"
@@ -542,7 +543,7 @@ export const ModelForm: FC<ModelFormProps> = ({
</div>
</div>
</div>
{/* Footer — pushed to bottom */}
{/* Footer - pushed to bottom */}
<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">
@@ -182,9 +182,12 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
// ── List view ──────────────────────────────────────────────
// Only show providers that have an API key configured.
// Only show providers that have a deployment key configured or allow
// end users to bring their own key.
const addableProviders = providerStates.filter(
(ps) => ps.providerConfig && ps.hasEffectiveAPIKey,
(ps) =>
ps.providerConfig &&
(ps.hasEffectiveAPIKey || ps.providerConfig.allow_user_api_key),
);
const addButton = addableProviders.length > 0 && (
@@ -1,5 +1,12 @@
import { ChevronLeftIcon, InfoIcon } from "lucide-react";
import { type FC, type FormEvent, useId, useState } from "react";
import {
type CSSProperties,
type FC,
type FormEvent,
type ReactNode,
useId,
useState,
} from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
@@ -13,6 +20,7 @@ import {
} from "#/components/Dialog/Dialog";
import { Input } from "#/components/Input/Input";
import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import {
Tooltip,
TooltipContent,
@@ -22,9 +30,10 @@ import { formatProviderLabel } from "../../utils/modelOptions";
import type { ProviderState } from "./ChatModelAdminPanel";
import { readOptionalString } from "./helpers";
import { ProviderIcon } from "./ProviderIcon";
import { normalizeProviderPolicyDefaults } from "./providerPolicyDefaults";
// Sentinel value used to represent an existing API key that the
// backend won't reveal. If the user hasn't touched the field,
// backend will not reveal. If the user has not touched the field,
// we know nothing changed.
const API_KEY_PLACEHOLDER = "••••••••••••••••";
@@ -64,11 +73,20 @@ export const ProviderForm: FC<ProviderFormProps> = ({
? "https://api.example.com"
: "https://api.example.com/v1";
const normalizedProviderConfig = providerConfig
? normalizeProviderPolicyDefaults(providerConfig)
: undefined;
// Initial values are snapshotted when the provider config changes
// so we can detect dirty state.
const [initialValues] = useState(() => ({
displayName: readOptionalString(providerConfig?.display_name) ?? "",
baseURL: baseURL,
baseURL,
centralAPIKeyEnabled:
normalizedProviderConfig?.central_api_key_enabled ?? true,
allowUserAPIKey: normalizedProviderConfig?.allow_user_api_key ?? false,
allowCentralAPIKeyFallback:
normalizedProviderConfig?.allow_central_api_key_fallback ?? false,
}));
const [displayName, setDisplayName] = useState(initialValues.displayName);
@@ -77,26 +95,57 @@ export const ProviderForm: FC<ProviderFormProps> = ({
);
const [apiKeyTouched, setApiKeyTouched] = useState(false);
const [baseURLValue, setBaseURLValue] = useState(initialValues.baseURL);
const [centralAPIKeyEnabled, setCentralAPIKeyEnabled] = useState(
initialValues.centralAPIKeyEnabled,
);
const [allowUserAPIKey, setAllowUserAPIKey] = useState(
initialValues.allowUserAPIKey,
);
const [allowCentralAPIKeyFallback, setAllowCentralAPIKeyFallback] = useState(
initialValues.allowCentralAPIKeyFallback,
);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const isAPIKeyEnvManaged = isEnvPreset && !providerConfig;
const requiresAPIKey = !providerConfig && !isAPIKeyEnvManaged;
const shouldShowAPIKeyField = centralAPIKeyEnabled;
const shouldShowFallbackToggle = centralAPIKeyEnabled && allowUserAPIKey;
const effectiveInitialFallback =
initialValues.centralAPIKeyEnabled &&
initialValues.allowUserAPIKey &&
initialValues.allowCentralAPIKeyFallback;
const effectiveFallback =
shouldShowFallbackToggle && allowCentralAPIKeyFallback;
// Require a key whenever central-key usage is enabled and there is no
// stored deployment key yet. This covers both create and update flows,
// including toggling central-key usage on for an existing provider.
const requiresAPIKey =
!isAPIKeyEnvManaged &&
centralAPIKeyEnabled &&
!providerState.hasManagedAPIKey;
// The actual API key value to submit — ignore the placeholder.
const effectiveApiKey =
apiKeyTouched && apiKey !== API_KEY_PLACEHOLDER ? apiKey.trim() : "";
const hasCredentialSource = centralAPIKeyEnabled || allowUserAPIKey;
const deleteProviderDescription = normalizedProviderConfig?.allow_user_api_key
? "Are you sure you want to delete this provider? Any personal API " +
"keys that users have saved for this provider will also be " +
"permanently deleted. This action is irreversible."
: "Are you sure you want to delete this provider? This action is irreversible.";
// Dirty detection: has anything changed from the initial state?
const isDirty =
displayName.trim() !== initialValues.displayName ||
effectiveApiKey !== "" ||
baseURLValue.trim() !== initialValues.baseURL.trim();
baseURLValue.trim() !== initialValues.baseURL.trim() ||
centralAPIKeyEnabled !== initialValues.centralAPIKeyEnabled ||
allowUserAPIKey !== initialValues.allowUserAPIKey ||
effectiveFallback !== effectiveInitialFallback;
const canSave =
!providerConfigsUnavailable &&
!isProviderMutationPending &&
!isAPIKeyEnvManaged &&
isDirty &&
hasCredentialSource &&
(!requiresAPIKey || effectiveApiKey);
const handleSubmit = async (event: FormEvent) => {
@@ -104,11 +153,16 @@ export const ProviderForm: FC<ProviderFormProps> = ({
if (
providerConfigsUnavailable ||
isProviderMutationPending ||
isAPIKeyEnvManaged
isAPIKeyEnvManaged ||
!hasCredentialSource
) {
return;
}
if (requiresAPIKey && !effectiveApiKey) {
return;
}
const trimmedDisplayName = displayName.trim();
const trimmedBaseURL = baseURLValue.trim();
@@ -120,13 +174,23 @@ export const ProviderForm: FC<ProviderFormProps> = ({
...(trimmedDisplayName !== currentDisplayName && {
display_name: trimmedDisplayName,
}),
...(effectiveApiKey && { api_key: effectiveApiKey }),
...(centralAPIKeyEnabled &&
effectiveApiKey && { api_key: effectiveApiKey }),
...(trimmedBaseURL !== currentBaseURL && {
base_url: trimmedBaseURL,
}),
...(centralAPIKeyEnabled !== initialValues.centralAPIKeyEnabled && {
central_api_key_enabled: centralAPIKeyEnabled,
}),
...(allowUserAPIKey !== initialValues.allowUserAPIKey && {
allow_user_api_key: allowUserAPIKey,
}),
...(effectiveFallback !== effectiveInitialFallback && {
allow_central_api_key_fallback: effectiveFallback,
}),
};
if (!req.display_name && !req.api_key && !req.base_url) {
if (Object.keys(req).length === 0) {
return;
}
@@ -138,13 +202,12 @@ export const ProviderForm: FC<ProviderFormProps> = ({
return;
}
} else {
if (!effectiveApiKey) {
return;
}
const req: TypesGen.CreateChatProviderConfigRequest = {
provider,
api_key: effectiveApiKey,
...(centralAPIKeyEnabled && { api_key: effectiveApiKey }),
central_api_key_enabled: centralAPIKeyEnabled,
allow_user_api_key: allowUserAPIKey,
allow_central_api_key_fallback: effectiveFallback,
...(trimmedDisplayName && {
display_name: trimmedDisplayName,
}),
@@ -161,11 +224,12 @@ export const ProviderForm: FC<ProviderFormProps> = ({
}
setApiKeyTouched(false);
setApiKey(API_KEY_PLACEHOLDER);
};
const handleApiKeyFocus = () => {
// Clear the placeholder on first focus so the user starts
// with a blank field and Chrome doesn't try to autofill.
// with a blank field and Chrome does not try to autofill.
if (!apiKeyTouched && apiKey === API_KEY_PLACEHOLDER) {
setApiKey("");
setApiKeyTouched(true);
@@ -180,20 +244,20 @@ export const ProviderForm: FC<ProviderFormProps> = ({
<button
type="button"
onClick={onBack}
className="mb-4 inline-flex cursor-pointer items-center gap-0.5 bg-transparent border-0 p-0 text-sm text-content-secondary transition-colors hover:text-content-primary"
className="mb-4 inline-flex cursor-pointer items-center gap-0.5 border-0 bg-transparent p-0 text-sm text-content-secondary transition-colors hover:text-content-primary"
>
<ChevronLeftIcon className="h-4 w-4" />
Back
</button>
{/* Provider header — editable name */}
{/* Provider header, editable name */}
<div className="flex items-center gap-3">
<ProviderIcon provider={provider} className="h-8 w-8" />
<div className="min-w-0 flex-1">
<input
type="text"
value={displayName || formatProviderLabel(provider)}
onChange={(e) => setDisplayName(e.target.value)}
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)}
@@ -226,33 +290,36 @@ export const ProviderForm: FC<ProviderFormProps> = ({
data-form-type="other"
>
<div className="space-y-5">
<ProviderField
label="API Key"
htmlFor={apiKeyInputId}
required={!providerConfig}
description="Secret key used to authenticate requests to this provider."
>
<Input
id={apiKeyInputId}
name="provider_api_token"
type="text"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
style={{ WebkitTextSecurity: "disc" } as React.CSSProperties}
className="h-9 font-mono text-[13px]"
placeholder="sk-..."
value={apiKey}
onFocus={handleApiKeyFocus}
onChange={(e) => {
setApiKey(e.target.value);
setApiKeyTouched(true);
}}
disabled={isDisabled}
/>
</ProviderField>
{shouldShowAPIKeyField && (
<ProviderField
label="API Key"
htmlFor={apiKeyInputId}
required={requiresAPIKey}
description="Secret key used to authenticate requests to this provider."
>
<Input
id={apiKeyInputId}
name="provider_api_token"
type="password"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
style={{ WebkitTextSecurity: "disc" } as CSSProperties}
className="h-9 font-mono text-[13px]"
placeholder="sk-..."
required={requiresAPIKey}
value={apiKey}
onFocus={handleApiKeyFocus}
onChange={(event) => {
setApiKey(event.target.value);
setApiKeyTouched(true);
}}
disabled={isDisabled}
/>
</ProviderField>
)}
<ProviderField
label="Base URL"
@@ -266,13 +333,54 @@ export const ProviderForm: FC<ProviderFormProps> = ({
placeholder={baseURLPlaceholder}
autoComplete="off"
value={baseURLValue}
onChange={(e) => setBaseURLValue(e.target.value)}
onChange={(event) => setBaseURLValue(event.target.value)}
disabled={isDisabled}
/>
</ProviderField>
<div className="space-y-3 rounded-lg border border-solid border-border/70 bg-surface-secondary/30 p-4">
<div className="space-y-1">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Key policy
</h3>
<p className="m-0 text-xs text-content-secondary">
Control which credential sources this provider can use.
</p>
</div>
<div className="space-y-3">
<ProviderToggleField
label="Central API key"
description="Use a deployment-managed API key for this provider"
checked={centralAPIKeyEnabled}
onCheckedChange={setCentralAPIKeyEnabled}
disabled={isDisabled}
/>
<ProviderToggleField
label="Allow user API keys"
description="Let users provide their own API keys for this provider"
checked={allowUserAPIKey}
onCheckedChange={setAllowUserAPIKey}
disabled={isDisabled}
/>
{shouldShowFallbackToggle && (
<ProviderToggleField
label="Use central key as fallback"
description="When a user has not saved a personal key, fall back to the central API key"
checked={effectiveFallback}
onCheckedChange={setAllowCentralAPIKeyFallback}
disabled={isDisabled}
/>
)}
</div>
{!hasCredentialSource && (
<p className="m-0 text-xs text-content-destructive">
At least one credential source must be enabled
</p>
)}
</div>
</div>
{/* Footer — pushed to bottom */}
{/* Footer, pushed to bottom */}
<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">
@@ -309,10 +417,7 @@ export const ProviderForm: FC<ProviderFormProps> = ({
<DialogContent variant="destructive">
<DialogHeader>
<DialogTitle>Delete provider</DialogTitle>
<DialogDescription>
Are you sure you want to delete this provider? This action is
irreversible.
</DialogDescription>
<DialogDescription>{deleteProviderDescription}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
@@ -340,14 +445,55 @@ export const ProviderForm: FC<ProviderFormProps> = ({
);
};
// ── Field wrapper ──────────────────────────────────────────────
interface ProviderToggleFieldProps {
label: string;
description: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
}
const ProviderToggleField: FC<ProviderToggleFieldProps> = ({
label,
description,
checked,
onCheckedChange,
disabled,
}) => {
const labelId = useId();
const descriptionId = useId();
return (
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<p
id={labelId}
className="m-0 text-sm font-medium text-content-primary"
>
{label}
</p>
<p id={descriptionId} className="m-0 text-xs text-content-secondary">
{description}
</p>
</div>
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
aria-labelledby={labelId}
aria-describedby={descriptionId}
/>
</div>
);
};
// Field wrapper.
interface ProviderFieldProps {
label: string;
htmlFor?: string;
required?: boolean;
description?: string;
children: React.ReactNode;
children: ReactNode;
}
export const ProviderField: FC<ProviderFieldProps> = ({
@@ -2,6 +2,7 @@ import { CheckCircleIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import type { FC, ReactNode } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { Badge } from "#/components/Badge/Badge";
import { cn } from "#/utils/cn";
import { SectionHeader } from "../SectionHeader";
import type { ProviderState } from "./ChatModelAdminPanel";
@@ -68,15 +69,29 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
});
};
// ── Detail view ───────────────────────────────────────────
// Detail view.
const detailProvider =
view.mode === "detail"
? providerStates.find((ps) => ps.provider === view.provider)
: undefined;
if (view.mode === "detail" && 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}
@@ -102,8 +117,7 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
);
}
// ── List view ─────────────────────────────────────────────
// List view.
if (providerStates.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border bg-surface-primary p-6 text-center text-[13px] text-content-secondary">
@@ -137,7 +151,7 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
);
}}
className={cn(
"flex w-full cursor-pointer items-center gap-3.5 bg-transparent border-0 p-0 px-3 py-3 text-left transition-colors hover:bg-surface-secondary/30",
"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",
)}
>
@@ -145,9 +159,18 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
provider={providerState.provider}
className="h-8 w-8 shrink-0"
/>
<span className="min-w-0 flex-1 truncate text-[15px] font-medium text-content-primary text-left">
{providerState.label}
</span>
<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="h-4 w-4 shrink-0 text-content-success" />
) : (
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import {
normalizeProviderPolicyDefaults,
type ProviderConfigWithOptionalPolicyFields,
} from "./providerPolicyDefaults";
const baseProviderConfig: ProviderConfigWithOptionalPolicyFields = {
id: "provider-1",
provider: "openai",
display_name: "OpenAI",
enabled: true,
has_api_key: true,
base_url: "https://api.openai.com/v1",
source: "database",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
describe("normalizeProviderPolicyDefaults", () => {
it("passes through explicit policy fields unchanged", () => {
const providerConfig: ProviderConfigWithOptionalPolicyFields = {
...baseProviderConfig,
central_api_key_enabled: false,
allow_user_api_key: true,
allow_central_api_key_fallback: true,
};
expect(normalizeProviderPolicyDefaults(providerConfig)).toEqual(
providerConfig,
);
});
it("defaults omitted policy fields to the expected values", () => {
expect(normalizeProviderPolicyDefaults(baseProviderConfig)).toMatchObject({
central_api_key_enabled: true,
allow_user_api_key: false,
allow_central_api_key_fallback: false,
});
});
it("defaults undefined policy fields to the expected values", () => {
const providerConfig: ProviderConfigWithOptionalPolicyFields = {
...baseProviderConfig,
central_api_key_enabled: undefined,
allow_user_api_key: undefined,
allow_central_api_key_fallback: undefined,
};
expect(normalizeProviderPolicyDefaults(providerConfig)).toMatchObject({
central_api_key_enabled: true,
allow_user_api_key: false,
allow_central_api_key_fallback: false,
});
});
});
@@ -0,0 +1,26 @@
import type * as TypesGen from "#/api/typesGenerated";
type ProviderPolicyFields = Pick<
TypesGen.ChatProviderConfig,
| "central_api_key_enabled"
| "allow_user_api_key"
| "allow_central_api_key_fallback"
>;
export type ProviderConfigWithOptionalPolicyFields = Omit<
TypesGen.ChatProviderConfig,
keyof ProviderPolicyFields
> &
Partial<ProviderPolicyFields>;
export function normalizeProviderPolicyDefaults(
providerConfig: ProviderConfigWithOptionalPolicyFields,
): TypesGen.ChatProviderConfig {
return {
...providerConfig,
central_api_key_enabled: providerConfig.central_api_key_enabled ?? true,
allow_user_api_key: providerConfig.allow_user_api_key ?? false,
allow_central_api_key_fallback:
providerConfig.allow_central_api_key_fallback ?? false,
};
}
@@ -1,4 +1,4 @@
import { type FC, Profiler, useEffect } from "react";
import { type FC, Profiler, type ReactNode, useEffect } from "react";
import { toast } from "sonner";
import type { UrlTransform } from "streamdown";
import type * as TypesGen from "#/api/typesGenerated";
@@ -128,6 +128,7 @@ interface ChatPageInputProps {
onModelChange: (modelID: string) => void;
modelOptions: readonly ModelSelectorOption[];
modelSelectorPlaceholder: string;
modelSelectorHelp?: ReactNode;
isModelCatalogLoading?: boolean;
// Imperative editor handle plus the one-time initial draft,
// owned by the conversation component.
@@ -181,6 +182,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
onModelChange,
modelOptions,
modelSelectorPlaceholder,
modelSelectorHelp,
isModelCatalogLoading = false,
inputRef,
initialValue,
@@ -291,7 +293,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
const isStreaming =
hasStreamState || chatStatus === "running" || chatStatus === "pending";
return (
const inputElement = (
<AgentChatInput
onSend={(message) => {
void (async () => {
@@ -363,4 +365,17 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
attachedWorkspace={attachedWorkspace}
/>
);
if (!modelSelectorHelp) {
return inputElement;
}
return (
<div>
{inputElement}
<div className="px-3 pt-1 text-2xs text-content-secondary">
{modelSelectorHelp}
</div>
</div>
);
};
@@ -0,0 +1,71 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { getModelSelectorHelp } from "./ModelSelectorHelp";
describe("getModelSelectorHelp", () => {
it("returns undefined while the model catalog is loading", () => {
expect(
getModelSelectorHelp({
isModelCatalogLoading: true,
hasModelOptions: false,
hasConfiguredModels: true,
hasUserFixableModelProviders: true,
}),
).toBeUndefined();
});
it("returns undefined when model options are available", () => {
expect(
getModelSelectorHelp({
isModelCatalogLoading: false,
hasModelOptions: true,
hasConfiguredModels: true,
hasUserFixableModelProviders: true,
}),
).toBeUndefined();
});
it("returns undefined when no models are configured", () => {
expect(
getModelSelectorHelp({
isModelCatalogLoading: false,
hasModelOptions: false,
hasConfiguredModels: false,
hasUserFixableModelProviders: true,
}),
).toBeUndefined();
});
it("returns settings help when configured models are user-fixable", () => {
render(
<MemoryRouter>
{getModelSelectorHelp({
isModelCatalogLoading: false,
hasModelOptions: false,
hasConfiguredModels: true,
hasUserFixableModelProviders: true,
})}
</MemoryRouter>,
);
expect(document.body).toHaveTextContent(
"Configure your API keys in Settings to enable models.",
);
expect(screen.getByRole("link", { name: "Settings" })).toHaveAttribute(
"href",
"/agents/settings/api-keys",
);
});
it("returns undefined when configured models are not user-fixable", () => {
expect(
getModelSelectorHelp({
isModelCatalogLoading: false,
hasModelOptions: false,
hasConfiguredModels: true,
hasUserFixableModelProviders: false,
}),
).toBeUndefined();
});
});
@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
import { Link } from "react-router";
interface GetModelSelectorHelpOptions {
isModelCatalogLoading: boolean;
hasModelOptions: boolean;
hasConfiguredModels: boolean;
hasUserFixableModelProviders: boolean;
}
export const getModelSelectorHelp = ({
isModelCatalogLoading,
hasModelOptions,
hasConfiguredModels,
hasUserFixableModelProviders,
}: GetModelSelectorHelpOptions): ReactNode | undefined => {
if (
isModelCatalogLoading ||
hasModelOptions ||
!hasConfiguredModels ||
!hasUserFixableModelProviders
) {
return undefined;
}
return (
<>
Configure your API keys in{" "}
<Link
to="/agents/settings/api-keys"
className="underline transition-colors hover:text-content-primary"
>
Settings
</Link>{" "}
to enable models.
</>
);
};
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { userChatProviderConfigsKey } from "#/api/queries/chats";
import type * as TypesGen from "#/api/typesGenerated";
import type { Chat } from "#/api/typesGenerated";
import { MockUserOwner } from "#/testHelpers/entities";
@@ -62,6 +63,15 @@ const agentsRouting = [
...{ path: string; useStoryElement: boolean }[],
];
const settingsRouting = [
{ path: "/agents/settings/:section", useStoryElement: true },
{ path: "/agents/settings", useStoryElement: true },
...agentsRouting,
] satisfies [
{ path: string; useStoryElement: boolean },
...{ path: string; useStoryElement: boolean }[],
];
const meta: Meta<typeof AgentsSidebar> = {
title: "pages/AgentsPage/AgentsSidebar",
component: AgentsSidebar,
@@ -1037,3 +1047,51 @@ export const FilterOnTimeGroupNoPins: Story = {
});
},
};
export const SettingsAPIKeysAdmin: Story = {
args: {
chats: [],
isAdmin: true,
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents/settings/api-keys" },
routing: settingsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("API Keys")).toBeInTheDocument();
},
};
export const SettingsAPIKeysNonAdmin: Story = {
args: {
chats: [],
isAdmin: false,
},
parameters: {
queries: [
{
key: userChatProviderConfigsKey,
data: [
{
provider_id: "prov-1",
provider: "openai",
display_name: "OpenAI",
has_user_api_key: false,
has_central_api_key_fallback: false,
},
],
},
],
reactRouter: reactRouterParameters({
location: { path: "/agents/settings/api-keys" },
routing: settingsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("API Keys")).toBeInTheDocument();
},
};
@@ -55,7 +55,9 @@ import {
useRef,
useState,
} from "react";
import { useQuery } from "react-query";
import { Link, NavLink, useLocation, useParams } from "react-router";
import { userChatProviderConfigs } from "#/api/queries/chats";
import type {
Chat,
ChatDiffStatus,
@@ -765,6 +767,14 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
const { appearance, buildInfo } = useDashboard();
const location = useLocation();
const sidebarView = sidebarViewFromPath(location.pathname);
const providerConfigsQuery = useQuery({
...userChatProviderConfigs(),
enabled: sidebarView.panel === "settings" && !isAdmin,
});
const isApiKeysSection =
sidebarView.panel === "settings" && sidebarView.section === "api-keys";
const showApiKeysItem =
isAdmin || isApiKeysSection || Boolean(providerConfigsQuery.data?.length);
const normalizedSearch = "";
const [expandedById, setExpandedById] = useState<Record<string, boolean>>({});
@@ -1256,6 +1266,15 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
to="/agents/settings/behavior"
state={location.state}
/>
{showApiKeysItem && (
<SettingsNavItem
icon={KeyRoundIcon}
label="API Keys"
active={sidebarView.section === "api-keys"}
to="/agents/settings/api-keys"
state={location.state}
/>
)}
{isAdmin && (
<>
<SettingsNavItem
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import type { ChatModelConfig, ChatModelsResponse } from "#/api/typesGenerated";
import {
formatProviderLabel,
getModelOptionsFromConfigs,
getModelSelectorPlaceholder,
getNormalizedModelRef,
hasUserFixableProviders,
resolveModelOptionId,
} from "./modelOptions";
@@ -59,6 +62,72 @@ describe("getNormalizedModelRef", () => {
});
});
describe("hasUserFixableProviders", () => {
it("returns true when a provider needs a user API key", () => {
const catalog = createCatalog([
{
provider: "openai",
available: false,
unavailable_reason: "user_api_key_required",
models: [],
},
]);
expect(hasUserFixableProviders(catalog)).toBe(true);
});
it("returns false when providers are only admin-fixable", () => {
const catalog = createCatalog([
{
provider: "openai",
available: false,
unavailable_reason: "missing_api_key",
models: [],
},
]);
expect(hasUserFixableProviders(catalog)).toBe(false);
});
});
describe("formatProviderLabel", () => {
it("formats OpenAI compatible providers", () => {
expect(formatProviderLabel("openai-compatible")).toBe("OpenAI-compatible");
});
});
describe("getModelSelectorPlaceholder", () => {
it("returns user guidance when only user-configured keys are missing", () => {
const catalog = createCatalog([
{
provider: "openai",
available: false,
unavailable_reason: "user_api_key_required",
models: [],
},
]);
expect(getModelSelectorPlaceholder([], false, true, catalog)).toBe(
"Configure API Keys",
);
});
it("keeps the generic unavailable placeholder for admin fixes", () => {
const catalog = createCatalog([
{
provider: "openai",
available: false,
unavailable_reason: "missing_api_key",
models: [],
},
]);
expect(getModelSelectorPlaceholder([], false, true, catalog)).toBe(
"No Models Available",
);
});
});
describe("resolveModelOptionId", () => {
const modelOptions = [
{
@@ -82,6 +82,17 @@ export const hasConfiguredModelsInCatalog = (
return getCatalogProviders(catalog).some(isProviderConfiguredInCatalog);
};
export const hasUserFixableProviders = (
catalog: TypesGen.ChatModelsResponse | null | undefined,
): boolean => {
if (!catalog?.providers) {
return false;
}
return catalog.providers.some(
(provider) => provider.unavailable_reason === "user_api_key_required",
);
};
const getAvailableProviders = (
catalog: TypesGen.ChatModelsResponse | null | undefined,
): ReadonlySet<string> => {
@@ -205,6 +216,7 @@ export const getModelSelectorPlaceholder = (
modelOptions: readonly ModelSelectorOption[],
isModelCatalogLoading: boolean,
hasConfiguredModels: boolean,
catalog?: TypesGen.ChatModelsResponse | null,
): string => {
if (modelOptions.length > 0) {
return "Select model";
@@ -213,7 +225,9 @@ export const getModelSelectorPlaceholder = (
return "Loading models...";
}
if (hasConfiguredModels) {
return "No Models Available";
return hasUserFixableProviders(catalog)
? "Configure API Keys"
: "No Models Available";
}
return "No Models Configured";
};
+4
View File
@@ -362,6 +362,9 @@ const AgentSettingsBehaviorPage = lazy(
const AgentSettingsProvidersPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsProvidersPage"),
);
const AgentSettingsAPIKeysPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsAPIKeysPage"),
);
const AgentSettingsModelsPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsModelsPage"),
);
@@ -702,6 +705,7 @@ export const router = createBrowserRouter(
<Route path="settings" element={<AgentSettingsPage />}>
<Route index element={<AgentSettingsBehaviorPage />} />
<Route path="behavior" element={<AgentSettingsBehaviorPage />} />
<Route path="api-keys" element={<AgentSettingsAPIKeysPage />} />
<Route path="providers" element={<AgentSettingsProvidersPage />} />
<Route path="models" element={<AgentSettingsModelsPage />} />
<Route