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

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 (49) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +12 -0
  3. package/dist/types/auth/oauth.d.ts +2 -2
  4. package/dist/types/auth/refresh.d.ts +3 -3
  5. package/dist/types/auth/store.d.ts +4 -2
  6. package/dist/types/auth/types.d.ts +20 -3
  7. package/dist/types/auth/usage-cache.d.ts +9 -2
  8. package/dist/types/auth/usage.d.ts +6 -5
  9. package/dist/types/auth-broker/client.d.ts +2 -2
  10. package/dist/types/auth-broker/remote-store.d.ts +2 -2
  11. package/dist/types/error/flags.d.ts +9 -2
  12. package/dist/types/error/rate-limit.d.ts +3 -2
  13. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  14. package/dist/types/providers/cursor-pi-args.d.ts +13 -18
  15. package/dist/types/stream.d.ts +7 -0
  16. package/dist/types/usage/cursor.d.ts +3 -1
  17. package/dist/types/usage/zai.d.ts +2 -0
  18. package/dist/types/usage.d.ts +6 -0
  19. package/package.json +6 -6
  20. package/src/auth/cascade.ts +1 -0
  21. package/src/auth/oauth.ts +4 -3
  22. package/src/auth/refresh.ts +105 -16
  23. package/src/auth/resets.ts +6 -2
  24. package/src/auth/select.ts +11 -1
  25. package/src/auth/store.ts +4 -0
  26. package/src/auth/types.ts +22 -2
  27. package/src/auth/usage-cache.ts +12 -8
  28. package/src/auth/usage.ts +45 -19
  29. package/src/auth-broker/client.ts +8 -3
  30. package/src/auth-broker/remote-store.ts +4 -2
  31. package/src/auth-broker/server.ts +7 -1
  32. package/src/auth-gateway/dispatch.ts +1 -0
  33. package/src/auth-retry.ts +5 -1
  34. package/src/error/flags.ts +10 -3
  35. package/src/error/rate-limit.ts +23 -3
  36. package/src/providers/anthropic.ts +62 -53
  37. package/src/providers/cowork-fetch.ts +45 -3
  38. package/src/providers/cursor/exec-modern.ts +1 -1
  39. package/src/providers/cursor-pi-args.ts +13 -18
  40. package/src/providers/cursor.ts +179 -57
  41. package/src/providers/devin.ts +43 -13
  42. package/src/providers/openai-completions.ts +122 -30
  43. package/src/stream.ts +77 -16
  44. package/src/usage/claude.ts +5 -0
  45. package/src/usage/cursor.ts +43 -1
  46. package/src/usage/devin.ts +8 -7
  47. package/src/usage/registry.ts +2 -1
  48. package/src/usage/zai.ts +20 -0
  49. package/src/usage.ts +6 -0
@@ -13,6 +13,8 @@ import {
13
13
  type AuthCredentialSnapshotEntry,
14
14
  type AuthStorageOptions,
15
15
  type OAuthCredential,
16
+ type OAuthRefreshByIdOptions,
17
+ type OAuthRefreshReason,
16
18
  type StoredOAuthRefreshOptions,
17
19
  type StoredOAuthRefreshResult,
18
20
  } from "./types";
@@ -30,6 +32,12 @@ import {
30
32
  * the rotation cadence by <4%.
31
33
  */
32
34
  export const OAUTH_REFRESH_SKEW_MS = 60_000;
35
+ /**
36
+ * How long auth recovery may reuse a token this refresher actually minted.
37
+ * Re-minting a token that a provider rejected moments ago only rotates the
38
+ * refresh token and churns broker snapshots during provider-wide outages.
39
+ */
40
+ const OAUTH_REMINT_COOLDOWN_MS = 5 * 60_000;
33
41
  const OAUTH_REFRESH_LEASE_TTL_MS = 15_000;
34
42
  const OAUTH_REFRESH_LEASE_POLL_MS = 50;
35
43
  const OAUTH_REFRESH_LEASE_RENEW_MS = 5_000;
@@ -67,11 +75,45 @@ export class OAuthRefresher {
67
75
  readonly #deps: OAuthRefresherDeps;
68
76
  #oauthRefreshInFlight: Map<number, Promise<AuthCredentialSnapshotEntry>> = new Map();
69
77
  #oauthCredentialRefreshInFlight: Map<number, Promise<OAuthCredentials>> = new Map();
78
+ /** Access token this process last minted per credential id. */
79
+ #recentMints = new Map<number, { provider: string; access: string; at: number }>();
70
80
 
71
81
  constructor(deps: OAuthRefresherDeps) {
72
82
  this.#deps = deps;
73
83
  }
74
84
 
85
+ /**
86
+ * Return the stored row while it still holds a usable token this refresher
87
+ * minted within {@link OAUTH_REMINT_COOLDOWN_MS}.
88
+ */
89
+ #recentMint(id: number): { provider: string; credential: OAuthCredential } | undefined {
90
+ const mint = this.#recentMints.get(id);
91
+ if (!mint) return undefined;
92
+ const now = Date.now();
93
+ if (now - mint.at >= OAUTH_REMINT_COOLDOWN_MS) {
94
+ this.#recentMints.delete(id);
95
+ return undefined;
96
+ }
97
+ const credential = this.#deps.pool.entries(mint.provider).find(entry => entry.id === id)?.credential;
98
+ if (
99
+ credential?.type !== "oauth" ||
100
+ credential.access !== mint.access ||
101
+ credential.expires <= now + OAUTH_REFRESH_SKEW_MS
102
+ ) {
103
+ this.#recentMints.delete(id);
104
+ return undefined;
105
+ }
106
+ return { provider: mint.provider, credential };
107
+ }
108
+
109
+ #rememberMint(provider: string, id: number, credential: OAuthCredentials): void {
110
+ const now = Date.now();
111
+ for (const [cachedId, mint] of this.#recentMints) {
112
+ if (now - mint.at >= OAUTH_REMINT_COOLDOWN_MS) this.#recentMints.delete(cachedId);
113
+ }
114
+ this.#recentMints.set(id, { provider, access: credential.access, at: now });
115
+ }
116
+
75
117
  /**
76
118
  * Refresh one stored OAuth credential under durable row ownership.
77
119
  */
@@ -278,6 +320,9 @@ export class OAuthRefresher {
278
320
  } else {
279
321
  this.#deps.store.updateAuthCredential(row.id, merged);
280
322
  }
323
+ if (this.#deps.override === undefined && this.#deps.store.refreshOAuthCredential === undefined) {
324
+ this.#rememberMint(provider, row.id, merged);
325
+ }
281
326
  this.#deps.pool.replace(
282
327
  provider,
283
328
  rows.map(entry => ({
@@ -361,6 +406,23 @@ export class OAuthRefresher {
361
406
  credential: OAuthCredential,
362
407
  credentialId: number | undefined,
363
408
  signal?: AbortSignal,
409
+ reason?: OAuthRefreshReason,
410
+ ): Promise<OAuthCredentials> {
411
+ const authRecovery = reason === "auth-recovery";
412
+ if (authRecovery && credentialId !== undefined && !this.#oauthCredentialRefreshInFlight.has(credentialId)) {
413
+ const recent = this.#recentMint(credentialId);
414
+ if (recent) return recent.credential;
415
+ }
416
+ return this.#refreshSingleFlight(provider, credential, credentialId, signal, reason);
417
+ }
418
+
419
+ /** Refresh without recent-mint reuse; still shares the per-credential in-flight request. */
420
+ async #refreshSingleFlight(
421
+ provider: Provider,
422
+ credential: OAuthCredential,
423
+ credentialId: number | undefined,
424
+ signal?: AbortSignal,
425
+ reason?: OAuthRefreshReason,
364
426
  ): Promise<OAuthCredentials> {
365
427
  credential = normalizeOAuthCredentialExpiry(provider, credential);
366
428
  if (credentialId !== undefined) {
@@ -369,11 +431,24 @@ export class OAuthRefresher {
369
431
  }
370
432
  if (Date.now() + OAUTH_REFRESH_SKEW_MS < credential.expires) return credential;
371
433
  if (credentialId === undefined) {
372
- return this.#refreshOAuthCredentialUnshared(provider, credential, undefined, signal);
434
+ return this.#refreshOAuthCredentialUnshared(provider, credential, undefined, signal, reason);
373
435
  }
374
- const promise = this.#refreshOAuthCredentialUnshared(provider, credential, credentialId).finally(() => {
375
- this.#oauthCredentialRefreshInFlight.delete(credentialId);
376
- });
436
+ const promise = this.#refreshOAuthCredentialUnshared(provider, credential, credentialId, signal, reason)
437
+ .then(refreshed => {
438
+ // A delegated refresh may have returned a broker-cached token rather
439
+ // than minting one. Only direct local requests can establish mint time.
440
+ if (
441
+ !hasRefreshLeases(this.#deps.store) &&
442
+ this.#deps.override === undefined &&
443
+ this.#deps.store.refreshOAuthCredential === undefined
444
+ ) {
445
+ this.#rememberMint(provider, credentialId, refreshed);
446
+ }
447
+ return refreshed;
448
+ })
449
+ .finally(() => {
450
+ this.#oauthCredentialRefreshInFlight.delete(credentialId);
451
+ });
377
452
  this.#oauthCredentialRefreshInFlight.set(credentialId, promise);
378
453
  return raceSignal(promise, signal, "credential refresh aborted");
379
454
  }
@@ -383,6 +458,7 @@ export class OAuthRefresher {
383
458
  credential: OAuthCredential,
384
459
  credentialId: number | undefined,
385
460
  signal?: AbortSignal,
461
+ reason?: OAuthRefreshReason,
386
462
  ): Promise<OAuthCredentials> {
387
463
  if (credentialId !== undefined && hasRefreshLeases(this.#deps.store)) {
388
464
  const forceRefresh = credential.expires === 0;
@@ -398,6 +474,7 @@ export class OAuthRefresher {
398
474
  current,
399
475
  credentialId,
400
476
  signal && refreshSignal ? AbortSignal.any([signal, refreshSignal]) : (signal ?? refreshSignal),
477
+ reason,
401
478
  ),
402
479
  isDefinitiveFailure: error => AIError.isDefinitiveOAuthFailure(String(error)),
403
480
  disabledCause: error => `oauth refresh failed: ${String(error)}`,
@@ -429,7 +506,7 @@ export class OAuthRefresher {
429
506
  provider,
430
507
  });
431
508
  }
432
- return this.#requestOAuthCredentialRefresh(provider, credential, credentialId, signal);
509
+ return this.#requestOAuthCredentialRefresh(provider, credential, credentialId, signal, reason);
433
510
  }
434
511
 
435
512
  async #requestOAuthCredentialRefresh(
@@ -437,6 +514,7 @@ export class OAuthRefresher {
437
514
  credential: OAuthCredential,
438
515
  credentialId: number | undefined,
439
516
  signal?: AbortSignal,
517
+ reason?: OAuthRefreshReason,
440
518
  ): Promise<OAuthCredentials> {
441
519
  let refreshPromise: Promise<OAuthCredentials>;
442
520
  // Caller override > store-level hook > local per-provider refresh.
@@ -445,7 +523,7 @@ export class OAuthRefresher {
445
523
  const storeRefresh = this.#deps.store.refreshOAuthCredential?.bind(this.#deps.store);
446
524
  const overrideRefresh = this.#deps.override ?? storeRefresh;
447
525
  if (overrideRefresh && credentialId !== undefined) {
448
- refreshPromise = overrideRefresh(provider, credentialId, credential, signal);
526
+ refreshPromise = overrideRefresh(provider, credentialId, credential, signal, reason);
449
527
  } else {
450
528
  const customProvider = getOAuthProvider(provider);
451
529
  if (customProvider) {
@@ -497,9 +575,15 @@ export class OAuthRefresher {
497
575
  * refresh attempt, which is required for providers that rotate refresh tokens
498
576
  * on every successful refresh.
499
577
  */
500
- async refreshById(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry> {
578
+ async refreshById(
579
+ id: number,
580
+ signal?: AbortSignal,
581
+ options?: OAuthRefreshByIdOptions,
582
+ ): Promise<AuthCredentialSnapshotEntry> {
501
583
  const existing = this.#oauthRefreshInFlight.get(id);
502
584
  if (existing) return raceSignal(existing, signal, "credential refresh aborted");
585
+ const recent = options?.reuseRecentMint ? this.#recentMint(id) : undefined;
586
+ if (recent) return snapshotEntry(id, recent.provider, recent.credential);
503
587
 
504
588
  const promise = (async () => {
505
589
  this.#deps.pool.bump("credential-refresh-start");
@@ -531,12 +615,12 @@ export class OAuthRefresher {
531
615
  // await so a definitive failure can CAS-disable the row against the
532
616
  // value we actually attempted (NOT the expires:0 clone below).
533
617
  const attempted = target.credential;
534
- // Pass a clone with expires=0 so the cached not-yet-expired short-circuit
535
- // in refresh doesn't suppress the requested refresh.
618
+ // Pass a clone with expires=0 and bypass recent-mint reuse so scheduled
619
+ // refreshes and direct force-refresh callers always mint.
536
620
  const stale: OAuthCredential = { ...attempted, expires: 0 };
537
621
  let refreshed: OAuthCredentials;
538
622
  try {
539
- refreshed = await this.refresh(provider as Provider, stale, id, signal);
623
+ refreshed = await this.#refreshSingleFlight(provider as Provider, stale, id, signal);
540
624
  } catch (error) {
541
625
  // A definitively-dead grant tears the row down here, where the
542
626
  // attempted credential is known. CAS on the persisted credential so a
@@ -563,13 +647,18 @@ export class OAuthRefresher {
563
647
  if (this.#deps.pool.replaceById(provider, id, updated) === -1) {
564
648
  throw new AIError.ValidationError(`No credential with id=${id}`);
565
649
  }
566
- return {
567
- id,
568
- provider,
569
- credential: { ...updated, refresh: REMOTE_REFRESH_SENTINEL },
570
- identityKey: resolveCredentialIdentityKey(provider, updated),
571
- };
650
+ return snapshotEntry(id, provider, updated);
572
651
  }
573
652
  throw new AIError.ValidationError(`No credential with id=${id}`);
574
653
  }
575
654
  }
655
+
656
+ /** Broker-facing snapshot entry for a refreshed row; the real refresh token never leaves the store. */
657
+ function snapshotEntry(id: number, provider: string, credential: OAuthCredential): AuthCredentialSnapshotEntry {
658
+ return {
659
+ id,
660
+ provider,
661
+ credential: { ...credential, refresh: REMOTE_REFRESH_SENTINEL },
662
+ identityKey: resolveCredentialIdentityKey(provider, credential),
663
+ };
664
+ }
@@ -200,7 +200,9 @@ export class ResetCredits implements ResetsApi {
200
200
  selected.remainingCount < pending.remainingCount
201
201
  ) {
202
202
  this.#pendingClaudeResets.delete(accountKey);
203
- this.#deps.usageCache.invalidateAfterReset(provider, options.baseUrl);
203
+ this.#deps.usageCache.invalidateAfterReset(provider, options.baseUrl, {
204
+ resetSpentCredentialId: access.credentialId,
205
+ });
204
206
  return { ...identity, ok: false, code: "already_redeemed", creditId };
205
207
  }
206
208
  if (pending.program === "juniper_tide") {
@@ -256,7 +258,9 @@ export class ResetCredits implements ResetsApi {
256
258
  result = { ...identity, ok: consumed.ok, code: consumed.code, creditId };
257
259
  }
258
260
  if (result.ok) {
259
- this.#deps.usageCache.invalidateAfterReset(provider, options.baseUrl);
261
+ this.#deps.usageCache.invalidateAfterReset(provider, options.baseUrl, {
262
+ resetSpentCredentialId: access.credentialId,
263
+ });
260
264
  if (this.#deps.store.invalidateUsageCache) {
261
265
  await this.#deps.store.invalidateUsageCache(options.signal).catch(err => {
262
266
  logger.debug("Failed to notify store of stale usage", { err });
@@ -697,8 +697,18 @@ export class CredentialSelector {
697
697
  refreshTarget,
698
698
  credentialId,
699
699
  options?.signal,
700
+ force ? options?.refreshReason : undefined,
700
701
  );
701
- const updated = mergeRefreshedCredential(candidate.selection.credential, refreshedCredentials);
702
+ const beforeRefresh = candidate.selection.credential;
703
+ const updated = mergeRefreshedCredential(beforeRefresh, refreshedCredentials);
704
+ if (credentialId !== undefined && authCredentialEquals(beforeRefresh, updated)) {
705
+ // The await may have allowed a peer to replace/remove this row or
706
+ // compact its index. Rebind by id without writing the cached result.
707
+ if (!this.#syncOAuthSelectionFromStore(provider, candidate.selection, credentialId)) {
708
+ preflightFailures.add(candidate);
709
+ }
710
+ return;
711
+ }
702
712
  candidate.selection.credential = updated;
703
713
  if (credentialId !== undefined) {
704
714
  const idx = this.#deps.pool.replaceById(provider, credentialId, updated);
package/src/auth/store.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
  AuthCredential,
14
14
  DisabledCredentialSummary,
15
15
  OAuthCredential,
16
+ OAuthRefreshReason,
16
17
  StoredAuthCredential,
17
18
  StoredCredentialBlock,
18
19
  } from "./types";
@@ -157,12 +158,15 @@ export interface CredentialUpstream {
157
158
  * `signal` propagates the agent's cancel (ESC, request abort, …) all the
158
159
  * way to the broker fetch so a hung connection can't strand the caller
159
160
  * for `timeoutMs * (maxRetries + 1)`.
161
+ * `reason` distinguishes provider-auth recovery from generic/MCP refreshes
162
+ * that must always mint a new access token.
160
163
  */
161
164
  refreshOAuthCredential(
162
165
  provider: Provider,
163
166
  credentialId: number,
164
167
  credential: OAuthCredential,
165
168
  signal?: AbortSignal,
169
+ reason?: OAuthRefreshReason,
166
170
  ): Promise<OAuthCredentials>;
167
171
  /**
168
172
  * Optional async pre-read hook invoked after AuthStorage selects a stored
package/src/auth/types.ts CHANGED
@@ -322,6 +322,9 @@ export interface CredentialDisabledEvent {
322
322
  orgName?: string;
323
323
  }
324
324
 
325
+ /** Why a delegated OAuth refresh was requested. */
326
+ export type OAuthRefreshReason = "auth-recovery";
327
+
325
328
  /** Configuration supplied when constructing credential storage. */
326
329
  export type AuthStorageOptions = {
327
330
  usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
@@ -351,6 +354,9 @@ export type AuthStorageOptions = {
351
354
  * per-provider local refresh function. Receives the credential id so the
352
355
  * implementation can address remote credentials.
353
356
  *
357
+ * `reason` is `"auth-recovery"` only for a provider-auth retry; generic and
358
+ * managed MCP force-refresh calls leave it unset.
359
+ *
354
360
  * Must return updated {@link OAuthCredentials} with at least `access` and
355
361
  * `expires`. `refresh` may be an opaque sentinel (e.g. `"__remote__"`) when
356
362
  * the actual refresh token never leaves the broker.
@@ -360,6 +366,7 @@ export type AuthStorageOptions = {
360
366
  credentialId: number,
361
367
  credential: OAuthCredential,
362
368
  signal?: AbortSignal,
369
+ reason?: OAuthRefreshReason,
363
370
  ) => Promise<OAuthCredentials>;
364
371
  /**
365
372
  * Human-readable description of the credential store backing this
@@ -475,6 +482,8 @@ export type AuthApiKeyOptions = {
475
482
  * that a peer/broker rotated out from under us is replaced before retrying.
476
483
  */
477
484
  forceRefresh?: boolean;
485
+ /** Explicit provider-401 recovery; generic force refreshes leave this unset. */
486
+ refreshReason?: OAuthRefreshReason;
478
487
  };
479
488
 
480
489
  /**
@@ -956,6 +965,16 @@ export interface KeysApi {
956
965
  resolver(provider: string, options?: { sessionId?: string; baseUrl?: string; modelId?: string }): ApiKeyResolver;
957
966
  }
958
967
 
968
+ /** Controls whether a row-id refresh may reuse a token minted by this refresher. */
969
+ export interface OAuthRefreshByIdOptions {
970
+ /**
971
+ * Return the stored credential when it still holds a fresh access token this
972
+ * refresher minted recently. Auth-recovery callers use this to avoid rotating
973
+ * refresh tokens repeatedly when a provider rejects every valid bearer.
974
+ */
975
+ reuseRecentMint?: boolean;
976
+ }
977
+
959
978
  /** OAuth login, access, account identity, and refresh operations. */
960
979
  export interface OAuthApi {
961
980
  /**
@@ -1034,9 +1053,10 @@ export interface OAuthApi {
1034
1053
  * Refresh the OAuth credential with the given id through a per-credential
1035
1054
  * single-flight. Concurrent callers for the same row await the same upstream
1036
1055
  * refresh attempt, which is required for providers that rotate refresh tokens
1037
- * on every successful refresh.
1056
+ * on every successful refresh. Mints unconditionally unless
1057
+ * {@link OAuthRefreshByIdOptions.reuseRecentMint} is set.
1038
1058
  */
1039
- refresh(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry>;
1059
+ refresh(id: number, signal?: AbortSignal, options?: OAuthRefreshByIdOptions): Promise<AuthCredentialSnapshotEntry>;
1040
1060
  /**
1041
1061
  * Refresh one stored OAuth credential under durable row ownership.
1042
1062
  */
@@ -287,23 +287,27 @@ export class UsageCache {
287
287
  /**
288
288
  * Force the next usage fetch for `provider` to bypass the 5-min cache, so
289
289
  * `/usage` reflects a freshly-redeemed reset instead of stale numbers.
290
+ * `resetSpentCredentialId` also forgets that credential's cached saved-reset
291
+ * block, so a failed follow-up reset probe cannot carry the pre-spend
292
+ * inventory forward.
290
293
  */
291
- invalidate(provider: string, baseUrl?: string): void {
294
+ invalidate(provider: string, baseUrl?: string, options?: { resetSpentCredentialId?: number }): void {
292
295
  this.#epoch += 1;
293
296
  const expired = Date.now() - 1;
294
297
  for (const entry of this.#pool.entries(provider)) {
295
298
  if (entry.credential.type !== "oauth") continue;
296
299
  const cacheKey = this.reportKey(oauthUsageRequest(provider, entry.credential, baseUrl));
297
- const existing = this.getStale<UsageReport | null>(cacheKey);
298
- this.set(cacheKey, {
299
- value: existing?.value ?? null,
300
- expiresAt: expired,
301
- });
300
+ let value = this.getStale<UsageReport | null>(cacheKey)?.value ?? null;
301
+ if (value?.resetCredits && entry.id === options?.resetSpentCredentialId) {
302
+ const { resetCredits: _spent, ...rest } = value;
303
+ value = rest;
304
+ }
305
+ this.set(cacheKey, { value, expiresAt: expired });
302
306
  }
303
307
  }
304
308
 
305
309
  /** A confirmed reset permits one fresh probe even during a pre-reset failure cooldown. */
306
- invalidateAfterReset(provider: Provider, baseUrl?: string): void {
310
+ invalidateAfterReset(provider: Provider, baseUrl?: string, options?: { resetSpentCredentialId?: number }): void {
307
311
  this.#recoveryEpochs.set(provider, this.recoveryEpoch(provider) + 1);
308
312
  this.#bumpRefreshEpoch(provider);
309
313
  if (!this.deletePrefix(`failure:report:${this.#usageCacheProviderKey(provider)}:`)) {
@@ -313,7 +317,7 @@ export class UsageCache {
313
317
  }
314
318
  for (const key of keys) this.set(this.failureKey(key), { value: null, expiresAt: 0 });
315
319
  }
316
- this.invalidate(provider, baseUrl);
320
+ this.invalidate(provider, baseUrl, options);
317
321
  }
318
322
 
319
323
  /**
package/src/auth/usage.ts CHANGED
@@ -103,6 +103,11 @@ export interface UsageServiceDeps {
103
103
  logger: UsageLogger;
104
104
  }
105
105
 
106
+ type UsageReportsOptions = {
107
+ baseUrlResolver?: (provider: Provider) => string | undefined;
108
+ signal?: AbortSignal;
109
+ };
110
+
106
111
  /** Usage reports: per-credential cached fetches, aggregate reports, header ingestion, history. */
107
112
  export class UsageService implements UsageApi {
108
113
  #deps: UsageServiceDeps;
@@ -261,9 +266,13 @@ export class UsageService implements UsageApi {
261
266
  if (providerImpl.supports && !providerImpl.supports(params)) return null;
262
267
 
263
268
  try {
269
+ const previousReport = this.#deps.cache.getStale<UsageReport | null>(
270
+ this.#deps.cache.reportKey(request),
271
+ )?.value;
264
272
  const report = await providerImpl.fetchUsage(params, {
265
273
  fetch: this.fetch,
266
274
  logger: this.logger,
275
+ ...(previousReport ? { previousReport } : {}),
267
276
  });
268
277
  // Attribute the report to the credential's organization. The orgId and
269
278
  // orgName fallbacks apply independently: Claude's usage endpoint stamps
@@ -563,16 +572,19 @@ export class UsageService implements UsageApi {
563
572
  return true;
564
573
  }
565
574
 
566
- /** Collect resolved account requests for all configured usage providers. */
567
- async #collectUsageRequests(options?: {
568
- baseUrlResolver?: (provider: Provider) => string | undefined;
569
- }): Promise<UsageRequestDescriptor[]> {
575
+ /** Collect resolved account requests, restricted to runtime providers when the store owns aggregate reports. */
576
+ async #collectUsageRequests(
577
+ options?: UsageReportsOptions,
578
+ providerFilter?: ReadonlySet<Provider>,
579
+ ): Promise<UsageRequestDescriptor[]> {
570
580
  const requests: UsageRequestDescriptor[] = [];
571
- const providers = new Set<string>([
572
- ...this.#deps.pool.providers(),
573
- ...this.#runtimeUsageProviderOverrides.keys(),
574
- ...DEFAULT_USAGE_PROVIDERS.map(provider => provider.id),
575
- ]);
581
+ const providers =
582
+ providerFilter ??
583
+ new Set<string>([
584
+ ...this.#deps.pool.providers(),
585
+ ...this.#runtimeUsageProviderOverrides.keys(),
586
+ ...DEFAULT_USAGE_PROVIDERS.map(provider => provider.id),
587
+ ]);
576
588
 
577
589
  for (const providerId of providers) {
578
590
  const provider = providerId as Provider;
@@ -742,14 +754,9 @@ export class UsageService implements UsageApi {
742
754
  }
743
755
 
744
756
  /** Fetch all providers’ current usage reports, sharing concurrent polls. */
745
- async reports(options?: {
746
- baseUrlResolver?: (provider: Provider) => string | undefined;
747
- /** Caller's cancel signal; only rejects this caller, never the shared upstream fetch. */
748
- signal?: AbortSignal;
749
- }): Promise<UsageReport[] | null> {
750
- // Store-level hook > local per-credential fan-out. `RemoteAuthCredentialStore`
751
- // implements the hook so a gateway backed by a broker routes usage to the
752
- // broker without the caller wiring it explicitly.
757
+ async reports(options?: UsageReportsOptions): Promise<UsageReport[] | null> {
758
+ // The broker owns its providers' reports; runtime providers registered
759
+ // only in this process still need local per-credential probes.
753
760
  const storeOverride = this.#deps.store.fetchUsageReports?.bind(this.#deps.store);
754
761
  if (storeOverride) {
755
762
  // Reuse the in-flight map so concurrent callers (widget poll + format
@@ -768,9 +775,28 @@ export class UsageService implements UsageApi {
768
775
  }
769
776
  const reports = await raceSignal(shared, options?.signal, "usage fetch aborted");
770
777
  if (reports) this.#deps.blocks.reconcileReports(reports);
771
- return reports;
778
+ if (!reports || this.#runtimeUsageProviderOverrides.size === 0) return reports;
779
+
780
+ // The broker owns its reported providers; only extension providers
781
+ // absent from its response need local credentials and a local probe.
782
+ const brokerProviders = new Set(reports.map(report => report.provider));
783
+ const localProviders = new Set<Provider>();
784
+ for (const provider of this.#runtimeUsageProviderOverrides.keys()) {
785
+ if (!brokerProviders.has(provider)) localProviders.add(provider);
786
+ }
787
+ if (localProviders.size === 0) return reports;
788
+ const localReports = await this.#fetchLocalReports(options, localProviders);
789
+ return localReports?.length ? [...reports, ...localReports] : reports;
772
790
  }
773
- const requests = await this.#collectUsageRequests(options);
791
+ return this.#fetchLocalReports(options);
792
+ }
793
+
794
+ /** Probe only the selected providers locally, preserving per-credential caching and cooldowns. */
795
+ async #fetchLocalReports(
796
+ options?: UsageReportsOptions,
797
+ providerFilter?: ReadonlySet<Provider>,
798
+ ): Promise<UsageReport[] | null> {
799
+ const requests = await this.#collectUsageRequests(options, providerFilter);
774
800
  if (requests.length === 0) return [];
775
801
 
776
802
  this.logger?.debug("Usage fetch requested", {
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { type } from "@oh-my-pi/omptype";
10
10
  import { readSseEvents } from "@oh-my-pi/pi-utils";
11
- import type { AuthCredential, DisabledCredentialSummary } from "../auth-storage";
11
+ import type { AuthCredential, DisabledCredentialSummary, OAuthRefreshReason } from "../auth-storage";
12
12
  import type {
13
13
  ClientUsageReportRequest,
14
14
  ClientUsageReportResponse,
@@ -327,8 +327,13 @@ export class AuthBrokerClient {
327
327
  });
328
328
  }
329
329
 
330
- async refreshCredential(id: number, signal?: AbortSignal): Promise<CredentialRefreshResponse> {
331
- return this.#request<CredentialRefreshResponse>("POST", `/v1/credential/${id}/refresh`, {
330
+ async refreshCredential(
331
+ id: number,
332
+ signal?: AbortSignal,
333
+ reason?: OAuthRefreshReason,
334
+ ): Promise<CredentialRefreshResponse> {
335
+ const suffix = reason === "auth-recovery" ? "?reason=auth-recovery" : "";
336
+ return this.#request<CredentialRefreshResponse>("POST", `/v1/credential/${id}/refresh${suffix}`, {
332
337
  schema: "credentialRefreshResponseSchema",
333
338
  signal,
334
339
  });
@@ -15,6 +15,7 @@ import {
15
15
  type AuthCredentialSnapshotEntry,
16
16
  type DisabledCredentialSummary,
17
17
  type OAuthCredential,
18
+ type OAuthRefreshReason,
18
19
  REMOTE_REFRESH_SENTINEL,
19
20
  type StoredAuthCredential,
20
21
  type StoredCredentialBlock,
@@ -856,7 +857,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
856
857
 
857
858
  async markCredentialSuspect(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<void> {
858
859
  this.#noteActivity();
859
- const { entry } = await this.#client.refreshCredential(credentialId, opts.signal);
860
+ const { entry } = await this.#client.refreshCredential(credentialId, opts.signal, "auth-recovery");
860
861
  if (entry.credential.type !== "oauth") {
861
862
  throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
862
863
  }
@@ -1172,9 +1173,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1172
1173
  credentialId: number,
1173
1174
  _credential: OAuthCredential,
1174
1175
  signal?: AbortSignal,
1176
+ reason?: OAuthRefreshReason,
1175
1177
  ): Promise<OAuthCredentials> {
1176
1178
  this.#noteActivity();
1177
- const { entry } = await this.#client.refreshCredential(credentialId, signal);
1179
+ const { entry } = await this.#client.refreshCredential(credentialId, signal, reason);
1178
1180
  if (entry.credential.type !== "oauth") {
1179
1181
  throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
1180
1182
  }
@@ -50,6 +50,7 @@ import {
50
50
  } from "./wire-schemas";
51
51
 
52
52
  const DEFAULT_EXTERNAL_CHANGE_POLL_MS = 250;
53
+ const AUTH_RECOVERY_REFRESH_OPTIONS = { reuseRecentMint: true } as const;
53
54
 
54
55
  export interface AuthBrokerServerOptions {
55
56
  /** Underlying credential storage (wraps the local SQLite store on the broker). */
@@ -765,8 +766,13 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
765
766
  const refreshMatch = req.method === "POST" ? pathname.match(REFRESH_ROUTE) : null;
766
767
  if (refreshMatch) {
767
768
  const id = Number.parseInt(refreshMatch[1], 10);
769
+ const authRecovery = url.searchParams.get("reason") === "auth-recovery";
768
770
  try {
769
- const entry = await opts.storage.oauth.refresh(id, req.signal);
771
+ const entry = await opts.storage.oauth.refresh(
772
+ id,
773
+ req.signal,
774
+ authRecovery ? AUTH_RECOVERY_REFRESH_OPTIONS : undefined,
775
+ );
770
776
  const body: CredentialRefreshResponse = { entry };
771
777
  logger.info("auth-broker credential refreshed", {
772
778
  id,
@@ -214,6 +214,7 @@ export function buildGatewayApiKeyResolver(
214
214
  const refreshed = await storage.keys.get(model.provider, sessionId, {
215
215
  ...modelKeyOptions(model, sig),
216
216
  forceRefresh: true,
217
+ refreshReason: AIError.status(error) === 401 ? "auth-recovery" : undefined,
217
218
  });
218
219
  lastKey = refreshed ?? lastKey;
219
220
  if (refreshed) onResolvedKey?.(refreshed);
package/src/auth-retry.ts CHANGED
@@ -423,7 +423,11 @@ export async function withOAuthAccess<T>(
423
423
  if (!refreshedCurrent) {
424
424
  refreshedCurrent = true;
425
425
  try {
426
- next = await storage.oauth.access(provider, sessionId, { forceRefresh: true, signal });
426
+ next = await storage.oauth.access(provider, sessionId, {
427
+ forceRefresh: true,
428
+ refreshReason: AIError.status(lastError) === 401 ? "auth-recovery" : undefined,
429
+ signal,
430
+ });
427
431
  } catch {
428
432
  next = undefined;
429
433
  }
@@ -875,14 +875,21 @@ export function attach<E extends object>(error: E, id: number): E {
875
875
 
876
876
  /** Overflow-classification evidence, including errors received before token usage is available. */
877
877
  export interface ContextOverflowMessage extends Pick<AssistantMessage, "errorId" | "stopReason" | "errorMessage"> {
878
- readonly usage?: Pick<Usage, "input" | "cacheRead" | "cacheWrite">;
878
+ readonly usage?: Pick<Usage, "input" | "cacheRead" | "cacheWrite" | "contextTokens">;
879
879
  }
880
880
 
881
- /** Provider-reported usage proves context-window excess — authoritative, compaction-owned (#9235). */
881
+ /**
882
+ * Provider-reported usage proves context-window excess — authoritative, compaction-owned (#9235).
883
+ *
884
+ * Prefers `contextTokens` when the provider reports it: providers that run
885
+ * several model calls per turn (Cursor's server-side tool loop) report
886
+ * `input`/`cacheRead` summed across those calls, which can exceed the window
887
+ * many times over while the conversation itself stays small.
888
+ */
882
889
  export function isUsageBackedContextOverflow(message: ContextOverflowMessage, contextWindow?: number): boolean {
883
890
  const usage = message.usage;
884
891
  if (!contextWindow || !usage) return false;
885
- const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite;
892
+ const inputTokens = usage.contextTokens ?? usage.input + usage.cacheRead + usage.cacheWrite;
886
893
  return inputTokens > contextWindow;
887
894
  }
888
895