@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,3 +1,4 @@
1
+ import { scheduler } from "node:timers/promises";
1
2
  import * as AIError from "../error";
2
3
  import { isUsageLimitOutcome } from "../error/rate-limit";
3
4
  import { extractProviderRetryHint } from "../utils/retry-after";
@@ -5,6 +6,8 @@ import type { CredentialRankingContext, CredentialRankingStrategy } from "../usa
5
6
  import type { RankingStrategyResolver } from "../usage/registry";
6
7
  import { raceSignal } from "./abort";
7
8
  import {
9
+ ACCOUNT_POLICY_BLOCK_SCOPE,
10
+ AUTH_BLOCK_SCOPE,
8
11
  DEFAULT_BLOCK_MS,
9
12
  credentialBlockScopesForRequest,
10
13
  modelAccountPolicyBlockScope,
@@ -17,6 +20,7 @@ import type { CredentialPool } from "./pool";
17
20
  import type { AuthCredentialStore } from "./store";
18
21
  import type {
19
22
  AuthCredential,
23
+ CredentialRotation,
20
24
  InvalidateCredentialMatchingOptions,
21
25
  LimitsApi,
22
26
  MarkUsageLimitOptions,
@@ -32,6 +36,16 @@ import {
32
36
  windowResetAt,
33
37
  } from "./usage-report";
34
38
 
39
+ /**
40
+ * Longest sibling block {@link RateLimits.rotate} sleeps out instead of giving
41
+ * up. Covers Cloud Code Assist capacity 429s ("Resets in 0s", sub-second
42
+ * `retryDelay`) that briefly block every healthy account; longer blocks stay
43
+ * with the caller's own backoff layer.
44
+ */
45
+ const SIBLING_UNBLOCK_WAIT_MAX_MS = 5_000;
46
+ /** Slack past a sibling's deadline so the post-wait resolve sees the block expired. */
47
+ const SIBLING_UNBLOCK_SLACK_MS = 25;
48
+
35
49
  /** Routing scope and strategy for one failed credential. */
36
50
  export type CredentialBlockRouting = {
37
51
  providerKey: string;
@@ -335,6 +349,7 @@ export class RateLimits implements LimitsApi {
335
349
  providerTypeKey(provider, matched.type),
336
350
  matched.index,
337
351
  Date.now() + DEFAULT_BLOCK_MS,
352
+ AUTH_BLOCK_SCOPE,
338
353
  );
339
354
 
340
355
  const markSuspect = this.#deps.store.markCredentialSuspect?.bind(this.#deps.store);
@@ -373,9 +388,16 @@ export class RateLimits implements LimitsApi {
373
388
  * reload when no broker hook is wired) and block it, then drop matching
374
389
  * sticky state.
375
390
  *
376
- * Returns whether another usable credential of the same type remains.
391
+ * For usage-limit and account-policy failures with no free sibling, sleeps
392
+ * until the earliest sibling unblocks when that is at most
393
+ * {@link SIBLING_UNBLOCK_WAIT_MAX_MS} away, then reports `afterSiblingWait`.
394
+ * Aborting `options.signal` during that wait rejects.
377
395
  */
378
- async rotate(provider: string, sessionId: string | undefined, options?: RotateCredentialOptions): Promise<boolean> {
396
+ async rotate(
397
+ provider: string,
398
+ sessionId: string | undefined,
399
+ options?: RotateCredentialOptions,
400
+ ): Promise<CredentialRotation> {
379
401
  await this.#deps.pool.adoptExternalChanges();
380
402
  const error = options?.error;
381
403
  const status = AIError.status(error);
@@ -387,16 +409,15 @@ export class RateLimits implements LimitsApi {
387
409
  // will reset in 13 minutes") into the block duration so the credential
388
410
  // is not reselected and hammered while the cap remains active.
389
411
  const retryAfterMs = extractProviderRetryHint(provider, message);
390
- return (
391
- await this.markReached(provider, sessionId, {
392
- retryAfterMs,
393
- providerTimed: retryAfterMs !== undefined,
394
- modelId: options?.modelId,
395
- apiKey: options?.apiKey,
396
- credentialId: options?.credentialId,
397
- signal: options?.signal,
398
- })
399
- ).switched;
412
+ const mark = await this.markReached(provider, sessionId, {
413
+ retryAfterMs,
414
+ providerTimed: retryAfterMs !== undefined,
415
+ modelId: options?.modelId,
416
+ apiKey: options?.apiKey,
417
+ credentialId: options?.credentialId,
418
+ signal: options?.signal,
419
+ });
420
+ return awaitSiblingUnblock(mark, options?.signal);
400
421
  }
401
422
 
402
423
  const deniedModel = AIError.codexChatGPTAccountPolicyModel(error);
@@ -408,16 +429,16 @@ export class RateLimits implements LimitsApi {
408
429
  apiKey: options?.apiKey,
409
430
  allowStaleOAuthBearer: accountPolicy || exactModelPolicy,
410
431
  });
411
- if (!sessionCredential) return false;
432
+ if (!sessionCredential) return { switched: false };
412
433
  // The exact sentence is provider-controlled input. A non-Codex provider,
413
434
  // absent request model, or mismatched model must not turn it into either a
414
435
  // global block or a hard-auth invalidation.
415
- if (deniedModel !== undefined && !exactCodexModelPolicy) return false;
436
+ if (deniedModel !== undefined && !exactCodexModelPolicy) return { switched: false };
416
437
  if (exactModelPolicy || accountPolicy) {
417
438
  const modelPolicyScope = exactModelPolicy
418
439
  ? modelAccountPolicyBlockScope(provider, options?.modelId)
419
440
  : undefined;
420
- if (exactModelPolicy && modelPolicyScope === undefined) return false;
441
+ if (exactModelPolicy && modelPolicyScope === undefined) return { switched: false };
421
442
  const routing = this.#credentialBlockRouting(
422
443
  provider,
423
444
  sessionCredential.type,
@@ -425,7 +446,7 @@ export class RateLimits implements LimitsApi {
425
446
  modelPolicyScope,
426
447
  );
427
448
  // Account-wide denials must not inherit a quota scope that healthy usage can heal.
428
- routing.blockScope = modelPolicyScope;
449
+ routing.blockScope = modelPolicyScope ?? ACCOUNT_POLICY_BLOCK_SCOPE;
429
450
  const sticky = this.#deps.affinity.get(provider, sessionId);
430
451
  if (
431
452
  !sessionCredential.explicit ||
@@ -433,14 +454,15 @@ export class RateLimits implements LimitsApi {
433
454
  ) {
434
455
  this.#deps.affinity.clear(provider, sessionId);
435
456
  }
436
- return this.#blockCredentialForRotation(
457
+ const mark = this.#blockCredentialForRotation(
437
458
  provider,
438
459
  sessionCredential.type,
439
460
  sessionCredential.index,
440
461
  Date.now() + DEFAULT_BLOCK_MS,
441
462
  routing,
442
463
  false,
443
- ).switched;
464
+ );
465
+ return awaitSiblingUnblock(mark, options?.signal);
444
466
  }
445
467
 
446
468
  const providerKey = providerTypeKey(provider, sessionCredential.type);
@@ -462,7 +484,13 @@ export class RateLimits implements LimitsApi {
462
484
  ) {
463
485
  this.#deps.affinity.clear(provider, sessionId);
464
486
  }
465
- this.#deps.blocks.mark(provider, providerKey, sessionCredential.index, Date.now() + DEFAULT_BLOCK_MS);
487
+ this.#deps.blocks.mark(
488
+ provider,
489
+ providerKey,
490
+ sessionCredential.index,
491
+ Date.now() + DEFAULT_BLOCK_MS,
492
+ AUTH_BLOCK_SCOPE,
493
+ );
466
494
 
467
495
  if (target && AIError.isInvalidatedOAuthTokenError(error)) {
468
496
  const disabledCause = message ?? "upstream reported invalidated OAuth token";
@@ -474,7 +502,7 @@ export class RateLimits implements LimitsApi {
474
502
  latestRows.map(row => ({ id: row.id, credential: row.credential })),
475
503
  );
476
504
  }
477
- return deleted && hasSibling;
505
+ return { switched: deleted && hasSibling };
478
506
  }
479
507
 
480
508
  if (target) {
@@ -491,6 +519,19 @@ export class RateLimits implements LimitsApi {
491
519
  );
492
520
  }
493
521
 
494
- return hasSibling;
522
+ return { switched: hasSibling };
495
523
  }
496
524
  }
525
+
526
+ /**
527
+ * Turn a no-sibling mark into a rotation by sleeping out the soonest sibling
528
+ * block when it expires within {@link SIBLING_UNBLOCK_WAIT_MAX_MS}.
529
+ */
530
+ async function awaitSiblingUnblock(mark: UsageLimitMarkResult, signal?: AbortSignal): Promise<CredentialRotation> {
531
+ if (mark.switched) return { switched: true };
532
+ if (mark.retryAtMs === undefined) return { switched: false };
533
+ const waitMs = mark.retryAtMs - Date.now() + SIBLING_UNBLOCK_SLACK_MS;
534
+ if (waitMs > SIBLING_UNBLOCK_WAIT_MAX_MS) return { switched: false };
535
+ if (waitMs > 0) await scheduler.wait(waitMs, { signal });
536
+ return { switched: true, afterSiblingWait: true };
537
+ }
@@ -6,7 +6,13 @@ import type { Provider } from "../types";
6
6
  import type { CredentialRankingContext, CredentialRankingStrategy, PlanGate, UsageReport } from "../usage";
7
7
  import type { RankingStrategyResolver } from "../usage/registry";
8
8
  import type { SessionAffinity } from "./affinity";
9
- import { credentialBlockScopesForRequest, DEFAULT_BLOCK_MS, providerTypeKey, type CredentialBlocks } from "./blocks";
9
+ import {
10
+ AUTH_BLOCK_SCOPE,
11
+ credentialBlockScopesForRequest,
12
+ DEFAULT_BLOCK_MS,
13
+ providerTypeKey,
14
+ type CredentialBlocks,
15
+ } from "./blocks";
10
16
  import type { AccountPolicies } from "./policy";
11
17
  import { authCredentialEquals, type CredentialPool } from "./pool";
12
18
  import {
@@ -749,7 +755,7 @@ export class CredentialSelector {
749
755
  providerKey,
750
756
  latestIndex,
751
757
  Date.now() + OAUTH_REFRESH_FAILURE_BACKOFF_MS,
752
- blockScope,
758
+ AUTH_BLOCK_SCOPE,
753
759
  );
754
760
  }
755
761
  }
@@ -1049,6 +1055,7 @@ export class CredentialSelector {
1049
1055
  providerKey,
1050
1056
  selection.index,
1051
1057
  Date.now() + OAUTH_REFRESH_FAILURE_BACKOFF_MS,
1058
+ AUTH_BLOCK_SCOPE,
1052
1059
  );
1053
1060
  }
1054
1061
  }
package/src/auth/types.ts CHANGED
@@ -632,6 +632,10 @@ export interface ResetCreditRedeemOutcome {
632
632
  /** One stored account's live saved-reset status, from {@link AuthStorage.resets.list}. */
633
633
  export interface ResetCreditAccountStatus extends UsageResetCredits {
634
634
  provider: string;
635
+ /** Live quota evidence from this exact account's reset-discovery response. */
636
+ report?: UsageReport;
637
+ /** Provider-requested wait before retrying throttled reset discovery (not redemption). */
638
+ retryAfterMs?: number;
635
639
  credentialId: number;
636
640
  accountId?: string;
637
641
  email?: string;
@@ -701,6 +705,21 @@ export type RotateCredentialOptions = {
701
705
  signal?: AbortSignal;
702
706
  };
703
707
 
708
+ /**
709
+ * Outcome of {@link LimitsApi.rotate}.
710
+ *
711
+ * `switched` is `true` when a usable same-type sibling credential is available,
712
+ * so the caller's next resolve hands it out. `afterSiblingWait` is `true` when
713
+ * no sibling was free at the failure but rotation slept out a sibling's short
714
+ * block (e.g. a Cloud Code Assist capacity 429 that resets in under a second);
715
+ * the freed credential may be one the caller already sent in this request, so
716
+ * attempted-bearer dedupe must let it through once.
717
+ */
718
+ export interface CredentialRotation {
719
+ switched: boolean;
720
+ afterSiblingWait?: boolean;
721
+ }
722
+
704
723
  /** Filter saved reset credits by provider and session. */
705
724
  export type ListResetCreditsOptions = {
706
725
  provider?: string;
@@ -1199,9 +1218,15 @@ export interface LimitsApi {
1199
1218
  * reload when no broker hook is wired) and block it, then drop matching
1200
1219
  * sticky state.
1201
1220
  *
1202
- * Returns whether another usable credential of the same type remains.
1221
+ * For usage-limit and account-policy failures with no free sibling, waits
1222
+ * (abortable via `options.signal`) when a sibling's block expires within a
1223
+ * few seconds, then reports `afterSiblingWait`.
1203
1224
  */
1204
- rotate(provider: string, sessionId: string | undefined, options?: RotateCredentialOptions): Promise<boolean>;
1225
+ rotate(
1226
+ provider: string,
1227
+ sessionId: string | undefined,
1228
+ options?: RotateCredentialOptions,
1229
+ ): Promise<CredentialRotation>;
1205
1230
  /** Invalidate a credential matching an API key after authentication failure. */
1206
1231
  invalidateMatching(
1207
1232
  provider: string,
@@ -1232,7 +1257,7 @@ export interface BlocksApi {
1232
1257
  */
1233
1258
  upsert(block: StoredCredentialBlock): void;
1234
1259
  /**
1235
- * Broker-server seam: clear all persisted blocks for one credential and notify snapshot waiters.
1260
+ * Broker-server seam: clear one exact persisted block and notify snapshot waiters.
1236
1261
  */
1237
1262
  delete(credentialId: number, providerKey: string, blockScope: string): void;
1238
1263
  /** Delete all persisted blocks for a credential. */
@@ -12,8 +12,8 @@ const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000;
12
12
  /**
13
13
  * Per-credential cool-down after a usage fetch fails. While this window is
14
14
  * active we serve the last successful value to avoid dropping the credential
15
- * from the report; without a previous value we just return null and retry
16
- * on the next poll. Used by UsageService.
15
+ * from the report; without a previous value we return null until the
16
+ * cooldown expires. Report invalidation does not bypass this cooldown.
17
17
  */
18
18
  export const USAGE_FAILURE_BACKOFF_MS = 10_000;
19
19
  /**
@@ -118,6 +118,9 @@ export function oauthUsageRequest(
118
118
  /** Store-backed usage report cache: keys, epoch, force-refresh markers, invalidation; used by AuthStorage. */
119
119
  export class UsageCache {
120
120
  #epoch = 0;
121
+ #recoveryEpochs: Map<Provider, number> = new Map();
122
+ #refreshEpochs: Map<Provider, number> = new Map();
123
+ #allRefreshEpoch = 0;
121
124
  #usageReportCacheKeysByProvider: Map<Provider, Set<string>> = new Map();
122
125
  #store: AuthCredentialStore;
123
126
  #pool: CredentialPool;
@@ -148,6 +151,30 @@ export class UsageCache {
148
151
  this.#epoch += 1;
149
152
  }
150
153
 
154
+ /** Identifies confirmed recoveries that supersede an in-flight failed probe. */
155
+ recoveryEpoch(provider: Provider): number {
156
+ return this.#recoveryEpochs.get(provider) ?? 0;
157
+ }
158
+
159
+ /**
160
+ * Changes when a user refresh, confirmed reset, or usage-implementation swap
161
+ * makes an in-flight probe too old to answer. Block marks do not change it:
162
+ * they stop a racing report from being cached, never trigger a re-probe.
163
+ */
164
+ refreshEpoch(provider: Provider): number {
165
+ return this.#allRefreshEpoch + (this.#refreshEpochs.get(provider) ?? 0);
166
+ }
167
+
168
+ #bumpRefreshEpoch(provider: Provider | undefined): void {
169
+ if (provider === undefined) this.#allRefreshEpoch += 1;
170
+ else this.#refreshEpochs.set(provider, (this.#refreshEpochs.get(provider) ?? 0) + 1);
171
+ }
172
+
173
+ /** Failure cooldowns survive report invalidation and process restarts. */
174
+ failureKey(reportKey: string): string {
175
+ return `failure:${reportKey}`;
176
+ }
177
+
151
178
  get<T>(key: string): UsageCacheEntry<T> | undefined {
152
179
  const raw = this.#store.getCache(`${USAGE_CACHE_PREFIX}${key}`);
153
180
  if (!raw) return undefined;
@@ -275,12 +302,27 @@ export class UsageCache {
275
302
  }
276
303
  }
277
304
 
305
+ /** A confirmed reset permits one fresh probe even during a pre-reset failure cooldown. */
306
+ invalidateAfterReset(provider: Provider, baseUrl?: string): void {
307
+ this.#recoveryEpochs.set(provider, this.recoveryEpoch(provider) + 1);
308
+ this.#bumpRefreshEpoch(provider);
309
+ if (!this.deletePrefix(`failure:report:${this.#usageCacheProviderKey(provider)}:`)) {
310
+ const keys = new Set(this.#usageReportCacheKeysByProvider.get(provider));
311
+ for (const entry of this.#pool.entries(provider)) {
312
+ keys.add(this.reportKey(usageRequest(provider, buildUsageCredential(entry.credential), baseUrl)));
313
+ }
314
+ for (const key of keys) this.set(this.failureKey(key), { value: null, expiresAt: 0 });
315
+ }
316
+ this.invalidate(provider, baseUrl);
317
+ }
318
+
278
319
  /**
279
320
  * Expire cached reports for a provider after its runtime usage implementation changes.
280
321
  * This keeps a newly installed extension provider from serving a built-in snapshot.
281
322
  */
282
323
  invalidateForProvider(provider: Provider): void {
283
324
  this.#epoch += 1;
325
+ this.#bumpRefreshEpoch(provider);
284
326
  const expired = Date.now() - 1;
285
327
  const prefix = `report:${this.#usageCacheProviderKey(provider)}:`;
286
328
  if (this.deletePrefix(prefix)) return;
@@ -308,6 +350,7 @@ export class UsageCache {
308
350
  collectRequests: () => Promise<UsageRequestDescriptor[]>,
309
351
  ): Promise<void> {
310
352
  this.#epoch += 1;
353
+ this.#bumpRefreshEpoch(provider);
311
354
  const prefix = provider ? `report:${this.#usageCacheProviderKey(provider)}:` : "report:";
312
355
  if (!this.deletePrefix(prefix)) {
313
356
  // Third-party stores may not support prefix deletion. Clear every active
package/src/auth/usage.ts CHANGED
@@ -108,7 +108,8 @@ export class UsageService implements UsageApi {
108
108
  #deps: UsageServiceDeps;
109
109
  /** Runtime extension providers take precedence over the configured/default resolver. */
110
110
  #runtimeUsageProviderOverrides: Map<Provider, { provider: UsageProvider; apiKey?: string }> = new Map();
111
- #usageRequestInFlight: Map<string, Promise<UsageReport | null>> = new Map();
111
+ /** One probe per credential report key, tagged with the refresh epoch it can answer. */
112
+ #usageRequestInFlight: Map<string, { refreshEpoch: number; promise: Promise<UsageReport | null> }> = new Map();
112
113
  #usageHeaderIngestAt: Map<string, number> = new Map();
113
114
  #usageReportsInFlight: Map<string, Promise<UsageReport[] | null>> = new Map();
114
115
  readonly fetch: typeof fetch;
@@ -305,28 +306,46 @@ export class UsageService implements UsageApi {
305
306
  /** Cache a credential report with jitter and failure cooldown. */
306
307
  async #fetchUsageCached(
307
308
  request: UsageRequestDescriptor,
308
- options: { timeoutMs?: number; forceRefresh?: boolean } = {},
309
+ options: { timeoutMs?: number } = {},
309
310
  ): Promise<UsageReport | null> {
310
311
  const timeoutMs = options.timeoutMs;
311
- const forceRefresh = options.forceRefresh ?? false;
312
312
  const cacheKey = this.#deps.cache.reportKey(request);
313
-
313
+ const failureKey = this.#deps.cache.failureKey(cacheKey);
314
314
  const now = Date.now();
315
- const cached = forceRefresh ? undefined : this.#deps.cache.get<UsageReport | null>(cacheKey);
315
+ const cached = this.#deps.cache.get<UsageReport | null>(cacheKey);
316
316
  // Fresh cache hit: return whatever's there (success or null fallback).
317
317
  if (cached && cached.expiresAt > now) {
318
318
  return cached.value;
319
319
  }
320
320
 
321
+ const failure = this.#deps.cache.get<null>(failureKey);
322
+ if (failure && failure.expiresAt > now) {
323
+ return this.#deps.cache.getStale<UsageReport | null>(cacheKey)?.value ?? null;
324
+ }
325
+
326
+ // Invalidation must not fork an already-running credential probe. Share it;
327
+ // only a refresh that postdates its start (user refresh, confirmed reset)
328
+ // earns one successor probe.
329
+ const inFlight = this.#usageRequestInFlight.get(cacheKey);
330
+ if (inFlight) {
331
+ const report = await inFlight.promise;
332
+ return inFlight.refreshEpoch === this.#deps.cache.refreshEpoch(request.provider)
333
+ ? report
334
+ : this.#fetchUsageCached(request, options);
335
+ }
321
336
  const usageCacheEpoch = this.#deps.cache.epoch;
322
- const inFlightKey = `${cacheKey}\0${usageCacheEpoch}`;
323
- const inFlight = this.#usageRequestInFlight.get(inFlightKey);
324
- if (inFlight) return inFlight;
337
+ const refreshEpoch = this.#deps.cache.refreshEpoch(request.provider);
338
+ const recoveryEpoch = this.#deps.cache.recoveryEpoch(request.provider);
325
339
  const promise = (async () => {
326
340
  const report = await this.#fetchUsageUncached(request, timeoutMs);
327
- if (usageCacheEpoch !== this.#deps.cache.epoch) return report;
328
- const ttlJitter = USAGE_REPORT_TTL_MS * (Math.random() * 0.5 - 0.25);
341
+ // Block marks bump the generation too: a report racing a fresh 429 may
342
+ // predate it, so it answers this probe but is neither cached nor used
343
+ // to heal blocks.
344
+ const invalidated = usageCacheEpoch !== this.#deps.cache.epoch;
329
345
  if (report !== null) {
346
+ if (invalidated) return report;
347
+ this.#deps.blocks.reconcileRequest(request, report);
348
+ const ttlJitter = USAGE_REPORT_TTL_MS * (Math.random() * 0.5 - 0.25);
330
349
  // Success: stagger per-credential cache expiry so all accounts don't
331
350
  // refresh in the same window — Anthropic / OpenAI rate-limit `/usage`
332
351
  // per source IP regardless of account, and synchronized 5-credential
@@ -337,7 +356,6 @@ export class UsageService implements UsageApi {
337
356
  expiresAt: Date.now() + USAGE_REPORT_TTL_MS + ttlJitter,
338
357
  });
339
358
  this.#recordUsageHistory(request, report);
340
- this.#deps.blocks.reconcileRequest(request, report);
341
359
  return report;
342
360
  }
343
361
  // Failure: apply a short jittered cool-down so the credential doesn't
@@ -345,21 +363,31 @@ export class UsageService implements UsageApi {
345
363
  // value through transient failures. Session-cookie providers can opt out
346
364
  // so an expired login does not display stale quota indefinitely.
347
365
  const providerImpl = this.providerFor(request.provider);
348
- const retainLastGood = !forceRefresh && providerImpl?.retainLastGoodOnFailure !== false;
366
+ const retainLastGood = !invalidated && providerImpl?.retainLastGoodOnFailure !== false;
349
367
  const lastGood = retainLastGood
350
368
  ? (this.#deps.cache.getStale<UsageReport | null>(cacheKey)?.value ?? null)
351
369
  : null;
352
370
  const failureBackoffMs = providerImpl?.failureBackoffMs ?? USAGE_FAILURE_BACKOFF_MS;
353
371
  const backoffJitter = failureBackoffMs * (Math.random() * 0.5 - 0.25);
354
372
  const coolDown = Date.now() + failureBackoffMs + backoffJitter;
355
- this.#deps.cache.set(cacheKey, { value: lastGood, expiresAt: coolDown });
373
+ // Dropping snapshots is not recovery from a failed endpoint. Persist its
374
+ // cooldown even if a manual refresh or block update raced this probe.
375
+ // A confirmed reset is the exception: the old failure cannot suppress
376
+ // the first post-reset probe.
377
+ if (recoveryEpoch === this.#deps.cache.recoveryEpoch(request.provider)) {
378
+ this.#deps.cache.set(failureKey, { value: null, expiresAt: coolDown });
379
+ }
380
+ if (!invalidated) this.#deps.cache.set(cacheKey, { value: lastGood, expiresAt: coolDown });
356
381
  return lastGood;
357
382
  })().finally(() => {
358
- this.#usageRequestInFlight.delete(inFlightKey);
383
+ this.#usageRequestInFlight.delete(cacheKey);
359
384
  });
360
385
 
361
- this.#usageRequestInFlight.set(inFlightKey, promise);
362
- return promise;
386
+ this.#usageRequestInFlight.set(cacheKey, { refreshEpoch, promise });
387
+ const report = await promise;
388
+ return refreshEpoch === this.#deps.cache.refreshEpoch(request.provider)
389
+ ? report
390
+ : this.#fetchUsageCached(request, options);
363
391
  }
364
392
 
365
393
  /**
@@ -690,8 +718,7 @@ export class UsageService implements UsageApi {
690
718
  const tails = new Map<Provider, Promise<void>>();
691
719
  return Promise.all(
692
720
  requests.map(request => {
693
- const forceRefresh = serializedProviders.has(request.provider);
694
- if (!forceRefresh) {
721
+ if (!serializedProviders.has(request.provider)) {
695
722
  return this.#fetchUsageCached(request, {
696
723
  timeoutMs: this.requestTimeoutMs,
697
724
  });
@@ -700,7 +727,6 @@ export class UsageService implements UsageApi {
700
727
  const current = tail.then(() =>
701
728
  this.#fetchUsageCached(request, {
702
729
  timeoutMs: this.requestTimeoutMs,
703
- forceRefresh: true,
704
730
  }),
705
731
  );
706
732
  tails.set(
@@ -757,10 +783,11 @@ export class UsageService implements UsageApi {
757
783
  // accounts can be missing from one fetch and present in the next; the
758
784
  // aggregate cache freezes whichever set landed first).
759
785
  const forcedRefresh = this.#deps.cache.forcedRefresh(requests);
760
- const cacheKey = `${this.#deps.cache.reportsKey(requests)}\0${this.#deps.cache.epoch}`;
786
+ const usageCacheEpoch = this.#deps.cache.epoch;
787
+ const cacheKey = `${this.#deps.cache.reportsKey(requests)}\0${usageCacheEpoch}`;
761
788
 
762
789
  const inFlight = this.#usageReportsInFlight.get(cacheKey);
763
- if (inFlight) return inFlight;
790
+ if (inFlight) return raceSignal(inFlight, options?.signal, "usage fetch aborted");
764
791
 
765
792
  const promise = (async () => {
766
793
  for (const request of requests) {
@@ -794,20 +821,20 @@ export class UsageService implements UsageApi {
794
821
  };
795
822
  }),
796
823
  });
797
- this.#deps.cache.clearForceRefresh(forcedRefresh);
824
+ if (usageCacheEpoch === this.#deps.cache.epoch) this.#deps.cache.clearForceRefresh(forcedRefresh);
798
825
  return resolved;
799
826
  })().finally(() => {
800
827
  this.#usageReportsInFlight.delete(cacheKey);
801
828
  });
802
829
 
803
830
  this.#usageReportsInFlight.set(cacheKey, promise);
804
- return promise;
831
+ return raceSignal(promise, options?.signal, "usage fetch aborted");
805
832
  }
806
833
 
807
834
  /**
808
835
  * Discard cached usage reports before a user-requested refresh. The next
809
- * read probes upstream serially per provider; a failure reports no fresh
810
- * usage instead of replaying an invalidated last-good snapshot.
836
+ * read probes upstream serially per provider unless a failure cooldown is
837
+ * active. Failed probes never replay an invalidated last-good snapshot.
811
838
  */
812
839
  async invalidate(provider?: string, signal?: AbortSignal): Promise<void> {
813
840
  await this.#deps.cache.clearReports(provider, () => this.#collectUsageRequests());
@@ -13,6 +13,7 @@ import type {
13
13
  ClientUsageReportRequest,
14
14
  ClientUsageReportResponse,
15
15
  ClientUsageSummaryResponse,
16
+ CredentialBlockDeleteRequest,
16
17
  CredentialBlockRequest,
17
18
  CredentialBlockResponse,
18
19
  CredentialBlocksDeleteResponse,
@@ -389,6 +390,18 @@ export class AuthBrokerClient {
389
390
  });
390
391
  }
391
392
 
393
+ async deleteCredentialBlock(
394
+ id: number,
395
+ block: CredentialBlockDeleteRequest,
396
+ signal?: AbortSignal,
397
+ ): Promise<CredentialBlocksDeleteResponse> {
398
+ return this.#request<CredentialBlocksDeleteResponse>("DELETE", `/v1/credential/${id}/block`, {
399
+ body: block,
400
+ schema: "credentialBlocksDeleteResponseSchema",
401
+ signal,
402
+ });
403
+ }
404
+
392
405
  async deleteCredentialBlocks(id: number, signal?: AbortSignal): Promise<CredentialBlocksDeleteResponse> {
393
406
  return this.#request<CredentialBlocksDeleteResponse>("DELETE", `/v1/credential/${id}/blocks`, {
394
407
  schema: "credentialBlocksDeleteResponseSchema",