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 <noreply@anthropic.com>
This commit is contained in:
@@ -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])
|
||||
|
||||
@@ -683,3 +683,4 @@ async def update_worker_config(
|
||||
enabled=cfg.enabled,
|
||||
updated_at=cfg.updated_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user