diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx index af492374278..693993079ea 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx @@ -136,6 +136,51 @@ export const AddBedrock: Story = { }, }; +// Mantle is a passthrough protocol selected via the Protocol dropdown. It +// does not configure model fields (the client sends the model), and the +// endpoint hint points at the mantle host. +export const AddBedrockMantle: Story = { + args: { + initialValues: { type: "bedrock", protocol: "mantle" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.queryByLabelText(/^model\s*\*?$/i)).not.toBeInTheDocument(); + expect( + canvas.queryByLabelText(/^small-fast model\s*\*?$/i), + ).not.toBeInTheDocument(); + await expect(canvas.findByText(/bedrock-mantle/i)).resolves.toBeVisible(); + }, +}; + +// Switching the Protocol selector from InvokeModel to Mantle hides the model +// fields and swaps the endpoint hint to the mantle host. +export const AddBedrockSwitchToMantle: Story = { + args: { + initialValues: { type: "bedrock" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Model fields are present for the default InvokeModel protocol. + await canvas.findByLabelText(/^model\s*\*?$/i); + + const trigger = canvas.getByRole("combobox"); + await userEvent.click(trigger); + const mantleOption = await screen.findByRole("option", { + name: /mantle/i, + }); + await userEvent.click(mantleOption); + + await waitFor(() => + expect(canvas.queryByLabelText(/^model\s*\*?$/i)).not.toBeInTheDocument(), + ); + expect( + canvas.queryByLabelText(/^small-fast model\s*\*?$/i), + ).not.toBeInTheDocument(); + await expect(canvas.findByText(/bedrock-mantle/i)).resolves.toBeVisible(); + }, +}; + // Regression coverage for CODAGT-626. The create form must accept Bedrock // configurations whose credentials come from the AWS environment (IAM // role, instance profile, AWS_PROFILE) instead of static access keys. diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx index a4eed13b375..6fdcadedad8 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx @@ -3,7 +3,10 @@ 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 type { + AIProviderBedrockProtocol, + AIProviderType, +} from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Button } from "#/components/Button/Button"; import { CodeExample } from "#/components/CodeExample/CodeExample"; @@ -12,6 +15,13 @@ import { Form, FormFields } from "#/components/Form/Form"; import { FormField } from "#/components/FormField/FormField"; import { Label } from "#/components/Label/Label"; import { Link as DocsLink } from "#/components/Link/Link"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "#/components/Select/Select"; import { Spinner } from "#/components/Spinner/Spinner"; import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt"; import { IconPickerField } from "#/pages/AISettingsPage/MCPServersPage/components/IconPickerField"; @@ -25,6 +35,7 @@ export type ProviderFormValues = { displayName: string; icon: string; baseUrl: string; + protocol: AIProviderBedrockProtocol; model: string; smallFastModel: string; accessKey: string; @@ -37,14 +48,24 @@ export type ProviderFormValues = { const HTTP_SCHEME_REGEX = /^https?:\/\//i; const BEDROCK_CANONICAL_URL_REGEX = /^https:\/\/bedrock-runtime\.([a-z0-9-]+)\.amazonaws\.com\/?$/i; +// Mantle is a passthrough protocol hosted at bedrock-mantle.{region}.api.aws, +// optionally suffixed with /anthropic. +const BEDROCK_MANTLE_URL_REGEX = + /^https:\/\/bedrock-mantle\.([a-z0-9-]+)\.api\.aws(\/anthropic)?\/?$/i; const PROVIDER_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; export const SAVED_CREDENTIAL_MASK = "********"; +// The region lives in the same subdomain slot for both the InvokeModel host +// (bedrock-runtime.{region}.amazonaws.com) and the mantle host +// (bedrock-mantle.{region}.api.aws), so either shape yields the region. export const parseBedrockRegionFromBaseUrl = ( baseUrl: string, ): string | undefined => { - const match = BEDROCK_CANONICAL_URL_REGEX.exec(baseUrl.trim()); + const trimmed = baseUrl.trim(); + const match = + BEDROCK_CANONICAL_URL_REGEX.exec(trimmed) ?? + BEDROCK_MANTLE_URL_REGEX.exec(trimmed); return match?.[1]?.toLowerCase(); }; @@ -68,6 +89,7 @@ const defaultInitialValues: ProviderFormValues = { displayName: "", icon: "", baseUrl: "", + protocol: "invoke-model", model: "", smallFastModel: "", accessKey: "", @@ -77,6 +99,14 @@ const defaultInitialValues: ProviderFormValues = { enabled: true, }; +// Base URL prefills used when switching the Bedrock protocol. The region is +// preserved from whatever the user already entered, falling back to us-east-1. +const BEDROCK_MANTLE_DEFAULT_REGION = "us-east-1"; +const bedrockInvokeModelBaseUrl = (region: string) => + `https://bedrock-runtime.${region}.amazonaws.com`; +const bedrockMantleBaseUrl = (region: string) => + `https://bedrock-mantle.${region}.api.aws/anthropic`; + // Bedrock model defaults mirror codersdk/deployment.go's // aiGatewayBedrockModel and aiGatewayBedrockSmallFastModel defaults // so the create form lands on the same models the env-seeded path @@ -167,16 +197,38 @@ const makeBedrockSchema = (editing: boolean) => name: makeNameSchema(editing), displayName: makeDisplayNameSchema(editing), icon: Yup.string(), + protocol: Yup.string() + .oneOf(["invoke-model", "mantle"] as const) + .required(), baseUrl: Yup.string() .url("Endpoint must be a valid URL") - .matches( - BEDROCK_CANONICAL_URL_REGEX, - "Endpoint must be a standard AWS Bedrock URL.", - ) + .when("protocol", { + is: "mantle", + then: (schema) => + schema.matches( + BEDROCK_MANTLE_URL_REGEX, + "Endpoint must be a Bedrock mantle URL (https://bedrock-mantle.{region}.api.aws/anthropic).", + ), + otherwise: (schema) => + schema.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"), + // Mantle passthrough forwards the model chosen by the client, so the + // model fields are not configured on the provider. + model: Yup.string().when("protocol", { + is: (protocol: string) => protocol !== "mantle", + then: (schema) => schema.required("Model is required"), + otherwise: (schema) => schema, + }), + smallFastModel: Yup.string().when("protocol", { + is: (protocol: string) => protocol !== "mantle", + then: (schema) => schema.required("Small-fast model is required"), + otherwise: (schema) => schema, + }), accessKey: Yup.string().test( "access-key-paired", BEDROCK_ACCESS_KEY_PAIRED_MESSAGE, @@ -374,6 +426,23 @@ export const ProviderForm: FC = ({ } }; + // Switching protocols rewrites the base URL to the matching host, keeping + // the region the user already entered so they do not retype it. + const handleBedrockProtocolChange = (protocol: AIProviderBedrockProtocol) => { + void form.setFieldValue("protocol", protocol); + const region = + parseBedrockRegionFromBaseUrl(form.values.baseUrl) ?? + BEDROCK_MANTLE_DEFAULT_REGION; + void form.setFieldValue( + "baseUrl", + protocol === "mantle" + ? bedrockMantleBaseUrl(region) + : bedrockInvokeModelBaseUrl(region), + ); + }; + + const isMantle = form.values.protocol === "mantle"; + // 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 @@ -492,49 +561,92 @@ export const ProviderForm: FC = ({ /> {iconField} +
+ + +

+ {isMantle + ? "Mantle is a passthrough protocol. The client selects the model at request time." + : "InvokeModel calls the standard AWS Bedrock runtime API."} +

+
- In the format of{" "} - - {"https://bedrock-runtime.{region}.amazonaws.com"} - - + isMantle ? ( + <> + In the format of{" "} + + {"https://bedrock-mantle.{region}.api.aws/anthropic"} + + + ) : ( + <> + In the format of{" "} + + {"https://bedrock-runtime.{region}.amazonaws.com"} + + + ) } className="w-full" - placeholder={baseUrlPlaceholder(form.values.type)} + placeholder={ + isMantle + ? bedrockMantleBaseUrl(BEDROCK_MANTLE_DEFAULT_REGION) + : baseUrlPlaceholder(form.values.type) + } /> -
- - -
-

- Find available Bedrock model IDs in the{" "} - - AWS Bedrock model cards - - . -

+ {!isMantle && ( + <> +
+ + +
+

+ Find available Bedrock model IDs in the{" "} + + AWS Bedrock model cards + + . +

+ + )}
{ ).toBe("us-west-2"); }); + it("extracts the region from a mantle URL", () => { + expect( + parseBedrockRegionFromBaseUrl("https://bedrock-mantle.us-east-1.api.aws"), + ).toBe("us-east-1"); + }); + + it("extracts the region from a mantle URL with the /anthropic suffix", () => { + expect( + parseBedrockRegionFromBaseUrl( + "https://bedrock-mantle.eu-west-1.api.aws/anthropic", + ), + ).toBe("eu-west-1"); + }); + it("lowercases the region", () => { expect( parseBedrockRegionFromBaseUrl( @@ -398,6 +415,34 @@ describe("providerFormValuesToCreate", () => { expect(s.region).toBe("us-east-1"); }); + it("omits the protocol discriminator and includes the model fields for InvokeModel", () => { + // InvokeModel is the default protocol, so the protocol is left out + // to keep the settings blob minimal; the model fields are configured + // on the provider. + const req = providerFormValuesToCreate(baseBedrockFormValues); + const s = req.settings as unknown as Record; + expect(s.protocol).toBeUndefined(); + expect(s.model).toBe("anthropic.claude-sonnet-4-5"); + expect(s.small_fast_model).toBe("anthropic.claude-haiku-4-5"); + }); + + it("sets protocol=mantle, derives the region, and omits the model fields", () => { + // Mantle is a passthrough: the client sends the model, so the + // provider stores neither model field but keeps the region so the + // backend recognises the Bedrock provider. + const req = providerFormValuesToCreate({ + ...baseBedrockFormValues, + protocol: "mantle", + baseUrl: "https://bedrock-mantle.us-east-1.api.aws", + }); + const s = req.settings as unknown as Record; + expect(s._type).toBe("bedrock"); + expect(s.protocol).toBe("mantle"); + expect(s.region).toBe("us-east-1"); + expect(s.model).toBeUndefined(); + expect(s.small_fast_model).toBeUndefined(); + }); + 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 @@ -746,6 +791,26 @@ describe("aiProviderToFormValues", () => { expect(values.smallFastModel).toBe("anthropic.claude-haiku-4-5"); }); + it("reads protocol=mantle back and leaves the model fields blank", () => { + const provider: AIProvider = { + ...MockAIProviderBedrock, + settings: settings({ + _type: "bedrock", + protocol: "mantle", + region: "us-east-1", + }), + }; + const values = aiProviderToFormValues(provider); + expect(values.protocol).toBe("mantle"); + expect(values.model).toBe(""); + expect(values.smallFastModel).toBe(""); + }); + + it("defaults protocol to invoke-model for a legacy provider without one", () => { + const values = aiProviderToFormValues(MockAIProviderBedrock); + expect(values.protocol).toBe("invoke-model"); + }); + 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. diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts index 8bed57a1c6e..f64eb72a390 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts @@ -1,5 +1,6 @@ import type { AIProvider, + AIProviderBedrockProtocol, AIProviderBedrockSettings, AIProviderKeyMutation, AIProviderSettings, @@ -114,22 +115,30 @@ export const getProviderDisplayType = ( }; const buildBedrockSettings = ( + protocol: AIProviderBedrockProtocol, region: string | undefined, model: string, smallFastModel: string, accessKey: string, accessKeySecret: string, roleArn: 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 } : {}), - ...(roleArn ? { role_arn: roleArn } : {}), -}); +): BedrockSettingsWire => { + // Mantle is a passthrough protocol: the client sends the model, so the + // provider omits the model fields. The protocol discriminator is only + // emitted for mantle to keep InvokeModel blobs minimal (an absent + // protocol resolves to InvokeModel server-side). + const isMantle = protocol === "mantle"; + return { + _type: BEDROCK_SETTINGS_TYPE, + _version: BEDROCK_SETTINGS_VERSION, + ...(region ? { region } : {}), + ...(isMantle ? { protocol } : {}), + ...(isMantle ? {} : { model, small_fast_model: smallFastModel }), + ...(accessKey ? { access_key: accessKey } : {}), + ...(accessKeySecret ? { access_key_secret: accessKeySecret } : {}), + ...(roleArn ? { role_arn: roleArn } : {}), + }; +}; // Bedrock credentials live in `settings`; openai/anthropic keys go in // `api_keys`. `display_name` is omitted when blank so the server stores @@ -150,6 +159,7 @@ export const providerFormValuesToCreate = ( if (values.type === "bedrock") { const region = parseBedrockRegionFromBaseUrl(base.base_url); const settings = buildBedrockSettings( + values.protocol, region, values.model.trim(), values.smallFastModel.trim(), @@ -226,6 +236,7 @@ export const providerFormValuesToUpdate = ( const region = parseBedrockRegionFromBaseUrl(base.base_url ?? ""); const settings = buildBedrockSettings( + values.protocol, region, values.model.trim(), values.smallFastModel.trim(), @@ -246,14 +257,20 @@ export const aiProviderToFormValues = ( const displayName = provider.display_name || provider.name; if (isBedrockProvider(provider)) { const s = (provider.settings as SettingsWire | null) ?? {}; + // A missing protocol resolves to InvokeModel (legacy rows). Mantle + // providers store no model fields, so leave them blank. + const protocol: AIProviderBedrockProtocol = + s.protocol === "mantle" ? "mantle" : "invoke-model"; + const isMantle = protocol === "mantle"; return { type: "bedrock", name: provider.name, displayName, icon: provider.icon || (getProviderIcon("bedrock") ?? ""), baseUrl: provider.base_url, - model: s.model ?? "", - smallFastModel: s.small_fast_model ?? "", + protocol, + model: isMantle ? "" : (s.model ?? ""), + smallFastModel: isMantle ? "" : (s.small_fast_model ?? ""), accessKey: "", accessKeySecret: "", roleArn: s.role_arn ?? "",