unsloth/studio/backend/core/inference/pricing.py
Daniel Han 7d1b68079e
Studio: Anthropic fast_mode toggle and streaming refusal handling (#5715)
* Studio: add Anthropic fast_mode toggle + surface streaming refusals

Fast mode (beta `fast-mode-2026-02-01`) lets Claude Opus 4.6 and 4.7
generate output tokens up to 2.5x faster at 6x standard Opus
pricing. The toggle lives in Configuration → Provider when the
selected Anthropic model is Opus 4.6 or 4.7 and is otherwise
hidden. Backend gates the same prefixes a second time so a stale
frontend cannot make Anthropic 400 the request, and the
`fast-mode-2026-02-01` beta header is merged onto whatever other
betas the request already needed (code-execution, compaction).

Streaming refusals (`message_delta.delta.stop_reason="refusal"` on
Claude 4 models) now surface a short user-facing notice in the
assistant message before the translated OpenAI chunk emits the
existing `finish_reason="content_filter"`. Previously the chat
bubble truncated silently because the SSE stopped mid-stream with
no visible explanation. Per the upstream docs the conversation
must be reset before continuing, so the notice tells the user
exactly that.

Reference:
- https://platform.claude.com/docs/en/build-with-claude/fast-mode
- https://platform.claude.com/docs/en/test-and-evaluate/strengthen-guardrails/handle-streaming-refusals

Tests:
- studio/backend/tests/test_anthropic_fast_mode_and_refusal.py (8 cases
  pinning fast_mode pass-through on 4.6/4.7, silent drop on Sonnet /
  Haiku / older Opus / None / False, and the refusal notice + finish
  reason on a synthetic refusal stream).

* Studio: drop refused Anthropic turns from the next request

Anthropic's streaming-refusal guidance says the refused assistant
turn must be removed or updated before the next call -- otherwise
the safety classifier keeps refusing. The PR only added a
user-visible notice; the partial assistant output (plus the notice
itself) still rode the next request via toOpenAIMessage.

Tag the refusal turn with an HTML-comment sentinel emitted alongside
the notice. The chat-adapter checks for that sentinel in
toOpenAIMessage and returns null, so the refused turn is excluded
from outboundMessages. The notice still renders in the transcript
(HTML comments don't display), so users keep the explanation.

* Studio: filter None finish_reason entries in test helper

test_refusal_maps_to_content_filter expects only ['content_filter']
in the finish_reasons list, but the post-PR refusal path emits a
user-visible content notice chunk first. Every _content_chunk
carries 'finish_reason: None' by construction; the helper was
appending those, so the assertion saw [None, 'content_filter']
instead of ['content_filter'].

None is not a finish reason -- it's just mid-stream delta noise.
Skip None values in _finish_reasons so the helper reflects what
the test names actually claim to check. Same fix applies cleanly
to the other helper usages (pause_turn test expects [] and the
sibling stop test expects ['stop'], both unaffected).

* Studio: cover Anthropic fast-mode edge cases

Adds 19 cases on top of the 9 in test_anthropic_fast_mode_and_refusal.
The base file pins the happy path; this file fills in the cliffs:

* Dated-snapshot prefix matching: claude-opus-4-7-2026-02-01 and
  claude-opus-4-6-2026-02-01 still gate fast_mode through, while
  claude-opus-4-5-2025-08-01 and claude-sonnet-4-6-2026-02-01 do not.
* Strict opt-in: a future claude-opus-4-8 or claude-opus-5 does NOT
  auto-enable fast_mode -- the prefix tuple must be bumped explicitly
  when a new family is whitelisted upstream.
* Beta-header merge: fast_mode coexists with code-execution-2025-08-25
  and compact-2026-01-12 in one comma-separated anthropic-beta header
  with no duplicates and no truncation. Pins the value to the exact
  fast-mode-2026-02-01 docs token so a typo would fail CI.
* Non-destruction: fast_mode=None produces byte-identical outbound
  body and headers to the version that omits the argument entirely.
  Same for fast_mode=False. Guarantees the upgrade path is
  non-breaking on existing Anthropic streams.
* Refusal stream ordering: the user-visible notice precedes the
  finish_reason chunk so a streaming UI paints text before flipping
  to content_filter. Refusal sentinel emitted exactly once. Notice
  rides a normal content delta chunk with finish_reason still null.
  Partial assistant deltas survive before the notice.
* Provider-side refusal coverage: a refusal on Sonnet (not just Opus)
  still emits the notice + sentinel + content_filter mapping, since
  refusal handling is not gated on fast-mode capability.

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

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

* Persist fastMode, drop refused user message on retry

Two follow-ups on #5715:

1) sanitizeInferenceParams stripped fastMode. fastMode is in
   PERSISTED_INFERENCE_PARAM_KEYS but the storage sanitizer only kept
   numeric fields plus systemPrompt and trustRemoteCode, so the new
   toggle was silently dropped on reload and on the
   /api/chat/settings round-trip. Save it the same way trustRemoteCode
   is saved.

2) Refusal recovery now also drops the triggering user turn.
   Returning null from toOpenAIMessage on the assistant side left the
   user prompt that caused the refusal in the outbound history, so
   the very next request would re-trigger the same classifier.
   Anthropic's refusal-handling guidance is explicit on this: remove
   the refused turn AND the user message that triggered it before
   the next call. Implemented via a pre-pass that pops the trailing
   user message when an assistant carries the refusal sentinel.

Typecheck clean.

* Studio: out-of-band refusal signal + fast-mode prefix/usage/pricing fixes

The text sentinel for the Anthropic refusal drop signal was spoofable:
any assistant message containing the literal
<!--studio:anthropic-refusal--> would prune the prior user + assistant
pair on the next request. Move the signal onto a separate _toolEvent
chunk that the chat adapter latches into
assistant.metadata.custom.anthropicRefusal; assistant text can no
longer control the pruner.

Tighten the fast-mode model gate (backend + frontend) to require a "-"
family boundary so claude-opus-4-70 / claude-opus-4-7b style IDs do
not get speed: "fast" on a naive startswith match.

Use survivingMessages for the image / audio attachment scan so a
refused user turn does not gate or mis-attribute the next non-refused
turn.

Propagate Anthropic usage.speed onto the OpenAI-style usage chunk and
apply the documented 6x fast-mode multiplier in the cost calculator
(stacks with prompt-cache multipliers per the docs); expose the new
multiplier on the pricing snapshot for the UI tooltip.

Tests cover the tool-event chunk shape, the prefix-collision rejects,
usage.speed propagation, the 6x pricing math, and that the visible
refusal text carries no embedded sentinel.

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

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

* Shorten fast-mode and refusal comments for PR #5715

---------

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

340 lines
15 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
"""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
# Anthropic fast-mode (Opus 4.6 / 4.7 only): 6x standard on input + output.
# https://platform.claude.com/docs/en/build-with-claude/fast-mode#pricing
ANTHROPIC_FAST_MODE_MULT = 6.0
# 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"]
# Anthropic fast-mode: 6x on input + output. Cache multipliers stack
# on top of fast-mode, so applying once to (base, out_per) propagates
# into the cache_*_usd buckets computed below.
if provider == "anthropic" and usage.get("speed") == "fast":
base *= ANTHROPIC_FAST_MODE_MULT
out_per *= ANTHROPIC_FAST_MODE_MULT
if out["model_priced"]:
out["model_priced"] = f"{out['model_priced']} (fast)"
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,
"fast_mode_mult": ANTHROPIC_FAST_MODE_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,
},
}