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.
159 lines
5.7 KiB
Python
Executable File
159 lines
5.7 KiB
Python
Executable File
"""Typed application configuration.
|
|
|
|
Every value comes from the environment (架构文档 §11.2: 密钥不进代码/镜像).
|
|
`.env` is only a developer convenience; dev/staging/prod inject via SSM/KMS.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from decimal import Decimal
|
|
from functools import lru_cache
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import Field, ValidationInfo, field_validator
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
Environment = Literal["local", "dev", "staging", "prod"]
|
|
CachePrefixMode = Literal["strict", "warn", "off"]
|
|
|
|
_PLACEHOLDER_JWT_SECRET = "change-me-local-only-do-not-use-outside-local"
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
"""Root settings object. Read it through :func:`get_settings`."""
|
|
|
|
model_config = SettingsConfigDict(
|
|
env_file=".env",
|
|
env_file_encoding="utf-8",
|
|
extra="ignore",
|
|
case_sensitive=False,
|
|
)
|
|
|
|
# --- Application -------------------------------------------------------
|
|
app_env: Environment = "local"
|
|
app_name: str = "meida-backend"
|
|
api_prefix: str = "/api/v1"
|
|
debug: bool = False
|
|
log_level: str = "INFO"
|
|
log_json: bool = True
|
|
log_extra_redact_keys: str = ""
|
|
|
|
# --- Database ----------------------------------------------------------
|
|
database_url: str = "postgresql+asyncpg://meida:meida@localhost:55432/meida"
|
|
db_pool_size: int = 10
|
|
db_max_overflow: int = 20
|
|
db_echo: bool = False
|
|
|
|
# --- Redis / Celery ----------------------------------------------------
|
|
redis_url: str = "redis://localhost:56379/0"
|
|
celery_broker_url: str = "redis://localhost:56379/1"
|
|
celery_result_backend: str = "redis://localhost:56379/2"
|
|
|
|
# --- Auth --------------------------------------------------------------
|
|
jwt_secret: str = _PLACEHOLDER_JWT_SECRET
|
|
jwt_algorithm: str = "HS256"
|
|
jwt_access_ttl_seconds: int = 900
|
|
jwt_issuer: str = "meida"
|
|
dev_auth_enabled: bool = True
|
|
|
|
# --- LLM routing -------------------------------------------------------
|
|
llm_primary_provider: str = "deepseek"
|
|
llm_backup_provider: str = "zhipu"
|
|
llm_structured_max_retries: int = 2
|
|
llm_circuit_failure_threshold: int = 3
|
|
llm_circuit_cooldown_seconds: float = 30.0
|
|
llm_scene_overrides: dict[str, dict[str, Any]] = Field(
|
|
default_factory=dict, alias="LLM_SCENE_OVERRIDES_JSON"
|
|
)
|
|
|
|
# --- DeepSeek (primary, OpenAI-compatible) -----------------------------
|
|
deepseek_api_key: str = ""
|
|
deepseek_base_url: str = "https://api.deepseek.com/v1"
|
|
deepseek_model: str = "deepseek-v4-flash"
|
|
deepseek_timeout_seconds: float = 30.0
|
|
|
|
# --- 智谱 GLM (backup slot, not implemented in V1) ---------------------
|
|
zhipu_api_key: str = ""
|
|
zhipu_base_url: str = "https://open.bigmodel.cn/api/paas/v4"
|
|
zhipu_model: str = "glm-4-flash"
|
|
zhipu_timeout_seconds: float = 30.0
|
|
|
|
# --- Prompt caching discipline (架构文档 §7.2) -------------------------
|
|
llm_cache_prefix_mode: CachePrefixMode = "strict"
|
|
prompts_dir: str = "prompts"
|
|
|
|
# --- Cost attribution (架构文档 §13) -----------------------------------
|
|
llm_cost_rates: dict[str, dict[str, Decimal]] = Field(
|
|
default_factory=dict, alias="LLM_COST_RATES_JSON"
|
|
)
|
|
# DeepSeek 自 2026-08-17 起峰谷定价,高峰单价是空闲时段的 2 倍
|
|
# (高峰:北京时间 09:00-12:00、14:00-18:00)。留空则两个时段同价。
|
|
llm_cost_rates_peak: dict[str, dict[str, Decimal]] = Field(
|
|
default_factory=dict, alias="LLM_COST_RATES_PEAK_JSON"
|
|
)
|
|
llm_peak_pricing_enabled: bool = True
|
|
llm_default_cost_rate: dict[str, Decimal] = Field(
|
|
default_factory=lambda: {
|
|
"input_cents_per_mtok": Decimal("100"),
|
|
"cached_input_cents_per_mtok": Decimal("20"),
|
|
"output_cents_per_mtok": Decimal("200"),
|
|
},
|
|
alias="LLM_DEFAULT_COST_RATE_JSON",
|
|
)
|
|
|
|
# --- Global daily cost circuit breaker ---------------------------------
|
|
llm_daily_budget_enabled: bool = True
|
|
llm_daily_budget_cents: Decimal = Decimal("100000")
|
|
llm_budget_timezone_offset_hours: int = 8
|
|
|
|
# --- Observability -----------------------------------------------------
|
|
otel_enabled: bool = False
|
|
otel_service_name: str = "meida-api"
|
|
otel_exporter_otlp_endpoint: str = ""
|
|
prometheus_enabled: bool = True
|
|
|
|
@field_validator(
|
|
"llm_scene_overrides",
|
|
"llm_cost_rates",
|
|
"llm_cost_rates_peak",
|
|
"llm_default_cost_rate",
|
|
mode="before",
|
|
)
|
|
@classmethod
|
|
def _parse_json_object(cls, value: Any) -> Any:
|
|
if isinstance(value, str):
|
|
stripped = value.strip()
|
|
if not stripped:
|
|
return {}
|
|
return json.loads(stripped)
|
|
return value
|
|
|
|
@field_validator("jwt_secret")
|
|
@classmethod
|
|
def _reject_placeholder_secret_outside_local(cls, value: str, info: ValidationInfo) -> str:
|
|
env = info.data.get("app_env", "local")
|
|
if env in ("staging", "prod") and (value == _PLACEHOLDER_JWT_SECRET or len(value) < 32):
|
|
msg = "JWT_SECRET must be set to a >=32 char random value outside local/dev"
|
|
raise ValueError(msg)
|
|
return value
|
|
|
|
@property
|
|
def is_local(self) -> bool:
|
|
return self.app_env in ("local", "dev")
|
|
|
|
@property
|
|
def extra_redact_keys(self) -> tuple[str, ...]:
|
|
return tuple(k.strip() for k in self.log_extra_redact_keys.split(",") if k.strip())
|
|
|
|
@property
|
|
def sync_database_url(self) -> str:
|
|
"""psycopg-style URL, used by tooling that cannot speak asyncpg."""
|
|
return self.database_url.replace("+asyncpg", "")
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def get_settings() -> Settings:
|
|
"""Process-wide settings singleton."""
|
|
return Settings()
|