@@ -33,6 +33,33 @@ import {
3333/** Tolerance for float rounding in the running spend total (well below $0.000001). */
3434const EPS = 1e-12 ;
3535
36+ /**
37+ * One priced spend event in the guard's per-call ledger.
38+ *
39+ * Every {@link BudgetGuard.record} / {@link BudgetGuard.settle } /
40+ * {@link BudgetGuard.recordTool } that accrues spend appends exactly one event, so
41+ * the ledger's costs sum to `spentUsd` (unless a `maxLogEvents` ring buffer has
42+ * evicted old events). The schema is identical in the Python
43+ * package (`SpendEvent` in `src/floe_guard/guard.py`) and
44+ * {@link BudgetGuard.exportLog} serialises it with the same snake_case keys in
45+ * both languages, so every agent emits the same shape regardless of stack.
46+ */
47+ export interface SpendEvent {
48+ /** Unix epoch seconds (UTC). */
49+ readonly timestamp : number ;
50+ readonly kind : "llm" | "tool" ;
51+ readonly modelOrTool : string ;
52+ /** `null` for tool events. */
53+ readonly promptTokens : number | null ;
54+ /** `null` for tool events. */
55+ readonly completionTokens : number | null ;
56+ readonly costUsd : number ;
57+ /** Caller-supplied tag (agent/task name). */
58+ readonly label ?: string ;
59+ /** The reservation settled by this call, if any. */
60+ readonly reserved ?: number ;
61+ }
62+
3663export interface BudgetGuardOptions {
3764 /** Per-model manual prices for models the bundled cost map cannot price. */
3865 priceOverrides ?: Record < string , ManualPrice > ;
@@ -53,6 +80,13 @@ export interface BudgetGuardOptions {
5380 * flags `nearLimit` so an agent can taper before the hard-stop. Default 8000.
5481 */
5582 nearLimitBps ?: number ;
83+ /**
84+ * Optional cap on the per-call spend ledger ({@link BudgetGuard.spendLog}).
85+ * When set, the ledger is a ring buffer keeping the most recent N events so a
86+ * long-running agent's memory stays bounded; the running totals are
87+ * unaffected. Default: keep every event.
88+ */
89+ maxLogEvents ?: number ;
5690}
5791
5892/**
@@ -92,6 +126,9 @@ export class BudgetGuard {
92126 private lastCost = 0 ;
93127 /** USD held for in-flight calls (reserved, not yet settled). Counts toward the ceiling. */
94128 private reserved = 0 ;
129+ /** Per-call ledger, oldest first; a ring buffer when maxLogEvents is set. */
130+ private readonly spendEvents : SpendEvent [ ] = [ ] ;
131+ private readonly maxLogEvents ?: number ;
95132
96133 /**
97134 * @param limitUsd the spend ceiling, in USD. `0` blocks the very first call.
@@ -112,7 +149,16 @@ export class BudgetGuard {
112149 `nearLimitBps must be an integer in 0..10000, got ${ nearLimitBps } ` ,
113150 ) ;
114151 }
152+ if (
153+ options . maxLogEvents !== undefined &&
154+ ( ! Number . isInteger ( options . maxLogEvents ) || options . maxLogEvents < 0 )
155+ ) {
156+ throw new RangeError (
157+ `maxLogEvents must be a non-negative integer, got ${ options . maxLogEvents } ` ,
158+ ) ;
159+ }
115160 this . limitUsd = limitUsd ;
161+ this . maxLogEvents = options . maxLogEvents ;
116162 this . priceOverrides = options . priceOverrides ;
117163 this . failClosed = options . failClosed ?? true ;
118164 this . onBlock = options . onBlock ?? defaultOnBlock ;
@@ -181,13 +227,16 @@ export class BudgetGuard {
181227 * Release a reservation and record the actual cost. `record` is `settle` with
182228 * no reservation. Returns the USD cost of this call; unpriceable-model handling
183229 * matches {@link BudgetGuard.record}, and any held reservation is released even
184- * on the warn-and-skip path.
230+ * on the warn-and-skip path. A priced call appends one {@link SpendEvent} to
231+ * {@link BudgetGuard.spendLog} (`label` tags it, e.g. with an agent/task name);
232+ * the warn-and-skip path accrues nothing and logs nothing, so the ledger stays
233+ * in lockstep with `spentUsd`.
185234 */
186235 settle (
187236 model : string ,
188237 promptTokens : number ,
189238 completionTokens : number ,
190- options : { reserved ?: number ; price ?: ManualPrice } = { } ,
239+ options : { reserved ?: number ; price ?: ManualPrice ; label ?: string } = { } ,
191240 ) : number {
192241 const reserved = options . reserved ?? 0 ;
193242 // A bad reserved handle would corrupt this.reserved and break the ceiling for
@@ -237,6 +286,18 @@ export class BudgetGuard {
237286 this . spentUsd = this . limitUsd ;
238287 }
239288 this . lastCost = cost ;
289+ this . appendEvent ( {
290+ timestamp : Date . now ( ) / 1000 ,
291+ kind : "llm" ,
292+ modelOrTool : model ,
293+ promptTokens,
294+ completionTokens,
295+ costUsd : cost ,
296+ ...( options . label !== undefined ? { label : options . label } : { } ) ,
297+ // 0 means "no reservation" (the plain record() path) — omit rather than
298+ // log a meaningless zero.
299+ ...( reserved ? { reserved } : { } ) ,
300+ } ) ;
240301 return cost ;
241302 }
242303
@@ -251,14 +312,48 @@ export class BudgetGuard {
251312 model : string ,
252313 promptTokens : number ,
253314 completionTokens : number ,
254- options : { price ?: ManualPrice } = { } ,
315+ options : { price ?: ManualPrice ; label ?: string } = { } ,
255316 ) : number {
256317 return this . settle ( model , promptTokens , completionTokens , {
257318 reserved : 0 ,
258319 price : options . price ,
320+ label : options . label ,
259321 } ) ;
260322 }
261323
324+ /**
325+ * Accrue a non-LLM cost (a paid tool/API call) against the same ceiling.
326+ *
327+ * Tools with direct dollar costs — search APIs, scrapers, sandboxes — spend the
328+ * same budget the LLM calls do; `recordTool` folds them into `spentUsd` (so
329+ * `check()` / `reserve()` see them) and appends a `kind: "tool"`
330+ * {@link SpendEvent} to {@link BudgetGuard.spendLog}. The caller supplies the
331+ * cost: tools have no token usage to price. Deliberately does NOT update the
332+ * next-call estimate — that predicts the next *LLM* call, and a tool's price
333+ * would skew it. Returns `costUsd`.
334+ */
335+ recordTool ( tool : string , costUsd : number , options : { label ?: string } = { } ) : number {
336+ if ( ! Number . isFinite ( costUsd ) || costUsd < 0 ) {
337+ throw new RangeError ( `costUsd must be a finite, non-negative number, got ${ costUsd } ` ) ;
338+ }
339+ this . spentUsd += costUsd ;
340+ // Same sub-epsilon clamp as settle(): never report a rounding-artifact
341+ // crossing of the ceiling.
342+ if ( this . spentUsd - this . limitUsd > 0 && this . spentUsd - this . limitUsd < EPS ) {
343+ this . spentUsd = this . limitUsd ;
344+ }
345+ this . appendEvent ( {
346+ timestamp : Date . now ( ) / 1000 ,
347+ kind : "tool" ,
348+ modelOrTool : tool ,
349+ promptTokens : null ,
350+ completionTokens : null ,
351+ costUsd,
352+ ...( options . label !== undefined ? { label : options . label } : { } ) ,
353+ } ) ;
354+ return costUsd ;
355+ }
356+
262357 /**
263358 * Drop an in-flight reservation without recording spend (e.g. the call failed
264359 * before producing usage). Safe to call with `0`.
@@ -278,6 +373,55 @@ export class BudgetGuard {
278373 return Math . max ( 0 , this . limitUsd - this . spentUsd - this . reserved ) ;
279374 }
280375
376+ /**
377+ * The per-call spend ledger, oldest first — one {@link SpendEvent} per priced
378+ * `record()` / `settle()` / `recordTool()`. Returns a snapshot copy: mutating
379+ * it cannot corrupt the ledger.
380+ */
381+ get spendLog ( ) : SpendEvent [ ] {
382+ return [ ...this . spendEvents ] ;
383+ }
384+
385+ /**
386+ * The spend ledger as JSONL — one event per line, newline-terminated.
387+ *
388+ * The schema is stable and language-independent (snake_case keys, fixed order;
389+ * optional fields omitted when absent), identical to the Python package's
390+ * `export_log()`, so heterogeneous agents produce logs you can concatenate and
391+ * analyse as one stream. (The *schema* is the contract, not the bytes: the two
392+ * runtimes may render the same float differently, e.g. JS `0.0000025` vs
393+ * Python `2.5e-06`.) Empty ledger yields `""`.
394+ */
395+ exportLog ( ) : string {
396+ return this . spendEvents
397+ . map ( ( e ) => {
398+ // snake_case wire shape, fixed key order — the cross-language schema.
399+ const row : Record < string , unknown > = {
400+ timestamp : e . timestamp ,
401+ kind : e . kind ,
402+ model_or_tool : e . modelOrTool ,
403+ prompt_tokens : e . promptTokens ,
404+ completion_tokens : e . completionTokens ,
405+ cost_usd : e . costUsd ,
406+ } ;
407+ if ( e . label !== undefined ) row . label = e . label ;
408+ if ( e . reserved !== undefined ) row . reserved = e . reserved ;
409+ return `${ JSON . stringify ( row ) } \n` ;
410+ } )
411+ . join ( "" ) ;
412+ }
413+
414+ private appendEvent ( event : SpendEvent ) : void {
415+ // Frozen for parity with Python's frozen dataclass: spendLog copies the
416+ // array but shares the event objects, so an unfrozen event would let a
417+ // consumer silently rewrite logged history.
418+ this . spendEvents . push ( Object . freeze ( event ) ) ;
419+ if ( this . maxLogEvents !== undefined && this . spendEvents . length > this . maxLogEvents ) {
420+ // Ring buffer: drop the oldest overflow (at most one per append).
421+ this . spendEvents . splice ( 0 , this . spendEvents . length - this . maxLogEvents ) ;
422+ }
423+ }
424+
281425 /**
282426 * Context-aware spend advisory for this budget — see {@link BudgetAdvisory}.
283427 *
0 commit comments