@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.
- package/CHANGELOG.md +35 -48
- package/dist/types/auth/blocks.d.ts +10 -19
- package/dist/types/auth/cascade.d.ts +1 -1
- package/dist/types/auth/rotation.d.ts +6 -3
- package/dist/types/auth/types.d.ts +23 -3
- package/dist/types/auth/usage-cache.d.ts +14 -2
- package/dist/types/auth/usage.d.ts +2 -2
- package/dist/types/auth-broker/client.d.ts +2 -1
- package/dist/types/auth-broker/remote-store.d.ts +5 -6
- package/dist/types/auth-broker/types.d.ts +3 -1
- package/dist/types/auth-broker/wire-schemas.d.ts +2 -1
- package/dist/types/auth-retry.d.ts +22 -13
- package/dist/types/images/types.d.ts +9 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/judgment/types.d.ts +3 -2
- package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
- package/dist/types/providers/cursor-pi-args.d.ts +10 -0
- package/dist/types/providers/cursor.d.ts +6 -2
- package/dist/types/providers/google-shared.d.ts +13 -2
- package/dist/types/types.d.ts +0 -10
- package/dist/types/usage/claude-reset.d.ts +6 -0
- package/dist/types/usage/claude.d.ts +6 -0
- package/dist/types/usage.d.ts +2 -0
- package/dist/types/utils/proxy.d.ts +2 -2
- package/package.json +6 -6
- package/src/auth/blocks.ts +79 -37
- package/src/auth/cascade.ts +5 -4
- package/src/auth/resets.ts +104 -40
- package/src/auth/rotation.ts +62 -21
- package/src/auth/select.ts +9 -2
- package/src/auth/types.ts +28 -3
- package/src/auth/usage-cache.ts +45 -2
- package/src/auth/usage.ts +52 -25
- package/src/auth-broker/client.ts +13 -0
- package/src/auth-broker/remote-store.ts +128 -27
- package/src/auth-broker/server.ts +20 -0
- package/src/auth-broker/types.ts +4 -1
- package/src/auth-broker/wire-schemas.ts +7 -0
- package/src/auth-retry.ts +50 -28
- package/src/images/openai-hosted.ts +18 -3
- package/src/images/types.ts +9 -0
- package/src/index.ts +1 -0
- package/src/judgment/types.ts +10 -6
- package/src/judgment/typesafe.ts +2 -2
- package/src/providers/anthropic.ts +54 -123
- package/src/providers/cursor/exec-modern.ts +1 -0
- package/src/providers/cursor/proto/agent.proto +5 -0
- package/src/providers/cursor-pi-args.ts +14 -0
- package/src/providers/cursor.ts +66 -28
- package/src/providers/google-gemini-cli.ts +2 -19
- package/src/providers/google-shared.ts +30 -22
- package/src/providers/ollama.ts +2 -0
- package/src/registry/oauth/oauth.html +206 -185
- package/src/stream.ts +2 -284
- package/src/types.ts +0 -10
- package/src/usage/claude-reset.ts +45 -11
- package/src/usage/claude.ts +79 -49
- package/src/usage.ts +2 -0
- package/src/utils/proxy.ts +3 -3
- package/src/utils/schema/CONSTRAINTS.md +30 -28
- package/src/utils/schema/normalize.ts +1 -1
- package/src/utils/schema/wire.ts +3 -3
package/src/auth/rotation.ts
CHANGED
|
@@ -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
|
-
*
|
|
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(
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
)
|
|
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(
|
|
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
|
+
}
|
package/src/auth/select.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
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(
|
|
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
|
|
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. */
|
package/src/auth/usage-cache.ts
CHANGED
|
@@ -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
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
323
|
-
const
|
|
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
|
-
|
|
328
|
-
|
|
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 = !
|
|
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
|
-
|
|
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(
|
|
383
|
+
this.#usageRequestInFlight.delete(cacheKey);
|
|
359
384
|
});
|
|
360
385
|
|
|
361
|
-
this.#usageRequestInFlight.set(
|
|
362
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
810
|
-
*
|
|
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",
|