fix(core): resolve Bedrock credentials from the AWS default chain
This commit is contained in:
@@ -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<string, unknown>) => {
|
||||
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<Record<string, unknown>>,
|
||||
configured: Readonly<Record<string, unknown>>,
|
||||
) {
|
||||
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,
|
||||
|
||||
@@ -21,6 +21,22 @@ interface ModelOptions {
|
||||
readonly limit?: Info["limit"]
|
||||
}
|
||||
|
||||
const withEnv =
|
||||
(env: Record<string, string | undefined>) =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>) => {
|
||||
const saved = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]))
|
||||
const apply = (values: Record<string, string | undefined>) => {
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user