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
52 changes: 51 additions & 1 deletion packages/core/src/model-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
109 changes: 109 additions & 0 deletions packages/core/test/model-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand Down
Loading