400 lines
16 KiB
Python
400 lines
16 KiB
Python
"""
|
|
providers/models.py — Dynamic model list for all active providers.
|
|
|
|
Anthropic has no public models API, so current models are hardcoded.
|
|
OpenRouter models are fetched from their API and cached for one hour.
|
|
|
|
Usage:
|
|
models, default = await get_available_models()
|
|
info = await get_models_info()
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Current Anthropic models (update when new ones ship)
|
|
_ANTHROPIC_MODELS = [
|
|
"anthropic:claude-opus-4-6",
|
|
"anthropic:claude-sonnet-4-6",
|
|
"anthropic:claude-haiku-4-5-20251001",
|
|
]
|
|
|
|
_ANTHROPIC_MODEL_INFO = [
|
|
{
|
|
"id": "anthropic:claude-opus-4-6",
|
|
"provider": "anthropic",
|
|
"bare_id": "claude-opus-4-6",
|
|
"name": "Claude Opus 4.6",
|
|
"context_length": 200000,
|
|
"description": "Anthropic's most powerful model. Best for complex reasoning, nuanced writing, and sophisticated analysis.",
|
|
"capabilities": {"vision": True, "tools": True, "online": False, "image_gen": False},
|
|
"pricing": {"prompt_per_1m": None, "completion_per_1m": None},
|
|
"architecture": {"tokenizer": "claude", "modality": "text+image->text"},
|
|
},
|
|
{
|
|
"id": "anthropic:claude-sonnet-4-6",
|
|
"provider": "anthropic",
|
|
"bare_id": "claude-sonnet-4-6",
|
|
"name": "Claude Sonnet 4.6",
|
|
"context_length": 200000,
|
|
"description": "Best balance of speed and intelligence. Ideal for most tasks requiring strong reasoning with faster response times.",
|
|
"capabilities": {"vision": True, "tools": True, "online": False, "image_gen": False},
|
|
"pricing": {"prompt_per_1m": None, "completion_per_1m": None},
|
|
"architecture": {"tokenizer": "claude", "modality": "text+image->text"},
|
|
},
|
|
{
|
|
"id": "anthropic:claude-haiku-4-5-20251001",
|
|
"provider": "anthropic",
|
|
"bare_id": "claude-haiku-4-5-20251001",
|
|
"name": "Claude Haiku 4.5",
|
|
"context_length": 200000,
|
|
"description": "Fastest and most compact Claude model. Great for quick tasks, simple Q&A, and high-throughput workloads.",
|
|
"capabilities": {"vision": True, "tools": True, "online": False, "image_gen": False},
|
|
"pricing": {"prompt_per_1m": None, "completion_per_1m": None},
|
|
"architecture": {"tokenizer": "claude", "modality": "text+image->text"},
|
|
},
|
|
]
|
|
|
|
# Current OpenAI models (hardcoded — update when new ones ship)
|
|
_OPENAI_MODELS = [
|
|
"openai:gpt-4o",
|
|
"openai:gpt-4o-mini",
|
|
"openai:gpt-4-turbo",
|
|
"openai:o3-mini",
|
|
"openai:gpt-5-image",
|
|
]
|
|
|
|
_OPENAI_MODEL_INFO = [
|
|
{
|
|
"id": "openai:gpt-4o",
|
|
"provider": "openai",
|
|
"bare_id": "gpt-4o",
|
|
"name": "GPT-4o",
|
|
"context_length": 128000,
|
|
"description": "OpenAI's flagship model. Multimodal, fast, and highly capable for complex reasoning and generation tasks.",
|
|
"capabilities": {"vision": True, "tools": True, "online": False, "image_gen": False},
|
|
"pricing": {"prompt_per_1m": 2.50, "completion_per_1m": 10.00},
|
|
"architecture": {"tokenizer": "cl100k", "modality": "text+image->text"},
|
|
},
|
|
{
|
|
"id": "openai:gpt-4o-mini",
|
|
"provider": "openai",
|
|
"bare_id": "gpt-4o-mini",
|
|
"name": "GPT-4o mini",
|
|
"context_length": 128000,
|
|
"description": "Fast and affordable GPT-4o variant. Great for high-throughput tasks that don't require maximum intelligence.",
|
|
"capabilities": {"vision": True, "tools": True, "online": False, "image_gen": False},
|
|
"pricing": {"prompt_per_1m": 0.15, "completion_per_1m": 0.60},
|
|
"architecture": {"tokenizer": "cl100k", "modality": "text+image->text"},
|
|
},
|
|
{
|
|
"id": "openai:gpt-4-turbo",
|
|
"provider": "openai",
|
|
"bare_id": "gpt-4-turbo",
|
|
"name": "GPT-4 Turbo",
|
|
"context_length": 128000,
|
|
"description": "Previous-generation GPT-4 with 128K context window. Vision and tool use supported.",
|
|
"capabilities": {"vision": True, "tools": True, "online": False, "image_gen": False},
|
|
"pricing": {"prompt_per_1m": 10.00, "completion_per_1m": 30.00},
|
|
"architecture": {"tokenizer": "cl100k", "modality": "text+image->text"},
|
|
},
|
|
{
|
|
"id": "openai:o3-mini",
|
|
"provider": "openai",
|
|
"bare_id": "o3-mini",
|
|
"name": "o3-mini",
|
|
"context_length": 200000,
|
|
"description": "OpenAI's efficient reasoning model. Excels at STEM tasks with strong tool-use support.",
|
|
"capabilities": {"vision": False, "tools": True, "online": False, "image_gen": False},
|
|
"pricing": {"prompt_per_1m": 1.10, "completion_per_1m": 4.40},
|
|
"architecture": {"tokenizer": "cl100k", "modality": "text->text"},
|
|
},
|
|
{
|
|
"id": "openai:gpt-5-image",
|
|
"provider": "openai",
|
|
"bare_id": "gpt-5-image",
|
|
"name": "GPT-5 Image",
|
|
"context_length": 128000,
|
|
"description": "GPT-5 with native image generation. Produces high-quality images from text prompts with rich contextual understanding.",
|
|
"capabilities": {"vision": True, "tools": False, "online": False, "image_gen": True},
|
|
"pricing": {"prompt_per_1m": None, "completion_per_1m": None},
|
|
"architecture": {"tokenizer": "cl100k", "modality": "text+image->image+text"},
|
|
},
|
|
]
|
|
|
|
_or_raw: list[dict] = [] # full raw objects from OpenRouter /api/v1/models
|
|
_or_cache_ts: float = 0.0
|
|
_OR_CACHE_TTL = 3600 # seconds
|
|
|
|
|
|
async def _fetch_openrouter_raw(api_key: str) -> list[dict]:
|
|
"""Fetch full OpenRouter model objects, with a 1-hour in-memory cache."""
|
|
global _or_raw, _or_cache_ts
|
|
now = time.monotonic()
|
|
if _or_raw and (now - _or_cache_ts) < _OR_CACHE_TTL:
|
|
return _or_raw
|
|
try:
|
|
import httpx
|
|
async with httpx.AsyncClient() as client:
|
|
r = await client.get(
|
|
"https://openrouter.ai/api/v1/models",
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|
timeout=10,
|
|
)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
_or_raw = [m for m in data.get("data", []) if m.get("id")]
|
|
_or_cache_ts = now
|
|
logger.info(f"[models] Fetched {len(_or_raw)} OpenRouter models")
|
|
return _or_raw
|
|
except Exception as e:
|
|
logger.warning(f"[models] Failed to fetch OpenRouter models: {e}")
|
|
return _or_raw # return stale cache on error
|
|
|
|
|
|
async def _get_keys(user_id: str | None = None, is_admin: bool = True) -> tuple[str, str, str]:
|
|
"""Resolve anthropic + openrouter + openai keys for a user (user setting → global store)."""
|
|
from ..database import credential_store, user_settings_store
|
|
|
|
if user_id and not is_admin:
|
|
# Admin may grant a user full access to system keys
|
|
use_admin_keys = await user_settings_store.get(user_id, "use_admin_keys")
|
|
if not use_admin_keys:
|
|
ant_key = await user_settings_store.get(user_id, "anthropic_api_key") or ""
|
|
oai_key = await user_settings_store.get(user_id, "openai_api_key") or ""
|
|
# Non-admin with no own OR key: fall back to global (free models only)
|
|
own_or = await user_settings_store.get(user_id, "openrouter_api_key")
|
|
or_key = own_or or await credential_store.get("system:openrouter_api_key") or ""
|
|
return ant_key, or_key, oai_key
|
|
|
|
# Admin, anonymous, or user granted admin key access: full access from global store
|
|
ant_key = await credential_store.get("system:anthropic_api_key") or ""
|
|
or_key = await credential_store.get("system:openrouter_api_key") or ""
|
|
oai_key = await credential_store.get("system:openai_api_key") or ""
|
|
return ant_key, or_key, oai_key
|
|
|
|
|
|
def _is_free_openrouter(m: dict) -> bool:
|
|
"""Return True if this OpenRouter model is free (pricing.prompt == "0")."""
|
|
pricing = m.get("pricing", {})
|
|
try:
|
|
return float(pricing.get("prompt", "1")) == 0.0 and float(pricing.get("completion", "1")) == 0.0
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
async def get_available_models(
|
|
user_id: str | None = None,
|
|
is_admin: bool = True,
|
|
) -> tuple[list[str], str]:
|
|
"""
|
|
Return (model_list, default_model).
|
|
|
|
Always auto-builds from active providers:
|
|
- Hardcoded Anthropic models if ANTHROPIC_API_KEY is set (and user has access)
|
|
- All OpenRouter models (fetched + cached 1h) if OPENROUTER_API_KEY is set
|
|
- Non-admin users with no own OR key are limited to free models only
|
|
|
|
DEFAULT_CHAT_MODEL in .env sets the pre-selected default.
|
|
"""
|
|
from ..config import settings
|
|
from ..database import user_settings_store
|
|
|
|
ant_key, or_key, oai_key = await _get_keys(user_id=user_id, is_admin=is_admin)
|
|
|
|
# Determine access restrictions for non-admin users
|
|
free_or_only = False
|
|
if user_id and not is_admin:
|
|
use_admin_keys = await user_settings_store.get(user_id, "use_admin_keys")
|
|
if not use_admin_keys:
|
|
own_ant = await user_settings_store.get(user_id, "anthropic_api_key")
|
|
own_or = await user_settings_store.get(user_id, "openrouter_api_key")
|
|
if not own_ant:
|
|
ant_key = "" # block Anthropic unless they have their own key
|
|
if not own_or and or_key:
|
|
free_or_only = True
|
|
|
|
models: list[str] = []
|
|
if ant_key:
|
|
models.extend(_ANTHROPIC_MODELS)
|
|
if oai_key:
|
|
models.extend(_OPENAI_MODELS)
|
|
if or_key:
|
|
raw = await _fetch_openrouter_raw(or_key)
|
|
if free_or_only:
|
|
raw = [m for m in raw if _is_free_openrouter(m)]
|
|
models.extend(sorted(f"openrouter:{m['id']}" for m in raw))
|
|
|
|
from ..database import credential_store
|
|
if free_or_only:
|
|
db_default = await credential_store.get("system:default_chat_model_free") \
|
|
or await credential_store.get("system:default_chat_model")
|
|
else:
|
|
db_default = await credential_store.get("system:default_chat_model")
|
|
|
|
# Resolve default: DB override → .env → first available model
|
|
candidate = db_default or settings.default_chat_model or (models[0] if models else "")
|
|
# Ensure the candidate is actually in the model list
|
|
default = candidate if candidate in models else (models[0] if models else "")
|
|
return models, default
|
|
|
|
|
|
def get_or_output_modalities(bare_model_id: str) -> list[str]:
|
|
"""
|
|
Return output_modalities for an OpenRouter model from the cached raw API data.
|
|
Falls back to ["text"] if not found or cache is empty.
|
|
Also detects known image-gen models by ID pattern as a fallback.
|
|
"""
|
|
for m in _or_raw:
|
|
if m.get("id") == bare_model_id:
|
|
return m.get("architecture", {}).get("output_modalities") or ["text"]
|
|
# Pattern fallback for when cache is cold or model isn't listed
|
|
low = bare_model_id.lower()
|
|
if any(p in low for p in ("-image", "/flux", "image-gen", "imagen")):
|
|
return ["image", "text"]
|
|
return ["text"]
|
|
|
|
|
|
async def get_capability_map(
|
|
user_id: str | None = None,
|
|
is_admin: bool = True,
|
|
) -> dict[str, dict]:
|
|
"""Return {model_id: {vision, tools, online}} for all available models."""
|
|
info = await get_models_info(user_id=user_id, is_admin=is_admin)
|
|
return {m["id"]: m.get("capabilities", {}) for m in info}
|
|
|
|
|
|
async def get_models_info(
|
|
user_id: str | None = None,
|
|
is_admin: bool = True,
|
|
) -> list[dict]:
|
|
"""
|
|
Return rich metadata for all available models, filtered by user access tier.
|
|
|
|
Anthropic entries use hardcoded info.
|
|
OpenRouter entries are derived from the live API response.
|
|
"""
|
|
from ..config import settings
|
|
from ..database import user_settings_store
|
|
|
|
ant_key, or_key, oai_key = await _get_keys(user_id=user_id, is_admin=is_admin)
|
|
|
|
free_or_only = False
|
|
if user_id and not is_admin:
|
|
own_ant = await user_settings_store.get(user_id, "anthropic_api_key")
|
|
own_or = await user_settings_store.get(user_id, "openrouter_api_key")
|
|
if not own_ant:
|
|
ant_key = ""
|
|
if not own_or and or_key:
|
|
free_or_only = True
|
|
|
|
results: list[dict] = []
|
|
|
|
if ant_key:
|
|
results.extend(_ANTHROPIC_MODEL_INFO)
|
|
|
|
if oai_key:
|
|
results.extend(_OPENAI_MODEL_INFO)
|
|
|
|
if or_key:
|
|
raw = await _fetch_openrouter_raw(or_key)
|
|
if free_or_only:
|
|
raw = [m for m in raw if _is_free_openrouter(m)]
|
|
for m in raw:
|
|
model_id = m.get("id", "")
|
|
pricing = m.get("pricing", {})
|
|
try:
|
|
prompt_per_1m = float(pricing.get("prompt", 0)) * 1_000_000
|
|
except (TypeError, ValueError):
|
|
prompt_per_1m = None
|
|
try:
|
|
completion_per_1m = float(pricing.get("completion", 0)) * 1_000_000
|
|
except (TypeError, ValueError):
|
|
completion_per_1m = None
|
|
|
|
arch = m.get("architecture", {})
|
|
|
|
# Vision: OpenRouter returns either a list (new) or a modality string (old)
|
|
input_modalities = arch.get("input_modalities") or []
|
|
if not input_modalities:
|
|
modality_str = arch.get("modality", "")
|
|
input_part = modality_str.split("->")[0] if "->" in modality_str else modality_str
|
|
input_modalities = [p.strip() for p in input_part.replace("+", " ").split() if p.strip()]
|
|
|
|
# Tools: field may be named either way depending on API version
|
|
supported_params = (
|
|
m.get("supported_generation_parameters")
|
|
or m.get("supported_parameters")
|
|
or []
|
|
)
|
|
|
|
# Online: inherently-online models have "online" in their ID or name,
|
|
# or belong to providers whose models are always web-connected
|
|
name_lower = (m.get("name") or "").lower()
|
|
online = (
|
|
"online" in model_id
|
|
or model_id.startswith("perplexity/")
|
|
or "online" in name_lower
|
|
)
|
|
|
|
out_modalities = arch.get("output_modalities", ["text"])
|
|
|
|
modality_display = arch.get("modality", "")
|
|
if not modality_display and input_modalities:
|
|
modality_display = "+".join(input_modalities) + "->" + "+".join(out_modalities)
|
|
|
|
results.append({
|
|
"id": f"openrouter:{model_id}",
|
|
"provider": "openrouter",
|
|
"bare_id": model_id,
|
|
"name": m.get("name") or model_id,
|
|
"context_length": m.get("context_length"),
|
|
"description": m.get("description") or "",
|
|
"capabilities": {
|
|
"vision": "image" in input_modalities,
|
|
"tools": "tools" in supported_params,
|
|
"online": online,
|
|
"image_gen": "image" in out_modalities,
|
|
},
|
|
"pricing": {
|
|
"prompt_per_1m": prompt_per_1m,
|
|
"completion_per_1m": completion_per_1m,
|
|
},
|
|
"architecture": {
|
|
"tokenizer": arch.get("tokenizer", ""),
|
|
"modality": modality_display,
|
|
},
|
|
})
|
|
|
|
return results
|
|
|
|
|
|
async def get_access_tier(
|
|
user_id: str | None = None,
|
|
is_admin: bool = True,
|
|
) -> dict:
|
|
"""Return access restriction flags for the given user."""
|
|
if not user_id or is_admin:
|
|
return {"anthropic_blocked": False, "openrouter_free_only": False, "openai_blocked": False}
|
|
from ..database import user_settings_store, credential_store
|
|
use_admin_keys = await user_settings_store.get(user_id, "use_admin_keys")
|
|
if use_admin_keys:
|
|
return {"anthropic_blocked": False, "openrouter_free_only": False, "openai_blocked": False}
|
|
own_ant = await user_settings_store.get(user_id, "anthropic_api_key")
|
|
own_or = await user_settings_store.get(user_id, "openrouter_api_key")
|
|
global_or = await credential_store.get("system:openrouter_api_key")
|
|
return {
|
|
"anthropic_blocked": not bool(own_ant),
|
|
"openrouter_free_only": not bool(own_or) and bool(global_or),
|
|
"openai_blocked": True, # Non-admins always need their own OpenAI key
|
|
}
|
|
|
|
|
|
def invalidate_openrouter_cache() -> None:
|
|
"""Force a fresh fetch on the next call (e.g. after an API key change)."""
|
|
global _or_cache_ts
|
|
_or_cache_ts = 0.0
|