diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 4464391c3..40f899002 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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. diff --git a/packages/ai/src/providers/bedrock-mantle.ts b/packages/ai/src/providers/bedrock-mantle.ts new file mode 100644 index 000000000..2f7324108 --- /dev/null +++ b/packages/ai/src/providers/bedrock-mantle.ts @@ -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 { + 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 => { + 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), + }, + }; +} diff --git a/packages/ai/src/registry/amazon-bedrock.ts b/packages/ai/src/registry/amazon-bedrock.ts index 222530724..d2c30aa93 100644 --- a/packages/ai/src/registry/amazon-bedrock.ts +++ b/packages/ai/src/registry/amazon-bedrock.ts @@ -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 ""; - } - }, + envKeys: () => (hasAwsCredentialSource() ? AUTHENTICATED_SENTINEL : undefined), } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/aws.ts b/packages/ai/src/registry/aws.ts new file mode 100644 index 000000000..9ed84e92f --- /dev/null +++ b/packages/ai/src/registry/aws.ts @@ -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 + ); +} diff --git a/packages/ai/src/registry/bedrock-mantle.ts b/packages/ai/src/registry/bedrock-mantle.ts new file mode 100644 index 000000000..3991e26ea --- /dev/null +++ b/packages/ai/src/registry/bedrock-mantle.ts @@ -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; diff --git a/packages/ai/src/registry/registry.ts b/packages/ai/src/registry/registry.ts index 20c74bf2e..6e5bf8096 100644 --- a/packages/ai/src/registry/registry.ts +++ b/packages/ai/src/registry/registry.ts @@ -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]; diff --git a/packages/ai/src/registry/types.ts b/packages/ai/src/registry/types.ts index 9b2838d22..a23bc4836 100644 --- a/packages/ai/src/registry/types.ts +++ b/packages/ai/src/registry/types.ts @@ -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 = ""; + /** * Declarative description of a single provider's auth/login wiring. All * fields are optional except `id`/`name`; presence of a field opts the diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 1cd31d2a9..235d378c4 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -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( } 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( 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 => { + // 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 => { const bufferedEvents: AssistantMessageEvent[] = []; let emittedReplayUnsafeEvent = false; const flushBuffered = (): void => { @@ -1099,6 +1104,11 @@ export function streamSimple( 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( // 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( 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": diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index fc822e798..ebdefb751 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -639,6 +639,12 @@ export interface SimpleStreamOptions extends Omit { * 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 diff --git a/packages/ai/test/bedrock-mantle-auth.test.ts b/packages/ai/test/bedrock-mantle-auth.test.ts new file mode 100644 index 000000000..48cc61011 --- /dev/null +++ b/packages/ai/test/bedrock-mantle-auth.test.ts @@ -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, + options: BedrockMantleOptions = {}, +): Promise { + 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"); + }); +}); diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index d90ca03eb..2d4bbb8df 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -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 diff --git a/packages/catalog/scripts/generate-models.ts b/packages/catalog/scripts/generate-models.ts index 48cecef63..95fb311f7 100644 --- a/packages/catalog/scripts/generate-models.ts +++ b/packages/catalog/scripts/generate-models.ts @@ -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 diff --git a/packages/catalog/scripts/generated-policies.ts b/packages/catalog/scripts/generated-policies.ts index 77a0bab37..1bbb59685 100644 --- a/packages/catalog/scripts/generated-policies.ts +++ b/packages/catalog/scripts/generated-policies.ts @@ -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 = { + "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> = { base: 0, mini: 1, diff --git a/packages/catalog/src/models.json b/packages/catalog/src/models.json index ef1743ea0..9eb1f4d40 100644 --- a/packages/catalog/src/models.json +++ b/packages/catalog/src/models.json @@ -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", diff --git a/packages/catalog/src/provider-models/descriptors.ts b/packages/catalog/src/provider-models/descriptors.ts index c001e8b4b..f9385fc93 100644 --- a/packages/catalog/src/provider-models/descriptors.ts +++ b/packages/catalog/src/provider-models/descriptors.ts @@ -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", diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index 53c5e5752..ecce9377c 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -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 = { - "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; diff --git a/packages/catalog/test/amazon-bedrock-openai.test.ts b/packages/catalog/test/amazon-bedrock-openai.test.ts index 3a5dcb94f..3c6256cc3 100644 --- a/packages/catalog/test/amazon-bedrock-openai.test.ts +++ b/packages/catalog/test/amazon-bedrock-openai.test.ts @@ -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", + ]); }); });