Studio: per-session cost calculator + /api/providers/pricing endpoint (#5690)
* Studio: per-session cost calculator + /api/providers/pricing endpoint 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. The frontend's "cost so far" display was impossible without scraping the server log. Land the math + a snapshot endpoint so the cost calculator can run client-side from the existing usage chunk plumbing. The actual UI hookup belongs in a frontend follow-up (and is gated on PR #5670's usage-chunk emission landing so the frontend sees the usage block in the first place). Changes: - New `core/inference/pricing.py` with: - Per-MTok base pricing tables for every active Anthropic and gpt-5.x family member. Dated snapshots inherit the canonical-id price via prefix match so future snapshots cost the same as the canonical id until pricing changes. - Shared multipliers for Anthropic cache writes (5m: 1.25x, 1h: 2x) and reads (0.1x); OpenAI cache reads (0.1x); Anthropic server tool surcharges ($10 / 1k web_search, $0.05 / hour code_exec beyond the 50-hour daily free tier). - `calculate_cost(provider, model, usage)` returns a per-turn USD breakdown plus billable token counts, with priced=False for unknown models so the UI can still render token counts. - `pricing_snapshot()` returns the whole table for the frontend so it doesn't re-implement the multipliers. - New `GET /api/providers/pricing` returning the snapshot, scoped behind the existing auth dependency. - New `backend/tests/test_pricing.py` with 12 cases pinning the math against documented values: base input/output multiplication, 5m / 1h / read multipliers, default-to-5m fallback when the breakdown is absent, web_search per-1k pricing, code_execution per-hour pricing, dated-snapshot fallback, OpenAI cache-read discount accounting (cached tokens subtracted from full-price bucket and re-billed at 0.1x), unknown model graceful-degrade, and the snapshot endpoint shape. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: verified OpenAI pricing + fix billable input double-count Address the cost-calculator review: - OpenAI prices were 2-6x under the actual published rates. Cross-checked the live developers.openai.com/api/docs/pricing page and replaced every entry. gpt-5.5 is 5/30, gpt-5.5-pro is 30/180, gpt-5.4 is 2.5/15, gpt-5.4-mini 0.75/4.5, gpt-5.4-nano 0.20/1.25, gpt-5.3-codex 1.75/14. Added chat-latest alias to the canonical chat-snapshot rate. Dropped o3 / o4 / gpt-4.5 rows that are no longer listed on the page; calculator returns priced=False instead of silently billing at zero. - billable_input_tokens was double-counting cached tokens for OpenAI. Anthropic excludes cache_* buckets from input_tokens so we add them; OpenAI folds cache_read_input_tokens into input_tokens already, so the tooltip read 1.8M for a 1.0M bill. Branched the math by provider and added a regression test. Sourcing notes in the module docstring updated. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review: canonical 4.5 ids, long-context tier, OpenAI tool fees Three Codex P1 follow-ups on the cost calculator: 1. Canonical Anthropic 4.5 ids missing from ANTHROPIC_PRICING. claude-opus-4-5 / claude-sonnet-4-5 / claude-haiku-4-5 (no date suffix) are the ids used by backend defaults (PROVIDER_REGISTRY['anthropic'].default_models), but the table only had the dated forms. _lookup's prefix fallback doesn't help because the canonical id is SHORTER than the dated key, so str.startswith goes the wrong way and the calculator returned priced=False + zero cost. Added the canonical aliases for opus-4-5, sonnet-4-5, haiku-4-5, and opus-4-1. 2. OpenAI long-context tier. gpt-5.5 and gpt-5.4 cross over at 272k input tokens to a 2x input / 1.5x output rate (gpt-5.5: $5/$30 -> $10/$45; gpt-5.4: $2.50/$15 -> $5/$22.50). Turns past the threshold were systematically undercounted at headline rates. Added long_context_threshold / long_context_input_per_mtok / long_context_output_per_mtok columns and a tier-selection step in calculate_cost; model_priced gains a "(long-context >272000)" suffix when the higher tier applies so the tooltip can show which rate was used. gpt-5.5-pro / gpt-5.4-pro / mini / nano / codex have no published long-context tier today, so they keep a single rate. 3. OpenAI server-tool surcharges. web_search is $10/1000 calls and the hosted shell container is $0.03 per 20-minute session on the default 1g tier (~$0.09/hr). server_tools_usd was previously stuck at 0.0 for OpenAI even when web_search and shell tools fired, so sessions with tool use understated cost. Added OPENAI_WEB_SEARCH_USD_PER_1K and OPENAI_CONTAINER_USD_PER_HOUR constants plus a parallel of the Anthropic surcharge block that reads counts from usage["openai_tool_use"]. The SSE translator wires the counts in a follow-up commit; the calculator is now ready for them. pricing_snapshot also exposes both constants so the frontend tooltip can render the per-call rate. Existing tests updated to stay in the short-context tier where they were testing base rates; new tests pin canonical 4.5 lookups, long-context crossover on gpt-5.5/gpt-5.4, the absence of crossover on mini/nano/codex, and OpenAI tool surcharges (web_search, container hours, combined total). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
5b41872e8b
commit
2201fd687b
3 changed files with 769 additions and 0 deletions
327
studio/backend/core/inference/pricing.py
Normal file
327
studio/backend/core/inference/pricing.py
Normal file
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
@ -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 ──────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
427
studio/backend/tests/test_pricing.py
Normal file
427
studio/backend/tests/test_pricing.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue