Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,17 @@ packages — `floe-guard` on [PyPI](https://pypi.org/project/floe-guard/) and
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
both packages adhere to [Semantic Versioning](https://semver.org/).

## Unreleased
## Unreleased — py 0.11.0 / js 0.8.0

### Added (py + js)

- **`advisory()` exposes `expected_cost` + `est_calls_remaining`** (`expectedCost`
/ `estCallsRemaining` in TS, issue #49): the guard's own next-call estimate and
how many more calls the remaining budget buys, so a planner can see call
headroom, not just dollars. `expected_cost` / `expectedCost` is `0.0` / `0`
before the first call is recorded; `est_calls_remaining` / `estCallsRemaining`
is `None` / `null` until then (unknown, not zero).
Additive fields — existing advisory consumers are unaffected.

## py 0.10.0 / js 0.7.0 — 2026-07-23

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,10 @@ guard.record(model, response.usage.prompt_tokens, response.usage.completion_toke
```

`advisory()` returns `near_limit`, `used_bps` (utilization in basis points),
`remaining_usd`, and the budget totals. It's a **soft** signal — the model may
`remaining_usd`, and the budget totals. It also reports `expected_cost` (the
guard's own next-call estimate) and `est_calls_remaining` (how many more calls
the remaining budget buys, `None` until the first call is recorded) — call
headroom, not just dollars. It's a **soft** signal — the model may
ignore it; `check()` is what enforces the ceiling. See
[`examples/budget_aware.py`](examples/budget_aware.py) for a runnable taper demo
(no API key).
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.7.0",
"version": "0.8.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
28 changes: 27 additions & 1 deletion js/src/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,22 @@ export interface BudgetAdvisory {
spentUsd: number;
/** Hosted reports the tightest cap across all scopes; local is always "local". */
scope: "local";
/**
* The guard's own next-call estimate (the costlier of the last LLM and last
* tool call — the same value the default reservation uses). 0 until the first
* call is recorded, so a planner can't divide by a cold estimate.
*
* Optional so adding it stays a non-breaking, additive change for any code
* that constructs a `BudgetAdvisory` literal; `advisory()` always sets it.
*/
expectedCost?: number;
/**
* How many more calls the remaining budget buys at expectedCost:
* floor(remainingUsd / expectedCost). null when expectedCost is 0 (no call
* recorded yet) — unknown, not zero. Optional for the same additive reason as
* expectedCost; `advisory()` always sets it.
*/
estCallsRemaining?: number | null;
}

export class BudgetGuard {
Expand Down Expand Up @@ -559,17 +575,27 @@ export class BudgetGuard {
this.limitUsd <= 0
? 10000
: Math.max(0, Math.min(10000, Math.floor((this.spentUsd / this.limitUsd) * 10000 + 1e-9)));
const remainingUsd = Math.max(0, this.limitUsd - this.spentUsd);
// The costlier of the last LLM and tool call — the guard's own default
// reservation estimate; 0 before any call is recorded.
const expectedCost = Math.max(this.lastLlmCost, this.lastToolCost);
// +1e-9 absorbs float noise so e.g. 0.6/0.2 floors to 3 not 2 (same epsilon
// rationale as usedBps above, and keeps Python int() parity).
const estCallsRemaining =
expectedCost > 0 ? Math.floor(remainingUsd / expectedCost + 1e-9) : null;
return {
nearLimit: usedBps >= this.nearLimitBps,
usedBps,
// Settled budget: limit minus accrued spend, deliberately NOT net of
// in-flight reservations. Unlike the remainingUsd getter (which subtracts
// `reserved`), the advisory is a soft utilization signal about money already
// spent, while the getter reports what a new call can still claim.
remainingUsd: Math.max(0, this.limitUsd - this.spentUsd),
remainingUsd,
limitUsd: this.limitUsd,
spentUsd: this.spentUsd,
scope: "local",
expectedCost,
estCallsRemaining,
};
}
}
Expand Down
31 changes: 31 additions & 0 deletions js/test/advisory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,37 @@ describe("BudgetGuard.advisory", () => {
expect(a.nearLimit).toBe(false); // 80% not actually reached yet
});

it("expectedCost is 0 and estCallsRemaining is null before any call", () => {
// No call recorded: no estimate yet, so calls-remaining is unknown (null),
// never a divide-by-zero or a misleading 0.
const a = new BudgetGuard(1.0).advisory();
expect(a.expectedCost).toBe(0);
expect(a.estCallsRemaining).toBeNull();
});

it("estCallsRemaining is floor(remaining / expectedCost) after a call", () => {
const g = new BudgetGuard(1.0);
g.recordTool("apollo.people_lookup", 0.1); // spent 0.10, remaining 0.90
const a = g.advisory();
expect(a.expectedCost).toBeCloseTo(0.1, 9);
expect(a.estCallsRemaining).toBe(9); // floor(0.90 / 0.10)
});

Comment thread
coderabbitai[bot] marked this conversation as resolved.
it("expectedCost is the costlier of the last LLM and tool call", () => {
// Parity with the Python suite: a cheap tool after an expensive LLM call
// must not shrink the estimate — the max wins (conservative). A ManualPrice
// literal keeps the LLM cost deterministic (no dependency on cost_map.json,
// which drifts when refreshed).
const g = new BudgetGuard(1.0);
g.record("m", 1_000, 0, {
price: { inputCostPerToken: 2e-4, outputCostPerToken: 0.0 },
}); // $0.20 LLM (costlier)
g.recordTool("db.query", 0.05); // $0.05 tool (cheaper), spent = $0.25
const a = g.advisory(); // remaining 0.75
expect(a.expectedCost).toBeCloseTo(0.2, 9); // LLM side, not the cheaper tool
expect(a.estCallsRemaining).toBe(3); // floor(0.75 / 0.20)
});

it("rejects an out-of-range or non-integer nearLimitBps", () => {
expect(() => new BudgetGuard(1.0, { nearLimitBps: -1 })).toThrow(RangeError);
expect(() => new BudgetGuard(1.0, { nearLimitBps: 10001 })).toThrow(RangeError);
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "floe-guard"
version = "0.10.0"
version = "0.11.0"
description = "Local budget guardrail for AI agents — hard-stops a runaway loop before its next LLM call crosses a spend ceiling. No account, no network."
Comment on lines 5 to 8
readme = "README.md"
requires-python = ">=3.10"
Expand Down
22 changes: 21 additions & 1 deletion src/floe_guard/guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ class BudgetAdvisory:
limit_usd: float
spent_usd: float
scope: str = "local" # hosted reports the tightest cap across all scopes
# The guard's own next-call estimate (the costlier of the last LLM and last
# tool call — same value the default reservation uses). 0.0 until the first
# call is recorded, so a planner can't divide by a cold estimate.
expected_cost: float = 0.0
# How many more calls the remaining budget buys at expected_cost:
# floor(remaining_usd / expected_cost). None when expected_cost is 0.0
# (no call recorded yet) — unknown, not zero.
est_calls_remaining: int | None = None


@dataclass(frozen=True)
Expand Down Expand Up @@ -569,6 +577,16 @@ def advisory(self) -> BudgetAdvisory:
# and floor matches JS Math.floor exactly — round() would diverge
# (Python banker's rounding vs JS ties-up).
used_bps = max(0, min(10000, int(self.spent_usd / self.limit_usd * 10000 + 1e-9)))
remaining = max(0.0, self.limit_usd - self.spent_usd)
# Lock-free read of the last-cost fields, consistent with reading
# spent_usd above: the estimate is the costlier of the last LLM and tool
# call (the guard's own default reservation), 0.0 before any call.
expected_cost = max(self._last_llm_cost, self._last_tool_cost)
# +1e-9 absorbs float noise so e.g. 0.6/0.2 floors to 3 not 2 (same
# epsilon rationale as used_bps above, and keeps JS Math.floor parity).
est_calls_remaining = (
int(remaining / expected_cost + 1e-9) if expected_cost > 0.0 else None
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return BudgetAdvisory(
near_limit=used_bps >= self.near_limit_bps,
used_bps=used_bps,
Expand All @@ -577,9 +595,11 @@ def advisory(self) -> BudgetAdvisory:
# (which subtracts _reserved): the advisory is a soft utilization signal
# about money already spent, while the property reports what a new call
# can still claim.
remaining_usd=max(0.0, self.limit_usd - self.spent_usd),
remaining_usd=remaining,
limit_usd=self.limit_usd,
spent_usd=self.spent_usd,
expected_cost=expected_cost,
est_calls_remaining=est_calls_remaining,
)

# ── internals ──────────────────────────────────────────────────────────────
Expand Down
29 changes: 28 additions & 1 deletion tests/test_advisory.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import pytest

from floe_guard import BudgetAdvisory, BudgetGuard
from floe_guard import BudgetAdvisory, BudgetGuard, ManualPrice


def test_fresh_guard_is_far_from_limit() -> None:
Expand Down Expand Up @@ -56,6 +56,33 @@ def test_used_bps_floors_not_rounds() -> None:
assert a.near_limit is False


def test_expected_cost_unknown_before_first_call() -> None:
# No call recorded yet: estimate is 0.0 and calls-remaining is unknown (None),
# not a divide-by-zero and not a misleading 0.
a = BudgetGuard(limit_usd=1.00).advisory()
assert a.expected_cost == 0.0
assert a.est_calls_remaining is None


def test_est_calls_remaining_after_a_call() -> None:
g = BudgetGuard(limit_usd=1.00)
# Drive state through the public API (manual price for determinism), not by
# poking private fields: this also proves record() feeds advisory().
g.record("m", 1_000, 0, price=ManualPrice(1e-4, 0.0)) # $0.10 spent, $0.90 left
a = g.advisory()
assert a.expected_cost == pytest.approx(0.10)
assert a.est_calls_remaining == 9 # floor(0.90 / 0.10)


def test_est_calls_remaining_uses_costlier_of_llm_and_tool() -> None:
g = BudgetGuard(limit_usd=1.00)
g.record("m", 1_000, 0, price=ManualPrice(2e-4, 0.0)) # $0.20 LLM (costlier)
g.record_tool("db.query", 0.05) # $0.05 tool (cheaper)
a = g.advisory() # spent 0.25, remaining 0.75
assert a.expected_cost == pytest.approx(0.20) # max(llm, tool) wins
assert a.est_calls_remaining == 3 # floor(0.75 / 0.20)


def test_invalid_near_limit_bps_rejected() -> None:
with pytest.raises(ValueError):
BudgetGuard(limit_usd=1.00, near_limit_bps=-1)
Expand Down
Loading