Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline d6bb74cb8e fix(core): resolve Bedrock credentials from the AWS default chain 2026-08-04 15:31:26 -05:00
2 changed files with 160 additions and 1 deletions
+51 -1
View File
@@ -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,
+109
View File
@@ -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")