metergraph-core 0.8.0__tar.gz → 0.8.2__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 (24) hide show
  1. {metergraph_core-0.8.0/src/metergraph_core.egg-info → metergraph_core-0.8.2}/PKG-INFO +41 -1
  2. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/README.md +40 -0
  3. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/pyproject.toml +1 -1
  4. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/__init__.py +2 -0
  5. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/catalog.py +190 -0
  6. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/catalog_delta.py +157 -17
  7. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/data/prices.yaml +15 -4
  8. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/loader.py +12 -1
  9. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/refresh.py +56 -14
  10. {metergraph_core-0.8.0 → metergraph_core-0.8.2/src/metergraph_core.egg-info}/PKG-INFO +41 -1
  11. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/MANIFEST.in +0 -0
  12. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/setup.cfg +0 -0
  13. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/billing.py +0 -0
  14. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/data/models.yaml +0 -0
  15. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/data/sources.yaml +0 -0
  16. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/extractors.py +0 -0
  17. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/models.py +0 -0
  18. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/responses.py +0 -0
  19. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/retrieval.py +0 -0
  20. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/sources.py +0 -0
  21. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core.egg-info/SOURCES.txt +0 -0
  22. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core.egg-info/dependency_links.txt +0 -0
  23. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core.egg-info/requires.txt +0 -0
  24. {metergraph_core-0.8.0 → metergraph_core-0.8.2}/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.8.0
3
+ Version: 0.8.2
4
4
  Summary: Open, effective-dated LLM pricing catalog and deterministic cost calculator for popular models across major providers and gateways, including OpenAI, Anthropic, Google Gemini, AWS Bedrock, OpenRouter, Vercel AI Gateway, and more.
5
5
  Author: Metergraph
6
6
  License-Expression: Apache-2.0
@@ -210,6 +210,11 @@ A call after it ended resolves to that price, stays `priced`, and carries
210
210
  applied `price.effective_to` says when it ended. A closed price followed by a
211
211
  later one never extends, so a gap before a scheduled price stays `unpriced`.
212
212
 
213
+ An `unpriced` result whose cause is a value you passed, such as an unknown
214
+ channel, provider or model, says why in `warnings`. Each `PricingWarning` has
215
+ a stable `code` and a `message` that may suggest the value you meant. Match on
216
+ `code`; the message text may change.
217
+
213
218
  ### Costing a provider response
214
219
 
215
220
  `cost_from_response` reads the usage out of a response you already have and
@@ -351,6 +356,41 @@ effective_from)` on a model the catalog already has:
351
356
  start on a later UTC date than the row it replaces, for the same reason.
352
357
  - `close_price` sets `effective_to` on the open row without a replacement.
353
358
 
359
+ Catalog operations change which models exist and what calls they match:
360
+
361
+ - `add_model` appends one complete model entry.
362
+ - `add_alias` appends one alias to an existing model. A spelling another
363
+ model already holds is a conflict only while both aliases are in effect.
364
+ - `close_alias` sets `effective_to` on an open alias, named by
365
+ `(canonical_id, provider, alias, channel)`; `expect` is the exact current
366
+ alias. An alias with dates must cite its source, so a close carries
367
+ `source_url` when the alias has none and must not when it has one.
368
+
369
+ A `close_alias`, `close_price`, `add_alias` and `add_price` together move a
370
+ spelling from a duplicate entry to the model it duplicates from a date. Calls
371
+ before that date keep the old model and price, calls from it resolve to the
372
+ surviving model, and the old entry stays for history with nothing in effect:
373
+
374
+ ```yaml
375
+ - op: close_alias
376
+ key: {canonical_id: example-host/model-a-instruct, provider: openrouter,
377
+ alias: example-host/model-a-instruct, channel: openrouter-api}
378
+ expect: {provider: openrouter, alias: example-host/model-a-instruct, channel: openrouter-api}
379
+ effective_to: "2026-10-20"
380
+ source_url: "https://..."
381
+ - op: close_price # the duplicate's open openrouter-api row
382
+ - op: add_alias
383
+ key: {canonical_id: example/model-a, provider: openrouter,
384
+ alias: example-host/model-a-instruct, channel: openrouter-api}
385
+ expect: null
386
+ alias: {provider: openrouter, alias: example-host/model-a-instruct,
387
+ channel: openrouter-api, effective_from: "2026-10-20", source_url: "https://..."}
388
+ - op: add_price # the same rate on example/model-a from 2026-10-20
389
+ ```
390
+
391
+ Gateway discovery matches a spelling only through aliases in effect on the
392
+ observation date, so it follows a moved spelling to its new model.
393
+
354
394
  A price id ends with the UTC date of its row's start. If two rows for one
355
395
  model, channel and region would still share an id (possible in a hand-edited
356
396
  catalog), each that does not start at UTC midnight also names its UTC start
@@ -180,6 +180,11 @@ A call after it ended resolves to that price, stays `priced`, and carries
180
180
  applied `price.effective_to` says when it ended. A closed price followed by a
181
181
  later one never extends, so a gap before a scheduled price stays `unpriced`.
182
182
 
183
+ An `unpriced` result whose cause is a value you passed, such as an unknown
184
+ channel, provider or model, says why in `warnings`. Each `PricingWarning` has
185
+ a stable `code` and a `message` that may suggest the value you meant. Match on
186
+ `code`; the message text may change.
187
+
183
188
  ### Costing a provider response
184
189
 
185
190
  `cost_from_response` reads the usage out of a response you already have and
@@ -321,6 +326,41 @@ effective_from)` on a model the catalog already has:
321
326
  start on a later UTC date than the row it replaces, for the same reason.
322
327
  - `close_price` sets `effective_to` on the open row without a replacement.
323
328
 
329
+ Catalog operations change which models exist and what calls they match:
330
+
331
+ - `add_model` appends one complete model entry.
332
+ - `add_alias` appends one alias to an existing model. A spelling another
333
+ model already holds is a conflict only while both aliases are in effect.
334
+ - `close_alias` sets `effective_to` on an open alias, named by
335
+ `(canonical_id, provider, alias, channel)`; `expect` is the exact current
336
+ alias. An alias with dates must cite its source, so a close carries
337
+ `source_url` when the alias has none and must not when it has one.
338
+
339
+ A `close_alias`, `close_price`, `add_alias` and `add_price` together move a
340
+ spelling from a duplicate entry to the model it duplicates from a date. Calls
341
+ before that date keep the old model and price, calls from it resolve to the
342
+ surviving model, and the old entry stays for history with nothing in effect:
343
+
344
+ ```yaml
345
+ - op: close_alias
346
+ key: {canonical_id: example-host/model-a-instruct, provider: openrouter,
347
+ alias: example-host/model-a-instruct, channel: openrouter-api}
348
+ expect: {provider: openrouter, alias: example-host/model-a-instruct, channel: openrouter-api}
349
+ effective_to: "2026-10-20"
350
+ source_url: "https://..."
351
+ - op: close_price # the duplicate's open openrouter-api row
352
+ - op: add_alias
353
+ key: {canonical_id: example/model-a, provider: openrouter,
354
+ alias: example-host/model-a-instruct, channel: openrouter-api}
355
+ expect: null
356
+ alias: {provider: openrouter, alias: example-host/model-a-instruct,
357
+ channel: openrouter-api, effective_from: "2026-10-20", source_url: "https://..."}
358
+ - op: add_price # the same rate on example/model-a from 2026-10-20
359
+ ```
360
+
361
+ Gateway discovery matches a spelling only through aliases in effect on the
362
+ observation date, so it follows a moved spelling to its new model.
363
+
324
364
  A price id ends with the UTC date of its row's start. If two rows for one
325
365
  model, channel and region would still share an id (possible in a hand-edited
326
366
  catalog), each that does not start at UTC midnight also names its UTC start
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "metergraph-core"
3
- version = "0.8.0"
3
+ version = "0.8.2"
4
4
  description = "Open, effective-dated LLM pricing catalog and deterministic cost calculator for popular models across major providers and gateways, including OpenAI, Anthropic, Google Gemini, AWS Bedrock, OpenRouter, Vercel AI Gateway, and more."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -8,6 +8,7 @@ from .catalog import (
8
8
  ModelUsage,
9
9
  Price,
10
10
  PriceResult,
11
+ PricingWarning,
11
12
  ResolvedPrice,
12
13
  counts_cache_read_in_input,
13
14
  direct_channel_for_provider,
@@ -128,6 +129,7 @@ __all__ = [
128
129
  "GatewayBillingEvidence",
129
130
  "Price",
130
131
  "PriceResult",
132
+ "PricingWarning",
131
133
  "ResolvedPrice",
132
134
  "RetrievalCatalog",
133
135
  "RetrievalCostResult",
@@ -1,5 +1,6 @@
1
1
  """Effective-dated model catalog and deterministic token-cost enrichment."""
2
2
 
3
+ import difflib
3
4
  import re
4
5
  from dataclasses import dataclass
5
6
  from datetime import date, datetime, timezone
@@ -86,6 +87,16 @@ _GLOBAL_REGION = "global"
86
87
  # Reason for a result priced at a price that ended and was not replaced.
87
88
  PRICE_ENDED = "price_ended"
88
89
 
90
+ # Warning codes: why a result is unpriced, when the cause is a value the
91
+ # caller sent. The codes are stable; the messages may change.
92
+ UNKNOWN_CHANNEL = "unknown_channel"
93
+ UNKNOWN_PROVIDER = "unknown_provider"
94
+ UNKNOWN_MODEL = "unknown_model"
95
+ MODEL_NOT_ON_CHANNEL = "model_not_on_channel"
96
+ MODEL_NOT_IN_EFFECT = "model_not_in_effect"
97
+ MODEL_NOT_IN_REGION = "model_not_in_region"
98
+ PRICE_NOT_IN_EFFECT = "price_not_in_effect"
99
+
89
100
 
90
101
  def _bedrock_fallback_ids(model_key: str) -> tuple[tuple[str, bool], ...]:
91
102
  """Candidate ids for an unmatched Bedrock model, most specific first.
@@ -301,6 +312,9 @@ class CostResult:
301
312
  # Inputs the caller did not supply that the figure depends on: "region",
302
313
  # "channel", "input_tokens", "output_tokens".
303
314
  missing_dimensions: tuple[str, ...] = ()
315
+ # Why an unpriced result is unpriced, when the cause is a value the
316
+ # caller sent. Empty otherwise.
317
+ warnings: tuple["PricingWarning", ...] = ()
304
318
 
305
319
  @property
306
320
  def model_id(self) -> str | None:
@@ -343,6 +357,30 @@ class PriceResult:
343
357
  missing_dimensions: tuple[str, ...] = ()
344
358
  # Notes that do not make the result partial, such as ``price_ended``.
345
359
  reasons: tuple[str, ...] = ()
360
+ # Why an unpriced result is unpriced, when the cause is a value the
361
+ # caller sent. Empty otherwise.
362
+ warnings: tuple["PricingWarning", ...] = ()
363
+
364
+
365
+ @dataclass(frozen=True, slots=True)
366
+ class PricingWarning:
367
+ """Why a result is unpriced, when the cause is a value the caller sent:
368
+ a channel or provider the catalog cannot bill on, a model name it does
369
+ not know, or a model the channel does not sell at that time. ``code`` is
370
+ stable; ``message`` names the value and, when one is close, the value
371
+ the caller probably meant."""
372
+
373
+ code: str
374
+ message: str
375
+
376
+
377
+ def _did_you_mean(
378
+ value: str, options: Any, guess: str | None = None, cutoff: float = 0.6
379
+ ) -> str:
380
+ options = sorted(options)
381
+ close = difflib.get_close_matches(value, options, 1, cutoff)
382
+ match = guess if guess in options else next(iter(close), None)
383
+ return f' Did you mean "{match}"?' if match else ""
346
384
 
347
385
 
348
386
  @dataclass(frozen=True, slots=True)
@@ -713,6 +751,17 @@ class CatalogSnapshot:
713
751
  )
714
752
  for candidates in self._prices.values():
715
753
  candidates.sort(key=lambda price: price.effective_from, reverse=True)
754
+ # For explaining an unpriced result: the channels some price row
755
+ # bills on, and each model name with the channels that sell it under
756
+ # that name, an alias there and a price there, at any time.
757
+ priced = {(model_id, channel.lower()) for model_id, channel in self._prices}
758
+ self._priced_channels = frozenset(channel for _, channel in priced)
759
+ sold: dict[str, set[str]] = {}
760
+ for (model, channel), candidates in self._deployment_aliases.items():
761
+ found = sold.setdefault(model, set())
762
+ if any((alias.model_id, channel) in priced for alias in candidates):
763
+ found.add(channel)
764
+ self._sold = {model: frozenset(channels) for model, channels in sold.items()}
716
765
  # Every named region a price or pin exists for: the regions an omitted
717
766
  # query region could have meant.
718
767
  self._regions = tuple(sorted(
@@ -894,6 +943,147 @@ class CatalogSnapshot:
894
943
  return resolved, ("region",)
895
944
  return resolved, ()
896
945
 
946
+ def _explain(
947
+ self, *, model: Any, channel: Any, provider: Any, at: Any, region: Any
948
+ ) -> tuple[PricingWarning, ...]:
949
+ """Why :meth:`_lookup` found no price, when the cause is a value the
950
+ caller sent. Each check follows the step of the lookup it explains:
951
+ the channel, or the provider's direct channel, then the model name on
952
+ that channel, then the alias and price windows at ``at``."""
953
+ channel_key = str(channel or "").strip().lower()
954
+ if channel_key:
955
+ if channel_key not in self._priced_channels:
956
+ # A provider's name, with or without "-api", points at its
957
+ # direct channel.
958
+ guess = direct_channel_for_provider(
959
+ channel_key
960
+ ) or direct_channel_for_provider(channel_key.removesuffix("-api"))
961
+ return (PricingWarning(
962
+ UNKNOWN_CHANNEL,
963
+ f'channel "{str(channel).strip()}" is not a channel the catalog prices.'
964
+ f"{_did_you_mean(channel_key, self._priced_channels, guess)}",
965
+ ),)
966
+ else:
967
+ if provider is None or not str(provider).strip():
968
+ return ()
969
+ channel_key = direct_channel_for_provider(provider)
970
+ if channel_key is None:
971
+ providers = set(_DIRECT_CHANNEL_BY_PROVIDER) | {
972
+ spelling for spelling, name in _PROVIDER_ALIASES.items()
973
+ if name in _DIRECT_CHANNEL_BY_PROVIDER
974
+ }
975
+ return (PricingWarning(
976
+ UNKNOWN_PROVIDER,
977
+ f'provider "{str(provider).strip()}" has no billing channel of its own.'
978
+ f"{_did_you_mean(str(provider).strip().lower(), providers)} If a gateway or "
979
+ "proxy relayed the call, name the provider that served it or the channel "
980
+ "that billed it.",
981
+ ),)
982
+ model_key = str(model or "").strip().lower()
983
+ if not model_key:
984
+ return ()
985
+ # Bedrock fallback ids, under the rules _resolve applies: dropping
986
+ # "global." is valid only on aws-bedrock, and never across a price
987
+ # region.
988
+ keys, dropped, forbidden = [model_key], set(), []
989
+ if channel_key in _BEDROCK_CHANNELS:
990
+ for candidate, dropped_global in _bedrock_fallback_ids(model_key):
991
+ if dropped_global and channel_key != _BEDROCK_CHANNEL:
992
+ forbidden.append(candidate)
993
+ continue
994
+ keys.append(candidate)
995
+ if dropped_global:
996
+ dropped.add(candidate)
997
+ name = str(model).strip()
998
+ if not any(key in self._sold for key in keys):
999
+ # A "global." id on a geo channel points at the bare id that
1000
+ # channel sells.
1001
+ guess = next(
1002
+ (key for key in forbidden if channel_key in self._sold.get(key, ())), None
1003
+ )
1004
+ return (PricingWarning(
1005
+ UNKNOWN_MODEL,
1006
+ f'model "{name}" is not in the catalog.'
1007
+ f"{_did_you_mean(model_key, self._sold, guess, cutoff=0.8)}",
1008
+ ),)
1009
+ candidates = tuple(
1010
+ alias
1011
+ for key in keys
1012
+ for alias in self._deployment_aliases.get((key, channel_key), ())
1013
+ if key not in dropped or not _crosses_price_region(alias)
1014
+ )
1015
+ sold = sorted({channel for key in keys for channel in self._sold.get(key, ())})
1016
+ if not candidates or channel_key not in sold:
1017
+ where = (
1018
+ f"It is priced on {', '.join(sold)}."
1019
+ if sold
1020
+ else "No channel prices it under that name."
1021
+ )
1022
+ return (PricingWarning(
1023
+ MODEL_NOT_ON_CHANNEL,
1024
+ f'model "{name}" has no price on channel "{channel_key}". {where}',
1025
+ ),)
1026
+ when = _coerce_datetime(at)
1027
+ stated = str(region or "").strip().lower() or None
1028
+ alias = self._alias_for(candidates, when, stated)
1029
+ if alias is None:
1030
+ return (self._unresolved(name, channel_key, candidates, when, stated),)
1031
+ price, _ = self._price_for(alias, when, stated)
1032
+ if price is None:
1033
+ scope = f' in region "{stated}"' if stated else ""
1034
+ return (PricingWarning(
1035
+ PRICE_NOT_IN_EFFECT,
1036
+ f'model "{name}" has no price on channel "{channel_key}"{scope} in effect at '
1037
+ f"{when.isoformat()}.",
1038
+ ),)
1039
+ return ()
1040
+
1041
+ def _unresolved(
1042
+ self,
1043
+ name: str,
1044
+ channel: str,
1045
+ candidates: tuple[Alias, ...],
1046
+ when: datetime,
1047
+ region: str | None,
1048
+ ) -> PricingWarning:
1049
+ """Why no alias for ``name`` on ``channel`` applies at ``when``: its
1050
+ windows close before or open after it, or the aliases in effect are
1051
+ pinned to other regions."""
1052
+ current = [
1053
+ alias for alias in candidates
1054
+ if (alias.effective_from is None or alias.effective_from <= when)
1055
+ and (alias.effective_to is None or when < alias.effective_to)
1056
+ ]
1057
+ if current:
1058
+ regions = sorted({pin for alias in current for pin in alias.price_region})
1059
+ asked = region or self._region
1060
+ return PricingWarning(
1061
+ MODEL_NOT_IN_REGION,
1062
+ f'model "{name}" resolves on channel "{channel}" only in '
1063
+ f"{', '.join(regions)}, not {asked}.",
1064
+ )
1065
+ ended = [alias.effective_to for alias in candidates
1066
+ if alias.effective_to is not None and alias.effective_to <= when]
1067
+ starts = [alias.effective_from for alias in candidates
1068
+ if alias.effective_from is not None and when < alias.effective_from]
1069
+ if ended and starts:
1070
+ when_text = (
1071
+ f"between {max(ended).date().isoformat()} and "
1072
+ f"{min(starts).date().isoformat()}"
1073
+ )
1074
+ message = f'model "{name}" does not resolve on channel "{channel}" {when_text}.'
1075
+ elif ended:
1076
+ message = (
1077
+ f'model "{name}" stopped resolving on channel "{channel}" on '
1078
+ f"{max(ended).date().isoformat()}."
1079
+ )
1080
+ else:
1081
+ message = (
1082
+ f'model "{name}" resolves on channel "{channel}" from '
1083
+ f"{min(starts).date().isoformat()}."
1084
+ )
1085
+ return PricingWarning(MODEL_NOT_IN_EFFECT, message)
1086
+
897
1087
  def cost(
898
1088
  self,
899
1089
  *,
@@ -15,7 +15,15 @@ catalog already has:
15
15
  It also supports reviewed catalog expansion:
16
16
 
17
17
  - ``add_model`` appends one complete model entry.
18
- - ``add_alias`` appends one alias to an existing model.
18
+ - ``add_alias`` appends one alias to an existing model. A spelling another
19
+ model holds conflicts only while both aliases are in effect, so a spelling
20
+ can move to another model from a date.
21
+ - ``close_alias`` sets ``effective_to`` on the open alias its ``expect``
22
+ names, so a spelling closed and reopened on one model keeps its earlier
23
+ window. With an
24
+ ``add_alias`` for the same spelling on another model in one delta, it
25
+ moves that spelling: calls before the date keep the old model, calls from
26
+ it resolve to the new one.
19
27
 
20
28
  ``provenance`` references the observations and evidence the delta came
21
29
  from.
@@ -34,7 +42,7 @@ from .loader import CatalogError, _canonical_json, load_catalog_document
34
42
  CATALOG_DELTA_SCHEMA_VERSION = 2
35
43
  PRICING_DELTA_KIND = "pricing_delta"
36
44
  PRICE_OPERATIONS = ("add_price", "replace_price", "close_price")
37
- CATALOG_OPERATIONS = ("add_model", "add_alias")
45
+ CATALOG_OPERATIONS = ("add_model", "add_alias", "close_alias")
38
46
  OPERATIONS = PRICE_OPERATIONS + CATALOG_OPERATIONS
39
47
 
40
48
  RATE_FIELDS = (
@@ -116,12 +124,14 @@ class DeltaOperation:
116
124
  expect: Mapping[str, Any] | None
117
125
  # The new row for add/replace; None for close.
118
126
  price: Mapping[str, Any] | None
119
- # The closing date for close_price.
127
+ # The closing date for close_price and close_alias.
120
128
  effective_to: str | None
121
129
  # The complete entry for add_model.
122
130
  model: Mapping[str, Any] | None = None
123
131
  # The alias row for add_alias.
124
132
  alias: Mapping[str, Any] | None = None
133
+ # The source a close_alias cites, for an alias that cites none.
134
+ source_url: str | None = None
125
135
 
126
136
 
127
137
  @dataclass(frozen=True, slots=True)
@@ -197,7 +207,7 @@ def _parse_model_key(raw: Any, label: str) -> ModelKey:
197
207
  return ModelKey(_text(raw["canonical_id"]))
198
208
 
199
209
 
200
- def _parse_alias_key(raw: Any, label: str) -> AliasKey:
210
+ def _parse_alias_key(raw: Any, label: str, op: str) -> AliasKey:
201
211
  if not isinstance(raw, Mapping):
202
212
  raise CatalogDeltaError(f"{label}: key must be a mapping")
203
213
  fields = ("canonical_id", "provider", "alias", "channel")
@@ -205,7 +215,7 @@ def _parse_alias_key(raw: Any, label: str) -> AliasKey:
205
215
  if _text(raw.get(field)) is None:
206
216
  raise CatalogDeltaError(f"{label}: key needs {field}")
207
217
  if set(raw) != set(fields):
208
- raise CatalogDeltaError(f"{label}: add_alias key has unknown fields")
218
+ raise CatalogDeltaError(f"{label}: {op} key has unknown fields")
209
219
  return AliasKey(
210
220
  _text(raw["canonical_id"]),
211
221
  _text(raw["provider"]).lower(),
@@ -324,8 +334,8 @@ def _parse_operation(index: int, raw: Any) -> DeltaOperation:
324
334
  raise CatalogDeltaError(f"{label}: unknown op {op!r}")
325
335
  if op == "add_model":
326
336
  key: OperationKey = _parse_model_key(raw.get("key"), label)
327
- elif op == "add_alias":
328
- key = _parse_alias_key(raw.get("key"), label)
337
+ elif op in ("add_alias", "close_alias"):
338
+ key = _parse_alias_key(raw.get("key"), label, op)
329
339
  else:
330
340
  key = _parse_key(raw.get("key"), label)
331
341
  expect = raw.get("expect")
@@ -338,6 +348,7 @@ def _parse_operation(index: int, raw: Any) -> DeltaOperation:
338
348
  effective_to = None
339
349
  model = None
340
350
  alias = None
351
+ source_url = None
341
352
  if op == "add_model":
342
353
  if "price" in raw or "alias" in raw or "effective_to" in raw:
343
354
  raise CatalogDeltaError(f"{label}: add_model takes only model")
@@ -346,6 +357,18 @@ def _parse_operation(index: int, raw: Any) -> DeltaOperation:
346
357
  if "price" in raw or "model" in raw or "effective_to" in raw:
347
358
  raise CatalogDeltaError(f"{label}: add_alias takes only alias")
348
359
  alias = _parse_alias(raw.get("alias"), key, label)
360
+ elif op == "close_alias":
361
+ if set(raw) - {"op", "key", "expect", "effective_to", "source_url"}:
362
+ raise CatalogDeltaError(
363
+ f"{label}: close_alias takes only effective_to and source_url"
364
+ )
365
+ effective_to = _iso_date(raw.get("effective_to"))
366
+ if effective_to is None:
367
+ raise CatalogDeltaError(f"{label}: invalid effective_to")
368
+ if "source_url" in raw:
369
+ source_url = _text(raw.get("source_url"))
370
+ if source_url is None:
371
+ raise CatalogDeltaError(f"{label}: invalid source_url")
349
372
  elif op == "close_price":
350
373
  if "price" in raw:
351
374
  raise CatalogDeltaError(f"{label}: close_price takes no price")
@@ -369,6 +392,7 @@ def _parse_operation(index: int, raw: Any) -> DeltaOperation:
369
392
  effective_to=effective_to,
370
393
  model=model,
371
394
  alias=alias,
395
+ source_url=source_url,
372
396
  )
373
397
 
374
398
 
@@ -480,8 +504,53 @@ def _key_label(key: OperationKey) -> str:
480
504
  return key.canonical_id
481
505
 
482
506
 
483
- def _precondition(document: Mapping[str, Any], operation: DeltaOperation) -> str | None:
484
- """Why ``operation`` does not apply to ``document``, or None when it does."""
507
+ def _alias_rows(
508
+ entry: Mapping[str, Any], key: AliasKey
509
+ ) -> list[Mapping[str, Any]]:
510
+ return [
511
+ alias
512
+ for alias in entry.get("aliases") or []
513
+ if isinstance(alias, Mapping)
514
+ and (_text(alias.get("provider")) or "").lower() == key.provider
515
+ and (_text(alias.get("alias")) or "").lower() == key.alias
516
+ and _text(alias.get("channel")) == key.channel
517
+ ]
518
+
519
+
520
+ def _expected_aliases(
521
+ entry: Mapping[str, Any], operation: DeltaOperation
522
+ ) -> list[Mapping[str, Any]]:
523
+ """The rows under a close_alias key that equal its ``expect``. A key can
524
+ hold several windows of one spelling; ``expect`` names the one to end."""
525
+ expected = _canonical_json(operation.expect)
526
+ return [
527
+ alias
528
+ for alias in _alias_rows(entry, operation.key)
529
+ if _canonical_json(alias) == expected
530
+ ]
531
+
532
+
533
+ def _windows_overlap(
534
+ left_from: Any, left_to: Any, right_from: Any, right_to: Any
535
+ ) -> bool:
536
+ """Whether two alias windows share an instant; a missing bound is open,
537
+ as the catalog loader reads it."""
538
+ left_from, left_to = _instant(left_from), _instant(left_to)
539
+ right_from, right_to = _instant(right_from), _instant(right_to)
540
+ return (left_to is None or right_from is None or right_from < left_to) and (
541
+ right_to is None or left_from is None or left_from < right_to
542
+ )
543
+
544
+
545
+ def _precondition(
546
+ document: Mapping[str, Any],
547
+ operation: DeltaOperation,
548
+ closing: Mapping[AliasKey, str] | None = None,
549
+ ) -> str | None:
550
+ """Why ``operation`` does not apply to ``document``, or None when it does.
551
+
552
+ ``closing`` maps the key of each alias a close_alias in the same delta
553
+ ends to its ``effective_to``, so an alias can move in one delta."""
485
554
  if operation.op == "add_model":
486
555
  key = operation.key
487
556
  if _model_indexes(document, key.canonical_id):
@@ -502,13 +571,69 @@ def _precondition(document: Mapping[str, Any], operation: DeltaOperation) -> str
502
571
  return "unknown_model"
503
572
  if len(models) > 1:
504
573
  return "duplicate_model"
505
- for canonical_id, provider, name in _aliases(document):
506
- if (provider, name) == (key.provider, key.alias):
507
- return (
508
- "alias_exists"
509
- if canonical_id == key.canonical_id
510
- else "alias_conflict"
511
- )
574
+ closing = closing or {}
575
+ new = operation.alias or {}
576
+ for entry in document.get("models") or []:
577
+ if not isinstance(entry, Mapping):
578
+ continue
579
+ canonical_id = str(entry.get("canonical_id") or "")
580
+ for alias in entry.get("aliases") or []:
581
+ if not isinstance(alias, Mapping):
582
+ continue
583
+ provider = (_text(alias.get("provider")) or "").lower()
584
+ name = (_text(alias.get("alias")) or "").lower()
585
+ if (provider, name) != (key.provider, key.alias):
586
+ continue
587
+ ends = alias.get("effective_to")
588
+ if ends is None:
589
+ ends = closing.get(
590
+ AliasKey(
591
+ canonical_id,
592
+ provider,
593
+ name,
594
+ _text(alias.get("channel")) or "",
595
+ )
596
+ )
597
+ if _windows_overlap(
598
+ alias.get("effective_from"),
599
+ ends,
600
+ new.get("effective_from"),
601
+ new.get("effective_to"),
602
+ ):
603
+ return (
604
+ "alias_exists"
605
+ if canonical_id == key.canonical_id
606
+ else "alias_conflict"
607
+ )
608
+ return None
609
+ if operation.op == "close_alias":
610
+ key = operation.key
611
+ models = _model_indexes(document, key.canonical_id)
612
+ if not models:
613
+ return "unknown_model"
614
+ if len(models) > 1:
615
+ return "duplicate_model"
616
+ entry = document["models"][models[0]]
617
+ if not _alias_rows(entry, key):
618
+ return "alias_missing"
619
+ rows = _expected_aliases(entry, operation)
620
+ if not rows:
621
+ return "expect_mismatch"
622
+ if len(rows) > 1:
623
+ return "duplicate_alias"
624
+ row = rows[0]
625
+ if row.get("effective_to") is not None:
626
+ return "alias_not_open"
627
+ starts = _instant(row.get("effective_from"))
628
+ if starts is not None and _instant(operation.effective_to) <= starts:
629
+ return "not_after_start"
630
+ # A dated alias must cite its source, so a close supplies one for an
631
+ # alias that has none, and never replaces one it has.
632
+ if _text(row.get("source_url")) is None:
633
+ if operation.source_url is None:
634
+ return "source_url_needed"
635
+ elif operation.source_url is not None:
636
+ return "source_url_exists"
512
637
  return None
513
638
  model_index, rows, reason = _locate(document, operation)
514
639
  if reason is not None:
@@ -565,10 +690,15 @@ def apply_catalog_delta(
565
690
  delta = parse_catalog_delta(delta_document)
566
691
  if not isinstance(document, Mapping):
567
692
  raise CatalogDeltaError("catalog document must be a mapping")
693
+ closing = {
694
+ operation.key: operation.effective_to
695
+ for operation in delta.operations
696
+ if operation.op == "close_alias"
697
+ }
568
698
  conflicts = tuple(
569
699
  DeltaConflict(operation.index, operation.op, operation.key, reason)
570
700
  for operation in delta.operations
571
- if (reason := _precondition(document, operation)) is not None
701
+ if (reason := _precondition(document, operation, closing)) is not None
572
702
  )
573
703
  if conflicts:
574
704
  raise CatalogDeltaConflict(conflicts)
@@ -586,6 +716,16 @@ def apply_catalog_delta(
586
716
  copy.deepcopy(dict(operation.alias))
587
717
  )
588
718
  continue
719
+ if operation.op == "close_alias":
720
+ [model_index] = _model_indexes(result, operation.key.canonical_id)
721
+ # The first equal row is the one checked: an alias this delta
722
+ # adds under the same key starts after this one ends, so it never
723
+ # equals ``expect``.
724
+ alias = _expected_aliases(result["models"][model_index], operation)[0]
725
+ alias["effective_to"] = operation.effective_to
726
+ if operation.source_url is not None:
727
+ alias["source_url"] = operation.source_url
728
+ continue
589
729
  model_index, rows, _ = _locate(result, operation)
590
730
  prices = result["models"][model_index].setdefault("prices", [])
591
731
  if operation.op == "add_price":
@@ -4985,7 +4985,7 @@ models:
4985
4985
  cache_read_per_mtok: 0.125
4986
4986
  batch_input_per_mtok: 0.625
4987
4987
  batch_output_per_mtok: 5
4988
- source_url: https://ai-gateway.vercel.sh/v1/models
4988
+ source_url: https://developers.openai.com/api/docs/models/gpt-5
4989
4989
  - channel: vercel-ai-gateway
4990
4990
  region: global
4991
4991
  effective_from: "2026-10-02"
@@ -5010,7 +5010,7 @@ models:
5010
5010
  input_per_mtok: 1.75
5011
5011
  output_per_mtok: 14
5012
5012
  cache_read_per_mtok: 0.175
5013
- source_url: https://ai-gateway.vercel.sh/v1/models
5013
+ source_url: https://developers.openai.com/api/docs/models/gpt-5.3-codex
5014
5014
  - channel: vercel-ai-gateway
5015
5015
  region: global
5016
5016
  effective_from: "2026-10-02"
@@ -5141,11 +5141,22 @@ models:
5141
5141
  - channel: openai-api
5142
5142
  region: global
5143
5143
  effective_from: "2026-07-09"
5144
+ effective_to: "2026-10-27"
5144
5145
  input_per_mtok: 12.5
5145
5146
  output_per_mtok: 75
5146
5147
  cache_read_per_mtok: 1.25
5147
5148
  cache_write_5m_per_mtok: 15.625
5148
- source_url: https://ai-gateway.vercel.sh/v1/models
5149
+ source_url: https://developers.openai.com/api/docs/models/gpt-5.6-cyber
5150
+ - channel: openai-api
5151
+ region: global
5152
+ effective_from: "2026-10-27"
5153
+ input_per_mtok: 12.5
5154
+ output_per_mtok: 75
5155
+ cache_read_per_mtok: 1.25
5156
+ cache_write_5m_per_mtok: 15.625
5157
+ rules:
5158
+ long_context: {threshold: 272000, threshold_counts_cached_tokens: true, input_multiplier: 2, output_multiplier: 1.5}
5159
+ source_url: https://developers.openai.com/api/docs/models/gpt-5.6-cyber
5149
5160
  - canonical_id: openai/o4-mini
5150
5161
  publisher: openai
5151
5162
  aliases:
@@ -5163,7 +5174,7 @@ models:
5163
5174
  cache_read_per_mtok: 0.275
5164
5175
  batch_input_per_mtok: 0.55
5165
5176
  batch_output_per_mtok: 2.2
5166
- source_url: https://ai-gateway.vercel.sh/v1/models
5177
+ source_url: https://developers.openai.com/api/docs/models/o4-mini
5167
5178
  - channel: vercel-ai-gateway
5168
5179
  region: global
5169
5180
  effective_from: "2025-04-16"
@@ -153,7 +153,8 @@ class LoadedCatalog:
153
153
  model=model, channel=channel, provider=provider, at=when, region=region
154
154
  )
155
155
  if resolved is None:
156
- return PriceResult("unpriced", None, None, when, missing)
156
+ warnings = self._explain(model, channel, provider, when, region, missing)
157
+ return PriceResult("unpriced", None, None, when, missing, warnings=warnings)
157
158
  return PriceResult(
158
159
  "partial" if missing else "priced",
159
160
  resolved.canonical_model,
@@ -163,6 +164,15 @@ class LoadedCatalog:
163
164
  (PRICE_ENDED,) if resolved.ended else (),
164
165
  )
165
166
 
167
+ def _explain(self, model, channel, provider, at, region, missing):
168
+ """Warnings for an unpriced result. A missing region already says
169
+ why, so it gets none."""
170
+ if "region" in missing:
171
+ return ()
172
+ return self.snapshot._explain(
173
+ model=model, channel=channel, provider=provider, at=at, region=region
174
+ )
175
+
166
176
  def calculate_cost(
167
177
  self,
168
178
  *,
@@ -190,6 +200,7 @@ class LoadedCatalog:
190
200
  reasons or ("unknown_deployment",),
191
201
  effective_at=when,
192
202
  missing_dimensions=missing,
203
+ warnings=self._explain(model, channel, provider, when, region, missing),
193
204
  )
194
205
  return _cost_result(resolved, at=when, usage=usage, missing=missing)
195
206
 
@@ -619,6 +619,8 @@ def _gateway_price(
619
619
 
620
620
  # Reviewed gateway spellings for publishers the catalog already names.
621
621
  _GATEWAY_PUBLISHER_PREFIXES = {
622
+ "bytedance-seed": "bytedance",
623
+ "meta-llama": "meta",
622
624
  "mistralai": "mistral",
623
625
  "qwen": "alibaba",
624
626
  "z-ai": "zai",
@@ -642,8 +644,48 @@ def _identity_shape(model_key: str) -> tuple[str, str]:
642
644
  return prefix, "".join(character for character in suffix if character.isalnum())
643
645
 
644
646
 
647
+ def _current_identity(
648
+ entry: Mapping[str, Any], day: date
649
+ ) -> tuple[str | None, list[Mapping[str, Any]]]:
650
+ """``entry``'s canonical id and its aliases in effect on ``day``.
651
+
652
+ An alias that has ended no longer names the model, so a spelling moved to
653
+ another model matches only there. The canonical id is left out when it is
654
+ also one of the model's alias spellings and none of those is in effect,
655
+ as ``_names`` treats the canonical tail."""
656
+ aliases = [a for a in entry.get("aliases") or [] if isinstance(a, Mapping)]
657
+ current = [a for a in aliases if _in_effect(a, day, dated=False)]
658
+ canonical_id = str(entry.get("canonical_id") or "").strip().lower()
659
+ spelled = {str(a.get("alias") or "").strip().lower() for a in aliases}
660
+ active = {str(a.get("alias") or "").strip().lower() for a in current}
661
+ if canonical_id in spelled and canonical_id not in active:
662
+ return None, current
663
+ return canonical_id, current
664
+
665
+
666
+ def _channel_models(
667
+ document: Mapping[str, Any], source: PriceSource, model_key: str, day: date
668
+ ) -> list[Mapping[str, Any]]:
669
+ """Models with an alias in effect on ``day`` that spells ``model_key`` on
670
+ the source's own channel. That alias is the reviewed mapping for this
671
+ gateway, so it decides even while another channel still gives the
672
+ spelling to a different model."""
673
+ provider, wanted = source.provider.lower(), model_key.strip().lower()
674
+ return [
675
+ entry
676
+ for entry in document.get("models") or []
677
+ if isinstance(entry, Mapping)
678
+ and any(
679
+ str(alias.get("provider") or "").strip().lower() == provider
680
+ and str(alias.get("alias") or "").strip().lower() == wanted
681
+ and alias.get("channel") == source.channel
682
+ for alias in _current_identity(entry, day)[1]
683
+ )
684
+ ]
685
+
686
+
645
687
  def _exact_models(
646
- document: Mapping[str, Any], model_key: str
688
+ document: Mapping[str, Any], model_key: str, day: date
647
689
  ) -> list[Mapping[str, Any]]:
648
690
  publisher, _, _ = _catalog_gateway_identity(model_key).partition("/")
649
691
  wanted = {
@@ -654,17 +696,15 @@ def _exact_models(
654
696
  for entry in document.get("models") or []:
655
697
  if not isinstance(entry, Mapping):
656
698
  continue
657
- names = {str(entry.get("canonical_id") or "").strip().lower()}
699
+ canonical_id, aliases = _current_identity(entry, day)
700
+ names = {canonical_id} if canonical_id else set()
658
701
  names.update(
659
- str(alias.get("alias") or "").strip().lower()
660
- for alias in entry.get("aliases") or []
661
- if isinstance(alias, Mapping)
702
+ str(alias.get("alias") or "").strip().lower() for alias in aliases
662
703
  )
663
704
  names.update(
664
705
  f"{publisher}/{str(alias.get('alias') or '').strip().lower()}"
665
- for alias in entry.get("aliases") or []
666
- if isinstance(alias, Mapping)
667
- and _catalog_publisher(alias.get("provider")) == publisher
706
+ for alias in aliases
707
+ if _catalog_publisher(alias.get("provider")) == publisher
668
708
  )
669
709
  if wanted & names:
670
710
  found.append(entry)
@@ -672,21 +712,21 @@ def _exact_models(
672
712
 
673
713
 
674
714
  def _near_models(
675
- document: Mapping[str, Any], model_key: str
715
+ document: Mapping[str, Any], model_key: str, day: date
676
716
  ) -> list[Mapping[str, Any]]:
677
717
  wanted = _identity_shape(model_key)
678
718
  found = []
679
719
  for entry in document.get("models") or []:
680
720
  if not isinstance(entry, Mapping):
681
721
  continue
682
- shapes = {_identity_shape(str(entry.get("canonical_id") or ""))}
722
+ canonical_id, aliases = _current_identity(entry, day)
723
+ shapes = {_identity_shape(canonical_id)} if canonical_id else set()
683
724
  shapes.update(
684
725
  _identity_shape(
685
726
  f"{_catalog_publisher(alias.get('provider'))}/"
686
727
  f"{str(alias.get('alias') or '').strip().lower()}"
687
728
  )
688
- for alias in entry.get("aliases") or []
689
- if isinstance(alias, Mapping)
729
+ for alias in aliases
690
730
  )
691
731
  if wanted in shapes:
692
732
  found.append(entry)
@@ -703,9 +743,11 @@ def _gateway_additions(
703
743
  operations: list[dict[str, Any]] = []
704
744
  identity_exclusions: list[dict[str, Any]] = []
705
745
  for rate in extraction.rates:
706
- matches = _exact_models(document, rate.model_key)
746
+ matches = _channel_models(
747
+ document, source, rate.model_key, observed_on
748
+ ) or _exact_models(document, rate.model_key, observed_on)
707
749
  if not matches:
708
- near = _near_models(document, rate.model_key)
750
+ near = _near_models(document, rate.model_key, observed_on)
709
751
  if near:
710
752
  identity_exclusions.append(
711
753
  {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph-core
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: Open, effective-dated LLM pricing catalog and deterministic cost calculator for popular models across major providers and gateways, including OpenAI, Anthropic, Google Gemini, AWS Bedrock, OpenRouter, Vercel AI Gateway, and more.
5
5
  Author: Metergraph
6
6
  License-Expression: Apache-2.0
@@ -210,6 +210,11 @@ A call after it ended resolves to that price, stays `priced`, and carries
210
210
  applied `price.effective_to` says when it ended. A closed price followed by a
211
211
  later one never extends, so a gap before a scheduled price stays `unpriced`.
212
212
 
213
+ An `unpriced` result whose cause is a value you passed, such as an unknown
214
+ channel, provider or model, says why in `warnings`. Each `PricingWarning` has
215
+ a stable `code` and a `message` that may suggest the value you meant. Match on
216
+ `code`; the message text may change.
217
+
213
218
  ### Costing a provider response
214
219
 
215
220
  `cost_from_response` reads the usage out of a response you already have and
@@ -351,6 +356,41 @@ effective_from)` on a model the catalog already has:
351
356
  start on a later UTC date than the row it replaces, for the same reason.
352
357
  - `close_price` sets `effective_to` on the open row without a replacement.
353
358
 
359
+ Catalog operations change which models exist and what calls they match:
360
+
361
+ - `add_model` appends one complete model entry.
362
+ - `add_alias` appends one alias to an existing model. A spelling another
363
+ model already holds is a conflict only while both aliases are in effect.
364
+ - `close_alias` sets `effective_to` on an open alias, named by
365
+ `(canonical_id, provider, alias, channel)`; `expect` is the exact current
366
+ alias. An alias with dates must cite its source, so a close carries
367
+ `source_url` when the alias has none and must not when it has one.
368
+
369
+ A `close_alias`, `close_price`, `add_alias` and `add_price` together move a
370
+ spelling from a duplicate entry to the model it duplicates from a date. Calls
371
+ before that date keep the old model and price, calls from it resolve to the
372
+ surviving model, and the old entry stays for history with nothing in effect:
373
+
374
+ ```yaml
375
+ - op: close_alias
376
+ key: {canonical_id: example-host/model-a-instruct, provider: openrouter,
377
+ alias: example-host/model-a-instruct, channel: openrouter-api}
378
+ expect: {provider: openrouter, alias: example-host/model-a-instruct, channel: openrouter-api}
379
+ effective_to: "2026-10-20"
380
+ source_url: "https://..."
381
+ - op: close_price # the duplicate's open openrouter-api row
382
+ - op: add_alias
383
+ key: {canonical_id: example/model-a, provider: openrouter,
384
+ alias: example-host/model-a-instruct, channel: openrouter-api}
385
+ expect: null
386
+ alias: {provider: openrouter, alias: example-host/model-a-instruct,
387
+ channel: openrouter-api, effective_from: "2026-10-20", source_url: "https://..."}
388
+ - op: add_price # the same rate on example/model-a from 2026-10-20
389
+ ```
390
+
391
+ Gateway discovery matches a spelling only through aliases in effect on the
392
+ observation date, so it follows a moved spelling to its new model.
393
+
354
394
  A price id ends with the UTC date of its row's start. If two rows for one
355
395
  model, channel and region would still share an id (possible in a hand-edited
356
396
  catalog), each that does not start at UTC midnight also names its UTC start