unsloth/studio/backend/tests/test_pricing.py
Daniel Han 542d74370f
Studio: pricing follow-up to #5690 (longest-prefix match + chat-style usage keys) (#5722)
* Studio: longest-prefix pricing match + accept chat-style usage keys

Two P1 / High follow-ups from PR 5690 review feedback:

1. Pricing prefix lookup returned the first key it iterated, so
   dated snapshots like ``gpt-5.4-mini-2026-04-23`` collided with
   the shorter ``gpt-5.4`` entry and overbilled by 3x+. Sort the
   table keys longest-first so the most specific entry wins.

2. ``calculate_cost`` only read ``input_tokens`` / ``output_tokens``,
   but Studio's OpenAI-Chat-style usage envelope re-emits
   ``prompt_tokens`` / ``completion_tokens`` (the OpenAI Chat
   Completions vocabulary). Callers handing in the chat-style
   shape silently got a zeroed bill. Accept either pair so the
   calculator works against both raw upstream usage and the
   Studio-translated envelope.

Tests (4 new in test_pricing.py): dated mini/pro snapshots inherit
the right rate; chat-style usage keys price correctly; raw key wins
when both shapes are present.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: dedupe cache buckets when costing chat-style Anthropic usage

When the caller hands in Studio's chat-style envelope (``prompt_tokens``
emitted by ``_build_usage_chunk``) for Anthropic, that value already
folds ``cache_creation_input_tokens`` + ``cache_read_input_tokens`` into
the total. The previous follow-up accepted the chat-style key but then
re-added both cache buckets in ``billable_input_tokens`` and ``input_usd``,
double-counting cache tokens on every Anthropic chat-style call.

Detect which envelope landed (``input_tokens`` present = raw upstream;
absent + ``prompt_tokens`` present = Studio chat-style) and peel the
cache buckets off for Anthropic before the downstream math so both
envelopes produce identical costs.

OpenAI: ``input_tokens`` and Studio's ``prompt_tokens`` both already
include ``cache_read`` and exclude any notional ``cache_creation``, so
the OpenAI path stays a straight passthrough.

Tests (2 new): both envelopes match for Anthropic on a triple
(uncached + cache_creation + cache_read); OpenAI envelopes match on a
cached-tokens fixture.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: prefer raw output_tokens over chat-style completion_tokens

Codex flagged that the previous fallback chain
'usage.get("output_tokens") or usage.get("completion_tokens")'
treats an explicit 0 as missing -- a mixed-envelope payload where
'output_tokens' is 0 but 'completion_tokens' is non-zero (or
stale) bills the wrong amount. Mirror the has_input_tokens
precedence pattern: when the raw key is present we use it even at
0; otherwise fall back to completion_tokens.

* Studio: read OpenAI cached tokens from prompt_tokens_details too

Codex flagged that the chat-style OpenAI envelope Studio re-emits
via _build_usage_chunk surfaces cached prompt tokens under
prompt_tokens_details.cached_tokens, not input_tokens_details. The
OpenAI branch only checked input_tokens_details, so a cache-heavy
chat-style turn billed every cached token at the full input rate
instead of the 0.1x cache_read discount.

Walk both keys when discovering the cached count. New regression
test pins that the two envelopes price identically for a turn with
80k of 100k tokens cached.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: tighten pricing prefix match + clamp corrupt usage

Three follow-ups on the longest-prefix pricing match landed in this PR:

- Prefix match now requires a dash boundary or end-of-string. The
  longest-key sort alone still falsely landed "claude-opus-4-15" on
  the "claude-opus-4-1" row, and "gpt-5.5-prod" on the "gpt-5.5-pro"
  row (a 6x overcharge). Demanding the next character be "-" rules
  out the lookalikes while keeping dated snapshots
  ("gpt-5.4-mini-2026-04-23", "claude-opus-4-7-20260414") landing on
  their canonical row.
- Clamp every token count to >= 0. A corrupted upstream payload
  (negative cached count, off-by-one in a fixture) could previously
  produce a negative bill that masked real spend in the session
  total tooltip.
- Tolerate a non-dict "cache_creation" (e.g. an upstream proxy
  folded the field down to a single int). The current code raised
  AttributeError mid-turn; now it falls back to the 5m-default
  bucket so the rest of the cost calculation still runs.

Adds tests/test_pricing_edge.py with 20 adversarial cases covering
the boundary check, negative / None / zero token values across both
envelopes, cache_read > prompt corruption, the OpenAI long-context
threshold crossover on cache-inflated billable input, malformed
sub-objects, and unknown-provider degradation. Combined suite is
51 tests, all green.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Surface Anthropic cache-read fallback and forward 1h breakdown

Two correctness gaps surfaced on the chat-style usage envelope:

1) Anthropic cache_read fell through to "uncached input" pricing when
   the envelope arrived without the native ``cache_read_input_tokens``
   key (e.g. via a proxy that only emits the mirrored
   ``prompt_tokens_details.cached_tokens`` block). Studio's canonical
   ``_build_usage_chunk`` always sets both so production traffic was
   never affected, but the calculator should accept either as a
   defense-in-depth measure. Add a fallback to read the mirrored
   field when the native one is missing or zero; the native key still
   wins when both are present so the math stays deterministic.

2) ``_build_usage_chunk`` dropped the ``cache_creation`` 5m / 1h
   breakdown. Downstream ``calculate_cost`` then could not apply the
   2x 1h premium and silently fell back to the 5m default,
   underbilling 1h cache writes by 2x on chat-style traffic. Forward
   the breakdown verbatim when the upstream usage carries it.

Tests grow by 4 (20 -> 24): two for the prompt_tokens_details
fallback (with native-precedence pin), one for the chunk shape, one
for the end-to-end pricing parity check at 1h.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Add Anthropic fast_mode pricing multiplier

PR 5715 wires the fast-mode-2026-02-01 beta header + speed:"fast"
field through to Anthropic, but the cost calculator never learnt
about the matching 6x premium documented at
https://platform.claude.com/docs/en/build-with-claude/fast-mode
(Opus 4.7 standard $5/$25 per MTok, fast $30/$150).

This adds:
- ANTHROPIC_FAST_MODE_MULT = 6.0 constant.
- calculate_cost(..., fast_mode=True) applies the 6x to base input
  AND output rates before any cache multipliers (cache mults stack
  on top of fast per Anthropic docs).
- Provider+model gate: silently no-op on every model that is not
  claude-opus-4-6 / claude-opus-4-7 so a stray fast_mode=True on
  Sonnet/Haiku can never over-charge.
- model_priced label tagged "(fast)" so the cost tooltip can
  surface which rate fired.
- pricing_snapshot now exposes fast_mode_mult so the frontend cost
  panel doesn't have to hard-code 6.

7 new edge tests pin the math; existing 55 still pass.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Honor explicit zero cache_read_input_tokens on Anthropic envelopes

The previous follow-up fell back to ``prompt_tokens_details.cached_tokens``
whenever the native ``cache_read_input_tokens`` was missing OR equal to 0,
even though the commit message stated the native key always wins when
present. A proxy that forwards a stale ``prompt_tokens_details`` block
alongside an authoritative ``cache_read_input_tokens: 0`` would then
inflate cache_read past the real native count, posting a false cache_read
line and bumping billable_input_tokens. Switch the gate to native-key
presence so an explicit zero stays authoritative; the mirror only kicks
in when the native key is absent. Add a regression test pinning the
explicit-zero precedence.

* Move fast_mode pricing back to #5715

The fast_mode 6x multiplier landed in two places at once -- here
(f66df7ba) and on #5715 (4f1afdb5) -- since both audits ran in
parallel. Drop the duplicate from this branch so the change lives
in its natural home (#5715, which introduces fast_mode itself);
this PR stays focused on the cache-read fallback + 1h breakdown.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Shorten pricing comments for PR #5722

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-25 23:39:58 -07:00

628 lines
21 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Unit tests for the per-session cost calculator. Verifies math
against ``core/inference/pricing.py`` and graceful degradation."""
import math
from core.inference.pricing import (
ANTHROPIC_CACHE_5M_WRITE_MULT,
ANTHROPIC_CACHE_1H_WRITE_MULT,
ANTHROPIC_CACHE_READ_MULT,
ANTHROPIC_FAST_MODE_MULT,
ANTHROPIC_PRICING,
OPENAI_CACHE_READ_MULT,
OPENAI_CONTAINER_USD_PER_HOUR,
OPENAI_PRICING,
OPENAI_WEB_SEARCH_USD_PER_1K,
calculate_cost,
pricing_snapshot,
)
def _isclose(a, b, tol = 1e-6):
return math.isclose(a, b, rel_tol = tol, abs_tol = tol)
# ── unknown model -> priced=False, totals zero, tokens still report ──
def test_unknown_model_priced_false():
out = calculate_cost(
"anthropic",
"made-up-model-9000",
{"input_tokens": 100, "output_tokens": 50},
)
assert out["priced"] is False
assert out["total_usd"] == 0.0
assert out["billable_input_tokens"] == 100
assert out["billable_output_tokens"] == 50
# ── Anthropic base math (Opus 4.7: 5/25 per MTok) ────────────────────
def test_anthropic_opus_4_7_input_and_output_math():
out = calculate_cost(
"anthropic",
"claude-opus-4-7",
{"input_tokens": 1_000_000, "output_tokens": 1_000_000},
)
assert _isclose(out["input_usd"], 5.0)
assert _isclose(out["output_usd"], 25.0)
assert _isclose(out["total_usd"], 30.0)
# ── Anthropic fast-mode 6x multiplier (Opus 4.6 / 4.7 only) ─────────
def test_anthropic_fast_mode_charges_6x_standard_opus():
"""6x on input + output when ``usage.speed == "fast"``.
https://platform.claude.com/docs/en/build-with-claude/fast-mode"""
out = calculate_cost(
"anthropic",
"claude-opus-4-7",
{
"input_tokens": 1_000_000,
"output_tokens": 1_000_000,
"speed": "fast",
},
)
assert _isclose(out["input_usd"], 5.0 * ANTHROPIC_FAST_MODE_MULT)
assert _isclose(out["output_usd"], 25.0 * ANTHROPIC_FAST_MODE_MULT)
assert _isclose(out["total_usd"], 30.0 * ANTHROPIC_FAST_MODE_MULT)
assert "(fast)" in out["model_priced"], out["model_priced"]
def test_anthropic_fast_mode_does_not_affect_standard_speed():
"""``speed: "standard"`` (or missing) keeps the base rates."""
out_standard = calculate_cost(
"anthropic",
"claude-opus-4-7",
{
"input_tokens": 1_000_000,
"output_tokens": 1_000_000,
"speed": "standard",
},
)
out_missing = calculate_cost(
"anthropic",
"claude-opus-4-7",
{"input_tokens": 1_000_000, "output_tokens": 1_000_000},
)
assert _isclose(out_standard["total_usd"], out_missing["total_usd"])
assert _isclose(out_standard["total_usd"], 30.0)
def test_anthropic_fast_mode_stacks_with_cache_read_multiplier():
"""Cache multipliers apply on top of fast-mode (per docs)."""
base = ANTHROPIC_PRICING["claude-opus-4-7"]["input_per_mtok"]
out = calculate_cost(
"anthropic",
"claude-opus-4-7",
{
"input_tokens": 0,
"output_tokens": 0,
"cache_read_input_tokens": 1_000_000,
"speed": "fast",
},
)
expected = base * ANTHROPIC_FAST_MODE_MULT * ANTHROPIC_CACHE_READ_MULT
assert _isclose(out["cache_read_usd"], expected)
# ── Anthropic cache write 5m + read multipliers ──────────────────────
def test_anthropic_cache_5m_and_read_use_correct_multipliers():
base = ANTHROPIC_PRICING["claude-opus-4-7"]["input_per_mtok"]
out = calculate_cost(
"anthropic",
"claude-opus-4-7",
{
"input_tokens": 0,
"output_tokens": 0,
"cache_creation_input_tokens": 1_000_000,
"cache_read_input_tokens": 1_000_000,
"cache_creation": {
"ephemeral_5m_input_tokens": 1_000_000,
"ephemeral_1h_input_tokens": 0,
},
},
)
assert _isclose(out["cache_write_usd"], base * ANTHROPIC_CACHE_5M_WRITE_MULT)
assert _isclose(out["cache_read_usd"], base * ANTHROPIC_CACHE_READ_MULT)
# billable_input_tokens = input + cache_create + cache_read
assert out["billable_input_tokens"] == 2_000_000
def test_anthropic_cache_1h_write_uses_2x_multiplier():
base = ANTHROPIC_PRICING["claude-opus-4-7"]["input_per_mtok"]
out = calculate_cost(
"anthropic",
"claude-opus-4-7",
{
"input_tokens": 0,
"output_tokens": 0,
"cache_creation_input_tokens": 1_000_000,
"cache_read_input_tokens": 0,
"cache_creation": {
"ephemeral_5m_input_tokens": 0,
"ephemeral_1h_input_tokens": 1_000_000,
},
},
)
assert _isclose(out["cache_write_usd"], base * ANTHROPIC_CACHE_1H_WRITE_MULT)
def test_anthropic_cache_5m_default_when_no_breakdown():
# No 5m/1h split surfaced -> assume the default 5m pool.
base = ANTHROPIC_PRICING["claude-opus-4-7"]["input_per_mtok"]
out = calculate_cost(
"anthropic",
"claude-opus-4-7",
{
"input_tokens": 0,
"output_tokens": 0,
"cache_creation_input_tokens": 500_000,
},
)
expected = 0.5 * base * ANTHROPIC_CACHE_5M_WRITE_MULT
assert _isclose(out["cache_write_usd"], expected)
# ── Anthropic server-tool surcharges ────────────────────────────────
def test_anthropic_web_search_charged_per_thousand():
out = calculate_cost(
"anthropic",
"claude-opus-4-7",
{
"input_tokens": 0,
"output_tokens": 0,
"server_tool_use": {"web_search_requests": 250},
},
)
assert _isclose(out["server_tools_usd"], 2.5) # $10/1000 * 250
def test_anthropic_code_exec_charged_per_hour():
out = calculate_cost(
"anthropic",
"claude-opus-4-7",
{
"input_tokens": 0,
"output_tokens": 0,
"server_tool_use": {"code_execution_hours": 2.0},
},
)
assert _isclose(out["server_tools_usd"], 0.10) # $0.05/hr * 2
def test_anthropic_dated_id_falls_back_to_canonical_prefix():
# Dated snapshot inherits canonical pricing via prefix-match.
out = calculate_cost(
"anthropic",
"claude-opus-4-7-20260712",
{"input_tokens": 1_000_000, "output_tokens": 0},
)
assert out["priced"] is True
assert _isclose(out["input_usd"], 5.0)
# ── OpenAI base math (gpt-5.5: 5/30 per MTok) ────────────────────────
def test_openai_gpt55_input_output_math():
# Sub-272k stays in short-context tier ($5/$30).
out = calculate_cost(
"openai",
"gpt-5.5",
{"input_tokens": 200_000, "output_tokens": 50_000},
)
assert _isclose(out["input_usd"], 200_000 / 1_000_000.0 * 5.0)
assert _isclose(out["output_usd"], 50_000 / 1_000_000.0 * 30.0)
assert _isclose(out["total_usd"], 1.0 + 1.5)
def test_openai_cache_read_subtracted_from_input_at_discount():
# OpenAI folds cached into input_tokens; subtract and re-bill at 0.1x.
base = OPENAI_PRICING["gpt-5.5"]["input_per_mtok"]
out = calculate_cost(
"openai",
"gpt-5.5",
{
"input_tokens": 100_000,
"output_tokens": 0,
"input_tokens_details": {"cached_tokens": 80_000},
},
)
# 20k charged at full price, 80k charged at 0.1x
assert _isclose(out["input_usd"], 20_000 / 1_000_000.0 * base)
assert _isclose(
out["cache_read_usd"], 80_000 / 1_000_000.0 * base * OPENAI_CACHE_READ_MULT
)
def test_openai_billable_input_tokens_does_not_double_count_cache_read():
# input_tokens already includes cached; don't double-count.
out = calculate_cost(
"openai",
"gpt-5.5",
{
"input_tokens": 100_000,
"output_tokens": 0,
"input_tokens_details": {"cached_tokens": 80_000},
},
)
assert out["billable_input_tokens"] == 100_000
def test_openai_dated_snapshot_inherits_canonical_pricing():
# Dated snapshot inherits gpt-5.5 pricing via prefix-match.
out = calculate_cost(
"openai",
"gpt-5.5-2026-04-23",
{"input_tokens": 200_000, "output_tokens": 0},
)
assert out["priced"] is True
assert _isclose(out["input_usd"], 200_000 / 1_000_000.0 * 5.0)
def test_openai_gpt54_family_uses_verified_prices():
# Spot-check lower-tier rows that previously underbilled.
cases = {
# (input_tokens, expected_input_usd, expected_output_usd)
"gpt-5.4": (200_000, 200_000 / 1_000_000.0 * 2.5, 200_000 / 1_000_000.0 * 15.0),
"gpt-5.4-mini": (1_000_000, 0.75, 4.5),
"gpt-5.4-nano": (1_000_000, 0.20, 1.25),
"gpt-5.3-codex": (1_000_000, 1.75, 14.0),
}
for model, (in_tokens, exp_in, exp_out) in cases.items():
out = calculate_cost(
"openai",
model,
{"input_tokens": in_tokens, "output_tokens": in_tokens},
)
assert out["priced"] is True, model
assert _isclose(out["input_usd"], exp_in), model
assert _isclose(out["output_usd"], exp_out), model
def test_openai_unlisted_model_priced_false_not_zero_default():
# o-series / gpt-4.5 are off the pricing page; drop rather than $0.
for model in ("o3", "o4-mini", "gpt-4.5", "gpt-4.5-preview"):
out = calculate_cost(
"openai",
model,
{"input_tokens": 1_000_000, "output_tokens": 1_000_000},
)
assert out["priced"] is False, model
assert out["total_usd"] == 0.0, model
# Token counts still report so the UI can render usage.
assert out["billable_input_tokens"] == 1_000_000, model
assert out["billable_output_tokens"] == 1_000_000, model
# ── canonical Anthropic 4.5 ids now resolve to a price ─────────────
def test_anthropic_canonical_4_5_ids_are_priced():
# Pin the bare-id aliases (backend defaults reference these).
cases = {
"claude-opus-4-5": (5.0, 25.0),
"claude-sonnet-4-5": (3.0, 15.0),
"claude-haiku-4-5": (1.0, 5.0),
# Opus 4.1 has the same problem.
"claude-opus-4-1": (15.0, 75.0),
}
for model, (inp, outp) in cases.items():
out = calculate_cost(
"anthropic",
model,
{"input_tokens": 1_000_000, "output_tokens": 1_000_000},
)
assert out["priced"] is True, model
assert _isclose(out["input_usd"], inp), model
assert _isclose(out["output_usd"], outp), model
# ── OpenAI long-context tier crossover ──────────────────────────────
def test_openai_gpt55_short_context_under_272k_uses_base_rates():
out = calculate_cost(
"openai",
"gpt-5.5",
{"input_tokens": 100_000, "output_tokens": 5_000},
)
assert _isclose(out["input_usd"], 100_000 / 1_000_000.0 * 5.0)
assert _isclose(out["output_usd"], 5_000 / 1_000_000.0 * 30.0)
# No long-context marker on the model id when we stayed under.
assert "long-context" not in out["model_priced"], out["model_priced"]
def test_openai_gpt55_long_context_crossover_uses_higher_rates():
# >272k billable -> long-context tier on the whole turn.
out = calculate_cost(
"openai",
"gpt-5.5",
{"input_tokens": 300_000, "output_tokens": 10_000},
)
assert _isclose(out["input_usd"], 300_000 / 1_000_000.0 * 10.0)
assert _isclose(out["output_usd"], 10_000 / 1_000_000.0 * 45.0)
assert "long-context" in out["model_priced"], out["model_priced"]
def test_openai_gpt54_long_context_crossover():
out = calculate_cost(
"openai",
"gpt-5.4",
{"input_tokens": 500_000, "output_tokens": 20_000},
)
assert _isclose(out["input_usd"], 500_000 / 1_000_000.0 * 5.0)
assert _isclose(out["output_usd"], 20_000 / 1_000_000.0 * 22.5)
def test_openai_gpt54_mini_has_no_long_context_tier():
# Mini/nano/codex have no long-context tier; base rate always applies.
out = calculate_cost(
"openai",
"gpt-5.4-mini",
{"input_tokens": 500_000, "output_tokens": 0},
)
assert _isclose(out["input_usd"], 500_000 / 1_000_000.0 * 0.75)
assert "long-context" not in out["model_priced"], out["model_priced"]
# ── OpenAI server-tool surcharges ──────────────────────────────────
def test_openai_web_search_charged_per_thousand():
out = calculate_cost(
"openai",
"gpt-5.5",
{
"input_tokens": 0,
"output_tokens": 0,
"openai_tool_use": {"web_search_requests": 250},
},
)
assert _isclose(
out["server_tools_usd"], 250 / 1_000.0 * OPENAI_WEB_SEARCH_USD_PER_1K
)
assert _isclose(out["total_usd"], 250 / 1_000.0 * OPENAI_WEB_SEARCH_USD_PER_1K)
def test_openai_container_hours_charged():
out = calculate_cost(
"openai",
"gpt-5.5",
{
"input_tokens": 0,
"output_tokens": 0,
"openai_tool_use": {"container_hours": 1.5},
},
)
assert _isclose(out["server_tools_usd"], 1.5 * OPENAI_CONTAINER_USD_PER_HOUR)
def test_openai_tool_surcharges_added_to_total():
# End-to-end: total must sum input + output + web_search + container.
out = calculate_cost(
"openai",
"gpt-5.5",
{
"input_tokens": 100_000,
"output_tokens": 5_000,
"openai_tool_use": {
"web_search_requests": 3,
"container_hours": 0.25,
},
},
)
expected_input = 100_000 / 1_000_000.0 * 5.0
expected_output = 5_000 / 1_000_000.0 * 30.0
expected_tools = (
3 / 1_000.0 * OPENAI_WEB_SEARCH_USD_PER_1K
+ 0.25 * OPENAI_CONTAINER_USD_PER_HOUR
)
assert _isclose(
out["total_usd"],
round(expected_input + expected_output + expected_tools, 6),
)
# ── snapshot endpoint includes the multipliers ───────────────────────
def test_snapshot_contains_provider_buckets_and_multipliers():
snap = pricing_snapshot()
assert set(snap.keys()) == {"anthropic", "openai"}
a = snap["anthropic"]
o = snap["openai"]
assert "models" in a and "claude-opus-4-7" in a["models"]
assert a["cache_5m_write_mult"] == ANTHROPIC_CACHE_5M_WRITE_MULT
assert a["cache_1h_write_mult"] == ANTHROPIC_CACHE_1H_WRITE_MULT
assert a["cache_read_mult"] == ANTHROPIC_CACHE_READ_MULT
assert a["fast_mode_mult"] == ANTHROPIC_FAST_MODE_MULT
assert "web_search_usd_per_1k" in a
assert "code_execution_usd_per_hour" in a
assert "models" in o and "gpt-5.5" in o["models"]
assert o["cache_read_mult"] == OPENAI_CACHE_READ_MULT
# OpenAI tool surcharge constants are exposed for the frontend.
assert o["web_search_usd_per_1k"] == OPENAI_WEB_SEARCH_USD_PER_1K
assert o["container_usd_per_hour"] == OPENAI_CONTAINER_USD_PER_HOUR
# Long-context tier metadata travels with the model row.
gpt55 = o["models"]["gpt-5.5"]
assert gpt55["long_context_threshold"] == 272_000
assert gpt55["long_context_input_per_mtok"] == 10.0
assert gpt55["long_context_output_per_mtok"] == 45.0
# ── longest-prefix match: dated mini variant must not collide with the
# shorter family prefix. ──
def test_longest_prefix_match_wins_for_dated_mini_snapshot():
"""`gpt-5.4-mini-2026-...` must inherit the mini rate, not the
shorter `gpt-5.4` rate (longest prefix wins)."""
out = calculate_cost(
"openai",
"gpt-5.4-mini-2026-04-23",
{"input_tokens": 1_000_000, "output_tokens": 0},
)
assert out["priced"] is True
# mini = 0.75/MTok, shorter gpt-5.4 = 2.5/MTok (>3x overcharge).
assert _isclose(out["input_usd"], 0.75), out
def test_longest_prefix_match_wins_for_dated_pro_snapshot():
out = calculate_cost(
"openai",
"gpt-5.5-pro-2026-04-23",
{"input_tokens": 1_000_000, "output_tokens": 0},
)
assert out["priced"] is True
# gpt-5.5-pro = 30/MTok vs gpt-5.5 = 5/MTok; longest wins.
assert _isclose(out["input_usd"], 30.0), out
# ── accept both chat-style and Responses envelope shapes. ──
def test_openai_chat_style_usage_keys_priced_correctly():
"""Chat-style envelope (`prompt_tokens` / `completion_tokens`) must
produce a non-zero cost (previously silently zeroed)."""
out = calculate_cost(
"openai",
"gpt-5.4-mini",
{"prompt_tokens": 1_000_000, "completion_tokens": 1_000_000},
)
# gpt-5.4-mini: 0.75 input + 4.5 output per MTok.
assert _isclose(out["input_usd"], 0.75), out
assert _isclose(out["output_usd"], 4.5), out
def test_input_tokens_preferred_when_both_keys_present():
"""Raw key wins when both envelope shapes are present."""
out = calculate_cost(
"openai",
"gpt-5.4-mini",
{
"input_tokens": 2_000_000,
"prompt_tokens": 5_000_000,
"output_tokens": 0,
},
)
# input_tokens=2M wins -> 2 * 0.75 = 1.50.
assert _isclose(out["input_usd"], 1.50), out
def test_anthropic_chat_style_prompt_tokens_dedupes_cache_buckets():
"""Anthropic chat-style prompt_tokens already folds cache buckets;
don't double-count billable input."""
# 1M uncached + 200K cache_creation + 500K cache_read -> 1.7M folded.
raw = calculate_cost(
"anthropic",
"claude-opus-4-7",
{
"input_tokens": 1_000_000,
"cache_creation_input_tokens": 200_000,
"cache_read_input_tokens": 500_000,
"output_tokens": 0,
},
)
chat = calculate_cost(
"anthropic",
"claude-opus-4-7",
{
"prompt_tokens": 1_700_000,
"cache_creation_input_tokens": 200_000,
"cache_read_input_tokens": 500_000,
"completion_tokens": 0,
},
)
# Both envelopes must price the same.
assert _isclose(chat["input_usd"], raw["input_usd"]), (chat, raw)
assert _isclose(chat["cache_write_usd"], raw["cache_write_usd"]), (chat, raw)
assert _isclose(chat["cache_read_usd"], raw["cache_read_usd"]), (chat, raw)
assert _isclose(chat["total_usd"], raw["total_usd"]), (chat, raw)
assert chat["billable_input_tokens"] == raw["billable_input_tokens"], (chat, raw)
def test_openai_chat_style_prompt_tokens_keeps_cache_read_semantics():
"""OpenAI prompt_tokens includes cache_read like raw input_tokens."""
raw = calculate_cost(
"openai",
"gpt-5.5",
{
"input_tokens": 1_000_000,
"input_tokens_details": {"cached_tokens": 200_000},
"output_tokens": 100_000,
},
)
chat = calculate_cost(
"openai",
"gpt-5.5",
{
"prompt_tokens": 1_000_000,
"cache_read_input_tokens": 200_000,
"completion_tokens": 100_000,
},
)
assert _isclose(chat["total_usd"], raw["total_usd"]), (chat, raw)
def test_openai_chat_style_envelope_reads_cache_from_prompt_tokens_details():
"""Chat-style envelope ships cached under prompt_tokens_details;
calculator must honour both this and input_tokens_details."""
base = OPENAI_PRICING["gpt-5.5"]["input_per_mtok"]
raw = calculate_cost(
"openai",
"gpt-5.5",
{
"input_tokens": 100_000,
"input_tokens_details": {"cached_tokens": 80_000},
"output_tokens": 0,
},
)
chat_style = calculate_cost(
"openai",
"gpt-5.5",
{
"prompt_tokens": 100_000,
"prompt_tokens_details": {"cached_tokens": 80_000},
"completion_tokens": 0,
},
)
# Both envelopes must price identically.
assert _isclose(chat_style["input_usd"], raw["input_usd"]), (chat_style, raw)
assert _isclose(chat_style["cache_read_usd"], raw["cache_read_usd"]), (
chat_style,
raw,
)
# 80k at 0.1x base, 20k at full.
assert _isclose(
chat_style["cache_read_usd"],
80_000 / 1_000_000.0 * base * OPENAI_CACHE_READ_MULT,
)
def test_explicit_zero_output_tokens_wins_over_stale_completion_tokens():
"""Explicit ``output_tokens: 0`` beats a stale ``completion_tokens``;
the previous `or` fallback treated 0 as missing."""
out = calculate_cost(
"openai",
"gpt-4o-mini",
{
"input_tokens": 100,
"output_tokens": 0,
# Stale chat-style mirror; must not bill against it.
"completion_tokens": 50,
},
)
assert out["billable_output_tokens"] == 0, out
assert out["output_usd"] == 0.0, out