@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.
- package/CHANGELOG.md +26 -0
- package/README.md +12 -0
- package/dist/types/auth/oauth.d.ts +2 -2
- package/dist/types/auth/refresh.d.ts +3 -3
- package/dist/types/auth/store.d.ts +4 -2
- package/dist/types/auth/types.d.ts +20 -3
- package/dist/types/auth/usage-cache.d.ts +9 -2
- package/dist/types/auth/usage.d.ts +6 -5
- package/dist/types/auth-broker/client.d.ts +2 -2
- package/dist/types/auth-broker/remote-store.d.ts +2 -2
- package/dist/types/error/flags.d.ts +9 -2
- package/dist/types/error/rate-limit.d.ts +3 -2
- package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
- package/dist/types/providers/cursor-pi-args.d.ts +13 -18
- package/dist/types/stream.d.ts +7 -0
- package/dist/types/usage/cursor.d.ts +3 -1
- package/dist/types/usage/zai.d.ts +2 -0
- package/dist/types/usage.d.ts +6 -0
- package/package.json +6 -6
- package/src/auth/cascade.ts +1 -0
- package/src/auth/oauth.ts +4 -3
- package/src/auth/refresh.ts +105 -16
- package/src/auth/resets.ts +6 -2
- package/src/auth/select.ts +11 -1
- package/src/auth/store.ts +4 -0
- package/src/auth/types.ts +22 -2
- package/src/auth/usage-cache.ts +12 -8
- package/src/auth/usage.ts +45 -19
- package/src/auth-broker/client.ts +8 -3
- package/src/auth-broker/remote-store.ts +4 -2
- package/src/auth-broker/server.ts +7 -1
- package/src/auth-gateway/dispatch.ts +1 -0
- package/src/auth-retry.ts +5 -1
- package/src/error/flags.ts +10 -3
- package/src/error/rate-limit.ts +23 -3
- package/src/providers/anthropic.ts +62 -53
- package/src/providers/cowork-fetch.ts +45 -3
- package/src/providers/cursor/exec-modern.ts +1 -1
- package/src/providers/cursor-pi-args.ts +13 -18
- package/src/providers/cursor.ts +179 -57
- package/src/providers/devin.ts +43 -13
- package/src/providers/openai-completions.ts +122 -30
- package/src/stream.ts +77 -16
- package/src/usage/claude.ts +5 -0
- package/src/usage/cursor.ts +43 -1
- package/src/usage/devin.ts +8 -7
- package/src/usage/registry.ts +2 -1
- package/src/usage/zai.ts +20 -0
- package/src/usage.ts +6 -0
package/src/auth/refresh.ts
CHANGED
|
@@ -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
|
|
375
|
-
|
|
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(
|
|
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
|
|
535
|
-
//
|
|
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
|
|
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
|
+
}
|
package/src/auth/resets.ts
CHANGED
|
@@ -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 });
|
package/src/auth/select.ts
CHANGED
|
@@ -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
|
|
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
|
*/
|
package/src/auth/usage-cache.ts
CHANGED
|
@@ -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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
567
|
-
async #collectUsageRequests(
|
|
568
|
-
|
|
569
|
-
|
|
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 =
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
747
|
-
|
|
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
|
-
|
|
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(
|
|
331
|
-
|
|
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(
|
|
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, {
|
|
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
|
}
|
package/src/error/flags.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|