@oh-my-pi/pi-ai 17.2.10 → 17.2.11
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 +18 -0
- package/dist/types/error/flags.d.ts +4 -4
- package/dist/types/registry/oauth/cursor.d.ts +1 -0
- package/dist/types/registry/oauth/index.d.ts +4 -0
- package/dist/types/types.d.ts +10 -2
- package/dist/types/usage/cursor.d.ts +1 -0
- package/dist/types/utils/http-inspector.d.ts +4 -5
- package/dist/types/utils/retry.d.ts +2 -3
- package/package.json +5 -5
- package/src/auth/sqlite-credential-store.ts +17 -0
- package/src/error/flags.ts +13 -15
- package/src/error/rate-limit.ts +53 -1
- package/src/providers/anthropic.ts +14 -4
- package/src/providers/register-builtins.ts +15 -2
- package/src/registry/oauth/cursor.ts +29 -13
- package/src/registry/oauth/index.ts +7 -0
- package/src/stream.ts +12 -4
- package/src/types.ts +24 -14
- package/src/usage/cursor.ts +181 -38
- package/src/utils/http-inspector.ts +4 -5
- package/src/utils/retry.ts +2 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.2.11] - 2026-08-07
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- Fixed handling of GitHub Copilot's model_not_available_for_integrator error to prevent unnecessary retries, preserving the actionable available models list.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added support for reporting Cursor personal monthly USD quotas and remaining balances, labeled by verified profile email accounts.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Fixed an issue where ANTHROPIC_BASE_URL was ignored for Anthropic chat requests, ensuring requests are routed to the configured host and forwarding ANTHROPIC_CUSTOM_HEADERS to non-official gateways.
|
|
18
|
+
- Fixed an issue where a legacy pre-organization login credential could persist and cause a permanent error row in omp usage even after a successful organization-scoped re-login.
|
|
19
|
+
- Fixed an issue where lazy provider streams (including Amazon Bedrock, Google, Cursor, Devin, and Ollama) ignored model-specific idle timeouts, which previously caused healthy but slow reasoning turns to prematurely time out.
|
|
20
|
+
- Improved error classification for Simplified Chinese quota-exhaustion and rate-limit messages, ensuring affected credentials are correctly rotated or backed off instead of being treated as unknown errors.
|
|
21
|
+
- Classified subscription and plan-cap 429 responses as rotatable usage limits rather than transient rate-limit throttles, enabling smoother credential rotation.
|
|
22
|
+
|
|
5
23
|
## [17.2.10] - 2026-08-06
|
|
6
24
|
|
|
7
25
|
### Breaking Changes
|
|
@@ -65,10 +65,10 @@ export declare function isGrammarError(error: unknown): boolean;
|
|
|
65
65
|
*/
|
|
66
66
|
export declare function isFastModeUnsupported(error: unknown): boolean;
|
|
67
67
|
/**
|
|
68
|
-
* GitHub Copilot 400
|
|
69
|
-
* transient fleet skew, not a malformed request. Reads the
|
|
70
|
-
* through the SDK/body envelopes, then falls back to the
|
|
71
|
-
* SDK families put in `message
|
|
68
|
+
* GitHub Copilot 400 `model_not_supported` response for a model advertised by
|
|
69
|
+
* `/models` — transient fleet skew, not a malformed request. Reads the
|
|
70
|
+
* structural `code` through the SDK/body envelopes, then falls back to the
|
|
71
|
+
* stringified body both SDK families put in `message`.
|
|
72
72
|
*/
|
|
73
73
|
export declare function isCopilotTransientModelError(error: unknown): boolean;
|
|
74
74
|
export declare function classifyMessage(message: {
|
|
@@ -12,4 +12,5 @@ export declare function pollCursorAuth(uuid: string, verifier: string): Promise<
|
|
|
12
12
|
}>;
|
|
13
13
|
export declare function loginCursor(onAuthUrl: (url: string) => void, onPollStart?: () => void): Promise<OAuthCredentials>;
|
|
14
14
|
export declare function refreshCursorToken(apiKeyOrRefreshToken: string): Promise<OAuthCredentials>;
|
|
15
|
+
export declare function extractCursorAccessTokenUserId(accessToken: string): string | undefined;
|
|
15
16
|
export declare function isCursorTokenExpiringSoon(token: string, thresholdSeconds?: number): boolean;
|
|
@@ -6,6 +6,10 @@ export type * from "./types.js";
|
|
|
6
6
|
* Register a custom OAuth provider.
|
|
7
7
|
*/
|
|
8
8
|
export declare function registerOAuthProvider(provider: OAuthProviderInterface): void;
|
|
9
|
+
/**
|
|
10
|
+
* Remove a custom OAuth provider by ID.
|
|
11
|
+
*/
|
|
12
|
+
export declare function unregisterOAuthProvider(id: string): void;
|
|
9
13
|
/**
|
|
10
14
|
* Get a custom OAuth provider by ID.
|
|
11
15
|
*/
|
package/dist/types/types.d.ts
CHANGED
|
@@ -649,8 +649,10 @@ export interface DeveloperMessage {
|
|
|
649
649
|
providerPayload?: ProviderPayload;
|
|
650
650
|
timestamp: number;
|
|
651
651
|
}
|
|
652
|
+
/** How an automatic retry recovered or ultimately settled a failed attempt. */
|
|
652
653
|
export type AssistantRetryRecoveryKind = "credential" | "model" | "wait" | "plain";
|
|
653
|
-
|
|
654
|
+
/** Persisted presentation state for an assistant error superseded by an automatic retry saga. */
|
|
655
|
+
export type AssistantRetryRecovery = {
|
|
654
656
|
kind: "auto-retry";
|
|
655
657
|
status: "recovered";
|
|
656
658
|
attempt: number;
|
|
@@ -663,7 +665,13 @@ export interface AssistantRetryRecovery {
|
|
|
663
665
|
provider: string;
|
|
664
666
|
model: string;
|
|
665
667
|
};
|
|
666
|
-
}
|
|
668
|
+
} | {
|
|
669
|
+
kind: "auto-retry";
|
|
670
|
+
status: "superseded";
|
|
671
|
+
attempt: number;
|
|
672
|
+
recovery: AssistantRetryRecoveryKind;
|
|
673
|
+
note: string;
|
|
674
|
+
};
|
|
667
675
|
export interface ContextSnapshot {
|
|
668
676
|
promptTokens: number;
|
|
669
677
|
nonMessageTokens: number;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { UsageProvider, UsageReport } from "../usage.js";
|
|
2
|
+
export declare function parseCursorIndividualUsage(payload: unknown, fetchedAt?: number): UsageReport | null;
|
|
2
3
|
export declare function parseCursorUsage(payload: unknown, fetchedAt?: number): UsageReport | null;
|
|
3
4
|
export declare const cursorUsageProvider: UsageProvider;
|
|
@@ -38,11 +38,10 @@ export declare function finalizeErrorMessage(error: unknown, rawRequestDump: Raw
|
|
|
38
38
|
* Rewrite error message for GitHub Copilot request failures.
|
|
39
39
|
* Must run AFTER finalizeErrorMessage since it replaces the message entirely.
|
|
40
40
|
*
|
|
41
|
-
* 400
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* in
|
|
45
|
-
* surface guidance rather than the raw error.
|
|
41
|
+
* 400 `model_not_supported` = Copilot fleet skew. A model that `/models`
|
|
42
|
+
* advertises can flap between 200 and 400 because only part of
|
|
43
|
+
* Copilot's fleet has it in the integrator allowlist. After the
|
|
44
|
+
* in-request retry exhausts, surface guidance rather than the raw error.
|
|
46
45
|
* 401 = token invalid/expired → credential removal is safe, prompt re-login.
|
|
47
46
|
* 403 = token valid but access denied (plan, model policy, org restriction) →
|
|
48
47
|
* do NOT reuse the auth-failed string (which triggers credential removal).
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { isCopilotTransientModelError } from "../error/flags.js";
|
|
2
2
|
export { isCopilotTransientModelError };
|
|
3
3
|
/**
|
|
4
|
-
* Wrap an initial Copilot request so transient
|
|
5
|
-
*
|
|
6
|
-
* small number of times. No-op for non-Copilot providers.
|
|
4
|
+
* Wrap an initial Copilot request so transient `model_not_supported` 400s are
|
|
5
|
+
* retried a small number of times. No-op for non-Copilot providers.
|
|
7
6
|
*
|
|
8
7
|
* The callback **MUST** create a fresh in-flight request each invocation — a
|
|
9
8
|
* once-consumed AsyncIterable cannot be re-iterated.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "17.2.
|
|
4
|
+
"version": "17.2.11",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,10 +38,10 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.1",
|
|
41
|
-
"@oh-my-pi/omptype": "17.2.
|
|
42
|
-
"@oh-my-pi/pi-catalog": "17.2.
|
|
43
|
-
"@oh-my-pi/pi-utils": "17.2.
|
|
44
|
-
"@oh-my-pi/pi-wire": "17.2.
|
|
41
|
+
"@oh-my-pi/omptype": "17.2.11",
|
|
42
|
+
"@oh-my-pi/pi-catalog": "17.2.11",
|
|
43
|
+
"@oh-my-pi/pi-utils": "17.2.11",
|
|
44
|
+
"@oh-my-pi/pi-wire": "17.2.11"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@bufbuild/protoc-gen-es": "^2.12.1",
|
|
@@ -1374,11 +1374,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1374
1374
|
try {
|
|
1375
1375
|
let hasActiveApiKey = false;
|
|
1376
1376
|
const activeIdentityKeys = new Set<string>();
|
|
1377
|
+
const activeOAuthCredentials: AuthCredential[] = [];
|
|
1377
1378
|
for (const row of activeRows) {
|
|
1378
1379
|
if (row.credential.type === "api_key") {
|
|
1379
1380
|
hasActiveApiKey = true;
|
|
1380
1381
|
continue;
|
|
1381
1382
|
}
|
|
1383
|
+
activeOAuthCredentials.push(row.credential);
|
|
1382
1384
|
const identityKey = resolveCredentialIdentityKey(provider, row.credential);
|
|
1383
1385
|
if (identityKey) activeIdentityKeys.add(identityKey);
|
|
1384
1386
|
}
|
|
@@ -1393,7 +1395,22 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1393
1395
|
const identityKey = resolveRowCredentialIdentityKey(provider, row);
|
|
1394
1396
|
if (identityKey && activeIdentityKeys.has(identityKey)) {
|
|
1395
1397
|
this.#hardDeleteStmt.run(row.id);
|
|
1398
|
+
continue;
|
|
1396
1399
|
}
|
|
1400
|
+
// Exact key equality misses a tombstone whose key predates a format
|
|
1401
|
+
// the active row now uses (pre-org `<b>` vs `<b>|org:<o>`). An active
|
|
1402
|
+
// credential that WOULD have replaced this row had it still been
|
|
1403
|
+
// active supersedes its tombstone too, so mirror the replacement
|
|
1404
|
+
// matcher rather than restating a weaker rule. The one-way upgrade
|
|
1405
|
+
// and shared-workspace guards in matchesReplacementCredential carry
|
|
1406
|
+
// over, so this never over-deletes another member's or subscription's
|
|
1407
|
+
// row.
|
|
1408
|
+
const disabledCredential = deserializeCredential(row);
|
|
1409
|
+
if (disabledCredential === null) continue;
|
|
1410
|
+
const superseded = activeOAuthCredentials.some(active =>
|
|
1411
|
+
matchesReplacementCredential(provider, disabledCredential, identityKey, active),
|
|
1412
|
+
);
|
|
1413
|
+
if (superseded) this.#hardDeleteStmt.run(row.id);
|
|
1397
1414
|
}
|
|
1398
1415
|
} catch {
|
|
1399
1416
|
// Best-effort cleanup; don't let it break the main operation
|
package/src/error/flags.ts
CHANGED
|
@@ -113,17 +113,15 @@ const STALE_RESPONSE_ITEM_DETAIL_PATTERN = /not[ _]?found|invalid|expired|stale|
|
|
|
113
113
|
export const LLAMA_CPP_TOOL_CALL_PARSE_PATTERN =
|
|
114
114
|
/failed to parse tool call arguments as json|\[json\.exception\.parse_error\.101\]/i;
|
|
115
115
|
|
|
116
|
-
// Copilot fleet skew: HTTP 400
|
|
117
|
-
// the
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
116
|
+
// Copilot fleet skew: HTTP 400 `model_not_supported` can reject a model that
|
|
117
|
+
// `/models` advertised on the same host when the request lands on a stale
|
|
118
|
+
// replica. `model_not_available_for_integrator` is deliberately excluded:
|
|
119
|
+
// GitHub also uses it for stable per-integrator entitlement denials and includes
|
|
120
|
+
// that integrator's actionable `Available models` list in the response.
|
|
121
121
|
const COPILOT_TRANSIENT_MODEL_CODES: Record<string, true> = {
|
|
122
122
|
model_not_supported: true,
|
|
123
|
-
model_not_available_for_integrator: true,
|
|
124
123
|
};
|
|
125
|
-
const
|
|
126
|
-
/model_not_supported|model_not_available_for_integrator|not available for integrator/i;
|
|
124
|
+
const COPILOT_TRANSIENT_MODEL_PATTERN = /model_not_supported/i;
|
|
127
125
|
// Anthropic strict-tool grammar too large / schema too complex (400 invalid_request_error).
|
|
128
126
|
// Feature-gated deployments (Azure Foundry, Baseten, …) reject `strict: true`
|
|
129
127
|
// tools outright when the hosted model lacks structured outputs, e.g.
|
|
@@ -377,8 +375,8 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un
|
|
|
377
375
|
kinds |= Flag.StaleResponsesItem;
|
|
378
376
|
}
|
|
379
377
|
|
|
380
|
-
// Copilot fleet-skew
|
|
381
|
-
if (statusClean === 400 &&
|
|
378
|
+
// Copilot's `model_not_supported` fleet-skew rejection is transient.
|
|
379
|
+
if (statusClean === 400 && COPILOT_TRANSIENT_MODEL_PATTERN.test(cleanMessage)) kinds |= Flag.Transient;
|
|
382
380
|
if (matchesStrictToolsRejection(cleanMessage, statusClean)) kinds |= Flag.Grammar;
|
|
383
381
|
if (matchesFastModeUnsupported(cleanMessage, statusClean)) kinds |= Flag.FastModeUnsupported;
|
|
384
382
|
}
|
|
@@ -513,10 +511,10 @@ function providerErrorCode(error: object): string | undefined {
|
|
|
513
511
|
}
|
|
514
512
|
|
|
515
513
|
/**
|
|
516
|
-
* GitHub Copilot 400
|
|
517
|
-
* transient fleet skew, not a malformed request. Reads the
|
|
518
|
-
* through the SDK/body envelopes, then falls back to the
|
|
519
|
-
* SDK families put in `message
|
|
514
|
+
* GitHub Copilot 400 `model_not_supported` response for a model advertised by
|
|
515
|
+
* `/models` — transient fleet skew, not a malformed request. Reads the
|
|
516
|
+
* structural `code` through the SDK/body envelopes, then falls back to the
|
|
517
|
+
* stringified body both SDK families put in `message`.
|
|
520
518
|
*/
|
|
521
519
|
export function isCopilotTransientModelError(error: unknown): boolean {
|
|
522
520
|
if (!error || typeof error !== "object" || status(error) !== 400) return false;
|
|
@@ -525,7 +523,7 @@ export function isCopilotTransientModelError(error: unknown): boolean {
|
|
|
525
523
|
// prototype key (`__proto__`, `toString`, …) would otherwise read truthy.
|
|
526
524
|
if (code !== undefined && Object.hasOwn(COPILOT_TRANSIENT_MODEL_CODES, code)) return true;
|
|
527
525
|
const message: unknown = "message" in error ? error.message : undefined;
|
|
528
|
-
return typeof message === "string" &&
|
|
526
|
+
return typeof message === "string" && COPILOT_TRANSIENT_MODEL_PATTERN.test(message);
|
|
529
527
|
}
|
|
530
528
|
|
|
531
529
|
export function classifyMessage(message: {
|
package/src/error/rate-limit.ts
CHANGED
|
@@ -22,6 +22,13 @@ const ACCOUNT_RATE_LIMIT_PATTERN =
|
|
|
22
22
|
/\baccount(?:'s)?\b[^\n]{0,80}\brate.?limit\b|\brate.?limit\b[^\n]{0,80}\baccount\b/i;
|
|
23
23
|
const INSUFFICIENT_BALANCE_PATTERN = /insufficient.?balance/i;
|
|
24
24
|
const SPEND_LIMIT_PATTERN = /spend.?limit/i;
|
|
25
|
+
const SUBSCRIPTION_CAP_PATTERN =
|
|
26
|
+
/\b(?:subscription|plan|membership)\b[^\n]{0,80}\b(?:rate.?limits?|quota|cap)\b|\b(?:rate.?limits?|quota|cap)\b[^\n]{0,80}\b(?:subscription|plan|membership)\b/i;
|
|
27
|
+
const TRANSIENT_INTERVAL_RATE_LIMIT_PATTERN = /\bper\s+(?:second|minute)\b/i;
|
|
28
|
+
|
|
29
|
+
function matchesSubscriptionCapText(errorMessage: string): boolean {
|
|
30
|
+
return SUBSCRIPTION_CAP_PATTERN.test(errorMessage) && !TRANSIENT_INTERVAL_RATE_LIMIT_PATTERN.test(errorMessage);
|
|
31
|
+
}
|
|
25
32
|
const OPENROUTER_DAILY_FREE_LIMIT_PATTERN = /\bfree[-_ ]models[-_ ]per[-_ ]day\b/i;
|
|
26
33
|
// gRPC/Connect end-streams carry the status as its name (`resource_exhausted`),
|
|
27
34
|
// while HTTP bodies use the phrase ("resource exhausted"). Strip either form
|
|
@@ -40,6 +47,25 @@ const ACCOUNT_SCOPED_403_PATTERN =
|
|
|
40
47
|
// "Your limit will reset in …"); the overall/account qualifiers arm above
|
|
41
48
|
// already covers the rest.
|
|
42
49
|
/\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;
|
|
50
|
+
// Simplified Chinese account-quota exhaustion phrasing. Zhipu Coding Plan
|
|
51
|
+
// returns e.g. "429 已达到 5 小时的使用上限。您的限额将在 2026-08-06 20:06:00 重置。"
|
|
52
|
+
// (type=1308) when the 5h window is spent; other CN providers use 额度已用完 /
|
|
53
|
+
// 配额已耗尽 / 余额不足. These are persistent account-local caps that must
|
|
54
|
+
// rotate to a sibling credential, not transient rate limits, so they are
|
|
55
|
+
// matched before the RATE_LIMIT_EXCEEDED branch. The 上限 arm is anchored on
|
|
56
|
+
// the 使用 token: a rate/concurrency cap phrased as 每分钟请求数已达上限 /
|
|
57
|
+
// 并发请求数已达上限 / 速率达到上限 (no 使用) must NOT match, or it would burn a
|
|
58
|
+
// healthy sibling credential as a false quota. "速率限制" is absent for the
|
|
59
|
+
// same reason.
|
|
60
|
+
const CN_QUOTA_EXHAUSTED_PATTERN = /使用.{0,30}?上限|(?:额度|配额)已?(?:用|耗)(?:完|尽)|限额.{0,30}重置|余额不足/;
|
|
61
|
+
// Simplified Chinese rate/concurrency caps can contain both 使用 and 上限, but
|
|
62
|
+
// remain transient rather than account quota exhaustion.
|
|
63
|
+
const CN_TRANSIENT_CAP_PATTERN =
|
|
64
|
+
/速率.{0,30}上限|频率.{0,30}上限|每分钟.{0,30}上限|并发.{0,30}上限|使用.{0,30}(?:速率|频率|每分钟|并发).{0,30}上限/;
|
|
65
|
+
// Common Simplified Chinese throttle phrasing. Consulted by
|
|
66
|
+
// isOpaqueStatusBody so CN transients stay in the provider backoff lane instead
|
|
67
|
+
// of rotating through the opaque-429 fallback.
|
|
68
|
+
const CN_THROTTLE_PATTERN = /速率(?:限制|过快)|频率(?:过高|过快)|过于频繁|稍后[重再]试/;
|
|
43
69
|
|
|
44
70
|
/**
|
|
45
71
|
* Classify a rate-limit error message into a reason category.
|
|
@@ -64,6 +90,13 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
|
|
|
64
90
|
return "QUOTA_EXHAUSTED";
|
|
65
91
|
}
|
|
66
92
|
|
|
93
|
+
// Simplified Chinese quota-exhaustion phrasing (Zhipu Coding Plan and other
|
|
94
|
+
// CN providers). Must precede the MODEL_CAPACITY / RATE_LIMIT branches so an
|
|
95
|
+
// account-local cap rotates instead of backing off as a transient.
|
|
96
|
+
if (CN_QUOTA_EXHAUSTED_PATTERN.test(errorMessage) && !CN_TRANSIENT_CAP_PATTERN.test(errorMessage)) {
|
|
97
|
+
return "QUOTA_EXHAUSTED";
|
|
98
|
+
}
|
|
99
|
+
|
|
67
100
|
if (CONCURRENT_LIMIT_PATTERN.test(errorMessage)) {
|
|
68
101
|
return "CONCURRENT_LIMIT";
|
|
69
102
|
}
|
|
@@ -80,6 +113,10 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
|
|
|
80
113
|
return "QUOTA_EXHAUSTED";
|
|
81
114
|
}
|
|
82
115
|
|
|
116
|
+
if (matchesSubscriptionCapText(errorMessage)) {
|
|
117
|
+
return "QUOTA_EXHAUSTED";
|
|
118
|
+
}
|
|
119
|
+
|
|
83
120
|
if (OPENROUTER_DAILY_FREE_LIMIT_PATTERN.test(errorMessage)) {
|
|
84
121
|
return "QUOTA_EXHAUSTED";
|
|
85
122
|
}
|
|
@@ -212,7 +249,20 @@ export function isOpaqueStatusBody(message: string): boolean {
|
|
|
212
249
|
const cleaned = message
|
|
213
250
|
.replace(/\b(?:429|402)\b/g, "")
|
|
214
251
|
.replace(/\b(?:http|https|status|error|code|response|message)\b/gi, "");
|
|
215
|
-
|
|
252
|
+
// A body is informative when the text classifier can act on it. Any Latin
|
|
253
|
+
// word or Simplified Chinese phrasing the classifier recognizes (quota
|
|
254
|
+
// exhaustion or a throttle) defers to parseRateLimitReason; a body that
|
|
255
|
+
// is only status digits / HTTP framing is opaque and rotates conservatively.
|
|
256
|
+
// A Han-only body the classifier cannot interpret (e.g. Japanese Kanji
|
|
257
|
+
// quota text, since Japanese is out of scope) must stay opaque so the
|
|
258
|
+
// opaque-429 fallback still rotates. This keeps the exception scoped to
|
|
259
|
+
// text we actually classify, rather than to any Han ideograph.
|
|
260
|
+
return (
|
|
261
|
+
!/[a-z\d]{3,}/i.test(cleaned) &&
|
|
262
|
+
!CN_QUOTA_EXHAUSTED_PATTERN.test(cleaned) &&
|
|
263
|
+
!CN_TRANSIENT_CAP_PATTERN.test(cleaned) &&
|
|
264
|
+
!CN_THROTTLE_PATTERN.test(cleaned)
|
|
265
|
+
);
|
|
216
266
|
}
|
|
217
267
|
|
|
218
268
|
/**
|
|
@@ -224,8 +274,10 @@ export function isOpaqueStatusBody(message: string): boolean {
|
|
|
224
274
|
export function matchesUsageLimitText(errorMessage: string): boolean {
|
|
225
275
|
return (
|
|
226
276
|
USAGE_LIMIT_PATTERN.test(errorMessage) ||
|
|
277
|
+
(CN_QUOTA_EXHAUSTED_PATTERN.test(errorMessage) && !CN_TRANSIENT_CAP_PATTERN.test(errorMessage)) ||
|
|
227
278
|
SPEND_LIMIT_PATTERN.test(errorMessage) ||
|
|
228
279
|
ACCOUNT_RATE_LIMIT_PATTERN.test(errorMessage) ||
|
|
280
|
+
matchesSubscriptionCapText(errorMessage) ||
|
|
229
281
|
OPENROUTER_DAILY_FREE_LIMIT_PATTERN.test(errorMessage)
|
|
230
282
|
);
|
|
231
283
|
}
|
|
@@ -1212,7 +1212,14 @@ function resolveAnthropicBaseUrl(model: Model<"anthropic-messages">, apiKey?: st
|
|
|
1212
1212
|
}
|
|
1213
1213
|
}
|
|
1214
1214
|
if (model.provider === "anthropic") {
|
|
1215
|
-
|
|
1215
|
+
const configured = normalizeAnthropicBaseUrl(model.baseUrl);
|
|
1216
|
+
// An explicitly configured non-official baseUrl (e.g. a models.yml provider
|
|
1217
|
+
// override) is more specific than the generic env fallback and wins.
|
|
1218
|
+
if (configured && !isOfficialAnthropicApiUrl(configured)) return configured;
|
|
1219
|
+
// Otherwise ANTHROPIC_BASE_URL routes chat through an enterprise gateway
|
|
1220
|
+
// (docs/environment-variables.md), ahead of the official default. The
|
|
1221
|
+
// Foundry redirect is already handled above.
|
|
1222
|
+
return normalizeAnthropicBaseUrl($env.ANTHROPIC_BASE_URL) ?? configured ?? "https://api.anthropic.com";
|
|
1216
1223
|
}
|
|
1217
1224
|
return normalizeAnthropicBaseUrl(model.baseUrl);
|
|
1218
1225
|
}
|
|
@@ -1272,9 +1279,12 @@ export function resolveAnthropicCustomHeadersForBaseUrl(
|
|
|
1272
1279
|
return parseAnthropicCustomHeaders($env.ANTHROPIC_CUSTOM_HEADERS);
|
|
1273
1280
|
}
|
|
1274
1281
|
|
|
1275
|
-
function resolveAnthropicCustomHeaders(
|
|
1282
|
+
function resolveAnthropicCustomHeaders(
|
|
1283
|
+
model: Model<"anthropic-messages">,
|
|
1284
|
+
baseUrl: string | undefined,
|
|
1285
|
+
): Record<string, string> | undefined {
|
|
1276
1286
|
if (model.provider !== "anthropic") return undefined;
|
|
1277
|
-
return resolveAnthropicCustomHeadersForBaseUrl(
|
|
1287
|
+
return resolveAnthropicCustomHeadersForBaseUrl(baseUrl);
|
|
1278
1288
|
}
|
|
1279
1289
|
|
|
1280
1290
|
function looksLikeFilePath(value: string): boolean {
|
|
@@ -2944,7 +2954,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
2944
2954
|
const supportsEagerToolInputStreaming = resolveEagerToolInputStreamingSupport(model, baseUrl);
|
|
2945
2955
|
const needsFineGrainedToolStreamingBeta =
|
|
2946
2956
|
hasTools && isOfficialAnthropicApiUrl(baseUrl) && !supportsEagerToolInputStreaming;
|
|
2947
|
-
const foundryCustomHeaders = resolveAnthropicCustomHeaders(model);
|
|
2957
|
+
const foundryCustomHeaders = resolveAnthropicCustomHeaders(model, baseUrl);
|
|
2948
2958
|
const tlsFetchOptions = buildCoworkTlsFetchOptions(model, baseUrl);
|
|
2949
2959
|
// Disable Bun's native ~300s pre-response fetch timeout (issue #2422).
|
|
2950
2960
|
// `AnthropicMessagesClient` already arms its own DEFAULT_TIMEOUT_MS timer
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* loading that can be integrated when stream.ts is refactored.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { CompatOf } from "@oh-my-pi/pi-catalog/types";
|
|
14
15
|
import * as AIError from "../error";
|
|
15
16
|
import type {
|
|
16
17
|
Api,
|
|
@@ -240,12 +241,23 @@ function forwardStream<TApi extends Api>(
|
|
|
240
241
|
(async () => {
|
|
241
242
|
try {
|
|
242
243
|
const providerHandlesStreamTimeouts = limits?.providerHandlesStreamTimeouts === true;
|
|
244
|
+
// Per-model catalog compat can widen the fallback watchdog for hosts
|
|
245
|
+
// with no keepalive events (e.g. Bedrock reasoning models that go
|
|
246
|
+
// quiet for minutes mid-thinking, issue #4758). Caller options and
|
|
247
|
+
// env overrides still take precedence over the compat fallback. The
|
|
248
|
+
// annotated local up-casts the generic CompatOf<TApi> by assignment,
|
|
249
|
+
// so any compat shape redeclaring `streamIdleTimeoutMs` with another
|
|
250
|
+
// type is a compile error here.
|
|
251
|
+
const compat: CompatOf<Api> | undefined = model.compat;
|
|
252
|
+
const compatIdleTimeoutMs =
|
|
253
|
+
compat !== undefined && "streamIdleTimeoutMs" in compat ? compat.streamIdleTimeoutMs : undefined;
|
|
254
|
+
const idleTimeoutFallbackMs = compatIdleTimeoutMs ?? limits?.defaultIdleTimeoutMs;
|
|
243
255
|
const idleTimeoutMs = providerHandlesStreamTimeouts
|
|
244
256
|
? undefined
|
|
245
257
|
: (options.streamIdleTimeoutMs ??
|
|
246
258
|
(limits?.openAIIdleEnvFloorsFirstEvent
|
|
247
|
-
? getOpenAIStreamIdleTimeoutMs(
|
|
248
|
-
: getStreamIdleTimeoutMs(
|
|
259
|
+
? getOpenAIStreamIdleTimeoutMs(idleTimeoutFallbackMs)
|
|
260
|
+
: getStreamIdleTimeoutMs(idleTimeoutFallbackMs)));
|
|
249
261
|
const firstItemTimeoutMs = providerHandlesStreamTimeouts
|
|
250
262
|
? 0
|
|
251
263
|
: (options.streamFirstEventTimeoutMs ??
|
|
@@ -311,6 +323,7 @@ function createLazyLoadErrorMessage<TApi extends Api>(
|
|
|
311
323
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
312
324
|
},
|
|
313
325
|
stopReason,
|
|
326
|
+
errorId: stopReason === "error" ? AIError.classify(error, model.api) || undefined : undefined,
|
|
314
327
|
errorMessage:
|
|
315
328
|
stopReason === "aborted" ? "Request was aborted" : error instanceof Error ? error.message : String(error),
|
|
316
329
|
timestamp: Date.now(),
|
|
@@ -138,18 +138,33 @@ export async function refreshCursorToken(apiKeyOrRefreshToken: string): Promise<
|
|
|
138
138
|
};
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
function
|
|
141
|
+
function decodeCursorAccessTokenPayload(token: string): unknown | undefined {
|
|
142
|
+
const parts = token.split(".");
|
|
143
|
+
if (parts.length !== 3) return undefined;
|
|
144
|
+
const payload = parts[1];
|
|
145
|
+
if (!payload) return undefined;
|
|
146
|
+
return JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function extractCursorAccessTokenUserId(accessToken: string): string | undefined {
|
|
142
150
|
try {
|
|
143
|
-
const
|
|
144
|
-
if (
|
|
145
|
-
return
|
|
151
|
+
const payload = decodeCursorAccessTokenPayload(accessToken);
|
|
152
|
+
if (!payload || typeof payload !== "object" || !("sub" in payload) || typeof payload.sub !== "string") {
|
|
153
|
+
return undefined;
|
|
146
154
|
}
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
155
|
+
const { sub } = payload;
|
|
156
|
+
const parts = sub.split("|");
|
|
157
|
+
const userId = (parts.length > 1 ? parts[1] : sub).trim();
|
|
158
|
+
return userId || undefined;
|
|
159
|
+
} catch {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getTokenExpiry(token: string): number {
|
|
165
|
+
try {
|
|
166
|
+
const decoded = decodeCursorAccessTokenPayload(token);
|
|
167
|
+
if (decoded && typeof decoded === "object" && "exp" in decoded && typeof decoded.exp === "number") {
|
|
153
168
|
return decoded.exp * 1000 - 5 * 60 * 1000;
|
|
154
169
|
}
|
|
155
170
|
} catch {
|
|
@@ -160,9 +175,10 @@ function getTokenExpiry(token: string): number {
|
|
|
160
175
|
|
|
161
176
|
export function isCursorTokenExpiringSoon(token: string, thresholdSeconds = 300): boolean {
|
|
162
177
|
try {
|
|
163
|
-
const
|
|
164
|
-
if (!
|
|
165
|
-
|
|
178
|
+
const decoded = decodeCursorAccessTokenPayload(token);
|
|
179
|
+
if (!decoded || typeof decoded !== "object" || !("exp" in decoded) || typeof decoded.exp !== "number") {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
166
182
|
const currentTime = Math.floor(Date.now() / 1000);
|
|
167
183
|
return decoded.exp - currentTime < thresholdSeconds;
|
|
168
184
|
} catch {
|
|
@@ -34,6 +34,13 @@ export function registerOAuthProvider(provider: OAuthProviderInterface): void {
|
|
|
34
34
|
customOAuthProviders.set(provider.id, provider);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Remove a custom OAuth provider by ID.
|
|
39
|
+
*/
|
|
40
|
+
export function unregisterOAuthProvider(id: string): void {
|
|
41
|
+
customOAuthProviders.delete(id);
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
/**
|
|
38
45
|
* Get a custom OAuth provider by ID.
|
|
39
46
|
*/
|
package/src/stream.ts
CHANGED
|
@@ -112,10 +112,18 @@ function isGoogleVertexAuthenticatedModel(model: Model<Api>): boolean {
|
|
|
112
112
|
*/
|
|
113
113
|
function isLeakedThinkingHealExempt(model: Model<Api>): boolean {
|
|
114
114
|
switch (model.provider) {
|
|
115
|
-
case "anthropic":
|
|
116
|
-
// Mirror resolveAnthropicBaseUrl: Foundry redirects
|
|
117
|
-
//
|
|
118
|
-
|
|
115
|
+
case "anthropic": {
|
|
116
|
+
// Mirror resolveAnthropicBaseUrl's effective endpoint: Foundry redirects
|
|
117
|
+
// an empty baseUrl to FOUNDRY_BASE_URL; otherwise an explicit non-official
|
|
118
|
+
// model.baseUrl wins, then the ANTHROPIC_BASE_URL gateway fallback, then
|
|
119
|
+
// the official default. Exempt only when the effective endpoint is official.
|
|
120
|
+
if (isFoundryEnabled()) {
|
|
121
|
+
const foundry = $env.FOUNDRY_BASE_URL?.trim();
|
|
122
|
+
if (foundry) return isOfficialAnthropicApiUrl(foundry);
|
|
123
|
+
}
|
|
124
|
+
if (model.baseUrl && !isOfficialAnthropicApiUrl(model.baseUrl)) return false;
|
|
125
|
+
return isOfficialAnthropicApiUrl($env.ANTHROPIC_BASE_URL?.trim() || model.baseUrl);
|
|
126
|
+
}
|
|
119
127
|
case "openai":
|
|
120
128
|
return isOfficialOpenAIApiUrl(model.baseUrl);
|
|
121
129
|
case "openai-codex":
|
package/src/types.ts
CHANGED
|
@@ -830,22 +830,32 @@ export interface DeveloperMessage {
|
|
|
830
830
|
timestamp: number; // Unix timestamp in milliseconds
|
|
831
831
|
}
|
|
832
832
|
|
|
833
|
+
/** How an automatic retry recovered or ultimately settled a failed attempt. */
|
|
833
834
|
export type AssistantRetryRecoveryKind = "credential" | "model" | "wait" | "plain";
|
|
834
835
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
836
|
+
/** Persisted presentation state for an assistant error superseded by an automatic retry saga. */
|
|
837
|
+
export type AssistantRetryRecovery =
|
|
838
|
+
| {
|
|
839
|
+
kind: "auto-retry";
|
|
840
|
+
status: "recovered";
|
|
841
|
+
attempt: number;
|
|
842
|
+
recoveredAt: string;
|
|
843
|
+
recovery: AssistantRetryRecoveryKind;
|
|
844
|
+
note: string;
|
|
845
|
+
supersededBy?: {
|
|
846
|
+
timestamp: number;
|
|
847
|
+
responseId?: string;
|
|
848
|
+
provider: string;
|
|
849
|
+
model: string;
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
| {
|
|
853
|
+
kind: "auto-retry";
|
|
854
|
+
status: "superseded";
|
|
855
|
+
attempt: number;
|
|
856
|
+
recovery: AssistantRetryRecoveryKind;
|
|
857
|
+
note: string;
|
|
858
|
+
};
|
|
849
859
|
|
|
850
860
|
export interface ContextSnapshot {
|
|
851
861
|
promptTokens: number; // authoritative provider prompt/input tokens
|
package/src/usage/cursor.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { extractCursorAccessTokenUserId } from "../registry/oauth/cursor";
|
|
1
2
|
import type {
|
|
2
3
|
UsageAmount,
|
|
3
4
|
UsageFetchContext,
|
|
@@ -22,11 +23,42 @@ function parseTimestamp(value: unknown): number | undefined {
|
|
|
22
23
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
const DEFAULT_CURSOR_BASE_URL = "https://api2.cursor.sh";
|
|
27
|
+
|
|
25
28
|
function normalizeCursorBaseUrl(baseUrl?: string): string {
|
|
26
|
-
if (!baseUrl) return
|
|
29
|
+
if (!baseUrl) return DEFAULT_CURSOR_BASE_URL;
|
|
27
30
|
return baseUrl.replace(/\/+$/, "");
|
|
28
31
|
}
|
|
29
32
|
|
|
33
|
+
type CursorUsageSource = "auth-usage" | "usage-summary" | "auth-me";
|
|
34
|
+
|
|
35
|
+
async function fetchCursorJson(
|
|
36
|
+
ctx: UsageFetchContext,
|
|
37
|
+
url: string,
|
|
38
|
+
init: RequestInit,
|
|
39
|
+
source: CursorUsageSource,
|
|
40
|
+
): Promise<unknown | undefined> {
|
|
41
|
+
try {
|
|
42
|
+
const response = await ctx.fetch(url, init);
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
ctx.logger?.warn("Cursor usage request failed", {
|
|
45
|
+
status: response.status,
|
|
46
|
+
provider: "cursor",
|
|
47
|
+
source,
|
|
48
|
+
});
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
return await response.json();
|
|
52
|
+
} catch (error) {
|
|
53
|
+
ctx.logger?.warn("Cursor usage request error", {
|
|
54
|
+
provider: "cursor",
|
|
55
|
+
source,
|
|
56
|
+
error: String(error),
|
|
57
|
+
});
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
30
62
|
function deriveResetsAt(payload: Record<string, unknown>): number | undefined {
|
|
31
63
|
const endKeys = ["billingCycleEnd", "endOfMonth", "resetsAt", "nextReset"];
|
|
32
64
|
for (const key of endKeys) {
|
|
@@ -46,6 +78,78 @@ function deriveResetsAt(payload: Record<string, unknown>): number | undefined {
|
|
|
46
78
|
return undefined;
|
|
47
79
|
}
|
|
48
80
|
|
|
81
|
+
function resolveCursorStatus(usedFraction: number | undefined): UsageStatus {
|
|
82
|
+
if (usedFraction === undefined) return "unknown";
|
|
83
|
+
if (usedFraction >= 1) return "exhausted";
|
|
84
|
+
if (usedFraction >= 0.9) return "warning";
|
|
85
|
+
return "ok";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function parseCursorIndividualUsage(payload: unknown, fetchedAt = Date.now()): UsageReport | null {
|
|
89
|
+
if (!isRecord(payload) || !isRecord(payload.individualUsage) || !isRecord(payload.individualUsage.overall)) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const individual = payload.individualUsage.overall;
|
|
93
|
+
if (individual.enabled === false) return null;
|
|
94
|
+
|
|
95
|
+
const reportedUsed = toNumber(individual.used);
|
|
96
|
+
const reportedRemaining = toNumber(individual.remaining);
|
|
97
|
+
const hasValidUsed = reportedUsed !== undefined && reportedUsed >= 0;
|
|
98
|
+
const hasValidRemaining = reportedRemaining !== undefined && reportedRemaining >= 0;
|
|
99
|
+
const limit = toNumber(individual.limit);
|
|
100
|
+
|
|
101
|
+
let amount: UsageAmount;
|
|
102
|
+
if (individual.limit === null || individual.limit === undefined) {
|
|
103
|
+
if (!hasValidUsed) return null;
|
|
104
|
+
amount = { used: reportedUsed / 100, unit: "usd" };
|
|
105
|
+
} else {
|
|
106
|
+
if (limit === undefined || limit <= 0) return null;
|
|
107
|
+
let used: number;
|
|
108
|
+
if (reportedUsed !== undefined && reportedUsed > 0) {
|
|
109
|
+
used = reportedUsed;
|
|
110
|
+
} else if (hasValidRemaining && reportedRemaining < limit) {
|
|
111
|
+
used = Math.max(0, limit - reportedRemaining);
|
|
112
|
+
} else if (hasValidUsed) {
|
|
113
|
+
used = reportedUsed;
|
|
114
|
+
} else {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const remaining = Math.max(0, limit - used);
|
|
118
|
+
amount = {
|
|
119
|
+
used: used / 100,
|
|
120
|
+
limit: limit / 100,
|
|
121
|
+
remaining: remaining / 100,
|
|
122
|
+
usedFraction: used / limit,
|
|
123
|
+
remainingFraction: remaining / limit,
|
|
124
|
+
unit: "usd",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const resetsAt = deriveResetsAt(payload);
|
|
129
|
+
const window: UsageWindow = {
|
|
130
|
+
id: "monthly",
|
|
131
|
+
label: "Monthly",
|
|
132
|
+
...(resetsAt !== undefined ? { resetsAt } : {}),
|
|
133
|
+
};
|
|
134
|
+
const limitEntry: UsageLimit = {
|
|
135
|
+
id: "cursor:usd:individual-overall",
|
|
136
|
+
label: "Personal Usage",
|
|
137
|
+
scope: {
|
|
138
|
+
provider: "cursor",
|
|
139
|
+
windowId: window.id,
|
|
140
|
+
},
|
|
141
|
+
window,
|
|
142
|
+
amount,
|
|
143
|
+
...(amount.usedFraction !== undefined ? { status: resolveCursorStatus(amount.usedFraction) } : {}),
|
|
144
|
+
};
|
|
145
|
+
return {
|
|
146
|
+
provider: "cursor",
|
|
147
|
+
fetchedAt,
|
|
148
|
+
limits: [limitEntry],
|
|
149
|
+
raw: payload,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
49
153
|
export function parseCursorUsage(payload: unknown, fetchedAt = Date.now()): UsageReport | null {
|
|
50
154
|
if (!isRecord(payload)) return null;
|
|
51
155
|
const limits: UsageLimit[] = [];
|
|
@@ -93,17 +197,7 @@ export function parseCursorUsage(payload: unknown, fetchedAt = Date.now()): Usag
|
|
|
93
197
|
unit,
|
|
94
198
|
};
|
|
95
199
|
|
|
96
|
-
const
|
|
97
|
-
let status: UsageStatus = "unknown";
|
|
98
|
-
if (usedFraction !== undefined) {
|
|
99
|
-
if (usedFraction >= 1) {
|
|
100
|
-
status = "exhausted";
|
|
101
|
-
} else if (usedFraction >= 0.9) {
|
|
102
|
-
status = "warning";
|
|
103
|
-
} else {
|
|
104
|
-
status = "ok";
|
|
105
|
-
}
|
|
106
|
-
}
|
|
200
|
+
const status = resolveCursorStatus(amount.usedFraction);
|
|
107
201
|
|
|
108
202
|
limits.push({
|
|
109
203
|
id: limitId,
|
|
@@ -152,41 +246,90 @@ export const cursorUsageProvider: UsageProvider = {
|
|
|
152
246
|
|
|
153
247
|
const baseUrl = normalizeCursorBaseUrl(params.baseUrl ?? credential.apiEndpoint);
|
|
154
248
|
const url = `${baseUrl}/auth/usage`;
|
|
155
|
-
|
|
156
249
|
const headers: Record<string, string> = {
|
|
157
250
|
Accept: "application/json",
|
|
158
251
|
Authorization: `Bearer ${token}`,
|
|
159
252
|
};
|
|
253
|
+
const fetchedAt = Date.now();
|
|
160
254
|
|
|
161
|
-
|
|
162
|
-
|
|
255
|
+
const legacyReportPromise = fetchCursorJson(
|
|
256
|
+
ctx,
|
|
257
|
+
url,
|
|
258
|
+
{
|
|
163
259
|
headers,
|
|
164
260
|
signal: params.signal,
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
...(credential.email ? { email: credential.email } : {}),
|
|
178
|
-
...(credential.accountId ? { accountId: credential.accountId } : {}),
|
|
179
|
-
...(credential.projectId ? { projectId: credential.projectId } : {}),
|
|
261
|
+
},
|
|
262
|
+
"auth-usage",
|
|
263
|
+
).then(payload => parseCursorUsage(payload, fetchedAt));
|
|
264
|
+
|
|
265
|
+
let summaryReportPromise = Promise.resolve<UsageReport | null>(null);
|
|
266
|
+
let profileEmailPromise = Promise.resolve<string | undefined>(undefined);
|
|
267
|
+
if (credential.type === "oauth" && baseUrl === DEFAULT_CURSOR_BASE_URL) {
|
|
268
|
+
const userId = extractCursorAccessTokenUserId(token);
|
|
269
|
+
if (userId) {
|
|
270
|
+
const sessionHeaders: Record<string, string> = {
|
|
271
|
+
Accept: "application/json",
|
|
272
|
+
Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${token}`)}`,
|
|
180
273
|
};
|
|
181
|
-
|
|
274
|
+
summaryReportPromise = fetchCursorJson(
|
|
275
|
+
ctx,
|
|
276
|
+
"https://cursor.com/api/usage-summary",
|
|
277
|
+
{
|
|
278
|
+
headers: sessionHeaders,
|
|
279
|
+
signal: params.signal,
|
|
280
|
+
},
|
|
281
|
+
"usage-summary",
|
|
282
|
+
).then(payload => parseCursorIndividualUsage(payload, fetchedAt));
|
|
283
|
+
profileEmailPromise = fetchCursorJson(
|
|
284
|
+
ctx,
|
|
285
|
+
"https://cursor.com/api/auth/me",
|
|
286
|
+
{
|
|
287
|
+
headers: sessionHeaders,
|
|
288
|
+
signal: params.signal,
|
|
289
|
+
},
|
|
290
|
+
"auth-me",
|
|
291
|
+
).then(payload => {
|
|
292
|
+
if (
|
|
293
|
+
!isRecord(payload) ||
|
|
294
|
+
payload.sub !== userId ||
|
|
295
|
+
typeof payload.email !== "string" ||
|
|
296
|
+
!payload.email.trim()
|
|
297
|
+
) {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
return payload.email.trim();
|
|
301
|
+
});
|
|
182
302
|
}
|
|
183
|
-
return report;
|
|
184
|
-
} catch (error) {
|
|
185
|
-
ctx.logger?.warn("Cursor usage request error", {
|
|
186
|
-
provider: params.provider,
|
|
187
|
-
error: String(error),
|
|
188
|
-
});
|
|
189
|
-
return null;
|
|
190
303
|
}
|
|
304
|
+
|
|
305
|
+
const [legacyReport, summaryReport, profileEmail] = await Promise.all([
|
|
306
|
+
legacyReportPromise,
|
|
307
|
+
summaryReportPromise,
|
|
308
|
+
profileEmailPromise,
|
|
309
|
+
]);
|
|
310
|
+
let report: UsageReport | null;
|
|
311
|
+
if (legacyReport && summaryReport) {
|
|
312
|
+
report = {
|
|
313
|
+
provider: "cursor",
|
|
314
|
+
fetchedAt,
|
|
315
|
+
limits: [...legacyReport.limits, ...summaryReport.limits],
|
|
316
|
+
raw: {
|
|
317
|
+
authUsage: legacyReport.raw,
|
|
318
|
+
usageSummary: summaryReport.raw,
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
} else {
|
|
322
|
+
report = legacyReport ?? summaryReport;
|
|
323
|
+
}
|
|
324
|
+
if (!report) return null;
|
|
325
|
+
|
|
326
|
+
const email = profileEmail ?? credential.email?.trim();
|
|
327
|
+
const metadata = {
|
|
328
|
+
...(email ? { email } : {}),
|
|
329
|
+
...(credential.accountId ? { accountId: credential.accountId } : {}),
|
|
330
|
+
...(credential.projectId ? { projectId: credential.projectId } : {}),
|
|
331
|
+
};
|
|
332
|
+
if (Object.keys(metadata).length > 0) report.metadata = metadata;
|
|
333
|
+
return report;
|
|
191
334
|
},
|
|
192
335
|
};
|
|
@@ -95,11 +95,10 @@ export async function finalizeErrorMessage(
|
|
|
95
95
|
* Rewrite error message for GitHub Copilot request failures.
|
|
96
96
|
* Must run AFTER finalizeErrorMessage since it replaces the message entirely.
|
|
97
97
|
*
|
|
98
|
-
* 400
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* in
|
|
102
|
-
* surface guidance rather than the raw error.
|
|
98
|
+
* 400 `model_not_supported` = Copilot fleet skew. A model that `/models`
|
|
99
|
+
* advertises can flap between 200 and 400 because only part of
|
|
100
|
+
* Copilot's fleet has it in the integrator allowlist. After the
|
|
101
|
+
* in-request retry exhausts, surface guidance rather than the raw error.
|
|
103
102
|
* 401 = token invalid/expired → credential removal is safe, prompt re-login.
|
|
104
103
|
* 403 = token valid but access denied (plan, model policy, org restriction) →
|
|
105
104
|
* do NOT reuse the auth-failed string (which triggers credential removal).
|
package/src/utils/retry.ts
CHANGED
|
@@ -23,9 +23,8 @@ const COPILOT_MODEL_RETRY_BASE_DELAY_MS = 400;
|
|
|
23
23
|
const COPILOT_RETRY_AFTER_MAX_WAIT_MS = 30_000;
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* Wrap an initial Copilot request so transient
|
|
27
|
-
*
|
|
28
|
-
* small number of times. No-op for non-Copilot providers.
|
|
26
|
+
* Wrap an initial Copilot request so transient `model_not_supported` 400s are
|
|
27
|
+
* retried a small number of times. No-op for non-Copilot providers.
|
|
29
28
|
*
|
|
30
29
|
* The callback **MUST** create a fresh in-flight request each invocation — a
|
|
31
30
|
* once-consumed AsyncIterable cannot be re-iterated.
|