fix(ai): address Bedrock Mantle review feedback

This commit is contained in:
Anatoli Tsinovoy
2026-08-01 12:29:07 +03:00
parent 7aa772beee
commit e5541a577a
24 changed files with 1997 additions and 670 deletions
+1 -1
View File
@@ -22,7 +22,7 @@
### Fixed
- Added Bedrock Mantle region selection and bearer-token or SigV4 authentication for OpenAI Responses models.
- 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 -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;
+102 -43
View File
@@ -21,7 +21,13 @@ 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 } from "../utils/aws-profile";
import {
type AwsIniFile,
parseAwsIni,
resolveAwsProfile,
resolveAwsRegion,
shouldLoadAwsSharedConfig,
} from "../utils/aws-profile";
import { isLocalOrMetadataHost } from "../utils/proxy";
import type { AwsCredentials } from "./aws-sigv4";
@@ -52,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;
@@ -60,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;
@@ -78,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 {
@@ -92,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> {
@@ -104,7 +139,7 @@ async function resolveFresh(
if (webIdentityCreds) return webIdentityCreds;
// 3. Profile (static, SSO, or credential_process).
const profileCreds = await readProfileCredentials(profile, region, signal, fetchImpl);
const profileCreds = await readProfileCredentials(profile, region, loadSharedConfig, signal, fetchImpl);
if (profileCreds) return profileCreds;
// 4. ECS/container credentials.
@@ -149,6 +184,7 @@ async function readIniFile(p: string): Promise<AwsIniFile | undefined> {
async function readProfileCredentials(
profile: string,
region: string,
loadSharedConfig: boolean,
signal: AbortSignal | undefined,
fetchImpl: FetchImpl,
): Promise<ResolvedCredentials | undefined> {
@@ -157,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.
@@ -380,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;
}
@@ -501,6 +538,11 @@ function xmlTag(xml: string, tag: string): string | undefined {
.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,
@@ -531,7 +573,7 @@ async function readWebIdentityCredentials(
RoleSessionName: $env.AWS_ROLE_SESSION_NAME || `omp-${process.pid}`,
WebIdentityToken: token,
});
const response = await fetchImpl(`https://sts.${region}.amazonaws.com/`, {
const response = await fetchImpl(stsEndpoint(region), {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: body.toString(),
@@ -553,10 +595,13 @@ async function readWebIdentityCredentials(
"web-identity",
);
}
const credentials: ResolvedCredentials = { accessKeyId, secretAccessKey, sessionToken };
const expiration = xmlTag(xml, "Expiration");
if (expiration) credentials.expiresAt = Date.parse(expiration);
return credentials;
const expiresAt = requireDynamicCredentialExpiration(xmlTag(xml, "Expiration"), "AWS web identity", "web-identity");
return {
accessKeyId,
secretAccessKey,
sessionToken,
expiresAt,
};
}
// ---------- ECS/container credentials ----------
@@ -568,6 +613,8 @@ interface ContainerCredentialResponse {
Expiration?: string;
}
const ECS_TASK_CREDENTIALS_BASE_URL = new URL("http://169.254.170.2/");
async function readContainerCredentials(
signal: AbortSignal | undefined,
fetchImpl: FetchImpl,
@@ -577,13 +624,13 @@ async function readContainerCredentials(
if (!relativeUri && !fullUri) return undefined;
let endpoint: URL;
if (relativeUri) {
if (!relativeUri.startsWith("/")) {
if (!relativeUri.startsWith("/") || relativeUri.startsWith("//")) {
throw new AIError.AwsCredentialsError(
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI must start with '/'.",
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI must be a single-host absolute path.",
"container",
);
}
endpoint = new URL(`http://169.254.170.2${relativeUri}`);
endpoint = new URL(relativeUri.slice(1), ECS_TASK_CREDENTIALS_BASE_URL);
} else {
try {
endpoint = new URL(fullUri as string);
@@ -626,55 +673,66 @@ async function readContainerCredentials(
);
}
const body = (await response.json()) as ContainerCredentialResponse;
if (!body.AccessKeyId || !body.SecretAccessKey) {
if (!body.AccessKeyId || !body.SecretAccessKey || !body.Token) {
throw new AIError.AwsCredentialsError(
"AWS container credential response is missing AccessKeyId/SecretAccessKey.",
"AWS container credential response is missing AccessKeyId/SecretAccessKey/Token.",
"container",
);
}
const credentials: ResolvedCredentials = {
return {
accessKeyId: body.AccessKeyId,
secretAccessKey: body.SecretAccessKey,
sessionToken: body.Token,
expiresAt: requireDynamicCredentialExpiration(body.Expiration, "AWS container credential", "container"),
};
if (body.Token) credentials.sessionToken = body.Token;
if (body.Expiration) credentials.expiresAt = Date.parse(body.Expiration);
return credentials;
}
// ---------- 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;
const endpoint = ($env.AWS_EC2_METADATA_SERVICE_ENDPOINT || `http://${IMDS_HOST}`).replace(/\/+$/, "");
try {
const tokenRes = await fetchImpl(`${endpoint}/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(`${endpoint}/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(
`${endpoint}/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;
@@ -684,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;
}
@@ -707,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)));
}
+40 -14
View File
@@ -1,15 +1,15 @@
import { $env } from "@oh-my-pi/pi-utils";
import { AUTHENTICATED_SENTINEL } from "../registry/types";
import { type AwsBedrockProviderOptions, resolveAwsBearerToken } from "../registry/aws";
import type { FetchImpl, Model } from "../types";
import { resolveAwsCredentials } from "./aws-credentials";
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 {
region?: string;
profile?: string;
/** Amazon Bedrock API key sent as a bearer token, ahead of SigV4 credential resolution. */
bearerToken?: string;
providerOptions?: BedrockMantleProviderOptions;
}
async function requestBody(input: string | URL | Request, init?: RequestInit): Promise<Uint8Array> {
@@ -33,7 +33,7 @@ function createSignedFetch(options: BedrockMantleOptions, region: string): Fetch
headers.delete("authorization");
const body = await requestBody(input, init);
const credentials = await resolveAwsCredentials({
profile: options.profile,
profile: options.providerOptions?.profile,
region,
signal: options.signal,
fetch: baseFetch,
@@ -52,11 +52,38 @@ function createSignedFetch(options: BedrockMantleOptions, region: string): Fetch
for (const [name, value] of Object.entries(signed)) {
if (value !== undefined && name !== "host") headers.set(name, value);
}
return baseFetch(url, { ...init, method, headers, body });
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;
@@ -66,10 +93,9 @@ export function prepareBedrockMantleRequest(
model: Model<"openai-responses">,
options: BedrockMantleOptions,
): PreparedBedrockMantleRequest {
const region = options.region || $env.AWS_REGION || $env.AWS_DEFAULT_REGION || "us-east-1";
const region = resolveAwsRegion(options.providerOptions?.region, options.providerOptions?.profile);
const resolvedModel = { ...model, baseUrl: model.baseUrl.replaceAll("{region}", encodeURIComponent(region)) };
const apiKey = options.apiKey === AUTHENTICATED_SENTINEL || options.apiKey === "N/A" ? undefined : options.apiKey;
const bearerToken = options.bearerToken || apiKey || $env.AWS_BEARER_TOKEN_BEDROCK;
const bearerToken = resolveBearerToken(options);
if (bearerToken) {
return { model: resolvedModel, options: { ...options, apiKey: bearerToken } };
}
@@ -77,8 +103,8 @@ export function prepareBedrockMantleRequest(
model: resolvedModel,
options: {
...options,
apiKey: "N/A",
fetch: createSignedFetch(options, region),
apiKey: NO_AUTH_SENTINEL,
fetch: createBedrockMantleAuthenticatedFetch(options),
},
};
}
+11 -3
View File
@@ -1,9 +1,17 @@
import { hasAwsCredentialSource } from "./aws";
import { AUTHENTICATED_SENTINEL, type ProviderDefinition } from "./types";
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: () => (hasAwsCredentialSource() ? AUTHENTICATED_SENTINEL : undefined),
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;
+21 -1
View File
@@ -1,9 +1,18 @@
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 {
if ($env.AWS_EXECUTION_ENV?.includes("EC2")) return true;
for (const candidate of [
"/sys/hypervisor/uuid",
"/sys/devices/virtual/dmi/id/product_uuid",
@@ -35,3 +44,14 @@ export function hasAwsCredentialSource(): boolean {
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;
}
+29 -3
View File
@@ -1,8 +1,34 @@
import { hasAwsCredentialSource } from "./aws";
import { AUTHENTICATED_SENTINEL, type ProviderDefinition } from "./types";
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: () => (hasAwsCredentialSource() ? AUTHENTICATED_SENTINEL : undefined),
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;
+27
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";
/**
@@ -21,6 +23,23 @@ 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
@@ -45,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>;
+33 -39
View File
@@ -24,7 +24,6 @@ import { isInvalidatedOAuthTokenError } from "./error/auth-classify";
import { isUsageLimitOutcome } from "./error/rate-limit";
import type { BedrockOptions } from "./providers/amazon-bedrock";
import type { AnthropicOptions } from "./providers/anthropic";
import { type BedrockMantleOptions, prepareBedrockMantleRequest } from "./providers/bedrock-mantle";
import type { CursorOptions } from "./providers/cursor";
import type { DevinOptions } from "./providers/devin";
import { isGitLabDuoModel, streamGitLabDuo } from "./providers/gitlab-duo";
@@ -60,7 +59,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,
@@ -806,39 +805,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);
} else if (model.provider === "bedrock-mantle" && model.api === "openai-responses") {
const prepared = prepareBedrockMantleRequest(
model as Model<"openai-responses">,
requestOptions as BedrockMantleOptions,
);
return streamOpenAIResponses(prepared.model, context, prepared.options);
}
const apiKey = requestOptions.apiKey || getEnvApiKey(model.provider);
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 +843,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 +857,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}`);
@@ -1104,7 +1101,7 @@ export function streamSimple<TApi extends Api>(
return;
}
if (lastKey === undefined) {
if (model.provider === "bedrock-mantle") {
if (getProviderDefinition(model.provider)?.allowsMissingApiKey) {
const failure = await runAttempt();
if (failure) emitFailure(failure);
return;
@@ -1158,11 +1155,11 @@ export function streamSimple<TApi extends Api>(
// Bedrock doesn't have any API keys instead it sources credentials from standard AWS env variables or from given AWS profile.
const providerOptions = mapOptionsForApi(model, requestOptions, undefined);
return stream(model, context, providerOptions);
} else if (model.provider === "bedrock-mantle" && model.api === "openai-responses") {
} else if (getProviderDefinition(model.provider)?.allowsMissingApiKey) {
const providerOptions = mapOptionsForApi(
model,
requestOptions,
typeof requestOptions.apiKey === "string" ? requestOptions.apiKey : undefined,
typeof requestOptions.apiKey === "string" ? requestOptions.apiKey : getEnvApiKey(model.provider),
);
return stream(model, context, providerOptions);
}
@@ -1463,6 +1460,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,
@@ -1492,6 +1490,7 @@ function mapOptionsForApi<TApi extends Api>(
execHandlers: options?.execHandlers,
fetch: options?.fetch,
fallbacks: options?.fallbacks,
...simpleProviderOptions,
};
switch (model.api) {
@@ -1677,11 +1676,6 @@ function mapOptionsForApi<TApi extends Api>(
textVerbosity: options?.textVerbosity,
promptCache: options?.promptCache,
statefulResponses: options?.statefulResponses,
...(model.provider === "bedrock-mantle" && {
region: options?.region,
profile: options?.profile,
bearerToken: options?.bearerToken,
}),
});
case "azure-openai-responses":
+5 -6
View File
@@ -434,6 +434,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[];
/**
@@ -639,12 +644,6 @@ export interface SimpleStreamOptions extends Omit<StreamOptions, "apiKey"> {
* provider. Non-Anthropic providers ignore the field.
*/
fallbacks?: FallbackParam[];
/** AWS region override for Amazon Bedrock transports. */
region?: string;
/** AWS profile override for Amazon Bedrock transports. */
profile?: string;
/** Amazon Bedrock API key, preferred over SigV4 credential resolution. */
bearerToken?: string;
}
// Generic StreamFunction with typed options
+36 -3
View File
@@ -40,12 +40,45 @@ function readAwsIniSync(filePath: string): AwsIniFile | undefined {
}
}
export function hasConfiguredAwsProfile(profile = $env.AWS_PROFILE || "default"): boolean {
/** 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 = readAwsIniSync(configPath);
const merged = { ...(configIni?.[profile] ?? {}), ...(credentialsIni?.[profile] ?? {}) };
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;
+124 -2
View File
@@ -9,6 +9,7 @@ import {
} 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
@@ -20,12 +21,14 @@ 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",
@@ -221,6 +224,41 @@ describe("resolveAwsCredentials", () => {
});
});
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");
@@ -257,12 +295,74 @@ describe("resolveAwsCredentials", () => {
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(
@@ -280,9 +380,9 @@ describe("resolveAwsCredentials", () => {
{ preconnect: fetch.preconnect },
);
const credentials = await resolveAwsCredentials({ region: "us-east-2", fetch: fetchImpl });
const credentials = await resolveAwsCredentials({ profile: "regional", fetch: fetchImpl });
expect(requestedUrl).toBe("https://sts.us-east-2.amazonaws.com/");
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({
@@ -292,4 +392,26 @@ describe("resolveAwsCredentials", () => {
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/,
);
});
});
+25
View File
@@ -11,6 +11,7 @@ const EMPTY_AWS_ENV = {
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,
@@ -61,6 +62,30 @@ describe("AWS provider availability", () => {
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(
{
@@ -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";
@@ -137,7 +141,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(
@@ -212,6 +216,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(
+99 -2
View File
@@ -1,9 +1,14 @@
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({
@@ -27,6 +32,10 @@ const cleanAwsEnv = {
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",
};
@@ -35,6 +44,7 @@ interface Capture {
url?: string;
authorization?: string | null;
securityToken?: string | null;
body?: RequestInit["body"];
}
function captureFetch(capture: Capture): FetchImpl {
@@ -44,6 +54,7 @@ function captureFetch(capture: Capture): FetchImpl {
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 },
@@ -72,6 +83,60 @@ describe("Bedrock Mantle authentication", () => {
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",
@@ -84,6 +149,36 @@ describe("Bedrock Mantle authentication", () => {
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;
@@ -92,8 +187,10 @@ describe("Bedrock Mantle authentication", () => {
resolverCalls++;
return "resolved-token";
},
region: "us-east-2",
profile: "ignored-for-bearer",
providerOptions: {
region: "us-east-2",
profile: "ignored-for-bearer",
},
fetch: captureFetch(capture),
maxTokens: 16,
};
+2 -2
View File
@@ -4,11 +4,11 @@
### Added
- Added the `bedrock-mantle` provider for OpenAI GPT-5.4, GPT-5.5, and GPT-5.6 models served through Amazon Bedrock's Responses endpoint.
- 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.
- 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
+7 -2
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 { fetchCodexModels } from "../src/discovery/codex";
@@ -123,7 +124,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
@@ -555,7 +559,8 @@ async function generateModels() {
// Seed Meta's documented Muse model so first-run selection does not depend on
// credentials or live discovery.
allModels.push(...META_MUSE_STATIC_MODELS);
// Bedrock Mantle has no catalog endpoint used by generation.
// 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,
File diff suppressed because it is too large Load Diff
@@ -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 {
@@ -14,6 +14,7 @@ import {
alibabaTokenPlanModelManagerOptions,
anthropicModelManagerOptions,
basetenModelManagerOptions,
bedrockMantleModelManagerOptions,
cerebrasModelManagerOptions,
cloudflareAiGatewayModelManagerOptions,
coreWeaveModelManagerOptions,
@@ -102,6 +103,9 @@ export const CATALOG_PROVIDERS = [
{
id: "bedrock-mantle",
defaultModel: "openai.gpt-5.6-terra",
envVars: ["AWS_BEARER_TOKEN_BEDROCK"],
createModelManagerOptions: (config: ModelManagerConfig) => bedrockMantleModelManagerOptions(config),
dynamicModelsAuthoritative: true,
},
{
id: "anthropic",
@@ -30,6 +30,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://models.dev/api.json";
@@ -3392,7 +3393,7 @@ export const BEDROCK_MANTLE_STATIC_MODELS: readonly ModelSpec<"openai-responses"
baseUrl: BEDROCK_MANTLE_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 1.1, output: 6.6, cacheRead: 0.11, cacheWrite: 1.38 },
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,
@@ -3418,13 +3419,43 @@ export const BEDROCK_MANTLE_STATIC_MODELS: readonly ModelSpec<"openai-responses"
baseUrl: BEDROCK_MANTLE_BASE_URL,
reasoning: true,
input: ["text", "image"],
cost: { input: 2.75, output: 16.5, cacheRead: 0.28, cacheWrite: 3.44 },
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;
@@ -1,7 +1,10 @@
import { describe, expect, test } from "bun:test";
import { DEFAULT_MODEL_PER_PROVIDER } from "@oh-my-pi/pi-catalog/provider-models/descriptors";
import { BEDROCK_MANTLE_STATIC_MODELS } from "@oh-my-pi/pi-catalog/provider-models/openai-compat";
import type { ModelSpec } from "@oh-my-pi/pi-catalog/types";
import { 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 = [
@@ -38,6 +41,56 @@ describe("Amazon Bedrock OpenAI routing", () => {
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)),
@@ -67,6 +67,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,
@@ -1997,14 +1998,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));
}
}