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.
@@ -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
@@ -0,0 +1,3 @@
1
+ from . import main
2
+
3
+ raise SystemExit(main())