fix(site/src/pages/AISettingsPage): allow Bedrock IAM-role setup (#26400)

The Bedrock create form required both `access_key` and
`access_key_secret`, blocking deployments that authenticate against AWS
through an IAM role, instance profile, or `AWS_PROFILE`. The backend
already accepts a Bedrock provider that is configured by region alone
(see `codersdk.AIProviderBedrockSettings.IsConfigured`), so the UI was
the only thing standing between the operator and a working IAM-role
provider.

The Yup schema now treats both fields as optional while keeping the
cross-validation that forces the pair to travel together. A descriptive
note under the inputs tells the operator that leaving both blank falls
back to ambient AWS credentials, and links to the [Amazon Bedrock
section](https://coder.com/docs/ai-coder/ai-gateway/providers#amazon-bedrock)
of the AI Gateway providers docs for the credential chain and IAM
permissions. The mapping into `CreateAIProviderRequest` already omits
empty credential fields, so the wire payload sends only `region`,
`model`, and `small_fast_model`, which is enough for `IsConfigured()` on
the backend.

The model and small-fast model fields are now pre-filled with the modern
Sonnet 4.5 and Haiku 4.5 IDs from `codersdk.aiGatewayBedrockModel` /
`codersdk.aiGatewayBedrockSmallFastModel`, matching the legacy
environment seed path. A second docs note under those fields points at
the [AWS Bedrock model
cards](https://docs.aws.amazon.com/bedrock/latest/userguide/model-cards.html)
page so operators can find the canonical model IDs without leaving the
form. This also addresses the AIGOV-411 ask.

Adds `providerFormValuesToCreate` coverage for the no-credential and
whitespace-only paths, plus three new `ProviderForm` stories: one that
verifies the model fields pre-fill, one that submits without static
credentials, and one that verifies a half-typed credential pair stays
blocked.

Closes
[CODAGT-626](https://linear.app/codercom/issue/CODAGT-626/bedrock-ui-requires-access-keys-for-iam-role-setup).
Partial coverage for
[AIGOV-411](https://linear.app/codercom/issue/AIGOV-411/ai-gateway-providers-improve-bedrock-model-fields-in-ui)
(model pre-fill plus docs link; combobox, model ID pattern validation,
and docs site updates remain).

> The Slack thread also flagged a separate edit-time regression: "if I
go to edit an existing provider, all the fields I set are not on the
UI." I did not see that reproduce against the masked-credential edit
story, and the issue description focuses on the create flow, so I left
it for a separate investigation rather than bundling it into this fix.

<img width="1169" height="814" alt="image"
src="https://github.com/user-attachments/assets/08741641-da86-4acc-82ac-ef758f739f58"
/>

<sub>This PR was created by a Coder Agent on behalf of
@dannykopping.</sub>
This commit is contained in:
Danny Kopping
2026-06-16 16:37:45 +02:00
committed by GitHub
parent 64289c7388
commit 0c1c4af40a
3 changed files with 168 additions and 18 deletions
@@ -115,19 +115,102 @@ export const AddOpenAI: Story = {
};
export const AddBedrock: Story = {
args: {
initialValues: { type: "bedrock" },
},
play: async ({ canvasElement }) => {
// AddProviderPageView passes only `{ type }`; verify the form
// pre-fills the deployment defaults for the model fields so an
// operator does not have to copy them from the docs.
const canvas = within(canvasElement);
const modelInput = await canvas.findByLabelText(/^model\s*\*?$/i);
const smallFastModelInput = await canvas.findByLabelText(
/^small-fast model\s*\*?$/i,
);
expect(modelInput).toHaveValue(
"global.anthropic.claude-sonnet-4-5-20250929-v1:0",
);
expect(smallFastModelInput).toHaveValue(
"global.anthropic.claude-haiku-4-5-20251001-v1:0",
);
},
};
// 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.
export const AddBedrockWithoutStaticCredentials: Story = {
args: {
initialValues: {
type: "bedrock",
name: "bedrock-prod",
displayName: "Bedrock Prod",
name: "bedrock-iam",
displayName: "Bedrock IAM",
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",
accessKey: "",
accessKeySecret: "",
enabled: true,
},
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const accessKeyInput = await canvas.findByLabelText(/^access key\s*$/i);
const accessKeySecretInput =
await canvas.findByLabelText(/access key secret/i);
// Neither field renders the required asterisk.
expect(accessKeyInput).toHaveValue("");
expect(accessKeySecretInput).toHaveValue("");
// The Add provider button is enabled even with both credentials blank.
const submitButton = canvas.getByRole("button", {
name: /add provider/i,
});
await waitFor(() => expect(submitButton).toBeEnabled());
await userEvent.click(submitButton);
await waitFor(() =>
expect(args.onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
type: "bedrock",
accessKey: "",
accessKeySecret: "",
}),
),
);
},
};
// A half-typed credential pair is blocked at the form layer because the
// backend treats access_key and access_key_secret as a pair. This story
// keeps the cross-validation honest.
export const AddBedrockHalfCredentialPairBlocked: Story = {
args: {
initialValues: {
type: "bedrock",
name: "bedrock-half",
displayName: "Bedrock Half",
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: "",
accessKeySecret: "",
enabled: true,
},
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const accessKeyInput = await canvas.findByLabelText(/^access key\s*$/i);
await userEvent.type(accessKeyInput, "AKIAIOSFODNN7EXAMPLE");
const submitButton = canvas.getByRole("button", {
name: /add provider/i,
});
await waitFor(() => expect(submitButton).toBeDisabled());
expect(args.onSubmit).not.toHaveBeenCalled();
},
};
export const EditBedrockKeepCredentials: Story = {
@@ -9,8 +9,10 @@ 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 { Link as DocsLink } from "#/components/Link/Link";
import { Spinner } from "#/components/Spinner/Spinner";
import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt";
import { docs } from "#/utils/docs";
import { getFormHelpers } from "#/utils/formUtils";
import { CredentialField } from "./CredentialField";
@@ -68,6 +70,17 @@ const defaultInitialValues: ProviderFormValues = {
enabled: true,
};
// 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
// uses. Update both sides together when AWS publishes new model IDs.
const BEDROCK_DEFAULT_MODEL =
"global.anthropic.claude-sonnet-4-5-20250929-v1:0";
const BEDROCK_DEFAULT_SMALL_FAST_MODEL =
"global.anthropic.claude-haiku-4-5-20251001-v1:0";
const BEDROCK_MODEL_CARDS_URL =
"https://docs.aws.amazon.com/bedrock/latest/userguide/model-cards.html";
const providerDefaults: Partial<
Record<AIProviderType, Partial<ProviderFormValues>>
> = {
@@ -76,6 +89,8 @@ const providerDefaults: Partial<
bedrock: {
name: "bedrock",
baseUrl: "https://bedrock-runtime.us-east-2.amazonaws.com",
model: BEDROCK_DEFAULT_MODEL,
smallFastModel: BEDROCK_DEFAULT_SMALL_FAST_MODEL,
},
azure: {
name: "azure",
@@ -129,6 +144,13 @@ const credentialFilled = (value: string | undefined): boolean => {
return trimmed !== "" && trimmed !== SAVED_CREDENTIAL_MASK;
};
const BEDROCK_ACCESS_KEY_PAIRED_MESSAGE =
"Enter both access key and secret, or leave both blank to use AWS environment credentials.";
// Bedrock access keys are optional: when both are blank the server
// falls back to ambient AWS credentials (IAM role, AWS_PROFILE, IRSA,
// instance profile). Yup still requires them to be supplied as a pair
// so a half-typed rotation does not slip through.
const makeBedrockSchema = (editing: boolean) =>
Yup.object({
type: Yup.string()
@@ -146,24 +168,18 @@ const makeBedrockSchema = (editing: boolean) =>
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(
accessKey: Yup.string().test(
"access-key-paired",
"Enter both access key and secret to rotate credentials.",
BEDROCK_ACCESS_KEY_PAIRED_MESSAGE,
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(
accessKeySecret: Yup.string().test(
"access-key-secret-paired",
"Enter both access key and secret to rotate credentials.",
BEDROCK_ACCESS_KEY_PAIRED_MESSAGE,
function (value) {
const accessKey = (this.parent as { accessKey?: string }).accessKey;
return !(credentialFilled(accessKey) && !credentialFilled(value));
@@ -460,19 +476,30 @@ export const ProviderForm: FC<ProviderFormProps> = ({
field={getFieldHelpers("model")}
label="Model"
className="w-full"
placeholder="anthropic.claude-3-5-sonnet-20241022-v2:0"
placeholder={BEDROCK_DEFAULT_MODEL}
/>
<FormField
required
field={getFieldHelpers("smallFastModel")}
label="Small-fast model"
className="w-full"
placeholder="anthropic.claude-3-haiku-20240307-v1:0"
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
required
label="Access key"
helpers={getFieldHelpers("accessKey")}
onBlur={() => handleCredentialBlur("accessKey")}
@@ -480,7 +507,6 @@ export const ProviderForm: FC<ProviderFormProps> = ({
autoComplete="new-password"
/>
<CredentialField
required
label="Access key secret"
helpers={getFieldHelpers("accessKeySecret")}
onBlur={() => handleCredentialBlur("accessKeySecret")}
@@ -488,6 +514,18 @@ export const ProviderForm: FC<ProviderFormProps> = ({
autoComplete="new-password"
/>
</div>
<p className="text-xs text-content-secondary m-0">
Optional. Leave both fields blank to authenticate with the AWS
environment (IAM role, instance profile, AWS_PROFILE).{" "}
<DocsLink
size="sm"
href={docs("/ai-coder/ai-gateway/providers#amazon-bedrock")}
target="_blank"
rel="noreferrer"
>
View docs
</DocsLink>
</p>
</>
)}
@@ -371,6 +371,35 @@ describe("providerFormValuesToCreate", () => {
expect(s.access_key_secret).toBeUndefined();
});
it("keeps the region so the backend recognises the Bedrock provider when access keys are omitted", () => {
// The backend treats Region as a configuration signal
// (codersdk.AIProviderBedrockSettings.IsConfigured), so omitting
// the keys must not also strip the region; otherwise the request
// would fail with "type=bedrock requires bedrock settings".
const req = providerFormValuesToCreate({
...baseBedrockFormValues,
accessKey: "",
accessKeySecret: "",
});
const s = req.settings as unknown as Record<string, unknown>;
expect(s.region).toBe("us-east-1");
expect(s._type).toBe("bedrock");
});
it("omits the access fields when only whitespace is supplied", () => {
// Mirrors the OpenAI/Anthropic whitespace handling: callers must
// not accidentally persist a credential whose plaintext is just
// blanks.
const req = providerFormValuesToCreate({
...baseBedrockFormValues,
accessKey: " ",
accessKeySecret: "\t",
});
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,