"""决策留痕测试(T-X4):store 三元结构落库 + grader 留痕 + admin 查询端点。""" import asyncio import pytest pytest.importorskip("fastapi") from fastapi import FastAPI from fastapi.testclient import TestClient import gateway.model_pool as mp import gateway.sense.grader as gr from gateway.sense.config import build_sense_config from gateway.sense.grader import Grader from gateway.sense.store import SenseStore from gateway.proxy.config import build_proxy_config from gateway.proxy.routes import build_proxy_router class _CountingObserver: def __init__(self): self.logs = [] def log(self, obs): self.logs.append(obs) class _FakeHead: version = "test-head" def predict(self, vec): return {"t1": 0.95, "t2": 0.03, "t3": 0.03} def _make_grader(tmp_path, mode="live", embed_raises=False): cfg = build_sense_config({"sense": { "enabled": True, "mode": mode, "db_path": str(tmp_path / "s.sqlite3"), "models_dir": str(tmp_path / "models")}}) store = SenseStore.init_db(cfg.db_path) g = Grader(cfg, store, _CountingObserver()) g._load_head = lambda: _FakeHead() async def fake_embed(text, c): if embed_raises: raise __import__("gateway.sense.errors", fromlist=["EmbedderDown"]).EmbedderDown("down") return [1, 2, 3] orig = gr.embed gr.embed = fake_embed return g, store, orig def test_store_decision_roundtrip(tmp_path): store = SenseStore.init_db(tmp_path / "s.sqlite3") store.insert_decision({ "ts": 1789874000, "request_id": "rt1", "consumer": "proxy", "decided_tier": "T1", "executed_tier": "T1", "mode": "live", "fallback": False, "policy_version": "head:test|th:conservative", "q_hash": "abc123", "reasons": '["t1_ok", "p1>=threshold"]', "candidate_scores": '{"t1": 0.95, "t2": 0.03, "t3": 0.03}', "rejected": '[{"tier": "T3", "reason": "p3=threshold" in r["reasons"] assert r["rejected"][0]["tier"] == "T3" assert r["candidate_scores"]["t1"] == 0.95 assert r["q_hash"] and len(r["q_hash"]) == 32 def test_grader_records_fallback_reasons(tmp_path): """embedder 故障:fallback 规则门决策同样留痕并标注 fallback。""" g, store, orig = _make_grader(tmp_path, embed_raises=True) try: d = asyncio.run(g.decide("什么是递归", "proxy")) finally: gr.embed = orig assert d.fallback is True rows = store.list_decisions() assert len(rows) == 1 r = rows[0] assert r["fallback"] == 1 assert "fallback:rule_gate" in r["reasons"] assert {x["reason"] for x in r["rejected"]} == {"fallback"} def test_admin_sense_decisions_endpoint(tmp_path, monkeypatch): """管理面查询端点:sense 未注入/未启用 -> enabled False;不报错。""" import gateway.model_pool as mp mp.reset_pool() monkeypatch.setattr(mp, "_store", __import__("gateway.model_pool", fromlist=["PoolStore"]).PoolStore( path=tmp_path / "pool.json")) cfg = build_proxy_config({"proxy": {"enabled": True, "db_path": str(tmp_path / "p.sqlite3")}}) app = FastAPI() app.include_router(build_proxy_router(cfg, mp.get_pool())) client = TestClient(app) r = client.get("/proxy/admin/sense-decisions") assert r.status_code == 200 body = r.json() assert body["enabled"] is False and body["decisions"] == [] def test_proxy_routes_sense_live_regression(tmp_path, monkeypatch): """T-X4 回归:settings_provider 注入后,sense live 分流真正生效 (修复前 NameError 被 except 吞掉,x-campus-tier 永远缺失)。""" import httpx import gateway.proxy.upstream as up from gateway.proxy.auth import issue_key from gateway.proxy.ledger import Ledger sense_dir = tmp_path / "sense" cfg = build_proxy_config({"proxy": { "enabled": True, "db_path": str(tmp_path / "p.sqlite3"), "pricing": {"m1": {"in_miss": 1.0, "out": 2.0}}}}) # 签发学生 key(代理面鉴权链:Bearer 形态 -> 哈希 -> 日限额 -> rpm) led = Ledger.init_db(tmp_path / "p.sqlite3") sid = led.upsert_student("测试生", "软件2201", balance_yuan=10.0, daily_cap_yuan=10.0) issued = issue_key(led, sid, rpm_cap=100, day_cap_req=1000) auth_header = {"Authorization": f"Bearer {issued['key']}"} import gateway.model_pool as mp mp.reset_pool() pool = mp.PoolStore(path=tmp_path / "pool.json") pool.upsert({"id": "t1e", "name": "本地", "tier": "local", "backend": "openai", "base_url": "http://mockup", "model": "local-m", "enabled": True}) class _Settings: def to_dict(self): return {"sense": { "enabled": True, "mode": "live", "db_path": str(sense_dir / "s.sqlite3"), "models_dir": str(sense_dir / "models"), "consumers": {"proxy": {"t1": "local", "t2": "budget", "t3": "premium"}}}} app = FastAPI() app.include_router(build_proxy_router(cfg, pool, settings_provider=_Settings)) def handler(request: httpx.Request) -> httpx.Response: body = ("\n\n".join([ 'data: {"choices":[{"delta":{"content":"hi"}}]}', "data: [DONE]", ]) + "\n\n").encode("utf-8") return httpx.Response(200, content=body) client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) orig_client = up._client up._client = client async def fake_embed(text, c): return [1, 2, 3] orig_embed = gr.embed gr.embed = fake_embed try: c = TestClient(app) r = c.post("/proxy/v1/chat/completions", json={"model": "local-m", "messages": [{"role": "user", "content": "什么是递归"}]}, headers=auth_header) assert r.status_code == 200 # 短文本 + t1_hard_ok + p1>=0.9 -> T1 生效并替换为 local 档模型 assert r.headers.get("x-campus-tier") == "T1" assert r.json()["model"] == "local-m" # 决策留痕可经管理面查询 d = c.get("/proxy/admin/sense-decisions") assert d.status_code == 200 decisions = d.json()["decisions"] assert len(decisions) == 1 and decisions[0]["decided_tier"] == "T1" finally: up._client = orig_client gr.embed = orig_embed mp.reset_pool()