diff --git a/core/pyproject.toml b/core/pyproject.toml index 9f05a2c..2a79f6b 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -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" diff --git a/core/src/metergraph_core/catalog.py b/core/src/metergraph_core/catalog.py index b8a8ca9..202e9aa 100644 --- a/core/src/metergraph_core/catalog.py +++ b/core/src/metergraph_core/catalog.py @@ -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", @@ -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], @@ -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`` @@ -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 @@ -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) @@ -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, @@ -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, @@ -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, diff --git a/core/src/metergraph_core/data/prices.yaml b/core/src/metergraph_core/data/prices.yaml index b18b2f6..a5ab906 100644 --- a/core/src/metergraph_core/data/prices.yaml +++ b/core/src/metergraph_core/data/prices.yaml @@ -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 @@ -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 diff --git a/core/src/metergraph_core/loader.py b/core/src/metergraph_core/loader.py index 4a89bb6..0277081 100644 --- a/core/src/metergraph_core/loader.py +++ b/core/src/metergraph_core/loader.py @@ -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 @@ -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()}) @@ -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`.""" @@ -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( @@ -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 ) diff --git a/core/tests/package/verify_artifacts.py b/core/tests/package/verify_artifacts.py index 3e23012..d6f0bff 100644 --- a/core/tests/package/verify_artifacts.py +++ b/core/tests/package/verify_artifacts.py @@ -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", diff --git a/core/tests/test_catalog.py b/core/tests/test_catalog.py index e1622ac..7392cdd 100644 --- a/core/tests/test_catalog.py +++ b/core/tests/test_catalog.py @@ -5,6 +5,7 @@ from metergraph_core import ( CatalogError, + CatalogSnapshot, direct_channel_for_provider, load_catalog, parse_catalog, @@ -24,7 +25,7 @@ def test_prices_yaml_parses(): assert VERSION assert DOC["models"] assert LOADED.currency == "USD" - assert LOADED.pricing_verified_at.isoformat() == "2026-09-12" + assert LOADED.pricing_verified_at.isoformat() == "2026-09-16" def test_resolve_price_by_deployment_identity_and_channel(): @@ -268,18 +269,190 @@ def test_gateway_luna_price_drop_does_not_reprice_history(): def test_partial_price_reports_uncaptured_fees(): + document = { + "version": "test", + "currency": "USD", + "pricing_verified_at": "2026-08-24", + "models": [ + { + "canonical_id": "example/model", + "aliases": [ + {"provider": "example", "alias": "model", "channel": "api"} + ], + "prices": [ + { + "channel": "api", + "effective_from": "2026-08-24", + "input_per_mtok": 1, + "output_per_mtok": 1, + "rules": {"uncaptured_fees": True}, + "source_url": "https://example.test/pricing", + } + ], + } + ], + } + _, aliases, prices = parse_catalog(document) + snapshot = CatalogSnapshot(aliases, prices, region="global") + result = snapshot.cost( + provider="example", + model="model", + at=_at("2026-08-25"), + input_tokens=1_000_000, + output_tokens=1_000_000, + ) + assert result.canonical_model == "example/model" + assert result.cost_usd == Decimal("2.00000000") + assert result.status == "partial" + assert result.reasons == ("uncaptured_fees",) + + +@pytest.mark.parametrize( + ("search_context_size", "fee"), + [("low", "0.005"), ("medium", "0.008"), ("high", "0.012")], +) +def test_sonar_search_context_fee_is_added_per_request(search_context_size, fee): + result = SNAPSHOT.cost( + provider="perplexity-ai", + model="sonar", + at=_at("2026-08-10"), + input_tokens=1_000_000, + output_tokens=1_000_000, + search_context_size=search_context_size, + ) + + assert result.cost_usd == Decimal("2") + Decimal(fee) + assert result.status == "priced" + assert result.reasons == () + + +@pytest.mark.parametrize("search_context_size", [None, "max", 3, ""]) +def test_sonar_without_valid_search_context_size_is_a_lower_bound(search_context_size): result = SNAPSHOT.cost( provider="perplexity-ai", model="sonar", at=_at("2026-08-10"), input_tokens=1_000_000, output_tokens=1_000_000, + search_context_size=search_context_size, ) - assert result.canonical_model == "perplexity/sonar" - assert result.price_id == "perplexity/sonar:perplexity-api:global:2025-04-18" + assert result.cost_usd == Decimal("2.00000000") assert result.status == "partial" - assert result.reasons == ("uncaptured_fees",) + assert result.reasons == ("search_context_size_unknown",) + + +def test_sonar_search_context_size_normalizes_case_and_whitespace(): + result = SNAPSHOT.cost( + provider="perplexity-ai", + model="sonar", + at=_at("2026-08-10"), + input_tokens=1_000_000, + output_tokens=1_000_000, + search_context_size=" Medium ", + ) + + assert result.cost_usd == Decimal("2.00800000") + assert result.status == "priced" + assert result.reasons == () + + +def test_search_context_fee_is_independent_of_batch(): + result = SNAPSHOT.cost( + provider="perplexity-ai", + model="sonar", + at=_at("2026-08-10"), + input_tokens=1_000_000, + output_tokens=1_000_000, + batch=True, + search_context_size="low", + ) + + assert result.cost_usd == Decimal("2.00500000") + + +def test_model_without_search_context_fee_rule_ignores_size(): + result = SNAPSHOT.cost( + provider="openai", + model="gpt-5.4-mini", + at=_at("2026-08-10"), + input_tokens=100_000, + output_tokens=100_000, + search_context_size="high", + ) + + assert result.cost_usd == Decimal("0.52500000") + assert result.status == "priced" + assert result.reasons == () + + +def test_search_context_tier_missing_from_fee_table_is_partial_not_free(): + document = { + "version": "test", + "currency": "USD", + "pricing_verified_at": "2026-09-16", + "models": [ + { + "canonical_id": "example/search", + "aliases": [ + {"provider": "example", "alias": "search", "channel": "api"} + ], + "prices": [ + { + "channel": "api", + "effective_from": "2026-09-01", + "input_per_mtok": 1, + "output_per_mtok": 1, + "rules": {"search_context_fee_per_request": {"low": 0.005}}, + "source_url": "https://example.test/pricing", + } + ], + } + ], + } + _, aliases, prices = parse_catalog(document) + snapshot = CatalogSnapshot(aliases, prices, region="global") + + low = snapshot.cost( + provider="example", + model="search", + at=_at("2026-09-16"), + input_tokens=0, + output_tokens=0, + search_context_size="low", + ) + high = snapshot.cost( + provider="example", + model="search", + at=_at("2026-09-16"), + input_tokens=0, + output_tokens=0, + search_context_size="high", + ) + + assert (low.status, low.cost_usd, low.reasons) == ( + "priced", + Decimal("0.00500000"), + (), + ) + assert high.status == "partial" + assert high.cost_usd == Decimal("0E-8") + assert high.reasons == ("search_context_fee_unavailable",) + + +def test_price_deployment_applies_search_context_fee(): + result = SNAPSHOT.price_deployment( + model="sonar", + channel="perplexity-api", + at=_at("2026-08-10"), + input_tokens=1_000_000, + output_tokens=1_000_000, + search_context_size="high", + ) + + assert result.cost_usd == Decimal("2.01200000") + assert result.status == "priced" + assert result.reasons == () @pytest.mark.parametrize( @@ -398,7 +571,7 @@ def test_design_partner_models_are_priced( "perplexity-api", Decimal("2.00"), "partial", - ("uncaptured_fees",), + ("search_context_size_unknown",), ), ( "openai", diff --git a/core/tests/test_loader.py b/core/tests/test_loader.py index f3c8736..358148c 100644 --- a/core/tests/test_loader.py +++ b/core/tests/test_loader.py @@ -3,7 +3,7 @@ import pytest -from metergraph_core import CatalogError, load_catalog, parse_retrieval +from metergraph_core import CatalogError, load_catalog, parse_catalog, parse_retrieval def _retrieval_entry(**overrides): @@ -40,7 +40,7 @@ def test_parse_retrieval_accepts_a_well_formed_entry(): def test_bundled_catalog_has_identity_and_prices_a_call(): loaded = load_catalog() - assert loaded.version == "2026-09-12" + assert loaded.version == "2026-09-16" assert len(loaded.content_hash) == 64 result = loaded.snapshot.cost( provider="openai", @@ -56,6 +56,49 @@ def test_bundled_catalog_has_identity_and_prices_a_call(): assert result.reasons == () +@pytest.mark.parametrize( + "fees", + [ + {}, + [], + None, + "low", + {"max": 0.005}, + {"low": -0.005}, + {"low": "NaN"}, + {"low": "Infinity"}, + {"low": "not-a-decimal"}, + ], +) +def test_search_context_fee_rule_rejects_invalid_shapes_or_values(fees): + document = { + "version": "test", + "currency": "USD", + "pricing_verified_at": "2026-08-24", + "models": [ + { + "canonical_id": "example/model", + "aliases": [ + {"provider": "example", "alias": "model", "channel": "api"} + ], + "prices": [ + { + "channel": "api", + "effective_from": "2026-08-24", + "input_per_mtok": 1, + "output_per_mtok": 2, + "rules": {"search_context_fee_per_request": fees}, + "source_url": "https://example.test/pricing", + } + ], + } + ], + } + + with pytest.raises(CatalogError, match="search_context_fee_per_request"): + parse_catalog(document) + + @pytest.mark.parametrize( ("model", "canonical", "input_rate", "output_rate"), [ @@ -84,3 +127,35 @@ def test_bundled_catalog_prices_aws_bedrock_analysis_models( assert result.canonical_model == canonical assert result.cost_usd == Decimal(input_rate) / 10 + Decimal(output_rate) assert loaded.canonical_model_id("bedrock", model) == canonical + + +def test_loaded_catalog_price_forwards_search_context_size(): + loaded = load_catalog() + at = datetime(2026, 9, 16, tzinfo=timezone.utc) + + exact = loaded.price( + model="sonar", + channel="perplexity-api", + at=at, + input_tokens=1_000_000, + output_tokens=1_000_000, + search_context_size="medium", + ) + lower_bound = loaded.price( + model="sonar", + channel="perplexity-api", + at=at, + input_tokens=1_000_000, + output_tokens=1_000_000, + ) + + assert (exact.status, exact.cost_usd, exact.reasons) == ( + "priced", + Decimal("2.00800000"), + (), + ) + assert (lower_bound.status, lower_bound.cost_usd, lower_bound.reasons) == ( + "partial", + Decimal("2.00000000"), + ("search_context_size_unknown",), + ) diff --git a/docs/prices.md b/docs/prices.md index fe31f87..7e83cd4 100644 --- a/docs/prices.md +++ b/docs/prices.md @@ -32,6 +32,7 @@ models: - `input_includes_cache_write: true` — provider reports cache-write tokens inside `input_tokens` (Vercel AI Gateway); cache writes are deducted before their cache rate is applied. - `long_context: {threshold, input_multiplier, output_multiplier}` — surcharge above a prompt-size threshold (OpenAI GPT-5.6, Gemini Pro). - `uncaptured_fees: true` — provider charges fees tokens can't express; rows are marked `partial`. +- `search_context_fee_per_request: {low, medium, high}` - per-request fees for the provider's search context size; a valid captured size is required for an exact price. `currency` is required and currently limited to `USD`. `pricing_verified_at` is the ISO date when the catalog was last checked against @@ -43,6 +44,8 @@ historical selection. Every stored call gets a `cost_status`: - `priced` — fully priced from the catalog - `partial` — priced, but something was missing (e.g. cache rate unavailable); the stored cost is a lower bound + - reason `search_context_size_unknown`: the price has `search_context_fee_per_request` but the call carried no valid `search_context_size` (`low`, `medium` or `high`), so only token charges are included. The size is never inferred. + - reason `search_context_fee_unavailable`: the call's size has no fee in that price's table. - `unpriced` — unknown model or no effective price window; the dashboard surfaces these so you know to update the catalog ## Updating diff --git a/server/pyproject.toml b/server/pyproject.toml index 16d7365..5bbcb2d 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "fastapi>=0.110", "uvicorn>=0.29", "psycopg[binary,pool]>=3.1", - "metergraph-core>=0.2,<0.3", + "metergraph-core>=0.2.24,<0.3", ] [project.optional-dependencies] diff --git a/server/src/metergraph_server/ingest.py b/server/src/metergraph_server/ingest.py index ba8a195..81e5cea 100644 --- a/server/src/metergraph_server/ingest.py +++ b/server/src/metergraph_server/ingest.py @@ -194,6 +194,7 @@ def project_row(row: dict, catalog: CatalogSnapshot) -> tuple: cache_read_tokens=row.get("cache_read_tokens"), cache_write_tokens=row.get("cache_write_tokens"), batch=row.get("batch") is True, + search_context_size=row.get("search_context_size"), ) billing = resolve_billing(enrichment, normalize_gateway_evidence(row)) status_code, finish_reason, finish_reason_raw = _status_fields(row) diff --git a/server/tests/package/verify_server_artifacts.py b/server/tests/package/verify_server_artifacts.py index bd09612..57d1e19 100644 --- a/server/tests/package/verify_server_artifacts.py +++ b/server/tests/package/verify_server_artifacts.py @@ -36,11 +36,11 @@ SERVER_DIST = SERVER_DIR / "dist" CORE_DISTRIBUTION = "metergraph-core" -EXPECTED_CORE_SPECIFIERS = {">=0.2", "<0.3"} +EXPECTED_CORE_SPECIFIERS = {">=0.2.24", "<0.3"} GOLDEN_COST = "0.52500000" GOLDEN_PRICE_ID = "openai/gpt-5.4-mini:openai-api:global:2026-03-17" -CATALOG_VERSION = "2026-09-12" +CATALOG_VERSION = "2026-09-16" class VerifyError(AssertionError): diff --git a/server/tests/test_api.py b/server/tests/test_api.py index 02be807..2119491 100644 --- a/server/tests/test_api.py +++ b/server/tests/test_api.py @@ -248,5 +248,5 @@ def test_healthz_reports_catalog_identity(client): response = client.get("/healthz") assert response.status_code == 200 assert response.json()["ok"] is True - assert response.json()["catalog_version"] == "2026-09-12" + assert response.json()["catalog_version"] == "2026-09-16" assert len(response.json()["catalog_hash"]) == 64 diff --git a/server/tests/test_catalog.py b/server/tests/test_catalog.py index e9837cb..4be8e8a 100644 --- a/server/tests/test_catalog.py +++ b/server/tests/test_catalog.py @@ -29,7 +29,7 @@ def test_server_catalog_reexports_core_types(): def test_server_loader_uses_core_bundled_catalog(): version, document, snapshot = prices.load() - assert version == "2026-09-12" + assert version == "2026-09-16" assert document["models"] assert isinstance(snapshot, CoreCatalogSnapshot) diff --git a/server/tests/test_projection.py b/server/tests/test_projection.py index e5e9eac..41188d1 100644 --- a/server/tests/test_projection.py +++ b/server/tests/test_projection.py @@ -53,6 +53,29 @@ def test_projection_maps_and_prices(): assert values["finish_reason_raw"] is None +def test_projection_prices_sonar_search_context_size(): + high = dict(zip(COLUMNS, project_row(_row( + provider="perplexity-ai", + model="sonar", + input_tokens=1_000_000, + output_tokens=1_000_000, + cache_read_tokens=0, + search_context_size="high", + ), SNAPSHOT))) + missing = dict(zip(COLUMNS, project_row(_row( + provider="perplexity-ai", + model="sonar", + input_tokens=1_000_000, + output_tokens=1_000_000, + cache_read_tokens=0, + ), SNAPSHOT))) + + assert high["cost_status"] == "priced" + assert high["cost_usd"] == Decimal("2.01200000") + assert missing["cost_status"] == "partial" + assert missing["cost_usd"] == Decimal("2.00000000") + + def test_projection_separates_explicit_status_and_finish_reason(): values = dict(zip(COLUMNS, project_row(_row( status="tool-calls",