AI Vision: multi-provider config, model testing, per-client AI preferences
CI / Lint & Format (push) Failing after 11s
CI / Tests (push) Has been skipped
CI / Security Scan (push) Failing after 8s
CI / Docker Build (push) Has been skipped

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:
2026-06-25 10:30:50 -04:00
parent 1260a46c7c
commit 9bb440707c
12 changed files with 691 additions and 14 deletions
+36 -11
View File
@@ -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")
+16
View File
@@ -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")