@oh-my-pi/pi-ai 17.2.7 → 17.2.9

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 (39) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/types/auth/sqlite-credential-store.d.ts +117 -0
  3. package/dist/types/auth-storage.d.ts +1 -108
  4. package/dist/types/error/flags.d.ts +4 -2
  5. package/dist/types/error/index.d.ts +1 -0
  6. package/dist/types/error/rate-limit.d.ts +21 -5
  7. package/dist/types/error/retryable.d.ts +1 -1
  8. package/dist/types/providers/anthropic.d.ts +2 -2
  9. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  10. package/dist/types/providers/cursor-pi-args.d.ts +6 -0
  11. package/dist/types/providers/mock.d.ts +1 -0
  12. package/dist/types/providers/openai-codex/request-transformer.d.ts +1 -1
  13. package/dist/types/providers/openai-codex-responses.d.ts +1 -1
  14. package/dist/types/providers/pi-native-server.d.ts +1 -1
  15. package/dist/types/utils/http-inspector.d.ts +5 -4
  16. package/dist/types/utils/retry.d.ts +3 -2
  17. package/package.json +5 -5
  18. package/src/auth/sqlite-credential-store.ts +2049 -0
  19. package/src/auth-retry.ts +8 -5
  20. package/src/auth-storage.ts +30 -2023
  21. package/src/error/auth-classify.ts +5 -4
  22. package/src/error/flags.ts +74 -18
  23. package/src/error/index.ts +1 -0
  24. package/src/error/rate-limit.ts +64 -5
  25. package/src/error/retryable.ts +1 -1
  26. package/src/providers/anthropic.ts +15 -3
  27. package/src/providers/cursor/exec-modern.ts +1 -0
  28. package/src/providers/cursor-pi-args.ts +30 -0
  29. package/src/providers/cursor.ts +16 -9
  30. package/src/providers/mock.ts +1 -1
  31. package/src/providers/openai-codex/request-transformer.ts +21 -27
  32. package/src/providers/openai-codex-responses.ts +2 -2
  33. package/src/providers/pi-native-client.ts +1 -1
  34. package/src/providers/pi-native-server.ts +1 -1
  35. package/src/registry/oauth/devin.ts +1 -1
  36. package/src/stream.ts +3 -3
  37. package/src/usage/openai-codex.ts +30 -19
  38. package/src/utils/http-inspector.ts +6 -5
  39. package/src/utils/retry.ts +24 -5
@@ -1,6 +1,6 @@
1
1
  import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
2
2
  import { isOAuthExpiry, isUsageLimit } from "./flags";
3
- import { isUsageLimitOutcome } from "./rate-limit";
3
+ import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./rate-limit";
4
4
 
5
5
  /**
6
6
  * Whether an OAuth refresh failure is definitive (the credential must be
@@ -38,9 +38,10 @@ export function isAuthRetryableError(error: unknown): boolean {
38
38
  if (isUsageLimit(error)) return true;
39
39
  if (isInvalidatedOAuthTokenError(error)) return true;
40
40
  const httpStatus = extractHttpStatusFromError(error);
41
- if (httpStatus === 401 || httpStatus === 403) return true;
42
41
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
43
42
  const embeddedStatus = message ? extractHttpStatusFromError({ message }) : undefined;
44
- if (embeddedStatus === 401 || embeddedStatus === 403) return true;
45
- return isUsageLimitOutcome(httpStatus ?? embeddedStatus, message);
43
+ const status = httpStatus ?? embeddedStatus;
44
+ if (isConcurrencyCapExclusion(status, message)) return false;
45
+ if (status === 401 || status === 403) return true;
46
+ return isUsageLimitOutcome(status, message);
46
47
  }
@@ -7,7 +7,13 @@ import {
7
7
  ProviderHttpError,
8
8
  STREAM_ENVELOPE_ERROR_PREFIX,
9
9
  } from "./classes";
10
- import { isOpaqueStatusBody, isUsageLimitStatus, matchesUsageLimitText, parseRateLimitReason } from "./rate-limit";
10
+ import {
11
+ isAccountScopedCapText,
12
+ isOpaqueStatusBody,
13
+ isUsageLimitStatus,
14
+ matchesUsageLimitText,
15
+ parseRateLimitReason,
16
+ } from "./rate-limit";
11
17
 
12
18
  export const Flag = {
13
19
  Class: 0x1000,
@@ -107,10 +113,17 @@ const STALE_RESPONSE_ITEM_DETAIL_PATTERN = /not[ _]?found|invalid|expired|stale|
107
113
  export const LLAMA_CPP_TOOL_CALL_PARSE_PATTERN =
108
114
  /failed to parse tool call arguments as json|\[json\.exception\.parse_error\.101\]/i;
109
115
 
110
- // Copilot routing flap: HTTP 400 `model_not_supported` (structural code on the
111
- // error, also surfaced in text). Treated as transient — a retry usually lands
112
- // on a backend that has the model.
113
- const COPILOT_MODEL_NOT_SUPPORTED_PATTERN = /model_not_supported/i;
116
+ // Copilot fleet skew: HTTP 400 rejecting a model that `/models` advertised on
117
+ // the very same host. Two codes appear in the wild — `model_not_supported`
118
+ // (per-OAuth-client rollout gap) and `model_not_available_for_integrator`
119
+ // (replicas whose integrator allowlist predates the model). Both flap
120
+ // request-to-request, so a retry usually lands on a backend that has the model.
121
+ const COPILOT_TRANSIENT_MODEL_CODES: Record<string, true> = {
122
+ model_not_supported: true,
123
+ model_not_available_for_integrator: true,
124
+ };
125
+ const COPILOT_MODEL_UNAVAILABLE_PATTERN =
126
+ /model_not_supported|model_not_available_for_integrator|not available for integrator/i;
114
127
  // Anthropic strict-tool grammar too large / schema too complex (400 invalid_request_error).
115
128
  // Feature-gated deployments (Azure Foundry, Baseten, …) reject `strict: true`
116
129
  // tools outright when the hosted model lacks structured outputs, e.g.
@@ -332,21 +345,40 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un
332
345
  const isOpaque = isOpaqueStatusBody(cleanMessage);
333
346
 
334
347
  const isLimitStatus = isUsageLimitStatus(statusClean);
348
+ const reason = parseRateLimitReason(cleanMessage);
349
+ // Concurrency caps (e.g. Vertex "Online prediction concurrent requests
350
+ // quota exceeded") are shed-and-backoff, not credential-rotatable —
351
+ // exclude them even when the quota-worded phrasing matches the generic
352
+ // usage-limit text matcher, whose `quota.?exceeded` arm would otherwise
353
+ // set Flag.UsageLimit and burn a healthy sibling credential. HTTP 402 is
354
+ // excluded from this gate: it is categorically an account-billing cap, so
355
+ // a 402 whose body merely mentions concurrency still classifies as a
356
+ // usage limit, mirroring isUsageLimitOutcome.
357
+ const isBillingCapStatus = statusClean === 402;
358
+ const concurrencyExcluded = reason === "CONCURRENT_LIMIT" && !isBillingCapStatus;
335
359
  if (
336
- matchesUsageLimitText(cleanMessage) ||
337
- (isLimitStatus && (isOpaque || parseRateLimitReason(cleanMessage) === "QUOTA_EXHAUSTED"))
360
+ !concurrencyExcluded &&
361
+ (matchesUsageLimitText(cleanMessage) ||
362
+ ((statusClean === 403 || statusClean === undefined) && isAccountScopedCapText(cleanMessage)) ||
363
+ (isLimitStatus &&
364
+ (isOpaque || reason === "QUOTA_EXHAUSTED" || (isBillingCapStatus && reason === "CONCURRENT_LIMIT"))))
338
365
  ) {
339
366
  kinds |= Flag.UsageLimit;
340
367
  }
341
368
 
342
369
  if (isTimeoutText(errorMessage)) kinds |= Flag.Transient | Flag.Timeout;
343
370
  else if (isTransientErrorText(errorMessage)) kinds |= Flag.Transient;
371
+ // A concurrency cap (e.g. Vertex "Online prediction concurrent requests
372
+ // quota exceeded") is transient — shed-and-backoff. The bare wording need
373
+ // not match TRANSIENT_TRANSPORT_PATTERN, so flag it explicitly to keep
374
+ // AIError.retriable from treating the temporary cap as terminal.
375
+ if (reason === "CONCURRENT_LIMIT") kinds |= Flag.Transient;
344
376
  if ((api === "openai-responses" || api === "openai-codex-responses") && isStaleResponsesText(errorMessage)) {
345
377
  kinds |= Flag.StaleResponsesItem;
346
378
  }
347
379
 
348
- // Copilot per-client routing flap is transient.
349
- if (statusClean === 400 && COPILOT_MODEL_NOT_SUPPORTED_PATTERN.test(cleanMessage)) kinds |= Flag.Transient;
380
+ // Copilot fleet-skew model rejection is transient.
381
+ if (statusClean === 400 && COPILOT_MODEL_UNAVAILABLE_PATTERN.test(cleanMessage)) kinds |= Flag.Transient;
350
382
  if (matchesStrictToolsRejection(cleanMessage, statusClean)) kinds |= Flag.Grammar;
351
383
  if (matchesFastModeUnsupported(cleanMessage, statusClean)) kinds |= Flag.FastModeUnsupported;
352
384
  }
@@ -398,7 +430,10 @@ export function classify(error: unknown, api?: Api): number {
398
430
  if (code === "overloaded_error" || code === "rate_limit_error") {
399
431
  linkKinds |= Flag.Transient;
400
432
  }
401
- if (codeStatus === 401 || codeStatus === 403) {
433
+ if (
434
+ (codeStatus === 401 || codeStatus === 403) &&
435
+ !(codeStatus === 403 && parseRateLimitReason(link.message) === "CONCURRENT_LIMIT")
436
+ ) {
402
437
  linkKinds |= Flag.AuthFailed;
403
438
  } else if (codeStatus === 429) {
404
439
  if ((linkKinds & Flag.UsageLimit) === 0) {
@@ -460,16 +495,37 @@ export function isFastModeUnsupported(error: unknown): boolean {
460
495
  }
461
496
 
462
497
  /**
463
- * GitHub Copilot 400 `model_not_supported` routing flap — transient. Reads the
464
- * structural `code` (and falls back to {@link Flag.Transient} text classification).
498
+ * Depth-bounded search for a provider error `code`. SDK error objects keep the
499
+ * parsed response body on `.error`, and Copilot's body is itself
500
+ * `{ error: { code } }`, so the code sits up to two envelopes below the thrown
501
+ * error depending on which SDK produced it.
465
502
  */
466
- export function isCopilotTransientModelError(error: unknown): boolean {
467
- if (status(error) === 400 && error && typeof error === "object") {
468
- const info = error as { code?: unknown; error?: { code?: unknown } | null };
469
- const code = typeof info.code === "string" ? info.code : info.error?.code;
470
- if (code === "model_not_supported") return true;
503
+ function providerErrorCode(error: object): string | undefined {
504
+ let node: object = error;
505
+ for (let depth = 0; depth < 3; depth++) {
506
+ if ("code" in node && typeof node.code === "string") return node.code;
507
+ if (!("error" in node)) return undefined;
508
+ const nested: unknown = node.error;
509
+ if (!nested || typeof nested !== "object") return undefined;
510
+ node = nested;
471
511
  }
472
- return false;
512
+ return undefined;
513
+ }
514
+
515
+ /**
516
+ * GitHub Copilot 400 rejecting a model its own `/models` catalog advertises —
517
+ * transient fleet skew, not a malformed request. Reads the structural `code`
518
+ * through the SDK/body envelopes, then falls back to the stringified body both
519
+ * SDK families put in `message` (shapes drift; the wire text does not).
520
+ */
521
+ export function isCopilotTransientModelError(error: unknown): boolean {
522
+ if (!error || typeof error !== "object" || status(error) !== 400) return false;
523
+ const code = providerErrorCode(error);
524
+ // `Object.hasOwn`, not a bare index: `code` is provider-controlled, and a
525
+ // prototype key (`__proto__`, `toString`, …) would otherwise read truthy.
526
+ if (code !== undefined && Object.hasOwn(COPILOT_TRANSIENT_MODEL_CODES, code)) return true;
527
+ const message: unknown = "message" in error ? error.message : undefined;
528
+ return typeof message === "string" && COPILOT_MODEL_UNAVAILABLE_PATTERN.test(message);
473
529
  }
474
530
 
475
531
  export function classifyMessage(message: {
@@ -9,5 +9,6 @@ export * from "./format";
9
9
  export * from "./gateway";
10
10
  export * from "./oauth";
11
11
  export * from "./provider";
12
+ export * from "./rate-limit";
12
13
  export * from "./retryable";
13
14
  export * from "./validation";
@@ -6,12 +6,14 @@
6
6
  export type RateLimitReason =
7
7
  | "QUOTA_EXHAUSTED"
8
8
  | "RATE_LIMIT_EXCEEDED"
9
+ | "CONCURRENT_LIMIT"
9
10
  | "MODEL_CAPACITY_EXHAUSTED"
10
11
  | "SERVER_ERROR"
11
12
  | "UNKNOWN";
12
13
 
13
14
  const QUOTA_EXHAUSTED_BACKOFF_MS = 30 * 60 * 1000; // 30 min
14
15
  const RATE_LIMIT_EXCEEDED_BACKOFF_MS = 30 * 1000; // 30s
16
+ const CONCURRENT_LIMIT_BACKOFF_MS = 5 * 1000; // 5s
15
17
  const MODEL_CAPACITY_BASE_MS = 45 * 1000; // 45s base
16
18
  const MODEL_CAPACITY_JITTER_MS = 30 * 1000; // ±15s
17
19
  const SERVER_ERROR_BACKOFF_MS = 20 * 1000; // 20s
@@ -26,12 +28,24 @@ const OPENROUTER_DAILY_FREE_LIMIT_PATTERN = /\bfree[-_ ]models[-_ ]per[-_ ]day\b
26
28
  // before classifying explicit details; an otherwise opaque status is transient
27
29
  // model capacity, while quota/rate-limit/server wording remains authoritative.
28
30
  const RESOURCE_EXHAUSTED_PATTERN = /resource.?exhausted/gi;
31
+ const CONCURRENT_LIMIT_PATTERN =
32
+ // Require an actual cap signal near "concurrent". "Too many concurrent
33
+ // requests" is itself a cap signal; bare feature rejections such as
34
+ // "concurrent invocation is not supported" remain excluded.
35
+ /\btoo many\s+concurren\w*\s+(?:requests?|invocations?)\b|\bconcurren\w*\b[^\n]{0,60}\b(?:limit|quota|exceed\w*|reach\w*)\b|\b(?:limit|quota|exceed\w*|reach\w*)\b[^\n]{0,60}\bconcurren\w*\b|\bconcurren[a-z]*[-_](?:[a-z]+[_-])*(?:limit|quota|exceed\w*|reach\w*)/i;
36
+ const ACCOUNT_SCOPED_403_PATTERN =
37
+ // The bare "limit will reset" / "will reset in" phrasing also appears on
38
+ // statusless per-minute transients ("Rate limit will reset in 30 seconds"),
39
+ // so gate the reset-window alternative on account-specific wording (Devin's
40
+ // "Your limit will reset in …"); the overall/account qualifiers arm above
41
+ // already covers the rest.
42
+ /\b(?:overall|account|organization|team|workspace)\b[^\n]{0,40}\b(?:message |request )?rate.?limit\b|\byour\b[^\n]{0,30}\b(?:limit )?will reset\b/i;
29
43
 
30
44
  /**
31
45
  * Classify a rate-limit error message into a reason category.
32
46
  * Priority order: explicit details in a resource-exhausted error > QUOTA
33
- * (Antigravity "quota will reset") > MODEL_CAPACITY > QUOTA (account) >
34
- * RATE_LIMIT > QUOTA (generic) > SERVER_ERROR > bare resource-exhausted > UNKNOWN.
47
+ * (Antigravity "quota will reset") > CONCURRENT_LIMIT > MODEL_CAPACITY >
48
+ * QUOTA (account) > RATE_LIMIT > QUOTA (generic) > SERVER_ERROR > bare resource-exhausted > UNKNOWN.
35
49
  *
36
50
  * Bare "resource exhausted" / "resource_exhausted" maps to MODEL_CAPACITY (transient, short wait).
37
51
  * Explicit details such as "quota exceeded" retain their normal classification.
@@ -50,6 +64,10 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
50
64
  return "QUOTA_EXHAUSTED";
51
65
  }
52
66
 
67
+ if (CONCURRENT_LIMIT_PATTERN.test(errorMessage)) {
68
+ return "CONCURRENT_LIMIT";
69
+ }
70
+
53
71
  if (lower.includes("capacity") || lower.includes("overloaded") || lower.includes("529") || lower.includes("503")) {
54
72
  return "MODEL_CAPACITY_EXHAUSTED";
55
73
  }
@@ -111,6 +129,8 @@ export function calculateRateLimitBackoffMs(reason: RateLimitReason): number {
111
129
  return QUOTA_EXHAUSTED_BACKOFF_MS;
112
130
  case "RATE_LIMIT_EXCEEDED":
113
131
  return RATE_LIMIT_EXCEEDED_BACKOFF_MS;
132
+ case "CONCURRENT_LIMIT":
133
+ return CONCURRENT_LIMIT_BACKOFF_MS;
114
134
  case "MODEL_CAPACITY_EXHAUSTED":
115
135
  return MODEL_CAPACITY_BASE_MS + Math.random() * MODEL_CAPACITY_JITTER_MS;
116
136
  case "SERVER_ERROR":
@@ -148,18 +168,37 @@ export function isUsageLimitStatus(status: number | undefined): boolean {
148
168
  * 3. Body is absent or {@link isOpaqueStatusBody opaque} (just the status,
149
169
  * empty JSON, HTTP framing only) → rotate conservatively: the server
150
170
  * gave us nothing else to go on.
151
- * 4. Body has content → defer to {@link parseRateLimitReason}. Only
152
- * `QUOTA_EXHAUSTED` rotates; `RATE_LIMIT_EXCEEDED` (`Too many requests`,
171
+ * 4. Body has content → defer to {@link parseRateLimitReason}. `QUOTA_EXHAUSTED`
172
+ * rotates; for the categorical 402 billing cap a `CONCURRENT_LIMIT` body
173
+ * also rotates (the cap is concurrent-worded but the status is still an
174
+ * exhausted billing cap). `RATE_LIMIT_EXCEEDED` (`Too many requests`,
153
175
  * per-minute caps), `MODEL_CAPACITY_EXHAUSTED` (`Service overloaded`),
154
176
  * `SERVER_ERROR`, and `UNKNOWN` (`Please retry in 5s`) stay in the
155
177
  * provider's own backoff layer so transient 429s don't burn sibling
156
178
  * credentials.
157
179
  */
158
180
  export function isUsageLimitOutcome(status: number | undefined, message: string | undefined): boolean {
181
+ // Concurrency caps are shed-and-backoff, not credential-rotatable — but only
182
+ // for quota-worded 429 / other statuses. HTTP 402 is categorically an
183
+ // account-billing cap, so a 402 whose body happens to mention concurrency is
184
+ // still an exhausted billing cap and must rotate; gate the exclusion on the
185
+ // status not being that categorical billing cap.
186
+ const isBillingCapStatus = status === 402;
187
+ if (isConcurrencyCapExclusion(status, message)) return false;
159
188
  if (message && matchesUsageLimitText(message)) return true;
189
+ // A 403 is normally an auth failure, but several providers deliver an
190
+ // account-scoped cap with it (Devin/Codeium Connect `permission_denied`,
191
+ // GitHub Copilot). Devin's end-of-stream Connect trailer carries no HTTP
192
+ // status at all (it arrives as a `permission_denied` ValidationError), so
193
+ // accept an undefined status too — but only when the body names a cap that
194
+ // resets, never on a bare 403, which stays an auth failure.
195
+ if ((status === 403 || status === undefined) && message && isAccountScopedCapText(message)) return true;
160
196
  if (!isUsageLimitStatus(status)) return false;
161
197
  if (!message || isOpaqueStatusBody(message)) return true;
162
- return parseRateLimitReason(message) === "QUOTA_EXHAUSTED";
198
+ const reason = parseRateLimitReason(message);
199
+ // For the categorical 402 billing cap a concurrency-worded body is still an
200
+ // exhausted cap (rotate); for 429 / other only QUOTA_EXHAUSTED rotates.
201
+ return reason === "QUOTA_EXHAUSTED" || (isBillingCapStatus && reason === "CONCURRENT_LIMIT");
163
202
  }
164
203
 
165
204
  /**
@@ -190,3 +229,23 @@ export function matchesUsageLimitText(errorMessage: string): boolean {
190
229
  OPENROUTER_DAILY_FREE_LIMIT_PATTERN.test(errorMessage)
191
230
  );
192
231
  }
232
+
233
+ /**
234
+ * Account-scoped cap phrasing delivered on a 403 (or a statusless Connect
235
+ * trailer): "Reached overall message rate limit", "Your limit will reset in …".
236
+ * Kept separate from {@link matchesUsageLimitText} because the bare wording is
237
+ * ambiguous without the 403 / statusless-account context; consumed by both
238
+ * {@link isUsageLimitOutcome} (rotation decision) and `flags.ts` (Flag.UsageLimit).
239
+ */
240
+ export function isAccountScopedCapText(message: string): boolean {
241
+ return ACCOUNT_SCOPED_403_PATTERN.test(message);
242
+ }
243
+
244
+ /**
245
+ * A concurrency cap on a non-billing status is shed-and-backoff, not
246
+ * credential-rotatable. This mirrors the exclusion in {@link isUsageLimitOutcome}
247
+ * for the 403 auth-retry entry points. A 402 remains a categorical billing cap.
248
+ */
249
+ export function isConcurrencyCapExclusion(status: number | undefined, message: string | undefined): boolean {
250
+ return message !== undefined && parseRateLimitReason(message) === "CONCURRENT_LIMIT" && status !== 402;
251
+ }
@@ -33,7 +33,7 @@ function isTransientTransportMessage(message: string): boolean {
33
33
  export interface ProviderRetryableHooks {
34
34
  /** Provider id of the failing request, used to gate provider-specific checks. */
35
35
  provider?: string;
36
- /** Provider-specific transient predicate (e.g. Copilot `model_not_supported`). */
36
+ /** Provider-specific transient predicate (e.g. Copilot model-availability 400s). */
37
37
  isProviderTransient?: (error: Error) => boolean;
38
38
  }
39
39
 
@@ -1531,6 +1531,13 @@ async function* observeDecodedAnthropicSdkEvents(
1531
1531
 
1532
1532
  const PROVIDER_MAX_RETRIES = 10;
1533
1533
 
1534
+ /**
1535
+ * Flat delay between attempts when Copilot 400s a model its own `/models`
1536
+ * catalog advertises. Part of the fleet carries the model and part doesn't, so
1537
+ * the retry is a reroll rather than a wait for capacity to free up.
1538
+ */
1539
+ const COPILOT_MODEL_FLAP_RETRY_DELAY_MS = 400;
1540
+
1534
1541
  /**
1535
1542
  * How long `ping` keepalives may keep extending the idle deadline without any
1536
1543
  * semantic stream progress, as a multiple of the idle timeout. Anthropic pings
@@ -1561,8 +1568,8 @@ function shouldIgnoreAnthropicPreambleEvent(eventType: unknown): boolean {
1561
1568
  /**
1562
1569
  * Whether an Anthropic (or Copilot-over-Anthropic) stream error should be
1563
1570
  * retried. The classification lives in {@link AIError.isProviderRetryableError};
1564
- * this wrapper injects the Copilot-specific `model_not_supported` transient
1565
- * check, which the error module must not import directly.
1571
+ * this wrapper injects the Copilot-specific model-availability transient check,
1572
+ * which the error module must not import directly.
1566
1573
  */
1567
1574
  export function isProviderRetryableError(error: unknown, provider?: string): boolean {
1568
1575
  return AIError.isProviderRetryableError(error, {
@@ -2709,7 +2716,12 @@ const streamAnthropicOnce = (
2709
2716
  throw streamFailure;
2710
2717
  }
2711
2718
  providerRetryAttempt++;
2712
- const backoffDelayMs = calculateAnthropicRetryDelayMs(providerRetryAttempt - 1);
2719
+ // Copilot's model-availability 400 is a per-request replica reroll, not
2720
+ // upstream backpressure — the exponential curve would just add dead
2721
+ // time to a coin flip that the next attempt is as likely to win.
2722
+ const backoffDelayMs = AIError.isCopilotTransientModelError(streamFailure)
2723
+ ? COPILOT_MODEL_FLAP_RETRY_DELAY_MS
2724
+ : calculateAnthropicRetryDelayMs(providerRetryAttempt - 1);
2713
2725
  // Honor the server's retry hint (`retry-after-ms`/`retry-after`) on
2714
2726
  // 429/529-style failures: retrying sooner than the server asked is a
2715
2727
  // guaranteed failure that just burns the retry budget.
@@ -81,6 +81,7 @@ export {
81
81
  piLsPath,
82
82
  piReadDisplayPath,
83
83
  piReadPath,
84
+ piReadPathHasRange,
84
85
  piTimeout,
85
86
  } from "../cursor-pi-args";
86
87
 
@@ -47,6 +47,36 @@ export function piReadPath(readPath: string, offset?: number, limit?: number): s
47
47
  return count === undefined ? `${readPath}:raw:${start}-` : `${readPath}:raw:${start}+${count}`;
48
48
  }
49
49
 
50
+ const READ_RANGE_CHUNK_RE = /^L?(\d+)(?:(\.\.|[-+])L?(\d+)?)?$/i;
51
+
52
+ function isReadRangeList(value: string): boolean {
53
+ return value.split(",").every(chunk => {
54
+ const match = READ_RANGE_CHUNK_RE.exec(chunk);
55
+ if (!match) return false;
56
+ const start = Number.parseInt(match[1]!, 10);
57
+ if (start < 1) return false;
58
+ const separator = match[2];
59
+ if (!separator) return true;
60
+ const end = match[3] ? Number.parseInt(match[3], 10) : undefined;
61
+ if (separator === "+") return end !== undefined && end >= 1;
62
+ return end === undefined || end >= start;
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Whether a read path ends in an OMP line selector, including compound `raw`
68
+ * forms. Cursor uses this only to describe the operation already executed by
69
+ * the coding-agent read tool; the selector remains embedded in the path.
70
+ */
71
+ export function piReadPathHasRange(readPath: string): boolean {
72
+ const chunks = readPath.split(":");
73
+ const last = chunks.at(-1);
74
+ if (last && isReadRangeList(last)) return true;
75
+ if (last?.toLowerCase() !== "raw") return false;
76
+ const preceding = chunks.at(-2);
77
+ return preceding !== undefined && isReadRangeList(preceding);
78
+ }
79
+
50
80
  /**
51
81
  * The same range as {@link piReadPath}, rendered for a transcript block rather
52
82
  * than for execution.
@@ -216,6 +216,7 @@ import {
216
216
  piLimit,
217
217
  piLsPath,
218
218
  piReadDisplayPath,
219
+ piReadPathHasRange,
219
220
  piTimeout,
220
221
  } from "./cursor/exec-modern";
221
222
 
@@ -1322,7 +1323,7 @@ async function handleExecServerMessage(
1322
1323
  buildReadResultFromToolResult(
1323
1324
  args.path,
1324
1325
  toolResult,
1325
- args.offset !== undefined || args.limit !== undefined,
1326
+ args.offset !== undefined || args.limit !== undefined || piReadPathHasRange(args.path),
1326
1327
  ),
1327
1328
  reason => buildReadRejectedResult(args.path, reason),
1328
1329
  error => buildReadErrorResult(args.path, error),
@@ -2439,15 +2440,21 @@ function toolResultDetailBoolean(toolResult: ToolResultMessage, key: string): bo
2439
2440
  /**
2440
2441
  * The file's own line count, when the tool recorded one.
2441
2442
  *
2442
- * `details.meta.truncation.totalLines` is the whole file; the flat
2443
- * `details.truncation.totalLines` counts from the window's start line and is
2444
- * deliberately not consulted here. Absent for a read that returned the file
2445
- * whole, where the payload IS the file and counting it is exact.
2443
+ * Read results expose the source-wide count directly when known. Older tool
2444
+ * results carry it at `details.meta.truncation.totalLines`; the flat
2445
+ * `details.truncation.totalLines` counts from a window's start and is
2446
+ * deliberately not consulted here.
2446
2447
  */
2447
2448
  function readTotalLinesFromDetails(toolResult: ToolResultMessage): number | undefined {
2448
- if (!toolResult.details || typeof toolResult.details !== "object") return undefined;
2449
- const meta = (toolResult.details as { meta?: { truncation?: { totalLines?: unknown } } }).meta;
2450
- const totalLines = meta?.truncation?.totalLines;
2449
+ const details = toolResult.details;
2450
+ if (!details || typeof details !== "object") return undefined;
2451
+ const direct = "totalLines" in details ? details.totalLines : undefined;
2452
+ if (typeof direct === "number" && Number.isFinite(direct)) return direct;
2453
+ const meta = "meta" in details ? details.meta : undefined;
2454
+ if (!meta || typeof meta !== "object") return undefined;
2455
+ const truncation = "truncation" in meta ? meta.truncation : undefined;
2456
+ if (!truncation || typeof truncation !== "object") return undefined;
2457
+ const totalLines = "totalLines" in truncation ? truncation.totalLines : undefined;
2451
2458
  return typeof totalLines === "number" && Number.isFinite(totalLines) ? totalLines : undefined;
2452
2459
  }
2453
2460
 
@@ -2467,7 +2474,7 @@ function buildReadResultFromToolResult(path: string, toolResult: ToolResultMessa
2467
2474
  // whole file. Under a composed window it is the window's, and answering a
2468
2475
  // 20-line page of a 100-line file with `total_lines: 20` tells a paginating
2469
2476
  // server it has reached the end.
2470
- const totalLines = readTotalLinesFromDetails(toolResult) ?? (text ? text.split("\n").length : 0);
2477
+ const totalLines = readTotalLinesFromDetails(toolResult) ?? (rangeApplied ? 0 : text ? text.split("\n").length : 0);
2471
2478
  return create(ReadResultSchema, {
2472
2479
  result: {
2473
2480
  case: "success",
@@ -67,7 +67,7 @@ export type MockApi = typeof MOCK_API;
67
67
  export type MockContent =
68
68
  | string
69
69
  | { type: "text"; text: string }
70
- | { type: "thinking"; thinking: string }
70
+ | { type: "thinking"; thinking: string; thinkingSignature?: string }
71
71
  | {
72
72
  type: "toolCall";
73
73
  /** Optional explicit id; auto-generated when omitted. */
@@ -33,7 +33,7 @@ export interface CodexRequestOptions {
33
33
  /** User-facing effort; maps 1:1 onto the wire tier of the same name. */
34
34
  reasoningEffort?: CodexCallerEffort | "none";
35
35
  reasoningSummary?: ReasoningConfig["summary"] | null;
36
- /** Explicit `reasoning.context` override; defaults to `all_turns` when unset. Gated to gpt-5.4+ Codex models (older ids reject it, so it is suppressed and `context` omitted). Note that under Responses Lite (`responsesLite`), the server strictly requires `reasoning.context` to be `all_turns`, which overrides this option and forces `all_turns`. */
36
+ /** Explicit `reasoning.context` override. Omitted by default; Responses Lite forces `all_turns` as required by that transport. */
37
37
  reasoningContext?: CodexReasoningContext;
38
38
  textVerbosity?: "low" | "medium" | "high";
39
39
  include?: string[];
@@ -145,13 +145,12 @@ function getReasoningConfig(
145
145
  const config: ReasoningConfig = {
146
146
  effort: effort === "none" ? "none" : mapCodexWireEffort(model, effort),
147
147
  };
148
- // `reasoning.summary` is accepted only from gpt-5.4 onward; earlier Codex ids
149
- // (gpt-5.1-codex, gpt-5.3-codex, gpt-5.3-codex-spark) reject it with
150
- // "Unsupported parameter: 'reasoning.summary' is not supported with this model".
151
- // Mirrors the all_turns gate: an explicit summary is suppressed on unsupported
152
- // ids, letting the server skip the human-readable summary stream.
153
- if (options.reasoningSummary !== null && supportsCodexReasoningSummary(model.id)) {
154
- config.summary = options.reasoningSummary ?? "detailed";
148
+ if (
149
+ options.reasoningSummary !== undefined &&
150
+ options.reasoningSummary !== null &&
151
+ supportsCodexReasoningSummary(model.id)
152
+ ) {
153
+ config.summary = options.reasoningSummary;
155
154
  }
156
155
  return config;
157
156
  }
@@ -444,21 +443,14 @@ export async function transformRequestBody(
444
443
  ...body.reasoning,
445
444
  ...reasoningConfig,
446
445
  };
447
- // Default reasoning replay to `all_turns`, mirroring codex-rs; an
448
- // explicit `reasoningContext` overrides the default. The `all_turns`
449
- // value is only accepted from gpt-5.4 onward — earlier Codex ids
450
- // (gpt-5.1-codex, gpt-5.3-codex, gpt-5.3-codex-spark) reject it with
451
- // "Unsupported value: 'all_turns' is not supported with this model".
452
- // For those, drop `context` so the server applies its `current_turn`
453
- // default. The version gate is authoritative: even an explicit
454
- // `all_turns` override is suppressed on unsupported models, while
455
- // `current_turn`/`auto` (universally supported) always pass through.
456
- // Note: Responses Lite forces `all_turns` to satisfy the transport's server invariant.
457
- const context = responsesLite ? "all_turns" : (options.reasoningContext ?? "all_turns");
458
- if (context === "all_turns" && !supportsAllTurnsReasoningContext(model.id)) {
459
- delete body.reasoning.context;
460
- } else {
461
- body.reasoning.context = context;
446
+ // Responses Lite requires `all_turns`; the full transport leaves context to the server unless explicitly set.
447
+ const context = responsesLite ? "all_turns" : options.reasoningContext;
448
+ if (context !== undefined) {
449
+ if (context === "all_turns" && !supportsAllTurnsReasoningContext(model.id)) {
450
+ delete body.reasoning.context;
451
+ } else {
452
+ body.reasoning.context = context;
453
+ }
462
454
  }
463
455
  } else {
464
456
  delete body.reasoning;
@@ -481,10 +473,12 @@ export async function transformRequestBody(
481
473
  delete body.stream_options;
482
474
  }
483
475
 
484
- body.text = {
485
- ...body.text,
486
- verbosity: options.textVerbosity || "medium",
487
- };
476
+ if (options.textVerbosity !== undefined) {
477
+ body.text = {
478
+ ...body.text,
479
+ verbosity: options.textVerbosity,
480
+ };
481
+ }
488
482
 
489
483
  const include = Array.isArray(options.include) ? [...options.include] : [];
490
484
  include.push("reasoning.encrypted_content");
@@ -128,7 +128,7 @@ import { redactSensitiveInObject, transformMessages } from "./transform-messages
128
128
  export interface OpenAICodexResponsesOptions extends StreamOptions {
129
129
  reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
130
130
  reasoningSummary?: "auto" | "concise" | "detailed" | null;
131
- /** `reasoning.context` replay scope; defaults to `all_turns` when unset. The `all_turns` value is gated to gpt-5.4+ Codex models — older ids reject it, so it is suppressed and `context` omitted. */
131
+ /** Explicit `reasoning.context` replay scope. Omitted by default so Codex applies its native request policy. */
132
132
  reasoningContext?: CodexReasoningContext;
133
133
  textVerbosity?: "low" | "medium" | "high";
134
134
  codexMode?: boolean;
@@ -1530,7 +1530,7 @@ export async function buildTransformedCodexRequestBody(
1530
1530
  }
1531
1531
  const codexOptions: CodexRequestOptions = {
1532
1532
  reasoningEffort: options?.reasoning,
1533
- reasoningSummary: options?.reasoningSummary === undefined ? "auto" : options.reasoningSummary,
1533
+ reasoningSummary: options?.reasoningSummary,
1534
1534
  reasoningContext: options?.reasoningContext,
1535
1535
  textVerbosity: options?.textVerbosity,
1536
1536
  include: options?.include,
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * Activated when a {@link Model} has `transport: "pi-native"` set; the
13
13
  * dispatch hook lives in `streamSimple()` (see `../stream.ts`). Used by
14
- * containerized omp deployments (robomp slots, the swarm extension) that
14
+ * containerized omp deployments (such as robomp slots) that
15
15
  * route every LLM call through a credential-holding sidecar so the slot
16
16
  * itself stays credential-free.
17
17
  */
@@ -4,7 +4,7 @@
4
4
  * Where the OpenAI / Anthropic / Responses route modules translate foreign
5
5
  * wire shapes through pi-ai's canonical {@link Context}, this module accepts
6
6
  * the canonical shape *directly* — for clients that already speak pi-ai
7
- * (containerized omp, the swarm extension, robomp's sidecar auth-gateway).
7
+ * (containerized omp, robomp's sidecar auth-gateway).
8
8
  * Skipping the wire-format → Context → wire-format round-trip cuts
9
9
  * per-request CPU but, more importantly, avoids the quantization that those
10
10
  * translations impose on first-class pi-ai fields (service tier, cache
@@ -33,7 +33,7 @@ class DevinOAuthFlow extends OAuthCallbackFlow {
33
33
  });
34
34
  }
35
35
 
36
- generateState(): string {
36
+ override generateState(): string {
37
37
  return crypto.randomUUID();
38
38
  }
39
39
 
package/src/stream.ts CHANGED
@@ -21,7 +21,7 @@ import { createAuthRetryKeyState, isApiKeyResolver, resolveNextAuthRetryKey } fr
21
21
  import * as AIError from "./error";
22
22
  import { ProviderHttpError } from "./error";
23
23
  import { isInvalidatedOAuthTokenError } from "./error/auth-classify";
24
- import { isUsageLimitOutcome } from "./error/rate-limit";
24
+ import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./error/rate-limit";
25
25
  import type { BedrockOptions } from "./providers/amazon-bedrock";
26
26
  import type { AnthropicOptions } from "./providers/anthropic";
27
27
  import { coworkFetch } from "./providers/cowork-fetch";
@@ -995,7 +995,7 @@ function isRetryableUpstreamError(error: unknown, status: number | undefined, me
995
995
  // instead of burning siblings.
996
996
  if (AIError.isUsageLimit(error)) return true;
997
997
  if (isInvalidatedOAuthTokenError(error)) return true;
998
- if (status === 401 || status === 403) return true;
998
+ if (status === 401 || (status === 403 && !isConcurrencyCapExclusion(status, message))) return true;
999
999
  return isUsageLimitOutcome(status, message);
1000
1000
  }
1001
1001
 
@@ -1705,7 +1705,7 @@ function mapOptionsForApi<TApi extends Api>(
1705
1705
  serviceTier: options?.serviceTier,
1706
1706
  preferWebsockets: options?.preferWebsockets,
1707
1707
  codexCompaction: options?.codexCompaction,
1708
- reasoningSummary: options?.hideThinkingSummary ? null : "detailed",
1708
+ reasoningSummary: options?.hideThinkingSummary ? null : undefined,
1709
1709
  textVerbosity: options?.textVerbosity,
1710
1710
  });
1711
1711