mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
+567
-55
@@ -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" />
|
||||
) : (
|
||||
|
||||
+55
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user