P1.1 — Implement real ai_router.chat_completion / stream_chat_completion using Ollama Cloud primary + OpenRouter fallback. Fixes AttributeError on /api/v1/chat and /api/v1/wallet-clusters. P1.2 — Fix broken RAG call paths: telegram bot now hits /api/v1/rag/v2/search then ai_router.chat_completion; unified_wallet_scanner._rag_enrich uses async httpx + correct path. Mount app.domains.databus.router. P1.3 — Implement setup_otel / shutdown_otel in core/tracing.py and create core/langfuse.py with init_langfuse / flush_langfuse as safe no-ops when SDK is missing or env vars unset. P1.4 — Fix Prometheus alert rule metric name drift: rmi_requests_total -> rmi_http_requests_total (matches metrics.py). P1.5 — Move dead admin_backend.py (1691 LOC) to app/domains/admin/router.py and mount it. Admin login endpoint now reachable. P2.1 — Rename app/domains/auth/__init__.py (507 LOC router file that broke mypy) -> app/domains/auth/router.py. Mount app.domains.auth.router so /register, /login, /wallet/*, /user/me, /2fa/*, OAuth, Telegram are live. P2.1b — Add pyotp + qrcode to requirements.txt so 2FA endpoints don't 503.
288 lines
11 KiB
Python
288 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
AI Router — service functions for chat completions.
|
|
|
|
Primary: Ollama Cloud (deepseek-v4-flash, free).
|
|
Fallback: OpenRouter (various models via OPENROUTER_API_KEY).
|
|
|
|
Both endpoints are OpenAI-compatible /v1/chat/completions.
|
|
Called by app/routers/chat.py and app/routers/wallet_clustering_router.py
|
|
as ``ai_router.chat_completion(...)`` and
|
|
``ai_router.stream_chat_completion(...)``.
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from collections.abc import AsyncGenerator
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import APIRouter
|
|
|
|
logger = logging.getLogger("rmi.ai_router")
|
|
|
|
router = APIRouter(tags=["AI Router"])
|
|
|
|
# Decode base64 LLM key if present, otherwise use plain LLM_API_KEY
|
|
if os.getenv("LLM_API_KEY_B64"):
|
|
os.environ["LLM_API_KEY"] = base64.b64decode(os.getenv("LLM_API_KEY_B64")).decode()
|
|
|
|
# ── Provider config ─────────────────────────────────────────────
|
|
OLLAMA_KEY = os.getenv("OLLAMA_API_KEY", "")
|
|
OLLAMA_URL = "https://ollama.com/v1/chat/completions"
|
|
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "deepseek-v4-flash")
|
|
|
|
OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
|
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "deepseek/deepseek-chat")
|
|
|
|
# Tier → model mapping (best effort; Ollama Cloud ignores non-model fields)
|
|
TIER_MODELS: dict[str, str] = {
|
|
"T0": "deepseek-v4-pro",
|
|
"T1": "deepseek-v4-pro",
|
|
"T2": "deepseek-v4-flash",
|
|
"T3": "deepseek-v4-flash",
|
|
"T4": "deepseek-v4-flash",
|
|
}
|
|
|
|
MODEL_TIERS = {
|
|
"T0": {"name": "Ultra", "models": ["deepseek-v4-pro"], "max_cost_per_1k": 0.0},
|
|
"T1": {"name": "Premium", "models": ["deepseek-v4-pro"], "max_cost_per_1k": 0.0},
|
|
"T2": {"name": "Standard", "models": ["deepseek-v4-flash"], "max_cost_per_1k": 0.0},
|
|
"T3": {"name": "Fast", "models": ["deepseek-v4-flash"], "max_cost_per_1k": 0.0},
|
|
"T4": {"name": "Free", "models": ["deepseek-v4-flash"], "max_cost_per_1k": 0.0},
|
|
}
|
|
|
|
PROVIDERS = {
|
|
"ollama": {"url": OLLAMA_URL, "key_env": "OLLAMA_API_KEY", "model": OLLAMA_MODEL, "rpm": 100},
|
|
"openrouter": {"url": OPENROUTER_URL, "key_env": "OPENROUTER_API_KEY", "model": OPENROUTER_MODEL, "rpm": 100},
|
|
}
|
|
|
|
|
|
def _resolve_model(model: str | None, tier: str | None) -> str:
|
|
"""Pick the model: explicit model > tier mapping > default."""
|
|
if model:
|
|
return model
|
|
if tier and tier in TIER_MODELS:
|
|
return TIER_MODELS[tier]
|
|
return OLLAMA_MODEL
|
|
|
|
|
|
async def chat_completion(
|
|
*,
|
|
messages: list[dict[str, str]],
|
|
model: str | None = None,
|
|
tier: str | None = None,
|
|
temperature: float = 0.3,
|
|
max_tokens: int = 500,
|
|
timeout: float = 30.0,
|
|
) -> dict[str, Any]:
|
|
"""Non-streaming chat completion.
|
|
|
|
Returns dict with keys: content, model, _provider, _latency_ms, usage, error (on failure).
|
|
Tries Ollama Cloud first, falls back to OpenRouter.
|
|
"""
|
|
resolved = _resolve_model(model, tier)
|
|
start = time.time()
|
|
|
|
# Provider 1: Ollama Cloud
|
|
if OLLAMA_KEY:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
resp = await client.post(
|
|
OLLAMA_URL,
|
|
headers={
|
|
"Authorization": f"Bearer {OLLAMA_KEY}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={
|
|
"model": resolved,
|
|
"messages": messages,
|
|
"temperature": temperature,
|
|
"max_tokens": max_tokens,
|
|
"stream": False,
|
|
},
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
|
return {
|
|
"content": content,
|
|
"model": data.get("model", resolved),
|
|
"_provider": "ollama",
|
|
"_latency_ms": round((time.time() - start) * 1000, 1),
|
|
"usage": data.get("usage", {}),
|
|
}
|
|
logger.warning("ai_router ollama status=%s body=%s", resp.status_code, resp.text[:200])
|
|
except Exception as e:
|
|
logger.warning("ai_router ollama error: %s", e)
|
|
|
|
# Provider 2: OpenRouter fallback
|
|
if OPENROUTER_KEY:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
resp = await client.post(
|
|
OPENROUTER_URL,
|
|
headers={
|
|
"Authorization": f"Bearer {OPENROUTER_KEY}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={
|
|
"model": resolved,
|
|
"messages": messages,
|
|
"temperature": temperature,
|
|
"max_tokens": max_tokens,
|
|
"stream": False,
|
|
},
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
|
return {
|
|
"content": content,
|
|
"model": data.get("model", resolved),
|
|
"_provider": "openrouter",
|
|
"_latency_ms": round((time.time() - start) * 1000, 1),
|
|
"usage": data.get("usage", {}),
|
|
}
|
|
logger.warning("ai_router openrouter status=%s body=%s", resp.status_code, resp.text[:200])
|
|
except Exception as e:
|
|
logger.warning("ai_router openrouter error: %s", e)
|
|
|
|
return {
|
|
"content": "",
|
|
"model": resolved,
|
|
"_provider": "none",
|
|
"_latency_ms": round((time.time() - start) * 1000, 1),
|
|
"usage": {},
|
|
"error": "No AI provider available (set OLLAMA_API_KEY or OPENROUTER_API_KEY)",
|
|
}
|
|
|
|
|
|
async def stream_chat_completion(
|
|
*,
|
|
messages: list[dict[str, str]],
|
|
model: str | None = None,
|
|
tier: str | None = None,
|
|
temperature: float = 0.3,
|
|
max_tokens: int = 500,
|
|
timeout: float = 30.0,
|
|
) -> AsyncGenerator[str, None]:
|
|
"""Streaming chat completion via SSE.
|
|
|
|
Yields content tokens as strings. On error, yields ``[ERROR: ...]``.
|
|
Tries Ollama Cloud first, falls back to OpenRouter.
|
|
"""
|
|
resolved = _resolve_model(model, tier)
|
|
|
|
# Provider 1: Ollama Cloud (streaming)
|
|
if OLLAMA_KEY:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=timeout) as client, client.stream(
|
|
"POST",
|
|
OLLAMA_URL,
|
|
headers={
|
|
"Authorization": f"Bearer {OLLAMA_KEY}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={
|
|
"model": resolved,
|
|
"messages": messages,
|
|
"temperature": temperature,
|
|
"max_tokens": max_tokens,
|
|
"stream": True,
|
|
},
|
|
) as resp:
|
|
if resp.status_code == 200:
|
|
async for line in resp.aiter_lines():
|
|
if line.startswith("data: ") and line != "data: [DONE]":
|
|
try:
|
|
chunk = json.loads(line[6:])
|
|
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
|
content = delta.get("content", "")
|
|
if content:
|
|
yield content
|
|
except json.JSONDecodeError:
|
|
pass
|
|
return
|
|
logger.warning("ai_router ollama stream status=%s", resp.status_code)
|
|
except Exception as e:
|
|
logger.warning("ai_router ollama stream error: %s", e)
|
|
|
|
# Provider 2: OpenRouter streaming fallback
|
|
if OPENROUTER_KEY:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=timeout) as client, client.stream(
|
|
"POST",
|
|
OPENROUTER_URL,
|
|
headers={
|
|
"Authorization": f"Bearer {OPENROUTER_KEY}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={
|
|
"model": resolved,
|
|
"messages": messages,
|
|
"temperature": temperature,
|
|
"max_tokens": max_tokens,
|
|
"stream": True,
|
|
},
|
|
) as resp:
|
|
if resp.status_code == 200:
|
|
async for line in resp.aiter_lines():
|
|
if line.startswith("data: ") and line != "data: [DONE]":
|
|
try:
|
|
chunk = json.loads(line[6:])
|
|
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
|
content = delta.get("content", "")
|
|
if content:
|
|
yield content
|
|
except json.JSONDecodeError:
|
|
pass
|
|
return
|
|
logger.warning("ai_router openrouter stream status=%s", resp.status_code)
|
|
except Exception as e:
|
|
logger.warning("ai_router openrouter stream error: %s", e)
|
|
|
|
yield "[ERROR: No AI provider available (set OLLAMA_API_KEY or OPENROUTER_API_KEY)]"
|
|
|
|
|
|
# ── HTTP endpoints (kept for compat, now backed by real service) ──
|
|
|
|
|
|
@router.post("/ai/completions")
|
|
async def ai_completions(request: dict[str, Any]) -> dict[str, Any]:
|
|
"""AI completion via optimal provider routing."""
|
|
return await chat_completion(
|
|
messages=request.get("messages", []),
|
|
model=request.get("model"),
|
|
tier=request.get("tier"),
|
|
temperature=request.get("temperature", 0.3),
|
|
max_tokens=request.get("max_tokens", 500),
|
|
)
|
|
|
|
|
|
@router.post("/ai/chat")
|
|
async def ai_chat(request: dict[str, Any]) -> dict[str, Any]:
|
|
"""AI chat endpoint with provider fallback."""
|
|
return await chat_completion(
|
|
messages=request.get("messages", []),
|
|
model=request.get("model"),
|
|
tier=request.get("tier"),
|
|
temperature=request.get("temperature", 0.3),
|
|
max_tokens=request.get("max_tokens", 500),
|
|
)
|
|
|
|
|
|
@router.get("/ai/providers")
|
|
async def list_providers() -> dict[str, Any]:
|
|
"""List available AI providers."""
|
|
return {"providers": list(PROVIDERS.keys())}
|
|
|
|
|
|
@router.get("/ai/models")
|
|
async def list_models() -> dict[str, Any]:
|
|
"""List available models by tier."""
|
|
return {"tiers": MODEL_TIERS}
|