Skip to content

Commit 1e00254

Browse files
authored
Merge pull request #27 from SpowZy/feat/cache-aware-pricing
feat: natively support Anthropic cache-aware pricing (#26)
2 parents 9e6fa94 + 8d022b4 commit 1e00254

5 files changed

Lines changed: 180 additions & 42 deletions

File tree

src/floe_guard/guard.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,9 @@ def settle(
281281
*,
282282
reserved: float = 0.0,
283283
price: ManualPrice | None = None,
284+
cache_creation_input_tokens: int = 0,
285+
cache_creation_input_tokens_1h: int = 0,
286+
cache_read_input_tokens: int = 0,
284287
label: str | None = None,
285288
) -> float:
286289
"""Release a reservation and record the actual cost. Concurrency-safe.
@@ -316,7 +319,14 @@ def settle(
316319
return 0.0
317320

318321
try:
319-
cost = price_tokens(priced, prompt_tokens, completion_tokens)
322+
cost = price_tokens(
323+
priced,
324+
prompt_tokens,
325+
completion_tokens,
326+
cache_creation_input_tokens=cache_creation_input_tokens,
327+
cache_creation_input_tokens_1h=cache_creation_input_tokens_1h,
328+
cache_read_input_tokens=cache_read_input_tokens,
329+
)
320330
except Exception:
321331
# price_tokens can raise (e.g. non-finite token counts). Release the
322332
# in-flight hold before propagating so _reserved doesn't leak and
@@ -356,6 +366,9 @@ def record(
356366
completion_tokens: int,
357367
*,
358368
price: ManualPrice | None = None,
369+
cache_creation_input_tokens: int = 0,
370+
cache_creation_input_tokens_1h: int = 0,
371+
cache_read_input_tokens: int = 0,
359372
label: str | None = None,
360373
) -> float:
361374
"""Price one response's tokens offline and add the cost to the total.
@@ -365,7 +378,15 @@ def record(
365378
docstring): warn + raise (default), or warn + skip accrual.
366379
"""
367380
return self.settle(
368-
model, prompt_tokens, completion_tokens, reserved=0.0, price=price, label=label
381+
model,
382+
prompt_tokens,
383+
completion_tokens,
384+
reserved=0.0,
385+
price=price,
386+
cache_creation_input_tokens=cache_creation_input_tokens,
387+
cache_creation_input_tokens_1h=cache_creation_input_tokens_1h,
388+
cache_read_input_tokens=cache_read_input_tokens,
389+
label=label,
369390
)
370391

371392
def record_tool(self, tool: str, cost_usd: float, *, label: str | None = None) -> float:

src/floe_guard/integrations/anthropic.py

Lines changed: 55 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,8 @@
2424
from ..guard import BudgetGuard
2525
from ..pricing import resolve_price
2626

27-
# Anthropic prompt-cache pricing multipliers, relative to the base input rate:
28-
# 5-minute cache writes bill at ~1.25x, cache reads at ~0.1x. See _usage_from.
29-
_CACHE_WRITE_WEIGHT = 1.25
30-
_CACHE_READ_WEIGHT = 0.1
27+
# Anthropic prompt-cache pricing is now handled natively in the core (guard.py / pricing.py).
28+
# We extract the buckets and pass them through.
3129

3230

3331
def _model_from(kwargs: dict[str, Any], response: Any) -> str:
@@ -41,38 +39,46 @@ def _model_from(kwargs: dict[str, Any], response: Any) -> str:
4139
return str(model or "")
4240

4341

44-
def _usage_from(response: Any) -> tuple[int, int]:
45-
"""Map an Anthropic message's usage onto (prompt_tokens, completion_tokens).
42+
def _usage_from(response: Any) -> tuple[int, int, int, int, int]:
43+
"""Map an Anthropic message's usage onto token buckets.
4644
47-
Anthropic reports ``usage.input_tokens`` / ``usage.output_tokens``; the guard
48-
settles on the OpenAI-style ``(prompt_tokens, completion_tokens)`` pair, so
49-
input maps to prompt and output to completion.
50-
51-
Prompt caching: ``input_tokens`` counts only the *fresh* prompt tokens —
52-
cached tokens are reported separately as ``cache_creation_input_tokens``
53-
(billed ~1.25x base input) and ``cache_read_input_tokens`` (~0.1x). Dropping
54-
them would under-meter a cached call, and a budget guard must never
55-
under-count. Since the guard prices with a flat per-model input rate, we fold
56-
the cache tokens into the prompt bucket at their *effective* weight so the
57-
metered cost approximates the real spend (an estimate, not exact cache-aware
58-
pricing — safe-direction).
45+
Anthropic reports ``usage.input_tokens`` / ``usage.output_tokens``.
46+
Prompt caching tokens are reported as ``cache_creation_input_tokens``
47+
and ``cache_read_input_tokens``. We return all five buckets so the
48+
core pricing engine can apply exact cost multipliers.
5949
"""
6050
usage = getattr(response, "usage", None)
6151
if usage is None and isinstance(response, dict):
6252
usage = response.get("usage")
6353
if usage is None:
64-
return 0, 0
54+
return 0, 0, 0, 0, 0
6555
get = usage.get if isinstance(usage, dict) else lambda k, d=0: getattr(usage, k, d)
6656
input_tokens = int(get("input_tokens", 0) or 0)
6757
output_tokens = int(get("output_tokens", 0) or 0)
68-
cache_write = int(get("cache_creation_input_tokens", 0) or 0)
58+
cache_write_total = int(get("cache_creation_input_tokens", 0) or 0)
6959
cache_read = int(get("cache_read_input_tokens", 0) or 0)
70-
prompt_tokens = (
71-
input_tokens
72-
+ round(cache_write * _CACHE_WRITE_WEIGHT)
73-
+ round(cache_read * _CACHE_READ_WEIGHT)
74-
)
75-
return prompt_tokens, output_tokens
60+
61+
# Resolve TTL buckets from `cache_creation` if present
62+
cache_creation = get("cache_creation", None)
63+
cache_write_5m = 0
64+
cache_write_1h = 0
65+
if cache_creation is not None:
66+
get_cc = (
67+
cache_creation.get
68+
if isinstance(cache_creation, dict)
69+
else lambda k, d=0: getattr(cache_creation, k, d)
70+
)
71+
cache_write_5m = int(get_cc("ephemeral_5m_input_tokens", 0) or 0)
72+
cache_write_1h = int(get_cc("ephemeral_1h_input_tokens", 0) or 0)
73+
74+
# Fallback/defense-in-depth: if there are missing or future buckets, allocate
75+
# any leftover cache_creation tokens to the most expensive known bucket (1h) so
76+
# the guard over-counts rather than under-meters spend it can't fully attribute.
77+
leftover = cache_write_total - (cache_write_5m + cache_write_1h)
78+
if leftover > 0:
79+
cache_write_1h += leftover
80+
81+
return input_tokens, output_tokens, cache_write_5m, cache_write_1h, cache_read
7682

7783

7884
def _settle_model(guard: BudgetGuard, kwargs: dict[str, Any], response: Any) -> str:
@@ -103,16 +109,36 @@ def _record_response(
103109
if not isinstance(kwargs, dict):
104110
kwargs = {}
105111
model = _settle_model(guard, kwargs, response)
106-
prompt_tokens, completion_tokens = _usage_from(response)
107-
if prompt_tokens <= 0 and completion_tokens <= 0:
112+
(
113+
prompt_tokens,
114+
completion_tokens,
115+
cache_creation_5m,
116+
cache_creation_1h,
117+
cache_read,
118+
) = _usage_from(response)
119+
if (
120+
prompt_tokens <= 0
121+
and completion_tokens <= 0
122+
and cache_creation_5m <= 0
123+
and cache_creation_1h <= 0
124+
and cache_read <= 0
125+
):
108126
# No tokens spent — free the reservation.
109127
guard.release(reserved)
110128
return
111129
# There IS spend to account for. Route it through settle() even when the
112130
# model id is missing, so the guard's policy applies (fail-closed → warn +
113131
# raise; fail-open → warn + skip) rather than letting a completed call go
114132
# unmetered and skew the next check().
115-
guard.settle(model, prompt_tokens, completion_tokens, reserved=reserved)
133+
guard.settle(
134+
model,
135+
prompt_tokens,
136+
completion_tokens,
137+
reserved=reserved,
138+
cache_creation_input_tokens=cache_creation_5m,
139+
cache_creation_input_tokens_1h=cache_creation_1h,
140+
cache_read_input_tokens=cache_read,
141+
)
116142

117143

118144
def _reject_streaming(kwargs: dict[str, Any]) -> None:

src/floe_guard/pricing.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,13 @@ def resolve_price(
132132
return None
133133

134134

135+
# Anthropic prompt-cache pricing multipliers: creation (5m) is 1.25x base input,
136+
# creation (1h) is 2.0x base input, read is 0.1x base input.
137+
_CACHE_CREATION_MULTIPLIER = 1.25
138+
_CACHE_CREATION_1H_MULTIPLIER = 2.00
139+
_CACHE_READ_MULTIPLIER = 0.10
140+
141+
135142
def _both_finite(a: Any, b: Any) -> bool:
136143
return (
137144
isinstance(a, (int, float))
@@ -141,11 +148,33 @@ def _both_finite(a: Any, b: Any) -> bool:
141148
)
142149

143150

144-
def price_tokens(priced: PricedModel, prompt_tokens: int, completion_tokens: int) -> float:
151+
def price_tokens(
152+
priced: PricedModel,
153+
prompt_tokens: int,
154+
completion_tokens: int,
155+
*,
156+
cache_creation_input_tokens: int = 0,
157+
cache_read_input_tokens: int = 0,
158+
cache_creation_input_tokens_1h: int = 0,
159+
) -> float:
145160
"""USD cost for token usage. Negative counts are clamped to zero."""
146161
p = max(0, prompt_tokens)
147162
c = max(0, completion_tokens)
148-
cost = p * priced.input_cost_per_token + c * priced.output_cost_per_token
163+
cc = max(0, cache_creation_input_tokens)
164+
cc_1h = max(0, cache_creation_input_tokens_1h)
165+
cr = max(0, cache_read_input_tokens)
166+
167+
cache_creation_cost = cc * priced.input_cost_per_token * _CACHE_CREATION_MULTIPLIER
168+
cache_creation_1h_cost = cc_1h * priced.input_cost_per_token * _CACHE_CREATION_1H_MULTIPLIER
169+
cache_read_cost = cr * priced.input_cost_per_token * _CACHE_READ_MULTIPLIER
170+
171+
cost = (
172+
(p * priced.input_cost_per_token)
173+
+ (c * priced.output_cost_per_token)
174+
+ cache_creation_cost
175+
+ cache_creation_1h_cost
176+
+ cache_read_cost
177+
)
149178
if not math.isfinite(cost):
150179
# Defense-in-depth: resolve_price already guarantees finite rates.
151180
raise ValueError("Non-finite LLM cost — pricing entry is invalid")

tests/test_anthropic_adapter.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,24 +65,55 @@ def __init__(self, messages: object) -> None:
6565
_MODEL = "claude-3-7-sonnet-20250219" # present in the bundled cost map
6666

6767

68-
def test_usage_maps_input_output_to_prompt_completion() -> None:
69-
# The Anthropic-specific bit: input_tokens -> prompt, output_tokens -> completion.
70-
assert _usage_from(_Response(_MODEL, _Usage(5, 7))) == (5, 7)
71-
assert _usage_from({"usage": {"input_tokens": 5, "output_tokens": 7}}) == (5, 7)
68+
def test_usage_maps_input_output_and_cache_buckets() -> None:
69+
# Anthropic-specific: input_tokens -> prompt, output_tokens -> completion, plus cache buckets
70+
assert _usage_from(_Response(_MODEL, _Usage(5, 7))) == (5, 7, 0, 0, 0)
71+
assert _usage_from({"usage": {"input_tokens": 5, "output_tokens": 7}}) == (5, 7, 0, 0, 0)
7272

7373

74-
def test_usage_folds_prompt_cache_tokens() -> None:
75-
# Cached calls must not be under-metered: cache write ~1.25x, read ~0.1x,
76-
# folded into the prompt bucket. 100 + round(200*1.25) + round(1000*0.1).
74+
def test_usage_extracts_prompt_cache_tokens() -> None:
75+
# Cached calls must return buckets unmodified so pricing engine can multiply them.
7776
usage = {
7877
"input_tokens": 100,
7978
"output_tokens": 50,
8079
"cache_creation_input_tokens": 200,
8180
"cache_read_input_tokens": 1000,
8281
}
83-
assert _usage_from({"usage": usage}) == (100 + 250 + 100, 50)
82+
# Direct fallback: no per-TTL cache_creation breakdown, so the leftover
83+
# cache_creation tokens default to the most expensive known bucket (1h) —
84+
# defense-in-depth so the guard over-counts rather than under-meters.
85+
assert _usage_from({"usage": usage}) == (100, 50, 0, 200, 1000)
86+
87+
# Detailed cache_creation breakdown with 5m and 1h TTLs
88+
usage_with_breakdown = {
89+
"input_tokens": 100,
90+
"output_tokens": 50,
91+
"cache_creation_input_tokens": 300,
92+
"cache_read_input_tokens": 1000,
93+
"cache_creation": {
94+
"ephemeral_5m_input_tokens": 100,
95+
"ephemeral_1h_input_tokens": 200,
96+
}
97+
}
98+
assert _usage_from({"usage": usage_with_breakdown}) == (100, 50, 100, 200, 1000)
99+
100+
# Detailed cache_creation breakdown with leftover tokens mapped to the 1h
101+
# bucket (defense-in-depth: leftover goes to the most expensive known bucket).
102+
usage_with_leftover = {
103+
"input_tokens": 100,
104+
"output_tokens": 50,
105+
"cache_creation_input_tokens": 300,
106+
"cache_read_input_tokens": 1000,
107+
"cache_creation": {
108+
"ephemeral_5m_input_tokens": 50,
109+
"ephemeral_1h_input_tokens": 200,
110+
}
111+
}
112+
# 5m=50, 1h=200 → leftover 50 → 1h, so 5m=50, 1h=250.
113+
assert _usage_from({"usage": usage_with_leftover}) == (100, 50, 50, 250, 1000)
114+
84115
# No cache fields → unchanged (the object path getattr-defaults to 0).
85-
assert _usage_from(_Response(_MODEL, _Usage(5, 7))) == (5, 7)
116+
assert _usage_from(_Response(_MODEL, _Usage(5, 7))) == (5, 7, 0, 0, 0)
86117

87118

88119
def test_model_from_prefers_response_then_kwargs() -> None:

tests/test_pricing.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,34 @@ def test_price_tokens_math() -> None:
142142
def test_price_tokens_clamps_negative_counts() -> None:
143143
priced = PricedModel(input_cost_per_token=1e-6, output_cost_per_token=2e-6, source="cost_map")
144144
assert price_tokens(priced, -50, -50) == 0.0
145+
146+
147+
def test_price_tokens_with_prompt_caching_math() -> None:
148+
priced = PricedModel(input_cost_per_token=1e-6, output_cost_per_token=2e-6, source="cost_map")
149+
# Base input price: 1e-6
150+
# 5-minute write (1.25x): 100 * 1e-6 * 1.25 = 0.000125
151+
# 1-hour write (2.0x): 200 * 1e-6 * 2.0 = 0.0004
152+
# Read (0.1x): 1000 * 1e-6 * 0.1 = 0.0001
153+
# Regular prompt (0 tokens), Completion (0 tokens)
154+
expected_cost = 0.000125 + 0.0004 + 0.0001
155+
cost = price_tokens(
156+
priced,
157+
prompt_tokens=0,
158+
completion_tokens=0,
159+
cache_creation_input_tokens=100,
160+
cache_read_input_tokens=1000,
161+
cache_creation_input_tokens_1h=200,
162+
)
163+
assert cost == pytest.approx(expected_cost)
164+
165+
166+
def test_price_tokens_caching_constants() -> None:
167+
from floe_guard.pricing import (
168+
_CACHE_CREATION_1H_MULTIPLIER,
169+
_CACHE_CREATION_MULTIPLIER,
170+
_CACHE_READ_MULTIPLIER,
171+
)
172+
assert _CACHE_CREATION_MULTIPLIER == 1.25
173+
assert _CACHE_CREATION_1H_MULTIPLIER == 2.00
174+
assert _CACHE_READ_MULTIPLIER == 0.10
175+

0 commit comments

Comments
 (0)