feat(site): add AI settings provider form components (#25581)

> 🤖 This PR was written by Coder Agents on behalf of Jake Howell.

Linear: [DEVEX-355](https://linear.app/coder/issue/DEVEX-355)

Third PR in a 5-PR stack splitting #25328. Adds the component-level
pieces used by the provider management pages landing in the next PR of
the stack.

- `ProviderForm` + `CredentialField` + a provider type-to-form mapping
for reading and editing the per-type credential and config fields, with
the form API map covered by unit tests.
- `ProviderIcon` resolves the bundled per-provider SVG icons and falls
back to a building glyph for unknown types.
- `ProviderRow` renders a single provider entry for the list view.
- `useUnsavedChangesPrompt` hook intercepts unsaved-form navigation.
- Storybook stories for `ProviderForm`, `ProviderIcon`, and
`ProviderRow` exercise each provider type and form state and consume the
mock providers from PR 2.

Stories now consume `MockAIProviderOpenAI` / `Anthropic` / `Bedrock` so
their per-mock `@lintignore` tags are removed; the `MockAIProviders`
aggregate and the `addableProviderTypes` / `aiProviders` query modules
keep their exclusions for the page stories in the next PR.

<details>
<summary>Stack</summary>

1. #25579 jakehwll/DEVEX-355/01-primitives, primitives
2. #25580 jakehwll/DEVEX-355/02-api, API client and query layer
3. **jakehwll/DEVEX-355/03-components, provider form components (this
PR)**
4. jakehwll/DEVEX-355/04-pages, pages and routes
5. jakehwll/DEVEX-355/05-section, section reshuffle

Replaces #25328 once the stack lands.
</details>
This commit is contained in:
Jake Howell
2026-05-27 02:27:41 +10:00
committed by GitHub
parent 5d39c833f8
commit 99a00259eb
13 changed files with 1758 additions and 4 deletions
+4
View File
@@ -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.
+43
View File
@@ -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?.(),
};
};
@@ -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<CredentialFieldProps> = ({
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 = (
<Label htmlFor={inputId}>
{label}{" "}
{required && (
<span className="text-xs font-bold text-content-destructive">*</span>
)}
</Label>
);
const descriptionNode = description && (
<div id={descriptionId} className="text-xs text-content-secondary">
{description}
</div>
);
const helperNode = helpers.error ? (
<span id={errorId} className="text-xs text-content-destructive">
{helpers.helperText}
</span>
) : helpers.helperText ? (
<span id={helperId} className="text-xs text-content-secondary">
{helpers.helperText}
</span>
) : null;
const inputNode = (
<Input
id={inputId}
name={helpers.name}
value={helpers.value}
onChange={helpers.onChange}
onBlur={helpers.onBlur}
onFocus={onFocus}
autoComplete={autoComplete}
placeholder={placeholder}
aria-invalid={helpers.error}
aria-describedby={describedBy || undefined}
/>
);
return (
<div className="flex flex-col gap-2">
{labelNode}
{descriptionNode}
{inputNode}
{helperNode}
</div>
);
};
@@ -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<typeof ProviderForm> = {
title: "pages/AISettingsPage/ProviderForm",
component: ProviderForm,
args: {
editing: false,
isLoading: false,
onSubmit: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ProviderForm>;
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();
},
};
@@ -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<AIProviderType, Partial<ProviderFormValues>>
> = {
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<ProviderFormValues>;
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<ProviderFormProps> = ({
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<ProviderFormValues>({
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 (
<Form onSubmit={form.handleSubmit}>
<FormFields>
{Boolean(submitError) && <ErrorAlert error={submitError} />}
{typeSelectValue !== "" && typeSelectValue !== "bedrock" && (
<>
<div className="grid grid-cols-2 items-start gap-4">
<FormField
required
field={getFieldHelpers("name")}
label="Name"
description="Unique identifier (used in urls, can't be changed)"
className="w-full"
placeholder={namePlaceholder(form.values.type)}
disabled={editing}
/>
<FormField
field={getFieldHelpers("displayName")}
label="Display name"
description="Friendly name. Defaults to name if blank."
className="w-full"
/>
</div>
<FormField
required
field={getFieldHelpers("baseUrl")}
label="Endpoint"
description="The base URL where the provider's API is hosted."
className="w-full"
placeholder={baseUrlPlaceholder(form.values.type)}
/>
<CredentialField
required
label="API key"
helpers={getFieldHelpers("apiKey")}
onFocus={() => handleCredentialFocus("apiKey")}
autoComplete="new-password"
placeholder={apiKeyPlaceholder(form.values.type)}
/>
</>
)}
{typeSelectValue === "bedrock" && (
<>
<div className="grid grid-cols-2 items-start gap-4">
<FormField
required
field={getFieldHelpers("name")}
label="Name"
description="Unique identifier (used in urls, can't be changed)"
className="w-full"
placeholder={namePlaceholder(form.values.type)}
disabled={editing}
/>
<FormField
field={getFieldHelpers("displayName")}
label="Display name"
description="Friendly name. Defaults to name if blank."
className="w-full"
/>
</div>
<FormField
required
field={getFieldHelpers("baseUrl")}
label="Endpoint"
description={
<>
In the format of{" "}
<code>
{"https://bedrock-runtime.{region}.amazonaws.com"}
</code>
</>
}
className="w-full"
placeholder={baseUrlPlaceholder(form.values.type)}
/>
<div className="grid grid-cols-2 items-start gap-4">
<FormField
required
field={getFieldHelpers("model")}
label="Model"
className="w-full"
placeholder="anthropic.claude-3-5-sonnet-20241022-v2:0"
/>
<FormField
required
field={getFieldHelpers("smallFastModel")}
label="Small-fast model"
className="w-full"
placeholder="anthropic.claude-3-haiku-20240307-v1:0"
/>
</div>
<div className="grid grid-cols-2 items-start gap-4">
<CredentialField
required
label="Access key"
helpers={getFieldHelpers("accessKey")}
onFocus={() => handleCredentialFocus("accessKey")}
/>
<CredentialField
required
label="Access key secret"
helpers={getFieldHelpers("accessKeySecret")}
onFocus={() => handleCredentialFocus("accessKeySecret")}
autoComplete="new-password"
/>
</div>
</>
)}
<div className="flex justify-end gap-4">
<Link to="/ai/settings">
<Button variant="outline" type="button">
Cancel
</Button>
</Link>
<Button
disabled={isLoading || !form.dirty || !form.isValid}
type="submit"
>
<Spinner loading={isLoading} />
{editing ? "Update provider" : "Add provider"}
</Button>
</div>
</FormFields>
<ConfirmDialog
type="info"
hideCancel={false}
open={unsavedChanges.isOpen}
onClose={unsavedChanges.onCancel}
onConfirm={unsavedChanges.onConfirm}
title="Unsaved changes"
confirmText="Confirm"
description={
<div className="flex items-start gap-3">
<TriangleAlertIcon className="size-icon-sm mt-1 shrink-0" />
<p className="m-0">
Your updates haven't been saved. Leave anyway?
</p>
</div>
}
/>
</Form>
);
};
@@ -0,0 +1,54 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ProviderIcon } from "./ProviderIcon";
const meta: Meta<typeof ProviderIcon> = {
title: "pages/AISettingsPage/ProviderIcon",
component: ProviderIcon,
};
export default meta;
type Story = StoryObj<typeof ProviderIcon>;
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",
},
};
@@ -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<ProviderIconProps> = ({ provider }) => {
const iconSrc = getProviderIcon(provider);
const name = getProviderName(provider);
if (iconSrc === undefined) {
return (
<Building2Icon className="size-icon-sm flex-shrink-0" aria-label={name} />
);
}
return <ExternalImage src={iconSrc} alt={name} className="size-icon-sm" />;
};
@@ -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<typeof ProviderRow> = {
title: "pages/AISettingsPage/ProviderRow",
component: ProviderRow,
args: {
onClick: fn(),
},
decorators: [
(Story) => (
<Table className="table-fixed" aria-label="AI providers">
<TableHeader>
<TableRow>
<TableHead className="w-[42%]">Name</TableHead>
<TableHead className="w-[38%]">Base URL</TableHead>
<TableHead className="w-20 text-center">
<span className="sr-only">Enabled</span>
</TableHead>
<TableHead className="w-12">
<span className="sr-only">Open provider</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<Story />
</TableBody>
</Table>
),
],
};
export default meta;
type Story = StoryObj<typeof ProviderRow>;
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",
},
},
};
@@ -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<ProviderRowProps> = ({
provider,
onClick,
}) => {
const clickableProps = useClickableTableRow({
onClick: () => onClick?.(),
});
const displayName = provider.display_name || provider.name;
return (
<TableRow key={provider.name} {...clickableProps}>
<TableCell className="min-w-0">
<AvatarData
title={displayName}
avatar={
<Avatar className="flex shrink-0 items-center justify-center">
<ProviderIcon provider={getProviderDisplayType(provider)} />
</Avatar>
}
/>
</TableCell>
<TableCell className="min-w-0">
<span
className="block truncate text-content-secondary"
title={provider.base_url}
>
{provider.base_url}
</span>
</TableCell>
<TableCell>
{provider.enabled && <Badge variant="default">Enabled</Badge>}
</TableCell>
<TableCell className="w-10 text-center">
<div className="flex justify-end items-center gap-8 pr-4">
<ChevronRightIcon
aria-hidden
className="size-icon-md text-content-primary flex-shrink-0"
/>
</div>
</TableCell>
</TableRow>
);
};
@@ -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" },
];
@@ -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<string, unknown>): 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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");
});
});
@@ -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<AIProviderBedrockSettings> & {
_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<ProviderFormValues> => {
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,
};
};
-4
View File
@@ -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",