llm-rates 0.3.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 m0j0d
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,237 @@
1
+ Metadata-Version: 2.4
2
+ Name: llm-rates
3
+ Version: 0.3.0
4
+ Summary: One vendored litellm-catalog snapshot + one internal overlay + shared lookup/cost math for portfolio LLM pricing
5
+ Author-email: Mike Donnelly <82827803+m0j0d@users.noreply.github.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/m0j0d/libs/tree/main/llm-rates
8
+ Project-URL: Repository, https://github.com/m0j0d/libs
9
+ Project-URL: Issues, https://github.com/m0j0d/libs/issues
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Development Status :: 4 - Beta
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=9.0.3; extra == "dev"
23
+ Provides-Extra: refresh
24
+ Requires-Dist: anthropic>=0.122.0; extra == "refresh"
25
+ Dynamic: license-file
26
+
27
+ # llm-rates
28
+
29
+ Named `llm-rates` (not `llm-prices`, its name until 2026-08-21) because
30
+ PyPI's similarity check rejected `llm-prices` as too close to the existing
31
+ `llmprices` project — collapsed-separator collision, not an exact-name
32
+ clash. `llm-rates` was verified free in every form (`llm-rates`,
33
+ `llmrates`, `llm_rates`, singular `llm-rate`/`llmrate`) before adopting it;
34
+ don't re-litigate the name.
35
+
36
+ One vendored model-price catalog + one internal overlay + a shared Python
37
+ lookup/cost-math module, replacing four independently hand-maintained price
38
+ tables that were drifting apart. Full origin story and decisions: this
39
+ repo's sibling checkout `plugin/docs/plans/model-catalog-consolidation.md`
40
+ (cross-repo — `plugin` is a separate git repository from `libs`, so this is
41
+ a path reference, not a clickable link).
42
+
43
+ ## Why this exists
44
+
45
+ Four tables held the same vendor facts (`plugin/scripts/prices/anthropic.json`,
46
+ `plugin/skills/tokenator/scripts/pricing.json`,
47
+ `sessions/src/sessmon/pricing.py`,
48
+ `factory-bench/runs/model-bench/model-bench-runner.py`). One of them silently
49
+ returned `$0.00` for any model it didn't recognise — a lookup bug, not a data
50
+ bug — and under-reported real spend by over $800 in one 30-day window. This
51
+ package fixes the *lookup logic* (never $0 for an unknown model) and gives
52
+ the data one home.
53
+
54
+ ## Files
55
+
56
+ - `catalog.json` — vendored snapshot of
57
+ [litellm's `model_prices_and_context_window.json`](https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json),
58
+ filtered to the providers/models the portfolio actually uses. Vendor list
59
+ price, per-single-token USD (litellm's native unit). Never hand-edit —
60
+ regenerate prices via `refresh.py --apply`; for Anthropic-provider rows,
61
+ the capability fields (`max_input_tokens`/`max_output_tokens`) are instead
62
+ kept in sync with Anthropic's own live Models API via
63
+ `refresh_context.py --apply` (litellm's snapshot is a third-party guess
64
+ for those fields, not authoritative).
65
+ - `overlay.json` — everything no public catalog can know: actual-billing
66
+ overrides (e.g. Groq OSS models are free-tier here, not their list price),
67
+ family-rate fallbacks (`claude-opus` → tier rate, so an unrecognised new
68
+ model prices at its family's rate instead of $0), model-ID aliases (dated
69
+ suffixes → canonical key), retirement/deprecation history (a retired
70
+ model's row is kept, never deleted, so historical transcripts still
71
+ reprice correctly), `context_overrides` for the rare model whose real
72
+ default-served context genuinely differs from its published maximum
73
+ (empty as of 2026-08-21 — see "Context-window data" below), and
74
+ per-consumer portfolio pins.
75
+ - `llm_rates/` — `lookup(model_id) -> PriceRecord` + cost math (in
76
+ `__init__.py`); `catalog.json` and `overlay.json` ship inside this
77
+ directory as package data so an installed wheel carries them alongside
78
+ the code. Shared by every Python consumer; the sole PowerShell consumer
79
+ (`tokenator.ps1`) can't import Python, so it reads a **generated** JSON
80
+ table instead — see `generate_tokenator_table.py` below and the plan
81
+ doc's "Known constraint".
82
+ - `refresh.py` — re-pulls the live litellm catalog, re-filters it to the
83
+ same model set, diffs against the vendored `catalog.json`, and prints a
84
+ report. Never applies silently — pass `--apply` to write. Price source
85
+ only; does not touch capability fields for Anthropic-provider rows.
86
+ - `refresh_context.py` — re-pulls `max_input_tokens`/`max_tokens` for every
87
+ Anthropic-provider `catalog.json` row from Anthropic's own live Models API
88
+ (`client.models.retrieve()`), diffs against the vendored values, and
89
+ prints a report. Never applies silently — pass `--apply` to write.
90
+ Requires the `anthropic` package (`pip install "llm-rates[refresh]"`) and
91
+ a resolvable Anthropic credential (`ANTHROPIC_API_KEY`,
92
+ `ANTHROPIC_AUTH_TOKEN`, or an `ant auth login` profile) — never falls back
93
+ to a guessed value if no credential resolves. Run manually; not wired
94
+ into any scheduled job. `lookup()`/`cost()` never import this module or
95
+ touch the network — see "Context-window data" below.
96
+ - `generate_tokenator_table.py` — emits a plain JSON price table shaped for
97
+ `tokenator.ps1` (a `models` map of `{input, output, context}` per model id,
98
+ plus flat `cache_read_multiplier`/`cache_write_multiplier` and a
99
+ `default` row) from `catalog.json` + `overlay.json`, so that repo can hold
100
+ a generated copy instead of a hand-maintained one. Deterministic — the
101
+ same inputs always produce byte-identical output.
102
+
103
+ ## Usage
104
+
105
+ ```python
106
+ from llm_rates import lookup, cost, UnknownModelError
107
+
108
+ record = lookup("claude-sonnet-5")
109
+ record.input # 2.0 ($/MTok)
110
+ record.output # 10.0 ($/MTok)
111
+ record.source # "catalog"
112
+ record.context # 1000000 (context-window token limit, or None)
113
+
114
+ # A client-side "[1m]" resolvedModel signal (not a vendor id — see
115
+ # overlay.json's aliases) resolves to the same price and, as of 2026-08-21,
116
+ # the same context — the base id already reports the model's real
117
+ # 1,000,000-token window:
118
+ lookup("claude-sonnet-5[1m]").context # 1000000
119
+
120
+ # One-shot cost for a turn:
121
+ usd = cost(
122
+ "claude-opus-4-8",
123
+ input_tokens=12_000,
124
+ output_tokens=800,
125
+ cache_read_tokens=50_000,
126
+ )
127
+
128
+ # An unrecognised model still resolves at its family rate:
129
+ lookup("claude-opus-4-9").source # "overlay-family-fallback"
130
+
131
+ # A genuinely unknown vendor/model raises instead of returning $0:
132
+ try:
133
+ lookup("some-new-vendor/mystery-model")
134
+ except UnknownModelError as e:
135
+ ...
136
+ ```
137
+
138
+ `lookup()` and `cost()` both accept optional `catalog=`/`overlay=` kwargs
139
+ (already-loaded dicts) — useful for tests, or for a caller that wants to load
140
+ once and reuse across many lookups instead of re-reading the JSON files each
141
+ call.
142
+
143
+ ## Refreshing the catalog
144
+
145
+ ```bash
146
+ python refresh.py # pull live litellm catalog, diff, print report
147
+ python refresh.py --apply # also overwrite catalog.json with the diff
148
+ ```
149
+
150
+ `refresh.py` always pulls the **live** GitHub-hosted catalog URL, never the
151
+ `litellm` pip package's bundled snapshot — that bundled copy is stale (it was
152
+ missing Haiku 4.5, Opus 5, Opus 4.8, Fable 5, and Sonnet 5 entirely as of
153
+ 2026-07-28; see the plan doc's "Two traps found").
154
+
155
+ ## Refreshing context-window data
156
+
157
+ ```bash
158
+ python refresh_context.py # pull the live Models API, diff, print report
159
+ python refresh_context.py --apply # also overwrite catalog.json with the diff
160
+ ```
161
+
162
+ Context-window limits (`max_input_tokens`) and output caps
163
+ (`max_output_tokens`) for Anthropic-provider rows come from Anthropic's own
164
+ **Models API** (`client.models.retrieve(model_id)`), not litellm's snapshot
165
+ — litellm is a third-party-maintained guess for these fields, and it was
166
+ wrong at least once (see "Pricing notes worth knowing" below). `lookup()`
167
+ itself never calls this API: it's a refresh-time-only script, exactly like
168
+ `refresh.py`, that writes into the vendored `catalog.json`, so
169
+ `lookup()`/`cost()` stay pure and offline (they price historical
170
+ transcripts and run behind a PowerShell overlay with no network access).
171
+ Requires `pip install "llm-rates[refresh]"` (the `anthropic` SDK) and a
172
+ credential the SDK's own resolution chain can find
173
+ (`ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`/`ant auth login`) — with none
174
+ resolvable, the script fails with an actionable message and writes nothing,
175
+ never a guessed value.
176
+
177
+ ## Generating tokenator's pricing table
178
+
179
+ ```bash
180
+ python generate_tokenator_table.py # print to stdout
181
+ python generate_tokenator_table.py --out FILE # write to FILE
182
+ ```
183
+
184
+ This is a read-only export — it never touches `plugin`'s checkout. The
185
+ consumer migration that adopts the generated bytes as `tokenator/pricing.json`
186
+ is tracked separately. Before emitting anything, the generator proves (via
187
+ `verify_cache_multipliers()`) that its flat cache multipliers (0.1x read,
188
+ 2.0x write of input — tuned to Claude Code's 1h-TTL cache behaviour) match
189
+ this package's real per-model cache rates for every model tokenator prices;
190
+ a divergence raises instead of silently drifting.
191
+
192
+ ## Pricing notes worth knowing
193
+
194
+ - **`claude-sonnet-5` is $2.00/$10.00 per MTok, permanently** — not a
195
+ time-limited introductory rate. It was announced as an introductory price
196
+ through 2026-08-31, but the vendor cancelled the scheduled 2026-09-01 rise
197
+ to $3.00/$15.00 (~2026-08-17). See `overlay.json`'s `notes`.
198
+ - **Groq OSS models list at $0.075–$0.29/MTok but bill $0.00** on the
199
+ portfolio's free-tier key — the vendor list price lives in `catalog.json`,
200
+ the actual billed rate lives in `overlay.json`'s
201
+ `actual_billing_overrides`. Both are correct; they answer different
202
+ questions.
203
+ - **A retired/de-listed model keeps its row.** `claude-opus-4-1` (retired
204
+ 2026-08-05) and the Groq-delisted `llama-4-scout`/`qwen3-32b` entries stay
205
+ priced at their historical rate so old transcripts still reprice
206
+ correctly — deleting a row would silently re-price past usage at whatever
207
+ family-fallback rate happens to apply now.
208
+ - **`claude-fable-5`, `claude-opus-5`, and `claude-sonnet-5` all serve
209
+ 1,000,000 tokens of context, natively** — confirmed by Anthropic's own
210
+ Models API. A prior `overlay.json` `context_overrides` block forced all
211
+ three to 200,000 (on the mistaken theory that catalog.json's
212
+ `max_input_tokens=1000000` was an extended-context-beta-only ceiling);
213
+ that override has been removed (corrected 2026-08-21, C8). Only
214
+ `claude-haiku-4-5` among current Claude models genuinely has a
215
+ 200,000-token window.
216
+
217
+ ## Setup for consumers
218
+
219
+ ```bash
220
+ pip install -e C:/data/projects/libs/llm-rates
221
+ ```
222
+
223
+ Same convention as `libs/plan-doc` and `libs/triad-base` — see
224
+ `plugin/CLAUDE.md`.
225
+
226
+ ## Status
227
+
228
+ **Rollout step 1 of `model-catalog-consolidation.md` — the library itself,
229
+ built and tested.** Consumer migrations (`activity_cost.py`, `sessmon`,
230
+ `model-bench-runner.py`, `tokenator.ps1`) are steps 2–5, tracked separately
231
+ in the plan doc and out of scope for this package's initial PR.
232
+
233
+ ## Testing
234
+
235
+ ```bash
236
+ pytest llm-rates/tests/ -v
237
+ ```
@@ -0,0 +1,211 @@
1
+ # llm-rates
2
+
3
+ Named `llm-rates` (not `llm-prices`, its name until 2026-08-21) because
4
+ PyPI's similarity check rejected `llm-prices` as too close to the existing
5
+ `llmprices` project — collapsed-separator collision, not an exact-name
6
+ clash. `llm-rates` was verified free in every form (`llm-rates`,
7
+ `llmrates`, `llm_rates`, singular `llm-rate`/`llmrate`) before adopting it;
8
+ don't re-litigate the name.
9
+
10
+ One vendored model-price catalog + one internal overlay + a shared Python
11
+ lookup/cost-math module, replacing four independently hand-maintained price
12
+ tables that were drifting apart. Full origin story and decisions: this
13
+ repo's sibling checkout `plugin/docs/plans/model-catalog-consolidation.md`
14
+ (cross-repo — `plugin` is a separate git repository from `libs`, so this is
15
+ a path reference, not a clickable link).
16
+
17
+ ## Why this exists
18
+
19
+ Four tables held the same vendor facts (`plugin/scripts/prices/anthropic.json`,
20
+ `plugin/skills/tokenator/scripts/pricing.json`,
21
+ `sessions/src/sessmon/pricing.py`,
22
+ `factory-bench/runs/model-bench/model-bench-runner.py`). One of them silently
23
+ returned `$0.00` for any model it didn't recognise — a lookup bug, not a data
24
+ bug — and under-reported real spend by over $800 in one 30-day window. This
25
+ package fixes the *lookup logic* (never $0 for an unknown model) and gives
26
+ the data one home.
27
+
28
+ ## Files
29
+
30
+ - `catalog.json` — vendored snapshot of
31
+ [litellm's `model_prices_and_context_window.json`](https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json),
32
+ filtered to the providers/models the portfolio actually uses. Vendor list
33
+ price, per-single-token USD (litellm's native unit). Never hand-edit —
34
+ regenerate prices via `refresh.py --apply`; for Anthropic-provider rows,
35
+ the capability fields (`max_input_tokens`/`max_output_tokens`) are instead
36
+ kept in sync with Anthropic's own live Models API via
37
+ `refresh_context.py --apply` (litellm's snapshot is a third-party guess
38
+ for those fields, not authoritative).
39
+ - `overlay.json` — everything no public catalog can know: actual-billing
40
+ overrides (e.g. Groq OSS models are free-tier here, not their list price),
41
+ family-rate fallbacks (`claude-opus` → tier rate, so an unrecognised new
42
+ model prices at its family's rate instead of $0), model-ID aliases (dated
43
+ suffixes → canonical key), retirement/deprecation history (a retired
44
+ model's row is kept, never deleted, so historical transcripts still
45
+ reprice correctly), `context_overrides` for the rare model whose real
46
+ default-served context genuinely differs from its published maximum
47
+ (empty as of 2026-08-21 — see "Context-window data" below), and
48
+ per-consumer portfolio pins.
49
+ - `llm_rates/` — `lookup(model_id) -> PriceRecord` + cost math (in
50
+ `__init__.py`); `catalog.json` and `overlay.json` ship inside this
51
+ directory as package data so an installed wheel carries them alongside
52
+ the code. Shared by every Python consumer; the sole PowerShell consumer
53
+ (`tokenator.ps1`) can't import Python, so it reads a **generated** JSON
54
+ table instead — see `generate_tokenator_table.py` below and the plan
55
+ doc's "Known constraint".
56
+ - `refresh.py` — re-pulls the live litellm catalog, re-filters it to the
57
+ same model set, diffs against the vendored `catalog.json`, and prints a
58
+ report. Never applies silently — pass `--apply` to write. Price source
59
+ only; does not touch capability fields for Anthropic-provider rows.
60
+ - `refresh_context.py` — re-pulls `max_input_tokens`/`max_tokens` for every
61
+ Anthropic-provider `catalog.json` row from Anthropic's own live Models API
62
+ (`client.models.retrieve()`), diffs against the vendored values, and
63
+ prints a report. Never applies silently — pass `--apply` to write.
64
+ Requires the `anthropic` package (`pip install "llm-rates[refresh]"`) and
65
+ a resolvable Anthropic credential (`ANTHROPIC_API_KEY`,
66
+ `ANTHROPIC_AUTH_TOKEN`, or an `ant auth login` profile) — never falls back
67
+ to a guessed value if no credential resolves. Run manually; not wired
68
+ into any scheduled job. `lookup()`/`cost()` never import this module or
69
+ touch the network — see "Context-window data" below.
70
+ - `generate_tokenator_table.py` — emits a plain JSON price table shaped for
71
+ `tokenator.ps1` (a `models` map of `{input, output, context}` per model id,
72
+ plus flat `cache_read_multiplier`/`cache_write_multiplier` and a
73
+ `default` row) from `catalog.json` + `overlay.json`, so that repo can hold
74
+ a generated copy instead of a hand-maintained one. Deterministic — the
75
+ same inputs always produce byte-identical output.
76
+
77
+ ## Usage
78
+
79
+ ```python
80
+ from llm_rates import lookup, cost, UnknownModelError
81
+
82
+ record = lookup("claude-sonnet-5")
83
+ record.input # 2.0 ($/MTok)
84
+ record.output # 10.0 ($/MTok)
85
+ record.source # "catalog"
86
+ record.context # 1000000 (context-window token limit, or None)
87
+
88
+ # A client-side "[1m]" resolvedModel signal (not a vendor id — see
89
+ # overlay.json's aliases) resolves to the same price and, as of 2026-08-21,
90
+ # the same context — the base id already reports the model's real
91
+ # 1,000,000-token window:
92
+ lookup("claude-sonnet-5[1m]").context # 1000000
93
+
94
+ # One-shot cost for a turn:
95
+ usd = cost(
96
+ "claude-opus-4-8",
97
+ input_tokens=12_000,
98
+ output_tokens=800,
99
+ cache_read_tokens=50_000,
100
+ )
101
+
102
+ # An unrecognised model still resolves at its family rate:
103
+ lookup("claude-opus-4-9").source # "overlay-family-fallback"
104
+
105
+ # A genuinely unknown vendor/model raises instead of returning $0:
106
+ try:
107
+ lookup("some-new-vendor/mystery-model")
108
+ except UnknownModelError as e:
109
+ ...
110
+ ```
111
+
112
+ `lookup()` and `cost()` both accept optional `catalog=`/`overlay=` kwargs
113
+ (already-loaded dicts) — useful for tests, or for a caller that wants to load
114
+ once and reuse across many lookups instead of re-reading the JSON files each
115
+ call.
116
+
117
+ ## Refreshing the catalog
118
+
119
+ ```bash
120
+ python refresh.py # pull live litellm catalog, diff, print report
121
+ python refresh.py --apply # also overwrite catalog.json with the diff
122
+ ```
123
+
124
+ `refresh.py` always pulls the **live** GitHub-hosted catalog URL, never the
125
+ `litellm` pip package's bundled snapshot — that bundled copy is stale (it was
126
+ missing Haiku 4.5, Opus 5, Opus 4.8, Fable 5, and Sonnet 5 entirely as of
127
+ 2026-07-28; see the plan doc's "Two traps found").
128
+
129
+ ## Refreshing context-window data
130
+
131
+ ```bash
132
+ python refresh_context.py # pull the live Models API, diff, print report
133
+ python refresh_context.py --apply # also overwrite catalog.json with the diff
134
+ ```
135
+
136
+ Context-window limits (`max_input_tokens`) and output caps
137
+ (`max_output_tokens`) for Anthropic-provider rows come from Anthropic's own
138
+ **Models API** (`client.models.retrieve(model_id)`), not litellm's snapshot
139
+ — litellm is a third-party-maintained guess for these fields, and it was
140
+ wrong at least once (see "Pricing notes worth knowing" below). `lookup()`
141
+ itself never calls this API: it's a refresh-time-only script, exactly like
142
+ `refresh.py`, that writes into the vendored `catalog.json`, so
143
+ `lookup()`/`cost()` stay pure and offline (they price historical
144
+ transcripts and run behind a PowerShell overlay with no network access).
145
+ Requires `pip install "llm-rates[refresh]"` (the `anthropic` SDK) and a
146
+ credential the SDK's own resolution chain can find
147
+ (`ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`/`ant auth login`) — with none
148
+ resolvable, the script fails with an actionable message and writes nothing,
149
+ never a guessed value.
150
+
151
+ ## Generating tokenator's pricing table
152
+
153
+ ```bash
154
+ python generate_tokenator_table.py # print to stdout
155
+ python generate_tokenator_table.py --out FILE # write to FILE
156
+ ```
157
+
158
+ This is a read-only export — it never touches `plugin`'s checkout. The
159
+ consumer migration that adopts the generated bytes as `tokenator/pricing.json`
160
+ is tracked separately. Before emitting anything, the generator proves (via
161
+ `verify_cache_multipliers()`) that its flat cache multipliers (0.1x read,
162
+ 2.0x write of input — tuned to Claude Code's 1h-TTL cache behaviour) match
163
+ this package's real per-model cache rates for every model tokenator prices;
164
+ a divergence raises instead of silently drifting.
165
+
166
+ ## Pricing notes worth knowing
167
+
168
+ - **`claude-sonnet-5` is $2.00/$10.00 per MTok, permanently** — not a
169
+ time-limited introductory rate. It was announced as an introductory price
170
+ through 2026-08-31, but the vendor cancelled the scheduled 2026-09-01 rise
171
+ to $3.00/$15.00 (~2026-08-17). See `overlay.json`'s `notes`.
172
+ - **Groq OSS models list at $0.075–$0.29/MTok but bill $0.00** on the
173
+ portfolio's free-tier key — the vendor list price lives in `catalog.json`,
174
+ the actual billed rate lives in `overlay.json`'s
175
+ `actual_billing_overrides`. Both are correct; they answer different
176
+ questions.
177
+ - **A retired/de-listed model keeps its row.** `claude-opus-4-1` (retired
178
+ 2026-08-05) and the Groq-delisted `llama-4-scout`/`qwen3-32b` entries stay
179
+ priced at their historical rate so old transcripts still reprice
180
+ correctly — deleting a row would silently re-price past usage at whatever
181
+ family-fallback rate happens to apply now.
182
+ - **`claude-fable-5`, `claude-opus-5`, and `claude-sonnet-5` all serve
183
+ 1,000,000 tokens of context, natively** — confirmed by Anthropic's own
184
+ Models API. A prior `overlay.json` `context_overrides` block forced all
185
+ three to 200,000 (on the mistaken theory that catalog.json's
186
+ `max_input_tokens=1000000` was an extended-context-beta-only ceiling);
187
+ that override has been removed (corrected 2026-08-21, C8). Only
188
+ `claude-haiku-4-5` among current Claude models genuinely has a
189
+ 200,000-token window.
190
+
191
+ ## Setup for consumers
192
+
193
+ ```bash
194
+ pip install -e C:/data/projects/libs/llm-rates
195
+ ```
196
+
197
+ Same convention as `libs/plan-doc` and `libs/triad-base` — see
198
+ `plugin/CLAUDE.md`.
199
+
200
+ ## Status
201
+
202
+ **Rollout step 1 of `model-catalog-consolidation.md` — the library itself,
203
+ built and tested.** Consumer migrations (`activity_cost.py`, `sessmon`,
204
+ `model-bench-runner.py`, `tokenator.ps1`) are steps 2–5, tracked separately
205
+ in the plan doc and out of scope for this package's initial PR.
206
+
207
+ ## Testing
208
+
209
+ ```bash
210
+ pytest llm-rates/tests/ -v
211
+ ```
@@ -0,0 +1,192 @@
1
+ """Generate a plain JSON price table for tokenator.ps1 — the sole PowerShell
2
+ consumer of this package's data, which can't `import llm_rates` and instead
3
+ reads a generated JSON file directly (see README.md's "Known constraint").
4
+
5
+ Deterministic: the same catalog.json/overlay.json inputs always produce
6
+ byte-identical output, so a downstream drift test in the tokenator skill's
7
+ own repo can assert its committed pricing.json matches a fresh generation.
8
+
9
+ Usage:
10
+ python generate_tokenator_table.py # print to stdout
11
+ python generate_tokenator_table.py --out FILE # write to FILE
12
+
13
+ Never writes tokenator's actual pricing.json from this repo — that file
14
+ lives in a separate repository (plugin) and is owned by a separate
15
+ migration; this script only produces the bytes for that migration to adopt.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import sys
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from llm_rates import load_catalog, load_overlay, lookup
27
+
28
+ # Order matters for output determinism/readability — mirrors tokenator's
29
+ # existing pricing.json key order exactly. Each key IS the model_id passed
30
+ # to lookup(): the bare family ids ("claude-opus", "claude-sonnet",
31
+ # "claude-haiku") resolve via overlay.json's family_fallback (no specific
32
+ # version pinned), and the "[1m]" entries are client-side resolvedModel
33
+ # signals (not vendor ids) that resolve via overlay.json's aliases +
34
+ # context_overrides — see llm_rates.py's module docstring "Context-window
35
+ # limits" section and overlay.json's notes.
36
+ TOKENATOR_MODEL_IDS = [
37
+ "claude-fable-5",
38
+ "claude-mythos-5",
39
+ "claude-opus-5[1m]",
40
+ "claude-opus",
41
+ "claude-sonnet-5[1m]",
42
+ "claude-sonnet-5",
43
+ "claude-sonnet",
44
+ "claude-haiku",
45
+ ]
46
+
47
+ # tokenator.ps1 uses flat cache multipliers tuned to Claude Code's 1h-TTL
48
+ # cache behaviour: reads at 0.1x input, writes at 2.0x input (the 1h-TTL
49
+ # creation rate — Claude Code writes 1h-TTL caches exclusively; see
50
+ # tokenator/pricing.json's own _comment). These are not independently
51
+ # invented constants: verify_cache_multipliers() below proves, for every
52
+ # model in TOKENATOR_MODEL_IDS, that the lib's real per-model
53
+ # cache_read/cache_creation_1h rates divide by that model's input rate to
54
+ # exactly these two numbers, before build_table() ever emits them. If a
55
+ # future catalog update broke that identity for any model, this raises
56
+ # instead of silently drifting from what tokenator actually bills.
57
+ CACHE_READ_MULTIPLIER = 0.1
58
+ CACHE_WRITE_MULTIPLIER = 2.0
59
+
60
+ # tokenator's "default" row (used when no prefix in its models map matches)
61
+ # has always carried the claude-opus family rate — same lookup id used for
62
+ # the bare "claude-opus" entry above.
63
+ DEFAULT_MODEL_ID = "claude-opus"
64
+
65
+ _RATIO_TOLERANCE = 1e-9
66
+
67
+
68
+ def verify_cache_multipliers(
69
+ model_ids: list[str],
70
+ *,
71
+ catalog: dict[str, Any] | None = None,
72
+ overlay: dict[str, Any] | None = None,
73
+ ) -> None:
74
+ """Raise ValueError if any model's real cache_read/cache_creation_1h
75
+ rate diverges from the flat CACHE_READ_MULTIPLIER/CACHE_WRITE_MULTIPLIER
76
+ this module emits. Called by build_table() before it writes anything.
77
+ """
78
+ for model_id in model_ids:
79
+ record = lookup(model_id, catalog=catalog, overlay=overlay)
80
+ if record.input == 0:
81
+ # Free-tier override (not reachable for any current tokenator
82
+ # id, but guarded for future-proofing) — ratio is undefined.
83
+ continue
84
+ read_ratio = record.cache_read / record.input
85
+ write_ratio = record.cache_creation_1h / record.input
86
+ if abs(read_ratio - CACHE_READ_MULTIPLIER) > _RATIO_TOLERANCE:
87
+ raise ValueError(
88
+ f"{model_id}: cache_read/input ratio {read_ratio!r} does not "
89
+ f"match declared CACHE_READ_MULTIPLIER {CACHE_READ_MULTIPLIER!r} "
90
+ "— tokenator's flat multiplier would silently diverge from "
91
+ "the lib's real rate for this model."
92
+ )
93
+ if abs(write_ratio - CACHE_WRITE_MULTIPLIER) > _RATIO_TOLERANCE:
94
+ raise ValueError(
95
+ f"{model_id}: cache_creation_1h/input ratio {write_ratio!r} "
96
+ f"does not match declared CACHE_WRITE_MULTIPLIER "
97
+ f"{CACHE_WRITE_MULTIPLIER!r}."
98
+ )
99
+
100
+
101
+ def build_table(
102
+ *,
103
+ catalog: dict[str, Any] | None = None,
104
+ overlay: dict[str, Any] | None = None,
105
+ ) -> dict[str, Any]:
106
+ """Pure function: build the tokenator-shaped pricing dict from the
107
+ catalog + overlay. No I/O beyond catalog=/overlay=None triggering the
108
+ package's own load_catalog()/load_overlay().
109
+ """
110
+ catalog = catalog if catalog is not None else load_catalog()
111
+ overlay = overlay if overlay is not None else load_overlay()
112
+
113
+ verify_cache_multipliers(TOKENATOR_MODEL_IDS, catalog=catalog, overlay=overlay)
114
+
115
+ models: dict[str, Any] = {}
116
+ for model_id in TOKENATOR_MODEL_IDS:
117
+ record = lookup(model_id, catalog=catalog, overlay=overlay)
118
+ if record.context is None:
119
+ raise ValueError(
120
+ f"{model_id}: lookup() resolved no context-window value — "
121
+ "tokenator needs a context for every emitted model; refusing "
122
+ "to emit a guessed number. Add a context_overrides row (or a "
123
+ "family_fallback 'context' field) in overlay.json."
124
+ )
125
+ models[model_id] = {
126
+ "input": record.input,
127
+ "output": record.output,
128
+ "context": record.context,
129
+ }
130
+
131
+ default_record = lookup(DEFAULT_MODEL_ID, catalog=catalog, overlay=overlay)
132
+ if default_record.context is None:
133
+ raise ValueError(f"{DEFAULT_MODEL_ID}: no context-window value for default row.")
134
+
135
+ return {
136
+ "_comment": (
137
+ "USD per million tokens. GENERATED by "
138
+ "libs/llm-rates/generate_tokenator_table.py from llm-rates' "
139
+ "catalog.json + overlay.json — do not hand-edit; regenerate "
140
+ "instead. Cache multipliers are flat (read 0.1x, write 2.0x of "
141
+ "input) because Claude Code uses 1h-TTL cache writes exclusively; "
142
+ "the generator's verify_cache_multipliers() proves per-model "
143
+ "that this matches the lib's real per-token cache rates before "
144
+ "every generation. Longest-prefix match on model id; 'default' "
145
+ "used when no prefix matches. 'context' is the model's "
146
+ "context-window token limit. The bracketed '[1m]' entries are "
147
+ "client-side resolvedModel signals (tokenator.ps1's Get-Context) "
148
+ "for the 1M-context beta, not vendor model ids -- the base model "
149
+ "id never carries a bracket suffix on its own."
150
+ ),
151
+ "models": models,
152
+ "cache_read_multiplier": CACHE_READ_MULTIPLIER,
153
+ "cache_write_multiplier": CACHE_WRITE_MULTIPLIER,
154
+ "default": {
155
+ "input": default_record.input,
156
+ "output": default_record.output,
157
+ "context": default_record.context,
158
+ },
159
+ }
160
+
161
+
162
+ def render_json(table: dict[str, Any]) -> str:
163
+ """Deterministic JSON rendering — same table -> byte-identical string
164
+ every call. Trailing newline matches the existing hand-maintained file.
165
+ """
166
+ return json.dumps(table, indent=2) + "\n"
167
+
168
+
169
+ def _cli(argv: list[str] | None = None) -> int:
170
+ parser = argparse.ArgumentParser(description=__doc__)
171
+ parser.add_argument(
172
+ "--out",
173
+ type=Path,
174
+ default=None,
175
+ help="Write to this path instead of stdout.",
176
+ )
177
+ args = parser.parse_args(argv)
178
+
179
+ table = build_table()
180
+ rendered = render_json(table)
181
+
182
+ if args.out is not None:
183
+ args.out.write_text(rendered, encoding="utf-8", newline="\n")
184
+ print(f"Wrote {args.out}", file=sys.stderr)
185
+ else:
186
+ sys.stdout.write(rendered)
187
+
188
+ return 0
189
+
190
+
191
+ if __name__ == "__main__":
192
+ raise SystemExit(_cli())