@oh-my-pi/pi-ai 17.2.5 → 17.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/dist/types/auth-broker/client.d.ts +7 -0
- package/dist/types/auth-broker/server.d.ts +11 -0
- package/dist/types/auth-broker/wire-schema-resource.d.ts +1 -1
- package/dist/types/auth-broker/wire-schemas.d.ts +4 -2
- package/dist/types/dialect/examples.d.ts +8 -0
- package/dist/types/error/aws.d.ts +5 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/providers/anthropic-messages-server-schema.d.ts +695 -255
- package/dist/types/providers/aws-credentials.d.ts +5 -10
- package/dist/types/providers/bedrock-mantle.d.ts +13 -0
- package/dist/types/providers/openai-chat-server-schema.d.ts +909 -293
- package/dist/types/providers/openai-responses-server-schema.d.ts +980 -353
- package/dist/types/registry/amazon-bedrock.d.ts +7 -1
- package/dist/types/registry/aws.d.ts +13 -0
- package/dist/types/registry/bedrock-mantle.d.ts +22 -0
- package/dist/types/registry/registry.d.ts +25 -1
- package/dist/types/registry/types.d.ts +24 -0
- package/dist/types/types.d.ts +13 -5
- package/dist/types/usage.d.ts +152 -46
- package/dist/types/utils/aws-profile.d.ts +17 -0
- package/dist/types/utils/schema/wire.d.ts +1 -1
- package/dist/types/utils/validation.d.ts +24 -0
- package/package.json +5 -5
- package/src/auth-broker/client.ts +2 -1
- package/src/auth-broker/server.ts +2 -1
- package/src/auth-broker/wire-schema-resource.ts +1 -1
- package/src/dialect/examples.ts +34 -8
- package/src/dialect/inventory.ts +2 -2
- package/src/error/aws.ts +5 -1
- package/src/index.ts +1 -1
- package/src/providers/amazon-bedrock.ts +5 -5
- package/src/providers/anthropic-messages-server-schema.ts +1 -1
- package/src/providers/anthropic-messages-server.ts +1 -1
- package/src/providers/aws-credentials.ts +262 -76
- package/src/providers/bedrock-mantle.ts +110 -0
- package/src/providers/cursor.ts +31 -3
- package/src/providers/google-gemini-cli.ts +1 -1
- package/src/providers/ollama.ts +27 -1
- package/src/providers/openai-chat-server-schema.ts +1 -1
- package/src/providers/openai-chat-server.ts +1 -1
- package/src/providers/openai-codex-responses.ts +1 -1
- package/src/providers/openai-responses-server-schema.ts +1 -1
- package/src/providers/openai-responses-server.ts +1 -1
- package/src/registry/amazon-bedrock.ts +9 -14
- package/src/registry/aws.ts +57 -0
- package/src/registry/bedrock-mantle.ts +34 -0
- package/src/registry/google-vertex.ts +2 -2
- package/src/registry/registry.ts +2 -0
- package/src/registry/types.ts +30 -0
- package/src/stream.ts +46 -30
- package/src/types.ts +14 -6
- package/src/usage.ts +1 -1
- package/src/utils/aws-profile.ts +88 -0
- package/src/utils/schema/wire.ts +1 -1
- package/src/utils/validation.ts +2 -1
package/src/error/aws.ts
CHANGED
|
@@ -9,7 +9,11 @@ export type AwsCredentialsErrorKind =
|
|
|
9
9
|
/** SSO `GetRoleCredentials` call failed or returned no role. */
|
|
10
10
|
| "sso-role"
|
|
11
11
|
/** External `credential_process` failed, timed out, or emitted bad output. */
|
|
12
|
-
| "credential-process"
|
|
12
|
+
| "credential-process"
|
|
13
|
+
/** STS web-identity exchange failed or returned malformed credentials. */
|
|
14
|
+
| "web-identity"
|
|
15
|
+
/** ECS/container credential endpoint failed or returned malformed credentials. */
|
|
16
|
+
| "container";
|
|
13
17
|
|
|
14
18
|
/** A failure resolving AWS credentials for the Bedrock provider. */
|
|
15
19
|
export class AwsCredentialsError extends Error {
|
package/src/index.ts
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
import type { Effort } from "@oh-my-pi/pi-catalog/effort";
|
|
11
11
|
import { mapEffortToAnthropicAdaptiveEffort, requireSupportedEffort } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
12
12
|
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
|
|
13
|
-
import { $
|
|
13
|
+
import { $flag, fetchWithRetry, parseStreamingJson, parseStreamingJsonThrottled } from "@oh-my-pi/pi-utils";
|
|
14
14
|
import { renderDemotedThinking } from "../dialect/demotion";
|
|
15
15
|
import * as AIError from "../error";
|
|
16
|
+
import { resolveAwsBearerToken } from "../registry/aws";
|
|
16
17
|
import type {
|
|
17
18
|
Api,
|
|
18
19
|
AssistantMessage,
|
|
@@ -30,6 +31,7 @@ import type {
|
|
|
30
31
|
ToolResultMessage,
|
|
31
32
|
} from "../types";
|
|
32
33
|
import { normalizeSystemPrompts, normalizeToolCallId, resolveCacheRetention } from "../utils";
|
|
34
|
+
import { resolveAwsAmbientRegion } from "../utils/aws-profile";
|
|
33
35
|
import {
|
|
34
36
|
clearStreamingPartialJson,
|
|
35
37
|
kStreamingBlockIndex,
|
|
@@ -74,11 +76,9 @@ export interface BedrockOptions extends StreamOptions {
|
|
|
74
76
|
*/
|
|
75
77
|
thinkingDisplay?: BedrockThinkingDisplay;
|
|
76
78
|
}
|
|
77
|
-
const AUTHENTICATED_API_KEY_SENTINEL = "<authenticated>";
|
|
78
79
|
|
|
79
80
|
function resolveBearerToken(options: BedrockOptions): string | undefined {
|
|
80
|
-
|
|
81
|
-
return options.bearerToken || apiKey || $env.AWS_BEARER_TOKEN_BEDROCK;
|
|
81
|
+
return resolveAwsBearerToken(options.apiKey, options.bearerToken);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
function inferRegionFromBedrockArn(modelId: string): string | undefined {
|
|
@@ -149,7 +149,7 @@ function regionServesGeo(region: string, geo: string): boolean {
|
|
|
149
149
|
function resolveBedrockRegion(modelId: string, options: BedrockOptions): string {
|
|
150
150
|
const explicit = options.region || inferRegionFromBedrockArn(modelId);
|
|
151
151
|
if (explicit) return explicit;
|
|
152
|
-
const ambient =
|
|
152
|
+
const ambient = resolveAwsAmbientRegion(options.profile);
|
|
153
153
|
const geo = inferenceProfileGeo(modelId);
|
|
154
154
|
if (geo) {
|
|
155
155
|
if (ambient && regionServesGeo(ambient, geo)) return ambient;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { type } from "@oh-my-pi/omptype";
|
|
1
2
|
import { Effort } from "@oh-my-pi/pi-catalog/effort";
|
|
2
3
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
3
|
-
import { type } from "arktype";
|
|
4
4
|
import { captureRequestHeaders, resolvePromptCacheKey } from "../auth-gateway/http";
|
|
5
5
|
import * as AIError from "../error";
|
|
6
6
|
import type {
|
|
@@ -4,16 +4,11 @@
|
|
|
4
4
|
* Chain (first hit wins):
|
|
5
5
|
* 1. Static credentials from the environment
|
|
6
6
|
* (`AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` [+ `AWS_SESSION_TOKEN`]).
|
|
7
|
-
* 2.
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* - `credential_process` — an external command emitting the AWS SDK
|
|
13
|
-
* `Version: 1` JSON envelope on stdout. Used by `aws-vault`, `granted`,
|
|
14
|
-
* in-house brokers, etc.
|
|
15
|
-
* 3. EC2 IMDSv2 (only when `AWS_EC2_METADATA_DISABLED` is unset / falsey and
|
|
16
|
-
* `169.254.169.254` is reachable within a 1 s timeout).
|
|
7
|
+
* 2. Web identity (`AWS_WEB_IDENTITY_TOKEN_FILE` + `AWS_ROLE_ARN`).
|
|
8
|
+
* 3. Profile in `~/.aws/credentials` (and `~/.aws/config` for SSO):
|
|
9
|
+
* - static keys, SSO, or `credential_process`.
|
|
10
|
+
* 4. ECS/container credentials from `AWS_CONTAINER_CREDENTIALS_*`.
|
|
11
|
+
* 5. EC2 IMDSv2 when metadata is enabled.
|
|
17
12
|
*
|
|
18
13
|
* Resolved credentials are cached process-wide per profile and refreshed
|
|
19
14
|
* 60 s before `Expiration` to absorb clock skew.
|
|
@@ -26,6 +21,14 @@ import { $env, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
|
26
21
|
import * as AIError from "../error";
|
|
27
22
|
import type { FetchImpl } from "../types";
|
|
28
23
|
import { raceWithSignal } from "../utils/abort";
|
|
24
|
+
import {
|
|
25
|
+
type AwsIniFile,
|
|
26
|
+
parseAwsIni,
|
|
27
|
+
resolveAwsProfile,
|
|
28
|
+
resolveAwsRegion,
|
|
29
|
+
shouldLoadAwsSharedConfig,
|
|
30
|
+
} from "../utils/aws-profile";
|
|
31
|
+
import { isLocalOrMetadataHost } from "../utils/proxy";
|
|
29
32
|
import type { AwsCredentials } from "./aws-sigv4";
|
|
30
33
|
|
|
31
34
|
export interface ResolvedCredentials extends AwsCredentials {
|
|
@@ -55,6 +58,23 @@ const FILE_SESSION_CREDS_TTL_MS = 5 * 60_000;
|
|
|
55
58
|
*/
|
|
56
59
|
const SHARED_RESOLVE_TIMEOUT_MS = 30_000;
|
|
57
60
|
|
|
61
|
+
function requireDynamicCredentialExpiration(
|
|
62
|
+
value: string | undefined,
|
|
63
|
+
source: "AWS web identity" | "AWS container credential",
|
|
64
|
+
kind: "web-identity" | "container",
|
|
65
|
+
): number {
|
|
66
|
+
const expiresAt = value ? Date.parse(value) : Number.NaN;
|
|
67
|
+
if (Number.isFinite(expiresAt)) return expiresAt;
|
|
68
|
+
throw new AIError.AwsCredentialsError(`${source} response has a missing or invalid Expiration.`, kind);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Credential-process expiry is optional; missing/malformed values disable caching. */
|
|
72
|
+
function dynamicCredentialExpiration(value: string | undefined): number {
|
|
73
|
+
if (!value) return Date.now();
|
|
74
|
+
const expiresAt = Date.parse(value);
|
|
75
|
+
return Number.isFinite(expiresAt) ? expiresAt : Date.now();
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
interface CacheEntry {
|
|
59
79
|
creds: ResolvedCredentials;
|
|
60
80
|
expiresAt: number;
|
|
@@ -63,10 +83,15 @@ interface CacheEntry {
|
|
|
63
83
|
const cache: Map<string, CacheEntry> = new Map();
|
|
64
84
|
const inflight: Map<string, Promise<ResolvedCredentials>> = new Map();
|
|
65
85
|
|
|
86
|
+
function credentialCacheKey(profile: string, region: string, loadSharedConfig: boolean): string {
|
|
87
|
+
return `${profile}\x00${region}\x00${loadSharedConfig ? "config" : "credentials"}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
66
90
|
export async function resolveAwsCredentials(opts: CredentialResolveOptions = {}): Promise<ResolvedCredentials> {
|
|
67
|
-
const profile = opts.profile
|
|
68
|
-
const region = opts.region
|
|
69
|
-
const
|
|
91
|
+
const profile = resolveAwsProfile(opts.profile);
|
|
92
|
+
const region = resolveAwsRegion(opts.region, opts.profile);
|
|
93
|
+
const loadSharedConfig = shouldLoadAwsSharedConfig(opts.profile);
|
|
94
|
+
const cacheKey = credentialCacheKey(profile, region, loadSharedConfig);
|
|
70
95
|
|
|
71
96
|
const hit = cache.get(cacheKey);
|
|
72
97
|
if (hit && hit.expiresAt - REFRESH_SKEW_MS > Date.now()) return hit.creds;
|
|
@@ -81,7 +106,13 @@ export async function resolveAwsCredentials(opts: CredentialResolveOptions = {})
|
|
|
81
106
|
const fetchImpl = opts.fetch ?? (globalThis.fetch as FetchImpl);
|
|
82
107
|
const promise = (async () => {
|
|
83
108
|
try {
|
|
84
|
-
const creds = await resolveFresh(
|
|
109
|
+
const creds = await resolveFresh(
|
|
110
|
+
profile,
|
|
111
|
+
region,
|
|
112
|
+
loadSharedConfig,
|
|
113
|
+
AbortSignal.timeout(SHARED_RESOLVE_TIMEOUT_MS),
|
|
114
|
+
fetchImpl,
|
|
115
|
+
);
|
|
85
116
|
cache.set(cacheKey, { creds, expiresAt: creds.expiresAt ?? Number.POSITIVE_INFINITY });
|
|
86
117
|
return creds;
|
|
87
118
|
} finally {
|
|
@@ -95,6 +126,7 @@ export async function resolveAwsCredentials(opts: CredentialResolveOptions = {})
|
|
|
95
126
|
async function resolveFresh(
|
|
96
127
|
profile: string,
|
|
97
128
|
region: string,
|
|
129
|
+
loadSharedConfig: boolean,
|
|
98
130
|
signal?: AbortSignal,
|
|
99
131
|
fetchImpl: FetchImpl = globalThis.fetch as FetchImpl,
|
|
100
132
|
): Promise<ResolvedCredentials> {
|
|
@@ -102,19 +134,27 @@ async function resolveFresh(
|
|
|
102
134
|
const envCreds = readEnvCredentials();
|
|
103
135
|
if (envCreds) return envCreds;
|
|
104
136
|
|
|
105
|
-
// 2.
|
|
106
|
-
const
|
|
137
|
+
// 2. Web identity.
|
|
138
|
+
const webIdentityCreds = await readWebIdentityCredentials(region, signal, fetchImpl);
|
|
139
|
+
if (webIdentityCreds) return webIdentityCreds;
|
|
140
|
+
|
|
141
|
+
// 3. Profile (static, SSO, or credential_process).
|
|
142
|
+
const profileCreds = await readProfileCredentials(profile, region, loadSharedConfig, signal, fetchImpl);
|
|
107
143
|
if (profileCreds) return profileCreds;
|
|
108
144
|
|
|
109
|
-
//
|
|
145
|
+
// 4. ECS/container credentials.
|
|
146
|
+
const containerCreds = await readContainerCredentials(signal, fetchImpl);
|
|
147
|
+
if (containerCreds) return containerCreds;
|
|
148
|
+
|
|
149
|
+
// 5. EC2 IMDSv2.
|
|
110
150
|
if ($env.AWS_EC2_METADATA_DISABLED?.toLowerCase() !== "true") {
|
|
111
151
|
const imdsCreds = await readImdsCredentials(signal, fetchImpl);
|
|
112
152
|
if (imdsCreds) return imdsCreds;
|
|
113
153
|
}
|
|
114
154
|
|
|
115
155
|
throw new AIError.AwsCredentialsError(
|
|
116
|
-
`Unable to resolve AWS credentials.
|
|
117
|
-
`
|
|
156
|
+
`Unable to resolve AWS credentials. Configure static environment keys, web identity, ` +
|
|
157
|
+
`an AWS profile, ECS credentials, or an EC2 instance role.`,
|
|
118
158
|
"resolution",
|
|
119
159
|
);
|
|
120
160
|
}
|
|
@@ -129,42 +169,10 @@ function readEnvCredentials(): ResolvedCredentials | undefined {
|
|
|
129
169
|
: { accessKeyId: ak, secretAccessKey: sk };
|
|
130
170
|
}
|
|
131
171
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
/** Map of section name -> map of key -> value. Section names are stripped of
|
|
135
|
-
* any leading `profile ` (so `~/.aws/config` aligns with `~/.aws/credentials`). */
|
|
136
|
-
type IniFile = Record<string, Record<string, string>>;
|
|
137
|
-
|
|
138
|
-
function parseIni(text: string): IniFile {
|
|
139
|
-
const out: IniFile = {};
|
|
140
|
-
let current: Record<string, string> | null = null;
|
|
141
|
-
for (const rawLine of text.split(/\r?\n/)) {
|
|
142
|
-
const line = rawLine.trim();
|
|
143
|
-
if (!line || line.startsWith("#") || line.startsWith(";")) continue;
|
|
144
|
-
if (line.startsWith("[") && line.endsWith("]")) {
|
|
145
|
-
let name = line.slice(1, -1).trim();
|
|
146
|
-
if (name.startsWith("profile ")) name = name.slice(8).trim();
|
|
147
|
-
if (name.startsWith("sso-session ")) name = `sso-session:${name.slice(12).trim()}`;
|
|
148
|
-
let section = out[name];
|
|
149
|
-
if (!section) {
|
|
150
|
-
section = {};
|
|
151
|
-
out[name] = section;
|
|
152
|
-
}
|
|
153
|
-
current = section;
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
if (!current) continue;
|
|
157
|
-
const eq = line.indexOf("=");
|
|
158
|
-
if (eq === -1) continue;
|
|
159
|
-
current[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
|
160
|
-
}
|
|
161
|
-
return out;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
async function readIniFile(p: string): Promise<IniFile | undefined> {
|
|
172
|
+
async function readIniFile(p: string): Promise<AwsIniFile | undefined> {
|
|
165
173
|
try {
|
|
166
174
|
const text = await fs.promises.readFile(p, "utf8");
|
|
167
|
-
return
|
|
175
|
+
return parseAwsIni(text);
|
|
168
176
|
} catch (err) {
|
|
169
177
|
if (isEnoent(err)) return undefined;
|
|
170
178
|
throw err;
|
|
@@ -176,6 +184,7 @@ async function readIniFile(p: string): Promise<IniFile | undefined> {
|
|
|
176
184
|
async function readProfileCredentials(
|
|
177
185
|
profile: string,
|
|
178
186
|
region: string,
|
|
187
|
+
loadSharedConfig: boolean,
|
|
179
188
|
signal: AbortSignal | undefined,
|
|
180
189
|
fetchImpl: FetchImpl,
|
|
181
190
|
): Promise<ResolvedCredentials | undefined> {
|
|
@@ -184,7 +193,7 @@ async function readProfileCredentials(
|
|
|
184
193
|
const configPath = $env.AWS_CONFIG_FILE || path.join(home, ".aws", "config");
|
|
185
194
|
|
|
186
195
|
const credentialsIni = await readIniFile(credentialsPath);
|
|
187
|
-
const configIni = await readIniFile(configPath);
|
|
196
|
+
const configIni = loadSharedConfig ? await readIniFile(configPath) : undefined;
|
|
188
197
|
|
|
189
198
|
// Static credentials live in ~/.aws/credentials; SSO config lives in
|
|
190
199
|
// ~/.aws/config under `[profile foo]`. Merge into a single view.
|
|
@@ -225,7 +234,7 @@ interface SsoCachedToken {
|
|
|
225
234
|
|
|
226
235
|
async function readSsoCredentials(
|
|
227
236
|
profileCfg: Record<string, string>,
|
|
228
|
-
configIni:
|
|
237
|
+
configIni: AwsIniFile | undefined,
|
|
229
238
|
defaultRegion: string,
|
|
230
239
|
signal: AbortSignal | undefined,
|
|
231
240
|
fetchImpl: FetchImpl,
|
|
@@ -407,10 +416,11 @@ async function readCredentialProcess(
|
|
|
407
416
|
accessKeyId: parsed.AccessKeyId,
|
|
408
417
|
secretAccessKey: parsed.SecretAccessKey,
|
|
409
418
|
};
|
|
410
|
-
if (parsed.SessionToken)
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
419
|
+
if (parsed.SessionToken) {
|
|
420
|
+
out.sessionToken = parsed.SessionToken;
|
|
421
|
+
out.expiresAt = dynamicCredentialExpiration(parsed.Expiration);
|
|
422
|
+
} else if (parsed.Expiration) {
|
|
423
|
+
out.expiresAt = dynamicCredentialExpiration(parsed.Expiration);
|
|
414
424
|
}
|
|
415
425
|
return out;
|
|
416
426
|
}
|
|
@@ -515,39 +525,214 @@ export function tokenizeCredentialProcessCommand(cmd: string): string[] {
|
|
|
515
525
|
return tokens;
|
|
516
526
|
}
|
|
517
527
|
|
|
528
|
+
// ---------- Web identity ----------
|
|
529
|
+
|
|
530
|
+
function xmlTag(xml: string, tag: string): string | undefined {
|
|
531
|
+
const value = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(xml)?.[1];
|
|
532
|
+
if (!value) return undefined;
|
|
533
|
+
return value
|
|
534
|
+
.replaceAll("&", "&")
|
|
535
|
+
.replaceAll("<", "<")
|
|
536
|
+
.replaceAll(">", ">")
|
|
537
|
+
.replaceAll(""", '"')
|
|
538
|
+
.replaceAll("'", "'");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function stsEndpoint(region: string): string {
|
|
542
|
+
const dnsSuffix = region.startsWith("cn-") ? "amazonaws.com.cn" : "amazonaws.com";
|
|
543
|
+
return `https://sts.${region}.${dnsSuffix}/`;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function readWebIdentityCredentials(
|
|
547
|
+
region: string,
|
|
548
|
+
signal: AbortSignal | undefined,
|
|
549
|
+
fetchImpl: FetchImpl,
|
|
550
|
+
): Promise<ResolvedCredentials | undefined> {
|
|
551
|
+
const tokenFile = $env.AWS_WEB_IDENTITY_TOKEN_FILE;
|
|
552
|
+
const roleArn = $env.AWS_ROLE_ARN;
|
|
553
|
+
if (!tokenFile || !roleArn) return undefined;
|
|
554
|
+
let token: string;
|
|
555
|
+
try {
|
|
556
|
+
token = (await Bun.file(tokenFile).text()).trim();
|
|
557
|
+
} catch (err) {
|
|
558
|
+
throw new AIError.AwsCredentialsError(
|
|
559
|
+
`Unable to read AWS web identity token file: ${String(err)}`,
|
|
560
|
+
"web-identity",
|
|
561
|
+
{
|
|
562
|
+
cause: err,
|
|
563
|
+
},
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
if (!token) {
|
|
567
|
+
throw new AIError.AwsCredentialsError("AWS web identity token file is empty.", "web-identity");
|
|
568
|
+
}
|
|
569
|
+
const body = new URLSearchParams({
|
|
570
|
+
Action: "AssumeRoleWithWebIdentity",
|
|
571
|
+
Version: "2011-06-15",
|
|
572
|
+
RoleArn: roleArn,
|
|
573
|
+
RoleSessionName: $env.AWS_ROLE_SESSION_NAME || `omp-${process.pid}`,
|
|
574
|
+
WebIdentityToken: token,
|
|
575
|
+
});
|
|
576
|
+
const response = await fetchImpl(stsEndpoint(region), {
|
|
577
|
+
method: "POST",
|
|
578
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
579
|
+
body: body.toString(),
|
|
580
|
+
signal,
|
|
581
|
+
});
|
|
582
|
+
const xml = await response.text();
|
|
583
|
+
if (!response.ok) {
|
|
584
|
+
throw new AIError.AwsCredentialsError(
|
|
585
|
+
`AWS AssumeRoleWithWebIdentity failed: ${response.status} ${xmlTag(xml, "Message") ?? xml.slice(0, 200)}`,
|
|
586
|
+
"web-identity",
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
const accessKeyId = xmlTag(xml, "AccessKeyId");
|
|
590
|
+
const secretAccessKey = xmlTag(xml, "SecretAccessKey");
|
|
591
|
+
const sessionToken = xmlTag(xml, "SessionToken");
|
|
592
|
+
if (!accessKeyId || !secretAccessKey || !sessionToken) {
|
|
593
|
+
throw new AIError.AwsCredentialsError(
|
|
594
|
+
"AWS AssumeRoleWithWebIdentity response is missing credentials.",
|
|
595
|
+
"web-identity",
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
const expiresAt = requireDynamicCredentialExpiration(xmlTag(xml, "Expiration"), "AWS web identity", "web-identity");
|
|
599
|
+
return {
|
|
600
|
+
accessKeyId,
|
|
601
|
+
secretAccessKey,
|
|
602
|
+
sessionToken,
|
|
603
|
+
expiresAt,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ---------- ECS/container credentials ----------
|
|
608
|
+
|
|
609
|
+
interface ContainerCredentialResponse {
|
|
610
|
+
AccessKeyId?: string;
|
|
611
|
+
SecretAccessKey?: string;
|
|
612
|
+
Token?: string;
|
|
613
|
+
Expiration?: string;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const ECS_TASK_CREDENTIALS_BASE_URL = new URL("http://169.254.170.2/");
|
|
617
|
+
|
|
618
|
+
async function readContainerCredentials(
|
|
619
|
+
signal: AbortSignal | undefined,
|
|
620
|
+
fetchImpl: FetchImpl,
|
|
621
|
+
): Promise<ResolvedCredentials | undefined> {
|
|
622
|
+
const relativeUri = $env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI;
|
|
623
|
+
const fullUri = $env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
|
|
624
|
+
if (!relativeUri && !fullUri) return undefined;
|
|
625
|
+
let endpoint: URL;
|
|
626
|
+
if (relativeUri) {
|
|
627
|
+
if (!relativeUri.startsWith("/") || relativeUri.startsWith("//")) {
|
|
628
|
+
throw new AIError.AwsCredentialsError(
|
|
629
|
+
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI must be a single-host absolute path.",
|
|
630
|
+
"container",
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
endpoint = new URL(relativeUri.slice(1), ECS_TASK_CREDENTIALS_BASE_URL);
|
|
634
|
+
} else {
|
|
635
|
+
try {
|
|
636
|
+
endpoint = new URL(fullUri as string);
|
|
637
|
+
} catch (err) {
|
|
638
|
+
throw new AIError.AwsCredentialsError(
|
|
639
|
+
`AWS_CONTAINER_CREDENTIALS_FULL_URI is invalid: ${String(err)}`,
|
|
640
|
+
"container",
|
|
641
|
+
{ cause: err },
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
if (endpoint.protocol !== "https:" && !isLocalOrMetadataHost(endpoint.hostname)) {
|
|
645
|
+
throw new AIError.AwsCredentialsError(
|
|
646
|
+
"AWS_CONTAINER_CREDENTIALS_FULL_URI must use HTTPS or a local metadata host.",
|
|
647
|
+
"container",
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
let authorization = $env.AWS_CONTAINER_AUTHORIZATION_TOKEN;
|
|
652
|
+
const authorizationTokenFile = $env.AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE;
|
|
653
|
+
if (!authorization && authorizationTokenFile) {
|
|
654
|
+
try {
|
|
655
|
+
authorization = (await Bun.file(authorizationTokenFile).text()).trim();
|
|
656
|
+
} catch (err) {
|
|
657
|
+
throw new AIError.AwsCredentialsError(
|
|
658
|
+
`Unable to read AWS container authorization token file: ${String(err)}`,
|
|
659
|
+
"container",
|
|
660
|
+
{ cause: err },
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
const response = await fetchImpl(endpoint, {
|
|
665
|
+
headers: authorization ? { authorization } : undefined,
|
|
666
|
+
signal,
|
|
667
|
+
});
|
|
668
|
+
if (!response.ok) {
|
|
669
|
+
const body = await response.text().catch(() => "");
|
|
670
|
+
throw new AIError.AwsCredentialsError(
|
|
671
|
+
`AWS container credential endpoint failed: ${response.status} ${body.slice(0, 200)}`,
|
|
672
|
+
"container",
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
const body = (await response.json()) as ContainerCredentialResponse;
|
|
676
|
+
if (!body.AccessKeyId || !body.SecretAccessKey || !body.Token) {
|
|
677
|
+
throw new AIError.AwsCredentialsError(
|
|
678
|
+
"AWS container credential response is missing AccessKeyId/SecretAccessKey/Token.",
|
|
679
|
+
"container",
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
return {
|
|
683
|
+
accessKeyId: body.AccessKeyId,
|
|
684
|
+
secretAccessKey: body.SecretAccessKey,
|
|
685
|
+
sessionToken: body.Token,
|
|
686
|
+
expiresAt: requireDynamicCredentialExpiration(body.Expiration, "AWS container credential", "container"),
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
518
690
|
// ---------- IMDSv2 ----------
|
|
519
691
|
|
|
520
|
-
const
|
|
692
|
+
const IMDS_IPV4_BASE_URL = "http://169.254.169.254/";
|
|
693
|
+
const IMDS_IPV6_BASE_URL = "http://[fd00:ec2::254]/";
|
|
521
694
|
const IMDS_TIMEOUT_MS = 1000;
|
|
522
695
|
|
|
696
|
+
function imdsRequestSignal(parentSignal: AbortSignal | undefined): AbortSignal {
|
|
697
|
+
const timeout = AbortSignal.timeout(IMDS_TIMEOUT_MS);
|
|
698
|
+
return parentSignal ? AbortSignal.any([parentSignal, timeout]) : timeout;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function imdsBaseUrl(): URL {
|
|
702
|
+
const mode = $env.AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE?.toLowerCase();
|
|
703
|
+
const fallback = mode === "ipv6" ? IMDS_IPV6_BASE_URL : IMDS_IPV4_BASE_URL;
|
|
704
|
+
const endpoint = new URL($env.AWS_EC2_METADATA_SERVICE_ENDPOINT || fallback);
|
|
705
|
+
if (!endpoint.pathname.endsWith("/")) endpoint.pathname += "/";
|
|
706
|
+
return endpoint;
|
|
707
|
+
}
|
|
708
|
+
|
|
523
709
|
async function readImdsCredentials(
|
|
524
710
|
parentSignal: AbortSignal | undefined,
|
|
525
711
|
fetchImpl: FetchImpl,
|
|
526
712
|
): Promise<ResolvedCredentials | undefined> {
|
|
527
|
-
const timeout = AbortSignal.timeout(IMDS_TIMEOUT_MS);
|
|
528
|
-
const signal = parentSignal ? AbortSignal.any([parentSignal, timeout]) : timeout;
|
|
529
713
|
try {
|
|
530
|
-
const
|
|
714
|
+
const endpoint = imdsBaseUrl();
|
|
715
|
+
const tokenRes = await fetchImpl(new URL("latest/api/token", endpoint), {
|
|
531
716
|
method: "PUT",
|
|
532
717
|
headers: { "x-aws-ec2-metadata-token-ttl-seconds": "21600" },
|
|
533
|
-
signal,
|
|
718
|
+
signal: imdsRequestSignal(parentSignal),
|
|
534
719
|
});
|
|
535
720
|
if (!tokenRes.ok) return undefined;
|
|
536
721
|
const token = await tokenRes.text();
|
|
537
722
|
|
|
538
|
-
const roleRes = await fetchImpl(
|
|
723
|
+
const roleRes = await fetchImpl(new URL("latest/meta-data/iam/security-credentials/", endpoint), {
|
|
539
724
|
headers: { "x-aws-ec2-metadata-token": token },
|
|
540
|
-
signal,
|
|
725
|
+
signal: imdsRequestSignal(parentSignal),
|
|
541
726
|
});
|
|
542
727
|
if (!roleRes.ok) return undefined;
|
|
543
728
|
const role = (await roleRes.text()).trim();
|
|
544
729
|
if (!role) return undefined;
|
|
545
730
|
|
|
546
731
|
const credsRes = await fetchImpl(
|
|
547
|
-
`
|
|
732
|
+
new URL(`latest/meta-data/iam/security-credentials/${encodeURIComponent(role)}`, endpoint),
|
|
548
733
|
{
|
|
549
734
|
headers: { "x-aws-ec2-metadata-token": token },
|
|
550
|
-
signal,
|
|
735
|
+
signal: imdsRequestSignal(parentSignal),
|
|
551
736
|
},
|
|
552
737
|
);
|
|
553
738
|
if (!credsRes.ok) return undefined;
|
|
@@ -557,14 +742,15 @@ async function readImdsCredentials(
|
|
|
557
742
|
Token?: string;
|
|
558
743
|
Expiration?: string;
|
|
559
744
|
};
|
|
560
|
-
if (!body.AccessKeyId || !body.SecretAccessKey) return undefined;
|
|
561
|
-
const
|
|
745
|
+
if (!body.AccessKeyId || !body.SecretAccessKey || !body.Token || !body.Expiration) return undefined;
|
|
746
|
+
const expiresAt = Date.parse(body.Expiration);
|
|
747
|
+
if (!Number.isFinite(expiresAt)) return undefined;
|
|
748
|
+
return {
|
|
562
749
|
accessKeyId: body.AccessKeyId,
|
|
563
750
|
secretAccessKey: body.SecretAccessKey,
|
|
751
|
+
sessionToken: body.Token,
|
|
752
|
+
expiresAt,
|
|
564
753
|
};
|
|
565
|
-
if (body.Token) out.sessionToken = body.Token;
|
|
566
|
-
if (body.Expiration) out.expiresAt = Date.parse(body.Expiration);
|
|
567
|
-
return out;
|
|
568
754
|
} catch {
|
|
569
755
|
return undefined;
|
|
570
756
|
}
|
|
@@ -580,7 +766,7 @@ export function clearAwsCredentialCache(): void {
|
|
|
580
766
|
* 401/403 responses so stale credentials are re-resolved instead of served until restart.
|
|
581
767
|
*/
|
|
582
768
|
export function invalidateAwsCredentialCache(opts: { profile?: string; region?: string } = {}): void {
|
|
583
|
-
const profile = opts.profile
|
|
584
|
-
const region = opts.region
|
|
585
|
-
cache.delete(
|
|
769
|
+
const profile = resolveAwsProfile(opts.profile);
|
|
770
|
+
const region = resolveAwsRegion(opts.region, opts.profile);
|
|
771
|
+
cache.delete(credentialCacheKey(profile, region, shouldLoadAwsSharedConfig(opts.profile)));
|
|
586
772
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { type AwsBedrockProviderOptions, resolveAwsBearerToken } from "../registry/aws";
|
|
2
|
+
import type { FetchImpl, Model } from "../types";
|
|
3
|
+
import { resolveAwsRegion } from "../utils/aws-profile";
|
|
4
|
+
import { invalidateAwsCredentialCache, resolveAwsCredentials } from "./aws-credentials";
|
|
5
|
+
import { signRequest } from "./aws-sigv4";
|
|
6
|
+
import type { OpenAIResponsesOptions } from "./openai-responses";
|
|
7
|
+
import { NO_AUTH_SENTINEL } from "./openai-shared";
|
|
8
|
+
|
|
9
|
+
export type BedrockMantleProviderOptions = AwsBedrockProviderOptions;
|
|
10
|
+
|
|
11
|
+
export interface BedrockMantleOptions extends OpenAIResponsesOptions {
|
|
12
|
+
providerOptions?: BedrockMantleProviderOptions;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function requestBody(input: string | URL | Request, init?: RequestInit): Promise<Uint8Array> {
|
|
16
|
+
if (init?.body !== undefined && init.body !== null) {
|
|
17
|
+
if (typeof init.body === "string") return new TextEncoder().encode(init.body);
|
|
18
|
+
if (init.body instanceof Uint8Array) return init.body;
|
|
19
|
+
if (init.body instanceof ArrayBuffer) return new Uint8Array(init.body);
|
|
20
|
+
throw new TypeError(`Cannot SigV4-sign ${init.body.constructor?.name ?? typeof init.body} request body`);
|
|
21
|
+
}
|
|
22
|
+
if (input instanceof Request) return new Uint8Array(await input.clone().arrayBuffer());
|
|
23
|
+
return new Uint8Array();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createSignedFetch(options: BedrockMantleOptions, region: string): FetchImpl {
|
|
27
|
+
const baseFetch = options.fetch ?? (globalThis.fetch as FetchImpl);
|
|
28
|
+
const signedFetch = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
29
|
+
const url = new URL(input instanceof Request ? input.url : input.toString());
|
|
30
|
+
const method = init?.method ?? (input instanceof Request ? input.method : "POST");
|
|
31
|
+
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
|
32
|
+
for (const [name, value] of new Headers(init?.headers)) headers.set(name, value);
|
|
33
|
+
headers.delete("authorization");
|
|
34
|
+
const body = await requestBody(input, init);
|
|
35
|
+
const credentials = await resolveAwsCredentials({
|
|
36
|
+
profile: options.providerOptions?.profile,
|
|
37
|
+
region,
|
|
38
|
+
signal: options.signal,
|
|
39
|
+
fetch: baseFetch,
|
|
40
|
+
});
|
|
41
|
+
const signed = await signRequest({
|
|
42
|
+
method,
|
|
43
|
+
host: url.host,
|
|
44
|
+
path: url.pathname,
|
|
45
|
+
query: url.search.slice(1),
|
|
46
|
+
body,
|
|
47
|
+
region,
|
|
48
|
+
service: "bedrock-mantle",
|
|
49
|
+
credentials,
|
|
50
|
+
headers: { "content-type": headers.get("content-type") ?? "application/json" },
|
|
51
|
+
});
|
|
52
|
+
for (const [name, value] of Object.entries(signed)) {
|
|
53
|
+
if (value !== undefined && name !== "host") headers.set(name, value);
|
|
54
|
+
}
|
|
55
|
+
const response = await baseFetch(
|
|
56
|
+
url,
|
|
57
|
+
method === "GET" || method === "HEAD" ? { ...init, method, headers } : { ...init, method, headers, body },
|
|
58
|
+
);
|
|
59
|
+
if (response.status === 401 || response.status === 403) {
|
|
60
|
+
invalidateAwsCredentialCache({ profile: options.providerOptions?.profile, region });
|
|
61
|
+
}
|
|
62
|
+
return response;
|
|
63
|
+
};
|
|
64
|
+
return Object.assign(signedFetch, baseFetch.preconnect ? { preconnect: baseFetch.preconnect } : {});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveBearerToken(options: BedrockMantleOptions): string | undefined {
|
|
68
|
+
const apiKey = options.apiKey === NO_AUTH_SENTINEL ? undefined : options.apiKey;
|
|
69
|
+
return resolveAwsBearerToken(apiKey, options.providerOptions?.bearerToken);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createBedrockMantleAuthenticatedFetch(options: BedrockMantleOptions = {}): FetchImpl {
|
|
73
|
+
const region = resolveAwsRegion(options.providerOptions?.region, options.providerOptions?.profile);
|
|
74
|
+
const bearerToken = resolveBearerToken(options);
|
|
75
|
+
if (!bearerToken) return createSignedFetch(options, region);
|
|
76
|
+
|
|
77
|
+
const baseFetch = options.fetch ?? (globalThis.fetch as FetchImpl);
|
|
78
|
+
const authenticatedFetch = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
79
|
+
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
|
80
|
+
for (const [name, value] of new Headers(init?.headers)) headers.set(name, value);
|
|
81
|
+
headers.set("authorization", `Bearer ${bearerToken}`);
|
|
82
|
+
return baseFetch(input, { ...init, headers });
|
|
83
|
+
};
|
|
84
|
+
return Object.assign(authenticatedFetch, baseFetch.preconnect ? { preconnect: baseFetch.preconnect } : {});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface PreparedBedrockMantleRequest {
|
|
88
|
+
model: Model<"openai-responses">;
|
|
89
|
+
options: OpenAIResponsesOptions;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function prepareBedrockMantleRequest(
|
|
93
|
+
model: Model<"openai-responses">,
|
|
94
|
+
options: BedrockMantleOptions,
|
|
95
|
+
): PreparedBedrockMantleRequest {
|
|
96
|
+
const region = resolveAwsRegion(options.providerOptions?.region, options.providerOptions?.profile);
|
|
97
|
+
const resolvedModel = { ...model, baseUrl: model.baseUrl.replaceAll("{region}", encodeURIComponent(region)) };
|
|
98
|
+
const bearerToken = resolveBearerToken(options);
|
|
99
|
+
if (bearerToken) {
|
|
100
|
+
return { model: resolvedModel, options: { ...options, apiKey: bearerToken } };
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
model: resolvedModel,
|
|
104
|
+
options: {
|
|
105
|
+
...options,
|
|
106
|
+
apiKey: NO_AUTH_SENTINEL,
|
|
107
|
+
fetch: createBedrockMantleAuthenticatedFetch(options),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|