metergraph-core 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- metergraph_core/__init__.py +21 -0
- metergraph_core/catalog.py +275 -0
- metergraph_core/data/prices.yaml +886 -0
- metergraph_core/loader.py +167 -0
- metergraph_core-0.1.0.dist-info/METADATA +110 -0
- metergraph_core-0.1.0.dist-info/RECORD +8 -0
- metergraph_core-0.1.0.dist-info/WHEEL +5 -0
- metergraph_core-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Parse and load the bundled prices.yaml into a CatalogSnapshot."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import date, datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from .catalog import Alias, CatalogSnapshot, Price, _decimal
|
|
13
|
+
|
|
14
|
+
DEFAULT_CATALOG_PATH = Path(__file__).parent / "data" / "prices.yaml"
|
|
15
|
+
_PROVIDER_SYNONYMS = {"bedrock": ("aws-bedrock", "aws")}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CatalogError(ValueError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _freeze(value: Any) -> Any:
|
|
23
|
+
if isinstance(value, dict):
|
|
24
|
+
return MappingProxyType({key: _freeze(item) for key, item in value.items()})
|
|
25
|
+
if isinstance(value, list):
|
|
26
|
+
return tuple(_freeze(item) for item in value)
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class LoadedCatalog:
|
|
32
|
+
version: str
|
|
33
|
+
currency: str
|
|
34
|
+
pricing_verified_at: date
|
|
35
|
+
content_hash: str
|
|
36
|
+
document: dict[str, Any]
|
|
37
|
+
snapshot: CatalogSnapshot
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _date(value: Any, *, field: str, model: str) -> datetime:
|
|
41
|
+
if value is None:
|
|
42
|
+
raise CatalogError(f"{model}: missing {field}")
|
|
43
|
+
try:
|
|
44
|
+
parsed = datetime.fromisoformat(str(value))
|
|
45
|
+
except ValueError as exc:
|
|
46
|
+
raise CatalogError(f"{model}: invalid {field} {value!r}") from exc
|
|
47
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _catalog_metadata(document: dict[str, Any]) -> tuple[str, date]:
|
|
51
|
+
currency = str(document.get("currency") or "").strip().upper()
|
|
52
|
+
if currency != "USD":
|
|
53
|
+
raise CatalogError("prices document currency must be USD")
|
|
54
|
+
try:
|
|
55
|
+
pricing_verified_at = date.fromisoformat(
|
|
56
|
+
str(document.get("pricing_verified_at") or "")
|
|
57
|
+
)
|
|
58
|
+
except ValueError as exc:
|
|
59
|
+
raise CatalogError(
|
|
60
|
+
"prices document must have an ISO pricing_verified_at date"
|
|
61
|
+
) from exc
|
|
62
|
+
return currency, pricing_verified_at
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_catalog(
|
|
66
|
+
document: Any,
|
|
67
|
+
) -> tuple[str, dict[tuple[str, str], Alias], list[Price]]:
|
|
68
|
+
if not isinstance(document, dict) or not isinstance(document.get("models"), list):
|
|
69
|
+
raise CatalogError("prices document must have a models list")
|
|
70
|
+
version = str(document.get("version") or "")
|
|
71
|
+
if not version:
|
|
72
|
+
raise CatalogError("prices document must have a version")
|
|
73
|
+
_catalog_metadata(document)
|
|
74
|
+
aliases: dict[tuple[str, str], Alias] = {}
|
|
75
|
+
prices: list[Price] = []
|
|
76
|
+
for entry in document["models"]:
|
|
77
|
+
canonical = str(entry.get("canonical_id") or "")
|
|
78
|
+
if not canonical:
|
|
79
|
+
raise CatalogError("model entry missing canonical_id")
|
|
80
|
+
for alias in entry.get("aliases") or []:
|
|
81
|
+
provider = str(alias.get("provider") or "").lower()
|
|
82
|
+
name = str(alias.get("alias") or "").lower()
|
|
83
|
+
channel = str(alias.get("channel") or "")
|
|
84
|
+
if not provider or not name or not channel:
|
|
85
|
+
raise CatalogError(f"{canonical}: alias needs provider/alias/channel")
|
|
86
|
+
keys = [(provider, name)]
|
|
87
|
+
for synonym in _PROVIDER_SYNONYMS.get(provider, ()):
|
|
88
|
+
keys.append((synonym, name))
|
|
89
|
+
for key in keys:
|
|
90
|
+
if key in aliases:
|
|
91
|
+
raise CatalogError(f"{canonical}: duplicate alias {key}")
|
|
92
|
+
aliases[key] = Alias(
|
|
93
|
+
model_id=canonical,
|
|
94
|
+
canonical_id=canonical,
|
|
95
|
+
pricing_channel=channel,
|
|
96
|
+
rules=_freeze(alias.get("rules") or {}),
|
|
97
|
+
)
|
|
98
|
+
seen_windows: list[tuple[str, str, datetime, datetime | None]] = []
|
|
99
|
+
for price in entry.get("prices") or []:
|
|
100
|
+
channel = str(price.get("channel") or "")
|
|
101
|
+
region = str(price.get("region") or "global")
|
|
102
|
+
if not channel:
|
|
103
|
+
raise CatalogError(f"{canonical}: price entry needs channel")
|
|
104
|
+
if not str(price.get("source_url") or "").strip():
|
|
105
|
+
raise CatalogError(f"{canonical}: price entry needs source_url")
|
|
106
|
+
effective_from = _date(
|
|
107
|
+
price.get("effective_from"), field="effective_from", model=canonical
|
|
108
|
+
)
|
|
109
|
+
effective_to = (
|
|
110
|
+
_date(price.get("effective_to"), field="effective_to", model=canonical)
|
|
111
|
+
if price.get("effective_to") is not None
|
|
112
|
+
else None
|
|
113
|
+
)
|
|
114
|
+
if effective_to is not None and effective_to <= effective_from:
|
|
115
|
+
raise CatalogError(f"{canonical}: effective_to before effective_from")
|
|
116
|
+
for other_channel, other_region, other_from, other_to in seen_windows:
|
|
117
|
+
if (other_channel, other_region) != (channel, region):
|
|
118
|
+
continue
|
|
119
|
+
if (effective_to is None or other_from < effective_to) and (
|
|
120
|
+
other_to is None or effective_from < other_to
|
|
121
|
+
):
|
|
122
|
+
raise CatalogError(
|
|
123
|
+
f"{canonical}: overlapping {channel}/{region} price windows"
|
|
124
|
+
)
|
|
125
|
+
seen_windows.append((channel, region, effective_from, effective_to))
|
|
126
|
+
prices.append(
|
|
127
|
+
Price(
|
|
128
|
+
id=f"{canonical}:{channel}:{region}:{effective_from.date()}",
|
|
129
|
+
model_id=canonical,
|
|
130
|
+
pricing_channel=channel,
|
|
131
|
+
region=region,
|
|
132
|
+
input_per_mtok=_decimal(price.get("input_per_mtok")),
|
|
133
|
+
output_per_mtok=_decimal(price.get("output_per_mtok")),
|
|
134
|
+
cache_read_per_mtok=_decimal(price.get("cache_read_per_mtok")),
|
|
135
|
+
cache_write_5m_per_mtok=_decimal(
|
|
136
|
+
price.get("cache_write_5m_per_mtok")
|
|
137
|
+
),
|
|
138
|
+
cache_write_1h_per_mtok=_decimal(
|
|
139
|
+
price.get("cache_write_1h_per_mtok")
|
|
140
|
+
),
|
|
141
|
+
batch_input_per_mtok=_decimal(price.get("batch_input_per_mtok")),
|
|
142
|
+
batch_output_per_mtok=_decimal(price.get("batch_output_per_mtok")),
|
|
143
|
+
rules=_freeze(price.get("rules") or {}),
|
|
144
|
+
effective_from=effective_from,
|
|
145
|
+
effective_to=effective_to,
|
|
146
|
+
source_url=str(price["source_url"]).strip(),
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
return version, aliases, prices
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def load_catalog(
|
|
153
|
+
path: str | Path | None = None, *, region: str = "global"
|
|
154
|
+
) -> LoadedCatalog:
|
|
155
|
+
resolved = Path(path) if path is not None else DEFAULT_CATALOG_PATH
|
|
156
|
+
content = resolved.read_bytes()
|
|
157
|
+
document = yaml.safe_load(content)
|
|
158
|
+
version, aliases, prices = parse_catalog(document)
|
|
159
|
+
currency, pricing_verified_at = _catalog_metadata(document)
|
|
160
|
+
return LoadedCatalog(
|
|
161
|
+
version=version,
|
|
162
|
+
currency=currency,
|
|
163
|
+
pricing_verified_at=pricing_verified_at,
|
|
164
|
+
content_hash=hashlib.sha256(content).hexdigest(),
|
|
165
|
+
document=document,
|
|
166
|
+
snapshot=CatalogSnapshot(aliases, prices, region=region),
|
|
167
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: metergraph-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reusable MeterGraph catalog and deterministic token-cost pricing 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 catalog and deterministic token-cost pricing engine for MeterGraph.
|
|
16
|
+
|
|
17
|
+
`metergraph-core` owns the public effective-dated model catalog and the pricing
|
|
18
|
+
logic shared across MeterGraph systems: catalog parsing and validation, provider
|
|
19
|
+
and model alias resolution, channel and region selection, input/output/cache/
|
|
20
|
+
batch/long-context pricing rules, deterministic cost calculation with reason
|
|
21
|
+
codes, stable logical price identifiers, and catalog version and content-hash
|
|
22
|
+
reporting.
|
|
23
|
+
|
|
24
|
+
It does not own HTTP routes, database access, migrations, authentication,
|
|
25
|
+
tenancy, ingest, dashboard code, or any hosted-only concern, and it never reads
|
|
26
|
+
server environment variables.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
python -m pip install metergraph-core
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from datetime import datetime, timezone
|
|
38
|
+
|
|
39
|
+
from metergraph_core import load_catalog
|
|
40
|
+
|
|
41
|
+
catalog = load_catalog(region="global")
|
|
42
|
+
result = catalog.snapshot.cost(
|
|
43
|
+
provider="openai",
|
|
44
|
+
model="gpt-5.4-mini",
|
|
45
|
+
at=datetime(2026, 8, 17, tzinfo=timezone.utc),
|
|
46
|
+
input_tokens=1000,
|
|
47
|
+
output_tokens=200,
|
|
48
|
+
)
|
|
49
|
+
print(result.canonical_model, result.price_id, result.cost_usd, result.status)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`load_catalog()` loads the catalog bundled in the installed package. Pass an
|
|
53
|
+
explicit `path` for tests or a self-hosted catalog replacement. The returned
|
|
54
|
+
`LoadedCatalog` exposes the declared catalog `version`, the SHA-256
|
|
55
|
+
`content_hash` of the loaded bytes, the parsed `document`, and the immutable
|
|
56
|
+
`snapshot` used for pricing.
|
|
57
|
+
|
|
58
|
+
Planning and evaluation systems that know the deployment channel before making
|
|
59
|
+
a call can resolve the exact effective price without emulating a provider
|
|
60
|
+
response:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
price = catalog.snapshot.resolve_price(
|
|
64
|
+
model="openai/gpt-5.6-luna",
|
|
65
|
+
channel="vercel-ai-gateway",
|
|
66
|
+
at=datetime(2026, 8, 17, tzinfo=timezone.utc),
|
|
67
|
+
)
|
|
68
|
+
if price is not None:
|
|
69
|
+
print(
|
|
70
|
+
price.canonical_model,
|
|
71
|
+
price.price.id,
|
|
72
|
+
price.price.input_per_mtok,
|
|
73
|
+
price.price.source_url,
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Resolution accepts canonical IDs and channel-scoped aliases, normalizes case
|
|
78
|
+
and surrounding whitespace, applies the configured region fallback and
|
|
79
|
+
effective-date windows, and returns `None` when no exact model/channel price
|
|
80
|
+
exists. It never substitutes a direct-provider price for a gateway price.
|
|
81
|
+
|
|
82
|
+
`LoadedCatalog.currency` is currently always `USD`, and
|
|
83
|
+
`LoadedCatalog.pricing_verified_at` records when the bundled catalog was last
|
|
84
|
+
checked against its linked provider sources.
|
|
85
|
+
|
|
86
|
+
## Public API
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from metergraph_core import (
|
|
90
|
+
Alias,
|
|
91
|
+
CatalogError,
|
|
92
|
+
CatalogSnapshot,
|
|
93
|
+
CostResult,
|
|
94
|
+
LoadedCatalog,
|
|
95
|
+
Price,
|
|
96
|
+
ResolvedPrice,
|
|
97
|
+
load_catalog,
|
|
98
|
+
parse_catalog,
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Catalog maintenance
|
|
103
|
+
|
|
104
|
+
The only manually maintained public catalog lives at
|
|
105
|
+
`src/metergraph_core/data/prices.yaml`. Every record requires its provider
|
|
106
|
+
source URL and effective date. Corrections close or add effective windows; they
|
|
107
|
+
never rewrite historical prices in place. A catalog change updates the declared
|
|
108
|
+
catalog version and produces a patch release of `metergraph-core`. Software
|
|
109
|
+
version and catalog version are separate values because code and price data have
|
|
110
|
+
different lifecycles.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
metergraph_core/__init__.py,sha256=P9swsQXDsqRz4_XN3YA7XQCd2xFuZbydZLdAQ4N11KQ,439
|
|
2
|
+
metergraph_core/catalog.py,sha256=rpuvaCRIKthnOJ2RvextEj6y5nLs1mOnGT61NQuMalA,9545
|
|
3
|
+
metergraph_core/loader.py,sha256=RfaZZKNk0rNjlOE1bkBYCyRWMhPCjO-5fCplXk5EQsE,6927
|
|
4
|
+
metergraph_core/data/prices.yaml,sha256=GJFTuC6Sj6RJTRwPROOvgFaQiGUJBM-N_2wPXsYJcfc,31937
|
|
5
|
+
metergraph_core-0.1.0.dist-info/METADATA,sha256=6NWNuBtAB1z5-L7zfEdqULJ1mbW9pogy7Oso0EVrRxI,3538
|
|
6
|
+
metergraph_core-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
metergraph_core-0.1.0.dist-info/top_level.txt,sha256=N9yCRr05z4tcQCXAyi9_XFieR-yBZ9dcHNjlolBnn8o,16
|
|
8
|
+
metergraph_core-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
metergraph_core
|