From 0c1c4af40af9425b5c1ff3158c16c6874178f65d Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Tue, 16 Jun 2026 16:37:45 +0200 Subject: [PATCH] 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. image This PR was created by a Coder Agent on behalf of @dannykopping. --- .../components/ProviderForm.stories.tsx | 91 ++++++++++++++++++- .../ProvidersPage/components/ProviderForm.tsx | 66 +++++++++++--- .../components/providerFormApiMap.test.ts | 29 ++++++ 3 files changed, 168 insertions(+), 18 deletions(-) diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx index 49c1e0c866..7851332862 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx @@ -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 = { diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx index 1388bdcb26..99a1d24a92 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx @@ -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> > = { @@ -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 = ({ field={getFieldHelpers("model")} label="Model" className="w-full" - placeholder="anthropic.claude-3-5-sonnet-20241022-v2:0" + placeholder={BEDROCK_DEFAULT_MODEL} /> +

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

handleCredentialBlur("accessKey")} @@ -480,7 +507,6 @@ export const ProviderForm: FC = ({ autoComplete="new-password" /> handleCredentialBlur("accessKeySecret")} @@ -488,6 +514,18 @@ export const ProviderForm: FC = ({ autoComplete="new-password" />
+

+ Optional. Leave both fields blank to authenticate with the AWS + environment (IAM role, instance profile, AWS_PROFILE).{" "} + + View docs + +

)} diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts index b02e1413dc..1ae786008c 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.test.ts @@ -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; + 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; + expect(s.access_key).toBeUndefined(); + expect(s.access_key_secret).toBeUndefined(); + }); + it("ignores the OpenAI/Anthropic api key field", () => { const req = providerFormValuesToCreate({ ...baseBedrockFormValues,