@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.
- package/CHANGELOG.md +11 -0
- package/dist/types/auth/sqlite-credential-store.d.ts +117 -0
- package/dist/types/auth-storage.d.ts +1 -108
- package/dist/types/error/flags.d.ts +4 -2
- package/dist/types/error/index.d.ts +1 -0
- package/dist/types/error/rate-limit.d.ts +21 -5
- package/dist/types/error/retryable.d.ts +1 -1
- package/dist/types/providers/anthropic.d.ts +2 -2
- package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
- package/dist/types/providers/cursor-pi-args.d.ts +6 -0
- package/dist/types/providers/mock.d.ts +1 -0
- package/dist/types/providers/openai-codex/request-transformer.d.ts +1 -1
- package/dist/types/providers/openai-codex-responses.d.ts +1 -1
- package/dist/types/providers/pi-native-server.d.ts +1 -1
- package/dist/types/utils/http-inspector.d.ts +5 -4
- package/dist/types/utils/retry.d.ts +3 -2
- package/package.json +5 -5
- package/src/auth/sqlite-credential-store.ts +2049 -0
- package/src/auth-retry.ts +8 -5
- package/src/auth-storage.ts +30 -2023
- package/src/error/auth-classify.ts +5 -4
- package/src/error/flags.ts +74 -18
- package/src/error/index.ts +1 -0
- package/src/error/rate-limit.ts +64 -5
- package/src/error/retryable.ts +1 -1
- package/src/providers/anthropic.ts +15 -3
- package/src/providers/cursor/exec-modern.ts +1 -0
- package/src/providers/cursor-pi-args.ts +30 -0
- package/src/providers/cursor.ts +16 -9
- package/src/providers/mock.ts +1 -1
- package/src/providers/openai-codex/request-transformer.ts +21 -27
- package/src/providers/openai-codex-responses.ts +2 -2
- package/src/providers/pi-native-client.ts +1 -1
- package/src/providers/pi-native-server.ts +1 -1
- package/src/registry/oauth/devin.ts +1 -1
- package/src/stream.ts +3 -3
- package/src/usage/openai-codex.ts +30 -19
- package/src/utils/http-inspector.ts +6 -5
- 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
|
-
|
|
45
|
-
|
|
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
|
}
|
package/src/error/flags.ts
CHANGED
|
@@ -7,7 +7,13 @@ import {
|
|
|
7
7
|
ProviderHttpError,
|
|
8
8
|
STREAM_ENVELOPE_ERROR_PREFIX,
|
|
9
9
|
} from "./classes";
|
|
10
|
-
import {
|
|
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
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
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
|
-
|
|
337
|
-
(
|
|
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
|
|
349
|
-
if (statusClean === 400 &&
|
|
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 (
|
|
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
|
-
*
|
|
464
|
-
*
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (
|
|
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
|
|
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: {
|
package/src/error/index.ts
CHANGED
package/src/error/rate-limit.ts
CHANGED
|
@@ -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") >
|
|
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}.
|
|
152
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|
package/src/error/retryable.ts
CHANGED
|
@@ -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
|
|
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
|
|
1565
|
-
*
|
|
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
|
-
|
|
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.
|
|
@@ -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.
|
package/src/providers/cursor.ts
CHANGED
|
@@ -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
|
-
*
|
|
2443
|
-
* `details.truncation.totalLines
|
|
2444
|
-
*
|
|
2445
|
-
*
|
|
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
|
-
|
|
2449
|
-
|
|
2450
|
-
const
|
|
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",
|
package/src/providers/mock.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
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 :
|
|
1708
|
+
reasoningSummary: options?.hideThinkingSummary ? null : undefined,
|
|
1709
1709
|
textVerbosity: options?.textVerbosity,
|
|
1710
1710
|
});
|
|
1711
1711
|
|