mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 15:02:20 +02:00
193 lines
5.9 KiB
Python
193 lines
5.9 KiB
Python
"""Server-side USD cost estimation for LLM turns.
|
|
|
|
Mirrors the webui's client-side pricing (static/js/chatRenderer.js MODEL_INFO +
|
|
static/js/model/matchKey.js) so the TUI and API surfaces can show $ spend
|
|
without a browser. Reported costs from the provider (OpenRouter `usage.cost`)
|
|
are always preferred; this module is the fallback estimator.
|
|
|
|
Unknown models return None — we never guess a price. Local / self-hosted /
|
|
subscription endpoints are free by definition (see is_local_endpoint /
|
|
is_subscription_endpoint).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from urllib.parse import urlsplit
|
|
|
|
# Per-1M-token USD prices, ported from static/js/chatRenderer.js MODEL_INFO.
|
|
# Keep in sync with the JS table (ctx window kept for parity/debugging).
|
|
MODEL_PRICING: dict[str, tuple[float, float]] = {
|
|
# --- Anthropic ---
|
|
"claude-sonnet-4-5": (3.00, 15.00),
|
|
"claude-sonnet-4-6": (3.00, 15.00),
|
|
"claude-sonnet-4": (3.00, 15.00),
|
|
"claude-opus-4": (15.00, 75.00),
|
|
"claude-opus-4-6": (15.00, 75.00),
|
|
"claude-haiku-4": (0.80, 4.00),
|
|
"claude-haiku-3-5": (0.80, 4.00),
|
|
"claude-3-5-sonnet": (3.00, 15.00),
|
|
"claude-3-5-haiku": (0.80, 4.00),
|
|
"claude-3-opus": (15.00, 75.00),
|
|
"claude-3-sonnet": (3.00, 15.00),
|
|
"claude-3-haiku": (0.25, 1.25),
|
|
# --- OpenAI ---
|
|
"gpt-5": (2.00, 8.00),
|
|
"gpt-4.1": (2.00, 8.00),
|
|
"gpt-4.1-mini": (0.40, 1.60),
|
|
"gpt-4.1-nano": (0.10, 0.40),
|
|
"gpt-4o": (2.50, 10.00),
|
|
"gpt-4o-mini": (0.15, 0.60),
|
|
"gpt-4-turbo": (10.00, 30.00),
|
|
"o1": (15.00, 60.00),
|
|
"o1-mini": (3.00, 12.00),
|
|
"o1-pro": (150.0, 600.0),
|
|
"o3": (2.00, 8.00),
|
|
"o3-mini": (1.10, 4.40),
|
|
"o4-mini": (1.10, 4.40),
|
|
# --- DeepSeek ---
|
|
"deepseek-chat": (0.27, 1.10),
|
|
"deepseek-coder": (0.27, 1.10),
|
|
"deepseek-reasoner": (0.55, 2.19),
|
|
"deepseek-r1": (0.55, 2.19),
|
|
"deepseek-v3": (0.27, 1.10),
|
|
"deepseek-v2": (0.14, 0.28),
|
|
# --- Google ---
|
|
"gemini-2.5-pro": (1.25, 10.00),
|
|
"gemini-2.5-flash": (0.15, 0.60),
|
|
"gemini-2.0-flash": (0.10, 0.40),
|
|
"gemini-1.5-pro": (1.25, 5.00),
|
|
"gemini-1.5-flash": (0.075, 0.30),
|
|
"gemma-3": (0.10, 0.10),
|
|
# --- Mistral ---
|
|
"mistral-large": (2.00, 6.00),
|
|
"mistral-medium": (2.00, 6.00),
|
|
"mistral-small": (0.20, 0.60),
|
|
"mistral-nemo": (0.15, 0.15),
|
|
"mixtral": (0.24, 0.24),
|
|
"codestral": (0.30, 0.90),
|
|
"pixtral": (2.00, 6.00),
|
|
# --- xAI ---
|
|
"grok-4": (3.00, 15.00),
|
|
"grok-3": (3.00, 15.00),
|
|
"grok-2": (2.00, 10.00),
|
|
# --- Meta ---
|
|
"llama-4": (0.20, 0.20),
|
|
"llama-3.3": (0.20, 0.20),
|
|
"llama-3.2": (0.20, 0.20),
|
|
"llama-3.1": (0.20, 0.20),
|
|
"llama-3": (0.20, 0.20),
|
|
# --- Qwen ---
|
|
"qwen3": (0.30, 1.20),
|
|
"qwen2.5": (0.30, 1.20),
|
|
"qwq": (0.30, 1.20),
|
|
# --- Cohere ---
|
|
"command-a": (2.50, 10.00),
|
|
"command-r-plus": (2.50, 10.00),
|
|
"command-r": (0.15, 0.60),
|
|
# --- Perplexity ---
|
|
"sonar-pro": (3.00, 15.00),
|
|
"sonar": (1.00, 1.00),
|
|
# --- MiniMax ---
|
|
"minimax": (0.70, 0.70),
|
|
# --- Kimi / Moonshot ---
|
|
"moonshot": (1.00, 1.00),
|
|
"kimi": (1.00, 1.00),
|
|
# --- Microsoft ---
|
|
"phi-4": (0.07, 0.14),
|
|
"phi-3": (0.07, 0.14),
|
|
# --- Nvidia ---
|
|
"nemotron": (0.30, 1.20),
|
|
# --- Nous ---
|
|
"hermes": (0.20, 0.20),
|
|
}
|
|
|
|
_CGNAT_RE = re.compile(r"^100\.(\d+)\.")
|
|
_PRIVATE_172_RE = re.compile(r"^172\.(1[6-9]|2\d|3[01])\.")
|
|
|
|
|
|
def match_model_key(name: str, keys) -> str | None:
|
|
"""Most specific (longest) key that is a substring of `name` (case-blind)."""
|
|
n = (name or "").lower()
|
|
best: str | None = None
|
|
for key in keys:
|
|
if key in n and (best is None or len(key) > len(best)):
|
|
best = key
|
|
return best
|
|
|
|
|
|
def is_local_endpoint(url: str | None) -> bool:
|
|
"""Local / self-hosted model server → free. Missing/unparseable → local."""
|
|
if not url:
|
|
return True
|
|
try:
|
|
host = (urlsplit(str(url)).hostname or "").lower()
|
|
except ValueError:
|
|
return True
|
|
if not host:
|
|
return True
|
|
if (
|
|
host == "localhost"
|
|
or host == "0.0.0.0"
|
|
or host == "host.docker.internal"
|
|
or host.endswith(".local")
|
|
):
|
|
return True
|
|
# Single-label hostname = internal Docker service / LAN shortname, never a
|
|
# public API (which needs an FQDN).
|
|
if "." not in host:
|
|
return True
|
|
if host.startswith("127.") or host.startswith("10.") or host.startswith("192.168."):
|
|
return True
|
|
if _PRIVATE_172_RE.match(host):
|
|
return True
|
|
m = _CGNAT_RE.match(host) # Tailscale CGNAT 100.64-127.x
|
|
if m and 64 <= int(m.group(1)) <= 127:
|
|
return True
|
|
return False
|
|
|
|
|
|
def is_subscription_endpoint(url: str | None) -> bool:
|
|
"""ChatGPT Codex subscription endpoints are paid via subscription, not per-token."""
|
|
if not url:
|
|
return False
|
|
try:
|
|
parts = urlsplit(str(url))
|
|
path = parts.path.rstrip("/")
|
|
except ValueError:
|
|
return False
|
|
return parts.hostname == "chatgpt.com" and (
|
|
path == "/backend-api/codex" or path.startswith("/backend-api/codex/")
|
|
)
|
|
|
|
|
|
def is_cost_tracked_endpoint(url: str | None) -> bool:
|
|
return not is_local_endpoint(url) and not is_subscription_endpoint(url)
|
|
|
|
|
|
def estimate_cost_usd(
|
|
model: str | None,
|
|
input_tokens: int | float | None,
|
|
output_tokens: int | float | None,
|
|
endpoint_url: str | None = None,
|
|
) -> float | None:
|
|
"""Estimated USD cost for a turn, or None when it can't be priced.
|
|
|
|
None cases: unknown model (never guess), local/subscription/unknown
|
|
endpoint, or no token counts.
|
|
"""
|
|
if not model:
|
|
return None
|
|
if not is_cost_tracked_endpoint(endpoint_url):
|
|
return None
|
|
try:
|
|
in_tok = int(input_tokens or 0)
|
|
out_tok = int(output_tokens or 0)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
key = match_model_key(model, MODEL_PRICING.keys())
|
|
if not key:
|
|
return None
|
|
price_in, price_out = MODEL_PRICING[key]
|
|
return (in_tok * price_in + out_tok * price_out) / 1_000_000
|