feat(ai): sign Bedrock Mantle requests with SigV4 from the AWS credential chain

This commit is contained in:
Cristina Poncela Cubeiro 2026-10-28 15:57:07 +01:00
parent bbde7ed20b
commit 0d7b1096b4
12 changed files with 206 additions and 23 deletions

2
package-lock.json generated
View file

@ -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",

View file

@ -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

View file

@ -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",

View file

@ -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<MantleSigV4Module> {
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;
}

View file

@ -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<Uint8Array> {
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<string, string> = { host: url.host };
request.headers.forEach((value, name) => {
if (name !== "authorization" && name !== "x-api-key") headers[name] = value;
});
const query: Record<string, string | string[]> = {};
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<string, string>);
signedHeaders.delete("host");
return baseFetch(url, {
method: request.method,
headers: signedHeaders,
body,
signal: init?.signal ?? request.signal,
});
};
}

View file

@ -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<TOptions extends OpenAIResponsesOptions | SimpleStreamOptions>(
// The OpenAI SDK requires an API key. SigV4 requests replace its Authorization header.
const SIGV4_PLACEHOLDER_API_KEY = "bedrock-mantle-sigv4";
async function prepare<TOptions extends OpenAIResponsesOptions | SimpleStreamOptions>(
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);
});

View file

@ -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,
};

View file

@ -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";

View file

@ -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");
});
});

View file

@ -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

View file

@ -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",

View file

@ -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);