From d6bb74cb8e8dc211b9e87374d2203a6fc2bf50fa Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 4 Aug 2026 15:31:26 -0500 Subject: [PATCH] fix(core): resolve Bedrock credentials from the AWS default chain --- packages/core/src/model-resolver.ts | 52 ++++++++++- packages/core/test/model-resolver.test.ts | 109 ++++++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index fbf345a02a..bf42caab70 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -209,9 +209,12 @@ export const fromCatalogModel = ( body: Provider.mergeOverlay(mapping?.body, resolved.body), limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output }, } + const finalized = specifier.startsWith("@opencode-ai/ai/providers/amazon-bedrock") + ? yield* withBedrockCredentials(settings, configured) + : settings return yield* Effect.try({ try: () => { - const runtime = module.model(resolved.modelID ?? resolved.id, settings) + const runtime = module.model(resolved.modelID ?? resolved.id, finalized) return LanguageModel.update(runtime, { provider: resolved.providerID, compatibility: resolved.compatibility @@ -245,6 +248,53 @@ const withoutNativeAuthSettings = (settings: Record) => { return rest } +// Chain providers cache and refresh resolved credentials internally, so reuse one per +// AWS profile instead of re-walking SSO, shared config, and instance metadata each turn. +const bedrockChains = new Map< + string, + () => Promise<{ accessKeyId: string; secretAccessKey: string; sessionToken?: string }> +>() + +// Bedrock signs requests with static credentials captured at model build time; the route +// never refreshes them. Model resolution runs before every provider turn, so falling back +// to the AWS default chain (env, shared config, SSO, process, instance roles) here keeps +// credentials fresh without persisting them. +const withBedrockCredentials = Effect.fnUntraced(function* ( + settings: Readonly>, + configured: Readonly>, +) { + const base = + typeof settings.region !== "string" && process.env.AWS_REGION + ? { ...settings, region: process.env.AWS_REGION } + : settings + if (typeof base.apiKey === "string" || base.credentials !== undefined) return base + if (process.env.AWS_BEARER_TOKEN_BEDROCK) return { ...base, apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK } + const profile = typeof configured.profile === "string" ? configured.profile : process.env.AWS_PROFILE + const identity = yield* Effect.tryPromise(() => { + const chain = bedrockChains.get(profile ?? "") + if (chain) return chain() + return import("@aws-sdk/credential-providers").then((mod) => { + const created = mod.fromNodeProviderChain(profile === undefined ? {} : { profile }) + bedrockChains.set(profile ?? "", created) + return created() + }) + }).pipe( + Effect.catch((cause) => + Effect.logWarning("AWS credential chain resolution failed for Bedrock", { cause }).pipe(Effect.as(undefined)), + ), + ) + if (!identity) return base + return { + ...base, + credentials: { + region: typeof base.region === "string" ? base.region : "us-east-1", + accessKeyId: identity.accessKeyId, + secretAccessKey: identity.secretAccessKey, + ...(identity.sessionToken === undefined ? {} : { sessionToken: identity.sessionToken }), + }, + } +}) + const unsupported = (model: Info) => new UnsupportedPackageError({ providerID: model.providerID, diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index b74a9b799c..1e465f3118 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -21,6 +21,22 @@ interface ModelOptions { readonly limit?: Info["limit"] } +const withEnv = + (env: Record) => + (effect: Effect.Effect) => { + const saved = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]])) + const apply = (values: Record) => { + for (const [key, value] of Object.entries(values)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + return Effect.sync(() => apply(env)).pipe( + Effect.andThen(effect), + Effect.ensuring(Effect.sync(() => apply(saved))), + ) + } + const model = (packageName: string | undefined, options: ModelOptions = {}) => Info.make({ id: ID.make("test-model"), @@ -479,6 +495,97 @@ describe("ModelResolver", () => { }), ) + it.effect("resolves Bedrock credentials from the AWS default chain when none are configured", () => + Effect.gen(function* () { + const native = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/openai"), { + settings: { baseURL: "https://openai.example/v1" }, + }), + ) + const env = { + AWS_ACCESS_KEY_ID: "chain-access", + AWS_SECRET_ACCESS_KEY: "chain-secret", + AWS_SESSION_TOKEN: "chain-session", + AWS_REGION: "eu-west-1", + AWS_PROFILE: undefined, + AWS_BEARER_TOKEN_BEDROCK: undefined, + } + yield* Effect.gen(function* () { + const resolved = yield* ModelResolver.fromCatalogModel( + model("@opencode-ai/ai/providers/amazon-bedrock", { settings: {} }), + undefined, + { + loadPackage: () => + Effect.succeed({ + model: (modelID, settings) => { + expect(settings.credentials).toEqual({ + region: "eu-west-1", + accessKeyId: "chain-access", + secretAccessKey: "chain-secret", + sessionToken: "chain-session", + }) + expect(settings).not.toHaveProperty("apiKey") + return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route }) + }, + }), + }, + ) + expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" }) + }).pipe(withEnv(env)) + }), + ) + + it.effect("uses the Bedrock bearer token env before the AWS default chain", () => + Effect.gen(function* () { + const native = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/openai"), { + settings: { baseURL: "https://openai.example/v1" }, + }), + ) + yield* Effect.gen(function* () { + yield* ModelResolver.fromCatalogModel( + model("@opencode-ai/ai/providers/amazon-bedrock", { settings: { region: "us-west-2" } }), + undefined, + { + loadPackage: () => + Effect.succeed({ + model: (modelID, settings) => { + expect(settings.apiKey).toBe("bearer-token") + expect(settings).not.toHaveProperty("credentials") + return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route }) + }, + }), + }, + ) + }).pipe(withEnv({ AWS_BEARER_TOKEN_BEDROCK: "bearer-token" })) + }), + ) + + it.effect("keeps explicitly configured Bedrock credentials untouched", () => + Effect.gen(function* () { + const native = yield* ModelResolver.fromCatalogModel( + model(Provider.aisdk("@ai-sdk/openai"), { + settings: { baseURL: "https://openai.example/v1" }, + }), + ) + const credentials = { region: "us-east-1", accessKeyId: "configured", secretAccessKey: "configured-secret" } + yield* ModelResolver.fromCatalogModel( + model("@opencode-ai/ai/providers/amazon-bedrock", { settings: { credentials } }), + undefined, + { + loadPackage: () => + Effect.succeed({ + model: (modelID, settings) => { + expect(settings.credentials).toEqual(credentials) + expect(settings).not.toHaveProperty("apiKey") + return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route }) + }, + }), + }, + ) + }), + ) + it.effect("maps OAuth credentials to native provider auth settings", () => Effect.gen(function* () { const native = yield* ModelResolver.fromCatalogModel( @@ -625,12 +732,14 @@ describe("ModelResolver", () => { settings: { region: "us-east-1", topP: 0.8, serviceTier: "priority" }, body: {}, }), + Credential.Key.make({ type: "key", key: "secret" }), ) const mantle = yield* ModelResolver.fromCatalogModel( model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), { modelID: "openai.gpt-oss-120b", settings: { region: "us-east-1" }, }), + Credential.Key.make({ type: "key", key: "secret" }), ) expect(google.route.id).toBe("gemini")