From 0d7b1096b48e99ba9f8fce08cd5fa81ddcc63903 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:57:07 +0100 Subject: [PATCH] feat(ai): sign Bedrock Mantle requests with SigV4 from the AWS credential chain --- package-lock.json | 2 + packages/ai/README.md | 2 +- packages/ai/package.json | 2 + .../ai/src/api/bedrock-mantle-sigv4.lazy.ts | 26 +++++ packages/ai/src/api/bedrock-mantle-sigv4.ts | 94 +++++++++++++++++++ packages/ai/src/api/bedrock-mantle.ts | 45 +++++---- packages/ai/src/bedrock-provider.ts | 5 + packages/ai/src/compat.ts | 1 + packages/ai/test/bedrock-mantle.test.ts | 43 ++++++++- packages/coding-agent/docs/providers.md | 2 +- .../install-lock/package-lock.json | 2 + .../coding-agent/src/bun/runtime-setup.ts | 5 +- 12 files changed, 206 insertions(+), 23 deletions(-) create mode 100644 packages/ai/src/api/bedrock-mantle-sigv4.lazy.ts create mode 100644 packages/ai/src/api/bedrock-mantle-sigv4.ts diff --git a/package-lock.json b/package-lock.json index d24542930..00ac4e8d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5726,9 +5726,11 @@ "dependencies": { "@anthropic-ai/sdk": "0.129.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", + "@aws-sdk/credential-provider-node": "3.972.82", "@earendil-works/pi-telemetry": "1.4.0", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", + "@smithy/signature-v4": "5.7.3", "google-auth-library": "10.6.2", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", diff --git a/packages/ai/README.md b/packages/ai/README.md index b95af9368..3555102fe 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -556,7 +556,7 @@ The Z.AI pay-as-you-go APIs (`zai-api`, `zai-api-cn`) and Alibaba Cloud Model St subscriptions, while the existing provider retains its broader catalog for backward compatibility. Stored credentials remain provider-scoped, so save the key under the provider ID you register. -Amazon Bedrock resolves ambient AWS credentials (`AWS_PROFILE`, access key pairs, `AWS_BEARER_TOKEN_BEDROCK`, ECS task roles, web identity tokens); its provider-owned login flow supports bearer tokens, AWS profiles, and the existing credential chain. Models Bedrock only serves on Mantle, its OpenAI-compatible endpoint (for example OpenAI's GPT models), use the Responses API at `bedrock-mantle.{region}.api.aws` and currently need a Bedrock API key. Vertex AI resolves either an explicit key or gcloud Application Default Credentials plus project/location, with a provider-owned login flow for API keys, ADC, and service-account files. +Amazon Bedrock resolves ambient AWS credentials (`AWS_PROFILE`, access key pairs, `AWS_BEARER_TOKEN_BEDROCK`, ECS task roles, web identity tokens); its provider-owned login flow supports bearer tokens, AWS profiles, and the existing credential chain. Models Bedrock only serves on Mantle, its OpenAI-compatible endpoint (for example OpenAI's GPT models), use the Responses API at `bedrock-mantle.{region}.api.aws`, with a Bedrock API key when one is set and SigV4-signed requests from the AWS credential chain otherwise. Vertex AI resolves either an explicit key or gcloud Application Default Credentials plus project/location, with a provider-owned login flow for API keys, ADC, and service-account files. ## Tools diff --git a/packages/ai/package.json b/packages/ai/package.json index eb399112e..c9167b5aa 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -72,9 +72,11 @@ "dependencies": { "@anthropic-ai/sdk": "0.129.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", + "@aws-sdk/credential-provider-node": "3.972.82", "@earendil-works/pi-telemetry": "1.4.0", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", + "@smithy/signature-v4": "5.7.3", "google-auth-library": "10.6.2", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", diff --git a/packages/ai/src/api/bedrock-mantle-sigv4.lazy.ts b/packages/ai/src/api/bedrock-mantle-sigv4.lazy.ts new file mode 100644 index 000000000..b4d709aa8 --- /dev/null +++ b/packages/ai/src/api/bedrock-mantle-sigv4.lazy.ts @@ -0,0 +1,26 @@ +import type { createMantleSigV4Fetch } from "./bedrock-mantle-sigv4.ts"; + +export interface MantleSigV4Module { + createMantleSigV4Fetch: typeof createMantleSigV4Fetch; +} + +let sigV4ModuleOverride: MantleSigV4Module | undefined; + +/** + * Overrides the dynamically imported Mantle SigV4 signer. Used by the Bun + * binary build, where the variable-specifier import cannot be bundled. + */ +export function setBedrockMantleSigV4Module(module: MantleSigV4Module): void { + sigV4ModuleOverride = module; +} + +/** + * Loads the SigV4 signer through a variable specifier so bundlers (browser + * smoke, Bun compile) cannot follow it into Node-only AWS credential code. + */ +export async function loadMantleSigV4Module(): Promise { + if (sigV4ModuleOverride) return sigV4ModuleOverride; + const specifier = "./bedrock-mantle-sigv4.ts"; + const runtimeSpecifier = import.meta.url.endsWith(".js") ? specifier.replace(/\.ts$/, ".js") : specifier; + return (await import(runtimeSpecifier)) as MantleSigV4Module; +} diff --git a/packages/ai/src/api/bedrock-mantle-sigv4.ts b/packages/ai/src/api/bedrock-mantle-sigv4.ts new file mode 100644 index 000000000..2ed629699 --- /dev/null +++ b/packages/ai/src/api/bedrock-mantle-sigv4.ts @@ -0,0 +1,94 @@ +// SigV4 signing for Bedrock Mantle requests. Node-only: loaded through a +// variable-specifier import from bedrock-mantle.ts so browser bundles skip it. + +import { createHash, createHmac } from "node:crypto"; +import { defaultProvider } from "@aws-sdk/credential-provider-node"; +import { SignatureV4 } from "@smithy/signature-v4"; +import type { FetchFunction, ProviderEnv } from "../types.ts"; +import { getProviderEnvValue } from "../utils/provider-env.ts"; + +// Mantle rejects SigV4 credentials scoped to any other service. +const SIGV4_SERVICE = "bedrock-mantle"; + +function staticCredentials(env: ProviderEnv | undefined) { + const accessKeyId = getProviderEnvValue("AWS_ACCESS_KEY_ID", env); + const secretAccessKey = getProviderEnvValue("AWS_SECRET_ACCESS_KEY", env); + if (!accessKeyId || !secretAccessKey) return undefined; + const sessionToken = getProviderEnvValue("AWS_SESSION_TOKEN", env); + return { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }; +} + +/** Node hash for SigV4; avoids pulling in @aws-crypto. */ +class Sha256 { + private readonly hash; + constructor(secret?: string | ArrayBuffer | ArrayBufferView) { + this.hash = secret + ? createHmac("sha256", typeof secret === "string" ? secret : new Uint8Array(secret as ArrayBuffer)) + : createHash("sha256"); + } + update(data: string | ArrayBufferView): void { + this.hash.update(data as string); + } + async digest(): Promise { + return new Uint8Array(this.hash.digest()); + } +} + +/** + * A fetch that signs each request with SigV4 for the `bedrock-mantle` service. + * Credentials resolve like Converse: a profile from pi's auth flow wins over + * ambient access keys, which win over the rest of the AWS default chain. + */ +export function createMantleSigV4Fetch(options: { + region: string; + env?: ProviderEnv; + profile?: string; + fetch?: FetchFunction; +}): FetchFunction { + const baseFetch = options.fetch ?? globalThis.fetch; + let signer: SignatureV4 | undefined; + const getSigner = () => { + signer ??= (() => { + const profile = options.profile || options.env?.AWS_PROFILE; + const credentials = + (!profile && staticCredentials(options.env)) || + defaultProvider({ + profile: profile || getProviderEnvValue("AWS_PROFILE", options.env), + }); + return new SignatureV4({ credentials, region: options.region, service: SIGV4_SERVICE, sha256: Sha256 }); + })(); + return signer; + }; + + return async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + const body = request.body ? await request.text() : undefined; + const headers: Record = { host: url.host }; + request.headers.forEach((value, name) => { + if (name !== "authorization" && name !== "x-api-key") headers[name] = value; + }); + const query: Record = {}; + for (const [name, value] of url.searchParams) { + const current = query[name]; + query[name] = current === undefined ? value : [...(Array.isArray(current) ? current : [current]), value]; + } + const signed = await getSigner().sign({ + method: request.method, + protocol: url.protocol, + hostname: url.hostname, + path: url.pathname, + query, + headers, + body, + }); + const signedHeaders = new Headers(signed.headers as Record); + signedHeaders.delete("host"); + return baseFetch(url, { + method: request.method, + headers: signedHeaders, + body, + signal: init?.signal ?? request.signal, + }); + }; +} diff --git a/packages/ai/src/api/bedrock-mantle.ts b/packages/ai/src/api/bedrock-mantle.ts index 3b0551d8b..5bc48efba 100644 --- a/packages/ai/src/api/bedrock-mantle.ts +++ b/packages/ai/src/api/bedrock-mantle.ts @@ -1,12 +1,15 @@ // Bedrock Mantle: Amazon Bedrock's OpenAI-compatible endpoint, which serves // models Converse does not (GPT, Grok, Gemma). This module reuses the OpenAI -// Responses implementation with the region filled into the catalog URL and a -// Bedrock API key as the bearer token. +// Responses implementation with the region filled into the catalog URL. It +// authenticates with a Bedrock API key when one is set, and otherwise signs +// each request with SigV4 using the AWS credential chain (profiles, IAM keys, +// SSO, ECS task roles, web identity), like Converse. // https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-mantle.html import type { Model, ProviderEnv, SimpleStreamOptions, StreamFunction, TranscriptContext } from "../types.ts"; import type { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; +import { loadMantleSigV4Module } from "./bedrock-mantle-sigv4.lazy.ts"; import { lazyStream } from "./lazy.ts"; import { type OpenAIResponsesOptions, @@ -20,23 +23,33 @@ export function resolveMantleRegion(env: ProviderEnv | undefined): string { } /** The Bedrock API key for Mantle: a stored credential or `AWS_BEARER_TOKEN_BEDROCK`. */ -export function resolveMantleBearerToken(apiKey: string | undefined, env: ProviderEnv | undefined): string { - const token = apiKey || getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", env); - if (!token) { - throw new Error( - "Bedrock Mantle models need an Amazon Bedrock API key. Set AWS_BEARER_TOKEN_BEDROCK or log in with a bearer token; AWS profiles and IAM credentials are not supported for these models yet.", - ); - } - return token; +export function resolveMantleBearerToken(apiKey: string | undefined, env: ProviderEnv | undefined): string | undefined { + return apiKey || getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", env) || undefined; } -function prepare( +// The OpenAI SDK requires an API key. SigV4 requests replace its Authorization header. +const SIGV4_PLACEHOLDER_API_KEY = "bedrock-mantle-sigv4"; + +async function prepare( model: Model<"openai-responses">, options: TOptions | undefined, -): { model: Model<"openai-responses">; options: TOptions } { +): Promise<{ model: Model<"openai-responses">; options: TOptions }> { + const region = resolveMantleRegion(options?.env); + const resolvedModel = { ...model, baseUrl: model.baseUrl.replace("{region}", region) }; + const bearerToken = resolveMantleBearerToken(options?.apiKey, options?.env); + if (bearerToken) return { model: resolvedModel, options: { ...options, apiKey: bearerToken } as TOptions }; return { - model: { ...model, baseUrl: model.baseUrl.replace("{region}", resolveMantleRegion(options?.env)) }, - options: { ...options, apiKey: resolveMantleBearerToken(options?.apiKey, options?.env) } as TOptions, + model: resolvedModel, + options: { + ...options, + apiKey: SIGV4_PLACEHOLDER_API_KEY, + fetch: (await loadMantleSigV4Module()).createMantleSigV4Fetch({ + region, + env: options?.env, + profile: (options as { profile?: string } | undefined)?.profile, + fetch: options?.fetch, + }), + } as TOptions, }; } @@ -46,7 +59,7 @@ export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions> options?: OpenAIResponsesOptions, ): AssistantMessageEventStream => lazyStream(model, async () => { - const prepared = prepare(model, options); + const prepared = await prepare(model, options); return responsesStream(prepared.model, context, prepared.options); }); @@ -56,6 +69,6 @@ export const streamSimple: StreamFunction<"openai-responses", SimpleStreamOption options?: SimpleStreamOptions, ): AssistantMessageEventStream => lazyStream(model, async () => { - const prepared = prepare(model, options); + const prepared = await prepare(model, options); return responsesStreamSimple(prepared.model, context, prepared.options); }); diff --git a/packages/ai/src/bedrock-provider.ts b/packages/ai/src/bedrock-provider.ts index 10430092f..4c8ef642a 100644 --- a/packages/ai/src/bedrock-provider.ts +++ b/packages/ai/src/bedrock-provider.ts @@ -1,6 +1,11 @@ import { stream, streamSimple } from "./api/bedrock-converse-stream.ts"; +import { createMantleSigV4Fetch } from "./api/bedrock-mantle-sigv4.ts"; export const bedrockProviderModule = { stream, streamSimple, }; + +export const bedrockMantleSigV4Module = { + createMantleSigV4Fetch, +}; diff --git a/packages/ai/src/compat.ts b/packages/ai/src/compat.ts index e41ff083c..3a82de05f 100644 --- a/packages/ai/src/compat.ts +++ b/packages/ai/src/compat.ts @@ -13,6 +13,7 @@ export * from "./api/anthropic-messages.lazy.ts"; export * from "./api/azure-openai-responses.lazy.ts"; export * from "./api/bedrock-converse-stream.lazy.ts"; +export { setBedrockMantleSigV4Module } from "./api/bedrock-mantle-sigv4.lazy.ts"; export * from "./api/google-generative-ai.lazy.ts"; export * from "./api/google-vertex.lazy.ts"; export * from "./api/mistral-conversations.lazy.ts"; diff --git a/packages/ai/test/bedrock-mantle.test.ts b/packages/ai/test/bedrock-mantle.test.ts index f97c18124..121feeb70 100644 --- a/packages/ai/test/bedrock-mantle.test.ts +++ b/packages/ai/test/bedrock-mantle.test.ts @@ -61,9 +61,46 @@ describe("Bedrock Mantle", () => { }); }); - it("explains that Mantle models need a Bedrock API key", async () => { - const result = await streamSimple(mantleModels()[0], context, { env: {} }).result(); + it("signs requests with SigV4 for bedrock-mantle when no Bedrock API key is set", async () => { + const model = mantleModels()[0]; + const requests: { url: string; headers: Headers; body: string }[] = []; + const result = await streamSimple(model, context, { + env: { + AWS_REGION: "eu-west-1", + AWS_ACCESS_KEY_ID: "AKIDEXAMPLE", + AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + AWS_SESSION_TOKEN: "session-token", + }, + fetch: async (input, init) => { + requests.push({ url: String(input), headers: new Headers(init?.headers), body: String(init?.body) }); + return new Response(JSON.stringify({ error: { message: "captured" } }), { status: 400 }); + }, + }).result(); + expect(result.stopReason).toBe("error"); - expect(result.errorMessage).toContain("AWS_BEARER_TOKEN_BEDROCK"); + const request = requests[0]; + expect(request.url).toBe(`${model.baseUrl.replace("{region}", "eu-west-1")}/responses`); + expect(request.headers.get("authorization")).toMatch( + /^AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE\/\d{8}\/eu-west-1\/bedrock-mantle\/aws4_request, SignedHeaders=[^,]*content-type[^,]*host[^,]*, Signature=[0-9a-f]{64}$/, + ); + expect(request.headers.get("x-amz-security-token")).toBe("session-token"); + expect(request.headers.get("x-amz-content-sha256") ?? request.headers.get("x-amz-date")).toBeTruthy(); + expect(JSON.parse(request.body)).toMatchObject({ model: model.id }); + }); + + it("prefers a Bedrock API key over AWS credentials", async () => { + let authorization: string | null = null; + await streamSimple(mantleModels()[0], context, { + env: { + AWS_BEARER_TOKEN_BEDROCK: "env-key", + AWS_ACCESS_KEY_ID: "AKIDEXAMPLE", + AWS_SECRET_ACCESS_KEY: "secret", + }, + fetch: async (_input, init) => { + authorization = new Headers(init?.headers).get("authorization"); + return new Response("{}", { status: 400 }); + }, + }).result(); + expect(authorization).toBe("Bearer env-key"); }); }); diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 3261a1f74..2fcf646db 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -213,7 +213,7 @@ export AWS_REGION=us-west-2 Pi also supports ECS task credentials and IRSA through the standard `AWS_CONTAINER_CREDENTIALS_*` and `AWS_WEB_IDENTITY_TOKEN_FILE` variables. -Some models, such as OpenAI's GPT models, Grok, and Gemma on Bedrock, are only served by Bedrock Mantle, Bedrock's OpenAI-compatible endpoint. Pi sends them to Mantle's Responses API in your region. Mantle models currently need a Bedrock API key (`AWS_BEARER_TOKEN_BEDROCK` or a bearer token from `/login amazon-bedrock`); profiles and IAM credentials only work for the other Bedrock models. +Some models, such as OpenAI's GPT models, Grok, and Gemma on Bedrock, are only served by Bedrock Mantle, Bedrock's OpenAI-compatible endpoint. Pi sends them to Mantle's Responses API in your region. Mantle models use a Bedrock API key (`AWS_BEARER_TOKEN_BEDROCK` or a bearer token from `/login amazon-bedrock`) when one is set. Otherwise pi signs requests with SigV4 using the same AWS credentials as other Bedrock models (profiles, IAM keys, SSO, ECS task roles, web identity); the IAM principal needs `bedrock-mantle:CreateInference`. ### Cloudflare AI Gateway diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index bc6c6bb41..c5fd2b536 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -404,9 +404,11 @@ "dependencies": { "@anthropic-ai/sdk": "0.129.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", + "@aws-sdk/credential-provider-node": "3.972.82", "@earendil-works/pi-telemetry": "1.4.0", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", + "@smithy/signature-v4": "5.7.3", "google-auth-library": "10.6.2", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", diff --git a/packages/coding-agent/src/bun/runtime-setup.ts b/packages/coding-agent/src/bun/runtime-setup.ts index 20b66365f..c1b65778b 100644 --- a/packages/coding-agent/src/bun/runtime-setup.ts +++ b/packages/coding-agent/src/bun/runtime-setup.ts @@ -1,6 +1,6 @@ -import { bedrockProviderModule } from "@earendil-works/pi-ai/bedrock-provider"; +import { bedrockMantleSigV4Module, bedrockProviderModule } from "@earendil-works/pi-ai/bedrock-provider"; import { registerBunOAuthFlows } from "@earendil-works/pi-ai/bun-oauth"; -import { setBedrockProviderModule } from "@earendil-works/pi-ai/compat"; +import { setBedrockMantleSigV4Module, setBedrockProviderModule } from "@earendil-works/pi-ai/compat"; // Bun loads .wasm imports as files: embedded in compiled executables, evaluating to a readable path. import quickjsWasmPath from "quickjs-wasi/quickjs.wasm"; import { APP_NAME, setEmbeddedQuickJSWasmPath } from "../config.ts"; @@ -9,4 +9,5 @@ process.title = APP_NAME; process.emitWarning = (() => {}) as typeof process.emitWarning; registerBunOAuthFlows(); setBedrockProviderModule(bedrockProviderModule); +setBedrockMantleSigV4Module(bedrockMantleSigV4Module); setEmbeddedQuickJSWasmPath(quickjsWasmPath);