@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/foundry-core",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
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 {
@@ -15,15 +15,28 @@ export interface TokenUsage extends TokenCounts {
15
15
  }
16
16
 
17
17
  /**
18
- * A single recorded usage entry — one LLM call.
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[]): TokenUsage {
494
+ private _aggregate(entries: ReadonlyArray<UsageEntry>): TokenUsage {
344
495
  const tokens = sumTokenCounts(entries.map((e) => e.tokens));
345
496
  return {
346
497
  ...tokens,