@cortexkit/antigravity-auth-core 3.0.9 → 3.0.11
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/dist/common-auth-embedded/claustrum/consumer.d.ts +3 -1
- package/dist/common-auth-embedded/claustrum/consumer.js +25 -21
- package/dist/common-auth-embedded/claustrum/enrollment.d.ts +5 -0
- package/dist/common-auth-embedded/claustrum/enrollment.js +27 -0
- package/dist/common-auth-embedded/claustrum/index.d.ts +1 -0
- package/dist/common-auth-embedded/claustrum/index.js +1 -0
- package/dist/common-auth-embedded/claustrum/quota-writer.d.ts +18 -0
- package/dist/common-auth-embedded/claustrum/quota-writer.js +253 -0
- package/dist/common-auth-embedded/claustrum/roster.d.ts +18 -2
- package/dist/common-auth-embedded/claustrum/roster.js +100 -43
- package/dist/common-auth-embedded/fs/private-files.d.ts +2 -0
- package/dist/common-auth-embedded/fs/private-files.js +49 -3
- package/dist/common-auth-embedded/fs/with-lock.d.ts +1 -0
- package/dist/common-auth-embedded/fs/with-lock.js +2 -0
- package/dist/common-auth-embedded/quota/format.d.ts +19 -0
- package/dist/common-auth-embedded/quota/format.js +50 -5
- package/dist/common-auth-embedded/quota/index.d.ts +3 -3
- package/dist/common-auth-embedded/quota/index.js +2 -2
- package/dist/common-auth-embedded/quota/map.d.ts +24 -1
- package/dist/common-auth-embedded/quota/map.js +31 -6
- package/dist/common-auth-embedded/quota/merge.d.ts +6 -1
- package/dist/common-auth-embedded/quota/merge.js +17 -3
- package/dist/common-auth-embedded/quota/projection.d.ts +3 -1
- package/dist/common-auth-embedded/quota/projection.js +3 -1
- package/dist/common-auth-embedded/source-output.json +89 -73
- package/dist/pre-response-deadline.d.ts.map +1 -1
- package/dist/pre-response-deadline.js +5 -1
- package/dist/pre-response-deadline.js.map +1 -1
- package/dist/quota-manager.d.ts +3 -3
- package/dist/quota-manager.d.ts.map +1 -1
- package/dist/quota-manager.js +59 -36
- package/dist/quota-manager.js.map +1 -1
- package/dist/transform/gemini.d.ts.map +1 -1
- package/dist/transform/gemini.js +12 -0
- package/dist/transform/gemini.js.map +1 -1
- package/dist/vault-enrollment-authority.d.ts +2 -0
- package/dist/vault-enrollment-authority.d.ts.map +1 -1
- package/dist/vault-enrollment-authority.js +2 -0
- package/dist/vault-enrollment-authority.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { acquireRefreshFileLock, LockContentionError, withLock, writeJsonAtomic, } from '../fs/index.js';
|
|
3
4
|
import { readPrivateText, windowsPrivateFiles } from '../fs/private-files.js';
|
|
4
|
-
import { decodeQuotaMap, mergeQuotaObservation, } from '../quota/index.js';
|
|
5
|
+
import { decodeQuotaMap, isQuotaObservation, mergeQuotaObservation, } from '../quota/index.js';
|
|
5
6
|
import { ClaustrumConsumerError } from './errors.js';
|
|
6
7
|
import { acceptAccount, declineAccount, isDeclined, } from './interlock.js';
|
|
8
|
+
import { enqueueVaultQuota, pendingVaultQuota, } from './quota-writer.js';
|
|
9
|
+
export { drainVaultQuota } from './quota-writer.js';
|
|
7
10
|
export const DEFAULT_ROUTE_PREFIX = 'vault:';
|
|
8
11
|
function hash(value) {
|
|
9
12
|
return createHash('sha256').update(value).digest('hex');
|
|
@@ -313,6 +316,7 @@ const WRITE_LOCK = {
|
|
|
313
316
|
name: 'claustrum-roster-write',
|
|
314
317
|
ttlMs: 10_000,
|
|
315
318
|
timeoutMs: 5_000,
|
|
319
|
+
renew: true,
|
|
316
320
|
};
|
|
317
321
|
const DISCOVERY_LEASE = { name: 'claustrum-roster', ttlMs: 30_000 };
|
|
318
322
|
/**
|
|
@@ -321,7 +325,7 @@ const DISCOVERY_LEASE = { name: 'claustrum-roster', ttlMs: 30_000 };
|
|
|
321
325
|
* before any rename performed inside `change`; if it throws, do not rename.
|
|
322
326
|
*/
|
|
323
327
|
export async function mutateVaultRoster(path, change, options = {}) {
|
|
324
|
-
return withLock(path, WRITE_LOCK, async (lock) => {
|
|
328
|
+
return withLock(path, { ...WRITE_LOCK, signal: options.signal }, async (lock) => {
|
|
325
329
|
const current = await readVaultRoster(path);
|
|
326
330
|
const { next, result } = await change(current, {
|
|
327
331
|
assertOwned: () => lock.assertOwned(),
|
|
@@ -333,6 +337,7 @@ export async function mutateVaultRoster(path, change, options = {}) {
|
|
|
333
337
|
await writeJsonAtomic(path, next, {
|
|
334
338
|
beforeRename: async () => {
|
|
335
339
|
await options.beforePublish?.();
|
|
340
|
+
options.signal?.throwIfAborted();
|
|
336
341
|
await lock.assertOwned();
|
|
337
342
|
},
|
|
338
343
|
});
|
|
@@ -415,55 +420,73 @@ function receiptFence(row, receipt) {
|
|
|
415
420
|
* write is fenced on the receipt the reading was taken with: it lands only
|
|
416
421
|
* while the row still holds that receipt's credential (as representative or
|
|
417
422
|
* alias) and account, so a slow read for a replaced account can never land on
|
|
418
|
-
* its successor.
|
|
423
|
+
* its successor. Resolves false when the fence refuses the reading against the
|
|
424
|
+
* roster as it stands now (on disk, plus readings this process has not
|
|
425
|
+
* written yet), and true once the reading is queued for the next write. The
|
|
426
|
+
* write applies the fence again, so a rebind between the two still refuses.
|
|
419
427
|
*/
|
|
420
|
-
export function recordVaultQuota(path, input) {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
// A bound account is `unclaimed`: the vault's list has not named it, and
|
|
428
|
-
// the next projection keeps it as the account this credential last
|
|
429
|
-
// logged into until the vault names one.
|
|
430
|
-
const bound = fence === 'bind'
|
|
431
|
-
? { accountIdentity: input.accountIdentity, unclaimed: true }
|
|
432
|
-
: {};
|
|
433
|
-
return {
|
|
434
|
-
next: {
|
|
435
|
-
...current,
|
|
436
|
-
rows: current.rows.map((entry) => entry === row ? { ...entry, ...bound, quota } : entry),
|
|
437
|
-
},
|
|
438
|
-
result: true,
|
|
439
|
-
};
|
|
428
|
+
export async function recordVaultQuota(path, input) {
|
|
429
|
+
const { routeId, observation, ...receipt } = input;
|
|
430
|
+
return enqueueFencedVaultQuota(path, {
|
|
431
|
+
routeId,
|
|
432
|
+
observation,
|
|
433
|
+
accountIdentity: receipt.accountIdentity,
|
|
434
|
+
receipt,
|
|
440
435
|
});
|
|
441
436
|
}
|
|
437
|
+
// The answer callers rely on (false for a replaced account) comes from the
|
|
438
|
+
// fence, so it runs here against the current roster before anything is
|
|
439
|
+
// queued. The read takes no lock: the roster file is replaced atomically.
|
|
440
|
+
async function enqueueFencedVaultQuota(path, publication) {
|
|
441
|
+
if (!isQuotaObservation(publication.observation))
|
|
442
|
+
return false;
|
|
443
|
+
const current = pendingVaultQuota(path, await readVaultRoster(path));
|
|
444
|
+
if (!applyVaultQuota(current, publication).kept)
|
|
445
|
+
return false;
|
|
446
|
+
return enqueueVaultQuota(path, publication);
|
|
447
|
+
}
|
|
442
448
|
/**
|
|
443
449
|
* Merge a quota observation that belongs to an account rather than to a
|
|
444
450
|
* served credential, such as a background reading from insula. The write
|
|
445
451
|
* lands only while the row is still bound to that exact known account, so a
|
|
446
452
|
* reading taken before a replacement never reaches the successor. Unlike
|
|
447
453
|
* `recordVaultQuota` it never binds an account: a row without a known account
|
|
448
|
-
* is refused.
|
|
454
|
+
* is refused. Resolves false when the row is not bound to that account now,
|
|
455
|
+
* and true once the reading is queued, as `recordVaultQuota` does.
|
|
449
456
|
*/
|
|
450
457
|
export function recordVaultAccountQuota(path, input) {
|
|
451
|
-
return
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
};
|
|
466
|
-
|
|
458
|
+
return enqueueFencedVaultQuota(path, input);
|
|
459
|
+
}
|
|
460
|
+
/** Apply the same identity fence to memory overlays and durable quota batches. */
|
|
461
|
+
export function applyVaultQuota(current, input) {
|
|
462
|
+
const row = current?.rows.find((entry) => entry.routeId === input.routeId);
|
|
463
|
+
const fence = !row
|
|
464
|
+
? 'refuse'
|
|
465
|
+
: input.receipt
|
|
466
|
+
? receiptFence(row, input.receipt)
|
|
467
|
+
: row.accountIdentity !== undefined &&
|
|
468
|
+
row.accountIdentity === input.accountIdentity
|
|
469
|
+
? 'hold'
|
|
470
|
+
: 'refuse';
|
|
471
|
+
if (!current || !row || fence === 'refuse')
|
|
472
|
+
return { roster: current, kept: false };
|
|
473
|
+
const quota = mergeQuotaObservation(row.quota, input.observation);
|
|
474
|
+
// Binding remembers which account this credential proved, without claiming
|
|
475
|
+
// that the vault's discovery has named that account yet.
|
|
476
|
+
const bound = fence === 'bind'
|
|
477
|
+
? { accountIdentity: input.accountIdentity, unclaimed: true }
|
|
478
|
+
: {};
|
|
479
|
+
return {
|
|
480
|
+
roster: {
|
|
481
|
+
...current,
|
|
482
|
+
rows: current.rows.map((entry) => entry === row ? { ...entry, ...bound, quota } : entry),
|
|
483
|
+
},
|
|
484
|
+
kept: true,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
const pendingDiscoveries = new Map();
|
|
488
|
+
export function forgetPendingVaultDiscovery(path) {
|
|
489
|
+
pendingDiscoveries.delete(resolve(path));
|
|
467
490
|
}
|
|
468
491
|
/**
|
|
469
492
|
* Discover and commit the roster. The lease covers the list call and the
|
|
@@ -471,12 +494,16 @@ export function recordVaultAccountQuota(path, input) {
|
|
|
471
494
|
* from a newer one, so a run that lost its lease is refused at commit. A
|
|
472
495
|
* failed list, or custody switching off mid-run, never writes anything; a
|
|
473
496
|
* peer holding the lease means its last committed roster is served instead.
|
|
497
|
+
* A write-lock timeout keeps the discovered inventory for the next poll,
|
|
498
|
+
* unless a peer has superseded its base view in the meantime.
|
|
474
499
|
*/
|
|
475
500
|
export async function refreshVaultRoster(options) {
|
|
476
501
|
options.signal?.throwIfAborted();
|
|
477
502
|
const active = async () => (await options.isActive?.()) ?? true;
|
|
478
|
-
if (!(await active()))
|
|
503
|
+
if (!(await active())) {
|
|
504
|
+
forgetPendingVaultDiscovery(options.path);
|
|
479
505
|
return undefined;
|
|
506
|
+
}
|
|
480
507
|
const lease = await acquireRefreshFileLock({
|
|
481
508
|
...DISCOVERY_LEASE,
|
|
482
509
|
path: options.path,
|
|
@@ -489,8 +516,25 @@ export async function refreshVaultRoster(options) {
|
|
|
489
516
|
throw new ClaustrumConsumerError('roster-busy', 'Claustrum account discovery is already in progress');
|
|
490
517
|
return persisted;
|
|
491
518
|
}
|
|
519
|
+
const key = resolve(options.path);
|
|
520
|
+
let discovered;
|
|
492
521
|
try {
|
|
493
|
-
const
|
|
522
|
+
const previous = await readVaultRoster(options.path);
|
|
523
|
+
const pending = pendingDiscoveries.get(key);
|
|
524
|
+
// Opaque view cursors cannot be ordered. Reuse a discovery only while no
|
|
525
|
+
// peer has committed a different view since it was read.
|
|
526
|
+
discovered =
|
|
527
|
+
pending &&
|
|
528
|
+
(pending.previousView === previous?.view ||
|
|
529
|
+
pending.inventory.view === previous?.view)
|
|
530
|
+
? pending
|
|
531
|
+
: {
|
|
532
|
+
inventory: await options.custody.discover(options.signal),
|
|
533
|
+
previousView: previous?.view,
|
|
534
|
+
};
|
|
535
|
+
pendingDiscoveries.delete(key);
|
|
536
|
+
const { inventory } = discovered;
|
|
537
|
+
options.onInventory?.(inventory);
|
|
494
538
|
options.signal?.throwIfAborted();
|
|
495
539
|
return await mutateVaultRoster(options.path, async (current) => {
|
|
496
540
|
if (!(await active()))
|
|
@@ -503,8 +547,21 @@ export async function refreshVaultRoster(options) {
|
|
|
503
547
|
const next = projectVaultRoster(current, inventory, projection);
|
|
504
548
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
505
549
|
return { ...(changed && { next }), result: next };
|
|
550
|
+
}, {
|
|
551
|
+
beforePublish: async () => {
|
|
552
|
+
options.signal?.throwIfAborted();
|
|
553
|
+
await lease.assertOwned();
|
|
554
|
+
},
|
|
506
555
|
});
|
|
507
556
|
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
if (error instanceof LockContentionError &&
|
|
559
|
+
discovered &&
|
|
560
|
+
!options.signal?.aborted &&
|
|
561
|
+
(await active()))
|
|
562
|
+
pendingDiscoveries.set(key, discovered);
|
|
563
|
+
throw error;
|
|
564
|
+
}
|
|
508
565
|
finally {
|
|
509
566
|
await lease.release();
|
|
510
567
|
}
|
|
@@ -5,6 +5,8 @@ type Backend = typeof import('@cortexkit/private-files');
|
|
|
5
5
|
type Kind = 'file' | 'directory';
|
|
6
6
|
/** Tests inject a backend here; production always imports the private-files package. */
|
|
7
7
|
export declare function setPrivateFilesBackendForTest(api?: Backend): void;
|
|
8
|
+
/** The fixed `reason` of a refusal because the Windows native addon did not load. */
|
|
9
|
+
export declare const PRIVATE_FILES_UNAVAILABLE_REASON = "native-addon-unavailable";
|
|
8
10
|
export declare class PrivateFileError extends Error {
|
|
9
11
|
readonly path: string;
|
|
10
12
|
readonly stage: string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { lstat, readFile, rename, unlink } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
5
|
export const windowsPrivateFiles = process.platform === 'win32';
|
|
5
6
|
const migrationNotices = new Set();
|
|
6
7
|
function ioCode(detail) {
|
|
@@ -20,8 +21,51 @@ let backend;
|
|
|
20
21
|
export function setPrivateFilesBackendForTest(api) {
|
|
21
22
|
backend = api ? Promise.resolve(api) : undefined;
|
|
22
23
|
}
|
|
24
|
+
/** The fixed `reason` of a refusal because the Windows native addon did not load. */
|
|
25
|
+
export const PRIVATE_FILES_UNAVAILABLE_REASON = 'native-addon-unavailable';
|
|
26
|
+
function privateFilesUnavailable(cause) {
|
|
27
|
+
return Object.assign(new Error('Windows private-files native addon is unavailable', { cause }), {
|
|
28
|
+
code: 'ERR_PRIVATE_FILES_UNAVAILABLE',
|
|
29
|
+
reason: PRIVATE_FILES_UNAVAILABLE_REASON,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function isUnavailable(api, error) {
|
|
33
|
+
return (error instanceof api.PrivateFilesUnavailableError ||
|
|
34
|
+
error?.code ===
|
|
35
|
+
'ERR_PRIVATE_FILES_UNAVAILABLE');
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Importing @cortexkit/private-files succeeds even when its Windows addon
|
|
39
|
+
* cannot load: the package loads the addon on first use, and some of its
|
|
40
|
+
* checks then report the failure as an ordinary refusal such as
|
|
41
|
+
* `unsupported-filesystem`. So prove the addon loads before handing the
|
|
42
|
+
* package out. Verifying a path that does not exist loads the addon first and
|
|
43
|
+
* then fails harmlessly; only the package's own "unavailable" error means the
|
|
44
|
+
* addon is missing.
|
|
45
|
+
*/
|
|
46
|
+
async function assertNativeAddonLoads(api) {
|
|
47
|
+
try {
|
|
48
|
+
await api.verifyPrivatePath(join(tmpdir(), `.common-auth-private-files-probe-${randomUUID()}`), 'file');
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (isUnavailable(api, error))
|
|
52
|
+
throw privateFilesUnavailable(error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Load the package once per process. On Windows a failed import or addon load
|
|
57
|
+
* is kept, so every later private-file operation refuses at once with
|
|
58
|
+
* ERR_PRIVATE_FILES_UNAVAILABLE. There is no fallback to permission bits:
|
|
59
|
+
* they do not protect files on Windows.
|
|
60
|
+
*/
|
|
23
61
|
function loadBackend() {
|
|
24
|
-
backend ??= import('@cortexkit/private-files')
|
|
62
|
+
backend ??= import('@cortexkit/private-files').then(async (api) => {
|
|
63
|
+
if (windowsPrivateFiles)
|
|
64
|
+
await assertNativeAddonLoads(api);
|
|
65
|
+
return api;
|
|
66
|
+
}, (cause) => {
|
|
67
|
+
throw windowsPrivateFiles ? privateFilesUnavailable(cause) : cause;
|
|
68
|
+
});
|
|
25
69
|
return backend;
|
|
26
70
|
}
|
|
27
71
|
export class PrivateFileError extends Error {
|
|
@@ -38,7 +82,9 @@ export class PrivateFileError extends Error {
|
|
|
38
82
|
orphanPath;
|
|
39
83
|
constructor(path, stage, cause) {
|
|
40
84
|
const detail = cause;
|
|
41
|
-
super(
|
|
85
|
+
super(detail?.code === 'ERR_PRIVATE_FILES_UNAVAILABLE'
|
|
86
|
+
? `Private file refused at ${stage}: ${path} (${PRIVATE_FILES_UNAVAILABLE_REASON}). The Windows native addon of @cortexkit/private-files could not be loaded, so owner-only access cannot be enforced and nothing was read or written. Reinstall the plugin so the private-files Windows prebuild for this architecture is present; Windows has no safe fallback to permission bits.`
|
|
87
|
+
: `Private file refused at ${stage}: ${path} (${detail?.reason ?? detail?.code ?? 'io-error'}). Keep this file in a folder that only you, the system or administrators can change, such as a folder in your home directory. If other users could read the file before, sign in again to replace the credentials it held. On Windows the file must also be on a local drive that keeps Windows file permissions, such as NTFS.`, { cause });
|
|
42
88
|
this.path = path;
|
|
43
89
|
this.stage = stage;
|
|
44
90
|
this.name = 'PrivateFileError';
|
|
@@ -28,6 +28,7 @@ export interface LockOptions {
|
|
|
28
28
|
ttlMs: number;
|
|
29
29
|
timeoutMs: number;
|
|
30
30
|
renew?: boolean;
|
|
31
|
+
signal?: AbortSignal;
|
|
31
32
|
onStep?: Parameters<typeof acquireRefreshFileLock>[0]['onStep'];
|
|
32
33
|
onRenewalTimer?: Parameters<typeof acquireRefreshFileLock>[0]['onRenewalTimer'];
|
|
33
34
|
}
|
|
@@ -21,6 +21,7 @@ export class LockOwnershipError extends Error {
|
|
|
21
21
|
export async function withLock(target, options, fn) {
|
|
22
22
|
const started = performance.now();
|
|
23
23
|
for (;;) {
|
|
24
|
+
options.signal?.throwIfAborted();
|
|
24
25
|
const lock = await acquireRefreshFileLock({
|
|
25
26
|
path: target,
|
|
26
27
|
name: options.name,
|
|
@@ -33,6 +34,7 @@ export async function withLock(target, options, fn) {
|
|
|
33
34
|
});
|
|
34
35
|
if (lock) {
|
|
35
36
|
try {
|
|
37
|
+
options.signal?.throwIfAborted();
|
|
36
38
|
return await fn(lock);
|
|
37
39
|
}
|
|
38
40
|
finally {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type QuotaCredits } from './map.js';
|
|
1
2
|
import type { ProjectedLimit, ProjectedQuota } from './projection.js';
|
|
2
3
|
/** A reading older than this is marked with its age. */
|
|
3
4
|
export declare const QUOTA_STALE_AFTER_MS: number;
|
|
@@ -31,6 +32,13 @@ export interface QuotaWindowPart {
|
|
|
31
32
|
kind: 'window' | 'budget';
|
|
32
33
|
/** The display name of a quota window, or `credits` for the budget. */
|
|
33
34
|
name: string;
|
|
35
|
+
/**
|
|
36
|
+
* The label the narrow line gives this item: an account-wide window keeps
|
|
37
|
+
* its name (`5h`), a model-scoped window reads as its model's short name
|
|
38
|
+
* (`Fa` for Fable, extended until names in the same reading differ), and
|
|
39
|
+
* the budget reads `cr`. For layouts too narrow for the full name.
|
|
40
|
+
*/
|
|
41
|
+
short: string;
|
|
34
42
|
/** The shared figure wording, including `credits` for budget figures. */
|
|
35
43
|
figure: string;
|
|
36
44
|
/** The unrounded remaining percentage, clamped to 0–100, when known. */
|
|
@@ -84,3 +92,14 @@ export declare function quotaTextParts(quota: ProjectedQuota | undefined, option
|
|
|
84
92
|
* provider answered without a limit for any window.
|
|
85
93
|
*/
|
|
86
94
|
export declare function formatQuota(quota: ProjectedQuota | undefined, options: QuotaTextOptions): string;
|
|
95
|
+
/**
|
|
96
|
+
* Prepaid credits as one short value, worded as the official Codex client's
|
|
97
|
+
* status view words them: `Unlimited` for unlimited credits, `<n> credits`
|
|
98
|
+
* for a finite positive balance (rounded to a whole number), `Available` when
|
|
99
|
+
* the account has credits but no usable balance was reported, and undefined
|
|
100
|
+
* when the account has no credits (or `credits` is undefined), so a caller
|
|
101
|
+
* shows no credits row at all. Unlimited wins even when `hasCredits` is false.
|
|
102
|
+
* No other formatter adds this text; a caller that wants a credits row asks
|
|
103
|
+
* for it.
|
|
104
|
+
*/
|
|
105
|
+
export declare function quotaCreditsText(credits: Pick<QuotaCredits, 'hasCredits' | 'unlimited' | 'balance'> | undefined): string | undefined;
|
|
@@ -106,6 +106,25 @@ export function quotaWindowParts(quota, options) {
|
|
|
106
106
|
const parts = windows.map(({ part }) => part);
|
|
107
107
|
return age === undefined ? { windows: parts } : { windows: parts, age };
|
|
108
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* The narrow label of each scoped window part, as `narrowTextParts` names
|
|
111
|
+
* them: model short names are worked out over every model-scoped window in
|
|
112
|
+
* the reading, so two models never share a short name.
|
|
113
|
+
*/
|
|
114
|
+
function withShortNames(windows) {
|
|
115
|
+
const abbreviations = scopeAbbreviations(windows
|
|
116
|
+
.filter(({ part, scope }) => part.kind === 'window' && scope !== ALL_SCOPE)
|
|
117
|
+
.map(({ scope }) => scope));
|
|
118
|
+
for (const window of windows) {
|
|
119
|
+
window.part.short =
|
|
120
|
+
window.part.kind === 'budget'
|
|
121
|
+
? 'cr'
|
|
122
|
+
: window.scope === ALL_SCOPE
|
|
123
|
+
? window.part.name
|
|
124
|
+
: (abbreviations.get(window.scope) ?? window.scope);
|
|
125
|
+
}
|
|
126
|
+
return windows;
|
|
127
|
+
}
|
|
109
128
|
/**
|
|
110
129
|
* `quotaWindowParts` with each window's scope kept beside it. The narrow form
|
|
111
130
|
* needs the scope to name and pick model-scoped windows; the public parts do
|
|
@@ -128,6 +147,7 @@ function scopedWindowParts(quota, options) {
|
|
|
128
147
|
part: {
|
|
129
148
|
kind: 'window',
|
|
130
149
|
name: quotaWindowName(limit),
|
|
150
|
+
short: '',
|
|
131
151
|
figure: percentLeft(limit.remainingPercent ?? 0),
|
|
132
152
|
remainingPercent,
|
|
133
153
|
checkedAt: limit.checkedAt,
|
|
@@ -149,6 +169,7 @@ function scopedWindowParts(quota, options) {
|
|
|
149
169
|
part: {
|
|
150
170
|
kind: 'budget',
|
|
151
171
|
name: 'credits',
|
|
172
|
+
short: '',
|
|
152
173
|
figure,
|
|
153
174
|
...(remainingPercent === undefined
|
|
154
175
|
? {}
|
|
@@ -158,6 +179,7 @@ function scopedWindowParts(quota, options) {
|
|
|
158
179
|
},
|
|
159
180
|
});
|
|
160
181
|
}
|
|
182
|
+
withShortNames(windows);
|
|
161
183
|
if (windows.length > 0) {
|
|
162
184
|
const age = now - Math.min(...windows.map(({ part }) => part.checkedAt));
|
|
163
185
|
if (age > (options.staleAfterMs ?? QUOTA_STALE_AFTER_MS)) {
|
|
@@ -298,20 +320,21 @@ function narrowTextParts(quota, options) {
|
|
|
298
320
|
return grouped;
|
|
299
321
|
const general = windows.filter(({ part, scope }) => part.kind === 'window' && scope === ALL_SCOPE);
|
|
300
322
|
const scoped = windows.filter(({ part, scope }) => part.kind === 'window' && scope !== ALL_SCOPE);
|
|
301
|
-
const abbreviations = scopeAbbreviations(scoped.map(({ scope }) => scope));
|
|
302
323
|
// The items competing for the one slot after the account-wide windows, in
|
|
303
324
|
// tie-break order: scoped windows in display order, then the budget. A
|
|
304
325
|
// spent budget counts as 0% left; one without a percentage cannot compete.
|
|
326
|
+
// Their labels are the parts' short names, so the narrow line and a layout
|
|
327
|
+
// reading `short` always agree.
|
|
305
328
|
const budget = windows.find(({ part }) => part.kind === 'budget');
|
|
306
329
|
const budgetLeft = budget?.spent ? 0 : budget?.part.remainingPercent;
|
|
307
330
|
const extras = [
|
|
308
|
-
...scoped.map(({
|
|
309
|
-
label:
|
|
331
|
+
...scoped.map(({ part }) => ({
|
|
332
|
+
label: part.short,
|
|
310
333
|
remaining: part.remainingPercent ?? 0,
|
|
311
334
|
})),
|
|
312
|
-
...(budgetLeft === undefined
|
|
335
|
+
...(budgetLeft === undefined || budget === undefined
|
|
313
336
|
? []
|
|
314
|
-
: [{ label:
|
|
337
|
+
: [{ label: budget.part.short, remaining: budgetLeft }]),
|
|
315
338
|
];
|
|
316
339
|
// Strictly lower wins, so on a tie the earlier item keeps the slot.
|
|
317
340
|
const tightest = extras.reduce((best, item) => best === undefined || item.remaining < best.remaining ? item : best, undefined);
|
|
@@ -357,3 +380,25 @@ export function formatQuota(quota, options) {
|
|
|
357
380
|
? NO_LIMITS_REPORTED
|
|
358
381
|
: NO_QUOTA_READING;
|
|
359
382
|
}
|
|
383
|
+
/**
|
|
384
|
+
* Prepaid credits as one short value, worded as the official Codex client's
|
|
385
|
+
* status view words them: `Unlimited` for unlimited credits, `<n> credits`
|
|
386
|
+
* for a finite positive balance (rounded to a whole number), `Available` when
|
|
387
|
+
* the account has credits but no usable balance was reported, and undefined
|
|
388
|
+
* when the account has no credits (or `credits` is undefined), so a caller
|
|
389
|
+
* shows no credits row at all. Unlimited wins even when `hasCredits` is false.
|
|
390
|
+
* No other formatter adds this text; a caller that wants a credits row asks
|
|
391
|
+
* for it.
|
|
392
|
+
*/
|
|
393
|
+
export function quotaCreditsText(credits) {
|
|
394
|
+
if (credits === undefined)
|
|
395
|
+
return undefined;
|
|
396
|
+
if (credits.unlimited)
|
|
397
|
+
return 'Unlimited';
|
|
398
|
+
if (!credits.hasCredits)
|
|
399
|
+
return undefined;
|
|
400
|
+
const { balance } = credits;
|
|
401
|
+
return typeof balance === 'number' && Number.isFinite(balance) && balance > 0
|
|
402
|
+
? `${Math.round(balance)} credits`
|
|
403
|
+
: 'Available';
|
|
404
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export type { QuotaCodec } from './codec.js';
|
|
2
2
|
export { quotaCodec } from './codec.js';
|
|
3
3
|
export type { QuotaParts, QuotaTextForm, QuotaTextOptions, QuotaWindowPart, } from './format.js';
|
|
4
|
-
export { formatQuota, isQuotaStale, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, quotaWindowParts, } from './format.js';
|
|
5
|
-
export type { CreditBudgetCleared, CreditBudgetEntry, CreditBudgetReading, QuotaAbsentEntry, QuotaCounter, QuotaEntry, QuotaMap, QuotaReadingEntry, QuotaRetiredEntry, } from './map.js';
|
|
6
|
-
export { ALL_SCOPE, DEFAULT_REQUIRED_LABELS, decodeQuotaMap, emptyQuotaMap, isQuotaMap, } from './map.js';
|
|
4
|
+
export { formatQuota, isQuotaStale, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaCreditsText, quotaTextParts, quotaWindowName, quotaWindowParts, } from './format.js';
|
|
5
|
+
export type { CreditBudgetCleared, CreditBudgetEntry, CreditBudgetReading, QuotaAbsentEntry, QuotaCounter, QuotaCredits, QuotaEntry, QuotaMap, QuotaReadingEntry, QuotaRetiredEntry, } from './map.js';
|
|
6
|
+
export { ALL_SCOPE, DEFAULT_REQUIRED_LABELS, decodeQuotaMap, emptyQuotaMap, isQuotaCredits, isQuotaMap, } from './map.js';
|
|
7
7
|
export type { ObservedBudget, ObservedPair, ObservedReading, QuotaObservation, } from './merge.js';
|
|
8
8
|
export { isQuotaObservation, mergeQuotaObservation, QuotaCodecError, } from './merge.js';
|
|
9
9
|
export type { ExhaustionReset, ProjectedBudget, ProjectedLimit, ProjectedQuota, QuotaGroup, } from './projection.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { quotaCodec } from './codec.js';
|
|
2
|
-
export { formatQuota, isQuotaStale, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, quotaWindowParts, } from './format.js';
|
|
3
|
-
export { ALL_SCOPE, DEFAULT_REQUIRED_LABELS, decodeQuotaMap, emptyQuotaMap, isQuotaMap, } from './map.js';
|
|
2
|
+
export { formatQuota, isQuotaStale, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaCreditsText, quotaTextParts, quotaWindowName, quotaWindowParts, } from './format.js';
|
|
3
|
+
export { ALL_SCOPE, DEFAULT_REQUIRED_LABELS, decodeQuotaMap, emptyQuotaMap, isQuotaCredits, isQuotaMap, } from './map.js';
|
|
4
4
|
export { isQuotaObservation, mergeQuotaObservation, QuotaCodecError, } from './merge.js';
|
|
5
5
|
export { budgetExhaustedResetAt, futureResetAt, projectQuota, readsExhausted, } from './projection.js';
|
|
@@ -48,12 +48,30 @@ export interface QuotaCounter {
|
|
|
48
48
|
value: number;
|
|
49
49
|
checkedAt: number;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Prepaid credits a provider account holds beside its rate-limit windows (for
|
|
53
|
+
* example a ChatGPT workspace's credits, which keep requests working after a
|
|
54
|
+
* window is used up). This is a state, not a count or the spend budget: the
|
|
55
|
+
* account has credits or not, they may be unlimited, and the provider may
|
|
56
|
+
* report a balance. It carries its own observation time.
|
|
57
|
+
*/
|
|
58
|
+
export interface QuotaCredits {
|
|
59
|
+
hasCredits: boolean;
|
|
60
|
+
unlimited: boolean;
|
|
61
|
+
/** The balance the provider reported, when it reported one. */
|
|
62
|
+
balance?: number;
|
|
63
|
+
/** The provider's verdict that the overage allowance is used up. */
|
|
64
|
+
overageLimitReached?: boolean;
|
|
65
|
+
checkedAt: number;
|
|
66
|
+
}
|
|
51
67
|
export interface QuotaMap {
|
|
52
68
|
limits: QuotaEntry[];
|
|
53
69
|
/** Absent means no observation has reported on the budget yet. */
|
|
54
70
|
budget?: CreditBudgetEntry;
|
|
55
71
|
/** Provider-named counts, each with its own observation time. */
|
|
56
72
|
counters?: Record<string, QuotaCounter>;
|
|
73
|
+
/** Absent means no observation has reported on prepaid credits yet. */
|
|
74
|
+
credits?: QuotaCredits;
|
|
57
75
|
}
|
|
58
76
|
export declare function emptyQuotaMap(): QuotaMap;
|
|
59
77
|
/** The time an entry speaks for: its reading, retirement or absence time. */
|
|
@@ -61,9 +79,14 @@ export declare function entryTime(entry: QuotaEntry): number;
|
|
|
61
79
|
export declare function limitKey(scope: string, label: string): string;
|
|
62
80
|
export declare function isCreditBudgetEntry(value: unknown): value is CreditBudgetEntry;
|
|
63
81
|
export declare function isQuotaCounters(value: unknown): value is Record<string, QuotaCounter>;
|
|
82
|
+
/** True when `value` is a well-formed prepaid-credits record. */
|
|
83
|
+
export declare function isQuotaCredits(value: unknown): value is QuotaCredits;
|
|
64
84
|
/** Keep usable counters without letting one malformed count hide a quota map. */
|
|
65
85
|
export declare function readQuotaCounters(value: unknown): Record<string, QuotaCounter> | undefined;
|
|
66
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* Decode persisted quota, dropping malformed counters or a malformed credits
|
|
88
|
+
* record but not limit or budget evidence.
|
|
89
|
+
*/
|
|
67
90
|
export declare function decodeQuotaMap(value: unknown): QuotaMap | undefined;
|
|
68
91
|
/**
|
|
69
92
|
* True when `value` is a well-formed quota map. Unrecognised top-level keys
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
// reset clock. It is a tri-state: absent (never reported), a reading, or
|
|
15
15
|
// cleared (the provider reported that no budget exists).
|
|
16
16
|
//
|
|
17
|
+
// Provider-named counters and the prepaid credits record sit beside them too,
|
|
18
|
+
// each judged by its own observation time rather than the limits'.
|
|
19
|
+
//
|
|
17
20
|
// The map is plain JSON so the store can persist it without interpreting it.
|
|
18
21
|
/** The scope of a limit that binds every request regardless of model. */
|
|
19
22
|
export const ALL_SCOPE = 'all';
|
|
@@ -93,6 +96,18 @@ function isQuotaCounter(value) {
|
|
|
93
96
|
export function isQuotaCounters(value) {
|
|
94
97
|
return isRecord(value) && Object.values(value).every(isQuotaCounter);
|
|
95
98
|
}
|
|
99
|
+
function isBoolean(value) {
|
|
100
|
+
return typeof value === 'boolean';
|
|
101
|
+
}
|
|
102
|
+
/** True when `value` is a well-formed prepaid-credits record. */
|
|
103
|
+
export function isQuotaCredits(value) {
|
|
104
|
+
return (isRecord(value) &&
|
|
105
|
+
typeof value.hasCredits === 'boolean' &&
|
|
106
|
+
typeof value.unlimited === 'boolean' &&
|
|
107
|
+
optional(value, 'balance', isFiniteNumber) &&
|
|
108
|
+
optional(value, 'overageLimitReached', isBoolean) &&
|
|
109
|
+
isFiniteNumber(value.checkedAt));
|
|
110
|
+
}
|
|
96
111
|
/** Keep usable counters without letting one malformed count hide a quota map. */
|
|
97
112
|
export function readQuotaCounters(value) {
|
|
98
113
|
if (!isRecord(value))
|
|
@@ -102,20 +117,29 @@ export function readQuotaCounters(value) {
|
|
|
102
117
|
? undefined
|
|
103
118
|
: Object.fromEntries(entries);
|
|
104
119
|
}
|
|
105
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Decode persisted quota, dropping malformed counters or a malformed credits
|
|
122
|
+
* record but not limit or budget evidence.
|
|
123
|
+
*/
|
|
106
124
|
export function decodeQuotaMap(value) {
|
|
107
125
|
// A valid map is returned as the same object, so stored quota keeps the
|
|
108
|
-
// identity it had before counters existed
|
|
109
|
-
// counter is copied
|
|
126
|
+
// identity it had before counters and credits existed. A map with a
|
|
127
|
+
// malformed counter or credits record is copied with those malformed
|
|
128
|
+
// fields left out.
|
|
110
129
|
if (isQuotaMap(value))
|
|
111
130
|
return value;
|
|
112
131
|
if (!isRecord(value))
|
|
113
132
|
return undefined;
|
|
114
|
-
const { counters: rawCounters, ...rest } = value;
|
|
133
|
+
const { counters: rawCounters, credits: rawCredits, ...rest } = value;
|
|
115
134
|
if (!isQuotaMap(rest))
|
|
116
135
|
return undefined;
|
|
117
136
|
const counters = readQuotaCounters(rawCounters);
|
|
118
|
-
|
|
137
|
+
const credits = isQuotaCredits(rawCredits) ? rawCredits : undefined;
|
|
138
|
+
return {
|
|
139
|
+
...rest,
|
|
140
|
+
...(counters === undefined ? {} : { counters }),
|
|
141
|
+
...(credits === undefined ? {} : { credits }),
|
|
142
|
+
};
|
|
119
143
|
}
|
|
120
144
|
/**
|
|
121
145
|
* True when `value` is a well-formed quota map. Unrecognised top-level keys
|
|
@@ -136,5 +160,6 @@ export function isQuotaMap(value) {
|
|
|
136
160
|
seen.add(key);
|
|
137
161
|
}
|
|
138
162
|
return (optional(value, 'budget', isCreditBudgetEntry) &&
|
|
139
|
-
optional(value, 'counters', isQuotaCounters)
|
|
163
|
+
optional(value, 'counters', isQuotaCounters) &&
|
|
164
|
+
optional(value, 'credits', isQuotaCredits));
|
|
140
165
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type QuotaCounter, type QuotaMap } from './map.js';
|
|
1
|
+
import { type QuotaCounter, type QuotaCredits, type QuotaMap } from './map.js';
|
|
2
2
|
export interface ObservedReading {
|
|
3
3
|
/** Defaults to `all`. */
|
|
4
4
|
scope?: string;
|
|
@@ -34,6 +34,11 @@ export interface QuotaObservation {
|
|
|
34
34
|
budget?: ObservedBudget;
|
|
35
35
|
/** Each named count uses its own time, independently of limits and budget. */
|
|
36
36
|
counters?: Record<string, QuotaCounter>;
|
|
37
|
+
/**
|
|
38
|
+
* Prepaid credits, judged by their own time independently of limits, budget
|
|
39
|
+
* and counters. Absent means the observation says nothing about credits.
|
|
40
|
+
*/
|
|
41
|
+
credits?: QuotaCredits;
|
|
37
42
|
}
|
|
38
43
|
/** Thrown by `mergeQuotaObservation` when either argument is malformed. */
|
|
39
44
|
export declare class QuotaCodecError extends TypeError {
|