Files
meida_backend/packages/shared/config.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

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()