model-prices 0.0.1__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.
- model_prices/__init__.py +606 -0
- model_prices/__main__.py +3 -0
- model_prices/backfill.py +211 -0
- model_prices/checks.py +419 -0
- model_prices/data/LICENSE.models.dev +21 -0
- model_prices/data/aliases.toml +19 -0
- model_prices/data/corrections.toml +186 -0
- model_prices/data/plans.toml +122 -0
- model_prices/data/prices.json +5345 -0
- model_prices/data/research_corrections.toml +632 -0
- model_prices/data/schedules.toml +65 -0
- model_prices/genai_prices.py +103 -0
- model_prices-0.0.1.dist-info/METADATA +128 -0
- model_prices-0.0.1.dist-info/RECORD +18 -0
- model_prices-0.0.1.dist-info/WHEEL +4 -0
- model_prices-0.0.1.dist-info/entry_points.txt +2 -0
- model_prices-0.0.1.dist-info/licenses/LICENSE +21 -0
- model_prices-0.0.1.dist-info/licenses/src/model_prices/data/LICENSE.models.dev +21 -0
model_prices/__init__.py
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
"""Effective-dated LLM API prices from models.dev history, with sourced corrections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from bisect import bisect_right
|
|
7
|
+
from dataclasses import asdict, dataclass, replace
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from functools import cache
|
|
10
|
+
import hashlib
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
from importlib.resources import files
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
import tomllib
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
# Search order for model names given without a known provider.
|
|
21
|
+
LAB_PROVIDERS = (
|
|
22
|
+
"anthropic", "openai", "google", "zai", "deepseek", "xai", "moonshotai", "alibaba",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_EFFORT_SUFFIX = re.compile(r"-(minimal|low|medium|high|xhigh|max|thinking)$")
|
|
26
|
+
# Dated snapshots: -2026-04-23 (OpenAI), -20251001 (Anthropic), -0731 (DeepSeek).
|
|
27
|
+
_DATE_SUFFIX = re.compile(r"-(\d{4}-\d{2}-\d{2}|\d{8}|\d{4})$")
|
|
28
|
+
# Context-window markers that tools append, such as Claude Code's "[1m]".
|
|
29
|
+
_CONTEXT_MARKER = re.compile(r"\[[^\]]*\]$")
|
|
30
|
+
# xAI's subscription endpoint, used by Grok Build, reports grok-4.6 as grok-4.6-build:
|
|
31
|
+
# a server-side alias, not a separate model. grok-build-0.1 is a separate model.
|
|
32
|
+
_GROK_BUILD_ALIAS = re.compile(r"^(grok-\d+\.\d+)(-build)$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Tier:
|
|
37
|
+
above: int
|
|
38
|
+
input: float | None = None
|
|
39
|
+
output: float | None = None
|
|
40
|
+
cache_read: float | None = None
|
|
41
|
+
cache_write: float | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class CostBreakdown:
|
|
46
|
+
"""USD cost of one request by token type, and the long-context tier applied, if any."""
|
|
47
|
+
|
|
48
|
+
tier_above: int | None
|
|
49
|
+
input: float
|
|
50
|
+
output: float
|
|
51
|
+
cache_read: float
|
|
52
|
+
cache_write: float
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def total(self) -> float:
|
|
56
|
+
return self.input + self.output + self.cache_read + self.cache_write
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Rates:
|
|
61
|
+
"""USD per million tokens in effect for one model at one time."""
|
|
62
|
+
|
|
63
|
+
provider: str
|
|
64
|
+
model: str
|
|
65
|
+
valid_from: str
|
|
66
|
+
source: str
|
|
67
|
+
input: float
|
|
68
|
+
output: float
|
|
69
|
+
cache_read: float | None = None
|
|
70
|
+
cache_write: float | None = None
|
|
71
|
+
tiers: tuple[Tier, ...] = ()
|
|
72
|
+
# "peak" or "off_peak" for providers with time-of-day pricing, else None.
|
|
73
|
+
period: str | None = None
|
|
74
|
+
# Suffix text removed from the requested name to find this model, such as "-max".
|
|
75
|
+
removed_suffix: str = ""
|
|
76
|
+
# How valid_from was dated: "models.dev commit", which usually lags the provider's
|
|
77
|
+
# change by days, or "documented", a date from the correction's cited source.
|
|
78
|
+
valid_from_basis: str = "models.dev commit"
|
|
79
|
+
|
|
80
|
+
def price_key(self) -> tuple:
|
|
81
|
+
"""The prices alone, without provenance, for comparing rates from different sources."""
|
|
82
|
+
return (self.input, self.output, self.cache_read, self.cache_write, self.tiers,
|
|
83
|
+
self.period)
|
|
84
|
+
|
|
85
|
+
def breakdown(self, input_tokens: int = 0, output_tokens: int = 0,
|
|
86
|
+
cache_read_tokens: int = 0, cache_write_tokens: int = 0) -> CostBreakdown:
|
|
87
|
+
"""Price one request by token type; input_tokens excludes cache reads and writes.
|
|
88
|
+
|
|
89
|
+
Long-context tiers apply when the request's whole prompt exceeds the tier size.
|
|
90
|
+
Missing cache prices fall back to the input price.
|
|
91
|
+
"""
|
|
92
|
+
prompt = input_tokens + cache_read_tokens + cache_write_tokens
|
|
93
|
+
rates: dict[str, float | None] = {
|
|
94
|
+
"input": self.input, "output": self.output,
|
|
95
|
+
"cache_read": self.cache_read, "cache_write": self.cache_write,
|
|
96
|
+
}
|
|
97
|
+
applied = None
|
|
98
|
+
for tier in self.tiers:
|
|
99
|
+
if prompt > tier.above:
|
|
100
|
+
applied = tier.above
|
|
101
|
+
rates.update({
|
|
102
|
+
key: value for key, value in asdict(tier).items()
|
|
103
|
+
if key != "above" and value is not None
|
|
104
|
+
})
|
|
105
|
+
input_rate = rates["input"]
|
|
106
|
+
cache_read = rates["cache_read"] if rates["cache_read"] is not None else input_rate
|
|
107
|
+
cache_write = rates["cache_write"] if rates["cache_write"] is not None else input_rate
|
|
108
|
+
return CostBreakdown(
|
|
109
|
+
tier_above=applied,
|
|
110
|
+
input=input_tokens * input_rate / 1e6,
|
|
111
|
+
output=output_tokens * rates["output"] / 1e6,
|
|
112
|
+
cache_read=cache_read_tokens * cache_read / 1e6,
|
|
113
|
+
cache_write=cache_write_tokens * cache_write / 1e6,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def cost(self, input_tokens: int = 0, output_tokens: int = 0,
|
|
117
|
+
cache_read_tokens: int = 0, cache_write_tokens: int = 0) -> float:
|
|
118
|
+
"""Price one request in USD; input_tokens excludes cache reads and writes."""
|
|
119
|
+
return self.breakdown(input_tokens, output_tokens, cache_read_tokens,
|
|
120
|
+
cache_write_tokens).total
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@cache
|
|
124
|
+
def _prices() -> dict[str, Any]:
|
|
125
|
+
return json.loads(files(__package__).joinpath("data/prices.json").read_text())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@cache
|
|
129
|
+
def _config(name: str) -> dict[str, Any]:
|
|
130
|
+
return tomllib.loads(files(__package__).joinpath(f"data/{name}").read_text())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _parse_time(value: datetime | str | None) -> datetime:
|
|
134
|
+
if value is None:
|
|
135
|
+
return datetime.now(timezone.utc)
|
|
136
|
+
parsed = (
|
|
137
|
+
value if isinstance(value, datetime)
|
|
138
|
+
else datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
139
|
+
)
|
|
140
|
+
if parsed.tzinfo is None:
|
|
141
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
142
|
+
return parsed.astimezone(timezone.utc)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def data_as_of() -> datetime:
|
|
146
|
+
"""The date of the models.dev commit the price history was built from.
|
|
147
|
+
|
|
148
|
+
The history is rebuilt only when a tracked price changes, so this is when models.dev
|
|
149
|
+
last changed a tracked price as of the build. Rates for requests after this time
|
|
150
|
+
assume no price has changed since; callers can mark such costs provisional or warn
|
|
151
|
+
when the installed data is old.
|
|
152
|
+
"""
|
|
153
|
+
return _parse_time(_prices()["source_committed_at"])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@cache
|
|
157
|
+
def pricing_basis() -> str:
|
|
158
|
+
"""Identify the price data, for recording alongside computed costs.
|
|
159
|
+
|
|
160
|
+
Names the package version and models.dev commit, plus a hash of every data file,
|
|
161
|
+
so a change to corrections, schedules, or aliases also changes the basis.
|
|
162
|
+
"""
|
|
163
|
+
digest = hashlib.sha256()
|
|
164
|
+
for item in sorted(files(__package__).joinpath("data").iterdir(), key=lambda f: f.name):
|
|
165
|
+
digest.update(item.name.encode() + b"\0" + item.read_bytes())
|
|
166
|
+
return (f"model-prices-{_version()}+models.dev@{_prices()['source_commit'][:12]}"
|
|
167
|
+
f"+synced@{data_as_of().date().isoformat()}+data@{digest.hexdigest()[:12]}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _known(provider: str, model: str) -> bool:
|
|
171
|
+
data = _prices()
|
|
172
|
+
return (model in data["providers"].get(provider, {})
|
|
173
|
+
or model in data["links"].get(provider, {}))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _candidates(name: str, strict: bool) -> list[tuple[str, str]]:
|
|
177
|
+
"""Names to look up, with the suffix text removed to reach each, most exact first."""
|
|
178
|
+
found = [(name, "")]
|
|
179
|
+
if strict:
|
|
180
|
+
return found
|
|
181
|
+
marker = _CONTEXT_MARKER.search(name)
|
|
182
|
+
if marker:
|
|
183
|
+
found.append((name[:marker.start()], marker.group(0)))
|
|
184
|
+
# Anthropic ids write versions with hyphens: claude-sonnet-4.5 is claude-sonnet-4-5.
|
|
185
|
+
found += [(candidate.replace(".", "-"), removed) for candidate, removed in found
|
|
186
|
+
if candidate.startswith("claude-") and "." in candidate]
|
|
187
|
+
for pattern in (_EFFORT_SUFFIX, _DATE_SUFFIX):
|
|
188
|
+
for candidate, removed in list(found):
|
|
189
|
+
match = pattern.search(candidate)
|
|
190
|
+
if match:
|
|
191
|
+
found.append((candidate[:match.start()], match.group(0) + removed))
|
|
192
|
+
# After suffix removal, so grok-4.6-build-high reaches grok-4.6 as well.
|
|
193
|
+
found += [(match.group(1), match.group(2) + removed) for candidate, removed in found
|
|
194
|
+
if (match := _GROK_BUILD_ALIAS.match(candidate))]
|
|
195
|
+
unique: dict[str, str] = {}
|
|
196
|
+
for candidate, removed in found:
|
|
197
|
+
unique.setdefault(candidate, removed)
|
|
198
|
+
return list(unique.items())
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@cache
|
|
202
|
+
def resolve_details(model: str, provider: str | None = None,
|
|
203
|
+
strict: bool = False) -> tuple[str, str, str] | None:
|
|
204
|
+
"""Like resolve, but also return the suffix text removed to find the model.
|
|
205
|
+
|
|
206
|
+
The removed text is "" for an exact, alias, or first-party match, and otherwise
|
|
207
|
+
names what was dropped, such as "-max" or "-2026-04-23", so callers can record
|
|
208
|
+
that the price belongs to a related name.
|
|
209
|
+
"""
|
|
210
|
+
aliases = _config("aliases.toml")
|
|
211
|
+
name = re.sub(r"[\s_]+", "-", re.sub(r"[()]", "", model.strip().lower())).strip("-")
|
|
212
|
+
if "/" in name:
|
|
213
|
+
prefix, name = name.split("/", 1)
|
|
214
|
+
provider = provider or prefix
|
|
215
|
+
hint = aliases["providers"].get((provider or "").lower(), (provider or "").lower())
|
|
216
|
+
|
|
217
|
+
candidates = _candidates(name, strict)
|
|
218
|
+
for candidate, removed in candidates:
|
|
219
|
+
if candidate in aliases["models"]:
|
|
220
|
+
target_provider, target_model = aliases["models"][candidate].split("/", 1)
|
|
221
|
+
return target_provider, target_model, removed
|
|
222
|
+
order = ([hint] if hint in _prices()["providers"] else []) + [
|
|
223
|
+
lab for lab in LAB_PROVIDERS if lab != hint
|
|
224
|
+
]
|
|
225
|
+
# Prefer the model's own lab over another lab that also serves a variant of it.
|
|
226
|
+
# A lab model name resolves to the first-party id that serves and prices it.
|
|
227
|
+
bases = _prices()["bases"]
|
|
228
|
+
for lab in order:
|
|
229
|
+
for candidate, removed in candidates:
|
|
230
|
+
if _known(lab, candidate):
|
|
231
|
+
return lab, candidate, removed
|
|
232
|
+
if candidate in bases.get(lab, {}):
|
|
233
|
+
return lab, bases[lab][candidate], removed
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def resolve(model: str, provider: str | None = None,
|
|
238
|
+
strict: bool = False) -> tuple[str, str] | None:
|
|
239
|
+
"""Map a log or harness model name to a models.dev (provider, model) pair.
|
|
240
|
+
|
|
241
|
+
strict=True skips removing effort, date, and context suffixes, for names that must
|
|
242
|
+
match exactly.
|
|
243
|
+
"""
|
|
244
|
+
found = resolve_details(model, provider, strict)
|
|
245
|
+
return found[:2] if found else None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _tiers(raw: list[dict[str, Any]] | None) -> tuple[Tier, ...]:
|
|
249
|
+
return tuple(Tier(**tier) for tier in raw or ())
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@cache
|
|
253
|
+
def _corrections(provider: str, model: str) -> tuple[tuple[datetime, datetime | None, dict], ...]:
|
|
254
|
+
return tuple(
|
|
255
|
+
(_parse_time(item["valid_from"]),
|
|
256
|
+
_parse_time(item["valid_until"]) if "valid_until" in item else None, item)
|
|
257
|
+
# Hand corrections first, so they take precedence over researched ones.
|
|
258
|
+
for name in ("corrections.toml", "research_corrections.toml")
|
|
259
|
+
for item in _config(name).get("correction", [])
|
|
260
|
+
if item["provider"] == provider and item["model"] == model
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@cache
|
|
265
|
+
def _entry_times(provider: str, model: str) -> tuple[datetime, ...]:
|
|
266
|
+
entries = _prices()["providers"].get(provider, {}).get(model, [])
|
|
267
|
+
return tuple(_parse_time(entry["valid_from"]) for entry in entries)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _correction(provider: str, model: str, at: datetime) -> Rates | None:
|
|
271
|
+
for start, end, item in _corrections(provider, model):
|
|
272
|
+
if start <= at and (end is None or at < end):
|
|
273
|
+
if "replaces" in item:
|
|
274
|
+
recorded = _history_rates(provider, model, at, None)
|
|
275
|
+
if recorded is None or any(getattr(recorded, key) != value
|
|
276
|
+
for key, value in item["replaces"].items()):
|
|
277
|
+
continue
|
|
278
|
+
return Rates(
|
|
279
|
+
provider=provider, model=model, valid_from=item["valid_from"],
|
|
280
|
+
source=item["source"], input=item["input"], output=item["output"],
|
|
281
|
+
cache_read=item.get("cache_read"), cache_write=item.get("cache_write"),
|
|
282
|
+
tiers=_tiers(item.get("tiers")), valid_from_basis="documented",
|
|
283
|
+
)
|
|
284
|
+
# An alias without its own history at this time takes the corrections of the model it
|
|
285
|
+
# points to, just as it takes that model's price history.
|
|
286
|
+
link = _link(provider, model, at)
|
|
287
|
+
return _correction(provider, link, at) if link else None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _link(provider: str, model: str, at: datetime) -> str | None:
|
|
291
|
+
"""The model an alias points to at `at`, when the alias has no price of its own then."""
|
|
292
|
+
link = _prices()["links"].get(provider, {}).get(model)
|
|
293
|
+
times = _entry_times(provider, model)
|
|
294
|
+
return link if link and (not times or at < times[0]) else None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _in_mode(corrected: Rates, standard: Rates, moded: Rates) -> Rates:
|
|
298
|
+
"""Corrected rates in a mode, scaled by the mode's ratio to models.dev's standard rates."""
|
|
299
|
+
def scaled(field: str) -> float | None:
|
|
300
|
+
value, base, mode_value = (getattr(item, field) for item in (corrected, standard, moded))
|
|
301
|
+
if value is None or not base or mode_value is None:
|
|
302
|
+
return value
|
|
303
|
+
return value * mode_value / base
|
|
304
|
+
return replace(corrected, input=scaled("input"), output=scaled("output"),
|
|
305
|
+
cache_read=scaled("cache_read"), cache_write=scaled("cache_write"), tiers=())
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _history_rates(provider: str, model: str, at: datetime, mode: str | None) -> Rates | None:
|
|
309
|
+
data = _prices()
|
|
310
|
+
entries = data["providers"].get(provider, {}).get(model, [])
|
|
311
|
+
times = _entry_times(provider, model)
|
|
312
|
+
link = _link(provider, model, at)
|
|
313
|
+
if link:
|
|
314
|
+
return _history_rates(provider, link, at, mode)
|
|
315
|
+
if not entries:
|
|
316
|
+
return None
|
|
317
|
+
# Usage before a model's first recorded price uses that first price.
|
|
318
|
+
entry = entries[max(bisect_right(times, at) - 1, 0)]
|
|
319
|
+
values = dict(entry["rates"])
|
|
320
|
+
tiers = values.pop("tiers", None)
|
|
321
|
+
modes = values.pop("modes", {})
|
|
322
|
+
if mode is not None:
|
|
323
|
+
if mode not in modes:
|
|
324
|
+
return None
|
|
325
|
+
values.update(modes[mode])
|
|
326
|
+
tiers = None
|
|
327
|
+
return Rates(
|
|
328
|
+
provider=provider, model=model, valid_from=entry["valid_from"],
|
|
329
|
+
source=f"{data['source']}/commit/{entry['commit']}",
|
|
330
|
+
input=values["input"], output=values["output"],
|
|
331
|
+
cache_read=values.get("cache_read"), cache_write=values.get("cache_write"),
|
|
332
|
+
tiers=_tiers(tiers),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _in_window(moment: datetime, window: list[str]) -> bool:
|
|
337
|
+
"""Whether a UTC time falls in a window; a window may run past midnight."""
|
|
338
|
+
start, end = (datetime.strptime(value, "%H:%M").time() for value in window)
|
|
339
|
+
if start <= end:
|
|
340
|
+
return start <= moment.time() < end
|
|
341
|
+
return moment.time() >= start or moment.time() < end
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@cache
|
|
345
|
+
def _schedules(provider: str) -> tuple[tuple[datetime, datetime | None, dict], ...]:
|
|
346
|
+
return tuple(
|
|
347
|
+
(_parse_time(item["valid_from"]),
|
|
348
|
+
_parse_time(item["valid_until"]) if "valid_until" in item else None, item)
|
|
349
|
+
for item in _config("schedules.toml").get("schedule", [])
|
|
350
|
+
if item["provider"] == provider
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _schedule(provider: str, moment: datetime) -> dict[str, Any] | None:
|
|
355
|
+
"""Return the provider's time-of-day pricing schedule in effect at `moment`."""
|
|
356
|
+
for start, end, schedule in _schedules(provider):
|
|
357
|
+
if start <= moment and (end is None or moment < end):
|
|
358
|
+
return schedule
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _period(provider: str, moment: datetime,
|
|
363
|
+
model: str | None = None) -> tuple[str, float] | None:
|
|
364
|
+
"""Return the time-of-day period at `moment` and its multiplier on recorded rates.
|
|
365
|
+
|
|
366
|
+
A schedule either raises prices inside peak hours or, like DeepSeek's 2025 discount,
|
|
367
|
+
lowers them for listed models inside off-peak hours; outside those hours the recorded
|
|
368
|
+
price is the standard price.
|
|
369
|
+
"""
|
|
370
|
+
schedule = _schedule(provider, moment)
|
|
371
|
+
if schedule is None:
|
|
372
|
+
return None
|
|
373
|
+
if "off_peak_hours_utc" in schedule:
|
|
374
|
+
factor = schedule["off_peak_multiplier"].get(model or "")
|
|
375
|
+
if factor is None:
|
|
376
|
+
return None
|
|
377
|
+
off_peak = any(_in_window(moment, window) for window in schedule["off_peak_hours_utc"])
|
|
378
|
+
return ("off_peak", factor) if off_peak else ("standard", 1.0)
|
|
379
|
+
peak = any(_in_window(moment, window) for window in schedule["peak_hours_utc"])
|
|
380
|
+
if schedule.get("weekdays_only") and moment.weekday() >= 5:
|
|
381
|
+
peak = False
|
|
382
|
+
calendar = _config("schedules.toml").get("holidays", {}).get(schedule.get("holidays", ""))
|
|
383
|
+
if calendar:
|
|
384
|
+
local = moment + timedelta(hours=calendar["utc_offset_hours"])
|
|
385
|
+
peak = peak and local.date().isoformat() not in calendar["dates"]
|
|
386
|
+
return ("peak", schedule["peak_multiplier"]) if peak else ("off_peak", 1.0)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _scaled(found: Rates, period: str, factor: float) -> Rates:
|
|
390
|
+
def scale(value: float | None) -> float | None:
|
|
391
|
+
return None if value is None else value * factor
|
|
392
|
+
return replace(
|
|
393
|
+
found, period=period, input=found.input * factor, output=found.output * factor,
|
|
394
|
+
cache_read=scale(found.cache_read), cache_write=scale(found.cache_write),
|
|
395
|
+
tiers=tuple(
|
|
396
|
+
replace(tier, input=scale(tier.input), output=scale(tier.output),
|
|
397
|
+
cache_read=scale(tier.cache_read), cache_write=scale(tier.cache_write))
|
|
398
|
+
for tier in found.tiers
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def rates(model: str, at: datetime | str | None = None, provider: str | None = None,
|
|
404
|
+
mode: str | None = None, corrected: bool = True,
|
|
405
|
+
prices_at: datetime | str | None = None) -> Rates | None:
|
|
406
|
+
"""Return the rates for a request made at `at` (default now), or None for an unknown model.
|
|
407
|
+
|
|
408
|
+
Time-of-day pricing applies the provider's peak or off-peak rates for `at`.
|
|
409
|
+
prices_at selects a different price list, such as today's, while the time-of-day rule
|
|
410
|
+
in force at `at` still applies to it; it defaults to `at`.
|
|
411
|
+
corrected=False returns models.dev's recorded rates without local corrections.
|
|
412
|
+
"""
|
|
413
|
+
details = resolve_details(model, provider)
|
|
414
|
+
if details is None:
|
|
415
|
+
return None
|
|
416
|
+
resolved, removed = details[:2], details[2]
|
|
417
|
+
moment = _parse_time(at)
|
|
418
|
+
listed = _parse_time(prices_at) if prices_at is not None else moment
|
|
419
|
+
found = _correction(*resolved, listed) if corrected else None
|
|
420
|
+
if found is not None and mode is not None:
|
|
421
|
+
# Corrections give standard rates; a mode keeps its models.dev ratio to them.
|
|
422
|
+
standard, moded = (_history_rates(*resolved, listed, name) for name in (None, mode))
|
|
423
|
+
found = _in_mode(found, standard, moded) if moded is not None and standard else None
|
|
424
|
+
if moded is None:
|
|
425
|
+
return None
|
|
426
|
+
found = found or _history_rates(*resolved, listed, mode)
|
|
427
|
+
if found is None:
|
|
428
|
+
return None
|
|
429
|
+
found = replace(found, removed_suffix=removed) if removed else found
|
|
430
|
+
# The time-of-day rule in force when the request ran applies to the chosen price list.
|
|
431
|
+
period = _period(resolved[0], moment, resolved[1])
|
|
432
|
+
return _scaled(found, *period) if period else found
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _change_times(provider: str, model: str, start: datetime, end: datetime) -> set[datetime]:
|
|
436
|
+
"""Every moment in (start, end) at which the model's rates could change."""
|
|
437
|
+
times: set[datetime] = set(_entry_times(provider, model))
|
|
438
|
+
link = _prices()["links"].get(provider, {}).get(model)
|
|
439
|
+
if link:
|
|
440
|
+
times |= set(_entry_times(provider, link))
|
|
441
|
+
linked = _corrections(provider, link) if link else ()
|
|
442
|
+
for begin, until, _ in (*_corrections(provider, model), *linked, *_schedules(provider)):
|
|
443
|
+
times |= {begin} | ({until} if until else set())
|
|
444
|
+
calendars = _config("schedules.toml").get("holidays", {})
|
|
445
|
+
for _, _, schedule in _schedules(provider):
|
|
446
|
+
calendar = calendars.get(schedule.get("holidays", ""))
|
|
447
|
+
day = start.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=1)
|
|
448
|
+
while day <= end:
|
|
449
|
+
times.add(day) # weekday rules change at UTC midnight
|
|
450
|
+
for window in schedule.get("peak_hours_utc", schedule.get("off_peak_hours_utc", [])):
|
|
451
|
+
for clock in window:
|
|
452
|
+
hour, minute = map(int, clock.split(":"))
|
|
453
|
+
times.add(day.replace(hour=hour, minute=minute))
|
|
454
|
+
if calendar:
|
|
455
|
+
times.add(day - timedelta(hours=calendar["utc_offset_hours"]))
|
|
456
|
+
day += timedelta(days=1)
|
|
457
|
+
return {moment for moment in times if start < moment < end}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def rates_between(model: str, start: datetime | str, end: datetime | str,
|
|
461
|
+
provider: str | None = None) -> list[tuple[datetime, Rates]]:
|
|
462
|
+
"""Return the distinct rates in effect from `start` to `end`, each with the time it begins.
|
|
463
|
+
|
|
464
|
+
For usage known only to fall within a span, a single entry means one price covered the
|
|
465
|
+
whole span. Entries differ in price, not only in provenance. An unknown model gives [].
|
|
466
|
+
"""
|
|
467
|
+
details = resolve_details(model, provider)
|
|
468
|
+
begin, finish = _parse_time(start), _parse_time(end)
|
|
469
|
+
first = rates(model, at=begin, provider=provider) if details else None
|
|
470
|
+
if first is None:
|
|
471
|
+
return []
|
|
472
|
+
segments = [(begin, first)]
|
|
473
|
+
for moment in sorted(_change_times(*details[:2], begin, finish)):
|
|
474
|
+
found = rates(model, at=moment, provider=provider)
|
|
475
|
+
if found is not None and found.price_key() != segments[-1][1].price_key():
|
|
476
|
+
segments.append((moment, found))
|
|
477
|
+
return segments
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def cost(model: str, *, input_tokens: int = 0, output_tokens: int = 0,
|
|
481
|
+
cache_read_tokens: int = 0, cache_write_tokens: int = 0,
|
|
482
|
+
at: datetime | str | None = None, provider: str | None = None,
|
|
483
|
+
mode: str | None = None, prices_at: datetime | str | None = None) -> float | None:
|
|
484
|
+
"""Price one request made at `at`; input_tokens excludes cache reads and writes."""
|
|
485
|
+
found = rates(model, at=at, provider=provider, mode=mode, prices_at=prices_at)
|
|
486
|
+
if found is None:
|
|
487
|
+
return None
|
|
488
|
+
return found.cost(input_tokens, output_tokens, cache_read_tokens, cache_write_tokens)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@dataclass(frozen=True)
|
|
492
|
+
class Plan:
|
|
493
|
+
provider: str
|
|
494
|
+
id: str
|
|
495
|
+
name: str
|
|
496
|
+
usd_per_month: float
|
|
497
|
+
source: str
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def plan_ids(provider: str, at: datetime | str | None = None) -> list[str]:
|
|
501
|
+
"""Return the provider's plan ids with a price in effect at `at` (default now)."""
|
|
502
|
+
ids = {item["id"] for item in _config("plans.toml").get("plan", [])
|
|
503
|
+
if item["provider"] == provider}
|
|
504
|
+
return sorted(plan_id for plan_id in ids if plan(provider, plan_id, at) is not None)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def plan(provider: str, plan_id: str, at: datetime | str | None = None) -> Plan | None:
|
|
508
|
+
"""Return a subscription plan's monthly price in effect at `at` (default now)."""
|
|
509
|
+
moment = _parse_time(at)
|
|
510
|
+
found = None
|
|
511
|
+
for item in _config("plans.toml").get("plan", []):
|
|
512
|
+
if item["provider"] != provider or item["id"] != plan_id:
|
|
513
|
+
continue
|
|
514
|
+
if "valid_from" in item and _parse_time(item["valid_from"]) > moment:
|
|
515
|
+
continue
|
|
516
|
+
if "valid_until" in item and _parse_time(item["valid_until"]) <= moment:
|
|
517
|
+
continue
|
|
518
|
+
found = item
|
|
519
|
+
if found is None:
|
|
520
|
+
return None
|
|
521
|
+
return Plan(provider, plan_id, found["name"], found["usd_per_month"], found["source"])
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _version() -> str:
|
|
525
|
+
"""The installed version, or "unknown" when run from an uninstalled source tree."""
|
|
526
|
+
try:
|
|
527
|
+
return version("model-prices")
|
|
528
|
+
except PackageNotFoundError:
|
|
529
|
+
return "unknown"
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _cli_time(value: str) -> datetime:
|
|
533
|
+
try:
|
|
534
|
+
return _parse_time(value)
|
|
535
|
+
except ValueError:
|
|
536
|
+
raise argparse.ArgumentTypeError(f"not an ISO 8601 time: {value!r}") from None
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def main(argv: list[str] | None = None) -> int:
|
|
540
|
+
parser = argparse.ArgumentParser(prog="model-prices", description=__doc__)
|
|
541
|
+
parser.add_argument("--version", action="version", version=_version())
|
|
542
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
543
|
+
show = commands.add_parser("rate", help="Show the rates for a model at a time")
|
|
544
|
+
show.add_argument("model")
|
|
545
|
+
show.add_argument("--provider")
|
|
546
|
+
show.add_argument("--at", type=_cli_time, help="ISO 8601 time (default: now)")
|
|
547
|
+
show.add_argument("--mode", help="Alternate pricing mode, such as fast")
|
|
548
|
+
show.add_argument("--json", action="store_true")
|
|
549
|
+
update = commands.add_parser(
|
|
550
|
+
"update", help="Rebuild price history from a models.dev git checkout"
|
|
551
|
+
)
|
|
552
|
+
update.add_argument("models_dev", type=Path)
|
|
553
|
+
update.add_argument("--output", type=Path,
|
|
554
|
+
default=Path(str(files(__package__).joinpath("data", "prices.json"))),
|
|
555
|
+
help="File to write (default: this installation's prices.json)")
|
|
556
|
+
check = commands.add_parser(
|
|
557
|
+
"check", help="Compare effective prices with official provider pricing pages"
|
|
558
|
+
)
|
|
559
|
+
check.add_argument("providers", nargs="*", help="Providers to check (default: all)")
|
|
560
|
+
check.add_argument("--json", action="store_true")
|
|
561
|
+
check.add_argument(
|
|
562
|
+
"--genai-prices", nargs="?", const="url", metavar="SOURCE",
|
|
563
|
+
help="Also score Pydantic's genai-prices against the official pages, from its data.json "
|
|
564
|
+
"URL (the default), the file, or a checkout")
|
|
565
|
+
args = parser.parse_args(argv)
|
|
566
|
+
|
|
567
|
+
if args.command == "check":
|
|
568
|
+
from model_prices import genai_prices
|
|
569
|
+
from model_prices.checks import SOURCES, as_json, report, run
|
|
570
|
+
unknown = sorted(set(args.providers) - set(SOURCES))
|
|
571
|
+
if unknown:
|
|
572
|
+
parser.error(f"no official pricing check for: {', '.join(unknown)}")
|
|
573
|
+
genai = None
|
|
574
|
+
if args.genai_prices:
|
|
575
|
+
source = genai_prices.DATA_URL if args.genai_prices == "url" else args.genai_prices
|
|
576
|
+
genai = genai_prices.load(source)
|
|
577
|
+
results = run(args.providers or None, genai=genai)
|
|
578
|
+
print(json.dumps(as_json(results), indent=2) if args.json
|
|
579
|
+
else report(results, genai=genai is not None))
|
|
580
|
+
return 1 if any(result.status == "mismatch" for result in results) else 0
|
|
581
|
+
|
|
582
|
+
if args.command == "update":
|
|
583
|
+
from model_prices.backfill import build, write
|
|
584
|
+
changed = write(build(args.models_dev), args.output)
|
|
585
|
+
print(f"Updated {args.output}" if changed else "No tracked price changes.")
|
|
586
|
+
return 0
|
|
587
|
+
|
|
588
|
+
found = rates(args.model, at=args.at, provider=args.provider, mode=args.mode)
|
|
589
|
+
if found is None:
|
|
590
|
+
print(f"No price found for {args.model}", file=sys.stderr)
|
|
591
|
+
return 1
|
|
592
|
+
if args.json:
|
|
593
|
+
print(json.dumps(asdict(found), indent=2))
|
|
594
|
+
return 0
|
|
595
|
+
period = f", {found.period.replace('_', '-')} rate" if found.period else ""
|
|
596
|
+
print(f"{found.provider}/{found.model} from {found.valid_from}{period} ({found.source})")
|
|
597
|
+
def usd(value: float | None) -> str:
|
|
598
|
+
return f"${value:g}" if value is not None else "- (input price)"
|
|
599
|
+
|
|
600
|
+
print(f"input {usd(found.input)} output {usd(found.output)} "
|
|
601
|
+
f"cache read {usd(found.cache_read)} cache write {usd(found.cache_write)} per 1M tokens")
|
|
602
|
+
for tier in found.tiers:
|
|
603
|
+
print(f"above {tier.above:,} prompt tokens: input {usd(tier.input)} "
|
|
604
|
+
f"output {usd(tier.output)} cache read {usd(tier.cache_read)} "
|
|
605
|
+
f"cache write {usd(tier.cache_write)}")
|
|
606
|
+
return 0
|
model_prices/__main__.py
ADDED