mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
23477ed6ac
commit
024abf6dda
8 changed files with 166 additions and 11 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 } }),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
4
packages/ai/src/api/bedrock-mantle.lazy.ts
Normal file
4
packages/ai/src/api/bedrock-mantle.lazy.ts
Normal 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"));
|
||||||
61
packages/ai/src/api/bedrock-mantle.ts
Normal file
61
packages/ai/src/api/bedrock-mantle.ts
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -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(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
59
packages/ai/test/bedrock-mantle.test.ts
Normal file
59
packages/ai/test/bedrock-mantle.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue