@inixiative/foundry-core 0.1.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.
@@ -0,0 +1,433 @@
1
+ import { type TokenCounts, sumTokenCounts, totalTokenCount } from "./token-counts";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Token & Cost Tracking
5
+ // ---------------------------------------------------------------------------
6
+
7
+ /**
8
+ * Accumulated token usage with estimated cost.
9
+ */
10
+ export interface TokenUsage extends TokenCounts {
11
+ readonly input: number;
12
+ readonly output: number;
13
+ readonly total: number;
14
+ readonly estimatedCost: number;
15
+ }
16
+
17
+ /**
18
+ * A single recorded usage entry — one LLM call.
19
+ */
20
+ export interface UsageEntry {
21
+ readonly timestamp: number;
22
+ readonly provider: string;
23
+ readonly model: string;
24
+ readonly agentId?: string;
25
+ readonly threadId?: string;
26
+ readonly spanId?: string;
27
+ readonly tokens: Readonly<TokenCounts>;
28
+ readonly cost: number;
29
+ /** False means cost is an unpriced subtotal, not a zero-price acknowledgment. */
30
+ readonly costKnown?: boolean;
31
+ readonly cached?: boolean;
32
+ }
33
+
34
+ /**
35
+ * Per-model pricing: cost in dollars per 1 million tokens.
36
+ */
37
+ export interface ModelPricing {
38
+ readonly inputPer1M: number;
39
+ readonly outputPer1M: number;
40
+ }
41
+
42
+ /**
43
+ * Nested cost table: provider -> model -> pricing.
44
+ */
45
+ export interface CostTable {
46
+ readonly [provider: string]: {
47
+ readonly [model: string]: ModelPricing;
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Budget enforcement configuration.
53
+ */
54
+ export interface BudgetConfig {
55
+ /** Maximum total tokens (input + output + cache reads/writes) before budget is exceeded. */
56
+ readonly maxTokens?: number;
57
+ /** Maximum cost in dollars before budget is exceeded. */
58
+ readonly maxCost?: number;
59
+ /** Percentage (0-1) of budget at which to emit a warning. Default 0.8. */
60
+ readonly warnAt?: number;
61
+ /** Percentage (0-1) of budget at which to throw / halt. Default 1.0. */
62
+ readonly haltAt?: number;
63
+ }
64
+
65
+ /**
66
+ * Current budget status snapshot.
67
+ */
68
+ export interface BudgetStatus {
69
+ readonly usedTokens: number;
70
+ readonly usedCost: number;
71
+ readonly limitTokens: number | undefined;
72
+ readonly limitCost: number | undefined;
73
+ readonly percentage: number;
74
+ readonly warning: boolean;
75
+ readonly exceeded: boolean;
76
+ }
77
+
78
+ /**
79
+ * Breakdown row used inside UsageSummary.
80
+ */
81
+ export interface UsageBreakdown extends TokenCounts {
82
+ readonly key: string;
83
+ readonly input: number;
84
+ readonly output: number;
85
+ readonly total: number;
86
+ readonly cost: number;
87
+ readonly calls: number;
88
+ }
89
+
90
+ /**
91
+ * Full summary across all dimensions.
92
+ */
93
+ export interface UsageSummary {
94
+ /** Disjoint input/output/cache totals; tags live on recent entries. */
95
+ readonly tokens: Readonly<TokenCounts>;
96
+ readonly totalInput: number;
97
+ readonly totalOutput: number;
98
+ readonly totalTokens: number;
99
+ readonly totalCost: number;
100
+ readonly totalCalls: number;
101
+ readonly byProvider: ReadonlyArray<UsageBreakdown>;
102
+ readonly byModel: ReadonlyArray<UsageBreakdown>;
103
+ readonly byAgent: ReadonlyArray<UsageBreakdown>;
104
+ readonly byThread: ReadonlyArray<UsageBreakdown>;
105
+ readonly budget: BudgetStatus;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Default cost table (approximate pricing as of early 2026)
110
+ // ---------------------------------------------------------------------------
111
+
112
+ export const DEFAULT_COST_TABLE: CostTable = {
113
+ anthropic: {
114
+ "claude-opus-4-7": { inputPer1M: 5, outputPer1M: 25 },
115
+ "claude-opus-4-6": { inputPer1M: 5, outputPer1M: 25 },
116
+ "claude-sonnet-4-6": { inputPer1M: 3, outputPer1M: 15 },
117
+ "claude-haiku-4-5-20251001": { inputPer1M: 1, outputPer1M: 5 },
118
+ },
119
+ openai: {
120
+ "gpt-5.4": { inputPer1M: 2.5, outputPer1M: 15 },
121
+ "gpt-5.4-mini": { inputPer1M: 0.75, outputPer1M: 4.5 },
122
+ "gpt-5.3-codex": { inputPer1M: 1.75, outputPer1M: 14 },
123
+ "o4-mini": { inputPer1M: 1.1, outputPer1M: 4.4 }, // retired Feb 2026 — kept for historical cost tracking
124
+ },
125
+ gemini: {
126
+ "gemini-3.1-pro-preview": { inputPer1M: 2, outputPer1M: 12 },
127
+ "gemini-3-flash-preview": { inputPer1M: 0.5, outputPer1M: 3 },
128
+ "gemini-3.1-flash-lite-preview": { inputPer1M: 0.25, outputPer1M: 1.5 },
129
+ },
130
+ // Claude Code provider tracks usage via session output, not cost table
131
+ "claude-code": {},
132
+ };
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Token estimation
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /** Heuristic patterns that indicate source code rather than prose. */
139
+ const CODE_SIGNALS = /[{}();=]|=>|function\s|import\s|export\s|const\s|let\s|var\s|class\s|def\s|return\s/;
140
+
141
+ /**
142
+ * Estimate the number of tokens in `text` without a full tokenizer.
143
+ *
144
+ * Strategy:
145
+ * - Detect whether the text is predominantly code or natural language.
146
+ * - For code, use ~0.4 tokens per character (code is denser).
147
+ * - For prose, use ~0.75 tokens per whitespace-delimited word.
148
+ * - Falls back gracefully for empty / tiny strings.
149
+ *
150
+ * This is intentionally fast — no WASM, no external deps — but
151
+ * meaningfully more accurate than the naive `Math.ceil(len / 4)`.
152
+ */
153
+ export function estimateTokens(text: string, _model?: string): number {
154
+ if (!text) return 0;
155
+
156
+ const trimmed = text.trim();
157
+ if (trimmed.length === 0) return 0;
158
+
159
+ // Sample up to the first 2000 chars for the code-detection heuristic
160
+ const sample = trimmed.slice(0, 2000);
161
+ const isCode = CODE_SIGNALS.test(sample);
162
+
163
+ if (isCode) {
164
+ // Code: roughly 0.4 tokens per character
165
+ return Math.max(1, Math.ceil(trimmed.length * 0.4));
166
+ }
167
+
168
+ // Prose: roughly 0.75 tokens per word
169
+ const words = trimmed.split(/\s+/).length;
170
+ return Math.max(1, Math.ceil(words * 0.75));
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // TokenTracker
175
+ // ---------------------------------------------------------------------------
176
+
177
+ type EventHandler = (status: BudgetStatus) => void;
178
+
179
+ export class TokenTracker {
180
+ private readonly _entries: UsageEntry[] = [];
181
+ private readonly _costTable: CostTable;
182
+ private readonly _budget: BudgetConfig;
183
+ private readonly _warningHandlers: EventHandler[] = [];
184
+ private readonly _exceededHandlers: EventHandler[] = [];
185
+
186
+ /** Tracks whether a warning has already been emitted for this budget cycle. */
187
+ private _warningEmitted = false;
188
+
189
+ constructor(opts?: { costTable?: CostTable; budget?: BudgetConfig }) {
190
+ this._costTable = opts?.costTable ?? DEFAULT_COST_TABLE;
191
+ this._budget = opts?.budget ?? {};
192
+ }
193
+
194
+ // -----------------------------------------------------------------------
195
+ // Recording
196
+ // -----------------------------------------------------------------------
197
+
198
+ /**
199
+ * Record a token usage entry. Automatically computes cost from the cost
200
+ * table and timestamps the entry. Returns the full entry.
201
+ *
202
+ * Throws if budget.haltAt is exceeded (when configured).
203
+ */
204
+ record(
205
+ entry: Omit<UsageEntry, "timestamp" | "cost">
206
+ ): UsageEntry {
207
+ const cost = this._computeCost(
208
+ entry.provider,
209
+ entry.model,
210
+ entry.tokens.input,
211
+ entry.tokens.output,
212
+ entry.cached
213
+ );
214
+
215
+ const full: UsageEntry = {
216
+ ...entry,
217
+ timestamp: Date.now(),
218
+ cost,
219
+ costKnown: !!this._costTable[entry.provider]?.[entry.model],
220
+ };
221
+
222
+ this._entries.push(full);
223
+ this._checkBudget();
224
+ return full;
225
+ }
226
+
227
+ // -----------------------------------------------------------------------
228
+ // Queries
229
+ // -----------------------------------------------------------------------
230
+
231
+ get totalTokens(): TokenUsage {
232
+ return this._aggregate(this._entries);
233
+ }
234
+
235
+ byProvider(provider: string): TokenUsage {
236
+ return this._aggregate(this._entries.filter((e) => e.provider === provider));
237
+ }
238
+
239
+ byModel(model: string): TokenUsage {
240
+ return this._aggregate(this._entries.filter((e) => e.model === model));
241
+ }
242
+
243
+ byAgent(agentId: string): TokenUsage {
244
+ return this._aggregate(this._entries.filter((e) => e.agentId === agentId));
245
+ }
246
+
247
+ byThread(threadId: string): TokenUsage {
248
+ return this._aggregate(this._entries.filter((e) => e.threadId === threadId));
249
+ }
250
+
251
+ // -----------------------------------------------------------------------
252
+ // Budget
253
+ // -----------------------------------------------------------------------
254
+
255
+ get budgetStatus(): BudgetStatus {
256
+ return this._buildBudgetStatus();
257
+ }
258
+
259
+ // -----------------------------------------------------------------------
260
+ // History & summary
261
+ // -----------------------------------------------------------------------
262
+
263
+ /** Return the most recent entries (default 50). */
264
+ recent(limit = 50): ReadonlyArray<UsageEntry> {
265
+ return this._entries.slice(-limit);
266
+ }
267
+
268
+ /** Full usage summary across all dimensions. */
269
+ summary(): UsageSummary {
270
+ const tokens = sumTokenCounts(this._entries.map(e => e.tokens));
271
+ return {
272
+ tokens,
273
+ totalInput: tokens.input,
274
+ totalOutput: tokens.output,
275
+ totalTokens: totalTokenCount(tokens),
276
+ totalCost: this._entries.reduce((s, e) => s + e.cost, 0),
277
+ totalCalls: this._entries.length,
278
+ byProvider: this._groupBy("provider"),
279
+ byModel: this._groupBy("model"),
280
+ byAgent: this._groupBy("agentId"),
281
+ byThread: this._groupBy("threadId"),
282
+ budget: this._buildBudgetStatus(),
283
+ };
284
+ }
285
+
286
+ /** Clear all recorded entries and reset warning state. */
287
+ reset(): void {
288
+ this._entries.length = 0;
289
+ this._warningEmitted = false;
290
+ }
291
+
292
+ // -----------------------------------------------------------------------
293
+ // Events
294
+ // -----------------------------------------------------------------------
295
+
296
+ /** Subscribe to budget warnings. Returns an unsubscribe function. */
297
+ onWarning(handler: EventHandler): () => void {
298
+ this._warningHandlers.push(handler);
299
+ return () => {
300
+ const idx = this._warningHandlers.indexOf(handler);
301
+ if (idx !== -1) this._warningHandlers.splice(idx, 1);
302
+ };
303
+ }
304
+
305
+ /** Subscribe to budget exceeded events. Returns an unsubscribe function. */
306
+ onExceeded(handler: EventHandler): () => void {
307
+ this._exceededHandlers.push(handler);
308
+ return () => {
309
+ const idx = this._exceededHandlers.indexOf(handler);
310
+ if (idx !== -1) this._exceededHandlers.splice(idx, 1);
311
+ };
312
+ }
313
+
314
+ // -----------------------------------------------------------------------
315
+ // Private helpers
316
+ // -----------------------------------------------------------------------
317
+
318
+ private _computeCost(
319
+ provider: string,
320
+ model: string,
321
+ input: number,
322
+ output: number,
323
+ cached?: boolean
324
+ ): number {
325
+ const pricing = this._costTable[provider]?.[model];
326
+ if (!pricing) return 0;
327
+
328
+ // Cached input tokens are typically free or heavily discounted
329
+ const effectiveInput = cached ? input * 0.1 : input;
330
+ return (
331
+ (effectiveInput / 1_000_000) * pricing.inputPer1M +
332
+ (output / 1_000_000) * pricing.outputPer1M
333
+ );
334
+ }
335
+
336
+ private _aggregate(entries: UsageEntry[]): TokenUsage {
337
+ const tokens = sumTokenCounts(entries.map(e => e.tokens));
338
+ return { ...tokens, total: totalTokenCount(tokens), estimatedCost: entries.reduce((sum, e) => sum + e.cost, 0) };
339
+ }
340
+
341
+ private _groupBy(
342
+ field: "provider" | "model" | "agentId" | "threadId"
343
+ ): UsageBreakdown[] {
344
+ const groups = new Map<string, UsageEntry[]>();
345
+ for (const entry of this._entries) {
346
+ const key = entry[field];
347
+ if (key == null) continue;
348
+ const group = groups.get(key) ?? [];
349
+ group.push(entry);
350
+ groups.set(key, group);
351
+ }
352
+ return [...groups].map(([key, entries]) => {
353
+ const { estimatedCost, ...tokens } = this._aggregate(entries);
354
+ return { key, ...tokens, cost: estimatedCost, calls: entries.length };
355
+ });
356
+ }
357
+
358
+ private _buildBudgetStatus(): BudgetStatus {
359
+ const usedTokens = totalTokenCount(sumTokenCounts(this._entries.map(e => e.tokens)));
360
+ const usedCost = this._entries.reduce((s, e) => s + e.cost, 0);
361
+
362
+ const limitTokens = this._budget.maxTokens;
363
+ const limitCost = this._budget.maxCost;
364
+
365
+ // Calculate percentage based on whichever limit is closer to being hit
366
+ let percentage = 0;
367
+ if (limitTokens != null && limitTokens > 0) {
368
+ percentage = Math.max(percentage, usedTokens / limitTokens);
369
+ }
370
+ if (limitCost != null && limitCost > 0) {
371
+ percentage = Math.max(percentage, usedCost / limitCost);
372
+ }
373
+
374
+ const warnAt = this._budget.warnAt ?? 0.8;
375
+ const haltAt = this._budget.haltAt ?? 1.0;
376
+
377
+ return {
378
+ usedTokens,
379
+ usedCost,
380
+ limitTokens,
381
+ limitCost,
382
+ percentage,
383
+ warning: percentage >= warnAt,
384
+ exceeded: percentage >= haltAt,
385
+ };
386
+ }
387
+
388
+ private _checkBudget(): void {
389
+ const status = this._buildBudgetStatus();
390
+
391
+ if (status.warning && !this._warningEmitted) {
392
+ this._warningEmitted = true;
393
+ for (const handler of [...this._warningHandlers]) {
394
+ try {
395
+ handler(status);
396
+ } catch {
397
+ // Don't let one bad handler break recording
398
+ }
399
+ }
400
+ }
401
+
402
+ if (status.exceeded) {
403
+ for (const handler of [...this._exceededHandlers]) {
404
+ try {
405
+ handler(status);
406
+ } catch {
407
+ // Don't let one bad handler break recording
408
+ }
409
+ }
410
+ throw new BudgetExceededError(status);
411
+ }
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Thrown when token/cost budget is exceeded (at haltAt threshold).
417
+ */
418
+ export class BudgetExceededError extends Error {
419
+ readonly status: BudgetStatus;
420
+
421
+ constructor(status: BudgetStatus) {
422
+ const parts: string[] = [];
423
+ if (status.limitTokens != null) {
424
+ parts.push(`${status.usedTokens}/${status.limitTokens} tokens`);
425
+ }
426
+ if (status.limitCost != null) {
427
+ parts.push(`$${status.usedCost.toFixed(4)}/$${status.limitCost.toFixed(2)}`);
428
+ }
429
+ super(`Budget exceeded: ${parts.join(", ")} (${(status.percentage * 100).toFixed(1)}%)`);
430
+ this.name = "BudgetExceededError";
431
+ this.status = status;
432
+ }
433
+ }