نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی

مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
mjalmoudy94
2026-10-13 00:39:28 +03:30
commit 717e53a7ce
185 changed files with 39910 additions and 0 deletions
View File
+197
View File
@@ -0,0 +1,197 @@
"""FastAPI dependencies: authentication, tenant resolution and capability checks.
The single most important rule of this file: **every** organization-scoped
endpoint resolves its tenant through ``get_org_context``, which refuses to
return anything unless the caller has an active membership. There is no
"trust the org_id in the path" path.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.core.errors import AuthenticationError, NotFoundError, PermissionDeniedError
from app.core.security import decode_access_token
from app.domain.identity.models import Membership, Organization, Role, User
from app.infrastructure.repositories.identity import (
MembershipRepository,
OrganizationRepository,
UserRepository,
)
from app.interfaces.http.request_context import note_actor
if TYPE_CHECKING:
from app.application.services.billing_service import BillingService
from app.application.services.knowledge_service import KnowledgeService
from app.application.services.project_service import ProjectService
from app.application.services.sandbox_service import SandboxService
from app.application.services.workforce_service import WorkforceService
_bearer = HTTPBearer(auto_error=False)
@dataclass(slots=True)
class OrgContext:
"""Resolved tenant context for an organization-scoped request."""
organization: Organization
membership: Membership
user: User
@property
def org_id(self) -> str:
return self.organization.id
def require(self, capability: str) -> None:
if not self.membership.has(capability):
raise PermissionDeniedError("شما دسترسی لازم برای این عملیات را ندارید.")
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
session: AsyncSession = Depends(get_session),
) -> User:
if credentials is None or not credentials.credentials:
raise AuthenticationError("برای این عملیات باید وارد شوید.")
payload = decode_access_token(credentials.credentials)
user_id = payload.get("sub")
if not user_id:
raise AuthenticationError("توکن نامعتبر است.")
user = await UserRepository(session).get(str(user_id))
if user is None or not user.is_active:
raise AuthenticationError("حساب کاربری یافت نشد یا غیرفعال است.")
# Record the verified actor so any later failure is attributable. Uses the
# id from the database, not the one in the token.
note_actor(request, user_id=user.id)
return user
async def get_optional_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
session: AsyncSession = Depends(get_session),
) -> User | None:
if credentials is None or not credentials.credentials:
return None
try:
return await get_current_user(request, credentials, session)
except AuthenticationError:
return None
async def require_platform_owner(user: User = Depends(get_current_user)) -> User:
if not user.is_platform_owner:
raise PermissionDeniedError("این بخش فقط برای مالک سامانه قابل دسترسی است.")
return user
async def get_org_context(
request: Request,
org_id: str,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> OrgContext:
"""Resolve ``org_id`` into an authorized tenant context.
Returns 404 (not 403) when the caller has no membership, so the API does not
leak the existence of other organizations.
"""
organization = await OrganizationRepository(session).get(org_id)
if organization is None:
raise NotFoundError("سازمان یافت نشد.")
membership = await MembershipRepository(session).get(org_id, user.id)
if membership is None or not membership.is_active:
raise NotFoundError("سازمان یافت نشد.")
# The tenant is only known *after* the membership check passes, so this is
# the earliest point at which it is safe to attribute a failure to an org.
note_actor(request, organization_id=organization.id, user_id=user.id)
return OrgContext(organization=organization, membership=membership, user=user)
def require_capability(capability: str):
"""Dependency factory: ``Depends(require_capability(CAN_MANAGE_PROJECTS))``."""
async def _checker(context: OrgContext = Depends(get_org_context)) -> OrgContext:
context.require(capability)
return context
return _checker
def require_role_at_least(minimum: Role):
from app.domain.identity.models import ROLE_RANK
async def _checker(context: OrgContext = Depends(get_org_context)) -> OrgContext:
if ROLE_RANK[context.membership.role] < ROLE_RANK[minimum]:
raise PermissionDeniedError("سطح دسترسی شما برای این عملیات کافی نیست.")
return context
return _checker
def client_meta(request: Request) -> tuple[str, str]:
"""``(ip, user_agent)`` for the audit trail."""
forwarded = request.headers.get("x-forwarded-for", "")
ip = (
forwarded.split(",")[0].strip()
if forwarded
else (request.client.host if request.client else "")
)
return ip, request.headers.get("user-agent", "")[:255]
# The builders themselves live in ``app.application.container`` so that the
# standalone workers compose services exactly the way the API does. These
# functions are thin FastAPI dependency wrappers around them.
async def get_project_service(
session: AsyncSession = Depends(get_session),
) -> ProjectService:
"""Build the project/domain service with the configured adapters."""
from app.application.container import build_project_service
return build_project_service(session)
async def get_workforce_service(
session: AsyncSession = Depends(get_session),
) -> WorkforceService:
"""Build the workforce/chat/run service with the configured adapters."""
from app.application.container import build_workforce_service
return build_workforce_service(session)
async def get_billing_service(
session: AsyncSession = Depends(get_session),
) -> BillingService:
"""Build the billing service with the configured payment adapter."""
from app.application.container import build_billing_service
return build_billing_service(session)
async def get_knowledge_service(
session: AsyncSession = Depends(get_session),
) -> KnowledgeService:
"""Build the knowledge service with the configured vector adapter."""
from app.application.container import build_knowledge_service
return build_knowledge_service(session)
async def get_sandbox_service(
session: AsyncSession = Depends(get_session),
) -> SandboxService:
"""Build the sandbox gateway with the configured execution adapter."""
from app.application.container import build_sandbox_service
return build_sandbox_service(session)
+213
View File
@@ -0,0 +1,213 @@
"""Translate domain errors into HTTP responses — and record them.
The inner layers never import FastAPI; this module is the only place that knows
about status codes. It is also the single choke point through which every
request-scoped failure passes, which makes it the right place to persist them.
What gets recorded, and why:
* **Unhandled exceptions and 5xx ``AppError``s** — always. A provider being down
or a quota check failing is an operational fault someone must see.
* **A bare ``ValueError`` escaping to the handler** — always, at ``warning``.
The codebase has a typed ``ValidationError`` for expected bad input and uses
it 30 times, so a bare ``ValueError`` reaching here is usually a typing gap
worth reviewing. The set of call sites is small and bounded.
* **Other 4xx** — only when ``ERROR_LOG_CLIENT_ERRORS`` is on. Most 4xx are a
caller sending bad input, and a public API receives a steady stream of 404s
from scanners; recording all of them by default would bury the real bugs.
Recorded at ``warning`` so they never look like faults.
Every error body carries the request id, and every response carries it in the
``X-Request-ID`` header, so a user can quote one string and land on exactly one
row in the console.
"""
from __future__ import annotations
import logging
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.core.config import settings
from app.core.errors import AppError
from app.domain.audit.models import ErrorSeverity, ErrorSource
from app.infrastructure.error_log import record_error
from app.interfaces.http.request_context import REQUEST_ID_HEADER, context_of
logger = logging.getLogger("agy.api")
#: Statuses at or above this are "the server is broken".
SERVER_ERROR_THRESHOLD = 500
#: Cap on the per-field validation detail echoed back and stored. A caller
#: sending a 10 000-element array should not produce a 10 000-line error row.
MAX_VALIDATION_ERRORS = 20
def _request_id(request: Request) -> str:
context = context_of(request)
return context.request_id if context else ""
def _error_response(
*,
status_code: int,
code: str,
message: str,
request_id: str,
details: object = None,
) -> JSONResponse:
"""The one place an error response is built.
The request id goes in the header as well as the body, and it has to be set
*here* rather than by the middleware: an unhandled exception is turned into a
response by Starlette's ``ServerErrorMiddleware``, which sits outside every
user middleware, so the middleware never sees the response to add a header
to. The body alone would leave an HTTP client that only reads headers with
no way to correlate the failure.
"""
error: dict = {"code": code, "message": message, "request_id": request_id}
if details is not None:
error["details"] = details
headers = {REQUEST_ID_HEADER: request_id} if request_id else None
return JSONResponse(
status_code=status_code, content={"error": error}, headers=headers
)
async def _record(
request: Request,
*,
kind: str,
message: str,
status_code: int | None,
exc: BaseException | None = None,
severity: str = ErrorSeverity.ERROR,
metadata: dict | None = None,
) -> None:
"""Persist a request-scoped failure. Never raises."""
context = context_of(request)
try:
await record_error(
kind=kind,
message=message,
exc=exc,
source=ErrorSource.HTTP,
severity=severity,
status_code=status_code,
method=context.method if context else request.method,
path=context.path if context else request.url.path,
organization_id=context.organization_id if context else None,
user_id=context.user_id if context else None,
run_id=context.run_id if context else None,
request_id=context.request_id if context else "",
ip=context.ip if context else "",
user_agent=context.user_agent if context else "",
metadata=metadata,
)
except Exception: # pragma: no cover - the recorder is already total
logger.exception("error-log: unexpected failure while recording an error")
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def _app_error_handler(request: Request, exc: AppError) -> JSONResponse:
is_server_fault = exc.status_code >= SERVER_ERROR_THRESHOLD
if settings.error_log_enabled and (is_server_fault or settings.error_log_client_errors):
await _record(
request,
kind=exc.code,
message=exc.message,
status_code=exc.status_code,
# Pass the exception so the stack is captured: a
# ProviderUnavailable raised three layers down is far easier to
# fix with the traceback than with the message alone.
exc=exc,
severity=ErrorSeverity.ERROR if is_server_fault else ErrorSeverity.WARNING,
metadata={"details": exc.details} if exc.details is not None else None,
)
return _error_response(
status_code=exc.status_code,
code=exc.code,
message=exc.message,
request_id=_request_id(request),
details=exc.details,
)
@app.exception_handler(RequestValidationError)
async def _validation_error_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
"""Reject malformed input in the standard envelope.
FastAPI's default body is ``{"detail": [...]}``, which the web client
cannot read — it would surface every validation failure as a generic
"server error (422)". Same status, useful shape.
"""
errors = [
{
"loc": [str(part) for part in err.get("loc", ())],
"msg": str(err.get("msg", "")),
}
for err in exc.errors()[:MAX_VALIDATION_ERRORS]
]
if settings.error_log_enabled and settings.error_log_client_errors:
await _record(
request,
kind="request_validation_error",
message="; ".join(f"{'.'.join(e['loc'])}: {e['msg']}" for e in errors),
status_code=422,
severity=ErrorSeverity.WARNING,
)
return _error_response(
status_code=422,
code="validation_error",
message="داده‌های ارسالی نامعتبر است.",
request_id=_request_id(request),
details=errors,
)
@app.exception_handler(ValueError)
async def _value_error_handler(request: Request, exc: ValueError) -> JSONResponse:
if settings.error_log_enabled:
await _record(
request,
kind="value_error",
message=str(exc),
status_code=422,
exc=exc,
severity=ErrorSeverity.WARNING,
)
return _error_response(
status_code=422,
code="validation_error",
message=str(exc),
request_id=_request_id(request),
)
@app.exception_handler(Exception)
async def _unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
"""The last line of defence: an unexpected failure.
Recorded unconditionally when logging is enabled — this is precisely the
class of error the console exists for.
"""
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
if settings.error_log_enabled:
await _record(
request,
kind="unhandled_exception",
message=str(exc) or type(exc).__name__,
status_code=500,
exc=exc,
severity=ErrorSeverity.CRITICAL,
)
return _error_response(
status_code=500,
code="internal_error",
message="خطای داخلی سرور. لطفاً دوباره تلاش کنید.",
request_id=_request_id(request),
)
@@ -0,0 +1,139 @@
"""Per-request correlation and actor context.
Every request gets an id before it reaches a route. That id is returned in the
``X-Request-ID`` response header, included in every error body, and written onto
every error record — so a user reporting "it broke" can hand over one string
that points at exactly one row in the error console.
The same object carries the resolved tenant and user. Authentication happens in
dependencies, long after the middleware has run, so the middleware seeds the
context with what it knows (method, path, ip, user agent) and the auth
dependencies fill in the rest via :func:`note_actor`. Error handlers then read
one object instead of re-deriving anything.
Deliberately stored on ``request.state`` rather than in a ``contextvar``: it is
the same object the handler already has in hand, it survives every task
boundary Starlette may introduce, and it cannot leak between requests.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import Response
#: Header used to both accept a caller-supplied id and echo it back. Accepting
#: an upstream id matters when the API sits behind a reverse proxy or a
#: frontend BFF that already correlates its own logs.
REQUEST_ID_HEADER = "X-Request-ID"
#: Long enough to be unique, short enough to paste into a bug report.
REQUEST_ID_LENGTH = 16
_STATE_ATTR = "error_context"
@dataclass(slots=True)
class RequestContext:
"""What we know about the request an error happened on."""
request_id: str
method: str
path: str
ip: str = ""
user_agent: str = ""
#: Filled in by the auth dependencies once the caller is authenticated.
organization_id: str | None = None
user_id: str | None = None
#: Set when a failure happens inside a specific agent run.
run_id: str | None = None
#: Query string, kept separate from ``path`` so fingerprints group across
#: different filters on the same endpoint.
query: str = ""
def new_request_id() -> str:
return uuid.uuid4().hex[:REQUEST_ID_LENGTH]
def client_ip(request: Request) -> str:
"""Real client address, honouring one proxy hop.
Same rule as ``deps.client_meta``: a trusted reverse proxy sets
``X-Forwarded-For``, and the left-most entry is the original caller.
"""
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else ""
def context_of(request: Request) -> RequestContext | None:
"""The context for this request, or ``None`` outside the middleware."""
return getattr(request.state, _STATE_ATTR, None)
def note_actor(
request: Request,
*,
user_id: str | None = None,
organization_id: str | None = None,
run_id: str | None = None,
) -> None:
"""Record who/which tenant this request belongs to.
Called from the auth dependencies. Silent when there is no context (a
direct unit-test call to a dependency, for instance) — correlation is
telemetry, and telemetry must never be the reason a request fails.
"""
context = context_of(request)
if context is None:
return
if user_id:
context.user_id = user_id
if organization_id:
context.organization_id = organization_id
if run_id:
context.run_id = run_id
class RequestContextMiddleware(BaseHTTPMiddleware):
"""Attach a :class:`RequestContext` to every request and echo its id."""
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
incoming = request.headers.get(REQUEST_ID_HEADER, "").strip()
# Cap the accepted value: an unbounded header would let a caller write
# arbitrarily long ids into the error table.
request_id = incoming[:64] if incoming else new_request_id()
setattr( # noqa: B010 - the attribute name is a module constant
request.state,
_STATE_ATTR,
RequestContext(
request_id=request_id,
method=request.method,
path=request.url.path,
query=request.url.query or "",
ip=client_ip(request),
user_agent=request.headers.get("user-agent", "")[:255],
),
)
response = await call_next(request)
response.headers[REQUEST_ID_HEADER] = request_id
return response
__all__ = [
"REQUEST_ID_HEADER",
"RequestContext",
"RequestContextMiddleware",
"client_ip",
"context_of",
"new_request_id",
"note_actor",
]
@@ -0,0 +1,715 @@
"""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)
@@ -0,0 +1,182 @@
"""Authentication endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel, ConfigDict, EmailStr, 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 AuthenticationError, ConflictError
from app.core.security import create_access_token, hash_password, verify_password
from app.domain.audit.models import AuditAction
from app.domain.identity.models import User
from app.infrastructure.repositories.identity import (
MembershipRepository,
OrganizationRepository,
UserRepository,
)
from app.interfaces.http.deps import client_meta, get_current_user
router = APIRouter(prefix="/auth", tags=["auth"])
class SignupRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
name: str = Field(default="", max_length=160)
class LoginRequest(BaseModel):
email: EmailStr
password: str
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
email: str
name: str
avatar_url: str | None = None
is_platform_owner: bool = False
class OrganizationSummary(BaseModel):
id: str
name: str
slug: str
role: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserOut
class MeResponse(BaseModel):
user: UserOut
organizations: list[OrganizationSummary]
def _to_user_out(user: User) -> UserOut:
return UserOut(
id=user.id,
email=user.email,
name=user.display_name,
avatar_url=user.avatar_url,
is_platform_owner=user.is_platform_owner,
)
@router.post("/signup", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def signup(
payload: SignupRequest,
request: Request,
session: AsyncSession = Depends(get_session),
) -> TokenResponse:
users = UserRepository(session)
if await users.get_by_email(payload.email) is not None:
raise ConflictError("این ایمیل قبلاً ثبت شده است.")
user = await users.create(
User(
email=payload.email,
password_hash=hash_password(payload.password),
name=payload.name.strip(),
)
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.SIGNUP,
actor_user_id=user.id,
target_type="user",
target_id=user.id,
ip=ip,
user_agent=ua,
)
return TokenResponse(access_token=create_access_token(user.id), user=_to_user_out(user))
@router.post("/login", response_model=TokenResponse)
async def login(
payload: LoginRequest,
request: Request,
session: AsyncSession = Depends(get_session),
) -> TokenResponse:
user = await UserRepository(session).get_by_email(payload.email)
if user is None or not verify_password(payload.password, user.password_hash):
raise AuthenticationError("ایمیل یا رمز عبور نادرست است.")
if not user.is_active:
raise AuthenticationError("حساب کاربری غیرفعال است.")
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.LOGIN,
actor_user_id=user.id,
target_type="user",
target_id=user.id,
ip=ip,
user_agent=ua,
)
return TokenResponse(access_token=create_access_token(user.id), user=_to_user_out(user))
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
request: Request,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> None:
"""JWTs are stateless; logout is an audit marker today and a token-revocation
hook once a denylist (Redis) is wired in."""
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.LOGOUT,
actor_user_id=user.id,
target_type="user",
target_id=user.id,
ip=ip,
user_agent=ua,
)
@router.get("/me", response_model=MeResponse)
async def me(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> MeResponse:
orgs = await OrganizationRepository(session).list_for_user(user.id)
return MeResponse(
user=_to_user_out(user),
organizations=[
OrganizationSummary(id=org.id, name=org.name, slug=org.slug, role=role.value)
for org, role in orgs
],
)
@router.get("/me/memberships")
async def my_memberships(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> list[dict]:
orgs = await OrganizationRepository(session).list_for_user(user.id)
memberships = MembershipRepository(session)
out: list[dict] = []
for org, role in orgs:
membership = await memberships.get(org.id, user.id)
out.append(
{
"organization_id": org.id,
"organization_name": org.name,
"role": role.value,
"status": membership.status.value if membership else "unknown",
}
)
return out
@@ -0,0 +1,294 @@
"""Billing endpoints: summary, usage ledger, invoices, credit and checkout.
Reading billing requires ``billing.view``; changing it requires
``billing.manage``. A tenant can therefore never see — or move — another
organization's money.
"""
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.application.services.billing_service import BillingService
from app.core.database import get_session
from app.domain.audit.models import AuditAction
from app.domain.identity.models import CAN_MANAGE_BILLING, CAN_VIEW_BILLING
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_billing_service,
get_org_context,
)
router = APIRouter(prefix="/organizations/{org_id}/billing", tags=["billing"])
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class UnitAggregateOut(BaseModel):
unit: str
quantity: float
total_price: float
class BillingSummaryOut(BaseModel):
balance: float
currency: str
low_balance: bool
low_balance_threshold: float
total_spend: float
by_unit: list[UnitAggregateOut]
by_employee: dict[str, float]
by_project: dict[str, float]
class UsageEventOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
unit: str
quantity: float
unit_price: float
total_price: float
project_id: str | None
employee_id: str | None
run_id: str | None
created_at: datetime
class InvoiceOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
amount: float
currency: str
status: str
period_start: datetime | None
period_end: datetime | None
created_at: datetime
class PaymentOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
amount: float
currency: str
status: str
provider: str
created_at: datetime
class AddCreditRequest(BaseModel):
amount: float = Field(description="Positive to top up; negative is platform-owner only.")
class WalletOut(BaseModel):
balance: float
currency: str
low_balance: bool
class CheckoutRequest(BaseModel):
amount: float = Field(gt=0)
success_url: str = Field(default="", max_length=512)
cancel_url: str = Field(default="", max_length=512)
class CheckoutResponse(BaseModel):
payment_id: str
checkout_url: str
amount: float
class SettlePaymentRequest(BaseModel):
succeeded: bool = True
# ---------------------------------------------------------------------------
# Reads
# ---------------------------------------------------------------------------
@router.get("/summary", response_model=BillingSummaryOut)
async def billing_summary(
since: datetime | None = Query(default=None),
until: datetime | None = Query(default=None),
context: OrgContext = Depends(get_org_context),
service: BillingService = Depends(get_billing_service),
) -> BillingSummaryOut:
context.require(CAN_VIEW_BILLING)
summary = await service.summary(
organization_id=context.org_id, since=since, until=until
)
return BillingSummaryOut(
balance=summary["balance"],
currency=summary["currency"],
low_balance=summary["low_balance"],
low_balance_threshold=summary["low_balance_threshold"],
total_spend=summary["total_spend"],
by_unit=[
UnitAggregateOut(
unit=str(a.unit), quantity=a.quantity, total_price=a.total_price
)
for a in summary["by_unit"]
],
by_employee=summary["by_employee"],
by_project=summary["by_project"],
)
@router.get("/usage-events", response_model=list[UsageEventOut])
async def list_usage_events(
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
context: OrgContext = Depends(get_org_context),
service: BillingService = Depends(get_billing_service),
) -> list[UsageEventOut]:
context.require(CAN_VIEW_BILLING)
rows = await service.list_usage_events(
organization_id=context.org_id, limit=limit, offset=offset
)
return [
UsageEventOut(
id=r.id, unit=r.unit, quantity=r.quantity, unit_price=r.unit_price,
total_price=r.total_price, project_id=r.project_id,
employee_id=r.employee_id, run_id=r.run_id, created_at=r.created_at,
)
for r in rows
]
@router.get("/invoices", response_model=list[InvoiceOut])
async def list_invoices(
context: OrgContext = Depends(get_org_context),
service: BillingService = Depends(get_billing_service),
) -> list[InvoiceOut]:
context.require(CAN_VIEW_BILLING)
invoices = await service.list_invoices(context.org_id)
return [
InvoiceOut(
id=i.id, amount=i.amount, currency=i.currency, status=str(i.status),
period_start=i.period_start, period_end=i.period_end, created_at=i.created_at,
)
for i in invoices
]
@router.get("/payments", response_model=list[PaymentOut])
async def list_payments(
context: OrgContext = Depends(get_org_context),
service: BillingService = Depends(get_billing_service),
) -> list[PaymentOut]:
context.require(CAN_VIEW_BILLING)
wallet = await service.wallet_for(context.org_id)
payments = await service.list_payments(context.org_id)
return [
PaymentOut(
id=p.id, amount=p.amount, currency=wallet.currency, status=str(p.status),
provider=p.provider, created_at=p.created_at,
)
for p in payments
]
# ---------------------------------------------------------------------------
# Writes
# ---------------------------------------------------------------------------
@router.post("/add-credit", response_model=WalletOut)
async def add_credit(
payload: AddCreditRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: BillingService = Depends(get_billing_service),
session: AsyncSession = Depends(get_session),
) -> WalletOut:
# A negative delta is a platform-owner-only clawback.
if payload.amount < 0:
if not context.user.is_platform_owner:
context.require(CAN_MANAGE_BILLING) # will raise PermissionDeniedError
from app.core.errors import PermissionDeniedError
raise PermissionDeniedError("کسر اعتبار فقط توسط مالک سامانه انجام می‌شود.")
else:
context.require(CAN_MANAGE_BILLING)
wallet = await service.add_credit(
organization_id=context.org_id,
amount=payload.amount,
allow_negative=context.user.is_platform_owner,
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.BILLING_CREDIT, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="credit_wallet",
target_id=wallet.id,
metadata={"amount": payload.amount, "balance": wallet.balance},
ip=ip, user_agent=ua,
)
return WalletOut(
balance=wallet.balance, currency=wallet.currency, low_balance=wallet.is_low
)
@router.post("/checkout", response_model=CheckoutResponse, status_code=status.HTTP_201_CREATED)
async def start_checkout(
payload: CheckoutRequest,
context: OrgContext = Depends(get_org_context),
service: BillingService = Depends(get_billing_service),
) -> CheckoutResponse:
context.require(CAN_MANAGE_BILLING)
payment, url = await service.start_checkout(
organization_id=context.org_id,
amount=payload.amount,
success_url=payload.success_url,
cancel_url=payload.cancel_url,
)
return CheckoutResponse(
payment_id=payment.id, checkout_url=url, amount=payment.amount
)
@router.post("/payments/{payment_id}/settle", response_model=PaymentOut)
async def settle_payment(
payment_id: str,
payload: SettlePaymentRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: BillingService = Depends(get_billing_service),
session: AsyncSession = Depends(get_session),
) -> PaymentOut:
"""Finalise a payment.
In production the payment provider calls a webhook; this endpoint is the
manual/dev equivalent. It is the only path that converts money into credit.
"""
context.require(CAN_MANAGE_BILLING)
payment = await service.settle_payment(
organization_id=context.org_id, payment_id=payment_id, succeeded=payload.succeeded
)
wallet = await service.wallet_for(context.org_id)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.BILLING_PAYMENT, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="payment", target_id=payment.id,
metadata={"status": str(payment.status), "amount": payment.amount},
ip=ip, user_agent=ua,
)
return PaymentOut(
id=payment.id, amount=payment.amount, currency=wallet.currency,
status=str(payment.status), provider=payment.provider,
created_at=payment.created_at,
)
@@ -0,0 +1,46 @@
"""Health and readiness endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_session
from app.infrastructure.adapters.registry import get_model_provider
router = APIRouter(tags=["health"])
@router.get("/health")
async def health() -> dict:
"""Liveness. Must stay dependency-free so it answers during a DB outage."""
return {"status": "ok", "app": settings.app_name, "env": settings.app_env}
@router.get("/health/ready")
async def readiness(session: AsyncSession = Depends(get_session)) -> dict:
"""Readiness: verifies the database and reports which adapters are active."""
checks: dict[str, object] = {}
try:
await session.execute(text("SELECT 1"))
checks["database"] = "ok"
except Exception as exc: # noqa: BLE001 - reported, not raised
checks["database"] = f"error: {exc.__class__.__name__}"
try:
model_health = await get_model_provider().health()
checks["model_provider"] = "ok" if model_health.ok else f"degraded: {model_health.detail}"
except Exception as exc: # noqa: BLE001
checks["model_provider"] = f"error: {exc.__class__.__name__}"
checks["adapters"] = {
"ai": settings.ai_default_provider,
"git": settings.git_provider,
"domain_router": settings.domain_router,
"sandbox": settings.sandbox_provider,
"payment": settings.payment_provider,
}
healthy = checks.get("database") == "ok"
return {"status": "ok" if healthy else "degraded", "checks": checks}
@@ -0,0 +1,251 @@
"""Knowledge Base endpoints.
Ingestion and deletion require ``employees.manage`` (the same trust level as
editing an employee's prompt); search only requires ``tasks.run``, because
reading your own organization's knowledge is part of doing work.
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.knowledge_service import KnowledgeService
from app.core.database import get_session
from app.domain.identity.models import CAN_MANAGE_EMPLOYEES, CAN_RUN_TASKS
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_knowledge_service,
get_org_context,
)
router = APIRouter(prefix="/organizations/{org_id}/knowledge", tags=["knowledge"])
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class KnowledgeSourceOut(BaseModel):
id: str
name: str
kind: str
project_id: str | None
created_at: datetime
chunk_count: int = 0
class CreateTextSourceRequest(BaseModel):
name: str = Field(min_length=2, max_length=255)
content: str = Field(min_length=1, max_length=400_000)
title: str = Field(default="", max_length=255)
project_id: str | None = None
class DocumentOut(BaseModel):
id: str
title: str
content: str
created_at: datetime
class EmbeddingJobOut(BaseModel):
id: str
status: str
error: str | None
class KnowledgeSourceDetailOut(KnowledgeSourceOut):
documents: list[DocumentOut]
job: EmbeddingJobOut | None
class SearchHitOut(BaseModel):
chunk_id: str
content: str
index: int
document_title: str
source_id: str
source_name: str
project_id: str | None
class CreateRetrievalPolicyRequest(BaseModel):
name: str = Field(min_length=2, max_length=120)
scope: dict = Field(default_factory=dict)
class RetrievalPolicyOut(BaseModel):
id: str
name: str
scope_json: dict
# ---------------------------------------------------------------------------
# Sources
# ---------------------------------------------------------------------------
@router.post(
"/sources", response_model=KnowledgeSourceOut, status_code=status.HTTP_201_CREATED
)
async def create_text_source(
payload: CreateTextSourceRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: KnowledgeService = Depends(get_knowledge_service),
session: AsyncSession = Depends(get_session),
) -> KnowledgeSourceOut:
context.require(CAN_MANAGE_EMPLOYEES)
source, chunk_count = await service.create_text_source(
organization_id=context.org_id,
name=payload.name,
content=payload.content,
project_id=payload.project_id,
title=payload.title,
)
ip, ua = client_meta(request)
await record_audit(
session, action="knowledge.source_create", organization_id=context.org_id,
actor_user_id=context.user.id, target_type="knowledge_source",
target_id=source.id, metadata={"name": source.name, "chunks": chunk_count},
ip=ip, user_agent=ua,
)
return KnowledgeSourceOut(
id=source.id, name=source.name, kind=str(source.kind),
project_id=source.project_id, created_at=source.created_at,
chunk_count=chunk_count,
)
@router.get("/sources", response_model=list[KnowledgeSourceOut])
async def list_sources(
context: OrgContext = Depends(get_org_context),
service: KnowledgeService = Depends(get_knowledge_service),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[KnowledgeSourceOut]:
sources = await service.list_sources(context.org_id, limit=limit, offset=offset)
# One aggregate query for every source's chunk count. Asking per source
# inside the loop was an N+1: 100 sources meant 101 round trips.
chunk_counts = await service.chunk_counts([s.id for s in sources])
return [
KnowledgeSourceOut(
id=source.id, name=source.name, kind=str(source.kind),
project_id=source.project_id, created_at=source.created_at,
chunk_count=chunk_counts.get(source.id, 0),
)
for source in sources
]
@router.get("/sources/{source_id}", response_model=KnowledgeSourceDetailOut)
async def get_source(
source_id: str,
context: OrgContext = Depends(get_org_context),
service: KnowledgeService = Depends(get_knowledge_service),
) -> KnowledgeSourceDetailOut:
source = await service.get_source_or_404(
organization_id=context.org_id, source_id=source_id
)
detail = await service.source_detail(source_id=source.id)
job = detail["job"]
return KnowledgeSourceDetailOut(
id=source.id, name=source.name, kind=str(source.kind),
project_id=source.project_id, created_at=source.created_at,
chunk_count=detail["chunk_count"],
documents=[
DocumentOut(
id=d.id, title=d.title, content=d.content, created_at=d.created_at
)
for d in detail["documents"]
],
job=(
EmbeddingJobOut(id=job.id, status=str(job.status), error=job.error)
if job is not None
else None
),
)
@router.delete("/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_source(
source_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
service: KnowledgeService = Depends(get_knowledge_service),
session: AsyncSession = Depends(get_session),
) -> None:
context.require(CAN_MANAGE_EMPLOYEES)
await service.delete_source(organization_id=context.org_id, source_id=source_id)
ip, ua = client_meta(request)
await record_audit(
session, action="knowledge.source_delete", organization_id=context.org_id,
actor_user_id=context.user.id, target_type="knowledge_source",
target_id=source_id, ip=ip, user_agent=ua,
)
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
@router.get("/search", response_model=list[SearchHitOut])
async def search_knowledge(
q: str = Query(min_length=1, max_length=300),
project_id: str | None = Query(default=None),
limit: int = Query(default=5, ge=1, le=50),
context: OrgContext = Depends(get_org_context),
service: KnowledgeService = Depends(get_knowledge_service),
) -> list[SearchHitOut]:
context.require(CAN_RUN_TASKS)
hits = await service.search(
organization_id=context.org_id, query=q, project_id=project_id, limit=limit
)
return [SearchHitOut(**hit) for hit in hits]
# ---------------------------------------------------------------------------
# Retrieval policies
# ---------------------------------------------------------------------------
@router.get("/retrieval-policies", response_model=list[RetrievalPolicyOut])
async def list_retrieval_policies(
context: OrgContext = Depends(get_org_context),
service: KnowledgeService = Depends(get_knowledge_service),
) -> list[RetrievalPolicyOut]:
policies = await service.list_policies(context.org_id)
return [
RetrievalPolicyOut(id=p.id, name=p.name, scope_json=dict(p.scope_json))
for p in policies
]
@router.post(
"/retrieval-policies",
response_model=RetrievalPolicyOut,
status_code=status.HTTP_201_CREATED,
)
async def create_retrieval_policy(
payload: CreateRetrievalPolicyRequest,
context: OrgContext = Depends(get_org_context),
service: KnowledgeService = Depends(get_knowledge_service),
) -> RetrievalPolicyOut:
context.require(CAN_MANAGE_EMPLOYEES)
policy = await service.create_policy(
organization_id=context.org_id, name=payload.name, scope=payload.scope
)
return RetrievalPolicyOut(
id=policy.id, name=policy.name, scope_json=dict(policy.scope_json)
)
@@ -0,0 +1,358 @@
"""Organization, membership and invite endpoints.
Every route below the organization prefix resolves its tenant through
``get_org_context``, so a caller can never read or mutate another tenant's data
by guessing an id.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, ConfigDict, EmailStr, 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 NotFoundError, PermissionDeniedError, ValidationError
from app.core.security import generate_token, hash_token
from app.domain.audit.models import AuditAction
from app.domain.base import ensure_unique_slug, slugify
from app.domain.identity.models import (
CAN_MANAGE_MEMBERS,
CAN_VIEW_AUDIT,
ROLE_RANK,
Invite,
Membership,
MembershipStatus,
Organization,
Role,
User,
)
from app.infrastructure.db.models import CreditWalletRow
from app.infrastructure.repositories.audit import AuditRepository
from app.infrastructure.repositories.identity import (
InviteRepository,
MembershipRepository,
OrganizationRepository,
UserRepository,
)
from app.interfaces.http.deps import OrgContext, client_meta, get_current_user, get_org_context
router = APIRouter(prefix="/organizations", tags=["organizations"])
INVITE_TTL_DAYS = 14
class CreateOrganizationRequest(BaseModel):
name: str = Field(min_length=2, max_length=160)
class OrganizationOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
slug: str
owner_user_id: str
class OrganizationWithRole(OrganizationOut):
role: str
class MemberOut(BaseModel):
id: str
user_id: str
email: str = ""
name: str = ""
role: str
status: str
created_at: datetime
class InviteRequest(BaseModel):
email: EmailStr
role: Role = Role.MEMBER
class InviteOut(BaseModel):
id: str
email: str
role: str
expires_at: datetime
invite_token: str
invite_url: str
class UpdateMemberRequest(BaseModel):
role: Role | None = None
status: MembershipStatus | None = None
class AuditEventOut(BaseModel):
id: str
action: str
actor_user_id: str | None
target_type: str
target_id: str
created_at: datetime
def _org_out(org: Organization) -> OrganizationOut:
return OrganizationOut(
id=org.id, name=org.name, slug=org.slug, owner_user_id=org.owner_user_id
)
@router.post("", response_model=OrganizationWithRole, status_code=status.HTTP_201_CREATED)
async def create_organization(
payload: CreateOrganizationRequest,
request: Request,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> OrganizationWithRole:
orgs = OrganizationRepository(session)
base = slugify(payload.name)
slug = ensure_unique_slug(base, await orgs.list_all_slugs())
org = await orgs.create(
Organization(name=payload.name.strip(), slug=slug, owner_user_id=user.id)
)
await MembershipRepository(session).add(
Membership(
organization_id=org.id,
user_id=user.id,
role=Role.OWNER,
status=MembershipStatus.ACTIVE,
)
)
# Every organization gets a wallet up front so billing views are never empty.
session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD"))
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.ORG_CREATE,
organization_id=org.id,
actor_user_id=user.id,
target_type="organization",
target_id=org.id,
metadata={"name": org.name, "slug": org.slug},
ip=ip,
user_agent=ua,
)
await session.flush()
return OrganizationWithRole(
id=org.id, name=org.name, slug=org.slug,
owner_user_id=org.owner_user_id, role=Role.OWNER.value,
)
@router.get("", response_model=list[OrganizationWithRole])
async def list_organizations(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> list[OrganizationWithRole]:
orgs = await OrganizationRepository(session).list_for_user(user.id)
return [
OrganizationWithRole(
id=org.id, name=org.name, slug=org.slug,
owner_user_id=org.owner_user_id, role=role.value,
)
for org, role in orgs
]
@router.get("/{org_id}", response_model=OrganizationWithRole)
async def get_organization(context: OrgContext = Depends(get_org_context)) -> OrganizationWithRole:
return OrganizationWithRole(
id=context.organization.id,
name=context.organization.name,
slug=context.organization.slug,
owner_user_id=context.organization.owner_user_id,
role=context.membership.role.value,
)
@router.get("/{org_id}/members", response_model=list[MemberOut])
async def list_members(
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> list[MemberOut]:
memberships = await MembershipRepository(session).list_for_org(context.org_id)
users = UserRepository(session)
out: list[MemberOut] = []
for membership in memberships:
member_user = await users.get(membership.user_id)
out.append(
MemberOut(
id=membership.id,
user_id=membership.user_id,
email=member_user.email if member_user else "",
name=member_user.display_name if member_user else "",
role=membership.role.value,
status=membership.status.value,
created_at=membership.created_at,
)
)
return out
@router.post(
"/{org_id}/invites", response_model=InviteOut, status_code=status.HTTP_201_CREATED
)
async def create_invite(
payload: InviteRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> InviteOut:
if not context.membership.has(CAN_MANAGE_MEMBERS):
raise PermissionDeniedError("دعوت عضو نیازمند دسترسی مدیریت اعضا است.")
if ROLE_RANK[payload.role] >= ROLE_RANK[Role.OWNER]:
raise ValidationError("نقش owner از طریق دعوت قابل واگذاری نیست.")
raw_token = generate_token()
invite = await InviteRepository(session).create(
Invite(
organization_id=context.org_id,
email=str(payload.email),
role=payload.role,
token_hash=hash_token(raw_token),
invited_by_user_id=context.user.id,
expires_at=datetime.now(UTC) + timedelta(days=INVITE_TTL_DAYS),
)
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.MEMBER_INVITE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="invite",
target_id=invite.id,
metadata={"email": invite.email, "role": invite.role.value},
ip=ip,
user_agent=ua,
)
return InviteOut(
id=invite.id,
email=invite.email,
role=invite.role.value,
expires_at=invite.expires_at,
invite_token=raw_token,
invite_url=f"/accept-invite?token={raw_token}",
)
@router.patch("/{org_id}/members/{member_id}", response_model=MemberOut)
async def update_member(
member_id: str,
payload: UpdateMemberRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> MemberOut:
if not context.membership.has(CAN_MANAGE_MEMBERS):
raise PermissionDeniedError("تغییر نقش عضو نیازمند دسترسی مدیریت اعضا است.")
memberships = MembershipRepository(session)
target = await memberships.get_by_id(member_id)
if target is None or target.organization_id != context.org_id:
raise NotFoundError("عضو یافت نشد.")
# An admin may not outrank themselves or promote anyone to owner.
if target.role == Role.OWNER and context.user.id != target.user_id:
raise PermissionDeniedError("نقش owner فقط توسط خودش قابل تغییر است.")
if payload.role is not None and ROLE_RANK[payload.role] > ROLE_RANK[context.membership.role]:
raise PermissionDeniedError("نمی‌توانید نقشی بالاتر از نقش خود واگذار کنید.")
if payload.role is not None:
if target.role == Role.OWNER and await memberships.count_owners(context.org_id) <= 1:
raise ValidationError("آخرین owner سازمان را نمی‌توان تنزل داد.")
await memberships.set_role(member_id, payload.role)
if payload.status is not None:
await memberships.set_status(member_id, payload.status)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.MEMBER_ROLE_CHANGE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="membership",
target_id=member_id,
metadata={
"role": payload.role.value if payload.role else None,
"status": payload.status.value if payload.status else None,
},
ip=ip,
user_agent=ua,
)
updated = await memberships.get_by_id(member_id)
assert updated is not None
member_user = await UserRepository(session).get(updated.user_id)
return MemberOut(
id=updated.id,
user_id=updated.user_id,
email=member_user.email if member_user else "",
name=member_user.display_name if member_user else "",
role=updated.role.value,
status=updated.status.value,
created_at=updated.created_at,
)
@router.delete("/{org_id}/members/{member_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_member(
member_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> None:
if not context.membership.has(CAN_MANAGE_MEMBERS):
raise PermissionDeniedError("حذف عضو نیازمند دسترسی مدیریت اعضا است.")
memberships = MembershipRepository(session)
target = await memberships.get_by_id(member_id)
if target is None or target.organization_id != context.org_id:
raise NotFoundError("عضو یافت نشد.")
if target.role == Role.OWNER:
raise ValidationError("owner سازمان را نمی‌توان حذف کرد؛ ابتدا نقش را تغییر دهید.")
await memberships.remove(member_id)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.MEMBER_REMOVE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="membership",
target_id=member_id,
ip=ip,
user_agent=ua,
)
@router.get("/{org_id}/audit", response_model=list[AuditEventOut])
async def list_audit(
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> list[AuditEventOut]:
if not context.membership.has(CAN_VIEW_AUDIT):
raise PermissionDeniedError("مشاهدهٔ audit log نیازمند دسترسی مدیر است.")
events = await AuditRepository(session).list_for_organization(
organization_id=context.org_id, limit=limit, offset=offset
)
return [
AuditEventOut(
id=e.id, action=e.action, actor_user_id=e.actor_user_id,
target_type=e.target_type, target_id=e.target_id, created_at=e.created_at,
)
for e in events
]
@@ -0,0 +1,375 @@
"""Project, repository and preview-domain endpoints.
All routes are nested under ``/organizations/{org_id}/projects`` so the tenant
context is resolved by ``get_org_context`` before any handler body runs.
"""
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.application.services.project_service import ProjectService
from app.core.database import get_session
from app.core.errors import NotFoundError
from app.domain.audit.models import AuditAction
from app.domain.identity.models import CAN_MANAGE_PROJECTS
from app.interfaces.http.deps import OrgContext, client_meta, get_org_context, get_project_service
router = APIRouter(
prefix="/organizations/{org_id}/projects", tags=["projects"]
)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class ProjectOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
organization_id: str
name: str
slug: str
description: str
default_branch: str
created_at: datetime
class CreateProjectRequest(BaseModel):
name: str = Field(min_length=2, max_length=160)
description: str = Field(default="", max_length=2000)
class RepoOut(BaseModel):
id: str
provider: str
owner: str
repo_name: str
clone_url: str
web_url: str
default_branch: str
private: bool = True
class RepoStatusOut(BaseModel):
exists: bool
private: bool
default_branch: str
branches: list[str]
open_pull_requests: int
class CreateBranchRequest(BaseModel):
branch_name: str = Field(min_length=1, max_length=120)
from_branch: str = Field(default="main", max_length=120)
class BranchOut(BaseModel):
name: str
sha: str
class OpenPullRequestRequest(BaseModel):
head: str = Field(min_length=1, max_length=120)
base: str = Field(default="main", max_length=120)
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
class PullRequestOut(BaseModel):
number: int
url: str
state: str
class PreviewDomainOut(BaseModel):
id: str
host: str
port: int
status: str
protected: bool
url: str
class CreatePreviewDomainRequest(BaseModel):
subdomain: str = Field(min_length=3, max_length=40)
port: int | None = Field(default=None, ge=1, le=65535)
class AllocatePortRequest(BaseModel):
service_name: str = Field(default="web", max_length=80)
class PortOut(BaseModel):
port: int
service_name: str
# ---------------------------------------------------------------------------
# Projects
# ---------------------------------------------------------------------------
@router.post("", response_model=ProjectOut, status_code=status.HTTP_201_CREATED)
async def create_project(
payload: CreateProjectRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
session: AsyncSession = Depends(get_session),
) -> ProjectOut:
context.require(CAN_MANAGE_PROJECTS)
project = await service.create_project(
organization_id=context.org_id,
organization_slug=context.organization.slug,
created_by_user_id=context.user.id,
name=payload.name,
description=payload.description,
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.PROJECT_CREATE, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="project", target_id=project.id,
metadata={"name": project.name, "slug": project.slug}, ip=ip, user_agent=ua,
)
return ProjectOut(
id=project.id, organization_id=project.organization_id, name=project.name,
slug=project.slug, description=project.description,
default_branch=project.default_branch, created_at=project.created_at,
)
@router.get("", response_model=list[ProjectOut])
async def list_projects(
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[ProjectOut]:
projects = await service.projects.list_for_org(context.org_id, limit=limit, offset=offset)
return [
ProjectOut(
id=p.id, organization_id=p.organization_id, name=p.name, slug=p.slug,
description=p.description, default_branch=p.default_branch,
created_at=p.created_at,
)
for p in projects
]
@router.get("/{project_id}", response_model=ProjectOut)
async def get_project(
project_id: str,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> ProjectOut:
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
return ProjectOut(
id=project.id, organization_id=project.organization_id, name=project.name,
slug=project.slug, description=project.description,
default_branch=project.default_branch, created_at=project.created_at,
)
# ---------------------------------------------------------------------------
# Repository
# ---------------------------------------------------------------------------
@router.post("/{project_id}/repo/ensure", response_model=RepoOut)
async def ensure_repo(
project_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
session: AsyncSession = Depends(get_session),
) -> RepoOut:
context.require(CAN_MANAGE_PROJECTS)
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
repo = await service.ensure_repository(
organization_slug=context.organization.slug, project=project
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.REPO_CREATE, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="repository", target_id=repo.id,
metadata={"repo_name": repo.repo_name, "provider": repo.provider},
ip=ip, user_agent=ua,
)
return RepoOut(
id=repo.id, provider=repo.provider, owner=repo.owner, repo_name=repo.repo_name,
clone_url=repo.clone_url, web_url=repo.web_url,
default_branch=repo.default_branch, private=True,
)
@router.get("/{project_id}/repo", response_model=RepoOut)
async def get_repo(
project_id: str,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> RepoOut:
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
repo = await service.repos.get_for_project(project.id)
if repo is None:
raise NotFoundError("مخزنی برای این پروژه ثبت نشده است.")
# clone_url is a plain https URL; credentials are never included.
return RepoOut(
id=repo.id, provider=repo.provider, owner=repo.owner, repo_name=repo.repo_name,
clone_url=repo.clone_url, web_url=repo.web_url,
default_branch=repo.default_branch, private=True,
)
@router.get("/{project_id}/repo/status", response_model=RepoStatusOut)
async def get_repo_status(
project_id: str,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> RepoStatusOut:
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
status_result = await service.repo_status(project=project)
return RepoStatusOut(
exists=status_result.exists,
private=status_result.private,
default_branch=status_result.default_branch,
branches=list(status_result.branches),
open_pull_requests=status_result.open_pull_requests,
)
@router.post("/{project_id}/repo/branches", response_model=BranchOut)
async def create_branch(
project_id: str,
payload: CreateBranchRequest,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> BranchOut:
context.require(CAN_MANAGE_PROJECTS)
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
branch = await service.git.create_branch(
project_id=project.id, branch_name=payload.branch_name, from_branch=payload.from_branch
)
return BranchOut(name=branch.name, sha=branch.sha)
@router.post("/{project_id}/repo/pull-requests", response_model=PullRequestOut)
async def open_pull_request(
project_id: str,
payload: OpenPullRequestRequest,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> PullRequestOut:
context.require(CAN_MANAGE_PROJECTS)
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
pr = await service.git.open_pull_request(
project_id=project.id, head=payload.head, base=payload.base,
title=payload.title, body=payload.body,
)
return PullRequestOut(number=pr.number, url=pr.url, state=pr.state)
# ---------------------------------------------------------------------------
# Preview domains
# ---------------------------------------------------------------------------
@router.post("/{project_id}/preview/ports", response_model=PortOut)
async def allocate_port(
project_id: str,
payload: AllocatePortRequest,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> PortOut:
context.require(CAN_MANAGE_PROJECTS)
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
port = await service.allocate_port(project=project, service_name=payload.service_name)
return PortOut(port=port, service_name=payload.service_name)
@router.post(
"/{project_id}/preview/domains",
response_model=PreviewDomainOut,
status_code=status.HTTP_201_CREATED,
)
async def create_preview_domain(
project_id: str,
payload: CreatePreviewDomainRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
session: AsyncSession = Depends(get_session),
) -> PreviewDomainOut:
context.require(CAN_MANAGE_PROJECTS)
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
record = await service.create_preview_domain(
project=project,
requested_subdomain=payload.subdomain,
created_by_user_id=context.user.id,
port=payload.port,
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.DOMAIN_CREATE, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="preview_domain",
target_id=record["id"],
metadata={"host": record["host"], "port": record["port"]},
ip=ip, user_agent=ua,
)
return PreviewDomainOut(
id=record["id"], host=record["host"], port=record["port"],
status=record["status"], protected=record["protected"], url=record["url"],
)
@router.get("/{project_id}/preview/domains", response_model=list[PreviewDomainOut])
async def list_preview_domains(
project_id: str,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
) -> list[PreviewDomainOut]:
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
records = await service.list_preview_domains(project=project)
return [
PreviewDomainOut(
id=r["id"], host=r["host"], port=r["port"], status=r["status"],
protected=r["protected"], url=r["url"],
)
for r in records
]
@router.delete(
"/{project_id}/preview/domains/{domain_id}", status_code=status.HTTP_204_NO_CONTENT
)
async def delete_preview_domain(
project_id: str,
domain_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
session: AsyncSession = Depends(get_session),
) -> None:
context.require(CAN_MANAGE_PROJECTS)
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
await service.remove_preview_domain(project=project, domain_id=domain_id)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.DOMAIN_DELETE, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="preview_domain", target_id=domain_id,
ip=ip, user_agent=ua,
)
@@ -0,0 +1,449 @@
"""Sandbox Gateway endpoints.
Note what is **absent**: there is no route that accepts a shell command and runs
it on the host. Every execution goes to ``SandboxProviderPort``, is bounded by a
policy timeout and output cap, and is metered into the usage ledger.
Running a command requires ``sandbox.use``; tearing a workspace down requires
``projects.manage`` because it destroys work.
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.sandbox_service import SandboxService
from app.core.database import get_session
from app.domain.audit.models import AuditAction
from app.domain.identity.models import CAN_MANAGE_PROJECTS, CAN_USE_SANDBOX
from app.domain.sandbox.models import NetworkPolicy
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_org_context,
get_sandbox_service,
)
router = APIRouter(prefix="/organizations/{org_id}/sandbox", tags=["sandbox"])
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class SandboxTemplateOut(BaseModel):
id: str
name: str
provider: str
image: str
description: str
default_cpu: float
default_memory_mb: int
default_disk_mb: int
class WorkspaceOut(BaseModel):
id: str
project_id: str
template_id: str
provider: str
status: str
created_at: datetime
class CreateWorkspaceRequest(BaseModel):
project_id: str
template_id: str
class SessionOut(BaseModel):
id: str
workspace_id: str
status: str
started_at: datetime
last_active_at: datetime
class StartSessionRequest(BaseModel):
workspace_id: str
class CommandRunOut(BaseModel):
id: str
command: str
exit_code: int | None
stdout: str
stderr: str
duration_ms: int
truncated: bool
created_at: datetime
class RunCommandRequest(BaseModel):
command: str = Field(min_length=1, max_length=4_000)
class FileReadOut(BaseModel):
path: str
content: str
class ReadFileRequest(BaseModel):
path: str = Field(min_length=1, max_length=1_000)
class WriteFileRequest(BaseModel):
path: str = Field(min_length=1, max_length=1_000)
content: str = Field(max_length=1_000_000)
class SnapshotOut(BaseModel):
id: str
name: str
created_at: datetime
class CreateSnapshotRequest(BaseModel):
name: str = Field(min_length=1, max_length=120)
class PreviewUrlOut(BaseModel):
port: int
url: str
class ResourceLimitOut(BaseModel):
cpu: float
memory_mb: int
disk_mb: int
timeout_seconds: int
max_output_bytes: int
network_policy: str
class SetLimitsRequest(BaseModel):
scope: str = "organization"
scope_id: str | None = None
cpu: float = Field(default=1.0, gt=0, le=16)
memory_mb: int = Field(default=1024, ge=128, le=65_536)
disk_mb: int = Field(default=2048, ge=256, le=1_048_576)
timeout_seconds: int = Field(default=120, ge=1, le=900)
max_output_bytes: int = Field(default=200_000, ge=1024, le=2_000_000)
network_policy: NetworkPolicy = NetworkPolicy.DENY_ALL
# ---------------------------------------------------------------------------
# Templates & limits
# ---------------------------------------------------------------------------
@router.get("/templates", response_model=list[SandboxTemplateOut])
async def list_templates(
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> list[SandboxTemplateOut]:
templates = await service.list_templates()
return [
SandboxTemplateOut(
id=t.id, name=t.name, provider=str(t.provider), image=t.image,
description=t.description, default_cpu=t.default_cpu,
default_memory_mb=t.default_memory_mb, default_disk_mb=t.default_disk_mb,
)
for t in templates
]
@router.get("/limits", response_model=ResourceLimitOut)
async def get_limits(
project_id: str | None = Query(default=None),
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> ResourceLimitOut:
limit = await service.effective_limits(
organization_id=context.org_id, project_id=project_id
)
return ResourceLimitOut(
cpu=limit.cpu, memory_mb=limit.memory_mb, disk_mb=limit.disk_mb,
timeout_seconds=limit.timeout_seconds, max_output_bytes=limit.max_output_bytes,
network_policy=str(limit.network_policy),
)
@router.put("/limits", response_model=ResourceLimitOut)
async def set_limits(
payload: SetLimitsRequest,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> ResourceLimitOut:
context.require(CAN_MANAGE_PROJECTS)
limit = await service.set_limits(
scope=payload.scope, scope_id=payload.scope_id or context.org_id,
cpu=payload.cpu, memory_mb=payload.memory_mb, disk_mb=payload.disk_mb,
timeout_seconds=payload.timeout_seconds,
max_output_bytes=payload.max_output_bytes,
network_policy=payload.network_policy,
)
return ResourceLimitOut(
cpu=limit.cpu, memory_mb=limit.memory_mb, disk_mb=limit.disk_mb,
timeout_seconds=limit.timeout_seconds, max_output_bytes=limit.max_output_bytes,
network_policy=str(limit.network_policy),
)
# ---------------------------------------------------------------------------
# Workspaces
# ---------------------------------------------------------------------------
@router.post(
"/workspaces", response_model=WorkspaceOut, status_code=status.HTTP_201_CREATED
)
async def create_workspace(
payload: CreateWorkspaceRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
session: AsyncSession = Depends(get_session),
) -> WorkspaceOut:
context.require(CAN_USE_SANDBOX)
workspace = await service.create_workspace(
organization_id=context.org_id,
project_id=payload.project_id,
template_id=payload.template_id,
)
ip, ua = client_meta(request)
await record_audit(
session, action="sandbox.workspace_create", organization_id=context.org_id,
actor_user_id=context.user.id, target_type="workspace", target_id=workspace.id,
metadata={"project_id": workspace.project_id, "provider": str(workspace.provider)},
ip=ip, user_agent=ua,
)
return WorkspaceOut(
id=workspace.id, project_id=workspace.project_id,
template_id=workspace.template_id, provider=str(workspace.provider),
status=str(workspace.status), created_at=workspace.created_at,
)
@router.get("/workspaces", response_model=list[WorkspaceOut])
async def list_workspaces(
project_id: str = Query(min_length=1),
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[WorkspaceOut]:
workspaces = await service.list_workspaces(
organization_id=context.org_id,
project_id=project_id,
limit=limit,
offset=offset,
)
return [
WorkspaceOut(
id=w.id, project_id=w.project_id, template_id=w.template_id,
provider=str(w.provider), status=str(w.status), created_at=w.created_at,
)
for w in workspaces
]
@router.delete("/workspaces/{workspace_id}", response_model=WorkspaceOut)
async def destroy_workspace(
workspace_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
session: AsyncSession = Depends(get_session),
) -> WorkspaceOut:
context.require(CAN_MANAGE_PROJECTS)
workspace = await service.destroy_workspace(
organization_id=context.org_id, workspace_id=workspace_id
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.SANDBOX_DESTROY, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="workspace",
target_id=workspace.id, ip=ip, user_agent=ua,
)
return WorkspaceOut(
id=workspace.id, project_id=workspace.project_id,
template_id=workspace.template_id, provider=str(workspace.provider),
status=str(workspace.status), created_at=workspace.created_at,
)
# ---------------------------------------------------------------------------
# Sessions
# ---------------------------------------------------------------------------
@router.post(
"/sessions", response_model=SessionOut, status_code=status.HTTP_201_CREATED
)
async def start_session(
payload: StartSessionRequest,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> SessionOut:
context.require(CAN_USE_SANDBOX)
sandbox_session = await service.start_session(
organization_id=context.org_id, workspace_id=payload.workspace_id
)
return SessionOut(
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
last_active_at=sandbox_session.last_active_at,
)
@router.post("/sessions/{session_id}/stop", response_model=SessionOut)
async def stop_session(
session_id: str,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> SessionOut:
context.require(CAN_USE_SANDBOX)
sandbox_session = await service.stop_session(
organization_id=context.org_id, session_id=session_id
)
return SessionOut(
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
last_active_at=sandbox_session.last_active_at,
)
# ---------------------------------------------------------------------------
# Commands & files
# ---------------------------------------------------------------------------
@router.post("/sessions/{session_id}/commands", response_model=CommandRunOut)
async def run_command(
session_id: str,
payload: RunCommandRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
session: AsyncSession = Depends(get_session),
) -> CommandRunOut:
context.require(CAN_USE_SANDBOX)
run, _result = await service.run_command(
organization_id=context.org_id,
session_id=session_id,
command=payload.command,
user_id=context.user.id,
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.SANDBOX_COMMAND, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="sandbox_session",
target_id=session_id,
# The command itself is recorded, but never a secret from the environment.
metadata={"exit_code": run.exit_code, "duration_ms": run.duration_ms},
ip=ip, user_agent=ua,
)
return CommandRunOut(
id=run.id, command=run.command, exit_code=run.exit_code,
stdout=run.stdout_ref, stderr=run.stderr_ref, duration_ms=run.duration_ms,
truncated=run.truncated, created_at=run.created_at,
)
@router.get("/sessions/{session_id}/commands", response_model=list[CommandRunOut])
async def list_commands(
session_id: str,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> list[CommandRunOut]:
await service.get_session_or_404(
organization_id=context.org_id, session_id=session_id
)
runs = await service.list_commands(session_id=session_id)
return [
CommandRunOut(
id=r.id, command=r.command, exit_code=r.exit_code, stdout=r.stdout_ref,
stderr=r.stderr_ref, duration_ms=r.duration_ms, truncated=r.truncated,
created_at=r.created_at,
)
for r in runs
]
@router.post("/sessions/{session_id}/files/read", response_model=FileReadOut)
async def read_file(
session_id: str,
payload: ReadFileRequest,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> FileReadOut:
context.require(CAN_USE_SANDBOX)
content = await service.read_file(
organization_id=context.org_id, session_id=session_id, path=payload.path
)
return FileReadOut(path=payload.path, content=content)
@router.post("/sessions/{session_id}/files/write", status_code=status.HTTP_204_NO_CONTENT)
async def write_file(
session_id: str,
payload: WriteFileRequest,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> None:
context.require(CAN_USE_SANDBOX)
await service.write_file(
organization_id=context.org_id, session_id=session_id,
path=payload.path, content=payload.content,
)
# ---------------------------------------------------------------------------
# Snapshots & preview
# ---------------------------------------------------------------------------
@router.post(
"/sessions/{session_id}/snapshots",
response_model=SnapshotOut,
status_code=status.HTTP_201_CREATED,
)
async def create_snapshot(
session_id: str,
payload: CreateSnapshotRequest,
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> SnapshotOut:
context.require(CAN_USE_SANDBOX)
snapshot = await service.snapshot(
organization_id=context.org_id, session_id=session_id, name=payload.name
)
return SnapshotOut(
id=snapshot.id, name=snapshot.name, created_at=snapshot.created_at
)
@router.post("/sessions/{session_id}/preview-url", response_model=PreviewUrlOut)
async def get_preview_url(
session_id: str,
port: int = Query(ge=1, le=65535),
context: OrgContext = Depends(get_org_context),
service: SandboxService = Depends(get_sandbox_service),
) -> PreviewUrlOut:
context.require(CAN_USE_SANDBOX)
preview = await service.get_preview_url(
organization_id=context.org_id, session_id=session_id, port=port
)
return PreviewUrlOut(port=preview.port, url=preview.url)
@@ -0,0 +1,803 @@
"""AI Workforce, Manager Chat, Tasks and Runs.
Two things are load-bearing in this module:
* **Model identity never leaves.** ``EmployeeOut`` and ``RunOut`` carry a routing
policy id at most. There is no field capable of holding a model or provider
name, so a future refactor cannot accidentally leak one.
* **Every route resolves its tenant** through ``get_org_context``, and every
nested id (employee, thread, task, run) is re-scoped to that tenant.
"""
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.application.services.workforce_service import WorkforceService
from app.core.database import get_session
from app.domain.audit.models import AuditAction
from app.domain.identity.models import CAN_MANAGE_EMPLOYEES, CAN_RUN_TASKS
from app.domain.workforce.models import (
EmployeeStatus,
RoleKey,
TaskStatus,
)
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_org_context,
get_workforce_service,
)
router = APIRouter(prefix="/organizations/{org_id}", tags=["workforce"])
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class AgentTemplateOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
role_key: str
description: str
default_tools_json: list[str]
class RoutingChoiceOut(BaseModel):
"""A routing policy as a *customer* may see it.
Name and description only. Deliberately omits ``mode`` and every field that
could hint at which model sits behind the choice.
"""
id: str
name: str
description: str
class ToolSpecOut(BaseModel):
key: str
label: str
description: str
requires_approval: bool
destructive: bool
category: str
class SetToolPermissionRequest(BaseModel):
allowed: bool
class DecideApprovalRequest(BaseModel):
approve: bool
note: str = Field(default="", max_length=1000)
class EmployeeOut(BaseModel):
id: str
organization_id: str
project_id: str | None
template_id: str | None
name: str
role_key: str
status: str
# A routing policy id — never a model or provider name.
routing_policy_id: str | None
created_at: datetime
class EmployeeDetailOut(EmployeeOut):
prompt_versions: list[PromptVersionOut]
tool_permissions: list[ToolPermissionOut]
open_task_count: int
class CreateEmployeeRequest(BaseModel):
name: str = Field(min_length=2, max_length=160)
role_key: RoleKey
template_id: str | None = None
project_id: str | None = None
routing_policy_id: str | None = None
class PromptVersionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
version: int
content: str
created_at: datetime
class ToolPermissionOut(BaseModel):
tool_key: str
allowed: bool
# ``EmployeeDetailOut`` refers to the two models above by name. With
# ``from __future__ import annotations`` every annotation is a string, so Pydantic
# must be told to resolve them once the names exist.
EmployeeDetailOut.model_rebuild()
class UpdateEmployeeRequest(BaseModel):
name: str | None = Field(default=None, min_length=2, max_length=160)
status: EmployeeStatus | None = None
project_id: str | None = None
routing_policy_id: str | None = None
class CreatePromptVersionRequest(BaseModel):
content: str = Field(min_length=1, max_length=20_000)
class ThreadOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
organization_id: str
project_id: str | None
title: str
created_at: datetime
class MessageOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
thread_id: str
role: str
content: str
employee_id: str | None
created_at: datetime
class ThreadDetailOut(ThreadOut):
messages: list[MessageOut]
class CreateThreadRequest(BaseModel):
title: str = Field(default="", max_length=255)
project_id: str | None = None
class PostMessageRequest(BaseModel):
content: str = Field(min_length=1, max_length=20_000)
employee_id: str | None = None
class PostMessageResponse(BaseModel):
user_message: MessageOut
assistant_message: MessageOut
class TaskOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
organization_id: str
project_id: str | None
thread_id: str | None
title: str
description: str
status: str
assigned_employee_id: str | None
created_at: datetime
updated_at: datetime
class CreateTaskRequest(BaseModel):
title: str = Field(min_length=2, max_length=255)
description: str = Field(default="", max_length=10_000)
project_id: str | None = None
thread_id: str | None = None
assigned_employee_id: str | None = None
class RunOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
task_id: str
employee_id: str | None
status: str
error: str | None
started_at: datetime | None
finished_at: datetime | None
created_at: datetime
class RunStepOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
index: int
kind: str
title: str
payload_json: dict
created_at: datetime
class ToolCallOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tool_key: str
status: str
requires_approval: bool
arguments_json: dict
result_json: dict
created_at: datetime
class ArtifactOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
kind: str
content_ref: str
size_bytes: int
run_id: str | None
created_at: datetime
class RunDetailOut(RunOut):
steps: list[RunStepOut]
tool_calls: list[ToolCallOut]
artifacts: list[ArtifactOut]
class TaskDetailOut(TaskOut):
runs: list[RunOut]
class StartRunRequest(BaseModel):
employee_id: str | None = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _employee_out(employee) -> EmployeeOut:
return EmployeeOut(
id=employee.id, organization_id=employee.organization_id,
project_id=employee.project_id, template_id=employee.template_id,
name=employee.name, role_key=str(employee.role_key),
status=str(employee.status), routing_policy_id=employee.routing_policy_id,
created_at=employee.created_at,
)
def _thread_out(thread) -> ThreadOut:
return ThreadOut(
id=thread.id, organization_id=thread.organization_id,
project_id=thread.project_id, title=thread.title, created_at=thread.created_at,
)
def _task_out(task) -> TaskOut:
return TaskOut(
id=task.id, organization_id=task.organization_id, project_id=task.project_id,
thread_id=task.thread_id, title=task.title, description=task.description,
status=str(task.status), assigned_employee_id=task.assigned_employee_id,
created_at=task.created_at, updated_at=task.updated_at,
)
def _run_out(run) -> RunOut:
return RunOut(
id=run.id, task_id=run.task_id, employee_id=run.employee_id,
status=str(run.status), error=run.error, started_at=run.started_at,
finished_at=run.finished_at, created_at=run.created_at,
)
# ---------------------------------------------------------------------------
# Templates
# ---------------------------------------------------------------------------
@router.get("/agent-templates", response_model=list[AgentTemplateOut])
async def list_agent_templates(
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> list[AgentTemplateOut]:
templates = await service.list_templates()
return [
AgentTemplateOut(
id=t.id, name=t.name, role_key=str(t.role_key), description=t.description,
default_tools_json=list(t.default_tools_json),
)
for t in templates
]
@router.get("/routing-choices", response_model=list[RoutingChoiceOut])
async def list_routing_choices(
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> list[RoutingChoiceOut]:
"""Let a customer pick *how* an employee should work, without naming a model."""
policies = await service.list_routing_choices()
return [
RoutingChoiceOut(id=p.id, name=p.name, description=p.description) for p in policies
]
# ---------------------------------------------------------------------------
# Employees
# ---------------------------------------------------------------------------
@router.post(
"/employees", response_model=EmployeeOut, status_code=status.HTTP_201_CREATED
)
async def create_employee(
payload: CreateEmployeeRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
session: AsyncSession = Depends(get_session),
) -> EmployeeOut:
context.require(CAN_MANAGE_EMPLOYEES)
employee = await service.create_employee(
organization_id=context.org_id,
created_by_user_id=context.user.id,
name=payload.name,
role_key=payload.role_key,
template_id=payload.template_id,
project_id=payload.project_id,
routing_policy_id=payload.routing_policy_id,
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.EMPLOYEE_CREATE, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="employee", target_id=employee.id,
metadata={"name": employee.name, "role_key": str(employee.role_key)},
ip=ip, user_agent=ua,
)
return _employee_out(employee)
@router.get("/employees", response_model=list[EmployeeOut])
async def list_employees(
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[EmployeeOut]:
return [
_employee_out(e)
for e in await service.list_employees(context.org_id, limit=limit, offset=offset)
]
@router.get("/employees/{employee_id}", response_model=EmployeeDetailOut)
async def get_employee(
employee_id: str,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> EmployeeDetailOut:
employee = await service.get_employee_or_404(
organization_id=context.org_id, employee_id=employee_id
)
base = _employee_out(employee)
return EmployeeDetailOut(
**base.model_dump(),
prompt_versions=[
PromptVersionOut(
id=v.id, version=v.version, content=v.content, created_at=v.created_at
)
for v in await service.list_prompt_versions(employee.id)
],
tool_permissions=[
ToolPermissionOut(tool_key=p.tool_key, allowed=p.allowed)
for p in await service.list_tool_permissions(employee.id)
],
open_task_count=await service.tasks.count_open_for_employee(employee.id),
)
@router.patch("/employees/{employee_id}", response_model=EmployeeOut)
async def update_employee(
employee_id: str,
payload: UpdateEmployeeRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
session: AsyncSession = Depends(get_session),
) -> EmployeeOut:
context.require(CAN_MANAGE_EMPLOYEES)
employee = await service.update_employee(
organization_id=context.org_id,
employee_id=employee_id,
name=payload.name,
status=payload.status,
project_id=payload.project_id,
routing_policy_id=payload.routing_policy_id,
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.EMPLOYEE_UPDATE, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="employee", target_id=employee.id,
metadata={"status": str(employee.status)},
ip=ip, user_agent=ua,
)
return _employee_out(employee)
@router.post(
"/employees/{employee_id}/prompt-versions",
response_model=PromptVersionOut,
status_code=status.HTTP_201_CREATED,
)
async def add_prompt_version(
employee_id: str,
payload: CreatePromptVersionRequest,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> PromptVersionOut:
context.require(CAN_MANAGE_EMPLOYEES)
# Confirms tenant ownership before writing anything.
await service.get_employee_or_404(
organization_id=context.org_id, employee_id=employee_id
)
version = await service.add_prompt_version(
employee_id=employee_id, content=payload.content,
created_by_user_id=context.user.id,
)
return PromptVersionOut(
id=version.id, version=version.version, content=version.content,
created_at=version.created_at,
)
# ---------------------------------------------------------------------------
# Threads & messages
# ---------------------------------------------------------------------------
@router.post(
"/threads", response_model=ThreadOut, status_code=status.HTTP_201_CREATED
)
async def create_thread(
payload: CreateThreadRequest,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> ThreadOut:
thread = await service.create_thread(
organization_id=context.org_id, created_by_user_id=context.user.id,
title=payload.title, project_id=payload.project_id,
)
return _thread_out(thread)
@router.get("/threads", response_model=list[ThreadOut])
async def list_threads(
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[ThreadOut]:
return [
_thread_out(t)
for t in await service.list_threads(context.org_id, limit=limit, offset=offset)
]
@router.get("/threads/{thread_id}", response_model=ThreadDetailOut)
async def get_thread(
thread_id: str,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> ThreadDetailOut:
thread = await service.get_thread_or_404(
organization_id=context.org_id, thread_id=thread_id
)
messages = await service.list_messages(thread.id)
base = _thread_out(thread)
return ThreadDetailOut(
**base.model_dump(),
messages=[
MessageOut(
id=m.id, thread_id=m.thread_id, role=str(m.role), content=m.content,
employee_id=m.employee_id, created_at=m.created_at,
)
for m in messages
],
)
@router.post(
"/threads/{thread_id}/messages",
response_model=PostMessageResponse,
status_code=status.HTTP_201_CREATED,
)
async def post_message(
thread_id: str,
payload: PostMessageRequest,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> PostMessageResponse:
context.require(CAN_RUN_TASKS)
user_message, assistant_message = await service.post_user_message(
organization_id=context.org_id,
thread_id=thread_id,
content=payload.content,
user_id=context.user.id,
employee_id=payload.employee_id,
)
return PostMessageResponse(
user_message=MessageOut(
id=user_message.id, thread_id=user_message.thread_id,
role=str(user_message.role), content=user_message.content,
employee_id=user_message.employee_id, created_at=user_message.created_at,
),
assistant_message=MessageOut(
id=assistant_message.id, thread_id=assistant_message.thread_id,
role=str(assistant_message.role), content=assistant_message.content,
employee_id=assistant_message.employee_id,
created_at=assistant_message.created_at,
),
)
# ---------------------------------------------------------------------------
# Tasks
# ---------------------------------------------------------------------------
@router.post("/tasks", response_model=TaskOut, status_code=status.HTTP_201_CREATED)
async def create_task(
payload: CreateTaskRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
session: AsyncSession = Depends(get_session),
) -> TaskOut:
context.require(CAN_RUN_TASKS)
task = await service.create_task(
organization_id=context.org_id,
created_by_user_id=context.user.id,
title=payload.title,
description=payload.description,
project_id=payload.project_id,
thread_id=payload.thread_id,
assigned_employee_id=payload.assigned_employee_id,
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.TASK_CREATE, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="task", target_id=task.id,
metadata={"title": task.title}, ip=ip, user_agent=ua,
)
return _task_out(task)
@router.get("/tasks", response_model=list[TaskOut])
async def list_tasks(
status_filter: TaskStatus | None = Query(default=None, alias="status"),
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[TaskOut]:
tasks = await service.list_tasks(
context.org_id, status=status_filter, limit=limit, offset=offset
)
return [_task_out(t) for t in tasks]
@router.get("/tasks/{task_id}", response_model=TaskDetailOut)
async def get_task(
task_id: str,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> TaskDetailOut:
task = await service.get_task_or_404(organization_id=context.org_id, task_id=task_id)
runs = await service.list_task_runs(task.id)
base = _task_out(task)
return TaskDetailOut(**base.model_dump(), runs=[_run_out(r) for r in runs])
# ---------------------------------------------------------------------------
# Runs
# ---------------------------------------------------------------------------
@router.post(
"/tasks/{task_id}/runs",
response_model=RunDetailOut,
status_code=status.HTTP_201_CREATED,
)
async def start_run(
task_id: str,
payload: StartRunRequest,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> RunDetailOut:
context.require(CAN_RUN_TASKS)
run = await service.start_run(
organization_id=context.org_id,
task_id=task_id,
user_id=context.user.id,
employee_id=payload.employee_id,
)
return await _run_detail(service, context.org_id, run.id)
@router.get("/runs/{run_id}", response_model=RunDetailOut)
async def get_run(
run_id: str,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> RunDetailOut:
return await _run_detail(service, context.org_id, run_id)
@router.post("/runs/{run_id}/cancel", response_model=RunDetailOut)
async def cancel_run(
run_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
session: AsyncSession = Depends(get_session),
) -> RunDetailOut:
context.require(CAN_RUN_TASKS)
run = await service.cancel_run(organization_id=context.org_id, run_id=run_id)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.RUN_CANCEL, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="run", target_id=run.id,
ip=ip, user_agent=ua,
)
return await _run_detail(service, context.org_id, run.id)
@router.get("/approvals/pending", response_model=list[ToolCallOut])
async def list_pending_approvals(
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> list[ToolCallOut]:
calls = await service.list_pending_approvals(context.org_id)
return [
ToolCallOut(
id=c.id, tool_key=c.tool_key, status=str(c.status),
requires_approval=c.requires_approval,
arguments_json=dict(c.arguments_json), result_json=dict(c.result_json),
created_at=c.created_at,
)
for c in calls
]
# ---------------------------------------------------------------------------
# Tool catalog & approvals
# ---------------------------------------------------------------------------
@router.get("/tools", response_model=list[ToolSpecOut])
async def list_tools(
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> list[ToolSpecOut]:
"""The catalog of tools an employee can be granted."""
return [
ToolSpecOut(
key=spec.key, label=spec.label, description=spec.description,
requires_approval=spec.requires_approval, destructive=spec.destructive,
category=spec.category,
)
for spec in service.tool_catalog()
]
@router.put(
"/employees/{employee_id}/tools/{tool_key}", response_model=ToolPermissionOut
)
async def set_tool_permission(
employee_id: str,
tool_key: str,
payload: SetToolPermissionRequest,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
) -> ToolPermissionOut:
context.require(CAN_MANAGE_EMPLOYEES)
permission = await service.set_tool_permission(
organization_id=context.org_id,
employee_id=employee_id,
tool_key=tool_key,
allowed=payload.allowed,
)
return ToolPermissionOut(tool_key=permission.tool_key, allowed=permission.allowed)
@router.post("/approvals/{tool_call_id}/decide", response_model=ToolCallOut)
async def decide_approval(
tool_call_id: str,
payload: DecideApprovalRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
service: WorkforceService = Depends(get_workforce_service),
session: AsyncSession = Depends(get_session),
) -> ToolCallOut:
context.require(CAN_RUN_TASKS)
call = await service.decide_approval(
organization_id=context.org_id,
tool_call_id=tool_call_id,
approve=payload.approve,
decided_by_user_id=context.user.id,
note=payload.note,
)
ip, ua = client_meta(request)
await record_audit(
session, action=AuditAction.APPROVAL_DECISION, organization_id=context.org_id,
actor_user_id=context.user.id, target_type="tool_call", target_id=call.id,
metadata={"tool_key": call.tool_key, "approved": payload.approve},
ip=ip, user_agent=ua,
)
return ToolCallOut(
id=call.id, tool_key=call.tool_key, status=str(call.status),
requires_approval=call.requires_approval,
arguments_json=dict(call.arguments_json), result_json=dict(call.result_json),
created_at=call.created_at,
)
async def _run_detail(
service: WorkforceService, organization_id: str, run_id: str
) -> RunDetailOut:
run, steps, tool_calls, artifacts = await service.get_run_or_404(
organization_id=organization_id, run_id=run_id
)
base = _run_out(run)
return RunDetailOut(
**base.model_dump(),
steps=[
RunStepOut(
id=s.id, index=s.index, kind=s.kind, title=s.title,
payload_json=dict(s.payload_json), created_at=s.created_at,
)
for s in steps
],
tool_calls=[
ToolCallOut(
id=c.id, tool_key=c.tool_key, status=str(c.status),
requires_approval=c.requires_approval,
arguments_json=dict(c.arguments_json),
result_json=dict(c.result_json), created_at=c.created_at,
)
for c in tool_calls
],
artifacts=[
ArtifactOut(
id=a.id, name=a.name, kind=a.kind, content_ref=a.content_ref,
size_bytes=a.size_bytes, run_id=a.run_id, created_at=a.created_at,
)
for a in artifacts
],
)