mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-22 04:32:22 +02:00
169 lines
6.7 KiB
Python
169 lines
6.7 KiB
Python
"""OpenRouter + 9Router pricing/catalog plumbing. Caches, pricing tables, model fetch."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
|
|
# `or:` prefix on picker values so resolve_model_id_for_sdk recognises them without a side-table.
|
|
OPENROUTER_VALUE_PREFIX = "or:"
|
|
|
|
P_OR_MODELS_TTL_OK = 3600.0
|
|
P_OR_MODELS_TTL_FAIL = 30.0
|
|
p_or_models_cache: dict = {"models": None, "fetched_at": 0.0, "ok": False}
|
|
|
|
p_9router_cache: dict = {"available": None, "checked_at": 0}
|
|
|
|
|
|
# Per-model published pricing in $/1M tokens (input, output) for direct API key lanes. Sourced from each provider's official pricing page as of May 2026. The Claude Agent SDK ALWAYS computes total_cost_usd at Anthropic rates; for any non-Anthropic upstream the SDK number is 50-1000x wrong and we MUST recompute. Used by agent_manager's cost recompute logic.
|
|
P_DIRECT_API_PRICING: dict[str, tuple[float, float]] = {
|
|
# OpenAI GPT-5.x family (source: platform.openai.com/docs/pricing).
|
|
"gpt-5.5": (1.25, 10.00),
|
|
"gpt-5.4": (1.25, 10.00),
|
|
"gpt-5.4-mini": (0.25, 2.00),
|
|
"gpt-5.3-codex": (1.25, 10.00),
|
|
"gpt-5.3-codex-high": (1.25, 10.00),
|
|
"gpt-5.3-codex-xhigh": (1.25, 10.00),
|
|
# Google Gemini direct API (ai.google.dev/pricing).
|
|
"gemini-3.1-pro-preview": (1.25, 10.00),
|
|
"gemini-3.1-flash-lite-preview": (0.10, 0.40),
|
|
"gemini-3-pro-preview": (1.25, 10.00),
|
|
"gemini-3-flash-preview": (0.30, 2.50),
|
|
}
|
|
|
|
|
|
def get_direct_pricing(model_id: str) -> tuple[float, float] | None:
|
|
"""($/1M input, $/1M output) for an OpenAI or Gemini direct-API model_id.
|
|
Returns None for any model not in the pricing table; callers fall back
|
|
to the SDK's (Anthropic-rate) estimate which is wrong but at least
|
|
deterministic."""
|
|
if not isinstance(model_id, str):
|
|
return None
|
|
bare = model_id
|
|
for prefix in ("cp-openai/", "cp-gemini/", "cp-google/", "openai/", "google/", "gemini/"):
|
|
if bare.startswith(prefix):
|
|
bare = bare[len(prefix):]
|
|
break
|
|
return P_DIRECT_API_PRICING.get(bare)
|
|
|
|
|
|
def get_openrouter_pricing(resolved_model: str) -> tuple[float, float] | None:
|
|
"""($/1M input, $/1M output) for an openrouter/ id, or None if not cached."""
|
|
if not isinstance(resolved_model, str) or not resolved_model.startswith("openrouter/"):
|
|
return None
|
|
bare = resolved_model[len("openrouter/"):]
|
|
for m in p_or_models_cache.get("models") or []:
|
|
if m.get("model_id") == bare:
|
|
return (
|
|
float(m.get("input_cost_per_1m", 0.0)),
|
|
float(m.get("output_cost_per_1m", 0.0)),
|
|
)
|
|
return None
|
|
|
|
|
|
def invalidate_openrouter_cache() -> None:
|
|
p_or_models_cache["models"] = None
|
|
p_or_models_cache["fetched_at"] = 0.0
|
|
p_or_models_cache["ok"] = False
|
|
|
|
|
|
async def fetch_openrouter_models(api_key: str | None) -> list[dict]:
|
|
"""Return OR's tool-capable chat catalog. Cached. Never raises."""
|
|
import time as p_time
|
|
if not api_key:
|
|
invalidate_openrouter_cache()
|
|
return []
|
|
|
|
now = p_time.monotonic()
|
|
fetched_at = p_or_models_cache["fetched_at"]
|
|
if p_or_models_cache["models"] is not None:
|
|
ttl = P_OR_MODELS_TTL_OK if p_or_models_cache["ok"] else P_OR_MODELS_TTL_FAIL
|
|
if now - fetched_at < ttl:
|
|
return p_or_models_cache["models"]
|
|
|
|
import httpx
|
|
try:
|
|
async with httpx.AsyncClient(timeout=8.0) as client:
|
|
r = await client.get(
|
|
f"{OPENROUTER_BASE_URL}/models",
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|
)
|
|
if r.status_code != 200:
|
|
p_or_models_cache.update(models=[], fetched_at=now, ok=False)
|
|
logger.debug(f"OpenRouter /models returned {r.status_code}")
|
|
return []
|
|
raw = r.json().get("data") or []
|
|
except Exception as e:
|
|
p_or_models_cache.update(models=[], fetched_at=now, ok=False)
|
|
logger.debug(f"OpenRouter /models fetch failed: {e}")
|
|
return []
|
|
|
|
out: list[dict] = []
|
|
for m in raw:
|
|
if not isinstance(m, dict):
|
|
continue
|
|
model_id = m.get("id") or ""
|
|
if not model_id or "/" not in model_id:
|
|
continue
|
|
arch = m.get("architecture") or {}
|
|
in_mods = arch.get("input_modalities") or []
|
|
out_mods = arch.get("output_modalities") or []
|
|
if isinstance(in_mods, list) and in_mods and "text" not in in_mods:
|
|
continue
|
|
if isinstance(out_mods, list) and out_mods and "text" not in out_mods:
|
|
continue
|
|
# Tools required; agent loop doesn't work without function calling.
|
|
params = m.get("supported_parameters") or []
|
|
if not isinstance(params, list) or "tools" not in params:
|
|
continue
|
|
ctx = m.get("context_length") or 128_000
|
|
try:
|
|
ctx = int(ctx)
|
|
except (TypeError, ValueError):
|
|
ctx = 128_000
|
|
reasoning_capable = bool("reasoning" in params or "include_reasoning" in params)
|
|
vendor = model_id.split("/", 1)[0]
|
|
label = m.get("name") or model_id
|
|
pricing = m.get("pricing") or {}
|
|
try:
|
|
prompt_per_tok = float(pricing.get("prompt") or 0)
|
|
completion_per_tok = float(pricing.get("completion") or 0)
|
|
except (TypeError, ValueError):
|
|
prompt_per_tok = completion_per_tok = 0.0
|
|
# Negative price = OR's "varies" sentinel (e.g. openrouter/auto). Clamp.
|
|
is_variable_pricing = prompt_per_tok < 0 or completion_per_tok < 0
|
|
if is_variable_pricing:
|
|
prompt_per_tok = 0.0
|
|
completion_per_tok = 0.0
|
|
is_free = (
|
|
not is_variable_pricing
|
|
and prompt_per_tok == 0.0 and completion_per_tok == 0.0
|
|
)
|
|
top_provider = m.get("top_provider") or {}
|
|
max_completion = top_provider.get("max_completion_tokens")
|
|
try:
|
|
max_completion = int(max_completion) if max_completion else None
|
|
except (TypeError, ValueError):
|
|
max_completion = None
|
|
out.append({
|
|
"value": f"{OPENROUTER_VALUE_PREFIX}{model_id}",
|
|
"label": label,
|
|
"context_window": ctx,
|
|
"model_id": model_id,
|
|
"router_model_id": f"openrouter/{model_id}",
|
|
"api": "openrouter",
|
|
"route": "openrouter",
|
|
"reasoning": reasoning_capable,
|
|
"vendor": vendor,
|
|
"input_cost_per_1m": prompt_per_tok * 1_000_000,
|
|
"output_cost_per_1m": completion_per_tok * 1_000_000,
|
|
"is_free": is_free,
|
|
"max_completion_tokens": max_completion,
|
|
})
|
|
|
|
p_or_models_cache.update(models=out, fetched_at=now, ok=True)
|
|
return out
|