merge: T1 四档分级降级熔断(T-P1-25b,NFR-COST-02)
This commit is contained in:
@@ -12,6 +12,8 @@ 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
|
||||
|
||||
@@ -31,40 +33,48 @@ pytestmark = [
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> Any:
|
||||
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
|
||||
|
||||
from apps.api.main import create_app
|
||||
from packages.llm.types import ProviderResponse, Usage
|
||||
from packages.shared.config import get_settings
|
||||
|
||||
app = create_app(get_settings())
|
||||
|
||||
class FakeProvider:
|
||||
name = "deepseek"
|
||||
default_model = "deepseek-v4-flash"
|
||||
|
||||
async def complete(self, request: Any) -> ProviderResponse:
|
||||
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
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
|
||||
app.router.lifespan_context(app),
|
||||
):
|
||||
# 替换掉真实 provider:集成用例验证配额与计量链路,不该花钱也不该受网络影响
|
||||
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]}"}
|
||||
@@ -99,3 +109,67 @@ async def test_unauthenticated_requests_get_the_unified_envelope(client: Any) ->
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user