feat(ai): add OpenRouter and Ollama Cloud providers (#231)

Co-authored-by: Alex Kenley <Alex.Kenley@threatvectorsecurity.com>
This commit is contained in:
Alexander Kenley
2026-06-01 15:26:10 +10:00
committed by GitHub
parent 4dbc0fe73a
commit 2c4b8b57dd
27 changed files with 699 additions and 169 deletions
+9 -5
View File
@@ -188,7 +188,7 @@ def try_fallback_endpoint(sess, session_id: str) -> dict | None:
Returns {"model": ..., "endpoint_url": ..., "endpoint_name": ...} or None.
"""
import requests as _req
from src.endpoint_resolver import build_chat_url, build_headers, normalize_base
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
current_url = sess.endpoint_url or ""
db = SessionLocal()
@@ -205,15 +205,19 @@ def try_fallback_endpoint(sess, session_id: str) -> dict | None:
if current_url and base in current_url:
continue
# Quick ping
ping_url = base + "/models"
headers = {}
if ep.api_key:
headers["Authorization"] = f"Bearer {ep.api_key}"
ping_url = build_models_url(base)
headers = build_headers(ep.api_key, base)
try:
r = _req.get(ping_url, headers=headers, timeout=5)
r.raise_for_status()
data = r.json()
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
if not models:
models = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
if not models:
continue
# Found a working endpoint — update session
+4 -2
View File
@@ -62,14 +62,16 @@ def setup_compare_routes(session_manager: SessionManager):
db = SessionLocal()
try:
from core.database import ModelEndpoint
from src.endpoint_resolver import build_headers, normalize_base
# Find matching endpoint by URL
base = normalize_base(endpoint)
ep = db.query(ModelEndpoint).filter(
ModelEndpoint.base_url == endpoint.replace('/chat/completions', '')
ModelEndpoint.base_url == base
).first()
if ep and ep.api_key:
s = session_manager.sessions.get(sid)
if s:
s.headers = {"Authorization": f"Bearer {ep.api_key}"}
s.headers = build_headers(ep.api_key, ep.base_url)
finally:
db.close()
+69 -14
View File
@@ -16,12 +16,60 @@ from core.database import SessionLocal, ModelEndpoint, Session as DbSession
from core.middleware import require_admin
from src.llm_core import _detect_provider, ANTHROPIC_MODELS
from src.settings import load_settings as _load_settings, save_settings as _save_settings
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url, build_headers, _anthropic_api_root
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url
from src.auth_helpers import owner_filter
logger = logging.getLogger(__name__)
def _anthropic_api_root(base: str) -> str:
"""Return Anthropic's API root without duplicating /v1."""
base = (base or "").strip().rstrip("/")
host = urlparse(base).hostname or ""
if host.endswith("anthropic.com") and base.endswith("/v1"):
return base[:-3].rstrip("/")
return base
def _ollama_api_root(base: str) -> str:
"""Return Ollama's native API root without depending on deferred imports."""
base = (base or "").strip().rstrip("/")
parsed = urlparse(base)
host = parsed.hostname or ""
path = (parsed.path or "").rstrip("/")
if path.endswith("/api"):
return base
if host.endswith("ollama.com"):
root = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme and parsed.netloc else "https://ollama.com"
return root.rstrip("/") + "/api"
return base
def _models_url(base: str) -> str:
"""Return provider-specific model-list URL for route-local probing."""
provider = _detect_provider(base)
host = urlparse(base).hostname or ""
if provider == "anthropic" or host.endswith("anthropic.com"):
return _anthropic_api_root(base) + "/v1/models"
if provider == "ollama" or host.endswith("ollama.com"):
return _ollama_api_root(base) + "/tags"
return base.rstrip("/") + "/models"
def _provider_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
"""Build provider auth headers without depending on import-time stubs."""
if not api_key:
return {}
provider = _detect_provider(base)
host = urlparse(base).hostname or ""
if provider == "anthropic" or host.endswith("anthropic.com"):
return {
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
return {"Authorization": f"Bearer {api_key}"}
# ── Curated model lists per provider ──
# For cloud providers that return 100+ models, only show these by default.
# A model ID matches if it starts with or equals a curated entry.
@@ -87,6 +135,7 @@ _URL_TO_CURATED = {
"generativelanguage.googleapis.com": "google",
"api.x.ai": "xai",
"openrouter.ai": "openrouter",
"ollama.com": "ollama",
}
@@ -183,9 +232,15 @@ def _probe_single_model(base: str, api_key: str, model_id: str, timeout: int = 1
payload = _build_anthropic_payload(model_id, messages, 0.0, 5)
if _test_tools:
payload["tools"] = [{"name": "test", "description": "Test tool", "input_schema": {"type": "object", "properties": {}}}]
elif provider == "ollama":
from src.llm_core import _build_ollama_payload
target_url = build_chat_url(base)
h = _provider_headers(api_key, base)
h["Content-Type"] = "application/json"
payload = _build_ollama_payload(model_id, messages, 0.0, 5, stream=False, tools=_test_tools)
else:
target_url = build_chat_url(base)
h = build_headers(api_key, base)
h = _provider_headers(api_key, base)
h["Content-Type"] = "application/json"
from src.llm_core import _uses_max_completion_tokens
_max_key = "max_completion_tokens" if _uses_max_completion_tokens(model_id) else "max_tokens"
@@ -276,10 +331,8 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
return []
logger.warning(f"Anthropic /v1/models failed, using hardcoded list: {e}")
return list(ANTHROPIC_MODELS)
url = base + "/models"
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
url = _models_url(base)
headers = _provider_headers(api_key, base)
try:
r = httpx.get(url, headers=headers, timeout=timeout)
r.raise_for_status()
@@ -494,10 +547,7 @@ def setup_model_routes(model_discovery):
pass
model_ids = [m for m in model_ids if m not in hidden]
# Build correct URL based on provider
if provider == "anthropic":
chat_url = build_chat_url(base)
else:
chat_url = base + "/chat/completions"
chat_url = build_chat_url(base)
category = _classify_endpoint(base)
if model_ids:
@@ -671,10 +721,8 @@ def setup_model_routes(model_discovery):
entry["error"] = str(e)
entry["model_count"] = 0
else:
url = base + "/models"
headers = {}
if ep.api_key:
headers["Authorization"] = f"Bearer {ep.api_key}"
url = _models_url(base)
headers = _provider_headers(ep.api_key, base)
try:
t0 = _time.time()
r = httpx.get(url, headers=headers, timeout=5)
@@ -682,6 +730,12 @@ def setup_model_routes(model_discovery):
r.raise_for_status()
data = r.json()
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
if not models:
models = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
entry["status"] = "online"
entry["model_count"] = len(models)
except Exception as e:
@@ -896,6 +950,7 @@ def setup_model_routes(model_discovery):
for suffix in ["/models", "/chat/completions", "/completions", "/v1/messages"]:
if base_url.endswith(suffix):
base_url = base_url[:-len(suffix)].rstrip("/")
base_url = _normalize_base(base_url)
if not base_url:
raise HTTPException(400, "Base URL is required")
# Resolve hostname via Tailscale if DNS fails
+4 -1
View File
@@ -227,6 +227,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
)
# Set auth headers for custom API-key endpoints
resolved_key = api_key.strip() if api_key else ""
resolved_base = endpoint_url
if not resolved_key and endpoint_id and endpoint_id.strip():
from core.database import ModelEndpoint
_db = SessionLocal()
@@ -234,10 +235,12 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
ep = _db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id.strip()).first()
if ep and ep.api_key:
resolved_key = ep.api_key
resolved_base = ep.base_url
finally:
_db.close()
if resolved_key:
session.headers = {"Authorization": f"Bearer {resolved_key}"}
from src.endpoint_resolver import build_headers
session.headers = build_headers(resolved_key, resolved_base)
session_manager.save_sessions()
# Fire webhook (sync-safe)
if webhook_manager:
+18 -7
View File
@@ -157,6 +157,7 @@ def setup_webhook_routes(
"groq": "https://api.groq.com/openai/v1",
"together": "https://api.together.xyz/v1",
"openrouter": "https://openrouter.ai/api/v1",
"ollama": "https://ollama.com/api",
"fireworks": "https://api.fireworks.ai/inference/v1",
}
@@ -203,6 +204,7 @@ def setup_webhook_routes(
from core.models import ChatMessage
from src.llm_core import llm_call_async
from core.database import ModelEndpoint
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
message = body.message.strip()
if not message:
@@ -244,7 +246,8 @@ def setup_webhook_routes(
"Could not auto-detect provider. Pass base_url (e.g. 'https://api.deepseek.com/v1') "
"or provider ('deepseek', 'openai', 'groq', etc.)")
endpoint_url = base_url + "/chat/completions"
base_url = normalize_base(base_url)
endpoint_url = build_chat_url(base_url)
if not session_manager:
raise HTTPException(500, "Session manager not available")
@@ -254,7 +257,7 @@ def setup_webhook_routes(
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
model=model, owner=token_owner,
)
sess.headers = {"Authorization": f"Bearer {api_key}"}
sess.headers = build_headers(api_key, base_url)
session_manager.save_sessions()
session_id = sid
@@ -271,18 +274,26 @@ def setup_webhook_routes(
"No session, api_key, or configured endpoints. "
"Pass api_key + model, or configure an endpoint in Admin.")
endpoint_url = ep.base_url.rstrip("/") + "/chat/completions"
base_url = normalize_base(ep.base_url)
endpoint_url = build_chat_url(base_url)
model = body.model or "auto"
api_key = ep.api_key
if model == "auto":
try:
async with httpx.AsyncClient(timeout=5) as client:
models_url = ep.base_url.rstrip("/") + "/models"
hdrs = {"Authorization": f"Bearer {api_key}"} if api_key else {}
models_url = build_models_url(base_url)
hdrs = build_headers(api_key, base_url)
resp = await client.get(models_url, headers=hdrs)
resp.raise_for_status()
ids = [m.get("id") for m in (resp.json().get("data") or []) if m.get("id")]
data = resp.json()
ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
if not ids:
ids = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
model = ids[0] if ids else "auto"
except Exception:
raise HTTPException(500, "Could not discover models from endpoint")
@@ -296,7 +307,7 @@ def setup_webhook_routes(
model=model, owner=token_owner,
)
if api_key:
sess.headers = {"Authorization": f"Bearer {api_key}"}
sess.headers = build_headers(api_key, base_url)
session_manager.save_sessions()
session_id = sid