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