metergraph-core 0.1.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,4 @@
1
+ # Tests are not part of the distributed package. setuptools would otherwise
2
+ # auto-include top-level test modules in the sdist; prune the whole tree so the
3
+ # sdist matches the wheel and ships only the package and its bundled catalog.
4
+ prune tests
@@ -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,98 @@
1
+ # metergraph-core
2
+
3
+ Reusable catalog and deterministic token-cost pricing engine for MeterGraph.
4
+
5
+ `metergraph-core` owns the public effective-dated model catalog and the pricing
6
+ logic shared across MeterGraph systems: catalog parsing and validation, provider
7
+ and model alias resolution, channel and region selection, input/output/cache/
8
+ batch/long-context pricing rules, deterministic cost calculation with reason
9
+ codes, stable logical price identifiers, and catalog version and content-hash
10
+ reporting.
11
+
12
+ It does not own HTTP routes, database access, migrations, authentication,
13
+ tenancy, ingest, dashboard code, or any hosted-only concern, and it never reads
14
+ server environment variables.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ python -m pip install metergraph-core
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```python
25
+ from datetime import datetime, timezone
26
+
27
+ from metergraph_core import load_catalog
28
+
29
+ catalog = load_catalog(region="global")
30
+ result = catalog.snapshot.cost(
31
+ provider="openai",
32
+ model="gpt-5.4-mini",
33
+ at=datetime(2026, 8, 17, tzinfo=timezone.utc),
34
+ input_tokens=1000,
35
+ output_tokens=200,
36
+ )
37
+ print(result.canonical_model, result.price_id, result.cost_usd, result.status)
38
+ ```
39
+
40
+ `load_catalog()` loads the catalog bundled in the installed package. Pass an
41
+ explicit `path` for tests or a self-hosted catalog replacement. The returned
42
+ `LoadedCatalog` exposes the declared catalog `version`, the SHA-256
43
+ `content_hash` of the loaded bytes, the parsed `document`, and the immutable
44
+ `snapshot` used for pricing.
45
+
46
+ Planning and evaluation systems that know the deployment channel before making
47
+ a call can resolve the exact effective price without emulating a provider
48
+ response:
49
+
50
+ ```python
51
+ price = catalog.snapshot.resolve_price(
52
+ model="openai/gpt-5.6-luna",
53
+ channel="vercel-ai-gateway",
54
+ at=datetime(2026, 8, 17, tzinfo=timezone.utc),
55
+ )
56
+ if price is not None:
57
+ print(
58
+ price.canonical_model,
59
+ price.price.id,
60
+ price.price.input_per_mtok,
61
+ price.price.source_url,
62
+ )
63
+ ```
64
+
65
+ Resolution accepts canonical IDs and channel-scoped aliases, normalizes case
66
+ and surrounding whitespace, applies the configured region fallback and
67
+ effective-date windows, and returns `None` when no exact model/channel price
68
+ exists. It never substitutes a direct-provider price for a gateway price.
69
+
70
+ `LoadedCatalog.currency` is currently always `USD`, and
71
+ `LoadedCatalog.pricing_verified_at` records when the bundled catalog was last
72
+ checked against its linked provider sources.
73
+
74
+ ## Public API
75
+
76
+ ```python
77
+ from metergraph_core import (
78
+ Alias,
79
+ CatalogError,
80
+ CatalogSnapshot,
81
+ CostResult,
82
+ LoadedCatalog,
83
+ Price,
84
+ ResolvedPrice,
85
+ load_catalog,
86
+ parse_catalog,
87
+ )
88
+ ```
89
+
90
+ ## Catalog maintenance
91
+
92
+ The only manually maintained public catalog lives at
93
+ `src/metergraph_core/data/prices.yaml`. Every record requires its provider
94
+ source URL and effective date. Corrections close or add effective windows; they
95
+ never rewrite historical prices in place. A catalog change updates the declared
96
+ catalog version and produces a patch release of `metergraph-core`. Software
97
+ version and catalog version are separate values because code and price data have
98
+ different lifecycles.
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "metergraph-core"
3
+ version = "0.1.0"
4
+ description = "Reusable MeterGraph catalog and deterministic token-cost pricing engine"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.10"
8
+ dependencies = [
9
+ "pyyaml>=6.0",
10
+ ]
11
+
12
+ [project.optional-dependencies]
13
+ dev = ["build>=1.2", "pytest>=8"]
14
+
15
+ [build-system]
16
+ requires = ["setuptools>=68"]
17
+ build-backend = "setuptools.build_meta"
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
21
+
22
+ [tool.setuptools.package-data]
23
+ metergraph_core = ["data/prices.yaml"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,21 @@
1
+ """Reusable MeterGraph catalog and deterministic token-cost pricing core."""
2
+
3
+ from .catalog import Alias, CatalogSnapshot, CostResult, Price, ResolvedPrice
4
+ from .loader import (
5
+ CatalogError,
6
+ LoadedCatalog,
7
+ load_catalog,
8
+ parse_catalog,
9
+ )
10
+
11
+ __all__ = [
12
+ "Alias",
13
+ "CatalogError",
14
+ "CatalogSnapshot",
15
+ "CostResult",
16
+ "LoadedCatalog",
17
+ "Price",
18
+ "ResolvedPrice",
19
+ "load_catalog",
20
+ "parse_catalog",
21
+ ]
@@ -0,0 +1,275 @@
1
+ """Effective-dated model catalog and deterministic token-cost enrichment."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+ from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
6
+ from types import MappingProxyType
7
+ from typing import Any, Mapping
8
+
9
+ _MILLION = Decimal("1000000")
10
+ _COST_QUANTUM = Decimal("0.00000001")
11
+ _PROVIDER_ALIASES = {
12
+ "amazon-bedrock": "bedrock",
13
+ "aws": "bedrock",
14
+ "aws-bedrock": "bedrock",
15
+ "gemini": "google",
16
+ "google-genai": "google",
17
+ }
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Alias:
22
+ model_id: str
23
+ canonical_id: str
24
+ pricing_channel: str
25
+ rules: Mapping[str, Any]
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class Price:
30
+ id: str
31
+ model_id: str
32
+ pricing_channel: str
33
+ region: str
34
+ input_per_mtok: Decimal | None
35
+ output_per_mtok: Decimal | None
36
+ cache_read_per_mtok: Decimal | None
37
+ cache_write_5m_per_mtok: Decimal | None
38
+ cache_write_1h_per_mtok: Decimal | None
39
+ batch_input_per_mtok: Decimal | None
40
+ batch_output_per_mtok: Decimal | None
41
+ rules: Mapping[str, Any]
42
+ effective_from: datetime
43
+ effective_to: datetime | None
44
+ source_url: str
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class CostResult:
49
+ canonical_model: str | None
50
+ price_id: str | None
51
+ cost_usd: Decimal | None
52
+ status: str
53
+ reasons: tuple[str, ...] = ()
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class ResolvedPrice:
58
+ """An effective price selected for a planned model deployment."""
59
+
60
+ canonical_model: str
61
+ price: Price
62
+ rules: Mapping[str, Any]
63
+
64
+
65
+ def _decimal(value: Any) -> Decimal | None:
66
+ if value is None:
67
+ return None
68
+ try:
69
+ result = Decimal(str(value))
70
+ except (InvalidOperation, ValueError, TypeError):
71
+ return None
72
+ return result if result.is_finite() else None
73
+
74
+
75
+ def _tokens(value: Any) -> int | None:
76
+ if value is None or isinstance(value, bool):
77
+ return None
78
+ try:
79
+ result = int(value)
80
+ except (ValueError, TypeError, OverflowError):
81
+ return None
82
+ return result if result >= 0 else None
83
+
84
+
85
+ class CatalogSnapshot:
86
+ def __init__(
87
+ self,
88
+ aliases: Mapping[tuple[str, str], Alias],
89
+ prices: list[Price],
90
+ *,
91
+ region: str,
92
+ ) -> None:
93
+ self._aliases = dict(aliases)
94
+ self._prices: dict[tuple[str, str], list[Price]] = {}
95
+ self._deployment_aliases: dict[tuple[str, str], Alias] = {}
96
+ self._region = region.strip().lower()
97
+ for (_, observed_model), alias in aliases.items():
98
+ channel = alias.pricing_channel.strip().lower()
99
+ for model in (observed_model, alias.canonical_id):
100
+ key = (model.strip().lower(), channel)
101
+ existing = self._deployment_aliases.get(key)
102
+ if existing is not None and (
103
+ existing.canonical_id != alias.canonical_id
104
+ or dict(existing.rules) != dict(alias.rules)
105
+ ):
106
+ raise ValueError(
107
+ f"ambiguous deployment alias {model!r} for channel {channel!r}"
108
+ )
109
+ self._deployment_aliases[key] = alias
110
+ for price in prices:
111
+ self._prices.setdefault((price.model_id, price.pricing_channel), []).append(
112
+ price
113
+ )
114
+ for candidates in self._prices.values():
115
+ candidates.sort(key=lambda price: price.effective_from, reverse=True)
116
+
117
+ def _price_for(self, alias: Alias, at: datetime) -> Price | None:
118
+ at = at if at.tzinfo else at.replace(tzinfo=timezone.utc)
119
+ candidates = self._prices.get((alias.model_id, alias.pricing_channel), [])
120
+ for region in dict.fromkeys((self._region, "*", "global")):
121
+ for price in candidates:
122
+ if price.region.lower() != region:
123
+ continue
124
+ if price.effective_from <= at and (
125
+ price.effective_to is None or at < price.effective_to
126
+ ):
127
+ return price
128
+ return None
129
+
130
+ def resolve_price(
131
+ self, *, model: Any, channel: Any, at: datetime
132
+ ) -> ResolvedPrice | None:
133
+ """Resolve pricing for an explicitly selected model and channel.
134
+
135
+ This is intended for planners and evaluation pipelines that know the
136
+ deployment channel before making a provider call. It never falls back
137
+ to pricing from a different channel.
138
+ """
139
+
140
+ model_key = str(model or "").strip().lower()
141
+ channel_key = str(channel or "").strip().lower()
142
+ alias = self._deployment_aliases.get((model_key, channel_key))
143
+ if alias is None:
144
+ return None
145
+ price = self._price_for(alias, at)
146
+ if price is None:
147
+ return None
148
+ return ResolvedPrice(
149
+ canonical_model=alias.canonical_id,
150
+ price=price,
151
+ rules=MappingProxyType({**price.rules, **alias.rules}),
152
+ )
153
+
154
+ def cost(
155
+ self,
156
+ *,
157
+ provider: Any,
158
+ model: Any,
159
+ at: datetime,
160
+ input_tokens: Any,
161
+ output_tokens: Any,
162
+ cache_read_tokens: Any = None,
163
+ cache_write_tokens: Any = None,
164
+ batch: bool = False,
165
+ ) -> CostResult:
166
+ provider_key = str(provider or "").strip().lower()
167
+ provider_key = _PROVIDER_ALIASES.get(provider_key, provider_key)
168
+ model_key = str(model or "").strip().lower()
169
+ alias = self._aliases.get((provider_key, model_key))
170
+ if alias is None:
171
+ return CostResult(None, None, None, "unpriced", ("unknown_model",))
172
+ price = self._price_for(alias, at)
173
+ if price is None:
174
+ return CostResult(
175
+ alias.canonical_id,
176
+ None,
177
+ None,
178
+ "unpriced",
179
+ ("no_effective_price",),
180
+ )
181
+
182
+ reasons: list[str] = []
183
+ input_count = _tokens(input_tokens)
184
+ output_count = _tokens(output_tokens)
185
+ cache_read_count = _tokens(cache_read_tokens) or 0
186
+ cache_write_count = _tokens(cache_write_tokens) or 0
187
+ if input_count is None:
188
+ reasons.append("missing_input_tokens")
189
+ input_count = 0
190
+ if output_count is None:
191
+ reasons.append("missing_output_tokens")
192
+ output_count = 0
193
+
194
+ rules = {**price.rules, **alias.rules}
195
+ billable_input = input_count
196
+ deducted_input = 0
197
+ if rules.get("input_includes_cache_read"):
198
+ if cache_read_count > input_count:
199
+ reasons.append("cache_read_exceeds_input")
200
+ deducted_input = input_count
201
+ else:
202
+ deducted_input += cache_read_count
203
+ if rules.get("input_includes_cache_write"):
204
+ if cache_write_count > input_count - deducted_input:
205
+ reasons.append("cache_write_exceeds_input")
206
+ deducted_input = input_count
207
+ else:
208
+ deducted_input += cache_write_count
209
+ billable_input -= deducted_input
210
+
211
+ input_rate = price.input_per_mtok
212
+ output_rate = price.output_per_mtok
213
+ if batch:
214
+ if (
215
+ price.batch_input_per_mtok is None
216
+ or price.batch_output_per_mtok is None
217
+ ):
218
+ reasons.append("batch_rate_unavailable")
219
+ else:
220
+ input_rate = price.batch_input_per_mtok
221
+ output_rate = price.batch_output_per_mtok
222
+
223
+ input_multiplier = Decimal("1")
224
+ output_multiplier = Decimal("1")
225
+ long_context = rules.get("long_context") or {}
226
+ threshold = _tokens(long_context.get("threshold"))
227
+ if threshold is not None and input_count > threshold:
228
+ input_multiplier = _decimal(
229
+ long_context.get("input_multiplier")
230
+ ) or Decimal("1")
231
+ output_multiplier = _decimal(
232
+ long_context.get("output_multiplier")
233
+ ) or Decimal("1")
234
+
235
+ cost = Decimal("0")
236
+ if input_rate is None:
237
+ if billable_input:
238
+ reasons.append("input_rate_unavailable")
239
+ else:
240
+ cost += Decimal(billable_input) * input_rate * input_multiplier / _MILLION
241
+ if output_rate is None:
242
+ if output_count:
243
+ reasons.append("output_rate_unavailable")
244
+ else:
245
+ cost += Decimal(output_count) * output_rate * output_multiplier / _MILLION
246
+ if cache_read_count:
247
+ if price.cache_read_per_mtok is None:
248
+ reasons.append("cache_read_rate_unavailable")
249
+ else:
250
+ cost += (
251
+ Decimal(cache_read_count)
252
+ * price.cache_read_per_mtok
253
+ * input_multiplier
254
+ / _MILLION
255
+ )
256
+ if cache_write_count:
257
+ if price.cache_write_5m_per_mtok is None:
258
+ reasons.append("cache_write_rate_unavailable")
259
+ else:
260
+ cost += (
261
+ Decimal(cache_write_count)
262
+ * price.cache_write_5m_per_mtok
263
+ * input_multiplier
264
+ / _MILLION
265
+ )
266
+ if rules.get("uncaptured_fees"):
267
+ reasons.append("uncaptured_fees")
268
+
269
+ return CostResult(
270
+ alias.canonical_id,
271
+ price.id,
272
+ cost.quantize(_COST_QUANTUM, rounding=ROUND_HALF_UP),
273
+ "partial" if reasons else "priced",
274
+ tuple(dict.fromkeys(reasons)),
275
+ )