From 4b270fd21b29e8a69f68e2bdb0cf28b0943c3d8b Mon Sep 17 00:00:00 2001 From: Manoj Prabhakar Paidiparthy Date: Tue, 30 Jun 2026 15:42:11 -0700 Subject: [PATCH 1/3] feat(provider-anthropic): populate ModelInfo.pricing from _RATES table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_models() now surfaces pricing data that was previously only used internally for cost accounting (compute_cost() in _cost.py). A new _build_pricing(model_id) helper reads the existing _RATES dict and builds a Pricing object (input/output per-million rates, cache-read and cache-write rates, currency), passed through as ModelInfo(..., pricing=_build_pricing(model_id)). Models with no _RATES entry get pricing=None, matching the existing None-means-unknown convention used by compute_cost(). This lets HTTP-bridge applications (e.g. amplifier-app-opencode) read pricing from /v1/models instead of maintaining their own hardcoded pricing table. Fixes: microsoft-amplifier/amplifier-support#295 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../__init__.py | 27 +++++++++++ tests/test_model_pricing.py | 45 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 tests/test_model_pricing.py diff --git a/amplifier_module_provider_anthropic/__init__.py b/amplifier_module_provider_anthropic/__init__.py index 4ae41a9..e9d4703 100644 --- a/amplifier_module_provider_anthropic/__init__.py +++ b/amplifier_module_provider_anthropic/__init__.py @@ -26,6 +26,7 @@ from amplifier_core import ConfigField from amplifier_core import ModelInfo from amplifier_core import ModuleCoordinator +from amplifier_core import Pricing from amplifier_core import ProviderInfo from amplifier_core import TextContent from amplifier_core import ThinkingContent @@ -58,6 +59,7 @@ OverloadedError as AnthropicOverloadedError, ) # Not exported in public API as of SDK v0.96.0 (private import still works) +from ._cost import _RATES from ._cost import compute_cost @@ -412,6 +414,30 @@ async def cleanup(): return cleanup +def _build_pricing(model_id: str) -> Pricing | None: + """Build a Pricing object for a model from the internal _RATES table. + + Returns None if the model has no entry in _RATES. + """ + rates = _RATES.get(model_id) + if rates is None: + return None + return Pricing( + input_per_million=float(rates["input_per_m"]), + output_per_million=float(rates["output_per_m"]), + cache_read_per_million=( + float(rates["cache_read_per_m"]) if "cache_read_per_m" in rates else None + ), + cache_write_per_million=( + float(rates["cache_write_per_m"]) + if "cache_write_per_m" in rates + else None + ), + currency="USD", + as_of=None, + ) + + class AnthropicProvider: """Anthropic API integration. @@ -854,6 +880,7 @@ async def list_models(self) -> list[ModelInfo]: "temperature": 0.7, "max_tokens": caps.max_output_tokens, }, + pricing=_build_pricing(model_id), ) ) diff --git a/tests/test_model_pricing.py b/tests/test_model_pricing.py new file mode 100644 index 0000000..7106a1c --- /dev/null +++ b/tests/test_model_pricing.py @@ -0,0 +1,45 @@ +"""Tests for _build_pricing(): wiring _RATES into ModelInfo.pricing. + +list_models() surfaces pricing previously only used internally for cost +accounting (see _cost.py / compute_cost). _build_pricing() is the pure +function that does the _RATES -> Pricing translation; tested directly here +since no existing test mocks the async client.models.list() call. +""" + +from amplifier_core import Pricing +from amplifier_module_provider_anthropic import _build_pricing +from amplifier_module_provider_anthropic._cost import _RATES + + +class TestBuildPricing: + """_build_pricing() translates _RATES entries into Pricing objects.""" + + def test_known_model_returns_populated_pricing(self): + pricing = _build_pricing("claude-sonnet-4-5-20250929") + + assert pricing is not None + assert isinstance(pricing, Pricing) + assert pricing.input_per_million > 0 + assert pricing.output_per_million > 0 + assert pricing.currency == "USD" + + def test_pricing_matches_rates_table(self): + rates = _RATES["claude-sonnet-4-5-20250929"] + pricing = _build_pricing("claude-sonnet-4-5-20250929") + + assert pricing is not None + assert pricing.input_per_million == float(rates["input_per_m"]) + assert pricing.output_per_million == float(rates["output_per_m"]) + assert pricing.cache_read_per_million == float(rates["cache_read_per_m"]) + assert pricing.cache_write_per_million == float(rates["cache_write_per_m"]) + + def test_unknown_model_returns_none(self): + assert _build_pricing("claude-mystery-9-9") is None + + def test_all_rate_table_entries_build_valid_pricing(self): + """Every model in _RATES should produce a valid Pricing object.""" + for model_id in _RATES: + pricing = _build_pricing(model_id) + assert pricing is not None, f"Expected pricing for {model_id}" + assert pricing.input_per_million > 0 + assert pricing.output_per_million > 0 From 56dfeefaa942ca90409c60edf5b62dd845e93f15 Mon Sep 17 00:00:00 2001 From: Manoj Prabhakar Paidiparthy Date: Tue, 30 Jun 2026 18:13:41 -0700 Subject: [PATCH 2/3] refactor(provider-anthropic): use _find_rates for snapshot-alias normalization and drop as_of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triage feedback on this PR identified two problems in _build_pricing(): 1. Three _RATES entries are asymmetric between bare-alias and dated-snapshot id shapes: claude-sonnet-4-6 and claude-opus-4-8 are alias-only (no dated row), while claude-haiku-3-5 is dated-only (claude-haiku-3-5-20250929, no bare alias). _build_pricing() did a plain _RATES.get(model_id), so whichever shape wasn't in the table produced a silent pricing=None if the Anthropic Models API happened to return the other shape. 2. The companion amplifier-core PR dropped Pricing.as_of entirely (all Pricing fields are now float/str, no dates) and added ISO 4217 currency validation. _build_pricing() was still passing as_of=None, which no longer exists as a constructor parameter. Fix: - Added _find_rates() to _cost.py: tries an exact _RATES match first, then falls back to comparing normalized ids (Anthropic's "-YYYYMMDD" dated snapshot suffix stripped from both the query and each _RATES key) so either shape -- bare alias or dated snapshot -- resolves to the same rate entry regardless of which shape happens to be populated in _RATES. - _build_pricing() now calls _find_rates(model_id) instead of _RATES.get(model_id) directly. - Removed as_of=None from the Pricing(...) construction to match the updated core schema. Per triage guidance, no entries were added to _RATES itself -- the fix is purely the lookup-normalization layer, since Anthropic can introduce new dated snapshots at any time and hand-enumerating them doesn't scale. Tests: added two cases to tests/test_model_pricing.py covering both asymmetry directions (dated snapshot of a bare-alias-only model, and bare alias of a dated-only model), both resolving correctly through the new _find_rates() normalization. No `from datetime import date` import existed in this module prior to this change (verified via grep), so there was nothing to remove on that front. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../__init__.py | 30 ++++++----- amplifier_module_provider_anthropic/_cost.py | 51 +++++++++++++++++++ tests/test_model_pricing.py | 20 ++++++++ 3 files changed, 87 insertions(+), 14 deletions(-) diff --git a/amplifier_module_provider_anthropic/__init__.py b/amplifier_module_provider_anthropic/__init__.py index e9d4703..6fb10d3 100644 --- a/amplifier_module_provider_anthropic/__init__.py +++ b/amplifier_module_provider_anthropic/__init__.py @@ -59,7 +59,7 @@ OverloadedError as AnthropicOverloadedError, ) # Not exported in public API as of SDK v0.96.0 (private import still works) -from ._cost import _RATES +from ._cost import _find_rates from ._cost import compute_cost @@ -417,9 +417,15 @@ async def cleanup(): def _build_pricing(model_id: str) -> Pricing | None: """Build a Pricing object for a model from the internal _RATES table. - Returns None if the model has no entry in _RATES. + Uses _find_rates() (amplifier_module_provider_anthropic/_cost.py) to + tolerate the snapshot-id/alias asymmetry in _RATES -- e.g. an API + response of "claude-sonnet-4-6-20260201" resolves to the bare + "claude-sonnet-4-6" entry, and "claude-haiku-3-5" resolves to the dated + "claude-haiku-3-5-20250929" entry. + + Returns None if the model has no exact or normalized match in _RATES. """ - rates = _RATES.get(model_id) + rates = _find_rates(model_id) if rates is None: return None return Pricing( @@ -429,12 +435,9 @@ def _build_pricing(model_id: str) -> Pricing | None: float(rates["cache_read_per_m"]) if "cache_read_per_m" in rates else None ), cache_write_per_million=( - float(rates["cache_write_per_m"]) - if "cache_write_per_m" in rates - else None + float(rates["cache_write_per_m"]) if "cache_write_per_m" in rates else None ), currency="USD", - as_of=None, ) @@ -2475,9 +2478,7 @@ async def _do_complete(): ) try: async with asyncio.timeout(self.timeout): - async with self.client.messages.stream( - **params - ) as stream: + async with self.client.messages.stream(**params) as stream: async for event in stream: etype = type(event).__name__ idx = getattr(event, "index", None) @@ -2500,7 +2501,10 @@ async def _do_complete(): # Tool-use blocks carry a name so the # streaming overlay's placeholder can # show "Building tool call: ..." - if btype == "tool_use" and block is not None: + if ( + btype == "tool_use" + and block is not None + ): name = getattr(block, "name", None) if name: payload["name"] = name @@ -2532,9 +2536,7 @@ async def _do_complete(): ) partial_emitted = True elif dtype == "thinking_delta": - text = ( - getattr(delta, "thinking", "") or "" - ) + text = getattr(delta, "thinking", "") or "" if text and hooks_available: await self.coordinator.hooks.emit( "llm:stream_block_delta", diff --git a/amplifier_module_provider_anthropic/_cost.py b/amplifier_module_provider_anthropic/_cost.py index 3a8bfa1..283db0f 100644 --- a/amplifier_module_provider_anthropic/_cost.py +++ b/amplifier_module_provider_anthropic/_cost.py @@ -18,6 +18,7 @@ from __future__ import annotations +import re from decimal import Decimal # --------------------------------------------------------------------------- @@ -233,3 +234,53 @@ def compute_cost( cost *= 2 return cost + + +# Anthropic dated-snapshot suffix, e.g. the "-20250929" in +# "claude-sonnet-4-5-20250929". +_DATE_SUFFIX_RE = re.compile(r"-\d{8}$") + + +def _normalize_model_id(model_id: str) -> str: + """Strip a trailing Anthropic dated-snapshot suffix (``-YYYYMMDD``), if present. + + Bare aliases (e.g. ``"claude-sonnet-4-6"``) are returned unchanged. + """ + return _DATE_SUFFIX_RE.sub("", model_id) + + +def _find_rates(model_id: str) -> dict[str, Decimal] | None: + """Look up ``_RATES`` for *model_id*, tolerating snapshot/alias asymmetry. + + ``_RATES`` is not consistently populated with both a bare-alias entry + (e.g. ``"claude-sonnet-4-6"``) and a dated-snapshot entry (e.g. + ``"claude-sonnet-4-6-20260101"``) for every model. A plain + ``_RATES.get(model_id)`` silently misses in two directions: + + - An alias-only entry misses when the API returns a dated snapshot id + (e.g. ``"claude-sonnet-4-6"`` is in ``_RATES`` but the API returns + ``"claude-sonnet-4-6-20260201"``). + - A snapshot-only entry misses when the API returns the bare alias + (e.g. only ``"claude-haiku-3-5-20250929"`` is in ``_RATES`` but the + API returns ``"claude-haiku-3-5"``). + + This function tries an exact match first, then falls back to comparing + *normalized* ids (date suffix stripped from both the query and each + ``_RATES`` key) so either shape resolves to the same rate entry. + + Returns + ------- + dict[str, Decimal] | None + The matching rate dict, or ``None`` if no exact or normalized match + exists. + """ + rates = _RATES.get(model_id) + if rates is not None: + return rates + + normalized_query = _normalize_model_id(model_id) + for key, value in _RATES.items(): + if _normalize_model_id(key) == normalized_query: + return value + + return None diff --git a/tests/test_model_pricing.py b/tests/test_model_pricing.py index 7106a1c..1a6bd97 100644 --- a/tests/test_model_pricing.py +++ b/tests/test_model_pricing.py @@ -43,3 +43,23 @@ def test_all_rate_table_entries_build_valid_pricing(self): assert pricing is not None, f"Expected pricing for {model_id}" assert pricing.input_per_million > 0 assert pricing.output_per_million > 0 + + def test_dated_snapshot_of_bare_alias_resolves_via_find_rates(self): + """claude-sonnet-4-6 is alias-only in _RATES; a fabricated dated + snapshot of it must still resolve, via _find_rates() normalization. + """ + rates = _RATES["claude-sonnet-4-6"] + pricing = _build_pricing("claude-sonnet-4-6-20260201") + + assert pricing is not None + assert pricing.input_per_million == float(rates["input_per_m"]) + + def test_bare_alias_of_dated_only_entry_resolves_via_find_rates(self): + """claude-haiku-3-5 has only a dated entry in _RATES; the bare alias + must still resolve, via _find_rates() normalization. + """ + rates = _RATES["claude-haiku-3-5-20250929"] + pricing = _build_pricing("claude-haiku-3-5") + + assert pricing is not None + assert pricing.input_per_million == float(rates["input_per_m"]) From c64ebeb5c878c6e4f595966d61d4fc92b310b04a Mon Sep 17 00:00:00 2001 From: Manoj Prabhakar Paidiparthy Date: Tue, 30 Jun 2026 18:15:40 -0700 Subject: [PATCH 3/3] test(provider-anthropic): add list_models() wiring integration test + rate-table invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing test_model_pricing.py tests only exercised _build_pricing() in isolation. If someone deleted `pricing=_build_pricing(model_id)` from the ModelInfo(...) construction in list_models(), none of those tests would fail -- the wiring itself was untested. Added TestListModelsPricingWiring, which mocks client.models.list() (via AsyncMock on a MagicMock client, matching the pattern already used in tests/test_close.py) and calls the real list_models() end to end. It asserts that a model present in _RATES ends up with a populated ModelInfo.pricing, and a fabricated model absent from _RATES ends up with pricing=None -- exercising list_models()'s family grouping/filtering plus the pricing wiring together, not _build_pricing() directly. Also, per triage: - Added a module-load assertion (_validate_rates_table() in _cost.py) that every _RATES entry carries all four required rate keys (input_per_m/output_per_m/cache_read_per_m/cache_write_per_m). This makes the invariant explicit and fails fast at import time instead of relying on convention. With the invariant enforced by the loader, the optional-key guards in _build_pricing() (`if "cache_read_per_m" in rates else None`, same for cache_write) were dead code -- every current entry already has all four keys -- so they're removed in favor of direct unconditional access. - Added a comment above the deprecated-models block in _RATES (claude-3-haiku-20240307, claude-sonnet-4-20250514, claude-opus-4-20250514) noting they're retained for historical cost accounting and not expected from list_models() post-retirement. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../__init__.py | 8 +- amplifier_module_provider_anthropic/_cost.py | 28 +++++++ tests/test_model_pricing.py | 73 ++++++++++++++++++- 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/amplifier_module_provider_anthropic/__init__.py b/amplifier_module_provider_anthropic/__init__.py index 6fb10d3..0b6eac5 100644 --- a/amplifier_module_provider_anthropic/__init__.py +++ b/amplifier_module_provider_anthropic/__init__.py @@ -431,12 +431,8 @@ def _build_pricing(model_id: str) -> Pricing | None: return Pricing( input_per_million=float(rates["input_per_m"]), output_per_million=float(rates["output_per_m"]), - cache_read_per_million=( - float(rates["cache_read_per_m"]) if "cache_read_per_m" in rates else None - ), - cache_write_per_million=( - float(rates["cache_write_per_m"]) if "cache_write_per_m" in rates else None - ), + cache_read_per_million=float(rates["cache_read_per_m"]), + cache_write_per_million=float(rates["cache_write_per_m"]), currency="USD", ) diff --git a/amplifier_module_provider_anthropic/_cost.py b/amplifier_module_provider_anthropic/_cost.py index 283db0f..3a0d823 100644 --- a/amplifier_module_provider_anthropic/_cost.py +++ b/amplifier_module_provider_anthropic/_cost.py @@ -139,6 +139,8 @@ }, # ------------------------------------------------------------------ # Deprecated models + # Retained for historical cost accounting; not expected from + # list_models() post-retirement. # ------------------------------------------------------------------ "claude-3-haiku-20240307": { "input_per_m": Decimal("0.25"), @@ -284,3 +286,29 @@ def _find_rates(model_id: str) -> dict[str, Decimal] | None: return value return None + + +# --------------------------------------------------------------------------- +# Module-load invariant: every _RATES entry carries all four rate fields. +# --------------------------------------------------------------------------- +_REQUIRED_RATE_KEYS = frozenset( + {"input_per_m", "output_per_m", "cache_read_per_m", "cache_write_per_m"} +) + + +def _validate_rates_table() -> None: + """Assert every ``_RATES`` entry carries all four required rate keys. + + ``_build_pricing()`` (amplifier_module_provider_anthropic/__init__.py) + relies on every ``_RATES`` entry having all four keys and reads them + unconditionally. Fail fast at import time if a future entry omits one, + rather than letting a partial entry silently produce a ``KeyError`` deep + in ``_build_pricing()`` or reintroducing a defensive-but-dead fallback + path there. + """ + for model_id, rate in _RATES.items(): + missing = _REQUIRED_RATE_KEYS - rate.keys() + assert not missing, f"_RATES[{model_id!r}] is missing required keys: {missing}" + + +_validate_rates_table() diff --git a/tests/test_model_pricing.py b/tests/test_model_pricing.py index 1a6bd97..98a9220 100644 --- a/tests/test_model_pricing.py +++ b/tests/test_model_pricing.py @@ -4,10 +4,19 @@ accounting (see _cost.py / compute_cost). _build_pricing() is the pure function that does the _RATES -> Pricing translation; tested directly here since no existing test mocks the async client.models.list() call. + +TestListModelsPricingWiring (below) additionally mocks client.models.list() +to guard the pricing=_build_pricing(model_id) call site in list_models() +itself -- the unit tests above would all still pass even if that argument +were deleted from the ModelInfo(...) construction. """ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest from amplifier_core import Pricing -from amplifier_module_provider_anthropic import _build_pricing +from amplifier_module_provider_anthropic import AnthropicProvider, _build_pricing from amplifier_module_provider_anthropic._cost import _RATES @@ -63,3 +72,65 @@ def test_bare_alias_of_dated_only_entry_resolves_via_find_rates(self): assert pricing is not None assert pricing.input_per_million == float(rates["input_per_m"]) + + +class _FakeApiModel: + """Minimal stand-in for an Anthropic Models API list entry. + + Only carries the attributes list_models() actually reads (id, + display_name, created_at); other lookups (e.g. capabilities metadata) + resolve to None via getattr-with-default, matching how the real + provider handles models the Models API doesn't annotate. + """ + + def __init__(self, model_id: str, display_name: str) -> None: + self.id = model_id + self.display_name = display_name + self.created_at = "2026-01-01T00:00:00Z" + + +class TestListModelsPricingWiring: + """Integration test: pricing=_build_pricing(model_id) wiring in list_models(). + + Mocks client.models.list() so the assertion exercises the real + list_models() code path (family grouping, filtering, ModelInfo + construction) rather than calling _build_pricing() directly. + """ + + @pytest.mark.asyncio + async def test_list_models_populates_pricing_from_rates(self): + provider = AnthropicProvider(api_key="test-key") + # _client is normally lazily created by the `client` property from a + # real api_key; short-circuit it here with a MagicMock, matching the + # pattern used in tests/test_close.py (SimpleNamespace fails the + # AsyncAnthropic | None attribute type check under pyright). + mock_client = MagicMock() + mock_client.models.list = AsyncMock( + return_value=SimpleNamespace( + data=[ + # In _RATES -> pricing should be populated. + _FakeApiModel("claude-opus-4-8", "Claude Opus 4.8"), + # Not in _RATES (fabricated) -> pricing is None. + # Uses "sonnet" in the id so it lands in a different + # family bucket than claude-opus-4-8 above and isn't + # dropped by filtered=True (default) latest-only family + # filtering. + _FakeApiModel("claude-sonnet-9-9", "Claude Sonnet 9.9 (fake)"), + ] + ) + ) + provider._client = mock_client + + models = await provider.list_models() + by_id = {m.id: m for m in models} + + assert "claude-opus-4-8" in by_id + assert "claude-sonnet-9-9" in by_id + + rates = _RATES["claude-opus-4-8"] + opus_pricing = by_id["claude-opus-4-8"].pricing + assert opus_pricing is not None + assert opus_pricing.input_per_million == float(rates["input_per_m"]) + assert opus_pricing.output_per_million == float(rates["output_per_m"]) + + assert by_id["claude-sonnet-9-9"].pricing is None