Files
smart-org/apps/api/app/interfaces/http/routers/admin.py
T
mjalmoudy94 d5c9917371 feat(admin/models): delete an added model with inline confirm
Backend: AiModelRepository.delete() + DELETE /api/admin/models/{id} (owner-only,
audited) in the RPC-served admin router; session commits per request.
Frontend: api.adminDeleteModel() (WebSocket RPC DELETE) and a per-row حذف
action with a two-step inline confirm (تأیید حذف / انصراف) that removes the
row from state on success. tsc clean, py_compile clean.
2026-10-13 17:40:16 +03:30

617 lines
21 KiB
Python

"""Platform owner console: agy models, routing policies and rate cards.
Everything in this router is invisible to customers. Two rules are enforced
here and tested:
1. Only a platform owner may call any of it.
2. Model identity stays inside the console: a customer never receives a raw
model or account name, and no credential material (a Google refresh token)
is ever returned — only the agy account metadata is, from the agy router.
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.core.database import get_session
from app.core.errors import ConflictError, NotFoundError, ValidationError
from app.domain.ai_control.models import (
REASONING_EFFORTS,
AiModel,
Capability,
RateCard,
RateUnit,
RoutingMode,
RoutingPolicy,
)
from app.domain.audit.models import AuditAction
from app.domain.base import utcnow
from app.domain.identity.models import User
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ProviderRepository,
RateCardRepository,
RoutingPolicyRepository,
)
from app.infrastructure.repositories.audit import (
ObservabilityRepository,
UsageLedgerRepository,
)
from app.infrastructure.repositories.identity import (
MembershipRepository,
OrganizationRepository,
)
from app.interfaces.http.deps import client_meta, require_platform_owner
router = APIRouter(
prefix="/admin",
tags=["admin"],
# Deny-by-default for the whole console. This is a router-level dependency on
# purpose: an endpoint added later is protected without its author having to
# remember. Relying on per-endpoint checks alone already caused a real
# privilege-escalation bug — every GET here once shipped without the check,
# letting any authenticated customer list all providers and every
# organization on the platform.
#
# Endpoints that also declare `owner: User = Depends(require_platform_owner)`
# do so because they *use* the object (for the audit actor); FastAPI caches
# the dependency within a request, so it is not evaluated twice.
dependencies=[Depends(require_platform_owner)],
)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class ModelOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
provider_id: str
internal_name: str
display_name_for_admin: str
capabilities: list[str]
input_price_per_1m: float
output_price_per_1m: float
reasoning_effort: str
is_active: bool
class CreateModelRequest(BaseModel):
#: The provider is no longer a client choice — agy is the only one, resolved
#: server-side. The model id must come from the account's live catalog.
internal_name: str = Field(min_length=1, max_length=160)
display_name_for_admin: str = Field(default="", max_length=160)
capabilities: list[Capability] = Field(default_factory=list)
input_price_per_1m: float = Field(default=0.0, ge=0)
output_price_per_1m: float = Field(default=0.0, ge=0)
reasoning_effort: str = Field(default="", max_length=16)
class RoutingPolicyOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
description: str
mode: str
is_default: bool
is_active: bool
class CreateRoutingPolicyRequest(BaseModel):
name: str = Field(min_length=2, max_length=120)
mode: RoutingMode = RoutingMode.BALANCED
description: str = Field(default="", max_length=500)
is_default: bool = False
budget_limits_json: dict = Field(default_factory=dict)
fallback_policy_json: dict = Field(default_factory=dict)
class RateCardOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
unit: str
customer_price: float
internal_cost_estimate: float
is_active: bool
class UpsertRateCardRequest(BaseModel):
unit: RateUnit
name: str = Field(default="", max_length=120)
customer_price: float = Field(ge=0)
internal_cost_estimate: float = Field(default=0.0, ge=0)
is_active: bool = True
class AdminOrganizationOut(BaseModel):
id: str
name: str
slug: str
member_count: int
created_at: datetime
class AdminUsageRow(BaseModel):
organization_id: str
quantity: float
total_price: float
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
def _model_out(model: AiModel) -> ModelOut:
return ModelOut(
id=model.id, provider_id=model.provider_id, internal_name=model.internal_name,
display_name_for_admin=model.display_name_for_admin,
capabilities=list(model.capabilities),
input_price_per_1m=model.input_price_per_1m,
output_price_per_1m=model.output_price_per_1m,
reasoning_effort=model.reasoning_effort, is_active=model.is_active,
)
@router.get("/models", response_model=list[ModelOut])
async def list_models(
provider_id: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
) -> list[ModelOut]:
models = await AiModelRepository(session).list(provider_id=provider_id)
return [_model_out(m) for m in models]
@router.post("/models", response_model=ModelOut, status_code=status.HTTP_201_CREATED)
async def create_model(
payload: CreateModelRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> ModelOut:
from app.interfaces.http.routers.agy import ensure_agy_provider
effort = payload.reasoning_effort.strip().lower()
if effort and effort not in REASONING_EFFORTS:
raise ValidationError(
"افورت نامعتبر است. مقادیر مجاز: " + " | ".join(REASONING_EFFORTS)
)
provider = await ensure_agy_provider(ProviderRepository(session))
models = AiModelRepository(session)
existing = await models.list(provider_id=provider.id)
if any(m.internal_name == payload.internal_name for m in existing):
raise ConflictError("مدلی با این نام داخلی از قبل ثبت شده است.")
model = await models.create(
AiModel(
provider_id=provider.id,
internal_name=payload.internal_name,
display_name_for_admin=payload.display_name_for_admin or payload.internal_name,
capabilities=[str(c) for c in payload.capabilities],
input_price_per_1m=payload.input_price_per_1m,
output_price_per_1m=payload.output_price_per_1m,
reasoning_effort=effort,
)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.MODEL_CHANGE, actor_user_id=owner.id,
target_type="model", target_id=model.id,
metadata={"internal_name": model.internal_name, "reasoning_effort": effort},
ip=ip, user_agent=ua,
)
return _model_out(model)
@router.delete("/models/{model_id}", status_code=status.HTTP_200_OK)
async def delete_model(
model_id: str,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> dict:
repo = AiModelRepository(session)
existing = await repo.get(model_id)
if existing is None:
raise NotFoundError("مدلی با این شناسه یافت نشد.")
await repo.delete(model_id)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.MODEL_CHANGE, actor_user_id=owner.id,
target_type="model", target_id=model_id,
metadata={"internal_name": existing.internal_name, "deleted": True},
ip=ip, user_agent=ua,
)
return {"ok": True}
# ---------------------------------------------------------------------------
# Routing policies
# ---------------------------------------------------------------------------
@router.get("/routing-policies", response_model=list[RoutingPolicyOut])
async def list_routing_policies(
session: AsyncSession = Depends(get_session),
) -> list[RoutingPolicyOut]:
policies = await RoutingPolicyRepository(session).list()
return [
RoutingPolicyOut(
id=p.id, name=p.name, description=p.description, mode=p.mode.value,
is_default=p.is_default, is_active=p.is_active,
)
for p in policies
]
@router.post(
"/routing-policies", response_model=RoutingPolicyOut, status_code=status.HTTP_201_CREATED
)
async def create_routing_policy(
payload: CreateRoutingPolicyRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> RoutingPolicyOut:
policy = await RoutingPolicyRepository(session).create(
RoutingPolicy(
name=payload.name, mode=payload.mode, description=payload.description,
is_default=payload.is_default,
budget_limits_json=payload.budget_limits_json,
fallback_policy_json=payload.fallback_policy_json,
)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.ROUTING_CHANGE, actor_user_id=owner.id,
target_type="routing_policy", target_id=policy.id,
metadata={"name": policy.name, "mode": policy.mode.value},
ip=ip, user_agent=ua,
)
return RoutingPolicyOut(
id=policy.id, name=policy.name, description=policy.description,
mode=policy.mode.value, is_default=policy.is_default, is_active=policy.is_active,
)
# ---------------------------------------------------------------------------
# Rate cards
# ---------------------------------------------------------------------------
@router.get("/rate-cards", response_model=list[RateCardOut])
async def list_rate_cards(session: AsyncSession = Depends(get_session)) -> list[RateCardOut]:
cards = await RateCardRepository(session).list()
return [
RateCardOut(
id=c.id, name=c.name, unit=c.unit.value, customer_price=c.customer_price,
internal_cost_estimate=c.internal_cost_estimate, is_active=c.is_active,
)
for c in cards
]
@router.put("/rate-cards", response_model=RateCardOut)
async def upsert_rate_card(
payload: UpsertRateCardRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> RateCardOut:
card = await RateCardRepository(session).upsert(
RateCard(
name=payload.name or payload.unit.value.replace("_", " "),
unit=payload.unit,
customer_price=payload.customer_price,
internal_cost_estimate=payload.internal_cost_estimate,
is_active=payload.is_active,
)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.BILLING_CREDIT, actor_user_id=owner.id,
target_type="rate_card", target_id=card.id,
metadata={"unit": card.unit.value, "customer_price": card.customer_price},
ip=ip, user_agent=ua,
)
return RateCardOut(
id=card.id, name=card.name, unit=card.unit.value,
customer_price=card.customer_price,
internal_cost_estimate=card.internal_cost_estimate, is_active=card.is_active,
)
# ---------------------------------------------------------------------------
# Cross-organization visibility
# ---------------------------------------------------------------------------
@router.get("/organizations", response_model=list[AdminOrganizationOut])
async def list_all_organizations(
session: AsyncSession = Depends(get_session),
) -> list[AdminOrganizationOut]:
organizations = await OrganizationRepository(session).list_all()
memberships = MembershipRepository(session)
out: list[AdminOrganizationOut] = []
for org in organizations:
members = await memberships.list_for_org(org.id)
out.append(
AdminOrganizationOut(
id=org.id, name=org.name, slug=org.slug,
member_count=len(members), created_at=org.created_at,
)
)
return out
@router.get("/usage", response_model=list[AdminUsageRow])
async def platform_usage(
session: AsyncSession = Depends(get_session),
) -> list[AdminUsageRow]:
rows = await UsageLedgerRepository(session).platform_totals()
return [
AdminUsageRow(
organization_id=org_id, quantity=quantity, total_price=total_price
)
for org_id, quantity, total_price in rows
]
@router.post("/validate-model-choice")
async def validate_model_choice(
mode: RoutingMode,
_: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> dict:
"""Dry-run of routing: which model would this mode pick, and why?"""
model = await AiModelRepository(session).pick_for_mode(mode)
if model is None:
raise ValidationError("هیچ مدل فعالی برای این حالت مسیریابی وجود ندارد.")
return {
"mode": mode.value,
"model_id": model.id,
"internal_name": model.internal_name,
"capabilities": list(model.capabilities),
"estimated_input_price_per_1m": model.input_price_per_1m,
"estimated_output_price_per_1m": model.output_price_per_1m,
}
# ---------------------------------------------------------------------------
# Error console
#
# Every failure the platform catches lands in ``error_events``. This is where a
# platform owner reads them: grouped by fingerprint so one recurring bug is one
# line, with the full record (stack, request id, tenant, actor) one click away.
#
# Resolution is *per fingerprint* for the group view — fixing a bug means the
# whole group is dealt with — and per row for a single occurrence.
# ---------------------------------------------------------------------------
class ErrorEventOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
kind: str
message: str
stack: str
severity: str
source: str
error_type: str
fingerprint: str
status_code: int | None
method: str
path: str
organization_id: str | None
user_id: str | None
run_id: str | None
request_id: str
ip: str
user_agent: str
metadata_json: dict
resolved_at: datetime | None
resolved_note: str
created_at: datetime
class ErrorGroupOut(BaseModel):
fingerprint: str
occurrences: int
open_count: int
first_seen: datetime
last_seen: datetime
kind: str
error_type: str
severity: str
source: str
path: str
sample_message: str
class ErrorSummaryOut(BaseModel):
open_by_severity: dict[str, int]
total_open: int
class ErrorListOut(BaseModel):
items: list[ErrorEventOut]
total: int
limit: int
offset: int
class ResolveErrorIn(BaseModel):
note: str = Field(default="", max_length=2000)
class ResolveErrorOut(BaseModel):
fingerprint: str
resolved: int
#: Newest-first page size ceiling. An unbounded ``limit`` would let one request
#: pull the entire table — including every stack trace — into memory.
MAX_ERROR_PAGE_SIZE = 200
@router.get("/errors", response_model=ErrorListOut)
async def list_errors(
severity: str | None = Query(default=None),
source: str | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
kind: str | None = Query(default=None),
organization_id: str | None = Query(default=None),
fingerprint: str | None = Query(default=None),
search: str | None = Query(default=None, max_length=200),
since: datetime | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=MAX_ERROR_PAGE_SIZE),
offset: int = Query(default=0, ge=0),
session: AsyncSession = Depends(get_session),
) -> ErrorListOut:
"""Individual occurrences, newest first."""
repo = ObservabilityRepository(session)
filters = {
"severity": severity,
"source": source,
"status": status_filter,
"kind": kind,
"organization_id": organization_id,
"fingerprint": fingerprint,
"search": search,
"since": since,
}
items = await repo.list_errors(limit=limit, offset=offset, **filters)
total = await repo.count_errors(**filters)
return ErrorListOut(
items=[ErrorEventOut.model_validate(item) for item in items],
total=total,
limit=limit,
offset=offset,
)
@router.get("/errors/summary", response_model=ErrorSummaryOut)
async def error_summary(
session: AsyncSession = Depends(get_session),
) -> ErrorSummaryOut:
"""Unresolved counts, for the console's header tiles."""
by_severity = await ObservabilityRepository(session).count_open_by_severity()
return ErrorSummaryOut(
open_by_severity=by_severity, total_open=sum(by_severity.values())
)
# Declared before ``/errors/{error_id}`` on purpose: FastAPI matches routes in
# declaration order, so the other way round "groups" would be captured as an id.
@router.get("/errors/groups", response_model=list[ErrorGroupOut])
async def list_error_groups(
status_filter: str | None = Query(default=None, alias="status"),
severity: str | None = Query(default=None),
source: str | None = Query(default=None),
organization_id: str | None = Query(default=None),
since: datetime | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=MAX_ERROR_PAGE_SIZE),
session: AsyncSession = Depends(get_session),
) -> list[ErrorGroupOut]:
"""One row per distinct bug, most recently active first."""
groups = await ObservabilityRepository(session).list_error_groups(
limit=limit,
since=since,
status=status_filter,
severity=severity,
source=source,
organization_id=organization_id,
)
return [ErrorGroupOut(**group) for group in groups]
@router.get("/errors/{error_id}", response_model=ErrorEventOut)
async def get_error(
error_id: str,
session: AsyncSession = Depends(get_session),
) -> ErrorEventOut:
event = await ObservabilityRepository(session).get_error(error_id)
if event is None:
raise NotFoundError("خطای مورد نظر یافت نشد.")
return ErrorEventOut.model_validate(event)
@router.post("/errors/{error_id}/resolve", response_model=ResolveErrorOut)
async def resolve_error(
error_id: str,
payload: ResolveErrorIn,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> ResolveErrorOut:
"""Mark one occurrence as dealt with."""
repo = ObservabilityRepository(session)
event = await repo.resolve_error(error_id, note=payload.note, resolved_at=utcnow())
if event is None:
raise NotFoundError("خطای مورد نظر یافت نشد.")
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.ERROR_RESOLVE,
actor_user_id=owner.id,
target_type="error_event",
target_id=error_id,
metadata={"fingerprint": event.fingerprint, "note": payload.note[:200]},
ip=ip,
user_agent=ua,
)
return ResolveErrorOut(fingerprint=event.fingerprint, resolved=1)
@router.post("/errors/groups/{fingerprint}/resolve", response_model=ResolveErrorOut)
async def resolve_error_group(
fingerprint: str,
payload: ResolveErrorIn,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> ResolveErrorOut:
"""Mark every open occurrence of one bug as dealt with."""
repo = ObservabilityRepository(session)
resolved = await repo.resolve_error_group(
fingerprint, note=payload.note, resolved_at=utcnow()
)
if resolved == 0:
raise NotFoundError("موردی برای بستن پیدا نشد؛ ممکن است قبلاً بسته شده باشد.")
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.ERROR_RESOLVE,
actor_user_id=owner.id,
target_type="error_group",
target_id=fingerprint,
metadata={"resolved": resolved, "note": payload.note[:200]},
ip=ip,
user_agent=ua,
)
return ResolveErrorOut(fingerprint=fingerprint, resolved=resolved)
@router.post("/errors/groups/{fingerprint}/reopen", response_model=ResolveErrorOut)
async def reopen_error_group(
fingerprint: str,
_: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> ResolveErrorOut:
"""Undo a resolution — for when the bug comes back."""
resolved = await ObservabilityRepository(session).reopen_error_group(fingerprint)
return ResolveErrorOut(fingerprint=fingerprint, resolved=resolved)