feat(v2): T3 ArchitectClient(DeepSeek API,JSON 约束输出)
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
"""T3 ArchitectClient 单测(封闭:httpx.MockTransport 注入,D11)。"""
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from router_system.architect import (
|
||||
ArchitectCircuitBreaker,
|
||||
ArchitectClient,
|
||||
ArchitectError,
|
||||
build_architect,
|
||||
)
|
||||
from router_system.workspace import Workspace
|
||||
|
||||
BRIEF_JSON = json.dumps({
|
||||
"goal": "实现快排",
|
||||
"constraints": ["标准库"],
|
||||
"tags": ["code"],
|
||||
"acceptance": [{"id": "a1", "check": "排序正确", "machine_checkable": True}],
|
||||
"plan": [{"id": "s1", "task": "实现", "deps": [], "done_criteria": "可运行"}],
|
||||
}, ensure_ascii=False)
|
||||
|
||||
DECIDE_JSON = json.dumps({"reply": "改用断言", "patch_plan": [{"id": "s2", "task": "修"}]}, ensure_ascii=False)
|
||||
REVIEW_JSON = json.dumps({"verdict": "done", "notes": "通过", "fix_issues": []}, ensure_ascii=False)
|
||||
|
||||
|
||||
def _make_client(handler, api_key="test-key", **kw):
|
||||
transport = httpx.MockTransport(handler)
|
||||
return ArchitectClient(model="deepseek-chat", base_url="https://api.deepseek.com/v1",
|
||||
api_key=api_key, transport=transport, **kw)
|
||||
|
||||
|
||||
def _resp_json(content, usage=None):
|
||||
return httpx.Response(200, json={
|
||||
"choices": [{"message": {"content": content}}],
|
||||
"usage": usage or {"prompt_tokens": 100, "completion_tokens": 20},
|
||||
})
|
||||
|
||||
|
||||
def _ws(**kw):
|
||||
return Workspace.new(request_id="a1b2c3d4e5f6", query=kw.get("query", "写个快排"),
|
||||
api_token_cap=kw.get("cap", 8000), rounds_cap=kw.get("rounds", 6))
|
||||
|
||||
|
||||
# ---------- brief 成功 ----------
|
||||
def test_brief_success_records_budget():
|
||||
calls = []
|
||||
def handler(request):
|
||||
calls.append(request.url.path)
|
||||
return _resp_json(BRIEF_JSON)
|
||||
client = _make_client(handler)
|
||||
ws = _ws()
|
||||
brief = asyncio_run(client.brief("写个快排", ws))
|
||||
assert brief["goal"] == "实现快排"
|
||||
assert brief["plan"][0]["id"] == "s1"
|
||||
assert calls == ["/v1/chat/completions"]
|
||||
# token 计量回写
|
||||
assert ws.budget()["api_input_tokens"] == 100
|
||||
assert ws.budget()["api_output_tokens"] == 20
|
||||
|
||||
|
||||
# ---------- 缺 key ----------
|
||||
def test_no_key_raises():
|
||||
client = ArchitectClient(model="deepseek-chat", api_key=None)
|
||||
ws = _ws()
|
||||
with pytest.raises(ArchitectError):
|
||||
asyncio_run(client.brief("hi", ws))
|
||||
|
||||
|
||||
# ---------- 坏 JSON 重试一次成功 ----------
|
||||
def test_bad_json_retry_once_success():
|
||||
seq = [{"body": "不是json{{", "ok": False}, {"body": BRIEF_JSON, "ok": True}]
|
||||
calls = []
|
||||
def handler(request):
|
||||
calls.append(1)
|
||||
item = seq[len(calls) - 1]
|
||||
return _resp_json(item["body"])
|
||||
client = _make_client(handler)
|
||||
ws = _ws()
|
||||
brief = asyncio_run(client.brief("hi", ws))
|
||||
assert len(calls) == 2
|
||||
assert brief["goal"] == "实现快排"
|
||||
|
||||
|
||||
# ---------- 坏 JSON 两次失败 ----------
|
||||
def test_bad_json_twice_raises():
|
||||
def handler(request):
|
||||
return _resp_json("垃圾输出{")
|
||||
client = _make_client(handler)
|
||||
ws = _ws()
|
||||
with pytest.raises(ArchitectError):
|
||||
asyncio_run(client.brief("hi", ws))
|
||||
|
||||
|
||||
# ---------- API 错误(非 2xx) ----------
|
||||
def test_http_error_raises():
|
||||
def handler(request):
|
||||
return httpx.Response(500, text="server error")
|
||||
client = _make_client(handler)
|
||||
ws = _ws()
|
||||
with pytest.raises(ArchitectError):
|
||||
asyncio_run(client.brief("hi", ws))
|
||||
|
||||
|
||||
# ---------- 缺 choices ----------
|
||||
def test_missing_choices_raises():
|
||||
def handler(request):
|
||||
return httpx.Response(200, json={"usage": {}})
|
||||
client = _make_client(handler)
|
||||
ws = _ws()
|
||||
with pytest.raises(ArchitectError):
|
||||
asyncio_run(client.brief("hi", ws))
|
||||
|
||||
|
||||
# ---------- 熔断:预算触顶,不再调用 transport ----------
|
||||
def test_circuit_breaker_before_transport():
|
||||
called = []
|
||||
def handler(request):
|
||||
called.append(1)
|
||||
return _resp_json(BRIEF_JSON)
|
||||
client = _make_client(handler)
|
||||
ws = _ws(cap=1)
|
||||
ws.add_budget(input_tokens=1, output_tokens=0) # used=1 >= cap=1
|
||||
with pytest.raises(ArchitectCircuitBreaker):
|
||||
asyncio_run(client.brief("hi", ws))
|
||||
assert called == [] # 未触达 API
|
||||
|
||||
|
||||
# ---------- decide / final_review ----------
|
||||
def test_decide():
|
||||
def handler(request):
|
||||
return _resp_json(DECIDE_JSON)
|
||||
client = _make_client(handler)
|
||||
ws = _ws()
|
||||
ws.apply_brief({"goal": "x", "constraints": [], "tags": ["code"],
|
||||
"acceptance": [], "plan": [{"id": "s1", "task": "t", "deps": [], "done_criteria": "c"}]})
|
||||
ws.add_issue("s1", "a://f.py#L1", "obs", "exp", "try", "ask")
|
||||
out = asyncio_run(client.decide(ws))
|
||||
assert out["reply"] == "改用断言"
|
||||
|
||||
|
||||
def test_final_review_done():
|
||||
def handler(request):
|
||||
return _resp_json(REVIEW_JSON)
|
||||
client = _make_client(handler)
|
||||
ws = _ws()
|
||||
ws.apply_brief({"goal": "x", "constraints": [], "tags": ["code"],
|
||||
"acceptance": [], "plan": [{"id": "s1", "task": "t", "deps": [], "done_criteria": "c"}]})
|
||||
out = asyncio_run(client.final_review(ws))
|
||||
assert out["verdict"] == "done"
|
||||
|
||||
|
||||
# ---------- build_architect 工厂 ----------
|
||||
def test_build_architect_reads_env(monkeypatch):
|
||||
cfg = {"model": "deepseek-chat", "api_key_env": "DEEPSEEK_API_KEY"}
|
||||
client = build_architect(cfg, get_env=lambda name: "sk-fake")
|
||||
assert client.api_key == "sk-fake"
|
||||
|
||||
|
||||
def test_build_architect_no_key():
|
||||
cfg = {"api_key_env": "DEEPSEEK_API_KEY"}
|
||||
client = build_architect(cfg, get_env=lambda name: None)
|
||||
assert client.api_key is None
|
||||
|
||||
|
||||
# ---------- 小工具 ----------
|
||||
def asyncio_run(coro):
|
||||
import asyncio
|
||||
return asyncio.run(coro)
|
||||
Reference in New Issue
Block a user