Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions app/model_costs.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,31 @@ class ModelCost(NamedTuple):


# Model family costs in USD per million tokens
# Updated: 2025-10-15 based on https://claude.com/pricing
# Updated: 2026-05-06 based on https://platform.claude.com/docs/en/about-claude/pricing
#
# Notes:
# - Opus 4.5+ moved to a new lower price tier ($5/$25) and now bundle the 1M
# context window at flat rates. Opus 4.1 and earlier remain at the old
# $15/$75 pricing until retirement.
# - Sonnet 4.x stays at $3/$15 with 1M context included on 4.6.
# - Retired models (Opus 3, Haiku 3, Sonnet 3.5, Sonnet 3.7) have been removed
# from the lookup; the regex normaliser still parses their names so callers
# get a clear "not found" error rather than a parse failure.
MODEL_FAMILIES = {
"opus-4.6": ModelCost(15.0, 75.0),
"opus-4.5": ModelCost(15.0, 75.0),
# Opus 4.5+: new pricing tier, 1M context bundled
"opus-4.7": ModelCost(5.0, 25.0),
"opus-4.6": ModelCost(5.0, 25.0),
"opus-4.5": ModelCost(5.0, 25.0),
# Opus 4.0/4.1: legacy pricing, 200K context
"opus-4.1": ModelCost(15.0, 75.0),
"opus-4": ModelCost(15.0, 75.0),
# Sonnet 4.x
"sonnet-4.6": ModelCost(3.0, 15.0),
"sonnet-4.5": ModelCost(3.0, 15.0),
"sonnet-4": ModelCost(3.0, 15.0),
"sonnet-3.7": ModelCost(3.0, 15.0),
"sonnet-3.5": ModelCost(3.0, 15.0),
# Haiku
"haiku-4.5": ModelCost(1.0, 5.0),
"haiku-3.5": ModelCost(0.80, 4.0),
"opus-3": ModelCost(15.0, 75.0),
"haiku-3": ModelCost(0.25, 1.25),
}


Expand Down
62 changes: 38 additions & 24 deletions app/test_model_costs.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,42 +71,51 @@ def test_haiku_3_5_cost(self):
assert input_cost == 0.80 / 1_000_000 # $0.80 per million
assert output_cost == 4.0 / 1_000_000 # $4.00 per million

def test_sonnet_3_5_cost(self):
"""Test Claude 3.5 Sonnet costs."""
input_cost, output_cost = get_model_cost("claude-3-5-sonnet-20241022")
assert input_cost == 3.0 / 1_000_000 # $3 per million
assert output_cost == 15.0 / 1_000_000 # $15 per million

def test_opus_3_cost(self):
"""Test Claude 3 Opus costs."""
input_cost, output_cost = get_model_cost("claude-3-opus-20240229")
assert input_cost == 15.0 / 1_000_000 # $15 per million
assert output_cost == 75.0 / 1_000_000 # $75 per million

def test_haiku_3_cost(self):
"""Test Claude 3 Haiku costs."""
input_cost, output_cost = get_model_cost("claude-3-haiku-20240307")
assert input_cost == 0.25 / 1_000_000 # $0.25 per million
assert output_cost == 1.25 / 1_000_000 # $1.25 per million

def test_sonnet_4_cost(self):
"""Test Claude 4 Sonnet costs."""
input_cost, output_cost = get_model_cost("claude-sonnet-4-0")
assert input_cost == 3.0 / 1_000_000 # $3 per million
assert output_cost == 15.0 / 1_000_000 # $15 per million

def test_sonnet_4_6_cost(self):
"""Test Claude 4.6 Sonnet costs."""
input_cost, output_cost = get_model_cost("claude-sonnet-4-6")
assert input_cost == 3.0 / 1_000_000 # $3 per million
assert output_cost == 15.0 / 1_000_000 # $15 per million

def test_opus_4_cost(self):
"""Test Claude 4 Opus costs."""
"""Test Claude 4 Opus costs (legacy $15/$75 pricing)."""
input_cost, output_cost = get_model_cost("claude-opus-4-0")
assert input_cost == 15.0 / 1_000_000 # $15 per million
assert output_cost == 75.0 / 1_000_000 # $75 per million

def test_opus_4_1_cost(self):
"""Test Claude 4.1 Opus costs."""
"""Test Claude 4.1 Opus costs (legacy $15/$75 pricing)."""
input_cost, output_cost = get_model_cost("claude-opus-4-1-20250805")
assert input_cost == 15.0 / 1_000_000 # $15 per million
assert output_cost == 75.0 / 1_000_000 # $75 per million

def test_opus_4_6_cost(self):
"""Test Claude 4.6 Opus costs (new $5/$25 pricing tier)."""
input_cost, output_cost = get_model_cost("claude-opus-4-6")
assert input_cost == 5.0 / 1_000_000 # $5 per million
assert output_cost == 25.0 / 1_000_000 # $25 per million

def test_opus_4_7_cost(self):
"""Test Claude 4.7 Opus costs (new $5/$25 pricing tier)."""
input_cost, output_cost = get_model_cost("claude-opus-4-7")
assert input_cost == 5.0 / 1_000_000 # $5 per million
assert output_cost == 25.0 / 1_000_000 # $25 per million

def test_retired_model_lookup(self):
"""Retired model families parse but are no longer in the price table."""
with pytest.raises(ValueError, match=r"Model family 'opus-3' not found"):
get_model_cost("claude-3-opus-20240229")
with pytest.raises(ValueError, match=r"Model family 'haiku-3' not found"):
get_model_cost("claude-3-haiku-20240307")
with pytest.raises(ValueError, match=r"Model family 'sonnet-3.5' not found"):
get_model_cost("claude-3-5-sonnet-20241022")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This retired-model test omits Sonnet 3.7 even though the pricing table removes it and the normalizer still parses it. Add an assertion that looking up a Sonnet 3.7 model name raises the same “not found” error to fully cover the retired-family behavior.


def test_unknown_model(self):
"""Test that unknown models raise ValueError."""
with pytest.raises(ValueError, match=r"Model family .* not found"):
Expand All @@ -127,11 +136,16 @@ def test_returns_dict_format(self):

def test_different_models(self):
"""Test various model cost lookups."""
# Test a few different models
# Opus 4.0 stays on legacy $15/$75 pricing
opus_info = get_model_cost_info("claude-opus-4")
assert opus_info["per_input_token"] == 15.0 / 1_000_000
assert opus_info["per_output_token"] == 75.0 / 1_000_000

haiku3_info = get_model_cost_info("claude-3-haiku-20240307")
assert haiku3_info["per_input_token"] == 0.25 / 1_000_000
assert haiku3_info["per_output_token"] == 1.25 / 1_000_000
# Opus 4.5+ moved to the cheaper $5/$25 tier
opus_new_info = get_model_cost_info("claude-opus-4-7")
assert opus_new_info["per_input_token"] == 5.0 / 1_000_000
assert opus_new_info["per_output_token"] == 25.0 / 1_000_000

haiku_info = get_model_cost_info("claude-haiku-4-5")
assert haiku_info["per_input_token"] == 1.0 / 1_000_000
assert haiku_info["per_output_token"] == 5.0 / 1_000_000
30 changes: 15 additions & 15 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,27 @@ description = "Explain Compiler Explorer output using AI"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"anthropic>=0.67.0",
"aws-embedded-metrics>=3.3.0",
"boto3>=1.40.30",
"click>=8.2.1",
"fastapi>=0.116.1",
"anthropic>=0.100.0",
"aws-embedded-metrics>=3.5.0",
"boto3>=1.43.4",
"click>=8.3.3",
"fastapi>=0.136.1",
"humanfriendly>=10.0",
"mangum>=0.19.0",
"pydantic-settings>=2.10.1",
"python-dotenv>=1.1.1",
"requests>=2.32.5",
"ruamel.yaml>=0.18.15",
"mangum>=0.21.0",
"pydantic-settings>=2.14.0",
"python-dotenv>=1.2.2",
"requests>=2.33.1",
"ruamel.yaml>=0.19.1",
]

[dependency-groups]
dev = [
# In development mode, include the FastAPI development server.
"fastapi[standard]>=0.116.1",
"pre-commit>=4.3.0",
"pytest>=8.4.2",
"pytest-asyncio>=1.2.0",
"ruff>=0.13.0",
"fastapi[standard]>=0.136.1",
"pre-commit>=4.6.0",
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
"ruff>=0.15.12",
]

[tool.ruff]
Expand Down
Loading
Loading