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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user