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é"])
|
@app.get("/health", tags=["Santé"])
|
||||||
async def health():
|
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 = (
|
ai_configured = (
|
||||||
(settings.AI_PROVIDER == "gemini" and bool(settings.GEMINI_API_KEY)) or
|
(active_provider == "gemini" and bool(settings.GEMINI_API_KEY)) or
|
||||||
(settings.AI_PROVIDER == "openrouter" and bool(settings.OPENROUTER_API_KEY))
|
(active_provider == "openrouter" and bool(settings.OPENROUTER_API_KEY))
|
||||||
)
|
)
|
||||||
active_model = settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter" else settings.GEMINI_MODEL
|
|
||||||
return {
|
return {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"ai_enabled": settings.AI_ENABLED,
|
"ai_enabled": settings.AI_ENABLED,
|
||||||
"ai_provider": settings.AI_PROVIDER,
|
"ai_provider": active_provider,
|
||||||
"ai_configured": ai_configured,
|
"ai_configured": ai_configured,
|
||||||
"ocr_enabled": settings.OCR_ENABLED,
|
"ocr_enabled": settings.OCR_ENABLED,
|
||||||
"model": active_model,
|
"model": active_model,
|
||||||
|
|||||||
+69
-3
@@ -17,6 +17,7 @@ from app.config import settings
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.dependencies.auth import require_scope
|
from app.dependencies.auth import require_scope
|
||||||
from app.models.client import APIClient
|
from app.models.client import APIClient
|
||||||
|
from app.services.runtime_config import runtime_ai_config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -114,13 +115,14 @@ def _build_providers() -> list[ProviderInfo]:
|
|||||||
@router.get("/status", response_model=AIStatusResponse)
|
@router.get("/status", response_model=AIStatusResponse)
|
||||||
async def get_ai_status():
|
async def get_ai_status():
|
||||||
"""Retourne la configuration AI actuelle et les fournisseurs/modèles disponibles."""
|
"""Retourne la configuration AI actuelle et les fournisseurs/modèles disponibles."""
|
||||||
active_model = (
|
active_provider = runtime_ai_config.provider or settings.AI_PROVIDER
|
||||||
settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter"
|
active_model = runtime_ai_config.model or (
|
||||||
|
settings.OPENROUTER_MODEL if active_provider == "openrouter"
|
||||||
else settings.GEMINI_MODEL
|
else settings.GEMINI_MODEL
|
||||||
)
|
)
|
||||||
return AIStatusResponse(
|
return AIStatusResponse(
|
||||||
ai_enabled=settings.AI_ENABLED,
|
ai_enabled=settings.AI_ENABLED,
|
||||||
current_provider=settings.AI_PROVIDER,
|
current_provider=active_provider,
|
||||||
current_model=active_model,
|
current_model=active_model,
|
||||||
providers=_build_providers(),
|
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}
|
return {"success": True, "response": text[:500], "tokens": tokens}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"success": False, "error": str(e)[:500]}
|
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.config import settings
|
||||||
from app.services.storage_backend import get_storage_backend
|
from app.services.storage_backend import get_storage_backend
|
||||||
|
from app.services.runtime_config import runtime_ai_config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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(types.Part.from_bytes(data=image_bytes, mime_type=media_type))
|
||||||
contents.append(prompt)
|
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():
|
async def _call():
|
||||||
return await asyncio.wait_for(
|
return await asyncio.wait_for(
|
||||||
@@ -165,7 +166,7 @@ async def _generate_openrouter(
|
|||||||
logger.warning("ai.openrouter.no_key")
|
logger.warning("ai.openrouter.no_key")
|
||||||
return {"text": None, "usage": (None, None)}
|
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 = {
|
headers = {
|
||||||
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
|
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
|
||||||
@@ -233,7 +234,7 @@ async def _generate(
|
|||||||
model_override: Optional[str] = None,
|
model_override: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Dispatcher vers le bon provider, avec override optionnel par client."""
|
"""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})
|
logger.info("ai.generate", extra={"provider": provider})
|
||||||
|
|
||||||
if provider == "openrouter":
|
if provider == "openrouter":
|
||||||
@@ -289,8 +290,8 @@ async def analyze_image(
|
|||||||
if not settings.AI_ENABLED:
|
if not settings.AI_ENABLED:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
active_provider = provider_override or settings.AI_PROVIDER
|
active_provider = provider_override or runtime_ai_config.provider or settings.AI_PROVIDER
|
||||||
active_model = model_override or (
|
active_model = model_override or runtime_ai_config.model or (
|
||||||
settings.OPENROUTER_MODEL if active_provider == "openrouter"
|
settings.OPENROUTER_MODEL if active_provider == "openrouter"
|
||||||
else settings.GEMINI_MODEL
|
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';
|
import type { AIStatus, TestModelRequest, TestModelResponse } from '@/types/ai.types';
|
||||||
|
|
||||||
export async function fetchAIStatus(): Promise<AIStatus> {
|
export async function fetchAIStatus(): Promise<AIStatus> {
|
||||||
@@ -8,3 +8,24 @@ export async function fetchAIStatus(): Promise<AIStatus> {
|
|||||||
export async function testModel(body: TestModelRequest): Promise<TestModelResponse> {
|
export async function testModel(body: TestModelRequest): Promise<TestModelResponse> {
|
||||||
return apiPost<TestModelResponse>('/admin/api/ai/test', body);
|
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 { useState, useEffect, useCallback } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 type { TestModelResponse } from '@/types/ai.types';
|
||||||
import { Bot, Zap, AlertTriangle, CheckCircle, Loader2, RefreshCw, Cpu, ChevronDown } from 'lucide-react';
|
import { Bot, Zap, AlertTriangle, CheckCircle, Loader2, RefreshCw, Cpu, ChevronDown } from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
@@ -18,6 +18,8 @@ export function AIConfigSection() {
|
|||||||
const [testResult, setTestResult] = useState<TestModelResponse | null>(null);
|
const [testResult, setTestResult] = useState<TestModelResponse | null>(null);
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
const [testError, setTestError] = useState<string | null>(null);
|
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
|
// Sync dropdowns with current config when data loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,6 +66,26 @@ export function AIConfigSection() {
|
|||||||
}
|
}
|
||||||
}, [selectedProvider, selectedModel]);
|
}, [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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<section className="card mb-6">
|
<section className="card mb-6">
|
||||||
@@ -240,6 +262,30 @@ export function AIConfigSection() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</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 */}
|
{/* Test result — live, no refresh needed */}
|
||||||
{(testResult || testError || isTesting) && (
|
{(testResult || testError || isTesting) && (
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
|
|||||||
Reference in New Issue
Block a user