Spaces:
Running
Running
Download app/pricing.py from codey-lab/Universal-AI-Hub: direct link, hf CLI and curl.
- Browser
- Download file 20.8 kB
-
https://huggingface.co/spaces/codey-lab/Universal-AI-Hub/resolve/main/app/pricing.py
- Command line
-
hf download hf://spaces/codey-lab/Universal-AI-Hub/app/pricing.py
-
curl -L -o pricing.py https://huggingface.co/spaces/codey-lab/Universal-AI-Hub/resolve/main/app/pricing.py
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.") | |