maf-cachebench 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,461 @@
1
+ """Pick the cheapest compaction strategy for one model, and say how sure we are.
2
+
3
+ Compaction is usually assumed to save money. It does not always: a model whose prompt
4
+ cache already covers most of each request loses more to the broken prefix than it gains
5
+ from the shorter prompt. Which way it falls is a property of the model, so the only
6
+ reliable way to know is to price both options against that model directly.
7
+
8
+ This module runs a strategy sweep for one model, prices its token usage, and returns a verdict.
9
+ It withholds recommendations when repeat variability is wider than the gap between options.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING, Any, Final, cast
17
+
18
+ import httpx
19
+
20
+ from ._metrics import clamp_cached_tokens, percentile
21
+
22
+ if TYPE_CHECKING:
23
+ from ._types import CellSummary
24
+
25
+ __all__ = [
26
+ "ModelPricing",
27
+ "StrategyCost",
28
+ "Verdict",
29
+ "advise",
30
+ "fetch_openrouter_pricing",
31
+ ]
32
+
33
+ #: Relative spread across repeats above which a strategy's cost is treated as unstable.
34
+ #: Set from measurement: the reproducible models varied by well under 1%, while the noisy
35
+ #: ones varied by 100% or more, so anything past a quarter is firmly in the noisy camp.
36
+ UNSTABLE_SPREAD: Final[float] = 0.25
37
+
38
+ #: Cost difference below which two strategies are called equivalent rather than ranked.
39
+ NEGLIGIBLE_SAVING: Final[float] = 0.05
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class ModelPricing:
44
+ """What a model charges per million tokens."""
45
+
46
+ input_per_million: float
47
+ cached_read_per_million: float
48
+ output_per_million: float = 0.0
49
+ """Generation rate. Zero for the replay benchmarks, which cap output at a few tokens
50
+ and study only the prompt side; a live run generates real replies and must price them."""
51
+ cache_write_per_million: float | None = None
52
+ """Rate for a prompt token the provider writes into its cache, ``None`` when it charges none.
53
+
54
+ Some models bill the uncached part of a prompt above the input rate, because that is the
55
+ part written into the cache for the next call to read -- gpt-6-luna at 1.25x. Pricing it at
56
+ the input rate would under-charge exactly what compaction causes, a broken prefix, and so
57
+ flatter the strategies that break it most. With automatic caching every uncached prompt
58
+ token is taken as written, which is what the provider bills when the prefix is long enough to
59
+ cache at all; a prompt too short to cache is billed at the input rate instead and is
60
+ over-charged here by the premium, which on this benchmark's prompts does not arise."""
61
+ long_context_threshold: int | None = None
62
+ """Input tokens above which a whole request is billed at :attr:`long_context`'s rates.
63
+
64
+ Some models price by request size: gpt-6-luna bills a request with more than 272,000 input
65
+ tokens at its long-context rates for every token of it, cached reads, cache writes and
66
+ output included, not only for the tokens past the line. ``None`` for a model with one rate.
67
+ """
68
+ long_context: ModelPricing | None = None
69
+ """The rates a request above :attr:`long_context_threshold` is billed at, ``None`` for none."""
70
+
71
+ def __post_init__(self) -> None:
72
+ if self.long_context_threshold is not None and self.long_context_threshold <= 0:
73
+ raise ValueError("long_context_threshold must be greater than 0.")
74
+ for name in (
75
+ "input_per_million",
76
+ "cached_read_per_million",
77
+ "output_per_million",
78
+ "cache_write_per_million",
79
+ ):
80
+ value = getattr(self, name)
81
+ if value is not None and (not math.isfinite(value) or value < 0):
82
+ raise ValueError(f"{name} must be finite and non-negative.")
83
+
84
+ @property
85
+ def cache_discount(self) -> float:
86
+ """Fraction off the input price that a cache hit earns, 0.0 when there is none."""
87
+ if self.input_per_million <= 0:
88
+ return 0.0
89
+ return max(0.0, 1.0 - self.cached_read_per_million / self.input_per_million)
90
+
91
+ def input_cost(self, input_tokens: int, cached_tokens: int) -> float:
92
+ """Return what a prompt cost, cached and uncached together and output excluded.
93
+
94
+ Here rather than at each caller because a total and its input half that were computed
95
+ by two expressions could disagree about the cached share, and the whole use of the
96
+ input half is that it is the same money read on one axis.
97
+
98
+ Args:
99
+ input_tokens: Every token billed on the prompt side, cached ones included.
100
+ cached_tokens: How many of those the provider served from its cache.
101
+
102
+ Returns:
103
+ Cost in the pricing's currency units.
104
+ """
105
+ cached_tokens = clamp_cached_tokens(input_tokens, cached_tokens)
106
+ fresh = max(input_tokens - cached_tokens, 0)
107
+ return (
108
+ fresh * self.fresh_per_million + cached_tokens * self.cached_read_per_million
109
+ ) / 1_000_000
110
+
111
+ def tier(self, input_tokens: int) -> ModelPricing:
112
+ """Return the rates one request of this many input tokens is billed at."""
113
+ if (
114
+ self.long_context is not None
115
+ and self.long_context_threshold is not None
116
+ and input_tokens > self.long_context_threshold
117
+ ):
118
+ return self.long_context
119
+ return self
120
+
121
+ def tiered_input_cost(
122
+ self,
123
+ input_tokens: int,
124
+ cached_tokens: int,
125
+ *,
126
+ long_input_tokens: int = 0,
127
+ long_cached_tokens: int = 0,
128
+ ) -> float:
129
+ """Return what a run's prompt side cost, when some of its requests were long-context.
130
+
131
+ Args:
132
+ input_tokens: Every input token the run billed, long-context requests included.
133
+ cached_tokens: How many of those were served from cache.
134
+
135
+ Keyword Args:
136
+ long_input_tokens: The input tokens of the requests above the threshold.
137
+ long_cached_tokens: How many of those were served from cache.
138
+
139
+ Returns:
140
+ Cost in the pricing's currency units.
141
+ """
142
+ rates = self.long_context or self
143
+ return self.input_cost(
144
+ input_tokens - long_input_tokens, cached_tokens - long_cached_tokens
145
+ ) + (rates.input_cost(long_input_tokens, long_cached_tokens))
146
+
147
+ def tiered_cost(
148
+ self,
149
+ input_tokens: int,
150
+ cached_tokens: int,
151
+ output_tokens: int,
152
+ *,
153
+ long_input_tokens: int = 0,
154
+ long_cached_tokens: int = 0,
155
+ long_output_tokens: int = 0,
156
+ ) -> float:
157
+ """Return what a run cost, prompt and output, splitting its long-context requests out.
158
+
159
+ Args:
160
+ input_tokens: Every input token billed.
161
+ cached_tokens: How many of those were served from cache.
162
+ output_tokens: Every output token billed.
163
+
164
+ Keyword Args:
165
+ long_input_tokens: Input tokens of the requests above the threshold.
166
+ long_cached_tokens: How many of those were served from cache.
167
+ long_output_tokens: Output tokens of those requests.
168
+
169
+ Returns:
170
+ Cost in the pricing's currency units.
171
+ """
172
+ rates = self.long_context or self
173
+ return (
174
+ self.tiered_input_cost(
175
+ input_tokens,
176
+ cached_tokens,
177
+ long_input_tokens=long_input_tokens,
178
+ long_cached_tokens=long_cached_tokens,
179
+ )
180
+ + (output_tokens - long_output_tokens) * self.output_per_million / 1_000_000
181
+ + long_output_tokens * rates.output_per_million / 1_000_000
182
+ )
183
+
184
+ @property
185
+ def fresh_per_million(self) -> float:
186
+ """Return the rate an uncached prompt token is billed at: the cache-write rate when set."""
187
+ return (
188
+ self.cache_write_per_million
189
+ if self.cache_write_per_million is not None
190
+ else self.input_per_million
191
+ )
192
+
193
+ def describe(self) -> str:
194
+ """Return the rates as the reports print them, the cache-write rate only when set."""
195
+ text = (
196
+ f"${self.input_per_million:.2f}/M in, ${self.cached_read_per_million:.3f}/M cached, "
197
+ f"${self.output_per_million:.2f}/M out"
198
+ )
199
+ if self.cache_write_per_million is not None:
200
+ text += (
201
+ f", ${self.cache_write_per_million:.3f}/M cache write "
202
+ "(charged on every uncached input token)"
203
+ )
204
+ if self.long_context is not None and self.long_context_threshold is not None:
205
+ text += (
206
+ f"; a request over {self.long_context_threshold:,} input tokens bills whole at "
207
+ f"{self.long_context.describe()}"
208
+ )
209
+ return text
210
+
211
+
212
+ @dataclass(frozen=True, slots=True)
213
+ class StrategyCost:
214
+ """Measured cost of one strategy across its repeats."""
215
+
216
+ strategy: str
217
+ costs: tuple[float, ...]
218
+ total_input_tokens: int
219
+ total_cached_tokens: int
220
+ cache_reported: bool
221
+
222
+ @property
223
+ def median(self) -> float:
224
+ """Median cost across repeats, the figure the verdict ranks on."""
225
+ return percentile(self.costs, 0.5) or 0.0
226
+
227
+ @property
228
+ def spread(self) -> float:
229
+ """Relative gap between the cheapest and dearest repeat.
230
+
231
+ Zero when a single repeat was run, which is why the advisor warns separately about
232
+ having nothing to measure stability with.
233
+ """
234
+ if not self.costs or self.median <= 0:
235
+ return 0.0
236
+ return (max(self.costs) - min(self.costs)) / self.median
237
+
238
+ @property
239
+ def hit_rate(self) -> float | None:
240
+ """Share of input tokens served from cache, or None when unreported."""
241
+ if not self.cache_reported or self.total_input_tokens <= 0:
242
+ return None
243
+ return self.total_cached_tokens / self.total_input_tokens
244
+
245
+
246
+ @dataclass(frozen=True, slots=True)
247
+ class Verdict:
248
+ """The recommendation, its confidence, and the reasoning behind it."""
249
+
250
+ recommended: str
251
+ baseline: StrategyCost
252
+ best: StrategyCost
253
+ contender: StrategyCost
254
+ ranked: tuple[StrategyCost, ...]
255
+ confidence: str
256
+ rationale: str
257
+
258
+ @property
259
+ def saving_fraction(self) -> float:
260
+ """What the cheapest compaction option saves against not compacting.
261
+
262
+ Negative means every compaction option is dearer than leaving it off.
263
+ """
264
+ if self.baseline.median <= 0:
265
+ return 0.0
266
+ return (self.baseline.median - self.contender.median) / self.baseline.median
267
+
268
+
269
+ def cost_of(summary: CellSummary, pricing: ModelPricing) -> float:
270
+ """Return what one replay of a cell costs in input charges.
271
+
272
+ Cached tokens use the read rate; fresh ones use the cache-write rate when set. Output is
273
+ ignored: the benchmark caps generation at a handful of tokens because only the prompt
274
+ side is under study.
275
+
276
+ Args:
277
+ summary: One cell's measured usage.
278
+ pricing: The model's per-million rates.
279
+
280
+ Returns:
281
+ Cost in the pricing's currency units.
282
+ """
283
+ cached = (
284
+ clamp_cached_tokens(summary.total_input_tokens, summary.total_cached_tokens)
285
+ if summary.reports_cache_tokens
286
+ else 0
287
+ )
288
+ return pricing.input_cost(summary.total_input_tokens, cached)
289
+
290
+
291
+ def _collect(summaries: list[CellSummary], pricing: ModelPricing) -> list[StrategyCost]:
292
+ """Group cell summaries by strategy and price each group."""
293
+ grouped: dict[str, list[CellSummary]] = {}
294
+ for summary in summaries:
295
+ grouped.setdefault(summary.cell.strategy, []).append(summary)
296
+ collected: list[StrategyCost] = []
297
+ for strategy, cells in grouped.items():
298
+ usable = [
299
+ cell
300
+ for cell in cells
301
+ if cell.turns > 0
302
+ and cell.total_input_tokens > 0
303
+ and not cell.errors
304
+ and not cell.turns_missing_input
305
+ ]
306
+ if not usable:
307
+ continue
308
+ collected.append(
309
+ StrategyCost(
310
+ strategy=strategy,
311
+ costs=tuple(cost_of(cell, pricing) for cell in usable),
312
+ total_input_tokens=sum(cell.total_input_tokens for cell in usable),
313
+ total_cached_tokens=sum(
314
+ clamp_cached_tokens(cell.total_input_tokens, cell.total_cached_tokens)
315
+ for cell in usable
316
+ ),
317
+ cache_reported=all(cell.reports_cache_tokens for cell in usable),
318
+ )
319
+ )
320
+ return collected
321
+
322
+
323
+ def advise(
324
+ summaries: list[CellSummary], pricing: ModelPricing, *, baseline: str = "none"
325
+ ) -> Verdict:
326
+ """Rank strategies by measured cost and recommend one.
327
+
328
+ Args:
329
+ summaries: Every cell measured for a single model.
330
+ pricing: That model's rates.
331
+
332
+ Keyword Args:
333
+ baseline: Strategy representing "no compaction", which every other option is
334
+ judged against.
335
+
336
+ Returns:
337
+ The verdict, whose ``confidence`` is ``"inconclusive"`` when the measurements
338
+ cannot support a recommendation.
339
+
340
+ Raises:
341
+ ValueError: If priced cells lack the baseline or a compaction alternative.
342
+ """
343
+ collected = _collect(summaries, pricing)
344
+ if not collected:
345
+ raise ValueError("No cells with usable token counts to price.")
346
+ by_name = {entry.strategy: entry for entry in collected}
347
+ if baseline not in by_name:
348
+ raise ValueError(f"Baseline strategy {baseline!r} is missing; measured: {sorted(by_name)}")
349
+
350
+ base = by_name[baseline]
351
+ ranked = tuple(sorted(collected, key=lambda entry: entry.median))
352
+ best = ranked[0]
353
+
354
+ # The comparison that decides the verdict is baseline versus the cheapest *compacted*
355
+ # option — not baseline versus the overall cheapest. When the baseline already wins,
356
+ # those are the same entry and their difference is zero, which would otherwise be
357
+ # reported as "every option ties with not compacting" even though the alternatives
358
+ # might be 50% dearer.
359
+ compacted = [entry for entry in collected if entry.strategy != baseline]
360
+ if not compacted:
361
+ raise ValueError("At least one compaction strategy is required for a comparison.")
362
+ contender = min(compacted, key=lambda entry: entry.median)
363
+
364
+ # A cost gap smaller than repeat variability cannot support a ranking.
365
+ worst_spread = max(base.spread, contender.spread)
366
+ single_sample = min(len(base.costs), len(contender.costs)) < 2
367
+ saving = (base.median - contender.median) / base.median if base.median > 0 else 0.0
368
+
369
+ if not base.cache_reported or not contender.cache_reported:
370
+ confidence = "low"
371
+ rationale = (
372
+ "Some repeats reported no cache statistics, so their cost assumes no discount. "
373
+ "Incomplete cache telemetry can bias the comparison in either direction."
374
+ )
375
+ elif single_sample:
376
+ confidence = "low"
377
+ rationale = (
378
+ "Only one repeat per strategy, so nothing measures stability. Re-run with --repeats 3."
379
+ )
380
+ elif worst_spread > UNSTABLE_SPREAD or worst_spread > abs(saving):
381
+ confidence = "inconclusive"
382
+ rationale = (
383
+ f"Repeats varied by {worst_spread:.0%} against a {abs(saving):.0%} cost gap. "
384
+ "The measurements are too erratic to rank: spread must stay within both "
385
+ f"the cost gap and the {UNSTABLE_SPREAD:.0%} stability threshold."
386
+ )
387
+ elif saving > NEGLIGIBLE_SAVING:
388
+ confidence = "high"
389
+ rationale = (
390
+ f"{contender.strategy!r} is {saving:.0%} cheaper than not compacting, well beyond the "
391
+ f"{worst_spread:.0%} spread between repeats."
392
+ )
393
+ elif saving < -NEGLIGIBLE_SAVING:
394
+ confidence = "high"
395
+ rationale = (
396
+ f"The cheapest compaction option, {contender.strategy!r}, costs {-saving:.0%} MORE "
397
+ f"than not "
398
+ f"compacting: this model's cache already covers {base.hit_rate or 0:.0%} of each "
399
+ f"request, and "
400
+ "compaction breaks that discount to save fewer tokens than it forfeits."
401
+ )
402
+ else:
403
+ confidence = "high"
404
+ rationale = (
405
+ f"The cheapest compaction option is within {abs(saving):.0%} of not compacting, so "
406
+ f"cost is not "
407
+ "a reason to choose between them. Decide on context-overflow safety instead."
408
+ )
409
+
410
+ recommended = contender.strategy if saving > NEGLIGIBLE_SAVING else base.strategy
411
+ return Verdict(
412
+ recommended=recommended,
413
+ baseline=base,
414
+ best=best,
415
+ contender=contender,
416
+ ranked=ranked,
417
+ confidence=confidence,
418
+ rationale=rationale,
419
+ )
420
+
421
+
422
+ def fetch_openrouter_pricing(model: str, *, timeout: float = 30.0) -> ModelPricing:
423
+ """Look up a model's rates from OpenRouter's public catalogue.
424
+
425
+ Args:
426
+ model: An OpenRouter model slug, such as ``openai/gpt-5.6-luna``.
427
+
428
+ Keyword Args:
429
+ timeout: Seconds to wait for the catalogue.
430
+
431
+ Returns:
432
+ The model's input and cached-read rates per million tokens.
433
+
434
+ Raises:
435
+ KeyError: If the slug is not in the catalogue.
436
+ OSError: If the catalogue request fails.
437
+ """
438
+ try:
439
+ response = httpx.get("https://openrouter.ai/api/v1/models", timeout=timeout)
440
+ response.raise_for_status()
441
+ except httpx.HTTPError as error:
442
+ raise OSError(f"OpenRouter catalogue request failed: {error}") from error
443
+ catalogue = cast("dict[str, Any]", response.json())
444
+ for entry in cast("list[dict[str, Any]]", catalogue["data"]):
445
+ if entry["id"] != model:
446
+ continue
447
+ pricing = cast("dict[str, Any]", entry.get("pricing") or {})
448
+ input_price = float(pricing.get("prompt") or 0.0) * 1_000_000
449
+ # A missing input_cache_read means the model advertises no cache discount at all —
450
+ # 142 of OpenRouter's 417 paid models are in that position. Reading the absent field
451
+ # as zero would price cache reads as free, inventing a 100% discount for exactly the
452
+ # models that have none, and biasing the verdict against compacting them.
453
+ cached_raw = pricing.get("input_cache_read")
454
+ cached_price = float(cached_raw) * 1_000_000 if cached_raw is not None else input_price
455
+ output_price = float(pricing.get("completion") or 0.0) * 1_000_000
456
+ return ModelPricing(
457
+ input_per_million=input_price,
458
+ cached_read_per_million=cached_price,
459
+ output_per_million=output_price,
460
+ )
461
+ raise KeyError(f"{model!r} is not in the OpenRouter catalogue.")