Files
meida_backend/apps/api/deps.py
T
xsl f46ecc5eb0 fix(llm): price by call time and stop paying for thinking tokens
Two defects that only surface against the real DeepSeek API, both of which
break the cost model rather than the code:

deepseek-v4-flash enables thinking mode by default. The same single-turn
question took 13.5s with it on and 1.8s with it off, and the thinking tokens
are billed at the output rate. That misses NFR-PERF-02b (15s per turn) and
inflates every conversational call. SceneConfig now carries an explicit
`thinking` flag, all four V1 scenes set it to false, and the provider always
sends the parameter rather than relying on the upstream default. Reasoning
tokens are recorded separately in Usage.tokens_reasoning so the cost of ever
turning it back on is visible.

DeepSeek moves to peak/off-peak pricing on 2026-08-17, with peak (Beijing
09:00-12:00 and 14:00-18:00) at twice the off-peak rate. Reports are generated
mostly during peak hours, so a single blended rate would leave ob_ai_call --
the only input to V2 pricing -- off by nearly a factor of two.
PeakAwareRateTable resolves the rate at call time; cost is computed and stored
then, never recomputed later at whatever the current rate happens to be.

Also here:
- Replace the live API key in the redaction test with a synthetic one. The
  test that proves we redact secrets was itself a secret leak waiting for its
  first commit.
- Correct LLM_COST_RATES_JSON, which held placeholder rates.
- Make ids.SnowflakeGenerator take an injectable clock so rollover and
  clock-rollback are testable without patching a slots class.
- Rename exceptions to the -Error suffix (N818) and clear the remaining ruff,
  mypy and import-linter findings, including two dead lines: a duplicate
  pytestmark assignment and a no-op assert on a None return.

Gates: ruff, mypy (96 files incl. tests), 4/4 import contracts,
detect-secrets, 166 unit + 3 integration tests, migration up/down round-trip.
2026-08-14 00:54:40 +08:00

195 lines
6.6 KiB
Python
Executable File

"""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,
PeakAwareRateTable,
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,
)
)
default_rate = ModelRate.from_mapping(settings.llm_default_cost_rate)
rates = PeakAwareRateTable(
RateTable(
{
model: ModelRate.from_mapping(rate)
for model, rate in settings.llm_cost_rates.items()
},
default=default_rate,
),
RateTable(
{
model: ModelRate.from_mapping(rate)
for model, rate in settings.llm_cost_rates_peak.items()
},
default=default_rate,
)
if settings.llm_cost_rates_peak
else None,
enabled=settings.llm_peak_pricing_enabled,
)
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:
# app.state 是无类型的属性袋,取出来必须显式收窄
gateway: LLMGateway | None = 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)]