Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,15 +251,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
Expand Down
31 changes: 23 additions & 8 deletions src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
}
Expand Down
19 changes: 15 additions & 4 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,16 +200,18 @@ export const PROVIDER_CONFIGS: Record<OpenWikiProvider, ProviderConfig> = {
],
},
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,
Expand Down Expand Up @@ -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;
}

Expand Down
10 changes: 4 additions & 6 deletions src/credentials.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1213,6 +1213,7 @@ export function InitSetup({

setApiKey(trimmedInput);
setInput("");

const nextStep = getNextStepAfterApiKey(
provider,
modelIdOverride,
Expand Down Expand Up @@ -1257,6 +1258,7 @@ export function InitSetup({

setSecretKey(trimmedInput);
setInput("");

const nextStep = getNextStepAfterSecretKey(
provider,
modelIdOverride,
Expand Down Expand Up @@ -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 {
Expand Down
47 changes: 28 additions & 19 deletions test/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getDefaultModelId,
getMissingProviderEnvKey,
getProviderApiKeyEnvKey,
getProviderCredentialHint,
getProviderModelOptions,
getProviderRegionEnvKey,
getProviderSecretKeyEnvKey,
Expand All @@ -22,7 +23,6 @@ import {
resolveConfiguredProvider,
resolveProviderBaseUrl,
resolveProviderLocation,
resolveProviderRegion,
resolveProviderRetryAttempts,
} from "../src/constants.ts";

Expand Down Expand Up @@ -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", () => {
Expand Down
99 changes: 99 additions & 0 deletions test/create-model.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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];
Expand Down