4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
276 lines
12 KiB
Python
276 lines
12 KiB
Python
"""Tests for the model pricing table and $/hr computation (#2023)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
|
||
import pytest
|
||
|
||
from tools.system.fleet_monitoring.meters import TokenUsage
|
||
from tools.system.fleet_monitoring.pricing import (
|
||
MODEL_PRICES,
|
||
PriceOverride,
|
||
normalize_model_name,
|
||
usd_for_usage,
|
||
usd_per_hour,
|
||
usd_per_hour_for_usage,
|
||
usd_per_token_blended,
|
||
)
|
||
|
||
|
||
class TestUsdPerTokenBlended:
|
||
def test_known_claude_model(self) -> None:
|
||
# claude-sonnet-4-5: 3 USD/M input, 15 USD/M output, 70/30 blend.
|
||
# Expected: 0.7 * 3e-6 + 0.3 * 15e-6 = 2.1e-6 + 4.5e-6 = 6.6e-6.
|
||
rate = usd_per_token_blended("claude-sonnet-4-5")
|
||
assert rate is not None
|
||
assert rate == pytest.approx(6.6e-6, rel=1e-9)
|
||
|
||
def test_known_gpt5_model(self) -> None:
|
||
# gpt-5: 1.25 USD/M input, 10 USD/M output, 70/30 blend.
|
||
# Expected: 0.7 * 1.25e-6 + 0.3 * 10e-6 = 0.875e-6 + 3e-6 = 3.875e-6.
|
||
rate = usd_per_token_blended("gpt-5")
|
||
assert rate is not None
|
||
assert rate == pytest.approx(3.875e-6, rel=1e-9)
|
||
|
||
def test_unknown_model_returns_none(self) -> None:
|
||
# The dashboard renders ``-`` for unknown models rather than
|
||
# inventing a price. The contract is "never invent".
|
||
assert usd_per_token_blended("claude-galaxy-9000") is None
|
||
|
||
def test_none_model_returns_none(self) -> None:
|
||
# Meters emit ``None`` when no chunk carries a model hint;
|
||
# the pricing module must accept that without raising.
|
||
assert usd_per_token_blended(None) is None
|
||
|
||
def test_dated_suffix_falls_back_to_family(self) -> None:
|
||
# ``claude-sonnet-4-5-20251015`` (date-suffixed id from a
|
||
# future release that we have not added explicitly to the
|
||
# table) should still resolve via the family prefix.
|
||
rate = usd_per_token_blended("claude-sonnet-4-5-20251015")
|
||
assert rate == usd_per_token_blended("claude-sonnet-4-5")
|
||
|
||
def test_family_prefix_does_not_collide_with_shorter_family(self) -> None:
|
||
# ``claude-opus-4-1`` must NOT match the ``claude-opus-4``
|
||
# family before the longer ``claude-opus-4-1`` entry — the
|
||
# opus-4-1 rate would otherwise be misread as opus-4.
|
||
opus_4 = usd_per_token_blended("claude-opus-4")
|
||
opus_4_1 = usd_per_token_blended("claude-opus-4-1")
|
||
# Both happen to be the same today; the regression we guard
|
||
# against is a future opus-4-1 with different rates getting
|
||
# silently shadowed by a generic ``claude-opus-4`` rule.
|
||
assert opus_4 is not None
|
||
assert opus_4_1 is not None
|
||
|
||
|
||
class TestGpt56Family:
|
||
"""GPT-5.6 Sol / Terra / Luna (#3931).
|
||
|
||
Rates from https://developers.openai.com/api/docs/pricing (GA 2026-07-09),
|
||
per 1M tokens: sol 5/30, terra 2.50/15, luna 1/6. Cached input is 90% off.
|
||
"""
|
||
|
||
@pytest.mark.parametrize(
|
||
("model", "input_usd_per_million", "output_usd_per_million"),
|
||
[
|
||
("gpt-5.6-sol", 5.00, 30.00),
|
||
("gpt-5.6-terra", 2.50, 15.00),
|
||
("gpt-5.6-luna", 1.00, 6.00),
|
||
],
|
||
)
|
||
def test_published_rates(
|
||
self, model: str, input_usd_per_million: float, output_usd_per_million: float
|
||
) -> None:
|
||
price = MODEL_PRICES[model]
|
||
assert price.usd_per_input_token == pytest.approx(input_usd_per_million / 1e6)
|
||
assert price.usd_per_output_token == pytest.approx(output_usd_per_million / 1e6)
|
||
# Cached input reads are 90% off uncached input.
|
||
assert price.usd_per_cache_read_input_token == pytest.approx(
|
||
input_usd_per_million / 1e6 * 0.10
|
||
)
|
||
|
||
def test_sol_is_not_priced_as_base_gpt5(self) -> None:
|
||
# The regression this whole entry exists to prevent: before #3931,
|
||
# ``gpt-5.6-sol`` matched the ``gpt-5`` family catch-all and was
|
||
# silently billed at the base gpt-5 rate (1.25/10) — a 4x
|
||
# under-report on the flagship, with no error and no ``-`` cell.
|
||
assert usd_per_token_blended("gpt-5.6-sol") != usd_per_token_blended("gpt-5")
|
||
|
||
def test_tiers_are_distinctly_priced(self) -> None:
|
||
# Each tier must resolve to its own rate rather than collapsing
|
||
# onto a sibling via the shared ``gpt-5.6`` prefix.
|
||
sol = usd_per_token_blended("gpt-5.6-sol")
|
||
terra = usd_per_token_blended("gpt-5.6-terra")
|
||
luna = usd_per_token_blended("gpt-5.6-luna")
|
||
assert sol is not None and terra is not None and luna is not None
|
||
assert sol > terra > luna
|
||
|
||
def test_bare_alias_resolves_to_sol(self) -> None:
|
||
# OpenAI routes the bare ``gpt-5.6`` alias to Sol server-side, so
|
||
# billing it as anything else would mis-report real spend.
|
||
assert usd_per_token_blended("gpt-5.6") == usd_per_token_blended("gpt-5.6-sol")
|
||
assert normalize_model_name("gpt-5.6") == "gpt-5.6-sol"
|
||
|
||
def test_unknown_tier_suffix_does_not_borrow_sol_rate(self) -> None:
|
||
# ``gpt-5.6-terra-preview`` must land on terra, not on Sol via the
|
||
# shorter ``gpt-5.6`` alias prefix. This is what the per-tier
|
||
# family rows buy us over a single alias row.
|
||
assert usd_per_token_blended("gpt-5.6-terra-preview") == usd_per_token_blended(
|
||
"gpt-5.6-terra"
|
||
)
|
||
|
||
def test_openai_provider_prefix_resolves(self) -> None:
|
||
# OpenRouter-style ids (``openai/gpt-5.6-sol``) are stripped to the
|
||
# bare model before lookup.
|
||
assert usd_per_token_blended("openai/gpt-5.6-sol") == usd_per_token_blended("gpt-5.6-sol")
|
||
|
||
|
||
class TestUsdPerHour:
|
||
def test_zero_tokens_per_min_is_zero_cost(self) -> None:
|
||
# An idle agent costs $0/hr — the cell shows ``$0.00``, not
|
||
# ``-`` (because the model is known).
|
||
assert usd_per_hour(0.0, "claude-sonnet-4-5") == pytest.approx(0.0)
|
||
|
||
def test_unknown_model_returns_none(self) -> None:
|
||
# Even with real tokens flowing, unknown model means ``-``.
|
||
assert usd_per_hour(1000.0, "claude-galaxy-9000") is None
|
||
|
||
def test_none_model_returns_none(self) -> None:
|
||
assert usd_per_hour(1000.0, None) is None
|
||
|
||
def test_formula_matches_tokens_per_min_times_60_times_rate(self) -> None:
|
||
# The contract is ``tokens_per_min × 60 × rate``. Locking
|
||
# this in so a refactor that switches to ``per second`` or
|
||
# ``per hour`` directly does not change the dashboard's units.
|
||
rate = usd_per_token_blended("claude-sonnet-4-5")
|
||
assert rate is not None
|
||
assert usd_per_hour(500.0, "claude-sonnet-4-5") == pytest.approx(500.0 * 60.0 * rate)
|
||
|
||
def test_realistic_sonnet_session_under_a_dollar_per_hour(self) -> None:
|
||
# Sanity check the numbers come out at a believable scale.
|
||
# 200 tokens/min on Sonnet-4.5 (typical light agentic
|
||
# session) should land under $0.10/hr.
|
||
cost = usd_per_hour(200.0, "claude-sonnet-4-5")
|
||
assert cost is not None
|
||
assert 0.0 < cost < 0.10
|
||
|
||
|
||
class TestUsdForUsage:
|
||
def test_codex_cached_input_uses_discounted_rate(self) -> None:
|
||
usage = TokenUsage(input_tokens=1000, cached_input_tokens=250, output_tokens=100)
|
||
cost = usd_for_usage(usage, "gpt-5-codex")
|
||
expected = (750 * 1.25e-6) + (250 * 0.125e-6) + (100 * 10e-6)
|
||
assert cost == pytest.approx(expected)
|
||
|
||
def test_codex_cached_input_is_clamped_to_input(self) -> None:
|
||
usage = TokenUsage(input_tokens=100, cached_input_tokens=500, output_tokens=0)
|
||
cost = usd_for_usage(usage, "gpt-5-codex")
|
||
assert cost == pytest.approx(100 * 0.125e-6)
|
||
|
||
def test_codex_cached_input_clamp_logs_debug(self, caplog: pytest.LogCaptureFixture) -> None:
|
||
usage = TokenUsage(input_tokens=100, cached_input_tokens=500)
|
||
with caplog.at_level(logging.DEBUG, logger="tools.system.fleet_monitoring.pricing"):
|
||
usd_for_usage(usage, "gpt-5-codex")
|
||
|
||
assert "cached_input_tokens exceeded input_tokens" in caplog.text
|
||
|
||
def test_claude_cache_buckets_use_separate_rates(self) -> None:
|
||
usage = TokenUsage(
|
||
input_tokens=100,
|
||
cache_read_input_tokens=2000,
|
||
cache_creation_input_tokens=500,
|
||
output_tokens=50,
|
||
)
|
||
cost = usd_for_usage(usage, "claude-sonnet-4-5")
|
||
expected = (100 * 3e-6) + (2000 * 0.3e-6) + (500 * 3.75e-6) + (50 * 15e-6)
|
||
assert cost == pytest.approx(expected)
|
||
|
||
def test_hourly_usage_rate_projects_cost_per_minute(self) -> None:
|
||
usage = TokenUsage(input_tokens=1000, cached_input_tokens=250, output_tokens=100)
|
||
per_min_cost = usd_for_usage(usage, "gpt-5-codex")
|
||
assert per_min_cost is not None
|
||
assert usd_per_hour_for_usage(usage, "gpt-5-codex") == pytest.approx(per_min_cost * 60.0)
|
||
|
||
def test_unknown_model_returns_none(self) -> None:
|
||
assert usd_for_usage(TokenUsage(input_tokens=100), "claude-galaxy-9000") is None
|
||
|
||
def test_input_output_overrides_keep_cache_ratio_for_known_model(self) -> None:
|
||
usage = TokenUsage(input_tokens=1000, cached_input_tokens=100, output_tokens=10)
|
||
override = PriceOverride(input_usd_per_million=2.0, output_usd_per_million=20.0)
|
||
cost = usd_for_usage(usage, "gpt-5-codex", override)
|
||
expected = (900 * 2e-6) + (100 * 0.2e-6) + (10 * 20e-6)
|
||
assert cost == pytest.approx(expected)
|
||
|
||
|
||
class TestNormalizeModelName:
|
||
def test_openai_prefix_is_stripped(self) -> None:
|
||
assert normalize_model_name("openai/gpt-5.3-codex") == "gpt-5.3-codex"
|
||
|
||
def test_anthropic_prefix_is_stripped(self) -> None:
|
||
assert normalize_model_name("anthropic/claude-sonnet-4-5") == "claude-sonnet-4-5"
|
||
|
||
def test_bedrock_style_claude_id_resolves_to_base(self) -> None:
|
||
assert (
|
||
normalize_model_name("us.anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||
== "claude-sonnet-4-5-20250929"
|
||
)
|
||
|
||
def test_at_default_variant_resolves_to_base(self) -> None:
|
||
assert normalize_model_name("claude-sonnet-4-5@default") == "claude-sonnet-4-5"
|
||
|
||
|
||
class TestFamilyFallbackCoherence:
|
||
"""Drift guards on ``_FAMILY_FALLBACKS`` ↔ ``MODEL_PRICES``."""
|
||
|
||
def test_family_fallbacks_are_longest_prefix_first(self) -> None:
|
||
from tools.system.fleet_monitoring.pricing import _FAMILY_FALLBACKS
|
||
|
||
lengths = [len(prefix) for prefix, _canonical_id in _FAMILY_FALLBACKS]
|
||
assert lengths == sorted(lengths, reverse=True)
|
||
|
||
def test_every_family_fallback_canonical_id_has_a_price(self) -> None:
|
||
# Without this guard, a typo in ``_FAMILY_FALLBACKS``'s
|
||
# canonical id would silently break the family-prefix path:
|
||
# ``_lookup_price`` would return ``None`` for what looks like
|
||
# a known model and the dashboard would render ``-``.
|
||
from tools.system.fleet_monitoring.pricing import _FAMILY_FALLBACKS
|
||
|
||
for prefix, canonical_id in _FAMILY_FALLBACKS:
|
||
assert canonical_id in MODEL_PRICES, (
|
||
f"family prefix {prefix!r} → canonical {canonical_id!r} not present in MODEL_PRICES"
|
||
)
|
||
|
||
|
||
class TestModelPricesTable:
|
||
def test_claude_code_default_models_have_prices(self) -> None:
|
||
# Defensive regression: the most common claude-code models
|
||
# must each return a price so the dashboard does not silently
|
||
# degrade to ``-`` after a routine model bump.
|
||
for model in ("claude-sonnet-4-5", "claude-opus-4-1", "claude-3-5-sonnet-20241022"):
|
||
assert model in MODEL_PRICES or usd_per_token_blended(model) is not None
|
||
|
||
def test_claude_fable_5_has_a_price(self) -> None:
|
||
# #3621: claude-fable-5 must not render ``-`` on the dashboard.
|
||
# $10/$50 per MTok, cache read $1.00, cache write $12.50.
|
||
usage = TokenUsage(
|
||
input_tokens=100,
|
||
cache_read_input_tokens=2000,
|
||
cache_creation_input_tokens=500,
|
||
output_tokens=50,
|
||
)
|
||
cost = usd_for_usage(usage, "claude-fable-5")
|
||
expected = (100 * 10e-6) + (2000 * 1.0e-6) + (500 * 12.5e-6) + (50 * 50e-6)
|
||
assert cost == pytest.approx(expected)
|
||
|
||
def test_claude_fable_5_dated_suffix_falls_back_to_family(self) -> None:
|
||
# A future date-suffixed release id resolves via the family prefix.
|
||
rate = usd_per_token_blended("claude-fable-5-20260609")
|
||
assert rate is not None
|
||
assert rate == usd_per_token_blended("claude-fable-5")
|
||
|
||
def test_codex_default_models_have_prices(self) -> None:
|
||
# Same guarantee for the codex side. ``gpt-5-codex`` is the
|
||
# default model the Codex CLI configures for paid accounts.
|
||
for model in ("gpt-5", "gpt-5-codex", "gpt-4o"):
|
||
assert model in MODEL_PRICES or usd_per_token_blended(model) is not None
|