feat(site): add Bedrock mantle protocol selector to provider form (#27156)

Implements:
https://linear.app/codercom/issue/AIGOV-517/add-ui-for-bedrock-mantle

Follow-up to https://github.com/coder/coder/pull/26745, which added AWS
Bedrock **mantle** support to the backend and modeled it as a `protocol`
field on the Bedrock provider settings.
## What changed

- Adds a **Protocol** selector to the Bedrock provider form: InvokeModel
(default) or Mantle.
- The form is now protocol-aware:
- **Model** / **Small-fast model** fields are shown only for
InvokeModel. Mantle is a passthrough (the client sends the model at
request time), so the fields are hidden and omitted from the saved
settings.
- **Endpoint** validation, placeholder, and hint switch per protocol.
Mantle requires a `https://bedrock-mantle.{region}.api.aws/anthropic`
URL; InvokeModel keeps the
`https://bedrock-runtime.{region}.amazonaws.com` shape. The `/anthropic`
suffix is required for mantle because the SDK appends `/v1/messages` to
the base URL.

## Manual Testing

Tested the following scenarios:

1. Creating a new Mantle provider — works.
2. Creating a new InvokeModel provider — works.
3. Verifying that an existing InvokeModel provider continues to work.
4. Upgrading an existing InvokeModel provider to Mantle — works.

## Screenshots

### InvokeModel

<img width="1085" height="474" alt="image"
src="https://github.com/user-attachments/assets/4c36b9b0-0eb8-4b17-80b7-e2112325f8d1"
/>


### Mantle

<img width="1082" height="357" alt="image"
src="https://github.com/user-attachments/assets/e8caef26-83d1-4b7e-8f96-79079949e8f1"
/>

---------

Co-authored-by: Jake Howell <jake@hwll.me>
This commit is contained in:
Yevhenii Shcherbina
2026-07-16 09:37:48 -04:00
committed by GitHub
co-authored by Jake Howell
parent b3ff4baeb9
commit 9862f10484
4 changed files with 414 additions and 50 deletions
@@ -136,6 +136,148 @@ 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", { name: /protocol/i });
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();
},
};
// Switching protocol must not leave a stale endpoint validation error that
// keeps Save disabled. Protocol and base URL are updated atomically, so after
// switching a fully valid config keeps the submit button enabled.
export const AddBedrockProtocolSwitchKeepsSaveEnabled: Story = {
args: {
initialValues: {
type: "bedrock",
name: "bedrock",
baseUrl: "https://bedrock-runtime.eu-west-1.amazonaws.com",
model: "anthropic.claude-sonnet-4-5",
smallFastModel: "anthropic.claude-haiku-4-5",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const submit = canvas.getByRole("button", { name: /add provider/i });
// Switch InvokeModel -> Mantle.
await userEvent.click(canvas.getByRole("combobox", { name: /protocol/i }));
await userEvent.click(
await screen.findByRole("option", { name: /mantle/i }),
);
// The endpoint is rewritten to a valid mantle URL and the model fields
// drop out, so the form stays valid and Save is not disabled by a stale
// endpoint error.
await waitFor(() => {
expect(canvas.queryByLabelText(/^model\s*\*?$/i)).not.toBeInTheDocument();
expect(canvas.getByLabelText(/^endpoint\s*\*?$/i)).toHaveValue(
"https://bedrock-mantle.eu-west-1.api.aws/anthropic",
);
expect(submit).toBeEnabled();
});
},
};
// Reverse switch: Mantle -> InvokeModel restores the model fields and rewrites
// the endpoint back to the InvokeModel host, preserving the region the user
// already entered.
export const AddBedrockSwitchToInvokeModel: Story = {
args: {
initialValues: {
type: "bedrock",
name: "bedrock",
protocol: "mantle",
baseUrl: "https://bedrock-mantle.eu-west-1.api.aws/anthropic",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Model fields are hidden under Mantle.
expect(canvas.queryByLabelText(/^model\s*\*?$/i)).not.toBeInTheDocument();
await userEvent.click(canvas.getByRole("combobox", { name: /protocol/i }));
await userEvent.click(
await screen.findByRole("option", { name: /invokemodel/i }),
);
// The model fields return and the endpoint is rewritten to the
// InvokeModel host, keeping the eu-west-1 region.
await canvas.findByLabelText(/^model\s*\*?$/i);
await canvas.findByLabelText(/^small-fast model\s*\*?$/i);
await waitFor(() =>
expect(canvas.getByLabelText(/^endpoint\s*\*?$/i)).toHaveValue(
"https://bedrock-runtime.eu-west-1.amazonaws.com",
),
);
},
};
// When the current endpoint has no parseable region (e.g. a blank field),
// switching protocol falls back to us-east-1 rather than producing an
// invalid URL.
export const AddBedrockProtocolSwitchRegionFallback: Story = {
args: {
initialValues: { type: "bedrock", baseUrl: "" },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Model fields are present for the default InvokeModel protocol.
await canvas.findByLabelText(/^model\s*\*?$/i);
await userEvent.click(canvas.getByRole("combobox", { name: /protocol/i }));
await userEvent.click(
await screen.findByRole("option", { name: /mantle/i }),
);
// The blank endpoint has no region, so the rewrite falls back to
// us-east-1.
await waitFor(() =>
expect(canvas.getByLabelText(/^endpoint\s*\*?$/i)).toHaveValue(
"https://bedrock-mantle.us-east-1.api.aws/anthropic",
),
);
},
};
// 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.
@@ -213,6 +355,29 @@ export const AddBedrockHalfCredentialPairBlocked: Story = {
},
};
// Under mantle, the endpoint must match the mantle host. An InvokeModel-shaped
// URL is rejected by the schema, so Save stays disabled and onSubmit never
// fires. Guards the protocol-conditional baseUrl validation.
export const AddBedrockMantleRejectsInvokeUrl: Story = {
args: {
initialValues: {
type: "bedrock",
name: "bedrock-mantle",
protocol: "mantle",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
model: "",
smallFastModel: "",
},
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const submitButton = canvas.getByRole("button", { name: /add provider/i });
await waitFor(() => expect(submitButton).toBeDisabled());
expect(args.onSubmit).not.toHaveBeenCalled();
},
};
export const EditBedrockKeepCredentials: Story = {
render: (args) => {
bedrockSubmitDeferred = createDeferred<void>();
@@ -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;
@@ -35,16 +46,26 @@ export type ProviderFormValues = {
};
const HTTP_SCHEME_REGEX = /^https?:\/\//i;
const BEDROCK_CANONICAL_URL_REGEX =
// AWS Bedrock InvokeModel URL, e.g. https://bedrock-runtime.{region}.amazonaws.com
const BEDROCK_INVOKE_MODEL_URL_REGEX =
/^https:\/\/bedrock-runtime\.([a-z0-9-]+)\.amazonaws\.com\/?$/i;
// AWS Bedrock Mantle URL, e.g. https://bedrock-mantle.{region}.api.aws/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_INVOKE_MODEL_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_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
@@ -95,7 +125,7 @@ const providerDefaults: Partial<
anthropic: { name: "anthropic", baseUrl: "https://api.anthropic.com" },
bedrock: {
name: "bedrock",
baseUrl: "https://bedrock-runtime.us-east-2.amazonaws.com",
baseUrl: bedrockInvokeModelBaseUrl(BEDROCK_DEFAULT_REGION),
model: BEDROCK_DEFAULT_MODEL,
smallFastModel: BEDROCK_DEFAULT_SMALL_FAST_MODEL,
},
@@ -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_INVOKE_MODEL_URL_REGEX,
"Endpoint must be a Bedrock InvokeModel URL (https://bedrock-runtime.{region}.amazonaws.com).",
),
})
.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,21 @@ export const ProviderForm: FC<ProviderFormProps> = ({
}
};
// 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) => {
const region =
parseBedrockRegionFromBaseUrl(form.values.baseUrl) ??
BEDROCK_DEFAULT_REGION;
const baseUrl =
protocol === "mantle"
? bedrockMantleBaseUrl(region)
: bedrockInvokeModelBaseUrl(region);
void form.setValues({ ...form.values, protocol, baseUrl });
};
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,6 +559,32 @@ export const ProviderForm: FC<ProviderFormProps> = ({
/>
</div>
{iconField}
<div className="flex flex-col gap-2">
<Label htmlFor="bedrock-protocol">Protocol</Label>
<Select
value={form.values.protocol}
onValueChange={(value) =>
handleBedrockProtocolChange(
value as AIProviderBedrockProtocol,
)
}
>
<SelectTrigger id="bedrock-protocol" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="invoke-model">
InvokeModel (default)
</SelectItem>
<SelectItem value="mantle">Mantle</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-content-secondary m-0">
{isMantle
? "Newer Anthropic-compatible Bedrock endpoint, recommended by AWS for new deployments."
: "Legacy Bedrock runtime API. Still supported; Mantle is recommended for new deployments."}
</p>
</div>
<FormField
required
field={getFieldHelpers("baseUrl")}
@@ -500,41 +593,51 @@ export const ProviderForm: FC<ProviderFormProps> = ({
<>
In the format of{" "}
<code>
{"https://bedrock-runtime.{region}.amazonaws.com"}
{isMantle
? "https://bedrock-mantle.{region}.api.aws/anthropic"
: "https://bedrock-runtime.{region}.amazonaws.com"}
</code>
</>
}
className="w-full"
placeholder={baseUrlPlaceholder(form.values.type)}
placeholder={
isMantle
? bedrockMantleBaseUrl(BEDROCK_DEFAULT_REGION)
: 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={BEDROCK_DEFAULT_MODEL}
/>
<FormField
required
field={getFieldHelpers("smallFastModel")}
label="Small-fast model"
className="w-full"
placeholder={BEDROCK_DEFAULT_SMALL_FAST_MODEL}
/>
</div>
<p className="text-xs text-content-secondary m-0">
Find available Bedrock model IDs in the{" "}
<DocsLink
size="sm"
href={BEDROCK_MODEL_CARDS_URL}
target="_blank"
rel="noreferrer"
>
AWS Bedrock model cards
</DocsLink>
.
</p>
{!isMantle && (
<>
<div className="grid grid-cols-2 items-start gap-4">
<FormField
required
field={getFieldHelpers("model")}
label="Model"
className="w-full"
placeholder={BEDROCK_DEFAULT_MODEL}
/>
<FormField
required
field={getFieldHelpers("smallFastModel")}
label="Small-fast model"
className="w-full"
placeholder={BEDROCK_DEFAULT_SMALL_FAST_MODEL}
/>
</div>
<p className="text-xs text-content-secondary m-0">
Find available Bedrock model IDs in the{" "}
<DocsLink
size="sm"
href={BEDROCK_MODEL_CARDS_URL}
target="_blank"
rel="noreferrer"
>
AWS Bedrock model cards
</DocsLink>
.
</p>
</>
)}
<div className="grid grid-cols-2 items-start gap-4">
<CredentialField
label="Access key"
@@ -27,6 +27,7 @@ const baseOpenAIFormValues: ProviderFormValues = {
displayName: "Primary OpenAI",
icon: "",
baseUrl: "https://api.openai.com",
protocol: "invoke-model",
model: "",
smallFastModel: "",
accessKey: "",
@@ -42,6 +43,7 @@ const baseBedrockFormValues: ProviderFormValues = {
displayName: "Primary Bedrock",
icon: "",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
protocol: "invoke-model",
model: "anthropic.claude-sonnet-4-5",
smallFastModel: "anthropic.claude-haiku-4-5",
accessKey: "AKIA-test",
@@ -57,6 +59,7 @@ const baseCopilotFormValues: ProviderFormValues = {
displayName: "GitHub Copilot",
icon: "",
baseUrl: "https://api.business.githubcopilot.com",
protocol: "invoke-model",
model: "",
smallFastModel: "",
accessKey: "",
@@ -89,6 +92,22 @@ describe("parseBedrockRegionFromBaseUrl", () => {
).toBe("us-west-2");
});
it("extracts the region from a mantle URL", () => {
expect(
parseBedrockRegionFromBaseUrl(
"https://bedrock-mantle.eu-west-1.api.aws/anthropic",
),
).toBe("eu-west-1");
});
it("returns undefined for a mantle URL missing the /anthropic suffix", () => {
// The /anthropic suffix is required, so a bare mantle host is not a
// valid endpoint and yields no region.
expect(
parseBedrockRegionFromBaseUrl("https://bedrock-mantle.us-east-1.api.aws"),
).toBeUndefined();
});
it("lowercases the region", () => {
expect(
parseBedrockRegionFromBaseUrl(
@@ -398,6 +417,33 @@ describe("providerFormValuesToCreate", () => {
expect(s.region).toBe("us-east-1");
});
it("emits protocol=invoke-model and includes the model fields for InvokeModel", () => {
// The protocol is emitted explicitly, and the model fields are
// configured on the provider.
const req = providerFormValuesToCreate(baseBedrockFormValues);
const s = req.settings as unknown as Record<string, unknown>;
expect(s.protocol).toBe("invoke-model");
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/anthropic",
});
const s = req.settings as unknown as Record<string, unknown>;
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 +792,39 @@ 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("resolves an empty stored protocol to invoke-model", () => {
const provider: AIProvider = {
...MockAIProviderBedrock,
settings: settings({
_type: "bedrock",
protocol: "",
region: "us-east-1",
}),
};
const values = aiProviderToFormValues(provider);
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.
@@ -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 is always emitted so the
// stored settings state it explicitly instead of relying on an absent
// value resolving to InvokeModel server-side.
const isMantle = protocol === "mantle";
return {
_type: BEDROCK_SETTINGS_TYPE,
_version: BEDROCK_SETTINGS_VERSION,
...(region ? { region } : {}),
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,12 +257,18 @@ export const aiProviderToFormValues = (
const displayName = provider.display_name || provider.name;
if (isBedrockProvider(provider)) {
const s = (provider.settings as SettingsWire | null) ?? {};
// An empty or missing protocol resolves to InvokeModel (legacy rows),
// mirroring the backend. Any other stored value passes through unchanged
// rather than being collapsed to InvokeModel.
const protocol: AIProviderBedrockProtocol = s.protocol || "invoke-model";
return {
type: "bedrock",
name: provider.name,
displayName,
icon: provider.icon || (getProviderIcon("bedrock") ?? ""),
baseUrl: provider.base_url,
protocol,
// Mantle providers store no model fields, so these resolve to "".
model: s.model ?? "",
smallFastModel: s.small_fast_model ?? "",
accessKey: "",