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.
- maf_cachebench/__init__.py +236 -0
- maf_cachebench/__main__.py +8 -0
- maf_cachebench/_advise_cli.py +303 -0
- maf_cachebench/_advisor.py +461 -0
- maf_cachebench/_cli.py +456 -0
- maf_cachebench/_cli_selection.py +194 -0
- maf_cachebench/_fill.py +454 -0
- maf_cachebench/_live.py +2319 -0
- maf_cachebench/_live_cli.py +4371 -0
- maf_cachebench/_metrics.py +176 -0
- maf_cachebench/_providers.py +424 -0
- maf_cachebench/_recall.py +767 -0
- maf_cachebench/_recall_cli.py +238 -0
- maf_cachebench/_records.py +1771 -0
- maf_cachebench/_report.py +162 -0
- maf_cachebench/_run_identity.py +9 -0
- maf_cachebench/_runner.py +529 -0
- maf_cachebench/_strategies.py +745 -0
- maf_cachebench/_summary.py +335 -0
- maf_cachebench/_summary_cli.py +341 -0
- maf_cachebench/_tokenizers.py +200 -0
- maf_cachebench/_transcripts.py +387 -0
- maf_cachebench/_types.py +200 -0
- maf_cachebench/py.typed +0 -0
- maf_cachebench-0.1.0.dist-info/METADATA +125 -0
- maf_cachebench-0.1.0.dist-info/RECORD +29 -0
- maf_cachebench-0.1.0.dist-info/WHEEL +4 -0
- maf_cachebench-0.1.0.dist-info/entry_points.txt +7 -0
- maf_cachebench-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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.")
|