@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 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 rejecting a model its own `/models` catalog advertises —
69
- * transient fleet skew, not a malformed request. Reads the structural `code`
70
- * through the SDK/body envelopes, then falls back to the stringified body both
71
- * SDK families put in `message` (shapes drift; the wire text does not).
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
  */
@@ -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
- export interface AssistantRetryRecovery {
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 model-unavailable = Copilot fleet skew. A model that `/models` advertises
42
- * (claude-sonnet-4.6, claude-opus-4.6, gpt-5.4, gpt-5.3-codex, ...)
43
- * flaps between 200 and 400 because only part of Copilot's fleet has it
44
- * in the integrator allowlist. After the in-request retry exhausts,
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 model-availability 400s
5
- * (`model_not_supported`, `model_not_available_for_integrator`) are retried a
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.10",
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.10",
42
- "@oh-my-pi/pi-catalog": "17.2.10",
43
- "@oh-my-pi/pi-utils": "17.2.10",
44
- "@oh-my-pi/pi-wire": "17.2.10"
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
@@ -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 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.
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 COPILOT_MODEL_UNAVAILABLE_PATTERN =
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 model rejection is transient.
381
- if (statusClean === 400 && COPILOT_MODEL_UNAVAILABLE_PATTERN.test(cleanMessage)) kinds |= Flag.Transient;
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 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).
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" && COPILOT_MODEL_UNAVAILABLE_PATTERN.test(message);
526
+ return typeof message === "string" && COPILOT_TRANSIENT_MODEL_PATTERN.test(message);
529
527
  }
530
528
 
531
529
  export function classifyMessage(message: {
@@ -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
- return !/[a-z\d]{3,}/i.test(cleaned);
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
- return normalizeAnthropicBaseUrl(model.baseUrl) ?? "https://api.anthropic.com";
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(model: Model<"anthropic-messages">): Record<string, string> | undefined {
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(model.baseUrl);
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(limits.defaultIdleTimeoutMs)
248
- : getStreamIdleTimeoutMs(limits?.defaultIdleTimeoutMs)));
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 getTokenExpiry(token: string): number {
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 parts = token.split(".");
144
- if (parts.length !== 3) {
145
- return Date.now() + 3600 * 1000;
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 payload = parts[1];
148
- if (!payload) {
149
- return Date.now() + 3600 * 1000;
150
- }
151
- const decoded = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
152
- if (decoded && typeof decoded === "object" && typeof decoded.exp === "number") {
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 [, payload] = token.split(".");
164
- if (!payload) return true;
165
- const decoded = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
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 an empty baseUrl to
117
- // FOUNDRY_BASE_URL, so exempt only when the effective endpoint is official.
118
- return isOfficialAnthropicApiUrl((isFoundryEnabled() && $env.FOUNDRY_BASE_URL?.trim()) || model.baseUrl);
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
- export interface AssistantRetryRecovery {
836
- kind: "auto-retry";
837
- status: "recovered";
838
- attempt: number;
839
- recoveredAt: string;
840
- recovery: AssistantRetryRecoveryKind;
841
- note: string;
842
- supersededBy?: {
843
- timestamp: number;
844
- responseId?: string;
845
- provider: string;
846
- model: string;
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
@@ -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 "https://api2.cursor.sh";
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 usedFraction = amount.usedFraction;
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
- try {
162
- const response = await ctx.fetch(url, {
255
+ const legacyReportPromise = fetchCursorJson(
256
+ ctx,
257
+ url,
258
+ {
163
259
  headers,
164
260
  signal: params.signal,
165
- });
166
- if (!response.ok) {
167
- ctx.logger?.warn("Cursor usage request failed", {
168
- status: response.status,
169
- provider: params.provider,
170
- });
171
- return null;
172
- }
173
- const payload = await response.json();
174
- const report = parseCursorUsage(payload);
175
- if (report) {
176
- const metadata = {
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
- if (Object.keys(metadata).length > 0) report.metadata = metadata;
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 model-unavailable = Copilot fleet skew. A model that `/models` advertises
99
- * (claude-sonnet-4.6, claude-opus-4.6, gpt-5.4, gpt-5.3-codex, ...)
100
- * flaps between 200 and 400 because only part of Copilot's fleet has it
101
- * in the integrator allowlist. After the in-request retry exhausts,
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).
@@ -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 model-availability 400s
27
- * (`model_not_supported`, `model_not_available_for_integrator`) are retried a
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.