mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-07 02:07:45 +02:00
- PostHog integration: collector, analytics subapp, opt-in UI, Analytics page - 9Router: auto-start, OAuth subscription flow, /v1/messages Anthropic format support - Settings overhaul: multi-provider API keys, subscription connect UI, onboarding modal - Unified usage: merge 9Router cost/token data into Settings Usage tab - Provider system: providers/, agent_loop, tools/ (unused, for future non-Anthropic support) - Agent SDK: restored as primary with 9Router ANTHROPIC_BASE_URL fallback - Updated system prompt, credential resolution, dashboard analytics
329 lines
14 KiB
Python
329 lines
14 KiB
Python
"""Provider factory and model registry.
|
|
|
|
Two-tier system:
|
|
1. Built-in providers (Anthropic, OpenAI, Gemini) with curated model lists
|
|
2. User-configured custom providers (any OpenAI-compatible endpoint)
|
|
- Includes built-in OpenRouter integration for 300+ models
|
|
"""
|
|
|
|
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)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
|
"Anthropic": [
|
|
{"value": "sonnet", "label": "Claude Sonnet 4.6", "context_window": 1_000_000, "model_id": "claude-sonnet-4-6", "api": "anthropic"},
|
|
{"value": "opus", "label": "Claude Opus 4.6", "context_window": 1_000_000, "model_id": "claude-opus-4-6", "api": "anthropic"},
|
|
{"value": "haiku", "label": "Claude Haiku 4.5", "context_window": 200_000, "model_id": "claude-haiku-4-5", "api": "anthropic"},
|
|
],
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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")
|
|
|
|
# Check for GitHub Copilot
|
|
if provider_name in ("GitHub Copilot", "copilot"):
|
|
from backend.apps.agents.providers.copilot import CopilotProvider
|
|
copilot_token = getattr(settings, "copilot_token", None)
|
|
if not copilot_token:
|
|
raise ValueError("GitHub Copilot not connected. Sign in via Settings → Models.")
|
|
# Auto-refresh if expired
|
|
import time as _time
|
|
expires = getattr(settings, "copilot_token_expires", None)
|
|
if expires and _time.time() > expires - 120:
|
|
github_token = getattr(settings, "copilot_github_token", None)
|
|
if github_token:
|
|
import asyncio
|
|
from backend.apps.agents.copilot_auth import exchange_for_copilot_token
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
result = loop.run_until_complete(exchange_for_copilot_token(github_token))
|
|
copilot_token = result["token"]
|
|
settings.copilot_token = copilot_token
|
|
settings.copilot_token_expires = result["expires_at"]
|
|
from backend.apps.settings.settings import _save_settings
|
|
_save_settings(settings)
|
|
except Exception as e:
|
|
logger.warning(f"Copilot token refresh failed: {e}")
|
|
return CopilotProvider(copilot_token=copilot_token)
|
|
|
|
if api_type == "anthropic":
|
|
from backend.apps.agents.providers.anthropic import AnthropicProvider
|
|
if getattr(settings, "connection_mode", "own_key") == "managed":
|
|
return AnthropicProvider(
|
|
auth_token=getattr(settings, "openswarm_auth_token", None),
|
|
base_url=getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.ai",
|
|
)
|
|
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") == "managed":
|
|
return bool(getattr(settings, "openswarm_auth_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)
|
|
# Anthropic
|
|
("Anthropic", "sonnet"): (3.0, 15.0),
|
|
("Anthropic", "opus"): (5.0, 25.0),
|
|
("Anthropic", "haiku"): (1.0, 5.0),
|
|
# OpenAI
|
|
("OpenAI", "gpt-5.4"): (2.50, 15.0),
|
|
("OpenAI", "gpt-5.4-mini"): (0.75, 3.0),
|
|
("OpenAI", "o3"): (2.0, 8.0),
|
|
("OpenAI", "o4-mini"): (1.10, 4.40),
|
|
# Google
|
|
("Google", "gemini-2.5-flash"): (0.15, 0.60),
|
|
("Google", "gemini-2.5-pro"): (1.25, 10.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
|