feat(api): vertical slice POST /v1/agent/chat and the Celery ping path

One request exercises every layer: JWT auth, quota reservation, a versioned
prompt through the LLM gateway, a cost row, quota commit or refund, and the
unified envelope. The trivial ping task proves the queue routing and the
tk_task transitions before any real long-running task is written.

Routes are mounted at the /api/v1 base path of 架构文档 §9.1; /v1/* is
rewritten to it at the ASGI layer so both spellings work from one route table.
This commit is contained in:
xsl
2026-08-14 00:26:39 +08:00
parent 71204affab
commit 746b66da01
16 changed files with 822 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""HTTP 路由。只做编排,业务规则下沉到 packages/。"""
+64
View File
@@ -0,0 +1,64 @@
"""``POST /agent/chat`` — the vertical slice.
One request exercises: JWT auth → quota reserve → LLM gateway (routing, prompt
version, cache-prefix check, daily budget) → ``ob_ai_call`` cost row → quota
commit/refund → unified envelope. Streaming (SSE, FR-AGT-01) is intentionally
out of scope for the scaffold.
"""
from __future__ import annotations
import uuid
from fastapi import APIRouter
from apps.api.deps import AgentServiceDep, CurrentUserId
from apps.api.schemas import ChatRequest, ChatResponse, ChatUsage
from packages.llm.errors import (
AllProvidersFailedError,
CachePrefixViolationError,
DailyBudgetExceededError,
StructuredOutputError,
)
from packages.shared.errors import AppError, ErrorCode
from packages.shared.response import ok
router = APIRouter(prefix="/agent", tags=["agent"])
@router.post("/chat", summary="Single-turn styling chat (non-streaming)")
async def chat(
payload: ChatRequest,
user_id: CurrentUserId,
agent: AgentServiceDep,
) -> dict[str, object]:
request_key = payload.request_key or uuid.uuid4().hex
try:
result = await agent.chat(user_id=user_id, message=payload.message, request_key=request_key)
except DailyBudgetExceededError as exc:
raise AppError(ErrorCode.LLM_DAILY_BUDGET_EXCEEDED) from exc
except StructuredOutputError as exc:
raise AppError(ErrorCode.LLM_STRUCTURED_OUTPUT_FAILED) from exc
except AllProvidersFailedError as exc:
raise AppError(ErrorCode.LLM_ALL_PROVIDERS_FAILED) from exc
except CachePrefixViolationError as exc:
raise AppError(ErrorCode.LLM_PROMPT_CACHE_VIOLATION) from exc
return ok(
ChatResponse(
reply=result.reply,
provider=result.provider,
model=result.model,
prompt_id=result.prompt_id,
prompt_version=result.prompt_version,
call_id=str(result.call_id),
usage=ChatUsage(
tokens_in=result.tokens_in,
tokens_out=result.tokens_out,
tokens_cached=result.tokens_cached,
latency_ms=result.latency_ms,
cost_cents=str(result.cost_cents),
),
quota_remaining=result.quota_remaining,
).model_dump()
)
+50
View File
@@ -0,0 +1,50 @@
"""Development-only token issuance.
**This is not the V1 auth design.** FR-ACC-01/04/05 require 华为账号登录、手机号
验证码、双令牌与服务端可撤销的 refresh token. This endpoint exists so the
vertical slice can be exercised end to end, and it refuses to run unless
``DEV_AUTH_ENABLED`` is true and the environment is local/dev.
"""
from __future__ import annotations
from fastapi import APIRouter
from apps.api.deps import IdentityServiceDep, SettingsDep
from apps.api.schemas import DevTokenRequest, DevTokenResponse
from packages.identity.service import DEV_PROVIDER
from packages.shared.errors import AppError, ErrorCode
from packages.shared.response import ok
from packages.shared.security import create_access_token
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/dev-token", summary="[dev only] Issue an access token for testing")
async def dev_token(
payload: DevTokenRequest,
settings: SettingsDep,
identity: IdentityServiceDep,
) -> dict[str, object]:
if not (settings.dev_auth_enabled and settings.is_local):
raise AppError(ErrorCode.NOT_FOUND)
user = await identity.get_or_create_by_external_identity(
provider=DEV_PROVIDER,
union_id=payload.device_key,
register_source="dev",
)
token = create_access_token(
user_id=user.user_id,
secret=settings.jwt_secret,
algorithm=settings.jwt_algorithm,
ttl_seconds=settings.jwt_access_ttl_seconds,
issuer=settings.jwt_issuer,
)
return ok(
DevTokenResponse(
access_token=token,
expires_in=settings.jwt_access_ttl_seconds,
user_id=str(user.user_id),
).model_dump()
)
+59
View File
@@ -0,0 +1,59 @@
"""Liveness, readiness and metrics. Mounted at the root, outside ``/api/v1``.
``/healthz`` must never touch a dependency: if it did, a slow database would
make the orchestrator kill healthy processes. ``/readyz`` is the one that checks
dependencies, because a process that cannot reach PostgreSQL should be taken out
of the load-balancer rotation but not restarted.
"""
from __future__ import annotations
from fastapi import APIRouter, Response
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
from sqlalchemy import text
from packages.shared.config import get_settings
from packages.shared.db import get_session_factory
from packages.shared.errors import AppError, ErrorCode
from packages.shared.logging import get_logger
from packages.shared.observability import REGISTRY
from packages.shared.redis_client import get_redis
from packages.shared.response import ok
router = APIRouter(tags=["ops"])
logger = get_logger(__name__)
@router.get("/healthz", summary="Liveness probe")
async def healthz() -> dict[str, object]:
settings = get_settings()
return ok({"status": "ok", "app": settings.app_name, "env": settings.app_env})
@router.get("/readyz", summary="Readiness probe (checks PostgreSQL and Redis)")
async def readyz() -> dict[str, object]:
checks: dict[str, str] = {}
try:
factory = get_session_factory()
async with factory() as session:
await session.execute(text("SELECT 1"))
checks["postgres"] = "ok"
except Exception as exc:
checks["postgres"] = f"error: {type(exc).__name__}"
try:
await get_redis().ping()
checks["redis"] = "ok"
except Exception as exc:
checks["redis"] = f"error: {type(exc).__name__}"
if any(v != "ok" for v in checks.values()):
logger.warning("readiness_failed", checks=checks)
raise AppError(ErrorCode.UPSTREAM_ERROR, message="依赖未就绪", data={"checks": checks})
return ok({"status": "ready", "checks": checks})
@router.get("/metrics", summary="Prometheus scrape endpoint", include_in_schema=False)
async def metrics() -> Response:
return Response(content=generate_latest(REGISTRY), media_type=CONTENT_TYPE_LATEST)
+61
View File
@@ -0,0 +1,61 @@
"""``GET /quota`` and ``GET /quota/ledger`` (架构文档 §9.3 配额).
FR-BILL-07: 额度相关文案中**不得出现任何价格、购买、会员、升级字样**。
Only two recovery paths may ever be surfaced: 等待恢复 and 做任务换额度.
"""
from __future__ import annotations
from fastapi import APIRouter, Query
from apps.api.deps import BillingServiceDep, CurrentUserId
from apps.api.schemas import LedgerEntry, QuotaResponse
from packages.agent.service import QUOTA_KEY_AGENT_CHAT
from packages.shared.response import ok
router = APIRouter(prefix="/quota", tags=["quota"])
@router.get("", summary="Current period quota for a feature")
async def get_quota(
user_id: CurrentUserId,
billing: BillingServiceDep,
quota_key: str = Query(default=QUOTA_KEY_AGENT_CHAT, max_length=64),
) -> dict[str, object]:
snapshot = await billing.get_quota(user_id=user_id, quota_key=quota_key)
return ok(
QuotaResponse(
quota_key=snapshot.quota_key,
period_key=snapshot.period_key,
used=snapshot.used,
limit_value=snapshot.limit_value,
bonus_value=snapshot.bonus_value,
remaining=snapshot.remaining,
reset_at=snapshot.reset_at,
).model_dump()
)
@router.get("/ledger", summary="Quota ledger — 流水是真相,余额是缓存")
async def get_ledger(
user_id: CurrentUserId,
billing: BillingServiceDep,
limit: int = Query(default=50, ge=1, le=200),
) -> dict[str, object]:
rows = await billing.list_ledger(user_id=user_id, limit=limit)
return ok(
[
LedgerEntry(
tx_id=str(row.tx_id),
delta=row.delta,
tx_type=row.tx_type,
status=row.status,
reason=row.reason,
quota_key=row.quota_key,
period_key=row.period_key,
balance_after=row.balance_after,
created_at=row.created_at,
).model_dump()
for row in rows
]
)
+80
View File
@@ -0,0 +1,80 @@
"""任务中心 endpoints + a trivial async task that proves the Celery path.
The ``ping`` task carries no business value; it exists so that the ``tk_task``
state machine, the queue routing and the worker deployment are all exercised
before any real long-running task is written (M1 出口条件).
"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Query
from apps.api.deps import CurrentUserId, TaskServiceDep
from apps.api.schemas import TaskResponse, TaskSubmitRequest
from packages.domain.task.state import TaskStatus, TaskType
from packages.shared.models import Task
from packages.shared.response import ok
router = APIRouter(prefix="/tasks", tags=["tasks"])
def _serialize(task: Task) -> dict[str, object]:
return TaskResponse(
task_id=str(task.task_id),
task_type=task.task_type,
status=task.status,
progress=task.progress,
stage_text=task.stage_text,
result=task.result,
error_code=task.error_code,
error_message=task.error_message,
retry_count=task.retry_count,
submitted_at=task.submitted_at,
started_at=task.started_at,
finished_at=task.finished_at,
).model_dump()
@router.post("/ping", summary="Submit the scaffold's trivial async task")
async def submit_ping(
payload: TaskSubmitRequest,
user_id: CurrentUserId,
tasks: TaskServiceDep,
) -> dict[str, object]:
from apps.worker.tasks import ping_task
idempotency_key = payload.idempotency_key or f"ping:{user_id}:{uuid.uuid4().hex}"
task = await tasks.submit(
user_id=user_id,
task_type=TaskType.PING,
idempotency_key=idempotency_key,
payload={"echo": payload.echo},
eta_seconds=2,
)
if task.status == TaskStatus.PENDING.value:
task = await tasks.transition(
task_id=task.task_id, target=TaskStatus.QUEUED, stage_text="排队中"
)
ping_task.apply_async(args=[task.task_id], queue="cpu")
return ok(_serialize(task))
@router.get("/{task_id}", summary="Task status (ownership checked)")
async def get_task(
task_id: int,
user_id: CurrentUserId,
tasks: TaskServiceDep,
) -> dict[str, object]:
return ok(_serialize(await tasks.get(task_id=task_id, user_id=user_id)))
@router.get("", summary="Task centre list")
async def list_tasks(
user_id: CurrentUserId,
tasks: TaskServiceDep,
limit: int = Query(default=20, ge=1, le=100),
) -> dict[str, object]:
rows = await tasks.list_for_user(user_id=user_id, limit=limit)
return ok([_serialize(row) for row in rows])