diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 43f6ade2db..fc7ad5ddc0 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -159,6 +159,14 @@ export const updateChatProviderConfig = (queryClient: QueryClient) => ({ }, }); +export const deleteChatProviderConfig = (queryClient: QueryClient) => ({ + mutationFn: (providerConfigId: string) => + API.deleteChatProviderConfig(providerConfigId), + onSuccess: async () => { + await invalidateChatConfigurationQueries(queryClient); + }, +}); + export const createChatModelConfig = (queryClient: QueryClient) => ({ mutationFn: (req: TypesGen.CreateChatModelConfigRequest) => API.createChatModelConfig(req), diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx index 9c90afc93b..236cf1b834 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx @@ -217,27 +217,47 @@ export const EnvPresetProviders: Story = { }, play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - await userEvent.click(await body.findByRole("button", { name: /OpenAI/i })); + + // Both providers should be visible in the list. await expect( - await body.findByText("API key managed by environment variable."), - ).toBeVisible(); - expect(body.getByText("Anthropic")).toBeInTheDocument(); + await body.findByRole("button", { name: /OpenAI/i }), + ).toBeInTheDocument(); expect( - body.getByText( - "This provider API key is managed by an environment variable.", - ), - ).toBeVisible(); - expect( - body.getByText( + body.getByRole("button", { name: /Anthropic/i }), + ).toBeInTheDocument(); + + // Navigate to OpenAI detail view. + await userEvent.click(body.getByRole("button", { name: /OpenAI/i })); + + // In the detail view we should see the env-managed alert. + await expect( + await body.findByText( "This provider key is configured from deployment environment settings and cannot be edited in this UI.", ), ).toBeVisible(); + // No API key input or create button should be present. expect(body.queryByLabelText(/API key/i)).not.toBeInTheDocument(); expect( body.queryByRole("button", { name: "Create provider config", }), ).not.toBeInTheDocument(); + + // Navigate back to the list. + await userEvent.click(body.getByText("Back")); + + // Verify Anthropic is visible in the list again. + await expect( + await body.findByRole("button", { name: /Anthropic/i }), + ).toBeInTheDocument(); + + // Navigate to Anthropic detail view and verify it's also env-managed. + await userEvent.click(body.getByRole("button", { name: /Anthropic/i })); + await expect( + await body.findByText( + "This provider key is configured from deployment environment settings and cannot be edited in this UI.", + ), + ).toBeVisible(); }, }; @@ -271,7 +291,7 @@ export const CreateAndUpdateProvider: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - // Expand the accordion. + // Navigate to the OpenAI detail view. await userEvent.click(await body.findByRole("button", { name: /OpenAI/i })); // Fill in form to create a provider config. @@ -299,26 +319,24 @@ export const CreateAndUpdateProvider: Story = { }), ); - // After creation the form should switch to "Save changes". + // After creation, queries refetch and the component re-keys + // because providerConfig now exists. Navigate back to the list + // and re-enter the detail view to interact with the updated form. await waitFor(() => { expect( body.getByRole("button", { name: "Save changes" }), ).toBeInTheDocument(); }); - // Update the display name and base URL. - const displayNameInput = body.getByPlaceholderText( - "Friendly provider label", - ); - await userEvent.clear(displayNameInput); - await userEvent.type(displayNameInput, "Primary OpenAI"); + // The form was re-rendered with the new providerConfig. + // Focus the API key field, type a new key, update the base URL, + // and save. + 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"); await userEvent.clear(baseURLInput); await userEvent.type(baseURLInput, "https://internal-proxy.example.com/v2"); - await userEvent.type( - body.getByLabelText(/API key/i), - "sk-updated-provider-key", - ); await userEvent.click(body.getByRole("button", { name: "Save changes" })); await waitFor(() => { @@ -327,7 +345,6 @@ export const CreateAndUpdateProvider: Story = { expect(API.updateChatProviderConfig).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - display_name: "Primary OpenAI", api_key: "sk-updated-provider-key", base_url: "https://internal-proxy.example.com/v2", }), @@ -337,60 +354,26 @@ export const CreateAndUpdateProvider: Story = { // ── Models section stories ───────────────────────────────────── -export const ProviderSpecificModelConfigSchema: Story = { - args: { section: "models" as ChatModelAdminSection }, - beforeEach: () => { - setupChatSpies({ - providerConfigs: [ - createProviderConfig({ - id: "provider-openai", - provider: "openai", - display_name: "OpenAI", - source: "database", - has_api_key: true, - }), - createProviderConfig({ - id: "provider-anthropic", - provider: "anthropic", - display_name: "Anthropic", - source: "database", - has_api_key: true, - }), - ], - modelConfigs: [], - modelCatalog: { providers: [] }, +/** + * 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, +) => { + // Click the dropdown trigger to open the provider menu. + const trigger = await body.findByRole("button", { name: "Add model" }); + await userEvent.click(trigger); + // Radix portals dropdown content into the document body. + // Wait for the menu to appear and click the provider item. + await waitFor(async () => { + const item = body.getByRole("menuitem", { + name: new RegExp(providerLabel, "i"), }); - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - - await userEvent.click( - await body.findByRole("button", { name: "Add model" }), - ); - - const schemaBlock = await body.findByTestId("chat-model-config-schema"); - expect(schemaBlock).toHaveTextContent('"provider": "openai"'); - expect(schemaBlock).toHaveTextContent('"openai": {'); - expect(schemaBlock).toHaveTextContent('"reasoning_effort": "high"'); - - // Switch provider to Anthropic. - await userEvent.click(body.getByRole("combobox", { name: "Provider" })); - await userEvent.click( - await body.findByRole("option", { name: /Anthropic/i }), - ); - - await waitFor(() => { - expect(body.getByTestId("chat-model-config-schema")).toHaveTextContent( - '"provider": "anthropic"', - ); - }); - expect(body.getByTestId("chat-model-config-schema")).toHaveTextContent( - '"anthropic": {', - ); - expect(body.getByTestId("chat-model-config-schema")).toHaveTextContent( - '"thinking": {', - ); - }, + await userEvent.click(item); + }); }; export const NoModelConfigByDefault: Story = { @@ -413,16 +396,19 @@ export const NoModelConfigByDefault: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - await userEvent.click( - await body.findByRole("button", { name: "Add model" }), - ); - await userEvent.type(body.getByLabelText(/Model ID/i), "gpt-5-pro"); + // Open "Add model" dropdown and select the OpenAI provider. + await openAddModelForm(body, "OpenAI"); + + await userEvent.type(body.getByLabelText(/Model Identifier/i), "gpt-5-pro"); await userEvent.type(body.getByLabelText(/Context limit/i), "200000"); + // Max output tokens is under the "Advanced" toggle. + await userEvent.click(body.getByText("Advanced")); await expect(await body.findByLabelText(/Max output tokens/i)).toHaveValue( "", ); + // The submit button in ModelForm also says "Add model". await userEvent.click(body.getByRole("button", { name: "Add model" })); await waitFor(() => { expect(API.createChatModelConfig).toHaveBeenCalledTimes(1); @@ -461,18 +447,23 @@ export const SubmitModelConfigExplicitly: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - await userEvent.click( - await body.findByRole("button", { name: "Add model" }), + // Open "Add model" dropdown and select the OpenAI provider. + await openAddModelForm(body, "OpenAI"); + + await userEvent.type( + body.getByLabelText(/Model Identifier/i), + "gpt-5-pro-custom", ); - await userEvent.type(body.getByLabelText(/Model ID/i), "gpt-5-pro-custom"); await userEvent.type(body.getByLabelText(/Context limit/i), "200000"); + // Max output tokens and provider options are under "Advanced". + await userEvent.click(body.getByText("Advanced")); await userEvent.type( await body.findByLabelText(/Max output tokens/i), "32000", ); await userEvent.click( body.getByRole("combobox", { - name: "Reasoning effort", + name: "Reasoning Effort", }), ); await userEvent.click(await body.findByRole("option", { name: "high" })); @@ -518,11 +509,13 @@ export const ValidatesModelConfigFields: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - await userEvent.click( - await body.findByRole("button", { name: "Add model" }), - ); - await userEvent.type(body.getByLabelText(/Model ID/i), "gpt-5-pro"); + // Open "Add model" dropdown and select the OpenAI provider. + await openAddModelForm(body, "OpenAI"); + + await userEvent.type(body.getByLabelText(/Model Identifier/i), "gpt-5-pro"); await userEvent.type(body.getByLabelText(/Context limit/i), "200000"); + // Max output tokens is under the "Advanced" toggle. + await userEvent.click(body.getByText("Advanced")); const maxOutputTokensInput = await body.findByLabelText(/Max output tokens/i); await userEvent.type(maxOutputTokensInput, "not-a-number"); diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx index caa63ac884..0897ec13f9 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx @@ -5,6 +5,7 @@ import { createChatModelConfig as createChatModelConfigMutation, createChatProviderConfig as createChatProviderConfigMutation, deleteChatModelConfig as deleteChatModelConfigMutation, + deleteChatProviderConfig as deleteChatProviderConfigMutation, updateChatModelConfig as updateChatModelConfigMutation, updateChatProviderConfig as updateChatProviderConfigMutation, } from "api/queries/chats"; @@ -201,11 +202,13 @@ const useProviderStates = ( type ChatModelAdminPanelProps = { className?: string; section?: ChatModelAdminSection; + sectionLabel?: string; }; export const ChatModelAdminPanel: FC = ({ className, section = "providers", + sectionLabel, }) => { const queryClient = useQueryClient(); const [requestedProvider, setRequestedProvider] = useState( @@ -230,6 +233,9 @@ export const ChatModelAdminPanel: FC = ({ const updateModelMut = useMutation( updateChatModelConfigMutation(queryClient), ); + const deleteProviderMut = useMutation( + deleteChatProviderConfigMutation(queryClient), + ); const deleteModelMut = useMutation( deleteChatModelConfigMutation(queryClient), ); @@ -281,30 +287,68 @@ export const ChatModelAdminPanel: FC = ({ const providerConfigsUnavailable = providerConfigsQuery.data === null; const modelConfigsUnavailable = modelConfigsQuery.data === null; const isProviderMutationPending = - createProviderMut.isPending || updateProviderMut.isPending; + createProviderMut.isPending || + updateProviderMut.isPending || + deleteProviderMut.isPending; const providerMutationError = - createProviderMut.error ?? updateProviderMut.error; + createProviderMut.error ?? + updateProviderMut.error ?? + deleteProviderMut.error; const modelMutationError = createModelMut.error ?? updateModelMut.error ?? deleteModelMut.error; return ( -
- {/* Header */} -
-

- {section === "providers" - ? "Configure provider credentials and network settings." - : "Manage models available in Agents across all providers."} -

- {isLoading && ( -
- - Loading -
+
+ {isLoading && ( +
+ + Loading +
+ )} + + {/* Content */} +
+ {section === "providers" ? ( + createProviderMut.mutateAsync(req)} + onUpdateProvider={(providerConfigId, req) => + updateProviderMut.mutateAsync({ + providerConfigId, + req, + }) + } + onDeleteProvider={(id) => deleteProviderMut.mutateAsync(id)} + onSelectedProviderChange={setRequestedProvider} + /> + ) : ( + createModelMut.mutateAsync(req)} + onUpdateModel={(modelConfigId, req) => + updateModelMut.mutateAsync({ + modelConfigId, + req, + }) + } + onDeleteModel={(id) => deleteModelMut.mutateAsync(id)} + /> )}
- {/* Alerts */} + {/* Errors — rendered at the bottom */} {providerConfigsQuery.isError && ( )} @@ -318,7 +362,7 @@ export const ChatModelAdminPanel: FC = ({ {modelMutationError && } {providerConfigsUnavailable && ( - + Chat provider admin API is unavailable on this deployment. @@ -327,50 +371,13 @@ export const ChatModelAdminPanel: FC = ({ )} {modelConfigsUnavailable && ( - + Chat model admin API is unavailable on this deployment. /api/v2/chats/model-configs is missing. )} - - {/* Content */} - {section === "providers" ? ( - createProviderMut.mutateAsync(req)} - onUpdateProvider={(providerConfigId, req) => - updateProviderMut.mutateAsync({ - providerConfigId, - req, - }) - } - onSelectedProviderChange={setRequestedProvider} - /> - ) : ( - createModelMut.mutateAsync(req)} - onUpdateModel={(modelConfigId, req) => - updateModelMut.mutateAsync({ - modelConfigId, - req, - }) - } - onDeleteModel={(id) => deleteModelMut.mutateAsync(id)} - /> - )}
); }; diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx index 49f302a2ae..ff7f3ad84b 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx @@ -61,7 +61,7 @@ const InputField: FC< const fieldError = fieldErrors[fieldKey]; const fieldProps = form.getFieldProps(fieldKey); return ( -
+