Skip to content

Commit ecc6ad8

Browse files
authored
Merge pull request #35 from Floe-Labs/feat/per-call-log
feat: add per call native spend logs.
2 parents ea7b429 + 44c3c03 commit ecc6ad8

12 files changed

Lines changed: 714 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,31 @@ both packages adhere to [Semantic Versioning](https://semver.org/).
1010

1111
## Unreleased
1212

13+
## py 0.3.0 / js 0.3.0 — 2026-07-14
14+
15+
### Added (py + js)
16+
17+
- **Per-call spend ledger**: every priced `record()` / `settle()` appends a
18+
typed `SpendEvent` (`timestamp`, `kind: llm|tool`, `model_or_tool`,
19+
`prompt_tokens`, `completion_tokens`, `cost_usd`, optional `label` and
20+
`reserved`) to `guard.spend_log` (py) / `guard.spendLog` (js), so the ledger
21+
sums to the running total (unless the ring-buffer cap below has evicted old
22+
events) — no more rebuilding per-call breakdowns outside the guard. `export_log()` / `exportLog()` serialises it as JSONL with
23+
an identical snake_case schema in both languages, so heterogeneous agents
24+
emit one concatenable stream. An optional `max_log_events` / `maxLogEvents`
25+
ring-buffer cap bounds memory for long-running agents.
26+
- **`record_tool()` / `recordTool()`**: accrue a non-LLM cost (paid tool/API
27+
call) against the same ceiling and log it as a `kind: "tool"` event, so
28+
`check()` / `reserve()` enforce the budget across LLM and tool spend
29+
together.
30+
- `record()` / `settle()` accept an optional `label` to tag events with an
31+
agent/task name.
32+
33+
### Fixed (py)
34+
35+
- `floe_guard.__version__` now reports the real package version (it had been
36+
stuck at `0.1.0` since the 0.2.0 release).
37+
1338
## py 0.2.0 / js 0.2.1 — 2026-07-10
1439

1540
Everything the repo grew between the 0.1.0 uploads and this release ships here —

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,31 @@ unchanged — hosted just answers across *every* vendor and cap with server-trut
150150
balances and rolling-window reset timing, which a single local budget can't know.
151151
The TS package exposes the identical `guard.advisory()`.
152152

153+
## Per-call spend log
154+
155+
The guard keeps a typed, in-memory ledger of everything it priced: each
156+
`record()` / `settle()` appends one `SpendEvent`, and `record_tool()` lets paid
157+
non-LLM calls (search APIs, scrapers) spend the same budget and land in the same
158+
log. The events sum to `spent_usd` (unless a `max_log_events` ring buffer has
159+
evicted old ones) — no more rebuilding per-call breakdowns around the guard.
160+
161+
```python
162+
guard = BudgetGuard(limit_usd=1.00) # max_log_events=N caps memory
163+
guard.record("gpt-4o", 1_200, 350, label="researcher") # label is optional
164+
guard.record_tool("serpapi.search", 0.01, label="researcher")
165+
166+
guard.spend_log # [SpendEvent(timestamp=…, kind="llm", model_or_tool="gpt-4o",
167+
# prompt_tokens=1200, completion_tokens=350,
168+
# cost_usd=0.0065, label="researcher"), …]
169+
print(guard.export_log(), end="") # JSONL, one event per line
170+
```
171+
172+
`export_log()` emits a stable snake_case schema —
173+
`{timestamp, kind: llm|tool, model_or_tool, prompt_tokens, completion_tokens,
174+
cost_usd, label?, reserved?}` — identical to the TS package's `exportLog()`, so
175+
every agent produces the same shape regardless of stack and the streams can be
176+
concatenated and analysed together.
177+
153178
## Framework adapters (optional extras)
154179

155180
### CrewAI

js/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,27 @@ const adv = guard.advisory();
6363
const model = adv.nearLimit ? openai("gpt-4o-mini") : openai("gpt-4o");
6464
```
6565

66+
## Per-call spend log
67+
68+
The guard keeps a typed, in-memory ledger of everything it priced: each
69+
`record()` / `settle()` appends one `SpendEvent`, and `recordTool()` lets paid
70+
non-LLM calls spend the same budget and land in the same log. The events sum to
71+
`spentUsd` (unless a `maxLogEvents` ring buffer has evicted old ones).
72+
73+
```ts
74+
const guard = new BudgetGuard(1.0); // { maxLogEvents: N } caps memory
75+
guard.record("gpt-4o", 1_200, 350, { label: "researcher" });
76+
guard.recordTool("serpapi.search", 0.01, { label: "researcher" });
77+
78+
guard.spendLog; // [{ timestamp, kind: "llm", modelOrTool: "gpt-4o", … }, …]
79+
process.stdout.write(guard.exportLog()); // JSONL, one event per line
80+
```
81+
82+
`exportLog()` emits a stable snake_case schema —
83+
`{timestamp, kind: llm|tool, model_or_tool, prompt_tokens, completion_tokens,
84+
cost_usd, label?, reserved?}` — identical to the Python package's
85+
`export_log()`, so every agent produces the same shape regardless of stack.
86+
6687
## Compatibility
6788

6889
`ai` is declared as a peer dependency with the range `>=4.0.0 <6.0.0`:

js/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

js/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "floe-guard",
3-
"version": "0.2.1",
3+
"version": "0.3.0",
44
"description": "A local budget guardrail for AI agents — hard-stops your agent before its next LLM call crosses a USD ceiling. Vercel AI SDK middleware.",
55
"type": "module",
66
"license": "MIT",

js/src/guard.ts

Lines changed: 147 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,33 @@ import {
3333
/** Tolerance for float rounding in the running spend total (well below $0.000001). */
3434
const 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+
3663
export 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
*

js/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export {
1010
BudgetGuard,
1111
type BudgetGuardOptions,
1212
type BudgetAdvisory,
13+
type SpendEvent,
1314
} from "./guard.js";
1415
export {
1516
FloeGuardError,

0 commit comments

Comments
 (0)