Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

463 lines
17 KiB
Python

"""Per-model token pricing for the dashboard's ``$/hr`` column.
``$/hr`` is a *projected hourly burn rate* derived from the trailing
60 s usage window, not the actual spend over the last hour. The
sampler now keeps input/output/cache buckets, so pricing applies the
right rate to each bucket instead of using the legacy 70/30 blend.
The local price table is a vendored snapshot of the ``models.dev``
catalog for the Claude Code / Codex models this dashboard supports,
following CodexBar's offline-first approach. Unknown models return
``None`` so the dashboard renders ``-`` rather than inventing a rate.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from functools import lru_cache
from tools.system.fleet_monitoring.meters import TokenUsage
#: ``models.dev`` pricing snapshot last refreshed for this vendored table.
RATES_VERIFIED_AT = "2026-05-17"
_USD_PER_M = 1_000_000
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ModelPrice:
usd_per_input_token: float
usd_per_output_token: float
usd_per_cached_input_token: float | None = None
usd_per_cache_read_input_token: float | None = None
usd_per_cache_creation_input_token: float | None = None
@property
def cached_input_rate(self) -> float:
return (
self.usd_per_cached_input_token
if self.usd_per_cached_input_token is not None
else self.cache_read_rate
)
@property
def cache_read_rate(self) -> float:
return (
self.usd_per_cache_read_input_token
if self.usd_per_cache_read_input_token is not None
else self.usd_per_input_token
)
@property
def cache_creation_rate(self) -> float:
return (
self.usd_per_cache_creation_input_token
if self.usd_per_cache_creation_input_token is not None
else self.usd_per_input_token
)
@dataclass(frozen=True)
class PriceOverride:
"""Per-agent rate override loaded from ``agents.yaml``.
Overrides are USD per 1M input/output tokens. Cache rates keep the
base model's ratios when the model is known; for custom unknown
models they fall back to the effective input rate.
"""
input_usd_per_million: float | None = None
output_usd_per_million: float | None = None
def _price(
input_usd_per_million: float,
output_usd_per_million: float,
*,
cache_read_usd_per_million: float | None = None,
cache_write_usd_per_million: float | None = None,
) -> ModelPrice:
input_rate = input_usd_per_million / _USD_PER_M
cache_read_rate = (
cache_read_usd_per_million / _USD_PER_M if cache_read_usd_per_million is not None else None
)
return ModelPrice(
usd_per_input_token=input_rate,
usd_per_output_token=output_usd_per_million / _USD_PER_M,
usd_per_cached_input_token=cache_read_rate,
usd_per_cache_read_input_token=cache_read_rate,
usd_per_cache_creation_input_token=(
cache_write_usd_per_million / _USD_PER_M
if cache_write_usd_per_million is not None
else None
),
)
MODEL_PRICES: dict[str, ModelPrice] = {
# Anthropic / Claude Code. Values are USD per 1M tokens in
# models.dev; _price converts them to USD per token.
"claude-3-5-sonnet-20240620": _price(
3.00, 15.00, cache_read_usd_per_million=0.30, cache_write_usd_per_million=3.75
),
"claude-3-5-sonnet-20241022": _price(
3.00, 15.00, cache_read_usd_per_million=0.30, cache_write_usd_per_million=3.75
),
"claude-3-5-haiku-20241022": _price(
0.80, 4.00, cache_read_usd_per_million=0.08, cache_write_usd_per_million=1.00
),
"claude-3-5-haiku-latest": _price(
0.80, 4.00, cache_read_usd_per_million=0.08, cache_write_usd_per_million=1.00
),
"claude-haiku-4-5": _price(
1.00, 5.00, cache_read_usd_per_million=0.10, cache_write_usd_per_million=1.25
),
"claude-haiku-4-5-20251001": _price(
1.00, 5.00, cache_read_usd_per_million=0.10, cache_write_usd_per_million=1.25
),
"claude-sonnet-4": _price(
3.00, 15.00, cache_read_usd_per_million=0.30, cache_write_usd_per_million=3.75
),
"claude-sonnet-4-0": _price(
3.00, 15.00, cache_read_usd_per_million=0.30, cache_write_usd_per_million=3.75
),
"claude-sonnet-4-20250514": _price(
3.00, 15.00, cache_read_usd_per_million=0.30, cache_write_usd_per_million=3.75
),
"claude-sonnet-4-5": _price(
3.00, 15.00, cache_read_usd_per_million=0.30, cache_write_usd_per_million=3.75
),
"claude-sonnet-4-5-20250929": _price(
3.00, 15.00, cache_read_usd_per_million=0.30, cache_write_usd_per_million=3.75
),
"claude-sonnet-4-6": _price(
3.00, 15.00, cache_read_usd_per_million=0.30, cache_write_usd_per_million=3.75
),
"claude-opus-4": _price(
15.00, 75.00, cache_read_usd_per_million=1.50, cache_write_usd_per_million=18.75
),
"claude-opus-4-0": _price(
15.00, 75.00, cache_read_usd_per_million=1.50, cache_write_usd_per_million=18.75
),
"claude-opus-4-20250514": _price(
15.00, 75.00, cache_read_usd_per_million=1.50, cache_write_usd_per_million=18.75
),
"claude-opus-4-1": _price(
15.00, 75.00, cache_read_usd_per_million=1.50, cache_write_usd_per_million=18.75
),
"claude-opus-4-1-20250805": _price(
15.00, 75.00, cache_read_usd_per_million=1.50, cache_write_usd_per_million=18.75
),
"claude-opus-4-5": _price(
5.00, 25.00, cache_read_usd_per_million=0.50, cache_write_usd_per_million=6.25
),
"claude-opus-4-5-20251101": _price(
5.00, 25.00, cache_read_usd_per_million=0.50, cache_write_usd_per_million=6.25
),
"claude-opus-4-6": _price(
5.00, 25.00, cache_read_usd_per_million=0.50, cache_write_usd_per_million=6.25
),
"claude-opus-4-7": _price(
5.00, 25.00, cache_read_usd_per_million=0.50, cache_write_usd_per_million=6.25
),
"claude-fable-5": _price(
10.00, 50.00, cache_read_usd_per_million=1.00, cache_write_usd_per_million=12.50
),
# OpenAI / Codex.
"gpt-4o": _price(2.50, 10.00, cache_read_usd_per_million=1.25),
"gpt-4o-2024-05-13": _price(5.00, 15.00),
"gpt-4o-2024-08-06": _price(2.50, 10.00, cache_read_usd_per_million=1.25),
"gpt-4o-2024-11-20": _price(2.50, 10.00, cache_read_usd_per_million=1.25),
"gpt-4o-mini": _price(0.15, 0.60, cache_read_usd_per_million=0.08),
"gpt-5": _price(1.25, 10.00, cache_read_usd_per_million=0.125),
"gpt-5-chat-latest": _price(1.25, 10.00),
"gpt-5-codex": _price(1.25, 10.00, cache_read_usd_per_million=0.125),
"gpt-5-mini": _price(0.25, 2.00, cache_read_usd_per_million=0.025),
"gpt-5-nano": _price(0.05, 0.40, cache_read_usd_per_million=0.005),
"gpt-5-pro": _price(15.00, 120.00),
"gpt-5.1": _price(1.25, 10.00, cache_read_usd_per_million=0.13),
"gpt-5.1-chat-latest": _price(1.25, 10.00, cache_read_usd_per_million=0.125),
"gpt-5.1-codex": _price(1.25, 10.00, cache_read_usd_per_million=0.125),
"gpt-5.1-codex-max": _price(1.25, 10.00, cache_read_usd_per_million=0.125),
"gpt-5.1-codex-mini": _price(0.25, 2.00, cache_read_usd_per_million=0.025),
"gpt-5.2": _price(1.75, 14.00, cache_read_usd_per_million=0.175),
"gpt-5.2-chat-latest": _price(1.75, 14.00, cache_read_usd_per_million=0.175),
"gpt-5.2-codex": _price(1.75, 14.00, cache_read_usd_per_million=0.175),
"gpt-5.2-pro": _price(21.00, 168.00),
"gpt-5.3-chat-latest": _price(1.75, 14.00, cache_read_usd_per_million=0.175),
"gpt-5.3-codex": _price(1.75, 14.00, cache_read_usd_per_million=0.175),
"gpt-5.3-codex-spark": _price(1.75, 14.00, cache_read_usd_per_million=0.175),
"gpt-5.4": _price(2.50, 15.00, cache_read_usd_per_million=0.25),
"gpt-5.4-mini": _price(0.75, 4.50, cache_read_usd_per_million=0.075),
"gpt-5.4-nano": _price(0.20, 1.25, cache_read_usd_per_million=0.02),
"gpt-5.4-pro": _price(30.00, 180.00),
"gpt-5.5": _price(5.00, 30.00, cache_read_usd_per_million=0.50),
"gpt-5.5-pro": _price(30.00, 180.00),
"gpt-5.6-luna": _price(1.00, 6.00, cache_read_usd_per_million=0.10),
"gpt-5.6-sol": _price(5.00, 30.00, cache_read_usd_per_million=0.50),
"gpt-5.6-terra": _price(2.50, 15.00, cache_read_usd_per_million=0.25),
"o3": _price(2.00, 8.00, cache_read_usd_per_million=0.50),
"o3-deep-research": _price(10.00, 40.00, cache_read_usd_per_million=2.50),
"o3-mini": _price(1.10, 4.40, cache_read_usd_per_million=0.55),
"o3-pro": _price(20.00, 80.00),
}
_UNSORTED_FAMILY_FALLBACKS: tuple[tuple[str, str], ...] = (
("claude-3-5-sonnet", "claude-3-5-sonnet-20241022"),
("claude-3-5-haiku", "claude-3-5-haiku-20241022"),
("claude-haiku-4-5", "claude-haiku-4-5"),
("claude-fable-5", "claude-fable-5"),
("claude-sonnet-4-6", "claude-sonnet-4-6"),
("claude-sonnet-4-5", "claude-sonnet-4-5"),
("claude-sonnet-4", "claude-sonnet-4"),
("claude-opus-4-7", "claude-opus-4-7"),
("claude-opus-4-6", "claude-opus-4-6"),
("claude-opus-4-5", "claude-opus-4-5"),
("claude-opus-4-1", "claude-opus-4-1"),
("claude-opus-4", "claude-opus-4"),
("gpt-5.3-codex-spark", "gpt-5.3-codex-spark"),
("gpt-5.3-codex", "gpt-5.3-codex"),
("gpt-5.2-codex", "gpt-5.2-codex"),
("gpt-5.1-codex-mini", "gpt-5.1-codex-mini"),
("gpt-5.1-codex-max", "gpt-5.1-codex-max"),
("gpt-5.1-codex", "gpt-5.1-codex"),
("gpt-5-codex", "gpt-5-codex"),
("gpt-5.6-terra", "gpt-5.6-terra"),
("gpt-5.6-luna", "gpt-5.6-luna"),
("gpt-5.6-sol", "gpt-5.6-sol"),
# OpenAI routes the bare `gpt-5.6` alias to Sol server-side. The three
# rows above are longer prefixes, so they still win for terra/luna.
("gpt-5.6", "gpt-5.6-sol"),
("gpt-5.5-pro", "gpt-5.5-pro"),
("gpt-5.5", "gpt-5.5"),
("gpt-5.4-mini", "gpt-5.4-mini"),
("gpt-5.4-nano", "gpt-5.4-nano"),
("gpt-5.4-pro", "gpt-5.4-pro"),
("gpt-5.4", "gpt-5.4"),
("gpt-5.2-pro", "gpt-5.2-pro"),
("gpt-5.2", "gpt-5.2"),
("gpt-5.1", "gpt-5.1"),
("gpt-5-mini", "gpt-5-mini"),
("gpt-5-nano", "gpt-5-nano"),
("gpt-5-pro", "gpt-5-pro"),
("gpt-5", "gpt-5"),
("gpt-4o-mini", "gpt-4o-mini"),
("gpt-4o", "gpt-4o"),
("o3-deep-research", "o3-deep-research"),
("o3-mini", "o3-mini"),
("o3-pro", "o3-pro"),
("o3", "o3"),
)
# Longest-prefix-first so more specific families win. Build it
# programmatically so a future edit cannot silently shadow a longer
# family with its shorter prefix.
_FAMILY_FALLBACKS: tuple[tuple[str, str], ...] = tuple(
sorted(_UNSORTED_FAMILY_FALLBACKS, key=lambda item: len(item[0]), reverse=True)
)
def usd_for_usage(
usage: TokenUsage,
model: str | None,
override: PriceOverride | None = None,
) -> float | None:
"""Return USD for a structured usage sample.
Codex reports ``cached_input_tokens`` as a discounted subset of
``input_tokens``. If a future format reports cached input as a
disjoint counter, clamp to the current convention and log at
debug level instead of producing a negative non-cached input
total.
"""
price = _resolve_price(model, override)
if price is None:
return None
input_tokens = max(0.0, usage.input_tokens)
raw_cached_input_tokens = max(0.0, usage.cached_input_tokens)
if raw_cached_input_tokens > input_tokens:
logger.debug(
"cached_input_tokens exceeded input_tokens; clamping to input total",
extra={
"model": model,
"input_tokens": input_tokens,
"cached_input_tokens": raw_cached_input_tokens,
},
)
cached_input_tokens = min(raw_cached_input_tokens, input_tokens)
non_cached_input_tokens = input_tokens - cached_input_tokens
return (
non_cached_input_tokens * price.usd_per_input_token
+ cached_input_tokens * price.cached_input_rate
+ max(0.0, usage.output_tokens) * price.usd_per_output_token
+ max(0.0, usage.cache_read_input_tokens) * price.cache_read_rate
+ max(0.0, usage.cache_creation_input_tokens) * price.cache_creation_rate
)
def usd_per_hour_for_usage(
usage_per_min: TokenUsage,
model: str | None,
override: PriceOverride | None = None,
) -> float | None:
cost_per_min = usd_for_usage(usage_per_min, model, override)
if cost_per_min is None:
return None
return cost_per_min * 60.0
def usd_per_token_blended(model: str | None, override: PriceOverride | None = None) -> float | None:
price = _resolve_price(model, override)
if price is None:
return None
return 0.7 * price.usd_per_input_token + 0.3 * price.usd_per_output_token
def usd_per_hour(
tokens_per_min: float,
model: str | None,
override: PriceOverride | None = None,
) -> float | None:
"""Legacy blended API kept for callers/tests that only have a total."""
rate = usd_per_token_blended(model, override)
if rate is None:
return None
return tokens_per_min * 60.0 * rate
def normalize_model_name(model: str | None) -> str | None:
if model is None:
return None
candidates = _model_candidates(model)
for candidate in candidates:
if candidate in MODEL_PRICES:
return candidate
for candidate in candidates:
for prefix, canonical_id in _FAMILY_FALLBACKS:
if candidate.startswith(prefix):
return canonical_id
return candidates[0] if candidates else None
def _resolve_price(model: str | None, override: PriceOverride | None) -> ModelPrice | None:
base = _lookup_price(model) if model is not None else None
if override is None:
return base
input_rate = (
override.input_usd_per_million / _USD_PER_M
if override.input_usd_per_million is not None
else (base.usd_per_input_token if base is not None else None)
)
output_rate = (
override.output_usd_per_million / _USD_PER_M
if override.output_usd_per_million is not None
else (base.usd_per_output_token if base is not None else None)
)
if input_rate is None or output_rate is None:
return None
return ModelPrice(
usd_per_input_token=input_rate,
usd_per_output_token=output_rate,
usd_per_cached_input_token=_override_related_rate(
input_rate,
base.usd_per_cached_input_token if base is not None else None,
base.usd_per_input_token if base is not None else None,
),
usd_per_cache_read_input_token=_override_related_rate(
input_rate,
base.usd_per_cache_read_input_token if base is not None else None,
base.usd_per_input_token if base is not None else None,
),
usd_per_cache_creation_input_token=_override_related_rate(
input_rate,
base.usd_per_cache_creation_input_token if base is not None else None,
base.usd_per_input_token if base is not None else None,
),
)
def _override_related_rate(
effective_input_rate: float,
base_related_rate: float | None,
base_input_rate: float | None,
) -> float | None:
if base_related_rate is None or base_input_rate is None or base_input_rate == 0.0:
return None
return effective_input_rate * (base_related_rate / base_input_rate)
def _lookup_price(model: str) -> ModelPrice | None:
candidates = _model_candidates(model)
for candidate in candidates:
direct = MODEL_PRICES.get(candidate)
if direct is not None:
return direct
for candidate in candidates:
for prefix, canonical_id in _FAMILY_FALLBACKS:
if candidate.startswith(prefix):
resolved = MODEL_PRICES.get(canonical_id)
if resolved is not None:
return resolved
return None
@lru_cache(maxsize=512)
def _model_candidates(raw: str) -> tuple[str, ...]:
candidates: list[str] = []
def append(value: str) -> None:
normalized = value.strip().lower()
if normalized and normalized not in candidates:
candidates.append(normalized)
trimmed = raw.strip()
append(trimmed)
lower = trimmed.lower()
for prefix in ("openai/", "anthropic/", "anthropic."):
if lower.startswith(prefix):
append(trimmed[len(prefix) :])
if "claude-" in lower and "." in trimmed:
tail = trimmed.rsplit(".", maxsplit=1)[-1]
if tail.lower().startswith("claude-"):
append(tail)
index = 0
while index < len(candidates):
candidate = candidates[index]
if "@" in candidate:
base, suffix = candidate.split("@", maxsplit=1)
append(base)
if re.fullmatch(r"\d{8}", suffix):
append(f"{base}-{suffix}")
elif candidate.startswith("claude-"):
append(f"{candidate}@default")
for pattern in (r"-\d{4}-\d{2}-\d{2}$", r"-\d{8}$", r"-v\d+:\d+$"):
stripped = re.sub(pattern, "", candidate)
if stripped != candidate:
append(stripped)
index += 1
return tuple(candidates)
__all__ = [
"MODEL_PRICES",
"ModelPrice",
"PriceOverride",
"RATES_VERIFIED_AT",
"normalize_model_name",
"usd_for_usage",
"usd_per_hour",
"usd_per_hour_for_usage",
"usd_per_token_blended",
]