Skip to content

feat(provider-anthropic): populate ModelInfo.pricing from _RATES table - #63

Draft
manojp99 wants to merge 3 commits into
mainfrom
feature/populate-model-pricing
Draft

feat(provider-anthropic): populate ModelInfo.pricing from _RATES table#63
manojp99 wants to merge 3 commits into
mainfrom
feature/populate-model-pricing

Conversation

@manojp99

@manojp99 manojp99 commented Jun 30, 2026

Copy link
Copy Markdown

🔄 Updated per triage feedback (2026-06-30)

Two commits added addressing the "Updated Triage — PR Branch Review" from issue microsoft-amplifier/amplifier-support#295:

refactor(provider-anthropic): use _find_rates for snapshot-alias normalization and drop as_of (56dfeef)

  • Introduced _find_rates(model_id) in _cost.py — two-level lookup: exact match, then strip -YYYYMMDD suffix and re-lookup against normalized keys. Symmetric normalization handles both directions (dated-query vs bare-alias-only entry, and bare-alias-query vs dated-only entry).
  • Refactored _build_pricing() to use _find_rates() — closes the three asymmetry gaps the triage flagged: claude-sonnet-4-6, claude-opus-4-8, claude-haiku-3-5 no longer silently return pricing=None when the API returns the counterpart form.
  • Dropped as_of=None from the Pricing() call site (companion amplifier-core#92 removed the field).

Note: the triage's assertion that _find_rates() already existed in _cost.py:118-137 was inaccurate — it did not exist and was introduced by this commit. The design intent was unambiguous; the function follows the pattern described.

test(provider-anthropic): add list_models() wiring integration test + rate-table invariants (c64ebeb)

Verification results

  • 470/474 tests pass — the 4 failures (test_usage_model_stores_decimal_internally and three in test_tool_repair.py) are pre-existing (confirmed against the branch's prior HEAD 4b270fd with the same locally-installed amplifier-core). They surface a pre-existing mock/test-infra incompatibility unrelated to this work.
  • ruff, pyright clean on touched files.
  • Module-load assertion fires cleanly on the current 16-entry _RATES table.

Pre-existing bug surfaced (out of scope for this PR)

compute_cost() in _cost.py uses the same plain _RATES.get(model) lookup and has the same asymmetric-miss behaviour — a real API call billed against claude-haiku-3-5 (bare) or a claude-sonnet-4-6-*/claude-opus-4-8-* dated snapshot would silently return cost=None. This affects live cost accounting, not just /v1/models display. Left untouched — happy to file a follow-up PR (or issue) to route compute_cost() through _find_rates() as well.


What

AnthropicProvider.list_models() now populates the optional
ModelInfo.pricing field by reading from the existing _RATES dict in
_cost.py and building Pricing objects.

Why

Surfaces the pricing data this module already maintains internally (used
today for per-turn cost accounting) through the public ModelInfo
contract, so /v1/models carries it automatically. This is the second
half of the fix for
microsoft-amplifier/amplifier-support#295.

What's in this PR

  • New module-scope helper _build_pricing(model_id) in
    amplifier_module_provider_anthropic/__init__.py that reads from
    _RATES and returns a Pricing | None (None when the model has no
    rate entry)
  • list_models() now passes pricing=_build_pricing(model_id) to each
    ModelInfo(...) constructor
  • Smoke test in tests/test_model_pricing.py covering the helper

Compatibility

Backwards-compatible. Models without a _RATES entry return pricing=None,
the existing wire behavior. The change is opt-in for consumers — anyone
reading ModelInfo.pricing gets data; anyone ignoring the field is
unaffected.

Dependency

Requires amplifier-core ≥ the version that ships the Pricing
field
(see partner PR in microsoft/amplifier-core). The partner PR
must merge first: microsoft/amplifier-core#92

Validation

Validated end-to-end in a Digital Twin Universe environment with the
matching amplifier-core patch active:

  • Patched _build_pricing and pricing= argument confirmed present in
    the installed __init__.py
  • list_models() returns models with populated Pricing for entries in
    _RATES, and pricing=None for entries without (claude-sonnet-5 in
    the current catalog, by design — _RATES doesn't include it yet)
  • ModelInfo.model_dump(mode="json") emits the expected wire shape with
    a pricing object on entries that have rates
  • Opus values (5.0 / 25.0 USD per million in/out) match the _RATES
    table exactly

Known follow-up

A handful of models in the current list_models() catalog have no
_RATES entry and will surface pricing=None. Filling those in is a
follow-up — keeping it out of this PR so the schema fix is reviewable
in isolation.

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>
Manoj Prabhakar Paidiparthy and others added 2 commits June 30, 2026 18:13
…alization and drop as_of

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>
… rate-table invariants

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant