Files
xsl a4d59aca30 feat(llm): four-tier daily budget degradation with manual override
把单一日预算熔断升级为架构 §13.3 的四档分级降级(T-P1-25b / NFR-COST-02):

- budget.py:新增 BudgetTier 配置模型与档位判定(current_tier/tier_name/
  blocked_scenes)。阈值相对 LLM_DAILY_BUDGET_CENTS:warn ≥60% 只告警;
  degrade_secondary ≥80% 拒 hd_export/report_regen;degrade_generation ≥90%
  追加拒 hairstyle/tryon;block_all ≥100% 拒全部非 critical、allow 名单
  (默认 agent_chat)放行。高档位 blocked 取「截至当前档的并集」(架构原文
  「追加拒绝」)。critical 场景全档豁免但照常计成本。
- 手动 override:Redis 键 llm:budget:override(值 "1"~"4"),优先于成本水
  位,读不到/非法则忽略;set_override TTL 到当日 UTC+8 24 点,跨预算日必然
  失效。CostCounter 协议相应增加 set/delete。
- 指标:Gauge llm_budget_tier + Counter llm_budget_blocked_total{scene}
  挂到共享 REGISTRY 随 /metrics 暴露;档位上升沿只告警一次
  (budget_tier_raised,实例内记 last_tier)。
- errors.py 新增 SceneDegradedError(tier2/3 拒绝);gateway.py 调用点换成
  ensure_scene_allowed(保留 ensure_within_budget 兼容入口);agent.py 映射
  LLM_SCENE_DEGRADED(60006);100% 档维持 DailyBudgetExceededError/60003 语义。
- 单测:新增 tests/unit/test_tiered_budget.py(27 例:四档边界、各档放行/
  拒绝、critical 豁免、override 加严/放宽/非法值/TTL、配置驱动)。
- 集成:tests/integration/test_vertical_slice.py 新增用例——预算 1 分 +
  block_all allow 清空 + Redis 注入当日成本,POST /agent/chat 返回 60003,
  断言 bl_ledger 中 consume(refunded,-1)+refund(committed,+1) 净和为 0,
  覆盖「reserve 之后才熔断 → refund」路径;fixture 重构为共享
  FakeProvider/fake_client,断言语义未弱化。

门禁:ruff check/format、mypy(packages/llm strict + 其余包)、lint-imports、
单测 196 passed、集成 4 passed。

偏差说明:
1. tests/unit/test_gateway.py 的 test_daily_budget_rejects_non_critical_
   scenes_before_spending 把被测场景 AGENT_CHAT 换成 REPORT_COPY(test_daily_
   budget.py 同用例换 tryon):block_all 档默认 allow=["agent_chat"](架构
   「仅保留纯文本对话」)后 agent_chat 在 100% 档不再被拒,原场景选择与新
   语义冲突;断言本身(进 provider 前被拒)未弱化。
2. 集成测试的 block_all 档配置为 blocked="*"+allow=[](文档示例为
   blocked=["agent_chat"]):后者按档位规则会走 SceneDegradedError/60006,
   前者才能按设计走既有 60003 语义(文档允许二者取其一且"按上面设计应为
   60003")。
2026-08-14 16:27:21 +08:00

474 lines
18 KiB
Python
Executable File

"""LLM gateway: routing, failover, circuit breaking, structured output, cost.
No test here touches the network — providers are fakes.
"""
from __future__ import annotations
import itertools
from collections.abc import Mapping, Sequence
from decimal import Decimal
from pathlib import Path
import pytest
from pydantic import BaseModel
from packages.llm.budget import DailyBudgetGuard, InMemoryCostCounter
from packages.llm.circuit import CircuitBreakerRegistry, CircuitState
from packages.llm.cost import InMemoryAICallRecorder, ModelRate, RateTable
from packages.llm.errors import (
AllProvidersFailedError,
DailyBudgetExceededError,
ProviderBadRequestError,
ProviderRateLimitedError,
ProviderServerError,
ProviderTimeoutError,
SceneNotRoutedError,
StructuredOutputError,
)
from packages.llm.gateway import LLMGateway, extract_json
from packages.llm.prompts import PromptRegistry
from packages.llm.providers.base import LLMProvider
from packages.llm.providers.zhipu import ZhipuGLMProvider
from packages.llm.routing import SceneRouter, build_scene_configs
from packages.llm.types import Message, ProviderRequest, ProviderResponse, Scene, Usage
RATE = ModelRate(
input_cents_per_mtok=Decimal("100"),
cached_input_cents_per_mtok=Decimal("10"),
output_cents_per_mtok=Decimal("200"),
)
class FakeProvider:
"""Returns queued responses/exceptions in order; repeats the last forever."""
def __init__(
self,
name: str,
outcomes: Sequence[ProviderResponse | Exception],
default_model: str = "fake-model",
) -> None:
self._name = name
self._outcomes = list(outcomes)
self._default_model = default_model
self.requests: list[ProviderRequest] = []
self.closed = False
@property
def name(self) -> str:
return self._name
@property
def default_model(self) -> str:
return self._default_model
async def complete(self, request: ProviderRequest) -> ProviderResponse:
self.requests.append(request)
outcome = self._outcomes[min(len(self.requests) - 1, len(self._outcomes) - 1)]
if isinstance(outcome, Exception):
raise outcome
return outcome
async def aclose(self) -> None:
self.closed = True
def response(text: str, model: str = "fake-model") -> ProviderResponse:
return ProviderResponse(
text=text,
model=model,
usage=Usage(tokens_in=1000, tokens_out=200, tokens_cached=800),
finish_reason="stop",
)
def build_gateway(
prompts_dir: Path,
providers: Mapping[str, LLMProvider],
*,
primary: str = "p1",
backup: str | None = "p2",
budget_cents: str = "100000",
budget_enabled: bool = True,
failure_threshold: int = 3,
structured_max_retries: int = 2,
recorder: InMemoryAICallRecorder | None = None,
) -> tuple[LLMGateway, InMemoryAICallRecorder, DailyBudgetGuard]:
recorder = recorder or InMemoryAICallRecorder()
guard = DailyBudgetGuard(
InMemoryCostCounter(),
daily_budget_cents=Decimal(budget_cents),
enabled=budget_enabled,
)
gateway = LLMGateway(
providers=dict(providers),
router=SceneRouter(
build_scene_configs(
primary_provider=primary,
backup_provider=backup,
structured_max_retries=structured_max_retries,
)
),
prompts=PromptRegistry(prompts_dir),
rates=RateTable({"fake-model": RATE}, default=RATE),
recorder=recorder,
budget=guard,
breakers=CircuitBreakerRegistry(failure_threshold=failure_threshold),
id_factory=itertools.count(1).__next__,
)
return gateway, recorder, guard
# --------------------------------------------------------------------------
# routing
# --------------------------------------------------------------------------
async def test_routes_to_the_primary_provider(prompts_dir: Path) -> None:
primary = FakeProvider("p1", [response("好的")])
backup = FakeProvider("p2", [response("backup")])
gateway, _, _ = build_gateway(prompts_dir, {"p1": primary, "p2": backup})
result = await gateway.complete(scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "面试"})
assert result.provider == "p1"
assert result.text == "好的"
assert backup.requests == []
async def test_scene_config_drives_model_and_sampling(prompts_dir: Path) -> None:
primary = FakeProvider("p1", [response("ok")], default_model="deepseek-v4-flash")
gateway, _, _ = build_gateway(prompts_dir, {"p1": primary}, backup=None)
await gateway.complete(scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "hi"})
request = primary.requests[0]
assert request.model == "deepseek-v4-flash"
assert request.max_tokens == 800 # 架构文档 §7.2 输出预算
assert request.timeout_seconds == 30.0
async def test_cacheable_system_prefix_is_the_first_message(prompts_dir: Path) -> None:
primary = FakeProvider("p1", [response("ok")])
gateway, _, _ = build_gateway(prompts_dir, {"p1": primary}, backup=None)
await gateway.complete(
scene=Scene.AGENT_CHAT,
messages=[Message(role="user", content="历史消息")],
prompt_vars={"user_input": "现在的问题"},
)
messages = primary.requests[0].messages
assert messages[0].role == "system"
assert "穿搭顾问助手" in messages[0].content
assert messages[-1].content == "现在的问题"
async def test_unrouted_scene_raises(prompts_dir: Path) -> None:
gateway = LLMGateway(
providers={},
router=SceneRouter({}),
prompts=PromptRegistry(prompts_dir),
rates=RateTable({}, default=RATE),
recorder=InMemoryAICallRecorder(),
budget=DailyBudgetGuard(
InMemoryCostCounter(), daily_budget_cents=Decimal("1"), enabled=False
),
)
with pytest.raises(SceneNotRoutedError):
await gateway.complete(scene=Scene.AGENT_CHAT)
async def test_unregistered_provider_is_reported_as_a_failure(prompts_dir: Path) -> None:
gateway, _, _ = build_gateway(prompts_dir, {}, primary="ghost", backup=None)
with pytest.raises(AllProvidersFailedError):
await gateway.complete(scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "hi"})
# --------------------------------------------------------------------------
# failover
# --------------------------------------------------------------------------
@pytest.mark.parametrize(
"failure",
[
ProviderTimeoutError("p1", "timed out"),
ProviderServerError("p1", "502", status_code=502),
ProviderRateLimitedError("p1", "429", status_code=429),
],
)
async def test_timeout_5xx_and_rate_limit_fail_over_to_the_backup(
prompts_dir: Path, failure: Exception
) -> None:
primary = FakeProvider("p1", [failure])
backup = FakeProvider("p2", [response("备用回答")])
gateway, recorder, _ = build_gateway(prompts_dir, {"p1": primary, "p2": backup})
result = await gateway.complete(scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "hi"})
assert result.provider == "p2"
assert result.text == "备用回答"
# Both the failed and the successful attempt are attributable.
assert [(r.provider, r.success) for r in recorder.records] == [("p1", False), ("p2", True)]
async def test_bad_request_does_not_fail_over(prompts_dir: Path) -> None:
"""A malformed request would fail on the backup too — burning money twice."""
primary = FakeProvider("p1", [ProviderBadRequestError("p1", "400", status_code=400)])
backup = FakeProvider("p2", [response("should not be used")])
gateway, _, _ = build_gateway(prompts_dir, {"p1": primary, "p2": backup})
with pytest.raises(ProviderBadRequestError):
await gateway.complete(scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "hi"})
assert backup.requests == []
async def test_all_providers_failing_raises_with_every_cause(prompts_dir: Path) -> None:
primary = FakeProvider("p1", [ProviderTimeoutError("p1", "timeout")])
backup = FakeProvider("p2", [ProviderServerError("p2", "500", status_code=500)])
gateway, _, _ = build_gateway(prompts_dir, {"p1": primary, "p2": backup})
with pytest.raises(AllProvidersFailedError) as excinfo:
await gateway.complete(scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "hi"})
assert len(excinfo.value.failures) == 2
async def test_zhipu_placeholder_participates_in_the_real_routing_path(
prompts_dir: Path,
) -> None:
"""The backup slot is structural, but the code path through it is real."""
primary = FakeProvider("p1", [ProviderTimeoutError("p1", "timeout")])
gateway, recorder, _ = build_gateway(
prompts_dir, {"p1": primary, "zhipu": ZhipuGLMProvider()}, backup="zhipu"
)
with pytest.raises(AllProvidersFailedError) as excinfo:
await gateway.complete(scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "hi"})
assert isinstance(excinfo.value.failures[1], NotImplementedError)
assert {r.provider for r in recorder.records} == {"p1", "zhipu"}
# --------------------------------------------------------------------------
# circuit breaker
# --------------------------------------------------------------------------
async def test_open_circuit_skips_the_primary_entirely(prompts_dir: Path) -> None:
primary = FakeProvider("p1", [ProviderServerError("p1", "500", status_code=500)])
backup = FakeProvider("p2", [response("备用")])
gateway, _, _ = build_gateway(prompts_dir, {"p1": primary, "p2": backup}, failure_threshold=1)
first = await gateway.complete(scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "a"})
assert first.provider == "p2"
assert gateway.breaker_states()["p1"] == CircuitState.OPEN.value
second = await gateway.complete(scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "b"})
assert second.provider == "p2"
assert len(primary.requests) == 1 # not attempted the second time
async def test_success_keeps_the_circuit_closed(prompts_dir: Path) -> None:
primary = FakeProvider("p1", [response("ok")])
gateway, _, _ = build_gateway(prompts_dir, {"p1": primary}, backup=None)
await gateway.complete(scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "a"})
assert gateway.breaker_states()["p1"] == CircuitState.CLOSED.value
# --------------------------------------------------------------------------
# structured output
# --------------------------------------------------------------------------
class Outfit(BaseModel):
top: str
bottom: str
async def test_structured_output_is_parsed_and_validated(prompts_dir: Path) -> None:
primary = FakeProvider("p1", [response('{"top": "白衬衫", "bottom": "西裤"}')])
gateway, _, _ = build_gateway(prompts_dir, {"p1": primary}, backup=None)
result = await gateway.complete(
scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "hi"}, schema=Outfit
)
assert result.parsed_as(Outfit).top == "白衬衫"
assert result.attempts == 1
assert primary.requests[0].json_mode is True
assert "JSON Schema" in primary.requests[0].messages[-1].content
async def test_invalid_structured_output_is_retried_with_the_validation_error(
prompts_dir: Path,
) -> None:
primary = FakeProvider(
"p1",
[
response("这不是 JSON"),
response('{"top": "针织衫"}'), # missing `bottom`
response('{"top": "针织衫", "bottom": "阔腿裤"}'),
],
)
gateway, recorder, _ = build_gateway(prompts_dir, {"p1": primary}, backup=None)
result = await gateway.complete(
scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "hi"}, schema=Outfit
)
assert result.attempts == 3
assert result.parsed_as(Outfit).bottom == "阔腿裤"
# Every attempt costs money and every attempt is recorded.
assert len(recorder.records) == 3
assert [r.success for r in recorder.records] == [False, False, True]
assert recorder.records[0].error_code == "llm_structured_output_invalid"
# The repair turn carries the validation error back to the model.
assert "校验错误" in primary.requests[1].messages[-1].content
async def test_structured_output_gives_up_and_fails_over(prompts_dir: Path) -> None:
primary = FakeProvider("p1", [response("never json")])
backup = FakeProvider("p2", [response('{"top": "T恤", "bottom": "牛仔裤"}')])
gateway, _, _ = build_gateway(
prompts_dir, {"p1": primary, "p2": backup}, structured_max_retries=1
)
result = await gateway.complete(
scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "hi"}, schema=Outfit
)
assert result.provider == "p2"
assert len(primary.requests) == 2 # 1 + structured_max_retries
async def test_structured_failure_on_the_only_provider_surfaces(prompts_dir: Path) -> None:
primary = FakeProvider("p1", [response("never json")])
gateway, _, _ = build_gateway(
prompts_dir, {"p1": primary}, backup=None, structured_max_retries=0
)
with pytest.raises(AllProvidersFailedError) as excinfo:
await gateway.complete(
scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "hi"}, schema=Outfit
)
assert isinstance(excinfo.value.failures[0], StructuredOutputError)
@pytest.mark.parametrize(
("raw", "expected"),
[
('{"a": 1}', '{"a": 1}'),
('```json\n{"a": 1}\n```', '{"a": 1}'),
('好的:\n{"a": 1}\n以上。', '{"a": 1}'),
("no json here", "no json here"),
],
)
def test_extract_json_handles_fenced_and_chatty_replies(raw: str, expected: str) -> None:
assert extract_json(raw) == expected
# --------------------------------------------------------------------------
# cost attribution + daily budget
# --------------------------------------------------------------------------
async def test_every_call_records_a_cost_row(prompts_dir: Path) -> None:
primary = FakeProvider("p1", [response("ok")])
gateway, recorder, _ = build_gateway(prompts_dir, {"p1": primary}, backup=None)
result = await gateway.complete(
scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "hi"}, user_id=42, ref_id="req-1"
)
record = recorder.records[0]
assert record.scene == "agent_chat"
assert record.provider == "p1"
assert record.model == "fake-model"
assert record.prompt_id == "agent_chat"
assert record.prompt_version == "v1" # 可复现: 记录 prompt 版本
assert record.user_id == 42
assert record.ref_id == "req-1"
assert record.usage.tokens_cached == 800
assert record.cost_cents == result.cost_cents > Decimal("0")
assert record.created_at is not None
assert record.rate_tier # 复核成本要知道当次单价档位
async def test_failed_calls_are_recorded_too(prompts_dir: Path) -> None:
primary = FakeProvider("p1", [ProviderTimeoutError("p1", "timed out")])
gateway, recorder, _ = build_gateway(prompts_dir, {"p1": primary}, backup=None)
with pytest.raises(AllProvidersFailedError):
await gateway.complete(scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "hi"})
record = recorder.records[0]
assert record.success is False
assert record.error_code == "llm_timeout"
assert record.cost_cents == Decimal("0")
# 失败行也要带档位:排查「为什么这天成本高」时失败重试也是成本的一部分
assert record.rate_tier
async def test_a_broken_recorder_never_breaks_the_user_request(prompts_dir: Path) -> None:
class ExplodingRecorder:
async def record(self, record: object) -> None:
msg = "database is down"
raise RuntimeError(msg)
primary = FakeProvider("p1", [response("ok")])
gateway, _, _ = build_gateway(prompts_dir, {"p1": primary}, backup=None)
gateway._recorder = ExplodingRecorder()
result = await gateway.complete(scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "hi"})
assert result.text == "ok"
async def test_spend_accumulates_into_the_daily_budget(prompts_dir: Path) -> None:
primary = FakeProvider("p1", [response("ok")])
gateway, _, guard = build_gateway(prompts_dir, {"p1": primary}, backup=None)
first = await gateway.complete(scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "a"})
await gateway.complete(scene=Scene.AGENT_CHAT, prompt_vars={"user_input": "b"})
assert await guard.spent_today() == first.cost_cents * 2
async def test_daily_budget_rejects_non_critical_scenes_before_spending(
prompts_dir: Path,
) -> None:
# block_all 档默认放行 agent_chat(纯文本对话),用非 allow 场景断言拒绝
primary = FakeProvider("p1", [response("ok")])
gateway, _, guard = build_gateway(
prompts_dir, {"p1": primary}, backup=None, budget_cents="0.00001"
)
await guard.add_cost(Decimal("1"))
with pytest.raises(DailyBudgetExceededError):
await gateway.complete(
scene=Scene.REPORT_COPY, prompt_vars={"payload": "诊断结论与规则命中"}
)
assert primary.requests == [] # rejected, not merely alerted
async def test_daily_budget_lets_critical_scenes_through(prompts_dir: Path) -> None:
# 意图分类把守越界拒答,静默降级会让超范围提问漏过去,因此不受预算闸门限制
primary = FakeProvider("p1", [response("scene_consult")])
gateway, _, guard = build_gateway(
prompts_dir, {"p1": primary}, backup=None, budget_cents="0.00001"
)
await guard.add_cost(Decimal("1"))
result = await gateway.complete(
scene=Scene.INTENT_CLASSIFY, prompt_vars={"user_input": "面试穿什么"}
)
assert result.text == "scene_consult"
async def test_aclose_closes_every_provider(prompts_dir: Path) -> None:
primary = FakeProvider("p1", [response("ok")])
backup = FakeProvider("p2", [response("ok")])
gateway, _, _ = build_gateway(prompts_dir, {"p1": primary, "p2": backup})
await gateway.aclose()
assert primary.closed and backup.closed