Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 5ebc6d2146 fix(core): refresh Bedrock credentials 2026-08-07 20:21:21 +00:00
7 changed files with 141 additions and 45 deletions
@@ -4,12 +4,7 @@ import { Headers } from "effect/unstable/http"
import { Auth, type AuthInput } from "../../route/auth" import { Auth, type AuthInput } from "../../route/auth"
import { ProviderShared } from "../shared" import { ProviderShared } from "../shared"
/** /** AWS credentials for SigV4 signing. */
* AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth,
* which provider facades configure as route auth instead of SigV4. STS-vended
* credentials should be refreshed by the consumer (rebuild the model) before
* they expire; the route does not refresh.
*/
export interface Credentials { export interface Credentials {
readonly region: string readonly region: string
readonly accessKeyId: string readonly accessKeyId: string
@@ -17,6 +12,8 @@ export interface Credentials {
readonly sessionToken?: string readonly sessionToken?: string
} }
export type CredentialProvider = () => Promise<Credentials>
const signRequest = (input: { const signRequest = (input: {
readonly url: string readonly url: string
readonly body: string readonly body: string
@@ -48,7 +45,7 @@ const signRequest = (input: {
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */ /** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
export const sigV4 = ( export const sigV4 = (
credentials: Credentials | undefined, credentials: Credentials | CredentialProvider | undefined,
options: { readonly service?: string; readonly name?: string } = {}, options: { readonly service?: string; readonly name?: string } = {},
) => ) =>
Auth.custom((input: AuthInput) => { Auth.custom((input: AuthInput) => {
@@ -58,12 +55,22 @@ export const sigV4 = (
`${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`, `${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`,
) )
} }
const resolved =
typeof credentials === "function"
? yield* Effect.tryPromise({
try: credentials,
catch: (error) =>
ProviderShared.invalidRequest(
`${options.name ?? "Bedrock Converse"} credential resolution failed: ${error instanceof Error ? error.message : String(error)}`,
),
})
: credentials
const headersForSigning = Headers.set(input.headers, "content-type", "application/json") const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
const signed = yield* signRequest({ const signed = yield* signRequest({
url: input.url, url: input.url,
body: input.body, body: input.body,
headers: headersForSigning, headers: headersForSigning,
credentials, credentials: resolved,
service: options.service ?? "bedrock", service: options.service ?? "bedrock",
name: options.name ?? "Bedrock Converse", name: options.name ?? "Bedrock Converse",
}) })
+4 -3
View File
@@ -4,13 +4,14 @@ import type { ProviderPackage } from "../provider-package"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
import * as BedrockConverse from "../protocols/bedrock-converse" import * as BedrockConverse from "../protocols/bedrock-converse"
import type { BedrockCredentials } from "../protocols/bedrock-converse" import type { BedrockCredentials } from "../protocols/bedrock-converse"
import type { CredentialProvider } from "../protocols/utils/bedrock-auth"
export const id = ProviderID.make("amazon-bedrock") export const id = ProviderID.make("amazon-bedrock")
export type Config = RouteDefaultsInput & { export type Config = RouteDefaultsInput & {
readonly apiKey?: string readonly apiKey?: string
readonly headers?: Record<string, string> readonly headers?: Record<string, string>
readonly credentials?: BedrockCredentials readonly credentials?: BedrockCredentials | CredentialProvider
/** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */ /** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */
readonly region?: string readonly region?: string
/** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */ /** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
@@ -21,7 +22,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string readonly apiKey?: string
readonly auth?: "bearer" | "sigv4" readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string readonly baseURL?: string
readonly credentials?: BedrockCredentials readonly credentials?: BedrockCredentials | CredentialProvider
readonly region?: string readonly region?: string
readonly topP?: number readonly topP?: number
} }
@@ -31,7 +32,7 @@ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.am
const configuredRoute = (input: Config) => { const configuredRoute = (input: Config) => {
const { apiKey, credentials, region, baseURL, ...rest } = input const { apiKey, credentials, region, baseURL, ...rest } = input
const resolvedRegion = region ?? credentials?.region ?? "us-east-1" const resolvedRegion = region ?? (typeof credentials === "function" ? undefined : credentials?.region) ?? "us-east-1"
return BedrockConverse.route.with({ return BedrockConverse.route.with({
...rest, ...rest,
provider: id, provider: id,
@@ -519,14 +519,8 @@ describe("Bedrock Converse route", () => {
fixedBytes( fixedBytes(
eventStreamBody( eventStreamBody(
["messageStart", { role: "assistant" }], ["messageStart", { role: "assistant" }],
[ ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
"contentBlockDelta", ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
{ contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } },
],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["messageStop", { stopReason: "end_turn" }], ["messageStop", { stopReason: "end_turn" }],
), ),
), ),
@@ -561,10 +555,7 @@ describe("Bedrock Converse route", () => {
Effect.gen(function* () { Effect.gen(function* () {
const body = eventStreamBody( const body = eventStreamBody(
["messageStart", { role: "assistant" }], ["messageStart", { role: "assistant" }],
[ ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["contentBlockStop", { contentBlockIndex: 0 }], ["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "end_turn" }], ["messageStop", { stopReason: "end_turn" }],
) )
@@ -760,6 +751,26 @@ describe("Bedrock Converse route", () => {
}), }),
) )
it.effect("resolves fresh SigV4 credentials for each request", () => {
let calls = 0
const signed = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test",
credentials: async () => {
calls++
return {
region: "us-east-1",
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
},
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
return LLMClient.generate(LLMRequest.update(baseRequest, { model: signed })).pipe(
Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
Effect.tap(() => Effect.sync(() => expect(calls).toBe(1))),
)
})
it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () => it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
Effect.gen(function* () { Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" }) const cache = new CacheHint({ type: "ephemeral" })
+3 -1
View File
@@ -88,7 +88,9 @@ function mapBedrockSettings(
: typeof settings.bearerToken === "string" : typeof settings.bearerToken === "string"
? settings.bearerToken ? settings.bearerToken
: undefined : undefined
const credentials = mapBedrockCredentials(settings) const credentials =
mapBedrockCredentials(settings) ??
(typeof settings.credentialProvider === "function" ? settings.credentialProvider : undefined)
return { return {
...baseSettings, ...baseSettings,
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string" ...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
@@ -59,18 +59,40 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
return sdk.responses(modelID) return sdk.responses(modelID)
} }
function defaultCredentialProvider(profile: string | undefined, region: string) {
const load = import("@aws-sdk/credential-providers").then((mod) =>
mod.fromNodeProviderChain(profile ? { profile } : {}),
)
return async () => ({ ...(await (await load)()), region })
}
export const AmazonBedrockPlugin = define({ export const AmazonBedrockPlugin = define({
id: "opencode.provider.amazon-bedrock", id: "opencode.provider.amazon-bedrock",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (!Provider.isAISDK(item.provider.package)) continue if (!Provider.isAISDK(item.provider.package)) continue
if (Provider.packageName(item.provider.package) !== "@ai-sdk/amazon-bedrock") continue if (
!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(
Provider.packageName(item.provider.package) ?? "",
)
)
continue
evt.provider.update(item.provider.id, (provider) => { evt.provider.update(item.provider.id, (provider) => {
if (typeof provider.settings?.endpoint !== "string") return const settings = provider.settings ?? {}
const profile = typeof settings.profile === "string" ? settings.profile : process.env.AWS_PROFILE
const region = typeof settings.region === "string" ? settings.region : (process.env.AWS_REGION ?? "us-east-1")
provider.settings = {
...settings,
region,
...(typeof settings.credentialProvider === "function"
? {}
: { credentialProvider: defaultCredentialProvider(profile, region) }),
}
if (typeof settings.endpoint !== "string") return
// The AI SDK expects a base URL, but users configure Bedrock private/VPC // The AI SDK expects a base URL, but users configure Bedrock private/VPC
// endpoints as `endpoint`; move it into the catalog endpoint URL once. // endpoints as `endpoint`; move it into the catalog endpoint URL once.
provider.settings.baseURL = provider.settings.endpoint provider.settings.baseURL = settings.endpoint
delete provider.settings.endpoint delete provider.settings.endpoint
}) })
} }
@@ -95,8 +117,7 @@ export const AmazonBedrockPlugin = define({
if (!bearerToken && options.credentialProvider === undefined) { if (!bearerToken && options.credentialProvider === undefined) {
// Do not gate SDK creation on explicit AWS env vars. The default chain // Do not gate SDK creation on explicit AWS env vars. The default chain
// also handles ~/.aws/credentials, SSO, process creds, and instance roles. // also handles ~/.aws/credentials, SSO, process creds, and instance roles.
const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers")) options.credentialProvider = defaultCredentialProvider(profile, region)
options.credentialProvider = fromNodeProviderChain(profile ? { profile } : {})
} }
if (evt.package === "@ai-sdk/amazon-bedrock/mantle") { if (evt.package === "@ai-sdk/amazon-bedrock/mantle") {
+12
View File
@@ -158,6 +158,18 @@ describe("AISDKNative", () => {
}) })
}) })
test("maps a Bedrock credential provider to the native runtime", () => {
const credentialProvider = async () => ({
region: "us-east-1",
accessKeyId: "key",
secretAccessKey: "secret",
})
for (const packageName of ["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"]) {
expect(map(packageName, { credentialProvider })?.settings).toMatchObject({ credentials: credentialProvider })
}
})
test("maps the legacy Bedrock endpoint override", () => { test("maps the legacy Bedrock endpoint override", () => {
expect( expect(
map( map(
@@ -80,24 +80,66 @@ function openAIUrl(language: unknown, path: string, modelId: string) {
describe("AmazonBedrockPlugin", () => { describe("AmazonBedrockPlugin", () => {
it.effect("moves endpoint setting to baseURL", () => it.effect("moves endpoint setting to baseURL", () =>
Effect.gen(function* () { withEnv({ AWS_PROFILE: undefined, AWS_REGION: undefined }, () =>
const catalog = yield* Catalog.Service Effect.gen(function* () {
yield* catalog.transform((catalog) => { const catalog = yield* Catalog.Service
const bedrock = Provider.Info.make({ yield* catalog.transform((catalog) => {
...Provider.Info.empty(Provider.ID.amazonBedrock), const bedrock = Provider.Info.make({
package: Provider.aisdk("@ai-sdk/amazon-bedrock"), ...Provider.Info.empty(Provider.ID.amazonBedrock),
settings: { endpoint: "https://bedrock.example" }, package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
settings: { endpoint: "https://bedrock.example" },
})
catalog.provider.update(bedrock.id, (item) => {
item.package = bedrock.package
item.settings = { endpoint: "https://bedrock.example" }
})
}) })
catalog.provider.update(bedrock.id, (item) => { yield* addPlugin()
item.package = bedrock.package const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock))
item.settings = { endpoint: "https://bedrock.example" } expect(result.package).toBe(Provider.aisdk("@ai-sdk/amazon-bedrock"))
expect(result.settings).toMatchObject({
baseURL: "https://bedrock.example",
region: "us-east-1",
credentialProvider: expect.any(Function),
}) })
}) }),
yield* addPlugin() ),
const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock)) )
expect(result.package).toBe(Provider.aisdk("@ai-sdk/amazon-bedrock"))
expect(result.settings).toEqual({ baseURL: "https://bedrock.example" }) it.effect("discovers AWS credentials for the native Bedrock runtime", () =>
}), withEnv(
{
AWS_ACCESS_KEY_ID: "key",
AWS_SECRET_ACCESS_KEY: "secret",
AWS_SESSION_TOKEN: "session",
AWS_PROFILE: undefined,
AWS_REGION: "eu-west-1",
},
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
const bedrock = Provider.Info.make({
...Provider.Info.empty(Provider.ID.amazonBedrock),
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
})
catalog.provider.update(bedrock.id, (item) => {
item.package = bedrock.package
})
})
yield* addPlugin()
const result = required(yield* catalog.provider.get(Provider.ID.amazonBedrock))
const credentialProvider = result.settings?.credentialProvider
if (typeof credentialProvider !== "function") throw new Error("Expected credential provider")
const credentials = yield* Effect.promise(() => credentialProvider())
expect(credentials).toMatchObject({
region: "eu-west-1",
accessKeyId: "key",
secretAccessKey: "secret",
sessionToken: "session",
})
}),
),
) )
it.effect("prefers endpoint over baseURL for SDK base URL", () => it.effect("prefers endpoint over baseURL for SDK base URL", () =>