@cortexkit/antigravity-auth-core 3.0.6 → 3.0.7
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/account-migration.d.ts +1 -1
- package/dist/account-migration.d.ts.map +1 -1
- package/dist/account-migration.js +228 -53
- package/dist/account-migration.js.map +1 -1
- package/dist/agy-transport.d.ts.map +1 -1
- package/dist/agy-transport.js +2 -0
- package/dist/agy-transport.js.map +1 -1
- package/dist/common-auth-embedded/claustrum/consumer.d.ts +11 -1
- package/dist/common-auth-embedded/claustrum/consumer.js +38 -1
- package/dist/common-auth-embedded/claustrum/custody.d.ts +29 -0
- package/dist/common-auth-embedded/claustrum/custody.js +17 -0
- package/dist/common-auth-embedded/claustrum/index.d.ts +1 -1
- package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +8 -2
- package/dist/common-auth-embedded/fs/refresh-file-lock.js +6 -1
- package/dist/common-auth-embedded/pi-slot/lock.js +7 -3
- package/dist/common-auth-embedded/quota/format.d.ts +14 -3
- package/dist/common-auth-embedded/quota/format.js +135 -18
- package/dist/common-auth-embedded/quota/index.d.ts +1 -1
- package/dist/common-auth-embedded/quota/index.js +1 -1
- package/dist/common-auth-embedded/source-output.json +54 -54
- package/dist/common-auth-embedded/store/pool.d.ts +1 -1
- package/dist/common-auth-embedded/store/pool.js +0 -1
- package/dist/common-auth-embedded/store/refresh-lock.d.ts +0 -1
- package/dist/common-auth-embedded/store/refresh-lock.js +0 -1
- package/dist/credential-redaction.d.ts +8 -4
- package/dist/credential-redaction.d.ts.map +1 -1
- package/dist/credential-redaction.js +55 -28
- package/dist/credential-redaction.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/pre-response-deadline.d.ts +41 -0
- package/dist/pre-response-deadline.d.ts.map +1 -0
- package/dist/pre-response-deadline.js +150 -0
- package/dist/pre-response-deadline.js.map +1 -0
- package/dist/vault-account-source.d.ts +21 -1
- package/dist/vault-account-source.d.ts.map +1 -1
- package/dist/vault-account-source.js +5 -0
- package/dist/vault-account-source.js.map +1 -1
- package/dist/vault-folder-refusal.js +1 -1
- package/dist/vault-grant-reach.d.ts +23 -0
- package/dist/vault-grant-reach.d.ts.map +1 -0
- package/dist/vault-grant-reach.js +15 -0
- package/dist/vault-grant-reach.js.map +1 -0
- package/package.json +2 -2
|
@@ -21,6 +21,11 @@ export class ClaustrumConsumer {
|
|
|
21
21
|
#connecting;
|
|
22
22
|
#refreshing;
|
|
23
23
|
#roster;
|
|
24
|
+
/**
|
|
25
|
+
* The grants named by this instance's latest discovery, with the view that
|
|
26
|
+
* discovery committed. Memory only: the roster file never carries it.
|
|
27
|
+
*/
|
|
28
|
+
#grantReach;
|
|
24
29
|
#timer;
|
|
25
30
|
#started = false;
|
|
26
31
|
#insula;
|
|
@@ -102,6 +107,21 @@ export class ClaustrumConsumer {
|
|
|
102
107
|
snapshot() {
|
|
103
108
|
return this.#roster;
|
|
104
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* What this consumer is granted, from its latest discovery, without any I/O.
|
|
112
|
+
* It explains an empty roster: `grants === 0` means no grant at all, while
|
|
113
|
+
* `grants > 0` means a grant that reaches no credential (`tuples` names its
|
|
114
|
+
* selector). Undefined means unknown, never zero: the vault or client is too
|
|
115
|
+
* old to send it, nothing was discovered yet, or the roster now in memory is
|
|
116
|
+
* a different view than the one this instance discovered (a peer process
|
|
117
|
+
* held the discovery lease and its newer roster was served instead).
|
|
118
|
+
*/
|
|
119
|
+
grantReach() {
|
|
120
|
+
const latest = this.#grantReach;
|
|
121
|
+
return latest && this.#roster?.view === latest.view
|
|
122
|
+
? latest.reach
|
|
123
|
+
: undefined;
|
|
124
|
+
}
|
|
105
125
|
/** Rows for `/routing`, from the last committed roster. */
|
|
106
126
|
routingRows() {
|
|
107
127
|
return vaultRoutingRows(this.#roster);
|
|
@@ -113,6 +133,7 @@ export class ClaustrumConsumer {
|
|
|
113
133
|
this.#refreshing = (async () => {
|
|
114
134
|
if (!(await this.#active())) {
|
|
115
135
|
this.#roster = undefined;
|
|
136
|
+
this.#grantReach = undefined;
|
|
116
137
|
this.#custody?.close();
|
|
117
138
|
this.#custody = undefined;
|
|
118
139
|
this.#insula?.release();
|
|
@@ -120,9 +141,17 @@ export class ClaustrumConsumer {
|
|
|
120
141
|
return undefined;
|
|
121
142
|
}
|
|
122
143
|
const custody = await this.#getCustody();
|
|
144
|
+
// Capture the inventory so its grant fields can be kept beside the roster;
|
|
145
|
+
// the roster file itself never stores them.
|
|
146
|
+
let discovered;
|
|
123
147
|
const roster = await refreshVaultRoster({
|
|
124
148
|
path: this.#options.rosterPath,
|
|
125
|
-
custody
|
|
149
|
+
custody: {
|
|
150
|
+
discover: async (signal) => {
|
|
151
|
+
discovered = await custody.discover(signal);
|
|
152
|
+
return discovered;
|
|
153
|
+
},
|
|
154
|
+
},
|
|
126
155
|
isActive: () => this.#active(),
|
|
127
156
|
signal: this.#shutdown.signal,
|
|
128
157
|
projection: () => ({
|
|
@@ -135,6 +164,13 @@ export class ClaustrumConsumer {
|
|
|
135
164
|
this.#assertOpen();
|
|
136
165
|
const changed = roster?.view !== this.#roster?.view;
|
|
137
166
|
this.#roster = roster;
|
|
167
|
+
// Each discovery replaces the previous value, including with unknown. A
|
|
168
|
+
// refresh that served a peer's roster discovered nothing and leaves it;
|
|
169
|
+
// grantReach() hides it once the roster's view no longer matches.
|
|
170
|
+
if (discovered && roster?.view !== undefined)
|
|
171
|
+
this.#grantReach = discovered.grantReach
|
|
172
|
+
? { view: roster.view, reach: discovered.grantReach }
|
|
173
|
+
: undefined;
|
|
138
174
|
// Notify on view changes only: subscribers rewrite shared state on each
|
|
139
175
|
// notification, and the poll runs every few seconds.
|
|
140
176
|
if (roster && changed)
|
|
@@ -389,5 +425,6 @@ export class ClaustrumConsumer {
|
|
|
389
425
|
this.#insula?.release();
|
|
390
426
|
this.#insula = undefined;
|
|
391
427
|
this.#roster = undefined;
|
|
428
|
+
this.#grantReach = undefined;
|
|
392
429
|
}
|
|
393
430
|
}
|
|
@@ -44,6 +44,30 @@ export interface SkippedVaultRecord {
|
|
|
44
44
|
readonly credentialId?: string;
|
|
45
45
|
readonly reason: SkippedVaultReason;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* One grant this consumer's enrollment holds, as the vault's list reply names
|
|
49
|
+
* it: for example `{ selectorKind: 'category', selector: 'openai-native',
|
|
50
|
+
* operation: 'read' }`. `selectorKind` is `exact` (the selector is a credential
|
|
51
|
+
* id) or `category` (a bare category name); both open strings.
|
|
52
|
+
*/
|
|
53
|
+
export interface VaultGrantTuple {
|
|
54
|
+
readonly selectorKind: string;
|
|
55
|
+
readonly selector: string;
|
|
56
|
+
readonly operation: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* What this consumer is granted, independent of what those grants currently
|
|
60
|
+
* reach. It is what tells two empty listings apart: no rows with `grants === 0`
|
|
61
|
+
* means no grant at all (the fix is `ck auth grant`), while no rows with
|
|
62
|
+
* `grants > 0` means a grant that reaches no credential, usually logins that
|
|
63
|
+
* lack the category (`ck auth reclassify --from-registry`); `tuples` names the
|
|
64
|
+
* selector that matched nothing. Never written to the roster file.
|
|
65
|
+
*/
|
|
66
|
+
export interface VaultGrantReach {
|
|
67
|
+
/** How many grants the consumer holds, deposit grants excluded. */
|
|
68
|
+
readonly grants: number;
|
|
69
|
+
readonly tuples: readonly VaultGrantTuple[];
|
|
70
|
+
}
|
|
47
71
|
export interface VaultInventory {
|
|
48
72
|
/**
|
|
49
73
|
* The vault's change cursor: a digest over exactly what this consumer can see
|
|
@@ -53,6 +77,11 @@ export interface VaultInventory {
|
|
|
53
77
|
readonly view: string;
|
|
54
78
|
readonly credentials: readonly VaultCredential[];
|
|
55
79
|
readonly skipped: readonly SkippedVaultRecord[];
|
|
80
|
+
/**
|
|
81
|
+
* The consumer's grants, as the vault's list reply named them. Undefined means unknown
|
|
82
|
+
* (the vault or client predates the fields), never zero grants.
|
|
83
|
+
*/
|
|
84
|
+
readonly grantReach?: VaultGrantReach;
|
|
56
85
|
}
|
|
57
86
|
export interface ClaustrumScopedIdentity {
|
|
58
87
|
readonly credentialId: string;
|
|
@@ -83,6 +83,21 @@ function credentialTypeOf(row, family) {
|
|
|
83
83
|
return row.refreshAdapter === undefined ? 'api_key' : undefined;
|
|
84
84
|
return undefined;
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* The grants named in the vault's list reply, or undefined when it names none.
|
|
88
|
+
* Both fields must be present: a reply carrying only one of them is treated as
|
|
89
|
+
* unknown rather than completed with a guess. claustrum-client already refuses
|
|
90
|
+
* a malformed value, so nothing here validates it again.
|
|
91
|
+
*/
|
|
92
|
+
function grantReachOf(listed) {
|
|
93
|
+
const { grants, grantTuples } = listed;
|
|
94
|
+
if (grants === undefined || grantTuples === undefined)
|
|
95
|
+
return undefined;
|
|
96
|
+
return Object.freeze({
|
|
97
|
+
grants,
|
|
98
|
+
tuples: Object.freeze(grantTuples.map(({ selectorKind, selector, operation }) => Object.freeze({ selectorKind, selector, operation }))),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
86
101
|
/**
|
|
87
102
|
* Reads this consumer's vault credentials (list, fetch, 401 report), with the
|
|
88
103
|
* enrollment token as authorization. Used by every host of a plugin. There is
|
|
@@ -217,10 +232,12 @@ export class ClaustrumScopedCustody {
|
|
|
217
232
|
}
|
|
218
233
|
for (const record of skipped)
|
|
219
234
|
this.#logger.warn('skipped malformed vault record', record);
|
|
235
|
+
const grantReach = grantReachOf(inventory);
|
|
220
236
|
return {
|
|
221
237
|
view: inventory.view,
|
|
222
238
|
credentials: Object.freeze(credentials),
|
|
223
239
|
skipped: Object.freeze(skipped),
|
|
240
|
+
...(grantReach && { grantReach }),
|
|
224
241
|
};
|
|
225
242
|
}
|
|
226
243
|
/**
|
|
@@ -2,7 +2,7 @@ import { type ClaustrumClientOptions } from '@cortexkit/claustrum-client';
|
|
|
2
2
|
import type { ClaustrumScopedClient } from './custody.js';
|
|
3
3
|
export { ClaustrumCredentialError, type ClaustrumReporterSource, type EnrollmentTokenFile, } from '@cortexkit/claustrum-client';
|
|
4
4
|
export { ClaustrumConsumer, type ClaustrumConsumerOptions, type SendOptions, } from './consumer.js';
|
|
5
|
-
export { type AccountIdentitySource, type ClaustrumFamily, type ClaustrumScopedAttempt, type ClaustrumScopedClient, ClaustrumScopedCustody, type ClaustrumScopedIdentity, decideScopedRetryAfter401, type IdentityParser, isScopedCredentialRotation, type ScopedRetryReason, SERVING_MARGIN_MS, type SkippedVaultReason, type SkippedVaultRecord, type VaultCredential, type VaultCredentialType, type VaultInventory, } from './custody.js';
|
|
5
|
+
export { type AccountIdentitySource, type ClaustrumFamily, type ClaustrumScopedAttempt, type ClaustrumScopedClient, ClaustrumScopedCustody, type ClaustrumScopedIdentity, decideScopedRetryAfter401, type IdentityParser, isScopedCredentialRotation, type ScopedRetryReason, SERVING_MARGIN_MS, type SkippedVaultReason, type SkippedVaultRecord, type VaultCredential, type VaultCredentialType, type VaultGrantReach, type VaultGrantTuple, type VaultInventory, } from './custody.js';
|
|
6
6
|
export { type ClaustrumEnrollmentClient, type ClaustrumEnrollmentConnection, type ClaustrumEnrollmentDisconnectResult, ClaustrumEnrollmentManager, type ClaustrumEnrollmentPaths, type ClaustrumEnrollmentResetResult, type ClaustrumEnrollmentStatus, classifyEnrollmentError, connectClaustrumEnrollmentClient, disconnectClaustrumEnrollment, type EnrollmentDisposition, enrollmentAuthority, enrollmentName, getClaustrumEnrollmentPaths, hostEnrollmentPaths, RETRYABLE_ENROLLMENT_CODES, readClaustrumEnrollmentStatus, readClaustrumEnrollmentToken, resetClaustrumEnrollmentState, TERMINAL_ENROLLMENT_CODES, } from './enrollment.js';
|
|
7
7
|
export { ClaustrumConsumerError, type ClaustrumConsumerFailureKind, type ClaustrumLogger, } from './errors.js';
|
|
8
8
|
export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, type HostSlotContent, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
|
|
@@ -13,7 +13,7 @@ export interface RefreshFileLock {
|
|
|
13
13
|
whenLost(): Promise<LockLoss>;
|
|
14
14
|
hasLost(): boolean;
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
type RefreshFileLockOptions = {
|
|
17
17
|
name: string;
|
|
18
18
|
ttlMs: number;
|
|
19
19
|
/**
|
|
@@ -22,7 +22,6 @@ export declare function acquireRefreshFileLock(options: {
|
|
|
22
22
|
* be one host's path.
|
|
23
23
|
*/
|
|
24
24
|
path: string;
|
|
25
|
-
now?: () => number;
|
|
26
25
|
renew?: boolean;
|
|
27
26
|
renewIntervalMs?: number;
|
|
28
27
|
/** Notifies a live-owner refusal without awaiting; observer failures are ignored. */
|
|
@@ -30,4 +29,11 @@ export declare function acquireRefreshFileLock(options: {
|
|
|
30
29
|
/** Synchronous, never awaited; throws and returned promise/thenable rejections are isolated. */
|
|
31
30
|
onRenewalTimer?: (event: 'scheduled' | 'cancelled') => void;
|
|
32
31
|
onStep?: (step: 'stale-marker-stat' | 'stale-marker-claimed' | 'stale-lock-observed' | 'lock-read-ready' | 'lock-missing' | 'stale-lock-confirmed' | 'stale-lock-removed' | 'stale-lock-recreated' | 'eviction-marker-acquired' | 'renewal-owner-confirmed' | 'renewal-marker-unavailable' | 'renewal-write-fenced' | 'renewal-write-ready' | 'renewal-write-finished' | 'relinquish-read' | 'renewal-finished' | 'release-owner-confirmed') => void | Promise<void>;
|
|
32
|
+
};
|
|
33
|
+
/** Filesystem leases share wall time across all callers and processes. */
|
|
34
|
+
export declare function acquireRefreshFileLock(options: RefreshFileLockOptions): Promise<RefreshFileLock | null>;
|
|
35
|
+
/** @internal Clock seam for lease tests; not exported by the package's fs entry point. */
|
|
36
|
+
export declare function acquireRefreshFileLockWithClock(options: RefreshFileLockOptions & {
|
|
37
|
+
now?: () => number;
|
|
33
38
|
}): Promise<RefreshFileLock | null>;
|
|
39
|
+
export {};
|
|
@@ -31,7 +31,12 @@ export function isLostMarkerRaceError(error) {
|
|
|
31
31
|
return (code === 'ENOENT' ||
|
|
32
32
|
(!windowsPrivateFiles && (code === 'EINVAL' || code === 'ENOTDIR')));
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
/** Filesystem leases share wall time across all callers and processes. */
|
|
35
|
+
export function acquireRefreshFileLock(options) {
|
|
36
|
+
return acquireRefreshFileLockWithClock({ ...options, now: Date.now });
|
|
37
|
+
}
|
|
38
|
+
/** @internal Clock seam for lease tests; not exported by the package's fs entry point. */
|
|
39
|
+
export async function acquireRefreshFileLockWithClock(options) {
|
|
35
40
|
const lockPath = lockPathFor(options.path, options.name);
|
|
36
41
|
const legacyOwnerPath = join(lockPath, 'owner.json');
|
|
37
42
|
const ownerId = randomUUID();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { rmdirSync, statSync } from 'node:fs';
|
|
1
|
+
import { rmdirSync, statSync, utimesSync } from 'node:fs';
|
|
2
2
|
import { mkdir, rmdir, stat, utimes } from 'node:fs/promises';
|
|
3
3
|
// Protocol source: proper-lockfile 4.1.2 lib/lockfile.js and lib/mtime-precision.js;
|
|
4
4
|
// line-level citations and the Pi lock options are recorded in docs/sources.md.
|
|
@@ -135,7 +135,9 @@ export async function acquirePiLock(authPath, onCompromised, onRenew) {
|
|
|
135
135
|
return;
|
|
136
136
|
let observed;
|
|
137
137
|
try {
|
|
138
|
-
|
|
138
|
+
// Durable writes can occupy every async filesystem worker in fsync.
|
|
139
|
+
// Renew on the calling thread so their queue cannot consume the lease.
|
|
140
|
+
observed = statSync(path);
|
|
139
141
|
}
|
|
140
142
|
catch (error) {
|
|
141
143
|
retry(error);
|
|
@@ -151,12 +153,14 @@ export async function acquirePiLock(authPath, onCompromised, onRenew) {
|
|
|
151
153
|
}
|
|
152
154
|
const next = new Date(precision === 's' ? Math.ceil(Date.now() / 1_000) * 1_000 : Date.now());
|
|
153
155
|
try {
|
|
154
|
-
|
|
156
|
+
utimesSync(path, next, next);
|
|
155
157
|
}
|
|
156
158
|
catch (error) {
|
|
157
159
|
retry(error);
|
|
158
160
|
return;
|
|
159
161
|
}
|
|
162
|
+
// A metadata syscall can itself stall; never confirm an expired lease.
|
|
163
|
+
assertFresh();
|
|
160
164
|
if (released)
|
|
161
165
|
return;
|
|
162
166
|
mtime = next.getTime();
|
|
@@ -11,8 +11,11 @@ export declare const NO_LIMITS_REPORTED = "no limits reported";
|
|
|
11
11
|
/**
|
|
12
12
|
* `compact` fits a footer or a sidebar row: each window's figure, the credit
|
|
13
13
|
* budget and the stale marker, without reset times. `full` adds each reset.
|
|
14
|
+
* `narrow` fits one line per account in a narrow sidebar: `5h 92% · 7d 66%
|
|
15
|
+
* · Fa 98% left`, the account-wide windows plus at most one model-scoped
|
|
16
|
+
* window or the budget (as `cr`), and no stale marker (see `isQuotaStale`).
|
|
14
17
|
*/
|
|
15
|
-
export type QuotaTextForm = 'compact' | 'full';
|
|
18
|
+
export type QuotaTextForm = 'compact' | 'full' | 'narrow';
|
|
16
19
|
export interface QuotaTextOptions {
|
|
17
20
|
/** The current time in ms, for reset countdowns and the stale marker. */
|
|
18
21
|
now: number;
|
|
@@ -55,17 +58,25 @@ export declare function quotaWindowName(limit: Pick<ProjectedLimit, 'scope' | 'l
|
|
|
55
58
|
* and the optional age keep the shared formatter's wording.
|
|
56
59
|
*/
|
|
57
60
|
export declare function quotaWindowParts(quota: ProjectedQuota | undefined, options: QuotaTextOptions): QuotaParts;
|
|
61
|
+
/**
|
|
62
|
+
* Whether the reading is stale: true exactly when the compact and full forms
|
|
63
|
+
* would end with `checked … ago`. The narrow form leaves that age out to stay
|
|
64
|
+
* short, so a caller drawing a narrow line uses this to dim it instead.
|
|
65
|
+
*/
|
|
66
|
+
export declare function isQuotaStale(quota: ProjectedQuota | undefined, options: QuotaTextOptions): boolean;
|
|
58
67
|
/**
|
|
59
68
|
* The pieces of a quota text, in order: one per reported window
|
|
60
69
|
* (`5h 58% left, resets 2h`), then the credit budget when there is one, then
|
|
61
70
|
* `checked 3h ago` when the oldest piece shown is stale. Empty when nothing
|
|
62
71
|
* was reported; `formatQuota` turns that into a sentence. A caller printing
|
|
63
|
-
* one line per window (the terminal auth menu) uses these directly.
|
|
72
|
+
* one line per window (the terminal auth menu) uses these directly. In the
|
|
73
|
+
* narrow form the pieces are the narrow line's (`5h 92%`, `7d 66% left`).
|
|
64
74
|
*/
|
|
65
75
|
export declare function quotaTextParts(quota: ProjectedQuota | undefined, options: QuotaTextOptions): string[];
|
|
66
76
|
/**
|
|
67
77
|
* A row's quota as one line: `5h 58% left · 7d 90% left` in the compact
|
|
68
|
-
* form, with `, resets 2h` after each window in the full form
|
|
78
|
+
* form, with `, resets 2h` after each window in the full form, and
|
|
79
|
+
* `5h 58% · 7d 90% left` in the narrow form. With nothing
|
|
69
80
|
* reported it is `NO_QUOTA_READING`, or `NO_LIMITS_REPORTED` when the
|
|
70
81
|
* provider answered without a limit for any window.
|
|
71
82
|
*/
|
|
@@ -63,8 +63,12 @@ export function quotaWindowName(limit) {
|
|
|
63
63
|
function clampPercent(remaining) {
|
|
64
64
|
return Math.min(100, Math.max(0, remaining));
|
|
65
65
|
}
|
|
66
|
+
/** A remaining percentage as the whole number every form shows. */
|
|
67
|
+
function wholePercent(remaining) {
|
|
68
|
+
return Math.round(clampPercent(remaining));
|
|
69
|
+
}
|
|
66
70
|
function percentLeft(remaining) {
|
|
67
|
-
return `${
|
|
71
|
+
return `${wholePercent(remaining)}% left`;
|
|
68
72
|
}
|
|
69
73
|
function resetDetails(resetsAt, checkedAt, now) {
|
|
70
74
|
const at = resetsAt === undefined ? Number.NaN : Date.parse(resetsAt);
|
|
@@ -98,6 +102,16 @@ function displayOrder(left, right) {
|
|
|
98
102
|
* and the optional age keep the shared formatter's wording.
|
|
99
103
|
*/
|
|
100
104
|
export function quotaWindowParts(quota, options) {
|
|
105
|
+
const { windows, age } = scopedWindowParts(quota, options);
|
|
106
|
+
const parts = windows.map(({ part }) => part);
|
|
107
|
+
return age === undefined ? { windows: parts } : { windows: parts, age };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* `quotaWindowParts` with each window's scope kept beside it. The narrow form
|
|
111
|
+
* needs the scope to name and pick model-scoped windows; the public parts do
|
|
112
|
+
* not carry it, so their shape stays as it was.
|
|
113
|
+
*/
|
|
114
|
+
function scopedWindowParts(quota, options) {
|
|
101
115
|
if (!quota)
|
|
102
116
|
return { windows: [] };
|
|
103
117
|
const { now } = options;
|
|
@@ -107,12 +121,15 @@ export function quotaWindowParts(quota, options) {
|
|
|
107
121
|
const windows = readings.map((limit) => {
|
|
108
122
|
const remainingPercent = clampPercent(limit.remainingPercent ?? 0);
|
|
109
123
|
return {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
124
|
+
scope: limit.scope,
|
|
125
|
+
part: {
|
|
126
|
+
kind: 'window',
|
|
127
|
+
name: quotaWindowName(limit),
|
|
128
|
+
figure: percentLeft(limit.remainingPercent ?? 0),
|
|
129
|
+
remainingPercent,
|
|
130
|
+
checkedAt: limit.checkedAt,
|
|
131
|
+
...resetDetails(limit.resetsAt, limit.checkedAt, now),
|
|
132
|
+
},
|
|
116
133
|
};
|
|
117
134
|
});
|
|
118
135
|
const budget = quota.budget;
|
|
@@ -124,32 +141,131 @@ export function quotaWindowParts(quota, options) {
|
|
|
124
141
|
? `credits ${percentLeft(remainingPercent)}`
|
|
125
142
|
: 'credits available';
|
|
126
143
|
windows.push({
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
144
|
+
scope: ALL_SCOPE,
|
|
145
|
+
spent: budget.reached,
|
|
146
|
+
part: {
|
|
147
|
+
kind: 'budget',
|
|
148
|
+
name: 'credits',
|
|
149
|
+
figure,
|
|
150
|
+
...(remainingPercent === undefined
|
|
151
|
+
? {}
|
|
152
|
+
: { remainingPercent: clampPercent(remainingPercent) }),
|
|
153
|
+
checkedAt: budget.checkedAt,
|
|
154
|
+
...resetDetails(budget.resetsAt, budget.checkedAt, now),
|
|
155
|
+
},
|
|
135
156
|
});
|
|
136
157
|
}
|
|
137
158
|
if (windows.length > 0) {
|
|
138
|
-
const age = now - Math.min(...windows.map((part) => part.checkedAt));
|
|
159
|
+
const age = now - Math.min(...windows.map(({ part }) => part.checkedAt));
|
|
139
160
|
if (age > (options.staleAfterMs ?? QUOTA_STALE_AFTER_MS)) {
|
|
140
161
|
return { windows, age: `checked ${shortSpan(age)} ago` };
|
|
141
162
|
}
|
|
142
163
|
}
|
|
143
164
|
return { windows };
|
|
144
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Whether the reading is stale: true exactly when the compact and full forms
|
|
168
|
+
* would end with `checked … ago`. The narrow form leaves that age out to stay
|
|
169
|
+
* short, so a caller drawing a narrow line uses this to dim it instead.
|
|
170
|
+
*/
|
|
171
|
+
export function isQuotaStale(quota, options) {
|
|
172
|
+
return scopedWindowParts(quota, options).age !== undefined;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The model a scope names, as a display name: any namespace before a colon
|
|
176
|
+
* is dropped (`model:claude-opus-4` → `claude-opus-4`) and the first letter is
|
|
177
|
+
* capitalized (`fable` → `Fable`); the rest stays as written.
|
|
178
|
+
*/
|
|
179
|
+
function scopeDisplayName(scope) {
|
|
180
|
+
const model = scope.slice(scope.indexOf(':') + 1);
|
|
181
|
+
return model.charAt(0).toUpperCase() + model.slice(1);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Short names for the model scopes in one row: the first two letters of each
|
|
185
|
+
* display name. Names that would read the same for different models are each
|
|
186
|
+
* extended one letter at a time until they differ (`Fable` and `Fabric` read
|
|
187
|
+
* `Fabl` and `Fabr`); a name that runs out of letters stays whole.
|
|
188
|
+
*/
|
|
189
|
+
function scopeAbbreviations(scopes) {
|
|
190
|
+
const names = [...new Set(scopes.map(scopeDisplayName))];
|
|
191
|
+
const lengths = new Map(names.map((name) => [name, 2]));
|
|
192
|
+
const short = (name) => name.slice(0, lengths.get(name));
|
|
193
|
+
for (;;) {
|
|
194
|
+
const byShort = new Map();
|
|
195
|
+
for (const name of names) {
|
|
196
|
+
byShort.set(short(name), [...(byShort.get(short(name)) ?? []), name]);
|
|
197
|
+
}
|
|
198
|
+
let extended = false;
|
|
199
|
+
for (const group of byShort.values()) {
|
|
200
|
+
if (group.length < 2)
|
|
201
|
+
continue;
|
|
202
|
+
for (const name of group) {
|
|
203
|
+
const length = lengths.get(name) ?? 2;
|
|
204
|
+
if (length < name.length) {
|
|
205
|
+
lengths.set(name, length + 1);
|
|
206
|
+
extended = true;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (!extended)
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
return new Map(scopes.map((scope) => [scope, short(scopeDisplayName(scope))]));
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The narrow form's pieces: every account-wide window as `5h 92%`, then at
|
|
217
|
+
* most one more item, with ` left` added once to the last piece. That item is
|
|
218
|
+
* the tightest (least remaining) of the model-scoped windows and the credit
|
|
219
|
+
* budget, so the line stays bounded whatever the account carries: a scoped
|
|
220
|
+
* window reads under its short model name (`Fa 98%`), the budget as `cr 75%`
|
|
221
|
+
* (`cr 0%` when spent). On a tie a scoped window wins over the budget, and
|
|
222
|
+
* among scoped windows the first in display order wins. The choice depends
|
|
223
|
+
* only on the reading, never on a width. A budget without a percentage cannot
|
|
224
|
+
* be chosen; when nothing at all is shown it keeps its compact wording
|
|
225
|
+
* (`credits available`). No age is added; `isQuotaStale` says whether the
|
|
226
|
+
* reading is stale. A window whose reset arrived after the reading keeps its
|
|
227
|
+
* figure, as in the other forms.
|
|
228
|
+
*/
|
|
229
|
+
function narrowTextParts(quota, options) {
|
|
230
|
+
const { windows } = scopedWindowParts(quota, options);
|
|
231
|
+
const general = windows.filter(({ part, scope }) => part.kind === 'window' && scope === ALL_SCOPE);
|
|
232
|
+
const scoped = windows.filter(({ part, scope }) => part.kind === 'window' && scope !== ALL_SCOPE);
|
|
233
|
+
const abbreviations = scopeAbbreviations(scoped.map(({ scope }) => scope));
|
|
234
|
+
// The items competing for the one slot after the account-wide windows, in
|
|
235
|
+
// tie-break order: scoped windows in display order, then the budget. A
|
|
236
|
+
// spent budget counts as 0% left; one without a percentage cannot compete.
|
|
237
|
+
const budget = windows.find(({ part }) => part.kind === 'budget');
|
|
238
|
+
const budgetLeft = budget?.spent ? 0 : budget?.part.remainingPercent;
|
|
239
|
+
const extras = [
|
|
240
|
+
...scoped.map(({ scope, part }) => ({
|
|
241
|
+
label: abbreviations.get(scope) ?? scope,
|
|
242
|
+
remaining: part.remainingPercent ?? 0,
|
|
243
|
+
})),
|
|
244
|
+
...(budgetLeft === undefined
|
|
245
|
+
? []
|
|
246
|
+
: [{ label: 'cr', remaining: budgetLeft }]),
|
|
247
|
+
];
|
|
248
|
+
// Strictly lower wins, so on a tie the earlier item keeps the slot.
|
|
249
|
+
const tightest = extras.reduce((best, item) => best === undefined || item.remaining < best.remaining ? item : best, undefined);
|
|
250
|
+
const pieces = general.map(({ part }) => `${part.name} ${wholePercent(part.remainingPercent ?? 0)}%`);
|
|
251
|
+
if (tightest) {
|
|
252
|
+
pieces.push(`${tightest.label} ${wholePercent(tightest.remaining)}%`);
|
|
253
|
+
}
|
|
254
|
+
if (pieces.length === 0)
|
|
255
|
+
return windows.map(({ part }) => part.figure);
|
|
256
|
+
return pieces.map((piece, index) => index === pieces.length - 1 ? `${piece} left` : piece);
|
|
257
|
+
}
|
|
145
258
|
/**
|
|
146
259
|
* The pieces of a quota text, in order: one per reported window
|
|
147
260
|
* (`5h 58% left, resets 2h`), then the credit budget when there is one, then
|
|
148
261
|
* `checked 3h ago` when the oldest piece shown is stale. Empty when nothing
|
|
149
262
|
* was reported; `formatQuota` turns that into a sentence. A caller printing
|
|
150
|
-
* one line per window (the terminal auth menu) uses these directly.
|
|
263
|
+
* one line per window (the terminal auth menu) uses these directly. In the
|
|
264
|
+
* narrow form the pieces are the narrow line's (`5h 92%`, `7d 66% left`).
|
|
151
265
|
*/
|
|
152
266
|
export function quotaTextParts(quota, options) {
|
|
267
|
+
if (options.form === 'narrow')
|
|
268
|
+
return narrowTextParts(quota, options);
|
|
153
269
|
const parts = quotaWindowParts(quota, options);
|
|
154
270
|
const full = (options.form ?? 'full') === 'full';
|
|
155
271
|
const text = parts.windows.map((part) => {
|
|
@@ -160,7 +276,8 @@ export function quotaTextParts(quota, options) {
|
|
|
160
276
|
}
|
|
161
277
|
/**
|
|
162
278
|
* A row's quota as one line: `5h 58% left · 7d 90% left` in the compact
|
|
163
|
-
* form, with `, resets 2h` after each window in the full form
|
|
279
|
+
* form, with `, resets 2h` after each window in the full form, and
|
|
280
|
+
* `5h 58% · 7d 90% left` in the narrow form. With nothing
|
|
164
281
|
* reported it is `NO_QUOTA_READING`, or `NO_LIMITS_REPORTED` when the
|
|
165
282
|
* provider answered without a limit for any window.
|
|
166
283
|
*/
|
|
@@ -1,7 +1,7 @@
|
|
|
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, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, quotaWindowParts, } from './format.js';
|
|
4
|
+
export { formatQuota, isQuotaStale, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, quotaWindowParts, } from './format.js';
|
|
5
5
|
export type { CreditBudgetCleared, CreditBudgetEntry, CreditBudgetReading, QuotaAbsentEntry, QuotaCounter, QuotaEntry, QuotaMap, QuotaReadingEntry, QuotaRetiredEntry, } from './map.js';
|
|
6
6
|
export { ALL_SCOPE, DEFAULT_REQUIRED_LABELS, decodeQuotaMap, emptyQuotaMap, isQuotaMap, } from './map.js';
|
|
7
7
|
export type { ObservedBudget, ObservedPair, ObservedReading, QuotaObservation, } from './merge.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { quotaCodec } from './codec.js';
|
|
2
|
-
export { formatQuota, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, quotaWindowParts, } from './format.js';
|
|
2
|
+
export { formatQuota, isQuotaStale, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, quotaWindowParts, } from './format.js';
|
|
3
3
|
export { ALL_SCOPE, DEFAULT_REQUIRED_LABELS, decodeQuotaMap, emptyQuotaMap, isQuotaMap, } from './map.js';
|
|
4
4
|
export { isQuotaObservation, mergeQuotaObservation, QuotaCodecError, } from './merge.js';
|
|
5
5
|
export { budgetExhaustedResetAt, futureResetAt, projectQuota, readsExhausted, } from './projection.js';
|