Universal-AI-Hub / app /pricing.py
Alibrown's picture
Upload 58 files
5ef3828 verified
Raw History Blame Contribute Delete
20.8 kB
# =============================================================================
# app/pricing.py
# Auto Model Pricing β€” weil niemand Lust hat, Kosten von Hand zu pflegen.
# Universal MCP Hub (Sandboxed) - based on PyFundaments Architecture
# Copyright 2026 - Volkan KΓΌcΓΌkbudak
# Apache License V. 2 + ESOL 1.1
# =============================================================================
# ARCHITECTURE NOTE:
# This file lives exclusively in app/ and is ONLY started by app/app.py.
# NO direct access to fundaments/*, .env, or Guardian (main.py).
# All config comes from app/.pyfun [PRICING] via app/config.py.
#
# WHY THIS EXISTS:
# Not every provider has a pricing API (Anthropic & Google: none).
# Manually maintaining cost_input_per_1k in [MODELS] sucks. Solution:
# two free public sources cover almost everything, .pyfun stays the override.
#
# SOURCES (priority: .pyfun manual > OpenRouter > LiteLLM):
# 1. .pyfun [MODELS] β†’ your values ALWAYS win (manual override)
# 2. OpenRouter /api/v1/models β†’ public, no key. Pricing per token for all
# OR models + context_length + max_completion_tokens
# 3. LiteLLM community JSON β†’ github.com/BerriAI/litellm main branch.
# input_cost_per_token / output_cost_per_token
# for Anthropic, Gemini, OpenAI, Mistral, ...
#
# PRINCIPLE (same as everything here):
# No network = no crash. No match = costs stay as configured.
# Server always starts β€” pricing refresh runs in the BACKGROUND.
#
# CACHING:
# Fetched data lands in db_sync hub_state ("pricing.cache") with TTL
# from .pyfun (default 24h). Restart within TTL = no external call.
#
# MCP TOOLS (registered in mcp.py):
# get_model_pricing(model_name) β†’ merged cost info for one model
# refresh_pricing() β†’ force refresh, returns source counts
# =============================================================================
import asyncio
import logging
from typing import Any, Dict, Optional
import httpx
from . import config
from . import db_sync
logger = logging.getLogger("pricing")
# =============================================================================
# Internal State
# =============================================================================
_cache: Dict[str, Dict[str, Any]] = {} # normalized_name β†’ {cost_in_1k, cost_out_1k, context, max_out, source}
_meta: Dict[str, Any] = {} # {"fetched_at": iso, "sources": {...}, "model_count": int}
_task: Optional[asyncio.Task] = None # background refresh task
_initialized: bool = False
# Loose index: alnum-only keys for dot/dash-insensitive matching.
# Why: OpenRouter writes 'claude-opus-4.6', LiteLLM 'claude-opus-4-6',
# your .pyfun 'claude-opus-4-6'. All should find each other.
_loose_index: Dict[str, str] = {} # loose_key β†’ normalized cache key
_loose_dirty: bool = True
_HUB_STATE_KEY = "pricing.cache"
# Known vendor prefixes used by OpenRouter / LiteLLM IDs
# ("anthropic/claude-...", "gemini/gemini-...", "meta-llama/llama-...", ...)
_VENDOR_PREFIXES = (
"anthropic/", "google/", "openai/", "meta-llama/", "mistralai/", "mistral/",
"qwen/", "nvidia/", "microsoft/", "deepseek/", "x-ai/", "xai/", "perplexity/",
"cohere/", "gryphe/", "thedrummer/", "undi95/", "cognitivecomputations/",
"gemini/", "groq/", "together_ai/", "together/", "openrouter/", "fireworks_ai/",
"azure/", "vertex_ai/", "bedrock/", "cloudflare/", "voyage/", "sambanova/",
"ai21/", "huggingface/", "ollama/", "ollama_chat/",
)
# =============================================================================
# Normalization β€” model IDs differ wildly between sources
# =============================================================================
def _normalize(name: str) -> str:
"""
Normalize a model name for matching across sources.
'anthropic/claude-haiku-4.5' β†’ 'claude-haiku-4.5'
'Claude-Haiku-4.5' β†’ 'claude-haiku-4.5'
Dots vs dashes are kept β€” providers are inconsistent there by design.
"""
n = (name or "").strip().lower()
for prefix in _VENDOR_PREFIXES:
if n.startswith(prefix):
n = n[len(prefix):]
break
return n
def _safe_int(value: Any) -> Optional[int]:
"""int() that returns None instead of raising (LiteLLM ships doc samples
like sample_spec.max_input_tokens = 'max input tokens, if the provider specifies it')."""
try:
return int(value)
except (TypeError, ValueError):
return None
def _per_token_to_per_1k(cost: Any) -> Optional[float]:
"""'0.0000015' per token β†’ 0.0015 per 1k tokens. Returns None on garbage."""
try:
val = float(cost)
except (TypeError, ValueError):
return None
if val < 0: # negative = OpenRouter's "we pay you" marker β†’ treat as 0
val = 0.0
return round(val * 1000, 6)
def _loose(name: str) -> str:
"""Strip everything except a-z0-9 β€” kills dot/dash/underscore differences."""
return "".join(ch for ch in name if ch.isalnum())
def _rebuild_loose_index() -> None:
"""Rebuild the alnum-only lookup index after a cache change."""
global _loose_index, _loose_dirty
_loose_index = {_loose(k): k for k in _cache}
_loose_dirty = False
def _lookup(model_name: str) -> Optional[Dict[str, Any]]:
"""
Cache lookup with graceful fallback:
1. exact normalized match ('claude-opus-4-6')
2. loose alnum match ('claude-opus-4.6' β‰ˆ 'claude-opus-4-6')
"""
norm = _normalize(model_name)
hit = _cache.get(norm)
if hit:
return hit
if _loose_dirty:
_rebuild_loose_index()
loose_key = _loose_index.get(_loose(norm))
if loose_key:
return _cache.get(loose_key)
return None
# =============================================================================
# Source Fetchers β€” each returns {normalized_name: entry}
# =============================================================================
async def _fetch_openrouter(url: str):
"""
OpenRouter public models endpoint β€” no key required.
https://openrouter.ai/api/v1/models β†’ data[].{id, pricing{prompt,completion}, context_length}
Returns (entries, raw_ids): raw_ids = every real OpenRouter model ID (lowercase) β€”
used by check_models() to spot stale/removed models in .pyfun [MODELS].
"""
out: Dict[str, Dict[str, Any]] = {}
raw_ids = []
async with httpx.AsyncClient() as client:
r = await client.get(url, timeout=30)
r.raise_for_status()
payload = r.json()
for m in payload.get("data", []):
model_id = m.get("id", "")
if not model_id:
continue
raw_ids.append(model_id.lower())
pricing = m.get("pricing", {}) or {}
entry: Dict[str, Any] = {"source": "openrouter"}
cost_in = _per_token_to_per_1k(pricing.get("prompt"))
cost_out = _per_token_to_per_1k(pricing.get("completion"))
if cost_in is not None:
entry["cost_input_per_1k"] = cost_in
if cost_out is not None:
entry["cost_output_per_1k"] = cost_out
if m.get("context_length"):
ctx = _safe_int(m["context_length"])
if ctx:
entry["context_tokens"] = ctx
# max_completion_tokens is nested under top_provider in OR payloads
max_out = _safe_int((m.get("top_provider") or {}).get("max_completion_tokens"))
if max_out:
entry["max_output_tokens"] = max_out
out[_normalize(model_id)] = entry
return out, raw_ids
async def _fetch_litellm(url: str) -> Dict[str, Dict[str, Any]]:
"""
LiteLLM community price list β€” no key required.
github.com/BerriAI/litellm β†’ model_prices_and_context_window.json
Keys like 'claude-sonnet-4-5', 'gemini/gemini-2.5-flash', 'gpt-4o'.
"""
out: Dict[str, Dict[str, Any]] = {}
async with httpx.AsyncClient() as client:
r = await client.get(url, timeout=30)
r.raise_for_status()
payload = r.json()
for model_id, info in payload.items():
if not isinstance(info, dict):
continue # skip metadata keys like "additional_vendor_names"
entry: Dict[str, Any] = {"source": "litellm"}
cost_in = _per_token_to_per_1k(info.get("input_cost_per_token"))
cost_out = _per_token_to_per_1k(info.get("output_cost_per_token"))
if cost_in is not None:
entry["cost_input_per_1k"] = cost_in
if cost_out is not None:
entry["cost_output_per_1k"] = cost_out
if info.get("max_input_tokens"):
ctx = _safe_int(info["max_input_tokens"])
if ctx:
entry["context_tokens"] = ctx
if info.get("max_output_tokens"):
max_out = _safe_int(info["max_output_tokens"])
if max_out:
entry["max_output_tokens"] = max_out
out[_normalize(model_id)] = entry
return out
# =============================================================================
# Cache β€” db_sync hub_state, TTL-gated
# =============================================================================
def _cache_is_fresh() -> bool:
if not _cache or not _meta.get("fetched_at"):
return False
from datetime import datetime, timezone
try:
fetched = datetime.fromisoformat(_meta["fetched_at"])
except (ValueError, TypeError):
return False
ttl_hours = float(config.get_pricing().get("PRICING_TTL_HOURS", "24"))
age = datetime.now(timezone.utc) - fetched
return age.total_seconds() < ttl_hours * 3600
async def _load_cache_from_db() -> None:
"""Read pricing cache from db_sync β€” never raises."""
global _cache, _meta, _loose_dirty
try:
stored = await db_sync.read(_HUB_STATE_KEY, default=None)
if isinstance(stored, dict) and stored.get("models"):
_cache = stored["models"]
_meta = stored.get("meta", {})
_loose_dirty = True
logger.info(
f"Pricing cache loaded from db_sync: {len(_cache)} models "
f"(fetched_at: {_meta.get('fetched_at', 'unknown')})"
)
except Exception as e:
logger.warning(f"Pricing cache load failed (continuing without): {type(e).__name__}")
async def _save_cache_to_db() -> None:
"""Write pricing cache to db_sync β€” never raises."""
try:
await db_sync.write(_HUB_STATE_KEY, {"models": _cache, "meta": _meta})
except Exception as e:
logger.warning(f"Pricing cache save failed: {type(e).__name__}")
# =============================================================================
# Refresh β€” fetch sources, merge, cache
# =============================================================================
async def refresh() -> Dict[str, Any]:
"""
Fetch all pricing sources, rebuild the auto-pricing cache.
Never raises β€” a failing source is just skipped (no fetch = no crash).
"""
global _cache, _meta, _loose_dirty
from datetime import datetime, timezone
cfg = config.get_pricing()
or_url = cfg.get("OPENROUTER_MODELS_URL", "https://openrouter.ai/api/v1/models")
llm_url = cfg.get(
"LITELLM_PRICES_URL",
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json",
)
merged: Dict[str, Dict[str, Any]] = {}
sources: Dict[str, int] = {}
or_ids: list = []
# --- Source 1: OpenRouter ---
try:
or_models, or_ids = await _fetch_openrouter(or_url)
merged.update(or_models)
sources["openrouter"] = len(or_models)
logger.info(f"Pricing source OpenRouter: {len(or_models)} models.")
except Exception as e:
sources["openrouter"] = 0
logger.warning(f"Pricing source OpenRouter failed: {type(e).__name__} β€” skipped.")
# --- Source 2: LiteLLM (fills gaps β€” never overwrites OpenRouter) ---
try:
llm_models = await _fetch_litellm(llm_url)
for name, entry in llm_models.items():
if name not in merged:
merged[name] = entry
sources["litellm"] = len(llm_models)
logger.info(f"Pricing source LiteLLM: {len(llm_models)} models.")
except Exception as e:
sources["litellm"] = 0
logger.warning(f"Pricing source LiteLLM failed: {type(e).__name__} β€” skipped.")
if merged:
_cache = merged
_meta = {
"fetched_at": datetime.now(timezone.utc).isoformat(),
"sources": sources,
"model_count": len(merged),
# keep the last known OpenRouter ID list if this fetch failed
"openrouter_ids": or_ids or _meta.get("openrouter_ids", []),
}
_loose_dirty = True
await _save_cache_to_db()
stale = check_models()
if stale:
logger.warning(f"Models in .pyfun not found in OpenRouter catalog (stale/removed?): {stale}")
return {
"fetched_at": _meta.get("fetched_at"),
"sources": sources,
"models_cached": len(_cache),
"stale_models": stale,
}
# =============================================================================
# Initialization β€” called by app/app.py, NON-BLOCKING
# =============================================================================
async def initialize() -> None:
"""
Prepare auto-pricing. Loads the db_sync cache if fresh; otherwise kicks
off a BACKGROUND refresh (asyncio.create_task) β€” the server never waits
for external pricing APIs. No network = no crash.
"""
global _initialized, _task
if _initialized:
return
cfg = config.get_pricing()
enabled = cfg.get("PRICING_ENABLED", "true").lower() == "true"
if not enabled:
logger.info("Auto-pricing disabled in .pyfun [PRICING] β€” .pyfun [MODELS] values only.")
_initialized = True
return
await _load_cache_from_db()
if _cache_is_fresh():
logger.info("Auto-pricing cache fresh β€” no external fetch needed.")
else:
logger.info("Auto-pricing cache stale/empty β€” background refresh started.")
_task = asyncio.create_task(_background_refresh())
_initialized = True
async def _background_refresh() -> None:
"""Runs refresh() and swallows everything β€” background task must never crash the app."""
try:
await refresh()
except Exception as e:
logger.warning(f"Pricing background refresh failed (non-fatal): {type(e).__name__}")
# =============================================================================
# Public API β€” merged view: .pyfun manual values WIN over auto-fetched
# =============================================================================
async def get_pricing(model_name: str) -> Dict[str, Any]:
"""
Returns merged pricing info for one model.
Merge order (highest priority first):
1. .pyfun [MODELS.<model>] β†’ your manual values always win
2. auto-fetched cache β†’ OpenRouter / LiteLLM
3. nothing β†’ costs omitted, source: "unknown"
Returns dict with: model, provider (if known from .pyfun),
cost_input_per_1k, cost_output_per_1k (USD), context_tokens,
max_output_tokens, source ("pyfun" | "openrouter" | "litellm" | "unknown"),
pricing_note.
"""
from . import models as models_mod
result: Dict[str, Any] = {"model": model_name}
notes = []
# --- Layer 1: .pyfun manual config (models.py reads [MODELS]) ---
manual = models_mod.get(model_name)
if manual:
result["provider"] = manual.get("provider", "")
for key in ("cost_input_per_1k", "cost_output_per_1k",
"context_tokens", "max_output_tokens",
"requests_per_min", "requests_per_day", "capabilities"):
if manual.get(key):
value = manual[key]
if key.startswith("cost_"):
# .pyfun-Werte sind Strings ("0.015") β€” Auto-Werte sind float.
# Normalisieren, damit die API immer Zahlen liefert.
try:
value = round(float(value), 6)
except (TypeError, ValueError):
pass
result[key] = value
if manual.get("cost_input_per_1k") or manual.get("cost_output_per_1k"):
result["source"] = "pyfun"
notes.append(".pyfun manual values (override)")
else:
notes.append(".pyfun entry has no costs β€” auto values used")
# --- Layer 2: auto-fetched cache fills the gaps ---
auto = _lookup(model_name)
if auto:
for key in ("cost_input_per_1k", "cost_output_per_1k",
"context_tokens", "max_output_tokens"):
if key not in result and auto.get(key) is not None:
result[key] = auto[key]
if "source" not in result:
result["source"] = auto.get("source", "auto")
notes.append(f"auto-fetched from {auto.get('source', 'unknown')}")
elif "source" not in result:
result["source"] = "unknown"
notes.append("no auto source matched this model name")
# --- Stale hint ---
if _cache and not _cache_is_fresh():
notes.append("cache stale β€” refresh_pricing() to update")
result["pricing_note"] = "; ".join(notes) if notes else "ok"
return result
async def catalog(query: str = "", free_only: bool = False, limit: int = 50) -> Dict[str, Any]:
"""
Real model IDs of the live OpenRouter catalog (for picking valid [MODELS] entries).
Fetches the catalog once if it is not loaded yet. Read-only, no key needed.
"""
if not _meta.get("openrouter_ids"):
await refresh()
ids = _meta.get("openrouter_ids") or []
q = (query or "").strip().lower()
hits = sorted(i for i in ids if (not free_only or i.endswith(":free")) and (not q or q in i))
limit = max(1, min(int(limit or 50), 200))
rows = []
for i in hits[:limit]:
info = _lookup(i) or {}
rows.append({"id": i, "context_tokens": info.get("context_tokens"),
"cost_input_per_1k": info.get("cost_input_per_1k"),
"cost_output_per_1k": info.get("cost_output_per_1k")})
return {"in_catalog": len(ids), "matches": len(hits), "shown": len(rows), "models": rows}
def check_models() -> list:
"""
.pyfun [MODELS] entries with provider "openrouter" whose ID is NOT in the live
OpenRouter catalog (free models come and go). Empty list if the catalog is unknown.
"""
ids = set(_meta.get("openrouter_ids") or [])
if not ids:
return []
return sorted(
name for name, cfg in config.get_models().items()
if cfg.get("provider") == "openrouter" and name.lower() not in ids
)
def max_output_for(model_name: str) -> Optional[int]:
"""
Max output tokens for a model (sync, for clamping): manual .pyfun value wins,
then auto-fetched. None if unknown β†’ no clamp.
"""
from . import models as models_mod
manual = _safe_int((models_mod.get(model_name) or {}).get("max_output_tokens"))
if manual:
return manual
auto = _lookup(model_name) if _cache else None
return _safe_int(auto.get("max_output_tokens")) if auto else None
async def cost_for(model_name: str, tokens_in: Optional[int], tokens_out: Optional[int]) -> Optional[float]:
"""
Estimated USD cost of one call from merged pricing (manual > auto).
None if no price is known. Anthropic cache read/write surcharges are not modeled.
"""
info = await get_pricing(model_name)
c_in, c_out = info.get("cost_input_per_1k"), info.get("cost_output_per_1k")
if c_in is None and c_out is None:
return None
try:
return round(((tokens_in or 0) / 1000) * float(c_in or 0)
+ ((tokens_out or 0) / 1000) * float(c_out or 0), 8)
except (TypeError, ValueError):
return None
def is_ready() -> bool:
"""True if pricing cache holds at least one model."""
return bool(_cache)
def stats() -> Dict[str, Any]:
"""Cache stats for list_active_tools / debugging."""
return {
"models_cached": len(_cache),
"fetched_at": _meta.get("fetched_at"),
"sources": _meta.get("sources", {}),
"fresh": _cache_is_fresh(),
"stale_models": check_models(),
}
# =============================================================================
# Direct execution guard
# =============================================================================
if __name__ == "__main__":
print("WARNING: Run via main.py β†’ app.py, not directly.")