@oh-my-pi/pi-ai 18.3.4 → 18.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +35 -48
  2. package/dist/types/auth/blocks.d.ts +10 -19
  3. package/dist/types/auth/cascade.d.ts +1 -1
  4. package/dist/types/auth/rotation.d.ts +6 -3
  5. package/dist/types/auth/types.d.ts +23 -3
  6. package/dist/types/auth/usage-cache.d.ts +14 -2
  7. package/dist/types/auth/usage.d.ts +2 -2
  8. package/dist/types/auth-broker/client.d.ts +2 -1
  9. package/dist/types/auth-broker/remote-store.d.ts +5 -6
  10. package/dist/types/auth-broker/types.d.ts +3 -1
  11. package/dist/types/auth-broker/wire-schemas.d.ts +2 -1
  12. package/dist/types/auth-retry.d.ts +22 -13
  13. package/dist/types/images/types.d.ts +9 -0
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/judgment/types.d.ts +3 -2
  16. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  17. package/dist/types/providers/cursor-pi-args.d.ts +10 -0
  18. package/dist/types/providers/cursor.d.ts +6 -2
  19. package/dist/types/providers/google-shared.d.ts +13 -2
  20. package/dist/types/types.d.ts +0 -10
  21. package/dist/types/usage/claude-reset.d.ts +6 -0
  22. package/dist/types/usage/claude.d.ts +6 -0
  23. package/dist/types/usage.d.ts +2 -0
  24. package/dist/types/utils/proxy.d.ts +2 -2
  25. package/package.json +6 -6
  26. package/src/auth/blocks.ts +79 -37
  27. package/src/auth/cascade.ts +5 -4
  28. package/src/auth/resets.ts +104 -40
  29. package/src/auth/rotation.ts +62 -21
  30. package/src/auth/select.ts +9 -2
  31. package/src/auth/types.ts +28 -3
  32. package/src/auth/usage-cache.ts +45 -2
  33. package/src/auth/usage.ts +52 -25
  34. package/src/auth-broker/client.ts +13 -0
  35. package/src/auth-broker/remote-store.ts +128 -27
  36. package/src/auth-broker/server.ts +20 -0
  37. package/src/auth-broker/types.ts +4 -1
  38. package/src/auth-broker/wire-schemas.ts +7 -0
  39. package/src/auth-retry.ts +50 -28
  40. package/src/images/openai-hosted.ts +18 -3
  41. package/src/images/types.ts +9 -0
  42. package/src/index.ts +1 -0
  43. package/src/judgment/types.ts +10 -6
  44. package/src/judgment/typesafe.ts +2 -2
  45. package/src/providers/anthropic.ts +54 -123
  46. package/src/providers/cursor/exec-modern.ts +1 -0
  47. package/src/providers/cursor/proto/agent.proto +5 -0
  48. package/src/providers/cursor-pi-args.ts +14 -0
  49. package/src/providers/cursor.ts +66 -28
  50. package/src/providers/google-gemini-cli.ts +2 -19
  51. package/src/providers/google-shared.ts +30 -22
  52. package/src/providers/ollama.ts +2 -0
  53. package/src/registry/oauth/oauth.html +206 -185
  54. package/src/stream.ts +2 -284
  55. package/src/types.ts +0 -10
  56. package/src/usage/claude-reset.ts +45 -11
  57. package/src/usage/claude.ts +79 -49
  58. package/src/usage.ts +2 -0
  59. package/src/utils/proxy.ts +3 -3
  60. package/src/utils/schema/CONSTRAINTS.md +30 -28
  61. package/src/utils/schema/normalize.ts +1 -1
  62. package/src/utils/schema/wire.ts +3 -3
@@ -1,4 +1,10 @@
1
1
  import { type CredentialRankingStrategy, type UsageProvider, type UsageReport } from "../usage.js";
2
2
  export declare function parseClaudeRateLimitHeaders(headers: Record<string, string>, now?: number): UsageReport | null;
3
+ /** Parse the quota evidence in one live usage response, without any additional requests. */
4
+ export declare function parseClaudeUsagePayload(raw: unknown, identity?: {
5
+ accountId?: string;
6
+ email?: string;
7
+ orgId?: string;
8
+ }, endpoint?: string, fetchedAt?: number): UsageReport | null;
3
9
  export declare const claudeUsageProvider: UsageProvider;
4
10
  export declare const claudeRankingStrategy: CredentialRankingStrategy;
@@ -669,6 +669,8 @@ export interface CredentialRankingStrategy {
669
669
  limits: UsageLimit[];
670
670
  healthy?: boolean;
671
671
  }[];
672
+ /** Whether fresh reports can heal legacy account-wide quota backoffs. */
673
+ healsGlobalBlocks?: boolean;
672
674
  /** Fallback window durations (ms) when limits don't specify durationMs. */
673
675
  windowDefaults: {
674
676
  primaryMs: number;
@@ -23,8 +23,8 @@ export declare function getProxyForUrl(provider: string, url: URL): string | und
23
23
  /**
24
24
  * Return `init` with `proxy: proxyUrl` set when the request should tunnel.
25
25
  * A caller-supplied `init.proxy` always wins (the innermost, most specific
26
- * decision); local/metadata hosts, NO_PROXY matches, and unparseable URLs
27
- * pass through unchanged.
26
+ * decision); Unix sockets, local/metadata hosts, NO_PROXY matches, and
27
+ * unparseable URLs pass through unchanged.
28
28
  */
29
29
  export declare function withProxyInit(input: string | URL | Request, init: RequestInit | undefined, proxyUrl: string): RequestInit | undefined;
30
30
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.3.4",
3
+ "version": "18.4.0",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.3.4",
159
- "@oh-my-pi/pi-catalog": "18.3.4",
160
- "@oh-my-pi/pi-natives": "18.3.4",
161
- "@oh-my-pi/pi-utils": "18.3.4",
162
- "@oh-my-pi/pi-wire": "18.3.4"
158
+ "@oh-my-pi/omptype": "18.4.0",
159
+ "@oh-my-pi/pi-catalog": "18.4.0",
160
+ "@oh-my-pi/pi-natives": "18.4.0",
161
+ "@oh-my-pi/pi-utils": "18.4.0",
162
+ "@oh-my-pi/pi-wire": "18.4.0"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -22,6 +22,12 @@ export function scopedBackoffKey(providerKey: string, blockScope: string | undef
22
22
  return blockScope ? `${providerKey}\0${blockScope}` : providerKey;
23
23
  }
24
24
 
25
+ /** Authentication backoffs apply to every model and cannot be healed by quota evidence. */
26
+ export const AUTH_BLOCK_SCOPE = "auth";
27
+ /** Account-policy backoffs apply to every model and survive quota resets. */
28
+ export const ACCOUNT_POLICY_BLOCK_SCOPE = "account-policy";
29
+ const PROTECTED_BLOCK_SCOPES = [AUTH_BLOCK_SCOPE, ACCOUNT_POLICY_BLOCK_SCOPE] as const;
30
+
25
31
  const MODEL_ACCOUNT_POLICY_BLOCK_SCOPE_PREFIX = "model-policy:";
26
32
  const MODEL_ACCOUNT_POLICY_PROVIDERS: Readonly<Record<string, true>> = {
27
33
  "openai-codex": true,
@@ -56,7 +62,7 @@ export function credentialBlockScopesForRequest(
56
62
  * than a heuristic guess, tracking whichever deadline won the longest-wins
57
63
  * merge. `probeAfter` is the earliest time live usage reconciliation may clear it.
58
64
  */
59
- type CredentialBackoff = { until: number; timed: boolean; probeAfter: number };
65
+ type CredentialBackoff = { until: number; timed: boolean; probeAfter: number; persisted?: boolean };
60
66
 
61
67
  /** Latch for unrecoverably corrupt persisted block stores, shared with the credential pool. */
62
68
  export class BlockStoreHealth {
@@ -132,6 +138,23 @@ export class CredentialBlocks implements BlocksApi {
132
138
  #getCredentialBlockedUntilForKey(backoffKey: string, credentialId: number, nowMs: number): number | undefined {
133
139
  const block = this.#credentialBackoff.get(backoffKey)?.get(credentialId);
134
140
  if (!block) return undefined;
141
+ // Once mirrored successfully, the store is authoritative for cross-process
142
+ // deletion. A reset in a sibling must also retire this process's old map entry.
143
+ if (block.persisted && !this.#deps.health.damaged && this.#deps.store.getCredentialBlock) {
144
+ const separator = backoffKey.indexOf("\0");
145
+ const providerKey = separator < 0 ? backoffKey : backoffKey.slice(0, separator);
146
+ const scope = separator < 0 ? "" : backoffKey.slice(separator + 1);
147
+ try {
148
+ const persisted = this.#deps.store.getCredentialBlock(credentialId, providerKey, scope);
149
+ if (persisted === undefined) {
150
+ this.#deleteCredentialBackoff(backoffKey, credentialId);
151
+ return undefined;
152
+ }
153
+ } catch (err) {
154
+ // Failed reads never turn a local backoff into permission to retry.
155
+ this.#deps.health.handle(err);
156
+ }
157
+ }
135
158
  if (block.until <= nowMs) {
136
159
  this.#deleteCredentialBackoff(backoffKey, credentialId);
137
160
  return undefined;
@@ -189,6 +212,16 @@ export class CredentialBlocks implements BlocksApi {
189
212
  }
190
213
  }
191
214
 
215
+ #exactBlockedUntil(credentialId: number, providerKey: string, scope: string | undefined): number | undefined {
216
+ const memory = this.#getCredentialBlockedUntilForKey(
217
+ scopedBackoffKey(providerKey, scope),
218
+ credentialId,
219
+ Date.now(),
220
+ );
221
+ const persisted = this.#readPersistedCredentialBlock(credentialId, providerKey, scope);
222
+ return memory === undefined ? persisted : persisted === undefined ? memory : Math.max(memory, persisted);
223
+ }
224
+
192
225
  /** Returns block expiry timestamp for a credential, checking unscoped and scoped blocks. */
193
226
  blockedUntil(
194
227
  provider: string,
@@ -199,9 +232,10 @@ export class CredentialBlocks implements BlocksApi {
199
232
  const nowMs = Date.now();
200
233
  // A request honours its own scope plus any legacy catch-all scope, so a
201
234
  // block written before backoff was scoped still applies to everything.
202
- const scopes = (
203
- typeof blockScopeOrScopes === "string" ? [blockScopeOrScopes] : (blockScopeOrScopes ?? [])
204
- ).filter(scope => scope.length > 0);
235
+ const scopes = [
236
+ ...PROTECTED_BLOCK_SCOPES,
237
+ ...(typeof blockScopeOrScopes === "string" ? [blockScopeOrScopes] : (blockScopeOrScopes ?? [])),
238
+ ].filter(scope => scope.length > 0);
205
239
  const credentialId = this.#deps.pool.entries(provider)[credentialIndex]?.id;
206
240
  if (credentialId === undefined) return undefined;
207
241
  let blockedUntil = this.#getCredentialBlockedUntilForKey(providerKey, credentialId, nowMs);
@@ -262,9 +296,10 @@ export class CredentialBlocks implements BlocksApi {
262
296
  credentialId: number,
263
297
  deadline: number,
264
298
  ): boolean {
265
- const scopes = (
266
- typeof blockScopeOrScopes === "string" ? [blockScopeOrScopes] : (blockScopeOrScopes ?? [])
267
- ).filter(scope => scope.length > 0);
299
+ const scopes = [
300
+ ...PROTECTED_BLOCK_SCOPES,
301
+ ...(typeof blockScopeOrScopes === "string" ? [blockScopeOrScopes] : (blockScopeOrScopes ?? [])),
302
+ ].filter(scope => scope.length > 0);
268
303
  for (const key of [providerKey, ...scopes.map(scope => scopedBackoffKey(providerKey, scope))]) {
269
304
  const block = this.#credentialBackoff.get(key)?.get(credentialId);
270
305
  if (block?.until === deadline && block.timed) return true;
@@ -290,6 +325,8 @@ export class CredentialBlocks implements BlocksApi {
290
325
  const credentialId = this.#deps.pool.entries(provider)[credentialIndex]?.id;
291
326
  if (credentialId === undefined) return;
292
327
  const backoffKey = scopedBackoffKey(providerKey, blockScope);
328
+ // Adopt externally deleted rows before longest-wins merging a new failure.
329
+ this.#getCredentialBlockedUntilForKey(backoffKey, credentialId, Date.now());
293
330
  const backoffMap = this.#credentialBackoff.get(backoffKey) ?? new Map<number, CredentialBackoff>();
294
331
  const existing = backoffMap.get(credentialId);
295
332
  const existingUntil = existing?.until ?? 0;
@@ -318,6 +355,8 @@ export class CredentialBlocks implements BlocksApi {
318
355
  blockScope: blockScope ?? "",
319
356
  blockedUntilMs: nextBlockedUntil,
320
357
  });
358
+ const block = backoffMap.get(credentialId);
359
+ if (block) block.persisted = true;
321
360
  } catch (err) {
322
361
  if (this.#deps.health.handle(err)) return;
323
362
  logger.debug("Failed to persist credential block", {
@@ -356,18 +395,17 @@ export class CredentialBlocks implements BlocksApi {
356
395
  }
357
396
  }
358
397
 
359
- /**
360
- * Clear one backoff scope. The in-memory backoff is per scope so it is
361
- * dropped directly; the persisted store deletes a credential's blocks as a
362
- * unit, so it is only purged once no other scope still holds a live block.
363
- * Leaving a persisted row behind is safe: the scope it belongs to is
364
- * unblocked in memory, and the row heals on the pass where its own meter
365
- * recovers.
366
- */
367
- clearScope(provider: string, credentialId: number, providerKey: string, blockScope: string | undefined): void {
368
- this.#deleteCredentialBackoff(scopedBackoffKey(providerKey, blockScope), credentialId);
398
+ /** Clear exactly one quota scope, preserving unrelated account and model blocks. */
399
+ clearScope(provider: string, credentialId: number, providerKey: string, blockScope: string | undefined): boolean {
400
+ // A damaged store neither holds nor reads persisted rows; memory is the whole block.
401
+ if (this.#deps.health.damaged) {
402
+ this.#deleteCredentialBackoff(scopedBackoffKey(providerKey, blockScope), credentialId);
403
+ return true;
404
+ }
369
405
  try {
370
- this.delete(credentialId, providerKey, blockScope ?? "");
406
+ this.delete(credentialId, providerKey, blockScope ?? "", false);
407
+ this.#deleteCredentialBackoff(scopedBackoffKey(providerKey, blockScope), credentialId);
408
+ return true;
371
409
  } catch (err) {
372
410
  logger.debug("Failed to clear persisted credential block", {
373
411
  err,
@@ -375,6 +413,7 @@ export class CredentialBlocks implements BlocksApi {
375
413
  credentialId,
376
414
  blockScope,
377
415
  });
416
+ return false;
378
417
  }
379
418
  }
380
419
 
@@ -386,12 +425,8 @@ export class CredentialBlocks implements BlocksApi {
386
425
  /**
387
426
  * Whether a fresh report could lift what currently blocks this credential.
388
427
  *
389
- * A strategy that names healable scopes can only vouch for those scopes, so
390
- * a live unscoped block — an Opus/Sonnet usage limit, a refresh failure —
391
- * keeps the credential unusable whatever the report says about a tier. A
392
- * probe then cannot change the outcome and must not be spent; the tier scope
393
- * heals on a later pass, once the block that actually holds the credential
394
- * has lifted.
428
+ * Claude reports can heal legacy account-wide quota blocks too. Explicit
429
+ * auth/account-policy blocks never justify a usage probe.
395
430
  */
396
431
  canHeal(
397
432
  provider: Provider,
@@ -400,7 +435,15 @@ export class CredentialBlocks implements BlocksApi {
400
435
  blockScopeOrScopes: string | readonly string[] | undefined,
401
436
  ): boolean {
402
437
  if (!this.supportsHealing(provider)) return false;
403
- if (this.blockedUntil(provider, providerKey, credentialIndex) !== undefined) return false;
438
+ const credentialId = this.#deps.pool.entries(provider)[credentialIndex]?.id;
439
+ if (credentialId === undefined) return false;
440
+ if (PROTECTED_BLOCK_SCOPES.some(scope => this.#exactBlockedUntil(credentialId, providerKey, scope) !== undefined))
441
+ return false;
442
+ if (
443
+ !this.#deps.strategies(provider)?.healsGlobalBlocks &&
444
+ this.blockedUntil(provider, providerKey, credentialIndex) !== undefined
445
+ )
446
+ return false;
404
447
  return this.blockedUntil(provider, providerKey, credentialIndex, blockScopeOrScopes) !== undefined;
405
448
  }
406
449
 
@@ -423,7 +466,7 @@ export class CredentialBlocks implements BlocksApi {
423
466
  for (const { blockScope, limits, healthy } of strategy?.healableBlockScopes?.(report) ?? []) {
424
467
  if (healthy === false || isUsageLimitReached(limits) || (healthy === undefined && limits.length === 0))
425
468
  continue;
426
- this.#clearHealedBlockScope(provider, providerKey, credentialId, credentialIndex, blockScope);
469
+ this.#clearHealedBlockScope(provider, providerKey, credentialId, blockScope, report.fetchedAt);
427
470
  }
428
471
  }
429
472
 
@@ -437,25 +480,23 @@ export class CredentialBlocks implements BlocksApi {
437
480
  provider: Provider,
438
481
  providerKey: string,
439
482
  credentialId: number,
440
- credentialIndex: number,
441
483
  blockScope: string | undefined,
484
+ fetchedAt: number,
442
485
  ): void {
443
- const blockedUntilMs = this.blockedUntil(provider, providerKey, credentialIndex, blockScope);
486
+ const blockedUntilMs = this.#exactBlockedUntil(credentialId, providerKey, blockScope);
444
487
  if (blockedUntilMs === undefined) return;
445
488
  const nowMs = Date.now();
446
489
  const scopedKey = scopedBackoffKey(providerKey, blockScope);
447
- const globalProbeAfterMs = this.#credentialBackoff.get(providerKey)?.get(credentialId)?.probeAfter ?? 0;
448
490
  const scopedProbeAfterMs = this.#credentialBackoff.get(scopedKey)?.get(credentialId)?.probeAfter ?? 0;
449
- const storeGlobalProbeAfterMs = this.#readPersistedCredentialBlockReconcileAfter(credentialId, providerKey, "");
450
491
  const storeScopedProbeAfterMs = this.#readPersistedCredentialBlockReconcileAfter(
451
492
  credentialId,
452
493
  providerKey,
453
494
  blockScope ?? "",
454
495
  );
455
- if (Math.max(globalProbeAfterMs, scopedProbeAfterMs, storeGlobalProbeAfterMs, storeScopedProbeAfterMs) > nowMs) {
496
+ if (Math.max(scopedProbeAfterMs, storeScopedProbeAfterMs) > Math.min(nowMs, fetchedAt)) {
456
497
  return;
457
498
  }
458
- this.clearScope(provider, credentialId, providerKey, blockScope);
499
+ if (!this.clearScope(provider, credentialId, providerKey, blockScope)) return;
459
500
  logger.info("Cleared stale usage-limit block after healthy live usage report", {
460
501
  credentialId,
461
502
  provider,
@@ -474,6 +515,7 @@ export class CredentialBlocks implements BlocksApi {
474
515
  #findStoredCredentialIdsForUsageReport(report: UsageReport): number[] {
475
516
  if (!this.supportsHealing(report.provider)) return [];
476
517
  const email = usageReportMetadataValue(report, "email")?.toLowerCase();
518
+ const orgId = usageReportMetadataValue(report, "orgId");
477
519
  const accountId = (
478
520
  usageReportMetadataValue(report, "accountId") ?? usageReportScopeAccountId(report)
479
521
  )?.toLowerCase();
@@ -484,6 +526,7 @@ export class CredentialBlocks implements BlocksApi {
484
526
  if (credential.type !== "oauth") continue;
485
527
  const credentialEmail = credential.email?.trim().toLowerCase();
486
528
  const credentialAccountId = credential.accountId?.trim().toLowerCase();
529
+ if (orgId && credential.orgId && orgId !== credential.orgId.trim()) continue;
487
530
  // Every identity dimension present on BOTH sides must agree — the
488
531
  // account id is shared workspace-wide and one email can span
489
532
  // workspaces, so a single-dimension match can cross-link siblings.
@@ -540,20 +583,19 @@ export class CredentialBlocks implements BlocksApi {
540
583
  this.#deps.pool.bump("credential-block");
541
584
  }
542
585
 
543
- /**
544
- * Broker-server seam: clear all persisted blocks for one credential and notify snapshot waiters.
545
- */
546
- delete(credentialId: number, providerKey: string, blockScope: string): void {
586
+ /** Broker-server seam: clear exactly one persisted and local block and notify snapshot waiters. */
587
+ delete(credentialId: number, providerKey: string, blockScope: string, invalidateUsage = true): void {
547
588
  this.#deps.health.assertWritable();
548
589
  const deleteCredentialBlock = this.#deps.store.deleteCredentialBlock?.bind(this.#deps.store);
549
590
  if (!deleteCredentialBlock) return;
550
591
  try {
551
592
  deleteCredentialBlock(credentialId, providerKey, blockScope);
593
+ this.#deleteCredentialBackoff(scopedBackoffKey(providerKey, blockScope), credentialId);
552
594
  } catch (err) {
553
595
  if (this.#deps.health.handle(err)) this.#deps.health.assertWritable();
554
596
  throw err;
555
597
  }
556
- this.#deps.usageCache.invalidateProviderKey(providerKey);
598
+ if (invalidateUsage) this.#deps.usageCache.invalidateProviderKey(providerKey);
557
599
  this.#deps.pool.bump("credential-block");
558
600
  }
559
601
 
@@ -1,6 +1,6 @@
1
1
  import { authPolicyFor } from "@oh-my-pi/pi-catalog/compat/auth";
2
2
  import { $env, $envExact } from "@oh-my-pi/pi-utils";
3
- import type { ApiKeyResolver, ResolvedApiKey } from "../auth-retry";
3
+ import { type ApiKeyResolver, markAfterSiblingWait, type ResolvedApiKey } from "../auth-retry";
4
4
  import * as AIError from "../error";
5
5
  import { isUsageLimitOutcome } from "../error/rate-limit";
6
6
  import { AUTHENTICATED_SENTINEL } from "../registry/types";
@@ -374,13 +374,13 @@ export class KeyCascade implements KeysApi {
374
374
  });
375
375
  }
376
376
  if (lastChance) {
377
- const switched = await this.#deps.rotate(provider, sessionId, {
377
+ const rotation = await this.#deps.rotate(provider, sessionId, {
378
378
  error,
379
379
  modelId,
380
380
  signal,
381
381
  apiKey: previousKey,
382
382
  });
383
- if (!switched) {
383
+ if (!rotation.switched) {
384
384
  const status = AIError.status(error);
385
385
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
386
386
  // Preserve no-sibling quota backoff instead of re-resolving an
@@ -388,11 +388,12 @@ export class KeyCascade implements KeysApi {
388
388
  // because a peer may have refreshed the failed bearer.
389
389
  if (AIError.isUsageLimit(error) || isUsageLimitOutcome(status, message)) return undefined;
390
390
  }
391
- return this.getWithCredential(provider, sessionId, {
391
+ const resolved = await this.getWithCredential(provider, sessionId, {
392
392
  baseUrl,
393
393
  modelId,
394
394
  signal,
395
395
  });
396
+ return rotation.afterSiblingWait ? markAfterSiblingWait(resolved) : resolved;
396
397
  }
397
398
  return this.getWithCredential(provider, sessionId, {
398
399
  baseUrl,
@@ -5,12 +5,15 @@ import { claudeResetClearedBlockScopes, consumeClaudeResetCredit, listClaudeRese
5
5
  import { consumeCodexResetCredit, listCodexResetCredits, pickSoonestExpiringCredit } from "../usage/openai-codex-reset";
6
6
  import type { CredentialBlocks } from "./blocks";
7
7
  import { providerTypeKey } from "./blocks";
8
+ import { raceSignal } from "./abort";
9
+ import { isUsageLimitReached } from "./usage-report";
8
10
  import type { OAuthAccounts } from "./oauth";
9
11
  import type { CredentialPool } from "./pool";
10
12
  import type { AuthCredentialStore } from "./store";
11
13
  import type {
12
14
  ListResetCreditsOptions,
13
15
  OAuthAccess,
16
+ OAuthAccountSummary,
14
17
  RedeemResetCreditOptions,
15
18
  ResetCreditAccountStatus,
16
19
  ResetCreditRedeemOutcome,
@@ -34,10 +37,11 @@ export class ResetCredits implements ResetsApi {
34
37
  #deps: ResetCreditsDeps;
35
38
  /** Manual and automatic attempts on one stored account share a mutation. */
36
39
  #resetInFlight = new Map<string, { creditId?: string; promise: Promise<ResetCreditRedeemOutcome> }>();
40
+ #listInFlight = new Map<string, Promise<ResetCreditAccountStatus[]>>();
37
41
  /** Ambiguous Claude claims retain their idempotency key until reconciled. */
38
42
  #pendingClaudeResets = new Map<
39
43
  string,
40
- { creditId: string; requestId: string; program?: string; remainingCount?: number; startedAt: number }
44
+ { creditId: string; requestId: string; program?: string; remainingCount?: number }
41
45
  >();
42
46
 
43
47
  constructor(deps: ResetCreditsDeps) {
@@ -48,34 +52,72 @@ export class ResetCredits implements ResetsApi {
48
52
  async list(options?: ListResetCreditsOptions): Promise<ResetCreditAccountStatus[]> {
49
53
  const provider = options?.provider ?? "openai-codex";
50
54
  if (provider !== "openai-codex" && provider !== "anthropic") return [];
51
- const accounts = this.#deps.oauth.accounts(provider, options?.sessionId);
52
55
  const baseUrl = options?.baseUrlResolver?.(provider);
53
- return Promise.all(
54
- accounts.map(async (account): Promise<ResetCreditAccountStatus> => {
55
- const base = { ...account, provider };
56
- const access = await this.#deps.oauth.accessById(provider, account.credentialId, {
57
- signal: options?.signal,
58
- });
59
- if (!access?.ok)
60
- return {
61
- ...base,
62
- availableCount: 0,
63
- credits: [],
64
- error: access?.error ?? "Account no longer available",
65
- };
66
- const auth = { ...access, baseUrl, fetch: this.#deps.usage.fetch, signal: options?.signal };
67
- const list =
68
- provider === "anthropic" ? await listClaudeResetCredits(auth) : await listCodexResetCredits(auth);
69
- if (!list)
70
- return {
71
- ...base,
72
- availableCount: 0,
73
- credits: [],
74
- error: "Failed to load saved resets",
75
- };
76
- return { ...base, ...list };
77
- }),
78
- );
56
+ if (provider !== "anthropic") return this.#listAccounts(provider, baseUrl, options);
57
+ options?.signal?.throwIfAborted();
58
+ const activeId = this.#deps.oauth
59
+ .accounts(provider, options?.sessionId)
60
+ .find(account => account.active)?.credentialId;
61
+ // Discovery is sequential and probes the active account first, so only
62
+ // callers sharing that account can share one pass.
63
+ const key = JSON.stringify([provider, baseUrl, activeId]);
64
+ let pending = this.#listInFlight.get(key);
65
+ if (!pending) {
66
+ pending = this.#listAccounts(provider, baseUrl, { ...options, signal: undefined }).finally(() =>
67
+ this.#listInFlight.delete(key),
68
+ );
69
+ this.#listInFlight.set(key, pending);
70
+ }
71
+ return raceSignal(pending, options?.signal, "Reset discovery aborted");
72
+ }
73
+
74
+ async #listAccounts(
75
+ provider: string,
76
+ baseUrl: string | undefined,
77
+ options?: ListResetCreditsOptions,
78
+ ): Promise<ResetCreditAccountStatus[]> {
79
+ const accounts = this.#deps.oauth.accounts(provider, options?.sessionId);
80
+ const loadAccount = async (account: OAuthAccountSummary): Promise<ResetCreditAccountStatus> => {
81
+ const base = { ...account, provider };
82
+ const access = await this.#deps.oauth.accessById(provider, account.credentialId, {
83
+ signal: options?.signal,
84
+ });
85
+ if (!access?.ok)
86
+ return {
87
+ ...base,
88
+ availableCount: 0,
89
+ credits: [],
90
+ error: access?.error ?? "Account no longer available",
91
+ };
92
+ let retryAfterMs: number | undefined;
93
+ const auth = {
94
+ ...access,
95
+ baseUrl,
96
+ fetch: this.#deps.usage.fetch,
97
+ signal: options?.signal,
98
+ onRateLimited: (delay: number | undefined) => {
99
+ retryAfterMs = delay ?? 0;
100
+ },
101
+ };
102
+ const list = provider === "anthropic" ? await listClaudeResetCredits(auth) : await listCodexResetCredits(auth);
103
+ if (!list)
104
+ return {
105
+ ...base,
106
+ availableCount: 0,
107
+ credits: [],
108
+ error: "Failed to load saved resets",
109
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
110
+ };
111
+ return { ...base, ...list };
112
+ };
113
+ if (provider !== "anthropic") return Promise.all(accounts.map(loadAccount));
114
+ const results: ResetCreditAccountStatus[] = [];
115
+ accounts.sort((left, right) => Number(right.active) - Number(left.active));
116
+ for (const account of accounts) {
117
+ options?.signal?.throwIfAborted();
118
+ results.push(await loadAccount(account));
119
+ }
120
+ return results;
79
121
  }
80
122
 
81
123
  /**
@@ -142,7 +184,11 @@ export class ResetCredits implements ResetsApi {
142
184
  }
143
185
  creditId = selected.id;
144
186
  let pending = this.#pendingClaudeResets.get(accountKey);
145
- if (pending && Date.now() - pending.startedAt >= 10 * 60_000) {
187
+ const pendingCreditId = pending?.creditId;
188
+ // The ambiguous claim's credit left the live offer: it was consumed or
189
+ // expired. Either way its idempotency key can never settle, and the
190
+ // account is at a wall again now, so a new offer is a fresh claim.
191
+ if (pendingCreditId !== undefined && !list.credits.some(credit => credit.id === pendingCreditId)) {
146
192
  this.#pendingClaudeResets.delete(accountKey);
147
193
  pending = undefined;
148
194
  }
@@ -154,20 +200,14 @@ export class ResetCredits implements ResetsApi {
154
200
  selected.remainingCount < pending.remainingCount
155
201
  ) {
156
202
  this.#pendingClaudeResets.delete(accountKey);
157
- this.#deps.usageCache.invalidate(provider, options.baseUrl);
203
+ this.#deps.usageCache.invalidateAfterReset(provider, options.baseUrl);
158
204
  return { ...identity, ok: false, code: "already_redeemed", creditId };
159
205
  }
160
206
  if (pending.program === "juniper_tide") {
161
207
  return { ...identity, ok: false, code: "reset_unconfirmed", creditId };
162
208
  }
163
209
  }
164
- const credential = this.#deps.pool.entries(provider).find(entry => entry.id === access.credentialId);
165
- if (credential?.credential.type === "oauth") {
166
- report = await this.#deps.usage.report(provider, credential.credential, {
167
- baseUrl: options.baseUrl,
168
- signal: options.signal,
169
- });
170
- }
210
+ report = list.report ?? null;
171
211
  const requestId = pending?.requestId ?? crypto.randomUUID();
172
212
  options.signal?.throwIfAborted();
173
213
  this.#pendingClaudeResets.set(accountKey, {
@@ -175,7 +215,6 @@ export class ResetCredits implements ResetsApi {
175
215
  requestId,
176
216
  program: selected.program,
177
217
  remainingCount: selected.remainingCount,
178
- startedAt: pending?.startedAt ?? Date.now(),
179
218
  });
180
219
  const consumed = await consumeClaudeResetCredit({
181
220
  ...auth,
@@ -217,7 +256,7 @@ export class ResetCredits implements ResetsApi {
217
256
  result = { ...identity, ok: consumed.ok, code: consumed.code, creditId };
218
257
  }
219
258
  if (result.ok) {
220
- this.#deps.usageCache.invalidate(provider, options.baseUrl);
259
+ this.#deps.usageCache.invalidateAfterReset(provider, options.baseUrl);
221
260
  if (this.#deps.store.invalidateUsageCache) {
222
261
  await this.#deps.store.invalidateUsageCache(options.signal).catch(err => {
223
262
  logger.debug("Failed to notify store of stale usage", { err });
@@ -229,11 +268,36 @@ export class ResetCredits implements ResetsApi {
229
268
  const cleared = result.cleared ?? [];
230
269
  if (
231
270
  report &&
271
+ Number.isFinite(report.fetchedAt) &&
232
272
  Date.now() - report.fetchedAt <= USAGE_REPORT_TTL_MS &&
233
273
  cleared.length > 0 &&
234
274
  this.#deps.pool.entries(provider).some(entry => entry.id === access.credentialId)
235
275
  ) {
236
- for (const scope of claudeResetClearedBlockScopes(cleared, report)) {
276
+ const scopes = claudeResetClearedBlockScopes(cleared, report);
277
+ if (scopes.includes(undefined)) {
278
+ // A legacy global block may be the only stored evidence of a
279
+ // tier wall. Preserve that independent wall before lifting it.
280
+ for (const limit of report.limits) {
281
+ const tier = limit.scope.tier;
282
+ const reset = limit.window?.resetsAt;
283
+ if (
284
+ (tier !== "fable" && tier !== "mythos") ||
285
+ cleared.includes(limit.id) ||
286
+ !isUsageLimitReached([limit]) ||
287
+ typeof reset !== "number" ||
288
+ !Number.isFinite(reset) ||
289
+ reset <= Date.now()
290
+ )
291
+ continue;
292
+ this.#deps.blocks.upsert({
293
+ credentialId: access.credentialId,
294
+ providerKey: providerTypeKey(provider, "oauth"),
295
+ blockScope: `tier:${tier}`,
296
+ blockedUntilMs: reset,
297
+ });
298
+ }
299
+ }
300
+ for (const scope of scopes) {
237
301
  this.#deps.blocks.clearScope(
238
302
  provider,
239
303
  access.credentialId,