Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline 5ebc6d2146 fix(core): refresh Bedrock credentials 2026-08-07 20:21:21 +00:00
13 changed files with 148 additions and 57 deletions
@@ -4,12 +4,7 @@ import { Headers } from "effect/unstable/http"
import { Auth, type AuthInput } from "../../route/auth"
import { ProviderShared } from "../shared"
/**
* 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.
*/
/** AWS credentials for SigV4 signing. */
export interface Credentials {
readonly region: string
readonly accessKeyId: string
@@ -17,6 +12,8 @@ export interface Credentials {
readonly sessionToken?: string
}
export type CredentialProvider = () => Promise<Credentials>
const signRequest = (input: {
readonly url: string
readonly body: string
@@ -48,7 +45,7 @@ const signRequest = (input: {
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
export const sigV4 = (
credentials: Credentials | undefined,
credentials: Credentials | CredentialProvider | undefined,
options: { readonly service?: string; readonly name?: string } = {},
) =>
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`,
)
}
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 signed = yield* signRequest({
url: input.url,
body: input.body,
headers: headersForSigning,
credentials,
credentials: resolved,
service: options.service ?? "bedrock",
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 * as BedrockConverse 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 type Config = RouteDefaultsInput & {
readonly apiKey?: 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. */
readonly region?: string
/** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
@@ -21,7 +22,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: BedrockCredentials
readonly credentials?: BedrockCredentials | CredentialProvider
readonly region?: string
readonly topP?: number
}
@@ -31,7 +32,7 @@ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.am
const configuredRoute = (input: Config) => {
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({
...rest,
provider: id,
@@ -519,14 +519,8 @@ describe("Bedrock Converse route", () => {
fixedBytes(
eventStreamBody(
["messageStart", { role: "assistant" }],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } },
],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
["messageStop", { stopReason: "end_turn" }],
),
),
@@ -561,10 +555,7 @@ describe("Bedrock Converse route", () => {
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
["contentBlockStop", { contentBlockIndex: 0 }],
["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", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
+3 -1
View File
@@ -88,7 +88,9 @@ function mapBedrockSettings(
: typeof settings.bearerToken === "string"
? settings.bearerToken
: undefined
const credentials = mapBedrockCredentials(settings)
const credentials =
mapBedrockCredentials(settings) ??
(typeof settings.credentialProvider === "function" ? settings.credentialProvider : undefined)
return {
...baseSettings,
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
@@ -59,18 +59,40 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
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({
id: "opencode.provider.amazon-bedrock",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
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) => {
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
// 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
})
}
@@ -95,8 +117,7 @@ export const AmazonBedrockPlugin = define({
if (!bearerToken && options.credentialProvider === undefined) {
// Do not gate SDK creation on explicit AWS env vars. The default chain
// also handles ~/.aws/credentials, SSO, process creds, and instance roles.
const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers"))
options.credentialProvider = fromNodeProviderChain(profile ? { profile } : {})
options.credentialProvider = defaultCredentialProvider(profile, region)
}
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", () => {
expect(
map(
@@ -80,24 +80,66 @@ function openAIUrl(language: unknown, path: string, modelId: string) {
describe("AmazonBedrockPlugin", () => {
it.effect("moves endpoint setting to baseURL", () =>
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"),
settings: { endpoint: "https://bedrock.example" },
withEnv({ AWS_PROFILE: undefined, AWS_REGION: undefined }, () =>
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"),
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) => {
item.package = bedrock.package
item.settings = { endpoint: "https://bedrock.example" }
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).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", () =>
+1 -1
View File
@@ -407,7 +407,7 @@ export function RunCommandMenuBody(props: {
name: "compact",
display: "Compact session",
footer: "/compact",
keywords: "compact session context",
keywords: "compact summarize session context",
},
{
action: "slash",
+1 -1
View File
@@ -406,7 +406,7 @@ export function createPromptState(input: PromptInput): PromptState {
kind: "slash",
name: "compact",
display: "/compact",
description: "compact older session context to free space",
description: "summarize the session to reduce context usage",
} satisfies SlashOption,
{ kind: "slash", name: "exit", display: "/exit", description: "close OpenCode" } satisfies SlashOption,
]
+2 -1
View File
@@ -54,7 +54,8 @@ export function isNewCommand(input: string): boolean {
}
export function isCompactCommand(input: string): boolean {
return input.trim().toLowerCase() === "/compact"
const text = input.trim().toLowerCase()
return text === "/compact" || text === "/summarize"
}
export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
@@ -635,6 +635,7 @@ export function Session() {
group: "Session",
slash: {
name: "compact",
aliases: ["summarize"],
},
run: () => {
void client.api.session.compact({ sessionID: route.sessionID })
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"
import {
createPromptHistory,
isCompactCommand,
isExitCommand,
isNewCommand,
movePromptHistory,
@@ -99,10 +98,4 @@ describe("run prompt shared", () => {
expect(isNewCommand(" /NEW ")).toBe(true)
expect(isNewCommand("/new now")).toBe(false)
})
test("recognizes only the compact command", () => {
expect(isCompactCommand("/compact")).toBe(true)
expect(isCompactCommand(" /COMPACT ")).toBe(true)
expect(isCompactCommand("/summarize")).toBe(false)
})
})
+2 -2
View File
@@ -80,7 +80,7 @@ describe("run runtime queue", () => {
])
})
test("treats /compact as a local compaction command", async () => {
test.each(["/compact", "/summarize"])("treats %s as a local compaction command", async (command) => {
const ui = createFooterApiFixture()
const seen: string[] = []
let compacted = 0
@@ -96,7 +96,7 @@ describe("run runtime queue", () => {
},
})
ui.submit("/compact")
ui.submit(command)
ui.submit("hello")
await task