Merge PR #7080: fix(ai): add authenticated Bedrock Mantle routing (@anatoli-tsinovoy)

# Conflicts:
#	packages/ai/src/registry/registry.ts
#	packages/catalog/scripts/generated-policies.ts
#	packages/catalog/src/models.json
This commit is contained in:
can1357
2026-08-03 14:36:52 +02:00
27 changed files with 1705 additions and 289 deletions
+1
View File
@@ -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.
+5 -1
View File
@@ -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 {
+5 -5
View File
@@ -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 = "<authenticated>";
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;
+262 -76
View File
@@ -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<string, CacheEntry> = new Map();
const inflight: Map<string, Promise<ResolvedCredentials>> = 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<ResolvedCredentials> {
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<ResolvedCredentials> {
@@ -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<string, Record<string, string>>;
function parseIni(text: string): IniFile {
const out: IniFile = {};
let current: Record<string, string> | 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<IniFile | undefined> {
async function readIniFile(p: string): Promise<AwsIniFile | undefined> {
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<IniFile | undefined> {
async function readProfileCredentials(
profile: string,
region: string,
loadSharedConfig: boolean,
signal: AbortSignal | undefined,
fetchImpl: FetchImpl,
): Promise<ResolvedCredentials | undefined> {
@@ -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<string, string>,
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]*?)</${tag}>`).exec(xml)?.[1];
if (!value) return undefined;
return value
.replaceAll("&amp;", "&")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", '"')
.replaceAll("&apos;", "'");
}
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<ResolvedCredentials | undefined> {
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<ResolvedCredentials | undefined> {
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<ResolvedCredentials | undefined> {
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)));
}
+110
View File
@@ -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<Uint8Array> {
if (init?.body !== undefined && init.body !== null) {
if (typeof init.body === "string") return new TextEncoder().encode(init.body);
if (init.body instanceof Uint8Array) return init.body;
if (init.body instanceof ArrayBuffer) return new Uint8Array(init.body);
throw new TypeError(`Cannot SigV4-sign ${init.body.constructor?.name ?? typeof init.body} request body`);
}
if (input instanceof Request) return new Uint8Array(await input.clone().arrayBuffer());
return new Uint8Array();
}
function createSignedFetch(options: BedrockMantleOptions, region: string): FetchImpl {
const baseFetch = options.fetch ?? (globalThis.fetch as FetchImpl);
const signedFetch = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const url = new URL(input instanceof Request ? input.url : input.toString());
const method = init?.method ?? (input instanceof Request ? input.method : "POST");
const headers = new Headers(input instanceof Request ? input.headers : undefined);
for (const [name, value] of new Headers(init?.headers)) headers.set(name, value);
headers.delete("authorization");
const body = await requestBody(input, init);
const credentials = await resolveAwsCredentials({
profile: options.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<Response> => {
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),
},
};
}
+9 -14
View File
@@ -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 "<authenticated>";
}
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;
+57
View File
@@ -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<Record<string, unknown>> {
/** 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;
}
@@ -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;
+2 -2
View File
@@ -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 "<authenticated>";
return AUTHENTICATED_SENTINEL;
}
},
} as const satisfies ProviderDefinition;
+2
View File
@@ -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,
];
+30
View File
@@ -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 = "<authenticated>";
export interface PreparedProviderRequest {
readonly model: Model<Api>;
readonly options: StreamOptions;
}
export type ProviderRequestPreparer = (model: Model<Api>, options: StreamOptions) => PreparedProviderRequest;
export type ProviderSimpleOptionsMapper = (options: SimpleStreamOptions) => Readonly<Record<string, unknown>>;
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<OAuthCredentials | string>;
readonly refreshToken?: (credentials: OAuthCredentials) => Promise<OAuthCredentials>;
+46 -30
View File
@@ -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<TApi extends Api>(
} 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<Api>, requestOptions as StreamOptions);
const providerModel = prepared?.model ?? (model as Model<Api>);
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<TApi extends Api>(
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<TApi extends Api>(
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<TApi extends Api>(
if (apiKeyResolver) {
const outer = new AssistantMessageEventStream();
const signal = requestOptions?.signal;
// One inner attempt against a resolved string key. A retryable auth error
// that arrives before any replay-unsafe event is buffered and returned
// (so the caller can retry with a fresh key) instead of surfaced. Once any
// non-start event escapes, retry is no longer safe and the failure is
// emitted directly.
const runAttempt = async (apiKey: string): Promise<AuthRetryFailure | undefined> => {
// One inner attempt against a resolved key, or against the Bedrock AWS
// credential chain when its optional resolver has no stored bearer key.
// Retryable auth failures are buffered until replay is safe.
const runAttempt = async (apiKey?: string): Promise<AuthRetryFailure | undefined> => {
const bufferedEvents: AssistantMessageEvent[] = [];
let emittedReplayUnsafeEvent = false;
const flushBuffered = (): void => {
@@ -1107,6 +1109,11 @@ export function streamSimple<TApi extends Api>(
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<TApi extends Api>(
// Bedrock doesn't have any API keys instead it sources credentials from standard AWS env variables or from given AWS profile.
const providerOptions = mapOptionsForApi(model, requestOptions, undefined);
return stream(model, context, providerOptions);
} else if (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<TApi extends Api>(
apiKey?: string,
): OptionsForApi<TApi> {
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<TApi extends Api>(
execHandlers: options?.execHandlers,
fetch: options?.fetch,
fallbacks: options?.fallbacks,
...simpleProviderOptions,
};
switch (model.api) {
+5
View File
@@ -433,6 +433,11 @@ export interface StreamOptions {
* For example, Anthropic uses `user_id` for abuse tracking and rate limiting.
*/
metadata?: Record<string, unknown>;
/**
* Provider-owned request configuration. Provider hooks interpret this bag;
* generic API transports do not forward its fields onto the wire.
*/
providerOptions?: Readonly<Record<string, unknown>>;
/** OpenAI Responses/Codex response fields to include verbatim. */
include?: OpenAIResponseInclude[];
/**
+88
View File
@@ -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<string, Record<string, string>>;
export function parseAwsIni(text: string): AwsIniFile {
const out: AwsIniFile = {};
let current: Record<string, string> | 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);
}
+239 -1
View File
@@ -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<string, string | undefined>();
@@ -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(
`<AssumeRoleWithWebIdentityResponse><AssumeRoleWithWebIdentityResult><Credentials>
<AccessKeyId>AKIAWEB</AccessKeyId><SecretAccessKey>web-secret</SecretAccessKey>
<SessionToken>web-token</SessionToken><Expiration>2099-01-01T00:00:00Z</Expiration>
</Credentials></AssumeRoleWithWebIdentityResult></AssumeRoleWithWebIdentityResponse>`,
{ 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(
`<AssumeRoleWithWebIdentityResponse><AssumeRoleWithWebIdentityResult><Credentials>
<AccessKeyId>AKIAWEB</AccessKeyId><SecretAccessKey>web-secret</SecretAccessKey>
<SessionToken>web-token</SessionToken>
</Credentials></AssumeRoleWithWebIdentityResult></AssumeRoleWithWebIdentityResponse>`,
{ headers: { "content-type": "text/xml" } },
),
{ preconnect: fetch.preconnect },
);
await expect(resolveAwsCredentials({ region: "us-east-1", fetch: fetchImpl })).rejects.toThrow(
/missing or invalid Expiration/,
);
});
});
+101
View File
@@ -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(),
);
});
});
@@ -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<string> {
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(
@@ -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<string, string | undefined>,
options: BedrockMantleOptions = {},
): Promise<Capture> {
const capture: Capture = {};
await withEnv({ ...cleanAwsEnv, ...env }, async () => {
clearAwsCredentialCache();
await stream(mantleModel, context, { ...options, fetch: captureFetch(capture), maxTokens: 16 }).result();
});
return capture;
}
describe("Bedrock Mantle authentication", () => {
test("uses the configured region and Bedrock bearer token", async () => {
const capture = await runDirect({
AWS_BEARER_TOKEN_BEDROCK: "test-token",
AWS_REGION: "us-east-2",
});
expect(capture.url).toStartWith("https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses");
expect(capture.authorization).toBe("Bearer test-token");
});
test("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");
});
});
+7
View File
@@ -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
+11 -1
View File
@@ -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
@@ -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<string, true> = {
"openai.gpt-5.4": true,
"openai.gpt-5.5": true,
"openai.gpt-5.6-luna": true,
"openai.gpt-5.6-sol": true,
"openai.gpt-5.6-terra": true,
};
/**
* models.dev exposes these Responses-only models under amazon-bedrock, whose
* descriptor uses Converse. The working Mantle rows come from the static seed.
*/
export function dropBedrockMantleOpenAIModels(models: readonly ModelSpec[]): ModelSpec[] {
return models.filter(model => !(model.provider === "amazon-bedrock" && BEDROCK_MANTLE_OPENAI_MODEL_IDS[model.id]));
}
/** 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;
+151 -149
View File
@@ -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",
@@ -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 {
@@ -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",
@@ -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<Record<string, ModelSpec<"openai-responses">>> = 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;
@@ -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",
]);
});
});
@@ -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));
}
}