Files
smart-org/apps/api/app/interfaces/http/routers/admin.py
T
mjalmoudy94 717e53a7ce نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
2026-10-13 00:39:28 +03:30

716 lines
24 KiB
Python

"""Platform owner console: providers, 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. A provider API key is **never** returned — not masked-and-then-leaked, not in
a nested object. Only ``masked_key`` leaves this module.
"""
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.crypto import encrypt, mask
from app.core.database import get_session
from app.core.errors import ConflictError, NotFoundError, ValidationError
from app.domain.ai_control.models import (
AiModel,
Capability,
ModelCredential,
Provider,
ProviderType,
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,
ModelCredentialRepository,
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 ProviderOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
type: str
base_url: str
is_active: bool
created_at: datetime
class CreateProviderRequest(BaseModel):
name: str = Field(min_length=2, max_length=120)
type: ProviderType = ProviderType.MOCK
base_url: str = Field(default="", max_length=512)
class CredentialOut(BaseModel):
"""Deliberately has no field capable of holding the plaintext key."""
id: str
provider_id: str
name: str
masked_key: str
is_active: bool
class CreateCredentialRequest(BaseModel):
name: str = Field(min_length=1, max_length=120)
api_key: str = Field(min_length=4, max_length=512)
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
is_active: bool
class CreateModelRequest(BaseModel):
provider_id: str
internal_name: str = Field(min_length=1, max_length=160)
display_name_for_admin: str = Field(min_length=1, 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)
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
# ---------------------------------------------------------------------------
# Providers
# ---------------------------------------------------------------------------
@router.get("/providers", response_model=list[ProviderOut])
async def list_providers(session: AsyncSession = Depends(get_session)) -> list[ProviderOut]:
providers = await ProviderRepository(session).list()
return [
ProviderOut(
id=p.id, name=p.name, type=p.type.value, base_url=p.base_url,
is_active=p.is_active, created_at=p.created_at,
)
for p in providers
]
@router.post("/providers", response_model=ProviderOut, status_code=status.HTTP_201_CREATED)
async def create_provider(
payload: CreateProviderRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> ProviderOut:
repo = ProviderRepository(session)
if await repo.get_by_name(payload.name) is not None:
raise ConflictError("ارائه‌دهنده‌ای با این نام وجود دارد.")
provider = await repo.create(
Provider(name=payload.name, type=payload.type, base_url=payload.base_url)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id,
target_type="provider", target_id=provider.id,
metadata={"name": provider.name, "type": provider.type.value},
ip=ip, user_agent=ua,
)
return ProviderOut(
id=provider.id, name=provider.name, type=provider.type.value,
base_url=provider.base_url, is_active=provider.is_active,
created_at=provider.created_at,
)
@router.post(
"/providers/{provider_id}/credentials",
response_model=CredentialOut,
status_code=status.HTTP_201_CREATED,
)
async def create_credential(
provider_id: str,
payload: CreateCredentialRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> CredentialOut:
provider = await ProviderRepository(session).get(provider_id)
if provider is None:
raise NotFoundError("ارائه‌دهنده یافت نشد.")
credential = await ModelCredentialRepository(session).create(
ModelCredential(
provider_id=provider_id,
name=payload.name,
# Encrypted at rest; the plaintext exists only inside this request.
encrypted_api_key=encrypt(payload.api_key),
)
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id,
target_type="credential", target_id=credential.id,
metadata={"provider_id": provider_id, "name": credential.name},
ip=ip, user_agent=ua,
)
return CredentialOut(
id=credential.id, provider_id=credential.provider_id, name=credential.name,
masked_key=mask(payload.api_key), is_active=credential.is_active,
)
@router.get("/providers/{provider_id}/credentials", response_model=list[CredentialOut])
async def list_credentials(
provider_id: str, session: AsyncSession = Depends(get_session)
) -> list[CredentialOut]:
credentials = await ModelCredentialRepository(session).list_for_provider(provider_id)
return [
CredentialOut(
id=c.id, provider_id=c.provider_id, name=c.name,
# We cannot recover the plaintext to mask it — and we must not try.
masked_key="••••••",
is_active=c.is_active,
)
for c in credentials
]
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
@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 [
ModelOut(
id=m.id, provider_id=m.provider_id, internal_name=m.internal_name,
display_name_for_admin=m.display_name_for_admin,
capabilities=list(m.capabilities),
input_price_per_1m=m.input_price_per_1m,
output_price_per_1m=m.output_price_per_1m, is_active=m.is_active,
)
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:
if await ProviderRepository(session).get(payload.provider_id) is None:
raise NotFoundError("ارائه‌دهنده یافت نشد.")
model = await AiModelRepository(session).create(
AiModel(
provider_id=payload.provider_id,
internal_name=payload.internal_name,
display_name_for_admin=payload.display_name_for_admin,
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,
)
)
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},
ip=ip, user_agent=ua,
)
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, is_active=model.is_active,
)
# ---------------------------------------------------------------------------
# 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)