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

packages/llm/ — LLM 网关

架构 §4.3、§8.1。V1 是进程内包,V2 拆成独立服务。 因此接口必须按远程调用来设计:无共享内存假设、明确超时、可序列化的入参出参。

唯一入口

所有 LLM 调用都走 LLMGateway.complete()。业务代码不许自己 httpx.post 到任何 模型供应商 —— 那样会绕过成本归因、预算闸门、故障转移和 Prompt 版本记录,而这四件事 一旦有缺口就补不回来(历史数据是补不出来的)。

已实现

能力 位置
场景化路由(scene → provider/model/温度/超时/预算,全配置化) routing.py
故障转移 + 熔断(超时/5xx/限流切备用;连续失败熔断,冷却后半开探测) gateway.py circuit.py
结构化输出(Pydantic Schema 约束 + 校验失败重试) gateway.py
Prompt 版本(文件化,按 id+version 加载,版本随每次调用落库) prompts.py /prompts
缓存前缀纪律(固定前缀含时间戳/随机 ID/昵称 → 拒绝或告警) prompts.py
成本归因(每次 provider 调用写一行 ob_ai_call,含缓存命中 token) cost.py recording.py
全局日成本硬熔断(超预算即拒绝非关键场景,不是只告警) budget.py

未实现(按 §8.1 还差)

  • 流式输出 SSE(FR-AGT-01 要求首字 p95 < 2s,非流式做不到)
  • 审核联动:输入输出审核应在网关内统一完成,业务方无需关心(CR-06)
  • 用户级频控:目前只有平台级日预算与业务侧配额
  • A/B 分流:按用户哈希选择 prompt 版本
  • 智谱 GLM provider:仅结构占位,见 providers/zhipu.py

加一个新场景

  1. 在 types.Scene 加枚举值。
  2. 在 routing._DEFAULTS 加默认配置,想清楚 critical 该不该为真 —— critical=True 意味着日预算耗尽时它仍然会花钱。
  3. 在 prompts/<scene>/v1.txt 写模板。[system] 段不许有任何变量。
  4. 跑 pytest tests/unit/test_prompt_cache_prefix.py,它会扫描仓库里全部 prompt 的固定前缀。

加一个新 provider

  1. 实现 providers/base.LLMProvider 协议(name / default_model / complete / aclose)。
  2. HTTP 错误必须映射到 errors.py 的异常族,retryable 决定要不要切备用。 映射错了会导致「明明该切备用却没切」或者「明明是我们请求写错了却把备用也打一遍」。
  3. usage 字段必须映射出 缓存命中 token 数,否则 NFR-COST-03 的缓存命中率 看板是空的。
  4. 在 apps/api/deps.build_llm_gateway() 注册。

费率

RateTable 来自配置(LLM_COST_RATES_JSON),不是常量。未知模型走 default 费率 而不是 0 —— 成本上的盲区比不精确更危险。上线前必须用供应商实际价目表校准。