mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user