diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f37040276..ac1126993 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -86,6 +86,7 @@ ### Fixed +- Added profile-aware Bedrock Mantle region selection, authenticated model discovery, bearer-token or SigV4 authentication, and credential refresh handling for OpenAI Responses models ([#7080](https://github.com/can1357/oh-my-pi/pull/7080) by [@anatoli-tsinovoy](https://github.com/anatoli-tsinovoy)). - 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/error/aws.ts b/packages/ai/src/error/aws.ts index 1fa0b0c48..0f049ea78 100644 --- a/packages/ai/src/error/aws.ts +++ b/packages/ai/src/error/aws.ts @@ -9,7 +9,11 @@ export type AwsCredentialsErrorKind = /** SSO `GetRoleCredentials` call failed or returned no role. */ | "sso-role" /** External `credential_process` failed, timed out, or emitted bad output. */ - | "credential-process"; + | "credential-process" + /** STS web-identity exchange failed or returned malformed credentials. */ + | "web-identity" + /** ECS/container credential endpoint failed or returned malformed credentials. */ + | "container"; /** A failure resolving AWS credentials for the Bedrock provider. */ export class AwsCredentialsError extends Error { diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index cf9fe87a4..453d90e15 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -10,9 +10,10 @@ import type { Effort } from "@oh-my-pi/pi-catalog/effort"; import { mapEffortToAnthropicAdaptiveEffort, requireSupportedEffort } from "@oh-my-pi/pi-catalog/model-thinking"; import { calculateCost } from "@oh-my-pi/pi-catalog/models"; -import { $env, $flag, fetchWithRetry, parseStreamingJson, parseStreamingJsonThrottled } from "@oh-my-pi/pi-utils"; +import { $flag, fetchWithRetry, parseStreamingJson, parseStreamingJsonThrottled } from "@oh-my-pi/pi-utils"; import { renderDemotedThinking } from "../dialect/demotion"; import * as AIError from "../error"; +import { resolveAwsBearerToken } from "../registry/aws"; import type { Api, AssistantMessage, @@ -30,6 +31,7 @@ import type { ToolResultMessage, } from "../types"; import { normalizeSystemPrompts, normalizeToolCallId, resolveCacheRetention } from "../utils"; +import { resolveAwsAmbientRegion } from "../utils/aws-profile"; import { clearStreamingPartialJson, kStreamingBlockIndex, @@ -74,11 +76,9 @@ export interface BedrockOptions extends StreamOptions { */ thinkingDisplay?: BedrockThinkingDisplay; } -const AUTHENTICATED_API_KEY_SENTINEL = ""; function resolveBearerToken(options: BedrockOptions): string | undefined { - const apiKey = options.apiKey === AUTHENTICATED_API_KEY_SENTINEL ? undefined : options.apiKey; - return options.bearerToken || apiKey || $env.AWS_BEARER_TOKEN_BEDROCK; + return resolveAwsBearerToken(options.apiKey, options.bearerToken); } function inferRegionFromBedrockArn(modelId: string): string | undefined { @@ -149,7 +149,7 @@ function regionServesGeo(region: string, geo: string): boolean { function resolveBedrockRegion(modelId: string, options: BedrockOptions): string { const explicit = options.region || inferRegionFromBedrockArn(modelId); if (explicit) return explicit; - const ambient = $env.AWS_REGION || $env.AWS_DEFAULT_REGION; + const ambient = resolveAwsAmbientRegion(options.profile); const geo = inferenceProfileGeo(modelId); if (geo) { if (ambient && regionServesGeo(ambient, geo)) return ambient; diff --git a/packages/ai/src/providers/aws-credentials.ts b/packages/ai/src/providers/aws-credentials.ts index d05565985..825df53cb 100644 --- a/packages/ai/src/providers/aws-credentials.ts +++ b/packages/ai/src/providers/aws-credentials.ts @@ -4,16 +4,11 @@ * Chain (first hit wins): * 1. Static credentials from the environment * (`AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` [+ `AWS_SESSION_TOKEN`]). - * 2. Profile in `~/.aws/credentials` (and `~/.aws/config` for SSO): - * - static `aws_access_key_id` / `aws_secret_access_key` / `aws_session_token` - * - SSO profile referencing a cached token in `~/.aws/sso/cache/*.json`, - * which we exchange for short-lived role credentials via - * `https://portal.sso.{region}.amazonaws.com/federation/credentials`. - * - `credential_process` — an external command emitting the AWS SDK - * `Version: 1` JSON envelope on stdout. Used by `aws-vault`, `granted`, - * in-house brokers, etc. - * 3. EC2 IMDSv2 (only when `AWS_EC2_METADATA_DISABLED` is unset / falsey and - * `169.254.169.254` is reachable within a 1 s timeout). + * 2. Web identity (`AWS_WEB_IDENTITY_TOKEN_FILE` + `AWS_ROLE_ARN`). + * 3. Profile in `~/.aws/credentials` (and `~/.aws/config` for SSO): + * - static keys, SSO, or `credential_process`. + * 4. ECS/container credentials from `AWS_CONTAINER_CREDENTIALS_*`. + * 5. EC2 IMDSv2 when metadata is enabled. * * Resolved credentials are cached process-wide per profile and refreshed * 60 s before `Expiration` to absorb clock skew. @@ -26,6 +21,14 @@ import { $env, isEnoent, logger } from "@oh-my-pi/pi-utils"; import * as AIError from "../error"; import type { FetchImpl } from "../types"; import { raceWithSignal } from "../utils/abort"; +import { + type AwsIniFile, + parseAwsIni, + resolveAwsProfile, + resolveAwsRegion, + shouldLoadAwsSharedConfig, +} from "../utils/aws-profile"; +import { isLocalOrMetadataHost } from "../utils/proxy"; import type { AwsCredentials } from "./aws-sigv4"; export interface ResolvedCredentials extends AwsCredentials { @@ -55,6 +58,23 @@ const FILE_SESSION_CREDS_TTL_MS = 5 * 60_000; */ const SHARED_RESOLVE_TIMEOUT_MS = 30_000; +function requireDynamicCredentialExpiration( + value: string | undefined, + source: "AWS web identity" | "AWS container credential", + kind: "web-identity" | "container", +): number { + const expiresAt = value ? Date.parse(value) : Number.NaN; + if (Number.isFinite(expiresAt)) return expiresAt; + throw new AIError.AwsCredentialsError(`${source} response has a missing or invalid Expiration.`, kind); +} + +/** Credential-process expiry is optional; missing/malformed values disable caching. */ +function dynamicCredentialExpiration(value: string | undefined): number { + if (!value) return Date.now(); + const expiresAt = Date.parse(value); + return Number.isFinite(expiresAt) ? expiresAt : Date.now(); +} + interface CacheEntry { creds: ResolvedCredentials; expiresAt: number; @@ -63,10 +83,15 @@ interface CacheEntry { const cache: Map = new Map(); const inflight: Map> = new Map(); +function credentialCacheKey(profile: string, region: string, loadSharedConfig: boolean): string { + return `${profile}\x00${region}\x00${loadSharedConfig ? "config" : "credentials"}`; +} + export async function resolveAwsCredentials(opts: CredentialResolveOptions = {}): Promise { - const profile = opts.profile || $env.AWS_PROFILE || "default"; - const region = opts.region || $env.AWS_REGION || $env.AWS_DEFAULT_REGION || "us-east-1"; - const cacheKey = `${profile}\x00${region}`; + const profile = resolveAwsProfile(opts.profile); + const region = resolveAwsRegion(opts.region, opts.profile); + const loadSharedConfig = shouldLoadAwsSharedConfig(opts.profile); + const cacheKey = credentialCacheKey(profile, region, loadSharedConfig); const hit = cache.get(cacheKey); if (hit && hit.expiresAt - REFRESH_SKEW_MS > Date.now()) return hit.creds; @@ -81,7 +106,13 @@ export async function resolveAwsCredentials(opts: CredentialResolveOptions = {}) const fetchImpl = opts.fetch ?? (globalThis.fetch as FetchImpl); const promise = (async () => { try { - const creds = await resolveFresh(profile, region, AbortSignal.timeout(SHARED_RESOLVE_TIMEOUT_MS), fetchImpl); + const creds = await resolveFresh( + profile, + region, + loadSharedConfig, + AbortSignal.timeout(SHARED_RESOLVE_TIMEOUT_MS), + fetchImpl, + ); cache.set(cacheKey, { creds, expiresAt: creds.expiresAt ?? Number.POSITIVE_INFINITY }); return creds; } finally { @@ -95,6 +126,7 @@ export async function resolveAwsCredentials(opts: CredentialResolveOptions = {}) async function resolveFresh( profile: string, region: string, + loadSharedConfig: boolean, signal?: AbortSignal, fetchImpl: FetchImpl = globalThis.fetch as FetchImpl, ): Promise { @@ -102,19 +134,27 @@ async function resolveFresh( const envCreds = readEnvCredentials(); if (envCreds) return envCreds; - // 2. Profile (static or SSO). - const profileCreds = await readProfileCredentials(profile, region, signal, fetchImpl); + // 2. Web identity. + const webIdentityCreds = await readWebIdentityCredentials(region, signal, fetchImpl); + if (webIdentityCreds) return webIdentityCreds; + + // 3. Profile (static, SSO, or credential_process). + const profileCreds = await readProfileCredentials(profile, region, loadSharedConfig, signal, fetchImpl); if (profileCreds) return profileCreds; - // 3. EC2 IMDSv2. + // 4. ECS/container credentials. + const containerCreds = await readContainerCredentials(signal, fetchImpl); + if (containerCreds) return containerCreds; + + // 5. EC2 IMDSv2. if ($env.AWS_EC2_METADATA_DISABLED?.toLowerCase() !== "true") { const imdsCreds = await readImdsCredentials(signal, fetchImpl); if (imdsCreds) return imdsCreds; } throw new AIError.AwsCredentialsError( - `Unable to resolve AWS credentials. Set AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY, ` + - `or configure profile '${profile}' in ~/.aws/credentials (or ~/.aws/config for SSO).`, + `Unable to resolve AWS credentials. Configure static environment keys, web identity, ` + + `an AWS profile, ECS credentials, or an EC2 instance role.`, "resolution", ); } @@ -129,42 +169,10 @@ function readEnvCredentials(): ResolvedCredentials | undefined { : { accessKeyId: ak, secretAccessKey: sk }; } -// ---------- INI parsing ---------- - -/** Map of section name -> map of key -> value. Section names are stripped of - * any leading `profile ` (so `~/.aws/config` aligns with `~/.aws/credentials`). */ -type IniFile = Record>; - -function parseIni(text: string): IniFile { - const out: IniFile = {}; - let current: Record | null = null; - for (const rawLine of text.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line || line.startsWith("#") || line.startsWith(";")) continue; - if (line.startsWith("[") && line.endsWith("]")) { - let name = line.slice(1, -1).trim(); - if (name.startsWith("profile ")) name = name.slice(8).trim(); - if (name.startsWith("sso-session ")) name = `sso-session:${name.slice(12).trim()}`; - let section = out[name]; - if (!section) { - section = {}; - out[name] = section; - } - current = section; - continue; - } - if (!current) continue; - const eq = line.indexOf("="); - if (eq === -1) continue; - current[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); - } - return out; -} - -async function readIniFile(p: string): Promise { +async function readIniFile(p: string): Promise { try { const text = await fs.promises.readFile(p, "utf8"); - return parseIni(text); + return parseAwsIni(text); } catch (err) { if (isEnoent(err)) return undefined; throw err; @@ -176,6 +184,7 @@ async function readIniFile(p: string): Promise { async function readProfileCredentials( profile: string, region: string, + loadSharedConfig: boolean, signal: AbortSignal | undefined, fetchImpl: FetchImpl, ): Promise { @@ -184,7 +193,7 @@ async function readProfileCredentials( const configPath = $env.AWS_CONFIG_FILE || path.join(home, ".aws", "config"); const credentialsIni = await readIniFile(credentialsPath); - const configIni = await readIniFile(configPath); + const configIni = loadSharedConfig ? await readIniFile(configPath) : undefined; // Static credentials live in ~/.aws/credentials; SSO config lives in // ~/.aws/config under `[profile foo]`. Merge into a single view. @@ -225,7 +234,7 @@ interface SsoCachedToken { async function readSsoCredentials( profileCfg: Record, - configIni: IniFile | undefined, + configIni: AwsIniFile | undefined, defaultRegion: string, signal: AbortSignal | undefined, fetchImpl: FetchImpl, @@ -407,10 +416,11 @@ async function readCredentialProcess( accessKeyId: parsed.AccessKeyId, secretAccessKey: parsed.SecretAccessKey, }; - if (parsed.SessionToken) out.sessionToken = parsed.SessionToken; - if (parsed.Expiration) { - const exp = Date.parse(parsed.Expiration); - if (!Number.isNaN(exp)) out.expiresAt = exp; + if (parsed.SessionToken) { + out.sessionToken = parsed.SessionToken; + out.expiresAt = dynamicCredentialExpiration(parsed.Expiration); + } else if (parsed.Expiration) { + out.expiresAt = dynamicCredentialExpiration(parsed.Expiration); } return out; } @@ -515,39 +525,214 @@ export function tokenizeCredentialProcessCommand(cmd: string): string[] { return tokens; } +// ---------- Web identity ---------- + +function xmlTag(xml: string, tag: string): string | undefined { + const value = new RegExp(`<${tag}>([\\s\\S]*?)`).exec(xml)?.[1]; + if (!value) return undefined; + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", '"') + .replaceAll("'", "'"); +} + +function stsEndpoint(region: string): string { + const dnsSuffix = region.startsWith("cn-") ? "amazonaws.com.cn" : "amazonaws.com"; + return `https://sts.${region}.${dnsSuffix}/`; +} + +async function readWebIdentityCredentials( + region: string, + signal: AbortSignal | undefined, + fetchImpl: FetchImpl, +): Promise { + const tokenFile = $env.AWS_WEB_IDENTITY_TOKEN_FILE; + const roleArn = $env.AWS_ROLE_ARN; + if (!tokenFile || !roleArn) return undefined; + let token: string; + try { + token = (await Bun.file(tokenFile).text()).trim(); + } catch (err) { + throw new AIError.AwsCredentialsError( + `Unable to read AWS web identity token file: ${String(err)}`, + "web-identity", + { + cause: err, + }, + ); + } + if (!token) { + throw new AIError.AwsCredentialsError("AWS web identity token file is empty.", "web-identity"); + } + const body = new URLSearchParams({ + Action: "AssumeRoleWithWebIdentity", + Version: "2011-06-15", + RoleArn: roleArn, + RoleSessionName: $env.AWS_ROLE_SESSION_NAME || `omp-${process.pid}`, + WebIdentityToken: token, + }); + const response = await fetchImpl(stsEndpoint(region), { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: body.toString(), + signal, + }); + const xml = await response.text(); + if (!response.ok) { + throw new AIError.AwsCredentialsError( + `AWS AssumeRoleWithWebIdentity failed: ${response.status} ${xmlTag(xml, "Message") ?? xml.slice(0, 200)}`, + "web-identity", + ); + } + const accessKeyId = xmlTag(xml, "AccessKeyId"); + const secretAccessKey = xmlTag(xml, "SecretAccessKey"); + const sessionToken = xmlTag(xml, "SessionToken"); + if (!accessKeyId || !secretAccessKey || !sessionToken) { + throw new AIError.AwsCredentialsError( + "AWS AssumeRoleWithWebIdentity response is missing credentials.", + "web-identity", + ); + } + const expiresAt = requireDynamicCredentialExpiration(xmlTag(xml, "Expiration"), "AWS web identity", "web-identity"); + return { + accessKeyId, + secretAccessKey, + sessionToken, + expiresAt, + }; +} + +// ---------- ECS/container credentials ---------- + +interface ContainerCredentialResponse { + AccessKeyId?: string; + SecretAccessKey?: string; + Token?: string; + Expiration?: string; +} + +const ECS_TASK_CREDENTIALS_BASE_URL = new URL("http://169.254.170.2/"); + +async function readContainerCredentials( + signal: AbortSignal | undefined, + fetchImpl: FetchImpl, +): Promise { + const relativeUri = $env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI; + const fullUri = $env.AWS_CONTAINER_CREDENTIALS_FULL_URI; + if (!relativeUri && !fullUri) return undefined; + let endpoint: URL; + if (relativeUri) { + if (!relativeUri.startsWith("/") || relativeUri.startsWith("//")) { + throw new AIError.AwsCredentialsError( + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI must be a single-host absolute path.", + "container", + ); + } + endpoint = new URL(relativeUri.slice(1), ECS_TASK_CREDENTIALS_BASE_URL); + } else { + try { + endpoint = new URL(fullUri as string); + } catch (err) { + throw new AIError.AwsCredentialsError( + `AWS_CONTAINER_CREDENTIALS_FULL_URI is invalid: ${String(err)}`, + "container", + { cause: err }, + ); + } + if (endpoint.protocol !== "https:" && !isLocalOrMetadataHost(endpoint.hostname)) { + throw new AIError.AwsCredentialsError( + "AWS_CONTAINER_CREDENTIALS_FULL_URI must use HTTPS or a local metadata host.", + "container", + ); + } + } + let authorization = $env.AWS_CONTAINER_AUTHORIZATION_TOKEN; + const authorizationTokenFile = $env.AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE; + if (!authorization && authorizationTokenFile) { + try { + authorization = (await Bun.file(authorizationTokenFile).text()).trim(); + } catch (err) { + throw new AIError.AwsCredentialsError( + `Unable to read AWS container authorization token file: ${String(err)}`, + "container", + { cause: err }, + ); + } + } + const response = await fetchImpl(endpoint, { + headers: authorization ? { authorization } : undefined, + signal, + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new AIError.AwsCredentialsError( + `AWS container credential endpoint failed: ${response.status} ${body.slice(0, 200)}`, + "container", + ); + } + const body = (await response.json()) as ContainerCredentialResponse; + if (!body.AccessKeyId || !body.SecretAccessKey || !body.Token) { + throw new AIError.AwsCredentialsError( + "AWS container credential response is missing AccessKeyId/SecretAccessKey/Token.", + "container", + ); + } + return { + accessKeyId: body.AccessKeyId, + secretAccessKey: body.SecretAccessKey, + sessionToken: body.Token, + expiresAt: requireDynamicCredentialExpiration(body.Expiration, "AWS container credential", "container"), + }; +} + // ---------- IMDSv2 ---------- -const IMDS_HOST = "169.254.169.254"; +const IMDS_IPV4_BASE_URL = "http://169.254.169.254/"; +const IMDS_IPV6_BASE_URL = "http://[fd00:ec2::254]/"; const IMDS_TIMEOUT_MS = 1000; +function imdsRequestSignal(parentSignal: AbortSignal | undefined): AbortSignal { + const timeout = AbortSignal.timeout(IMDS_TIMEOUT_MS); + return parentSignal ? AbortSignal.any([parentSignal, timeout]) : timeout; +} + +function imdsBaseUrl(): URL { + const mode = $env.AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE?.toLowerCase(); + const fallback = mode === "ipv6" ? IMDS_IPV6_BASE_URL : IMDS_IPV4_BASE_URL; + const endpoint = new URL($env.AWS_EC2_METADATA_SERVICE_ENDPOINT || fallback); + if (!endpoint.pathname.endsWith("/")) endpoint.pathname += "/"; + return endpoint; +} + async function readImdsCredentials( parentSignal: AbortSignal | undefined, fetchImpl: FetchImpl, ): Promise { - const timeout = AbortSignal.timeout(IMDS_TIMEOUT_MS); - const signal = parentSignal ? AbortSignal.any([parentSignal, timeout]) : timeout; try { - const tokenRes = await fetchImpl(`http://${IMDS_HOST}/latest/api/token`, { + const endpoint = imdsBaseUrl(); + const tokenRes = await fetchImpl(new URL("latest/api/token", endpoint), { method: "PUT", headers: { "x-aws-ec2-metadata-token-ttl-seconds": "21600" }, - signal, + signal: imdsRequestSignal(parentSignal), }); if (!tokenRes.ok) return undefined; const token = await tokenRes.text(); - const roleRes = await fetchImpl(`http://${IMDS_HOST}/latest/meta-data/iam/security-credentials/`, { + const roleRes = await fetchImpl(new URL("latest/meta-data/iam/security-credentials/", endpoint), { headers: { "x-aws-ec2-metadata-token": token }, - signal, + signal: imdsRequestSignal(parentSignal), }); if (!roleRes.ok) return undefined; const role = (await roleRes.text()).trim(); if (!role) return undefined; const credsRes = await fetchImpl( - `http://${IMDS_HOST}/latest/meta-data/iam/security-credentials/${encodeURIComponent(role)}`, + new URL(`latest/meta-data/iam/security-credentials/${encodeURIComponent(role)}`, endpoint), { headers: { "x-aws-ec2-metadata-token": token }, - signal, + signal: imdsRequestSignal(parentSignal), }, ); if (!credsRes.ok) return undefined; @@ -557,14 +742,15 @@ async function readImdsCredentials( Token?: string; Expiration?: string; }; - if (!body.AccessKeyId || !body.SecretAccessKey) return undefined; - const out: ResolvedCredentials = { + if (!body.AccessKeyId || !body.SecretAccessKey || !body.Token || !body.Expiration) return undefined; + const expiresAt = Date.parse(body.Expiration); + if (!Number.isFinite(expiresAt)) return undefined; + return { accessKeyId: body.AccessKeyId, secretAccessKey: body.SecretAccessKey, + sessionToken: body.Token, + expiresAt, }; - if (body.Token) out.sessionToken = body.Token; - if (body.Expiration) out.expiresAt = Date.parse(body.Expiration); - return out; } catch { return undefined; } @@ -580,7 +766,7 @@ export function clearAwsCredentialCache(): void { * 401/403 responses so stale credentials are re-resolved instead of served until restart. */ export function invalidateAwsCredentialCache(opts: { profile?: string; region?: string } = {}): void { - const profile = opts.profile || $env.AWS_PROFILE || "default"; - const region = opts.region || $env.AWS_REGION || $env.AWS_DEFAULT_REGION || "us-east-1"; - cache.delete(`${profile}\x00${region}`); + const profile = resolveAwsProfile(opts.profile); + const region = resolveAwsRegion(opts.region, opts.profile); + cache.delete(credentialCacheKey(profile, region, shouldLoadAwsSharedConfig(opts.profile))); } diff --git a/packages/ai/src/providers/bedrock-mantle.ts b/packages/ai/src/providers/bedrock-mantle.ts new file mode 100644 index 000000000..d56b50a62 --- /dev/null +++ b/packages/ai/src/providers/bedrock-mantle.ts @@ -0,0 +1,110 @@ +import { type AwsBedrockProviderOptions, resolveAwsBearerToken } from "../registry/aws"; +import type { FetchImpl, Model } from "../types"; +import { resolveAwsRegion } from "../utils/aws-profile"; +import { invalidateAwsCredentialCache, resolveAwsCredentials } from "./aws-credentials"; +import { signRequest } from "./aws-sigv4"; +import type { OpenAIResponsesOptions } from "./openai-responses"; +import { NO_AUTH_SENTINEL } from "./openai-shared"; + +export type BedrockMantleProviderOptions = AwsBedrockProviderOptions; + +export interface BedrockMantleOptions extends OpenAIResponsesOptions { + providerOptions?: BedrockMantleProviderOptions; +} + +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.providerOptions?.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); + } + const response = await baseFetch( + url, + method === "GET" || method === "HEAD" ? { ...init, method, headers } : { ...init, method, headers, body }, + ); + if (response.status === 401 || response.status === 403) { + invalidateAwsCredentialCache({ profile: options.providerOptions?.profile, region }); + } + return response; + }; + return Object.assign(signedFetch, baseFetch.preconnect ? { preconnect: baseFetch.preconnect } : {}); +} + +function resolveBearerToken(options: BedrockMantleOptions): string | undefined { + const apiKey = options.apiKey === NO_AUTH_SENTINEL ? undefined : options.apiKey; + return resolveAwsBearerToken(apiKey, options.providerOptions?.bearerToken); +} + +export function createBedrockMantleAuthenticatedFetch(options: BedrockMantleOptions = {}): FetchImpl { + const region = resolveAwsRegion(options.providerOptions?.region, options.providerOptions?.profile); + const bearerToken = resolveBearerToken(options); + if (!bearerToken) return createSignedFetch(options, region); + + const baseFetch = options.fetch ?? (globalThis.fetch as FetchImpl); + const authenticatedFetch = async (input: string | URL | Request, init?: RequestInit): Promise => { + const headers = new Headers(input instanceof Request ? input.headers : undefined); + for (const [name, value] of new Headers(init?.headers)) headers.set(name, value); + headers.set("authorization", `Bearer ${bearerToken}`); + return baseFetch(input, { ...init, headers }); + }; + return Object.assign(authenticatedFetch, 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 = resolveAwsRegion(options.providerOptions?.region, options.providerOptions?.profile); + const resolvedModel = { ...model, baseUrl: model.baseUrl.replaceAll("{region}", encodeURIComponent(region)) }; + const bearerToken = resolveBearerToken(options); + if (bearerToken) { + return { model: resolvedModel, options: { ...options, apiKey: bearerToken } }; + } + return { + model: resolvedModel, + options: { + ...options, + apiKey: NO_AUTH_SENTINEL, + fetch: createBedrockMantleAuthenticatedFetch(options), + }, + }; +} diff --git a/packages/ai/src/registry/amazon-bedrock.ts b/packages/ai/src/registry/amazon-bedrock.ts index 222530724..37e2c40c8 100644 --- a/packages/ai/src/registry/amazon-bedrock.ts +++ b/packages/ai/src/registry/amazon-bedrock.ts @@ -1,22 +1,17 @@ -import { $env } from "@oh-my-pi/pi-utils"; +import { type AwsBedrockProviderOptions, resolveAwsRegistryApiKey } from "./aws"; import 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: resolveAwsRegistryApiKey, + mapSimpleOptions: options => { + const awsOptions = options.providerOptions as AwsBedrockProviderOptions | undefined; + return { + region: awsOptions?.region, + profile: awsOptions?.profile, + bearerToken: awsOptions?.bearerToken, + }; }, } 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..3e23f88f0 --- /dev/null +++ b/packages/ai/src/registry/aws.ts @@ -0,0 +1,57 @@ +import * as fs from "node:fs"; +import { $env } from "@oh-my-pi/pi-utils"; +import { hasConfiguredAwsProfile } from "../utils/aws-profile"; +import { AUTHENTICATED_SENTINEL } from "./types"; + +export interface AwsBedrockProviderOptions extends Readonly> { + /** AWS region used in the service endpoint and SigV4 credential scope. */ + region?: string; + /** Named AWS shared-credentials/config profile. */ + profile?: string; + /** Amazon Bedrock API key sent as a bearer token, ahead of SigV4 credential resolution. */ + bearerToken?: string; +} + +function isEc2Host(): boolean { + for (const candidate of [ + "/sys/hypervisor/uuid", + "/sys/devices/virtual/dmi/id/product_uuid", + "/sys/devices/virtual/dmi/id/board_asset_tag", + ]) { + try { + const value = fs.readFileSync(candidate, "utf8").trim().toLowerCase(); + if (value.startsWith("ec2")) return true; + } catch { + // Missing/unreadable DMI metadata means this probe is inconclusive. + } + } + return false; +} + +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; + const hasProfile = hasConfiguredAwsProfile(); + const hasInstanceRole = + $env.AWS_EC2_METADATA_DISABLED?.toLowerCase() !== "true" && + (!!$env.AWS_EC2_METADATA_SERVICE_ENDPOINT || isEc2Host()); + return !!( + ($env.AWS_ACCESS_KEY_ID && $env.AWS_SECRET_ACCESS_KEY) || + $env.AWS_BEARER_TOKEN_BEDROCK || + hasWebIdentity || + hasProfile || + hasEcsCredentials || + hasInstanceRole + ); +} + +/** Registry key marker for AWS transports that resolve their own bearer/IAM credentials. */ +export function resolveAwsRegistryApiKey(): string | undefined { + return hasAwsCredentialSource() ? AUTHENTICATED_SENTINEL : undefined; +} + +/** Resolve a real AWS bearer token while filtering the registry's auth marker. */ +export function resolveAwsBearerToken(apiKey?: string, bearerToken?: string): string | undefined { + const resolvedApiKey = apiKey === AUTHENTICATED_SENTINEL ? undefined : apiKey; + return bearerToken || resolvedApiKey || $env.AWS_BEARER_TOKEN_BEDROCK; +} diff --git a/packages/ai/src/registry/bedrock-mantle.ts b/packages/ai/src/registry/bedrock-mantle.ts new file mode 100644 index 000000000..8ba069aa2 --- /dev/null +++ b/packages/ai/src/registry/bedrock-mantle.ts @@ -0,0 +1,34 @@ +import { + type BedrockMantleOptions, + createBedrockMantleAuthenticatedFetch, + prepareBedrockMantleRequest, +} from "../providers/bedrock-mantle"; +import type { Model } from "../types"; +import { resolveAwsRegion } from "../utils/aws-profile"; +import { resolveAwsBearerToken, resolveAwsRegistryApiKey } from "./aws"; +import type { ProviderDefinition } from "./types"; + +export const bedrockMantleProvider = { + id: "bedrock-mantle", + name: "Amazon Bedrock Mantle", + envKeys: resolveAwsRegistryApiKey, + allowsMissingApiKey: true, + prepareRequest: (model, options) => + prepareBedrockMantleRequest(model as Model<"openai-responses">, options as BedrockMantleOptions), + mapSimpleOptions: options => ({ providerOptions: options.providerOptions }), + prepareModelDiscovery: config => { + const bearerToken = resolveAwsBearerToken(config.apiKey); + if (!bearerToken) { + return { ...config, apiKey: undefined, authenticated: false }; + } + const region = resolveAwsRegion(); + return { + authenticated: true, + baseUrl: `https://bedrock-mantle.${encodeURIComponent(region)}.api.aws/openai/v1`, + fetch: createBedrockMantleAuthenticatedFetch({ + fetch: config.fetch, + providerOptions: { bearerToken, region }, + }), + }; + }, +} as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/google-vertex.ts b/packages/ai/src/registry/google-vertex.ts index c58b20fb2..eae816807 100644 --- a/packages/ai/src/registry/google-vertex.ts +++ b/packages/ai/src/registry/google-vertex.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { $env } from "@oh-my-pi/pi-utils"; -import type { ProviderDefinition } from "./types"; +import { AUTHENTICATED_SENTINEL, type ProviderDefinition } from "./types"; let cachedVertexAdcCredentialsExists: boolean | null = null; @@ -32,7 +32,7 @@ export const googleVertexProvider = { const hasProject = !!($env.GOOGLE_CLOUD_PROJECT || $env.GCP_PROJECT || $env.GCLOUD_PROJECT); const hasLocation = !!($env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION); if (hasCredentials && hasProject && hasLocation) { - return ""; + return AUTHENTICATED_SENTINEL; } }, } as const satisfies ProviderDefinition; diff --git a/packages/ai/src/registry/registry.ts b/packages/ai/src/registry/registry.ts index 23f50ed2b..ce70e33ca 100644 --- a/packages/ai/src/registry/registry.ts +++ b/packages/ai/src/registry/registry.ts @@ -7,6 +7,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"; @@ -157,6 +158,7 @@ const ALL = [ mistralProvider, minimaxProvider, amazonBedrockProvider, + bedrockMantleProvider, gmiCloudProvider, ]; diff --git a/packages/ai/src/registry/types.ts b/packages/ai/src/registry/types.ts index 9b2838d22..2466ffaca 100644 --- a/packages/ai/src/registry/types.ts +++ b/packages/ai/src/registry/types.ts @@ -9,6 +9,8 @@ * (default model, model-manager factory, catalog discovery) lives in * `@oh-my-pi/pi-catalog`'s descriptor table. */ + +import type { Api, FetchImpl, Model, SimpleStreamOptions, StreamOptions } from "../types"; import type { OAuthCredentials, OAuthLoginCallbacks } from "./oauth/types"; /** @@ -18,6 +20,26 @@ 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 = ""; + +export interface PreparedProviderRequest { + readonly model: Model; + readonly options: StreamOptions; +} + +export type ProviderRequestPreparer = (model: Model, options: StreamOptions) => PreparedProviderRequest; +export type ProviderSimpleOptionsMapper = (options: SimpleStreamOptions) => Readonly>; + +export interface ProviderModelDiscoveryConfig { + readonly apiKey?: string; + readonly baseUrl?: string; + readonly fetch?: FetchImpl; + readonly authenticated?: boolean; +} + +export type ProviderModelDiscoveryPreparer = (config: ProviderModelDiscoveryConfig) => ProviderModelDiscoveryConfig; + /** * Declarative description of a single provider's auth/login wiring. All * fields are optional except `id`/`name`; presence of a field opts the @@ -42,6 +64,14 @@ export interface ProviderDefinition { readonly showInLoginList?: boolean; // --- env-var fallback (the catalog table's `envVars` supplies plain names; set this only for computed resolvers) --- readonly envKeys?: KeyResolver; + /** Provider transport can authenticate without a resolved API-key string. */ + readonly allowsMissingApiKey?: boolean; + /** Provider-owned request shaping applied before generic API dispatch. */ + readonly prepareRequest?: ProviderRequestPreparer; + /** Provider-owned projection from the generic simple-stream option bag. */ + readonly mapSimpleOptions?: ProviderSimpleOptionsMapper; + /** Provider-owned authentication and endpoint setup for model discovery. */ + readonly prepareModelDiscovery?: ProviderModelDiscoveryPreparer; // --- interactive login (OAuthProviderInterface-compatible) --- readonly login?: (callbacks: OAuthLoginCallbacks) => Promise; readonly refreshToken?: (credentials: OAuthCredentials) => Promise; diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 1ad774e90..aff282f07 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -60,7 +60,7 @@ import { streamOpenAIResponses, } from "./providers/register-builtins"; import { isSyntheticModel, streamSynthetic } from "./providers/synthetic"; -import { PROVIDER_REGISTRY } from "./registry"; +import { getProviderDefinition, PROVIDER_REGISTRY } from "./registry"; import type { Api, AssistantMessage, @@ -812,33 +812,37 @@ function streamDispatch( } as GitLabDuoWorkflowOptions); } - // Vertex AI uses Application Default Credentials, not API keys + // Vertex AI and Bedrock Converse authenticate outside the generic API-key path. if (model.api === "google-vertex") { return streamGoogleVertex(model as Model<"google-vertex">, context, requestOptions as GoogleVertexOptions); - } 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. + } + if (model.api === "bedrock-converse-stream") { return streamBedrock(model as Model<"bedrock-converse-stream">, context, requestOptions as BedrockOptions); } - const apiKey = requestOptions.apiKey || getEnvApiKey(model.provider); + const prepareRequest = getProviderDefinition(model.provider)?.prepareRequest; + const prepared = prepareRequest?.(model as Model, requestOptions as StreamOptions); + const providerModel = prepared?.model ?? (model as Model); + const preparedOptions = prepared?.options ?? (requestOptions as StreamOptions); + const apiKey = preparedOptions.apiKey || getEnvApiKey(providerModel.provider); if (!apiKey) { - throw new AIError.MissingApiKeyError(model.provider); + throw new AIError.MissingApiKeyError(providerModel.provider); } - const providerOptions = isGoogleVertexAuthenticatedModel(model) + const providerOptions = isGoogleVertexAuthenticatedModel(providerModel) ? { - ...requestOptions, + ...preparedOptions, apiKey: "vertex-adc", - fetch: createVertexAuthenticatedFetch(requestOptions), + fetch: createVertexAuthenticatedFetch(preparedOptions), } - : { ...requestOptions, apiKey }; + : { ...preparedOptions, apiKey }; - const api: Api = model.api; + const api: Api = providerModel.api; switch (api) { case "anthropic-messages": { const anthropicOptions = providerOptions as AnthropicOptions; - return streamAnthropic(model as Model<"anthropic-messages">, context, { + return streamAnthropic(providerModel as Model<"anthropic-messages">, context, { ...anthropicOptions, - isOAuth: anthropicOptions.isOAuth ?? model.isOAuth, + isOAuth: anthropicOptions.isOAuth ?? providerModel.isOAuth, }); } @@ -846,13 +850,13 @@ function streamDispatch( const useResponses = $env.PI_OPENROUTER_RESPONSES !== "0"; if (useResponses) { return streamOpenAIResponses( - model as Model<"openai-responses">, + providerModel as Model<"openai-responses">, context, providerOptions as OptionsForApi<"openai-responses">, ); } return streamOpenAICompletions( - model as Model<"openai-completions">, + providerModel as Model<"openai-completions">, context, providerOptions as OptionsForApi<"openai-completions">, ); @@ -860,50 +864,50 @@ function streamDispatch( case "openai-completions": return streamOpenAICompletions( - model as Model<"openai-completions">, + providerModel as Model<"openai-completions">, context, providerOptions as OptionsForApi<"openai-completions">, ); case "openai-responses": return streamOpenAIResponses( - model as Model<"openai-responses">, + providerModel as Model<"openai-responses">, context, providerOptions as OptionsForApi<"openai-responses">, ); case "azure-openai-responses": return streamAzureOpenAIResponses( - model as Model<"azure-openai-responses">, + providerModel as Model<"azure-openai-responses">, context, providerOptions as OptionsForApi<"azure-openai-responses">, ); case "openai-codex-responses": return streamOpenAICodexResponses( - model as Model<"openai-codex-responses">, + providerModel as Model<"openai-codex-responses">, context, providerOptions as OptionsForApi<"openai-codex-responses">, ); case "google-generative-ai": - return streamGoogle(model as Model<"google-generative-ai">, context, providerOptions); + return streamGoogle(providerModel as Model<"google-generative-ai">, context, providerOptions); case "google-gemini-cli": return streamGoogleGeminiCli( - model as Model<"google-gemini-cli">, + providerModel as Model<"google-gemini-cli">, context, providerOptions as GoogleGeminiCliOptions, ); case "ollama-chat": - return streamOllama(model as Model<"ollama-chat">, context, providerOptions as OllamaChatOptions); + return streamOllama(providerModel as Model<"ollama-chat">, context, providerOptions as OllamaChatOptions); case "cursor-agent": - return streamCursor(model as Model<"cursor-agent">, context, providerOptions as CursorOptions); + return streamCursor(providerModel as Model<"cursor-agent">, context, providerOptions as CursorOptions); case "devin-agent": - return streamDevin(model as Model<"devin-agent">, context, providerOptions as DevinOptions); + return streamDevin(providerModel as Model<"devin-agent">, context, providerOptions as DevinOptions); default: throw new AIError.ConfigurationError(`Unhandled API: ${api}`); @@ -1028,12 +1032,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 => { @@ -1107,6 +1109,11 @@ export function streamSimple( return; } if (lastKey === undefined) { + if (getProviderDefinition(model.provider)?.allowsMissingApiKey) { + const failure = await runAttempt(); + if (failure) emitFailure(failure); + return; + } outer.fail(new AIError.MissingApiKeyError(model.provider)); return; } @@ -1156,6 +1163,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 (getProviderDefinition(model.provider)?.allowsMissingApiKey) { + const providerOptions = mapOptionsForApi( + model, + requestOptions, + typeof requestOptions.apiKey === "string" ? requestOptions.apiKey : getEnvApiKey(model.provider), + ); + return stream(model, context, providerOptions); } // The resolver form is handled by the wrapper above; only a static string @@ -1454,6 +1468,7 @@ function mapOptionsForApi( apiKey?: string, ): OptionsForApi { const options = normalizeMandatoryReasoningOptions(model, rawOptions); + const simpleProviderOptions = getProviderDefinition(model.provider)?.mapSimpleOptions?.(options ?? {}); const base = { temperature: options?.temperature, topP: options?.topP, @@ -1483,6 +1498,7 @@ function mapOptionsForApi( execHandlers: options?.execHandlers, fetch: options?.fetch, fallbacks: options?.fallbacks, + ...simpleProviderOptions, }; switch (model.api) { diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 814cfbdfd..8a7cb8b18 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -433,6 +433,11 @@ export interface StreamOptions { * For example, Anthropic uses `user_id` for abuse tracking and rate limiting. */ metadata?: Record; + /** + * Provider-owned request configuration. Provider hooks interpret this bag; + * generic API transports do not forward its fields onto the wire. + */ + providerOptions?: Readonly>; /** OpenAI Responses/Codex response fields to include verbatim. */ include?: OpenAIResponseInclude[]; /** diff --git a/packages/ai/src/utils/aws-profile.ts b/packages/ai/src/utils/aws-profile.ts new file mode 100644 index 000000000..3f256fbe7 --- /dev/null +++ b/packages/ai/src/utils/aws-profile.ts @@ -0,0 +1,88 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { $env } from "@oh-my-pi/pi-utils"; + +/** INI sections with `profile ` / `sso-session ` prefixes normalized. */ +export type AwsIniFile = Record>; + +export function parseAwsIni(text: string): AwsIniFile { + const out: AwsIniFile = {}; + let current: Record | null = null; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#") || line.startsWith(";")) continue; + if (line.startsWith("[") && line.endsWith("]")) { + let name = line.slice(1, -1).trim(); + if (name.startsWith("profile ")) name = name.slice(8).trim(); + if (name.startsWith("sso-session ")) name = `sso-session:${name.slice(12).trim()}`; + let section = out[name]; + if (!section) { + section = {}; + out[name] = section; + } + current = section; + continue; + } + if (!current) continue; + const eq = line.indexOf("="); + if (eq === -1) continue; + current[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); + } + return out; +} + +function readAwsIniSync(filePath: string): AwsIniFile | undefined { + try { + return parseAwsIni(fs.readFileSync(filePath, "utf8")); + } catch { + return undefined; + } +} + +/** Resolve the selected shared-credentials profile. */ +export function resolveAwsProfile(profile?: string): string { + return profile || $env.AWS_PROFILE || "default"; +} + +/** + * Whether the shared config file participates in profile/region resolution. + * Explicit profile selection enables it; the implicit default profile follows + * the AWS SDK's `AWS_SDK_LOAD_CONFIG` opt-in. + */ +export function shouldLoadAwsSharedConfig(profile?: string): boolean { + if (profile || $env.AWS_PROFILE) return true; + const value = $env.AWS_SDK_LOAD_CONFIG?.toLowerCase(); + return value === "1" || value === "true"; +} + +export function resolveAwsProfileRegion(profile?: string): string | undefined { + if (!shouldLoadAwsSharedConfig(profile)) return undefined; + const configPath = $env.AWS_CONFIG_FILE || path.join(os.homedir(), ".aws", "config"); + return readAwsIniSync(configPath)?.[resolveAwsProfile(profile)]?.region; +} + +/** Region selected by the environment or active shared-config profile. */ +export function resolveAwsAmbientRegion(profile?: string): string | undefined { + return $env.AWS_REGION || $env.AWS_DEFAULT_REGION || resolveAwsProfileRegion(profile); +} + +/** Resolve the region precedence shared by AWS transports and credential exchanges. */ +export function resolveAwsRegion(explicitRegion?: string, profile?: string): string { + return explicitRegion || resolveAwsAmbientRegion(profile) || "us-east-1"; +} + +export function hasConfiguredAwsProfile(profile?: string): boolean { + const selectedProfile = resolveAwsProfile(profile); + const credentialsPath = $env.AWS_SHARED_CREDENTIALS_FILE || path.join(os.homedir(), ".aws", "credentials"); + const configPath = $env.AWS_CONFIG_FILE || path.join(os.homedir(), ".aws", "config"); + const credentialsIni = readAwsIniSync(credentialsPath); + const configIni = shouldLoadAwsSharedConfig(profile) ? readAwsIniSync(configPath) : undefined; + const merged = { ...(configIni?.[selectedProfile] ?? {}), ...(credentialsIni?.[selectedProfile] ?? {}) }; + if (merged.aws_access_key_id && merged.aws_secret_access_key) return true; + if (merged.credential_process) return true; + if (!merged.sso_account_id || !merged.sso_role_name) return false; + if (merged.sso_start_url && merged.sso_region) return true; + const session = merged.sso_session ? configIni?.[`sso-session:${merged.sso_session}`] : undefined; + return !!(session?.sso_start_url && session.sso_region); +} diff --git a/packages/ai/test/aws-credentials.test.ts b/packages/ai/test/aws-credentials.test.ts index ecb263ff0..c3cf95f86 100644 --- a/packages/ai/test/aws-credentials.test.ts +++ b/packages/ai/test/aws-credentials.test.ts @@ -7,7 +7,9 @@ import { resolveAwsCredentials, tokenizeCredentialProcessCommand, } from "@oh-my-pi/pi-ai/providers/aws-credentials"; +import type { FetchImpl } from "@oh-my-pi/pi-ai/types"; import { removeWithRetries } from "../../utils/src/temp"; +import { waitForDelayOrAbort } from "./helpers"; // `credential_process` integration coverage. Drives a real `Bun.spawn` // against a fixture script so the JSON envelope contract, exit-code @@ -19,11 +21,21 @@ const ENV_KEYS = [ "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_PROFILE", + "AWS_SDK_LOAD_CONFIG", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_EC2_METADATA_DISABLED", + "AWS_EC2_METADATA_SERVICE_ENDPOINT", + "AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AWS_ROLE_ARN", + "AWS_ROLE_SESSION_NAME", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_AUTHORIZATION_TOKEN", + "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", ] as const; function quoteForConfig(p: string): string { @@ -75,7 +87,7 @@ describe("tokenizeCredentialProcessCommand", () => { }); }); -describe("resolveAwsCredentials credential_process", () => { +describe("resolveAwsCredentials", () => { let tmp: string; const saved = new Map(); @@ -176,4 +188,230 @@ describe("resolveAwsCredentials credential_process", () => { setTimeout(() => ctrl.abort(new Error("test abort")), 50); await expect(promise).rejects.toBeDefined(); }); + + test("resolves ECS container credentials with the authorization token", async () => { + const credentialsPath = path.join(tmp, "empty-credentials"); + const configPath = path.join(tmp, "empty-config"); + await Promise.all([Bun.write(credentialsPath, ""), Bun.write(configPath, "")]); + Bun.env.AWS_SHARED_CREDENTIALS_FILE = credentialsPath; + Bun.env.AWS_CONFIG_FILE = configPath; + Bun.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = "/v2/credentials/test"; + Bun.env.AWS_CONTAINER_AUTHORIZATION_TOKEN = "container-auth"; + const capture: { url?: string; authorization?: string | null } = {}; + const fetchImpl: FetchImpl = Object.assign( + async (input: string | URL | Request, init?: RequestInit) => { + capture.url = String(input); + capture.authorization = new Headers(init?.headers).get("authorization"); + return Response.json({ + AccessKeyId: "AKIAECS", + SecretAccessKey: "ecs-secret", + Token: "ecs-token", + Expiration: "2099-01-01T00:00:00Z", + }); + }, + { preconnect: fetch.preconnect }, + ); + + const credentials = await resolveAwsCredentials({ fetch: fetchImpl }); + + expect(capture.url).toBe("http://169.254.170.2/v2/credentials/test"); + expect(capture.authorization).toBe("container-auth"); + expect(credentials).toEqual({ + accessKeyId: "AKIAECS", + secretAccessKey: "ecs-secret", + sessionToken: "ecs-token", + expiresAt: Date.parse("2099-01-01T00:00:00Z"), + }); + }); + + test("rejects dynamic container credentials without expiration", async () => { + const credentialsPath = path.join(tmp, "empty-dynamic-credentials"); + const configPath = path.join(tmp, "empty-dynamic-config"); + await Promise.all([Bun.write(credentialsPath, ""), Bun.write(configPath, "")]); + Bun.env.AWS_SHARED_CREDENTIALS_FILE = credentialsPath; + Bun.env.AWS_CONFIG_FILE = configPath; + Bun.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = "/v2/credentials/rotating"; + let calls = 0; + const fetchImpl: FetchImpl = Object.assign( + async () => { + calls++; + return Response.json({ + AccessKeyId: "AKIAECS", + SecretAccessKey: "ecs-secret", + Token: "ecs-token", + }); + }, + { preconnect: fetch.preconnect }, + ); + + await expect(resolveAwsCredentials({ fetch: fetchImpl })).rejects.toThrow(/missing or invalid Expiration/); + expect(calls).toBe(1); + }); + + test("rejects container relative URIs that can replace the metadata host", async () => { + const credentialsPath = path.join(tmp, "empty-relative-credentials"); + const configPath = path.join(tmp, "empty-relative-config"); + await Promise.all([Bun.write(credentialsPath, ""), Bun.write(configPath, "")]); + Bun.env.AWS_SHARED_CREDENTIALS_FILE = credentialsPath; + Bun.env.AWS_CONFIG_FILE = configPath; + Bun.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = "//attacker.invalid/credentials"; + + await expect(resolveAwsCredentials()).rejects.toThrow(/single-host absolute path/); + }); + + test("honors AWS_EC2_METADATA_SERVICE_ENDPOINT for instance-role credentials", async () => { + const credentialsPath = path.join(tmp, "empty-imds-credentials"); + const configPath = path.join(tmp, "empty-imds-config"); + await Promise.all([Bun.write(credentialsPath, ""), Bun.write(configPath, "")]); + Bun.env.AWS_SHARED_CREDENTIALS_FILE = credentialsPath; + Bun.env.AWS_CONFIG_FILE = configPath; + Bun.env.AWS_EC2_METADATA_DISABLED = "false"; + Bun.env.AWS_EC2_METADATA_SERVICE_ENDPOINT = "http://imds.internal:8181/"; + const requestedUrls: string[] = []; + const fetchImpl: FetchImpl = Object.assign( + async (input: string | URL | Request) => { + const url = String(input); + requestedUrls.push(url); + if (url.endsWith("/latest/api/token")) return new Response("imds-token"); + if (url.endsWith("/latest/meta-data/iam/security-credentials/")) return new Response("test-role"); + return Response.json({ + AccessKeyId: "AKIAIMDS", + SecretAccessKey: "imds-secret", + Token: "imds-session", + Expiration: "2099-01-01T00:00:00Z", + }); + }, + { preconnect: fetch.preconnect }, + ); + + const credentials = await resolveAwsCredentials({ fetch: fetchImpl }); + + expect(requestedUrls).toEqual([ + "http://imds.internal:8181/latest/api/token", + "http://imds.internal:8181/latest/meta-data/iam/security-credentials/", + "http://imds.internal:8181/latest/meta-data/iam/security-credentials/test-role", + ]); + expect(credentials.accessKeyId).toBe("AKIAIMDS"); + expect(credentials.sessionToken).toBe("imds-session"); + }); + + test("uses the IPv6 IMDS endpoint when endpoint mode requests it", async () => { + const credentialsPath = path.join(tmp, "empty-ipv6-imds-credentials"); + const configPath = path.join(tmp, "empty-ipv6-imds-config"); + await Promise.all([Bun.write(credentialsPath, ""), Bun.write(configPath, "")]); + Bun.env.AWS_SHARED_CREDENTIALS_FILE = credentialsPath; + Bun.env.AWS_CONFIG_FILE = configPath; + Bun.env.AWS_EC2_METADATA_DISABLED = "false"; + Bun.env.AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE = "IPv6"; + const requestedUrls: string[] = []; + const fetchImpl: FetchImpl = Object.assign( + async (input: string | URL | Request) => { + const url = String(input); + requestedUrls.push(url); + if (url.endsWith("/latest/api/token")) return new Response("imds-token"); + if (url.endsWith("/latest/meta-data/iam/security-credentials/")) return new Response("test-role"); + return Response.json({ + AccessKeyId: "AKIAIMDS", + SecretAccessKey: "imds-secret", + Token: "imds-session", + Expiration: "2099-01-01T00:00:00Z", + }); + }, + { preconnect: fetch.preconnect }, + ); + + await resolveAwsCredentials({ fetch: fetchImpl }); + + expect(requestedUrls[0]).toBe("http://[fd00:ec2::254]/latest/api/token"); + }); + + test("gives each IMDS request its own timeout budget", async () => { + const credentialsPath = path.join(tmp, "empty-slow-imds-credentials"); + const configPath = path.join(tmp, "empty-slow-imds-config"); + await Promise.all([Bun.write(credentialsPath, ""), Bun.write(configPath, "")]); + Bun.env.AWS_SHARED_CREDENTIALS_FILE = credentialsPath; + Bun.env.AWS_CONFIG_FILE = configPath; + Bun.env.AWS_EC2_METADATA_DISABLED = "false"; + Bun.env.AWS_EC2_METADATA_SERVICE_ENDPOINT = "http://slow-imds.internal"; + let calls = 0; + const fetchImpl: FetchImpl = Object.assign( + async (_input: string | URL | Request, init?: RequestInit) => { + await waitForDelayOrAbort(450, init?.signal ?? undefined); + calls++; + if (calls === 1) return new Response("imds-token"); + if (calls === 2) return new Response("test-role"); + return Response.json({ + AccessKeyId: "AKIASLOWIMDS", + SecretAccessKey: "imds-secret", + Token: "imds-session", + Expiration: "2099-01-01T00:00:00Z", + }); + }, + { preconnect: fetch.preconnect }, + ); + + const credentials = await resolveAwsCredentials({ fetch: fetchImpl }); + + expect(calls).toBe(3); + expect(credentials.accessKeyId).toBe("AKIASLOWIMDS"); + }); + + test("exchanges web identity tokens for STS credentials", async () => { + const tokenPath = path.join(tmp, "web-identity-token"); + await Bun.write(tokenPath, "signed-identity-token\n"); + Bun.env.AWS_WEB_IDENTITY_TOKEN_FILE = tokenPath; + Bun.env.AWS_ROLE_ARN = "arn:aws:iam::123456789012:role/test-role"; + Bun.env.AWS_ROLE_SESSION_NAME = "test-session"; + await writeConfig("regional", "region = cn-north-1"); + let requestedUrl = ""; + let requestBody = ""; + const fetchImpl: FetchImpl = Object.assign( + async (input: string | URL | Request, init?: RequestInit) => { + requestedUrl = String(input); + requestBody = String(init?.body); + return new Response( + ` + AKIAWEBweb-secret + web-token2099-01-01T00:00:00Z + `, + { headers: { "content-type": "text/xml" } }, + ); + }, + { preconnect: fetch.preconnect }, + ); + + const credentials = await resolveAwsCredentials({ profile: "regional", fetch: fetchImpl }); + + expect(requestedUrl).toBe("https://sts.cn-north-1.amazonaws.com.cn/"); + expect(new URLSearchParams(requestBody).get("WebIdentityToken")).toBe("signed-identity-token"); + expect(new URLSearchParams(requestBody).get("RoleSessionName")).toBe("test-session"); + expect(credentials).toEqual({ + accessKeyId: "AKIAWEB", + secretAccessKey: "web-secret", + sessionToken: "web-token", + expiresAt: Date.parse("2099-01-01T00:00:00Z"), + }); + }); + + test("rejects web-identity responses without a valid expiration", async () => { + const tokenPath = path.join(tmp, "web-identity-token-without-expiration"); + await Bun.write(tokenPath, "signed-identity-token\n"); + Bun.env.AWS_WEB_IDENTITY_TOKEN_FILE = tokenPath; + Bun.env.AWS_ROLE_ARN = "arn:aws:iam::123456789012:role/test-role"; + const fetchImpl: FetchImpl = Object.assign( + async () => + new Response( + ` + AKIAWEBweb-secret + web-token + `, + { headers: { "content-type": "text/xml" } }, + ), + { preconnect: fetch.preconnect }, + ); + + await expect(resolveAwsCredentials({ region: "us-east-1", fetch: fetchImpl })).rejects.toThrow( + /missing or invalid Expiration/, + ); + }); }); diff --git a/packages/ai/test/aws-registry.test.ts b/packages/ai/test/aws-registry.test.ts new file mode 100644 index 000000000..edf118fba --- /dev/null +++ b/packages/ai/test/aws-registry.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream"; +import { removeWithRetries } from "../../utils/src/temp"; +import { withEnv } from "./helpers"; + +const EMPTY_AWS_ENV = { + AWS_ACCESS_KEY_ID: undefined, + AWS_SECRET_ACCESS_KEY: undefined, + AWS_BEARER_TOKEN_BEDROCK: undefined, + AWS_PROFILE: undefined, + AWS_SDK_LOAD_CONFIG: undefined, + AWS_WEB_IDENTITY_TOKEN_FILE: undefined, + AWS_ROLE_ARN: undefined, + AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined, + AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined, + AWS_EXECUTION_ENV: undefined, + AWS_EC2_METADATA_SERVICE_ENDPOINT: undefined, +}; + +describe("AWS provider availability", () => { + test("recognizes the default shared credentials file", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "aws-registry-")); + try { + const credentialsPath = path.join(tmp, "credentials"); + await Bun.write(credentialsPath, "[default]\naws_access_key_id = test\naws_secret_access_key = test-secret\n"); + await withEnv( + { + ...EMPTY_AWS_ENV, + AWS_SHARED_CREDENTIALS_FILE: credentialsPath, + AWS_CONFIG_FILE: path.join(tmp, "missing-config"), + AWS_EC2_METADATA_DISABLED: "true", + }, + async () => expect(getEnvApiKey("bedrock-mantle")).toBeDefined(), + ); + } finally { + await removeWithRetries(tmp); + } + }); + + test("ignores profile files without a usable credential mechanism", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "aws-registry-empty-")); + try { + const credentialsPath = path.join(tmp, "credentials"); + const configPath = path.join(tmp, "config"); + await Promise.all([ + Bun.write(credentialsPath, "[default]\naws_access_key_id = incomplete\n"), + Bun.write(configPath, "[default]\nregion = us-east-1\n"), + ]); + await withEnv( + { + ...EMPTY_AWS_ENV, + AWS_SHARED_CREDENTIALS_FILE: credentialsPath, + AWS_CONFIG_FILE: configPath, + AWS_EC2_METADATA_DISABLED: "true", + }, + async () => expect(getEnvApiKey("bedrock-mantle")).toBeUndefined(), + ); + } finally { + await removeWithRetries(tmp); + } + }); + + test("loads implicit default config profiles only when AWS_SDK_LOAD_CONFIG is enabled", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "aws-registry-load-config-")); + try { + const credentialsPath = path.join(tmp, "credentials"); + const configPath = path.join(tmp, "config"); + await Promise.all([ + Bun.write(credentialsPath, ""), + Bun.write(configPath, "[default]\ncredential_process = /bin/credential-helper\n"), + ]); + const env = { + ...EMPTY_AWS_ENV, + AWS_SHARED_CREDENTIALS_FILE: credentialsPath, + AWS_CONFIG_FILE: configPath, + AWS_EC2_METADATA_DISABLED: "true", + }; + await withEnv(env, async () => expect(getEnvApiKey("bedrock-mantle")).toBeUndefined()); + await withEnv({ ...env, AWS_SDK_LOAD_CONFIG: "1" }, async () => + expect(getEnvApiKey("bedrock-mantle")).toBeDefined(), + ); + } finally { + await removeWithRetries(tmp); + } + }); + test("recognizes an explicitly configured EC2 metadata endpoint", async () => { + await withEnv( + { + ...EMPTY_AWS_ENV, + AWS_SHARED_CREDENTIALS_FILE: "/missing/aws-credentials", + AWS_CONFIG_FILE: "/missing/aws-config", + AWS_EC2_METADATA_DISABLED: undefined, + AWS_EC2_METADATA_SERVICE_ENDPOINT: "http://169.254.169.254", + }, + async () => expect(getEnvApiKey("bedrock-mantle")).toBeDefined(), + ); + }); +}); diff --git a/packages/ai/test/bedrock-inference-profile.test.ts b/packages/ai/test/bedrock-inference-profile.test.ts index 88a1e76e5..b3af78349 100644 --- a/packages/ai/test/bedrock-inference-profile.test.ts +++ b/packages/ai/test/bedrock-inference-profile.test.ts @@ -1,8 +1,12 @@ import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; import { streamBedrock } from "@oh-my-pi/pi-ai/providers/amazon-bedrock"; import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { Effort } from "@oh-my-pi/pi-catalog/effort"; +import { removeWithRetries } from "../../utils/src/temp"; import { withEnv } from "./helpers"; const profileArn = "arn:aws:bedrock:us-east-2:1234567890:application-inference-profile/company-opus-48"; @@ -138,7 +142,7 @@ function bedrockModel(id: string): Model<"bedrock-converse-stream"> { async function capturedRequestHost( model: Model<"bedrock-converse-stream">, - options: { region?: string } = {}, + options: { region?: string; profile?: string } = {}, ): Promise { const calls: string[] = []; const customFetch: FetchImpl = Object.assign( @@ -213,6 +217,31 @@ describe("Bedrock cross-region inference-profile geo routing", () => { }); }); + test("uses the selected profile region when environment regions are absent", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "bedrock-profile-region-")); + try { + const configPath = path.join(tmp, "config"); + await Bun.write(configPath, "[profile regional]\nregion = eu-west-2\n"); + await withEnv( + { + AWS_REGION: undefined, + AWS_DEFAULT_REGION: undefined, + AWS_PROFILE: "regional", + AWS_CONFIG_FILE: configPath, + }, + async () => { + expect( + await capturedRequestHost(bedrockModel("eu.anthropic.claude-opus-4-8"), { + profile: "regional", + }), + ).toBe("bedrock-runtime.eu-west-2.amazonaws.com"); + }, + ); + } finally { + await removeWithRetries(tmp); + } + }); + test("explicit per-request region wins over the geo prefix and ambient region", async () => { await withEnv({ AWS_REGION: "eu-central-1", AWS_DEFAULT_REGION: undefined }, async () => { expect(await capturedRequestHost(bedrockModel("eu.anthropic.claude-opus-4-8"), { region: "eu-west-3" })).toBe( 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..df2735ba7 --- /dev/null +++ b/packages/ai/test/bedrock-mantle-auth.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { clearAwsCredentialCache } from "@oh-my-pi/pi-ai/providers/aws-credentials"; +import type { BedrockMantleOptions } from "@oh-my-pi/pi-ai/providers/bedrock-mantle"; +import { getProviderDefinition } from "@oh-my-pi/pi-ai/registry"; +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 { removeWithRetries } from "../../utils/src/temp"; +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_CONFIG_FILE: undefined, + AWS_SHARED_CREDENTIALS_FILE: undefined, + AWS_EC2_METADATA_SERVICE_ENDPOINT: undefined, + AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE: undefined, + AWS_DEFAULT_REGION: undefined, + AWS_EC2_METADATA_DISABLED: "true", +}; + +interface Capture { + url?: string; + authorization?: string | null; + securityToken?: string | null; + body?: RequestInit["body"]; +} + +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"); + capture.body = init?.body; + 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("uses the selected profile region when environment regions are absent", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "bedrock-mantle-region-")); + try { + const configPath = path.join(tmp, "config"); + await Bun.write(configPath, "[profile regional]\nregion = eu-west-2\n"); + const capture = await runDirect({ + AWS_BEARER_TOKEN_BEDROCK: "test-token", + AWS_PROFILE: "regional", + AWS_CONFIG_FILE: configPath, + AWS_SHARED_CREDENTIALS_FILE: path.join(tmp, "missing-credentials"), + }); + expect(capture.url).toStartWith("https://bedrock-mantle.eu-west-2.api.aws/openai/v1/responses"); + } finally { + await removeWithRetries(tmp); + } + }); + + test("prepares bearer-authenticated model discovery", async () => { + const capture: Capture = {}; + await withEnv( + { + ...cleanAwsEnv, + AWS_BEARER_TOKEN_BEDROCK: "discovery-token", + AWS_REGION: "eu-west-2", + }, + async () => { + const config = getProviderDefinition("bedrock-mantle")?.prepareModelDiscovery?.({ + fetch: captureFetch(capture), + }); + expect(config?.authenticated).toBeTrue(); + expect(config?.baseUrl).toBe("https://bedrock-mantle.eu-west-2.api.aws/openai/v1"); + await config?.fetch?.("https://bedrock-mantle.eu-west-2.api.aws/v1/models", { method: "GET" }); + }, + ); + expect(capture.authorization).toBe("Bearer discovery-token"); + expect(capture.body).toBeUndefined(); + }); + + test("does not enable account-scoped discovery for SigV4-only credentials", async () => { + await withEnv( + { + ...cleanAwsEnv, + AWS_ACCESS_KEY_ID: "AKIADISCOVERY", + AWS_SECRET_ACCESS_KEY: "discovery-secret", + AWS_REGION: "eu-west-2", + }, + async () => { + const config = getProviderDefinition("bedrock-mantle")?.prepareModelDiscovery?.({}); + expect(config?.authenticated).toBeFalse(); + expect(config?.baseUrl).toBeUndefined(); + }, + ); + }); + + 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("invalidates cached SigV4 credentials after an authentication rejection", async () => { + const authorizations: string[] = []; + const rejectingFetch: FetchImpl = Object.assign( + async (input: string | URL | Request, init?: RequestInit) => { + const headers = new Headers(input instanceof Request ? input.headers : init?.headers); + authorizations.push(headers.get("authorization") ?? ""); + return new Response("rejected", { status: 403 }); + }, + { preconnect: fetch.preconnect }, + ); + await withEnv( + { + ...cleanAwsEnv, + AWS_ACCESS_KEY_ID: "AKIAFIRST", + AWS_SECRET_ACCESS_KEY: "first-secret", + AWS_REGION: "us-west-2", + }, + async () => { + clearAwsCredentialCache(); + await stream(mantleModel, context, { fetch: rejectingFetch, maxTokens: 16 }).result(); + Bun.env.AWS_ACCESS_KEY_ID = "AKIASECOND"; + Bun.env.AWS_SECRET_ACCESS_KEY = "second-secret"; + await stream(mantleModel, context, { fetch: rejectingFetch, maxTokens: 16 }).result(); + }, + ); + expect(authorizations).toHaveLength(2); + expect(authorizations[0]).toContain("Credential=AKIAFIRST/"); + expect(authorizations[1]).toContain("Credential=AKIASECOND/"); + }); + + 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"; + }, + providerOptions: { + 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 d486749e9..31e3aff82 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -48,6 +48,13 @@ ### Fixed - Fixed Ollama model-manager caches being reused after the configured base URL changed by scoping cache namespaces to the normalized native discovery endpoint, including reverse-proxy path prefixes ([#7087](https://github.com/can1357/oh-my-pi/issues/7087)). +### Added + +- Added the `bedrock-mantle` provider with authenticated model discovery for OpenAI GPT-5.4, GPT-5.5, and GPT-5.6 models served through Amazon Bedrock's Responses endpoint ([#7080](https://github.com/can1357/oh-my-pi/pull/7080) by [@anatoli-tsinovoy](https://github.com/anatoli-tsinovoy)). + +### Fixed + +- Removed unusable Converse entries for OpenAI models that Amazon Bedrock serves only through Mantle and corrected GPT-5.6 Luna and Terra pricing ([#7080](https://github.com/can1357/oh-my-pi/pull/7080) by [@anatoli-tsinovoy](https://github.com/anatoli-tsinovoy)). ## [17.2.0] - 2026-07-30 diff --git a/packages/catalog/scripts/generate-models.ts b/packages/catalog/scripts/generate-models.ts index b6900cfa7..1088c1708 100644 --- a/packages/catalog/scripts/generate-models.ts +++ b/packages/catalog/scripts/generate-models.ts @@ -14,6 +14,7 @@ import { discoverAuthStorage } from "@oh-my-pi/pi-ai/auth-broker/discover"; import type { OAuthAccess } from "@oh-my-pi/pi-ai/auth-storage"; import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types"; import { getGitLabDuoModels } from "@oh-my-pi/pi-ai/providers/gitlab-duo"; +import { getProviderDefinition } from "@oh-my-pi/pi-ai/registry"; import { $env } from "@oh-my-pi/pi-utils"; import { ANTIGRAVITY_PRIMARY_ENDPOINT, fetchAntigravityDiscoveryModels } from "../src/discovery/antigravity"; import { buildGitLabDuoWorkflowFallbackModel } from "../src/discovery/gitlab-duo-workflow"; @@ -31,6 +32,7 @@ import { AIAND_STATIC_MODELS, ALIBABA_TOKEN_PLAN_STATIC_MODELS, ANTHROPIC_CURATED_FALLBACK_MODELS, + BEDROCK_MANTLE_STATIC_MODELS, buildFireworksFastSeed, buildXaiOAuthStaticSeed, clampFireworksKimiMaxTokens, @@ -57,6 +59,7 @@ import { applyGeneratedModelPolicies, applyOllamaCloudOutputCap, CLOUDFLARE_FALLBACK_MODEL, + dropBedrockMantleOpenAIModels, dropUnsupportedBedrockGeoIds, hasBillableCost, linkOpenAIPromotionTargets, @@ -126,7 +129,10 @@ async function fetchProviderModelsFromCatalog( try { console.log(`Fetching models from ${descriptor.catalogDiscovery.label} model manager...`); - const managerOptions = descriptor.createModelManagerOptions({ apiKey }); + const discoveryConfig = { apiKey }; + const preparedConfig = + getProviderDefinition(descriptor.providerId)?.prepareModelDiscovery?.(discoveryConfig) ?? discoveryConfig; + const managerOptions = descriptor.createModelManagerOptions(preparedConfig); const manager = createModelManager(managerOptions); const result = await manager.refresh("online"); // `stale: true` means the dynamic fetch failed and the manager fell back @@ -554,6 +560,9 @@ 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); + // Mantle's catalog endpoint is account/API-key scoped. Keep the generated + // bundle deterministic; authenticated runtime discovery may replace this seed. + 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. @@ -657,6 +666,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 26f203702..b68004971 100644 --- a/packages/catalog/scripts/generated-policies.ts +++ b/packages/catalog/scripts/generated-policies.ts @@ -66,6 +66,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])); +} + /** True when any component of a model's per-million-token cost is nonzero. */ export function hasBillableCost(cost: ModelSpec["cost"]): boolean { return cost.input !== 0 || cost.output !== 0 || cost.cacheRead !== 0 || cost.cacheWrite !== 0; diff --git a/packages/catalog/src/models.json b/packages/catalog/src/models.json index 01217365b..07a28ec00 100644 --- a/packages/catalog/src/models.json +++ b/packages/catalog/src/models.json @@ -10186,155 +10186,6 @@ ] } }, - "openai.gpt-5.4": { - "id": "openai.gpt-5.4", - "name": "GPT-5.4", - "api": "bedrock-converse-stream", - "provider": "amazon-bedrock", - "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", - "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": "bedrock-converse-stream", - "provider": "amazon-bedrock", - "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", - "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": "bedrock-converse-stream", - "provider": "amazon-bedrock", - "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", - "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 0.22, - "output": 1.32, - "cacheRead": 0.022, - "cacheWrite": 0.275 - }, - "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": "bedrock-converse-stream", - "provider": "amazon-bedrock", - "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", - "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 5.5, - "output": 33, - "cacheRead": 0.55, - "cacheWrite": 6.88 - }, - "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": "bedrock-converse-stream", - "provider": "amazon-bedrock", - "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", - "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 2.2, - "output": 13.2, - "cacheRead": 0.22, - "cacheWrite": 2.75 - }, - "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", @@ -13666,6 +13517,157 @@ "supportsComputerUseConfig": false } }, + "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": 0.22, + "output": 1.32, + "cacheRead": 0.022, + "cacheWrite": 0.275 + }, + "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.2, + "output": 13.2, + "cacheRead": 0.22, + "cacheWrite": 2.75 + }, + "contextWindow": 272000, + "maxTokens": 128000, + "thinking": { + "mode": "effort", + "efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + } + }, "cerebras": { "gemma-4-31b": { "id": "gemma-4-31b", diff --git a/packages/catalog/src/provider-models/descriptor-types.ts b/packages/catalog/src/provider-models/descriptor-types.ts index 042213319..fc769a678 100644 --- a/packages/catalog/src/provider-models/descriptor-types.ts +++ b/packages/catalog/src/provider-models/descriptor-types.ts @@ -2,7 +2,13 @@ import type { ModelManagerOptions } from "../model-manager"; import type { Api, FetchImpl } from "../types"; /** Config passed to a provider's runtime model-manager factory. */ -export type ModelManagerConfig = { apiKey?: string; baseUrl?: string; fetch?: FetchImpl }; +export type ModelManagerConfig = { + apiKey?: string; + baseUrl?: string; + fetch?: FetchImpl; + /** The supplied fetch already applies provider-specific authentication. */ + authenticated?: boolean; +}; /** Catalog discovery configuration for providers that support endpoint-based model listing. */ export interface CatalogDiscoveryConfig { diff --git a/packages/catalog/src/provider-models/descriptors.ts b/packages/catalog/src/provider-models/descriptors.ts index 82b2d5239..20af095aa 100644 --- a/packages/catalog/src/provider-models/descriptors.ts +++ b/packages/catalog/src/provider-models/descriptors.ts @@ -15,6 +15,7 @@ import { alibabaTokenPlanModelManagerOptions, anthropicModelManagerOptions, basetenModelManagerOptions, + bedrockMantleModelManagerOptions, cerebrasModelManagerOptions, cloudflareAiGatewayModelManagerOptions, coreWeaveModelManagerOptions, @@ -109,6 +110,13 @@ export const CATALOG_PROVIDERS = [ id: "amazon-bedrock", defaultModel: "us.anthropic.claude-opus-4-8", }, + { + id: "bedrock-mantle", + defaultModel: "openai.gpt-5.6-terra", + envVars: ["AWS_BEARER_TOKEN_BEDROCK"], + createModelManagerOptions: (config: ModelManagerConfig) => bedrockMantleModelManagerOptions(config), + dynamicModelsAuthoritative: true, + }, { 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 9b1c10d31..78025a4f0 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -31,6 +31,7 @@ import { } from "../wire/github-copilot"; import { createBundledReferenceMap, createReferenceResolver, toModelSpec } from "./bundled-references"; import { getDefaultModelDiscoveryBaseUrl, resolveModelCacheProviderId } from "./cache-provider-id"; +import type { ModelManagerConfig } from "./descriptor-types"; const MODELS_DEV_URL = "https://catalog.stencil.so/models.json.zstd"; @@ -3622,6 +3623,122 @@ 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: 0.22, output: 1.32, cacheRead: 0.022, cacheWrite: 0.275 }, + 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.2, output: 13.2, cacheRead: 0.22, cacheWrite: 2.75 }, + contextWindow: 272_000, + maxTokens: 128_000, + thinking: BEDROCK_MANTLE_GPT_5_6_THINKING, + }, +]; + +const BEDROCK_MANTLE_MODEL_BY_ID: Partial>> = Object.fromEntries( + BEDROCK_MANTLE_STATIC_MODELS.map(model => [model.id, model]), +); + +export function bedrockMantleModelManagerOptions( + config: ModelManagerConfig = {}, +): ModelManagerOptions<"openai-responses"> { + const inferenceBaseUrl = config.baseUrl ?? BEDROCK_MANTLE_BASE_URL; + const discoveryBaseUrl = inferenceBaseUrl.replace(/\/openai\/v1\/?$/, "/v1"); + return { + providerId: "bedrock-mantle", + staticModels: BEDROCK_MANTLE_STATIC_MODELS, + ...(config.authenticated && { + fetchDynamicModels: () => + fetchOpenAICompatibleModels({ + api: "openai-responses", + provider: "bedrock-mantle", + baseUrl: discoveryBaseUrl, + fetch: config.fetch, + mapModel: (entry, defaults) => + mapWithBundledReference( + entry, + { ...defaults, baseUrl: BEDROCK_MANTLE_BASE_URL }, + BEDROCK_MANTLE_MODEL_BY_ID[defaults.id], + ), + }), + }), + }; +} + export interface MetaModelManagerConfig { apiKey?: string; baseUrl?: string; diff --git a/packages/catalog/test/amazon-bedrock-openai.test.ts b/packages/catalog/test/amazon-bedrock-openai.test.ts new file mode 100644 index 000000000..14391c0d9 --- /dev/null +++ b/packages/catalog/test/amazon-bedrock-openai.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-catalog/provider-models/descriptors"; +import { + BEDROCK_MANTLE_STATIC_MODELS, + bedrockMantleModelManagerOptions, +} from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { FetchImpl, ModelSpec } from "@oh-my-pi/pi-catalog/types"; +import { dropBedrockMantleOpenAIModels } from "../scripts/generated-policies"; + +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", +]; + +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("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("https://bedrock-mantle.{region}.api.aws/openai/v1"); + } + expect(DEFAULT_MODEL_PER_PROVIDER["bedrock-mantle"]).toBe("openai.gpt-5.6-terra"); + }); + + test("uses current Luna and Terra pricing", () => { + const byId = Object.fromEntries(BEDROCK_MANTLE_STATIC_MODELS.map(model => [model.id, model])); + expect(byId["openai.gpt-5.6-luna"]?.cost).toEqual({ + input: 0.22, + output: 1.32, + cacheRead: 0.022, + cacheWrite: 0.275, + }); + expect(byId["openai.gpt-5.6-terra"]?.cost).toEqual({ + input: 2.2, + output: 13.2, + cacheRead: 0.22, + cacheWrite: 2.75, + }); + }); + + test("builds bearer-authenticated Mantle runtime discovery", async () => { + let requestedUrl = ""; + const fetchImpl: FetchImpl = Object.assign( + async (input: string | URL | Request) => { + requestedUrl = String(input); + return Response.json({ + data: [ + { id: "openai.gpt-5.6-luna", name: "GPT-5.6 Luna" }, + { id: "openai.gpt-5.7-preview", name: "GPT-5.7 Preview" }, + ], + }); + }, + { preconnect: fetch.preconnect }, + ); + const managerOptions = bedrockMantleModelManagerOptions({ + authenticated: true, + baseUrl: "https://bedrock-mantle.eu-west-2.api.aws/openai/v1", + fetch: fetchImpl, + }); + + const models = await managerOptions.fetchDynamicModels?.(); + + expect(requestedUrl).toBe("https://bedrock-mantle.eu-west-2.api.aws/v1/models"); + expect(models).toHaveLength(2); + expect(models?.[0]).toMatchObject({ + id: "openai.gpt-5.6-luna", + baseUrl: "https://bedrock-mantle.{region}.api.aws/openai/v1", + cost: { input: 0.22, output: 1.32, cacheRead: 0.022, cacheWrite: 0.275 }, + }); + const descriptor = PROVIDER_DESCRIPTORS.find(descriptor => descriptor.providerId === "bedrock-mantle"); + expect(descriptor).toMatchObject({ dynamicModelsAuthoritative: true }); + expect(descriptor?.catalogDiscovery).toBeUndefined(); + }); + + 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(dropBedrockMantleOpenAIModels(input).map(model => `${model.provider}/${model.id}`)).toEqual([ + "amazon-bedrock/openai.gpt-oss-120b", + "bedrock-mantle/openai.gpt-5.6-sol", + ]); + }); +}); diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 00f6e02bd..1662b2fed 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -68,6 +68,7 @@ import type { ApiKeyResolver, FetchImpl } from "@oh-my-pi/pi-ai"; import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types"; import { setCodexAttestationProvider } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; +import { getProviderDefinition } from "@oh-my-pi/pi-ai/registry"; import { getBundledModelReferenceIndex, inheritReferenceThinking, @@ -2011,14 +2012,15 @@ export class ModelRegistry { this.#providerOverrides.has(descriptor.providerId) || this.#keylessProviders.has(descriptor.providerId)); if (isAuthenticated(apiKey) || descriptor.allowUnauthenticated || hasExplicitVllmConfig) { - const discoveryBaseUrl = this.#descriptorBaseUrl(descriptor.providerId); - options.push( - descriptor.createModelManagerOptions({ - apiKey: isDiscoveryBearerApiKey(apiKey) ? apiKey : undefined, - baseUrl: discoveryBaseUrl, - fetch: this.#fetch, - }), - ); + const discoveryConfig = { + apiKey: isDiscoveryBearerApiKey(apiKey) ? apiKey : undefined, + baseUrl: this.#descriptorBaseUrl(descriptor.providerId), + fetch: this.#fetch, + }; + const preparedConfig = + getProviderDefinition(descriptor.providerId)?.prepareModelDiscovery?.(discoveryConfig) ?? + discoveryConfig; + options.push(descriptor.createModelManagerOptions(preparedConfig)); } }