""" Unit tests for roboco.billing.pricing — calculate_cost(). Covers: - Each model tier (opus, sonnet, haiku) with all 4 token types. - Unknown model name returns 0.0 without raising. - Empty model string returns 0.0 without raising. - Substring match correctness: longer fragment wins (e.g. 'claude-sonnet-4-6' matches 'claude-sonnet-4' not bare 'sonnet'; 'claude-sonnet-5' matches its own promo entry). """ from __future__ import annotations from datetime import date import pytest from roboco.billing import pricing as p from roboco.billing.pricing import ( CostResult, _is_anthropic_model, calculate_cost, calculate_cost_result, ) # --------------------------------------------------------------------------- # Named constants (ruff PLR2004: magic values in comparisons must be named). # --------------------------------------------------------------------------- # Token counts _M = 1_000_000 # 1 million tokens # Pricing — per-1M USD, matches the _PRICING table in pricing.py _OPUS_INPUT = 5.00 _OPUS_OUTPUT = 25.00 _OPUS_CACHE_READ = 0.50 _OPUS_CACHE_WRITE = 6.25 _SONNET_INPUT = 3.00 _SONNET_OUTPUT = 15.00 _SONNET_CACHE_READ = 0.30 _SONNET_CACHE_WRITE = 0.75 # Sonnet 5 — promotional pricing (33% off Sonnet 4.6, pay 67%) through 2026-08-31 _SONNET5_INPUT = 2.01 _SONNET5_OUTPUT = 10.05 _SONNET5_CACHE_READ = 0.201 _SONNET5_CACHE_WRITE = 0.5025 _HAIKU_INPUT = 1.00 _HAIKU_OUTPUT = 5.00 _HAIKU_CACHE_READ = 0.10 _HAIKU_CACHE_WRITE = 1.25 _HAIKU3_INPUT = 0.25 # claude-haiku-3 is cheaper than haiku-3-5 / haiku-4 # xAI Grok — priced non-Anthropic (per the xAI API) _GROK_INPUT = 1.00 _GROK_OUTPUT = 2.00 _GROK_CACHE_READ = 0.20 _GROK_CACHE_WRITE = 1.00 # Tolerance for floating-point comparisons _TOL = 1e-4 # --------------------------------------------------------------------------- # Opus tier # --------------------------------------------------------------------------- class TestOpusTier: """claude-opus-4 family pricing.""" def test_input_only(self) -> None: cost = calculate_cost("claude-opus-4-5", tokens_input=_M, tokens_output=0) assert abs(cost - _OPUS_INPUT) < _TOL def test_output_only(self) -> None: cost = calculate_cost("claude-opus-4-5", tokens_input=0, tokens_output=_M) assert abs(cost - _OPUS_OUTPUT) < _TOL def test_cache_read_only(self) -> None: cost = calculate_cost( "claude-opus-4-5", tokens_input=0, tokens_output=0, tokens_cache_read=_M, ) assert abs(cost - _OPUS_CACHE_READ) < _TOL def test_cache_write_only(self) -> None: cost = calculate_cost( "claude-opus-4-5", tokens_input=0, tokens_output=0, tokens_cache_write=_M, ) assert abs(cost - _OPUS_CACHE_WRITE) < _TOL def test_all_token_types(self) -> None: cost = calculate_cost( "claude-opus-4-5", tokens_input=_M, tokens_output=_M, tokens_cache_read=_M, tokens_cache_write=_M, ) expected = _OPUS_INPUT + _OPUS_OUTPUT + _OPUS_CACHE_READ + _OPUS_CACHE_WRITE assert abs(cost - expected) < _TOL def test_short_alias(self) -> None: """Bare 'opus' alias resolves to the opus tier.""" cost = calculate_cost("opus", tokens_input=_M, tokens_output=0) assert abs(cost - _OPUS_INPUT) < _TOL def test_returns_float(self) -> None: cost = calculate_cost("claude-opus-4", tokens_input=100, tokens_output=50) assert isinstance(cost, float) # --------------------------------------------------------------------------- # Sonnet tier # --------------------------------------------------------------------------- class TestSonnetTier: """claude-sonnet-4 family pricing (full rate — pre-promo / historical).""" def test_input_only(self) -> None: cost = calculate_cost("claude-sonnet-4-6", tokens_input=_M, tokens_output=0) assert abs(cost - _SONNET_INPUT) < _TOL def test_output_only(self) -> None: cost = calculate_cost("claude-sonnet-4-6", tokens_input=0, tokens_output=_M) assert abs(cost - _SONNET_OUTPUT) < _TOL def test_cache_read_only(self) -> None: cost = calculate_cost( "claude-sonnet-4-6", tokens_input=0, tokens_output=0, tokens_cache_read=_M, ) assert abs(cost - _SONNET_CACHE_READ) < _TOL def test_cache_write_only(self) -> None: cost = calculate_cost( "claude-sonnet-4-6", tokens_input=0, tokens_output=0, tokens_cache_write=_M, ) assert abs(cost - _SONNET_CACHE_WRITE) < _TOL def test_all_token_types(self) -> None: cost = calculate_cost( "claude-sonnet-4-6", tokens_input=_M, tokens_output=_M, tokens_cache_read=_M, tokens_cache_write=_M, ) expected = ( _SONNET_INPUT + _SONNET_OUTPUT + _SONNET_CACHE_READ + _SONNET_CACHE_WRITE ) assert abs(cost - expected) < _TOL def test_short_alias(self) -> None: """Bare 'sonnet' alias resolves to the sonnet tier.""" cost = calculate_cost("sonnet", tokens_input=_M, tokens_output=0) assert abs(cost - _SONNET_INPUT) < _TOL def test_35_variant(self) -> None: """claude-3-5-sonnet resolves to sonnet tier.""" cost = calculate_cost( "claude-3-5-sonnet-20241022", tokens_input=_M, tokens_output=0 ) assert abs(cost - _SONNET_INPUT) < _TOL class TestSonnet5PromoTier: """claude-sonnet-5 promotional pricing — 33% off Sonnet 4.6 (through 2026-08-31). A dedicated table entry wins over the bare 'sonnet' fragment.""" def test_input_only(self) -> None: cost = calculate_cost("claude-sonnet-5", tokens_input=_M, tokens_output=0) assert abs(cost - _SONNET5_INPUT) < _TOL def test_output_only(self) -> None: cost = calculate_cost("claude-sonnet-5", tokens_input=0, tokens_output=_M) assert abs(cost - _SONNET5_OUTPUT) < _TOL def test_cache_read_only(self) -> None: cost = calculate_cost( "claude-sonnet-5", tokens_input=0, tokens_output=0, tokens_cache_read=_M ) assert abs(cost - _SONNET5_CACHE_READ) < _TOL def test_cache_write_only(self) -> None: cost = calculate_cost( "claude-sonnet-5", tokens_input=0, tokens_output=0, tokens_cache_write=_M ) assert abs(cost - _SONNET5_CACHE_WRITE) < _TOL def test_all_token_types(self) -> None: cost = calculate_cost( "claude-sonnet-5", tokens_input=_M, tokens_output=_M, tokens_cache_read=_M, tokens_cache_write=_M, ) expected = ( _SONNET5_INPUT + _SONNET5_OUTPUT + _SONNET5_CACHE_READ + _SONNET5_CACHE_WRITE ) assert abs(cost - expected) < _TOL def test_cheaper_than_sonnet4(self) -> None: """The promo must actually be cheaper than full Sonnet 4.6.""" five = calculate_cost("claude-sonnet-5", tokens_input=_M, tokens_output=_M) four = calculate_cost("claude-sonnet-4-6", tokens_input=_M, tokens_output=_M) assert five < four def test_dated_variant_matches_promo(self) -> None: """A dated 'claude-sonnet-5-*' id still resolves to the promo entry.""" cost = calculate_cost( "claude-sonnet-5-20260930", tokens_input=_M, tokens_output=0 ) assert abs(cost - _SONNET5_INPUT) < _TOL # --------------------------------------------------------------------------- # Haiku tier # --------------------------------------------------------------------------- class TestHaikuTier: """claude-haiku family pricing.""" def test_input_only(self) -> None: cost = calculate_cost("claude-haiku-4-5", tokens_input=_M, tokens_output=0) assert abs(cost - _HAIKU_INPUT) < _TOL def test_output_only(self) -> None: cost = calculate_cost("claude-haiku-4-5", tokens_input=0, tokens_output=_M) assert abs(cost - _HAIKU_OUTPUT) < _TOL def test_cache_read_only(self) -> None: cost = calculate_cost( "claude-haiku-4-5", tokens_input=0, tokens_output=0, tokens_cache_read=_M, ) assert abs(cost - _HAIKU_CACHE_READ) < _TOL def test_cache_write_only(self) -> None: cost = calculate_cost( "claude-haiku-4-5", tokens_input=0, tokens_output=0, tokens_cache_write=_M, ) assert abs(cost - _HAIKU_CACHE_WRITE) < _TOL def test_all_token_types(self) -> None: cost = calculate_cost( "claude-haiku-4-5", tokens_input=_M, tokens_output=_M, tokens_cache_read=_M, tokens_cache_write=_M, ) expected = _HAIKU_INPUT + _HAIKU_OUTPUT + _HAIKU_CACHE_READ + _HAIKU_CACHE_WRITE assert abs(cost - expected) < _TOL def test_short_alias(self) -> None: """Bare 'haiku' alias resolves to the haiku tier.""" cost = calculate_cost("haiku", tokens_input=_M, tokens_output=0) assert abs(cost - _HAIKU_INPUT) < _TOL def test_haiku3_variant(self) -> None: """claude-haiku-3 has lower pricing than haiku-3-5.""" cost = calculate_cost("claude-haiku-3", tokens_input=_M, tokens_output=0) assert abs(cost - _HAIKU3_INPUT) < _TOL # --------------------------------------------------------------------------- # Grok tier (xAI — priced non-Anthropic) # --------------------------------------------------------------------------- class TestGrokTier: """grok-build-0.1 pricing — a non-Anthropic model that IS billed per token.""" def test_input_only(self) -> None: cost = calculate_cost("grok-build-0.1", tokens_input=_M, tokens_output=0) assert abs(cost - _GROK_INPUT) < _TOL def test_output_only(self) -> None: cost = calculate_cost("grok-build-0.1", tokens_input=0, tokens_output=_M) assert abs(cost - _GROK_OUTPUT) < _TOL def test_cached_input(self) -> None: cost = calculate_cost( "grok-build-0.1", tokens_input=0, tokens_output=0, tokens_cache_read=_M ) assert abs(cost - _GROK_CACHE_READ) < _TOL def test_all_token_types(self) -> None: cost = calculate_cost( "grok-build-0.1", tokens_input=_M, tokens_output=_M, tokens_cache_read=_M, tokens_cache_write=_M, ) expected = _GROK_INPUT + _GROK_OUTPUT + _GROK_CACHE_READ + _GROK_CACHE_WRITE assert abs(cost - expected) < _TOL def test_grok_is_not_treated_as_anthropic(self) -> None: """Priced, but not an Anthropic model (no warn-on-unpriced path).""" assert _is_anthropic_model("grok-build-0.1") is False # Still resolves to a real (non-zero) per-token cost. assert calculate_cost("grok-build-0.1", tokens_input=_M, tokens_output=0) > 0.0 # --------------------------------------------------------------------------- # Unknown / edge cases — must return 0.0 without raising # --------------------------------------------------------------------------- class TestUnknownModels: def test_unknown_model_name_returns_zero(self) -> None: cost = calculate_cost("gpt-4o", tokens_input=_M, tokens_output=_M) assert cost == 0.0 def test_empty_string_returns_zero(self) -> None: cost = calculate_cost("", tokens_input=_M, tokens_output=_M) assert cost == 0.0 def test_gibberish_returns_zero(self) -> None: cost = calculate_cost( "totally-unknown-model-xyz", tokens_input=100, tokens_output=100 ) assert cost == 0.0 def test_zero_tokens_with_unknown_model_returns_zero(self) -> None: cost = calculate_cost("unknown", tokens_input=0, tokens_output=0) assert cost == 0.0 def test_does_not_raise_on_unknown_model(self) -> None: """Must not raise regardless of token counts.""" try: calculate_cost( "not-a-claude-model", tokens_input=999_999, tokens_output=999_999, ) except Exception as exc: pytest.fail(f"calculate_cost raised unexpectedly: {exc}") # --------------------------------------------------------------------------- # Substring match correctness # --------------------------------------------------------------------------- # Named constants for the comparison floor/ceiling used in these tests. _ZERO_COST = 0.0 _SONNET_CHEAPER_THAN_OPUS = True # structural assertion in the test below class TestSubstringMatchPriority: def test_claude_sonnet_4_resolves_non_zero(self) -> None: """'claude-sonnet-4-6' must find a match (non-zero cost).""" cost = calculate_cost("claude-sonnet-4-6", tokens_input=_M, tokens_output=0) assert cost > _ZERO_COST def test_haiku3_cheaper_than_haiku4(self) -> None: """claude-haiku-3 is cheaper than claude-haiku-4 — longest-match wins.""" haiku3_cost = calculate_cost("claude-haiku-3", tokens_input=_M, tokens_output=0) haiku4_cost = calculate_cost("claude-haiku-4", tokens_input=_M, tokens_output=0) # haiku-3 ($0.25/1M) < haiku-4 ($1.00/1M) assert haiku3_cost < haiku4_cost def test_non_claude_model_returns_zero(self) -> None: """A random non-Claude model must not match any Claude pricing entry.""" non_opus_cost = calculate_cost("llama-3-70b", tokens_input=_M, tokens_output=0) assert non_opus_cost == _ZERO_COST def test_opus_model_non_zero(self) -> None: """Claude opus model resolves to non-zero cost.""" opus_cost = calculate_cost("claude-opus-4", tokens_input=_M, tokens_output=0) assert opus_cost > _ZERO_COST def test_zero_tokens_returns_zero_for_known_model(self) -> None: """Known model with 0 tokens has 0 cost.""" cost = calculate_cost("claude-opus-4", tokens_input=0, tokens_output=0) assert cost == _ZERO_COST def test_case_insensitive_matching(self) -> None: """Model name matching is case-insensitive.""" lower_cost = calculate_cost( "claude-sonnet-5", tokens_input=1000, tokens_output=1000 ) upper_cost = calculate_cost( "CLAUDE-SONNET-5", tokens_input=1000, tokens_output=1000 ) assert lower_cost == upper_cost assert lower_cost > _ZERO_COST # --------------------------------------------------------------------------- # Provider awareness — non-Anthropic models have no per-token cost # --------------------------------------------------------------------------- class TestProviderAwareness: """Non-Anthropic models (local Ollama / Ollama Cloud) cost 0.0 per token.""" def test_ollama_prefixed_model_returns_zero(self) -> None: """Self-hosted Ollama models (``ollama/`` prefix) have no API cost.""" cost = calculate_cost("ollama/llama3", tokens_input=_M, tokens_output=_M) assert cost == _ZERO_COST def test_ollama_cloud_model_returns_zero(self) -> None: """Ollama Cloud (``:cloud`` tag) is subscription-billed, not per token.""" cost = calculate_cost("glm-5.2:cloud", tokens_input=_M, tokens_output=_M) assert cost == _ZERO_COST def test_bare_local_model_returns_zero(self) -> None: """A bare local embedding model has no per-token cost.""" cost = calculate_cost("qwen3-embedding:0.6b", tokens_input=_M, tokens_output=0) assert cost == _ZERO_COST def test_is_anthropic_model_true_for_claude_names(self) -> None: for name in ("claude-opus-4-6", "claude-fable-5", "opus", "sonnet", "haiku"): assert _is_anthropic_model(name) is True, name def test_is_anthropic_model_false_for_non_claude_names(self) -> None: for name in ("ollama/llama3", "glm-5.2:cloud", "qwen3-embedding", "gpt-4o"): assert _is_anthropic_model(name) is False, name # --------------------------------------------------------------------------- # Structured cost result — distinguish unpriced Anthropic from genuinely-free # (#65). ``calculate_cost`` keeps returning a plain float for existing callers; # ``calculate_cost_result`` returns a ``CostResult`` so a caller can tell real # spend we failed to price ($0, unpriced=True) apart from local inference # ($0, unpriced=False). # --------------------------------------------------------------------------- class TestCostResult: def test_unpriced_anthropic_model_is_flagged(self) -> None: result = calculate_cost_result( "claude-brand-new-unpriced", tokens_input=_M, tokens_output=0 ) assert isinstance(result, CostResult) assert result.cost_usd == 0.0 assert result.unpriced is True assert result.is_anthropic is True def test_free_non_anthropic_model_is_not_unpriced(self) -> None: result = calculate_cost_result( "ollama/qwen3-embedding", tokens_input=_M, tokens_output=0 ) assert result.cost_usd == 0.0 assert result.unpriced is False assert result.is_anthropic is False def test_priced_anthropic_model_is_not_unpriced(self) -> None: result = calculate_cost_result( "claude-sonnet-5", tokens_input=_M, tokens_output=0 ) assert result.cost_usd > 0.0 assert result.unpriced is False def test_priced_non_anthropic_grok_is_not_unpriced(self) -> None: result = calculate_cost_result( "grok-build-0.1", tokens_input=_M, tokens_output=0 ) assert result.cost_usd > 0.0 assert result.unpriced is False assert result.is_anthropic is False def test_calculate_cost_matches_structured_cost_usd(self) -> None: model = "claude-opus-4-6" assert ( calculate_cost(model, tokens_input=_M, tokens_output=_M) == calculate_cost_result(model, tokens_input=_M, tokens_output=_M).cost_usd ) def test_empty_model_is_not_unpriced(self) -> None: # An empty model name is a caller bug, not an unpriced-Anthropic miss. result = calculate_cost_result("", tokens_input=_M, tokens_output=0) assert result.cost_usd == 0.0 assert result.unpriced is False # --------------------------------------------------------------------------- # Sonnet-5 promo date gate — promo on/ before 2026-08-31, list rate after. # --------------------------------------------------------------------------- def test_sonnet5_promo_active_on_or_before_2026_08_31( monkeypatch: pytest.MonkeyPatch, ) -> None: class _D: @staticmethod def today() -> date: return date(2026, 8, 31) monkeypatch.setattr(p, "date", _D) assert p._lookup_prices("claude-sonnet-5") == ( _SONNET5_INPUT, _SONNET5_OUTPUT, _SONNET5_CACHE_READ, _SONNET5_CACHE_WRITE, ) def test_sonnet5_reverts_to_list_rate_after_2026_08_31( monkeypatch: pytest.MonkeyPatch, ) -> None: class _D: @staticmethod def today() -> date: return date(2026, 9, 1) monkeypatch.setattr(p, "date", _D) assert p._lookup_prices("claude-sonnet-5") == ( _SONNET_INPUT, _SONNET_OUTPUT, _SONNET_CACHE_READ, _SONNET_CACHE_WRITE, )