Files
openswarm/backend/apps/agents/providers/registry.py
T
2026-04-29 00:46:17 -04:00

644 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Provider registry and model catalog.
NOTE: `create_provider`, `BaseProvider`, `AnthropicProvider`, `OpenAICompatProvider`,
and the native `AgentLoop` are currently unused. The live agent path is
`claude_agent_sdk` via `agent_manager._run_agent_loop`. Kept as a foundation
for a potential future native multi-provider loop.
Multi-model subscription support routes non-Anthropic models through 9Router's
`/v1/messages` endpoint by passing prefixed model IDs (e.g. `cx/gpt-5.4`,
`gc/gemini-2.5-pro`). 9Router's translator converts the Anthropic-format
request into the provider's native format transparently.
"""
from __future__ import annotations
import logging
from typing import Any, TYPE_CHECKING
from backend.apps.agents.providers.base import BaseProvider
if TYPE_CHECKING:
from backend.apps.settings.models import AppSettings
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tier 1: Built-in models (curated, we know their quirks)
# ---------------------------------------------------------------------------
#
# Fields:
# value — short internal name stored on AgentSession.model
# label — display name in the model picker
# context_window — tokens
# model_id — bare model string for direct API calls (Anthropic key path)
# router_model_id — prefixed string for 9Router routing (cc/, cx/, gc/)
# api — "anthropic" | "codex" | "gemini-cli"
# subscription_only— True means hidden from picker unless 9Router has that
# provider actively connected
# reasoning — True for models that emit Anthropic `thinking` content
# blocks via 9Router's translator. OpenSwarm's stream
# handler at agent_manager.py:1141-1165 does not yet
# render these blocks — final text still appears but
# the reasoning trace is silently dropped. Tracked as
# a follow-up; add a `thinking` case to the handler
# to surface the trace.
#
# Model IDs match 9Router's internal routing catalog at
# 9router/src/shared/constants/pricing.js. Each provider has a distinct
# model-name convention:
# - cc/ (Claude Code subscription) uses dash-notation: claude-sonnet-4-6
# - cx/ (OpenAI Codex subscription) uses dot-notation with -codex suffix.
# Note: `gpt-5.4` is NOT available on this path — it's API-key-only.
# The Codex subscription's flagship is gpt-5.3-codex.
# - gc/ (Gemini CLI subscription) uses gemini-3-pro-preview / 3-flash-preview
# (thinking-capable) and gemini-2.5-pro / 2.5-flash (stable).
# Gemini 3 thought signatures handled via skip_thought_signature_validator.
BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
# Anthropic: current-gen trio. Sonnet 4.6 (Feb 17 2026), Opus 4.6
# (Feb 5 2026), Haiku 4.5 (Oct 2025). All three are the current
# production flagships in their respective size tiers.
"Anthropic": [
# Adaptive entries: route is chosen at call time based on
# settings.connection_mode (openswarm-pro → proxy; api_key → direct;
# else → 9Router cc/).
{"value": "sonnet", "label": "Claude Sonnet 4.6", "context_window": 1_000_000,
"model_id": "claude-sonnet-4-6", "router_model_id": "cc/claude-sonnet-4-6", "api": "anthropic", "reasoning": True},
{"value": "opus", "label": "Claude Opus 4.6", "context_window": 1_000_000,
"model_id": "claude-opus-4-6", "router_model_id": "cc/claude-opus-4-6", "api": "anthropic", "reasoning": True},
{"value": "haiku", "label": "Claude Haiku 4.5", "context_window": 200_000,
"model_id": "claude-haiku-4-5", "router_model_id": "cc/claude-haiku-4-5-20251001", "api": "anthropic", "reasoning": True},
# Pinned-subscription entries: always route via 9Router's `cc/` prefix
# (the user's personal Claude Pro/Max subscription), regardless of
# connection_mode. Surfaced in list_models only when the user has
# BOTH openswarm-pro active AND the 9Router `claude` subscription
# connected — so the model picker can offer a per-call choice between
# the managed OpenSwarm proxy and their own Claude subscription.
{"value": "sonnet-cc", "label": "Claude Sonnet 4.6", "context_window": 1_000_000,
"model_id": "claude-sonnet-4-6", "router_model_id": "cc/claude-sonnet-4-6", "api": "anthropic", "reasoning": True, "route": "cc"},
{"value": "opus-cc", "label": "Claude Opus 4.6", "context_window": 1_000_000,
"model_id": "claude-opus-4-6", "router_model_id": "cc/claude-opus-4-6", "api": "anthropic", "reasoning": True, "route": "cc"},
{"value": "haiku-cc", "label": "Claude Haiku 4.5", "context_window": 200_000,
"model_id": "claude-haiku-4-5", "router_model_id": "cc/claude-haiku-4-5-20251001", "api": "anthropic", "reasoning": True, "route": "cc"},
{"value": "sonnet-api", "label": "Claude Sonnet 4.6 (API key)", "context_window": 1_000_000,
"model_id": "claude-sonnet-4-6", "router_model_id": "claude-sonnet-4-6", "api": "anthropic", "reasoning": True, "route": "api"},
{"value": "opus-api", "label": "Claude Opus 4.6 (API key)", "context_window": 1_000_000,
"model_id": "claude-opus-4-6", "router_model_id": "claude-opus-4-6", "api": "anthropic", "reasoning": True, "route": "api"},
{"value": "haiku-api", "label": "Claude Haiku 4.5 (API key)", "context_window": 200_000,
"model_id": "claude-haiku-4-5", "router_model_id": "claude-haiku-4-5", "api": "anthropic", "reasoning": True, "route": "api"},
],
"OpenAI": [
{"value": "gpt-5.4", "label": "GPT-5.4",
"context_window": 1_000_000, "router_model_id": "cx/gpt-5.4",
"api": "codex", "subscription_only": True, "reasoning": True},
{"value": "gpt-5.4-mini", "label": "GPT-5.4 Mini",
"context_window": 400_000, "router_model_id": "cx/gpt-5.4-mini",
"api": "codex", "subscription_only": True, "reasoning": True},
{"value": "gpt-5.3-codex", "label": "GPT-5.3 Codex",
"context_window": 400_000, "router_model_id": "cx/gpt-5.3-codex",
"api": "codex", "subscription_only": True, "reasoning": True},
# Pinned-API-key entries: bypass 9Router and call api.openai.com
# directly with openai_api_key. Model ids match what OpenAI's API
# accepts (no cx/ prefix). Surfaced when openai_api_key is set —
# gives a metered alternative to the ChatGPT-Plus subscription
# route. Same -api suffix convention as the Anthropic mirrors.
{"value": "gpt-5.4-api", "label": "GPT-5.4 (API key)",
"context_window": 1_000_000, "router_model_id": "gpt-5.4", "model_id": "gpt-5.4",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.4-mini-api", "label": "GPT-5.4 Mini (API key)",
"context_window": 400_000, "router_model_id": "gpt-5.4-mini", "model_id": "gpt-5.4-mini",
"api": "openai", "reasoning": True, "route": "api"},
{"value": "gpt-5.3-codex-api", "label": "GPT-5.3 Codex (API key)",
"context_window": 400_000, "router_model_id": "gpt-5.3-codex", "model_id": "gpt-5.3-codex",
"api": "openai", "reasoning": True, "route": "api"},
],
# Google: Gemini via Gemini CLI subscription. Both 3.x (thinking-
# capable) and 2.5 (stable) are offered. Gemini 3 models have
# always-on thinking with per-session thought signatures that are
# lost during the format translation round-trip. We use Google's
# official workaround: `skip_thought_signature_validator` on all
# historical function call and thinking parts (see 9router
# openai-to-gemini.js). This bypasses signature validation at the
# cost of the model not being able to build on prior reasoning
# across turns — but all tools work and thinking is visible.
"Google": [
{"value": "gemini-3-pro", "label": "Gemini 3 Pro",
"context_window": 1_000_000, "router_model_id": "gc/gemini-3-pro-preview",
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
{"value": "gemini-3-flash", "label": "Gemini 3 Flash",
"context_window": 1_000_000, "router_model_id": "gc/gemini-3-flash-preview",
"api": "gemini-cli", "subscription_only": True, "reasoning": True},
{"value": "gemini-2.5-pro", "label": "Gemini 2.5 Pro",
"context_window": 1_000_000, "router_model_id": "gc/gemini-2.5-pro",
"api": "gemini-cli", "subscription_only": True},
{"value": "gemini-2.5-flash", "label": "Gemini 2.5 Flash",
"context_window": 1_000_000, "router_model_id": "gc/gemini-2.5-flash",
"api": "gemini-cli", "subscription_only": True},
# Pinned-API-key entries for Google AI Studio (api="gemini"). Bypass
# both 9Router (which routes via Gemini CLI/Antigravity OAuth) and
# any subscription path; call generativelanguage.googleapis.com
# directly with google_api_key. Free-tier quota is generous (~1K
# requests/day) and lives separately from the OAuth lanes.
{"value": "gemini-3-pro-api", "label": "Gemini 3 Pro (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-3-pro-preview", "model_id": "gemini-3-pro-preview",
"api": "gemini", "reasoning": True, "route": "api"},
{"value": "gemini-3-flash-api", "label": "Gemini 3 Flash (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-3-flash-preview", "model_id": "gemini-3-flash-preview",
"api": "gemini", "reasoning": True, "route": "api"},
{"value": "gemini-2.5-pro-api", "label": "Gemini 2.5 Pro (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-2.5-pro", "model_id": "gemini-2.5-pro",
"api": "gemini", "route": "api"},
{"value": "gemini-2.5-flash-api", "label": "Gemini 2.5 Flash (API key)",
"context_window": 1_000_000, "router_model_id": "gemini-2.5-flash", "model_id": "gemini-2.5-flash",
"api": "gemini", "route": "api"},
],
}
# ---------------------------------------------------------------------------
# Thinking level translation
# ---------------------------------------------------------------------------
# Each provider has a different API shape for "how hard should the model
# think." We expose a single provider-agnostic level (off/low/medium/high/
# auto) on the session and translate here.
#
# Returns the provider-specific payload to merge into request params, or
# None if no special thinking params should be sent (use defaults).
def thinking_params_for(api: str, level: str, model_id: str = "") -> dict | None:
"""Translate a provider-agnostic thinking level to per-provider API params.
Args:
api: "anthropic" | "codex" | "gemini-cli"
level: "off" | "low" | "medium" | "high" | "auto"
model_id: optional, used to pick adaptive vs legacy for Claude
Returns a dict to merge into request params, or None for "use defaults".
"""
if level == "auto":
# Let provider use its own default. For Claude 4.6 we still want
# adaptive thinking on by default so users see reasoning.
if api == "anthropic":
return {"thinking": {"type": "adaptive"}}
return None
if level == "off":
if api == "anthropic":
return {"thinking": {"type": "disabled"}}
if api == "codex":
return {"reasoning": {"effort": "none"}}
# Gemini: lowest available level
if api == "gemini-cli":
return {"thinkingConfig": {"thinkingLevel": "LOW"}}
return None
# Claude 4.6 models use adaptive thinking (no manual budget). For older
# Claude models we'd use budget_tokens; we don't ship those today.
if api == "anthropic":
return {"thinking": {"type": "adaptive"}}
if api == "codex":
effort_map = {"low": "low", "medium": "medium", "high": "high"}
return {"reasoning": {"effort": effort_map[level]}}
if api == "gemini-cli":
level_map = {"low": "LOW", "medium": "MEDIUM", "high": "HIGH"}
return {"thinkingConfig": {"thinkingLevel": level_map[level]}}
return None
# ---------------------------------------------------------------------------
# OpenRouter: built-in integration for 300+ models
# ---------------------------------------------------------------------------
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
_9router_cache: dict = {"available": None, "checked_at": 0}
def _is_9router_available() -> bool:
"""Check if 9Router is running on localhost:20128. Caches for 30 seconds."""
import time as _time
now = _time.time()
if _9router_cache["available"] is not None and now - _9router_cache["checked_at"] < 30:
return _9router_cache["available"]
try:
import httpx
r = httpx.get("http://localhost:20128/v1/models", timeout=2.0)
available = r.status_code == 200
except Exception:
available = False
_9router_cache["available"] = available
_9router_cache["checked_at"] = now
return available
# ---------------------------------------------------------------------------
# Model resolution (used by the live claude_agent_sdk path)
# ---------------------------------------------------------------------------
def _find_builtin_model(short_name: str) -> dict | None:
"""Look up a model entry by its short `value`."""
for models in BUILTIN_MODELS.values():
for m in models:
if m.get("value") == short_name:
return m
return None
def get_api_type(short_name: str) -> str:
"""Return the api type for a short model name.
Returns one of: "anthropic", "codex", "gemini-cli".
Defaults to "anthropic" for unknown names so existing behavior is preserved.
"""
entry = _find_builtin_model(short_name)
return (entry or {}).get("api", "anthropic")
def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
"""Resolve a short model name into the id string passed to ClaudeAgentOptions.
Priority:
- Anthropic model + openswarm-pro mode → bare `model_id` (our cloud proxy)
- Anthropic model with an API key set → bare `model_id` (real Anthropic API)
- Everything else → `router_model_id` (9Router with cc/ cx/ gc/ prefix)
- Unknown names pass through unchanged
"""
entry = _find_builtin_model(short_name)
if entry is None:
return short_name
# Pinned-route entries (e.g. "sonnet-cc") always use their router_model_id,
# bypassing connection_mode. This is what lets the picker offer a
# distinct "Anthropic" group pointing at the user's 9Router Claude
# subscription even while openswarm-pro is the default Claude route.
if entry.get("route") == "cc":
return entry.get("router_model_id", entry.get("model_id", short_name))
# route="api" is the analogue for the user's direct Anthropic API key:
# bare model_id, and agent_manager will force the spawn env to point at
# api.anthropic.com with the api_key (skipping both the Pro proxy AND
# 9Router). This is what makes "use my API key" reachable even when
# connection_mode is openswarm-pro.
if entry.get("route") == "api":
return entry.get("model_id", short_name)
if entry.get("api") == "anthropic":
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
return entry.get("model_id", short_name)
if getattr(settings, "anthropic_api_key", None):
return entry.get("model_id", short_name)
# Gemini: prefer lanes with higher quota in order —
# 1. AI Studio apikey (free 1K/day, separate from any OAuth limit)
# 2. Antigravity OAuth (preview, 5-10× the Gemini CLI free tier)
# 3. Gemini CLI OAuth (free tier, ~5 RPM — last resort)
#
# Antigravity exposes differently-named Gemini models than Gemini CLI:
# gc/gemini-3-pro-preview → ag/gemini-3.1-pro-high
# gc/gemini-3-flash-preview → ag/gemini-3-flash
# gc/gemini-2.5-pro → (not available on Antigravity)
# gc/gemini-2.5-flash → (not available on Antigravity)
# When Antigravity lacks a model we fall back to gc/.
_ANTIGRAVITY_MAP = {
"gemini-3-pro-preview": "gemini-3.1-pro-high",
"gemini-3-flash-preview": "gemini-3-flash",
}
if entry.get("api") == "gemini-cli":
rid = entry.get("router_model_id", "")
if isinstance(rid, str) and rid.startswith("gc/"):
suffix = rid[len("gc/"):]
if getattr(settings, "google_api_key", None):
return "gemini/" + suffix
ag_suffix = _ANTIGRAVITY_MAP.get(suffix)
if ag_suffix:
# Check 9Router for an Antigravity connection.
try:
import httpx as _httpx
r = _httpx.get("http://localhost:20128/api/providers", timeout=2.0)
if r.status_code == 200:
data = r.json()
conns = data.get("connections", []) if isinstance(data, dict) else (data if isinstance(data, list) else [])
has_ag = any(
isinstance(c, dict)
and c.get("provider") == "antigravity"
and c.get("isActive")
for c in conns
)
if has_ag:
return "ag/" + ag_suffix
except Exception:
pass
return entry.get("router_model_id", entry.get("model_id", short_name))
async def resolve_aux_model(settings: AppSettings, preferred_tier: str = "haiku") -> tuple[str, str | None]:
"""Pick the cheapest/most-available model for auxiliary LLM calls.
Used by title generation, group meta, dashboard naming, outputs/view
builder, and browser_agent — wherever we need a quick one-shot LLM call
that is NOT the user's selected chat model.
Returns (model_id, base_url).
- If base_url is None, caller should use the default Anthropic client.
- If base_url is set, caller should route through 9Router.
Priority:
1. Anthropic API key set → bare haiku/sonnet on real Anthropic API
2. 9Router + Claude subscription connected → cc/<model>
3. 9Router + Codex connected → cx/gpt-5.4-mini
4. 9Router + Gemini connected → gc/gemini-2.5-flash
5. Nothing available → raise ValueError
"""
haiku_bare = "claude-haiku-4-5-20251001"
sonnet_bare = "claude-sonnet-4-20250514"
bare = haiku_bare if preferred_tier == "haiku" else sonnet_bare
# OpenSwarm Pro — route through our cloud proxy
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
proxy_url = getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.com"
return (bare, proxy_url)
# Direct API key wins
if getattr(settings, "anthropic_api_key", None):
return (bare, None)
# Fall back to 9Router
from backend.apps.nine_router import is_running as _9r_running, get_providers as _9r_providers
if not _9r_running():
raise ValueError(
"No AI provider configured for auxiliary LLM call. "
"Set an Anthropic API key or connect a subscription."
)
connections = await _9r_providers()
connected = {c.get("provider") for c in connections if c.get("isActive")}
base_url = "http://localhost:20128"
if "claude" in connected:
return (f"cc/{haiku_bare}" if preferred_tier == "haiku" else f"cc/{sonnet_bare}", base_url)
if "codex" in connected:
return ("cx/gpt-5.4-mini", base_url)
if "gemini-cli" in connected:
return ("gc/gemini-2.5-flash", base_url)
raise ValueError(
"No AI provider connected for auxiliary LLM call. "
"Connect at least one subscription in Settings."
)
# ---------------------------------------------------------------------------
# Provider factory
# ---------------------------------------------------------------------------
def create_provider(
provider_name: str,
settings: AppSettings,
provider_config: dict | None = None,
) -> BaseProvider:
"""Create a provider adapter.
Routes based on the 'api' field in BUILTIN_MODELS:
- "anthropic" → native Anthropic SDK
- "openai" → native OpenAI SDK (direct API)
- "gemini" → native Google GenAI SDK
- "openrouter" → OpenAI-compat via openrouter.ai (Meta, Mistral, DeepSeek, Qwen, xAI, etc.)
Custom providers use OpenAI-compat with user's base_url.
"""
api_type = _get_api_type(provider_name)
# Check for 9Router first
if provider_name in ("9Router", "9router"):
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
if api_type == "anthropic":
from backend.apps.agents.providers.anthropic import AnthropicProvider
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
return AnthropicProvider(
auth_token=getattr(settings, "openswarm_bearer_token", None),
base_url=getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.com",
)
# Priority: API key → 9Router subscription
if settings.anthropic_api_key:
return AnthropicProvider(api_key=settings.anthropic_api_key)
# No API key — try 9Router as fallback
if _is_9router_available():
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
provider = OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
# Override get_model_id to map our short names to 9Router's cc/ prefixed IDs
_original_get_model = provider.get_model_id
_9r_model_map = {
"sonnet": "cc/claude-sonnet-4-6",
"opus": "cc/claude-opus-4-6",
"haiku": "cc/claude-haiku-4-5-20251001",
}
provider.get_model_id = lambda name: _9r_model_map.get(name, f"cc/{name}" if not name.startswith("cc/") else name)
return provider
raise ValueError("Anthropic API key not configured. Set it in Settings, or connect 9Router.")
if api_type == "openai":
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
if settings.openai_api_key:
return OpenAICompatProvider(api_key=settings.openai_api_key, base_url="https://api.openai.com/v1")
# No API key — try 9Router as fallback
if _is_9router_available():
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
raise ValueError("OpenAI API key not configured. Set it in Settings, or connect 9Router.")
if api_type == "gemini":
from backend.apps.agents.providers.gemini import GeminiProvider
if settings.google_api_key:
return GeminiProvider(api_key=settings.google_api_key)
# No API key — try 9Router as fallback
if _is_9router_available():
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
raise ValueError("Google API key not configured. Set it in Settings, or connect 9Router.")
if api_type == "openrouter":
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
openrouter_key = getattr(settings, "openrouter_api_key", None)
if openrouter_key:
return OpenAICompatProvider(api_key=openrouter_key, base_url=OPENROUTER_BASE_URL)
# No OpenRouter key — try 9Router as fallback
if _is_9router_available():
return OpenAICompatProvider(api_key="9router", base_url="http://localhost:20128/v1")
raise ValueError(f"OpenRouter API key not configured for {provider_name}. Set it in Settings, or connect a subscription.")
# Custom provider — look up in settings.custom_providers
if provider_config:
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(
api_key=provider_config.get("api_key", ""),
base_url=provider_config.get("base_url", ""),
)
for cp in getattr(settings, "custom_providers", []):
if cp.name == provider_name:
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(
api_key=cp.api_key,
base_url=cp.base_url,
)
raise ValueError(f"Unknown provider: {provider_name}")
def _get_api_type(provider_name: str) -> str:
"""Get the API type for a provider from BUILTIN_MODELS.
Accepts both display names ('Anthropic') and lowercase API names ('anthropic').
"""
# Direct lookup first (display name like 'Anthropic', 'OpenAI', etc.)
models = BUILTIN_MODELS.get(provider_name, [])
if models:
return models[0].get("api", "openrouter")
# Lowercase API name mapping
_API_NAME_MAP = {
"anthropic": "anthropic",
"openai": "openai",
"gemini": "gemini",
"google": "gemini",
"openrouter": "openrouter",
}
if provider_name.lower() in _API_NAME_MAP:
return _API_NAME_MAP[provider_name.lower()]
# Case-insensitive lookup into BUILTIN_MODELS
lower = provider_name.lower()
for key, models in BUILTIN_MODELS.items():
if key.lower() == lower:
return models[0].get("api", "openrouter")
return "openrouter"
def _has_credentials(provider_name: str, settings: AppSettings) -> bool:
"""Check if a provider has credentials configured."""
api_type = _get_api_type(provider_name)
if api_type == "anthropic":
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
return bool(getattr(settings, "openswarm_bearer_token", None))
return bool(settings.anthropic_api_key)
if api_type == "openai":
return bool(settings.openai_api_key)
if api_type == "gemini":
return bool(getattr(settings, "google_api_key", None))
if api_type == "openrouter":
return bool(getattr(settings, "openrouter_api_key", None))
return False
def get_available_models(settings: AppSettings) -> dict[str, list[dict]]:
"""Return all models — always show everything, mark which have keys configured.
Like Cursor: show all models upfront, prompt for key when user tries to use one.
Returns: {"provider_name": [{"value": ..., "label": ..., "context_window": ..., "configured": bool}, ...]}
"""
result: dict[str, list[dict]] = {}
# Built-in providers — always show all
for provider_name, models in BUILTIN_MODELS.items():
configured = _has_credentials(provider_name, settings)
result[provider_name] = [
{**m, "configured": configured}
for m in models
]
# Custom providers
for cp in getattr(settings, "custom_providers", []):
if cp.models:
result[cp.name] = [
{
"value": m.get("value", m.get("id", "")),
"label": m.get("label", m.get("value", m.get("id", ""))),
"context_window": m.get("context_window", 128_000),
"configured": True,
}
for m in cp.models
]
return result
def get_context_window(provider: str, model: str, settings: AppSettings | None = None) -> int:
"""Look up context window for any model."""
# Check built-in models first
for models in BUILTIN_MODELS.values():
for m in models:
if m["value"] == model:
return m.get("context_window", 128_000)
# Check custom providers
if settings:
for cp in getattr(settings, "custom_providers", []):
for m in cp.models:
if m.get("value") == model or m.get("id") == model:
return m.get("context_window", 128_000)
return 128_000 # safe default
# ---------------------------------------------------------------------------
# Cost tracking
# ---------------------------------------------------------------------------
COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = {
# (provider, model): (input_cost_per_1M, output_cost_per_1M)
# NOTE: `calculate_cost` is currently unused in the live path — real
# cost tracking comes from 9Router's usage stats (analytics.py:270+).
# These entries are kept so the table matches BUILTIN_MODELS and can
# be used by any future native-loop path. Subscription-routed models
# are zero-cost to the user, but API rates are recorded here for
# reference where they exist.
# Anthropic (direct API rates)
("Anthropic", "sonnet"): (3.0, 15.0),
("Anthropic", "opus"): (5.0, 25.0),
("Anthropic", "haiku"): (1.0, 5.0),
# OpenAI — Codex subscription path, user pays nothing per token
("OpenAI", "gpt-5.4"): (0.0, 0.0),
("OpenAI", "gpt-5.4-mini"): (0.0, 0.0),
("OpenAI", "gpt-5.3-codex"): (0.0, 0.0),
# Google — Gemini CLI subscription path, user pays nothing per token
("Google", "gemini-3-pro"): (0.0, 0.0),
("Google", "gemini-3-flash"): (0.0, 0.0),
("Google", "gemini-2.5-pro"): (0.0, 0.0),
("Google", "gemini-2.5-flash"): (0.0, 0.0),
# OpenRouter-backed (approximate)
("xAI", "x-ai/grok-4-0214"): (3.0, 15.0),
("Meta", "meta-llama/llama-4-maverick"): (0.50, 0.70),
("Meta", "meta-llama/llama-4-scout"): (0.15, 0.40),
("DeepSeek", "deepseek/deepseek-chat-v3-0324"): (0.30, 0.90),
("DeepSeek", "deepseek/deepseek-r1"): (0.80, 2.40),
("Mistral", "mistralai/mistral-large-2501"): (2.0, 6.0),
("Mistral", "mistralai/mistral-small-3.1-24b-instruct"): (0.10, 0.30),
("Qwen", "qwen/qwen3-coder"): (0.0, 0.0),
("Qwen", "qwen/qwen3-235b-a22b"): (0.20, 0.70),
("Cohere", "cohere/command-a-03-2025"): (2.50, 10.0),
}
def calculate_cost(
provider: str, model: str,
input_tokens: int, output_tokens: int,
) -> float:
"""Calculate cost in USD from token counts."""
# Direct lookup first
rates = COST_PER_1M_TOKENS.get((provider, model))
if not rates:
# Case-insensitive provider lookup
lower = provider.lower()
for (p, m), r in COST_PER_1M_TOKENS.items():
if p.lower() == lower and m == model:
rates = r
break
if not rates:
return 0.0
input_rate, output_rate = rates
return (input_tokens * input_rate + output_tokens * output_rate) / 1_000_000