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:
+46
-22
@@ -1,36 +1,60 @@
|
||||
"""Shared fixtures.
|
||||
|
||||
Unit tests must stay hermetic: no database, no Redis, no network. Anything that
|
||||
needs real infrastructure belongs in ``tests/integration`` behind the
|
||||
``integration`` marker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Unit tests never touch a real database, Redis or LLM provider. Setting these
|
||||
# before any import keeps `packages.shared.config` deterministic.
|
||||
os.environ.setdefault("APP_ENV", "local")
|
||||
os.environ.setdefault("JWT_SECRET", "unit-test-secret-value-not-used-in-any-real-env")
|
||||
os.environ.setdefault("DEEPSEEK_API_KEY", "unit-test-placeholder")
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
# 单测里给出确定的配置,避免开发者本机 .env 的取值渗进断言里。
|
||||
# 必须在导入任何读取 Settings 的模块之前生效,故用 autouse=True + session 作用域。
|
||||
_TEST_ENV = {
|
||||
"APP_ENV": "local",
|
||||
"LOG_JSON": "false",
|
||||
"JWT_SECRET": "unit-test-secret-not-used-outside-tests-0123456789",
|
||||
"DEEPSEEK_API_KEY": "unit-test-placeholder-never-called",
|
||||
"LLM_DAILY_BUDGET_ENABLED": "true",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _isolate_settings() -> None:
|
||||
import os
|
||||
|
||||
for key, value in _TEST_ENV.items():
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def repo_root() -> Path:
|
||||
return REPO_ROOT
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prompts_dir(tmp_path: Path) -> Iterator[Path]:
|
||||
"""A minimal on-disk prompt tree with a clean, cacheable system prefix."""
|
||||
root = tmp_path / "prompts"
|
||||
(root / "agent_chat").mkdir(parents=True)
|
||||
(root / "agent_chat" / "v1.txt").write_text(
|
||||
"[system]\n你是穿搭顾问助手。只回答穿搭相关问题。\n\n[user]\n{user_input}\n",
|
||||
def prompts_dir(repo_root: Path, tmp_path: Path) -> Path:
|
||||
"""A writable copy of the shipped prompts, plus a second version of agent_chat.
|
||||
|
||||
Copying rather than pointing at ``prompts/`` keeps two properties that matter:
|
||||
tests can add versions without dirtying the repo, and the shipped prompts are
|
||||
still the ones under test (a hand-written fixture would drift from production).
|
||||
"""
|
||||
target = tmp_path / "prompts"
|
||||
shutil.copytree(repo_root / "prompts", target)
|
||||
|
||||
v2 = target / "agent_chat" / "v2.txt"
|
||||
v2.write_text(
|
||||
"[system]\n"
|
||||
"你是「美搭」的穿搭顾问助手(v2 试验版本,仅用于测试版本切换)。\n"
|
||||
"规则与 v1 相同,此处仅改动固定前缀以便断言两个版本不同。\n"
|
||||
"\n"
|
||||
"[user]\n"
|
||||
"{user_input}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "agent_chat" / "v2.txt").write_text(
|
||||
"[system]\n你是穿搭顾问助手。回答要具体可执行。\n\n[user]\n{user_input}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
yield root
|
||||
return target
|
||||
|
||||
Reference in New Issue
Block a user