@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/types/dialect/examples.d.ts +18 -2
  3. package/dist/types/dialect/inventory.d.ts +6 -9
  4. package/dist/types/dialect/rendering.d.ts +9 -0
  5. package/dist/types/dialect/types.d.ts +0 -1
  6. package/dist/types/error/aws.d.ts +5 -1
  7. package/dist/types/error/flags.d.ts +5 -0
  8. package/dist/types/providers/aws-credentials.d.ts +5 -10
  9. package/dist/types/providers/bedrock-mantle.d.ts +13 -0
  10. package/dist/types/providers/openai-shared.d.ts +17 -5
  11. package/dist/types/providers/transform-messages.d.ts +1 -1
  12. package/dist/types/registry/amazon-bedrock.d.ts +7 -1
  13. package/dist/types/registry/aws.d.ts +13 -0
  14. package/dist/types/registry/bedrock-mantle.d.ts +22 -0
  15. package/dist/types/registry/registry.d.ts +25 -1
  16. package/dist/types/registry/types.d.ts +24 -0
  17. package/dist/types/types.d.ts +11 -4
  18. package/dist/types/utils/aws-profile.d.ts +17 -0
  19. package/dist/types/utils/harmony-leak.d.ts +9 -0
  20. package/dist/types/utils/schema/typescript.d.ts +8 -2
  21. package/package.json +4 -4
  22. package/src/auth-broker/discover.ts +2 -1
  23. package/src/auth-broker/wire-schema-resource.ts +6 -1
  24. package/src/auth-storage.ts +2 -2
  25. package/src/dialect/examples.ts +50 -12
  26. package/src/dialect/gemini.ts +17 -31
  27. package/src/dialect/harmony.ts +1 -2
  28. package/src/dialect/inventory.ts +21 -64
  29. package/src/dialect/rendering.ts +54 -0
  30. package/src/dialect/types.ts +0 -1
  31. package/src/error/aws.ts +5 -1
  32. package/src/error/flags.ts +8 -0
  33. package/src/providers/amazon-bedrock.ts +5 -5
  34. package/src/providers/anthropic.ts +77 -32
  35. package/src/providers/aws-credentials.ts +262 -76
  36. package/src/providers/bedrock-mantle.ts +110 -0
  37. package/src/providers/cursor.ts +6 -3
  38. package/src/providers/ollama.ts +27 -1
  39. package/src/providers/openai-codex-responses.ts +19 -5
  40. package/src/providers/openai-shared.ts +84 -35
  41. package/src/providers/transform-messages.ts +1 -1
  42. package/src/registry/amazon-bedrock.ts +9 -14
  43. package/src/registry/aws.ts +57 -0
  44. package/src/registry/bedrock-mantle.ts +34 -0
  45. package/src/registry/google-vertex.ts +2 -2
  46. package/src/registry/registry.ts +2 -0
  47. package/src/registry/types.ts +30 -0
  48. package/src/stream.ts +46 -30
  49. package/src/types.ts +14 -8
  50. package/src/utils/aws-profile.ts +88 -0
  51. package/src/utils/harmony-leak.ts +12 -0
  52. package/src/utils/schema/typescript.ts +21 -7
  53. 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. Profile in `~/.aws/credentials` (and `~/.aws/config` for SSO):
8
- * - static `aws_access_key_id` / `aws_secret_access_key` / `aws_session_token`
9
- * - SSO profile referencing a cached token in `~/.aws/sso/cache/*.json`,
10
- * which we exchange for short-lived role credentials via
11
- * `https://portal.sso.{region}.amazonaws.com/federation/credentials`.
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 || $env.AWS_PROFILE || "default";
68
- const region = opts.region || $env.AWS_REGION || $env.AWS_DEFAULT_REGION || "us-east-1";
69
- const cacheKey = `${profile}\x00${region}`;
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(profile, region, AbortSignal.timeout(SHARED_RESOLVE_TIMEOUT_MS), fetchImpl);
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. Profile (static or SSO).
106
- const profileCreds = await readProfileCredentials(profile, region, signal, fetchImpl);
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
- // 3. EC2 IMDSv2.
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. Set AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY, ` +
117
- `or configure profile '${profile}' in ~/.aws/credentials (or ~/.aws/config for SSO).`,
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
- // ---------- INI parsing ----------
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 parseIni(text);
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: IniFile | undefined,
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) out.sessionToken = parsed.SessionToken;
411
- if (parsed.Expiration) {
412
- const exp = Date.parse(parsed.Expiration);
413
- if (!Number.isNaN(exp)) out.expiresAt = exp;
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("&amp;", "&")
535
+ .replaceAll("&lt;", "<")
536
+ .replaceAll("&gt;", ">")
537
+ .replaceAll("&quot;", '"')
538
+ .replaceAll("&apos;", "'");
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 IMDS_HOST = "169.254.169.254";
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 tokenRes = await fetchImpl(`http://${IMDS_HOST}/latest/api/token`, {
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(`http://${IMDS_HOST}/latest/meta-data/iam/security-credentials/`, {
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
- `http://${IMDS_HOST}/latest/meta-data/iam/security-credentials/${encodeURIComponent(role)}`,
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 out: ResolvedCredentials = {
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 || $env.AWS_PROFILE || "default";
584
- const region = opts.region || $env.AWS_REGION || $env.AWS_DEFAULT_REGION || "us-east-1";
585
- cache.delete(`${profile}\x00${region}`);
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
+ }
@@ -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` is the local `edit` tool's replace mode verbatim:
1820
- // snake_case `old_text`/`new_text` entries against one path.
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
- edits: args.edits.map(edit => ({ old_text: edit.oldText, new_text: edit.newText })),
1825
+ old_string: firstEdit?.oldText ?? "",
1826
+ new_string: firstEdit?.newText ?? "",
1824
1827
  });
1825
1828
  const { execResult } = await resolveExecHandler(
1826
1829
  { args, toolCallId },
@@ -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
- return transformMessages(messages, model).map((msg, index) => {
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