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.
107 lines
3.8 KiB
Python
Executable File
107 lines
3.8 KiB
Python
Executable File
"""Scene-based routing table (架构文档 §8.1 场景化路由).
|
|
|
|
``scene → (provider, model, temperature, max_tokens, timeout)`` is configuration,
|
|
so switching models never requires a code change (NFR-MAINT-05). Defaults live
|
|
here; ``LLM_SCENE_OVERRIDES_JSON`` overrides any field per scene at deploy time.
|
|
|
|
Token budgets follow 架构文档 §7.2: 输入合计 ≤ 4200, 输出 ≤ 800.
|
|
|
|
``thinking`` 在每个场景都显式给值。deepseek-v4 默认开启思考模式,实测同一个单句
|
|
问答开启要 13.5s、关闭只要 1.8s,且思考 token 按输出价计费 —— 漏配就会同时踩中
|
|
「响应超时」和「成本翻倍」。要开某个场景的思考,改 ``LLM_SCENE_OVERRIDES_JSON``
|
|
即可,不必改代码。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from packages.llm.errors import SceneNotRoutedError
|
|
from packages.llm.types import Scene, SceneConfig
|
|
|
|
_DEFAULTS: dict[Scene, dict[str, Any]] = {
|
|
Scene.AGENT_CHAT: {
|
|
"temperature": 0.7,
|
|
"max_tokens": 800,
|
|
"timeout_seconds": 30.0,
|
|
"prompt_id": "agent_chat",
|
|
"prompt_version": "v1",
|
|
"critical": False,
|
|
# 对话要在 NFR-PERF-02b 的整轮 15s 内返回,思考模式做不到
|
|
"thinking": False,
|
|
},
|
|
Scene.INTENT_CLASSIFY: {
|
|
"temperature": 0.0,
|
|
"max_tokens": 128,
|
|
"timeout_seconds": 10.0,
|
|
"prompt_id": "intent_classify",
|
|
"prompt_version": "v1",
|
|
# 分类是短输出的判别任务,思考只会拖慢并多花钱
|
|
"thinking": False,
|
|
# Intent classification gates 越界拒答 (FR-AGT-02); degrading it silently
|
|
# would let out-of-scope questions through, so it is never budget-gated.
|
|
"critical": True,
|
|
},
|
|
Scene.PREFERENCE_EXTRACT: {
|
|
"temperature": 0.0,
|
|
"max_tokens": 256,
|
|
"timeout_seconds": 15.0,
|
|
"prompt_id": "preference_extract",
|
|
"prompt_version": "v1",
|
|
"critical": False,
|
|
"thinking": False,
|
|
},
|
|
Scene.REPORT_COPY: {
|
|
"temperature": 0.6,
|
|
"max_tokens": 3000,
|
|
"timeout_seconds": 90.0,
|
|
"prompt_id": "report_copy",
|
|
"prompt_version": "v1",
|
|
"critical": False,
|
|
# 报告文案是异步任务,延迟宽松,理论上最适合开思考;V1 先关,
|
|
# 等黄金集能量化"思考带来的质量提升"再按场景打开(改配置即可)。
|
|
"thinking": False,
|
|
},
|
|
}
|
|
|
|
|
|
def build_scene_configs(
|
|
*,
|
|
primary_provider: str,
|
|
backup_provider: str | None = None,
|
|
structured_max_retries: int = 2,
|
|
models: dict[str, str] | None = None,
|
|
overrides: dict[str, dict[str, Any]] | None = None,
|
|
) -> dict[Scene, SceneConfig]:
|
|
"""Materialise the routing table from settings."""
|
|
providers = tuple(p for p in (primary_provider, backup_provider) if p)
|
|
configs: dict[Scene, SceneConfig] = {}
|
|
for scene, defaults in _DEFAULTS.items():
|
|
merged: dict[str, Any] = {
|
|
**defaults,
|
|
"structured_max_retries": structured_max_retries,
|
|
**(overrides or {}).get(scene.value, {}),
|
|
}
|
|
configs[scene] = SceneConfig(
|
|
scene=scene,
|
|
providers=tuple(merged.pop("providers", providers)),
|
|
models=dict(merged.pop("models", models or {})),
|
|
**merged,
|
|
)
|
|
return configs
|
|
|
|
|
|
class SceneRouter:
|
|
def __init__(self, configs: dict[Scene, SceneConfig]) -> None:
|
|
self._configs = dict(configs)
|
|
|
|
def resolve(self, scene: Scene) -> SceneConfig:
|
|
config = self._configs.get(scene)
|
|
if config is None:
|
|
msg = f"no routing configured for scene {scene.value}"
|
|
raise SceneNotRoutedError(msg)
|
|
return config
|
|
|
|
def scenes(self) -> tuple[Scene, ...]:
|
|
return tuple(self._configs)
|