fix(ai): route Bedrock models Converse does not serve through Bedrock Mantle

Bedrock serves OpenAI GPT, Grok, and Gemma 4 models only on Mantle, its OpenAI-compatible endpoint; Converse rejects them as invalid model identifiers. models.dev v2 lists each model's APIs, so the catalog now maps models without a Converse API to the Responses API at the Mantle URL, and amazon-bedrock dispatches them to a thin wrapper around the OpenAI Responses implementation that fills in the region and authenticates with a Bedrock API key. IAM and profile credentials are not supported for Mantle models yet.
This commit is contained in:
Armin Ronacher 2026-10-24 17:44:58 +02:00
parent 23477ed6ac
commit 024abf6dda
8 changed files with 166 additions and 11 deletions

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. 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` 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.
## Tools

View file

@ -1,4 +1,6 @@
import { defineProvider, modelsDevModels, withInputLimits } from "../define.ts";
import type { Model } from "../../src/types.ts";
import { type ChatModel, defineProvider, modelsDevModels, withInputLimits } from "../define.ts";
import type { ModelsDevModel } from "../sources/models-dev.ts";
// Opus 5 is only callable through cross-region inference profiles.
const INFERENCE_PROFILE_ONLY_MODEL_IDS = new Set(["anthropic.claude-opus-5"]);
@ -9,6 +11,24 @@ function bedrockBaseUrl(modelId: string): string {
: "https://bedrock-runtime.us-east-1.amazonaws.com";
}
/**
* Models Bedrock serves only on Mantle, its OpenAI-compatible endpoint, are
* rejected by Converse as invalid model identifiers. They use the Responses API
* at the Mantle URL models.dev lists, with `{region}` filled in per request.
*/
function mantleModel(model: ChatModel, source: ModelsDevModel): Model<"openai-responses"> | undefined {
const responses = source.apis.find((api) => api.protocol === "responses");
if (!responses) return undefined;
return {
...model,
api: "openai-responses",
// models.dev writes the region as a `${AWS_REGION}` template.
baseUrl: responses.baseUrl.replace(/\$\{AWS_REGION\}/, "{region}"),
// Mantle does not document OpenAI's extended prompt cache retention.
compat: { supportsLongCacheRetention: false },
};
}
export default defineProvider({
id: "amazon-bedrock",
models: (ctx) =>
@ -22,11 +42,14 @@ export default defineProvider({
// Mistral 7B Instruct does not support system messages.
!source.id.startsWith("mistral.mistral-7b-instruct-v0"),
// Includes models.dev pricing tiers, e.g. the long-context tier for OpenAI models (#10326).
map: (model, source) => ({
...model,
baseUrl: bedrockBaseUrl(model.id),
...(source.structuredOutput ? { compat: { supportsStrictMode: true } } : {}),
}),
map: (model, source) => {
if (!source.apis.some((api) => api.protocol === "converse")) return mantleModel(model, source);
return {
...model,
baseUrl: bedrockBaseUrl(model.id),
...(source.structuredOutput ? { compat: { supportsStrictMode: true } } : {}),
};
},
}),
finalize: (model) => withInputLimits(model, { images: { maxPerMessage: 20 } }),
});

View file

@ -0,0 +1,4 @@
import type { ProviderStreams } from "../types.ts";
import { lazyApi } from "./lazy.ts";
export const bedrockMantleApi = (): ProviderStreams => lazyApi(() => import("./bedrock-mantle.ts"));

View file

@ -0,0 +1,61 @@
// 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.
// 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 { lazyStream } from "./lazy.ts";
import {
type OpenAIResponsesOptions,
stream as responsesStream,
streamSimple as responsesStreamSimple,
} from "./openai-responses.ts";
/** The region Mantle requests go to, resolved like Bedrock Converse requests. */
export function resolveMantleRegion(env: ProviderEnv | undefined): string {
return getProviderEnvValue("AWS_REGION", env) || getProviderEnvValue("AWS_DEFAULT_REGION", env) || "us-east-1";
}
/** 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;
}
function prepare<TOptions extends OpenAIResponsesOptions | SimpleStreamOptions>(
model: Model<"openai-responses">,
options: TOptions | undefined,
): { model: Model<"openai-responses">; options: TOptions } {
return {
model: { ...model, baseUrl: model.baseUrl.replace("{region}", resolveMantleRegion(options?.env)) },
options: { ...options, apiKey: resolveMantleBearerToken(options?.apiKey, options?.env) } as TOptions,
};
}
export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions> = (
model: Model<"openai-responses">,
context: TranscriptContext,
options?: OpenAIResponsesOptions,
): AssistantMessageEventStream =>
lazyStream(model, async () => {
const prepared = prepare(model, options);
return responsesStream(prepared.model, context, prepared.options);
});
export const streamSimple: StreamFunction<"openai-responses", SimpleStreamOptions> = (
model: Model<"openai-responses">,
context: TranscriptContext,
options?: SimpleStreamOptions,
): AssistantMessageEventStream =>
lazyStream(model, async () => {
const prepared = prepare(model, options);
return responsesStreamSimple(prepared.model, context, prepared.options);
});

View file

@ -1,4 +1,5 @@
import { bedrockConverseStreamApi } from "../api/bedrock-converse-stream.lazy.ts";
import { bedrockMantleApi } from "../api/bedrock-mantle.lazy.ts";
import type { ApiKeyAuth } from "../auth/types.ts";
import { createProvider, type Provider } from "../models.ts";
import { AMAZON_BEDROCK_MODELS } from "./amazon-bedrock.models.ts";
@ -79,12 +80,16 @@ const bedrockAuth: ApiKeyAuth = {
},
};
export function amazonBedrockProvider(): Provider<"bedrock-converse-stream"> {
return createProvider({
/** Converse for most models; Mantle's OpenAI Responses API for models Converse does not serve. */
export function amazonBedrockProvider(): Provider<"bedrock-converse-stream" | "openai-responses"> {
return createProvider<"bedrock-converse-stream" | "openai-responses">({
id: "amazon-bedrock",
name: "Amazon Bedrock",
auth: { apiKey: bedrockAuth },
models: Object.values(AMAZON_BEDROCK_MODELS),
api: bedrockConverseStreamApi(),
api: {
"bedrock-converse-stream": bedrockConverseStreamApi(),
"openai-responses": bedrockMantleApi(),
},
});
}

View file

@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { streamSimple } from "../src/api/bedrock-mantle.ts";
import { hasApi } from "../src/models.ts";
import { getBuiltinModels } from "../src/providers/all.ts";
import type { Model } from "../src/types.ts";
import { normalizeContext } from "../src/utils/transcript.ts";
function mantleModels(): Model<"openai-responses">[] {
return getBuiltinModels("amazon-bedrock").filter((model) => hasApi(model, "openai-responses"));
}
const context = normalizeContext({ messages: [{ role: "user", content: "hi", timestamp: 0 }] });
describe("Bedrock Mantle", () => {
let savedToken: string | undefined;
beforeEach(() => {
savedToken = process.env.AWS_BEARER_TOKEN_BEDROCK;
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
});
afterEach(() => {
if (savedToken === undefined) delete process.env.AWS_BEARER_TOKEN_BEDROCK;
else process.env.AWS_BEARER_TOKEN_BEDROCK = savedToken;
});
it("routes models Converse does not serve to the Mantle Responses endpoint", () => {
const models = mantleModels();
expect(models.length).toBeGreaterThan(0);
for (const model of models) {
expect(model.baseUrl).toMatch(/^https:\/\/bedrock-mantle\.\{region\}\.api\.aws\//);
}
const converse = getBuiltinModels("amazon-bedrock").filter((model) => hasApi(model, "bedrock-converse-stream"));
expect(converse.some((model) => model.id.includes("anthropic.claude"))).toBe(true);
});
it("sends Responses requests to the regional Mantle host with the Bedrock API key", async () => {
const model = mantleModels()[0];
const requests: { url: string; authorization: string | null }[] = [];
const result = await streamSimple(model, context, {
apiKey: "bedrock-api-key",
env: { AWS_REGION: "us-west-2" },
fetch: async (input, init) => {
requests.push({ url: String(input), authorization: new Headers(init?.headers).get("authorization") });
return new Response(JSON.stringify({ error: { message: "captured" } }), { status: 400 });
},
}).result();
expect(result.stopReason).toBe("error");
expect(requests[0]).toEqual({
url: `${model.baseUrl.replace("{region}", "us-west-2")}/responses`,
authorization: "Bearer bedrock-api-key",
});
});
it("explains that Mantle models need a Bedrock API key", async () => {
const result = await streamSimple(mantleModels()[0], context, { env: {} }).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("AWS_BEARER_TOKEN_BEDROCK");
});
});

View file

@ -213,6 +213,8 @@ 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.
### Cloudflare AI Gateway
The gateway requires a token, account ID, and gateway ID:

View file

@ -7,5 +7,6 @@
// 1.2.1: kimi-coding models use openai-completions at /coding/v1; older clients only
// implement anthropic-messages for kimi-coding and ignore a model's api.
// 1.4.1: google-vertex serves Claude through anthropic-messages; older clients only
// implement Google's Vertex API for google-vertex.
// implement Google's Vertex API for google-vertex. amazon-bedrock serves Mantle-only
// models through openai-responses; older clients only implement Converse for it.
export const MINIMUM_PI_VERSION = "1.4.1";