metergraph-core 0.2.61__tar.gz → 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.
Files changed (18) hide show
  1. {metergraph_core-0.2.61/src/metergraph_core.egg-info → metergraph_core-0.3.0}/PKG-INFO +55 -1
  2. {metergraph_core-0.2.61 → metergraph_core-0.3.0}/README.md +54 -0
  3. {metergraph_core-0.2.61 → metergraph_core-0.3.0}/pyproject.toml +1 -1
  4. {metergraph_core-0.2.61 → metergraph_core-0.3.0}/src/metergraph_core/__init__.py +4 -0
  5. {metergraph_core-0.2.61 → metergraph_core-0.3.0}/src/metergraph_core/catalog.py +230 -58
  6. {metergraph_core-0.2.61 → metergraph_core-0.3.0}/src/metergraph_core/data/models.yaml +43 -1
  7. {metergraph_core-0.2.61 → metergraph_core-0.3.0}/src/metergraph_core/data/prices.yaml +131 -2
  8. {metergraph_core-0.2.61 → metergraph_core-0.3.0}/src/metergraph_core/loader.py +65 -1
  9. {metergraph_core-0.2.61 → metergraph_core-0.3.0/src/metergraph_core.egg-info}/PKG-INFO +55 -1
  10. {metergraph_core-0.2.61 → metergraph_core-0.3.0}/MANIFEST.in +0 -0
  11. {metergraph_core-0.2.61 → metergraph_core-0.3.0}/setup.cfg +0 -0
  12. {metergraph_core-0.2.61 → metergraph_core-0.3.0}/src/metergraph_core/billing.py +0 -0
  13. {metergraph_core-0.2.61 → metergraph_core-0.3.0}/src/metergraph_core/models.py +0 -0
  14. {metergraph_core-0.2.61 → metergraph_core-0.3.0}/src/metergraph_core/retrieval.py +0 -0
  15. {metergraph_core-0.2.61 → metergraph_core-0.3.0}/src/metergraph_core.egg-info/SOURCES.txt +0 -0
  16. {metergraph_core-0.2.61 → metergraph_core-0.3.0}/src/metergraph_core.egg-info/dependency_links.txt +0 -0
  17. {metergraph_core-0.2.61 → metergraph_core-0.3.0}/src/metergraph_core.egg-info/requires.txt +0 -0
  18. {metergraph_core-0.2.61 → metergraph_core-0.3.0}/src/metergraph_core.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph-core
3
- Version: 0.2.61
3
+ Version: 0.3.0
4
4
  Summary: Reusable MeterGraph catalog and deterministic billing engine
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -92,6 +92,58 @@ prefix, the latter only onto an `aws-bedrock` alias. Geographic prefixes such as
92
92
  `LoadedCatalog.pricing_verified_at` records when the bundled catalog was last
93
93
  checked against its linked provider sources.
94
94
 
95
+ ### Direct lookup and usage cost
96
+
97
+ `lookup_price` returns the price a call is billed at, and `calculate_cost`
98
+ costs a call's usage at that price:
99
+
100
+ ```python
101
+ from metergraph_core import ModelUsage
102
+
103
+ found = catalog.lookup_price(
104
+ model="gpt-5.4-mini",
105
+ provider="openai",
106
+ at=datetime(2026, 8, 17, tzinfo=timezone.utc),
107
+ )
108
+ print(found.status, found.model_id, found.price.id, found.price.currency)
109
+
110
+ result = catalog.calculate_cost(
111
+ model="gpt-5.4-mini",
112
+ provider="openai",
113
+ at=datetime(2026, 8, 17, tzinfo=timezone.utc),
114
+ usage=ModelUsage(input_tokens=1000, output_tokens=200, cache_read_tokens=500),
115
+ )
116
+ print(result.status, result.model_id, result.cost, result.currency)
117
+ print(result.components.input, result.components.cache_read)
118
+ ```
119
+
120
+ Both resolve exactly as `resolve_price` and `price` do, including
121
+ effective-date windows and the Bedrock fallback, and cost through the same
122
+ arithmetic. `model_id` is the resolved catalog ID, not the alias passed in, and
123
+ `price` is the applied catalog row. An explicit `channel` is authoritative;
124
+ without one, `provider` supplies its direct channel, and with neither the
125
+ result is `unpriced` with `missing_dimensions=("channel",)`.
126
+
127
+ Every `Price` states its `currency`, and `CostResult.currency` is the currency
128
+ of the applied price. The bundled catalog is USD. `cost` and
129
+ `components.input`, `output`, `cache_read`, `cache_write` and `request` are in
130
+ that currency; the older `cost_usd` and `*_usd` names return the same values.
131
+
132
+ A `ModelUsage` count left `None` is unknown, not zero. A missing input or
133
+ output count keeps the subtotal that is known, marks the result `partial`, and
134
+ lists `input_tokens` or `output_tokens` in `missing_dimensions`.
135
+
136
+ `region` selects a regional price and overrides the `region` passed to
137
+ `load_catalog`. When neither names a region:
138
+
139
+ - if every region bills the model the same, the result is `priced`;
140
+ - if a global price applies but some region bills differently, the global
141
+ price is returned as `partial` with `missing_dimensions=("region",)`;
142
+ - if only specific regions price the model, the result is `unpriced` with
143
+ `missing_dimensions=("region",)`.
144
+
145
+ A region the model is not sold in is `unpriced` with nothing missing.
146
+
95
147
  ## Model registry
96
148
 
97
149
  ```python
@@ -165,7 +217,9 @@ from metergraph_core import (
165
217
  LoadedCatalog,
166
218
  ModelRegistry,
167
219
  ModelRoute,
220
+ ModelUsage,
168
221
  Price,
222
+ PriceResult,
169
223
  ResolvedPrice,
170
224
  load_catalog,
171
225
  load_model_registry,
@@ -80,6 +80,58 @@ prefix, the latter only onto an `aws-bedrock` alias. Geographic prefixes such as
80
80
  `LoadedCatalog.pricing_verified_at` records when the bundled catalog was last
81
81
  checked against its linked provider sources.
82
82
 
83
+ ### Direct lookup and usage cost
84
+
85
+ `lookup_price` returns the price a call is billed at, and `calculate_cost`
86
+ costs a call's usage at that price:
87
+
88
+ ```python
89
+ from metergraph_core import ModelUsage
90
+
91
+ found = catalog.lookup_price(
92
+ model="gpt-5.4-mini",
93
+ provider="openai",
94
+ at=datetime(2026, 8, 17, tzinfo=timezone.utc),
95
+ )
96
+ print(found.status, found.model_id, found.price.id, found.price.currency)
97
+
98
+ result = catalog.calculate_cost(
99
+ model="gpt-5.4-mini",
100
+ provider="openai",
101
+ at=datetime(2026, 8, 17, tzinfo=timezone.utc),
102
+ usage=ModelUsage(input_tokens=1000, output_tokens=200, cache_read_tokens=500),
103
+ )
104
+ print(result.status, result.model_id, result.cost, result.currency)
105
+ print(result.components.input, result.components.cache_read)
106
+ ```
107
+
108
+ Both resolve exactly as `resolve_price` and `price` do, including
109
+ effective-date windows and the Bedrock fallback, and cost through the same
110
+ arithmetic. `model_id` is the resolved catalog ID, not the alias passed in, and
111
+ `price` is the applied catalog row. An explicit `channel` is authoritative;
112
+ without one, `provider` supplies its direct channel, and with neither the
113
+ result is `unpriced` with `missing_dimensions=("channel",)`.
114
+
115
+ Every `Price` states its `currency`, and `CostResult.currency` is the currency
116
+ of the applied price. The bundled catalog is USD. `cost` and
117
+ `components.input`, `output`, `cache_read`, `cache_write` and `request` are in
118
+ that currency; the older `cost_usd` and `*_usd` names return the same values.
119
+
120
+ A `ModelUsage` count left `None` is unknown, not zero. A missing input or
121
+ output count keeps the subtotal that is known, marks the result `partial`, and
122
+ lists `input_tokens` or `output_tokens` in `missing_dimensions`.
123
+
124
+ `region` selects a regional price and overrides the `region` passed to
125
+ `load_catalog`. When neither names a region:
126
+
127
+ - if every region bills the model the same, the result is `priced`;
128
+ - if a global price applies but some region bills differently, the global
129
+ price is returned as `partial` with `missing_dimensions=("region",)`;
130
+ - if only specific regions price the model, the result is `unpriced` with
131
+ `missing_dimensions=("region",)`.
132
+
133
+ A region the model is not sold in is `unpriced` with nothing missing.
134
+
83
135
  ## Model registry
84
136
 
85
137
  ```python
@@ -153,7 +205,9 @@ from metergraph_core import (
153
205
  LoadedCatalog,
154
206
  ModelRegistry,
155
207
  ModelRoute,
208
+ ModelUsage,
156
209
  Price,
210
+ PriceResult,
157
211
  ResolvedPrice,
158
212
  load_catalog,
159
213
  load_model_registry,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "metergraph-core"
3
- version = "0.2.61"
3
+ version = "0.3.0"
4
4
  description = "Reusable MeterGraph catalog and deterministic billing engine"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -5,7 +5,9 @@ from .catalog import (
5
5
  Alias,
6
6
  CatalogSnapshot,
7
7
  CostResult,
8
+ ModelUsage,
8
9
  Price,
10
+ PriceResult,
9
11
  ResolvedPrice,
10
12
  counts_cache_read_in_input,
11
13
  direct_channel_for_provider,
@@ -60,10 +62,12 @@ __all__ = [
60
62
  "ModelRegistry",
61
63
  "ModelRegistryError",
62
64
  "ModelRoute",
65
+ "ModelUsage",
63
66
  "OfferGroup",
64
67
  "ReleaseDate",
65
68
  "GatewayBillingEvidence",
66
69
  "Price",
70
+ "PriceResult",
67
71
  "ResolvedPrice",
68
72
  "RetrievalCatalog",
69
73
  "RetrievalCostResult",
@@ -212,6 +212,9 @@ class Price:
212
212
  # A fixed fee charged once per call on top of tokens, in USD per 1,000
213
213
  # calls: a gateway billing a model's built-in search per request.
214
214
  request_fee_per_1k_usd: Decimal | None = None
215
+ # The currency every rate on this row is stated in. The `_usd` names on
216
+ # rates and results predate it and are kept for existing callers.
217
+ currency: str = "USD"
215
218
 
216
219
 
217
220
  @dataclass(frozen=True, slots=True)
@@ -220,7 +223,9 @@ class CostComponents:
220
223
  ``cost_usd`` exactly: each is quantized and the rounding residue lands in
221
224
  the input part, so a consumer that stores them can rebuild the total
222
225
  without a second rounding. ``request_usd`` is a per-call fee, zero on
223
- every row that has none."""
226
+ every row that has none. Each part is in the applied price's currency;
227
+ ``input``, ``output``, ``cache_read``, ``cache_write`` and ``request``
228
+ name the same values without implying USD."""
224
229
 
225
230
  input_usd: Decimal
226
231
  output_usd: Decimal
@@ -228,6 +233,26 @@ class CostComponents:
228
233
  cache_write_usd: Decimal
229
234
  request_usd: Decimal = Decimal("0")
230
235
 
236
+ @property
237
+ def input(self) -> Decimal:
238
+ return self.input_usd
239
+
240
+ @property
241
+ def output(self) -> Decimal:
242
+ return self.output_usd
243
+
244
+ @property
245
+ def cache_read(self) -> Decimal:
246
+ return self.cache_read_usd
247
+
248
+ @property
249
+ def cache_write(self) -> Decimal:
250
+ return self.cache_write_usd
251
+
252
+ @property
253
+ def request(self) -> Decimal:
254
+ return self.request_usd
255
+
231
256
 
232
257
  @dataclass(frozen=True, slots=True)
233
258
  class CostResult:
@@ -241,6 +266,53 @@ class CostResult:
241
266
  # The catalog's publisher for the priced model, for billing rules that
242
267
  # apply per publisher rather than per channel.
243
268
  publisher: str | None = None
269
+ # The applied price, its currency, and the time it was resolved at.
270
+ price: Price | None = None
271
+ currency: str | None = None
272
+ effective_at: datetime | None = None
273
+ # Inputs the caller did not supply that the figure depends on: "region",
274
+ # "channel", "input_tokens", "output_tokens".
275
+ missing_dimensions: tuple[str, ...] = ()
276
+
277
+ @property
278
+ def model_id(self) -> str | None:
279
+ """The resolved catalog model id; the same value as ``canonical_model``."""
280
+ return self.canonical_model
281
+
282
+ @property
283
+ def cost(self) -> Decimal | None:
284
+ """The total in ``currency``; the same value as ``cost_usd``."""
285
+ return self.cost_usd
286
+
287
+
288
+ @dataclass(frozen=True, slots=True)
289
+ class ModelUsage:
290
+ """Token usage for one model call. A count left ``None`` is unknown, not
291
+ zero: input and output turn the result partial, and cached counts bill as
292
+ nothing cached, as in :meth:`CatalogSnapshot.cost`."""
293
+
294
+ input_tokens: int | None = None
295
+ output_tokens: int | None = None
296
+ cache_read_tokens: int | None = None
297
+ cache_write_tokens: int | None = None
298
+ cache_write_5m_tokens: int | None = None
299
+ cache_write_1h_tokens: int | None = None
300
+ batch: bool = False
301
+
302
+
303
+ @dataclass(frozen=True, slots=True)
304
+ class PriceResult:
305
+ """The price a model call would be billed at.
306
+
307
+ ``status`` is ``priced``, ``partial`` (a price applies but depends on a
308
+ dimension listed in ``missing_dimensions``), or ``unpriced``. ``model_id``
309
+ is the resolved catalog id, not the alias the caller sent."""
310
+
311
+ status: str
312
+ model_id: str | None
313
+ price: Price | None
314
+ effective_at: datetime
315
+ missing_dimensions: tuple[str, ...] = ()
244
316
 
245
317
 
246
318
  @dataclass(frozen=True, slots=True)
@@ -252,6 +324,26 @@ class ResolvedPrice:
252
324
  rules: Mapping[str, Any]
253
325
 
254
326
 
327
+ def _billed_terms(resolved: ResolvedPrice) -> tuple[Any, ...]:
328
+ """What a call is billed by: two resolutions with equal terms cost the
329
+ same for any usage, whichever region or source row they came from."""
330
+ price = resolved.price
331
+ return (
332
+ resolved.canonical_model,
333
+ price.pricing_channel,
334
+ price.currency,
335
+ price.input_per_mtok,
336
+ price.output_per_mtok,
337
+ price.cache_read_per_mtok,
338
+ price.cache_write_5m_per_mtok,
339
+ price.cache_write_1h_per_mtok,
340
+ price.batch_input_per_mtok,
341
+ price.batch_output_per_mtok,
342
+ price.request_fee_per_1k_usd,
343
+ resolved.rules,
344
+ )
345
+
346
+
255
347
  def _decimal(value: Any) -> Decimal | None:
256
348
  if value is None:
257
349
  return None
@@ -351,9 +443,9 @@ def _price_tokens(
351
443
  batch: bool,
352
444
  ) -> tuple[Decimal, list[str], CostComponents]:
353
445
  """Cost a token usage against one already-selected price and its merged
354
- rules. Shared by ``cost`` (provider+model entry) and ``price_deployment``
355
- (model+channel entry) so both compute identically. Returns the total, the
356
- reasons it is partial, and the split by driver."""
446
+ rules. Every token entry point reaches it through ``_cost_result`` so all
447
+ compute identically. Returns the total, the reasons it is partial, and the
448
+ split by driver."""
357
449
  reasons: list[str] = []
358
450
  input_count = _tokens(input_tokens)
359
451
  output_count = _tokens(output_tokens)
@@ -489,6 +581,48 @@ def _price_tokens(
489
581
  return cost, reasons, CostComponents(*parts)
490
582
 
491
583
 
584
+ def _cost_result(
585
+ resolved: ResolvedPrice,
586
+ *,
587
+ at: datetime,
588
+ usage: ModelUsage,
589
+ missing: tuple[str, ...] = (),
590
+ ) -> CostResult:
591
+ """Cost ``usage`` against a resolved price. Every token entry point builds
592
+ its result here, so all of them compute and report identically."""
593
+ cost, reasons, components = _price_tokens(
594
+ resolved.price,
595
+ resolved.rules,
596
+ at=at,
597
+ input_tokens=usage.input_tokens,
598
+ output_tokens=usage.output_tokens,
599
+ cache_read_tokens=usage.cache_read_tokens,
600
+ cache_write_tokens=usage.cache_write_tokens,
601
+ cache_write_5m_tokens=usage.cache_write_5m_tokens,
602
+ cache_write_1h_tokens=usage.cache_write_1h_tokens,
603
+ batch=usage.batch,
604
+ )
605
+ reasons = [f"missing_{dimension}" for dimension in missing] + reasons
606
+ missing += tuple(
607
+ dimension
608
+ for dimension in ("input_tokens", "output_tokens")
609
+ if f"missing_{dimension}" in reasons
610
+ )
611
+ return CostResult(
612
+ resolved.canonical_model,
613
+ resolved.price.id,
614
+ cost,
615
+ "partial" if reasons else "priced",
616
+ tuple(dict.fromkeys(reasons)),
617
+ components=components,
618
+ publisher=resolved.price.publisher,
619
+ price=resolved.price,
620
+ currency=resolved.price.currency,
621
+ effective_at=at,
622
+ missing_dimensions=missing,
623
+ )
624
+
625
+
492
626
  class CatalogSnapshot:
493
627
  def __init__(
494
628
  self,
@@ -532,8 +666,20 @@ class CatalogSnapshot:
532
666
  )
533
667
  for candidates in self._prices.values():
534
668
  candidates.sort(key=lambda price: price.effective_from, reverse=True)
535
-
536
- def _alias_for(self, candidates: tuple[Alias, ...], at: datetime) -> Alias | None:
669
+ # Every named region a price or pin exists for: the regions an omitted
670
+ # query region could have meant.
671
+ self._regions = tuple(sorted(
672
+ {price.region.lower() for price in prices}.union(
673
+ region
674
+ for candidates in self._aliases.values()
675
+ for alias in candidates
676
+ for region in alias.price_region
677
+ ) - {"*", _GLOBAL_REGION}
678
+ ))
679
+
680
+ def _alias_for(
681
+ self, candidates: tuple[Alias, ...], at: datetime, region: str | None = None
682
+ ) -> Alias | None:
537
683
  when = _coerce_datetime(at)
538
684
  effective = [
539
685
  alias
@@ -542,29 +688,31 @@ class CatalogSnapshot:
542
688
  and (alias.effective_to is None or when < alias.effective_to)
543
689
  ]
544
690
  # Regional pins constrain availability; global pins match everywhere.
545
- for region in dict.fromkeys((self._region, _GLOBAL_REGION)):
691
+ for pinned in dict.fromkeys((region or self._region, _GLOBAL_REGION)):
546
692
  for alias in effective:
547
- if region in alias.price_region:
693
+ if pinned in alias.price_region:
548
694
  return alias
549
695
  for alias in effective:
550
696
  if not alias.price_region:
551
697
  return alias
552
698
  return None
553
699
 
554
- def _price_regions(self, alias: Alias) -> tuple[str, ...]:
700
+ def _price_regions(self, alias: Alias, region: str) -> tuple[str, ...]:
555
701
  if not alias.price_region:
556
- return tuple(dict.fromkeys((self._region, "*", _GLOBAL_REGION)))
557
- for region in (self._region, _GLOBAL_REGION):
558
- if region in alias.price_region:
559
- return (region,)
702
+ return tuple(dict.fromkeys((region, "*", _GLOBAL_REGION)))
703
+ for candidate in (region, _GLOBAL_REGION):
704
+ if candidate in alias.price_region:
705
+ return (candidate,)
560
706
  return ()
561
707
 
562
- def _price_for(self, alias: Alias, at: datetime) -> Price | None:
708
+ def _price_for(
709
+ self, alias: Alias, at: datetime, region: str | None = None
710
+ ) -> Price | None:
563
711
  at = at if at.tzinfo else at.replace(tzinfo=timezone.utc)
564
712
  candidates = self._prices.get((alias.model_id, alias.pricing_channel), [])
565
- for region in self._price_regions(alias):
713
+ for scope in self._price_regions(alias, region or self._region):
566
714
  for price in candidates:
567
- if price.region.lower() != region:
715
+ if price.region.lower() != scope:
568
716
  continue
569
717
  if price.effective_from <= at and (
570
718
  price.effective_to is None or at < price.effective_to
@@ -611,25 +759,31 @@ class CatalogSnapshot:
611
759
  deployment channel before making a provider call. It never falls back
612
760
  to pricing from a different channel.
613
761
  """
762
+ return self._resolve(model=model, channel=channel, at=at)
614
763
 
764
+ def _resolve(
765
+ self, *, model: Any, channel: Any, at: Any, region: str | None = None
766
+ ) -> ResolvedPrice | None:
767
+ """``resolve_price`` in ``region``, or the snapshot's region when
768
+ ``None``."""
615
769
  model_key = str(model or "").strip().lower()
616
770
  channel_key = str(channel or "").strip().lower()
617
771
  when = _coerce_datetime(at)
618
772
  candidates = self._deployment_aliases.get((model_key, channel_key), ())
619
- alias = self._alias_for(candidates, when)
773
+ alias = self._alias_for(candidates, when, region)
620
774
  if alias is None and channel_key in _BEDROCK_CHANNELS:
621
775
  for candidate, dropped_global in _bedrock_fallback_ids(model_key):
622
776
  if dropped_global and channel_key != _BEDROCK_CHANNEL:
623
777
  continue
624
778
  candidates = self._deployment_aliases.get((candidate, channel_key), ())
625
- alias = self._alias_for(candidates, when)
779
+ alias = self._alias_for(candidates, when, region)
626
780
  if alias is not None and dropped_global and _crosses_price_region(alias):
627
781
  alias = None
628
782
  if alias is not None:
629
783
  break
630
784
  if alias is None:
631
785
  return None
632
- price = self._price_for(alias, when)
786
+ price = self._price_for(alias, when, region)
633
787
  if price is None:
634
788
  return None
635
789
  return ResolvedPrice(
@@ -638,6 +792,41 @@ class CatalogSnapshot:
638
792
  rules=MappingProxyType({**price.rules, **alias.rules}),
639
793
  )
640
794
 
795
+ def _lookup(
796
+ self, *, model: Any, channel: Any, provider: Any, at: Any, region: Any
797
+ ) -> tuple[ResolvedPrice | None, tuple[str, ...]]:
798
+ """Resolve a model call for the public lookup and cost entry points,
799
+ with the dimensions the answer depends on but the caller left out.
800
+
801
+ An explicit channel wins; otherwise a provider names its direct
802
+ channel. An explicit region resolves that region. An omitted one uses
803
+ the snapshot's region, and when that is the default ``global`` it is
804
+ checked against every named region: a global price that some region
805
+ bills differently is returned with ``region`` missing, and a model only
806
+ some region prices is unpriced with ``region`` missing.
807
+ """
808
+ if channel is None or not str(channel).strip():
809
+ channel = direct_channel_for_provider(provider)
810
+ if channel is None:
811
+ return None, ("channel",)
812
+ stated = str(region or "").strip().lower() or None
813
+ resolved = self._resolve(model=model, channel=channel, at=at, region=stated)
814
+ if stated is not None or self._region != _GLOBAL_REGION:
815
+ return resolved, ()
816
+ regional = [
817
+ found
818
+ for found in (
819
+ self._resolve(model=model, channel=channel, at=at, region=name)
820
+ for name in self._regions
821
+ )
822
+ if found is not None
823
+ ]
824
+ if resolved is None:
825
+ return None, ("region",) if regional else ()
826
+ if any(_billed_terms(found) != _billed_terms(resolved) for found in regional):
827
+ return resolved, ("region",)
828
+ return resolved, ()
829
+
641
830
  def cost(
642
831
  self,
643
832
  *,
@@ -688,27 +877,18 @@ class CatalogSnapshot:
688
877
  "unpriced",
689
878
  ("no_effective_price",),
690
879
  )
691
-
692
- cost, reasons, components = _price_tokens(
693
- price,
694
- {**price.rules, **alias.rules},
880
+ return _cost_result(
881
+ ResolvedPrice(alias.canonical_id, price, {**price.rules, **alias.rules}),
695
882
  at=when,
696
- input_tokens=input_tokens,
697
- output_tokens=output_tokens,
698
- cache_read_tokens=cache_read_tokens,
699
- cache_write_tokens=cache_write_tokens,
700
- cache_write_5m_tokens=cache_write_5m_tokens,
701
- cache_write_1h_tokens=cache_write_1h_tokens,
702
- batch=batch,
703
- )
704
- return CostResult(
705
- alias.canonical_id,
706
- price.id,
707
- cost,
708
- "partial" if reasons else "priced",
709
- tuple(dict.fromkeys(reasons)),
710
- components=components,
711
- publisher=price.publisher,
883
+ usage=ModelUsage(
884
+ input_tokens=input_tokens,
885
+ output_tokens=output_tokens,
886
+ cache_read_tokens=cache_read_tokens,
887
+ cache_write_tokens=cache_write_tokens,
888
+ cache_write_5m_tokens=cache_write_5m_tokens,
889
+ cache_write_1h_tokens=cache_write_1h_tokens,
890
+ batch=batch,
891
+ ),
712
892
  )
713
893
 
714
894
  def price_deployment(
@@ -739,24 +919,16 @@ class CatalogSnapshot:
739
919
  resolved = self.resolve_price(model=model, channel=channel, at=when)
740
920
  if resolved is None:
741
921
  return CostResult(None, None, None, "unpriced", ("unknown_deployment",))
742
- cost, reasons, components = _price_tokens(
743
- resolved.price,
744
- resolved.rules,
922
+ return _cost_result(
923
+ resolved,
745
924
  at=when,
746
- input_tokens=input_tokens,
747
- output_tokens=output_tokens,
748
- cache_read_tokens=cache_read_tokens,
749
- cache_write_tokens=cache_write_tokens,
750
- cache_write_5m_tokens=cache_write_5m_tokens,
751
- cache_write_1h_tokens=cache_write_1h_tokens,
752
- batch=batch,
753
- )
754
- return CostResult(
755
- resolved.canonical_model,
756
- resolved.price.id,
757
- cost,
758
- "partial" if reasons else "priced",
759
- tuple(dict.fromkeys(reasons)),
760
- components=components,
761
- publisher=resolved.price.publisher,
925
+ usage=ModelUsage(
926
+ input_tokens=input_tokens,
927
+ output_tokens=output_tokens,
928
+ cache_read_tokens=cache_read_tokens,
929
+ cache_write_tokens=cache_write_tokens,
930
+ cache_write_5m_tokens=cache_write_5m_tokens,
931
+ cache_write_1h_tokens=cache_write_1h_tokens,
932
+ batch=batch,
933
+ ),
762
934
  )
@@ -1,4 +1,4 @@
1
- version: '2026-10-16'
1
+ version: '2026-10-19'
2
2
  models:
3
3
  - canonical_id: openai/gpt-5.6-sol
4
4
  display_name: GPT-5.6 Sol
@@ -447,6 +447,30 @@ models:
447
447
  pricing_channel: vercel-ai-gateway
448
448
  execution_profiles:
449
449
  - default
450
+ - canonical_id: deepseek/deepseek-v4.1-flash-1017
451
+ display_name: DeepSeek V4.1 Flash 1017
452
+ publisher: deepseek
453
+ released: '2026-10-17' # https://vercel.com/ai-gateway/models/deepseek-v4.1-flash-1017
454
+ routes:
455
+ - key: default:deepseek/deepseek-v4.1-flash-1017
456
+ id: deepseek/deepseek-v4.1-flash-1017
457
+ provider: vercel
458
+ model_id: deepseek/deepseek-v4.1-flash-1017
459
+ pricing_channel: vercel-ai-gateway
460
+ execution_profiles:
461
+ - default
462
+ - canonical_id: deepseek/deepseek-v4.1-flash-1017-fast
463
+ display_name: DeepSeek V4.1 Flash 1017 Fast
464
+ publisher: deepseek
465
+ released: '2026-10-17' # https://vercel.com/ai-gateway/models/deepseek-v4.1-flash-1017-fast
466
+ routes:
467
+ - key: default:deepseek/deepseek-v4.1-flash-1017-fast
468
+ id: deepseek/deepseek-v4.1-flash-1017-fast
469
+ provider: vercel
470
+ model_id: deepseek/deepseek-v4.1-flash-1017-fast
471
+ pricing_channel: vercel-ai-gateway
472
+ execution_profiles:
473
+ - default
450
474
  - canonical_id: alibaba/qwen3.8-flash
451
475
  display_name: Qwen 3.8 Flash
452
476
  publisher: alibaba
@@ -471,6 +495,18 @@ models:
471
495
  pricing_channel: vercel-ai-gateway
472
496
  execution_profiles:
473
497
  - default
498
+ - canonical_id: inception/mercury-3
499
+ display_name: Mercury 3
500
+ publisher: inception
501
+ released: '2026-10-19' # https://www.inceptionlabs.ai/blog/introducing-mercury-3
502
+ routes:
503
+ - key: default:inception/mercury-3
504
+ id: inception/mercury-3
505
+ provider: vercel
506
+ model_id: inception/mercury-3
507
+ pricing_channel: vercel-ai-gateway
508
+ execution_profiles:
509
+ - default
474
510
  - canonical_id: openai/gpt-5.4-mini
475
511
  display_name: GPT-5.4 Mini
476
512
  publisher: openai
@@ -882,6 +918,9 @@ execution_profiles:
882
918
  - default:meta/muse-wonder-1.0
883
919
  - default:meta/muse-spark-open
884
920
  - default:nvidia/nemotron-3.5-super-vl
921
+ - default:deepseek/deepseek-v4.1-flash-1017
922
+ - default:deepseek/deepseek-v4.1-flash-1017-fast
923
+ - default:inception/mercury-3
885
924
  - default:openai:gpt-6.1-sol
886
925
  - default:anthropic:claude-sonnet-5-5
887
926
  - default:anthropic:claude-haiku-5-5
@@ -948,6 +987,9 @@ offer_groups:
948
987
  - default:meta/muse-wonder-1.0
949
988
  - default:meta/muse-spark-open
950
989
  - default:nvidia/nemotron-3.5-super-vl
990
+ - default:deepseek/deepseek-v4.1-flash-1017
991
+ - default:deepseek/deepseek-v4.1-flash-1017-fast
992
+ - default:inception/mercury-3
951
993
  - id: fireworks
952
994
  credential: FIREWORKS_API_KEY
953
995
  routes:
@@ -6,9 +6,9 @@
6
6
  #
7
7
  # Community updates welcome: add an alias or price entry with its provider
8
8
  # source_url and open a PR. CI validates structure and date overlaps.
9
- version: "2026-10-16"
9
+ version: "2026-10-19"
10
10
  currency: USD
11
- pricing_verified_at: "2026-10-16"
11
+ pricing_verified_at: "2026-10-19"
12
12
  models:
13
13
  - canonical_id: openai/gpt-6-sol
14
14
  publisher: openai
@@ -2709,12 +2709,28 @@ models:
2709
2709
  - channel: vercel-ai-gateway
2710
2710
  region: global
2711
2711
  effective_from: "2026-10-02"
2712
+ effective_to: "2026-10-19"
2712
2713
  input_per_mtok: 0.30
2713
2714
  output_per_mtok: 1.20
2714
2715
  cache_read_per_mtok: 0.007
2715
2716
  rules:
2716
2717
  varies_by_provider: true
2717
2718
  source_url: https://ai-gateway.vercel.sh/v1/models
2719
+ - channel: vercel-ai-gateway
2720
+ region: global
2721
+ effective_from: "2026-10-19"
2722
+ # Store Vercel's 2x peak rate; this rule restores its off-peak rate.
2723
+ input_per_mtok: 0.15
2724
+ output_per_mtok: 0.60
2725
+ cache_read_per_mtok: 0.003
2726
+ rules:
2727
+ input_includes_cache_read: true
2728
+ varies_by_provider: true
2729
+ off_peak_discount:
2730
+ multiplier: 0.5
2731
+ peak_hours_utc: [[1, 4], [6, 10]]
2732
+ peak_weekdays_only: true
2733
+ source_url: https://ai-gateway.vercel.sh/v1/models
2718
2734
  # Displayed serverless billing rates take precedence over conflicting prose.
2719
2735
  - channel: fireworks-api
2720
2736
  region: global
@@ -4602,6 +4618,119 @@ models:
4602
4618
  input_per_mtok: 0.00
4603
4619
  output_per_mtok: 0.00
4604
4620
  source_url: https://vercel.com/ai-gateway/models/nemotron-3.5-lightning-free
4621
+
4622
+ - canonical_id: deepseek/deepseek-v4.1-flash-1017
4623
+ publisher: deepseek
4624
+ aliases:
4625
+ - provider: deepseek
4626
+ alias: deepseek/deepseek-v4.1-flash-1017
4627
+ channel: vercel-ai-gateway
4628
+ rules: {input_includes_cache_read: true}
4629
+ prices:
4630
+ - channel: vercel-ai-gateway
4631
+ region: global
4632
+ effective_from: "2026-10-17"
4633
+ # Store Vercel's 2x peak rate; this rule restores its off-peak rate.
4634
+ input_per_mtok: 0.15
4635
+ output_per_mtok: 0.60
4636
+ cache_read_per_mtok: 0.003
4637
+ rules:
4638
+ input_includes_cache_read: true
4639
+ varies_by_provider: true
4640
+ off_peak_discount:
4641
+ multiplier: 0.5
4642
+ peak_hours_utc: [[1, 4], [6, 10]]
4643
+ peak_weekdays_only: true
4644
+ source_url: https://vercel.com/ai-gateway/models/deepseek-v4.1-flash-1017
4645
+
4646
+ - canonical_id: deepseek/deepseek-v4.1-flash-1017-fast
4647
+ publisher: deepseek
4648
+ aliases:
4649
+ - provider: deepseek
4650
+ alias: deepseek/deepseek-v4.1-flash-1017-fast
4651
+ channel: vercel-ai-gateway
4652
+ rules: {input_includes_cache_read: true}
4653
+ prices:
4654
+ - channel: vercel-ai-gateway
4655
+ region: global
4656
+ effective_from: "2026-10-17"
4657
+ # The source lists a fixed Baseten rate without peak pricing.
4658
+ input_per_mtok: 0.60
4659
+ output_per_mtok: 2.40
4660
+ cache_read_per_mtok: 0.014
4661
+ source_url: https://vercel.com/ai-gateway/models/deepseek-v4.1-flash-1017-fast
4662
+
4663
+ # Price observed traffic, but exclude this route from default execution while
4664
+ # Vercel permits retention and prompt training.
4665
+ - canonical_id: elorian/glimpse
4666
+ publisher: elorian
4667
+ aliases:
4668
+ - provider: elorian
4669
+ alias: elorian/glimpse
4670
+ channel: vercel-ai-gateway
4671
+ rules: {input_includes_cache_read: true}
4672
+ prices:
4673
+ - channel: vercel-ai-gateway
4674
+ region: global
4675
+ effective_from: "2026-10-16"
4676
+ input_per_mtok: 0.60
4677
+ output_per_mtok: 3.00
4678
+ cache_read_per_mtok: 0.06
4679
+ source_url: https://vercel.com/ai-gateway/models/glimpse
4680
+
4681
+ - canonical_id: inception/mercury-3
4682
+ publisher: inception
4683
+ aliases:
4684
+ - provider: inception
4685
+ alias: inception/mercury-3
4686
+ channel: vercel-ai-gateway
4687
+ rules: {input_includes_cache_read: true}
4688
+ prices:
4689
+ # No end date is published for this 50% launch price. Close the row when
4690
+ # Inception or Vercel announces the standard rate.
4691
+ - channel: vercel-ai-gateway
4692
+ region: global
4693
+ effective_from: "2026-10-19"
4694
+ input_per_mtok: 0.10
4695
+ output_per_mtok: 0.35
4696
+ cache_read_per_mtok: 0.01
4697
+ source_url: https://www.inceptionlabs.ai/blog/introducing-mercury-3
4698
+
4699
+ # Price observed traffic, but exclude this route from default execution while
4700
+ # Vercel permits retention and prompt training.
4701
+ - canonical_id: minimax/minimax-m3.1-flash
4702
+ publisher: minimax
4703
+ aliases:
4704
+ - {provider: minimax, alias: minimax-m3.1-flash, channel: minimax-api}
4705
+ - {provider: litellm, alias: minimax-m3.1-flash, channel: minimax-api}
4706
+ - {provider: unknown, alias: minimax-m3.1-flash, channel: minimax-api}
4707
+ - provider: minimax
4708
+ alias: minimax/minimax-m3.1-flash
4709
+ channel: vercel-ai-gateway
4710
+ rules: {input_includes_cache_read: true}
4711
+ prices:
4712
+ - channel: minimax-api
4713
+ region: global
4714
+ effective_from: "2026-10-19"
4715
+ input_per_mtok: 0.15
4716
+ output_per_mtok: 0.60
4717
+ cache_read_per_mtok: 0.015
4718
+ source_url: https://www.minimax.io/blog/minimax-m31f
4719
+ - channel: vercel-ai-gateway
4720
+ region: global
4721
+ effective_from: "2026-10-19"
4722
+ effective_to: "2026-10-23"
4723
+ input_per_mtok: 0.00
4724
+ output_per_mtok: 0.00
4725
+ cache_read_per_mtok: 0.00
4726
+ source_url: https://vercel.com/changelog/minimax-m3-1-flash-now-available-on-ai-gateway-for-free
4727
+ - channel: vercel-ai-gateway
4728
+ region: global
4729
+ effective_from: "2026-10-23"
4730
+ input_per_mtok: 0.15
4731
+ output_per_mtok: 0.60
4732
+ cache_read_per_mtok: 0.015
4733
+ source_url: https://vercel.com/changelog/minimax-m3-1-flash-now-available-on-ai-gateway-for-free
4605
4734
  retrieval:
4606
4735
  - channel: anthropic-api
4607
4736
  operation: web_search
@@ -13,7 +13,11 @@ from .catalog import (
13
13
  Alias,
14
14
  CatalogSnapshot,
15
15
  CostResult,
16
+ ModelUsage,
16
17
  Price,
18
+ PriceResult,
19
+ _coerce_datetime,
20
+ _cost_result,
17
21
  _decimal,
18
22
  _effective_windows_overlap,
19
23
  normalize_provider,
@@ -123,6 +127,65 @@ class LoadedCatalog:
123
127
  batch=batch,
124
128
  )
125
129
 
130
+ def lookup_price(
131
+ self,
132
+ *,
133
+ model: Any,
134
+ at: Any,
135
+ channel: Any = None,
136
+ provider: Any = None,
137
+ region: Any = None,
138
+ ) -> PriceResult:
139
+ """The price a call to ``model`` at ``at`` is billed at.
140
+
141
+ ``channel`` is authoritative; without one, ``provider`` supplies its
142
+ direct channel. ``region`` overrides the region the catalog was
143
+ loaded with. A result that depends on a dimension the caller left out
144
+ lists it in ``missing_dimensions``."""
145
+ when = _coerce_datetime(at)
146
+ resolved, missing = self.snapshot._lookup(
147
+ model=model, channel=channel, provider=provider, at=when, region=region
148
+ )
149
+ if resolved is None:
150
+ return PriceResult("unpriced", None, None, when, missing)
151
+ return PriceResult(
152
+ "partial" if missing else "priced",
153
+ resolved.canonical_model,
154
+ resolved.price,
155
+ when,
156
+ missing,
157
+ )
158
+
159
+ def calculate_cost(
160
+ self,
161
+ *,
162
+ model: Any,
163
+ usage: ModelUsage,
164
+ at: Any,
165
+ channel: Any = None,
166
+ provider: Any = None,
167
+ region: Any = None,
168
+ ) -> CostResult:
169
+ """Cost ``usage`` at the price :meth:`lookup_price` resolves. A
170
+ missing dimension, from the lookup or the usage, turns the result
171
+ ``partial`` and keeps the subtotal that is known."""
172
+ when = _coerce_datetime(at)
173
+ resolved, missing = self.snapshot._lookup(
174
+ model=model, channel=channel, provider=provider, at=when, region=region
175
+ )
176
+ if resolved is None:
177
+ reasons = tuple(f"missing_{dimension}" for dimension in missing)
178
+ return CostResult(
179
+ None,
180
+ None,
181
+ None,
182
+ "unpriced",
183
+ reasons or ("unknown_deployment",),
184
+ effective_at=when,
185
+ missing_dimensions=missing,
186
+ )
187
+ return _cost_result(resolved, at=when, usage=usage, missing=missing)
188
+
126
189
  def price_retrieval(
127
190
  self,
128
191
  *,
@@ -191,7 +254,7 @@ def parse_catalog(
191
254
  version = str(document.get("version") or "")
192
255
  if not version:
193
256
  raise CatalogError("prices document must have a version")
194
- _catalog_metadata(document)
257
+ currency, _ = _catalog_metadata(document)
195
258
  aliases: dict[tuple[str, str], list[Alias]] = {}
196
259
  prices: list[Price] = []
197
260
  for entry in document["models"]:
@@ -311,6 +374,7 @@ def parse_catalog(
311
374
  effective_to=effective_to,
312
375
  source_url=str(price["source_url"]).strip(),
313
376
  publisher=publisher,
377
+ currency=currency,
314
378
  )
315
379
  )
316
380
  return version, {key: tuple(value) for key, value in aliases.items()}, prices
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph-core
3
- Version: 0.2.61
3
+ Version: 0.3.0
4
4
  Summary: Reusable MeterGraph catalog and deterministic billing engine
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -92,6 +92,58 @@ prefix, the latter only onto an `aws-bedrock` alias. Geographic prefixes such as
92
92
  `LoadedCatalog.pricing_verified_at` records when the bundled catalog was last
93
93
  checked against its linked provider sources.
94
94
 
95
+ ### Direct lookup and usage cost
96
+
97
+ `lookup_price` returns the price a call is billed at, and `calculate_cost`
98
+ costs a call's usage at that price:
99
+
100
+ ```python
101
+ from metergraph_core import ModelUsage
102
+
103
+ found = catalog.lookup_price(
104
+ model="gpt-5.4-mini",
105
+ provider="openai",
106
+ at=datetime(2026, 8, 17, tzinfo=timezone.utc),
107
+ )
108
+ print(found.status, found.model_id, found.price.id, found.price.currency)
109
+
110
+ result = catalog.calculate_cost(
111
+ model="gpt-5.4-mini",
112
+ provider="openai",
113
+ at=datetime(2026, 8, 17, tzinfo=timezone.utc),
114
+ usage=ModelUsage(input_tokens=1000, output_tokens=200, cache_read_tokens=500),
115
+ )
116
+ print(result.status, result.model_id, result.cost, result.currency)
117
+ print(result.components.input, result.components.cache_read)
118
+ ```
119
+
120
+ Both resolve exactly as `resolve_price` and `price` do, including
121
+ effective-date windows and the Bedrock fallback, and cost through the same
122
+ arithmetic. `model_id` is the resolved catalog ID, not the alias passed in, and
123
+ `price` is the applied catalog row. An explicit `channel` is authoritative;
124
+ without one, `provider` supplies its direct channel, and with neither the
125
+ result is `unpriced` with `missing_dimensions=("channel",)`.
126
+
127
+ Every `Price` states its `currency`, and `CostResult.currency` is the currency
128
+ of the applied price. The bundled catalog is USD. `cost` and
129
+ `components.input`, `output`, `cache_read`, `cache_write` and `request` are in
130
+ that currency; the older `cost_usd` and `*_usd` names return the same values.
131
+
132
+ A `ModelUsage` count left `None` is unknown, not zero. A missing input or
133
+ output count keeps the subtotal that is known, marks the result `partial`, and
134
+ lists `input_tokens` or `output_tokens` in `missing_dimensions`.
135
+
136
+ `region` selects a regional price and overrides the `region` passed to
137
+ `load_catalog`. When neither names a region:
138
+
139
+ - if every region bills the model the same, the result is `priced`;
140
+ - if a global price applies but some region bills differently, the global
141
+ price is returned as `partial` with `missing_dimensions=("region",)`;
142
+ - if only specific regions price the model, the result is `unpriced` with
143
+ `missing_dimensions=("region",)`.
144
+
145
+ A region the model is not sold in is `unpriced` with nothing missing.
146
+
95
147
  ## Model registry
96
148
 
97
149
  ```python
@@ -165,7 +217,9 @@ from metergraph_core import (
165
217
  LoadedCatalog,
166
218
  ModelRegistry,
167
219
  ModelRoute,
220
+ ModelUsage,
168
221
  Price,
222
+ PriceResult,
169
223
  ResolvedPrice,
170
224
  load_catalog,
171
225
  load_model_registry,