Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,32 @@ both packages adhere to [Semantic Versioning](https://semver.org/).

## Unreleased

## py 0.5.0 / js 0.4.0 — 2026-07-17

### Added (py + js)

- **Tool spend as a first-class primitive** with the full reserve/settle
contract, sharing the token ceiling: `reserve_tool(estimated_cost)` /
`reserveTool` holds a tool call's known price in-flight and raises
`BudgetExceeded` BEFORE the call would cross the cap (stronger than the LLM
path — the price is exact, not an estimate); `settle_tool(name, cost_usd,
reserved=…)` / `settleTool` releases the hold and accrues the actual cost;
`record_tool` / `recordTool` remains the post-hoc form. The caller supplies
the USD — there is no tool cost-map.
- **`tool_costs` / `toolCosts`**: per-tool-name running totals (e.g.
`{"apollo.people_lookup": 0.42, "exa.search": 0.11}`), so the token/tool
split of the one shared ceiling is inspectable. Tool settles land in the
spend ledger as `kind: "tool"` events with the reservation recorded.
- Example: `examples/tool_budget.py` (no API key) — a prospecting loop whose
Apollo/Exa spend dies at the ceiling.

### Changed (py + js)

- `record_tool` / `recordTool` (and the new `settle_tool` / `settleTool`) now
update the next-call estimate, so a plain `check()` + `record_tool` loop
stops BEFORE the crossing tool call — the same stop-one-early contract as
tokens. Previously tool costs accrued but did not inform the prediction.

## py 0.4.0 — 2026-07-15

### Added (py)
Expand Down
34 changes: 31 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)

**A local budget guardrail for AI agents.** It hard-stops your agent *before its
next LLM call* when it would cross a spend ceiling — so a runaway loop dies at
$0.10 instead of $4,000. No account, no signup, no network, **no telemetry**.
Runs in your process.
next LLM or paid tool call* when it would cross a spend ceiling — tokens and
tool calls under **one local ceiling**, so a runaway loop dies at $0.10 instead
of $4,000. No account, no signup, no network, **no telemetry**. Runs in your
process.

Works with [CrewAI](#crewai) · [LiteLLM](#litellm) · [LangChain](#langchain) ·
[OpenAI](#openai) · [Anthropic](#anthropic) ·
Expand Down Expand Up @@ -175,6 +176,33 @@ cost_usd, label?, reserved?}` — identical to the TS package's `exportLog()`, s
every agent produces the same shape regardless of stack and the streams can be
concatenated and analysed together.

## Tool spend under the same ceiling

Tool-heavy agents often spend more on paid APIs (Apollo lookups, Exa searches,
scrapers) than on tokens — and those dollars must count against the same cap,
or the kill-switch guarantee is fiction for them. Tool spend is a first-class
primitive with the full reserve/settle contract; it's actually **stronger**
than the LLM path, because the price is known *before* the call:

```python
# pre-call hard-stop — the crossing call NEVER runs
handle = guard.reserve_tool(0.02) # raises BudgetExceeded before Apollo
result = apollo.people_lookup(...)
guard.settle_tool("apollo.people_lookup", 0.02, reserved=handle)

guard.record_tool("exa.search", 0.004) # post-hoc, for metered APIs

guard.tool_costs # {"apollo.people_lookup": 0.42, "exa.search": 0.11}
guard.remaining_usd # tokens + tools, one ceiling
```

`record_tool` also updates the next-call estimate, so a plain
`check()`/`record_tool` loop stops *before* the crossing call — a runaway tool
loop dies exactly like a runaway LLM loop. The caller supplies the USD (there
is no tool cost-map); every tool call lands in `spend_log` as a
`kind: "tool"` event. Same API in TS (`reserveTool`/`settleTool`/`recordTool`/
`toolCosts`). See [`examples/tool_budget.py`](examples/tool_budget.py).

## Request-sized estimates and mid-stream enforcement

Two gaps in last-cost prediction, closed in 0.4.0 (Python):
Expand Down
64 changes: 64 additions & 0 deletions examples/tool_budget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Tool-spend kill-switch demo — runs with NO API key and NO account.

Some agents spend more per run on paid tool calls (Apollo lookups, Exa
searches, scraping APIs) than on LLM tokens. Those costs never touch a token
cost map — the caller knows the price — but they burn the same real dollars.
This demo shows tool spend as a first-class citizen of the SAME ceiling:

1. ``reserve_tool``/``settle_tool`` — the pre-call hard-stop. A tool's price is
known BEFORE the call, so enforcement is exact: the crossing call never runs.
2. ``check()`` + ``record_tool`` — the sequential loop contract. A runaway
tool loop dies at the ceiling, exactly like a runaway LLM loop.
3. ``tool_costs`` — per-tool attribution, so you can see where the money went.

Run it::

python examples/tool_budget.py

The "tools" are stubs — no network, no keys, no real spend.
"""

from __future__ import annotations

from floe_guard import BudgetExceeded, BudgetGuard

APOLLO_COST = 0.02 # $ per people-lookup — known up front
EXA_COST = 0.004 # $ per search


def stub_apollo_lookup(company: str) -> dict[str, str]:
return {"company": company, "contact": "jane@..."}


def stub_exa_search(query: str) -> list[str]:
return [f"result for {query!r}"]


def main() -> None:
guard = BudgetGuard(limit_usd=0.10)
print(f"Budget: ${guard.limit_usd:.2f} — shared by tokens AND tools\n")

# ── 1. pre-call hard-stop: reserve the KNOWN price before the call ─────────
print("Prospecting until the budget says stop...")
companies = 0
try:
while True:
handle = guard.reserve_tool(APOLLO_COST) # raises BEFORE the call
stub_apollo_lookup(f"company-{companies}")
guard.settle_tool("apollo.people_lookup", APOLLO_COST, reserved=handle)
for _ in range(2): # a couple of searches per company
guard.check() # sequential contract works for tools too
stub_exa_search("intent signals")
guard.record_tool("exa.search", EXA_COST)
companies += 1
except BudgetExceeded:
print(f" stopped after {companies} companies — the crossing call never ran.\n")

# ── 2. attribution: where did the money go? ────────────────────────────────
print(f"spent ${guard.spent_usd:.4f} of ${guard.limit_usd:.2f}, by tool:")
for tool, total in sorted(guard.tool_costs.items()):
print(f" {tool:<22} ${total:.4f}")


if __name__ == "__main__":
main()
22 changes: 19 additions & 3 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](../LICENSE)

**A local budget guardrail for AI agents** — the TypeScript counterpart to the
[Python `floe-guard`](../README.md). It hard-stops your agent *before its next LLM
call* when it would cross a USD spend ceiling. No account, no signup, no network.
Runs in your process.
[Python `floe-guard`](../README.md). It hard-stops your agent *before its next
LLM or paid tool call* when it would cross a USD spend ceiling — tokens and
tool calls under one local ceiling. No account, no signup, no network. Runs in
your process.

Works with both **AI SDK v4 and v5** (`ai@4` / `ai@5`).

Expand Down Expand Up @@ -63,6 +64,21 @@ const adv = guard.advisory();
const model = adv.nearLimit ? openai("gpt-4o-mini") : openai("gpt-4o");
```

## Tool spend under the same ceiling

Paid tool calls (Apollo, Exa, scrapers) burn the same budget as tokens. The
full reserve/settle contract applies — and the price is known *before* the
call, so the pre-call hard-stop is exact:

```ts
const handle = guard.reserveTool(0.02); // throws BudgetExceeded BEFORE the call
const result = await apollo.peopleLookup(...);
guard.settleTool("apollo.people_lookup", 0.02, { reserved: handle });

guard.recordTool("exa.search", 0.004); // post-hoc, for metered APIs
guard.toolCosts; // { "apollo.people_lookup": 0.42, "exa.search": 0.11 }
```

## Per-call spend log

The guard keeps a typed, in-memory ledger of everything it priced: each
Expand Down
4 changes: 2 additions & 2 deletions js/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion js/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "floe-guard",
"version": "0.3.0",
"version": "0.4.0",
"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.",
"type": "module",
"license": "MIT",
Expand Down
92 changes: 80 additions & 12 deletions js/src/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ const EPS = 1e-12;
* One priced spend event in the guard's per-call ledger.
*
* Every {@link BudgetGuard.record} / {@link BudgetGuard.settle} /
* {@link BudgetGuard.recordTool} that accrues spend appends exactly one event, so
* {@link BudgetGuard.recordTool} / {@link BudgetGuard.settleTool} that accrues
* spend appends exactly one event, so
* the ledger's costs sum to `spentUsd` (unless a `maxLogEvents` ring buffer has
* evicted old events). The schema is identical in the Python
* package (`SpendEvent` in `src/floe_guard/guard.py`) and
Expand Down Expand Up @@ -129,6 +130,13 @@ export class BudgetGuard {
/** Per-call ledger, oldest first; a ring buffer when maxLogEvents is set. */
private readonly spendEvents: SpendEvent[] = [];
private readonly maxLogEvents?: number;
/**
* Per-tool running totals (settleTool/recordTool) — the tool side of the one
* shared ceiling, exposed via the toolCosts getter. null-prototype: tool
* names are caller-supplied strings, so a "__proto__" name is stored as
* plain data instead of mutating the object's prototype.
*/
private readonly toolCostTotals: Record<string, number> = Object.create(null);

/**
* @param limitUsd the spend ceiling, in USD. `0` blocks the very first call.
Expand Down Expand Up @@ -322,26 +330,63 @@ export class BudgetGuard {
}

/**
* Accrue a non-LLM cost (a paid tool/API call) against the same ceiling.
* Atomically check the ceiling AND hold a tool call's cost in flight.
*
* The tool-spend counterpart of {@link BudgetGuard.reserve} — and STRONGER
* than the LLM path, because a paid tool's price is usually known exactly
* before the call, so the pre-call hard-stop is precise rather than
* estimated:
*
* Tools with direct dollar costs — search APIs, scrapers, sandboxes — spend the
* same budget the LLM calls do; `recordTool` folds them into `spentUsd` (so
* `check()` / `reserve()` see them) and appends a `kind: "tool"`
* {@link SpendEvent} to {@link BudgetGuard.spendLog}. The caller supplies the
* cost: tools have no token usage to price. Deliberately does NOT update the
* next-call estimate — that predicts the next *LLM* call, and a tool's price
* would skew it. Returns `costUsd`.
* const handle = guard.reserveTool(0.02); // throws BEFORE Apollo runs
* const result = await apollo.peopleLookup(...);
* guard.settleTool("apollo.people_lookup", 0.02, { reserved: handle });
*
* Throws {@link BudgetExceeded} (without reserving) if the call would cross
* the ceiling. The estimate is required — tools have no last-cost prediction
* worth falling back to. Pass the returned handle to
* {@link BudgetGuard.settleTool}, or {@link BudgetGuard.release} on failure.
*/
recordTool(tool: string, costUsd: number, options: { label?: string } = {}): number {
reserveTool(estimatedCost: number): number {
return this.reserve(estimatedCost);
}

/**
* Release a reservation and record a tool call's actual cost.
*
* `recordTool` is `settleTool` with no reservation. The caller supplies the
* cost — tools have no token usage to price. Accrues into the same
* `spentUsd` ceiling as tokens, tallies the per-tool total
* ({@link BudgetGuard.toolCosts}), updates the next-call estimate (so a
* tool-hammering loop's plain `check()` predicts one tool call ahead and
* stops BEFORE the crossing call — the same contract as tokens), and appends
* a `kind: "tool"` {@link SpendEvent} to {@link BudgetGuard.spendLog}.
* Returns `costUsd`.
*/
settleTool(
tool: string,
costUsd: number,
options: { reserved?: number; label?: string } = {},
): number {
if (!Number.isFinite(costUsd) || costUsd < 0) {
throw new RangeError(`costUsd must be a finite, non-negative number, got ${costUsd}`);
}
const reserved = options.reserved ?? 0;
// A bad reserved handle would corrupt the in-flight tally and break the
// ceiling for OTHER calls — same contract as settle().
if (!Number.isFinite(reserved) || reserved < 0) {
throw new RangeError(`reserved must be a finite, non-negative number, got ${reserved}`);
}
if (reserved) {
this.reserved = Math.max(0, this.reserved - reserved);
}
Comment thread
shivamfloe marked this conversation as resolved.
this.spentUsd += costUsd;
// Same sub-epsilon clamp as settle(): never report a rounding-artifact
// crossing of the ceiling.
if (this.spentUsd - this.limitUsd > 0 && this.spentUsd - this.limitUsd < EPS) {
this.spentUsd = this.limitUsd;
}
this.lastCost = costUsd;
this.toolCostTotals[tool] = (this.toolCostTotals[tool] ?? 0) + costUsd;
this.appendEvent({
Comment thread
shivamfloe marked this conversation as resolved.
timestamp: Date.now() / 1000,
kind: "tool",
Expand All @@ -350,10 +395,23 @@ export class BudgetGuard {
completionTokens: null,
costUsd,
...(options.label !== undefined ? { label: options.label } : {}),
...(reserved ? { reserved } : {}),
});
return costUsd;
}

/**
* Accrue a non-LLM cost (a paid tool/API call) against the same ceiling.
*
* Post-hoc accrual for costs only known after the call (metered APIs); when
* the price is known up front, {@link BudgetGuard.reserveTool} /
* {@link BudgetGuard.settleTool} give the stronger pre-call hard-stop. See
* `settleTool` for the full contract. Returns `costUsd`.
*/
recordTool(tool: string, costUsd: number, options: { label?: string } = {}): number {
return this.settleTool(tool, costUsd, { reserved: 0, label: options.label });
}

/**
* Drop an in-flight reservation without recording spend (e.g. the call failed
* before producing usage). Safe to call with `0`.
Expand All @@ -373,10 +431,20 @@ export class BudgetGuard {
return Math.max(0, this.limitUsd - this.spentUsd - this.reserved);
}

/**
* Per-tool running USD totals, keyed by the name given to `settleTool()` /
* `recordTool()` — e.g. `{"apollo.people_lookup": 0.42, "exa.search": 0.11}`.
* Makes the token/tool split of the one shared ceiling inspectable
* (`spentUsd - sum of toolCosts` is the token side). Returns a snapshot copy.
*/
get toolCosts(): Record<string, number> {
return { ...this.toolCostTotals };
}

/**
* The per-call spend ledger, oldest first — one {@link SpendEvent} per priced
* `record()` / `settle()` / `recordTool()`. Returns a snapshot copy: mutating
* it cannot corrupt the ledger.
* `record()` / `settle()` / `recordTool()` / `settleTool()`. Returns a
* snapshot copy: mutating it cannot corrupt the ledger.
*/
get spendLog(): SpendEvent[] {
return [...this.spendEvents];
Expand Down
Loading
Loading