Skip to content

Commit ebdbc04

Browse files
authored
Merge pull request #53 from hatimanees/feat/advisory-expected-cost
feat: expose expected_cost + est_calls_remaining on BudgetAdvisory (#49)
2 parents d6b36e2 + 0fca1ab commit ebdbc04

9 files changed

Lines changed: 126 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,17 @@ packages — `floe-guard` on [PyPI](https://pypi.org/project/floe-guard/) and
88
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
99
both packages adhere to [Semantic Versioning](https://semver.org/).
1010

11-
## Unreleased
11+
## Unreleased — py 0.11.0 / js 0.8.0
12+
13+
### Added (py + js)
14+
15+
- **`advisory()` exposes `expected_cost` + `est_calls_remaining`** (`expectedCost`
16+
/ `estCallsRemaining` in TS, issue #49): the guard's own next-call estimate and
17+
how many more calls the remaining budget buys, so a planner can see call
18+
headroom, not just dollars. `expected_cost` / `expectedCost` is `0.0` / `0`
19+
before the first call is recorded; `est_calls_remaining` / `estCallsRemaining`
20+
is `None` / `null` until then (unknown, not zero).
21+
Additive fields — existing advisory consumers are unaffected.
1222

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

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,10 @@ guard.record(model, response.usage.prompt_tokens, response.usage.completion_toke
156156
```
157157

158158
`advisory()` returns `near_limit`, `used_bps` (utilization in basis points),
159-
`remaining_usd`, and the budget totals. It's a **soft** signal — the model may
159+
`remaining_usd`, and the budget totals. It also reports `expected_cost` (the
160+
guard's own next-call estimate) and `est_calls_remaining` (how many more calls
161+
the remaining budget buys, `None` until the first call is recorded) — call
162+
headroom, not just dollars. It's a **soft** signal — the model may
160163
ignore it; `check()` is what enforces the ceiling. See
161164
[`examples/budget_aware.py`](examples/budget_aware.py) for a runnable taper demo
162165
(no API key).

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.7.0",
3+
"version": "0.8.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: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,22 @@ export interface BudgetAdvisory {
113113
spentUsd: number;
114114
/** Hosted reports the tightest cap across all scopes; local is always "local". */
115115
scope: "local";
116+
/**
117+
* The guard's own next-call estimate (the costlier of the last LLM and last
118+
* tool call — the same value the default reservation uses). 0 until the first
119+
* call is recorded, so a planner can't divide by a cold estimate.
120+
*
121+
* Optional so adding it stays a non-breaking, additive change for any code
122+
* that constructs a `BudgetAdvisory` literal; `advisory()` always sets it.
123+
*/
124+
expectedCost?: number;
125+
/**
126+
* How many more calls the remaining budget buys at expectedCost:
127+
* floor(remainingUsd / expectedCost). null when expectedCost is 0 (no call
128+
* recorded yet) — unknown, not zero. Optional for the same additive reason as
129+
* expectedCost; `advisory()` always sets it.
130+
*/
131+
estCallsRemaining?: number | null;
116132
}
117133

118134
export class BudgetGuard {
@@ -559,17 +575,27 @@ export class BudgetGuard {
559575
this.limitUsd <= 0
560576
? 10000
561577
: Math.max(0, Math.min(10000, Math.floor((this.spentUsd / this.limitUsd) * 10000 + 1e-9)));
578+
const remainingUsd = Math.max(0, this.limitUsd - this.spentUsd);
579+
// The costlier of the last LLM and tool call — the guard's own default
580+
// reservation estimate; 0 before any call is recorded.
581+
const expectedCost = Math.max(this.lastLlmCost, this.lastToolCost);
582+
// +1e-9 absorbs float noise so e.g. 0.6/0.2 floors to 3 not 2 (same epsilon
583+
// rationale as usedBps above, and keeps Python int() parity).
584+
const estCallsRemaining =
585+
expectedCost > 0 ? Math.floor(remainingUsd / expectedCost + 1e-9) : null;
562586
return {
563587
nearLimit: usedBps >= this.nearLimitBps,
564588
usedBps,
565589
// Settled budget: limit minus accrued spend, deliberately NOT net of
566590
// in-flight reservations. Unlike the remainingUsd getter (which subtracts
567591
// `reserved`), the advisory is a soft utilization signal about money already
568592
// spent, while the getter reports what a new call can still claim.
569-
remainingUsd: Math.max(0, this.limitUsd - this.spentUsd),
593+
remainingUsd,
570594
limitUsd: this.limitUsd,
571595
spentUsd: this.spentUsd,
572596
scope: "local",
597+
expectedCost,
598+
estCallsRemaining,
573599
};
574600
}
575601
}

js/test/advisory.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,37 @@ describe("BudgetGuard.advisory", () => {
5050
expect(a.nearLimit).toBe(false); // 80% not actually reached yet
5151
});
5252

53+
it("expectedCost is 0 and estCallsRemaining is null before any call", () => {
54+
// No call recorded: no estimate yet, so calls-remaining is unknown (null),
55+
// never a divide-by-zero or a misleading 0.
56+
const a = new BudgetGuard(1.0).advisory();
57+
expect(a.expectedCost).toBe(0);
58+
expect(a.estCallsRemaining).toBeNull();
59+
});
60+
61+
it("estCallsRemaining is floor(remaining / expectedCost) after a call", () => {
62+
const g = new BudgetGuard(1.0);
63+
g.recordTool("apollo.people_lookup", 0.1); // spent 0.10, remaining 0.90
64+
const a = g.advisory();
65+
expect(a.expectedCost).toBeCloseTo(0.1, 9);
66+
expect(a.estCallsRemaining).toBe(9); // floor(0.90 / 0.10)
67+
});
68+
69+
it("expectedCost is the costlier of the last LLM and tool call", () => {
70+
// Parity with the Python suite: a cheap tool after an expensive LLM call
71+
// must not shrink the estimate — the max wins (conservative). A ManualPrice
72+
// literal keeps the LLM cost deterministic (no dependency on cost_map.json,
73+
// which drifts when refreshed).
74+
const g = new BudgetGuard(1.0);
75+
g.record("m", 1_000, 0, {
76+
price: { inputCostPerToken: 2e-4, outputCostPerToken: 0.0 },
77+
}); // $0.20 LLM (costlier)
78+
g.recordTool("db.query", 0.05); // $0.05 tool (cheaper), spent = $0.25
79+
const a = g.advisory(); // remaining 0.75
80+
expect(a.expectedCost).toBeCloseTo(0.2, 9); // LLM side, not the cheaper tool
81+
expect(a.estCallsRemaining).toBe(3); // floor(0.75 / 0.20)
82+
});
83+
5384
it("rejects an out-of-range or non-integer nearLimitBps", () => {
5485
expect(() => new BudgetGuard(1.0, { nearLimitBps: -1 })).toThrow(RangeError);
5586
expect(() => new BudgetGuard(1.0, { nearLimitBps: 10001 })).toThrow(RangeError);

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "floe-guard"
7-
version = "0.10.0"
7+
version = "0.11.0"
88
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."
99
readme = "README.md"
1010
requires-python = ">=3.10"

src/floe_guard/guard.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ class BudgetAdvisory:
7171
limit_usd: float
7272
spent_usd: float
7373
scope: str = "local" # hosted reports the tightest cap across all scopes
74+
# The guard's own next-call estimate (the costlier of the last LLM and last
75+
# tool call — same value the default reservation uses). 0.0 until the first
76+
# call is recorded, so a planner can't divide by a cold estimate.
77+
expected_cost: float = 0.0
78+
# How many more calls the remaining budget buys at expected_cost:
79+
# floor(remaining_usd / expected_cost). None when expected_cost is 0.0
80+
# (no call recorded yet) — unknown, not zero.
81+
est_calls_remaining: int | None = None
7482

7583

7684
@dataclass(frozen=True)
@@ -569,6 +577,16 @@ def advisory(self) -> BudgetAdvisory:
569577
# and floor matches JS Math.floor exactly — round() would diverge
570578
# (Python banker's rounding vs JS ties-up).
571579
used_bps = max(0, min(10000, int(self.spent_usd / self.limit_usd * 10000 + 1e-9)))
580+
remaining = max(0.0, self.limit_usd - self.spent_usd)
581+
# Lock-free read of the last-cost fields, consistent with reading
582+
# spent_usd above: the estimate is the costlier of the last LLM and tool
583+
# call (the guard's own default reservation), 0.0 before any call.
584+
expected_cost = max(self._last_llm_cost, self._last_tool_cost)
585+
# +1e-9 absorbs float noise so e.g. 0.6/0.2 floors to 3 not 2 (same
586+
# epsilon rationale as used_bps above, and keeps JS Math.floor parity).
587+
est_calls_remaining = (
588+
int(remaining / expected_cost + 1e-9) if expected_cost > 0.0 else None
589+
)
572590
return BudgetAdvisory(
573591
near_limit=used_bps >= self.near_limit_bps,
574592
used_bps=used_bps,
@@ -577,9 +595,11 @@ def advisory(self) -> BudgetAdvisory:
577595
# (which subtracts _reserved): the advisory is a soft utilization signal
578596
# about money already spent, while the property reports what a new call
579597
# can still claim.
580-
remaining_usd=max(0.0, self.limit_usd - self.spent_usd),
598+
remaining_usd=remaining,
581599
limit_usd=self.limit_usd,
582600
spent_usd=self.spent_usd,
601+
expected_cost=expected_cost,
602+
est_calls_remaining=est_calls_remaining,
583603
)
584604

585605
# ── internals ──────────────────────────────────────────────────────────────

tests/test_advisory.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import pytest
55

6-
from floe_guard import BudgetAdvisory, BudgetGuard
6+
from floe_guard import BudgetAdvisory, BudgetGuard, ManualPrice
77

88

99
def test_fresh_guard_is_far_from_limit() -> None:
@@ -56,6 +56,33 @@ def test_used_bps_floors_not_rounds() -> None:
5656
assert a.near_limit is False
5757

5858

59+
def test_expected_cost_unknown_before_first_call() -> None:
60+
# No call recorded yet: estimate is 0.0 and calls-remaining is unknown (None),
61+
# not a divide-by-zero and not a misleading 0.
62+
a = BudgetGuard(limit_usd=1.00).advisory()
63+
assert a.expected_cost == 0.0
64+
assert a.est_calls_remaining is None
65+
66+
67+
def test_est_calls_remaining_after_a_call() -> None:
68+
g = BudgetGuard(limit_usd=1.00)
69+
# Drive state through the public API (manual price for determinism), not by
70+
# poking private fields: this also proves record() feeds advisory().
71+
g.record("m", 1_000, 0, price=ManualPrice(1e-4, 0.0)) # $0.10 spent, $0.90 left
72+
a = g.advisory()
73+
assert a.expected_cost == pytest.approx(0.10)
74+
assert a.est_calls_remaining == 9 # floor(0.90 / 0.10)
75+
76+
77+
def test_est_calls_remaining_uses_costlier_of_llm_and_tool() -> None:
78+
g = BudgetGuard(limit_usd=1.00)
79+
g.record("m", 1_000, 0, price=ManualPrice(2e-4, 0.0)) # $0.20 LLM (costlier)
80+
g.record_tool("db.query", 0.05) # $0.05 tool (cheaper)
81+
a = g.advisory() # spent 0.25, remaining 0.75
82+
assert a.expected_cost == pytest.approx(0.20) # max(llm, tool) wins
83+
assert a.est_calls_remaining == 3 # floor(0.75 / 0.20)
84+
85+
5986
def test_invalid_near_limit_bps_rejected() -> None:
6087
with pytest.raises(ValueError):
6188
BudgetGuard(limit_usd=1.00, near_limit_bps=-1)

0 commit comments

Comments
 (0)