feat(v3): T17-T18 模型池 + 工具智能体后端

- T17 模型池:PoolStore(local/budget/premium 条目 + architect/worker/agent 角色指派),
  /pool CRUD+连通测试+模型探测端点;build_v2_pipeline 池指派优先(测试 override 最后);
  V2Stats 新增 by_model 按 token/成本分账
- T18 智能体:OpenAI 兼容工具调用客户端(transport 可注入)+ AgentService
  (事件落盘 agent_runs/{id}/events.jsonl)+ /agent 提交/status/events/SSE stream
  + 工作区浏览/读取端点(越界 400);轮数与 token 双护栏,模型经池 agent 角色或经典回退
- 新增测试 16 项,全量 262 passed(httpx 假注入,不依赖真实模型/key)
This commit is contained in:
tzt
2026-09-01 08:51:52 +08:00
parent 6247e053c1
commit 374dc765c2
8 changed files with 1571 additions and 36 deletions
+170
View File
@@ -0,0 +1,170 @@
"""智能体端点测试:注入脚本化 chat_fn,不依赖真实模型/API key。"""
import json
import time
import pytest
pytest.importorskip("fastapi")
pytest.importorskip("httpx")
from fastapi.testclient import TestClient
import gateway.agent as ag
import gateway.api as ga
from gateway.model_pool import PoolStore
import gateway.model_pool as mp
@pytest.fixture()
def agent_env(tmp_path, monkeypatch):
"""隔离:池/服务/工作区全部指向临时目录,chat_fn 用脚本替身。"""
mp.reset_pool()
mp._store = PoolStore(path=tmp_path / "pool.json")
ag.reset_agent_service()
service = ag.AgentService(run_dir=tmp_path / "agent_runs")
ag._service = service
# 快照用户真实设置,测试后原样恢复(settings.json 是活文件,不能污染)
store = ga.settings_store()
snapshot = json.loads(json.dumps(store._data, ensure_ascii=False))
# 工作区指向临时目录 + 给经典回退一个假 key(防止 .env 缺失时 400
store.update({"agent": {"workspace_dir": str(tmp_path / "ws")},
"architect": {"api_key": "sk-fake-test"}})
ga.rebuild_pipeline()
script = []
def set_script(events):
script.clear()
script.extend(events)
def fake_chat_factory(acfg):
async def chat_fn(messages, tools_spec):
if not script:
return {"content": "(脚本用尽)好的。", "tool_calls": [], "usage": {}}
return script.pop(0)
return chat_fn
monkeypatch.setattr(ga, "build_agent_chat", fake_chat_factory)
yield {"service": service, "set_script": set_script, "ws": tmp_path / "ws"}
store._data = snapshot
store.save()
mp.reset_pool()
ag.reset_agent_service()
ga.rebuild_pipeline()
@pytest.fixture()
def client():
return TestClient(ga.app)
def _wait_done(service, rid, timeout=10.0):
t0 = time.time()
while time.time() - t0 < timeout:
info = service.get(rid)
if info and info.state in ("done", "failed"):
return info
time.sleep(0.05)
return service.get(rid)
def test_agent_full_flow(agent_env, client):
"""写文件 -> 最终答复:验证事件、工作区落盘、状态终态。"""
agent_env["set_script"]([
{"content": None,
"tool_calls": [{"id": "c1", "name": "write_file",
"arguments": {"path": "notes.md", "content": "# 笔记"}}],
"usage": {"prompt_tokens": 30, "completion_tokens": 6}},
{"content": "已创建 notes.md,任务完成。", "tool_calls": [],
"usage": {"prompt_tokens": 40, "completion_tokens": 8}},
])
r = client.post("/agent", json={"task": "帮我建一个 notes.md"})
assert r.status_code == 200
rid = r.json()["request_id"]
assert r.json()["status"] == "running"
info = _wait_done(agent_env["service"], rid)
assert info.state == "done", info.error
assert "notes.md" in info.response
# 工作区真实落盘
assert (agent_env["ws"] / "notes.md").read_text(encoding="utf-8") == "# 笔记"
# 事件序列
events = client.get(f"/agent/{rid}/events").json()
kinds = [e["type"] for e in events]
assert "tool_call" in kinds and "tool_result" in kinds and "final" in kinds
assert events[-1]["reason"] == "answer"
# 状态端点
st = client.get(f"/agent/{rid}/status").json()
assert st["state"] == "done"
assert st["prompt_tokens"] == 70 and st["completion_tokens"] == 14
# 工作区浏览端点
ls = client.get("/agent/workspace").json()
assert ls["ok"] is True
assert any(e["name"] == "notes.md" for e in ls["entries"])
f = client.get("/agent/file", params={"path": "notes.md"}).json()
assert f["content"] == "# 笔记"
def test_agent_jail_via_api(agent_env, client):
"""工具结果为 ok=False(越界被拒),循环仍能继续到最终答复。"""
agent_env["set_script"]([
{"content": None,
"tool_calls": [{"id": "c1", "name": "read_file",
"arguments": {"path": "../../secret.txt"}}],
"usage": {"prompt_tokens": 10, "completion_tokens": 2}},
{"content": "越界访问被拒绝。", "tool_calls": [], "usage": {}},
])
r = client.post("/agent", json={"task": "读一下上级目录"})
rid = r.json()["request_id"]
info = _wait_done(agent_env["service"], rid)
assert info.state == "done"
events = agent_env["service"].read_events(rid)
tool_result = next(e for e in events if e["type"] == "tool_result")
assert tool_result["ok"] is False
# 文件读取 API 直接越界 -> 404/400
r2 = client.get("/agent/file", params={"path": "../../x.txt"})
assert r2.status_code in (400, 404)
def test_agent_model_from_pool(agent_env, client, monkeypatch):
"""池 agent 角色(或显式 pool_id)应被采用;mock 池模型拒绝。"""
from gateway.agent import OpenAICompatChat
captured = {}
real_factory = None
# 先放一个 openai 池条目并指派 agent 角色
client.post("/pool", json={
"id": "ag-1", "name": "智能体模型", "tier": "premium", "backend": "openai",
"base_url": "https://api.example.com", "model": "big-model-x",
"api_key": "sk-abc1234567", "enabled": True,
})
client.put("/pool/roles", json={"agent": "ag-1"})
# /agent 不带 pool_id -> 用池 agent 角色
r = client.post("/agent", json={"task": "hi"})
assert r.status_code == 200
assert r.json()["model"] == "big-model-x"
# mock 条目 -> 400
client.post("/pool", json={
"id": "mk-1", "name": "mock", "tier": "local", "backend": "mock",
"model": "mock", "enabled": True,
})
r2 = client.post("/agent", json={"task": "hi", "pool_id": "mk-1"})
assert r2.status_code == 400
def test_agent_task_validation(agent_env, client):
assert client.post("/agent", json={"task": ""}).status_code == 400
assert client.post("/agent", json={}).status_code == 400
def test_agent_404(agent_env, client):
assert client.get("/agent/ghost/status").status_code == 404
assert client.get("/agent/ghost/events").json() == []
+171
View File
@@ -0,0 +1,171 @@
"""模型池(PoolStore)测试:条目校验/CRUD/角色指派/管线解析/成本分账。"""
import pytest
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
import gateway.api as ga
import gateway.model_pool as mp
from gateway.model_pool import PoolStore, compute_cost, entry_to_architect_cfg, entry_to_worker_cfg
@pytest.fixture()
def pool(tmp_path):
"""独立文件的全局池(不污染 config/model_pool.json)。"""
mp.reset_pool()
store = PoolStore(path=tmp_path / "model_pool.json")
mp._store = store
yield store
mp.reset_pool()
ga.rebuild_pipeline()
@pytest.fixture()
def client():
return TestClient(ga.app)
def _entry(**over):
base = {
"id": "prem-1", "name": "旗舰模型", "tier": "premium", "backend": "openai",
"base_url": "https://api.deepseek.com", "model": "deepseek-v4-pro",
"api_key": "sk-test-1234567890", "price_in": 1.0, "price_out": 2.0,
"enabled": True,
}
base.update(over)
return base
# ---------------- PoolStore 单元 ----------------
def test_pool_upsert_and_mask(pool):
masked = pool.upsert(_entry())
assert masked["api_key_set"] is True
assert "sk-test" not in masked["api_key"] # 明文不打回
data = pool.list()
assert data["entries"][0]["model"] == "deepseek-v4-pro"
assert data["entries"][0]["api_key_set"] is True
def test_pool_upsert_keeps_key_when_blank(pool):
pool.upsert(_entry())
pool.upsert(_entry(api_key="")) # 前端不回传明文 -> 保留
assert pool.get("prem-1")["api_key"] == "sk-test-1234567890"
def test_pool_validation(pool):
with pytest.raises(ValueError):
pool.upsert(_entry(tier="超豪华"))
with pytest.raises(ValueError):
pool.upsert(_entry(backend="magic"))
with pytest.raises(ValueError):
pool.upsert(_entry(backend="openai", base_url="")) # 非 mock 缺端点
with pytest.raises(ValueError):
pool.upsert(_entry(hack="x")) # 未知字段
with pytest.raises(ValueError):
pool.upsert(_entry(price_in=-1))
def test_pool_roles_and_resolve(pool):
pool.upsert(_entry())
pool.upsert(_entry(id="local-1", tier="local", backend="llama_server",
base_url="http://127.0.0.1:8901/v1", model="qwen3.5-4b",
price_in=0, price_out=0))
assert pool.resolve("architect") is None # 未指派
pool.set_roles({"architect": "prem-1", "worker": "local-1"})
assert pool.resolve("architect")["id"] == "prem-1"
assert pool.resolve("worker")["id"] == "local-1"
assert pool.resolve("agent") is None
# 指派不存在的条目
with pytest.raises(ValueError):
pool.set_roles({"agent": "ghost"})
# 删除条目 -> 角色自动清空
pool.delete("prem-1")
assert pool.resolve("architect") is None
def test_pool_disabled_entry_not_resolved(pool):
pool.upsert(_entry(enabled=False))
pool.set_roles({"architect": "prem-1"})
assert pool.resolve("architect") is None # 禁用 -> 回退经典设置
def test_entry_cfg_mapping(pool):
e = pool.get("prem-1") or _entry()
acfg = entry_to_architect_cfg(_entry())
assert acfg["model"] == "deepseek-v4-pro"
assert acfg["api_key"] == "sk-test-1234567890"
wcfg = entry_to_worker_cfg(_entry())
assert wcfg["backend"] == "openai"
def test_compute_cost():
e = {"price_in": 1.0, "price_out": 2.0}
assert compute_cost(e, 1_000_000, 500_000) == pytest.approx(2.0)
assert compute_cost({"price_in": 0, "price_out": 0}, 999, 999) == 0.0
# ---------------- API 端点 ----------------
def test_pool_api_crud(pool, client):
r = client.get("/pool")
assert r.status_code == 200
assert r.json()["roles"]["architect"] == ""
r2 = client.post("/pool", json=_entry())
assert r2.status_code == 200
assert len(r2.json()["entries"]) == 1
# 非法条目 -> 400
r3 = client.post("/pool", json=_entry(tier="bad"))
assert r3.status_code == 400
# 角色指派
r4 = client.put("/pool/roles", json={"architect": "prem-1"})
assert r4.status_code == 200
assert r4.json()["roles"]["architect"] == "prem-1"
# 删除
r5 = client.delete("/pool/prem-1")
assert r5.status_code == 200
assert r5.json()["roles"]["architect"] == ""
def test_build_pipeline_uses_pool(pool, monkeypatch):
"""池指派应覆盖经典设置,测试 override 最后生效。"""
pool.upsert(_entry())
pool.set_roles({"architect": "prem-1"})
ga.rebuild_pipeline()
captured = {}
def fake_build_architect(cfg):
captured["architect"] = dict(cfg)
from router_system.architect import ArchitectClient
return ArchitectClient(model=cfg.get("model", "m"), api_key="k")
monkeypatch.setattr(ga, "build_architect", fake_build_architect)
pipe = ga.build_v2_pipeline(worker_cfg_override={"backend": "mock"})
assert pipe is not None
assert captured["architect"]["model"] == "deepseek-v4-pro" # 池条目生效
ga.rebuild_pipeline()
def test_v2stats_by_model():
from router_system.v2stats import V2Stats
class R:
request_id = "x"
fast_path = False
status = "done"
rounds_used = 1
api_input_tokens = 1000
api_output_tokens = 500
cost_est = 0.002
model_used = "deepseek-v4-pro"
route = []
s = V2Stats()
s.record(R())
summary = s.summary()
bucket = summary["by_model"]["deepseek-v4-pro"]
assert bucket["requests"] == 1
assert bucket["input_tokens"] == 1000
assert bucket["cost_est_usd"] == pytest.approx(0.002)