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
+172
View File
@@ -0,0 +1,172 @@
"""Composition root + FastAPI dependencies.
This is the only place that wires concrete adapters into the domain packages.
Everything below it takes its collaborators as constructor arguments, which is
what keeps ``packages/domain`` and ``packages/llm`` unit-testable without IO.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from decimal import Decimal
from typing import Annotated
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from packages.agent.service import AgentService
from packages.billing.service import BillingService
from packages.identity.service import IdentityService
from packages.llm.budget import DailyBudgetGuard, RedisCostCounter
from packages.llm.circuit import CircuitBreakerRegistry
from packages.llm.cost import CompositeAICallRecorder, ModelRate, RateTable
from packages.llm.gateway import LLMGateway
from packages.llm.prompts import PromptRegistry
from packages.llm.providers.base import LLMProvider
from packages.llm.providers.deepseek import DeepSeekProvider
from packages.llm.providers.zhipu import ZhipuGLMProvider
from packages.llm.recording import PrometheusAICallRecorder, SqlAlchemyAICallRecorder
from packages.llm.routing import SceneRouter, build_scene_configs
from packages.shared.config import Settings, get_settings
from packages.shared.context import bind_context
from packages.shared.db import get_session_factory
from packages.shared.errors import AppError, ErrorCode
from packages.shared.redis_client import get_redis
from packages.shared.security import decode_access_token
from packages.task.service import TaskService
bearer_scheme = HTTPBearer(auto_error=False)
def build_llm_gateway(settings: Settings) -> LLMGateway:
"""Assemble the gateway from configuration (架构文档 §8.1)."""
providers: dict[str, LLMProvider] = {
"deepseek": DeepSeekProvider(
api_key=settings.deepseek_api_key,
base_url=settings.deepseek_base_url,
default_model=settings.deepseek_model,
),
"zhipu": ZhipuGLMProvider(
api_key=settings.zhipu_api_key,
base_url=settings.zhipu_base_url,
default_model=settings.zhipu_model,
),
}
router = SceneRouter(
build_scene_configs(
primary_provider=settings.llm_primary_provider,
backup_provider=settings.llm_backup_provider,
structured_max_retries=settings.llm_structured_max_retries,
overrides=settings.llm_scene_overrides,
)
)
rates = RateTable(
{model: ModelRate.from_mapping(rate) for model, rate in settings.llm_cost_rates.items()},
default=ModelRate.from_mapping(settings.llm_default_cost_rate),
)
recorder = CompositeAICallRecorder(
SqlAlchemyAICallRecorder(get_session_factory()),
PrometheusAICallRecorder(),
)
budget = DailyBudgetGuard(
RedisCostCounter(get_redis()),
daily_budget_cents=Decimal(settings.llm_daily_budget_cents),
enabled=settings.llm_daily_budget_enabled,
tz_offset_hours=settings.llm_budget_timezone_offset_hours,
)
return LLMGateway(
providers=providers,
router=router,
prompts=PromptRegistry(
settings.prompts_dir, cache_prefix_mode=settings.llm_cache_prefix_mode
),
rates=rates,
recorder=recorder,
budget=budget,
breakers=CircuitBreakerRegistry(
failure_threshold=settings.llm_circuit_failure_threshold,
cooldown_seconds=settings.llm_circuit_cooldown_seconds,
),
)
# --- request-scoped dependencies -------------------------------------------
def settings_dep() -> Settings:
return get_settings()
async def session_dep() -> AsyncIterator[AsyncSession]:
factory = get_session_factory()
async with factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
def gateway_dep(request: Request) -> LLMGateway:
gateway = getattr(request.app.state, "llm_gateway", None)
if gateway is None: # pragma: no cover - startup guarantees this
raise AppError(ErrorCode.CONFIG_ERROR, message="LLM 网关未初始化")
return gateway
SettingsDep = Annotated[Settings, Depends(settings_dep)]
SessionDep = Annotated[AsyncSession, Depends(session_dep)]
GatewayDep = Annotated[LLMGateway, Depends(gateway_dep)]
async def current_user_id(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)],
settings: SettingsDep,
) -> int:
"""架构文档 §9.1: ``Authorization: Bearer <access_token>``."""
if credentials is None or not credentials.credentials:
raise AppError(ErrorCode.UNAUTHENTICATED)
claims = decode_access_token(
credentials.credentials,
secret=settings.jwt_secret,
algorithm=settings.jwt_algorithm,
issuer=settings.jwt_issuer,
)
bind_context(user_id=claims.user_id)
return claims.user_id
CurrentUserId = Annotated[int, Depends(current_user_id)]
def identity_service_dep(session: SessionDep) -> IdentityService:
return IdentityService(session)
def billing_service_dep(session: SessionDep, settings: SettingsDep) -> BillingService:
return BillingService(session, tz_offset_hours=settings.llm_budget_timezone_offset_hours)
def task_service_dep(session: SessionDep) -> TaskService:
return TaskService(session)
def agent_service_dep(
session: SessionDep,
gateway: GatewayDep,
billing: Annotated[BillingService, Depends(billing_service_dep)],
) -> AgentService:
return AgentService(session, gateway=gateway, billing=billing)
IdentityServiceDep = Annotated[IdentityService, Depends(identity_service_dep)]
BillingServiceDep = Annotated[BillingService, Depends(billing_service_dep)]
TaskServiceDep = Annotated[TaskService, Depends(task_service_dep)]
AgentServiceDep = Annotated[AgentService, Depends(agent_service_dep)]