mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user