Skip to content

Commit af8019a

Browse files
Merge pull request #36 from Floe-Labs/feat/pre-request-call
feat:request-sized pre-call estimates and mid-stream budget enforceme…
2 parents ecc6ad8 + 7834cc5 commit af8019a

10 files changed

Lines changed: 860 additions & 12 deletions

File tree

CHANGELOG.md

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

1111
## Unreleased
1212

13+
## py 0.4.0 — 2026-07-15
14+
15+
### Added (py)
16+
17+
- **Request-sized pre-call estimates**: `BudgetGuard.estimate_call(model,
18+
prompt_tokens, max_completion_tokens)` prices the actual incoming request
19+
from the cost map, so `reserve(est)` / `check(est)` block an oversized call
20+
— including the very first one, which the last-cost prediction is blind to.
21+
The LiteLLM adapter reserves request-sized automatically (prompt via
22+
`litellm.token_counter`, cap from `max_tokens`); the LangChain handler sizes
23+
its pre-call `check()` from the serialized model config and a ~4 chars/token
24+
prompt heuristic. Anything unpriceable/unsized falls back to the previous
25+
last-cost behaviour.
26+
- **Mid-stream enforcement**: `StreamGuard` / `guard_stream()` re-price a
27+
streaming response chunk-by-chunk and raise `BudgetExceeded`
28+
mid-generation when the running call would cross the ceiling — the partial
29+
spend is settled (and lands in `spend_log`) instead of the whole overshoot
30+
being discovered post-mortem. `finish(prompt_tokens=…, completion_tokens=…)`
31+
reconciles the chunk heuristic to provider-reported usage; unpriceable
32+
models fail closed before the stream starts; parallel streams count each
33+
other's in-flight accrual, so unreserved streams share the ceiling instead
34+
of each spending it in full. Demo: `examples/streaming_guard.py` (no API
35+
key).
36+
1337
## py 0.3.0 / js 0.3.0 — 2026-07-14
1438

1539
### Added (py + js)

README.md

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,45 @@ cost_usd, label?, reserved?}` — identical to the TS package's `exportLog()`, s
175175
every agent produces the same shape regardless of stack and the streams can be
176176
concatenated and analysed together.
177177

178+
## Request-sized estimates and mid-stream enforcement
179+
180+
Two gaps in last-cost prediction, closed in 0.4.0 (Python):
181+
182+
**The oversized first call.** `check()`/`reserve()` predict from the *last*
183+
call — blind on call #1, wrong for a call much bigger than the previous one.
184+
`estimate_call()` prices the **actual incoming request** so even a first call
185+
that alone would cross the cap blocks pre-flight:
186+
187+
```python
188+
est = guard.estimate_call("gpt-4o", prompt_tokens=12_000, max_completion_tokens=4_096)
189+
handle = guard.reserve(est) # raises BudgetExceeded NOW if this call can't fit
190+
```
191+
192+
The LiteLLM adapter does this automatically (prompt tokens via
193+
`litellm.token_counter`, output cap from `max_tokens`), and the LangChain
194+
handler sizes its pre-call `check()` the same way. Unpriceable or unsized
195+
requests fall back to the old last-cost prediction — the wiring only ever
196+
tightens enforcement.
197+
198+
**The stream that runs long.** `record()` meters a *completed* response — too
199+
late for a generation that starts cheap and keeps going. `guard_stream()` (or
200+
the underlying `StreamGuard`) re-prices the call on every chunk and cuts the
201+
stream off **mid-generation**, settling the tokens actually consumed instead of
202+
recording a big overshoot after the fact:
203+
204+
```python
205+
from floe_guard import guard_stream
206+
207+
for chunk in guard_stream(guard, "gpt-4o", stream, prompt_tokens=1_000):
208+
print(chunk, end="") # raises BudgetExceeded mid-stream at the ceiling
209+
```
210+
211+
Chunk sizes are estimated at ~4 chars/token (pass `count_tokens=` for a real
212+
tokenizer); the final accrual reconciles to provider-reported usage via
213+
`StreamGuard.finish(...)`. See
214+
[`examples/streaming_guard.py`](examples/streaming_guard.py) for a runnable
215+
demo (no API key).
216+
178217
## Framework adapters (optional extras)
179218

180219
### CrewAI
@@ -320,10 +359,13 @@ vendored cost map *inside your process*:
320359
- It only sees the vendors you instrument.
321360
- A determined agent or a bug could route around an in-process check.
322361
- Under heavy or cold-start concurrency it bounds steady-state spend, not the
323-
first parallel wave. Reservations size from the last call's cost (`0` until the
324-
first `record()`), so the opening fan-out has nothing to estimate from. Pass a
325-
known per-call max to `reserve()` to bound it, or use hosted Floe for a hard cap
362+
first parallel wave. Reservations default to the last call's cost (`0` until
363+
the first `record()`) — size them to the real request with `estimate_call()`
364+
(the LiteLLM adapter does this for you), or use hosted Floe for a hard cap
326365
under arbitrary concurrency.
366+
- Mid-stream enforcement (`guard_stream`) prices chunks by a ~4 chars/token
367+
heuristic unless you supply a tokenizer, so the cut-off point is approximate;
368+
the final accrual reconciles to provider-reported usage.
327369

328370
It's genuinely useful on its own, and it's honest about its limits. No inflated
329371
metrics, no "zero defaults" claims — it's a free local stop, not a vault.

examples/streaming_guard.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Mid-stream kill-switch demo — runs with NO API key and NO account.
2+
3+
Two enforcement gaps this demo closes (both new in 0.4.0):
4+
5+
1. **The oversized first call.** ``check()``/``reserve()`` predict the next
6+
call from the LAST one — on call #1 there is no baseline, so a huge request
7+
sails through and the overshoot is only discovered after the money is spent.
8+
``estimate_call()`` prices the ACTUAL request (real prompt size + output
9+
cap) so the very first call blocks pre-flight if it alone would cross.
10+
11+
2. **The stream that runs long.** ``record()`` meters a COMPLETED response —
12+
too late to stop a generation that starts cheap and keeps going.
13+
``guard_stream()`` re-prices the call on every chunk and cuts the stream
14+
off mid-generation, settling only the tokens actually consumed.
15+
16+
Run it::
17+
18+
python examples/streaming_guard.py
19+
20+
The "LLM" is a stub generator — no network, no key. Costs are computed offline
21+
from the bundled cost map, exactly as for a real ``gpt-4o`` call.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
from collections.abc import Iterator
27+
28+
from floe_guard import BudgetExceeded, BudgetGuard, guard_stream
29+
30+
MODEL = "gpt-4o"
31+
32+
33+
def stub_llm_stream() -> Iterator[str]:
34+
"""A fake streaming LLM that never stops talking."""
35+
while True:
36+
yield "and another thing... " # ~5 tokens per chunk by the heuristic
37+
38+
39+
def main() -> None:
40+
guard = BudgetGuard(limit_usd=0.01) # ≙ 1_000 gpt-4o output tokens
41+
42+
# ── 1. the oversized FIRST call is blocked pre-flight ──────────────────────
43+
print(f"Budget: ${guard.limit_usd:.2f}\n")
44+
print("1) First call asks for 100k output tokens (≈ $1.00):")
45+
estimate = guard.estimate_call(MODEL, prompt_tokens=1_000, max_completion_tokens=100_000)
46+
try:
47+
guard.reserve(estimate) # request-sized, so call #1 has no free pass
48+
except BudgetExceeded:
49+
print(" blocked BEFORE the request was sent — $0.00 spent.\n")
50+
51+
# ── 2. a stream that starts cheap but runs long is cut off mid-flight ──────
52+
print("2) Streaming a response that never wants to stop:")
53+
chunks = 0
54+
try:
55+
for _ in guard_stream(guard, MODEL, stub_llm_stream()):
56+
chunks += 1
57+
except BudgetExceeded:
58+
print(f" stream cut off mid-generation after {chunks} chunks.")
59+
print(f" spent ${guard.spent_usd:.4f} of the ${guard.limit_usd:.2f} ceiling — "
60+
"the partial spend is settled, not lost:")
61+
for event in guard.spend_log:
62+
print(f" ledger: {event.kind} {event.model_or_tool} "
63+
f"{event.completion_tokens} tokens → ${event.cost_usd:.4f}")
64+
65+
66+
if __name__ == "__main__":
67+
main()

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.3.0"
7+
version = "0.4.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/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,16 @@
2323
from .guard import BudgetAdvisory, BudgetGuard, SpendEvent
2424
from .hosted import hosted_enforcement_available, hosted_remaining_usd
2525
from .pricing import ManualPrice, PricedModel, price_tokens, resolve_price
26+
from .stream import StreamGuard, guard_stream
2627

27-
__version__ = "0.3.0" # keep in lockstep with pyproject.toml
28+
__version__ = "0.4.0" # keep in lockstep with pyproject.toml
2829

2930
__all__ = [
3031
"BudgetGuard",
3132
"BudgetAdvisory",
3233
"SpendEvent",
34+
"StreamGuard",
35+
"guard_stream",
3336
"BudgetExceeded",
3437
"FloeGuardError",
3538
"HostedEnforcementError",

src/floe_guard/guard.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,10 @@ def __init__(
193193
)
194194
# Per-call ledger; deque(maxlen=None) is unbounded, otherwise a ring buffer.
195195
self._spend_log: deque[SpendEvent] = deque(maxlen=max_log_events)
196+
# Active streams' (accrued_usd, reserved_usd), keyed by registry token —
197+
# see _stream_register(). Lets parallel streams count each other's
198+
# in-flight accrual against the ceiling before anything settles.
199+
self._stream_costs: dict[object, tuple[float, float]] = {}
196200
self._lock = threading.Lock()
197201

198202
# ── enforcement ───────────────────────────────────────────────────────────
@@ -214,6 +218,36 @@ def check(self, estimated_next_cost: float | None = None) -> None:
214218
if self._would_cross(estimated_next_cost):
215219
self._block()
216220

221+
def estimate_call(
222+
self,
223+
model: str,
224+
prompt_tokens: int,
225+
max_completion_tokens: int = 0,
226+
*,
227+
price: ManualPrice | None = None,
228+
) -> float | None:
229+
"""Price the ACTUAL incoming request, for a request-sized reserve()/check().
230+
231+
:meth:`check` and :meth:`reserve` default to predicting the next call
232+
from the LAST call's cost — which is blind on the first call and wrong
233+
for a call much larger than the previous one. Feed this the request you
234+
are about to send (its real prompt size and output cap) and pass the
235+
result straight through::
236+
237+
est = guard.estimate_call("gpt-4o", prompt_tokens, max_completion_tokens=1024)
238+
handle = guard.reserve(est) # blocks NOW if this call alone would cross
239+
240+
The estimate is worst-case on output (the model may stop well short of
241+
``max_completion_tokens``); the hold is corrected to actual cost at
242+
:meth:`settle`. Returns ``None`` when the model is unpriceable — and
243+
``reserve(None)`` / ``check(None)`` fall back to the last-cost
244+
prediction, so the wiring degrades gracefully instead of failing.
245+
"""
246+
priced = self._resolve(model, price)
247+
if priced is None:
248+
return None
249+
return price_tokens(priced, prompt_tokens, max_completion_tokens)
250+
217251
def reserve(self, estimated_cost: float | None = None) -> float:
218252
"""Atomically check the ceiling AND hold the estimated cost in-flight.
219253
@@ -454,6 +488,41 @@ def _validate_estimate(self, estimated: float | None) -> None:
454488
if estimated is not None and not math.isfinite(estimated):
455489
raise ValueError(f"estimated cost must be a finite number, got {estimated!r}")
456490

491+
def _stream_register(self, reserved: float) -> object:
492+
"""Register an active stream (see :class:`~floe_guard.stream.StreamGuard`)
493+
and return its registry key. Active streams' accrued-but-unsettled costs
494+
count against the ceiling for each OTHER stream, so parallel unreserved
495+
streams share the budget instead of each spending the full ceiling."""
496+
key = object()
497+
with self._lock:
498+
self._stream_costs[key] = (0.0, max(0.0, reserved))
499+
return key
500+
501+
def _stream_unregister(self, key: object) -> None:
502+
"""Drop a stream's registry entry once its cost is settled (settle()
503+
moves the accrual into ``spent_usd``, so keeping it would double-count)."""
504+
with self._lock:
505+
self._stream_costs.pop(key, None)
506+
507+
def _stream_would_cross(self, key: object, cumulative_call_cost: float) -> bool:
508+
"""Atomically record stream ``key``'s cumulative cost so far and answer:
509+
would it cross the ceiling? Counted against the limit: settled spend,
510+
other calls' reservations (this stream's own hold is excluded — its real
511+
accrued cost replaces the estimate), and each OTHER active stream's
512+
accrual beyond its own reservation (the reservation part is already
513+
inside ``_reserved``). Used by :class:`~floe_guard.stream.StreamGuard`.
514+
"""
515+
with self._lock:
516+
own_reserved = self._stream_costs.get(key, (0.0, 0.0))[1]
517+
self._stream_costs[key] = (cumulative_call_cost, own_reserved)
518+
other_overage = sum(
519+
max(0.0, accrued - held)
520+
for k, (accrued, held) in self._stream_costs.items()
521+
if k is not key
522+
)
523+
others = self.spent_usd + max(0.0, self._reserved - own_reserved) + other_overage
524+
return others + cumulative_call_cost > self.limit_usd + _EPS
525+
457526
def _would_cross(self, estimated_next_cost: float | None) -> bool:
458527
with self._lock:
459528
estimate = (

src/floe_guard/integrations/langchain.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from typing import Any
2424

2525
from ..guard import BudgetGuard
26+
from ..stream import approx_tokens
2627

2728

2829
def _require_langchain() -> Any:
@@ -36,6 +37,43 @@ def _require_langchain() -> Any:
3637
return langchain_core
3738

3839

40+
def _estimate_start(guard: BudgetGuard, serialized: Any, texts: list[str]) -> float | None:
41+
"""Request-sized cost estimate for the pre-call check.
42+
43+
``check()`` defaults to predicting from the LAST call's cost — blind on the
44+
first call and wrong for a much larger one. Size the prediction to the
45+
request instead: model id and ``max_tokens`` from the serialized model
46+
config, prompt tokens via the ~4 chars/token heuristic (langchain-core has
47+
no tokenizer; a rough request-sized figure still beats a stale or zero
48+
baseline). Returns ``None`` when the model is unknown/unpriceable —
49+
``check(None)`` keeps the old behaviour.
50+
"""
51+
model_kwargs = serialized.get("kwargs") if isinstance(serialized, dict) else None
52+
if not isinstance(model_kwargs, dict):
53+
return None
54+
model = model_kwargs.get("model_name") or model_kwargs.get("model")
55+
if not model:
56+
return None
57+
prompt_tokens = sum(approx_tokens(t) for t in texts if isinstance(t, str))
58+
max_out = model_kwargs.get("max_tokens") or model_kwargs.get("max_completion_tokens") or 0
59+
try:
60+
max_out = max(0, int(max_out))
61+
except (TypeError, ValueError):
62+
max_out = 0
63+
return guard.estimate_call(str(model), prompt_tokens, max_out)
64+
65+
66+
def _chat_texts(messages: Any) -> list[str]:
67+
"""Flatten LangChain's batched chat messages to their text contents."""
68+
texts: list[str] = []
69+
for batch in messages or []:
70+
for message in batch or []:
71+
content = getattr(message, "content", None)
72+
if isinstance(content, str):
73+
texts.append(content)
74+
return texts
75+
76+
3977
def _model_from_result(response: Any) -> str:
4078
"""Pull the model name from a LangChain ``LLMResult``."""
4179
llm_output = getattr(response, "llm_output", None)
@@ -112,10 +150,13 @@ def __init__(self) -> None:
112150
self.guard = guard
113151

114152
def on_llm_start(self, serialized: Any, prompts: Any, **kwargs: Any) -> None:
115-
self.guard.check()
153+
# Request-sized when derivable (see _estimate_start); check(None)
154+
# falls back to the last-cost prediction.
155+
texts = [p for p in (prompts or []) if isinstance(p, str)]
156+
self.guard.check(_estimate_start(self.guard, serialized, texts))
116157

117158
def on_chat_model_start(self, serialized: Any, messages: Any, **kwargs: Any) -> None:
118-
self.guard.check()
159+
self.guard.check(_estimate_start(self.guard, serialized, _chat_texts(messages)))
119160

120161
def on_llm_end(self, response: Any, **kwargs: Any) -> None:
121162
_record_result(self.guard, response)

0 commit comments

Comments
 (0)