Live AI provider/model switching without restart
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:
+9
-4
@@ -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
@@ -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']}",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user