@oh-my-pi/pi-ai 17.2.4 → 17.2.6
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 +25 -0
- package/dist/types/dialect/examples.d.ts +18 -2
- package/dist/types/dialect/inventory.d.ts +6 -9
- package/dist/types/dialect/rendering.d.ts +9 -0
- package/dist/types/dialect/types.d.ts +0 -1
- package/dist/types/error/aws.d.ts +5 -1
- package/dist/types/error/flags.d.ts +5 -0
- 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-shared.d.ts +17 -5
- package/dist/types/providers/transform-messages.d.ts +1 -1
- 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 +11 -4
- package/dist/types/utils/aws-profile.d.ts +17 -0
- package/dist/types/utils/harmony-leak.d.ts +9 -0
- package/dist/types/utils/schema/typescript.d.ts +8 -2
- package/package.json +4 -4
- package/src/auth-broker/discover.ts +2 -1
- package/src/auth-broker/wire-schema-resource.ts +6 -1
- package/src/auth-storage.ts +2 -2
- package/src/dialect/examples.ts +50 -12
- package/src/dialect/gemini.ts +17 -31
- package/src/dialect/harmony.ts +1 -2
- package/src/dialect/inventory.ts +21 -64
- package/src/dialect/rendering.ts +54 -0
- package/src/dialect/types.ts +0 -1
- package/src/error/aws.ts +5 -1
- package/src/error/flags.ts +8 -0
- package/src/providers/amazon-bedrock.ts +5 -5
- package/src/providers/anthropic.ts +77 -32
- package/src/providers/aws-credentials.ts +262 -76
- package/src/providers/bedrock-mantle.ts +110 -0
- package/src/providers/cursor.ts +6 -3
- package/src/providers/ollama.ts +27 -1
- package/src/providers/openai-codex-responses.ts +19 -5
- package/src/providers/openai-shared.ts +84 -35
- package/src/providers/transform-messages.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 -8
- package/src/utils/aws-profile.ts +88 -0
- package/src/utils/harmony-leak.ts +12 -0
- package/src/utils/schema/typescript.ts +21 -7
- package/src/utils.ts +1 -1
|
@@ -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
|
+
}
|
package/src/providers/cursor.ts
CHANGED
|
@@ -1816,11 +1816,14 @@ async function handleExecServerMessage(
|
|
|
1816
1816
|
case "piEditArgs": {
|
|
1817
1817
|
const args = execMsg.message.value;
|
|
1818
1818
|
const toolCallId = crypto.randomUUID();
|
|
1819
|
-
// `PiEditReplacement`
|
|
1820
|
-
// snake_case `
|
|
1819
|
+
// `PiEditReplacement` maps onto the local `edit` tool's replace mode:
|
|
1820
|
+
// one snake_case `old_string`/`new_string` per call. Multi-replacement
|
|
1821
|
+
// frames display the first replacement; the exec handler applies all.
|
|
1822
|
+
const firstEdit = args.edits[0];
|
|
1821
1823
|
synthesizeCursorExecToolCall(output, stream, state, toolCallId, "edit", {
|
|
1822
1824
|
path: args.path,
|
|
1823
|
-
|
|
1825
|
+
old_string: firstEdit?.oldText ?? "",
|
|
1826
|
+
new_string: firstEdit?.newText ?? "",
|
|
1824
1827
|
});
|
|
1825
1828
|
const { execResult } = await resolveExecHandler(
|
|
1826
1829
|
{ args, toolCallId },
|
package/src/providers/ollama.ts
CHANGED
|
@@ -251,7 +251,7 @@ function convertMessages(model: Model<"ollama-chat">, context: Context): OllamaM
|
|
|
251
251
|
const messages: Message[] = [...systemMessages, ...context.messages];
|
|
252
252
|
const isCloud = model.provider === "ollama-cloud";
|
|
253
253
|
const supportsImages = model.input.includes("image");
|
|
254
|
-
|
|
254
|
+
const converted = transformMessages(messages, model).map((msg, index) => {
|
|
255
255
|
// Real `systemPrompt` entries (always emitted first) stay on Ollama's
|
|
256
256
|
// `system` role. After the static prefix, a developer turn keeps `system`
|
|
257
257
|
// when it's an agent-owned control instruction (empty/unexpected-stop
|
|
@@ -272,6 +272,21 @@ function convertMessages(model: Model<"ollama-chat">, context: Context): OllamaM
|
|
|
272
272
|
}
|
|
273
273
|
return converted;
|
|
274
274
|
});
|
|
275
|
+
// Ollama returns `done_reason: "load"` and generates nothing when a request
|
|
276
|
+
// carries no `user`-role message (e.g. a plan-approval handoff into a fresh
|
|
277
|
+
// session whose only non-system turn is an agent-attributed developer message
|
|
278
|
+
// mapped to `system`). Demote the last non-prefix system turn to `user` so the
|
|
279
|
+
// request can actually produce output; the static system-prompt prefix stays
|
|
280
|
+
// on `system` for prefix caching. (#7465)
|
|
281
|
+
if (!converted.some(m => m.role === "user")) {
|
|
282
|
+
for (let i = converted.length - 1; i >= systemPrompts.length; i--) {
|
|
283
|
+
if (converted[i].role === "system") {
|
|
284
|
+
converted[i].role = "user";
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return converted;
|
|
275
290
|
}
|
|
276
291
|
|
|
277
292
|
function convertTools(tools: Tool[] | undefined): OllamaFunctionTool[] | undefined {
|
|
@@ -403,6 +418,12 @@ function mapDoneReason(doneReason: string | undefined, output: AssistantMessage)
|
|
|
403
418
|
if (doneReason === "tool_calls") {
|
|
404
419
|
return "toolUse";
|
|
405
420
|
}
|
|
421
|
+
if (doneReason === "load") {
|
|
422
|
+
// Ollama emits done_reason:"load" (model loaded, nothing generated) when a
|
|
423
|
+
// request has no user-role turn. Surface it as an error rather than a clean
|
|
424
|
+
// empty stop so it isn't laundered and retried behind a misleading hint. (#7465)
|
|
425
|
+
return "error";
|
|
426
|
+
}
|
|
406
427
|
if (doneReason === undefined && output.content.some(block => block.type === "toolCall")) {
|
|
407
428
|
return "toolUse";
|
|
408
429
|
}
|
|
@@ -411,6 +432,8 @@ function mapDoneReason(doneReason: string | undefined, output: AssistantMessage)
|
|
|
411
432
|
|
|
412
433
|
const EMPTY_OLLAMA_LENGTH_COMPLETION_MESSAGE =
|
|
413
434
|
"Model returned no content: prompt filled the context window; raise Ollama num_ctx or shorten the prompt.";
|
|
435
|
+
const EMPTY_OLLAMA_LOAD_COMPLETION_MESSAGE =
|
|
436
|
+
"Ollama loaded the model but generated nothing (done_reason: load): the request contained no user-role message.";
|
|
414
437
|
|
|
415
438
|
function hasVisibleAssistantContent(output: AssistantMessage): boolean {
|
|
416
439
|
return output.content.some(block => {
|
|
@@ -697,6 +720,9 @@ const streamOllamaOnce = (
|
|
|
697
720
|
output.stopReason = "error";
|
|
698
721
|
output.errorMessage = EMPTY_OLLAMA_LENGTH_COMPLETION_MESSAGE;
|
|
699
722
|
}
|
|
723
|
+
if (output.stopReason === "error" && !output.errorMessage) {
|
|
724
|
+
output.errorMessage = EMPTY_OLLAMA_LOAD_COMPLETION_MESSAGE;
|
|
725
|
+
}
|
|
700
726
|
// Tool calls always mean "execute and continue" in the OpenAI/Ollama contract.
|
|
701
727
|
// If the turn produced tool-call blocks but reported a natural `stop`, promote
|
|
702
728
|
// to `toolUse` so the agent loop runs them (it gates execution on the stop
|