diff --git a/pyproject.toml b/pyproject.toml index 6481d59..db073e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "alpha-engine-lib" -version = "0.40.0" +version = "0.40.1" description = "Shared utilities for the Alpha Engine modules: preflight, logging, ArcticDB, dates, decision capture, cost telemetry, Anthropic payload chokepoint, artifact freshness, RAG, agent schemas, SSM secrets, Telegram + SNS alerts, EC2 spot resilience, SSM log-capture, SSM dispatcher, Step-Functions execution-state projection, and S3-conditional-PUT writer locks. Full surface documented in README." readme = "README.md" # EC2 still runs Python 3.9 on the always-on micro instance (boto3 drops diff --git a/src/alpha_engine_lib/__init__.py b/src/alpha_engine_lib/__init__.py index 32b0506..6a9887f 100644 --- a/src/alpha_engine_lib/__init__.py +++ b/src/alpha_engine_lib/__init__.py @@ -1,3 +1,3 @@ """alpha-engine-lib — shared utilities for Alpha Engine modules.""" -__version__ = "0.40.0" +__version__ = "0.40.1" diff --git a/src/alpha_engine_lib/cost.py b/src/alpha_engine_lib/cost.py index 46dc2b7..9e4cc1b 100644 --- a/src/alpha_engine_lib/cost.py +++ b/src/alpha_engine_lib/cost.py @@ -58,6 +58,7 @@ from __future__ import annotations +import re from datetime import date, datetime, timezone from importlib import resources from pathlib import Path @@ -68,6 +69,17 @@ from alpha_engine_lib.decision_capture import ModelMetadata +# Anthropic SDK model IDs come in two forms: the family alias +# (e.g. ``claude-haiku-4-5``) and the dated snapshot form +# (e.g. ``claude-haiku-4-5-20251001``). ``Message.model`` returns the dated +# form even when the caller requested the alias, but our pricing YAML is +# keyed on the alias so a new snapshot date doesn't require a card refresh. +_DATED_SNAPSHOT_SUFFIX_RE = re.compile(r"-\d{8}$") + + +def _strip_dated_snapshot_suffix(model_name: str) -> str: + return _DATED_SNAPSHOT_SUFFIX_RE.sub("", model_name) + if TYPE_CHECKING: # Structural Protocol below describes the only attributes we touch on # an Anthropic SDK ``Message`` — kept here so that ``anthropic`` does @@ -172,14 +184,27 @@ def get(self, model_name: str, at: datetime | date) -> PriceCard: component is used for lookup) or a ``date``. The returned card is the one whose ``effective_from`` is the latest among cards ≤ ``at``. - Raises :exc:`PriceCardLookupError` if the model has no cards or - every card's ``effective_from`` is later than ``at``. + Lookup tries the model name as-given first; on miss, retries with + any trailing ``-YYYYMMDD`` snapshot suffix stripped. This lets the + YAML stay keyed on family aliases (``claude-haiku-4-5``) while + accepting the dated form (``claude-haiku-4-5-20251001``) that the + Anthropic SDK returns in ``Message.model``. + + Raises :exc:`PriceCardLookupError` if neither form matches. """ query_date = at.date() if isinstance(at, datetime) else at - candidates = [ - c for c in self.cards - if c.model_name == model_name and c.effective_from <= query_date - ] + + def _candidates_for(name: str) -> list[PriceCard]: + return [ + c for c in self.cards + if c.model_name == name and c.effective_from <= query_date + ] + + candidates = _candidates_for(model_name) + if not candidates: + alias = _strip_dated_snapshot_suffix(model_name) + if alias != model_name: + candidates = _candidates_for(alias) if not candidates: raise PriceCardLookupError( f"No price card for model {model_name!r} active on {query_date}" diff --git a/tests/test_cost.py b/tests/test_cost.py index 8fd0a81..18c3e29 100644 --- a/tests/test_cost.py +++ b/tests/test_cost.py @@ -155,6 +155,47 @@ def test_query_before_first_effective_date_hard_fails(self): self.table.get("haiku", date(2025, 12, 31)) +class TestPriceTableLookupDatedSnapshotSuffix: + """Anthropic SDK returns ``Message.model`` in the dated snapshot form + (e.g. ``claude-haiku-4-5-20251001``) even when the caller requested + the alias; the YAML is keyed on the alias. Lookup must accept both. + """ + + def setup_method(self): + self.table = PriceTable(cards=[ + _card("claude-haiku-4-5", 2026, 1, 1, in_p=1.0), + _card("claude-sonnet-4-6", 2026, 1, 1, in_p=3.0), + ]) + + def test_dated_suffix_falls_back_to_alias(self): + c = self.table.get("claude-haiku-4-5-20251001", date(2026, 5, 28)) + assert c.input_per_1m == 1.0 + + def test_alias_lookup_unchanged(self): + c = self.table.get("claude-haiku-4-5", date(2026, 5, 28)) + assert c.input_per_1m == 1.0 + + def test_exact_dated_match_wins_over_alias_fallback(self): + # If someone adds a dated card explicitly, it takes precedence. + table = PriceTable(cards=[ + _card("claude-haiku-4-5", 2026, 1, 1, in_p=1.0), + _card("claude-haiku-4-5-20251001", 2026, 1, 1, in_p=9.99), + ]) + c = table.get("claude-haiku-4-5-20251001", date(2026, 5, 28)) + assert c.input_per_1m == 9.99 + + def test_unknown_alias_with_dated_suffix_still_hard_fails(self): + with pytest.raises( + PriceCardLookupError, match="claude-foo-9-9-20251001" + ): + self.table.get("claude-foo-9-9-20251001", date(2026, 5, 28)) + + def test_non_dated_suffix_is_not_stripped(self): + # Bare 8-digit substring without leading dash → no normalization. + with pytest.raises(PriceCardLookupError): + self.table.get("claude-haiku-4-5.20251001", date(2026, 5, 28)) + + # ── compute_cost ──────────────────────────────────────────────────────────