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

176 lines
6.5 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""End-to-end vertical slice against real PostgreSQL + Redis.
Run with ``make up && make migrate`` first, or let ``scripts/smoke.sh`` do it.
Skipped automatically when the infrastructure is not reachable, so ``make test``
stays fast and hermetic.
The LLM provider is faked here — ``scripts/smoke.sh`` is the one that proves the
real DeepSeek path.
"""
from __future__ import annotations
import os
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from decimal import Decimal
from typing import Any
import pytest
def _infra_available() -> bool:
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not _infra_available(),
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
),
]
class FakeProvider:
"""替换真实 provider:集成用例验证配额与计量链路,不该花钱也不该受网络影响。"""
name = "deepseek"
default_model = "deepseek-v4-flash"
async def complete(self, request: Any) -> Any:
from packages.llm.types import ProviderResponse, Usage
return ProviderResponse(
text="建议白衬衫配西裤。",
model="deepseek-v4-flash",
usage=Usage(tokens_in=800, tokens_out=120, tokens_cached=600),
)
async def aclose(self) -> None:
return None
@asynccontextmanager
async def fake_client(app: Any) -> AsyncIterator[Any]:
"""起 app lifespan + ASGI 客户端,并把 deepseek 换成 FakeProvider。"""
import httpx
transport = httpx.ASGITransport(app=app)
async with (
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
app.router.lifespan_context(app),
):
app.state.llm_gateway._providers["deepseek"] = FakeProvider()
yield http
@pytest.fixture
async def client() -> Any:
from apps.api.main import create_app
from packages.shared.config import get_settings
async with fake_client(create_app(get_settings())) as http:
yield http
async def test_chat_slice_reserves_commits_and_records_cost(client: Any) -> None:
token_resp = await client.post(
"/api/v1/auth/dev-token", json={"device_key": f"it-{uuid.uuid4().hex[:8]}"}
)
assert token_resp.json()["code"] == 0
headers = {"Authorization": f"Bearer {token_resp.json()['data']['access_token']}"}
before = (await client.get("/api/v1/quota", headers=headers)).json()["data"]
chat = await client.post("/api/v1/agent/chat", json={"message": "面试穿什么"}, headers=headers)
body = chat.json()
assert body["code"] == 0
assert body["data"]["reply"]
assert body["data"]["prompt_version"] == "v1"
assert Decimal(body["data"]["usage"]["cost_cents"]) > 0
after = (await client.get("/api/v1/quota", headers=headers)).json()["data"]
assert after["used"] == before["used"] + 1
ledger = (await client.get("/api/v1/quota/ledger", headers=headers)).json()["data"]
assert ledger[0]["tx_type"] == "consume"
assert ledger[0]["status"] == "committed"
async def test_alias_path_v1_matches_api_v1(client: Any) -> None:
assert (await client.get("/healthz")).status_code == 200
token = await client.post("/v1/auth/dev-token", json={"device_key": "alias-check"})
assert token.json()["code"] == 0
async def test_unauthenticated_requests_get_the_unified_envelope(client: Any) -> None:
resp = await client.post("/api/v1/agent/chat", json={"message": "hi"})
assert resp.status_code == 401
assert resp.json()["code"] == 40100
async def test_block_all_budget_rejects_and_refunds_the_reservation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""T-P1-25b:reserve 之后才触发熔断 → gateway 拒绝 → refund → 净扣减 = 0。
这是四档降级(架构 §13.3)的验收路径:把 block_all 档的 allow 名单清空
(连纯文本对话也拦),让 agent_chat 走 100% 档的既有 60003 语义,从而
断言配额三段式在「先预扣、后熔断」时的补偿行为。
"""
import json
from apps.api.main import create_app
from packages.llm.budget import DailyBudgetGuard, InMemoryCostCounter
from packages.shared.config import get_settings
from packages.shared.redis_client import get_redis
monkeypatch.setenv("LLM_DAILY_BUDGET_CENTS", "1") # 预算 1 分钱
monkeypatch.setenv(
"LLM_BUDGET_TIERS_JSON",
json.dumps(
[
{"threshold": 0.6, "name": "warn", "blocked": []},
{"threshold": 0.8, "name": "degrade_secondary", "blocked": ["hd_export"]},
{"threshold": 0.9, "name": "degrade_generation", "blocked": ["hairstyle"]},
# allow 清空:agent_chat 也被 block_all 拦下(演练配置)
{"threshold": 1.0, "name": "block_all", "blocked": "*", "allow": []},
]
),
)
get_settings.cache_clear()
try:
app = create_app(get_settings())
async with fake_client(app) as http:
redis = get_redis()
# 注入当日成本到预算以上(键格式照 DailyBudgetGuard.current_key())
key_helper = DailyBudgetGuard(InMemoryCostCounter(), daily_budget_cents=Decimal("1"))
cost_key = key_helper.current_key()
await redis.incrbyfloat(cost_key, 5)
token_resp = await http.post(
"/api/v1/auth/dev-token", json={"device_key": f"it-{uuid.uuid4().hex[:8]}"}
)
headers = {"Authorization": f"Bearer {token_resp.json()['data']['access_token']}"}
resp = await http.post(
"/api/v1/agent/chat", json={"message": "面试穿什么"}, headers=headers
)
assert resp.json()["code"] == 60003 # LLM_DAILY_BUDGET_EXCEEDED
# 被拒请求的配额净扣减 = 0:consume(-1, refunded) + refund(+1, committed)
rows = (await http.get("/api/v1/quota/ledger", headers=headers)).json()["data"]
consume = [r for r in rows if r["tx_type"] == "consume"]
refunds = [r for r in rows if r["tx_type"] == "refund"]
assert len(consume) == 1 and consume[0]["status"] == "refunded"
assert len(refunds) == 1 and refunds[0]["delta"] == 1
assert sum(r["delta"] for r in (*consume, *refunds)) == 0
await redis.delete(cost_key)
finally:
# 恢复全局 settings 缓存,避免本用例的环境覆盖渗进其它用例
monkeypatch.undo()
get_settings.cache_clear()