Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "metergraph-core"
version = "0.2.23"
version = "0.2.24"
description = "Reusable MeterGraph catalog and deterministic billing engine"
readme = "README.md"
license = "Apache-2.0"
Expand Down
26 changes: 26 additions & 0 deletions core/src/metergraph_core/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

_MILLION = Decimal("1000000")
_COST_QUANTUM = Decimal("0.00000001")
_SEARCH_CONTEXT_SIZES = frozenset({"low", "medium", "high"})
_PROVIDER_ALIASES = {
"amazon-bedrock": "bedrock",
"aws": "bedrock",
Expand Down Expand Up @@ -134,6 +135,13 @@ def _tokens(value: Any) -> int | None:
return result if result >= 0 else None


def _normalize_search_context_size(value: Any) -> str | None:
if not isinstance(value, str):
return None
normalized = value.strip().lower()
return normalized if normalized in _SEARCH_CONTEXT_SIZES else None


def _price_tokens(
price: Price,
rules: Mapping[str, Any],
Expand All @@ -145,6 +153,7 @@ def _price_tokens(
cache_write_5m_tokens: Any,
cache_write_1h_tokens: Any,
batch: bool,
search_context_size: Any,
) -> tuple[Decimal, list[str]]:
"""Cost a token usage against one already-selected price and its merged
rules. Shared by ``cost`` (provider+model entry) and ``price_deployment``
Expand Down Expand Up @@ -244,6 +253,19 @@ def _price_tokens(
if rules.get("uncaptured_fees"):
reasons.append("uncaptured_fees")

search_context_fees = rules.get("search_context_fee_per_request")
if search_context_fees is not None:
normalized_size = _normalize_search_context_size(search_context_size)
if normalized_size is None:
reasons.append("search_context_size_unknown")
else:
fee = _decimal(search_context_fees.get(normalized_size))
if fee is None:
# The catalog carries no fee for this tier: never price it as zero.
reasons.append("search_context_fee_unavailable")
else:
cost += fee

return cost.quantize(_COST_QUANTUM, rounding=ROUND_HALF_UP), reasons


Expand Down Expand Up @@ -357,6 +379,7 @@ def cost(
cache_write_5m_tokens: Any = None,
cache_write_1h_tokens: Any = None,
batch: bool = False,
search_context_size: Any = None,
) -> CostResult:
provider_key = str(provider or "").strip().lower()
provider_key = _PROVIDER_ALIASES.get(provider_key, provider_key)
Expand Down Expand Up @@ -384,6 +407,7 @@ def cost(
cache_write_5m_tokens=cache_write_5m_tokens,
cache_write_1h_tokens=cache_write_1h_tokens,
batch=batch,
search_context_size=search_context_size,
)
return CostResult(
alias.canonical_id,
Expand All @@ -406,6 +430,7 @@ def price_deployment(
cache_write_5m_tokens: Any = None,
cache_write_1h_tokens: Any = None,
batch: bool = False,
search_context_size: Any = None,
) -> CostResult:
"""Price an observed deployment from the identity a caller already has:
a model id, the pricing channel it was served on, the execution time,
Expand All @@ -431,6 +456,7 @@ def price_deployment(
cache_write_5m_tokens=cache_write_5m_tokens,
cache_write_1h_tokens=cache_write_1h_tokens,
batch=batch,
search_context_size=search_context_size,
)
return CostResult(
resolved.canonical_model,
Expand Down
6 changes: 3 additions & 3 deletions core/src/metergraph_core/data/prices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
#
# Community updates welcome: add an alias or price entry with its provider
# source_url and open a PR. CI validates structure and date overlaps.
version: "2026-09-12"
version: "2026-09-16"
currency: USD
pricing_verified_at: "2026-09-12"
pricing_verified_at: "2026-09-16"
models:
- canonical_id: openai/gpt-5.6-sol
publisher: openai
Expand Down Expand Up @@ -1073,7 +1073,7 @@ models:
input_per_mtok: 1.00
output_per_mtok: 1.00
rules:
uncaptured_fees: true
search_context_fee_per_request: {low: 0.005, medium: 0.008, high: 0.012}
source_url: https://docs.perplexity.ai/docs/getting-started/pricing

- canonical_id: openai/gpt-5-mini
Expand Down
33 changes: 33 additions & 0 deletions core/src/metergraph_core/loader.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Parse and load the bundled prices.yaml into a CatalogSnapshot."""

import hashlib
from collections.abc import Mapping as MappingABC
from dataclasses import dataclass
from datetime import date, datetime, timezone
from pathlib import Path
Expand All @@ -27,6 +28,35 @@ class CatalogError(ValueError):
pass


_SEARCH_CONTEXT_SIZES = frozenset({"low", "medium", "high"})


def _validate_price_rules(canonical: str, price: Mapping[str, Any]) -> None:
rules = price.get("rules") or {}
if not isinstance(rules, MappingABC):
return
if "search_context_fee_per_request" not in rules:
return
fees = rules["search_context_fee_per_request"]
if not isinstance(fees, MappingABC) or not fees:
raise CatalogError(
f"{canonical}: search_context_fee_per_request must be a non-empty mapping"
)
invalid_keys = set(fees) - _SEARCH_CONTEXT_SIZES
if invalid_keys:
raise CatalogError(
f"{canonical}: search_context_fee_per_request keys must be a subset of "
"low, medium, high"
)
for size, value in fees.items():
parsed = _decimal(value)
if parsed is None or parsed < 0:
raise CatalogError(
f"{canonical}: search_context_fee_per_request.{size} must be a "
"finite non-negative decimal"
)


def _freeze(value: Any) -> Any:
if isinstance(value, dict):
return MappingProxyType({key: _freeze(item) for key, item in value.items()})
Expand Down Expand Up @@ -103,6 +133,7 @@ def price(
cache_write_5m_tokens: Any = None,
cache_write_1h_tokens: Any = None,
batch: bool = False,
search_context_size: Any = None,
) -> CostResult:
"""Price an observed deployment channel-exactly. See
:meth:`CatalogSnapshot.price_deployment`."""
Expand All @@ -117,6 +148,7 @@ def price(
cache_write_5m_tokens=cache_write_5m_tokens,
cache_write_1h_tokens=cache_write_1h_tokens,
batch=batch,
search_context_size=search_context_size,
)

def price_retrieval(
Expand Down Expand Up @@ -208,6 +240,7 @@ def parse_catalog(
raise CatalogError(f"{canonical}: price entry needs channel")
if not str(price.get("source_url") or "").strip():
raise CatalogError(f"{canonical}: price entry needs source_url")
_validate_price_rules(canonical, price)
effective_from = _date(
price.get("effective_from"), field="effective_from", model=canonical
)
Expand Down
4 changes: 2 additions & 2 deletions core/tests/package/verify_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,13 @@
DIST_DIR = CORE_DIR / "dist"

EXPECTED_NAME = "metergraph-core"
EXPECTED_VERSION = "0.2.23"
EXPECTED_VERSION = "0.2.24"
EXPECTED_REQUIRES_PYTHON = ">=3.10"
GOLDEN_COST = "0.52500000"
GOLDEN_PRICE_ID = "openai/gpt-5.4-mini:openai-api:global:2026-03-17"
GOLDEN_RETRIEVAL_COST = "14.00000000"
GOLDEN_RETRIEVAL_PRICE_ID = "google-api:google_search_grounding:global:2026-08-26"
CATALOG_VERSION = "2026-09-12"
CATALOG_VERSION = "2026-09-16"

REQUIRED_MODULES = (
"__init__.py",
Expand Down
Loading
Loading