Files
Imago/app/routers/admin_ai.py
T
bruno 7cc2416411
CI / Lint & Format (push) Failing after 9s
CI / Tests (push) Has been skipped
CI / Security Scan (push) Failing after 7s
CI / Docker Build (push) Has been skipped
Add AI connectivity check endpoint: GET /admin/api/ai/connectivity
2026-06-25 23:00:20 -04:00

379 lines
14 KiB
Python

"""
Router — Admin AI : configuration, test et préférences AI par client.
"""
import asyncio
import base64
import logging
import time
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies.auth import require_scope
from app.models.client import APIClient
from app.services.runtime_config import runtime_ai_config
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/api/ai", tags=["Admin AI"])
# ─────────────────────────────────────────────────────────────
# Schemas
# ─────────────────────────────────────────────────────────────
class ProviderInfo(BaseModel):
key: str
name: str
configured: bool
models: list[str] = []
class AIStatusResponse(BaseModel):
ai_enabled: bool
current_provider: str
current_model: str
providers: list[ProviderInfo]
class TestModelRequest(BaseModel):
provider: str = Field(..., description="openrouter ou gemini")
model: str = Field(..., description="Nom du modèle à tester")
prompt: Optional[str] = Field(None, description="Prompt de test (optionnel)")
image_base64: Optional[str] = Field(None, description="Image base64 pour test vision (optionnel)")
class TestModelResponse(BaseModel):
success: bool
provider: str
model: str
response: Optional[str] = None
tokens: Optional[dict] = None
latency_ms: float = 0
error: Optional[str] = None
# ─────────────────────────────────────────────────────────────
# Constantes — Modèles disponibles par fournisseur
# ─────────────────────────────────────────────────────────────
OPENROUTER_VISION_MODELS = [
"google/gemini-2.5-flash-lite",
"google/gemini-2.5-flash",
"google/gemini-2.5-pro-preview",
"qwen/qwen2.5-vl-72b-instruct",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3-opus",
"openai/gpt-4o",
"openai/gpt-4o-mini",
"meta-llama/llama-4-maverick",
]
GEMINI_MODELS = [
"gemini-2.5-flash-lite",
"gemini-2.5-flash",
"gemini-2.5-pro-preview",
"gemini-2.0-flash",
]
def _build_providers() -> list[ProviderInfo]:
"""Construit la liste des fournisseurs disponibles selon la config .env."""
providers = []
# OpenRouter
or_configured = bool(settings.OPENROUTER_API_KEY)
providers.append(ProviderInfo(
key="openrouter",
name="OpenRouter",
configured=or_configured,
models=OPENROUTER_VISION_MODELS if or_configured else [],
))
# Gemini
gemini_configured = bool(settings.GEMINI_API_KEY)
providers.append(ProviderInfo(
key="gemini",
name="Google Gemini",
configured=gemini_configured,
models=GEMINI_MODELS if gemini_configured else [],
))
return providers
# ─────────────────────────────────────────────────────────────
# GET /admin/api/ai/status
# ─────────────────────────────────────────────────────────────
@router.get("/status", response_model=AIStatusResponse)
async def get_ai_status():
"""Retourne la configuration AI actuelle et les fournisseurs/modèles disponibles."""
active_provider = runtime_ai_config.provider or settings.AI_PROVIDER
active_model = runtime_ai_config.model or (
settings.OPENROUTER_MODEL if active_provider == "openrouter"
else settings.GEMINI_MODEL
)
return AIStatusResponse(
ai_enabled=settings.AI_ENABLED,
current_provider=active_provider,
current_model=active_model,
providers=_build_providers(),
)
# ─────────────────────────────────────────────────────────────
# POST /admin/api/ai/test
# ─────────────────────────────────────────────────────────────
@router.post("/test", response_model=TestModelResponse)
async def test_model(
body: TestModelRequest,
_=Depends(require_scope("admin")),
):
"""Teste un modèle AI avec un prompt simple (et optionnellement une image)."""
t0 = time.monotonic()
if body.provider == "openrouter":
result = await _test_openrouter(body.model, body.prompt, body.image_base64)
elif body.provider == "gemini":
result = await _test_gemini(body.model, body.prompt, body.image_base64)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Fournisseur inconnu : {body.provider}",
)
result["latency_ms"] = round((time.monotonic() - t0) * 1000, 1)
result["provider"] = body.provider
result["model"] = body.model
return result
async def _test_openrouter(model: str, prompt: Optional[str], image_b64: Optional[str]) -> dict:
"""Appel test vers OpenRouter."""
if not settings.OPENROUTER_API_KEY:
return {"success": False, "error": "Clé API OpenRouter non configurée"}
test_prompt = prompt or "Réponds uniquement avec le mot 'OK' pour confirmer que tu fonctionnes."
headers = {
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "imago-test",
"X-Title": "Imago AI Test",
}
messages = []
content_payload = [{"type": "text", "text": test_prompt}]
if image_b64:
content_payload.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{image_b64}"},
})
messages.append({"role": "user", "content": content_payload})
payload = {
"model": model,
"messages": messages,
"max_tokens": 64,
}
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
json=payload,
headers=headers,
timeout=60,
)
resp.raise_for_status()
data = resp.json()
text = data.get("choices", [{}])[0].get("message", {}).get("content", "")
usage = data.get("usage", {})
return {
"success": True,
"response": text[:500],
"tokens": {
"prompt": usage.get("prompt_tokens"),
"completion": usage.get("completion_tokens"),
},
}
except httpx.HTTPStatusError as e:
return {"success": False, "error": f"HTTP {e.response.status_code}: {e.response.text[:300]}"}
except Exception as e:
return {"success": False, "error": str(e)[:500]}
async def _test_gemini(model: str, prompt: Optional[str], image_b64: Optional[str]) -> dict:
"""Appel test vers Google Gemini."""
if not settings.GEMINI_API_KEY:
return {"success": False, "error": "Clé API Gemini non configurée"}
try:
from google import genai
from google.genai import types
except ImportError:
return {"success": False, "error": "SDK Google GenAI non installé"}
test_prompt = prompt or "Réponds uniquement avec le mot 'OK' pour confirmer que tu fonctionnes."
client = genai.Client(api_key=settings.GEMINI_API_KEY)
contents = []
if image_b64:
contents.append(types.Part.from_bytes(
data=base64.b64decode(image_b64),
mime_type="image/jpeg",
))
contents.append(test_prompt)
try:
response = await asyncio.wait_for(
asyncio.to_thread(
client.models.generate_content,
model=model,
contents=contents,
config=types.GenerateContentConfig(max_output_tokens=64),
),
timeout=60,
)
text = getattr(response, "text", "")
usage = getattr(response, "usage_metadata", None)
tokens = None
if usage:
tokens = {
"prompt": getattr(usage, "prompt_token_count", None),
"completion": getattr(usage, "candidates_token_count", None),
}
return {"success": True, "response": text[:500], "tokens": tokens}
except Exception as e:
return {"success": False, "error": str(e)[:500]}
# ─────────────────────────────────────────────────────────────
# GET /admin/api/ai/config — état actuel du runtime config
# ─────────────────────────────────────────────────────────────
@router.get("/config")
async def get_runtime_config():
"""Retourne la configuration AI runtime actuelle (provider + modèle)."""
return runtime_ai_config.to_dict()
# ─────────────────────────────────────────────────────────────
# PATCH /admin/api/ai/config — changer le provider/modèle live
# ─────────────────────────────────────────────────────────────
class UpdateAIConfigRequest(BaseModel):
provider: Optional[str] = Field(None, description="openrouter ou gemini")
model: Optional[str] = Field(None, description="Nom du modèle à activer")
reset: bool = Field(False, description="true = réinitialiser aux valeurs .env")
class UpdateAIConfigResponse(BaseModel):
provider: str
model: str
is_overridden: bool
message: str
@router.patch("/config", response_model=UpdateAIConfigResponse)
async def update_runtime_config(
body: UpdateAIConfigRequest,
_=Depends(require_scope("admin")),
):
"""Change le fournisseur et/ou le modèle AI actif sans redémarrer."""
if body.reset:
runtime_ai_config.clear()
state = runtime_ai_config.to_dict()
return UpdateAIConfigResponse(
provider=state["provider"],
model=state["model"],
is_overridden=False,
message="Configuration AI réinitialisée aux valeurs .env",
)
if not body.provider and not body.model:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Spécifiez provider et/ou model, ou reset=true",
)
runtime_ai_config.set(provider=body.provider, model=body.model)
state = runtime_ai_config.to_dict()
logger.info("ai.runtime_config.updated", extra={
"provider": state["provider"],
"model": state["model"],
})
return UpdateAIConfigResponse(
provider=state["provider"],
model=state["model"],
is_overridden=True,
message=f"Fournisseur changé à {state['provider']}, modèle {state['model']}",
)
# ─────────────────────────────────────────────────────────────
# GET /admin/api/ai/connectivity — test réseau vers les API AI
# ─────────────────────────────────────────────────────────────
@router.get("/connectivity")
async def check_ai_connectivity():
"""Vérifie la connectivité réseau vers OpenRouter et Gemini."""
results = {}
# Test OpenRouter
t0 = time.time()
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10, connect=5)) as client:
resp = await client.get(
"https://openrouter.ai/api/v1/models",
headers={"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}"} if settings.OPENROUTER_API_KEY else {},
)
results["openrouter"] = {
"reachable": True,
"status": resp.status_code,
"latency_ms": round((time.time() - t0) * 1000, 1),
"has_key": bool(settings.OPENROUTER_API_KEY),
}
except Exception as e:
results["openrouter"] = {
"reachable": False,
"error": str(e)[:200],
"latency_ms": round((time.time() - t0) * 1000, 1),
"has_key": bool(settings.OPENROUTER_API_KEY),
}
# Test Gemini
t0 = time.time()
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10, connect=5)) as client:
resp = await client.get("https://generativelanguage.googleapis.com")
results["gemini"] = {
"reachable": True,
"status": resp.status_code,
"latency_ms": round((time.time() - t0) * 1000, 1),
"has_key": bool(settings.GEMINI_API_KEY),
}
except Exception as e:
results["gemini"] = {
"reachable": False,
"error": str(e)[:200],
"latency_ms": round((time.time() - t0) * 1000, 1),
"has_key": bool(settings.GEMINI_API_KEY),
}
return {
"results": results,
"ai_enabled": settings.AI_ENABLED,
"ai_provider": runtime_ai_config.provider or settings.AI_PROVIDER,
"ai_request_timeout": settings.AI_REQUEST_TIMEOUT,
}