Skip to content

Commit e97ad6b

Browse files
committed
fix: close fail-closed bypass for usage-present/model-missing responses
- litellm _record_response: route through guard.record() whenever a response has usage tokens, even if the model id is missing, so the guard's fail-closed/fail-open policy applies instead of a silent unmetered skip (which also skewed the next check() by leaving _last_cost stale). A no-usage response stays a no-op. - errors: correct UnpriceableModelWarning docstring — the warning is ALWAYS emitted; fail-closed mode additionally raises UnpriceableModelError - tests: usage-present/model-missing fails closed (and warns+skips when fail-open); no-usage response is a no-op
1 parent 5d255ff commit e97ad6b

3 files changed

Lines changed: 42 additions & 4 deletions

File tree

src/floe_guard/errors.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,9 @@ def __init__(self, model: str) -> None:
4444

4545

4646
class UnpriceableModelWarning(UserWarning):
47-
"""Warned (loudly) when an unpriceable model is seen in non-fail-closed mode."""
47+
"""Warned (loudly) whenever an unpriceable model is seen.
48+
49+
Always emitted regardless of ``fail_closed``. In fail-closed mode an
50+
:class:`UnpriceableModelError` is additionally raised; in fail-open mode the
51+
warning is emitted and the call's spend is skipped.
52+
"""

src/floe_guard/integrations/litellm.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,14 @@ def _usage_from(response: Any) -> tuple[int, int]:
5858
def _record_response(guard: BudgetGuard, kwargs: dict[str, Any], response: Any) -> None:
5959
model = _model_from(kwargs, response)
6060
prompt_tokens, completion_tokens = _usage_from(response)
61-
if model:
62-
guard.record(model, prompt_tokens, completion_tokens)
61+
if prompt_tokens <= 0 and completion_tokens <= 0:
62+
# No tokens were spent (e.g. a usage-less event) — nothing to meter.
63+
return
64+
# There IS spend to account for. Route it through record() even when the
65+
# model id is missing, so the guard's configured policy applies (fail-closed
66+
# → warn + raise; fail-open → warn + skip). Silently skipping here would let
67+
# a real, completed call go unmetered and skew the next check().
68+
guard.record(model, prompt_tokens, completion_tokens)
6369

6470

6571
def guarded_completion(guard: BudgetGuard, **kwargs: Any) -> Any:

tests/test_litellm_adapter.py

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

1212
import pytest
1313

14-
from floe_guard import BudgetGuard
14+
from floe_guard import BudgetGuard, UnpriceableModelError, UnpriceableModelWarning
1515
from floe_guard.integrations.litellm import _model_from, _record_response, _usage_from
1616

1717

@@ -55,3 +55,30 @@ def test_record_response_accrues_dict_response() -> None:
5555
resp = {"model": "gpt-4o", "usage": {"prompt_tokens": 1_000, "completion_tokens": 1_000}}
5656
_record_response(guard, {}, resp)
5757
assert guard.spent_usd == pytest.approx(0.0125)
58+
59+
60+
def test_usage_present_but_model_missing_fails_closed() -> None:
61+
# The Major fix: tokens were spent but the model id is missing. This MUST go
62+
# through record() (fail-closed → raise), not be silently skipped unmetered.
63+
guard = BudgetGuard(limit_usd=1.0) # fail_closed defaults to True
64+
resp = {"usage": {"prompt_tokens": 1_000, "completion_tokens": 1_000}}
65+
with pytest.warns(UnpriceableModelWarning):
66+
with pytest.raises(UnpriceableModelError):
67+
_record_response(guard, {}, resp)
68+
assert guard.spent_usd == 0.0
69+
70+
71+
def test_usage_present_but_model_missing_fail_open_warns_and_skips() -> None:
72+
guard = BudgetGuard(limit_usd=1.0, fail_closed=False)
73+
resp = {"usage": {"prompt_tokens": 1_000, "completion_tokens": 1_000}}
74+
with pytest.warns(UnpriceableModelWarning):
75+
_record_response(guard, {}, resp)
76+
assert guard.spent_usd == 0.0
77+
78+
79+
def test_no_usage_response_is_a_noop() -> None:
80+
# A genuinely empty (no-usage) response: nothing spent, so no record/raise.
81+
guard = BudgetGuard(limit_usd=1.0)
82+
_record_response(guard, {}, {}) # no model, no usage
83+
_record_response(guard, {}, {"usage": {"prompt_tokens": 0, "completion_tokens": 0}})
84+
assert guard.spent_usd == 0.0

0 commit comments

Comments
 (0)