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:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(""", '"')
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function stsEndpoint(region: string): string {
|
||||
const dnsSuffix = region.startsWith("cn-") ? "amazonaws.com.cn" : "amazonaws.com";
|
||||
return `https://sts.${region}.${dnsSuffix}/`;
|
||||
}
|
||||
|
||||
async function readWebIdentityCredentials(
|
||||
region: string,
|
||||
signal: AbortSignal | undefined,
|
||||
fetchImpl: FetchImpl,
|
||||
): Promise<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)));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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[];
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user