metergraph-core 0.3.0__tar.gz → 0.4.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.
Files changed (26) hide show
  1. metergraph_core-0.4.0/PKG-INFO +452 -0
  2. metergraph_core-0.4.0/README.md +440 -0
  3. {metergraph_core-0.3.0 → metergraph_core-0.4.0}/pyproject.toml +2 -2
  4. {metergraph_core-0.3.0 → metergraph_core-0.4.0}/src/metergraph_core/__init__.py +60 -0
  5. {metergraph_core-0.3.0 → metergraph_core-0.4.0}/src/metergraph_core/catalog.py +8 -1
  6. metergraph_core-0.4.0/src/metergraph_core/catalog_delta.py +405 -0
  7. metergraph_core-0.4.0/src/metergraph_core/data/sources.yaml +196 -0
  8. metergraph_core-0.4.0/src/metergraph_core/extractors.py +677 -0
  9. {metergraph_core-0.3.0 → metergraph_core-0.4.0}/src/metergraph_core/loader.py +100 -14
  10. {metergraph_core-0.3.0 → metergraph_core-0.4.0}/src/metergraph_core/models.py +2 -1
  11. metergraph_core-0.4.0/src/metergraph_core/refresh.py +676 -0
  12. metergraph_core-0.4.0/src/metergraph_core/sources.py +252 -0
  13. metergraph_core-0.4.0/src/metergraph_core.egg-info/PKG-INFO +452 -0
  14. {metergraph_core-0.3.0 → metergraph_core-0.4.0}/src/metergraph_core.egg-info/SOURCES.txt +6 -1
  15. metergraph_core-0.3.0/PKG-INFO +0 -246
  16. metergraph_core-0.3.0/README.md +0 -234
  17. metergraph_core-0.3.0/src/metergraph_core.egg-info/PKG-INFO +0 -246
  18. {metergraph_core-0.3.0 → metergraph_core-0.4.0}/MANIFEST.in +0 -0
  19. {metergraph_core-0.3.0 → metergraph_core-0.4.0}/setup.cfg +0 -0
  20. {metergraph_core-0.3.0 → metergraph_core-0.4.0}/src/metergraph_core/billing.py +0 -0
  21. {metergraph_core-0.3.0 → metergraph_core-0.4.0}/src/metergraph_core/data/models.yaml +0 -0
  22. {metergraph_core-0.3.0 → metergraph_core-0.4.0}/src/metergraph_core/data/prices.yaml +0 -0
  23. {metergraph_core-0.3.0 → metergraph_core-0.4.0}/src/metergraph_core/retrieval.py +0 -0
  24. {metergraph_core-0.3.0 → metergraph_core-0.4.0}/src/metergraph_core.egg-info/dependency_links.txt +0 -0
  25. {metergraph_core-0.3.0 → metergraph_core-0.4.0}/src/metergraph_core.egg-info/requires.txt +0 -0
  26. {metergraph_core-0.3.0 → metergraph_core-0.4.0}/src/metergraph_core.egg-info/top_level.txt +0 -0
@@ -0,0 +1,452 @@
1
+ Metadata-Version: 2.4
2
+ Name: metergraph-core
3
+ Version: 0.4.0
4
+ Summary: Reusable MeterGraph catalog and deterministic billing engine
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: pyyaml>=6.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: build>=1.2; extra == "dev"
11
+ Requires-Dist: pytest>=8; extra == "dev"
12
+
13
+ # metergraph-core
14
+
15
+ Reusable model registry, price catalog, and deterministic billing engine for
16
+ MeterGraph.
17
+
18
+ `metergraph-core` owns two separate public datasets. `models.yaml` records
19
+ canonical model identity, customer-facing names, executable provider routes,
20
+ and the ordered candidate fields MeterGraph products offer. `prices.yaml`
21
+ records effective-dated rates and billing rules. Core parses and validates both
22
+ datasets, resolves provider aliases and routes, calculates deterministic costs,
23
+ and selects a qualified gateway-reported charge or catalog estimate as the
24
+ effective call cost. A third dataset, `sources.yaml`, registers the official
25
+ pricing sources that keep `prices.yaml` current; see
26
+ [Source registry](#source-registry).
27
+
28
+ It does not own HTTP routes, database access, migrations, authentication,
29
+ tenancy, ingest, dashboard code, or any hosted-only concern, and it never reads
30
+ server environment variables.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ python -m pip install metergraph-core
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+ from datetime import datetime, timezone
42
+
43
+ from metergraph_core import load_catalog
44
+
45
+ catalog = load_catalog(region="global")
46
+ result = catalog.snapshot.cost(
47
+ provider="openai",
48
+ model="gpt-5.4-mini",
49
+ at=datetime(2026, 8, 17, tzinfo=timezone.utc),
50
+ input_tokens=1000,
51
+ output_tokens=200,
52
+ )
53
+ print(result.canonical_model, result.price_id, result.cost_usd, result.status)
54
+ ```
55
+
56
+ `load_catalog()` loads the catalog bundled in the installed package. Pass an
57
+ explicit `path` for tests or a self-hosted catalog replacement. The returned
58
+ `LoadedCatalog` exposes the declared catalog `version`, the SHA-256
59
+ `content_hash` of the loaded bytes, the parsed `document`, and the immutable
60
+ `snapshot` used for pricing.
61
+
62
+ A catalog that is already parsed, for example a canonical JSON document read
63
+ from storage, loads with `load_catalog_document()`:
64
+
65
+ ```python
66
+ from metergraph_core import load_catalog_document
67
+
68
+ catalog = load_catalog_document(document, region="global")
69
+ ```
70
+
71
+ It validates exactly as `load_catalog()` does, because both build through the
72
+ same path, and a file and a document with the same content produce the same
73
+ catalog. The document is deep-copied, so later changes to the caller's mapping
74
+ never reach the loaded catalog. Its `content_hash` is the SHA-256 of the
75
+ document's canonical JSON (sorted keys, compact separators, ISO dates), so two
76
+ documents with the same content hash the same whatever their key order. That
77
+ differs from the raw-byte hash `load_catalog()` reports for a file.
78
+
79
+ Planning and evaluation systems that know the deployment channel before making
80
+ a call can resolve the exact effective price without emulating a provider
81
+ response:
82
+
83
+ ```python
84
+ price = catalog.snapshot.resolve_price(
85
+ model="openai/gpt-5.6-luna",
86
+ channel="vercel-ai-gateway",
87
+ at=datetime(2026, 8, 17, tzinfo=timezone.utc),
88
+ )
89
+ if price is not None:
90
+ print(
91
+ price.canonical_model,
92
+ price.price.id,
93
+ price.price.input_per_mtok,
94
+ price.price.source_url,
95
+ )
96
+ ```
97
+
98
+ Resolution accepts canonical IDs and channel-scoped aliases, normalizes case
99
+ and surrounding whitespace, applies the configured region fallback and
100
+ effective-date windows, and returns `None` when no exact model/channel price
101
+ exists. It never substitutes a direct-provider price for a gateway price.
102
+
103
+ On Bedrock only (provider `bedrock` for `cost`, channel `aws-bedrock` or
104
+ `aws-bedrock-geo` for `resolve_price`), an id with no exact alias falls back to
105
+ the id without its model-version suffix (`anthropic.claude-sonnet-5-v1:0` to
106
+ `anthropic.claude-sonnet-5`) and then without a `global.` inference-profile
107
+ prefix, the latter only onto an `aws-bedrock` alias. Geographic prefixes such as
108
+ `us.` or `eu.` are never stripped, because they bill on `aws-bedrock-geo`.
109
+
110
+ `LoadedCatalog.currency` is currently always `USD`, and
111
+ `LoadedCatalog.pricing_verified_at` records when the bundled catalog was last
112
+ checked against its linked provider sources.
113
+
114
+ ### Direct lookup and usage cost
115
+
116
+ `lookup_price` returns the price a call is billed at, and `calculate_cost`
117
+ costs a call's usage at that price:
118
+
119
+ ```python
120
+ from metergraph_core import ModelUsage
121
+
122
+ found = catalog.lookup_price(
123
+ model="gpt-5.4-mini",
124
+ provider="openai",
125
+ at=datetime(2026, 8, 17, tzinfo=timezone.utc),
126
+ )
127
+ print(found.status, found.model_id, found.price.id, found.price.currency)
128
+
129
+ result = catalog.calculate_cost(
130
+ model="gpt-5.4-mini",
131
+ provider="openai",
132
+ at=datetime(2026, 8, 17, tzinfo=timezone.utc),
133
+ usage=ModelUsage(input_tokens=1000, output_tokens=200, cache_read_tokens=500),
134
+ )
135
+ print(result.status, result.model_id, result.cost, result.currency)
136
+ print(result.components.input, result.components.cache_read)
137
+ ```
138
+
139
+ Both resolve exactly as `resolve_price` and `price` do, including
140
+ effective-date windows and the Bedrock fallback, and cost through the same
141
+ arithmetic. `model_id` is the resolved catalog ID, not the alias passed in, and
142
+ `price` is the applied catalog row. An explicit `channel` is authoritative;
143
+ without one, `provider` supplies its direct channel, and with neither the
144
+ result is `unpriced` with `missing_dimensions=("channel",)`.
145
+
146
+ Every `Price` states its `currency`, and `CostResult.currency` is the currency
147
+ of the applied price. The bundled catalog is USD. `cost` and
148
+ `components.input`, `output`, `cache_read`, `cache_write` and `request` are in
149
+ that currency; the older `cost_usd` and `*_usd` names return the same values.
150
+
151
+ A `ModelUsage` count left `None` is unknown, not zero. A missing input or
152
+ output count keeps the subtotal that is known, marks the result `partial`, and
153
+ lists `input_tokens` or `output_tokens` in `missing_dimensions`.
154
+
155
+ `region` selects a regional price and overrides the `region` passed to
156
+ `load_catalog`. When neither names a region:
157
+
158
+ - if every region bills the model the same, the result is `priced`;
159
+ - if a global price applies but some region bills differently, the global
160
+ price is returned as `partial` with `missing_dimensions=("region",)`;
161
+ - if only specific regions price the model, the result is `unpriced` with
162
+ `missing_dimensions=("region",)`.
163
+
164
+ A region the model is not sold in is `unpriced` with nothing missing.
165
+
166
+ ## Model registry
167
+
168
+ ```python
169
+ from metergraph_core import load_model_registry
170
+
171
+ models = load_model_registry()
172
+ for candidate in models.candidates("gateway"):
173
+ print(candidate.id, candidate.display_name, candidate.pricing_channel)
174
+ ```
175
+
176
+ `load_model_registry()` reads the packaged `models.yaml`. Execution profiles
177
+ describe every route the pipeline can run, while offer groups describe the
178
+ smaller ordered fields a product exposes for configured credentials. Candidate
179
+ IDs may intentionally repeat across execution profiles when gateway and
180
+ Bedrock use different physical routes; each route therefore also has a unique
181
+ internal `key`.
182
+
183
+ A model entry may record `released`, the publisher's general-availability
184
+ date as an ISO date. `models.release_date(canonical_id, load_catalog())`
185
+ returns that date with basis `released`, or, when none is recorded, the
186
+ earliest `effective_from` of the model's catalog prices with basis
187
+ `first_price`, so a consumer can tell a launch date from a pricing date
188
+ without computing the fallback itself.
189
+
190
+ Use `validate_model_registry(models, load_catalog())` to verify that every
191
+ route's provider-facing model ID is declared on its exact pricing channel,
192
+ resolves to its canonical model, and has an active price on the registry
193
+ version date. The parser also rejects unknown route providers, provider/channel
194
+ mismatches on any route, mismatched offer-group credentials/providers, and duplicate physical routes. Pricing
195
+ remains exclusively in `prices.yaml`; being priceable does not automatically
196
+ make a model an approved analysis candidate.
197
+
198
+ ## Billing evidence
199
+
200
+ Servers can pass content-blind, already-extracted gateway fields through the
201
+ shared trust boundary and combine them with a catalog result:
202
+
203
+ ```python
204
+ from metergraph_core import normalize_gateway_evidence, resolve_billing
205
+
206
+ evidence = normalize_gateway_evidence({
207
+ "gateway": "openrouter",
208
+ "endpoint": "chat.completions",
209
+ "reported_cost_usd": "0.00482",
210
+ "reported_cost_source": "openrouter.usage.cost",
211
+ })
212
+ decision = resolve_billing(result, evidence)
213
+ print(decision.cost_usd, decision.cost_provenance)
214
+ ```
215
+
216
+ The initial qualified contract is OpenRouter Chat Completions. A finite,
217
+ non-negative `openrouter.usage.cost` value, including zero, takes precedence
218
+ over a catalog estimate. The decision retains both values and never adds the
219
+ separately reported upstream inference cost to the OpenRouter account charge.
220
+ Unsupported or malformed evidence falls back to the catalog result.
221
+
222
+ The billing module validates only gateway, endpoint, fixed source names, and
223
+ decimal cost values. It does not inspect provider response content or own SDK
224
+ capture, HTTP, timestamps, trace context, persistence, or tenant behavior.
225
+
226
+ ## Pricing deltas
227
+
228
+ A pricing delta proposes changes to a catalog's price rows. It is a change
229
+ artifact to review and apply, never a catalog to serve.
230
+
231
+ ```yaml
232
+ schema_version: 1
233
+ kind: pricing_delta
234
+ provenance:
235
+ observations: [openai-api-pricing@2026-10-20]
236
+ evidence: [{source_id: openai-api-pricing, url: "https://...", fingerprint: "sha256:..."}]
237
+ operations:
238
+ - op: replace_price
239
+ key: {canonical_id: openai/gpt-x, channel: openai-api, region: global, effective_from: "2026-01-01"}
240
+ expect: {...the full current row...}
241
+ price: {channel: openai-api, region: global, effective_from: "2026-10-20",
242
+ input_per_mtok: "2.50", output_per_mtok: "10", source_url: "https://..."}
243
+ ```
244
+
245
+ Each operation names its row by `(canonical_id, channel, region,
246
+ effective_from)` on a model the catalog already has:
247
+
248
+ - `add_price` appends a row; `expect` is `null` because no such row may exist.
249
+ It may not start on the same UTC date as another row for that model,
250
+ channel and region, even one it does not overlap, because a price id names
251
+ its row by the UTC date of its start.
252
+ - `replace_price` closes the open row at the new row's `effective_from` and
253
+ appends the new row; `expect` is the exact current row. The new row must
254
+ start on a later UTC date than the row it replaces, for the same reason.
255
+ - `close_price` sets `effective_to` on the open row without a replacement.
256
+
257
+ A price id ends with the UTC date of its row's start. If two rows for one
258
+ model, channel and region would still share an id (possible in a hand-edited
259
+ catalog), each that does not start at UTC midnight also names its UTC start
260
+ time, such as `2025-01-02T10:00:00Z`.
261
+
262
+ New rates are decimal strings. `apply_catalog_delta(document, delta)` returns a
263
+ new document and never changes either input. It checks every operation's
264
+ precondition first; if any fails, `CatalogDeltaConflict.conflicts` names every
265
+ failing operation and nothing is applied. History is never rewritten, the
266
+ top-level `version` and `pricing_verified_at` are left for the publisher to
267
+ update, and the complete result is validated with `load_catalog_document()`.
268
+ Because preconditions cover only the rows a delta changes, an approved delta
269
+ still applies after unrelated catalog changes. `parse_catalog_delta()` validates
270
+ a delta without applying it.
271
+
272
+ ## Source registry
273
+
274
+ `src/metergraph_core/data/sources.yaml` lists the official pricing sources the
275
+ refresh tools read. `load_source_registry()` loads it, and
276
+ `validate_source_registry(registry, catalog)` checks its channels against a
277
+ catalog. Each entry records:
278
+
279
+ - the provider and pricing `channel`, and whether the catalog carries that
280
+ channel yet (`catalog_channel`);
281
+ - the official pricing `url`, the only address a refresh tool may fetch for it;
282
+ - the `parser` that reads it;
283
+ - the price dimensions it represents (`dimensions`): catalog price columns, or
284
+ GPU-hour and license units;
285
+ - the page's expected structural `fingerprint`;
286
+ - `low_risk`: whether automated refreshes may use it (`eligible`), and the
287
+ `dimensions` an eligible source may change.
288
+
289
+ To inspect the registered source for a provider:
290
+
291
+ ```python
292
+ from metergraph_core import load_source_registry
293
+
294
+ for source in load_source_registry().sources:
295
+ if source.provider == "anthropic":
296
+ print(source.id, source.url, source.parser, source.dimensions, source.low_risk)
297
+ ```
298
+
299
+ Seeding a source does not make it low-risk. Low-risk means a proven official
300
+ parser whose live structural fingerprint has been reviewed and recorded.
301
+ Anthropic, Google (Gemini API) and xAI start low-risk; each may change
302
+ existing rates, in its allowed dimensions, on existing rows.
303
+
304
+ OpenAI is reviewed discovery only. Its official parser still reads the live
305
+ rows, but the live pricing data no longer exposes the rendered column header
306
+ needed to verify the parser's positional columns, so its rows are evidence for
307
+ review, never automated changes.
308
+
309
+ Every other source is reviewed discovery only. It is read with a generic
310
+ parser, `html-tables` for any page's tables or `gpu-license-prose` for GPU or
311
+ license pricing stated in prose, which records normalized evidence and never
312
+ produces price rows. A generic parser does not mean the page's layout is
313
+ supported. DeepSeek, Mistral and Perplexity have official parsers (used by
314
+ `scripts/check_prices.py`) whose live pages do not currently parse cleanly to
315
+ the catalog; a source is promoted only through a reviewed registry change once
316
+ its parser and fingerprint are reviewed. NVIDIA's NIM production pricing is per
317
+ GPU or license, so it is recorded with GPU-hour and license dimensions and never
318
+ as token pricing.
319
+
320
+ ### Extractors
321
+
322
+ `extract(parser_id, content)` reads source content the caller supplies and
323
+ returns the rates it finds, mapped to catalog price columns, or evidence for a
324
+ generic discovery parser. `structural_fingerprint(parser_id, content)` summarizes
325
+ only the labels, headers and key paths that parser relies on, so a price change
326
+ or a newly listed model keeps the fingerprint and a format change alters it.
327
+ Extractors never fetch. The package adds no browser or LLM dependency.
328
+
329
+ ## Refresh and discovery scripts
330
+
331
+ Two repository scripts run the registry against live sources. Neither holds
332
+ credentials, applies a delta, writes the catalog, publishes, or touches GitHub.
333
+
334
+ Neither ever emits, stores or redistributes raw fetched page content. Their
335
+ reports and evidence files hold only structural fingerprints, normalized
336
+ extracted values (row labels as slugs, parsed amounts, numeric candidates),
337
+ candidates, operations and short non-verbatim diagnostics. No output may name
338
+ an input the run read.
339
+
340
+ `scripts/refresh_prices.py` takes the registry and a current catalog document.
341
+ For each low-risk source it fetches the registered URL, checks the page's
342
+ fingerprint, extracts prices, checks invariants, and prints a JSON
343
+ report: a pricing delta for the changes that pass, one observation per source
344
+ (including sources with no change), and every escalation. New models, new
345
+ regions, new dimensions, rows a page no longer lists, ambiguous parses, format
346
+ changes, non-positive or missing rates, changes outside the allowed
347
+ dimensions, duplicated model entries, changes to a row that already has a
348
+ scheduled end, a row more than one selected source proposes (whether or not
349
+ they agree), invalid current catalog rates, and backdated or same-day
350
+ effective dates escalate. A change takes effect on the UTC observation date. It exits 0 with no change, 2 with a delta or
351
+ an escalation, and 1 on error.
352
+
353
+ `scripts/discover_prices.py` scans sources that are not low-risk, the sources
354
+ behind a refresh report's escalations (`--escalations`), and new-source content
355
+ from a file or stdin (`--new-source`, never a URL). It prints evidence for each:
356
+ candidate and prior values, the reason for review, normalized page facts, and a
357
+ proposed delta for changed catalog rows. An optional adapter
358
+ (`--llm-adapter module:callable`) is called as
359
+ `propose(source, content, current_rows)` and returns delta operations. Its
360
+ output is kept as review evidence only after it passes schema validation and
361
+ encodes as strict JSON. Adapter failures and rejected items are reported with
362
+ stable codes, never with the adapter's own text. Everything discovery emits
363
+ requires review.
364
+
365
+ Both read `<source-id>.*` files from `--content-dir` instead of fetching. The
366
+ tests use one synthetic fixture per low-risk source, plus OpenAI's, in
367
+ `tests/fixtures/sources/`, reproducing only the structure its parser reads;
368
+ the generic discovery parsers are tested on small inline synthetic pages.
369
+
370
+ ## Provider review and contributions
371
+
372
+ Model providers, new and existing, can check and improve how MeterGraph prices
373
+ their models:
374
+
375
+ 1. **Review your entry.** Find your provider in `sources.yaml` (or run the
376
+ snippet above) and check the registered pricing URL, its parser, and the
377
+ dimensions it represents. Then check your models' current rows in
378
+ `prices.yaml`.
379
+ 2. **Propose a correction or a new source.** Edit your entry or add a new
380
+ provider with its official pricing URL. A new source starts non-low-risk.
381
+ 3. **Add the extractor, fixture and tests when proposing low-risk status.** A
382
+ discovery-only source needs no fixture of its own. A source proposed for
383
+ low-risk status needs an extractor in `extractors.py` and a minimal synthetic
384
+ fixture in `tests/fixtures/sources/` that reproduces only the structure the
385
+ parser reads, never a saved copy of the page, with tests.
386
+ 4. **Open a pull request** with the official-source evidence and an effective
387
+ date for each pricing change. Price changes close the old row's window and
388
+ add a new row; they never edit history.
389
+
390
+ Provider-submitted changes go through the same validation and review as any
391
+ other catalog change. A provider cannot mark its own source low-risk or publish
392
+ directly to the live catalog: low-risk status changes only through a reviewed
393
+ registry change, and publication is done by MeterGraph's release process.
394
+
395
+ ## Public API
396
+
397
+ ```python
398
+ from metergraph_core import (
399
+ Alias,
400
+ BillingDecision,
401
+ CatalogDeltaConflict,
402
+ CatalogDeltaError,
403
+ CatalogError,
404
+ CatalogSnapshot,
405
+ CostResult,
406
+ GatewayBillingEvidence,
407
+ LoadedCatalog,
408
+ ModelRegistry,
409
+ ModelRoute,
410
+ ModelUsage,
411
+ Price,
412
+ PriceResult,
413
+ PriceRowKey,
414
+ PriceSource,
415
+ RefreshResult,
416
+ ResolvedPrice,
417
+ SourceRegistry,
418
+ apply_catalog_delta,
419
+ discovery_evidence,
420
+ extract,
421
+ load_catalog,
422
+ load_catalog_document,
423
+ load_model_registry,
424
+ load_source_registry,
425
+ normalize_gateway_evidence,
426
+ parse_catalog,
427
+ parse_catalog_delta,
428
+ plan_low_risk_refresh,
429
+ resolve_billing,
430
+ structural_fingerprint,
431
+ validate_model_registry,
432
+ validate_source_registry,
433
+ )
434
+ ```
435
+
436
+ ## Catalog maintenance
437
+
438
+ The two manually maintained public datasets live under
439
+ `src/metergraph_core/data/`. Update `models.yaml` when model identity, display,
440
+ routing, or managed candidate policy changes. Update `prices.yaml` when rates
441
+ or billing rules change. `sources.yaml` changes through reviewed pull requests,
442
+ as described in
443
+ [Provider review and contributions](#provider-review-and-contributions). Price
444
+ corrections close or add effective windows; they never rewrite historical
445
+ prices in place. Either data change increments
446
+ its own declared version and produces a patch release of `metergraph-core`.
447
+ Software and data versions remain separate because they have different
448
+ lifecycles.
449
+
450
+ The model registry version is an ISO date. When more than one registry ships on
451
+ the same date, append a positive revision such as `2026-09-25.2` so consumers
452
+ can distinguish the policies while pricing validation still uses that date.