Follow-up fixes on top of the salvaged #83678 commit: 1. Hoist the MiniMax-M3 marker exclusion ABOVE the native-Anthropic early return. provider="anthropic" pointed at a MiniMax /anthropic proxy is a supported override (_anthropic_base_url_override_ok), and the is_native_anthropic branch matched on provider alone — returning (True, True) before the M3 exclusion was reached. Two regression tests pin the proxy route (M3 off, M2.7 still on). 2. Reuse the existing _model_name_suggests_minimax_m3() helper from agent/model_metadata.py instead of a second inline substring copy. 3. Drop the debug kwarg on normalize_usage() — it had zero production callers and duplicated standard logging level gating. The cache-observability line is now a plain logger.debug scoped to MiniMax providers on the Anthropic wire only, so the "+128 floor" note can no longer appear for native Anthropic where it is false. Tests updated accordingly (MiniMax logs, native Anthropic does not).
1454 lines
57 KiB
Python
1454 lines
57 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from decimal import Decimal
|
|
from typing import Any, Dict, Literal, Optional
|
|
|
|
from agent.model_metadata import fetch_endpoint_model_metadata, fetch_model_metadata
|
|
from utils import base_url_host_matches
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_PRICING = {"input": 0.0, "output": 0.0}
|
|
|
|
_ZERO = Decimal("0")
|
|
_ONE_MILLION = Decimal("1000000")
|
|
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
|
|
|
|
CostStatus = Literal["actual", "estimated", "included", "unknown"]
|
|
CostSource = Literal[
|
|
"provider_cost_api",
|
|
"provider_generation_api",
|
|
"provider_models_api",
|
|
"official_docs_snapshot",
|
|
"user_override",
|
|
"custom_contract",
|
|
"none",
|
|
]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CanonicalUsage:
|
|
input_tokens: int = 0
|
|
output_tokens: int = 0
|
|
cache_read_tokens: int = 0
|
|
cache_write_tokens: int = 0
|
|
reasoning_tokens: int = 0
|
|
request_count: int = 1
|
|
raw_usage: Optional[dict[str, Any]] = None
|
|
|
|
@property
|
|
def prompt_tokens(self) -> int:
|
|
return self.input_tokens + self.cache_read_tokens + self.cache_write_tokens
|
|
|
|
@property
|
|
def total_tokens(self) -> int:
|
|
return self.prompt_tokens + self.output_tokens
|
|
|
|
def __add__(self, other: "CanonicalUsage") -> "CanonicalUsage":
|
|
"""Sum two usage buckets (e.g. MoA advisor fan-out + aggregator).
|
|
|
|
``raw_usage`` is dropped on the sum — it describes a single API
|
|
response and cannot be meaningfully merged. ``request_count`` adds so
|
|
callers can see how many underlying API calls a combined figure covers.
|
|
"""
|
|
if not isinstance(other, CanonicalUsage):
|
|
return NotImplemented
|
|
return CanonicalUsage(
|
|
input_tokens=self.input_tokens + other.input_tokens,
|
|
output_tokens=self.output_tokens + other.output_tokens,
|
|
cache_read_tokens=self.cache_read_tokens + other.cache_read_tokens,
|
|
cache_write_tokens=self.cache_write_tokens + other.cache_write_tokens,
|
|
reasoning_tokens=self.reasoning_tokens + other.reasoning_tokens,
|
|
request_count=self.request_count + other.request_count,
|
|
raw_usage=None,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BillingRoute:
|
|
provider: str
|
|
model: str
|
|
base_url: str = ""
|
|
billing_mode: str = "unknown"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PricingEntry:
|
|
input_cost_per_million: Optional[Decimal] = None
|
|
output_cost_per_million: Optional[Decimal] = None
|
|
cache_read_cost_per_million: Optional[Decimal] = None
|
|
cache_write_cost_per_million: Optional[Decimal] = None
|
|
request_cost: Optional[Decimal] = None
|
|
source: CostSource = "none"
|
|
source_url: Optional[str] = None
|
|
pricing_version: Optional[str] = None
|
|
fetched_at: Optional[datetime] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CostResult:
|
|
amount_usd: Optional[Decimal]
|
|
status: CostStatus
|
|
source: CostSource
|
|
label: str
|
|
fetched_at: Optional[datetime] = None
|
|
pricing_version: Optional[str] = None
|
|
notes: tuple[str, ...] = ()
|
|
|
|
|
|
_UTC_NOW = lambda: datetime.now(timezone.utc)
|
|
|
|
|
|
# Official docs snapshot entries. Models whose published pricing and cache
|
|
# semantics are stable enough to encode exactly.
|
|
_OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
|
# ── OpenAI GPT-5.6 series (Sol/Terra/Luna) ───────────────────────────
|
|
# Announced in limited preview 2026-06-26; GA 2026-07-09 at the same
|
|
# rates (Sol $5/$30, Terra $2.50/$15, Luna $1/$6 per 1M in/out). Cache
|
|
# writes are billed at 1.25x the uncached input rate; cache reads get the
|
|
# standard 90% discount (0.10x input, confirmed: Sol $0.50/M cached).
|
|
# Note: "Sol Fast mode" ($12.5/$75, up to 750 tok/s via Cerebras) is a
|
|
# separate serving tier, not covered by these entries. The "-pro"
|
|
# variants (high-effort modes, GA alongside base tiers) bill at the
|
|
# SAME per-token rates and are aliased onto these entries below the
|
|
# dict (they cost more per task by consuming more tokens, not by a
|
|
# higher rate — verified against OpenRouter's live pricing 2026-07-09).
|
|
# Source: https://openai.com/index/previewing-gpt-5-6-sol/
|
|
(
|
|
"openai",
|
|
"gpt-5.6-sol",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("5.00"),
|
|
output_cost_per_million=Decimal("30.00"),
|
|
cache_read_cost_per_million=Decimal("0.50"),
|
|
cache_write_cost_per_million=Decimal("6.25"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://openai.com/index/previewing-gpt-5-6-sol/",
|
|
pricing_version="openai-gpt-5.6-2026-07",
|
|
),
|
|
(
|
|
"openai",
|
|
"gpt-5.6-terra",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("2.50"),
|
|
output_cost_per_million=Decimal("15.00"),
|
|
cache_read_cost_per_million=Decimal("0.25"),
|
|
cache_write_cost_per_million=Decimal("3.125"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://openai.com/index/previewing-gpt-5-6-sol/",
|
|
pricing_version="openai-gpt-5.6-2026-07",
|
|
),
|
|
(
|
|
"openai",
|
|
"gpt-5.6-luna",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("1.00"),
|
|
output_cost_per_million=Decimal("6.00"),
|
|
cache_read_cost_per_million=Decimal("0.10"),
|
|
cache_write_cost_per_million=Decimal("1.25"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://openai.com/index/previewing-gpt-5-6-sol/",
|
|
pricing_version="openai-gpt-5.6-2026-07",
|
|
),
|
|
# ── Anthropic Claude 4.8 ─────────────────────────────────────────────
|
|
# Same $5/$25 base pricing as 4.6/4.7. Fast-mode variant is a separate
|
|
# model ID with 2x premium (vs the 6x premium on older Opus generations).
|
|
# Source: https://openrouter.ai/anthropic/claude-opus-4.8
|
|
(
|
|
"anthropic",
|
|
"claude-opus-4-8",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("5.00"),
|
|
output_cost_per_million=Decimal("25.00"),
|
|
cache_read_cost_per_million=Decimal("0.50"),
|
|
cache_write_cost_per_million=Decimal("6.25"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
pricing_version="anthropic-pricing-2026-05",
|
|
),
|
|
(
|
|
"anthropic",
|
|
"claude-opus-4-8-fast",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("10.00"),
|
|
output_cost_per_million=Decimal("50.00"),
|
|
cache_read_cost_per_million=Decimal("1.00"),
|
|
cache_write_cost_per_million=Decimal("12.50"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://openrouter.ai/anthropic/claude-opus-4.8-fast",
|
|
pricing_version="anthropic-pricing-2026-05",
|
|
),
|
|
# ── Anthropic Claude Sonnet 5 ────────────────────────────────────────
|
|
# Launched 2026-06-30. Introductory pricing ($2/$10 per MTok) runs
|
|
# through 2026-08-31, after which it reverts to $3/$15 (matching
|
|
# Sonnet 4.6). Update this entry when the intro window closes.
|
|
# Source: https://platform.claude.com/docs/en/about-claude/pricing
|
|
(
|
|
"anthropic",
|
|
"claude-sonnet-5",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("2.00"),
|
|
output_cost_per_million=Decimal("10.00"),
|
|
cache_read_cost_per_million=Decimal("0.20"),
|
|
cache_write_cost_per_million=Decimal("2.50"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
pricing_version="anthropic-pricing-2026-06-intro",
|
|
),
|
|
# ── Anthropic Claude 4.7 ─────────────────────────────────────────────
|
|
# Opus 4.5/4.6/4.7 share $5/$25 pricing (new tokenizer, up to 35% more
|
|
# tokens for the same text).
|
|
# Source: https://platform.claude.com/docs/en/about-claude/pricing
|
|
(
|
|
"anthropic",
|
|
"claude-opus-4-7",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("5.00"),
|
|
output_cost_per_million=Decimal("25.00"),
|
|
cache_read_cost_per_million=Decimal("0.50"),
|
|
cache_write_cost_per_million=Decimal("6.25"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
pricing_version="anthropic-pricing-2026-05",
|
|
),
|
|
(
|
|
"anthropic",
|
|
"claude-opus-4-7-20250507",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("5.00"),
|
|
output_cost_per_million=Decimal("25.00"),
|
|
cache_read_cost_per_million=Decimal("0.50"),
|
|
cache_write_cost_per_million=Decimal("6.25"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
pricing_version="anthropic-pricing-2026-05",
|
|
),
|
|
# ── Anthropic Claude 4.6 ─────────────────────────────────────────────
|
|
(
|
|
"anthropic",
|
|
"claude-opus-4-6",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("5.00"),
|
|
output_cost_per_million=Decimal("25.00"),
|
|
cache_read_cost_per_million=Decimal("0.50"),
|
|
cache_write_cost_per_million=Decimal("6.25"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
pricing_version="anthropic-pricing-2026-05",
|
|
),
|
|
(
|
|
"anthropic",
|
|
"claude-opus-4-6-20250414",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("5.00"),
|
|
output_cost_per_million=Decimal("25.00"),
|
|
cache_read_cost_per_million=Decimal("0.50"),
|
|
cache_write_cost_per_million=Decimal("6.25"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
pricing_version="anthropic-pricing-2026-05",
|
|
),
|
|
(
|
|
"anthropic",
|
|
"claude-sonnet-4-6",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("3.00"),
|
|
output_cost_per_million=Decimal("15.00"),
|
|
cache_read_cost_per_million=Decimal("0.30"),
|
|
cache_write_cost_per_million=Decimal("3.75"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
pricing_version="anthropic-pricing-2026-05",
|
|
),
|
|
(
|
|
"anthropic",
|
|
"claude-sonnet-4-6-20250414",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("3.00"),
|
|
output_cost_per_million=Decimal("15.00"),
|
|
cache_read_cost_per_million=Decimal("0.30"),
|
|
cache_write_cost_per_million=Decimal("3.75"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
pricing_version="anthropic-pricing-2026-05",
|
|
),
|
|
# ── Anthropic Claude 4.5 ─────────────────────────────────────────────
|
|
(
|
|
"anthropic",
|
|
"claude-opus-4-5",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("5.00"),
|
|
output_cost_per_million=Decimal("25.00"),
|
|
cache_read_cost_per_million=Decimal("0.50"),
|
|
cache_write_cost_per_million=Decimal("6.25"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
pricing_version="anthropic-pricing-2026-05",
|
|
),
|
|
(
|
|
"anthropic",
|
|
"claude-sonnet-4-5",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("3.00"),
|
|
output_cost_per_million=Decimal("15.00"),
|
|
cache_read_cost_per_million=Decimal("0.30"),
|
|
cache_write_cost_per_million=Decimal("3.75"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
pricing_version="anthropic-pricing-2026-05",
|
|
),
|
|
(
|
|
"anthropic",
|
|
"claude-haiku-4-5",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("1.00"),
|
|
output_cost_per_million=Decimal("5.00"),
|
|
cache_read_cost_per_million=Decimal("0.10"),
|
|
cache_write_cost_per_million=Decimal("1.25"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
pricing_version="anthropic-pricing-2026-05",
|
|
),
|
|
# ── Anthropic Claude 4 / 4.1 ─────────────────────────────────────────
|
|
(
|
|
"anthropic",
|
|
"claude-opus-4-20250514",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("15.00"),
|
|
output_cost_per_million=Decimal("75.00"),
|
|
cache_read_cost_per_million=Decimal("1.50"),
|
|
cache_write_cost_per_million=Decimal("18.75"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
pricing_version="anthropic-pricing-2026-05",
|
|
),
|
|
(
|
|
"anthropic",
|
|
"claude-sonnet-4-20250514",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("3.00"),
|
|
output_cost_per_million=Decimal("15.00"),
|
|
cache_read_cost_per_million=Decimal("0.30"),
|
|
cache_write_cost_per_million=Decimal("3.75"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
pricing_version="anthropic-pricing-2026-05",
|
|
),
|
|
# OpenAI
|
|
(
|
|
"openai",
|
|
"gpt-4o",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("2.50"),
|
|
output_cost_per_million=Decimal("10.00"),
|
|
cache_read_cost_per_million=Decimal("1.25"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://openai.com/api/pricing/",
|
|
pricing_version="openai-pricing-2026-03-16",
|
|
),
|
|
(
|
|
"openai",
|
|
"gpt-4o-mini",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.15"),
|
|
output_cost_per_million=Decimal("0.60"),
|
|
cache_read_cost_per_million=Decimal("0.075"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://openai.com/api/pricing/",
|
|
pricing_version="openai-pricing-2026-03-16",
|
|
),
|
|
(
|
|
"openai",
|
|
"gpt-4.1",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("2.00"),
|
|
output_cost_per_million=Decimal("8.00"),
|
|
cache_read_cost_per_million=Decimal("0.50"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://openai.com/api/pricing/",
|
|
pricing_version="openai-pricing-2026-03-16",
|
|
),
|
|
(
|
|
"openai",
|
|
"gpt-4.1-mini",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.40"),
|
|
output_cost_per_million=Decimal("1.60"),
|
|
cache_read_cost_per_million=Decimal("0.10"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://openai.com/api/pricing/",
|
|
pricing_version="openai-pricing-2026-03-16",
|
|
),
|
|
(
|
|
"openai",
|
|
"gpt-4.1-nano",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.10"),
|
|
output_cost_per_million=Decimal("0.40"),
|
|
cache_read_cost_per_million=Decimal("0.025"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://openai.com/api/pricing/",
|
|
pricing_version="openai-pricing-2026-03-16",
|
|
),
|
|
(
|
|
"openai",
|
|
"o3",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("10.00"),
|
|
output_cost_per_million=Decimal("40.00"),
|
|
cache_read_cost_per_million=Decimal("2.50"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://openai.com/api/pricing/",
|
|
pricing_version="openai-pricing-2026-03-16",
|
|
),
|
|
(
|
|
"openai",
|
|
"o3-mini",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("1.10"),
|
|
output_cost_per_million=Decimal("4.40"),
|
|
cache_read_cost_per_million=Decimal("0.55"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://openai.com/api/pricing/",
|
|
pricing_version="openai-pricing-2026-03-16",
|
|
),
|
|
# ── Anthropic older models (pre-4.5 generation) ────────────────────────
|
|
(
|
|
"anthropic",
|
|
"claude-3-5-sonnet-20241022",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("3.00"),
|
|
output_cost_per_million=Decimal("15.00"),
|
|
cache_read_cost_per_million=Decimal("0.30"),
|
|
cache_write_cost_per_million=Decimal("3.75"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
pricing_version="anthropic-pricing-2026-05",
|
|
),
|
|
(
|
|
"anthropic",
|
|
"claude-3-5-haiku-20241022",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.80"),
|
|
output_cost_per_million=Decimal("4.00"),
|
|
cache_read_cost_per_million=Decimal("0.08"),
|
|
cache_write_cost_per_million=Decimal("1.00"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
pricing_version="anthropic-pricing-2026-05",
|
|
),
|
|
(
|
|
"anthropic",
|
|
"claude-3-opus-20240229",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("15.00"),
|
|
output_cost_per_million=Decimal("75.00"),
|
|
cache_read_cost_per_million=Decimal("1.50"),
|
|
cache_write_cost_per_million=Decimal("18.75"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
pricing_version="anthropic-pricing-2026-05",
|
|
),
|
|
(
|
|
"anthropic",
|
|
"claude-3-haiku-20240307",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.25"),
|
|
output_cost_per_million=Decimal("1.25"),
|
|
cache_read_cost_per_million=Decimal("0.03"),
|
|
cache_write_cost_per_million=Decimal("0.30"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
pricing_version="anthropic-pricing-2026-05",
|
|
),
|
|
# DeepSeek
|
|
# Snapshot of https://api-docs.deepseek.com/quick_start/pricing (2026-07).
|
|
# deepseek-chat / deepseek-reasoner are deprecated 2026-07-24 and now alias
|
|
# deepseek-v4-flash's non-thinking / thinking modes — same rates.
|
|
(
|
|
"deepseek",
|
|
"deepseek-chat",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.14"),
|
|
output_cost_per_million=Decimal("0.28"),
|
|
cache_read_cost_per_million=Decimal("0.0028"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://api-docs.deepseek.com/quick_start/pricing",
|
|
pricing_version="deepseek-pricing-2026-07",
|
|
),
|
|
(
|
|
"deepseek",
|
|
"deepseek-reasoner",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.14"),
|
|
output_cost_per_million=Decimal("0.28"),
|
|
cache_read_cost_per_million=Decimal("0.0028"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://api-docs.deepseek.com/quick_start/pricing",
|
|
pricing_version="deepseek-pricing-2026-07",
|
|
),
|
|
(
|
|
"deepseek",
|
|
"deepseek-v4-pro",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.435"),
|
|
output_cost_per_million=Decimal("0.87"),
|
|
cache_read_cost_per_million=Decimal("0.003625"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://api-docs.deepseek.com/quick_start/pricing",
|
|
pricing_version="deepseek-pricing-2026-07",
|
|
),
|
|
(
|
|
"deepseek",
|
|
"deepseek-v4-flash",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.14"),
|
|
output_cost_per_million=Decimal("0.28"),
|
|
cache_read_cost_per_million=Decimal("0.0028"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://api-docs.deepseek.com/quick_start/pricing",
|
|
pricing_version="deepseek-pricing-2026-07",
|
|
),
|
|
# Google Gemini
|
|
(
|
|
"google",
|
|
"gemini-3.6-flash",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("1.50"),
|
|
output_cost_per_million=Decimal("7.50"),
|
|
cache_read_cost_per_million=Decimal("0.15"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://ai.google.dev/gemini-api/docs/pricing",
|
|
pricing_version="google-pricing-2026-07-28",
|
|
),
|
|
(
|
|
"google",
|
|
"gemini-3.5-flash",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("1.50"),
|
|
output_cost_per_million=Decimal("9.00"),
|
|
cache_read_cost_per_million=Decimal("0.15"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://ai.google.dev/pricing",
|
|
pricing_version="google-pricing-2026-07-07",
|
|
),
|
|
(
|
|
"google",
|
|
"gemini-3.5-flash-lite",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.30"),
|
|
output_cost_per_million=Decimal("2.50"),
|
|
cache_read_cost_per_million=Decimal("0.03"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://ai.google.dev/gemini-api/docs/pricing",
|
|
pricing_version="google-pricing-2026-07-28",
|
|
),
|
|
(
|
|
"google",
|
|
"gemini-3.1-pro",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("2.00"),
|
|
output_cost_per_million=Decimal("12.00"),
|
|
cache_read_cost_per_million=Decimal("0.20"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://ai.google.dev/pricing",
|
|
pricing_version="google-pricing-2026-07-07",
|
|
),
|
|
(
|
|
"google",
|
|
"gemini-3.1-flash-lite",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.25"),
|
|
output_cost_per_million=Decimal("1.50"),
|
|
cache_read_cost_per_million=Decimal("0.025"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://ai.google.dev/pricing",
|
|
pricing_version="google-pricing-2026-07-07",
|
|
),
|
|
(
|
|
"google",
|
|
"gemini-3-pro-preview",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("2.00"),
|
|
output_cost_per_million=Decimal("12.00"),
|
|
cache_read_cost_per_million=Decimal("0.20"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://ai.google.dev/pricing",
|
|
pricing_version="google-pricing-2026-07-07",
|
|
),
|
|
(
|
|
"google",
|
|
"gemini-3-flash-preview",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.50"),
|
|
output_cost_per_million=Decimal("3.00"),
|
|
cache_read_cost_per_million=Decimal("0.05"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://ai.google.dev/pricing",
|
|
pricing_version="google-pricing-2026-07-07",
|
|
),
|
|
(
|
|
"google",
|
|
"gemini-2.5-pro",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("1.25"),
|
|
output_cost_per_million=Decimal("10.00"),
|
|
cache_read_cost_per_million=Decimal("0.125"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://ai.google.dev/pricing",
|
|
pricing_version="google-pricing-2026-07-07",
|
|
),
|
|
(
|
|
"google",
|
|
"gemini-2.5-flash",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.15"),
|
|
output_cost_per_million=Decimal("0.60"),
|
|
cache_read_cost_per_million=Decimal("0.015"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://ai.google.dev/pricing",
|
|
pricing_version="google-pricing-2026-07-07",
|
|
),
|
|
(
|
|
"google",
|
|
"gemini-2.0-flash",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.10"),
|
|
output_cost_per_million=Decimal("0.40"),
|
|
cache_read_cost_per_million=Decimal("0.01"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://ai.google.dev/pricing",
|
|
pricing_version="google-pricing-2026-07-07",
|
|
),
|
|
# AWS Bedrock — pricing per the Bedrock pricing page.
|
|
# Bedrock charges the same per-token rates as the model provider but
|
|
# through AWS billing. These are the on-demand prices (no commitment).
|
|
# Source: https://aws.amazon.com/bedrock/pricing/
|
|
# Current-gen Claude Opus on Bedrock. Commercial Bedrock on-demand
|
|
# mirrors Anthropic's published list price for the Claude line
|
|
# ($5/$25 for Opus 4.6/4.7/4.8; cache write = 1.25x input at the
|
|
# 5-minute TTL, cache read = 0.1x input). NOTE: the AWS Price List API
|
|
# had not published these SKUs machine-readably as of 2026-07 — these
|
|
# are commercial-list snapshots pending an authoritative machine source.
|
|
(
|
|
"bedrock",
|
|
"anthropic.claude-opus-4-8",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("5.00"),
|
|
output_cost_per_million=Decimal("25.00"),
|
|
cache_read_cost_per_million=Decimal("0.50"),
|
|
cache_write_cost_per_million=Decimal("6.25"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://aws.amazon.com/bedrock/pricing/",
|
|
pricing_version="anthropic-list-2026-07",
|
|
),
|
|
(
|
|
"bedrock",
|
|
"anthropic.claude-opus-4-7",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("5.00"),
|
|
output_cost_per_million=Decimal("25.00"),
|
|
cache_read_cost_per_million=Decimal("0.50"),
|
|
cache_write_cost_per_million=Decimal("6.25"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://aws.amazon.com/bedrock/pricing/",
|
|
pricing_version="anthropic-list-2026-07",
|
|
),
|
|
(
|
|
"bedrock",
|
|
"anthropic.claude-opus-4-6",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("5.00"),
|
|
output_cost_per_million=Decimal("25.00"),
|
|
cache_read_cost_per_million=Decimal("0.50"),
|
|
cache_write_cost_per_million=Decimal("6.25"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://aws.amazon.com/bedrock/pricing/",
|
|
pricing_version="anthropic-list-2026-07",
|
|
),
|
|
(
|
|
"bedrock",
|
|
"anthropic.claude-sonnet-5",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("3.00"),
|
|
output_cost_per_million=Decimal("15.00"),
|
|
cache_read_cost_per_million=Decimal("0.30"),
|
|
cache_write_cost_per_million=Decimal("3.75"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://aws.amazon.com/bedrock/pricing/",
|
|
pricing_version="bedrock-pricing-2026-06",
|
|
),
|
|
(
|
|
"bedrock",
|
|
"anthropic.claude-sonnet-4-6",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("3.00"),
|
|
output_cost_per_million=Decimal("15.00"),
|
|
cache_read_cost_per_million=Decimal("0.30"),
|
|
cache_write_cost_per_million=Decimal("3.75"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://aws.amazon.com/bedrock/pricing/",
|
|
pricing_version="bedrock-pricing-2026-04",
|
|
),
|
|
(
|
|
"bedrock",
|
|
"anthropic.claude-sonnet-4-5",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("3.00"),
|
|
output_cost_per_million=Decimal("15.00"),
|
|
cache_read_cost_per_million=Decimal("0.30"),
|
|
cache_write_cost_per_million=Decimal("3.75"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://aws.amazon.com/bedrock/pricing/",
|
|
pricing_version="bedrock-pricing-2026-04",
|
|
),
|
|
(
|
|
"bedrock",
|
|
"anthropic.claude-haiku-4-5",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.80"),
|
|
output_cost_per_million=Decimal("4.00"),
|
|
cache_read_cost_per_million=Decimal("0.08"),
|
|
cache_write_cost_per_million=Decimal("1.00"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://aws.amazon.com/bedrock/pricing/",
|
|
pricing_version="bedrock-pricing-2026-04",
|
|
),
|
|
(
|
|
"bedrock",
|
|
"amazon.nova-pro",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.80"),
|
|
output_cost_per_million=Decimal("3.20"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://aws.amazon.com/bedrock/pricing/",
|
|
pricing_version="bedrock-pricing-2026-04",
|
|
),
|
|
(
|
|
"bedrock",
|
|
"amazon.nova-lite",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.06"),
|
|
output_cost_per_million=Decimal("0.24"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://aws.amazon.com/bedrock/pricing/",
|
|
pricing_version="bedrock-pricing-2026-04",
|
|
),
|
|
(
|
|
"bedrock",
|
|
"amazon.nova-micro",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.035"),
|
|
output_cost_per_million=Decimal("0.14"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://aws.amazon.com/bedrock/pricing/",
|
|
pricing_version="bedrock-pricing-2026-04",
|
|
),
|
|
# MiniMax
|
|
(
|
|
"minimax",
|
|
"minimax-m2.7",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.30"),
|
|
output_cost_per_million=Decimal("1.20"),
|
|
source="official_docs_snapshot",
|
|
pricing_version="minimax-pricing-2026-04",
|
|
),
|
|
(
|
|
"minimax-cn",
|
|
"minimax-m2.7",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.30"),
|
|
output_cost_per_million=Decimal("1.20"),
|
|
source="official_docs_snapshot",
|
|
pricing_version="minimax-pricing-2026-04",
|
|
),
|
|
# Fireworks AI — serverless pricing for the models hermes typically routes
|
|
# through when configured with provider="fireworks". Fireworks publishes a
|
|
# cached_input rate per model alongside input/output, which maps to
|
|
# cache_read_cost_per_million. No separately published cache_write rate.
|
|
# Snapshot of https://docs.fireworks.ai/serverless/pricing (Standard tier).
|
|
(
|
|
"fireworks",
|
|
"kimi-k2p6",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.95"),
|
|
output_cost_per_million=Decimal("4.00"),
|
|
cache_read_cost_per_million=Decimal("0.16"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://docs.fireworks.ai/serverless/pricing",
|
|
pricing_version="fireworks-pricing-2026-07",
|
|
),
|
|
(
|
|
"fireworks",
|
|
"kimi-k2p7-code",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.95"),
|
|
output_cost_per_million=Decimal("4.00"),
|
|
cache_read_cost_per_million=Decimal("0.19"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://docs.fireworks.ai/serverless/pricing",
|
|
pricing_version="fireworks-pricing-2026-07",
|
|
),
|
|
(
|
|
"fireworks",
|
|
"glm-5p2",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("1.40"),
|
|
output_cost_per_million=Decimal("4.40"),
|
|
cache_read_cost_per_million=Decimal("0.14"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://docs.fireworks.ai/serverless/pricing",
|
|
pricing_version="fireworks-pricing-2026-07",
|
|
),
|
|
(
|
|
"fireworks",
|
|
"deepseek-v4-pro",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("1.74"),
|
|
output_cost_per_million=Decimal("3.48"),
|
|
cache_read_cost_per_million=Decimal("0.145"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://docs.fireworks.ai/serverless/pricing",
|
|
pricing_version="fireworks-pricing-2026-07",
|
|
),
|
|
(
|
|
"fireworks",
|
|
"deepseek-v4-flash",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.14"),
|
|
output_cost_per_million=Decimal("0.28"),
|
|
cache_read_cost_per_million=Decimal("0.028"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://docs.fireworks.ai/serverless/pricing",
|
|
pricing_version="fireworks-pricing-2026-07",
|
|
),
|
|
(
|
|
"fireworks",
|
|
"qwen3p7-plus",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.40"),
|
|
output_cost_per_million=Decimal("1.60"),
|
|
cache_read_cost_per_million=Decimal("0.08"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://docs.fireworks.ai/serverless/pricing",
|
|
pricing_version="fireworks-pricing-2026-07",
|
|
),
|
|
(
|
|
"fireworks",
|
|
"minimax-m3",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.30"),
|
|
output_cost_per_million=Decimal("1.20"),
|
|
cache_read_cost_per_million=Decimal("0.06"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://docs.fireworks.ai/serverless/pricing",
|
|
pricing_version="fireworks-pricing-2026-07",
|
|
),
|
|
(
|
|
"fireworks",
|
|
"gpt-oss-120b",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.15"),
|
|
output_cost_per_million=Decimal("0.60"),
|
|
cache_read_cost_per_million=Decimal("0.015"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://docs.fireworks.ai/serverless/pricing",
|
|
pricing_version="fireworks-pricing-2026-07",
|
|
),
|
|
(
|
|
"fireworks",
|
|
"gpt-oss-20b",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.07"),
|
|
output_cost_per_million=Decimal("0.30"),
|
|
cache_read_cost_per_million=Decimal("0.035"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://docs.fireworks.ai/serverless/pricing",
|
|
pricing_version="fireworks-pricing-2026-07",
|
|
),
|
|
(
|
|
"fireworks",
|
|
"glm-5p1",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("1.40"),
|
|
output_cost_per_million=Decimal("4.40"),
|
|
cache_read_cost_per_million=Decimal("0.26"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://docs.fireworks.ai/serverless/pricing",
|
|
pricing_version="fireworks-pricing-2026-07",
|
|
),
|
|
(
|
|
"fireworks",
|
|
"minimax-m2p7",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("0.30"),
|
|
output_cost_per_million=Decimal("1.20"),
|
|
cache_read_cost_per_million=Decimal("0.06"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://docs.fireworks.ai/serverless/pricing",
|
|
pricing_version="fireworks-pricing-2026-07",
|
|
),
|
|
# Fast/turbo serving tiers — exposed as accounts/fireworks/routers/<name>,
|
|
# so rsplit("/", 1) yields these distinct ids with their own (higher) rates.
|
|
(
|
|
"fireworks",
|
|
"kimi-k2p6-fast",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("2.00"),
|
|
output_cost_per_million=Decimal("8.00"),
|
|
cache_read_cost_per_million=Decimal("0.30"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://docs.fireworks.ai/serverless/pricing",
|
|
pricing_version="fireworks-pricing-2026-07",
|
|
),
|
|
(
|
|
"fireworks",
|
|
"kimi-k2p6-turbo",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("2.00"),
|
|
output_cost_per_million=Decimal("8.00"),
|
|
cache_read_cost_per_million=Decimal("0.30"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://docs.fireworks.ai/serverless/pricing",
|
|
pricing_version="fireworks-pricing-2026-07",
|
|
),
|
|
(
|
|
"fireworks",
|
|
"kimi-k2p7-code-fast",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("1.90"),
|
|
output_cost_per_million=Decimal("8.00"),
|
|
cache_read_cost_per_million=Decimal("0.38"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://docs.fireworks.ai/serverless/pricing",
|
|
pricing_version="fireworks-pricing-2026-07",
|
|
),
|
|
(
|
|
"fireworks",
|
|
"glm-5p2-fast",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("2.10"),
|
|
output_cost_per_million=Decimal("6.60"),
|
|
cache_read_cost_per_million=Decimal("0.21"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://docs.fireworks.ai/serverless/pricing",
|
|
pricing_version="fireworks-pricing-2026-07",
|
|
),
|
|
(
|
|
"fireworks",
|
|
"glm-5p1-fast",
|
|
): PricingEntry(
|
|
input_cost_per_million=Decimal("2.80"),
|
|
output_cost_per_million=Decimal("8.80"),
|
|
cache_read_cost_per_million=Decimal("0.52"),
|
|
source="official_docs_snapshot",
|
|
source_url="https://docs.fireworks.ai/serverless/pricing",
|
|
pricing_version="fireworks-pricing-2026-07",
|
|
),
|
|
}
|
|
|
|
# GPT-5.6 "-pro" high-effort variants bill at the same per-token rates as
|
|
# their base tiers (more tokens per task, not a higher rate). Alias them
|
|
# onto the base entries so the snapshot stays single-source.
|
|
for _base_56 in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"):
|
|
_OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-pro")] = _OFFICIAL_DOCS_PRICING[
|
|
("openai", _base_56)
|
|
]
|
|
del _base_56
|
|
|
|
# The direct Gemini provider currently exposes preview IDs for these two
|
|
# models. Keep the official snapshot keyed by both their documented stable
|
|
# names and the provider's emitted IDs so a catalog selection is billable.
|
|
for _alias, _canonical in {
|
|
"gemini-3.1-pro-preview": "gemini-3.1-pro",
|
|
"gemini-3.1-flash-lite-preview": "gemini-3.1-flash-lite",
|
|
}.items():
|
|
_OFFICIAL_DOCS_PRICING[("google", _alias)] = _OFFICIAL_DOCS_PRICING[
|
|
("google", _canonical)
|
|
]
|
|
del _alias, _canonical
|
|
|
|
|
|
def _to_decimal(value: Any) -> Optional[Decimal]:
|
|
if value is None:
|
|
return None
|
|
try:
|
|
return Decimal(str(value))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _to_int(value: Any) -> int:
|
|
try:
|
|
return int(value or 0)
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
def resolve_billing_route(
|
|
model_name: str,
|
|
provider: Optional[str] = None,
|
|
base_url: Optional[str] = None,
|
|
) -> BillingRoute:
|
|
provider_name = (provider or "").strip().lower()
|
|
base = (base_url or "").strip().lower()
|
|
model = (model_name or "").strip()
|
|
if not provider_name and "/" in model:
|
|
inferred_provider, bare_model = model.split("/", 1)
|
|
if inferred_provider in {"anthropic", "openai", "google"}:
|
|
provider_name = inferred_provider
|
|
model = bare_model
|
|
|
|
if provider_name == "openai-codex":
|
|
return BillingRoute(provider="openai-codex", model=model, base_url=base_url or "", billing_mode="subscription_included")
|
|
if provider_name == "openrouter" or base_url_host_matches(base_url or "", "openrouter.ai"):
|
|
return BillingRoute(provider="openrouter", model=model, base_url=base_url or "", billing_mode="official_models_api")
|
|
if provider_name == "nous" or base_url_host_matches(base_url or "", "inference-api.nousresearch.com"):
|
|
return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api")
|
|
if provider_name == "anthropic":
|
|
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
|
# "openai-api" is the picker/registry slug for direct api.openai.com; it
|
|
# bills identically to bare "openai", so normalize it here — otherwise the
|
|
# ("openai", <model>) _OFFICIAL_DOCS_PRICING keys are unreachable from the
|
|
# openai-api provider path.
|
|
if provider_name in {"openai", "openai-api"}:
|
|
return BillingRoute(provider="openai", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
|
if provider_name in {"minimax", "minimax-cn"}:
|
|
return BillingRoute(provider=provider_name, model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
|
# Google AI Studio (Gemini) and Vertex AI host the same Gemini models.
|
|
# Price them off the official docs snapshot — the pricing keys are
|
|
# keyed on provider='google', so normalize every Google-flavored
|
|
# provider name/host onto it. Strip the "google/" vendor prefix the
|
|
# Vertex OpenAI-compat endpoint requires so the pricing key matches.
|
|
if (
|
|
provider_name in {"google", "gemini", "vertex", "google-gemini", "google-ai-studio", "google-vertex", "vertex-ai"}
|
|
or base_url_host_matches(base_url or "", "aiplatform.googleapis.com")
|
|
or base_url_host_matches(base_url or "", "generativelanguage.googleapis.com")
|
|
):
|
|
return BillingRoute(provider="google", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
|
if provider_name == "fireworks" or base_url_host_matches(base_url or "", "api.fireworks.ai"):
|
|
# Fireworks model ids look like accounts/fireworks/models/<name>;
|
|
# rsplit("/", 1)[-1] yields just <name> which is what the dict keys on.
|
|
return BillingRoute(provider="fireworks", model=model.rsplit("/", 1)[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
|
if provider_name in {"custom", "local"} or (base and "localhost" in base):
|
|
return BillingRoute(provider=provider_name or "custom", model=model, base_url=base_url or "", billing_mode="unknown")
|
|
return BillingRoute(provider=provider_name or "unknown", model=model.split("/")[-1] if model else "", base_url=base_url or "", billing_mode="unknown")
|
|
|
|
|
|
def _normalize_bedrock_model_name(model: str) -> str:
|
|
"""Normalize a Bedrock model id to its bare foundation-model form.
|
|
|
|
Bedrock cross-region inference profiles prefix the foundation model id
|
|
with a region scope (``us.`` / ``global.`` / ``eu.`` / ``apac.`` / ``au.``
|
|
/ ...), e.g. ``us.anthropic.claude-opus-4-7`` or
|
|
``au.anthropic.claude-sonnet-4-5-20250929-v1:0``. The pricing table is
|
|
keyed on the bare ``anthropic.claude-*`` id, so the prefix must be
|
|
stripped before the lookup or every cross-region session prices as
|
|
unknown. Note Asia-Pacific uses ``apac.`` (a bare ``ap.`` never matches
|
|
an ``apac.*`` id) and Australia/New Zealand use ``au.``. Also normalizes
|
|
dot-notation version numbers (``4.7`` → ``4-7``) and the documented
|
|
trailing date, revision, and profile components (``-20250514-v1:0``).
|
|
"""
|
|
name = model.lower().strip()
|
|
for prefix in (
|
|
"global.",
|
|
"us.",
|
|
"eu.",
|
|
"apac.",
|
|
"ap.",
|
|
"au.",
|
|
"jp.",
|
|
"ca.",
|
|
"sa.",
|
|
"me.",
|
|
"af.",
|
|
):
|
|
if name.startswith(prefix):
|
|
name = name[len(prefix):]
|
|
break
|
|
name = re.sub(r"(\d+)\.(\d+)", r"\1-\2", name)
|
|
# Bedrock inference profile IDs append these documented components to the
|
|
# foundation model ID. Strip only the trailing forms, not arbitrary model
|
|
# name continuations that could be a distinct SKU.
|
|
name = re.sub(r":\d+$", "", name)
|
|
name = re.sub(r"-v\d+$", "", name)
|
|
name = re.sub(r"-\d{8}$", "", name)
|
|
return name
|
|
|
|
|
|
def _normalize_anthropic_model_name(model: str) -> str:
|
|
"""Normalize Anthropic model name variants to canonical form.
|
|
|
|
Handles:
|
|
- Dot notation: claude-opus-4.7 → claude-opus-4-7
|
|
- Short aliases: claude-opus-4.7 → claude-opus-4-7
|
|
- Strips anthropic/ prefix if present
|
|
"""
|
|
name = model.lower().strip()
|
|
if name.startswith("anthropic/"):
|
|
name = name[len("anthropic/"):]
|
|
# Normalize dots to dashes in version numbers (e.g. 4.7 → 4-7, 4.6 → 4-6)
|
|
# But preserve the rest of the name structure
|
|
name = re.sub(r"(\d+)\.(\d+)", r"\1-\2", name)
|
|
return name
|
|
|
|
|
|
def _lookup_official_docs_pricing(route: BillingRoute) -> Optional[PricingEntry]:
|
|
model = route.model.lower()
|
|
# Direct lookup first
|
|
entry = _OFFICIAL_DOCS_PRICING.get((route.provider, model))
|
|
if entry:
|
|
return entry
|
|
# Try normalized name for Anthropic (handles dot-notation like opus-4.7)
|
|
if route.provider == "anthropic":
|
|
normalized = _normalize_anthropic_model_name(model)
|
|
if normalized != model:
|
|
entry = _OFFICIAL_DOCS_PRICING.get((route.provider, normalized))
|
|
if entry:
|
|
return entry
|
|
# Bedrock cross-region inference profiles carry a region prefix
|
|
# (us./global./eu./...) that the bare pricing keys don't have.
|
|
if route.provider == "bedrock":
|
|
normalized = _normalize_bedrock_model_name(model)
|
|
if normalized != model:
|
|
entry = _OFFICIAL_DOCS_PRICING.get((route.provider, normalized))
|
|
if entry:
|
|
return entry
|
|
return None
|
|
|
|
|
|
def _openrouter_pricing_entry(route: BillingRoute) -> Optional[PricingEntry]:
|
|
return _pricing_entry_from_metadata(
|
|
fetch_model_metadata(),
|
|
route.model,
|
|
source_url="https://openrouter.ai/docs/api/api-reference/models/get-models",
|
|
pricing_version="openrouter-models-api",
|
|
)
|
|
|
|
|
|
def _pricing_entry_from_metadata(
|
|
metadata: Dict[str, Dict[str, Any]],
|
|
model_id: str,
|
|
*,
|
|
source_url: str,
|
|
pricing_version: str,
|
|
) -> Optional[PricingEntry]:
|
|
if model_id not in metadata:
|
|
return None
|
|
pricing = metadata[model_id].get("pricing") or {}
|
|
prompt = _to_decimal(pricing.get("prompt"))
|
|
completion = _to_decimal(pricing.get("completion"))
|
|
request = _to_decimal(pricing.get("request"))
|
|
cache_read = _to_decimal(
|
|
pricing.get("cache_read")
|
|
or pricing.get("cached_prompt")
|
|
or pricing.get("input_cache_read")
|
|
)
|
|
cache_write = _to_decimal(
|
|
pricing.get("cache_write")
|
|
or pricing.get("cache_creation")
|
|
or pricing.get("input_cache_write")
|
|
)
|
|
if prompt is None and completion is None and request is None:
|
|
return None
|
|
|
|
def _per_token_to_per_million(value: Optional[Decimal]) -> Optional[Decimal]:
|
|
if value is None:
|
|
return None
|
|
return value * _ONE_MILLION
|
|
|
|
return PricingEntry(
|
|
input_cost_per_million=_per_token_to_per_million(prompt),
|
|
output_cost_per_million=_per_token_to_per_million(completion),
|
|
cache_read_cost_per_million=_per_token_to_per_million(cache_read),
|
|
cache_write_cost_per_million=_per_token_to_per_million(cache_write),
|
|
request_cost=request,
|
|
source="provider_models_api",
|
|
source_url=source_url,
|
|
pricing_version=pricing_version,
|
|
fetched_at=_UTC_NOW(),
|
|
)
|
|
|
|
|
|
def get_pricing_entry(
|
|
model_name: str,
|
|
provider: Optional[str] = None,
|
|
base_url: Optional[str] = None,
|
|
api_key: Optional[str] = None,
|
|
) -> Optional[PricingEntry]:
|
|
route = resolve_billing_route(model_name, provider=provider, base_url=base_url)
|
|
if route.billing_mode == "subscription_included":
|
|
return PricingEntry(
|
|
input_cost_per_million=_ZERO,
|
|
output_cost_per_million=_ZERO,
|
|
cache_read_cost_per_million=_ZERO,
|
|
cache_write_cost_per_million=_ZERO,
|
|
source="none",
|
|
pricing_version="included-route",
|
|
)
|
|
if route.provider == "openrouter":
|
|
return _openrouter_pricing_entry(route)
|
|
if route.base_url:
|
|
entry = _pricing_entry_from_metadata(
|
|
fetch_endpoint_model_metadata(route.base_url, api_key=api_key or ""),
|
|
route.model,
|
|
source_url=f"{route.base_url.rstrip('/')}/models",
|
|
pricing_version="openai-compatible-models-api",
|
|
)
|
|
if entry:
|
|
return entry
|
|
return _lookup_official_docs_pricing(route)
|
|
|
|
|
|
def normalize_usage(
|
|
response_usage: Any,
|
|
*,
|
|
provider: Optional[str] = None,
|
|
api_mode: Optional[str] = None,
|
|
) -> CanonicalUsage:
|
|
"""Normalize raw API response usage into canonical token buckets.
|
|
|
|
Handles three API shapes:
|
|
- Anthropic: input_tokens/output_tokens/cache_read_input_tokens/cache_creation_input_tokens
|
|
- Codex Responses: input_tokens includes cache tokens; input_tokens_details.cached_tokens separates them
|
|
- OpenAI Chat Completions: prompt_tokens includes cache tokens; prompt_tokens_details.cached_tokens separates them
|
|
|
|
In both Codex and OpenAI modes, input_tokens is derived by subtracting cache
|
|
tokens from the total — the API contract is that input/prompt totals include
|
|
cached tokens and the details object breaks them out.
|
|
"""
|
|
if not response_usage:
|
|
return CanonicalUsage()
|
|
|
|
provider_name = (provider or "").strip().lower()
|
|
mode = (api_mode or "").strip().lower()
|
|
|
|
if mode == "anthropic_messages" or provider_name == "anthropic":
|
|
input_tokens = _to_int(getattr(response_usage, "input_tokens", 0))
|
|
output_tokens = _to_int(getattr(response_usage, "output_tokens", 0))
|
|
cache_read_tokens = _to_int(getattr(response_usage, "cache_read_input_tokens", 0))
|
|
cache_write_tokens = _to_int(getattr(response_usage, "cache_creation_input_tokens", 0))
|
|
elif mode == "codex_responses":
|
|
input_total = _to_int(getattr(response_usage, "input_tokens", 0))
|
|
output_tokens = _to_int(getattr(response_usage, "output_tokens", 0))
|
|
details = getattr(response_usage, "input_tokens_details", None)
|
|
cache_read_tokens = _to_int(getattr(details, "cached_tokens", 0) if details else 0)
|
|
cache_write_tokens = _to_int(
|
|
getattr(details, "cache_creation_tokens", 0) if details else 0
|
|
)
|
|
input_tokens = max(0, input_total - cache_read_tokens - cache_write_tokens)
|
|
else:
|
|
prompt_total = _to_int(getattr(response_usage, "prompt_tokens", 0))
|
|
output_tokens = _to_int(getattr(response_usage, "completion_tokens", 0))
|
|
details = getattr(response_usage, "prompt_tokens_details", None)
|
|
# Primary: OpenAI-style prompt_tokens_details. Fallback: Anthropic-style
|
|
# top-level fields that some OpenAI-compatible proxies (OpenRouter, Vercel
|
|
# AI Gateway, Cline) expose when routing Claude models — without this
|
|
# fallback, cache writes are undercounted as 0 and cache reads can be
|
|
# missed when the proxy only surfaces them at the top level.
|
|
# Port of cline/cline#10266.
|
|
cache_read_tokens = _to_int(getattr(details, "cached_tokens", 0) if details else 0)
|
|
if not cache_read_tokens:
|
|
cache_read_tokens = _to_int(getattr(response_usage, "cache_read_input_tokens", 0))
|
|
if not cache_read_tokens:
|
|
# DeepSeek's native API (api.deepseek.com) reports context-cache
|
|
# hits as top-level prompt_cache_hit_tokens (+ the complementary
|
|
# prompt_cache_miss_tokens; prompt_tokens = hit + miss), not the
|
|
# OpenAI nested shape. Without this, direct DeepSeek sessions
|
|
# always showed 0 cache-hit tokens (#61871).
|
|
cache_read_tokens = _to_int(
|
|
getattr(response_usage, "prompt_cache_hit_tokens", 0)
|
|
)
|
|
cache_write_tokens = _to_int(
|
|
getattr(details, "cache_write_tokens", 0) if details else 0
|
|
)
|
|
if not cache_write_tokens:
|
|
cache_write_tokens = _to_int(
|
|
getattr(response_usage, "cache_creation_input_tokens", 0)
|
|
)
|
|
input_tokens = max(0, prompt_total - cache_read_tokens - cache_write_tokens)
|
|
|
|
reasoning_tokens = 0
|
|
# Responses API shape: output_tokens_details.reasoning_tokens.
|
|
# Chat Completions shape (OpenAI, OpenRouter, DeepSeek, etc.):
|
|
# completion_tokens_details.reasoning_tokens. Reading only the former
|
|
# left reasoning_tokens=0 for every chat_completions reasoning model —
|
|
# hidden thinking was invisible in session accounting even though it
|
|
# dominates output spend on models like deepseek-v4-flash (measured:
|
|
# single calls burning 21K reasoning tokens to emit 500 visible tokens).
|
|
output_details = getattr(response_usage, "output_tokens_details", None)
|
|
if output_details:
|
|
reasoning_tokens = _to_int(getattr(output_details, "reasoning_tokens", 0))
|
|
if not reasoning_tokens:
|
|
completion_details = getattr(response_usage, "completion_tokens_details", None)
|
|
if completion_details:
|
|
reasoning_tokens = _to_int(
|
|
getattr(completion_details, "reasoning_tokens", 0)
|
|
)
|
|
|
|
# Cache observability for MiniMax's Anthropic wire: on MiniMax-M3,
|
|
# usage.cache_read_input_tokens carries a constant +128 floor and
|
|
# cache_creation_input_tokens is always 0, so cache_read is NOT a
|
|
# reliable hit signal — the signal that survives is the input_tokens
|
|
# drop between consecutive calls. Standard level-gated logger.debug;
|
|
# enable via logging config to confirm cache behavior.
|
|
# Docs: https://platform.minimax.io/docs/api-reference/text-prompt-caching
|
|
if provider_name in {"minimax", "minimax-cn"} and mode == "anthropic_messages":
|
|
logger.debug(
|
|
"cache_observability provider=%s mode=%s input_tokens=%s "
|
|
"output_tokens=%s cache_read_tokens=%s cache_write_tokens=%s "
|
|
"(note: on MiniMax-M3 cache_read carries a +128 constant "
|
|
"floor and is not a reliable hit signal — track input_tokens "
|
|
"drops across calls instead)",
|
|
provider_name, mode, input_tokens, output_tokens,
|
|
cache_read_tokens, cache_write_tokens,
|
|
)
|
|
|
|
return CanonicalUsage(
|
|
input_tokens=input_tokens,
|
|
output_tokens=output_tokens,
|
|
cache_read_tokens=cache_read_tokens,
|
|
cache_write_tokens=cache_write_tokens,
|
|
reasoning_tokens=reasoning_tokens,
|
|
)
|
|
|
|
|
|
def estimate_usage_cost(
|
|
model_name: str,
|
|
usage: CanonicalUsage,
|
|
*,
|
|
provider: Optional[str] = None,
|
|
base_url: Optional[str] = None,
|
|
api_key: Optional[str] = None,
|
|
) -> CostResult:
|
|
route = resolve_billing_route(model_name, provider=provider, base_url=base_url)
|
|
if route.billing_mode == "subscription_included":
|
|
return CostResult(
|
|
amount_usd=_ZERO,
|
|
status="included",
|
|
source="none",
|
|
label="included",
|
|
pricing_version="included-route",
|
|
)
|
|
|
|
entry = get_pricing_entry(model_name, provider=provider, base_url=base_url, api_key=api_key)
|
|
if not entry:
|
|
return CostResult(amount_usd=None, status="unknown", source="none", label="n/a")
|
|
|
|
notes: list[str] = []
|
|
amount = _ZERO
|
|
|
|
if usage.input_tokens and entry.input_cost_per_million is None:
|
|
return CostResult(amount_usd=None, status="unknown", source=entry.source, label="n/a")
|
|
if usage.output_tokens and entry.output_cost_per_million is None:
|
|
return CostResult(amount_usd=None, status="unknown", source=entry.source, label="n/a")
|
|
if usage.cache_read_tokens:
|
|
if entry.cache_read_cost_per_million is None:
|
|
return CostResult(
|
|
amount_usd=None,
|
|
status="unknown",
|
|
source=entry.source,
|
|
label="n/a",
|
|
notes=("cache-read pricing unavailable for route",),
|
|
)
|
|
if usage.cache_write_tokens:
|
|
if entry.cache_write_cost_per_million is None:
|
|
return CostResult(
|
|
amount_usd=None,
|
|
status="unknown",
|
|
source=entry.source,
|
|
label="n/a",
|
|
notes=("cache-write pricing unavailable for route",),
|
|
)
|
|
|
|
if entry.input_cost_per_million is not None:
|
|
amount += Decimal(usage.input_tokens) * entry.input_cost_per_million / _ONE_MILLION
|
|
if entry.output_cost_per_million is not None:
|
|
amount += Decimal(usage.output_tokens) * entry.output_cost_per_million / _ONE_MILLION
|
|
if entry.cache_read_cost_per_million is not None:
|
|
amount += Decimal(usage.cache_read_tokens) * entry.cache_read_cost_per_million / _ONE_MILLION
|
|
if entry.cache_write_cost_per_million is not None:
|
|
amount += Decimal(usage.cache_write_tokens) * entry.cache_write_cost_per_million / _ONE_MILLION
|
|
if entry.request_cost is not None and usage.request_count:
|
|
amount += Decimal(usage.request_count) * entry.request_cost
|
|
|
|
status: CostStatus = "estimated"
|
|
label = f"~${amount:.2f}"
|
|
if entry.source == "none" and amount == _ZERO:
|
|
status = "included"
|
|
label = "included"
|
|
|
|
if route.provider == "openrouter":
|
|
notes.append("OpenRouter cost is estimated from the models API until reconciled.")
|
|
|
|
return CostResult(
|
|
amount_usd=amount,
|
|
status=status,
|
|
source=entry.source,
|
|
label=label,
|
|
fetched_at=entry.fetched_at,
|
|
pricing_version=entry.pricing_version,
|
|
notes=tuple(notes),
|
|
)
|
|
|
|
|
|
def has_known_pricing(
|
|
model_name: str,
|
|
provider: Optional[str] = None,
|
|
base_url: Optional[str] = None,
|
|
api_key: Optional[str] = None,
|
|
) -> bool:
|
|
"""Check whether we have pricing data for this model+route.
|
|
|
|
Uses direct lookup instead of routing through the full estimation
|
|
pipeline — avoids creating dummy usage objects just to check status.
|
|
"""
|
|
route = resolve_billing_route(model_name, provider=provider, base_url=base_url)
|
|
if route.billing_mode == "subscription_included":
|
|
return True
|
|
entry = get_pricing_entry(model_name, provider=provider, base_url=base_url, api_key=api_key)
|
|
return entry is not None
|
|
|
|
|
|
|
|
def format_duration_compact(seconds: float) -> str:
|
|
if seconds < 60:
|
|
return f"{seconds:.0f}s"
|
|
minutes = seconds / 60
|
|
if minutes < 60:
|
|
return f"{minutes:.0f}m"
|
|
hours = minutes / 60
|
|
if hours < 24:
|
|
remaining_min = int(minutes % 60)
|
|
return f"{int(hours)}h {remaining_min}m" if remaining_min else f"{int(hours)}h"
|
|
days = hours / 24
|
|
return f"{days:.1f}d"
|
|
|
|
|
|
def format_token_count_compact(value: int) -> str:
|
|
abs_value = abs(int(value))
|
|
if abs_value < 1_000:
|
|
return str(int(value))
|
|
|
|
sign = "-" if value < 0 else ""
|
|
units = ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K"))
|
|
for threshold, suffix in units:
|
|
if abs_value >= threshold:
|
|
scaled = abs_value / threshold
|
|
if scaled < 10:
|
|
text = f"{scaled:.2f}"
|
|
elif scaled < 100:
|
|
text = f"{scaled:.1f}"
|
|
else:
|
|
text = f"{scaled:.0f}"
|
|
if "." in text:
|
|
text = text.rstrip("0").rstrip(".")
|
|
return f"{sign}{text}{suffix}"
|
|
|
|
return f"{value:,}"
|