"""AI Labs HTTP surfaces. Two routers, and the boundary between them is the product's privacy promise rather than a convention: * ``router`` (``/api/v1/ai-labs``) is **self-scoped, for everyone**. Every handler resolves the caller's own person id and passes it into the ``WHERE`` clause. No role grants a wider view, because there is no wider view to grant. * ``admin_router`` (``/api/v1/ai-labs/admin``) reads and writes **configuration and counters**. It cannot reach a conversation, a message, a prompt or an artifact — not because a check refuses, but because no handler here queries those tables. The whole domain is behind the fail-closed ``ai_labs.enabled`` switch, re-checked per request rather than cached, so turning it off takes effect immediately. """ from __future__ import annotations import uuid from typing import Any from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthUser from app.core.dependencies import ( check_ai_labs_generation_rate_limit, check_ai_labs_rate_limit, get_current_user, get_db, require_active_employee_for_mutations, require_admin, require_admin_read, ) from app.core.exceptions import AppException from app.core.idempotent_route import idempotent from app.core.response import paginated_response, success_response from app.domains.ai_labs import catalog, chat from app.domains.ai_labs import settings as ai_settings from app.domains.ai_labs.models import GEN_TERMINAL_STATUSES from app.domains.ai_labs.schemas import ( AdminSettingsBody, ChatTurnBody, CreateConversationBody, SubmitGenerationBody, TranslateBody, UpdateConversationBody, ) from app.domains.ai_labs.service import AiLabsService router = APIRouter( prefix="/api/v1/ai-labs", tags=["ai-labs"], # Mutations are closed to someone who is mid-offboarding, matching # every other employee-facing domain. Reads stay open, so a leaver can # still read and export their own history — but **not delete it**: # DELETE is a mutation and this gate refuses it like any other. That is # the right trade rather than an oversight. A leaver's rows are removed # by ``ai_labs.sweep_retention`` when a retention window is configured, # and carving a delete-only exception into the gate would mean an # account already being wound down could still issue destructive # writes. dependencies=[Depends(require_active_employee_for_mutations)], ) admin_router = APIRouter( prefix="/api/v1/ai-labs/admin", tags=["ai-labs-admin"], ) async def _require_available(db: AsyncSession) -> ai_settings.AiLabsConfig: """Resolve config, refusing when the surface is off or unconfigured. Distinguishes the two: "an admin turned this off" and "nobody has pasted a key" need different follow-up, and a single 503 would hide which one an operator is looking at. """ config = await ai_settings.resolve_config(db) if not config.enabled: raise AppException( code="AI_LABS_DISABLED", message="AI Labs is not enabled for this organisation.", status_code=403, ) if not config.api_key: raise AppException( code="AI_LABS_NOT_CONFIGURED", message=( "AI Labs is enabled but no AI/LLM connector key is " "configured, so no model can be reached." ), status_code=503, ) return config def _trace(request: Request) -> str: return str(getattr(request.state, "trace_id", "") or "") # ── discovery ────────────────────────────────────────────────── @router.get("/catalog", summary="AI 实验室 可用能力与模型(自助,只读)") async def get_catalog( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: """What this deployment offers. The frontend renders its menu, model picker and every option control from this response — never from a hard-coded list. That is what makes re-pointing a deprecated model a settings change instead of a release, and it means an option the vendor has stopped accepting can be removed without shipping a frontend that still shows it. Answers even when the surface is disabled, with ``available: false``, so the UI can explain the state instead of showing a broken page. """ config = await ai_settings.resolve_config(db) return success_response( data={ "available": config.available, "enabled": config.enabled, "catalog_version": catalog.CATALOG_VERSION, "chat_models": [ { "key": model.key, "tier": model.tier, "vendor": model.vendor, "supports_vision": model.supports_vision, "supports_thinking": model.supports_thinking, "default_thinking": model.default_thinking, "measured_ttfb_seconds": model.measured_ttfb_seconds, } for model in catalog.CHAT_MODELS ], "default_chat_model": catalog.DEFAULT_CHAT_MODEL_KEY, "capabilities": [ { "key": capability.key, "output_kind": capability.output_kind, "prompt_required": capability.prompt_required, "prompt_max_chars": capability.prompt_max_chars, "input_kind": capability.input_kind, "min_inputs": capability.min_inputs, "max_inputs": capability.max_inputs, "measured_seconds": capability.measured_seconds, "params": [ { "name": param.name, "default": param.default, "options": [ {"value": option.value, "label": option.label} for option in param.options ], } for param in capability.params ], } for capability in catalog.GENERATION_CAPABILITIES ], "translate": { "max_chars": catalog.TRANSLATE_MAX_CHARS, "languages": [ {"value": option.value, "label": option.label} for option in catalog.TRANSLATE_LANGUAGES ], }, "limits": { "message_chars": catalog.MAX_MESSAGE_CHARS, "attachments_per_turn": catalog.MAX_ATTACHMENTS_PER_TURN, # Per purpose, because they genuinely differ. The old # single figure was the document cap, so the UI offered # 20 MB for images the presign endpoint caps at 10. "attachment_bytes": catalog.MAX_ATTACHMENT_BYTES, "attachment_bytes_by_purpose": dict( catalog.ATTACHMENT_BYTES_BY_PURPOSE, ), "history_messages": catalog.MAX_HISTORY_MESSAGES, "conversations": catalog.MAX_CONVERSATIONS_PER_PERSON, }, "upload_purposes": { "image": "ai_labs_image", "document": "ai_labs_document", "audio": "ai_labs_audio", }, "content_types": { "image": sorted(catalog.CHAT_IMAGE_CONTENT_TYPES), "document": sorted(catalog.DOCUMENT_CONTENT_TYPES), "audio": sorted(catalog.AUDIO_CONTENT_TYPES), }, }, request=request, ) @router.get("/me/usage", summary="AI 实验室 我今天的用量与额度(自助,只读)") async def my_usage( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: config = await ai_settings.resolve_config(db) data = await AiLabsService(db).my_usage(person_id=user.id, config=config) return success_response(data=data, request=request) # ── conversations ────────────────────────────────────────────── @router.get("/conversations", summary="AI 实验室 我的对话列表(自助,只读)") async def list_conversations( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), page: int = Query(default=1, ge=1), page_size: int = Query(default=30, ge=1, le=100), ) -> dict[str, Any]: rows, total = await AiLabsService(db).list_conversations( person_id=user.id, page=page, page_size=page_size, ) return paginated_response( data=rows, total=total, page=page, page_size=page_size, request=request, ) @router.post("/conversations", summary="AI 实验室 新建对话(自助)") @idempotent("ai_labs.conversation.create", key_params=("user.id",)) async def create_conversation( body: CreateConversationBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: await _require_available(db) data = await AiLabsService(db).create_conversation( person_id=user.id, model_key=body.model_key, thinking=body.thinking, ) await db.commit() return success_response(data=data, request=request) @router.get( "/conversations/{conversation_id}", summary="AI 实验室 对话详情含消息(自助,只读)", ) async def get_conversation( conversation_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: data = await AiLabsService(db).get_conversation( person_id=user.id, conversation_id=conversation_id, ) return success_response(data=data, request=request) @router.patch( "/conversations/{conversation_id}", summary="AI 实验室 重命名/置顶/切换模型(自助)", ) async def update_conversation( conversation_id: uuid.UUID, body: UpdateConversationBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: data = await AiLabsService(db).update_conversation( person_id=user.id, conversation_id=conversation_id, title=body.title, pinned=body.pinned, model_key=body.model_key, thinking=body.thinking, ) await db.commit() return success_response(data=data, request=request) @router.delete( "/conversations/{conversation_id}", summary="AI 实验室 删除对话(自助,硬删除含附件)", ) async def delete_conversation( conversation_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: await AiLabsService(db).delete_conversation( person_id=user.id, conversation_id=conversation_id, trace_id=_trace(request), ) return success_response(data={"deleted": True}, request=request) @router.post( "/conversations/{conversation_id}/turns", summary="AI 实验室 发一轮对话(自助,SSE 流式)", ) async def chat_turn( conversation_id: uuid.UUID, body: ChatTurnBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_ai_labs_rate_limit), ) -> StreamingResponse: """Stream one turn as Server-Sent Events. Validation, quota and the employee's own message are all handled **before** the response starts, so a refusal is an ordinary JSON error with a status code rather than an ``error`` frame inside a 200 that the client has to parse to discover it failed. ``X-Accel-Buffering: no`` is the load-bearing header: without it nginx buffers the whole response and the employee sees nothing until the turn completes, which is precisely the experience streaming exists to avoid. """ config = await _require_available(db) from app.domains.ai_labs import usage as usage_module await usage_module.assert_within_quota( db, person_id=user.id, config=config, capability_key=usage_module.CAPABILITY_CHAT, ) # Touches the conversation and the attachments, so it must run while # the request's session is still open. prepared = await chat.prepare_turn( db, conversation_id=conversation_id, person_id=user.id, text=body.text, attachment_keys=list(body.attachment_object_keys or []), attachment_names=body.attachment_names, trace_id=_trace(request), config=config, model_key=body.model_key, thinking=body.thinking, client_token=body.client_token, ) return StreamingResponse( chat.stream_turn(prepared), media_type="text/event-stream", headers={ "Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no", "Connection": "keep-alive", }, ) # ── generations ──────────────────────────────────────────────── @router.get("/generations", summary="AI 实验室 我的作品列表(自助,只读)") async def list_generations( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), capability: str | None = Query(default=None, max_length=32), status: str | None = Query(default=None, max_length=16), page: int = Query(default=1, ge=1), page_size: int = Query(default=24, ge=1, le=100), ) -> dict[str, Any]: rows, total = await AiLabsService(db).list_generations( person_id=user.id, capability_key=capability, status=status, page=page, page_size=page_size, ) return paginated_response( data=rows, total=total, page=page, page_size=page_size, request=request, ) @router.post("/generations", summary="AI 实验室 提交生成任务(自助)") @idempotent( "ai_labs.generation.submit", key_params=("user.id",), # The response echoes the employee's prompt, and a cached body # lives in ``core_idempotency_records`` for the full TTL — a table # outside this domain, readable by anyone who can read the platform # tables, and NOT reached by the hard delete that is supposed to # remove everything an employee typed here. Withholding it on # replay keeps the promise; the replaying client already has the # prompt it just sent. redact_cached=("data.prompt",), ) async def submit_generation( body: SubmitGenerationBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_ai_labs_generation_rate_limit), ) -> dict[str, Any]: config = await _require_available(db) data = await AiLabsService(db).submit_generation( person_id=user.id, capability_key=body.capability, prompt=body.prompt, params=body.params or {}, input_object_keys=list(body.input_object_keys or []), config=config, trace_id=_trace(request), ) return success_response(data=data, request=request) @router.get( "/generations/{generation_id}", summary="AI 实验室 生成任务状态(自助,只读,供轮询)", ) async def get_generation( generation_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: data = await AiLabsService(db).get_generation( person_id=user.id, generation_id=generation_id, ) return success_response(data=data, request=request) @router.get( "/generations/{generation_id}/artifacts/{index}", summary="AI 实验室 取作品下载链接(自助,本人限时链接)", ) async def generation_artifact( generation_id: uuid.UUID, index: int, request: Request, download: bool = False, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: """A presigned link for one of the caller's own artifacts. JSON rather than a 302: the browser holds its access token in memory and attaches it through an interceptor, so a redirect-following navigation would arrive at S3 unauthenticated. Same reason the CLI download endpoint returns JSON. """ data = await AiLabsService(db).artifact_url( person_id=user.id, generation_id=generation_id, index=index, download=download, ) return success_response(data=data, request=request) @router.post( "/generations/{generation_id}/cancel", summary="AI 实验室 取消未完成的生成任务(自助)", ) async def cancel_generation( generation_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: data = await AiLabsService(db).cancel_generation( person_id=user.id, generation_id=generation_id, trace_id=_trace(request), ) return success_response(data=data, request=request) @router.delete( "/generations/{generation_id}", summary="AI 实验室 删除作品(自助,硬删除含文件)", ) async def delete_generation( generation_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: await AiLabsService(db).delete_generation( person_id=user.id, generation_id=generation_id, trace_id=_trace(request), ) return success_response(data={"deleted": True}, request=request) # ── translation ──────────────────────────────────────────────── @router.post("/translate", summary="AI 实验室 翻译(自助,同步)") @idempotent( "ai_labs.translate", key_params=("user.id",), # Same reasoning as the generation submit: the response carries the # translated text, which is the employee's content. redact_cached=("data.text",), ) async def translate( body: TranslateBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), _rl: None = Depends(check_ai_labs_rate_limit), ) -> dict[str, Any]: config = await _require_available(db) data = await AiLabsService(db).translate( person_id=user.id, text=body.text, target_language=body.target_language, config=config, trace_id=_trace(request), ) return success_response(data=data, request=request) # ── admin: configuration and counters ────────────────────────── @admin_router.get("/settings", summary="AI 实验室 配置(管理端,只读)") async def get_admin_settings( request: Request, _user: AuthUser = Depends(require_admin_read()), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: """Configuration, plus whether the surface can actually reach a model. ``enabled`` and ``available`` are reported separately on purpose: "on but no key" is a real and confusing state, and collapsing the two into one boolean is how it stays confusing. """ config = await ai_settings.resolve_config(db) return success_response( data={ "enabled": config.enabled, "available": config.available, # Never the key itself — only whether one is present, which is # the only fact this page needs. "connector_configured": bool(config.api_key), "region": config.region, "reporting_timezone": config.reporting_timezone, "retention_days": config.retention_days, "quotas": { "daily_tokens": config.quotas.daily_tokens, "daily_images": config.quotas.daily_images, "daily_video_seconds": config.quotas.daily_video_seconds, "daily_speech": config.quotas.daily_speech, "daily_transcribe": config.quotas.daily_transcribe, "any_enforced": config.quotas.any_enforced, }, "model_overrides": config.model_overrides, "catalog_version": catalog.CATALOG_VERSION, "catalog_models": { "chat": [ {"key": m.key, "vendor_model": m.vendor_model} for m in catalog.CHAT_MODELS ], "capabilities": [ {"key": c.key, "vendor_model": c.vendor_model} for c in catalog.GENERATION_CAPABILITIES ], }, }, request=request, ) @admin_router.put("/settings", summary="AI 实验室 配置(管理端,写)") async def put_admin_settings( body: AdminSettingsBody, request: Request, user: AuthUser = Depends(require_admin()), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: """Write only the fields that were sent. Goes through :class:`PlatformSettingService` rather than a bare ``db.merge`` so each change lands in the audit log with its actor — turning the feature on for 500 people, or capping it, is exactly the kind of act that should be attributable months later. """ from app.integrations.service import PlatformSettingService svc = PlatformSettingService(db) trace_id = _trace(request) written: list[str] = [] async def _write(key: str, value: Any, description: str) -> None: await svc.upsert( key, value, description, trace_id=trace_id, actor_user_id=user.id, ) written.append(key) if body.enabled is not None: await _write( ai_settings.SETTING_ENABLED, bool(body.enabled), "AI Labs employee surface on/off", ) for field_name, key, description in ( ("daily_tokens", ai_settings.SETTING_QUOTA_TOKENS, "AI Labs per-person daily token ceiling (0 = unlimited)"), ("daily_images", ai_settings.SETTING_QUOTA_IMAGES, "AI Labs per-person daily image ceiling (0 = unlimited)"), ("daily_video_seconds", ai_settings.SETTING_QUOTA_VIDEO_SECONDS, "AI Labs per-person daily video-second ceiling (0 = unlimited)"), ("daily_speech", ai_settings.SETTING_QUOTA_SPEECH, "AI Labs per-person daily speech ceiling (0 = unlimited)"), ("daily_transcribe", ai_settings.SETTING_QUOTA_TRANSCRIBE, "AI Labs per-person daily transcription ceiling (0 = unlimited)"), ("retention_days", ai_settings.SETTING_RETENTION_DAYS, "AI Labs history retention in days (0 = keep until deleted)"), ): value = getattr(body, field_name) if value is not None: await _write(key, int(value), description) if body.reporting_timezone is not None: # Validated here rather than trusted: an unknown zone would send # every quota window silently to UTC. from zoneinfo import ZoneInfo, ZoneInfoNotFoundError candidate = body.reporting_timezone.strip() try: ZoneInfo(candidate) except (ZoneInfoNotFoundError, ValueError, KeyError) as exc: raise AppException( code="AI_LABS_TIMEZONE_INVALID", message=f"'{candidate}' is not a known timezone name.", status_code=422, field="reporting_timezone", ) from exc await _write( ai_settings.SETTING_REPORTING_TIMEZONE, candidate, "AI Labs quota/reporting timezone", ) if body.model_overrides is not None: known = ( set(catalog.CHAT_MODELS_BY_KEY) | set(catalog.CAPABILITIES_BY_KEY) | {"translate"} ) unknown = sorted(set(body.model_overrides) - known) if unknown: raise AppException( code="AI_LABS_OVERRIDE_KEY_UNKNOWN", message=( "These override keys are not in the catalogue: " f"{', '.join(unknown)}." ), status_code=422, field="model_overrides", ) await _write( ai_settings.SETTING_MODEL_OVERRIDES, {k: v.strip() for k, v in body.model_overrides.items() if v.strip()}, "AI Labs per-capability vendor model overrides", ) await db.commit() config = await ai_settings.resolve_config(db) return success_response( data={"written": written, "enabled": config.enabled, "available": config.available}, request=request, ) @admin_router.get("/usage", summary="AI 实验室 用量看板(管理端,只读聚合)") async def admin_usage( request: Request, _user: AuthUser = Depends(require_admin_read()), db: AsyncSession = Depends(get_db), days: int = Query(default=30, ge=1, le=365), ) -> dict[str, Any]: """Aggregated counters only. Reads ``ai_labs_usage_daily`` and the generation queue's status counts. It cannot return a prompt or an artifact because it never queries a table that holds one — the guarantee is structural, not a filter. """ data = await AiLabsService(db).usage_overview(days=days) return success_response(data=data, request=request) @admin_router.get( "/usage/people", summary="AI 实验室 用量按人(管理端,只读聚合)", ) async def admin_usage_people( request: Request, _user: AuthUser = Depends(require_admin_read()), db: AsyncSession = Depends(get_db), days: int = Query(default=30, ge=1, le=365), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), sort: str = Query(default="requests"), q: str | None = Query(default=None, max_length=128), ) -> dict[str, Any]: """Who used AI Labs, and how much. ``ai_labs_usage_daily`` always carried ``person_id``; the console simply never grouped by it, so "8 requests" could not be turned into "these two people". This adds the GROUP BY and a directory label — and nothing else: the counters are the same rows, and there is still no query here that touches a table with a prompt in it. An unrecognised ``sort`` falls back to ``requests`` rather than erroring; the value is looked up in a fixed map, never interpolated. """ result = await AiLabsService(db).usage_by_person( days=days, page=page, page_size=page_size, sort=sort, search=q, ) # A list endpoint, so the rows go in ``data`` like every other paged # surface. The window is carried in ``meta`` beside the page counts # instead of wrapping the rows in an object, which would break the # frontend's shared ``Paged`` shape for this one table. return success_response( data=result["items"], meta={ **result["meta"], "since": result["since"], "through": result["through"], "timezone": result["timezone"], "window_days": result["window_days"], "sort": result["sort"], }, request=request, ) @admin_router.get( "/usage/people/{person_id}", summary="AI 实验室 单人用量(管理端,只读聚合)", ) async def admin_usage_person( person_id: uuid.UUID, request: Request, user: AuthUser = Depends(require_admin_read()), db: AsyncSession = Depends(get_db), days: int = Query(default=30, ge=1, le=365), ) -> dict[str, Any]: """One named person's counters, by day / capability / model / reason. Audited, unlike the aggregate list above. Looking up how much AI Labs one *named* employee used is a legitimate operational act and also exactly the act that should leave a trace with the actor's name on it — the domain's whole proposition is that employees can trust this surface, and "who looked me up" is part of that. The aggregate list is not audited: it is a population statistic, and logging a row per page view would bury the lookups that matter. """ service = AiLabsService(db) data = await service.person_usage_detail(person_id=person_id, days=days) await service.audit.log( trace_id=_trace(request), actor_type="user", actor_user_id=user.id, action="ai_labs.admin.view_person_usage", status="success", resource_type="ai_labs_person_usage", resource_id=str(person_id), # Counts and a window. Nothing about content, because there is # nothing about content to record. detail={ "days": days, "requests": data["totals"]["requests"], "failures": data["totals"]["failures"], }, ) await db.commit() return success_response(data=data, request=request) @admin_router.get( "/usage/failures", summary="AI 实验室 失败原因(管理端,只读聚合,无内容)", ) async def admin_usage_failures( request: Request, _user: AuthUser = Depends(require_admin_read()), db: AsyncSession = Depends(get_db), days: int = Query(default=30, ge=1, le=365), ) -> dict[str, Any]: """What is failing: by error code, by capability, by model, by person. Reads ``ai_labs_failures_daily``, whose only non-numeric column is a normalised error code — the error *prose* is deliberately not stored anywhere an admin can read, because the vendor's prose can quote the employee's prompt back (see ``usage.normalize_error_code``). Whoever needs the full vendor text reads the application log. ``counted_failures`` vs ``classified_failures`` is not redundancy: the first comes from the authoritative counter that predates this table, and the gap between them is reported as ``unclassified_failures`` rather than hidden. """ data = await AiLabsService(db).failure_overview(days=days) return success_response(data=data, request=request) @admin_router.get( "/generations/health", summary="AI 实验室 任务队列健康(管理端,只读,无内容)", ) async def admin_queue_health( request: Request, _user: AuthUser = Depends(require_admin_read()), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: """Stuck-job visibility without reading any prompt. Returns per-status counts and the age of the oldest unfinished job. That is enough to tell "the queue is draining" from "the reaper has been failing since the deploy", which is the only operational question this surface raises. """ from sqlalchemy import func, select from app.domains.ai_labs.models import AiLabGeneration rows = ( await db.execute( select( AiLabGeneration.status, func.count(), func.min(AiLabGeneration.created_at), ).group_by(AiLabGeneration.status), ) ).all() by_status = { str(row[0]): { "count": int(row[1] or 0), "oldest_created_at": ( row[2].isoformat() if row[2] is not None else None ), } for row in rows } unfinished = sum( int(entry["count"] or 0) for status, entry in by_status.items() if status not in GEN_TERMINAL_STATUSES ) return success_response( data={"by_status": by_status, "unfinished": unfinished}, request=request, ) __all__ = ["admin_router", "router"]