@inixiative/foundry-core 0.5.5 → 0.5.6
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/package.json +1 -1
- package/src/index.ts +6 -0
- package/src/token-tracker.ts +154 -3
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -410,9 +410,15 @@ export {
|
|
|
410
410
|
type ModelPricing,
|
|
411
411
|
TokenTracker,
|
|
412
412
|
type TokenUsage,
|
|
413
|
+
type UsageAttribution,
|
|
413
414
|
type UsageBreakdown,
|
|
414
415
|
type UsageEntry,
|
|
416
|
+
type UsageFilter,
|
|
417
|
+
type UsageRecord,
|
|
415
418
|
type UsageSummary,
|
|
419
|
+
type UsageWindow,
|
|
420
|
+
type WindowReading,
|
|
421
|
+
windowDelta,
|
|
416
422
|
} from './token-tracker';
|
|
417
423
|
// Tools — typed execution interfaces
|
|
418
424
|
export {
|
package/src/token-tracker.ts
CHANGED
|
@@ -15,15 +15,28 @@ export interface TokenUsage extends TokenCounts {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
18
|
+
* What a usage entry or window reading is for, beyond the built-in dimensions: e.g. a replay's
|
|
19
|
+
* `fixtureId`, `arm`, `runId` and `role`. Filters match on these keys exactly.
|
|
20
|
+
*/
|
|
21
|
+
export type UsageAttribution = Readonly<Record<string, string>>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A single recorded usage entry — one LLM call (for a native session, one turn).
|
|
19
25
|
*/
|
|
20
26
|
export interface UsageEntry {
|
|
21
27
|
readonly timestamp: number;
|
|
22
28
|
readonly provider: string;
|
|
23
29
|
readonly model: string;
|
|
30
|
+
readonly effort?: string;
|
|
24
31
|
readonly agentId?: string;
|
|
25
32
|
readonly threadId?: string;
|
|
26
33
|
readonly spanId?: string;
|
|
34
|
+
/**
|
|
35
|
+
* The subscription login the call ran on, by a non-secret id: a Kingdom Integration id, or a
|
|
36
|
+
* local label (`local`, `setup-token:<sha256 prefix>`). Never the credential itself.
|
|
37
|
+
*/
|
|
38
|
+
readonly integrationId?: string;
|
|
39
|
+
readonly attribution?: UsageAttribution;
|
|
27
40
|
readonly tokens: Readonly<TokenCounts>;
|
|
28
41
|
readonly cost: number;
|
|
29
42
|
/** False means cost is an unpriced subtotal, not a zero-price acknowledgment. */
|
|
@@ -31,6 +44,96 @@ export interface UsageEntry {
|
|
|
31
44
|
readonly cached?: boolean;
|
|
32
45
|
}
|
|
33
46
|
|
|
47
|
+
/**
|
|
48
|
+
* One reading of a subscription login's usage windows (percent used per window id, e.g.
|
|
49
|
+
* `five_hour`), taken over the provider's control channel, not a model call. A window covers the
|
|
50
|
+
* whole login, so its growth between two readings is an upper bound on what the calls between them
|
|
51
|
+
* spent.
|
|
52
|
+
*/
|
|
53
|
+
/** One usage window in a reading. */
|
|
54
|
+
export interface UsageWindow {
|
|
55
|
+
/** 0–100. */
|
|
56
|
+
readonly usedPercent: number;
|
|
57
|
+
/** When the window resets (epoch ms); a different value in a later reading means it reset. */
|
|
58
|
+
readonly resetsAt?: number;
|
|
59
|
+
readonly windowMinutes?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface WindowReading {
|
|
63
|
+
/** When the provider observed it (epoch ms). */
|
|
64
|
+
readonly timestamp: number;
|
|
65
|
+
readonly provider: string;
|
|
66
|
+
readonly integrationId?: string;
|
|
67
|
+
/** Where in the work it was taken, e.g. `before`, `after`, `gate`. */
|
|
68
|
+
readonly phase: string;
|
|
69
|
+
/** How it was read, e.g. `session` (the work's own session) or `probe` (a separate one). */
|
|
70
|
+
readonly source: string;
|
|
71
|
+
readonly windows: Readonly<Record<string, UsageWindow>>;
|
|
72
|
+
/** The provider reports ordinary usage blocked. Absent means not reported. */
|
|
73
|
+
readonly blocked?: boolean;
|
|
74
|
+
/** The provider's reason label when a limit is reached. */
|
|
75
|
+
readonly reachedType?: string;
|
|
76
|
+
readonly plan?: string;
|
|
77
|
+
readonly attribution?: UsageAttribution;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Selects entries and readings: every given field must match, and every given attribution key.
|
|
82
|
+
* Window readings carry no model, effort, agent, thread or span, so only `provider`,
|
|
83
|
+
* `integrationId` and `attribution` select them.
|
|
84
|
+
*/
|
|
85
|
+
export interface UsageFilter {
|
|
86
|
+
readonly provider?: string;
|
|
87
|
+
readonly model?: string;
|
|
88
|
+
readonly effort?: string;
|
|
89
|
+
readonly agentId?: string;
|
|
90
|
+
readonly threadId?: string;
|
|
91
|
+
readonly spanId?: string;
|
|
92
|
+
readonly integrationId?: string;
|
|
93
|
+
readonly attribution?: UsageAttribution;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The usage a filter selects: its calls, their totals and the window readings, as recorded. */
|
|
97
|
+
export interface UsageRecord {
|
|
98
|
+
readonly calls: number;
|
|
99
|
+
readonly totals: TokenUsage;
|
|
100
|
+
readonly entries: ReadonlyArray<UsageEntry>;
|
|
101
|
+
readonly windowReadings: ReadonlyArray<WindowReading>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Two readings of one reset time this far apart are different windows (reset times jitter). */
|
|
105
|
+
const RESET_TOLERANCE_MS = 60_000;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Growth per window id present in both readings, in percentage points (2 decimals). A window
|
|
109
|
+
* whose reset time moved between the readings reset in between: its growth is unknown, so it is
|
|
110
|
+
* listed under `reset` instead of `delta`.
|
|
111
|
+
*/
|
|
112
|
+
export function windowDelta(
|
|
113
|
+
before: WindowReading['windows'],
|
|
114
|
+
after: WindowReading['windows'],
|
|
115
|
+
): { delta: Record<string, number>; reset: string[] } {
|
|
116
|
+
const delta: Record<string, number> = {};
|
|
117
|
+
const reset: string[] = [];
|
|
118
|
+
for (const [id, a] of Object.entries(after)) {
|
|
119
|
+
const b = before[id];
|
|
120
|
+
if (!b) continue;
|
|
121
|
+
if (
|
|
122
|
+
b.resetsAt !== undefined &&
|
|
123
|
+
a.resetsAt !== undefined &&
|
|
124
|
+
Math.abs(a.resetsAt - b.resetsAt) > RESET_TOLERANCE_MS
|
|
125
|
+
)
|
|
126
|
+
reset.push(id);
|
|
127
|
+
else delta[id] = Number((a.usedPercent - b.usedPercent).toFixed(2));
|
|
128
|
+
}
|
|
129
|
+
return { delta, reset };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const attributionMatches = (
|
|
133
|
+
attribution: UsageAttribution | undefined,
|
|
134
|
+
want: UsageAttribution | undefined,
|
|
135
|
+
): boolean => !want || Object.entries(want).every(([key, value]) => attribution?.[key] === value);
|
|
136
|
+
|
|
34
137
|
/**
|
|
35
138
|
* Per-model pricing: cost in dollars per 1 million tokens.
|
|
36
139
|
*/
|
|
@@ -188,6 +291,7 @@ type EventHandler = (status: BudgetStatus) => void;
|
|
|
188
291
|
|
|
189
292
|
export class TokenTracker {
|
|
190
293
|
private readonly _entries: UsageEntry[] = [];
|
|
294
|
+
private readonly _windowReadings: WindowReading[] = [];
|
|
191
295
|
private readonly _costTable: CostTable;
|
|
192
296
|
private readonly _budget: BudgetConfig;
|
|
193
297
|
private readonly _warningHandlers: EventHandler[] = [];
|
|
@@ -232,10 +336,56 @@ export class TokenTracker {
|
|
|
232
336
|
return full;
|
|
233
337
|
}
|
|
234
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Record a reading of a login's usage windows. It spends nothing, so no budget is checked.
|
|
341
|
+
* `timestamp` defaults to now.
|
|
342
|
+
*/
|
|
343
|
+
recordWindows(reading: Omit<WindowReading, 'timestamp'> & { timestamp?: number }): WindowReading {
|
|
344
|
+
const full: WindowReading = { ...reading, timestamp: reading.timestamp ?? Date.now() };
|
|
345
|
+
this._windowReadings.push(full);
|
|
346
|
+
return full;
|
|
347
|
+
}
|
|
348
|
+
|
|
235
349
|
// -----------------------------------------------------------------------
|
|
236
350
|
// Queries
|
|
237
351
|
// -----------------------------------------------------------------------
|
|
238
352
|
|
|
353
|
+
/** Every entry the filter selects, in recording order. */
|
|
354
|
+
entries(filter: UsageFilter = {}): ReadonlyArray<UsageEntry> {
|
|
355
|
+
return this._entries.filter(
|
|
356
|
+
(e) =>
|
|
357
|
+
(filter.provider === undefined || e.provider === filter.provider) &&
|
|
358
|
+
(filter.model === undefined || e.model === filter.model) &&
|
|
359
|
+
(filter.effort === undefined || e.effort === filter.effort) &&
|
|
360
|
+
(filter.agentId === undefined || e.agentId === filter.agentId) &&
|
|
361
|
+
(filter.threadId === undefined || e.threadId === filter.threadId) &&
|
|
362
|
+
(filter.spanId === undefined || e.spanId === filter.spanId) &&
|
|
363
|
+
(filter.integrationId === undefined || e.integrationId === filter.integrationId) &&
|
|
364
|
+
attributionMatches(e.attribution, filter.attribution),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Every window reading the filter selects (by provider, integrationId and attribution). */
|
|
369
|
+
windowReadings(filter: UsageFilter = {}): ReadonlyArray<WindowReading> {
|
|
370
|
+
return this._windowReadings.filter(
|
|
371
|
+
(r) =>
|
|
372
|
+
(filter.provider === undefined || r.provider === filter.provider) &&
|
|
373
|
+
(filter.integrationId === undefined || r.integrationId === filter.integrationId) &&
|
|
374
|
+
attributionMatches(r.attribution, filter.attribution),
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The calls, totals and window readings a filter selects, as one record. */
|
|
379
|
+
usageRecord(filter: UsageFilter = {}): UsageRecord {
|
|
380
|
+
const entries = this.entries(filter);
|
|
381
|
+
return {
|
|
382
|
+
calls: entries.length,
|
|
383
|
+
totals: this._aggregate(entries),
|
|
384
|
+
entries,
|
|
385
|
+
windowReadings: this.windowReadings(filter),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
239
389
|
get totalTokens(): TokenUsage {
|
|
240
390
|
return this._aggregate(this._entries);
|
|
241
391
|
}
|
|
@@ -291,9 +441,10 @@ export class TokenTracker {
|
|
|
291
441
|
};
|
|
292
442
|
}
|
|
293
443
|
|
|
294
|
-
/** Clear all recorded entries and reset warning state. */
|
|
444
|
+
/** Clear all recorded entries and window readings, and reset warning state. */
|
|
295
445
|
reset(): void {
|
|
296
446
|
this._entries.length = 0;
|
|
447
|
+
this._windowReadings.length = 0;
|
|
297
448
|
this._warningEmitted = false;
|
|
298
449
|
}
|
|
299
450
|
|
|
@@ -340,7 +491,7 @@ export class TokenTracker {
|
|
|
340
491
|
);
|
|
341
492
|
}
|
|
342
493
|
|
|
343
|
-
private _aggregate(entries: UsageEntry
|
|
494
|
+
private _aggregate(entries: ReadonlyArray<UsageEntry>): TokenUsage {
|
|
344
495
|
const tokens = sumTokenCounts(entries.map((e) => e.tokens));
|
|
345
496
|
return {
|
|
346
497
|
...tokens,
|