metergraph-core 0.8.1__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.1/src/metergraph_core.egg-info → metergraph_core-0.8.2}/PKG-INFO +36 -1
  2. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/README.md +35 -0
  3. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/pyproject.toml +1 -1
  4. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/src/metergraph_core/catalog_delta.py +157 -17
  5. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/src/metergraph_core/data/prices.yaml +15 -4
  6. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/src/metergraph_core/refresh.py +56 -14
  7. {metergraph_core-0.8.1 → metergraph_core-0.8.2/src/metergraph_core.egg-info}/PKG-INFO +36 -1
  8. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/MANIFEST.in +0 -0
  9. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/setup.cfg +0 -0
  10. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/src/metergraph_core/__init__.py +0 -0
  11. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/src/metergraph_core/billing.py +0 -0
  12. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/src/metergraph_core/catalog.py +0 -0
  13. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/src/metergraph_core/data/models.yaml +0 -0
  14. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/src/metergraph_core/data/sources.yaml +0 -0
  15. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/src/metergraph_core/extractors.py +0 -0
  16. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/src/metergraph_core/loader.py +0 -0
  17. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/src/metergraph_core/models.py +0 -0
  18. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/src/metergraph_core/responses.py +0 -0
  19. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/src/metergraph_core/retrieval.py +0 -0
  20. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/src/metergraph_core/sources.py +0 -0
  21. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/src/metergraph_core.egg-info/SOURCES.txt +0 -0
  22. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/src/metergraph_core.egg-info/dependency_links.txt +0 -0
  23. {metergraph_core-0.8.1 → metergraph_core-0.8.2}/src/metergraph_core.egg-info/requires.txt +0 -0
  24. {metergraph_core-0.8.1 → 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.1
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
@@ -356,6 +356,41 @@ effective_from)` on a model the catalog already has:
356
356
  start on a later UTC date than the row it replaces, for the same reason.
357
357
  - `close_price` sets `effective_to` on the open row without a replacement.
358
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
+
359
394
  A price id ends with the UTC date of its row's start. If two rows for one
360
395
  model, channel and region would still share an id (possible in a hand-edited
361
396
  catalog), each that does not start at UTC midnight also names its UTC start
@@ -326,6 +326,41 @@ effective_from)` on a model the catalog already has:
326
326
  start on a later UTC date than the row it replaces, for the same reason.
327
327
  - `close_price` sets `effective_to` on the open row without a replacement.
328
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
+
329
364
  A price id ends with the UTC date of its row's start. If two rows for one
330
365
  model, channel and region would still share an id (possible in a hand-edited
331
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.1"
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"
@@ -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"
@@ -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.1
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
@@ -356,6 +356,41 @@ effective_from)` on a model the catalog already has:
356
356
  start on a later UTC date than the row it replaces, for the same reason.
357
357
  - `close_price` sets `effective_to` on the open row without a replacement.
358
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
+
359
394
  A price id ends with the UTC date of its row's start. If two rows for one
360
395
  model, channel and region would still share an id (possible in a hand-edited
361
396
  catalog), each that does not start at UTC midnight also names its UTC start