diff --git a/studio/backend/core/inference/pricing.py b/studio/backend/core/inference/pricing.py new file mode 100644 index 0000000000..74c57fa594 --- /dev/null +++ b/studio/backend/core/inference/pricing.py @@ -0,0 +1,327 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Static per-MTok pricing tables for external providers, plus a +``calculate_cost`` helper that turns an upstream ``usage`` block into +a USD figure for surfacing in the chat UI. + +Neither the Anthropic Messages API nor the OpenAI Responses API +reports a ``cost`` field on the response. Both expose detailed token +counts (input, output, cache hits, server-tool invocations); pricing +multipliers live in the provider docs. We fold the docs into a static +table here, multiply by the usage block, and emit a per-turn cost + +running session total client-side. + +Sources (verified live 2026-05-22): +- Anthropic models overview: + https://platform.claude.com/docs/en/about-claude/models/overview +- Anthropic prompt-caching multipliers (5m write 1.25x, 1h write 2x, + read 0.1x): + https://platform.claude.com/docs/en/build-with-claude/prompt-caching +- Anthropic web search ($10 / 1000 searches, code execution + free-with-paid when paired with the newer web tools): + https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool + https://platform.claude.com/docs/en/agents-and-tools/tool-use/code-execution-tool +- OpenAI pricing page (input / output per MTok per model family): + https://platform.openai.com/docs/pricing +""" + +from __future__ import annotations + +from typing import Any, Optional + +# Per-million-token base pricing. `cache_5m_write_mult`, `cache_1h_write_mult`, +# `cache_read_mult` are multipliers ON `input_per_mtok` -- not absolute prices -- +# matching how Anthropic publishes them (5m write = 1.25x base, etc.). +# +# `input_per_mtok` and `output_per_mtok` are USD per 1,000,000 tokens. +ANTHROPIC_PRICING: dict[str, dict[str, float]] = { + "claude-opus-4-7": {"input_per_mtok": 5.0, "output_per_mtok": 25.0}, + "claude-opus-4-6": {"input_per_mtok": 5.0, "output_per_mtok": 25.0}, + # Canonical 4.5 ids are referenced from backend defaults (e.g. + # PROVIDER_REGISTRY['anthropic'].default_models) without the date + # suffix. The dated ids ARE the canonical names per Anthropic's + # models overview, but lookups for the bare id ("claude-opus-4-5") + # don't prefix-match the dated key the other way around, so we + # alias both forms here. Otherwise calculate_cost returns + # priced=False + zero cost for the common ids. + "claude-opus-4-5": {"input_per_mtok": 5.0, "output_per_mtok": 25.0}, + "claude-opus-4-5-20251101": {"input_per_mtok": 5.0, "output_per_mtok": 25.0}, + "claude-opus-4-1": {"input_per_mtok": 15.0, "output_per_mtok": 75.0}, + "claude-opus-4-1-20250805": {"input_per_mtok": 15.0, "output_per_mtok": 75.0}, + "claude-opus-4-20250514": {"input_per_mtok": 15.0, "output_per_mtok": 75.0}, + "claude-sonnet-4-6": {"input_per_mtok": 3.0, "output_per_mtok": 15.0}, + "claude-sonnet-4-5": {"input_per_mtok": 3.0, "output_per_mtok": 15.0}, + "claude-sonnet-4-5-20250929": {"input_per_mtok": 3.0, "output_per_mtok": 15.0}, + "claude-sonnet-4-20250514": {"input_per_mtok": 3.0, "output_per_mtok": 15.0}, + "claude-haiku-4-5": {"input_per_mtok": 1.0, "output_per_mtok": 5.0}, + "claude-haiku-4-5-20251001": {"input_per_mtok": 1.0, "output_per_mtok": 5.0}, +} + +OPENAI_PRICING: dict[str, dict[str, float]] = { + # All values verified against developers.openai.com/api/docs/pricing + # 2026-05-22. Update against the live pricing page on every model launch. + # Initial commit underbilled every gpt-5.x family 2-6x -- fixed here + # after PR review caught it via doc cross-check. + # + # `long_context_input_per_mtok` / `long_context_output_per_mtok` / + # `long_context_threshold` are populated when OpenAI publishes a + # second pricing tier for prompts above N input tokens. gpt-5.5 and + # gpt-5.4 cross over at 272k input tokens; the long-context rates + # are double the headline input price (and ~1.5x on output). Other + # families currently ship with a single rate (no `long_context_*` + # keys = no tier crossover). Reference: + # https://developers.openai.com/api/docs/pricing + "gpt-5.5": { + "input_per_mtok": 5.0, + "output_per_mtok": 30.0, + "long_context_threshold": 272_000, + "long_context_input_per_mtok": 10.0, + "long_context_output_per_mtok": 45.0, + }, + "gpt-5.5-pro": {"input_per_mtok": 30.0, "output_per_mtok": 180.0}, + "gpt-5.4": { + "input_per_mtok": 2.5, + "output_per_mtok": 15.0, + "long_context_threshold": 272_000, + "long_context_input_per_mtok": 5.0, + "long_context_output_per_mtok": 22.5, + }, + "gpt-5.4-pro": {"input_per_mtok": 30.0, "output_per_mtok": 180.0}, + "gpt-5.4-mini": {"input_per_mtok": 0.75, "output_per_mtok": 4.5}, + "gpt-5.4-nano": {"input_per_mtok": 0.20, "output_per_mtok": 1.25}, + "gpt-5.3-codex": {"input_per_mtok": 1.75, "output_per_mtok": 14.0}, + # chat-latest / gpt-5.3-chat-latest is an alias for the current + # ChatGPT model; same price as gpt-5.5. + "gpt-5.3-chat-latest": {"input_per_mtok": 5.0, "output_per_mtok": 30.0}, + "chat-latest": {"input_per_mtok": 5.0, "output_per_mtok": 30.0}, + # o-series and gpt-4.5: NOT currently listed on the pricing page. + # Removed to avoid silent-underbilling drift. Returning priced=False + # is honest; the UI can still render token counts. Restore with + # verified per-MTok rates if/when the page lists them again. +} + +# Shared multipliers (same across every Anthropic model). +ANTHROPIC_CACHE_5M_WRITE_MULT = 1.25 +ANTHROPIC_CACHE_1H_WRITE_MULT = 2.0 +ANTHROPIC_CACHE_READ_MULT = 0.1 + +# OpenAI: cache reads are 0.1x base input, cache writes are not billed +# separately (the first prefix-write request just pays normal input). +OPENAI_CACHE_READ_MULT = 0.1 + +# Server-tool surcharges. +# Anthropic: $10 / 1000 web searches; code_execution is $0.05/hr after +# 50 free hours/day per org (no per-org visibility here, so the +# calculator reports the marginal rate). +ANTHROPIC_WEB_SEARCH_USD_PER_1K = 10.0 +ANTHROPIC_CODE_EXEC_USD_PER_HOUR = 0.05 + +# OpenAI: web_search is billed at $10/1000 calls plus the model's +# token rate for the returned search content (already captured under +# input/output_tokens). The hosted shell tool bills per 20-minute +# session per container memory tier (1g/4g/16g/64g at +# $0.03/$0.12/$0.48/$1.92). Since Studio doesn't surface the memory +# tier in the cost ledger and most users land on the default 1g, we +# bill the 1g rate ($0.09/hour) and let the user inspect the OpenAI +# dashboard for the exact figure on heavier configs. +# Source: developers.openai.com/api/docs/pricing 2026-05-22. +OPENAI_WEB_SEARCH_USD_PER_1K = 10.0 +OPENAI_CONTAINER_USD_PER_HOUR = 0.09 # 1g default tier; 3 x $0.03 / 60min + + +def _lookup(provider: str, model: str) -> Optional[dict[str, float]]: + table = ( + ANTHROPIC_PRICING + if provider == "anthropic" + else OPENAI_PRICING + if provider == "openai" + else None + ) + if table is None: + return None + if model in table: + return table[model] + # Fall back to a prefix match so date-suffixed snapshots + # ("gpt-5.5-2026-04-23") inherit the canonical-id prices. + for key, val in table.items(): + if model.startswith(key): + return val + return None + + +def calculate_cost( + provider: str, + model: str, + usage: dict[str, Any], +) -> dict[str, float]: + """Return a per-turn USD cost breakdown. + + Returns a dict with the per-bucket cost AND the totals so the + frontend can render either a single number or a "where did the + money go" tooltip without re-doing the math: + + { + "input_usd": 0.0042, + "output_usd": 0.012, + "cache_write_usd": 0.0001, + "cache_read_usd": 0.0008, + "server_tools_usd": 0.01, + "total_usd": 0.0271, + "billable_input_tokens": 5023, # input + cache_create + cache_read + "billable_output_tokens": 480, + "model_priced": "claude-opus-4-7", + "priced": true, + } + + When the model isn't in the static table (new family, custom base + URL), `priced` is False and every USD field is 0.0; the frontend + can still show the token counts. + """ + prices = _lookup(provider, model) + out: dict[str, float] = { + "input_usd": 0.0, + "output_usd": 0.0, + "cache_write_usd": 0.0, + "cache_read_usd": 0.0, + "server_tools_usd": 0.0, + "total_usd": 0.0, + "billable_input_tokens": 0, + "billable_output_tokens": 0, + "model_priced": model if prices else "", + "priced": bool(prices), + } + + input_tokens = int(usage.get("input_tokens") or 0) + output_tokens = int(usage.get("output_tokens") or 0) + cache_creation = int(usage.get("cache_creation_input_tokens") or 0) + cache_read = int(usage.get("cache_read_input_tokens") or 0) + # OpenAI Responses reports cached tokens under input_tokens_details + # but ALSO folds them into the top-level input_tokens, so we don't + # add cache_read into the billable total again below (Anthropic + # excludes cache buckets from input_tokens, OpenAI includes them -- + # the two providers differ here and the calculator must match). + if provider == "openai": + details = usage.get("input_tokens_details") or {} + if isinstance(details, dict): + cache_read = max(cache_read, int(details.get("cached_tokens") or 0)) + # OpenAI: cache_read already counted inside input_tokens. + out["billable_input_tokens"] = input_tokens + cache_creation + else: + # Anthropic: input_tokens excludes cache_* buckets, add them all. + out["billable_input_tokens"] = input_tokens + cache_creation + cache_read + out["billable_output_tokens"] = output_tokens + + if not prices: + return out + + # Long-context tier crossover (gpt-5.5 / gpt-5.4 today). OpenAI + # bills the whole turn at the long-context rate once the prompt + # crosses the threshold, NOT a per-token blend, so we pick a + # single (base, out_per) pair for this turn based on + # billable_input_tokens. + lc_thresh = prices.get("long_context_threshold") + in_long_context_tier = ( + lc_thresh is not None + and out["billable_input_tokens"] >= int(lc_thresh) + and "long_context_input_per_mtok" in prices + and "long_context_output_per_mtok" in prices + ) + if in_long_context_tier: + base = prices["long_context_input_per_mtok"] + out_per = prices["long_context_output_per_mtok"] + out["model_priced"] = f"{model} (long-context >{lc_thresh})" + else: + base = prices["input_per_mtok"] + out_per = prices["output_per_mtok"] + + out["input_usd"] = (input_tokens / 1_000_000.0) * base + out["output_usd"] = (output_tokens / 1_000_000.0) * out_per + + if provider == "anthropic": + # Split cache_creation across 5m / 1h buckets when the + # response surfaces the breakdown. + cc_breakdown = usage.get("cache_creation") or {} + cc_5m = int(cc_breakdown.get("ephemeral_5m_input_tokens") or 0) + cc_1h = int(cc_breakdown.get("ephemeral_1h_input_tokens") or 0) + if cc_5m + cc_1h == 0 and cache_creation > 0: + # Fall back: assume default 5m pool when no breakdown is given. + cc_5m = cache_creation + out["cache_write_usd"] = ( + cc_5m / 1_000_000.0 + ) * base * ANTHROPIC_CACHE_5M_WRITE_MULT + ( + cc_1h / 1_000_000.0 + ) * base * ANTHROPIC_CACHE_1H_WRITE_MULT + out["cache_read_usd"] = ( + (cache_read / 1_000_000.0) * base * ANTHROPIC_CACHE_READ_MULT + ) + # Server-tool surcharges. + srv = usage.get("server_tool_use") or {} + if isinstance(srv, dict): + web_searches = int(srv.get("web_search_requests") or 0) + code_exec_hours = float(srv.get("code_execution_hours") or 0.0) + out["server_tools_usd"] = ( + web_searches / 1_000.0 * ANTHROPIC_WEB_SEARCH_USD_PER_1K + + code_exec_hours * ANTHROPIC_CODE_EXEC_USD_PER_HOUR + ) + else: + # OpenAI: cache writes share the base input price (no premium). + # Only cache reads get the 0.1x multiplier; subtract those from + # the input_usd we already counted so we don't double-bill. + # Anthropic excludes cache buckets from input_tokens, but + # OpenAI folds them in, so the math differs. + if cache_read > 0: + non_cached_input = max(0, input_tokens - cache_read) + out["input_usd"] = (non_cached_input / 1_000_000.0) * base + out["cache_read_usd"] = ( + (cache_read / 1_000_000.0) * base * OPENAI_CACHE_READ_MULT + ) + # Server-tool surcharges. OpenAI doesn't include these on its + # `usage` object directly -- web_search invocations are counted + # from `ResponseFunctionWebSearch` items in the output array, + # and container hours come from the SSE translator's shell-tool + # accounting. Studio surfaces both under a normalised + # `openai_tool_use` key on the usage dict the SSE finaliser + # hands to this calculator. + srv = usage.get("openai_tool_use") or {} + if isinstance(srv, dict): + web_searches = int(srv.get("web_search_requests") or 0) + container_hours = float(srv.get("container_hours") or 0.0) + out["server_tools_usd"] = ( + web_searches / 1_000.0 * OPENAI_WEB_SEARCH_USD_PER_1K + + container_hours * OPENAI_CONTAINER_USD_PER_HOUR + ) + + out["total_usd"] = round( + out["input_usd"] + + out["output_usd"] + + out["cache_write_usd"] + + out["cache_read_usd"] + + out["server_tools_usd"], + 6, + ) + return out + + +def pricing_snapshot() -> dict[str, Any]: + """Whole pricing table, for the /api/providers/pricing endpoint. + + Returns a flat structure the frontend can hand to its cost + formatter without re-implementing the multipliers. + """ + return { + "anthropic": { + "models": dict(ANTHROPIC_PRICING), + "cache_5m_write_mult": ANTHROPIC_CACHE_5M_WRITE_MULT, + "cache_1h_write_mult": ANTHROPIC_CACHE_1H_WRITE_MULT, + "cache_read_mult": ANTHROPIC_CACHE_READ_MULT, + "web_search_usd_per_1k": ANTHROPIC_WEB_SEARCH_USD_PER_1K, + "code_execution_usd_per_hour": ANTHROPIC_CODE_EXEC_USD_PER_HOUR, + }, + "openai": { + "models": dict(OPENAI_PRICING), + "cache_read_mult": OPENAI_CACHE_READ_MULT, + "web_search_usd_per_1k": OPENAI_WEB_SEARCH_USD_PER_1K, + "container_usd_per_hour": OPENAI_CONTAINER_USD_PER_HOUR, + }, + } diff --git a/studio/backend/routes/providers.py b/studio/backend/routes/providers.py index acfaa6e427..2bb1de5366 100644 --- a/studio/backend/routes/providers.py +++ b/studio/backend/routes/providers.py @@ -27,6 +27,7 @@ from core.inference.providers import ( get_provider_info, list_available_providers, ) +from core.inference.pricing import pricing_snapshot from core.inference.external_provider import ExternalProviderClient from models.providers import ( ProviderCreate, @@ -77,6 +78,20 @@ async def list_registry( return list_available_providers() +# ── Per-MTok pricing snapshot for client-side cost display ────────── + + +@router.get("/pricing") +async def get_pricing_snapshot( + current_subject: str = Depends(get_current_subject), +): + """Static per-MTok pricing table the frontend uses to convert + upstream usage chunks into a per-turn USD cost. See + ``core/inference/pricing.py`` for sourcing notes; values reflect + the published prices as of the file's last update.""" + return pricing_snapshot() + + # ── Provider config CRUD ────────────────────────────────────────── diff --git a/studio/backend/tests/test_pricing.py b/studio/backend/tests/test_pricing.py new file mode 100644 index 0000000000..cc8c16993c --- /dev/null +++ b/studio/backend/tests/test_pricing.py @@ -0,0 +1,427 @@ +# 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. + +Pricing inputs are baked into ``core/inference/pricing.py``; this +test verifies the math (with multipliers from the prompt-caching +docs) and that unknown models / empty usage degrade gracefully. +""" + +import math + +from core.inference.pricing import ( + ANTHROPIC_CACHE_5M_WRITE_MULT, + ANTHROPIC_CACHE_1H_WRITE_MULT, + ANTHROPIC_CACHE_READ_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 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(): + # When the docs/response doesn't surface the 5m/1h split, treat + # the full cache_creation bucket as 5m (the upstream default 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(): + # Hypothetical dated snapshot of claude-opus-4-7 should still + # inherit the canonical-id pricing via the prefix-match fallback. + 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 input keeps us in the short-context tier ($5/$30). + # The dedicated long-context tests below exercise the crossover. + 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 tokens into input_tokens, unlike Anthropic. + # The calculator must subtract cached_tokens from the "full price" + # bucket and re-bill them at 0.1x. Use a sub-272k total so the + # short-context tier applies (long-context crossover is exercised + # in its own test below). + 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(): + # OpenAI's input_tokens already includes cached_tokens, so the + # billable counter must NOT add cache_read on top -- otherwise the + # tooltip says 180k input when the bill is for 100k. + 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(): + # Sub-272k stays in the short-context tier; the prefix-match + # fallback is what proves the dated snapshot inherits gpt-5.5 + # pricing. + 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 the lower-tier rows that previously underbilled. + # gpt-5.4 has a long-context tier so the input has to stay + # below 272k; the mini/nano/codex rows have no crossover so + # 1M tokens is fine. + 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 no longer on the pricing page, so we + # intentionally drop them rather than silently underbill at $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(): + # Codex P1: claude-opus-4-5 (no date) is the canonical id used + # in backend defaults but was missing from the table, so the + # calculator returned priced=False + zero cost. Pin the aliases. + 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(): + # 300k billable input > 272k threshold -> long-context tier + # applies to the WHOLE turn, not a per-token blend. + 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 don't publish a long-context price; the base + # rate must keep applying even at very large prompts. + 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: input + output + web_search + container in one + # turn. Total must sum all four buckets. + 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 "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 also exposed so the frontend + # tooltip can render the per-call rate. + 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