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:
@@ -0,0 +1 @@
|
||||
"""HTTP 路由。只做编排,业务规则下沉到 packages/。"""
|
||||
Executable
+64
@@ -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()
|
||||
)
|
||||
Executable
+50
@@ -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()
|
||||
)
|
||||
Executable
+59
@@ -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)
|
||||
Executable
+61
@@ -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
|
||||
]
|
||||
)
|
||||
Executable
+80
@@ -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])
|
||||
Reference in New Issue
Block a user