- حذف کامل providerهای mock و openai_compatible: دامنه، آداپوترها، رجیستری، کانفیگ، seed و رابطٔ مدیریت - بخش «ارائهدهندهها» از کنسول حذف شد؛ ورود اکانتها فقط از صفحهٔ اکانتهای agy - صفحهٔ مدلها بازطراحی شد: ارائهدهنده → اکانت → مدل (کاتالوگ لحظهای و زندهٔ همان اکانت از Google) → افورت استدلال؛ بدون هیچ ورود دستی نام مدل - مدلها ستون reasoning_effort گرفتند؛ آداپتور آن را به thinkingConfig (بودجهٔ ۱۰۲۴ تا ۳۲۷۶۸) ترجمه میکند و فقط به مدلهای استدلالی میفرستد - مهاجرت c2d3e4f5b6a7: افزودن reasoning_effort و حذف ردیفهای providerهای قدیمی - تستها: ۲۴۶ تست بکاند و ۵۱ تست فرانت سبز؛ ruff و mypy و tsc و eslint تمیز
595 lines
20 KiB
Python
595 lines
20 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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|