Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline 5ebc6d2146 fix(core): refresh Bedrock credentials 2026-08-07 20:21:21 +00:00
17 changed files with 152 additions and 125 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", () =>
+2 -3
View File
@@ -18,7 +18,6 @@ const BindingObject = Schema.StructWithRest(
Schema.Struct({
key: Schema.Union([Schema.String, KeyStroke]),
event: Schema.optional(Schema.Literals(["press", "release"])),
source: Schema.optional(Schema.Literals(["raw", "kitty"])),
preventDefault: Schema.optional(Schema.Boolean),
fallthrough: Schema.optional(Schema.Boolean),
}),
@@ -163,7 +162,7 @@ export const Definitions = {
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
prompt_submit: keybind("none", "Submit prompt"),
prompt_queue: keybind({ key: "alt+return", source: "kitty" }, "Queue prompt"),
prompt_queue: keybind("alt+return", "Queue prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_skills: keybind("none", "Open skill selector"),
prompt_stash: keybind("none", "Stash prompt"),
@@ -173,7 +172,7 @@ export const Definitions = {
input_clear: keybind("ctrl+c", "Clear input field"),
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
input_submit: keybind("return", "Submit input"),
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
input_newline: keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"),
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
input_move_up: keybind("up", "Move cursor up in input"),
-26
View File
@@ -66,32 +66,6 @@ function Provider(props: ParentProps<{ config?: KeymapConfig }>) {
}
}
const dispose = [
keymap.registerBindingFields({
source: (source, context) => {
if (source !== "raw" && source !== "kitty") throw new Error(`Invalid key source: ${String(source)}`)
context.attr("source", source)
},
}),
keymap.appendBindingTransformer((binding, context) => {
const source = binding.source
if (source !== "raw" && source !== "kitty") return
const command = binding.cmd
if (!command) return
context.add({
...binding,
cmd: (commandContext) => {
if (commandContext.event.source !== source) return false
if (typeof command === "function") return command(commandContext)
return commandContext.keymap.runCommand(command, {
event: commandContext.event,
focused: commandContext.focused,
target: commandContext.target,
payload: commandContext.payload,
})
},
})
context.skipOriginal()
}),
registerCommaBindings(keymap),
keymap.appendBindingExpander((context) => {
const key = Object.entries({ enter: "return", esc: "escape", pgdown: "pagedown", pgup: "pageup" }).reduce(
+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 })
-37
View File
@@ -1,6 +1,5 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { expect, test } from "bun:test"
import { ConfigProvider } from "../src/config"
import { Keymap } from "../src/context/keymap"
@@ -140,39 +139,3 @@ test("global commands stay reachable when the mode changes", async () => {
app.renderer.destroy()
}
})
test("queues explicit option enter and keeps raw option enter as newline", async () => {
async function exercise(kittyKeyboard: boolean) {
let area: TextareaRenderable | undefined
let queued = 0
function Harness() {
Keymap.createLayer(() => ({
priority: 1,
commands: [{ id: "prompt.queue", run: () => void queued++ }],
}))
return <textarea ref={(value) => (area = value)} focused />
}
const app = await testRender(
() => (
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<Harness />
</Keymap.Provider>
</ConfigProvider>
),
{ kittyKeyboard },
)
try {
app.mockInput.pressEnter({ meta: true })
await app.renderOnce()
return { queued, text: area?.plainText }
} finally {
app.renderer.destroy()
}
}
expect(await exercise(true)).toEqual({ queued: 1, text: "" })
expect(await exercise(false)).toEqual({ queued: 0, text: "\n" })
})
@@ -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
@@ -82,8 +82,8 @@ describe("run runtime boot", () => {
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j")
expect(result.keybinds.get("prompt.queue")?.[0]).toMatchObject({ key: "alt+return", source: "kitty" })
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j")
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
})
test("preserves disabled leader from resolved tui config", async () => {
+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