把单一日预算熔断升级为架构 §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")。
176 lines
6.5 KiB
Python
Executable File
176 lines
6.5 KiB
Python
Executable File
"""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()
|