mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
644 lines
30 KiB
Python
644 lines
30 KiB
Python
"""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
|