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:
Executable
+172
@@ -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)]
|
||||
Reference in New Issue
Block a user