diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 021cecc607..a37ad02e73 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -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( + userChatProviderConfigsPath, + ); + return response.data; + }; + + upsertUserChatProviderKey = async ( + providerConfigId: string, + req: TypesGen.CreateUserChatProviderKeyRequest, + ): Promise => { + const response = await this.axios.put( + `${userChatProviderConfigsPath}/${encodeURIComponent(providerConfigId)}`, + req, + ); + return response.data; + }; + + deleteUserChatProviderKey = async ( + providerConfigId: string, + ): Promise => { + await this.axios.delete( + `${userChatProviderConfigsPath}/${encodeURIComponent(providerConfigId)}`, + ); + }; + getMCPServerConfigs = async (): Promise => { const response = await this.axios.get(mcpServerConfigsPath); diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 0d13f7b19f..e5613aec8b 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -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 => + 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 }), diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 881ad34262..34e9296f59 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -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} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 5f85836e1f..93e480f7c1 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -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 = ({ setSelectedModel, modelOptions, modelSelectorPlaceholder, + modelSelectorHelp, hasModelOptions, isModelCatalogLoading = false, compressionThreshold, @@ -403,6 +411,7 @@ export const AgentChatPageView: FC = ({ onModelChange={setSelectedModel} modelOptions={modelOptions} modelSelectorPlaceholder={modelSelectorPlaceholder} + modelSelectorHelp={modelSelectorHelp} isModelCatalogLoading={isModelCatalogLoading} inputRef={editing.chatInputRef} initialValue={editing.editorInitialValue} diff --git a/site/src/pages/AgentsPage/AgentSettingsAPIKeysPage.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsAPIKeysPage.stories.tsx new file mode 100644 index 0000000000..4d52527673 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsAPIKeysPage.stories.tsx @@ -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 & + Pick, +): 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 & + Pick, +): 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; + +export default meta; +type Story = StoryObj; + +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"), + }, +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsAPIKeysPage.tsx b/site/src/pages/AgentsPage/AgentSettingsAPIKeysPage.tsx new file mode 100644 index 0000000000..2c7664d33a --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsAPIKeysPage.tsx @@ -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, + providerConfigId: string, +) => ({ + ...current, + [providerConfigId]: (current[providerConfigId] ?? 0) + 1, +}); + +const AgentSettingsAPIKeysPage: FC = () => { + const queryClient = useQueryClient(); + const [providerPanelResetTokens, setProviderPanelResetTokens] = useState< + Record + >({}); + + 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 ( + { + upsertMutation.mutate({ + providerConfigId, + req: { api_key: apiKey }, + }); + }} + onRemove={(providerConfigId) => { + deleteMutation.mutate(providerConfigId); + }} + /> + ); +}; + +export default AgentSettingsAPIKeysPage; diff --git a/site/src/pages/AgentsPage/AgentSettingsAPIKeysPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsAPIKeysPageView.tsx new file mode 100644 index 0000000000..9497c82137 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentSettingsAPIKeysPageView.tsx @@ -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 = ({ + 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) => { + 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 ( +
+
+
+
+ {providerName} +
+ {status.note && ( +

{status.note}

+ )} +
+ + {status.label} + +
+ +
+ +
+ { + setApiKey(event.target.value); + setApiKeyTouched(true); + }} + disabled={inputDisabled} + /> +
+ + {provider.has_user_api_key && ( + + )} +
+
+
+ +
+

+ Enabled models +

+ {areModelsUnavailable ? ( +

+ Enabled model badges are temporarily unavailable. +

+ ) : isModelsLoading ? ( +

+ Loading models... +

+ ) : enabledModels.length > 0 ? ( +
+ {enabledModels.map((model) => ( + + {model.display_name || model.model} + + ))} +
+ ) : ( +

+ No enabled models configured. +

+ )} +
+ + setIsDeleteDialogOpen(false)} + onConfirm={handleRemoveKey} + title="Remove API key?" + description={deleteDescription} + confirmText="Remove" + confirmLoading={isRemoving} + type="delete" + /> +
+ ); +}; + +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 ( +
+
+ +
+ {error ? ( + + ) : isLoading ? ( + + ) : providerItems.length === 0 ? ( + + ) : ( +
+ {providerItems.map((item) => ( + + ))} +
+ )} +
+
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index 70ebde7ddc..98244feed4 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -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: , - statusLabel: "Workspace running", - }, - }, -}; - -export const AttachedWorkspaceStopped: Story = { - args: { - attachedWorkspace: { - name: "my-workspace", - route: "/@admin/my-workspace", - statusIcon: , - statusLabel: "Workspace stopped", - }, - }, -}; - -export const AttachedWorkspaceStarting: Story = { - args: { - attachedWorkspace: { - name: "my-workspace", - route: "/@admin/my-workspace", - statusIcon: , - statusLabel: "Workspace starting", - }, - }, -}; - -export const AttachedWorkspaceError: Story = { - args: { - attachedWorkspace: { - name: "my-workspace", - route: "/@admin/my-workspace", - statusIcon: , - statusLabel: "Workspace failed", - }, - }, -}; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 17055b988c..ba9f0f43db 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -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 = ({ ); 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 = ({ onWorkspaceChange={handleWorkspaceChange} isWorkspaceLoading={isWorkspacesLoading} /> + {modelSelectorHelp ? ( +
+ {modelSelectorHelp} +
+ ) : null}

Coder Agents is available via{" "} ; + +/** + * 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 = { @@ -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 ( + + ); + }, 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, 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), diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelForm.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelForm.tsx index 3dd45e9e36..dae8055260 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelForm.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelForm.tsx @@ -115,7 +115,8 @@ export const ModelForm: FC = ({ const canManageModels = Boolean( selectedProviderState?.providerConfig && - selectedProviderState.hasEffectiveAPIKey, + (selectedProviderState.hasEffectiveAPIKey || + selectedProviderState.providerConfig.allow_user_api_key), ); const form = useFormik({ @@ -315,7 +316,7 @@ export const ModelForm: FC = ({ Back - {/* Header — editable display name */} + {/* Header - editable display name */}

- {/* Footer — pushed to bottom */} + {/* Footer - pushed to bottom */}

diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.tsx index 7cd702aec0..fa8563eb1d 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.tsx @@ -182,9 +182,12 @@ export const ModelsSection: FC = ({ // ── 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 && ( diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx index 01594e22c6..b694837c04 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx @@ -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 = ({ ? "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 = ({ ); 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 = ({ 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 = ({ ...(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 = ({ 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 = ({ } 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 = ({ - {/* Provider header — editable name */} + {/* Provider header, editable name */}
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 = ({ data-form-type="other" >
- - { - setApiKey(e.target.value); - setApiKeyTouched(true); - }} - disabled={isDisabled} - /> - + {shouldShowAPIKeyField && ( + + { + setApiKey(event.target.value); + setApiKeyTouched(true); + }} + disabled={isDisabled} + /> + + )} = ({ placeholder={baseURLPlaceholder} autoComplete="off" value={baseURLValue} - onChange={(e) => setBaseURLValue(e.target.value)} + onChange={(event) => setBaseURLValue(event.target.value)} disabled={isDisabled} /> + +
+
+

+ Key policy +

+

+ Control which credential sources this provider can use. +

+
+
+ + + {shouldShowFallbackToggle && ( + + )} +
+ {!hasCredentialSource && ( +

+ At least one credential source must be enabled +

+ )} +
- {/* Footer — pushed to bottom */} + {/* Footer, pushed to bottom */}

@@ -309,10 +417,7 @@ export const ProviderForm: FC = ({ Delete provider - - Are you sure you want to delete this provider? This action is - irreversible. - + {deleteProviderDescription}