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. 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. 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 ## 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. // Opus 5 is only callable through cross-region inference profiles.
const INFERENCE_PROFILE_ONLY_MODEL_IDS = new Set(["anthropic.claude-opus-5"]); 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"; : "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({ export default defineProvider({
id: "amazon-bedrock", id: "amazon-bedrock",
models: (ctx) => models: (ctx) =>
@ -22,11 +42,14 @@ export default defineProvider({
// Mistral 7B Instruct does not support system messages. // Mistral 7B Instruct does not support system messages.
!source.id.startsWith("mistral.mistral-7b-instruct-v0"), !source.id.startsWith("mistral.mistral-7b-instruct-v0"),
// Includes models.dev pricing tiers, e.g. the long-context tier for OpenAI models (#10326). // Includes models.dev pricing tiers, e.g. the long-context tier for OpenAI models (#10326).
map: (model, source) => ({ map: (model, source) => {
...model, if (!source.apis.some((api) => api.protocol === "converse")) return mantleModel(model, source);
baseUrl: bedrockBaseUrl(model.id), return {
...(source.structuredOutput ? { compat: { supportsStrictMode: true } } : {}), ...model,
}), baseUrl: bedrockBaseUrl(model.id),
...(source.structuredOutput ? { compat: { supportsStrictMode: true } } : {}),
};
},
}), }),
finalize: (model) => withInputLimits(model, { images: { maxPerMessage: 20 } }), 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 { bedrockConverseStreamApi } from "../api/bedrock-converse-stream.lazy.ts";
import { bedrockMantleApi } from "../api/bedrock-mantle.lazy.ts";
import type { ApiKeyAuth } from "../auth/types.ts"; import type { ApiKeyAuth } from "../auth/types.ts";
import { createProvider, type Provider } from "../models.ts"; import { createProvider, type Provider } from "../models.ts";
import { AMAZON_BEDROCK_MODELS } from "./amazon-bedrock.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"> { /** Converse for most models; Mantle's OpenAI Responses API for models Converse does not serve. */
return createProvider({ export function amazonBedrockProvider(): Provider<"bedrock-converse-stream" | "openai-responses"> {
return createProvider<"bedrock-converse-stream" | "openai-responses">({
id: "amazon-bedrock", id: "amazon-bedrock",
name: "Amazon Bedrock", name: "Amazon Bedrock",
auth: { apiKey: bedrockAuth }, auth: { apiKey: bedrockAuth },
models: Object.values(AMAZON_BEDROCK_MODELS), 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. 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 ### Cloudflare AI Gateway
The gateway requires a token, account ID, and gateway ID: 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 // 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. // 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 // 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"; export const MINIMUM_PI_VERSION = "1.4.1";