metergraph-core 0.2.41__tar.gz → 0.2.52__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.41/src/metergraph_core.egg-info → metergraph_core-0.2.52}/PKG-INFO +1 -1
  2. {metergraph_core-0.2.41 → metergraph_core-0.2.52}/pyproject.toml +1 -1
  3. {metergraph_core-0.2.41 → metergraph_core-0.2.52}/src/metergraph_core/catalog.py +112 -32
  4. {metergraph_core-0.2.41 → metergraph_core-0.2.52}/src/metergraph_core/data/models.yaml +1 -14
  5. {metergraph_core-0.2.41 → metergraph_core-0.2.52}/src/metergraph_core/data/prices.yaml +1043 -81
  6. {metergraph_core-0.2.41 → metergraph_core-0.2.52}/src/metergraph_core/loader.py +69 -12
  7. {metergraph_core-0.2.41 → metergraph_core-0.2.52/src/metergraph_core.egg-info}/PKG-INFO +1 -1
  8. {metergraph_core-0.2.41 → metergraph_core-0.2.52}/MANIFEST.in +0 -0
  9. {metergraph_core-0.2.41 → metergraph_core-0.2.52}/README.md +0 -0
  10. {metergraph_core-0.2.41 → metergraph_core-0.2.52}/setup.cfg +0 -0
  11. {metergraph_core-0.2.41 → metergraph_core-0.2.52}/src/metergraph_core/__init__.py +0 -0
  12. {metergraph_core-0.2.41 → metergraph_core-0.2.52}/src/metergraph_core/billing.py +0 -0
  13. {metergraph_core-0.2.41 → metergraph_core-0.2.52}/src/metergraph_core/models.py +0 -0
  14. {metergraph_core-0.2.41 → metergraph_core-0.2.52}/src/metergraph_core/retrieval.py +0 -0
  15. {metergraph_core-0.2.41 → metergraph_core-0.2.52}/src/metergraph_core.egg-info/SOURCES.txt +0 -0
  16. {metergraph_core-0.2.41 → metergraph_core-0.2.52}/src/metergraph_core.egg-info/dependency_links.txt +0 -0
  17. {metergraph_core-0.2.41 → metergraph_core-0.2.52}/src/metergraph_core.egg-info/requires.txt +0 -0
  18. {metergraph_core-0.2.41 → metergraph_core-0.2.52}/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.41
3
+ Version: 0.2.52
4
4
  Summary: Reusable MeterGraph catalog and deterministic billing engine
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "metergraph-core"
3
- version = "0.2.41"
3
+ version = "0.2.52"
4
4
  description = "Reusable MeterGraph catalog and deterministic billing engine"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -81,6 +81,7 @@ _BEDROCK_CHANNEL = "aws-bedrock"
81
81
  _BEDROCK_CHANNELS = frozenset({_BEDROCK_CHANNEL, "aws-bedrock-geo"})
82
82
  _BEDROCK_VERSION_SUFFIX = re.compile(r"-v\d+(?::\d+)?$")
83
83
  _BEDROCK_GLOBAL_PREFIX = "global."
84
+ _GLOBAL_REGION = "global"
84
85
 
85
86
 
86
87
  def _bedrock_fallback_ids(model_key: str) -> tuple[tuple[str, bool], ...]:
@@ -163,6 +164,28 @@ class Alias:
163
164
  pricing_channel: str
164
165
  rules: Mapping[str, Any]
165
166
  publisher: str | None = None
167
+ effective_from: datetime | None = None
168
+ effective_to: datetime | None = None
169
+ source_url: str | None = None
170
+ # Limits an identifier to matching regions; global matches every snapshot.
171
+ price_region: tuple[str, ...] = ()
172
+
173
+
174
+ def _crosses_price_region(alias: Alias) -> bool:
175
+ """Whether global-prefix fallback would cross a price-region boundary."""
176
+ return bool(alias.price_region) and alias.price_region != (_GLOBAL_REGION,)
177
+
178
+
179
+ def _effective_windows_overlap(left: Alias, right: Alias) -> bool:
180
+ return (
181
+ left.effective_to is None
182
+ or right.effective_from is None
183
+ or right.effective_from < left.effective_to
184
+ ) and (
185
+ right.effective_to is None
186
+ or left.effective_from is None
187
+ or left.effective_from < right.effective_to
188
+ )
166
189
 
167
190
 
168
191
  @dataclass(frozen=True, slots=True)
@@ -421,28 +444,40 @@ def _price_tokens(
421
444
  class CatalogSnapshot:
422
445
  def __init__(
423
446
  self,
424
- aliases: Mapping[tuple[str, str], Alias],
447
+ aliases: Mapping[tuple[str, str], Alias | tuple[Alias, ...] | list[Alias]],
425
448
  prices: list[Price],
426
449
  *,
427
450
  region: str,
428
451
  ) -> None:
429
- self._aliases = dict(aliases)
452
+ self._aliases = {
453
+ key: (value,) if isinstance(value, Alias) else tuple(value)
454
+ for key, value in aliases.items()
455
+ }
430
456
  self._prices: dict[tuple[str, str], list[Price]] = {}
431
- self._deployment_aliases: dict[tuple[str, str], Alias] = {}
457
+ deployment_aliases: dict[tuple[str, str], list[Alias]] = {}
432
458
  self._region = region.strip().lower()
433
- for (_, observed_model), alias in aliases.items():
434
- channel = alias.pricing_channel.strip().lower()
435
- for model in (observed_model, alias.canonical_id):
436
- key = (model.strip().lower(), channel)
437
- existing = self._deployment_aliases.get(key)
438
- if existing is not None and (
439
- existing.canonical_id != alias.canonical_id
440
- or dict(existing.rules) != dict(alias.rules)
441
- ):
442
- raise ValueError(
443
- f"ambiguous deployment alias {model!r} for channel {channel!r}"
444
- )
445
- self._deployment_aliases[key] = alias
459
+ for (_, observed_model), candidates in self._aliases.items():
460
+ for alias in candidates:
461
+ channel = alias.pricing_channel.strip().lower()
462
+ for model in (observed_model, alias.canonical_id):
463
+ key = (model.strip().lower(), channel)
464
+ deployment_aliases.setdefault(key, []).append(alias)
465
+ self._deployment_aliases = {
466
+ key: tuple(candidates) for key, candidates in deployment_aliases.items()
467
+ }
468
+ for (model, channel), candidates in self._deployment_aliases.items():
469
+ for index, alias in enumerate(candidates):
470
+ for other in candidates[index + 1:]:
471
+ if not _effective_windows_overlap(alias, other):
472
+ continue
473
+ if (
474
+ alias.canonical_id != other.canonical_id
475
+ or dict(alias.rules) != dict(other.rules)
476
+ ):
477
+ raise ValueError(
478
+ f"ambiguous deployment alias {model!r} "
479
+ f"for channel {channel!r}"
480
+ )
446
481
  for price in prices:
447
482
  self._prices.setdefault((price.model_id, price.pricing_channel), []).append(
448
483
  price
@@ -450,10 +485,36 @@ class CatalogSnapshot:
450
485
  for candidates in self._prices.values():
451
486
  candidates.sort(key=lambda price: price.effective_from, reverse=True)
452
487
 
488
+ def _alias_for(self, candidates: tuple[Alias, ...], at: datetime) -> Alias | None:
489
+ when = _coerce_datetime(at)
490
+ effective = [
491
+ alias
492
+ for alias in candidates
493
+ if (alias.effective_from is None or alias.effective_from <= when)
494
+ and (alias.effective_to is None or when < alias.effective_to)
495
+ ]
496
+ # Regional pins constrain availability; global pins match everywhere.
497
+ for region in dict.fromkeys((self._region, _GLOBAL_REGION)):
498
+ for alias in effective:
499
+ if region in alias.price_region:
500
+ return alias
501
+ for alias in effective:
502
+ if not alias.price_region:
503
+ return alias
504
+ return None
505
+
506
+ def _price_regions(self, alias: Alias) -> tuple[str, ...]:
507
+ if not alias.price_region:
508
+ return tuple(dict.fromkeys((self._region, "*", _GLOBAL_REGION)))
509
+ for region in (self._region, _GLOBAL_REGION):
510
+ if region in alias.price_region:
511
+ return (region,)
512
+ return ()
513
+
453
514
  def _price_for(self, alias: Alias, at: datetime) -> Price | None:
454
515
  at = at if at.tzinfo else at.replace(tzinfo=timezone.utc)
455
516
  candidates = self._prices.get((alias.model_id, alias.pricing_channel), [])
456
- for region in dict.fromkeys((self._region, "*", "global")):
517
+ for region in self._price_regions(alias):
457
518
  for price in candidates:
458
519
  if price.region.lower() != region:
459
520
  continue
@@ -476,19 +537,21 @@ class CatalogSnapshot:
476
537
  model_key = model.strip().lower()
477
538
  canonical_ids = {
478
539
  alias.canonical_id
479
- for (known_model, _channel), alias in self._deployment_aliases.items()
540
+ for (known_model, _channel), candidates in self._deployment_aliases.items()
480
541
  if known_model == model_key
542
+ for alias in candidates
481
543
  }
482
544
  if len(canonical_ids) != 1:
483
545
  return None
484
546
  channels: set[str] = set()
485
547
  for canonical_id in canonical_ids:
486
- for alias in self._deployment_aliases.values():
487
- if alias.canonical_id != canonical_id:
488
- continue
489
- channel = direct_channel_for_provider(alias.publisher)
490
- if channel is not None and (canonical_id, channel) in self._prices:
491
- channels.add(channel)
548
+ for candidates in self._deployment_aliases.values():
549
+ for alias in candidates:
550
+ if alias.canonical_id != canonical_id:
551
+ continue
552
+ channel = direct_channel_for_provider(alias.publisher)
553
+ if channel is not None and (canonical_id, channel) in self._prices:
554
+ channels.add(channel)
492
555
  return next(iter(channels)) if len(channels) == 1 else None
493
556
 
494
557
  def resolve_price(
@@ -503,17 +566,22 @@ class CatalogSnapshot:
503
566
 
504
567
  model_key = str(model or "").strip().lower()
505
568
  channel_key = str(channel or "").strip().lower()
506
- alias = self._deployment_aliases.get((model_key, channel_key))
569
+ when = _coerce_datetime(at)
570
+ candidates = self._deployment_aliases.get((model_key, channel_key), ())
571
+ alias = self._alias_for(candidates, when)
507
572
  if alias is None and channel_key in _BEDROCK_CHANNELS:
508
573
  for candidate, dropped_global in _bedrock_fallback_ids(model_key):
509
574
  if dropped_global and channel_key != _BEDROCK_CHANNEL:
510
575
  continue
511
- alias = self._deployment_aliases.get((candidate, channel_key))
576
+ candidates = self._deployment_aliases.get((candidate, channel_key), ())
577
+ alias = self._alias_for(candidates, when)
578
+ if alias is not None and dropped_global and _crosses_price_region(alias):
579
+ alias = None
512
580
  if alias is not None:
513
581
  break
514
582
  if alias is None:
515
583
  return None
516
- price = self._price_for(alias, at)
584
+ price = self._price_for(alias, when)
517
585
  if price is None:
518
586
  return None
519
587
  return ResolvedPrice(
@@ -539,19 +607,31 @@ class CatalogSnapshot:
539
607
  provider_key = str(provider or "").strip().lower()
540
608
  provider_key = _PROVIDER_ALIASES.get(provider_key, provider_key)
541
609
  model_key = str(model or "").strip().lower()
542
- alias = self._aliases.get((provider_key, model_key))
610
+ when = _coerce_datetime(at)
611
+ candidates = self._aliases.get((provider_key, model_key), ())
612
+ alias_known = bool(candidates)
613
+ alias = self._alias_for(candidates, when)
543
614
  if alias is None and provider_key == "bedrock":
544
615
  for candidate, dropped_global in _bedrock_fallback_ids(model_key):
545
- found = self._aliases.get((provider_key, candidate))
616
+ fallback = self._aliases.get((provider_key, candidate), ())
617
+ alias_known = alias_known or bool(fallback)
618
+ found = self._alias_for(fallback, when)
546
619
  if found is None or found.pricing_channel not in _BEDROCK_CHANNELS:
547
620
  continue
548
- if dropped_global and found.pricing_channel != _BEDROCK_CHANNEL:
621
+ if dropped_global and (
622
+ found.pricing_channel != _BEDROCK_CHANNEL
623
+ or _crosses_price_region(found)
624
+ ):
549
625
  continue
550
626
  alias = found
551
627
  break
552
628
  if alias is None:
629
+ if alias_known:
630
+ return CostResult(
631
+ None, None, None, "unpriced", ("no_effective_alias",)
632
+ )
553
633
  return CostResult(None, None, None, "unpriced", ("unknown_model",))
554
- price = self._price_for(alias, at)
634
+ price = self._price_for(alias, when)
555
635
  if price is None:
556
636
  return CostResult(
557
637
  alias.canonical_id,
@@ -564,7 +644,7 @@ class CatalogSnapshot:
564
644
  cost, reasons = _price_tokens(
565
645
  price,
566
646
  {**price.rules, **alias.rules},
567
- at=_coerce_datetime(at),
647
+ at=when,
568
648
  input_tokens=input_tokens,
569
649
  output_tokens=output_tokens,
570
650
  cache_read_tokens=cache_read_tokens,
@@ -1,4 +1,4 @@
1
- version: '2026-09-29'
1
+ version: '2026-09-30'
2
2
  models:
3
3
  - canonical_id: openai/gpt-5.6-sol
4
4
  display_name: GPT-5.6 Sol
@@ -167,17 +167,6 @@ models:
167
167
  pricing_channel: aws-bedrock
168
168
  execution_profiles:
169
169
  - bedrock
170
- - canonical_id: anthropic/claude-3-haiku
171
- display_name: Claude 3 Haiku
172
- publisher: anthropic
173
- routes:
174
- - key: default:anthropic/claude-3-haiku
175
- id: anthropic/claude-3-haiku
176
- provider: vercel
177
- model_id: anthropic/claude-3-haiku
178
- pricing_channel: vercel-ai-gateway
179
- execution_profiles:
180
- - default
181
170
  - canonical_id: anthropic/claude-fable-5
182
171
  display_name: Claude Fable 5
183
172
  publisher: anthropic
@@ -604,7 +593,6 @@ execution_profiles:
604
593
  - default:openai/gpt-5.6-terra
605
594
  - default:anthropic/claude-opus-5
606
595
  - default:anthropic/claude-sonnet-5
607
- - default:anthropic/claude-3-haiku
608
596
  - default:anthropic/claude-fable-5
609
597
  - default:anthropic/claude-fable-5.1
610
598
  - default:google/gemini-3.1-pro-preview
@@ -663,7 +651,6 @@ offer_groups:
663
651
  - default:anthropic/claude-opus-5
664
652
  - default:anthropic/claude-opus-4.8
665
653
  - default:anthropic/claude-haiku-4.5
666
- - default:anthropic/claude-3-haiku
667
654
  - default:openai/gpt-5.6-sol
668
655
  - default:openai/gpt-5.6-luna
669
656
  - default:openai/gpt-5.6-terra