mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(ai): sign Bedrock Mantle requests with SigV4 from the AWS credential chain
This commit is contained in:
parent
bbde7ed20b
commit
0d7b1096b4
12 changed files with 206 additions and 23 deletions
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
26
packages/ai/src/api/bedrock-mantle-sigv4.lazy.ts
Normal file
26
packages/ai/src/api/bedrock-mantle-sigv4.lazy.ts
Normal 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;
|
||||
}
|
||||
94
packages/ai/src/api/bedrock-mantle-sigv4.ts
Normal file
94
packages/ai/src/api/bedrock-mantle-sigv4.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue