AI Vision: multi-provider config, model testing, per-client AI preferences
Backend: - Add ai_provider + ai_model columns to APIClient model - New admin_ai router: GET /admin/api/ai/status + POST /admin/api/ai/test - Update ai_vision.py: provider_override + model_override support - Update pipeline: load client AI prefs for image processing - ClientUpdate/ClientResponse schemas include ai_provider/ai_model Frontend: - New AIConfigSection component: provider list, model dropdowns, test button with live results (no page refresh needed) - AI types (ai.types.ts) + API (api/ai.ts) - APIClient types extended with ai_provider/ai_model
This commit is contained in:
+2
-1
@@ -12,7 +12,7 @@ from slowapi.errors import RateLimitExceeded
|
||||
from app.config import settings
|
||||
from app.database import init_db
|
||||
from app.logging_config import configure_logging
|
||||
from app.routers import images_router, ai_router, auth_router, files_router, ws_router, admin_router
|
||||
from app.routers import images_router, ai_router, auth_router, files_router, ws_router, admin_router, admin_ai_router
|
||||
from app.middleware import limiter
|
||||
from app.middleware.logging_middleware import LoggingMiddleware
|
||||
from app.middleware.versioning import APIVersioningMiddleware
|
||||
@@ -171,6 +171,7 @@ app.include_router(api_v1_router)
|
||||
# ── Routers non-versionnés ──────────────────────────────────
|
||||
app.include_router(ws_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(admin_ai_router)
|
||||
|
||||
|
||||
# ── Routes utilitaires ──────────────────────────────────────
|
||||
|
||||
@@ -47,6 +47,10 @@ class APIClient(Base):
|
||||
quota_storage_mb = Column(Integer, default=500, nullable=False)
|
||||
quota_images = Column(Integer, default=1000, nullable=False)
|
||||
|
||||
# ── AI Preferences (per-client provider/model override) ────
|
||||
ai_provider = Column(String(64), nullable=True)
|
||||
ai_model = Column(String(256), nullable=True)
|
||||
|
||||
# ── Timestamps ────────────────────────────────────────────
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(
|
||||
|
||||
@@ -4,5 +4,6 @@ from app.routers.auth import router as auth_router
|
||||
from app.routers.files import router as files_router
|
||||
from app.routers.websocket import router as ws_router
|
||||
from app.routers.admin import router as admin_router
|
||||
from app.routers.admin_ai import router as admin_ai_router
|
||||
|
||||
__all__ = ["images_router", "ai_router", "auth_router", "files_router", "ws_router", "admin_router"]
|
||||
__all__ = ["images_router", "ai_router", "auth_router", "files_router", "ws_router", "admin_router", "admin_ai_router"]
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""
|
||||
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
|
||||
|
||||
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_model = (
|
||||
settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter"
|
||||
else settings.GEMINI_MODEL
|
||||
)
|
||||
return AIStatusResponse(
|
||||
ai_enabled=settings.AI_ENABLED,
|
||||
current_provider=settings.AI_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]}
|
||||
@@ -47,6 +47,8 @@ class ClientUpdate(BaseModel):
|
||||
scopes: Optional[List[str]] = None
|
||||
plan: Optional[ClientPlan] = None
|
||||
is_active: Optional[bool] = None
|
||||
ai_provider: Optional[str] = Field(None, description="Fournisseur AI préféré (openrouter, gemini)")
|
||||
ai_model: Optional[str] = Field(None, description="Modèle AI préféré")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -62,6 +64,8 @@ class ClientResponse(BaseModel):
|
||||
scopes: List[str]
|
||||
plan: ClientPlan
|
||||
is_active: bool
|
||||
ai_provider: Optional[str] = None
|
||||
ai_model: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
+36
-11
@@ -114,7 +114,8 @@ async def _generate_gemini(
|
||||
prompt: str,
|
||||
image_bytes: Optional[bytes] = None,
|
||||
media_type: Optional[str] = None,
|
||||
max_tokens: int = 1024
|
||||
max_tokens: int = 1024,
|
||||
model_override: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Appel à Google Gemini via SDK avec timeout et retry."""
|
||||
if not settings.GEMINI_API_KEY:
|
||||
@@ -127,11 +128,13 @@ async def _generate_gemini(
|
||||
contents.append(types.Part.from_bytes(data=image_bytes, mime_type=media_type))
|
||||
contents.append(prompt)
|
||||
|
||||
model = model_override or settings.GEMINI_MODEL
|
||||
|
||||
async def _call():
|
||||
return await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
client.models.generate_content,
|
||||
model=settings.GEMINI_MODEL,
|
||||
model=model,
|
||||
contents=contents,
|
||||
config=types.GenerateContentConfig(
|
||||
max_output_tokens=max_tokens,
|
||||
@@ -154,13 +157,16 @@ async def _generate_openrouter(
|
||||
prompt: str,
|
||||
image_bytes: Optional[bytes] = None,
|
||||
media_type: Optional[str] = None,
|
||||
max_tokens: int = 1024
|
||||
max_tokens: int = 1024,
|
||||
model_override: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Appel à OpenRouter via HTTP avec timeout et retry."""
|
||||
if not settings.OPENROUTER_API_KEY:
|
||||
logger.warning("ai.openrouter.no_key")
|
||||
return {"text": None, "usage": (None, None)}
|
||||
|
||||
model = model_override or settings.OPENROUTER_MODEL
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
@@ -184,7 +190,7 @@ async def _generate_openrouter(
|
||||
messages.append({"role": "user", "content": content_payload})
|
||||
|
||||
payload = {
|
||||
"model": settings.OPENROUTER_MODEL,
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"response_format": {"type": "json_object"}
|
||||
@@ -222,16 +228,24 @@ async def _generate(
|
||||
prompt: str,
|
||||
image_bytes: Optional[bytes] = None,
|
||||
media_type: Optional[str] = None,
|
||||
max_tokens: int = 1024
|
||||
max_tokens: int = 1024,
|
||||
provider_override: Optional[str] = None,
|
||||
model_override: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Dispatcher vers le bon provider."""
|
||||
provider = settings.AI_PROVIDER.lower()
|
||||
"""Dispatcher vers le bon provider, avec override optionnel par client."""
|
||||
provider = provider_override or settings.AI_PROVIDER.lower()
|
||||
logger.info("ai.generate", extra={"provider": provider})
|
||||
|
||||
if provider == "openrouter":
|
||||
return await _generate_openrouter(prompt, image_bytes, media_type, max_tokens)
|
||||
return await _generate_openrouter(
|
||||
prompt, image_bytes, media_type, max_tokens,
|
||||
model_override=model_override,
|
||||
)
|
||||
else:
|
||||
return await _generate_gemini(prompt, image_bytes, media_type, max_tokens)
|
||||
return await _generate_gemini(
|
||||
prompt, image_bytes, media_type, max_tokens,
|
||||
model_override=model_override,
|
||||
)
|
||||
|
||||
|
||||
def _build_prompt(ocr_hint: Optional[str], language: str) -> str:
|
||||
@@ -265,18 +279,27 @@ async def analyze_image(
|
||||
file_path: str,
|
||||
ocr_hint: Optional[str] = None,
|
||||
language: str = "français",
|
||||
provider_override: Optional[str] = None,
|
||||
model_override: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Envoie l'image à l'AI pour analyse (Description + Tags).
|
||||
Supporte l'override par client pour le fournisseur/modèle.
|
||||
"""
|
||||
if not settings.AI_ENABLED:
|
||||
return {}
|
||||
|
||||
active_provider = provider_override or settings.AI_PROVIDER
|
||||
active_model = model_override or (
|
||||
settings.OPENROUTER_MODEL if active_provider == "openrouter"
|
||||
else settings.GEMINI_MODEL
|
||||
)
|
||||
|
||||
result = {
|
||||
"description": None,
|
||||
"tags": [],
|
||||
"confidence": None,
|
||||
"model": settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter" else settings.GEMINI_MODEL,
|
||||
"model": active_model,
|
||||
"prompt_tokens": None,
|
||||
"output_tokens": None,
|
||||
}
|
||||
@@ -289,7 +312,9 @@ async def analyze_image(
|
||||
prompt=prompt,
|
||||
image_bytes=image_bytes,
|
||||
media_type=media_type,
|
||||
max_tokens=settings.GEMINI_MAX_TOKENS
|
||||
max_tokens=settings.GEMINI_MAX_TOKENS,
|
||||
provider_override=provider_override,
|
||||
model_override=model_override,
|
||||
)
|
||||
|
||||
text = response.get("text")
|
||||
|
||||
@@ -181,9 +181,25 @@ async def process_image_pipeline(
|
||||
try:
|
||||
logger.info("pipeline.step.start", extra={"image_id": image_id, "step": "ai", "step_num": "3/3"})
|
||||
t0 = time.time()
|
||||
|
||||
# Charger les préférences AI du client
|
||||
provider_override = None
|
||||
model_override = None
|
||||
if image.client_id:
|
||||
from app.models.client import APIClient
|
||||
client_result = await db.execute(
|
||||
select(APIClient).where(APIClient.id == image.client_id)
|
||||
)
|
||||
owner = client_result.scalar_one_or_none()
|
||||
if owner:
|
||||
provider_override = owner.ai_provider
|
||||
model_override = owner.ai_model
|
||||
|
||||
ai = await analyze_image(
|
||||
file_path=file_path,
|
||||
ocr_hint=image.ocr_text,
|
||||
provider_override=provider_override,
|
||||
model_override=model_override,
|
||||
)
|
||||
|
||||
image.ai_description = ai.get("description")
|
||||
|
||||
Reference in New Issue
Block a user