metergraph-core 0.2.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph-core
3
- Version: 0.2.0
3
+ Version: 0.2.2
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.0"
3
+ version = "0.2.2"
4
4
  description = "Reusable MeterGraph catalog and deterministic billing engine"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -19,6 +19,12 @@ from .loader import (
19
19
  LoadedCatalog,
20
20
  load_catalog,
21
21
  parse_catalog,
22
+ parse_retrieval,
23
+ )
24
+ from .retrieval import (
25
+ RetrievalCatalog,
26
+ RetrievalCostResult,
27
+ RetrievalPrice,
22
28
  )
23
29
 
24
30
  __all__ = [
@@ -31,9 +37,13 @@ __all__ = [
31
37
  "GatewayBillingEvidence",
32
38
  "Price",
33
39
  "ResolvedPrice",
40
+ "RetrievalCatalog",
41
+ "RetrievalCostResult",
42
+ "RetrievalPrice",
34
43
  "direct_channel_for_provider",
35
44
  "load_catalog",
36
45
  "normalize_gateway_evidence",
37
46
  "parse_catalog",
47
+ "parse_retrieval",
38
48
  "resolve_billing",
39
49
  ]
@@ -69,6 +69,7 @@ class Alias:
69
69
  canonical_id: str
70
70
  pricing_channel: str
71
71
  rules: Mapping[str, Any]
72
+ publisher: str | None = None
72
73
 
73
74
 
74
75
  @dataclass(frozen=True, slots=True)
@@ -270,6 +271,34 @@ class CatalogSnapshot:
270
271
  return price
271
272
  return None
272
273
 
274
+ def infer_direct_channel(self, model: Any) -> str | None:
275
+ """Return the sole direct billing channel known for ``model``.
276
+
277
+ This supports captures that preserve a model identity but omit the
278
+ source provider. It uses the catalog's canonical model publisher and
279
+ returns ``None`` whenever model identity or direct channel is
280
+ ambiguous.
281
+ """
282
+ if not isinstance(model, str) or not model.strip():
283
+ return None
284
+ model_key = model.strip().lower()
285
+ canonical_ids = {
286
+ alias.canonical_id
287
+ for (known_model, _channel), alias in self._deployment_aliases.items()
288
+ if known_model == model_key
289
+ }
290
+ if len(canonical_ids) != 1:
291
+ return None
292
+ channels: set[str] = set()
293
+ for canonical_id in canonical_ids:
294
+ for alias in self._deployment_aliases.values():
295
+ if alias.canonical_id != canonical_id:
296
+ continue
297
+ channel = direct_channel_for_provider(alias.publisher)
298
+ if channel is not None and (canonical_id, channel) in self._prices:
299
+ channels.add(channel)
300
+ return next(iter(channels)) if len(channels) == 1 else None
301
+
273
302
  def resolve_price(
274
303
  self, *, model: Any, channel: Any, at: datetime
275
304
  ) -> ResolvedPrice | None:
@@ -6,9 +6,9 @@
6
6
  #
7
7
  # Community updates welcome: add an alias or price entry with its provider
8
8
  # source_url and open a PR. CI validates structure and date overlaps.
9
- version: "2026-08-25"
9
+ version: "2026-08-26"
10
10
  currency: USD
11
- pricing_verified_at: "2026-08-25"
11
+ pricing_verified_at: "2026-08-26"
12
12
  models:
13
13
  - canonical_id: openai/gpt-5.6-sol
14
14
  publisher: openai
@@ -975,3 +975,39 @@ models:
975
975
  output_per_mtok: 0.50
976
976
  cache_read_per_mtok: 0.05
977
977
  source_url: https://vercel.com/ai-gateway/models/grok-4.1-fast-reasoning/providers
978
+
979
+ # Retrieval prices: USD per 1,000 counted operations (completed searches, tool
980
+ # calls, executed grounding queries), billed on the provider's direct channel.
981
+ # These sit alongside the model-token catalog above but are priced per unit, not
982
+ # per token. Effective-dated like model prices: keep superseded entries and close
983
+ # them with `effective_to` rather than editing in place. A $0 fee is an explicit
984
+ # priced zero, not the absence of a price.
985
+ retrieval:
986
+ - channel: anthropic-api
987
+ operation: web_search
988
+ region: global
989
+ unit: completed_search
990
+ per_1k_usd: 10.00
991
+ effective_from: "2026-08-26"
992
+ source_url: https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/web-search-tool
993
+ - channel: anthropic-api
994
+ operation: web_fetch
995
+ region: global
996
+ unit: tool_call
997
+ per_1k_usd: 0.00
998
+ effective_from: "2026-08-26"
999
+ source_url: https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/web-fetch-tool
1000
+ - channel: openai-api
1001
+ operation: web_search
1002
+ region: global
1003
+ unit: tool_call
1004
+ per_1k_usd: 10.00
1005
+ effective_from: "2026-08-26"
1006
+ source_url: https://platform.openai.com/docs/pricing
1007
+ - channel: google-api
1008
+ operation: google_search_grounding
1009
+ region: global
1010
+ unit: executed_query
1011
+ per_1k_usd: 14.00
1012
+ effective_from: "2026-08-26"
1013
+ source_url: https://ai.google.dev/gemini-api/docs/pricing
@@ -17,6 +17,7 @@ from .catalog import (
17
17
  _decimal,
18
18
  _normalize_provider,
19
19
  )
20
+ from .retrieval import RetrievalCatalog, RetrievalCostResult, RetrievalPrice
20
21
 
21
22
  DEFAULT_CATALOG_PATH = Path(__file__).parent / "data" / "prices.yaml"
22
23
  _PROVIDER_SYNONYMS = {"bedrock": ("aws-bedrock", "aws")}
@@ -72,6 +73,7 @@ class LoadedCatalog:
72
73
  document: dict[str, Any]
73
74
  snapshot: CatalogSnapshot
74
75
  canonical_ids: Mapping[tuple[str, str], str]
76
+ retrieval: RetrievalCatalog
75
77
 
76
78
  def canonical_model_id(self, provider: str, model_id: str) -> str:
77
79
  """Resolve a captured provider-native model id to its canonical id,
@@ -84,6 +86,10 @@ class LoadedCatalog:
84
86
  (_normalize_provider(provider), model_id), model_id
85
87
  )
86
88
 
89
+ def infer_direct_channel(self, model: Any) -> str | None:
90
+ """Infer a direct pricing channel only when the catalog is unambiguous."""
91
+ return self.snapshot.infer_direct_channel(model)
92
+
87
93
  def price(
88
94
  self,
89
95
  *,
@@ -109,6 +115,26 @@ class LoadedCatalog:
109
115
  batch=batch,
110
116
  )
111
117
 
118
+ def price_retrieval(
119
+ self,
120
+ *,
121
+ channel: Any,
122
+ operation: Any,
123
+ units: Any,
124
+ at: Any,
125
+ region: Any = "global",
126
+ ) -> RetrievalCostResult:
127
+ """Price a retrieval operation (searches, tool calls, grounded queries)
128
+ by counted unit. See :meth:`RetrievalCatalog.price`. This is kept
129
+ distinct from :meth:`price`, which costs model-token usage."""
130
+ return self.retrieval.price(
131
+ channel=channel,
132
+ operation=operation,
133
+ units=units,
134
+ at=at,
135
+ region=region,
136
+ )
137
+
112
138
 
113
139
  def _date(value: Any, *, field: str, model: str) -> datetime:
114
140
  if value is None:
@@ -150,6 +176,7 @@ def parse_catalog(
150
176
  canonical = str(entry.get("canonical_id") or "")
151
177
  if not canonical:
152
178
  raise CatalogError("model entry missing canonical_id")
179
+ publisher = str(entry.get("publisher") or "").strip().lower() or None
153
180
  for alias in entry.get("aliases") or []:
154
181
  provider = str(alias.get("provider") or "").lower()
155
182
  name = str(alias.get("alias") or "").lower()
@@ -167,6 +194,7 @@ def parse_catalog(
167
194
  canonical_id=canonical,
168
195
  pricing_channel=channel,
169
196
  rules=_freeze(alias.get("rules") or {}),
197
+ publisher=publisher,
170
198
  )
171
199
  seen_windows: list[tuple[str, str, datetime, datetime | None]] = []
172
200
  for price in entry.get("prices") or []:
@@ -222,6 +250,79 @@ def parse_catalog(
222
250
  return version, aliases, prices
223
251
 
224
252
 
253
+ def parse_retrieval(document: Any) -> list[RetrievalPrice]:
254
+ """Parse the optional top-level ``retrieval`` list into effective-dated
255
+ per-1,000-unit prices, applying the same effective-date validation as the
256
+ token catalog (ISO dates, ``effective_to`` after ``effective_from``, no
257
+ overlapping windows per channel/operation/region). A per-unit rate must be
258
+ present and non-negative. An absent ``retrieval`` key yields no prices."""
259
+ if not isinstance(document, dict):
260
+ raise CatalogError("prices document must be a mapping")
261
+ entries = document.get("retrieval")
262
+ if entries is None:
263
+ return []
264
+ if not isinstance(entries, list):
265
+ raise CatalogError("prices document retrieval must be a list")
266
+ prices: list[RetrievalPrice] = []
267
+ seen_windows: list[tuple[str, str, str, datetime, datetime | None]] = []
268
+ for entry in entries:
269
+ channel = str(entry.get("channel") or "").strip().lower()
270
+ operation = str(entry.get("operation") or "").strip().lower()
271
+ region = str(entry.get("region") or "global")
272
+ unit = str(entry.get("unit") or "").strip()
273
+ if not channel or not operation:
274
+ raise CatalogError("retrieval entry needs channel/operation")
275
+ label = f"{channel}/{operation}"
276
+ if not unit:
277
+ raise CatalogError(f"{label}: retrieval entry needs a unit")
278
+ if not str(entry.get("source_url") or "").strip():
279
+ raise CatalogError(f"{label}: retrieval entry needs source_url")
280
+ per_1k_usd = _decimal(entry.get("per_1k_usd"))
281
+ if per_1k_usd is None or per_1k_usd < 0:
282
+ raise CatalogError(
283
+ f"{label}: retrieval entry needs a non-negative per_1k_usd"
284
+ )
285
+ effective_from = _date(
286
+ entry.get("effective_from"), field="effective_from", model=label
287
+ )
288
+ effective_to = (
289
+ _date(entry.get("effective_to"), field="effective_to", model=label)
290
+ if entry.get("effective_to") is not None
291
+ else None
292
+ )
293
+ if effective_to is not None and effective_to <= effective_from:
294
+ raise CatalogError(f"{label}: effective_to before effective_from")
295
+ for other in seen_windows:
296
+ other_channel, other_operation, other_region, other_from, other_to = other
297
+ if (other_channel, other_operation, other_region) != (
298
+ channel,
299
+ operation,
300
+ region,
301
+ ):
302
+ continue
303
+ if (effective_to is None or other_from < effective_to) and (
304
+ other_to is None or effective_from < other_to
305
+ ):
306
+ raise CatalogError(
307
+ f"{label}: overlapping {region} retrieval price windows"
308
+ )
309
+ seen_windows.append((channel, operation, region, effective_from, effective_to))
310
+ prices.append(
311
+ RetrievalPrice(
312
+ id=f"{channel}:{operation}:{region}:{effective_from.date()}",
313
+ channel=channel,
314
+ operation=operation,
315
+ region=region,
316
+ unit=unit,
317
+ per_1k_usd=per_1k_usd,
318
+ effective_from=effective_from,
319
+ effective_to=effective_to,
320
+ source_url=str(entry["source_url"]).strip(),
321
+ )
322
+ )
323
+ return prices
324
+
325
+
225
326
  def load_catalog(
226
327
  path: str | Path | None = None, *, region: str = "global"
227
328
  ) -> LoadedCatalog:
@@ -238,4 +339,5 @@ def load_catalog(
238
339
  document=document,
239
340
  snapshot=CatalogSnapshot(aliases, prices, region=region),
240
341
  canonical_ids=_canonical_index(document),
342
+ retrieval=RetrievalCatalog(parse_retrieval(document)),
241
343
  )
@@ -0,0 +1,141 @@
1
+ """Effective-dated retrieval pricing: searches, tool calls, grounded queries.
2
+
3
+ Retrieval pricing sits alongside -- and deliberately apart from -- the
4
+ model-token catalog. A retrieval operation is billed per counted unit (a
5
+ completed web search, a tool call, an executed grounding query), never per
6
+ token, so it carries its own price shape and its own catalog object.
7
+
8
+ The contract mirrors the token path's explicit priced/partial/unpriced
9
+ semantics: a resolved operation with a valid unit count is ``priced`` (a $0
10
+ fee is a real priced zero), while a missing, non-integer, or negative unit
11
+ count and an unknown operation or channel come back ``unpriced`` with a reason
12
+ -- never silently priced at zero.
13
+ """
14
+
15
+ from dataclasses import dataclass
16
+ from datetime import datetime, timezone
17
+ from decimal import Decimal, ROUND_HALF_UP
18
+ from typing import Any
19
+
20
+ from .catalog import _COST_QUANTUM, _coerce_datetime
21
+
22
+ _THOUSAND = Decimal("1000")
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class RetrievalPrice:
27
+ """An effective-dated per-1,000-unit price for one channel/operation."""
28
+
29
+ id: str
30
+ channel: str
31
+ operation: str
32
+ region: str
33
+ unit: str
34
+ per_1k_usd: Decimal
35
+ effective_from: datetime
36
+ effective_to: datetime | None
37
+ source_url: str
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class RetrievalCostResult:
42
+ """The outcome of pricing a retrieval operation.
43
+
44
+ ``status`` reuses the token path's vocabulary (``priced``/``partial``/
45
+ ``unpriced``); retrieval never produces ``partial`` because a unit count is
46
+ all-or-nothing -- a bad count leaves nothing knowable to price.
47
+ """
48
+
49
+ price_id: str | None
50
+ cost_usd: Decimal | None
51
+ status: str
52
+ reasons: tuple[str, ...] = ()
53
+
54
+
55
+ def _units(value: Any) -> tuple[int | None, str | None]:
56
+ """Coerce a retrieval unit count under a strict-integer contract.
57
+
58
+ Only a real, non-negative ``int`` is a valid count. ``bool`` (``type`` is
59
+ ``bool``, not ``int``), ``float``, strings, and ``Decimal`` are rejected as
60
+ ``invalid_units`` rather than truncated or parsed; ``None`` is
61
+ ``missing_units`` and a negative int is ``negative_units``. Each rejection
62
+ keeps the operation explicitly unpriced instead of turning it into zero.
63
+ """
64
+ if value is None:
65
+ return None, "missing_units"
66
+ if type(value) is not int:
67
+ return None, "invalid_units"
68
+ if value < 0:
69
+ return None, "negative_units"
70
+ return value, None
71
+
72
+
73
+ class RetrievalCatalog:
74
+ """Resolve and cost retrieval operations by ``(channel, operation)``.
75
+
76
+ Channel selection is exact and region resolution falls back
77
+ ``region -> "*" -> "global"``, mirroring the token snapshot: an operation a
78
+ channel does not carry stays unpriced rather than being repriced off another
79
+ channel or region.
80
+ """
81
+
82
+ def __init__(self, prices: list[RetrievalPrice]) -> None:
83
+ self._by_key: dict[tuple[str, str], list[RetrievalPrice]] = {}
84
+ self._channels: set[str] = set()
85
+ for price in prices:
86
+ self._channels.add(price.channel)
87
+ self._by_key.setdefault((price.channel, price.operation), []).append(price)
88
+ for candidates in self._by_key.values():
89
+ candidates.sort(key=lambda price: price.effective_from, reverse=True)
90
+
91
+ def _select(
92
+ self, channel: str, operation: str, region: str, at: datetime
93
+ ) -> RetrievalPrice | None:
94
+ at = at if at.tzinfo else at.replace(tzinfo=timezone.utc)
95
+ candidates = self._by_key.get((channel, operation), [])
96
+ for candidate_region in dict.fromkeys((region, "*", "global")):
97
+ for price in candidates:
98
+ if price.region.lower() != candidate_region:
99
+ continue
100
+ if price.effective_from <= at and (
101
+ price.effective_to is None or at < price.effective_to
102
+ ):
103
+ return price
104
+ return None
105
+
106
+ def price(
107
+ self,
108
+ *,
109
+ channel: Any,
110
+ operation: Any,
111
+ units: Any,
112
+ at: Any,
113
+ region: Any = "global",
114
+ ) -> RetrievalCostResult:
115
+ """Price ``units`` of ``operation`` on ``channel`` at time ``at``.
116
+
117
+ Resolution happens before unit validation: an unknown channel or
118
+ operation is reported as such regardless of the unit count, because an
119
+ operation that cannot be identified cannot be priced at all.
120
+ """
121
+ channel_key = str(channel or "").strip().lower()
122
+ operation_key = str(operation or "").strip().lower()
123
+ region_key = str(region or "global").strip().lower() or "global"
124
+
125
+ if channel_key not in self._channels:
126
+ return RetrievalCostResult(None, None, "unpriced", ("unknown_channel",))
127
+ if (channel_key, operation_key) not in self._by_key:
128
+ return RetrievalCostResult(None, None, "unpriced", ("unknown_operation",))
129
+
130
+ price = self._select(channel_key, operation_key, region_key, _coerce_datetime(at))
131
+ if price is None:
132
+ return RetrievalCostResult(None, None, "unpriced", ("no_effective_price",))
133
+
134
+ count, reason = _units(units)
135
+ if reason is not None:
136
+ return RetrievalCostResult(None, None, "unpriced", (reason,))
137
+
138
+ cost = (Decimal(count) * price.per_1k_usd / _THOUSAND).quantize(
139
+ _COST_QUANTUM, rounding=ROUND_HALF_UP
140
+ )
141
+ return RetrievalCostResult(price.id, cost, "priced", ())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph-core
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Reusable MeterGraph catalog and deterministic billing engine
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -5,6 +5,7 @@ src/metergraph_core/__init__.py
5
5
  src/metergraph_core/billing.py
6
6
  src/metergraph_core/catalog.py
7
7
  src/metergraph_core/loader.py
8
+ src/metergraph_core/retrieval.py
8
9
  src/metergraph_core.egg-info/PKG-INFO
9
10
  src/metergraph_core.egg-info/SOURCES.txt
10
11
  src/metergraph_core.egg-info/dependency_links.txt