diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx index 15713428f9..f0926e3dee 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.stories.tsx @@ -1,7 +1,16 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, screen, userEvent, within } from "storybook/test"; +import { + expect, + screen, + spyOn, + userEvent, + waitFor, + within, +} from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; -import type { AIProvider } from "#/api/typesGenerated"; +import { API } from "#/api/api"; +import { chatModelConfigs } from "#/api/queries/chats"; +import type { AIProvider, ChatModelConfig } from "#/api/typesGenerated"; import { MockAIProviderAnthropic, MockAIProviderBedrock, @@ -19,8 +28,58 @@ const routingFor = (path: string) => ], }); -const seed = (provider: AIProvider) => ({ - queries: [{ key: ["ai", "providers", provider.name], data: provider }], +const model = ( + overrides: Partial & + Pick, +): ChatModelConfig => ({ + id: overrides.id, + provider: overrides.provider, + ai_provider_id: overrides.ai_provider_id, + model: overrides.model, + display_name: overrides.display_name ?? 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-05-14T10:00:00Z", + updated_at: overrides.updated_at ?? "2026-05-14T10:00:00Z", +}); + +const seed = ( + provider: AIProvider, + models: readonly ChatModelConfig[] = [], +) => ({ + queries: [ + { key: ["ai", "providers", provider.name], data: provider }, + { key: chatModelConfigs().queryKey, data: models }, + ], +}); + +const openAIAssociatedModels = [ + model({ + id: "model-openai-default", + provider: "openai", + ai_provider_id: MockAIProviderOpenAI.id, + model: "gpt-4o", + display_name: "GPT-4o", + is_default: true, + }), + model({ + id: "model-openai-secondary", + provider: "openai", + ai_provider_id: MockAIProviderOpenAI.id, + model: "gpt-4o-mini", + display_name: "GPT-4o Mini", + }), +] satisfies readonly ChatModelConfig[]; + +const anthropicFallbackModel = model({ + id: "model-anthropic-fallback", + provider: "anthropic", + ai_provider_id: MockAIProviderAnthropic.id, + model: "claude-sonnet-4", + display_name: "Claude Sonnet 4", }); const meta: Meta = { @@ -71,9 +130,120 @@ export const DeleteDialogOpen: Story = { name: /^delete$/i, }); await userEvent.click(deleteButton); - // DeleteDialog renders via Radix portal, so search the document, not + // The dialog renders via Radix portal, so search the document, not // just the story canvas. await expect(await screen.findByRole("dialog")).toBeInTheDocument(); await expect(await screen.findByText(/irreversible/i)).toBeInTheDocument(); }, }; + +export const DeleteDialogWithAssociatedModels: Story = { + parameters: { + reactRouter: routingFor(`/ai/settings/${MockAIProviderOpenAI.name}`), + ...seed(MockAIProviderOpenAI, openAIAssociatedModels), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + await canvas.findByRole("button", { name: /^delete$/i }), + ); + + await expect( + await screen.findByText(/Deleting this provider will also disable/i), + ).toBeInTheDocument(); + await expect(screen.getByText("2 models")).toBeInTheDocument(); + }, +}; + +export const DeleteDialogCascadeConfirmed: Story = { + parameters: { + reactRouter: routingFor(`/ai/settings/${MockAIProviderOpenAI.name}`), + ...seed(MockAIProviderOpenAI, [ + ...openAIAssociatedModels, + anthropicFallbackModel, + ]), + }, + beforeEach: () => { + spyOn(API.experimental, "updateChatModelConfig").mockResolvedValue( + anthropicFallbackModel, + ); + spyOn(API, "deleteAIProvider").mockResolvedValue(undefined); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + await canvas.findByRole("button", { name: /^delete$/i }), + ); + await userEvent.type( + await screen.findByLabelText( + `Type ${MockAIProviderOpenAI.name} to confirm`, + ), + MockAIProviderOpenAI.name, + ); + await userEvent.click( + screen.getByRole("button", { name: "Delete provider" }), + ); + + await waitFor(() => { + expect(API.experimental.updateChatModelConfig).toHaveBeenCalledTimes(3); + }); + expect(API.experimental.updateChatModelConfig).toHaveBeenNthCalledWith( + 1, + "model-openai-default", + { enabled: false }, + ); + expect(API.experimental.updateChatModelConfig).toHaveBeenNthCalledWith( + 2, + "model-openai-secondary", + { enabled: false }, + ); + expect(API.experimental.updateChatModelConfig).toHaveBeenNthCalledWith( + 3, + "model-anthropic-fallback", + { is_default: true }, + ); + await waitFor(() => { + expect(API.deleteAIProvider).toHaveBeenCalledWith( + MockAIProviderOpenAI.name, + ); + }); + }, +}; + +export const DeleteDialogCascadeFailure: Story = { + parameters: { + reactRouter: routingFor(`/ai/settings/${MockAIProviderOpenAI.name}`), + ...seed(MockAIProviderOpenAI, [ + ...openAIAssociatedModels, + anthropicFallbackModel, + ]), + }, + beforeEach: () => { + spyOn(API.experimental, "updateChatModelConfig") + .mockResolvedValueOnce(anthropicFallbackModel) + .mockRejectedValueOnce(new Error("Failed to disable model.")); + spyOn(API, "deleteAIProvider").mockResolvedValue(undefined); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + await canvas.findByRole("button", { name: /^delete$/i }), + ); + await userEvent.type( + await screen.findByLabelText( + `Type ${MockAIProviderOpenAI.name} to confirm`, + ), + MockAIProviderOpenAI.name, + ); + await userEvent.click( + screen.getByRole("button", { name: "Delete provider" }), + ); + + await waitFor(() => { + expect(API.experimental.updateChatModelConfig).toHaveBeenCalledTimes(2); + }); + expect(API.deleteAIProvider).not.toHaveBeenCalled(); + await expect(await screen.findByRole("dialog")).toBeInTheDocument(); + await expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled(); + }, +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx index 42c6d60147..2470745255 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx @@ -21,6 +21,7 @@ import { Button } from "#/components/Button/Button"; import { Dialog, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogTitle, @@ -30,6 +31,7 @@ import { Loader } from "#/components/Loader/Loader"; import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader"; import { Spinner } from "#/components/Spinner/Spinner"; import { Switch } from "#/components/Switch/Switch"; +import { cascadeDisableProviderModels } from "#/pages/AISettingsPage/utils/providerDelete"; import { pageTitle } from "#/utils/page"; import { ProviderForm } from "../components/ProviderForm"; import { getProviderIcon } from "../components/ProviderIcon"; @@ -247,11 +249,11 @@ const UpdateProviderPageView: React.FC = () => { Delete provider + + Deleting this provider is irreversible! +
-

- Deleting this provider is irreversible! -

{associatedModelCount > 0 && (
  • @@ -306,31 +308,12 @@ const UpdateProviderPageView: React.FC = () => { const deleteAll = async () => { setIsCascadeDeleting(true); try { - const hadDefault = associatedModels.some( - (mc) => mc.is_default, - ); - const disabledIds = new Set( - associatedModels.map((mc) => mc.id), - ); - for (const mc of associatedModels) { - await API.experimental.updateChatModelConfig(mc.id, { - enabled: false, - }); - } - if (hadDefault) { - const allModels = modelConfigsQuery.data ?? []; - const newDefault = allModels.find( - (mc) => mc.enabled && !disabledIds.has(mc.id), - ); - if (newDefault) { - await API.experimental.updateChatModelConfig( - newDefault.id, - { - is_default: true, - }, - ); - } - } + await cascadeDisableProviderModels({ + associatedModels, + allModels: modelConfigsQuery.data ?? [], + updateModelConfig: + API.experimental.updateChatModelConfig, + }); await invalidateChatConfigurationQueries(queryClient); deleteMutation.mutate(undefined, { onSuccess: () => { diff --git a/site/src/pages/AISettingsPage/utils/providerDelete.test.ts b/site/src/pages/AISettingsPage/utils/providerDelete.test.ts new file mode 100644 index 0000000000..b225ee3045 --- /dev/null +++ b/site/src/pages/AISettingsPage/utils/providerDelete.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from "vitest"; +import type * as TypesGen from "#/api/typesGenerated"; +import { cascadeDisableProviderModels } from "./providerDelete"; + +const model = ( + overrides: Partial & + Pick, +): TypesGen.ChatModelConfig => ({ + id: overrides.id, + provider: overrides.provider, + ai_provider_id: overrides.ai_provider_id, + model: overrides.model, + display_name: overrides.display_name ?? 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-02-18T12:00:00.000Z", + updated_at: overrides.updated_at ?? "2026-02-18T12:00:00.000Z", +}); + +describe("cascadeDisableProviderModels", () => { + it("disables associated models before reassigning the default", async () => { + const associatedModels = [ + model({ + id: "model-associated-default", + provider: "openai", + model: "gpt-4o", + is_default: true, + }), + model({ + id: "model-associated-secondary", + provider: "openai", + model: "gpt-4o-mini", + }), + ]; + const allModels = [ + ...associatedModels, + model({ + id: "model-next-default", + provider: "anthropic", + model: "claude-sonnet-4", + }), + ]; + const updateModelConfig = vi.fn(async () => undefined); + + await cascadeDisableProviderModels({ + associatedModels, + allModels, + updateModelConfig, + }); + + expect(updateModelConfig).toHaveBeenNthCalledWith( + 1, + "model-associated-default", + { enabled: false }, + ); + expect(updateModelConfig).toHaveBeenNthCalledWith( + 2, + "model-associated-secondary", + { enabled: false }, + ); + expect(updateModelConfig).toHaveBeenNthCalledWith(3, "model-next-default", { + is_default: true, + }); + }); + + it("does not reassign the default when the deleted provider had no default", async () => { + const updateModelConfig = vi.fn(async () => undefined); + + await cascadeDisableProviderModels({ + associatedModels: [ + model({ + id: "model-associated", + provider: "openai", + model: "gpt-4o", + }), + ], + allModels: [ + model({ + id: "model-associated", + provider: "openai", + model: "gpt-4o", + }), + model({ + id: "model-other-default", + provider: "anthropic", + model: "claude-sonnet-4", + is_default: true, + }), + ], + updateModelConfig, + }); + + expect(updateModelConfig).toHaveBeenCalledTimes(1); + expect(updateModelConfig).toHaveBeenCalledWith("model-associated", { + enabled: false, + }); + }); + + it("stops before reassigning the default when a model disable fails", async () => { + const error = new Error("failed to disable model"); + const updateModelConfig = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(error); + + await expect( + cascadeDisableProviderModels({ + associatedModels: [ + model({ + id: "model-associated-default", + provider: "openai", + model: "gpt-4o", + is_default: true, + }), + model({ + id: "model-associated-secondary", + provider: "openai", + model: "gpt-4o-mini", + }), + ], + allModels: [ + model({ + id: "model-next-default", + provider: "anthropic", + model: "claude-sonnet-4", + }), + ], + updateModelConfig, + }), + ).rejects.toThrow(error); + + expect(updateModelConfig).toHaveBeenCalledTimes(2); + }); +}); diff --git a/site/src/pages/AISettingsPage/utils/providerDelete.ts b/site/src/pages/AISettingsPage/utils/providerDelete.ts new file mode 100644 index 0000000000..5abd34b1cb --- /dev/null +++ b/site/src/pages/AISettingsPage/utils/providerDelete.ts @@ -0,0 +1,34 @@ +import type * as TypesGen from "#/api/typesGenerated"; + +type UpdateModelConfig = ( + modelConfigId: string, + req: TypesGen.UpdateChatModelConfigRequest, +) => Promise; + +export const cascadeDisableProviderModels = async ({ + associatedModels, + allModels, + updateModelConfig, +}: { + associatedModels: readonly TypesGen.ChatModelConfig[]; + allModels: readonly TypesGen.ChatModelConfig[]; + updateModelConfig: UpdateModelConfig; +}) => { + const disabledIds = new Set(associatedModels.map((model) => model.id)); + const hadDefault = associatedModels.some((model) => model.is_default); + + for (const model of associatedModels) { + await updateModelConfig(model.id, { enabled: false }); + } + + if (!hadDefault) { + return; + } + + const newDefault = allModels.find( + (model) => model.enabled && !disabledIds.has(model.id), + ); + if (newDefault) { + await updateModelConfig(newDefault.id, { is_default: true }); + } +}; diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx index 09a5386369..2e4792b187 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx @@ -2264,6 +2264,23 @@ export const ProviderDeleteConfirmation: Story = { has_api_key: true, }), ], + modelConfigsData: [ + createModelConfig({ + id: "model-openai-default", + provider: "openai", + ai_provider_id: "provider-openai", + model: "gpt-4o", + display_name: "GPT-4o", + is_default: true, + }), + createModelConfig({ + id: "model-openai-secondary", + provider: "openai", + ai_provider_id: "provider-openai", + model: "gpt-4o-mini", + display_name: "GPT-4o Mini", + }), + ], }, play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); @@ -2278,8 +2295,9 @@ export const ProviderDeleteConfirmation: Story = { // 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), + await body.findByText(/Deleting this provider will also disable/i), ).toBeInTheDocument(); + expect(body.getByText(/2 models/i)).toBeInTheDocument(); await expect(body.getByRole("dialog")).toBeInTheDocument(); await expect( body.getByRole("button", { name: "Delete provider" }), @@ -2336,6 +2354,37 @@ export const ProviderDeleteConfirmed: Story = { source: "database", has_api_key: true, }), + createProviderConfig({ + id: "provider-anthropic", + provider: "anthropic", + display_name: "Anthropic", + source: "database", + has_api_key: true, + }), + ], + modelConfigsData: [ + createModelConfig({ + id: "model-openai-default", + provider: "openai", + ai_provider_id: "provider-openai", + model: "gpt-4o", + display_name: "GPT-4o", + is_default: true, + }), + createModelConfig({ + id: "model-openai-secondary", + provider: "openai", + ai_provider_id: "provider-openai", + model: "gpt-4o-mini", + display_name: "GPT-4o Mini", + }), + createModelConfig({ + id: "model-anthropic-fallback", + provider: "anthropic", + ai_provider_id: "provider-anthropic", + model: "claude-sonnet-4", + display_name: "Claude Sonnet 4", + }), ], }, play: async ({ canvasElement, args }) => { @@ -2348,7 +2397,26 @@ export const ProviderDeleteConfirmed: Story = { await body.findByRole("button", { name: "Delete provider" }), ); - // The delete callback should have been called. + await waitFor(() => { + expect(args.onUpdateModel).toHaveBeenCalledTimes(3); + }); + expect(args.onUpdateModel).toHaveBeenNthCalledWith( + 1, + "model-openai-default", + { + enabled: false, + }, + ); + expect(args.onUpdateModel).toHaveBeenNthCalledWith( + 2, + "model-openai-secondary", + { enabled: false }, + ); + expect(args.onUpdateModel).toHaveBeenNthCalledWith( + 3, + "model-anthropic-fallback", + { is_default: true }, + ); await waitFor(() => { expect(args.onDeleteProvider).toHaveBeenCalledTimes(1); }); diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx index 030b906d07..cd7a8c5e5e 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx @@ -324,7 +324,7 @@ export const ChatModelAdminPanel: FC = ({ onCreateProvider={onCreateProvider} onUpdateProvider={onUpdateProvider} onDeleteProvider={onDeleteProvider} - onDisableModel={onUpdateModel} + onUpdateModel={onUpdateModel} allModelConfigs={modelConfigs} /> ) : ( diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx index cfb6b98bd3..acdba7c880 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProviderForm.tsx @@ -7,9 +7,11 @@ import { useId, useState, } from "react"; +import { useQueryClient } from "react-query"; import { useNavigate } from "react-router"; import { toast } from "sonner"; import { getErrorMessage } from "#/api/errors"; +import { invalidateChatConfigurationQueries } from "#/api/queries/chats"; import type * as TypesGen from "#/api/typesGenerated"; import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; import { Button } from "#/components/Button/Button"; @@ -20,6 +22,7 @@ import { TooltipContent, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; +import { cascadeDisableProviderModels } from "#/pages/AISettingsPage/utils/providerDelete"; import { formatProviderLabel } from "../../utils/modelOptions"; import { BackButton } from "../BackButton"; import { ConfirmDeleteDialog } from "../ConfirmDeleteDialog"; @@ -47,7 +50,7 @@ interface ProviderFormProps { req: TypesGen.UpdateChatProviderConfigRequest, ) => Promise; onDeleteProvider: (providerConfigId: string) => Promise; - onDisableModel: ( + onUpdateModel: ( modelConfigId: string, req: TypesGen.UpdateChatModelConfigRequest, ) => Promise; @@ -62,11 +65,12 @@ export const ProviderForm: FC = ({ onCreateProvider, onUpdateProvider, onDeleteProvider, - onDisableModel, + onUpdateModel, allModelConfigs, onBack, }) => { const navigate = useNavigate(); + const queryClient = useQueryClient(); const { provider, providerConfig, baseURL, isEnvPreset } = providerState; const apiKeyInputId = useId(); @@ -383,36 +387,25 @@ export const ProviderForm: FC = ({ entity="provider" description={deleteProviderDescription} onConfirm={() => { - setIsCascadeDeleting(true); - const disabledIds = new Set( - providerState.modelConfigs.map((mc) => mc.id), - ); - const hadDefault = providerState.modelConfigs.some( - (mc) => mc.is_default, - ); - const chain = providerState.modelConfigs.reduce>( - (prev, mc) => - prev.then(() => onDisableModel(mc.id, { enabled: false })), - Promise.resolve(), - ); - chain - .then(() => { - if (hadDefault) { - const newDefault = allModelConfigs.find( - (mc) => mc.enabled && !disabledIds.has(mc.id), - ); - if (newDefault) { - return onDisableModel(newDefault.id, { is_default: true }); - } - } - }) - .then(() => onDeleteProvider(providerConfig.id)) - .catch((error: unknown) => { + const deleteProvider = async () => { + setIsCascadeDeleting(true); + try { + await cascadeDisableProviderModels({ + associatedModels: providerState.modelConfigs, + allModels: allModelConfigs, + updateModelConfig: onUpdateModel, + }); + await onDeleteProvider(providerConfig.id); + setIsCascadeDeleting(false); + } catch (error) { toast.error( getErrorMessage(error, "Failed to delete provider."), ); - }) - .then(() => setIsCascadeDeleting(false)); + setIsCascadeDeleting(false); + await invalidateChatConfigurationQueries(queryClient); + } + }; + void deleteProvider(); }} isPending={isCascadeDeleting || isProviderMutationPending} open={confirmingDelete} diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProvidersSection.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProvidersSection.tsx index 16e3be970b..a7e7bc8223 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProvidersSection.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProvidersSection.tsx @@ -81,7 +81,7 @@ interface ProvidersSectionProps { req: UpdateChatProviderConfigRequest, ) => Promise; onDeleteProvider: (providerConfigId: string) => Promise; - onDisableModel: ( + onUpdateModel: ( modelConfigId: string, req: UpdateChatModelConfigRequest, ) => Promise; @@ -97,7 +97,7 @@ export const ProvidersSection: FC = ({ onCreateProvider, onUpdateProvider, onDeleteProvider, - onDisableModel, + onUpdateModel, allModelConfigs, }) => { const [searchParams, setSearchParams] = useSearchParams(); @@ -207,7 +207,7 @@ export const ProvidersSection: FC = ({ } }} onBack={clearProviderView} - onDisableModel={onDisableModel} + onUpdateModel={onUpdateModel} allModelConfigs={allModelConfigs} /> );