From f5aa90777789e7444efc440b040d5ab9566e5fac Mon Sep 17 00:00:00 2001 From: Joseph Zhang Date: Fri, 17 Jul 2026 15:30:42 -0700 Subject: [PATCH] support default AWS credentials chain instead of static creds --- README.md | 8 ++-- src/agent/index.ts | 31 ++++++++---- src/constants.ts | 19 ++++++-- src/credentials.tsx | 10 ++-- test/constants.test.ts | 47 +++++++++++-------- test/create-model.test.ts | 99 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 174 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index d5dbd353..91263a52 100644 --- a/README.md +++ b/README.md @@ -238,15 +238,17 @@ OPENWIKI_MODEL_ID=your-gateway-model-name ### AWS Bedrock The `bedrock` provider calls foundation models hosted on AWS Bedrock using IAM -credentials rather than a single vendor API key. It authenticates with an AWS -access key ID, a secret access key, and a region: +credentials rather than a single vendor API key. ```bash OPENWIKI_PROVIDER=bedrock +OPENWIKI_MODEL_ID=anthropic.claude-sonnet-5 +# optional, static credentials BEDROCK_AWS_ACCESS_KEY_ID=your-access-key-id BEDROCK_AWS_SECRET_ACCESS_KEY=your-secret-access-key BEDROCK_AWS_REGION=us-east-1 -OPENWIKI_MODEL_ID=anthropic.claude-sonnet-5 +# or +# leave empty to use the default AWS credentials chain in runtime environment ``` Which model IDs are available depends on your AWS account and region (which diff --git a/src/agent/index.ts b/src/agent/index.ts index 06b99b07..2b677071 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -54,7 +54,9 @@ import type { } from "./types.js"; import { ANTHROPIC_BASE_URL_ENV_KEY, + BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY, BEDROCK_AWS_REGION_ENV_KEY, + BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY, getDefaultModelId, getMissingProviderEnvKey, getProviderApiKeyEnvKey, @@ -654,17 +656,30 @@ export function createModel( } if (provider === "bedrock") { - const secretKeyEnvKey = getProviderSecretKeyEnvKey(provider); + // build explicit credentials when static credentials are specified + // otherwise use the default AWS credentials chain in runtime environment + const accessKeyId = process.env[BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY]; + const secretAccessKey = process.env[BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY]; + const credentials = + accessKeyId && secretAccessKey + ? { accessKeyId, secretAccessKey } + : undefined; + // ChatBedrockConverse only checks AWS_DEFAULT_REGION on its own (not + // AWS_REGION), so BEDROCK_AWS_REGION/AWS_REGION are resolved here and + // always passed through explicitly. AWS_REGION is what IRSA/ECS/Lambda + // commonly set, so without this fallback the credential-chain path would + // still fail with "Please set the AWS_DEFAULT_REGION environment + // variable" in those environments. + const region = + process.env[BEDROCK_AWS_REGION_ENV_KEY]?.trim() ?? + process.env.AWS_REGION?.trim() ?? + process.env.AWS_DEFAULT_REGION?.trim() ?? + undefined; return new ChatBedrockConverse({ - credentials: { - accessKeyId: getProviderApiKey(provider) ?? "", - secretAccessKey: secretKeyEnvKey - ? (process.env[secretKeyEnvKey] ?? "") - : "", - }, + ...(credentials ? { credentials } : {}), model: modelId, - region: resolveProviderRegion(provider), + ...(region ? { region } : {}), ...retryOptions, }); } diff --git a/src/constants.ts b/src/constants.ts index c810ac8b..71994d6a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -200,16 +200,18 @@ export const PROVIDER_CONFIGS: Record = { ], }, bedrock: { - apiKeyEnvKey: BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY, + // BEDROCK_AWS_ACCESS_KEY_ID/BEDROCK_AWS_SECRET_ACCESS_KEY/ + // BEDROCK_AWS_REGION remain fully supported as an optional static-key + // override, but createModel() in agent/index.ts reads them directly from + // process.env rather than through apiKeyEnvKey/secretKeyEnvKey/ + // regionEnvKey, so none of the generic provider-config machinery here + // ever treats them as required. label: "AWS Bedrock", // Available model IDs are account- and region-specific (they depend on // which foundation models are enabled in Bedrock), so there is no safe // preset list here; paste the Bedrock model ID directly, for example // anthropic.claude-sonnet-5-20260101-v1:0. modelOptions: [], - secretKeyEnvKey: BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY, - regionEnvKey: BEDROCK_AWS_REGION_ENV_KEY, - requiresRegion: true, }, fireworks: { apiKeyEnvKey: FIREWORKS_API_KEY_ENV_KEY, @@ -425,6 +427,15 @@ export function getProviderCredentialHint( ); } + if (provider === "bedrock") { + return ( + "Uses the AWS default credential provider chain (a shared profile, " + + "IRSA, ECS/EC2 instance role, or ambient AWS_* env vars) unless " + + `${BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY}/${BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY} ` + + "are set." + ); + } + return null; } diff --git a/src/credentials.tsx b/src/credentials.tsx index 77718adc..e42fd6d5 100644 --- a/src/credentials.tsx +++ b/src/credentials.tsx @@ -1213,6 +1213,7 @@ export function InitSetup({ setApiKey(trimmedInput); setInput(""); + const nextStep = getNextStepAfterApiKey( provider, modelIdOverride, @@ -1257,6 +1258,7 @@ export function InitSetup({ setSecretKey(trimmedInput); setInput(""); + const nextStep = getNextStepAfterSecretKey( provider, modelIdOverride, @@ -3927,14 +3929,10 @@ function getProviderSetupDetail(provider: OpenWikiProvider): string { } /** - * Label for the provider's primary credential input. Bedrock authenticates - * with an IAM access key ID (paired with a secret access key), not a single - * opaque API key, so its prompt reads differently from every other provider. + * Label for the provider's primary credential input. */ function getApiKeyFieldLabel(provider: OpenWikiProvider): string { - return provider === "bedrock" - ? `${getProviderLabel(provider)} access key ID` - : `${getProviderLabel(provider)} API key`; + return `${getProviderLabel(provider)} API key`; } function hasValidConfiguredProvider(): boolean { diff --git a/test/constants.test.ts b/test/constants.test.ts index 3656b02f..4db5d5f8 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -7,6 +7,7 @@ import { getDefaultModelId, getMissingProviderEnvKey, getProviderApiKeyEnvKey, + getProviderCredentialHint, getProviderModelOptions, getProviderRegionEnvKey, getProviderSecretKeyEnvKey, @@ -22,7 +23,6 @@ import { resolveConfiguredProvider, resolveProviderBaseUrl, resolveProviderLocation, - resolveProviderRegion, resolveProviderRetryAttempts, } from "../src/constants.ts"; @@ -226,37 +226,46 @@ describe("getProviderModelOptions", () => { }); }); -describe("bedrock provider (IAM access key + secret key + region)", () => { - test("requires a secret key and a region, unlike API-key providers", () => { - expect(providerRequiresSecretKey("bedrock")).toBe(true); - expect(providerRequiresRegion("bedrock")).toBe(true); - expect(providerRequiresSecretKey("anthropic")).toBe(false); - expect(providerRequiresRegion("anthropic")).toBe(false); +describe("bedrock provider (keyless: AWS default credential chain, like gemini-enterprise's ADC)", () => { + test("is keyless — no apiKeyEnvKey, secretKeyEnvKey, or regionEnvKey — so none of these ever block startup", () => { + // Bedrock authenticates via the AWS SDK's default credential provider + // chain (a shared profile, IRSA, ECS/EC2 instance role, or ambient AWS_* + // env vars) by default, the same way gemini-enterprise is keyless and + // relies on Google Application Default Credentials. + expect(providerRequiresApiKey("bedrock")).toBe(false); + expect(getProviderApiKeyEnvKey("bedrock")).toBeUndefined(); + expect(providerRequiresSecretKey("bedrock")).toBe(false); + expect(getProviderSecretKeyEnvKey("bedrock")).toBeUndefined(); + expect(providerRequiresRegion("bedrock")).toBe(false); + expect(getProviderRegionEnvKey("bedrock")).toBeUndefined(); }); - test("exposes the AWS-flavored env keys", () => { - expect(getProviderSecretKeyEnvKey("bedrock")).toBe( - "BEDROCK_AWS_SECRET_ACCESS_KEY", - ); - expect(getProviderRegionEnvKey("bedrock")).toBe("BEDROCK_AWS_REGION"); + test("has no preset model list (Bedrock model availability is account/region specific)", () => { + expect(getProviderModelOptions("bedrock")).toEqual([]); }); - test("resolveProviderRegion reads the region env key and trims it", () => { + test("getMissingProviderEnvKey never blocks on bedrock (keyless, no projectEnvKey either)", () => { + expect(getMissingProviderEnvKey("bedrock", {})).toBeNull(); expect( - resolveProviderRegion("bedrock", { BEDROCK_AWS_REGION: " us-east-1 " }), - ).toBe("us-east-1"); - expect(resolveProviderRegion("bedrock", {})).toBeUndefined(); + getMissingProviderEnvKey("bedrock", { + BEDROCK_AWS_ACCESS_KEY_ID: "AKIA...", + }), + ).toBeNull(); }); - test("has no preset model list (Bedrock model availability is account/region specific)", () => { - expect(getProviderModelOptions("bedrock")).toEqual([]); + test("getProviderCredentialHint documents the default AWS credential chain and the static-key override", () => { + expect(getProviderCredentialHint("bedrock")).toMatch( + /default credential provider chain/iu, + ); }); }); describe("providerRequiresApiKey / getProviderApiKeyEnvKey", () => { - test("gemini-enterprise authenticates without an API key", () => { + test("gemini-enterprise and bedrock authenticate without an API key", () => { expect(providerRequiresApiKey("gemini-enterprise")).toBe(false); expect(getProviderApiKeyEnvKey("gemini-enterprise")).toBeUndefined(); + expect(providerRequiresApiKey("bedrock")).toBe(false); + expect(getProviderApiKeyEnvKey("bedrock")).toBeUndefined(); }); test("key-based providers still require one", () => { diff --git a/test/create-model.test.ts b/test/create-model.test.ts index d4520b13..77533f55 100644 --- a/test/create-model.test.ts +++ b/test/create-model.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { ChatAnthropic } from "@langchain/anthropic"; +import { ChatBedrockConverse } from "@langchain/aws"; import { ChatGoogle } from "@langchain/google/node"; import { ChatOpenAI } from "@langchain/openai"; import { createModel } from "../src/agent/index.ts"; @@ -138,6 +139,104 @@ describe("createModel gemini (AI Studio)", () => { }); }); +const BEDROCK_ACCESS_KEY = "BEDROCK_AWS_ACCESS_KEY_ID"; +const BEDROCK_SECRET_KEY = "BEDROCK_AWS_SECRET_ACCESS_KEY"; +const BEDROCK_REGION = "BEDROCK_AWS_REGION"; +const AWS_REGION_KEY = "AWS_REGION"; +const AWS_DEFAULT_REGION_KEY = "AWS_DEFAULT_REGION"; + +describe("createModel bedrock (default AWS credential chain vs static keys)", () => { + let savedAccessKey: string | undefined; + let savedSecretKey: string | undefined; + let savedRegion: string | undefined; + let savedAwsRegion: string | undefined; + let savedAwsDefaultRegion: string | undefined; + + beforeEach(() => { + savedAccessKey = process.env[BEDROCK_ACCESS_KEY]; + savedSecretKey = process.env[BEDROCK_SECRET_KEY]; + savedRegion = process.env[BEDROCK_REGION]; + savedAwsRegion = process.env[AWS_REGION_KEY]; + savedAwsDefaultRegion = process.env[AWS_DEFAULT_REGION_KEY]; + delete process.env[BEDROCK_ACCESS_KEY]; + delete process.env[BEDROCK_SECRET_KEY]; + delete process.env[BEDROCK_REGION]; + delete process.env[AWS_REGION_KEY]; + delete process.env[AWS_DEFAULT_REGION_KEY]; + }); + + afterEach(() => { + restoreEnv(BEDROCK_ACCESS_KEY, savedAccessKey); + restoreEnv(BEDROCK_SECRET_KEY, savedSecretKey); + restoreEnv(BEDROCK_REGION, savedRegion); + restoreEnv(AWS_REGION_KEY, savedAwsRegion); + restoreEnv(AWS_DEFAULT_REGION_KEY, savedAwsDefaultRegion); + }); + + test("omits credentials when none are configured, relying on the AWS default chain", () => { + // A region still has to come from somewhere (ChatBedrockConverse cannot + // resolve one from a bare credential provider chain the way it resolves + // credentials), so this sets AWS_REGION — the variable IRSA/ECS/Lambda + // commonly inject — and leaves every BEDROCK_AWS_* var unset. + process.env[AWS_REGION_KEY] = "us-west-2"; + + const model = createModel( + "bedrock", + "anthropic.claude-sonnet-5-20260101-v1:0", + 0, + ) as { region?: string }; + + expect(model).toBeInstanceOf(ChatBedrockConverse); + // ChatBedrockConverse resolves its own default-chain credentials + // internally when none are passed in, rather than exposing them as a + // field — so the observable contract here is that construction succeeds + // without BEDROCK_AWS_ACCESS_KEY_ID/BEDROCK_AWS_SECRET_ACCESS_KEY set, + // and that the AWS_REGION fallback reached the client. + expect(modelName(model)).toBe("anthropic.claude-sonnet-5-20260101-v1:0"); + expect(model.region).toBe("us-west-2"); + }); + + test("passes static credentials and region through when both are configured", () => { + process.env[BEDROCK_ACCESS_KEY] = "AKIAEXAMPLE"; + process.env[BEDROCK_SECRET_KEY] = "secret-example"; + process.env[BEDROCK_REGION] = "us-east-1"; + + const model = createModel( + "bedrock", + "anthropic.claude-sonnet-5-20260101-v1:0", + 0, + ) as { region?: string }; + + expect(model).toBeInstanceOf(ChatBedrockConverse); + expect(model.region).toBe("us-east-1"); + }); + + test("does not treat a lone access key (no secret) as static credentials", () => { + process.env[BEDROCK_ACCESS_KEY] = "AKIAEXAMPLE"; + process.env[AWS_REGION_KEY] = "us-west-2"; + + // Should not throw despite the secret key being absent — falls back to + // the AWS default credential chain instead of using a half-set pair. + expect(() => + createModel("bedrock", "anthropic.claude-sonnet-5-20260101-v1:0", 0), + ).not.toThrow(); + }); + + test("prefers BEDROCK_AWS_REGION over AWS_REGION over AWS_DEFAULT_REGION", () => { + process.env[AWS_DEFAULT_REGION_KEY] = "sa-east-1"; + process.env[AWS_REGION_KEY] = "eu-central-1"; + process.env[BEDROCK_REGION] = "ap-southeast-2"; + + const model = createModel( + "bedrock", + "anthropic.claude-sonnet-5-20260101-v1:0", + 0, + ) as { region?: string }; + + expect(model.region).toBe("ap-southeast-2"); + }); +}); + function restoreEnv(key: string, value: string | undefined): void { if (value === undefined) { delete process.env[key];