Live AI provider/model switching without restart
CI / Lint & Format (push) Failing after 8s
CI / Tests (push) Has been skipped
CI / Security Scan (push) Failing after 8s
CI / Docker Build (push) Has been skipped

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
This commit is contained in:
2026-06-25 11:07:17 -04:00
parent 7dd26ea636
commit 92cf23dbb3
6 changed files with 216 additions and 14 deletions
+9 -4
View File
@@ -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,
+69 -3
View File
@@ -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']}",
)
+6 -5
View File
@@ -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
)
+63
View File
@@ -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()
+22 -1
View File
@@ -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<AIStatus> {
@@ -8,3 +8,24 @@ export async function fetchAIStatus(): Promise<AIStatus> {
export async function testModel(body: TestModelRequest): Promise<TestModelResponse> {
return apiPost<TestModelResponse>('/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<UpdateAIConfigResponse> {
return apiPatch<UpdateAIConfigResponse>('/admin/api/ai/config', body);
}
export async function fetchRuntimeConfig(): Promise<UpdateAIConfigResponse> {
return apiGet<UpdateAIConfigResponse>('/admin/api/ai/config');
}
@@ -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<TestModelResponse | null>(null);
const [isTesting, setIsTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState<string | null>(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 (
<section className="card mb-6">
@@ -240,6 +262,30 @@ export function AIConfigSection() {
)}
</button>
<button
onClick={handleSetActive}
disabled={isSaving || !selectedProvider || !selectedModel}
className="btn-secondary flex items-center gap-2 text-sm disabled:opacity-50 ml-3"
>
{isSaving ? (
<>
<Loader2 size={14} className="animate-spin" />
Application...
</>
) : (
<>
<CheckCircle size={14} />
Définir comme actif
</>
)}
</button>
{saveMessage && (
<span className="ml-3 text-sm text-status-ok animate-fade-in">
{saveMessage}
</span>
)}
{/* Test result — live, no refresh needed */}
{(testResult || testError || isTesting) && (
<div className={clsx(