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.
This commit is contained in:
xsl
2026-08-14 00:54:40 +08:00
parent 8a4720472b
commit f46ecc5eb0
34 changed files with 1793 additions and 988 deletions
+96 -218
View File
@@ -1,169 +1,124 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "meida-backend"
version = "0.1.0"
description = "美搭 (Meida) backend — AI fashion & styling assistant for HarmonyOS"
readme = "README.md"
requires-python = ">=3.12,<3.13"
license = { text = "Proprietary" }
description = "美搭 · 智能穿搭美学助手服务端"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"gunicorn>=22.0",
"sqlalchemy[asyncio]>=2.0",
"asyncpg>=0.29",
"alembic>=1.13",
"pydantic>=2.7",
"pydantic-settings>=2.3",
"celery[redis]>=5.4",
"redis>=5.0",
"httpx>=0.27",
"structlog>=24.1",
"opentelemetry-api>=1.25",
"opentelemetry-sdk>=1.25",
"opentelemetry-instrumentation-fastapi>=0.46b0",
"opentelemetry-exporter-otlp-proto-http>=1.25",
"prometheus-client>=0.20",
"pgvector>=0.2.5",
"tenacity>=8.3",
"pyjwt[crypto]>=2.8",
"passlib[bcrypt]>=1.7.4",
"uvicorn[standard]>=0.32",
"pydantic>=2.9",
"pydantic-settings>=2.6",
"sqlalchemy[asyncio]>=2.0.36",
"asyncpg>=0.30",
"alembic>=1.14",
"pgvector>=0.3.6",
"redis>=5.2",
"celery>=5.4",
"httpx>=0.28",
"tenacity>=9.0",
"pyjwt[crypto]>=2.9",
"structlog>=24.4",
"orjson>=3.10",
"prometheus-client>=0.21",
]
[project.optional-dependencies]
dev = [
"ruff>=0.6",
"mypy>=1.11",
"pytest>=8.3",
"pytest-asyncio>=0.24",
"pytest-cov>=5.0",
"import-linter>=2.0",
"pre-commit>=3.8",
"pytest-cov>=6.0",
"respx>=0.22",
"ruff>=0.8",
"mypy>=1.13",
"import-linter>=2.1",
"detect-secrets>=1.5",
"testcontainers[postgres,redis]>=4.8",
"types-passlib>=1.7",
"pre-commit>=4.0",
]
[tool.hatch.build.targets.wheel]
packages = ["apps", "packages", "services"]
[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"
# ---------------------------------------------------------------------------
# ruff — lint + format (架构文档 §16.2 静态检查防线)
# ---------------------------------------------------------------------------
[tool.setuptools.packages.find]
include = ["apps*", "packages*", "services*"]
# ── ruff ────────────────────────────────────────────────────────────────────
[tool.ruff]
target-version = "py312"
line-length = 100
src = ["."]
target-version = "py312"
extend-exclude = ["migrations/versions"]
[tool.ruff.lint]
select = [
"E", "W", # pycodestyle
"F", # pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"B", # bugbear
"C4", # comprehensions
"SIM", # simplify
"ASYNC", # async correctness
"S", # bandit (security)
"T20", # no print
"RUF",
]
ignore = [
"S101", # assert is fine (pytest)
"B008", # FastAPI Depends() in defaults is idiomatic
# 本项目面向中文用户,注释/文案/提示词大量使用全角标点。
# RUF001-003 会把每一个中文标点都报成 "ambiguous unicode",无信号价值。
"RUF001",
"RUF002",
"RUF003",
]
select = ["E", "F", "I", "N", "UP", "B", "SIM", "ASYNC", "C4", "RUF"]
# B008 与 FastAPI 的 Depends() 默认值写法直接冲突
ignore = ["B008", "RUF001", "RUF002", "RUF003"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S105", "S106", "S311"]
"migrations/**" = ["E501"]
"tests/**" = ["SLF001"]
[tool.ruff.lint.isort]
known-first-party = ["apps", "packages", "services"]
# ── pytest ──────────────────────────────────────────────────────────────────
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
# 集成用例需要真实 PostgreSQL/Redis,默认不跑:`make test-integration` 显式开启
addopts = "-q --strict-markers --strict-config -m 'not integration' --cov=packages --cov=apps"
markers = ["integration: 需要真实 PostgreSQL / Redis(默认跳过)"]
[tool.ruff.format]
quote-style = "double"
[tool.coverage.run]
branch = true
source = ["packages", "apps"]
omit = ["*/__init__.py"]
# ---------------------------------------------------------------------------
# mypy — strict 仅覆盖 packages/domain 与 packages/llm (架构文档 §16.2)
# ---------------------------------------------------------------------------
[tool.coverage.report]
show_missing = true
skip_covered = true
# ── mypy ────────────────────────────────────────────────────────────────────
[tool.mypy]
python_version = "3.12"
plugins = ["pydantic.mypy"]
ignore_missing_imports = true
warn_unused_configs = true
warn_redundant_casts = true
show_error_codes = true
namespace_packages = true
explicit_package_bases = true
exclude = ["migrations/versions/"]
warn_unused_ignores = true
no_implicit_reexport = true
disallow_untyped_defs = true
check_untyped_defs = true
# 领域层与 LLM 网关是钱与正确性的所在,按 strict 要求;外层逐步收紧。
[[tool.mypy.overrides]]
module = ["packages.domain.*", "packages.llm.*"]
disallow_untyped_defs = true
disallow_incomplete_defs = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_any_generics = true
disallow_subclassing_any = true
check_untyped_defs = true
no_implicit_reexport = true
warn_return_any = true
warn_unused_ignores = true
strict_equality = true
extra_checks = true
strict = true
# ---------------------------------------------------------------------------
# pytest
# ---------------------------------------------------------------------------
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
addopts = [
"-ra",
"--strict-markers",
"--cov=packages.llm",
"--cov=packages.domain",
"--cov-report=term-missing",
"--cov-fail-under=80",
]
markers = [
"integration: needs PostgreSQL/Redis (docker compose or testcontainers)",
"golden: golden-set regression (retrieval / rules / report)",
]
[[tool.mypy.overrides]]
module = ["tests.*", "migrations.*"]
disallow_untyped_defs = false
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
"\\.\\.\\.",
]
# ---------------------------------------------------------------------------
# import-linter — 机器化执行架构文档 §4.2 的依赖规则
# CI 失败即阻断合并 (§16.2 分层依赖检查)
# ---------------------------------------------------------------------------
# ── import-linter:分层依赖(架构文档 §4.2)────────────────────────────────
# 这些契约用机器代替 code review 来守住依赖方向。没有它,
# "domain 不许 import 框架" 这类约定会在第一次赶工时悄悄破掉。
[tool.importlinter]
root_packages = ["apps", "packages", "services"]
include_external_packages = true
# --- §16.1: packages/domain 是纯逻辑层,不依赖 FastAPI / DB / HTTP / 队列 ---
[[tool.importlinter.contracts]]
name = "packages.domain is pure (no IO, no framework)"
name = "分层:apps → packages → domain,不得逆向"
type = "layers"
# 配额(billing)、账号(identity)、任务中心(task)是被各业务域共用的能力,
# 因此排在业务域下方一层 —— Agent 要预扣配额、报告要建任务,这些依赖是正当的。
# 反向不成立:billing 不该知道 Agent 的存在。
layers = [
"apps",
"packages.agent | packages.report | packages.tryon | packages.wardrobe",
"packages.identity | packages.billing | packages.task | packages.compliance | packages.notify",
"packages.kb | packages.retrieval | packages.rules | packages.memory | packages.profile",
"packages.llm",
"packages.shared",
"packages.domain",
]
[[tool.importlinter.contracts]]
name = "domain 层不依赖任何框架与 I/O"
type = "forbidden"
source_modules = ["packages.domain"]
forbidden_modules = [
@@ -171,109 +126,32 @@ forbidden_modules = [
"starlette",
"sqlalchemy",
"alembic",
"asyncpg",
"httpx",
"redis",
"celery",
"packages.shared",
"packages.llm",
"apps",
"services",
"redis",
"httpx",
"pydantic_settings",
]
# --- §4.2 禁止的依赖 (1): compliance 不得反向依赖任何业务域 ---
[[tool.importlinter.contracts]]
name = "compliance must not depend on any business domain"
type = "forbidden"
source_modules = ["packages.compliance"]
forbidden_modules = [
"packages.identity",
"packages.profile",
"packages.report",
"packages.agent",
"packages.tryon",
"packages.kb",
"packages.billing",
"packages.task",
"packages.notify",
"packages.wardrobe",
]
# --- §4.2 禁止的依赖 (2): identity 不得依赖其他业务域 ---
[[tool.importlinter.contracts]]
name = "identity must not depend on any other business domain"
type = "forbidden"
source_modules = ["packages.identity"]
forbidden_modules = [
"packages.profile",
"packages.report",
"packages.agent",
"packages.tryon",
"packages.kb",
"packages.billing",
"packages.task",
"packages.compliance",
"packages.notify",
"packages.wardrobe",
]
# --- §3.2 硬性规则 1: 禁止直接查对方的表,只能走对方的 service ---
[[tool.importlinter.contracts]]
name = "no cross-domain repository (table) access"
name = "业务包不得绕过网关直连大模型"
type = "forbidden"
source_modules = [
"packages.agent",
"packages.report",
"packages.identity",
"packages.billing",
"packages.task",
"packages.identity",
"packages.compliance",
]
forbidden_modules = [
"packages.agent.repo",
"packages.billing.repo",
"packages.task.repo",
"packages.identity.repo",
]
unmatched_ignore_imports_alerting = "none"
ignore_imports = [
"packages.agent.* -> packages.agent.repo",
"packages.billing.* -> packages.billing.repo",
"packages.task.* -> packages.task.repo",
"packages.identity.* -> packages.identity.repo",
]
forbidden_modules = ["packages.llm.providers"]
# 只禁直接 import。经由 packages.llm.gateway 的间接依赖是正常的 ——
# 要拦的是"业务代码自己拼 HTTP 请求",那样成本归因与熔断都会被绕过。
allow_indirect_imports = "true"
# --- §3.1 分层视图: L3 API → L4 业务域 → L5 AI 能力 → L6 数据 ---
[[tool.importlinter.contracts]]
name = "layered architecture (apps -> domains -> ai/shared -> domain)"
type = "layers"
layers = [
"apps",
"packages.agent | packages.report | packages.tryon | packages.profile | packages.notify | packages.wardrobe",
"packages.task | packages.billing | packages.kb",
"packages.compliance | packages.identity",
"packages.llm | packages.retrieval | packages.rules | packages.memory",
"packages.shared",
"packages.domain",
]
exhaustive = false
# --- AI 能力层不得回头依赖业务域 (§4.3: llm-gateway 必须按远程调用设计) ---
[[tool.importlinter.contracts]]
name = "AI capability packages must not depend on business domains"
type = "forbidden"
source_modules = [
"packages.llm",
"packages.retrieval",
"packages.rules",
]
forbidden_modules = [
"packages.identity",
"packages.profile",
"packages.report",
"packages.agent",
"packages.tryon",
"packages.kb",
"packages.billing",
"packages.task",
"apps",
]
name = "apps 之间互不依赖"
type = "independence"
modules = ["apps.api", "apps.worker", "apps.admin"]
# 例外:API 投递异步任务需要 worker 的任务签名。这是投递方向(api → worker),
# 反向不允许 —— worker 不得 import apps.api,否则 worker 会被迫加载整个 HTTP 层。
ignore_imports = ["apps.api.routers.tasks -> apps.worker.tasks"]