From 9bb440707c80110f329acf7e6f8eb143871d583c Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 25 Jun 2026 10:30:50 -0400 Subject: [PATCH] AI Vision: multi-provider config, model testing, per-client AI preferences 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 --- app/main.py | 3 +- app/models/client.py | 4 + app/routers/__init__.py | 3 +- app/routers/admin_ai.py | 254 ++++++++++++++ app/schemas/auth.py | 4 + app/services/ai_vision.py | 47 ++- app/services/pipeline.py | 16 + imago-admin/src/api/ai.ts | 10 + .../src/components/admin/AIConfigSection.tsx | 324 ++++++++++++++++++ imago-admin/src/pages/admin/Config.tsx | 5 +- imago-admin/src/types/ai.types.ts | 31 ++ imago-admin/src/types/api.types.ts | 4 + 12 files changed, 691 insertions(+), 14 deletions(-) create mode 100644 app/routers/admin_ai.py create mode 100644 imago-admin/src/api/ai.ts create mode 100644 imago-admin/src/components/admin/AIConfigSection.tsx create mode 100644 imago-admin/src/types/ai.types.ts diff --git a/app/main.py b/app/main.py index e151b7d..66cf334 100644 --- a/app/main.py +++ b/app/main.py @@ -12,7 +12,7 @@ from slowapi.errors import RateLimitExceeded from app.config import settings from app.database import init_db from app.logging_config import configure_logging -from app.routers import images_router, ai_router, auth_router, files_router, ws_router, admin_router +from app.routers import images_router, ai_router, auth_router, files_router, ws_router, admin_router, admin_ai_router from app.middleware import limiter from app.middleware.logging_middleware import LoggingMiddleware from app.middleware.versioning import APIVersioningMiddleware @@ -171,6 +171,7 @@ app.include_router(api_v1_router) # ── Routers non-versionnés ────────────────────────────────── app.include_router(ws_router) app.include_router(admin_router) +app.include_router(admin_ai_router) # ── Routes utilitaires ────────────────────────────────────── diff --git a/app/models/client.py b/app/models/client.py index fba643f..6a08e58 100644 --- a/app/models/client.py +++ b/app/models/client.py @@ -47,6 +47,10 @@ class APIClient(Base): quota_storage_mb = Column(Integer, default=500, nullable=False) quota_images = Column(Integer, default=1000, nullable=False) + # ── AI Preferences (per-client provider/model override) ──── + ai_provider = Column(String(64), nullable=True) + ai_model = Column(String(256), nullable=True) + # ── Timestamps ──────────────────────────────────────────── created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) updated_at = Column( diff --git a/app/routers/__init__.py b/app/routers/__init__.py index eb42033..82188be 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -4,5 +4,6 @@ from app.routers.auth import router as auth_router from app.routers.files import router as files_router from app.routers.websocket import router as ws_router from app.routers.admin import router as admin_router +from app.routers.admin_ai import router as admin_ai_router -__all__ = ["images_router", "ai_router", "auth_router", "files_router", "ws_router", "admin_router"] +__all__ = ["images_router", "ai_router", "auth_router", "files_router", "ws_router", "admin_router", "admin_ai_router"] diff --git a/app/routers/admin_ai.py b/app/routers/admin_ai.py new file mode 100644 index 0000000..8417341 --- /dev/null +++ b/app/routers/admin_ai.py @@ -0,0 +1,254 @@ +""" +Router — Admin AI : configuration, test et préférences AI par client. +""" +import asyncio +import base64 +import logging +import time +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database import get_db +from app.dependencies.auth import require_scope +from app.models.client import APIClient + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/api/ai", tags=["Admin AI"]) + + +# ───────────────────────────────────────────────────────────── +# Schemas +# ───────────────────────────────────────────────────────────── + +class ProviderInfo(BaseModel): + key: str + name: str + configured: bool + models: list[str] = [] + + +class AIStatusResponse(BaseModel): + ai_enabled: bool + current_provider: str + current_model: str + providers: list[ProviderInfo] + + +class TestModelRequest(BaseModel): + provider: str = Field(..., description="openrouter ou gemini") + model: str = Field(..., description="Nom du modèle à tester") + prompt: Optional[str] = Field(None, description="Prompt de test (optionnel)") + image_base64: Optional[str] = Field(None, description="Image base64 pour test vision (optionnel)") + + +class TestModelResponse(BaseModel): + success: bool + provider: str + model: str + response: Optional[str] = None + tokens: Optional[dict] = None + latency_ms: float = 0 + error: Optional[str] = None + + +# ───────────────────────────────────────────────────────────── +# Constantes — Modèles disponibles par fournisseur +# ───────────────────────────────────────────────────────────── + +OPENROUTER_VISION_MODELS = [ + "google/gemini-2.5-flash-lite", + "google/gemini-2.5-flash", + "google/gemini-2.5-pro-preview", + "qwen/qwen2.5-vl-72b-instruct", + "anthropic/claude-3.5-sonnet", + "anthropic/claude-3-opus", + "openai/gpt-4o", + "openai/gpt-4o-mini", + "meta-llama/llama-4-maverick", +] + +GEMINI_MODELS = [ + "gemini-2.5-flash-lite", + "gemini-2.5-flash", + "gemini-2.5-pro-preview", + "gemini-2.0-flash", +] + + +def _build_providers() -> list[ProviderInfo]: + """Construit la liste des fournisseurs disponibles selon la config .env.""" + providers = [] + + # OpenRouter + or_configured = bool(settings.OPENROUTER_API_KEY) + providers.append(ProviderInfo( + key="openrouter", + name="OpenRouter", + configured=or_configured, + models=OPENROUTER_VISION_MODELS if or_configured else [], + )) + + # Gemini + gemini_configured = bool(settings.GEMINI_API_KEY) + providers.append(ProviderInfo( + key="gemini", + name="Google Gemini", + configured=gemini_configured, + models=GEMINI_MODELS if gemini_configured else [], + )) + + return providers + + +# ───────────────────────────────────────────────────────────── +# GET /admin/api/ai/status +# ───────────────────────────────────────────────────────────── + +@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" + else settings.GEMINI_MODEL + ) + return AIStatusResponse( + ai_enabled=settings.AI_ENABLED, + current_provider=settings.AI_PROVIDER, + current_model=active_model, + providers=_build_providers(), + ) + + +# ───────────────────────────────────────────────────────────── +# POST /admin/api/ai/test +# ───────────────────────────────────────────────────────────── + +@router.post("/test", response_model=TestModelResponse) +async def test_model( + body: TestModelRequest, + _=Depends(require_scope("admin")), +): + """Teste un modèle AI avec un prompt simple (et optionnellement une image).""" + t0 = time.monotonic() + + if body.provider == "openrouter": + result = await _test_openrouter(body.model, body.prompt, body.image_base64) + elif body.provider == "gemini": + result = await _test_gemini(body.model, body.prompt, body.image_base64) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Fournisseur inconnu : {body.provider}", + ) + + result["latency_ms"] = round((time.monotonic() - t0) * 1000, 1) + result["provider"] = body.provider + result["model"] = body.model + return result + + +async def _test_openrouter(model: str, prompt: Optional[str], image_b64: Optional[str]) -> dict: + """Appel test vers OpenRouter.""" + if not settings.OPENROUTER_API_KEY: + return {"success": False, "error": "Clé API OpenRouter non configurée"} + + test_prompt = prompt or "Réponds uniquement avec le mot 'OK' pour confirmer que tu fonctionnes." + headers = { + "Authorization": f"Bearer {settings.OPENROUTER_API_KEY}", + "Content-Type": "application/json", + "HTTP-Referer": "imago-test", + "X-Title": "Imago AI Test", + } + + messages = [] + content_payload = [{"type": "text", "text": test_prompt}] + if image_b64: + content_payload.append({ + "type": "image_url", + "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}, + }) + messages.append({"role": "user", "content": content_payload}) + + payload = { + "model": model, + "messages": messages, + "max_tokens": 64, + } + + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + "https://openrouter.ai/api/v1/chat/completions", + json=payload, + headers=headers, + timeout=60, + ) + resp.raise_for_status() + data = resp.json() + + text = data.get("choices", [{}])[0].get("message", {}).get("content", "") + usage = data.get("usage", {}) + return { + "success": True, + "response": text[:500], + "tokens": { + "prompt": usage.get("prompt_tokens"), + "completion": usage.get("completion_tokens"), + }, + } + except httpx.HTTPStatusError as e: + return {"success": False, "error": f"HTTP {e.response.status_code}: {e.response.text[:300]}"} + except Exception as e: + return {"success": False, "error": str(e)[:500]} + + +async def _test_gemini(model: str, prompt: Optional[str], image_b64: Optional[str]) -> dict: + """Appel test vers Google Gemini.""" + if not settings.GEMINI_API_KEY: + return {"success": False, "error": "Clé API Gemini non configurée"} + + try: + from google import genai + from google.genai import types + except ImportError: + return {"success": False, "error": "SDK Google GenAI non installé"} + + test_prompt = prompt or "Réponds uniquement avec le mot 'OK' pour confirmer que tu fonctionnes." + + client = genai.Client(api_key=settings.GEMINI_API_KEY) + contents = [] + if image_b64: + contents.append(types.Part.from_bytes( + data=base64.b64decode(image_b64), + mime_type="image/jpeg", + )) + contents.append(test_prompt) + + try: + response = await asyncio.wait_for( + asyncio.to_thread( + client.models.generate_content, + model=model, + contents=contents, + config=types.GenerateContentConfig(max_output_tokens=64), + ), + timeout=60, + ) + text = getattr(response, "text", "") + usage = getattr(response, "usage_metadata", None) + tokens = None + if usage: + tokens = { + "prompt": getattr(usage, "prompt_token_count", None), + "completion": getattr(usage, "candidates_token_count", None), + } + return {"success": True, "response": text[:500], "tokens": tokens} + except Exception as e: + return {"success": False, "error": str(e)[:500]} diff --git a/app/schemas/auth.py b/app/schemas/auth.py index ba1a887..39831e1 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -47,6 +47,8 @@ class ClientUpdate(BaseModel): scopes: Optional[List[str]] = None plan: Optional[ClientPlan] = None is_active: Optional[bool] = None + ai_provider: Optional[str] = Field(None, description="Fournisseur AI préféré (openrouter, gemini)") + ai_model: Optional[str] = Field(None, description="Modèle AI préféré") # ───────────────────────────────────────────────────────────── @@ -62,6 +64,8 @@ class ClientResponse(BaseModel): scopes: List[str] plan: ClientPlan is_active: bool + ai_provider: Optional[str] = None + ai_model: Optional[str] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None diff --git a/app/services/ai_vision.py b/app/services/ai_vision.py index be9a5f7..7c4dd4e 100644 --- a/app/services/ai_vision.py +++ b/app/services/ai_vision.py @@ -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") diff --git a/app/services/pipeline.py b/app/services/pipeline.py index f2d00ee..bce1016 100644 --- a/app/services/pipeline.py +++ b/app/services/pipeline.py @@ -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") diff --git a/imago-admin/src/api/ai.ts b/imago-admin/src/api/ai.ts new file mode 100644 index 0000000..0d236ed --- /dev/null +++ b/imago-admin/src/api/ai.ts @@ -0,0 +1,10 @@ +import { apiGet, apiPost } from './client'; +import type { AIStatus, TestModelRequest, TestModelResponse } from '@/types/ai.types'; + +export async function fetchAIStatus(): Promise { + return apiGet('/admin/api/ai/status'); +} + +export async function testModel(body: TestModelRequest): Promise { + return apiPost('/admin/api/ai/test', body); +} diff --git a/imago-admin/src/components/admin/AIConfigSection.tsx b/imago-admin/src/components/admin/AIConfigSection.tsx new file mode 100644 index 0000000..a4dd49e --- /dev/null +++ b/imago-admin/src/components/admin/AIConfigSection.tsx @@ -0,0 +1,324 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { fetchAIStatus, testModel } from '@/api/ai'; +import type { AIStatus, TestModelResponse } from '@/types/ai.types'; +import { Bot, Zap, AlertTriangle, CheckCircle, Loader2, RefreshCw, Cpu, ChevronDown } from 'lucide-react'; +import { clsx } from 'clsx'; + +export function AIConfigSection() { + const { data: aiStatus, isLoading, refetch } = useQuery({ + queryKey: ['ai-status'], + queryFn: fetchAIStatus, + refetchInterval: 30000, + staleTime: 10000, + }); + + const [selectedProvider, setSelectedProvider] = useState(''); + const [selectedModel, setSelectedModel] = useState(''); + const [testResult, setTestResult] = useState(null); + const [isTesting, setIsTesting] = useState(false); + const [testError, setTestError] = useState(null); + + // Sync dropdowns with current config when data loads + useEffect(() => { + if (aiStatus && !selectedProvider) { + setSelectedProvider(aiStatus.current_provider); + setSelectedModel(aiStatus.current_model); + } + }, [aiStatus, selectedProvider]); + + // When provider changes, auto-select first model + const currentProviderModels = aiStatus?.providers.find( + p => p.key === selectedProvider + )?.models || []; + + const handleProviderChange = useCallback((provider: string) => { + setSelectedProvider(provider); + const providerInfo = aiStatus?.providers.find(p => p.key === provider); + if (providerInfo?.models.length) { + setSelectedModel(providerInfo.models[0]); + } else { + setSelectedModel(''); + } + setTestResult(null); + setTestError(null); + }, [aiStatus]); + + const handleTest = useCallback(async () => { + if (!selectedProvider || !selectedModel) return; + + setIsTesting(true); + setTestResult(null); + setTestError(null); + + try { + const result = await testModel({ + provider: selectedProvider, + model: selectedModel, + }); + setTestResult(result); + } catch (err: any) { + setTestError(err?.message || 'Erreur de test'); + } finally { + setIsTesting(false); + } + }, [selectedProvider, selectedModel]); + + if (isLoading) { + return ( +
+
+ +

Intelligence Artificielle

+
+
+
+
+
+
+ ); + } + + const activeProviderInfo = aiStatus?.providers.find(p => p.key === aiStatus.current_provider); + + return ( +
+
+ +

Intelligence Artificielle

+ {aiStatus?.ai_enabled ? ( + + Activé + + ) : ( + + Désactivé + + )} +
+ + {/* Current configuration */} +
+
+
Fournisseur actif
+
+ + + {activeProviderInfo?.name || aiStatus?.current_provider || '—'} + + {activeProviderInfo?.configured ? ( + + ) : ( + + )} +
+
+
+
Modèle actif
+
+ + + {aiStatus?.current_model || '—'} + +
+
+
+
Statut
+
+ {aiStatus?.ai_enabled && activeProviderInfo?.configured + ? '✅ Connecté' + : '⚠️ Non configuré'} +
+
+
+ + {/* Provider list */} +
+

+ Fournisseurs disponibles +

+
+ {aiStatus?.providers.map(provider => ( +
+
+ + {provider.name} + + {provider.configured ? ( + + configuré + + ) : ( + + clé manquante + + )} +
+ {provider.configured && provider.models.length > 0 && ( +
+ {provider.models.length} modèle{provider.models.length > 1 ? 's' : ''} disponible{provider.models.length > 1 ? 's' : ''} +
+ )} +
+ ))} +
+
+ + {/* Test section */} +
+

+ Tester un modèle +

+ +
+ {/* Provider selector */} +
+ +
+ + +
+
+ + {/* Model selector */} +
+ +
+ + +
+
+
+ + + + {/* Test result — live, no refresh needed */} + {(testResult || testError || isTesting) && ( +
+ {isTesting && ( +
+ + Envoi de la requête au modèle {selectedModel}... +
+ )} + + {testResult?.success && ( +
+
+ + Test réussi + + {testResult.latency_ms}ms + +
+ {testResult.response && ( +

+ {testResult.response} +

+ )} + {testResult.tokens && ( +
+ {testResult.tokens.prompt !== undefined && ( + Prompt: {testResult.tokens.prompt} tokens + )} + {testResult.tokens.completion !== undefined && ( + Completion: {testResult.tokens.completion} tokens + )} +
+ )} +
+ )} + + {(testResult && !testResult.success) && ( +
+
+ + Échec du test + + {testResult.latency_ms}ms + +
+

+ {testResult.error || 'Erreur inconnue'} +

+
+ )} + + {testError && ( +
+
+ + Erreur +
+

{testError}

+
+ )} + + {(testResult || testError) && ( + + )} +
+ )} +
+
+ ); +} diff --git a/imago-admin/src/pages/admin/Config.tsx b/imago-admin/src/pages/admin/Config.tsx index be3bc9c..7d9a15c 100644 --- a/imago-admin/src/pages/admin/Config.tsx +++ b/imago-admin/src/pages/admin/Config.tsx @@ -4,6 +4,7 @@ import { useHealth } from '@/hooks/useHealth'; import { Settings, Lock, Info } from 'lucide-react'; import { PLANS } from '@/utils/constants'; import { formatTokens, formatMB } from '@/utils/formatters'; +import { AIConfigSection } from '@/components/admin/AIConfigSection'; export default function Config() { const { data: health, isLoading } = useHealth(); @@ -112,6 +113,9 @@ export default function Config() { + {/* AI Configuration */} + + {/* Components */}
@@ -122,7 +126,6 @@ export default function Config() { -
diff --git a/imago-admin/src/types/ai.types.ts b/imago-admin/src/types/ai.types.ts new file mode 100644 index 0000000..cede318 --- /dev/null +++ b/imago-admin/src/types/ai.types.ts @@ -0,0 +1,31 @@ +// ── AI Configuration ─────────────────────────────────────── +export interface AIStatus { + ai_enabled: boolean; + current_provider: string; + current_model: string; + providers: AIProvider[]; +} + +export interface AIProvider { + key: string; + name: string; + configured: boolean; + models: string[]; +} + +export interface TestModelRequest { + provider: string; + model: string; + prompt?: string; + image_base64?: string; +} + +export interface TestModelResponse { + success: boolean; + provider: string; + model: string; + response?: string; + tokens?: { prompt?: number; completion?: number }; + latency_ms: number; + error?: string; +} diff --git a/imago-admin/src/types/api.types.ts b/imago-admin/src/types/api.types.ts index df95fdf..50c311a 100644 --- a/imago-admin/src/types/api.types.ts +++ b/imago-admin/src/types/api.types.ts @@ -9,6 +9,8 @@ export interface APIClient { quota_storage_mb: number; quota_ai_tokens: number; storage_used_bytes: number; + ai_provider: string | null; + ai_model: string | null; created_at: string; updated_at: string; } @@ -30,6 +32,8 @@ export interface APIClientUpdate { quota_images?: number; quota_storage_mb?: number; quota_ai_tokens?: number; + ai_provider?: string | null; + ai_model?: string | null; } export interface APIClientCreateResponse extends APIClient {