diff --git a/apps/web/src/features/settings/integrations/components/ai-section.test.tsx b/apps/web/src/features/settings/integrations/components/ai-section.test.tsx index 2224e10b3..8210a0b42 100644 --- a/apps/web/src/features/settings/integrations/components/ai-section.test.tsx +++ b/apps/web/src/features/settings/integrations/components/ai-section.test.tsx @@ -41,6 +41,8 @@ const queryClient = vi.hoisted(() => ({ setQueryData: vi.fn(), })); +const providers = vi.hoisted(() => ({ data: [] as MockProvider[] })); + const mutations = vi.hoisted(() => ({ create: vi.fn(), test: vi.fn(), @@ -56,7 +58,7 @@ const mutationOptions = vi.hoisted(() => ({ })); vi.mock("@tanstack/react-query", () => ({ - useQuery: () => ({ data: [], isLoading: false, error: null }), + useQuery: () => ({ data: providers.data, isLoading: false, error: null }), useQueryClient: () => queryClient, useMutation: (options: MutationOptions) => ({ isPending: false, @@ -140,6 +142,7 @@ describe("AISettingsSection", () => { mutations.test.mockReset(); mutations.update.mockReset(); mutations.delete.mockReset(); + providers.data = []; }); it("offers popular AI SDK providers and labels Ollama as cloud-hosted", () => { @@ -187,4 +190,18 @@ describe("AISettingsSection", () => { ]; expect(updater([created])).toEqual([tested]); }); + + it("updates a configured provider's model", async () => { + providers.data = [provider({})]; + mutations.update.mockResolvedValue(provider({ model: "gpt-5-mini", testStatus: "untested", enabled: false })); + + renderSection(); + + fireEvent.click(screen.getByRole("button", { name: "Edit model" })); + fireEvent.change(screen.getByLabelText("Provider model"), { target: { value: "gpt-5-mini" } }); + fireEvent.click(screen.getByRole("button", { name: "Save model" })); + + await waitFor(() => expect(mutations.update).toHaveBeenCalledWith({ id: "provider-1", model: "gpt-5-mini" })); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: ["aiProviders", "list"] }); + }); }); diff --git a/apps/web/src/features/settings/integrations/components/ai-section.tsx b/apps/web/src/features/settings/integrations/components/ai-section.tsx index c3b121236..9afeb8fe0 100644 --- a/apps/web/src/features/settings/integrations/components/ai-section.tsx +++ b/apps/web/src/features/settings/integrations/components/ai-section.tsx @@ -4,7 +4,15 @@ import type { RouterOutput } from "@/libs/orpc/client"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { ORPCError } from "@orpc/client"; -import { CheckCircleIcon, KeyIcon, PlusIcon, TrashIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react"; +import { + CheckCircleIcon, + KeyIcon, + PencilIcon, + PlusIcon, + TrashIcon, + WarningCircleIcon, + XCircleIcon, +} from "@phosphor-icons/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { toast } from "sonner"; @@ -202,11 +210,31 @@ function isAiProviderConfigError(error: unknown) { function ProviderRow({ provider }: ProviderRowProps) { const queryClient = useQueryClient(); + const [isEditingModel, setIsEditingModel] = useState(false); + const [model, setModel] = useState(provider.model); const invalidate = () => queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.queryKey() }); const { mutate: testProvider, isPending: isTesting } = useMutation(orpc.aiProviders.test.mutationOptions()); const { mutate: updateProvider, isPending: isUpdating } = useMutation(orpc.aiProviders.update.mutationOptions()); const { mutate: deleteProvider, isPending: isDeleting } = useMutation(orpc.aiProviders.delete.mutationOptions()); const isMutating = isTesting || isUpdating || isDeleting; + const saveModel = () => { + const nextModel = model.trim(); + if (!nextModel || nextModel === provider.model) { + setIsEditingModel(false); + return; + } + + updateProvider( + { id: provider.id, model: nextModel }, + { + onSuccess: () => { + setIsEditingModel(false); + void invalidate(); + }, + onError: (error) => toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to update provider.` })), + }, + ); + }; return (
@@ -223,8 +251,29 @@ function ProviderRow({ provider }: ProviderRowProps) {

- {providerLabel(provider.provider)} · {provider.model} + {providerLabel(provider.provider)} + {isEditingModel ? "" : ` · ${provider.model}`}

+ {isEditingModel ? ( +
+ setModel(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") saveModel(); + if (event.key === "Escape") setIsEditingModel(false); + }} + /> + + +
+ ) : null}

{provider.baseURL ?? AI_PROVIDER_DEFAULT_BASE_URLS[provider.provider]}

Key: {provider.apiKeyPreview} @@ -281,6 +330,21 @@ function ProviderRow({ provider }: ProviderRowProps) { Test + +