From 92cf23dbb39441a1b6d701d698da5bfe3b854262 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 25 Jun 2026 11:07:17 -0400 Subject: [PATCH] Live AI provider/model switching without restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - RuntimeAIConfig singleton: mutable in-memory overrides for provider/model - PATCH /admin/api/ai/config: change active provider/model on the fly - GET /admin/api/ai/config: read current runtime state - ai_vision.py: uses runtime_ai_config before falling back to settings - health/status endpoints reflect runtime overrides Frontend: - 'Définir comme actif' button in AI config section - Live feedback message on save --- app/main.py | 13 ++-- app/routers/admin_ai.py | 72 ++++++++++++++++++- app/services/ai_vision.py | 11 +-- app/services/runtime_config.py | 63 ++++++++++++++++ imago-admin/src/api/ai.ts | 23 +++++- .../src/components/admin/AIConfigSection.tsx | 48 ++++++++++++- 6 files changed, 216 insertions(+), 14 deletions(-) create mode 100644 app/services/runtime_config.py diff --git a/app/main.py b/app/main.py index 66cf334..36bed48 100644 --- a/app/main.py +++ b/app/main.py @@ -188,15 +188,20 @@ async def root(): @app.get("/health", tags=["Santé"]) async def health(): + from app.services.runtime_config import runtime_ai_config + 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 + ) ai_configured = ( - (settings.AI_PROVIDER == "gemini" and bool(settings.GEMINI_API_KEY)) or - (settings.AI_PROVIDER == "openrouter" and bool(settings.OPENROUTER_API_KEY)) + (active_provider == "gemini" and bool(settings.GEMINI_API_KEY)) or + (active_provider == "openrouter" and bool(settings.OPENROUTER_API_KEY)) ) - active_model = settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter" else settings.GEMINI_MODEL return { "status": "healthy", "ai_enabled": settings.AI_ENABLED, - "ai_provider": settings.AI_PROVIDER, + "ai_provider": active_provider, "ai_configured": ai_configured, "ocr_enabled": settings.OCR_ENABLED, "model": active_model, diff --git a/app/routers/admin_ai.py b/app/routers/admin_ai.py index 8417341..a858583 100644 --- a/app/routers/admin_ai.py +++ b/app/routers/admin_ai.py @@ -17,6 +17,7 @@ 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__) @@ -114,13 +115,14 @@ def _build_providers() -> list[ProviderInfo]: @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" + 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=settings.AI_PROVIDER, + current_provider=active_provider, current_model=active_model, providers=_build_providers(), ) @@ -252,3 +254,67 @@ async def _test_gemini(model: str, prompt: Optional[str], image_b64: Optional[st 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']}", + ) diff --git a/app/services/ai_vision.py b/app/services/ai_vision.py index 7c4dd4e..17dd132 100644 --- a/app/services/ai_vision.py +++ b/app/services/ai_vision.py @@ -22,6 +22,7 @@ from google.genai import types from app.config import settings from app.services.storage_backend import get_storage_backend +from app.services.runtime_config import runtime_ai_config logger = logging.getLogger(__name__) @@ -128,7 +129,7 @@ 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 + model = model_override or runtime_ai_config.model or settings.GEMINI_MODEL async def _call(): return await asyncio.wait_for( @@ -165,7 +166,7 @@ async def _generate_openrouter( logger.warning("ai.openrouter.no_key") return {"text": None, "usage": (None, None)} - model = model_override or settings.OPENROUTER_MODEL + model = model_override or runtime_ai_config.model or settings.OPENROUTER_MODEL headers = { "Authorization": f"Bearer {settings.OPENROUTER_API_KEY}", @@ -233,7 +234,7 @@ async def _generate( model_override: Optional[str] = None, ) -> dict: """Dispatcher vers le bon provider, avec override optionnel par client.""" - provider = provider_override or settings.AI_PROVIDER.lower() + provider = provider_override or runtime_ai_config.provider or settings.AI_PROVIDER.lower() logger.info("ai.generate", extra={"provider": provider}) if provider == "openrouter": @@ -289,8 +290,8 @@ async def analyze_image( if not settings.AI_ENABLED: return {} - active_provider = provider_override or settings.AI_PROVIDER - active_model = model_override or ( + active_provider = provider_override or runtime_ai_config.provider or settings.AI_PROVIDER + active_model = model_override or runtime_ai_config.model or ( settings.OPENROUTER_MODEL if active_provider == "openrouter" else settings.GEMINI_MODEL ) diff --git a/app/services/runtime_config.py b/app/services/runtime_config.py new file mode 100644 index 0000000..a077ce7 --- /dev/null +++ b/app/services/runtime_config.py @@ -0,0 +1,63 @@ +""" +Runtime AI Config — mutable overrides pour le provider/model actif. + +Permet à l'admin de changer le fournisseur/modèle sans redémarrer. +Les overrides sont stockés en mémoire (perdus au redémarrage du backend). +""" +import threading +from typing import Optional + + +class RuntimeAIConfig: + """Singleton thread-safe pour les overrides AI runtime.""" + + def __init__(self): + self._lock = threading.Lock() + self._provider: Optional[str] = None + self._model: Optional[str] = None + + @property + def provider(self) -> Optional[str]: + with self._lock: + return self._provider + + @property + def model(self) -> Optional[str]: + with self._lock: + return self._model + + @property + def has_override(self) -> bool: + with self._lock: + return self._provider is not None + + def set(self, provider: Optional[str] = None, model: Optional[str] = None) -> None: + """Définit les overrides. None = utiliser le défaut de settings.""" + with self._lock: + self._provider = provider + self._model = model + + def clear(self) -> None: + """Réinitialise aux valeurs par défaut.""" + with self._lock: + self._provider = None + self._model = None + + def to_dict(self) -> dict: + """Retourne l'état actuel pour les API.""" + from app.config import settings + with self._lock: + return { + "provider": self._provider or settings.AI_PROVIDER, + "model": self._model or ( + settings.OPENROUTER_MODEL if (self._provider or settings.AI_PROVIDER) == "openrouter" + else settings.GEMINI_MODEL + ), + "is_overridden": self._provider is not None, + "override_provider": self._provider, + "override_model": self._model, + } + + +# Singleton global +runtime_ai_config = RuntimeAIConfig() diff --git a/imago-admin/src/api/ai.ts b/imago-admin/src/api/ai.ts index 0d236ed..f1a8c3a 100644 --- a/imago-admin/src/api/ai.ts +++ b/imago-admin/src/api/ai.ts @@ -1,4 +1,4 @@ -import { apiGet, apiPost } from './client'; +import { apiGet, apiPost, apiPatch } from './client'; import type { AIStatus, TestModelRequest, TestModelResponse } from '@/types/ai.types'; export async function fetchAIStatus(): Promise { @@ -8,3 +8,24 @@ export async function fetchAIStatus(): Promise { export async function testModel(body: TestModelRequest): Promise { return apiPost('/admin/api/ai/test', body); } + +export interface UpdateAIConfigRequest { + provider?: string; + model?: string; + reset?: boolean; +} + +export interface UpdateAIConfigResponse { + provider: string; + model: string; + is_overridden: boolean; + message: string; +} + +export async function updateAIConfig(body: UpdateAIConfigRequest): Promise { + return apiPatch('/admin/api/ai/config', body); +} + +export async function fetchRuntimeConfig(): Promise { + return apiGet('/admin/api/ai/config'); +} diff --git a/imago-admin/src/components/admin/AIConfigSection.tsx b/imago-admin/src/components/admin/AIConfigSection.tsx index 1ae8f81..c3b3c60 100644 --- a/imago-admin/src/components/admin/AIConfigSection.tsx +++ b/imago-admin/src/components/admin/AIConfigSection.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { fetchAIStatus, testModel } from '@/api/ai'; +import { fetchAIStatus, testModel, updateAIConfig } from '@/api/ai'; import type { TestModelResponse } from '@/types/ai.types'; import { Bot, Zap, AlertTriangle, CheckCircle, Loader2, RefreshCw, Cpu, ChevronDown } from 'lucide-react'; import { clsx } from 'clsx'; @@ -18,6 +18,8 @@ export function AIConfigSection() { const [testResult, setTestResult] = useState(null); const [isTesting, setIsTesting] = useState(false); const [testError, setTestError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [saveMessage, setSaveMessage] = useState(null); // Sync dropdowns with current config when data loads useEffect(() => { @@ -64,6 +66,26 @@ export function AIConfigSection() { } }, [selectedProvider, selectedModel]); + const handleSetActive = useCallback(async () => { + if (!selectedProvider || !selectedModel) return; + + setIsSaving(true); + setSaveMessage(null); + try { + const result = await updateAIConfig({ + provider: selectedProvider, + model: selectedModel, + }); + setSaveMessage(result.message); + // Auto-clear message after 4s + setTimeout(() => setSaveMessage(null), 4000); + } catch (err: any) { + setSaveMessage(err?.message || 'Erreur lors de la mise à jour'); + } finally { + setIsSaving(false); + } + }, [selectedProvider, selectedModel]); + if (isLoading) { return (
@@ -240,6 +262,30 @@ export function AIConfigSection() { )} + + + {saveMessage && ( + + {saveMessage} + + )} + {/* Test result — live, no refresh needed */} {(testResult || testError || isTesting) && (