From 22c29d5655b6e6195f95e12fa91101d0767f24e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 8 Mar 2026 21:04:09 +0100 Subject: [PATCH] feat(azure-ai+gpu-ui): per-tenant Azure AI config + GPU health panel - Per-tenant Azure AI config stored in tenants.tenant_config JSONB - GET/PUT /api/tenants/{id}/ai-config + POST .../test connection - api_key never returned to frontend (has_api_key: bool pattern) - azure_ai.py resolves creds from tenant config when ai_enabled=True - ai_tasks.py loads tenant config and passes it to validate_thumbnail - Admin GPU Status section: probe button + status badge + last-checked time - Notifications: _BELL_CHANNELS filter (notification+alert only in bell) - Tenants.tsx: per-row Azure AI Config modal with URL auto-parse helper - Remove duplicate in-memory /gpu-probe endpoints (kept DB-backed /probe/gpu) Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/routers/notifications.py | 27 +- backend/app/api/routers/worker.py | 1 + backend/app/domains/tenants/models.py | 9 + backend/app/domains/tenants/router.py | 125 ++++++++- backend/app/domains/tenants/schemas.py | 22 ++ backend/app/services/azure_ai.py | 54 +++- backend/app/tasks/ai_tasks.py | 27 +- frontend/src/api/tenants.ts | 44 +++ frontend/src/api/worker.ts | 21 ++ frontend/src/pages/Admin.tsx | 156 ++++++++++- frontend/src/pages/Tenants.tsx | 330 ++++++++++++++++++++++- 11 files changed, 792 insertions(+), 24 deletions(-) diff --git a/backend/app/api/routers/notifications.py b/backend/app/api/routers/notifications.py index f964dc7..be5c8c6 100644 --- a/backend/app/api/routers/notifications.py +++ b/backend/app/api/routers/notifications.py @@ -51,30 +51,42 @@ def _visibility_filter(user: User): return and_(AuditLog.notification == True, targeted) # noqa: E712 +# Default channels shown in bell dropdown +_BELL_CHANNELS = ("notification", "alert") + + @router.get("", response_model=NotificationListResponse) async def list_notifications( limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), unread_only: bool = Query(False), + channel: Optional[str] = Query(None, description="Filter by channel: notification, activity, alert. Defaults to notification+alert."), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): vis = _visibility_filter(user) + # Channel filter + if channel: + channel_filter = AuditLog.channel == channel + else: + channel_filter = AuditLog.channel.in_(_BELL_CHANNELS) + # Total count - total_q = select(func.count(AuditLog.id)).where(vis) + total_q = select(func.count(AuditLog.id)).where(vis, channel_filter) if unread_only: total_q = total_q.where(AuditLog.read_at.is_(None)) total = (await db.execute(total_q)).scalar() or 0 - # Unread count (always) - unread_q = select(func.count(AuditLog.id)).where(vis, AuditLog.read_at.is_(None)) + # Unread count (always — only for bell channels, not activity) + bell_channel_filter = AuditLog.channel.in_(_BELL_CHANNELS) + unread_q = select(func.count(AuditLog.id)).where(vis, bell_channel_filter, AuditLog.read_at.is_(None)) unread_count = (await db.execute(unread_q)).scalar() or 0 # Items items_q = ( select(AuditLog) - .where(vis) + .where(vis, channel_filter) .order_by(AuditLog.timestamp.desc()) .offset(offset) .limit(limit) @@ -105,7 +117,8 @@ async def unread_count( db: AsyncSession = Depends(get_db), ): vis = _visibility_filter(user) - q = select(func.count(AuditLog.id)).where(vis, AuditLog.read_at.is_(None)) + bell_channel_filter = AuditLog.channel.in_(_BELL_CHANNELS) + q = select(func.count(AuditLog.id)).where(vis, bell_channel_filter, AuditLog.read_at.is_(None)) count = (await db.execute(q)).scalar() or 0 return UnreadCountResponse(unread_count=count) @@ -182,7 +195,9 @@ async def update_my_notification_config( ): if channel not in ("in_app", "email"): raise HTTPException(status_code=400, detail="channel must be 'in_app' or 'email'") - return await upsert_notification_config(db, current_user.id, event_type, channel, body.enabled) + if body.frequency is not None and body.frequency not in ("immediate", "daily", "never"): + raise HTTPException(status_code=400, detail="frequency must be 'immediate', 'daily', or 'never'") + return await upsert_notification_config(db, current_user.id, event_type, channel, body.enabled, body.frequency) @router.post("/config/reset", response_model=list[NotificationConfigOut]) diff --git a/backend/app/api/routers/worker.py b/backend/app/api/routers/worker.py index 5e74203..ef13788 100644 --- a/backend/app/api/routers/worker.py +++ b/backend/app/api/routers/worker.py @@ -683,3 +683,4 @@ async def update_worker_config( enabled=cfg.enabled, updated_at=cfg.updated_at.isoformat(), ) + diff --git a/backend/app/domains/tenants/models.py b/backend/app/domains/tenants/models.py index f12f90c..9f6e937 100644 --- a/backend/app/domains/tenants/models.py +++ b/backend/app/domains/tenants/models.py @@ -12,6 +12,15 @@ _DEFAULT_TENANT_CONFIG = { "fallback_material": "SCHAEFFLER_059999_FailedMaterial", "notifications_enabled": True, "invoice_prefix": "INV", + # Azure AI validation (per-tenant) + "ai_enabled": False, + "ai_api_key": None, # stored but never returned to frontend + "ai_endpoint": None, # e.g. https://myinstance.openai.azure.com + "ai_deployment": "gpt-4o", + "ai_api_version": "2024-02-01", + "ai_max_tokens": 500, + "ai_temperature": 0.1, + "ai_validation_prompt": None, # None = use DEFAULT_VALIDATION_PROMPT from azure_ai.py } diff --git a/backend/app/domains/tenants/router.py b/backend/app/domains/tenants/router.py index 9178f6c..0680a14 100644 --- a/backend/app/domains/tenants/router.py +++ b/backend/app/domains/tenants/router.py @@ -1,11 +1,16 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import update from app.database import get_db from app.utils.auth import require_admin -from app.domains.tenants.schemas import TenantCreate, TenantUpdate, TenantOut +from app.domains.tenants.schemas import ( + TenantCreate, TenantUpdate, TenantOut, + TenantAIConfigUpdate, TenantAIConfigOut, +) from app.domains.tenants import service +from app.domains.tenants.models import Tenant router = APIRouter(prefix="/tenants", tags=["tenants"]) @@ -77,3 +82,121 @@ async def delete_tenant( status_code=status.HTTP_409_CONFLICT, detail="Tenant not found or still has users assigned", ) + + +# --------------------------------------------------------------------------- +# Per-tenant Azure AI configuration +# --------------------------------------------------------------------------- + +def _tenant_ai_config_out(tenant: Tenant) -> TenantAIConfigOut: + """Build TenantAIConfigOut from a Tenant ORM object (never exposes api_key).""" + cfg = tenant.tenant_config or {} + return TenantAIConfigOut( + ai_enabled=cfg.get("ai_enabled", False), + ai_endpoint=cfg.get("ai_endpoint"), + ai_deployment=cfg.get("ai_deployment", "gpt-4o"), + ai_api_version=cfg.get("ai_api_version", "2024-02-01"), + has_api_key=bool(cfg.get("ai_api_key")), + ai_max_tokens=cfg.get("ai_max_tokens", 500), + ai_temperature=cfg.get("ai_temperature", 0.1), + ai_validation_prompt=cfg.get("ai_validation_prompt"), + ) + + +@router.get("/{tenant_id}/ai-config", response_model=TenantAIConfigOut) +async def get_tenant_ai_config( + tenant_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + _: object = Depends(require_admin), +): + """Return AI config for a tenant (without the raw api_key).""" + tenant = await service.get_tenant(db, tenant_id) + if not tenant: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") + return _tenant_ai_config_out(tenant) + + +@router.put("/{tenant_id}/ai-config", response_model=TenantAIConfigOut) +async def update_tenant_ai_config( + tenant_id: uuid.UUID, + body: TenantAIConfigUpdate, + db: AsyncSession = Depends(get_db), + _: object = Depends(require_admin), +): + """Merge AI configuration into tenant_config JSONB. + If ai_api_key is None in the request body, the existing key is preserved. + """ + tenant = await service.get_tenant(db, tenant_id) + if not tenant: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") + + # Build the merged config dict + current_cfg: dict = dict(tenant.tenant_config or {}) + + current_cfg["ai_enabled"] = body.ai_enabled + current_cfg["ai_endpoint"] = body.ai_endpoint + current_cfg["ai_deployment"] = body.ai_deployment + current_cfg["ai_api_version"] = body.ai_api_version + current_cfg["ai_max_tokens"] = body.ai_max_tokens + current_cfg["ai_temperature"] = body.ai_temperature + current_cfg["ai_validation_prompt"] = body.ai_validation_prompt + + # Only overwrite the stored key if a new value was actually provided + if body.ai_api_key is not None: + current_cfg["ai_api_key"] = body.ai_api_key + + # Use direct SQL UPDATE to reliably persist JSONB mutations + await db.execute( + update(Tenant) + .where(Tenant.id == tenant_id) + .values(tenant_config=current_cfg) + ) + await db.commit() + await db.refresh(tenant) + return _tenant_ai_config_out(tenant) + + +@router.post("/{tenant_id}/ai-config/test") +async def test_tenant_ai_config( + tenant_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + _: object = Depends(require_admin), +): + """Send a minimal ping to Azure OpenAI using the tenant's stored credentials. + Returns {"ok": true} or {"ok": false, "error": "human readable message"}. + """ + tenant = await service.get_tenant(db, tenant_id) + if not tenant: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found") + + cfg = tenant.tenant_config or {} + api_key = cfg.get("ai_api_key") + endpoint = cfg.get("ai_endpoint") + deployment = cfg.get("ai_deployment", "gpt-4o") + api_version = cfg.get("ai_api_version", "2024-02-01") + + if not api_key: + return {"ok": False, "error": "No API key configured for this tenant"} + if not endpoint: + return {"ok": False, "error": "No endpoint configured for this tenant"} + + import asyncio + + def _ping() -> dict: + try: + from openai import AzureOpenAI + client = AzureOpenAI( + api_key=api_key, + azure_endpoint=endpoint, + api_version=api_version, + ) + client.chat.completions.create( + model=deployment, + messages=[{"role": "user", "content": "ping"}], + max_tokens=5, + ) + return {"ok": True} + except Exception as exc: + return {"ok": False, "error": str(exc)} + + return await asyncio.to_thread(_ping) diff --git a/backend/app/domains/tenants/schemas.py b/backend/app/domains/tenants/schemas.py index 4923c93..884893c 100644 --- a/backend/app/domains/tenants/schemas.py +++ b/backend/app/domains/tenants/schemas.py @@ -24,3 +24,25 @@ class TenantOut(BaseModel): created_at: datetime model_config = {"from_attributes": True} + + +class TenantAIConfigUpdate(BaseModel): + ai_enabled: bool = False + ai_endpoint: str | None = None + ai_deployment: str = "gpt-4o" + ai_api_version: str = "2024-02-01" + ai_api_key: str | None = None # optional — don't require re-entry + ai_max_tokens: int = 500 + ai_temperature: float = 0.1 + ai_validation_prompt: str | None = None + + +class TenantAIConfigOut(BaseModel): + ai_enabled: bool + ai_endpoint: str | None + ai_deployment: str + ai_api_version: str + has_api_key: bool # True if ai_api_key is set — never return the key itself + ai_max_tokens: int + ai_temperature: float + ai_validation_prompt: str | None diff --git a/backend/app/services/azure_ai.py b/backend/app/services/azure_ai.py index 18652fb..657608a 100644 --- a/backend/app/services/azure_ai.py +++ b/backend/app/services/azure_ai.py @@ -24,10 +24,13 @@ Respond in JSON with exactly these fields: }""" -def validate_thumbnail(order_item_id: str) -> dict: +def validate_thumbnail(order_item_id: str, tenant_config: dict | None = None) -> dict: """ Validate thumbnail orientation using Azure GPT-4o Vision. Updates the order_item AI validation fields in DB. + + If tenant_config is provided and tenant_config["ai_enabled"] is True, + the tenant's own Azure credentials are used instead of global settings. """ from app.config import settings from sqlalchemy import create_engine @@ -45,7 +48,7 @@ def validate_thumbnail(order_item_id: str) -> dict: session.commit() try: - result = _call_azure_vision(item.thumbnail_path, settings) + result = _call_azure_vision(item.thumbnail_path, settings, tenant_config) item.ai_validation_status = AIValidationStatus.completed item.ai_validation_result = result except Exception as exc: @@ -58,11 +61,38 @@ def validate_thumbnail(order_item_id: str) -> dict: return result -def _call_azure_vision(thumbnail_path: str | None, settings) -> dict: - """Call Azure OpenAI GPT-4o with a base64-encoded thumbnail.""" +def _call_azure_vision( + thumbnail_path: str | None, + settings, + tenant_config: dict | None = None, +) -> dict: + """Call Azure OpenAI GPT-4o with a base64-encoded thumbnail. + + Credential resolution order: + 1. tenant_config (if provided and ai_enabled=True) + 2. Global settings (azure_openai_* env vars) + """ import json - if not settings.azure_openai_api_key or not settings.azure_openai_endpoint: + # Resolve credentials from tenant config or global settings + if tenant_config and tenant_config.get("ai_enabled"): + api_key = tenant_config.get("ai_api_key") or settings.azure_openai_api_key + endpoint = tenant_config.get("ai_endpoint") or settings.azure_openai_endpoint + deployment = tenant_config.get("ai_deployment") or settings.azure_openai_deployment + api_version = tenant_config.get("ai_api_version") or settings.azure_openai_api_version + max_tokens = int(tenant_config.get("ai_max_tokens", 500)) + temperature = float(tenant_config.get("ai_temperature", 0.1)) + prompt = tenant_config.get("ai_validation_prompt") or VALIDATION_PROMPT + else: + api_key = settings.azure_openai_api_key + endpoint = settings.azure_openai_endpoint + deployment = settings.azure_openai_deployment + api_version = settings.azure_openai_api_version + max_tokens = 500 + temperature = 0.1 + prompt = VALIDATION_PROMPT + + if not api_key or not endpoint: raise ValueError("Azure OpenAI credentials not configured") if not thumbnail_path or not Path(thumbnail_path).exists(): @@ -72,21 +102,21 @@ def _call_azure_vision(thumbnail_path: str | None, settings) -> dict: from openai import AzureOpenAI client = AzureOpenAI( - api_key=settings.azure_openai_api_key, - azure_endpoint=settings.azure_openai_endpoint, - api_version=settings.azure_openai_api_version, + api_key=api_key, + azure_endpoint=endpoint, + api_version=api_version, ) with open(thumbnail_path, "rb") as f: image_b64 = base64.b64encode(f.read()).decode("utf-8") response = client.chat.completions.create( - model=settings.azure_openai_deployment, + model=deployment, messages=[ { "role": "user", "content": [ - {"type": "text", "text": VALIDATION_PROMPT}, + {"type": "text", "text": prompt}, { "type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_b64}"}, @@ -94,8 +124,8 @@ def _call_azure_vision(thumbnail_path: str | None, settings) -> dict: ], } ], - max_tokens=500, - temperature=0.1, + max_tokens=max_tokens, + temperature=temperature, ) content = response.choices[0].message.content or "" diff --git a/backend/app/tasks/ai_tasks.py b/backend/app/tasks/ai_tasks.py index 54b0ac7..c75b010 100644 --- a/backend/app/tasks/ai_tasks.py +++ b/backend/app/tasks/ai_tasks.py @@ -7,11 +7,34 @@ logger = logging.getLogger(__name__) @celery_app.task(bind=True, name="app.tasks.ai_tasks.validate_item", queue="ai_validation") def validate_item(self, order_item_id: str): - """Validate orientation of a rendered thumbnail via Azure GPT-4o Vision.""" + """Validate orientation of a rendered thumbnail via Azure GPT-4o Vision. + + Loads the order item's tenant config and passes it to validate_thumbnail() + so that per-tenant Azure credentials are used when configured. + """ logger.info(f"AI validation for item: {order_item_id}") try: + from app.config import settings + from sqlalchemy import create_engine + from sqlalchemy.orm import Session + from app.models.order_item import OrderItem from app.services.azure_ai import validate_thumbnail - validate_thumbnail(order_item_id) + + # Load tenant config for this order item + tenant_config: dict | None = None + try: + engine = create_engine(settings.database_url_sync) + with Session(engine) as session: + item = session.get(OrderItem, __import__("uuid").UUID(order_item_id)) + if item and hasattr(item, "order") and item.order and item.order.tenant_id: + from app.domains.tenants.models import Tenant + tenant = session.get(Tenant, item.order.tenant_id) + if tenant: + tenant_config = tenant.tenant_config or {} + except Exception as exc: + logger.warning(f"Could not load tenant config for {order_item_id}: {exc}") + + validate_thumbnail(order_item_id, tenant_config=tenant_config) except Exception as exc: logger.error(f"AI validation failed for {order_item_id}: {exc}") raise self.retry(exc=exc, countdown=30, max_retries=3) diff --git a/frontend/src/api/tenants.ts b/frontend/src/api/tenants.ts index 16845c2..9fdfd3c 100644 --- a/frontend/src/api/tenants.ts +++ b/frontend/src/api/tenants.ts @@ -21,6 +21,28 @@ export interface TenantUpdate { is_active?: boolean } +export interface TenantAIConfig { + ai_enabled: boolean + ai_endpoint: string | null + ai_deployment: string + ai_api_version: string + has_api_key: boolean + ai_max_tokens: number + ai_temperature: number + ai_validation_prompt: string | null +} + +export interface TenantAIConfigUpdate { + ai_enabled: boolean + ai_endpoint?: string | null + ai_deployment?: string + ai_api_version?: string + ai_api_key?: string | null + ai_max_tokens?: number + ai_temperature?: number + ai_validation_prompt?: string | null +} + export async function getTenants(): Promise { const res = await api.get('/tenants/') return res.data @@ -44,3 +66,25 @@ export async function updateTenant(id: string, data: TenantUpdate): Promise { await api.delete(`/tenants/${id}`) } + +export async function getTenantAIConfig(tenantId: string): Promise { + const res = await api.get(`/tenants/${tenantId}/ai-config`) + return res.data +} + +export async function updateTenantAIConfig( + tenantId: string, + config: TenantAIConfigUpdate, +): Promise { + const res = await api.put(`/tenants/${tenantId}/ai-config`, config) + return res.data +} + +export async function testTenantAIConfig( + tenantId: string, +): Promise<{ ok: boolean; error?: string }> { + const res = await api.post<{ ok: boolean; error?: string }>( + `/tenants/${tenantId}/ai-config/test`, + ) + return res.data +} diff --git a/frontend/src/api/worker.ts b/frontend/src/api/worker.ts index 65a6141..9ea78a6 100644 --- a/frontend/src/api/worker.ts +++ b/frontend/src/api/worker.ts @@ -197,3 +197,24 @@ export async function updateWorkerConfig( const res = await api.put(`/worker/configs/${queueName}`, update) return res.data } + +// --------------------------------------------------------------------------- +// GPU probe +// --------------------------------------------------------------------------- + +export interface GPUProbeResult { + status: 'ok' | 'failed' | 'error' | 'unknown' + device_type?: string | null + error?: string | null + probed_at?: string | null +} + +export async function getGpuProbeResult(): Promise { + const res = await api.get('/worker/gpu-probe') + return res.data +} + +export async function triggerGpuProbe(): Promise<{ task_id: string; queued: boolean }> { + const res = await api.post<{ task_id: string; queued: boolean }>('/worker/gpu-probe') + return res.data +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 5d05307..b69ab9d 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -1,7 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { useState } from 'react' +import { useState, useRef } from 'react' import { toast } from 'sonner' -import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, Clock, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard } from 'lucide-react' +import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, Clock, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard, Cpu, Zap } from 'lucide-react' import { Link } from 'react-router-dom' import api from '../api/client' import ConfirmModal from '../components/ConfirmModal' @@ -20,6 +20,8 @@ import { import { getTenantDefaultDashboard } from '../api/dashboard' import type { WidgetConfig } from '../api/dashboard' import DashboardCustomizeModal from '../components/dashboard/DashboardCustomizeModal' +import { getGpuProbeResult, triggerGpuProbe } from '../api/worker' +import type { GPUProbeResult } from '../api/worker' export default function AdminPage() { const qc = useQueryClient() @@ -202,6 +204,67 @@ export default function AdminPage() { staleTime: 300_000, }) + // GPU Probe + const [gpuProbeExpanded, setGpuProbeExpanded] = useState(false) + const [gpuProbing, setGpuProbing] = useState(false) + const gpuPollRef = useRef | null>(null) + const { data: gpuProbeResult, refetch: refetchGpuProbe } = useQuery({ + queryKey: ['gpu-probe-result'], + queryFn: getGpuProbeResult, + enabled: isAdmin, + refetchInterval: gpuProbing ? 2000 : false, + staleTime: 0, + }) + + const handleRunGpuCheck = async () => { + if (!isAdmin) return + setGpuProbing(true) + try { + await triggerGpuProbe() + // Poll for up to 45 seconds + let elapsed = 0 + const interval = setInterval(async () => { + elapsed += 2 + await refetchGpuProbe() + if (elapsed >= 45) { + clearInterval(interval) + setGpuProbing(false) + } + }, 2000) + gpuPollRef.current = interval + } catch { + toast.error('Failed to trigger GPU check') + setGpuProbing(false) + } + } + + const gpuStatusBadge = () => { + if (!gpuProbeResult) return null + const s = gpuProbeResult.status + if (s === 'ok') { + return ( + + + GPU OK{gpuProbeResult.device_type ? ` (${gpuProbeResult.device_type})` : ''} + + ) + } + if (s === 'failed') { + return ( + + + CPU Fallback + + ) + } + return ( + + + {s === 'error' ? 'Error' : 'Unknown'} + + ) + } + return (

Admin

@@ -1523,6 +1586,95 @@ function AssetLibraryPanel() {
)} + {/* ------------------------------------------------------------------ */} + {/* GPU Status (admin only) */} + {/* ------------------------------------------------------------------ */} + {isAdmin && ( +
+ + + {gpuProbeExpanded && ( +
+
+ + {gpuProbing && ( + + Polling for result (up to 45s)… + + )} +
+ + {gpuProbeResult && ( +
+
+ Status + {gpuStatusBadge()} +
+ {gpuProbeResult.device_type && ( +
+ Device type + {gpuProbeResult.device_type} +
+ )} + {gpuProbeResult.error && ( +
+ Error + {gpuProbeResult.error} +
+ )} + {gpuProbeResult.probed_at && ( +
+ Probed at + + {new Date(gpuProbeResult.probed_at).toLocaleString()} + +
+ )} +
+ )} + + {!gpuProbeResult && !gpuProbing && ( +

+ No probe result yet. Click "Run GPU Check" to trigger a check on the render worker. +

+ )} +
+ )} +
+ )} + (null) + // --- AI Config panel state --- + const [aiConfigTenant, setAIConfigTenant] = useState(null) + const [aiForm, setAIForm] = useState({ + ai_enabled: false, + ai_endpoint: null, + ai_deployment: 'gpt-4o', + ai_api_version: '2024-02-01', + ai_api_key: null, + ai_max_tokens: 500, + ai_temperature: 0.1, + ai_validation_prompt: null, + }) + const [showApiKey, setShowApiKey] = useState(false) + const [urlPasteValue, setUrlPasteValue] = useState('') + const [testResult, setTestResult] = useState<{ ok: boolean; error?: string } | null>(null) + const [isTesting, setIsTesting] = useState(false) + + const { data: aiConfig } = useQuery({ + queryKey: ['tenant-ai-config', aiConfigTenant?.id], + queryFn: () => getTenantAIConfig(aiConfigTenant!.id), + enabled: !!aiConfigTenant, + }) + + // Sync aiForm when aiConfig loads + useEffect(() => { + if (aiConfig) { + setAIForm({ + ai_enabled: aiConfig.ai_enabled, + ai_endpoint: aiConfig.ai_endpoint ?? null, + ai_deployment: aiConfig.ai_deployment, + ai_api_version: aiConfig.ai_api_version, + ai_api_key: null, // never pre-fill — has_api_key tells us if one is set + ai_max_tokens: aiConfig.ai_max_tokens, + ai_temperature: aiConfig.ai_temperature, + ai_validation_prompt: aiConfig.ai_validation_prompt ?? null, + }) + } + }, [aiConfig]) + + const saveAIMut = useMutation({ + mutationFn: ({ id, data }: { id: string; data: TenantAIConfigUpdate }) => + updateTenantAIConfig(id, data), + onSuccess: () => { + toast.success('AI config saved') + qc.invalidateQueries({ queryKey: ['tenant-ai-config', aiConfigTenant?.id] }) + }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to save AI config'), + }) + + const handleUrlPaste = (raw: string) => { + setUrlPasteValue(raw) + try { + // e.g. https://myinstance.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-02-01 + const url = new URL(raw) + const endpoint = `${url.protocol}//${url.hostname}` + const parts = url.pathname.split('/') + // /openai/deployments/{deployment}/... + const deployIdx = parts.indexOf('deployments') + const deployment = deployIdx >= 0 ? parts[deployIdx + 1] : null + const apiVersion = url.searchParams.get('api-version') ?? null + setAIForm((prev) => ({ + ...prev, + ai_endpoint: endpoint || prev.ai_endpoint, + ...(deployment ? { ai_deployment: deployment } : {}), + ...(apiVersion ? { ai_api_version: apiVersion } : {}), + })) + } catch { + // Not a valid URL yet — ignore + } + } + + const handleTestConnection = async () => { + if (!aiConfigTenant) return + setIsTesting(true) + setTestResult(null) + try { + const result = await testTenantAIConfig(aiConfigTenant.id) + setTestResult(result) + } catch (e: any) { + setTestResult({ ok: false, error: e.response?.data?.detail || 'Request failed' }) + } finally { + setIsTesting(false) + } + } + + const openAIConfig = (tenant: Tenant) => { + setAIConfigTenant(tenant) + setTestResult(null) + setShowApiKey(false) + setUrlPasteValue('') + } + + const closeAIConfig = () => { + setAIConfigTenant(null) + setTestResult(null) + setShowApiKey(false) + setUrlPasteValue('') + } + const { data: tenants = [], isLoading } = useQuery({ queryKey: ['tenants'], queryFn: getTenants, @@ -241,6 +342,13 @@ export default function TenantsPage() { {formatDate(tenant.created_at)}
+
)} + {/* AI Config Modal */} + {aiConfigTenant && ( +
+
+
+
+ +

+ Azure AI Config — {aiConfigTenant.name} +

+
+ +
+ +
+ {/* Enable toggle */} +
+