diff --git a/site/.knip.jsonc b/site/.knip.jsonc index dc6b31edbc..d4cedd0c75 100644 --- a/site/.knip.jsonc +++ b/site/.knip.jsonc @@ -15,6 +15,10 @@ // AI settings stack; they are consumed by the provider pages in PR 4. // Remove this exclusion once those pages land. "src/api/queries/aiProviders.ts", + // TODO(ai-settings): addableProviderTypes.ts is staged in PR 3 of the + // AI settings stack; its exports are consumed by the provider pages + // in PR 4. Remove this exclusion once those pages land. + "src/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes.ts", // TODO(devtools): debugPanelUtils.ts is staged in PR 7; its exports are // consumed by the Debug panel components in PRs 8 and 9. Remove this // exclusion once the panel components land. diff --git a/site/src/hooks/useUnsavedChangesPrompt.ts b/site/src/hooks/useUnsavedChangesPrompt.ts new file mode 100644 index 0000000000..f5022fc932 --- /dev/null +++ b/site/src/hooks/useUnsavedChangesPrompt.ts @@ -0,0 +1,43 @@ +import { useEffect } from "react"; +import { useBlocker } from "react-router"; + +type UnsavedChangesPromptState = { + isOpen: boolean; + onCancel: () => void; + onConfirm: () => void; +}; + +/** + * Warns the user before leaving while there are unsaved changes. Pairs a + * `beforeunload` listener for hard navigations (tab close, refresh, address + * bar) with `useBlocker` for in-app navigations. The browser owns the dialog + * for hard navigations; the caller renders one for in-app navigations using + * the returned state. + */ +export const useUnsavedChangesPrompt = ( + enabled: boolean, +): UnsavedChangesPromptState => { + useEffect(() => { + if (!enabled) return; + const onBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + // Older browsers also require a return value to trigger the prompt. + return ""; + }; + window.addEventListener("beforeunload", onBeforeUnload); + return () => { + window.removeEventListener("beforeunload", onBeforeUnload); + }; + }, [enabled]); + + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => + enabled && currentLocation.pathname !== nextLocation.pathname, + ); + + return { + isOpen: blocker.state === "blocked", + onCancel: () => blocker.reset?.(), + onConfirm: () => blocker.proceed?.(), + }; +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/CredentialField.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/CredentialField.tsx new file mode 100644 index 0000000000..888818f859 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/CredentialField.tsx @@ -0,0 +1,84 @@ +import { useId } from "react"; +import { Input } from "#/components/Input/Input"; +import { Label } from "#/components/Label/Label"; +import type { FormHelpers } from "#/utils/formUtils"; + +type CredentialFieldProps = { + label: string; + helpers: FormHelpers; + autoComplete?: string; + placeholder?: string; + description?: React.ReactNode; + required?: boolean; + onFocus?: () => void; +}; + +export const CredentialField: React.FC = ({ + label, + helpers, + autoComplete, + placeholder, + description, + required = false, + onFocus, +}) => { + const inputId = useId(); + const errorId = `${inputId}-error`; + const helperId = `${inputId}-helper`; + const descriptionId = `${inputId}-description`; + const describedBy = [ + description ? descriptionId : null, + helpers.error ? errorId : helpers.helperText ? helperId : null, + ] + .filter(Boolean) + .join(" "); + + const labelNode = ( + + ); + + const descriptionNode = description && ( +
+ {description} +
+ ); + + const helperNode = helpers.error ? ( + + {helpers.helperText} + + ) : helpers.helperText ? ( + + {helpers.helperText} + + ) : null; + + const inputNode = ( + + ); + + return ( +
+ {labelNode} + {descriptionNode} + {inputNode} + {helperNode} +
+ ); +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx new file mode 100644 index 0000000000..8fda28fae2 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx @@ -0,0 +1,162 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test"; +import { ProviderForm } from "./ProviderForm"; + +const meta: Meta = { + title: "pages/AISettingsPage/ProviderForm", + component: ProviderForm, + args: { + editing: false, + isLoading: false, + onSubmit: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const AddAnthropicDefault: Story = {}; + +export const AddOpenAI: Story = { + args: { + initialValues: { + type: "openai", + name: "corporate-openai", + displayName: "Corporate OpenAI", + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-example", + enabled: true, + }, + }, +}; + +export const AddBedrock: Story = { + args: { + initialValues: { + type: "bedrock", + name: "bedrock-prod", + displayName: "Bedrock Prod", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + model: "anthropic.claude-3-5-sonnet-20241022-v2:0", + smallFastModel: "anthropic.claude-3-5-haiku-20241022-v1:0", + accessKey: "AKIAIOSFODNN7EXAMPLE", + accessKeySecret: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + enabled: true, + }, + }, +}; + +export const EditBedrockKeepCredentials: Story = { + args: { + editing: true, + bedrockSavedAccessCredentials: true, + initialValues: { + type: "bedrock", + name: "bedrock", + displayName: "Bedrock", + baseUrl: "https://bedrock-runtime.us-east-2.amazonaws.com", + model: "anthropic.claude-opus-4-7", + smallFastModel: "anthropic.claude-haiku-4-5", + accessKey: "", + accessKeySecret: "", + enabled: true, + }, + }, +}; + +export const EditProvider: Story = { + args: { + editing: true, + openAiAnthropicSavedApiKey: true, + openAiAnthropicMaskedApiKey: "sk-ant-***\u2026***ABCD", + initialValues: { + type: "anthropic", + name: "production-anthropic", + displayName: "Production Anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: "", + enabled: true, + }, + }, +}; + +export const EditOpenAiAnthropicNoSavedKey: Story = { + args: { + editing: true, + openAiAnthropicSavedApiKey: false, + initialValues: { + type: "anthropic", + name: "production-anthropic", + displayName: "Production Anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: "", + enabled: true, + }, + }, +}; + +export const Submitting: Story = { + args: { + isLoading: true, + initialValues: { + type: "openai", + name: "openai", + displayName: "OpenAI", + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-example", + }, + }, +}; + +export const CredentialFocusClear: Story = { + args: { + editing: true, + openAiAnthropicSavedApiKey: true, + openAiAnthropicMaskedApiKey: "sk-ant-***\u2026***ABCD", + initialValues: { + type: "anthropic", + name: "production-anthropic", + displayName: "Production Anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: "", + enabled: true, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const apiKeyInput = await canvas.findByLabelText(/api key/i); + expect(apiKeyInput).toHaveValue("sk-ant-***\u2026***ABCD"); + await userEvent.click(apiKeyInput); + await waitFor(() => expect(apiKeyInput).toHaveValue("")); + }, +}; +export const UnsavedChangesPrompt: Story = { + args: { + editing: true, + initialValues: { + type: "openai", + name: "corporate-openai", + displayName: "Corporate OpenAI", + baseUrl: "https://api.openai.com/v1", + apiKey: "", + enabled: true, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Dirty the form by editing the display name. + const displayName = await canvas.findByLabelText(/display name/i); + await userEvent.type(displayName, " Edited"); + // Attempt to leave via the in-form Cancel link. + const cancelLink = canvas.getByRole("link", { name: /cancel/i }); + await userEvent.click(cancelLink); + // The dialog renders in a portal, so search the document. + const dialog = await screen.findByRole("dialog"); + await expect( + within(dialog).getByText("Unsaved changes"), + ).toBeInTheDocument(); + await expect( + within(dialog).getByText(/your updates haven't been saved/i), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx new file mode 100644 index 0000000000..0a609de2ac --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx @@ -0,0 +1,439 @@ +import { useFormik } from "formik"; +import { TriangleAlertIcon } from "lucide-react"; +import { type FC, useEffect, useRef } from "react"; +import { Link } from "react-router"; +import * as Yup from "yup"; +import type { AIProviderType } from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Button } from "#/components/Button/Button"; +import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; +import { Form, FormFields } from "#/components/Form/Form"; +import { FormField } from "#/components/FormField/FormField"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt"; +import { getFormHelpers } from "#/utils/formUtils"; +import { CredentialField } from "./CredentialField"; + +export type ProviderFormValues = { + type: AIProviderType | ""; + name: string; + displayName: string; + baseUrl: string; + model: string; + smallFastModel: string; + accessKey: string; + accessKeySecret: string; + apiKey: string; + enabled: boolean; +}; + +const HTTP_SCHEME_REGEX = /^https?:\/\//i; +const BEDROCK_CANONICAL_URL_REGEX = + /^https:\/\/bedrock-runtime\.([a-z0-9-]+)\.amazonaws\.com\/?$/i; +const PROVIDER_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +export const SAVED_CREDENTIAL_MASK = "********"; + +export const parseBedrockRegionFromBaseUrl = ( + baseUrl: string, +): string | undefined => { + const match = BEDROCK_CANONICAL_URL_REGEX.exec(baseUrl.trim()); + return match?.[1]?.toLowerCase(); +}; + +const makeNameSchema = (editing: boolean) => + editing + ? Yup.string() + : Yup.string() + .matches( + PROVIDER_NAME_REGEX, + "Name must be lowercase, hyphen-separated (e.g. 'my-anthropic').", + ) + .required("Name is required"); + +// Display name is always optional. The form copy says blank falls back +// to the provider name, and the update API supports clearing the value. +const makeDisplayNameSchema = (_editing: boolean) => Yup.string(); + +const defaultInitialValues: ProviderFormValues = { + type: "anthropic", + name: "", + displayName: "", + baseUrl: "", + model: "", + smallFastModel: "", + accessKey: "", + accessKeySecret: "", + apiKey: "", + enabled: true, +}; + +const providerDefaults: Partial< + Record> +> = { + openai: { name: "openai", baseUrl: "https://api.openai.com/v1/" }, + anthropic: { name: "anthropic", baseUrl: "https://api.anthropic.com" }, + bedrock: { + name: "bedrock", + baseUrl: "https://bedrock-runtime.us-east-2.amazonaws.com", + }, + azure: { + name: "azure", + baseUrl: "https://YOUR-RESOURCE.openai.azure.com/openai/v1", + }, + google: { + name: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/", + }, + "openai-compat": { name: "openai-compat", baseUrl: "" }, + openrouter: { name: "openrouter", baseUrl: "https://openrouter.ai/api/v1" }, + vercel: { name: "vercel", baseUrl: "https://ai-gateway.vercel.sh/v1" }, +}; + +const makeOpenAiAnthropicSchema = (editing: boolean) => + Yup.object({ + type: Yup.string() + .oneOf([ + "openai", + "anthropic", + "azure", + "google", + "openai-compat", + "openrouter", + "vercel", + ] as const) + .required(), + name: makeNameSchema(editing), + displayName: makeDisplayNameSchema(editing), + baseUrl: Yup.string() + .url("Endpoint must be a valid URL") + .matches(HTTP_SCHEME_REGEX, "Endpoint must use http or https.") + .required("Endpoint is required"), + apiKey: editing + ? Yup.string() + : Yup.string().required("API key is required"), + enabled: Yup.boolean(), + }); + +const credentialFilled = (value: string | undefined): boolean => { + if (!value) return false; + const trimmed = value.trim(); + return trimmed !== "" && trimmed !== SAVED_CREDENTIAL_MASK; +}; + +const makeBedrockSchema = (editing: boolean) => + Yup.object({ + type: Yup.string() + .oneOf(["bedrock"] as const) + .required(), + name: makeNameSchema(editing), + displayName: makeDisplayNameSchema(editing), + baseUrl: Yup.string() + .url("Endpoint must be a valid URL") + .matches( + BEDROCK_CANONICAL_URL_REGEX, + "Endpoint must be a standard AWS Bedrock URL.", + ) + .required("Endpoint is required"), + apiKey: Yup.string(), + model: Yup.string().required("Model is required"), + smallFastModel: Yup.string().required("Small-fast model is required"), + accessKey: (editing + ? Yup.string() + : Yup.string().required("Access key is required") + ).test( + "access-key-paired", + "Enter both access key and secret to rotate credentials.", + function (value) { + const secret = (this.parent as { accessKeySecret?: string }) + .accessKeySecret; + return !(credentialFilled(secret) && !credentialFilled(value)); + }, + ), + accessKeySecret: (editing + ? Yup.string() + : Yup.string().required("Access key secret is required") + ).test( + "access-key-secret-paired", + "Enter both access key and secret to rotate credentials.", + function (value) { + const accessKey = (this.parent as { accessKey?: string }).accessKey; + return !(credentialFilled(accessKey) && !credentialFilled(value)); + }, + ), + enabled: Yup.boolean(), + }); + +const getProviderFormSchema = (editing: boolean) => + Yup.lazy((value: { type?: AIProviderType } | undefined) => { + switch (value?.type) { + case "openai": + case "anthropic": + case "azure": + case "google": + case "openai-compat": + case "openrouter": + case "vercel": + return makeOpenAiAnthropicSchema(editing); + case "bedrock": + return makeBedrockSchema(editing); + default: + return Yup.object({ + type: Yup.string() + .oneOf([ + "openai", + "anthropic", + "bedrock", + "azure", + "google", + "openai-compat", + "openrouter", + "vercel", + ]) + .required(), + }); + } + }); + +type ProviderFormProps = { + editing?: boolean; + /** When editing Bedrock and the API already has keys, show masked placeholders until cleared. */ + bedrockSavedAccessCredentials?: boolean; + /** When editing openai/anthropic and a key is on file, show a masked placeholder until cleared. */ + openAiAnthropicSavedApiKey?: boolean; + /** Masked rendering of the saved openai/anthropic key (e.g. `sk-***...ABCD`). Falls back to a generic mask when omitted. */ + openAiAnthropicMaskedApiKey?: string; + initialValues?: Partial; + onSubmit?: (values: ProviderFormValues) => void; + isLoading?: boolean; + submitError?: unknown; +}; + +const namePlaceholder = (provider: string) => + providerDefaults[provider as keyof typeof providerDefaults]?.name; + +const apiKeyPlaceholder = (provider: string) => { + switch (provider) { + case "openai": + return "sk-proj-..."; + case "anthropic": + return "sk-ant-..."; + } +}; + +const baseUrlPlaceholder = (provider: string) => + providerDefaults[provider as keyof typeof providerDefaults]?.baseUrl; + +export const ProviderForm: FC = ({ + editing = false, + bedrockSavedAccessCredentials = false, + openAiAnthropicSavedApiKey = false, + openAiAnthropicMaskedApiKey, + initialValues, + onSubmit, + isLoading = false, + submitError, +}) => { + const resolvedType = initialValues?.type ?? defaultInitialValues.type; + const typeDefaults = + providerDefaults[resolvedType as keyof typeof providerDefaults]; + + const form = useFormik({ + initialValues: { + ...defaultInitialValues, + // Layer order: base defaults < type prefills < parent's initialValues. + // Edit overrides prefills with server values; create gets them as-is. + ...(typeDefaults ?? {}), + ...initialValues, + // Seed Bedrock credentials with the mask when on file; focus clears it, + // and a re-submitted "" tells the API mapping to keep the value. + accessKey: bedrockSavedAccessCredentials ? SAVED_CREDENTIAL_MASK : "", + accessKeySecret: bedrockSavedAccessCredentials + ? SAVED_CREDENTIAL_MASK + : "", + // Same pattern for openai/anthropic. Prefer the API-supplied masked + // rendering so the user sees the key's identifying suffix. + apiKey: openAiAnthropicSavedApiKey + ? (openAiAnthropicMaskedApiKey ?? SAVED_CREDENTIAL_MASK) + : "", + }, + validationSchema: getProviderFormSchema(editing), + validateOnMount: true, + onSubmit: onSubmit ?? (() => {}), + }); + const getFieldHelpers = getFormHelpers(form, submitError); + + const typeSelectValue = form.values.type; + + // Clears the field once if it's still showing the seeded mask; + // subsequent focuses are no-ops. + const handleCredentialFocus = ( + field: "apiKey" | "accessKey" | "accessKeySecret", + ) => { + const initial = form.initialValues[field]; + if (form.values[field] === initial && initial !== "") { + void form.setFieldValue(field, ""); + } + }; + + // When the parent's mutation finishes without an error, treat the just- + // submitted values as the new baseline so the unsaved-changes prompt does + // not fire on subsequent navigations. React Query reports a missing error + // as `null`, so a truthy check covers both null and undefined. + const previousIsLoading = useRef(isLoading); + useEffect(() => { + if (previousIsLoading.current && !isLoading && !submitError) { + form.resetForm({ values: form.values }); + } + previousIsLoading.current = isLoading; + }, [isLoading, submitError, form]); + + const unsavedChanges = useUnsavedChangesPrompt( + form.dirty && !form.isSubmitting, + ); + + return ( +
+ + {Boolean(submitError) && } + {typeSelectValue !== "" && typeSelectValue !== "bedrock" && ( + <> +
+ + +
+ + handleCredentialFocus("apiKey")} + autoComplete="new-password" + placeholder={apiKeyPlaceholder(form.values.type)} + /> + + )} + + {typeSelectValue === "bedrock" && ( + <> +
+ + +
+ + In the format of{" "} + + {"https://bedrock-runtime.{region}.amazonaws.com"} + + + } + className="w-full" + placeholder={baseUrlPlaceholder(form.values.type)} + /> +
+ + +
+
+ handleCredentialFocus("accessKey")} + /> + handleCredentialFocus("accessKeySecret")} + autoComplete="new-password" + /> +
+ + )} + +
+ + + + +
+
+ + +

+ Your updates haven't been saved. Leave anyway? +

+ + } + /> + + ); +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.stories.tsx new file mode 100644 index 0000000000..dc16ada480 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ProviderIcon } from "./ProviderIcon"; + +const meta: Meta = { + title: "pages/AISettingsPage/ProviderIcon", + component: ProviderIcon, +}; + +export default meta; +type Story = StoryObj; + +export const OpenAI: Story = { + args: { + provider: "openai", + }, +}; + +export const Anthropic: Story = { + args: { + provider: "anthropic", + }, +}; + +export const Bedrock: Story = { + args: { + provider: "bedrock", + }, +}; + +export const Azure: Story = { + args: { + provider: "azure", + }, +}; + +export const Google: Story = { + args: { + provider: "google", + }, +}; + +export const Vercel: Story = { + args: { + provider: "vercel", + }, +}; + +// Provider types without a bundled icon (openai-compat, openrouter, or +// anything we don't recognize) render the generic Building2 glyph. +export const Fallback: Story = { + args: { + provider: "openai-compat", + }, +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx new file mode 100644 index 0000000000..feaf29f898 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderIcon.tsx @@ -0,0 +1,60 @@ +import { Building2Icon } from "lucide-react"; +import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; + +type ProviderIconProps = { + provider: string; +}; + +/** @lintignore Consumed by provider pages landing in the next PR of the AI settings stack. */ +export const getProviderIcon = (provider: string): string | undefined => { + switch (provider) { + case "openai": + return "/icon/openai.svg"; + case "anthropic": + return "/icon/anthropic.svg"; + case "bedrock": + return "/icon/aws.svg"; + case "azure": + return "/icon/azure.svg"; + case "google": + return "/icon/google.svg"; + case "vercel": + return "/icon/vercel.svg"; + default: + return undefined; + } +}; + +const getProviderName = (provider: string): string => { + switch (provider) { + case "openai": + return "OpenAI"; + case "anthropic": + return "Anthropic"; + case "bedrock": + return "AWS Bedrock"; + case "azure": + return "Azure OpenAI"; + case "google": + return "Google"; + case "openai-compat": + return "OpenAI-compatible"; + case "openrouter": + return "OpenRouter"; + case "vercel": + return "Vercel"; + default: + return provider || "Unknown provider"; + } +}; + +export const ProviderIcon: React.FC = ({ provider }) => { + const iconSrc = getProviderIcon(provider); + const name = getProviderName(provider); + if (iconSrc === undefined) { + return ( + + ); + } + return ; +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx new file mode 100644 index 0000000000..902c22c7c5 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "#/components/Table/Table"; +import { + MockAIProviderAnthropic, + MockAIProviderBedrock, + MockAIProviderOpenAI, +} from "#/testHelpers/entities"; +import { ProviderRow } from "./ProviderRow"; + +const meta: Meta = { + title: "pages/AISettingsPage/ProviderRow", + component: ProviderRow, + args: { + onClick: fn(), + }, + decorators: [ + (Story) => ( + + + + Name + Base URL + + Enabled + + + Open provider + + + + + + +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const OpenAI: Story = { + args: { + provider: MockAIProviderOpenAI, + }, +}; + +export const Anthropic: Story = { + args: { + provider: MockAIProviderAnthropic, + }, +}; + +export const Bedrock: Story = { + args: { + provider: MockAIProviderBedrock, + }, +}; + +export const LongText: Story = { + args: { + provider: { + ...MockAIProviderBedrock, + name: "bedrock12341234bedrock12341234bedrock12341234", + display_name: "thisisacoolexample11", + base_url: + "https://bedrock-runtime.us-east-2.amazonaws.com/very/long/path/segment", + }, + }, +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.tsx new file mode 100644 index 0000000000..bce69fdb3d --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.tsx @@ -0,0 +1,58 @@ +import { ChevronRightIcon } from "lucide-react"; +import type { AIProvider } from "#/api/typesGenerated"; +import { Avatar } from "#/components/Avatar/Avatar"; +import { AvatarData } from "#/components/Avatar/AvatarData"; +import { Badge } from "#/components/Badge/Badge"; +import { TableCell, TableRow } from "#/components/Table/Table"; +import { useClickableTableRow } from "#/hooks/useClickableTableRow"; +import { ProviderIcon } from "./ProviderIcon"; +import { getProviderDisplayType } from "./providerFormApiMap"; + +type ProviderRowProps = { + provider: AIProvider; + onClick?: () => void; +}; + +export const ProviderRow: React.FC = ({ + provider, + onClick, +}) => { + const clickableProps = useClickableTableRow({ + onClick: () => onClick?.(), + }); + const displayName = provider.display_name || provider.name; + + return ( + + + + + + } + /> + + + + {provider.base_url} + + + + {provider.enabled && Enabled} + + +
+ +
+
+
+ ); +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes.ts b/site/src/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes.ts new file mode 100644 index 0000000000..aebf2af08b --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/addableProviderTypes.ts @@ -0,0 +1,17 @@ +import type { AIProviderType } from "#/api/typesGenerated"; + +export type AddableProvider = { + value: AIProviderType; + label: string; +}; + +export const addableProviders: readonly AddableProvider[] = [ + { value: "anthropic", label: "Anthropic" }, + { value: "bedrock", label: "AWS Bedrock" }, + { value: "azure", label: "Azure OpenAI" }, + { value: "google", label: "Google" }, + { value: "openai", label: "OpenAI" }, + { value: "openai-compat", label: "OpenAI-compatible" }, + { value: "openrouter", label: "OpenRouter" }, + { value: "vercel", label: "Vercel" }, +]; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts new file mode 100644 index 0000000000..3f7ab9ca59 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts @@ -0,0 +1,507 @@ +import { describe, expect, it } from "vitest"; +import type { AIProvider } from "#/api/typesGenerated"; +import { + MockAIProviderAnthropic, + MockAIProviderBedrock, + MockAIProviderOpenAI, +} from "#/testHelpers/entities"; +import { + type ProviderFormValues, + parseBedrockRegionFromBaseUrl, + SAVED_CREDENTIAL_MASK, +} from "./ProviderForm"; +import { + aiProviderToFormValues, + getProviderDisplayType, + hasBedrockStoredCredentials, + isBedrockProvider, + providerFormValuesToCreate, + providerFormValuesToUpdate, +} from "./providerFormApiMap"; + +const baseOpenAIFormValues: ProviderFormValues = { + type: "openai", + name: "primary-openai", + displayName: "Primary OpenAI", + baseUrl: "https://api.openai.com", + model: "", + smallFastModel: "", + accessKey: "", + accessKeySecret: "", + apiKey: "sk-test", + enabled: true, +}; + +const baseBedrockFormValues: ProviderFormValues = { + type: "bedrock", + name: "primary-bedrock", + displayName: "Primary Bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + model: "anthropic.claude-sonnet-4-5", + smallFastModel: "anthropic.claude-haiku-4-5", + accessKey: "AKIA-test", + accessKeySecret: "secret", + apiKey: "", + enabled: true, +}; + +// Cast a plain object to AIProvider's discriminated `settings` shape; +// the generated TS interface is empty and the wire form carries the +// discriminator keys flattened in alongside the variant fields. +const settings = (raw: Record): AIProvider["settings"] => + raw as unknown as AIProvider["settings"]; + +describe("parseBedrockRegionFromBaseUrl", () => { + it("extracts the region from a canonical AWS Bedrock URL", () => { + expect( + parseBedrockRegionFromBaseUrl( + "https://bedrock-runtime.us-east-1.amazonaws.com", + ), + ).toBe("us-east-1"); + }); + + it("accepts a trailing slash", () => { + expect( + parseBedrockRegionFromBaseUrl( + "https://bedrock-runtime.us-west-2.amazonaws.com/", + ), + ).toBe("us-west-2"); + }); + + it("lowercases the region", () => { + expect( + parseBedrockRegionFromBaseUrl( + "https://bedrock-runtime.US-EAST-1.amazonaws.com", + ), + ).toBe("us-east-1"); + }); + + it("trims surrounding whitespace before matching", () => { + expect( + parseBedrockRegionFromBaseUrl( + " https://bedrock-runtime.us-east-1.amazonaws.com ", + ), + ).toBe("us-east-1"); + }); + + it("returns undefined for a non-AWS URL", () => { + expect( + parseBedrockRegionFromBaseUrl("https://bedrock.internal.example.com"), + ).toBeUndefined(); + }); + + it("returns undefined for an empty string", () => { + expect(parseBedrockRegionFromBaseUrl("")).toBeUndefined(); + }); + + it("returns undefined for an http (non-https) URL", () => { + expect( + parseBedrockRegionFromBaseUrl( + "http://bedrock-runtime.us-east-1.amazonaws.com", + ), + ).toBeUndefined(); + }); + + it("returns undefined for a URL with a path", () => { + expect( + parseBedrockRegionFromBaseUrl( + "https://bedrock-runtime.us-east-1.amazonaws.com/v1/something", + ), + ).toBeUndefined(); + }); + + it("returns undefined for the China partition (different TLD)", () => { + // AWS China uses *.amazonaws.com.cn, which the canonical regex does + // not match by design; cn-* users get the explicit Region input. + expect( + parseBedrockRegionFromBaseUrl( + "https://bedrock-runtime.cn-north-1.amazonaws.com.cn", + ), + ).toBeUndefined(); + }); +}); + +describe("isBedrockProvider", () => { + it("recognises a discriminated bedrock provider", () => { + expect(isBedrockProvider(MockAIProviderBedrock)).toBe(true); + }); + + it("rejects an OpenAI provider", () => { + expect(isBedrockProvider(MockAIProviderOpenAI)).toBe(false); + }); + + it("rejects an anthropic provider whose settings are null", () => { + // MockAIProviderAnthropic carries `settings: null`, which the Go + // server emits when there is no type-specific config. The helper + // must null-check before reading `_type`. + expect(isBedrockProvider(MockAIProviderAnthropic)).toBe(false); + }); + + it("rejects an anthropic provider whose settings carry a different discriminator", () => { + const provider: AIProvider = { + ...MockAIProviderAnthropic, + settings: settings({ _type: "copilot", _version: 1 }), + }; + expect(isBedrockProvider(provider)).toBe(false); + }); +}); + +describe("hasBedrockStoredCredentials", () => { + it("is true whenever the provider is Bedrock", () => { + // Bedrock secrets are write-only, so we cannot inspect their + // presence; the helper assumes any persisted Bedrock config + // implies credentials are on file. + expect(hasBedrockStoredCredentials(MockAIProviderBedrock)).toBe(true); + }); + + it("is false for non-Bedrock providers", () => { + expect(hasBedrockStoredCredentials(MockAIProviderOpenAI)).toBe(false); + expect(hasBedrockStoredCredentials(MockAIProviderAnthropic)).toBe(false); + }); +}); + +describe("getProviderDisplayType", () => { + it("returns bedrock for a Bedrock provider", () => { + expect(getProviderDisplayType(MockAIProviderBedrock)).toBe("bedrock"); + }); + + it("returns anthropic for a non-Bedrock Anthropic provider", () => { + expect(getProviderDisplayType(MockAIProviderAnthropic)).toBe("anthropic"); + }); + + it("returns openai for the canonical OpenAI host", () => { + expect(getProviderDisplayType(MockAIProviderOpenAI)).toBe("openai"); + }); + + it.each([ + ["azure", "https://my-resource.openai.azure.com/openai/v1"], + ["azure", "https://YOUR-RESOURCE.openai.azure.com/openai/v1"], + ["google", "https://generativelanguage.googleapis.com/v1beta/openai/"], + ["openrouter", "https://openrouter.ai/api/v1"], + ["vercel", "https://ai-gateway.vercel.sh/v1"], + ])("recovers the %s preset from a canonical base_url", (expected, baseUrl) => { + const provider: AIProvider = { + ...MockAIProviderOpenAI, + base_url: baseUrl, + }; + expect(getProviderDisplayType(provider)).toBe(expected); + }); + + it("falls back to the wire type for an unrecognized base_url", () => { + // Internal proxies and custom OpenAI-compatible endpoints keep the + // OpenAI glyph rather than dropping to a question mark. + const provider: AIProvider = { + ...MockAIProviderOpenAI, + base_url: "https://llm-proxy.internal.example.com/v1", + }; + expect(getProviderDisplayType(provider)).toBe("openai"); + }); + + it("falls back to the wire type when base_url is not a parseable URL", () => { + const provider: AIProvider = { + ...MockAIProviderOpenAI, + base_url: "not a url", + }; + expect(getProviderDisplayType(provider)).toBe("openai"); + }); +}); + +describe("providerFormValuesToCreate", () => { + describe("OpenAI/Anthropic", () => { + it("sends a plaintext API key in the api_keys list", () => { + const req = providerFormValuesToCreate(baseOpenAIFormValues); + expect(req.type).toBe("openai"); + expect(req.api_keys).toEqual(["sk-test"]); + }); + + it("omits api_keys when the user did not type a key", () => { + const req = providerFormValuesToCreate({ + ...baseOpenAIFormValues, + apiKey: "", + }); + expect(req.api_keys).toBeUndefined(); + }); + + it("omits api_keys when the value is only whitespace", () => { + const req = providerFormValuesToCreate({ + ...baseOpenAIFormValues, + apiKey: " ", + }); + expect(req.api_keys).toBeUndefined(); + }); + + it("does not round-trip the saved-credential mask back to the API", () => { + const req = providerFormValuesToCreate({ + ...baseOpenAIFormValues, + apiKey: SAVED_CREDENTIAL_MASK, + }); + expect(req.api_keys).toBeUndefined(); + }); + + it("omits display_name when blank so the server stores NULL", () => { + const req = providerFormValuesToCreate({ + ...baseOpenAIFormValues, + displayName: "", + }); + expect(req.display_name).toBeUndefined(); + }); + + it("trims whitespace from name and baseUrl", () => { + const req = providerFormValuesToCreate({ + ...baseOpenAIFormValues, + name: " primary-openai ", + baseUrl: " https://api.openai.com ", + }); + expect(req.name).toBe("primary-openai"); + expect(req.base_url).toBe("https://api.openai.com"); + }); + + it.each([ + ["azure", "https://YOUR-RESOURCE.openai.azure.com/openai/v1"], + ["google", "https://generativelanguage.googleapis.com/v1beta/openai/"], + ["openai-compat", "https://compat.example.com/v1"], + ["openrouter", "https://openrouter.ai/api/v1"], + ["vercel", "https://ai-gateway.vercel.sh/v1"], + ] as const)("collapses the %s UI type to type=openai on the wire", (type, baseUrl) => { + const req = providerFormValuesToCreate({ + ...baseOpenAIFormValues, + type, + baseUrl, + }); + expect(req.type).toBe("openai"); + expect(req.base_url).toBe(baseUrl); + expect(req.api_keys).toEqual(["sk-test"]); + }); + + it("rejects an empty type", () => { + // `type: ""` is blocked by the Yup schema; the helper still has + // to refuse to send a malformed payload if a caller bypasses it. + expect(() => + providerFormValuesToCreate({ ...baseOpenAIFormValues, type: "" }), + ).toThrowError(/provider type is required/); + }); + }); + + describe("Bedrock", () => { + it('maps Bedrock to a wire `type:"anthropic"`', () => { + const req = providerFormValuesToCreate(baseBedrockFormValues); + expect(req.type).toBe("anthropic"); + }); + + it("derives the region from a canonical AWS URL", () => { + const req = providerFormValuesToCreate(baseBedrockFormValues); + const s = req.settings as unknown as Record; + expect(s._type).toBe("bedrock"); + expect(s.region).toBe("us-east-1"); + }); + + it("omits the region when the URL is non-canonical", () => { + // The form schema blocks non-canonical endpoints before submit; the + // helper itself stays strict, returning an undefined region rather + // than inventing a value. + const req = providerFormValuesToCreate({ + ...baseBedrockFormValues, + baseUrl: "https://bedrock.internal.example.com", + }); + const s = req.settings as unknown as Record; + expect(s.region).toBeUndefined(); + }); + + it("includes access_key and access_key_secret when provided", () => { + const req = providerFormValuesToCreate(baseBedrockFormValues); + const s = req.settings as unknown as Record; + expect(s.access_key).toBe("AKIA-test"); + expect(s.access_key_secret).toBe("secret"); + }); + + it("omits the access fields when the form values are blank", () => { + const req = providerFormValuesToCreate({ + ...baseBedrockFormValues, + accessKey: "", + accessKeySecret: "", + }); + const s = req.settings as unknown as Record; + expect(s.access_key).toBeUndefined(); + expect(s.access_key_secret).toBeUndefined(); + }); + + it("ignores the OpenAI/Anthropic api key field", () => { + const req = providerFormValuesToCreate({ + ...baseBedrockFormValues, + apiKey: "should-be-ignored", + }); + expect(req.api_keys).toBeUndefined(); + }); + }); +}); + +describe("providerFormValuesToUpdate", () => { + describe("OpenAI/Anthropic", () => { + it("sends api_keys as a single-entry rotation list when a new key is typed", () => { + const req = providerFormValuesToUpdate( + { ...baseOpenAIFormValues, apiKey: "sk-new" }, + MockAIProviderOpenAI, + ); + expect(req.api_keys).toEqual([{ api_key: "sk-new" }]); + }); + + it("retains the saved key by id when the user left the masked rendering", () => { + // Seed the form with the saved masked rendering exactly as + // the API returns it; the declarative payload must reference + // the saved id so the server keeps the row. + const req = providerFormValuesToUpdate( + { + ...baseOpenAIFormValues, + apiKey: MockAIProviderOpenAI.api_keys[0].masked, + }, + MockAIProviderOpenAI, + ); + expect(req.api_keys).toEqual([ + { id: MockAIProviderOpenAI.api_keys[0].id }, + ]); + }); + + it("retains the saved key by id when the user left SAVED_CREDENTIAL_MASK", () => { + const req = providerFormValuesToUpdate( + { ...baseOpenAIFormValues, apiKey: SAVED_CREDENTIAL_MASK }, + MockAIProviderOpenAI, + ); + expect(req.api_keys).toEqual([ + { id: MockAIProviderOpenAI.api_keys[0].id }, + ]); + }); + + it("sends an empty api_keys list when no key was saved and none was typed", () => { + // Declarative wire shape: an empty list is the explicit "no keys" + // state, matching the user's intent for a provider that never had + // a credential on file. + const req = providerFormValuesToUpdate( + { ...baseOpenAIFormValues, apiKey: "" }, + MockAIProviderAnthropic, + ); + expect(req.api_keys).toEqual([]); + }); + }); + + describe("Bedrock", () => { + it("derives the region from the canonical URL", () => { + const req = providerFormValuesToUpdate( + { + ...baseBedrockFormValues, + baseUrl: "https://bedrock-runtime.us-west-2.amazonaws.com", + accessKey: SAVED_CREDENTIAL_MASK, + accessKeySecret: SAVED_CREDENTIAL_MASK, + }, + MockAIProviderBedrock, + ); + const s = req.settings as unknown as Record; + expect(s.region).toBe("us-west-2"); + }); + + it("omits the region when the URL is non-canonical", () => { + // The form schema blocks non-canonical endpoints before submit; the + // helper itself stays strict, returning an undefined region rather + // than inventing a value. + const req = providerFormValuesToUpdate( + { + ...baseBedrockFormValues, + baseUrl: "https://bedrock.internal.example.com", + accessKey: SAVED_CREDENTIAL_MASK, + accessKeySecret: SAVED_CREDENTIAL_MASK, + }, + MockAIProviderBedrock, + ); + const s = req.settings as unknown as Record; + expect(s.region).toBeUndefined(); + }); + + it("omits access_key/access_key_secret when the user left both masked (empty = keep)", () => { + const req = providerFormValuesToUpdate( + { + ...baseBedrockFormValues, + accessKey: SAVED_CREDENTIAL_MASK, + accessKeySecret: SAVED_CREDENTIAL_MASK, + }, + MockAIProviderBedrock, + ); + const s = req.settings as unknown as Record; + expect(s.access_key).toBeUndefined(); + expect(s.access_key_secret).toBeUndefined(); + }); + + it("sends new access keys when both were typed", () => { + const req = providerFormValuesToUpdate( + { + ...baseBedrockFormValues, + accessKey: "AKIA-rotate", + accessKeySecret: "rotated-secret", + }, + MockAIProviderBedrock, + ); + const s = req.settings as unknown as Record; + expect(s.access_key).toBe("AKIA-rotate"); + expect(s.access_key_secret).toBe("rotated-secret"); + }); + + it('treats a half-rotated credential pair as "do not rotate"', () => { + // Yup blocks this at the schema layer; the helper still has + // to refuse to send a partial rotation, lest a partial wire + // payload corrupt the stored credential. + const req = providerFormValuesToUpdate( + { + ...baseBedrockFormValues, + accessKey: "AKIA-rotate", + accessKeySecret: SAVED_CREDENTIAL_MASK, + }, + MockAIProviderBedrock, + ); + const s = req.settings as unknown as Record; + expect(s.access_key).toBeUndefined(); + expect(s.access_key_secret).toBeUndefined(); + }); + }); +}); + +describe("aiProviderToFormValues", () => { + it("seeds OpenAI form values from a wire provider", () => { + const values = aiProviderToFormValues(MockAIProviderOpenAI); + expect(values.type).toBe("openai"); + expect(values.name).toBe(MockAIProviderOpenAI.name); + expect(values.baseUrl).toBe(MockAIProviderOpenAI.base_url); + expect(values.apiKey).toBe(""); + }); + + it("seeds Bedrock form values from settings", () => { + const values = aiProviderToFormValues(MockAIProviderBedrock); + expect(values.type).toBe("bedrock"); + expect(values.model).toBe("anthropic.claude-opus-4-7"); + expect(values.smallFastModel).toBe("anthropic.claude-haiku-4-5"); + }); + + it("never round-trips Bedrock secrets back to the form", () => { + // AccessKey and AccessKeySecret are write-only; the API strips + // them from responses, so the form must seed them as empty. + const values = aiProviderToFormValues(MockAIProviderBedrock); + expect(values.accessKey).toBe(""); + expect(values.accessKeySecret).toBe(""); + }); + + it("falls back to the slug when display_name is empty", () => { + const provider: AIProvider = { + ...MockAIProviderOpenAI, + display_name: "", + }; + expect(aiProviderToFormValues(provider).displayName).toBe(provider.name); + }); + + it("handles a Bedrock provider whose settings are null", () => { + // `isBedrockProvider` will return false, so the provider falls + // through to the anthropic branch. The helper must not throw. + const provider: AIProvider = { + ...MockAIProviderBedrock, + settings: null as unknown as AIProvider["settings"], + }; + const values = aiProviderToFormValues(provider); + expect(values.type).toBe("anthropic"); + }); +}); diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts new file mode 100644 index 0000000000..4f958fdea7 --- /dev/null +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts @@ -0,0 +1,253 @@ +import type { + AIProvider, + AIProviderBedrockSettings, + AIProviderKeyMutation, + AIProviderSettings, + AIProviderType, + CreateAIProviderRequest, + UpdateAIProviderRequest, +} from "#/api/typesGenerated"; +import { + type ProviderFormValues, + parseBedrockRegionFromBaseUrl, + SAVED_CREDENTIAL_MASK, +} from "./ProviderForm"; + +/** Drop placeholder masks so they don't round-trip back to the API. */ +const sanitizeCredential = ( + value: string, + ...extraMasks: (string | undefined)[] +): string => { + const trimmed = value.trim(); + if (trimmed === "" || trimmed === SAVED_CREDENTIAL_MASK) { + return ""; + } + if (extraMasks.some((m) => m !== undefined && m === trimmed)) { + return ""; + } + return trimmed; +}; + +// The generated `AIProviderSettings` interface is empty (the Go side uses +// a custom marshaler), so we redeclare the structural wire shape here. +const BEDROCK_SETTINGS_TYPE = "bedrock"; +const BEDROCK_SETTINGS_VERSION = 1; + +type BedrockSettingsWire = AIProviderBedrockSettings & { + _type: typeof BEDROCK_SETTINGS_TYPE; + _version: typeof BEDROCK_SETTINGS_VERSION; +}; + +type SettingsWire = AIProviderSettings & + Partial & { + _type?: string; + _version?: number; + }; + +// Bedrock providers carry an Anthropic wire type plus a +// `settings._type === "bedrock"` discriminator. `settings` is non-null in +// the generated type but Go serializes zero settings as JSON `null`, so we +// null-check before reading the discriminator. +export const isBedrockProvider = (provider: AIProvider): boolean => { + if (provider.type !== "anthropic") { + return false; + } + const s = provider.settings as SettingsWire | null; + return s !== null && s._type === BEDROCK_SETTINGS_TYPE; +}; + +export const hasBedrockStoredCredentials = (provider: AIProvider): boolean => { + if (!isBedrockProvider(provider)) { + return false; + } + // Bedrock secrets are write-only. The server only persists Bedrock + // settings if credentials were supplied, so presence implies "on file". + return true; +}; + +const parseProviderHost = (url: string): string => { + try { + return new URL(url).host.toLowerCase(); + } catch { + return ""; + } +}; + +// UI types we recover from a saved provider's base_url because the wire +// `type` collapses them to `openai`. Matches the bare domain or any +// subdomain (Azure ships per-resource subdomains). +const displayTypeHosts: ReadonlyArray<[string, AIProviderType]> = [ + ["openai.azure.com", "azure"], + ["generativelanguage.googleapis.com", "google"], + ["openrouter.ai", "openrouter"], + ["ai-gateway.vercel.sh", "vercel"], +]; + +const matchesHost = (host: string, suffix: string): boolean => + host === suffix || host.endsWith(`.${suffix}`); + +// Wire `type` collapses azure/google/openrouter/vercel to `openai`, so +// we recover the original choice from the saved host. Bedrock comes +// through the settings discriminator. Unknown hosts fall back to wire. +export const getProviderDisplayType = ( + provider: AIProvider, +): AIProviderType => { + if (isBedrockProvider(provider)) { + return "bedrock"; + } + if (provider.type === "anthropic") { + return "anthropic"; + } + const host = parseProviderHost(provider.base_url ?? ""); + const match = displayTypeHosts.find(([h]) => matchesHost(host, h)); + return match?.[1] ?? provider.type; +}; + +const buildBedrockSettings = ( + region: string | undefined, + model: string, + smallFastModel: string, + accessKey: string, + accessKeySecret: string, +): BedrockSettingsWire => ({ + _type: BEDROCK_SETTINGS_TYPE, + _version: BEDROCK_SETTINGS_VERSION, + ...(region ? { region } : {}), + model, + small_fast_model: smallFastModel, + ...(accessKey ? { access_key: accessKey } : {}), + ...(accessKeySecret ? { access_key_secret: accessKeySecret } : {}), +}); + +// Bedrock credentials live in `settings`; openai/anthropic keys go in +// `api_keys`. `display_name` is omitted when blank so the server stores +// NULL and the UI falls back to `name`. +export const providerFormValuesToCreate = ( + values: ProviderFormValues, +): CreateAIProviderRequest => { + const name = values.name.trim(); + const displayName = values.displayName.trim(); + const baseUrl = values.baseUrl.trim(); + + if (values.type === "bedrock") { + const region = parseBedrockRegionFromBaseUrl(baseUrl); + const settings = buildBedrockSettings( + region, + values.model.trim(), + values.smallFastModel.trim(), + sanitizeCredential(values.accessKey), + sanitizeCredential(values.accessKeySecret), + ); + return { + type: "anthropic", + name, + ...(displayName ? { display_name: displayName } : {}), + base_url: baseUrl, + enabled: values.enabled, + settings: settings as AIProviderSettings, + }; + } + + const apiKey = sanitizeCredential(values.apiKey); + // `""` is unreachable here (Yup blocks it, Bedrock branched out), but the + // union still includes it; narrow so TS stays honest. + if (values.type === "") { + throw new Error("provider type is required"); + } + // Wire only accepts `openai` and `anthropic`; the other UI types are + // presets that collapse to `openai`. + const wireType: AIProvider["type"] = + values.type === "anthropic" ? "anthropic" : "openai"; + return { + type: wireType, + name, + ...(displayName ? { display_name: displayName } : {}), + base_url: baseUrl, + enabled: values.enabled, + ...(apiKey ? { api_keys: [apiKey] } : {}), + }; +}; + +// Bedrock secrets follow an "empty = keep" contract: blank inputs are +// omitted and the server leaves them unchanged. OpenAI/Anthropic keys ship +// as a declarative list: `{ id }` retains a saved key, `{ api_key }` inserts +// a new one, and any saved id missing from the list is deleted. +export const providerFormValuesToUpdate = ( + values: ProviderFormValues, + existingProvider: AIProvider, +): UpdateAIProviderRequest => { + const base: UpdateAIProviderRequest = { + display_name: values.displayName.trim(), + enabled: values.enabled, + base_url: values.baseUrl.trim(), + }; + + if (values.type !== "bedrock") { + // If the user didn't touch the input, the form still holds the seeded + // mask and sanitizes to `""` (no rotation). + const savedMasked = existingProvider.api_keys[0]?.masked; + const newApiKey = sanitizeCredential(values.apiKey, savedMasked); + // Rotation goes out as the new plaintext alone: the saved key's id is + // omitted (which deletes it) and the plaintext is inserted as a fresh + // row. The backend rejects sending both fields on the same entry today. + const apiKeys: AIProviderKeyMutation[] = + newApiKey === "" + ? existingProvider.api_keys.map((k) => ({ id: k.id })) + : [{ api_key: newApiKey }]; + return { ...base, api_keys: apiKeys }; + } + + const newAccessKey = sanitizeCredential(values.accessKey); + const newAccessKeySecret = sanitizeCredential(values.accessKeySecret); + // Yup enforces "both keys together"; if both survived the mask filter, + // the user is rotating credentials. + const credentialsChanged = newAccessKey !== "" && newAccessKeySecret !== ""; + + // Yup blocks non-canonical Bedrock URLs upstream, so any `undefined` + // region here is a real bug that should surface, not be papered over. + const region = parseBedrockRegionFromBaseUrl(base.base_url ?? ""); + + const settings = buildBedrockSettings( + region, + values.model.trim(), + values.smallFastModel.trim(), + credentialsChanged ? newAccessKey : "", + credentialsChanged ? newAccessKeySecret : "", + ); + + return { ...base, settings: settings as AIProviderSettings }; +}; + +// `name` is immutable on the server and the edit form hides it; we seed +// it anyway so the form values stay aligned with `ProviderFormValues`. +// `displayName` falls back to the slug for providers that never had one set. +export const aiProviderToFormValues = ( + provider: AIProvider, +): Partial => { + const displayName = provider.display_name || provider.name; + if (isBedrockProvider(provider)) { + const s = (provider.settings as SettingsWire | null) ?? {}; + return { + type: "bedrock", + name: provider.name, + displayName, + baseUrl: provider.base_url, + model: s.model ?? "", + smallFastModel: s.small_fast_model ?? "", + accessKey: "", + accessKeySecret: "", + enabled: provider.enabled, + }; + } + + // Wire `type` is only `openai` or `anthropic`; the dropdown's richer + // labels apply only on create. + return { + type: provider.type === "anthropic" ? "anthropic" : "openai", + name: provider.name, + displayName, + baseUrl: provider.base_url, + apiKey: "", + enabled: provider.enabled, + }; +}; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index f28637b1c4..f15d839e14 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -5518,7 +5518,6 @@ export const MockSession: TypesGen.AIBridgeSession = { last_active_at: "2026-03-09T10:28:15.03152Z", }; -/** @lintignore Consumed by component stories landing in the next PR of the AI settings stack. */ export const MockAIProviderOpenAI: TypesGen.AIProvider = { id: "7a5d6b6a-5f02-4a9c-9c4e-2b3e2a3d2f01", type: "openai", @@ -5538,7 +5537,6 @@ export const MockAIProviderOpenAI: TypesGen.AIProvider = { updated_at: "2026-05-14T10:00:00Z", }; -/** @lintignore Consumed by component stories landing in the next PR of the AI settings stack. */ export const MockAIProviderAnthropic: TypesGen.AIProvider = { id: "4f81f1ee-37c1-4a37-a9d5-7e0c1c8c0c11", type: "anthropic", @@ -5556,8 +5554,6 @@ export const MockAIProviderAnthropic: TypesGen.AIProvider = { * Bedrock providers come over the wire with `type: "anthropic"` and a * `settings._type: "bedrock"` discriminator. `isBedrockProvider` and the * backend (see `coderd/ai_providers.go`) enforce this convention. - * - * @lintignore Consumed by component stories landing in the next PR of the AI settings stack. */ export const MockAIProviderBedrock: TypesGen.AIProvider = { id: "9c2e3b41-2e9f-4c97-9a4f-2e1a3d8f9f21",