metergraph-core 0.2.53__tar.gz → 0.2.54__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.53/src/metergraph_core.egg-info → metergraph_core-0.2.54}/PKG-INFO +1 -1
  2. {metergraph_core-0.2.53 → metergraph_core-0.2.54}/pyproject.toml +1 -1
  3. {metergraph_core-0.2.53 → metergraph_core-0.2.54}/src/metergraph_core/__init__.py +6 -0
  4. {metergraph_core-0.2.53 → metergraph_core-0.2.54}/src/metergraph_core/billing.py +60 -5
  5. {metergraph_core-0.2.53 → metergraph_core-0.2.54}/src/metergraph_core/catalog.py +44 -11
  6. {metergraph_core-0.2.53 → metergraph_core-0.2.54}/src/metergraph_core/data/models.yaml +27 -1
  7. {metergraph_core-0.2.53 → metergraph_core-0.2.54}/src/metergraph_core/data/prices.yaml +11 -0
  8. {metergraph_core-0.2.53 → metergraph_core-0.2.54}/src/metergraph_core/loader.py +42 -0
  9. {metergraph_core-0.2.53 → metergraph_core-0.2.54/src/metergraph_core.egg-info}/PKG-INFO +1 -1
  10. {metergraph_core-0.2.53 → metergraph_core-0.2.54}/MANIFEST.in +0 -0
  11. {metergraph_core-0.2.53 → metergraph_core-0.2.54}/README.md +0 -0
  12. {metergraph_core-0.2.53 → metergraph_core-0.2.54}/setup.cfg +0 -0
  13. {metergraph_core-0.2.53 → metergraph_core-0.2.54}/src/metergraph_core/models.py +0 -0
  14. {metergraph_core-0.2.53 → metergraph_core-0.2.54}/src/metergraph_core/retrieval.py +0 -0
  15. {metergraph_core-0.2.53 → metergraph_core-0.2.54}/src/metergraph_core.egg-info/SOURCES.txt +0 -0
  16. {metergraph_core-0.2.53 → metergraph_core-0.2.54}/src/metergraph_core.egg-info/dependency_links.txt +0 -0
  17. {metergraph_core-0.2.53 → metergraph_core-0.2.54}/src/metergraph_core.egg-info/requires.txt +0 -0
  18. {metergraph_core-0.2.53 → metergraph_core-0.2.54}/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.53
3
+ Version: 0.2.54
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.53"
3
+ version = "0.2.54"
4
4
  description = "Reusable MeterGraph catalog and deterministic billing engine"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -1,6 +1,7 @@
1
1
  """Reusable MeterGraph catalog and deterministic token-cost pricing core."""
2
2
 
3
3
  from .catalog import (
4
+ CostComponents,
4
5
  Alias,
5
6
  CatalogSnapshot,
6
7
  CostResult,
@@ -11,12 +12,14 @@ from .catalog import (
11
12
  normalize_provider,
12
13
  )
13
14
  from .billing import (
15
+ Dispute,
14
16
  BillingDecision,
15
17
  GatewayBillingEvidence,
16
18
  normalize_gateway_evidence,
17
19
  resolve_billing,
18
20
  )
19
21
  from .loader import (
22
+ parse_disputes,
20
23
  CatalogError,
21
24
  LoadedCatalog,
22
25
  load_catalog,
@@ -44,7 +47,9 @@ __all__ = [
44
47
  "BillingDecision",
45
48
  "CatalogError",
46
49
  "CatalogSnapshot",
50
+ "CostComponents",
47
51
  "CostResult",
52
+ "Dispute",
48
53
  "LoadedCatalog",
49
54
  "ModelDefinition",
50
55
  "ModelRegistry",
@@ -65,6 +70,7 @@ __all__ = [
65
70
  "normalize_gateway_evidence",
66
71
  "parse_catalog",
67
72
  "parse_model_registry",
73
+ "parse_disputes",
68
74
  "parse_retrieval",
69
75
  "resolve_billing",
70
76
  "validate_model_registry",
@@ -1,10 +1,11 @@
1
1
  """Pure selection of effective LLM cost from catalog and gateway evidence."""
2
2
 
3
3
  from dataclasses import dataclass
4
+ from datetime import datetime
4
5
  from decimal import Decimal, InvalidOperation
5
- from typing import Any, Mapping
6
+ from typing import Any, Iterable, Mapping
6
7
 
7
- from .catalog import CostResult
8
+ from .catalog import CostResult, _coerce_datetime
8
9
 
9
10
  @dataclass(frozen=True, slots=True)
10
11
  class _QualifiedSource:
@@ -56,6 +57,20 @@ _QUALIFIED_SOURCES = (
56
57
  )
57
58
 
58
59
 
60
+ @dataclass(frozen=True, slots=True)
61
+ class Dispute:
62
+ """A gateway whose reported amount is not believed for one publisher's
63
+ models from a date: the catalog has shown its figure wrong there, and the
64
+ note says how. Billing takes the catalog price instead and keeps the
65
+ gateway's figure as evidence."""
66
+
67
+ gateway: str
68
+ publisher: str
69
+ since: datetime
70
+ note: str
71
+ source_url: str
72
+
73
+
59
74
  @dataclass(frozen=True, slots=True)
60
75
  class GatewayBillingEvidence:
61
76
  """Validated gateway billing fields from one content-blind call row."""
@@ -132,11 +147,43 @@ def _qualified_source(evidence: GatewayBillingEvidence) -> _QualifiedSource | No
132
147
  return None
133
148
 
134
149
 
150
+ def _disputed(
151
+ qualified: _QualifiedSource | None,
152
+ publisher: str | None,
153
+ disputes: Iterable[Dispute],
154
+ at: Any,
155
+ ) -> bool:
156
+ # Without the call time nothing is disputed: a dispute runs from a date,
157
+ # and declining a figure for a call that may predate it is the wrong
158
+ # side to err on.
159
+ if qualified is None or publisher is None or at is None:
160
+ return False
161
+ when = _coerce_datetime(at)
162
+ return any(
163
+ dispute.gateway == qualified.gateway
164
+ and dispute.publisher == publisher
165
+ and dispute.since <= when
166
+ for dispute in disputes
167
+ )
168
+
169
+
135
170
  def resolve_billing(
136
171
  catalog_result: CostResult,
137
172
  evidence: GatewayBillingEvidence,
173
+ *,
174
+ disputes: Iterable[Dispute] = (),
175
+ at: Any = None,
138
176
  ) -> BillingDecision:
139
- """Select effective cost without combining independent reported amounts."""
177
+ """Select effective cost without combining independent reported amounts.
178
+
179
+ A qualified gateway's amount is declined when the catalog disputes that
180
+ gateway for the priced model's publisher at ``at`` (with no ``at`` nothing
181
+ is disputed): the catalog price is
182
+ billed, the gateway's figure stays in ``reported_cost_usd`` as evidence,
183
+ and ``gateway_disputed`` is recorded among the reasons. When the catalog
184
+ cannot price the call, the gateway's figure still stands, with the same
185
+ reason recorded, because a disputed figure is better evidence than none.
186
+ """
140
187
 
141
188
  qualified = _qualified_source(evidence)
142
189
  reported_cost = (
@@ -145,6 +192,12 @@ def resolve_billing(
145
192
  and evidence.reported_cost_source == qualified.cost_source
146
193
  else None
147
194
  )
195
+ disputed = reported_cost is not None and _disputed(
196
+ qualified, catalog_result.publisher, disputes, at
197
+ )
198
+ reasons = catalog_result.reasons
199
+ if disputed:
200
+ reasons = tuple(dict.fromkeys((*reasons, "gateway_disputed")))
148
201
  upstream_cost = (
149
202
  evidence.reported_upstream_cost_usd
150
203
  if qualified is not None
@@ -153,7 +206,9 @@ def resolve_billing(
153
206
  else None
154
207
  )
155
208
 
156
- if reported_cost is not None:
209
+ if reported_cost is not None and not (
210
+ disputed and catalog_result.cost_usd is not None
211
+ ):
157
212
  cost_usd = reported_cost
158
213
  cost_status = "priced"
159
214
  provenance = "gateway_reported"
@@ -174,5 +229,5 @@ def resolve_billing(
174
229
  reported_upstream_cost_usd=upstream_cost,
175
230
  catalog_cost_usd=catalog_result.cost_usd,
176
231
  catalog_price_id=catalog_result.price_id,
177
- catalog_reasons=catalog_result.reasons,
232
+ catalog_reasons=reasons,
178
233
  )
@@ -211,6 +211,19 @@ class Price:
211
211
  publisher: str | None = None
212
212
 
213
213
 
214
+ @dataclass(frozen=True, slots=True)
215
+ class CostComponents:
216
+ """A priced call's cost by what it paid for. The four parts sum to the
217
+ call's ``cost_usd`` exactly: each is quantized and the rounding residue
218
+ lands in the input part, so a consumer that stores them can rebuild the
219
+ total without a second rounding."""
220
+
221
+ input_usd: Decimal
222
+ output_usd: Decimal
223
+ cache_read_usd: Decimal
224
+ cache_write_usd: Decimal
225
+
226
+
214
227
  @dataclass(frozen=True, slots=True)
215
228
  class CostResult:
216
229
  canonical_model: str | None
@@ -218,6 +231,11 @@ class CostResult:
218
231
  cost_usd: Decimal | None
219
232
  status: str
220
233
  reasons: tuple[str, ...] = ()
234
+ # Present whenever cost_usd is: the split of that cost by driver.
235
+ components: CostComponents | None = None
236
+ # The catalog's publisher for the priced model, for billing rules that
237
+ # apply per publisher rather than per channel.
238
+ publisher: str | None = None
221
239
 
222
240
 
223
241
  @dataclass(frozen=True, slots=True)
@@ -326,10 +344,11 @@ def _price_tokens(
326
344
  cache_write_5m_tokens: Any,
327
345
  cache_write_1h_tokens: Any,
328
346
  batch: bool,
329
- ) -> tuple[Decimal, list[str]]:
347
+ ) -> tuple[Decimal, list[str], CostComponents]:
330
348
  """Cost a token usage against one already-selected price and its merged
331
349
  rules. Shared by ``cost`` (provider+model entry) and ``price_deployment``
332
- (model+channel entry) so both compute identically."""
350
+ (model+channel entry) so both compute identically. Returns the total, the
351
+ reasons it is partial, and the split by driver."""
333
352
  reasons: list[str] = []
334
353
  input_count = _tokens(input_tokens)
335
354
  output_count = _tokens(output_tokens)
@@ -394,22 +413,22 @@ def _price_tokens(
394
413
  input_multiplier *= off_peak
395
414
  output_multiplier *= off_peak
396
415
 
397
- cost = Decimal("0")
416
+ input_usd = output_usd = cache_read_usd = cache_write_usd = Decimal("0")
398
417
  if input_rate is None:
399
418
  if billable_input:
400
419
  reasons.append("input_rate_unavailable")
401
420
  else:
402
- cost += Decimal(billable_input) * input_rate * input_multiplier / _MILLION
421
+ input_usd = Decimal(billable_input) * input_rate * input_multiplier / _MILLION
403
422
  if output_rate is None:
404
423
  if output_count:
405
424
  reasons.append("output_rate_unavailable")
406
425
  else:
407
- cost += Decimal(output_count) * output_rate * output_multiplier / _MILLION
426
+ output_usd = Decimal(output_count) * output_rate * output_multiplier / _MILLION
408
427
  if cache_read_count:
409
428
  if price.cache_read_per_mtok is None:
410
429
  reasons.append("cache_read_rate_unavailable")
411
430
  else:
412
- cost += (
431
+ cache_read_usd = (
413
432
  Decimal(cache_read_count)
414
433
  * price.cache_read_per_mtok
415
434
  * input_multiplier
@@ -419,7 +438,7 @@ def _price_tokens(
419
438
  if price.cache_write_5m_per_mtok is None:
420
439
  reasons.append("cache_write_5m_rate_unavailable")
421
440
  else:
422
- cost += (
441
+ cache_write_usd += (
423
442
  Decimal(cache_write_5m_count)
424
443
  * price.cache_write_5m_per_mtok
425
444
  * input_multiplier
@@ -429,7 +448,7 @@ def _price_tokens(
429
448
  if price.cache_write_1h_per_mtok is None:
430
449
  reasons.append("cache_write_1h_rate_unavailable")
431
450
  else:
432
- cost += (
451
+ cache_write_usd += (
433
452
  Decimal(cache_write_1h_count)
434
453
  * price.cache_write_1h_per_mtok
435
454
  * input_multiplier
@@ -438,7 +457,17 @@ def _price_tokens(
438
457
  if rules.get("uncaptured_fees"):
439
458
  reasons.append("uncaptured_fees")
440
459
 
441
- return cost.quantize(_COST_QUANTUM, rounding=ROUND_HALF_UP), reasons
460
+ # The total is rounded once, as it always was; the parts are rounded
461
+ # each and the residue goes to input, so they sum to the total exactly.
462
+ cost = (input_usd + output_usd + cache_read_usd + cache_write_usd).quantize(
463
+ _COST_QUANTUM, rounding=ROUND_HALF_UP
464
+ )
465
+ parts = [
466
+ part.quantize(_COST_QUANTUM, rounding=ROUND_HALF_UP)
467
+ for part in (input_usd, output_usd, cache_read_usd, cache_write_usd)
468
+ ]
469
+ parts[0] += cost - sum(parts)
470
+ return cost, reasons, CostComponents(*parts)
442
471
 
443
472
 
444
473
  class CatalogSnapshot:
@@ -641,7 +670,7 @@ class CatalogSnapshot:
641
670
  ("no_effective_price",),
642
671
  )
643
672
 
644
- cost, reasons = _price_tokens(
673
+ cost, reasons, components = _price_tokens(
645
674
  price,
646
675
  {**price.rules, **alias.rules},
647
676
  at=when,
@@ -659,6 +688,8 @@ class CatalogSnapshot:
659
688
  cost,
660
689
  "partial" if reasons else "priced",
661
690
  tuple(dict.fromkeys(reasons)),
691
+ components=components,
692
+ publisher=price.publisher,
662
693
  )
663
694
 
664
695
  def price_deployment(
@@ -689,7 +720,7 @@ class CatalogSnapshot:
689
720
  resolved = self.resolve_price(model=model, channel=channel, at=when)
690
721
  if resolved is None:
691
722
  return CostResult(None, None, None, "unpriced", ("unknown_deployment",))
692
- cost, reasons = _price_tokens(
723
+ cost, reasons, components = _price_tokens(
693
724
  resolved.price,
694
725
  resolved.rules,
695
726
  at=when,
@@ -707,4 +738,6 @@ class CatalogSnapshot:
707
738
  cost,
708
739
  "partial" if reasons else "priced",
709
740
  tuple(dict.fromkeys(reasons)),
741
+ components=components,
742
+ publisher=resolved.price.publisher,
710
743
  )
@@ -1,4 +1,4 @@
1
- version: '2026-09-30'
1
+ version: '2026-10-06'
2
2
  models:
3
3
  - canonical_id: openai/gpt-5.6-sol
4
4
  display_name: GPT-5.6 Sol
@@ -581,6 +581,28 @@ models:
581
581
  pricing_channel: aws-bedrock
582
582
  execution_profiles:
583
583
  - bedrock
584
+ - canonical_id: anthropic/claude-opus-5.5
585
+ display_name: Claude Opus 5.5
586
+ publisher: anthropic
587
+ routes:
588
+ - key: default:anthropic/claude-opus-5.5
589
+ id: anthropic/claude-opus-5.5
590
+ provider: vercel
591
+ model_id: anthropic/claude-opus-5.5
592
+ pricing_channel: vercel-ai-gateway
593
+ execution_profiles:
594
+ - default
595
+ - canonical_id: anthropic/claude-sonnet-5.5
596
+ display_name: Claude Sonnet 5.5
597
+ publisher: anthropic
598
+ routes:
599
+ - key: default:anthropic/claude-sonnet-5.5
600
+ id: anthropic/claude-sonnet-5.5
601
+ provider: vercel
602
+ model_id: anthropic/claude-sonnet-5.5
603
+ pricing_channel: vercel-ai-gateway
604
+ execution_profiles:
605
+ - default
584
606
  execution_profiles:
585
607
  - id: default
586
608
  routes:
@@ -617,6 +639,8 @@ execution_profiles:
617
639
  - default:deepseek/deepseek-v4.1-flash
618
640
  - default:alibaba/qwen3.8-flash
619
641
  - default:inception/mercury-2.5
642
+ - default:anthropic/claude-opus-5.5
643
+ - default:anthropic/claude-sonnet-5.5
620
644
  - default:openai/gpt-5.4-mini
621
645
  - default:openai/gpt-5.4-nano
622
646
  - default:anthropic/claude-opus-4.8
@@ -684,6 +708,8 @@ offer_groups:
684
708
  - default:deepseek/deepseek-v4.1-flash
685
709
  - default:alibaba/qwen3.8-flash
686
710
  - default:inception/mercury-2.5
711
+ - default:anthropic/claude-opus-5.5
712
+ - default:anthropic/claude-sonnet-5.5
687
713
  - id: fireworks
688
714
  credential: FIREWORKS_API_KEY
689
715
  routes:
@@ -4290,3 +4290,14 @@ retrieval:
4290
4290
  per_1k_usd: 12.00
4291
4291
  effective_from: "2025-04-18"
4292
4292
  source_url: https://docs.perplexity.ai/docs/getting-started/pricing
4293
+
4294
+ # Gateways whose reported amount billing declines for one publisher's models
4295
+ # from a date, because the catalog has shown the gateway's figure wrong there.
4296
+ # The call is billed at the catalog price and the gateway's figure is kept as
4297
+ # evidence; a product shows the note beside the figure.
4298
+ disputes:
4299
+ - gateway: portkey
4300
+ publisher: deepseek
4301
+ since: "2026-09-10"
4302
+ note: Portkey prices DeepSeek from a table that predates DeepSeek's 2026-09-10 price change, at a quarter to a half of the list price.
4303
+ source_url: https://api-docs.deepseek.com/updates/
@@ -18,6 +18,7 @@ from .catalog import (
18
18
  _effective_windows_overlap,
19
19
  normalize_provider,
20
20
  )
21
+ from .billing import Dispute
21
22
  from .retrieval import RetrievalCatalog, RetrievalCostResult, RetrievalPrice
22
23
 
23
24
  DEFAULT_CATALOG_PATH = Path(__file__).parent / "data" / "prices.yaml"
@@ -75,6 +76,8 @@ class LoadedCatalog:
75
76
  snapshot: CatalogSnapshot
76
77
  canonical_ids: Mapping[tuple[str, str], str]
77
78
  retrieval: RetrievalCatalog
79
+ # Gateways whose amount billing declines per publisher; see Dispute.
80
+ disputes: tuple[Dispute, ...] = ()
78
81
 
79
82
  def canonical_model_id(self, provider: str, model_id: str) -> str:
80
83
  """Resolve a captured provider-native model id to its canonical id,
@@ -385,6 +388,44 @@ def parse_retrieval(document: Any) -> list[RetrievalPrice]:
385
388
  return prices
386
389
 
387
390
 
391
+ def parse_disputes(document: Any) -> list[Dispute]:
392
+ """Parse the optional top-level ``disputes`` list: a gateway whose
393
+ reported amount is not believed for one publisher's models from a date,
394
+ with the note a product can show and the page that shows the figure is
395
+ wrong. An absent key yields no disputes."""
396
+ if not isinstance(document, dict):
397
+ raise CatalogError("prices document must be a mapping")
398
+ entries = document.get("disputes")
399
+ if entries is None:
400
+ return []
401
+ if not isinstance(entries, list):
402
+ raise CatalogError("prices document disputes must be a list")
403
+ disputes: list[Dispute] = []
404
+ for entry in entries:
405
+ if not isinstance(entry, dict):
406
+ raise CatalogError("dispute entry must be a mapping")
407
+ gateway = str(entry.get("gateway") or "").strip().lower()
408
+ publisher = str(entry.get("publisher") or "").strip().lower()
409
+ if not gateway or not publisher:
410
+ raise CatalogError("dispute entry needs gateway/publisher")
411
+ label = f"{gateway}/{publisher}"
412
+ note = str(entry.get("note") or "").strip()
413
+ if not note:
414
+ raise CatalogError(f"{label}: dispute entry needs a note")
415
+ if not str(entry.get("source_url") or "").strip():
416
+ raise CatalogError(f"{label}: dispute entry needs source_url")
417
+ disputes.append(
418
+ Dispute(
419
+ gateway=gateway,
420
+ publisher=publisher,
421
+ since=_date(entry.get("since"), field="since", model=label),
422
+ note=note,
423
+ source_url=str(entry["source_url"]).strip(),
424
+ )
425
+ )
426
+ return disputes
427
+
428
+
388
429
  def load_catalog(
389
430
  path: str | Path | None = None, *, region: str = "global"
390
431
  ) -> LoadedCatalog:
@@ -402,4 +443,5 @@ def load_catalog(
402
443
  snapshot=CatalogSnapshot(aliases, prices, region=region),
403
444
  canonical_ids=_canonical_index(document),
404
445
  retrieval=RetrievalCatalog(parse_retrieval(document)),
446
+ disputes=tuple(parse_disputes(document)),
405
447
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph-core
3
- Version: 0.2.53
3
+ Version: 0.2.54
4
4
  Summary: Reusable MeterGraph catalog and deterministic billing engine
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.10