mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
co-authored by
Jake Howell
parent
b3ff4baeb9
commit
9862f10484
@@ -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: "",
|
||||
|
||||
Reference in New Issue
Block a user