fix(ai): authenticate Bedrock Mantle responses

This commit is contained in:
Anatoli Tsinovoy
2026-07-30 15:21:52 +03:00
parent d44d297c15
commit ef6d4fb119
17 changed files with 607 additions and 240 deletions
+1
View File
@@ -22,6 +22,7 @@
### Fixed
- Added Bedrock Mantle region selection and bearer-token or SigV4 authentication for OpenAI Responses models.
- Fixed Novita login rejecting valid API keys belonging to Developer and Basic team members by validating against the chat completions endpoint instead of the billing balance endpoint.
- Fixed Cursor resource_exhausted errors being incorrectly classified as QUOTA_EXHAUSTED (which caused 30-minute credential blocks), mapping them to MODEL_CAPACITY_EXHAUSTED with a shorter backoff instead.
- Fixed a crash in Amazon Bedrock and Devin providers when Context.systemPrompt is passed as a bare string.
@@ -0,0 +1,84 @@
import { $env } from "@oh-my-pi/pi-utils";
import { AUTHENTICATED_SENTINEL } from "../registry/types";
import type { FetchImpl, Model } from "../types";
import { resolveAwsCredentials } from "./aws-credentials";
import { signRequest } from "./aws-sigv4";
import type { OpenAIResponsesOptions } from "./openai-responses";
export interface BedrockMantleOptions extends OpenAIResponsesOptions {
region?: string;
profile?: string;
/** Amazon Bedrock API key sent as a bearer token, ahead of SigV4 credential resolution. */
bearerToken?: string;
}
async function requestBody(input: string | URL | Request, init?: RequestInit): Promise<Uint8Array> {
if (init?.body !== undefined && init.body !== null) {
if (typeof init.body === "string") return new TextEncoder().encode(init.body);
if (init.body instanceof Uint8Array) return init.body;
if (init.body instanceof ArrayBuffer) return new Uint8Array(init.body);
throw new TypeError(`Cannot SigV4-sign ${init.body.constructor?.name ?? typeof init.body} request body`);
}
if (input instanceof Request) return new Uint8Array(await input.clone().arrayBuffer());
return new Uint8Array();
}
function createSignedFetch(options: BedrockMantleOptions, region: string): FetchImpl {
const baseFetch = options.fetch ?? (globalThis.fetch as FetchImpl);
const signedFetch = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const url = new URL(input instanceof Request ? input.url : input.toString());
const method = init?.method ?? (input instanceof Request ? input.method : "POST");
const headers = new Headers(input instanceof Request ? input.headers : undefined);
for (const [name, value] of new Headers(init?.headers)) headers.set(name, value);
headers.delete("authorization");
const body = await requestBody(input, init);
const credentials = await resolveAwsCredentials({
profile: options.profile,
region,
signal: options.signal,
fetch: baseFetch,
});
const signed = await signRequest({
method,
host: url.host,
path: url.pathname,
query: url.search.slice(1),
body,
region,
service: "bedrock-mantle",
credentials,
headers: { "content-type": headers.get("content-type") ?? "application/json" },
});
for (const [name, value] of Object.entries(signed)) {
if (value !== undefined && name !== "host") headers.set(name, value);
}
return baseFetch(url, { ...init, method, headers, body });
};
return Object.assign(signedFetch, baseFetch.preconnect ? { preconnect: baseFetch.preconnect } : {});
}
export interface PreparedBedrockMantleRequest {
model: Model<"openai-responses">;
options: OpenAIResponsesOptions;
}
export function prepareBedrockMantleRequest(
model: Model<"openai-responses">,
options: BedrockMantleOptions,
): PreparedBedrockMantleRequest {
const region = options.region || $env.AWS_REGION || $env.AWS_DEFAULT_REGION || "us-east-1";
const resolvedModel = { ...model, baseUrl: model.baseUrl.replaceAll("{region}", encodeURIComponent(region)) };
const apiKey = options.apiKey === AUTHENTICATED_SENTINEL || options.apiKey === "N/A" ? undefined : options.apiKey;
const bearerToken = options.bearerToken || apiKey || $env.AWS_BEARER_TOKEN_BEDROCK;
if (bearerToken) {
return { model: resolvedModel, options: { ...options, apiKey: bearerToken } };
}
return {
model: resolvedModel,
options: {
...options,
apiKey: "N/A",
fetch: createSignedFetch(options, region),
},
};
}
+3 -16
View File
@@ -1,22 +1,9 @@
import { $env } from "@oh-my-pi/pi-utils";
import type { ProviderDefinition } from "./types";
import { hasAwsCredentialSource } from "./aws";
import { AUTHENTICATED_SENTINEL, type ProviderDefinition } from "./types";
export const amazonBedrockProvider = {
id: "amazon-bedrock",
name: "Amazon Bedrock",
// Amazon Bedrock accepts bearer tokens, IAM keys, profiles, ECS/IRSA credential chains.
envKeys: () => {
const hasEcsCredentials =
!!$env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || !!$env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
const hasWebIdentity = !!$env.AWS_WEB_IDENTITY_TOKEN_FILE && !!$env.AWS_ROLE_ARN;
if (
$env.AWS_PROFILE ||
($env.AWS_ACCESS_KEY_ID && $env.AWS_SECRET_ACCESS_KEY) ||
$env.AWS_BEARER_TOKEN_BEDROCK ||
hasEcsCredentials ||
hasWebIdentity
) {
return "<authenticated>";
}
},
envKeys: () => (hasAwsCredentialSource() ? AUTHENTICATED_SENTINEL : undefined),
} as const satisfies ProviderDefinition;
+13
View File
@@ -0,0 +1,13 @@
import { $env } from "@oh-my-pi/pi-utils";
export function hasAwsCredentialSource(): boolean {
const hasEcsCredentials = !!$env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || !!$env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
const hasWebIdentity = !!$env.AWS_WEB_IDENTITY_TOKEN_FILE && !!$env.AWS_ROLE_ARN;
return !!(
$env.AWS_PROFILE ||
($env.AWS_ACCESS_KEY_ID && $env.AWS_SECRET_ACCESS_KEY) ||
$env.AWS_BEARER_TOKEN_BEDROCK ||
hasEcsCredentials ||
hasWebIdentity
);
}
@@ -0,0 +1,8 @@
import { hasAwsCredentialSource } from "./aws";
import { AUTHENTICATED_SENTINEL, type ProviderDefinition } from "./types";
export const bedrockMantleProvider = {
id: "bedrock-mantle",
name: "Amazon Bedrock Mantle",
envKeys: () => (hasAwsCredentialSource() ? AUTHENTICATED_SENTINEL : undefined),
} as const satisfies ProviderDefinition;
+2
View File
@@ -6,6 +6,7 @@ import { amazonBedrockProvider } from "./amazon-bedrock";
import { anthropicProvider } from "./anthropic";
import { azureProvider } from "./azure";
import { basetenProvider } from "./baseten";
import { bedrockMantleProvider } from "./bedrock-mantle";
import { cerebrasProvider } from "./cerebras";
import { cloudflareAiGatewayProvider } from "./cloudflare-ai-gateway";
import { coreWeaveProvider } from "./coreweave";
@@ -154,6 +155,7 @@ const ALL = [
mistralProvider,
minimaxProvider,
amazonBedrockProvider,
bedrockMantleProvider,
];
export type RegistryDef = (typeof ALL)[number];
+3
View File
@@ -18,6 +18,9 @@ import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types";
*/
export type KeyResolver = string | (() => string | undefined);
/** Credentials are resolved by the provider transport rather than used as a bearer string. */
export const AUTHENTICATED_SENTINEL = "<authenticated>";
/**
* Declarative description of a single provider's auth/login wiring. All
* fields are optional except `id`/`name`; presence of a field opts the
+28 -6
View File
@@ -24,6 +24,7 @@ import { isInvalidatedOAuthTokenError } from "./error/auth-classify";
import { isUsageLimitOutcome } from "./error/rate-limit";
import type { BedrockOptions } from "./providers/amazon-bedrock";
import type { AnthropicOptions } from "./providers/anthropic";
import { type BedrockMantleOptions, prepareBedrockMantleRequest } from "./providers/bedrock-mantle";
import type { CursorOptions } from "./providers/cursor";
import type { DevinOptions } from "./providers/devin";
import { isGitLabDuoModel, streamGitLabDuo } from "./providers/gitlab-duo";
@@ -811,6 +812,12 @@ function streamDispatch<TApi extends Api>(
} else if (model.api === "bedrock-converse-stream") {
// Bedrock doesn't have any API keys instead it sources credentials from standard AWS env variables or from given AWS profile.
return streamBedrock(model as Model<"bedrock-converse-stream">, context, requestOptions as BedrockOptions);
} else if (model.provider === "bedrock-mantle" && model.api === "openai-responses") {
const prepared = prepareBedrockMantleRequest(
model as Model<"openai-responses">,
requestOptions as BedrockMantleOptions,
);
return streamOpenAIResponses(prepared.model, context, prepared.options);
}
const apiKey = requestOptions.apiKey || getEnvApiKey(model.provider);
@@ -1020,12 +1027,10 @@ export function streamSimple<TApi extends Api>(
if (apiKeyResolver) {
const outer = new AssistantMessageEventStream();
const signal = requestOptions?.signal;
// One inner attempt against a resolved string key. A retryable auth error
// that arrives before any replay-unsafe event is buffered and returned
// (so the caller can retry with a fresh key) instead of surfaced. Once any
// non-start event escapes, retry is no longer safe and the failure is
// emitted directly.
const runAttempt = async (apiKey: string): Promise<AuthRetryFailure | undefined> => {
// One inner attempt against a resolved key, or against the Bedrock AWS
// credential chain when its optional resolver has no stored bearer key.
// Retryable auth failures are buffered until replay is safe.
const runAttempt = async (apiKey?: string): Promise<AuthRetryFailure | undefined> => {
const bufferedEvents: AssistantMessageEvent[] = [];
let emittedReplayUnsafeEvent = false;
const flushBuffered = (): void => {
@@ -1099,6 +1104,11 @@ export function streamSimple<TApi extends Api>(
return;
}
if (lastKey === undefined) {
if (model.provider === "bedrock-mantle") {
const failure = await runAttempt();
if (failure) emitFailure(failure);
return;
}
outer.fail(new AIError.MissingApiKeyError(model.provider));
return;
}
@@ -1148,6 +1158,13 @@ export function streamSimple<TApi extends Api>(
// Bedrock doesn't have any API keys instead it sources credentials from standard AWS env variables or from given AWS profile.
const providerOptions = mapOptionsForApi(model, requestOptions, undefined);
return stream(model, context, providerOptions);
} else if (model.provider === "bedrock-mantle" && model.api === "openai-responses") {
const providerOptions = mapOptionsForApi(
model,
requestOptions,
typeof requestOptions.apiKey === "string" ? requestOptions.apiKey : undefined,
);
return stream(model, context, providerOptions);
}
// The resolver form is handled by the wrapper above; only a static string
@@ -1660,6 +1677,11 @@ function mapOptionsForApi<TApi extends Api>(
textVerbosity: options?.textVerbosity,
promptCache: options?.promptCache,
statefulResponses: options?.statefulResponses,
...(model.provider === "bedrock-mantle" && {
region: options?.region,
profile: options?.profile,
bearerToken: options?.bearerToken,
}),
});
case "azure-openai-responses":
+6
View File
@@ -639,6 +639,12 @@ export interface SimpleStreamOptions extends Omit<StreamOptions, "apiKey"> {
* provider. Non-Anthropic providers ignore the field.
*/
fallbacks?: FallbackParam[];
/** AWS region override for Amazon Bedrock transports. */
region?: string;
/** AWS profile override for Amazon Bedrock transports. */
profile?: string;
/** Amazon Bedrock API key, preferred over SigV4 credential resolution. */
bearerToken?: string;
}
// Generic StreamFunction with typed options
@@ -0,0 +1,149 @@
import { describe, expect, test } from "bun:test";
import { clearAwsCredentialCache } from "@oh-my-pi/pi-ai/providers/aws-credentials";
import type { BedrockMantleOptions } from "@oh-my-pi/pi-ai/providers/bedrock-mantle";
import { stream, streamSimple } from "@oh-my-pi/pi-ai/stream";
import type { Context, FetchImpl, Model, SimpleStreamOptions } from "@oh-my-pi/pi-ai/types";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { withEnv } from "./helpers";
const mantleModel: Model<"openai-responses"> = buildModel({
id: "openai.gpt-5.6-sol",
name: "GPT-5.6 Sol",
api: "openai-responses",
provider: "bedrock-mantle",
baseUrl: "https://bedrock-mantle.{region}.api.aws/openai/v1",
reasoning: true,
input: ["text", "image"],
cost: { input: 5.5, output: 33, cacheRead: 0.55, cacheWrite: 6.88 },
contextWindow: 272_000,
maxTokens: 128_000,
});
const context: Context = { messages: [{ role: "user", content: "Say hello", timestamp: 0 }] };
const cleanAwsEnv = {
AWS_BEARER_TOKEN_BEDROCK: undefined,
AWS_ACCESS_KEY_ID: undefined,
AWS_SECRET_ACCESS_KEY: undefined,
AWS_SESSION_TOKEN: undefined,
AWS_PROFILE: undefined,
AWS_REGION: undefined,
AWS_DEFAULT_REGION: undefined,
AWS_EC2_METADATA_DISABLED: "true",
};
interface Capture {
url?: string;
authorization?: string | null;
securityToken?: string | null;
}
function captureFetch(capture: Capture): FetchImpl {
return Object.assign(
async (input: string | URL | Request, init?: RequestInit) => {
capture.url = String(input instanceof Request ? input.url : input);
const headers = new Headers(input instanceof Request ? input.headers : init?.headers);
capture.authorization = headers.get("authorization");
capture.securityToken = headers.get("x-amz-security-token");
return new Response("captured", { status: 418 });
},
{ preconnect: fetch.preconnect },
);
}
async function runDirect(
env: Record<string, string | undefined>,
options: BedrockMantleOptions = {},
): Promise<Capture> {
const capture: Capture = {};
await withEnv({ ...cleanAwsEnv, ...env }, async () => {
clearAwsCredentialCache();
await stream(mantleModel, context, { ...options, fetch: captureFetch(capture), maxTokens: 16 }).result();
});
return capture;
}
describe("Bedrock Mantle authentication", () => {
test("uses the configured region and Bedrock bearer token", async () => {
const capture = await runDirect({
AWS_BEARER_TOKEN_BEDROCK: "test-token",
AWS_REGION: "us-east-2",
});
expect(capture.url).toStartWith("https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses");
expect(capture.authorization).toBe("Bearer test-token");
});
test("SigV4-signs with the standard AWS credential chain", async () => {
const capture = await runDirect({
AWS_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE",
AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
AWS_SESSION_TOKEN: "test-session-token",
AWS_REGION: "us-west-2",
});
expect(capture.url).toStartWith("https://bedrock-mantle.us-west-2.api.aws/openai/v1/responses");
expect(capture.authorization).toContain("/us-west-2/bedrock-mantle/aws4_request");
expect(capture.securityToken).toBe("test-session-token");
});
test("streamSimple preserves AWS options and resolver-supplied keys", async () => {
const capture: Capture = {};
let resolverCalls = 0;
const options: SimpleStreamOptions = {
apiKey: async () => {
resolverCalls++;
return "resolved-token";
},
region: "us-east-2",
profile: "ignored-for-bearer",
fetch: captureFetch(capture),
maxTokens: 16,
};
await withEnv(cleanAwsEnv, async () => {
await streamSimple(mantleModel, context, options).result();
});
expect(resolverCalls).toBe(1);
expect(capture.url).toStartWith("https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses");
expect(capture.authorization).toBe("Bearer resolved-token");
});
test("streamSimple falls back to SigV4 when its optional key resolver is empty", async () => {
const capture: Capture = {};
let resolverCalls = 0;
await withEnv(
{
...cleanAwsEnv,
AWS_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE",
AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
AWS_REGION: "us-east-2",
},
async () => {
await streamSimple(mantleModel, context, {
apiKey: async () => {
resolverCalls++;
return undefined;
},
fetch: captureFetch(capture),
maxTokens: 16,
}).result();
},
);
expect(resolverCalls).toBe(1);
expect(capture.authorization).toContain("/us-east-2/bedrock-mantle/aws4_request");
});
test("pi-native transport wins over local Mantle authentication", async () => {
const capture: Capture = {};
const gatewayModel = {
...mantleModel,
baseUrl: "http://gateway.internal",
transport: "pi-native" as const,
};
await expect(
streamSimple(gatewayModel, context, {
apiKey: "gateway-token",
fetch: captureFetch(capture),
maxTokens: 16,
}).result(),
).rejects.toThrow("auth-gateway 418");
expect(capture.url).toBe("http://gateway.internal/v1/pi/stream");
});
});
+5 -1
View File
@@ -2,9 +2,13 @@
## [Unreleased]
### Added
- Added the `bedrock-mantle` provider for OpenAI GPT-5.4, GPT-5.5, and GPT-5.6 models served through Amazon Bedrock's Responses endpoint.
### Fixed
- Routed Amazon Bedrock GPT-5.4, GPT-5.5, and GPT-5.6 models through the supported Bedrock Mantle Responses endpoint instead of the unsupported Converse API.
- Removed unusable Converse entries for OpenAI models that Amazon Bedrock serves only through Mantle.
## [17.2.0] - 2026-07-30
@@ -31,6 +31,7 @@ import { PROVIDER_DESCRIPTORS } from "../src/provider-models/descriptors";
import {
ALIBABA_TOKEN_PLAN_STATIC_MODELS,
ANTHROPIC_CURATED_FALLBACK_MODELS,
BEDROCK_MANTLE_STATIC_MODELS,
buildFireworksFastSeed,
buildXaiOAuthStaticSeed,
clampFireworksKimiMaxTokens,
@@ -53,6 +54,7 @@ import {
applyCanonicalLimitFallback,
applyGeneratedModelPolicies,
CLOUDFLARE_FALLBACK_MODEL,
dropBedrockMantleOpenAIModels,
dropUnsupportedBedrockGeoIds,
linkOpenAIPromotionTargets,
} from "./generated-policies";
@@ -553,6 +555,8 @@ async function generateModels() {
// Seed Meta's documented Muse model so first-run selection does not depend on
// credentials or live discovery.
allModels.push(...META_MUSE_STATIC_MODELS);
// Bedrock Mantle has no catalog endpoint used by generation.
allModels.push(...BEDROCK_MANTLE_STATIC_MODELS);
// Seed Sakana's documented Fugu models so the provider is usable when
// catalog generation has no live API key. If live `/v1/models` succeeds,
// Sakana is authoritative and stale seed IDs must stay out.
@@ -643,6 +647,7 @@ async function generateModels() {
allModels = dropUnusableZaiContextTierIds(allModels);
allModels = dropXiaomiAudioOnlyIds(allModels);
allModels = dropUnsupportedBedrockGeoIds(allModels);
allModels = dropBedrockMantleOpenAIModels(allModels);
allModels = normalizeAntigravityEndpoint(allModels);
// Normalize display names: gateway author prefixes ("OpenAI: …"), alias
// markers ("(latest)"), provider attribution ("(Antigravity)"), and
@@ -65,6 +65,22 @@ export function dropUnsupportedBedrockGeoIds(models: readonly ModelSpec[]): Mode
return models.filter(model => !(model.provider === "amazon-bedrock" && model.id === "jp.anthropic.claude-opus-5"));
}
const BEDROCK_MANTLE_OPENAI_MODEL_IDS: Record<string, true> = {
"openai.gpt-5.4": true,
"openai.gpt-5.5": true,
"openai.gpt-5.6-luna": true,
"openai.gpt-5.6-sol": true,
"openai.gpt-5.6-terra": true,
};
/**
* models.dev exposes these Responses-only models under amazon-bedrock, whose
* descriptor uses Converse. The working Mantle rows come from the static seed.
*/
export function dropBedrockMantleOpenAIModels(models: readonly ModelSpec[]): ModelSpec[] {
return models.filter(model => !(model.provider === "amazon-bedrock" && BEDROCK_MANTLE_OPENAI_MODEL_IDS[model.id]));
}
const CODEX_GPT_5_4_PRIORITY_BY_VARIANT: Partial<Record<OpenAIVariant, number>> = {
base: 0,
mini: 1,
+151 -149
View File
@@ -9946,155 +9946,6 @@
]
}
},
"openai.gpt-5.4": {
"id": "openai.gpt-5.4",
"name": "GPT-5.4",
"api": "openai-responses",
"provider": "amazon-bedrock",
"baseUrl": "https://bedrock-mantle.us-east-1.api.aws/openai/v1",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 2.75,
"output": 16.5,
"cacheRead": 0.275,
"cacheWrite": 0
},
"contextWindow": 272000,
"maxTokens": 128000,
"thinking": {
"mode": "budget",
"efforts": [
"low",
"medium",
"high",
"xhigh"
]
}
},
"openai.gpt-5.5": {
"id": "openai.gpt-5.5",
"name": "GPT-5.5",
"api": "openai-responses",
"provider": "amazon-bedrock",
"baseUrl": "https://bedrock-mantle.us-east-1.api.aws/openai/v1",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 5.5,
"output": 33,
"cacheRead": 0.55,
"cacheWrite": 0
},
"contextWindow": 272000,
"maxTokens": 128000,
"thinking": {
"mode": "budget",
"efforts": [
"low",
"medium",
"high",
"xhigh"
]
},
"contextPromotionTarget": "amazon-bedrock/openai.gpt-5.4"
},
"openai.gpt-5.6-luna": {
"id": "openai.gpt-5.6-luna",
"name": "GPT-5.6 Luna",
"api": "openai-responses",
"provider": "amazon-bedrock",
"baseUrl": "https://bedrock-mantle.us-east-1.api.aws/openai/v1",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 1,
"output": 6,
"cacheRead": 0.1,
"cacheWrite": 1.25
},
"contextWindow": 272000,
"maxTokens": 128000,
"thinking": {
"mode": "budget",
"efforts": [
"low",
"medium",
"high",
"xhigh",
"max"
]
}
},
"openai.gpt-5.6-sol": {
"id": "openai.gpt-5.6-sol",
"name": "GPT-5.6 Sol",
"api": "openai-responses",
"provider": "amazon-bedrock",
"baseUrl": "https://bedrock-mantle.us-east-1.api.aws/openai/v1",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 5,
"output": 30,
"cacheRead": 0.5,
"cacheWrite": 6.25
},
"contextWindow": 272000,
"maxTokens": 128000,
"thinking": {
"mode": "budget",
"efforts": [
"low",
"medium",
"high",
"xhigh",
"max"
]
}
},
"openai.gpt-5.6-terra": {
"id": "openai.gpt-5.6-terra",
"name": "GPT-5.6 Terra",
"api": "openai-responses",
"provider": "amazon-bedrock",
"baseUrl": "https://bedrock-mantle.us-east-1.api.aws/openai/v1",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 2.5,
"output": 15,
"cacheRead": 0.25,
"cacheWrite": 3.125
},
"contextWindow": 272000,
"maxTokens": 128000,
"thinking": {
"mode": "budget",
"efforts": [
"low",
"medium",
"high",
"xhigh",
"max"
]
}
},
"openai.gpt-oss-120b": {
"id": "openai.gpt-oss-120b",
"name": "gpt-oss-120b",
@@ -12001,6 +11852,157 @@
}
}
},
"bedrock-mantle": {
"openai.gpt-5.4": {
"id": "openai.gpt-5.4",
"name": "GPT-5.4",
"api": "openai-responses",
"provider": "bedrock-mantle",
"baseUrl": "https://bedrock-mantle.{region}.api.aws/openai/v1",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 2.75,
"output": 16.5,
"cacheRead": 0.275,
"cacheWrite": 0
},
"contextWindow": 272000,
"maxTokens": 128000,
"thinking": {
"mode": "effort",
"efforts": [
"low",
"medium",
"high",
"xhigh"
]
}
},
"openai.gpt-5.5": {
"id": "openai.gpt-5.5",
"name": "GPT-5.5",
"api": "openai-responses",
"provider": "bedrock-mantle",
"baseUrl": "https://bedrock-mantle.{region}.api.aws/openai/v1",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 5.5,
"output": 33,
"cacheRead": 0.55,
"cacheWrite": 0
},
"contextWindow": 272000,
"maxTokens": 128000,
"thinking": {
"mode": "effort",
"efforts": [
"low",
"medium",
"high",
"xhigh"
]
},
"contextPromotionTarget": "bedrock-mantle/openai.gpt-5.4"
},
"openai.gpt-5.6-luna": {
"id": "openai.gpt-5.6-luna",
"name": "GPT-5.6 Luna",
"api": "openai-responses",
"provider": "bedrock-mantle",
"baseUrl": "https://bedrock-mantle.{region}.api.aws/openai/v1",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 1.1,
"output": 6.6,
"cacheRead": 0.11,
"cacheWrite": 1.38
},
"contextWindow": 272000,
"maxTokens": 128000,
"thinking": {
"mode": "effort",
"efforts": [
"low",
"medium",
"high",
"xhigh",
"max"
]
}
},
"openai.gpt-5.6-sol": {
"id": "openai.gpt-5.6-sol",
"name": "GPT-5.6 Sol",
"api": "openai-responses",
"provider": "bedrock-mantle",
"baseUrl": "https://bedrock-mantle.{region}.api.aws/openai/v1",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 5.5,
"output": 33,
"cacheRead": 0.55,
"cacheWrite": 6.88
},
"contextWindow": 272000,
"maxTokens": 128000,
"thinking": {
"mode": "effort",
"efforts": [
"low",
"medium",
"high",
"xhigh",
"max"
]
}
},
"openai.gpt-5.6-terra": {
"id": "openai.gpt-5.6-terra",
"name": "GPT-5.6 Terra",
"api": "openai-responses",
"provider": "bedrock-mantle",
"baseUrl": "https://bedrock-mantle.{region}.api.aws/openai/v1",
"reasoning": true,
"input": [
"text",
"image"
],
"cost": {
"input": 2.75,
"output": 16.5,
"cacheRead": 0.28,
"cacheWrite": 3.44
},
"contextWindow": 272000,
"maxTokens": 128000,
"thinking": {
"mode": "effort",
"efforts": [
"low",
"medium",
"high",
"xhigh",
"max"
]
}
}
},
"azure": {
"codex-mini": {
"id": "codex-mini",
@@ -99,6 +99,10 @@ export const CATALOG_PROVIDERS = [
id: "amazon-bedrock",
defaultModel: "us.anthropic.claude-opus-4-8",
},
{
id: "bedrock-mantle",
defaultModel: "openai.gpt-5.6-terra",
},
{
id: "anthropic",
defaultModel: "claude-opus-4-8",
@@ -3339,6 +3339,92 @@ export const META_MUSE_STATIC_MODELS: readonly ModelSpec<"openai-responses">[] =
},
];
// ---------------------------------------------------------------------------
// 15.76 Amazon Bedrock Mantle
// ---------------------------------------------------------------------------
const BEDROCK_MANTLE_BASE_URL = "https://bedrock-mantle.{region}.api.aws/openai/v1";
const BEDROCK_MANTLE_GPT_5_X_THINKING: ThinkingConfig = {
mode: "effort",
efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
};
const BEDROCK_MANTLE_GPT_5_6_THINKING: ThinkingConfig = {
mode: "effort",
efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh, Effort.Max],
};
/**
* OpenAI frontier models served exclusively through Bedrock Mantle's Responses
* endpoint. Pricing is per million tokens from the Amazon Bedrock pricing page.
*/
export const BEDROCK_MANTLE_STATIC_MODELS: readonly ModelSpec<"openai-responses">[] = [
{
id: "openai.gpt-5.4",
name: "GPT-5.4",
api: "openai-responses",
provider: "bedrock-mantle",
baseUrl: BEDROCK_MANTLE_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 2.75, output: 16.5, cacheRead: 0.275, cacheWrite: 0 },
contextWindow: 272_000,
maxTokens: 128_000,
thinking: BEDROCK_MANTLE_GPT_5_X_THINKING,
},
{
id: "openai.gpt-5.5",
name: "GPT-5.5",
api: "openai-responses",
provider: "bedrock-mantle",
baseUrl: BEDROCK_MANTLE_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 5.5, output: 33, cacheRead: 0.55, cacheWrite: 0 },
contextWindow: 272_000,
maxTokens: 128_000,
thinking: BEDROCK_MANTLE_GPT_5_X_THINKING,
},
{
id: "openai.gpt-5.6-luna",
name: "GPT-5.6 Luna",
api: "openai-responses",
provider: "bedrock-mantle",
baseUrl: BEDROCK_MANTLE_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 1.1, output: 6.6, cacheRead: 0.11, cacheWrite: 1.38 },
contextWindow: 272_000,
maxTokens: 128_000,
thinking: BEDROCK_MANTLE_GPT_5_6_THINKING,
},
{
id: "openai.gpt-5.6-sol",
name: "GPT-5.6 Sol",
api: "openai-responses",
provider: "bedrock-mantle",
baseUrl: BEDROCK_MANTLE_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 5.5, output: 33, cacheRead: 0.55, cacheWrite: 6.88 },
contextWindow: 272_000,
maxTokens: 128_000,
thinking: BEDROCK_MANTLE_GPT_5_6_THINKING,
},
{
id: "openai.gpt-5.6-terra",
name: "GPT-5.6 Terra",
api: "openai-responses",
provider: "bedrock-mantle",
baseUrl: BEDROCK_MANTLE_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 2.75, output: 16.5, cacheRead: 0.28, cacheWrite: 3.44 },
contextWindow: 272_000,
maxTokens: 128_000,
thinking: BEDROCK_MANTLE_GPT_5_6_THINKING,
},
];
export interface MetaModelManagerConfig {
apiKey?: string;
baseUrl?: string;
@@ -4997,34 +5083,13 @@ function resolveGoogleVertexApi(modelId: string, raw: ModelsDevModel): { api: Ap
return { api: "google-vertex", baseUrl: GOOGLE_VERTEX_BASE_URL };
}
const BEDROCK_RUNTIME_RESOLUTION = {
api: "bedrock-converse-stream",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
} as const;
const BEDROCK_MANTLE_RESPONSES_RESOLUTION = {
api: "openai-responses",
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/openai/v1",
} as const;
const BEDROCK_MANTLE_OPENAI_MODEL_IDS: Record<string, true> = {
"openai.gpt-5.4": true,
"openai.gpt-5.5": true,
"openai.gpt-5.6-luna": true,
"openai.gpt-5.6-sol": true,
"openai.gpt-5.6-terra": true,
};
function resolveAmazonBedrockApi(modelId: string): { api: Api; baseUrl: string } {
return BEDROCK_MANTLE_OPENAI_MODEL_IDS[modelId] ? BEDROCK_MANTLE_RESPONSES_RESOLUTION : BEDROCK_RUNTIME_RESOLUTION;
}
const MODELS_DEV_PROVIDER_DESCRIPTORS_BEDROCK: readonly ModelsDevProviderDescriptor[] = [
// --- Amazon Bedrock ---
{
modelsDevKey: "amazon-bedrock",
providerId: "amazon-bedrock",
api: BEDROCK_RUNTIME_RESOLUTION.api,
baseUrl: BEDROCK_RUNTIME_RESOLUTION.baseUrl,
resolveApi: modelId => resolveAmazonBedrockApi(modelId),
api: "bedrock-converse-stream",
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
filterModel: (id, m) => {
if (m.tool_call !== true) return false;
if (id.startsWith("ai21.jamba")) return false;
@@ -1,57 +1,53 @@
import { describe, expect, test } from "bun:test";
import { MODELS_DEV_PROVIDER_DESCRIPTORS, mapModelsDevToModels } from "@oh-my-pi/pi-catalog/provider-models";
import { DEFAULT_MODEL_PER_PROVIDER } from "@oh-my-pi/pi-catalog/provider-models/descriptors";
import { BEDROCK_MANTLE_STATIC_MODELS } from "@oh-my-pi/pi-catalog/provider-models/openai-compat";
import type { ModelSpec } from "@oh-my-pi/pi-catalog/types";
import { dropBedrockMantleOpenAIModels } from "../scripts/generated-policies";
const BEDROCK_OPENAI_FIXTURE = {
"amazon-bedrock": {
models: Object.fromEntries(
[
"openai.gpt-5.4",
"openai.gpt-5.5",
"openai.gpt-5.6-luna",
"openai.gpt-5.6-sol",
"openai.gpt-5.6-terra",
"openai.gpt-oss-120b-1:0",
].map(id => [
id,
{
name: id,
tool_call: true,
reasoning: true,
limit: { context: 272_000, output: 128_000 },
cost: { input: 5, output: 30, cache_read: 0.5, cache_write: 6.25 },
modalities: { input: ["text", "image"] },
},
]),
),
},
};
const MANTLE_MODEL_IDS = [
"openai.gpt-5.4",
"openai.gpt-5.5",
"openai.gpt-5.6-luna",
"openai.gpt-5.6-sol",
"openai.gpt-5.6-terra",
];
const BEDROCK_MANTLE_BASE_URL = "https://bedrock-mantle.us-east-1.api.aws/openai/v1";
const BEDROCK_RUNTIME_BASE_URL = "https://bedrock-runtime.us-east-1.amazonaws.com";
function bedrockModel(provider: string, id: string): ModelSpec<"bedrock-converse-stream"> {
return {
id,
name: id,
api: "bedrock-converse-stream",
provider,
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 272_000,
maxTokens: 128_000,
};
}
describe("Amazon Bedrock OpenAI routing", () => {
test("routes GPT-5.4+ frontier models through Bedrock Mantle Responses", () => {
const models = mapModelsDevToModels(BEDROCK_OPENAI_FIXTURE, MODELS_DEV_PROVIDER_DESCRIPTORS);
const frontierModels = models.filter(model => model.id.startsWith("openai.gpt-5."));
expect(frontierModels.map(model => model.id)).toEqual([
"openai.gpt-5.4",
"openai.gpt-5.5",
"openai.gpt-5.6-luna",
"openai.gpt-5.6-sol",
"openai.gpt-5.6-terra",
]);
for (const model of frontierModels) {
test("seeds Responses-only models under the Bedrock Mantle provider", () => {
expect(BEDROCK_MANTLE_STATIC_MODELS.map(model => model.id)).toEqual(MANTLE_MODEL_IDS);
for (const model of BEDROCK_MANTLE_STATIC_MODELS) {
expect(model.provider).toBe("bedrock-mantle");
expect(model.api).toBe("openai-responses");
expect(model.baseUrl).toBe(BEDROCK_MANTLE_BASE_URL);
expect(model.baseUrl).toBe("https://bedrock-mantle.{region}.api.aws/openai/v1");
}
expect(DEFAULT_MODEL_PER_PROVIDER["bedrock-mantle"]).toBe("openai.gpt-5.6-terra");
});
test("keeps GPT-OSS on the Bedrock Converse transport", () => {
const models = mapModelsDevToModels(BEDROCK_OPENAI_FIXTURE, MODELS_DEV_PROVIDER_DESCRIPTORS);
const model = models.find(candidate => candidate.id === "openai.gpt-oss-120b-1:0");
test("drops only the unusable Converse rows for Mantle models", () => {
const input = [
...MANTLE_MODEL_IDS.map(id => bedrockModel("amazon-bedrock", id)),
bedrockModel("amazon-bedrock", "openai.gpt-oss-120b"),
bedrockModel("bedrock-mantle", "openai.gpt-5.6-sol"),
];
expect(model?.api).toBe("bedrock-converse-stream");
expect(model?.baseUrl).toBe(BEDROCK_RUNTIME_BASE_URL);
expect(dropBedrockMantleOpenAIModels(input).map(model => `${model.provider}/${model.id}`)).toEqual([
"amazon-bedrock/openai.gpt-oss-120b",
"bedrock-mantle/openai.gpt-5.6-sol",
]);
});
});