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
+5
View File
@@ -30,3 +30,8 @@ cached_results/
Thumbs.db
.idea/
.vscode/
# 模型池与智能体运行时
config/model_pool.json
agent_runs/
agent_workspace/
+309
View File
@@ -0,0 +1,309 @@
"""智能体服务(AgentService)—— zcode 式"模型操作工作区文件"的网关侧封装。
职责:
- OpenAICompatChatOpenAI 兼容 /chat/completions 的工具调用客户端(ToolLoop 的 chat_fn),
支持 httpx transport/client 注入(测试用 MockTransport,对齐 D11 封闭性)。
- AgentService:运行一次智能体任务——事件逐条落盘 agent_runs/{id}/events.jsonl
终态写 status.json;SSE 端点轮询事件文件增量推送(与 v3 workspace 监视同思路,
不侵入 router_system)。
- 模型来源:模型池 agent 角色(或显式 pool_id),否则回退经典 Architect 设置。
安全与护栏:
- 文件操作被 WorkspaceTools 关押在工作区根目录内
- 轮数上限(agent.max_rounds)与 token 熔断(agent.token_cap)双护栏
"""
from __future__ import annotations
import asyncio
import json
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
from router_system.tools import ToolLoop, WorkspaceTools
# 运行目录(与 runs/ 平级)
AGENT_RUNS_DIR = Path("agent_runs")
STATE_RUNNING = "running"
STATE_DONE = "done"
STATE_FAILED = "failed"
AGENT_SYSTEM_PROMPT = (
"你是端云协同 LLM 系统中的智能体(Agent)。你拥有工作区文件工具:"
"list_dir(列目录)、read_file(读文件)、write_file(写文件)。"
"像程序员助手一样工作:先列目录/读文件了解现状,需要时再写文件;"
"任务完成或给出结论后,直接输出给用户的最终答复(中文,不要再调用工具)。"
)
# ─────────────────────────────────────────────────────────────────────────────
# OpenAI 兼容工具调用客户端
# ─────────────────────────────────────────────────────────────────────────────
class OpenAICompatChat:
"""ToolLoop.chat_fn 的 OpenAI 兼容实现(支持 tools 参数)。"""
def __init__(
self,
base_url: str,
api_key: Optional[str],
model: str,
temperature: float = 0.3,
max_tokens: int = 4096,
timeout_s: float = 120.0,
transport: Any = None,
_client: Any = None,
):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model
self.temperature = temperature
self.max_tokens = max_tokens
self.timeout_s = timeout_s
self._transport = transport
self._client = _client
self._owns = _client is None
def _get_client(self):
if self._client is None:
import httpx
kwargs: Dict[str, Any] = {"timeout": self.timeout_s}
if self._transport is not None:
kwargs["transport"] = self._transport
self._client = httpx.AsyncClient(**kwargs)
return self._client
async def aclose(self) -> None:
if self._owns and self._client is not None:
await self._client.aclose()
self._client = None
async def __call__(self, messages: List[Dict[str, Any]],
tools_spec: List[Dict[str, Any]]) -> Dict[str, Any]:
body: Dict[str, Any] = {
"model": self.model,
"messages": messages,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
}
if tools_spec:
body["tools"] = tools_spec
body["tool_choice"] = "auto"
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
client = self._get_client()
resp = await client.post(f"{self.base_url}/chat/completions",
headers=headers, json=body)
resp.raise_for_status()
data = resp.json()
msg = (data.get("choices") or [{}])[0].get("message") or {}
# tool_calls 解析放这里(网关层),内核 tools.parse_tool_calls 供其他调用方复用
from router_system.tools import parse_tool_calls
return {
"content": msg.get("content"),
"tool_calls": parse_tool_calls(msg),
"usage": data.get("usage") or {},
}
# ─────────────────────────────────────────────────────────────────────────────
# 智能体服务
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class AgentRunInfo:
"""一次智能体运行的状态快照(内存 + status.json 双写)。"""
request_id: str
task: str = ""
model: str = ""
state: str = STATE_RUNNING
started_at: float = 0.0
finished_at: float = 0.0
error: Optional[str] = None
response: str = ""
rounds: int = 0
prompt_tokens: int = 0
completion_tokens: int = 0
pool_id: str = ""
asyncio_task: Optional[asyncio.Task] = field(default=None, repr=False)
def to_dict(self) -> Dict[str, Any]:
return {
"request_id": self.request_id,
"task": self.task,
"model": self.model,
"state": self.state,
"started_at": self.started_at,
"finished_at": self.finished_at,
"error": self.error,
"response": self.response,
"rounds": self.rounds,
"prompt_tokens": self.prompt_tokens,
"completion_tokens": self.completion_tokens,
"pool_id": self.pool_id,
}
class AgentService:
"""智能体运行服务:事件落盘 + 状态管理。"""
def __init__(self, run_dir: str | Path = AGENT_RUNS_DIR):
self.run_dir = Path(run_dir)
self._runs: Dict[str, AgentRunInfo] = {}
self.max_running = 5
# ---------- 路径 ----------
def _dir(self, request_id: str) -> Path:
return self.run_dir / request_id
def events_path(self, request_id: str) -> Path:
return self._dir(request_id) / "events.jsonl"
def status_path(self, request_id: str) -> Path:
return self._dir(request_id) / "status.json"
# ---------- 注册与查询 ----------
def register(self, request_id: str, task: str, model: str, pool_id: str) -> Optional[AgentRunInfo]:
running = [r for r in self._runs.values() if r.state == STATE_RUNNING]
if len(running) >= self.max_running:
return None
info = AgentRunInfo(request_id=request_id, task=task, model=model,
pool_id=pool_id, started_at=time.time())
self._runs[request_id] = info
self._dir(request_id).mkdir(parents=True, exist_ok=True)
self._write_status(info)
return info
def get(self, request_id: str) -> Optional[AgentRunInfo]:
return self._runs.get(request_id)
# ---------- 执行 ----------
async def run(self, info: AgentRunInfo, chat: Any, workspace_dir: str | Path,
max_rounds: int = 8, token_cap: int = 0) -> None:
"""执行智能体任务(由调用方包成后台协程)。"""
tools = WorkspaceTools(workspace_dir)
loop = ToolLoop(tools, chat, max_rounds=max_rounds, token_cap=token_cap,
on_event=self._make_event_writer(info))
try:
result = await loop.run(info.task, system=AGENT_SYSTEM_PROMPT)
info.response = result.get("response", "")
info.rounds = int(result.get("rounds", 0))
info.prompt_tokens = int(result.get("prompt_tokens", 0))
info.completion_tokens = int(result.get("completion_tokens", 0))
if result.get("reason") == "error":
info.state = STATE_FAILED
info.error = result.get("error")
elif result.get("reason") in ("token_cap", "max_rounds"):
# 触顶属于护栏行为:结果仍交付,但标记部分完成信息
info.state = STATE_DONE
info.error = result.get("error")
else:
info.state = STATE_DONE
except Exception as exc: # pragma: no cover
info.state = STATE_FAILED
info.error = f"{type(exc).__name__}: {exc}"
self._append_event(info, {"type": "final", "round": info.rounds,
"reason": "error", "error": info.error})
finally:
info.finished_at = time.time()
self._write_status(info)
# ---------- 事件 ----------
def _make_event_writer(self, info: AgentRunInfo):
def _on_event(ev: Dict[str, Any]) -> None:
self._append_event(info, ev)
return _on_event
def _append_event(self, info: AgentRunInfo, ev: Dict[str, Any]) -> None:
ev = {"ts": time.time(), **ev}
try:
with self.events_path(info.request_id).open("a", encoding="utf-8") as f:
f.write(json.dumps(ev, ensure_ascii=False) + "\n")
except OSError:
pass
def read_events(self, request_id: str) -> List[Dict[str, Any]]:
p = self.events_path(request_id)
if not p.exists():
return []
out = []
for line in p.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except json.JSONDecodeError:
pass # 半行(正在写入)忽略
return out
# ---------- 状态 ----------
def _write_status(self, info: AgentRunInfo) -> None:
try:
self.status_path(info.request_id).write_text(
json.dumps(info.to_dict(), ensure_ascii=False, indent=2),
encoding="utf-8")
except OSError:
pass
async def watch_events(self, request_id: str, cancel_event: asyncio.Event,
poll_interval: float = 0.3, max_seconds: float = 900.0):
"""SSE 生成器:增量推送 events.jsonl 新行,直到终态/取消/超时。
从文件头开始回放(晚加入的订阅者也能看到完整过程)。
"""
p = self.events_path(request_id)
offset = 0
deadline = time.time() + max_seconds
while not cancel_event.is_set() and time.time() < deadline:
if p.exists():
try:
size = p.stat().st_size
if size > offset:
with p.open("r", encoding="utf-8") as f:
f.seek(offset)
new_text = f.read()
offset = f.tell()
for line in new_text.splitlines():
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
yield ev
if ev.get("type") == "final":
return
except OSError:
pass
info = self.get(request_id)
if info and info.state in (STATE_DONE, STATE_FAILED):
# 终态兜底:状态已结束但可能没有 final 事件(如注册即失败)
yield {"type": "final", "round": info.rounds,
"reason": "answer" if info.state == STATE_DONE else "error",
"error": info.error}
return
await asyncio.sleep(poll_interval)
yield {"type": "final", "round": 0, "reason": "error", "error": "订阅超时"}
# ---------- 全局单例 ----------
_service: Optional[AgentService] = None
def get_agent_service() -> AgentService:
global _service
if _service is None:
_service = AgentService()
return _service
def reset_agent_service() -> None:
"""测试用:重置全局智能体服务单例。"""
global _service
_service = None
def new_request_id() -> str:
return "ag" + uuid.uuid4().hex[:10]
+618 -30
View File
@@ -12,6 +12,16 @@ v2 端点(《实现方案_v2》5.3):
"""
from __future__ import annotations
import os as _os
from pathlib import Path as _Path
# 加载项目根目录的 .env 文件(包含 DEEPSEEK_API_KEY 等密钥)
_dotenv_path = _Path(__file__).resolve().parent.parent / ".env"
if _dotenv_path.exists():
from dotenv import load_dotenv
load_dotenv(_dotenv_path)
import asyncio
from pathlib import Path
from typing import List, Optional
@@ -20,6 +30,14 @@ from pydantic import BaseModel, Field
from router_system.config import load_config
from router_system.router import Router, build_router
from gateway.jobs import get_job_store, JobStore
from gateway.model_pool import (
compute_cost,
entry_to_architect_cfg,
entry_to_worker_cfg,
get_pool,
)
# ---- v2 依赖(惰性导入,缺依赖时降级提示) ----
try:
from router_system.architect import build_architect
@@ -84,14 +102,22 @@ def build_v2_pipeline(worker_cfg_override: Optional[dict] = None):
s = settings_store().to_dict() if _V2_OK else {}
kb = KnowledgeBase()
# architect(合并用户设置)
# architect(合并用户设置;模型池指派优先——多价位模型,D1
acfg = dict(cfg.get("architect", {}))
acfg.update(s.get("architect", {}))
pool = get_pool()
pe = pool.resolve("architect")
if pe is not None:
acfg.update(entry_to_architect_cfg(pe))
architect = build_architect(acfg)
# worker(合并用户设置;backend 可 mock/openai/llama_server
# worker(合并用户设置;backend 可 mock/openai/llama_server
# 模型池指派优先,测试注入 override 最后生效)
wcfg = dict(cfg.get("worker", {}))
wcfg.update(s.get("worker", {}))
pw = pool.resolve("worker")
if pw is not None:
wcfg.update(entry_to_worker_cfg(pw))
if worker_cfg_override:
wcfg.update(worker_cfg_override)
worker = build_worker(wcfg, kb=kb)
@@ -124,6 +150,10 @@ class QueryRequest(BaseModel):
domain_group: Optional[str] = Field(
None, description="大领域组(两级路由第一级):tech | professional | lifestyle | general;不指定则自动检测"
)
mode: str = Field(
"fast",
description="执行模式:fast(快路径,小模型直答优先)| full(完整协作,DeepSeek 架构师全程参与)"
)
class HealthResponse(BaseModel):
@@ -137,9 +167,11 @@ class HealthResponse(BaseModel):
# ---- FastAPI 应用 ----
try:
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse
_INDEX_PATH = Path(__file__).resolve().parent / "static" / "index.html"
_STATIC_DIR = Path(__file__).resolve().parent / "static"
app = FastAPI(
title="端云协同 LLM 协作系统",
@@ -147,43 +179,150 @@ try:
version="2.0.0",
)
# Vue SPA 静态资源(html=True:对不存在的路径 fallback 到 index.html,支持 SPA 路由)
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR), html=True), name="static")
@app.get("/", response_class=HTMLResponse, tags=["ui"])
async def index():
"""Vue SPA 的 index.htmlFastAPI API 路由优先,此处仅作 fallback)。"""
if _INDEX_PATH.exists():
return HTMLResponse(_INDEX_PATH.read_text(encoding="utf-8"))
return HTMLResponse("<h1>端云协同 LLM 系统</h1><p>请先构建前端:cd webapp && npm run build</p>")
@app.get("/health", response_model=HealthResponse, tags=["system"])
async def health():
return get_router().health()
@app.get("/", response_class=HTMLResponse, tags=["ui"])
async def index():
"""端云协同 Web 界面(单文件前端,无需构建)。"""
if _INDEX_PATH.exists():
return HTMLResponse(_INDEX_PATH.read_text(encoding="utf-8"))
return HTMLResponse("<h1>Web 界面未生成</h1><p>缺少 gateway/static/index.html</p>")
# ---------------- v2/chat ----------------
# Vue SPA(由 StaticFiles mount 在 / 路径提供)
@app.post("/chat", tags=["chat"])
async def chat(req: QueryRequest):
"""立即返回 request_id,协作管线在后台 asyncio.Task 中运行。
完成后结果写入 runs/{id}/workspace.json(由 pipeline 内部完成),
状态通过 GET /runs/{id}/status 查询,SSE 通过 /runs/{id}/stream 订阅。
"""
import uuid
request_id = uuid.uuid4().hex[:12]
run_dir = Path("runs") / request_id
run_dir.mkdir(parents=True, exist_ok=True)
# 注册任务(容量满时拒绝)
ok, msg = get_job_store().register(request_id)
if not ok:
raise HTTPException(status_code=503, detail=msg)
# 提交后台协程
async def _run():
job_store = get_job_store()
try:
result = await get_pipeline().run(req.query)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# 记账 + 入队
pipeline = get_pipeline()
# mode=full 时临时跳过快路径,直接走架构师协作(DeepSeek V4)
if req.mode == "full":
old_fast = pipeline.fast_path
pipeline.fast_path = False
try:
result = await pipeline.run(req.query, request_id=request_id)
finally:
pipeline.fast_path = old_fast
else:
result = await pipeline.run(req.query, request_id=request_id)
if _v2stats is not None:
# 按模型池单价分账(条目未命中时 cost_est 保持管线原值)
entry = get_pool().find_by_model(result.model_used or "")
if entry is not None:
result.cost_est = compute_cost(
entry, result.api_input_tokens, result.api_output_tokens)
_v2stats.record(result)
_maybe_enqueue(result)
return {
"response": result.response,
"request_id": result.request_id,
"status": result.status,
"fast_path": result.fast_path,
"rounds_used": result.rounds_used,
"api_input_tokens": result.api_input_tokens,
"api_output_tokens": result.api_output_tokens,
"cost_est": result.cost_est,
"model_used": result.model_used,
"latency_ms": round(result.latency_ms, 2),
"route": result.route,
"workspace_path": result.workspace_path,
"error": result.error,
job_store._task_done(request_id, result)
except Exception as exc:
import traceback
traceback.print_exc()
job_store._task_failed(request_id, "pipeline_error", detail=str(exc))
# 写一份失败状态到 workspace.jsonpipeline 异常时)
try:
import json
fail_ws = {
"request_id": request_id,
"query": req.query,
"status": "failed",
"error": str(exc),
"brief": None, "plan": [], "progress": [],
"issues": [], "decisions": [], "archive": [],
"meta": {"state": "failed", "round": 0, "rounds_cap": 6,
"api_input_tokens": 0, "api_output_tokens": 0},
}
(run_dir / "workspace.json").write_text(
json.dumps(fail_ws, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
pass
get_job_store().submit(request_id, _run())
return {"request_id": request_id, "status": "pending"}
# ---------------- v2:任务状态 ----------------
@app.get("/runs/{request_id}/status", tags=["v2"])
async def get_run_status(request_id: str):
"""查询任务当前状态(pending / running / done / failed)。"""
info = get_job_store().get(request_id)
if info is None:
raise HTTPException(status_code=404, detail=f"任务 {request_id} 不存在")
ws_state = None
ws_path = Path("runs") / request_id / "workspace.json"
if ws_path.exists():
import json
try:
ws_data = json.loads(ws_path.read_text(encoding="utf-8"))
ws_state = ws_data.get("meta", {}).get("state") or ws_data.get("status")
except Exception:
ws_state = "done"
return {
"request_id": request_id,
"status": info.state, # pending | running | done | failed
"ws_state": ws_state,
"started_at": info.started_at,
"finished_at": info.finished_at,
"error": info.error,
# PipelineResult 等效字段(扁平存储在 TaskInfo 中)
"response": info.response,
"pipeline_status": info.status,
"fast_path": info.fast_path,
"rounds_used": info.rounds_used,
"api_input_tokens": info.api_input_tokens,
"api_output_tokens": info.api_output_tokens,
"cost_est": info.cost_est,
"model_used": info.model_used,
"latency_ms": round(info.latency_ms, 2) if info.latency_ms else None,
"route": info.route,
"workspace_path": info.workspace_path,
}
# ---------------- v2SSE 实时流 ----------------
@app.get("/runs/{request_id}/stream", tags=["v2"])
async def stream_run(request_id: str):
"""SSE 端点:实时推送 workspace.json 状态变化(供前端协作可视化)。"""
from fastapi.responses import StreamingResponse
async def event_generator():
import json # noqa: F401
cancel_event = get_job_store().new_cancel(request_id)
try:
async for ev in get_job_store().watch_workspace(request_id, cancel_event):
payload = json.dumps(ev, ensure_ascii=False)
yield f"data: {payload}\n\n"
finally:
cancel_event.set()
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# ---------------- v1/chat/legacy ----------------
@app.post("/chat/legacy", tags=["chat"])
@@ -251,6 +390,225 @@ try:
raise HTTPException(status_code=404, detail=f"审核记录不存在或已审核: {review_id}")
return {"ok": True, "review_id": review_id, "verdict": verdict}
# ---------------- 模型池(多价位异构模型) ----------------
@app.get("/pool", tags=["pool"])
async def pool_list():
"""读取模型池(角色指派 + 条目列表,api_key 打码)。"""
return get_pool().list()
@app.post("/pool", tags=["pool"])
async def pool_upsert(entry: dict):
"""新增或更新池条目(按 id)。api_key 留空表示保留原值。"""
try:
get_pool().upsert(entry)
except ValueError as e:
raise HTTPException(status_code=400, detail=f"条目非法: {e}")
rebuild_pipeline()
return get_pool().list()
@app.delete("/pool/{entry_id}", tags=["pool"])
async def pool_delete(entry_id: str):
ok = get_pool().delete(entry_id)
if ok:
rebuild_pipeline()
return {"ok": ok, **get_pool().list()}
@app.put("/pool/roles", tags=["pool"])
async def pool_roles(roles: dict):
"""指派角色:{"architect": "<id|>", "worker": "<id|>", "agent": "<id|>"}。
空串 = 沿用经典单模型设置。"""
try:
get_pool().set_roles(roles)
except ValueError as e:
raise HTTPException(status_code=400, detail=f"指派非法: {e}")
rebuild_pipeline()
return get_pool().list()
@app.post("/pool/{entry_id}/test", tags=["pool"])
async def pool_test(entry_id: str):
"""连通性测试:用条目自身的端点/凭据探测 /models。"""
entry = get_pool().get(entry_id)
if entry is None:
raise HTTPException(status_code=404, detail=f"条目不存在: {entry_id}")
if entry["backend"] == "mock":
return {"ok": True, "detail": "mock 后端无需测试"}
r = await _probe_backend(entry["backend"], entry["base_url"], entry.get("api_key", ""))
return r
@app.get("/pool/{entry_id}/models", tags=["pool"])
async def pool_models(entry_id: str):
"""探测该条目端点下的可用模型列表。"""
entry = get_pool().get(entry_id)
if entry is None:
raise HTTPException(status_code=404, detail=f"条目不存在: {entry_id}")
if entry["backend"] == "mock":
return {"models": [{"id": "mock", "name": "mock(内置模拟)"}]}
return await _list_backend_models(
entry["backend"], entry["base_url"], entry.get("api_key", ""))
# ---------------- 智能体(工具调用,zcode 式文件操作) ----------------
def _resolve_agent_chat(pool_id: str = ""):
"""解析智能体模型:显式 pool_id > 池 agent 角色 > 经典 Architect 设置。
返回 (OpenAICompatChat, model_name, pool_id);无法解析时抛 HTTPException。
"""
from gateway.agent import OpenAICompatChat
pool = get_pool()
entry = pool.get(pool_id) if pool_id else pool.resolve("agent")
if entry is not None:
if entry["backend"] == "llama_server" and not entry.get("api_key"):
chat = OpenAICompatChat(
base_url=entry["base_url"] or "http://127.0.0.1:8901/v1",
api_key=None, model=entry["model"],
temperature=float(entry.get("temperature", 0.3)),
max_tokens=int(entry.get("max_tokens", 4096)))
elif entry["backend"] == "mock":
raise HTTPException(status_code=400,
detail="mock 模型不支持智能体工具调用,请选择真实模型")
else:
chat = OpenAICompatChat(
base_url=entry["base_url"], api_key=entry.get("api_key") or None,
model=entry["model"],
temperature=float(entry.get("temperature", 0.3)),
max_tokens=int(entry.get("max_tokens", 4096)))
return chat, entry["model"], entry["id"]
# 经典回退:Architect 设置(api_key 走 .env
import os
s = settings_store().to_dict()
acfg = dict(load_config().get("architect", {}))
acfg.update(s.get("architect", {}))
key = acfg.get("api_key") or os.environ.get(acfg.get("api_key_env", "DEEPSEEK_API_KEY"))
if not key:
raise HTTPException(
status_code=400,
detail="未配置大模型 API Key:请在模型池指派 agent 角色(填 key),或在设置中配置 Architect API Key")
chat = build_agent_chat(acfg)
return chat, acfg.get("model", "unknown"), ""
def build_agent_chat(acfg: dict):
"""经典设置 -> OpenAICompatChat(独立函数便于测试注入替身)。"""
from gateway.agent import OpenAICompatChat
import os
key = acfg.get("api_key") or os.environ.get(acfg.get("api_key_env", "DEEPSEEK_API_KEY"))
return OpenAICompatChat(
base_url=acfg.get("base_url", "https://api.deepseek.com"),
api_key=key,
model=acfg.get("model", "deepseek-v4-flash"),
temperature=float(acfg.get("temperature", 0.2)),
max_tokens=int(acfg.get("max_tokens", 4096)),
)
@app.post("/agent", tags=["agent"])
async def agent_run(req: dict):
"""提交智能体任务:{"task": "...", "pool_id": "可选模型条目"}。
立即返回 request_id;过程事件经 GET /agent/{id}/stream (SSE) 推送。
"""
from gateway.agent import get_agent_service, new_request_id
task = str((req or {}).get("task") or "").strip()
if not task:
raise HTTPException(status_code=400, detail="task 不能为空")
if len(task) > 8000:
raise HTTPException(status_code=400, detail="task 过长(>8000")
pool_id = str((req or {}).get("pool_id") or "")
chat, model, used_pool_id = _resolve_agent_chat(pool_id)
service = get_agent_service()
request_id = new_request_id()
info = service.register(request_id, task, model, used_pool_id)
if info is None:
raise HTTPException(status_code=503, detail="智能体同时运行任务已达上限")
s = settings_store().to_dict()
agent_cfg = s.get("agent", {})
async def _run():
try:
await service.run(
info, chat,
workspace_dir=agent_cfg.get("workspace_dir", "agent_workspace"),
max_rounds=int(agent_cfg.get("max_rounds", 8)),
token_cap=int(agent_cfg.get("token_cap", 20000)),
)
except Exception as exc:
import traceback
traceback.print_exc()
info.state = "failed"
info.error = str(exc)
info.finished_at = __import__("time").time()
service._write_status(info)
info.asyncio_task = asyncio.create_task(_run())
return {"request_id": request_id, "status": "running", "model": model}
@app.get("/agent/{request_id}/status", tags=["agent"])
async def agent_status(request_id: str):
from gateway.agent import get_agent_service
info = get_agent_service().get(request_id)
if info is None:
# 尝试磁盘恢复(服务重启后仍可查历史)
p = get_agent_service().status_path(request_id)
if p.exists():
import json
return json.loads(p.read_text(encoding="utf-8"))
raise HTTPException(status_code=404, detail=f"智能体任务不存在: {request_id}")
return info.to_dict()
@app.get("/agent/{request_id}/events", tags=["agent"])
async def agent_events(request_id: str):
"""完整事件列表(JSON,刷新后恢复用)。"""
from gateway.agent import get_agent_service
return get_agent_service().read_events(request_id)
@app.get("/agent/{request_id}/stream", tags=["agent"])
async def agent_stream(request_id: str):
"""SSE:实时推送智能体过程事件(round/tool_call/tool_result/usage/final)。"""
from fastapi.responses import StreamingResponse
from gateway.agent import get_agent_service
async def event_generator():
import json
cancel = asyncio.Event()
try:
async for ev in get_agent_service().watch_events(request_id, cancel):
yield f"data: {json.dumps(ev, ensure_ascii=False)}\n\n"
finally:
cancel.set()
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive",
"X-Accel-Buffering": "no"},
)
@app.get("/agent/workspace", tags=["agent"])
async def agent_workspace(path: str = ""):
"""列出智能体工作区(默认根目录;path 指定子目录)。越界返回 400。"""
from router_system.tools import ToolError, WorkspaceTools
s = settings_store().to_dict()
tools = WorkspaceTools(s.get("agent", {}).get("workspace_dir", "agent_workspace"))
try:
return tools.list_dir(path)
except ToolError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/agent/file", tags=["agent"])
async def agent_file(path: str):
"""读取智能体工作区内文件(前端预览,越界即 400)。"""
from router_system.tools import ToolError, WorkspaceTools
s = settings_store().to_dict()
tools = WorkspaceTools(s.get("agent", {}).get("workspace_dir", "agent_workspace"))
try:
result = tools.read_file(path)
except ToolError as e:
raise HTTPException(status_code=400, detail=str(e))
if not result.get("ok"):
raise HTTPException(status_code=404, detail=result.get("error", "读取失败"))
return result
# ---------------- 模型设置(用户可调整) ----------------
@app.get("/config", tags=["settings"])
async def get_config():
@@ -276,8 +634,238 @@ try:
rebuild_pipeline()
return merged
# ---------------- metrics ----------------
@app.get("/metrics", tags=["system"])
# ---------------- 模型发现 & 验证(/config 与 /pool 共用) ----------------
async def _list_backend_models(backend: str, base_url: str = "", api_key: str = "") -> dict:
"""探测 OpenAI 兼容端点的模型列表。返回 {"models":[...]} 或 {"error": "..."}。"""
import httpx
if backend == "llama_server":
url = (base_url or f"http://127.0.0.1:{settings_store().get('worker','port',8901)}/v1") + "/models"
elif backend == "openai":
if not base_url:
return {"error": "openai 后端需要填写 API 地址"}
url = base_url.rstrip("/") + "/models"
else:
return {"error": f"不支持的后端类型: {backend}"}
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
data = resp.json()
models = []
# OpenAI /models 格式:{"object": "list", "data": [{"id": "..."}]}
# Ollama /models 格式:{"models": [{"name": "..."}]}
raw = data.get("data") or data.get("models") or []
for m in raw:
mid = m.get("id") or m.get("name") or ""
if mid:
models.append({"id": mid, "name": mid})
return {"models": models}
except httpx.TimeoutException:
return {"error": "连接超时,模型服务可能未启动"}
except httpx.HTTPStatusError as e:
return {"error": f"HTTP {e.response.status_code}{e.response.text[:200]}"}
except Exception as e:
return {"error": str(e)}
async def _probe_backend(backend: str, base_url: str = "", api_key: str = "") -> dict:
"""后端连通性探测。返回 {"ok": bool, ...}。"""
import httpx
if backend == "llama_server":
url = (base_url or f"http://127.0.0.1:{settings_store().get('worker','port',8901)}/v1") + "/models"
elif backend == "openai":
if not base_url:
return {"ok": False, "detail": "openai 后端需要填写 API 地址"}
url = base_url.rstrip("/") + "/models"
else:
return {"ok": False, "detail": f"不支持的后端: {backend}"}
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
return {"ok": True, "status_code": resp.status_code}
except Exception as e:
return {"ok": False, "detail": str(e)}
@app.get("/config/models", tags=["settings"])
async def list_models(backend: str, base_url: str = "", api_key: str = ""):
"""探测指定后端支持的模型列表。
backend: llama_server | openai
base_url: 端点地址(llama_server 默认 http://127.0.0.1:8901/v1
api_key: 可选(云端 API 需要)
返回 {"models": [{"id": "...", "name": "..."}]}
"""
return await _list_backend_models(backend, base_url, api_key)
@app.get("/config/ping", tags=["settings"])
async def ping_backend(backend: str, base_url: str = "", api_key: str = ""):
"""验证后端连接是否可用(健康检查)。"""
return await _probe_backend(backend, base_url, api_key)
# ─────────────────────────────────────────────────────────────────────
# llama-server 内置管理
# ─────────────────────────────────────────────────────────────────────
from gateway.llama_manager import get_llama_manager
@app.get("/llama/status", tags=["llama"])
async def llama_status():
"""查询 llama-server 运行状态。"""
lm = get_llama_manager()
s = lm.status()
return {
"running": s.running,
"pid": s.pid,
"model": s.model,
"port": s.port,
"base_url": s.base_url,
"started_at": s.started_at,
"error": s.error,
}
@app.get("/llama/models", tags=["llama"])
async def llama_local_models():
"""列出本地已有模型文件(models/*.gguf)。"""
lm = get_llama_manager()
return {"models": lm.list_local_models()}
@app.post("/llama/start", tags=["llama"])
async def llama_start(
model: str,
port: int = 8901,
ngl: int = 99,
ctx: int = 4096,
):
"""启动本地 llama-server。
model: 模型文件路径(相对于项目根,或绝对路径)
port/ngl/ctx: 服务参数
"""
lm = get_llama_manager()
s = await lm.start(model=model, port=port, ngl=ngl, ctx=ctx)
return {
"running": s.running,
"pid": s.pid,
"model": s.model,
"port": s.port,
"base_url": s.base_url,
"error": s.error,
}
@app.post("/llama/stop", tags=["llama"])
async def llama_stop():
"""停止本地 llama-server。"""
lm = get_llama_manager()
await lm.stop()
return {"running": False}
@app.post("/llama/download", tags=["llama"])
async def llama_download(
url: str,
dest: Optional[str] = None,
):
"""从 HuggingFace 或直链下载 .gguf 模型到 models/ 目录。
支持 HuggingFace 路径别名,如 "Qwen/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf"
"""
lm = get_llama_manager()
# 检查是否已在下载
existing = lm.get_download_progress(url)
if existing and not existing.done:
return {
"url": url,
"dest": existing.dest,
"downloaded_bytes": existing.downloaded_bytes,
"total_bytes": existing.total_bytes,
"progress_pct": existing.progress_pct,
"speed": existing.speed,
"eta": existing.eta,
"done": False,
"error": None,
}
prog = await lm.download_model(url=url, dest=dest)
return {
"url": prog.url,
"dest": prog.dest,
"downloaded_bytes": prog.downloaded_bytes,
"total_bytes": prog.total_bytes,
"progress_pct": prog.progress_pct,
"speed": prog.speed,
"eta": prog.eta,
"done": prog.done,
"error": prog.error,
}
@app.get("/llama/download/stream", tags=["llama"])
async def llama_download_progress(url: str):
"""SSE:推送模型下载进度。"""
from fastapi.responses import StreamingResponse
async def event_gen():
lm = get_llama_manager()
last_pct = -1.0
while True:
prog = lm.get_download_progress(url)
if prog and (prog.done or abs(prog.progress_pct - last_pct) > 0.1):
payload = json.dumps({
"url": prog.url,
"dest": prog.dest,
"downloaded_bytes": prog.downloaded_bytes,
"total_bytes": prog.total_bytes,
"progress_pct": round(prog.progress_pct, 2),
"speed": prog.speed,
"eta": prog.eta,
"done": prog.done,
"error": prog.error,
}, ensure_ascii=False)
yield f"data: {payload}\n\n"
last_pct = prog.progress_pct
if prog and prog.done:
break
await asyncio.sleep(0.5)
return StreamingResponse(
event_gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive",
"X-Accel-Buffering": "no"},
)
# ─────────────────────────────────────────────────────────────────
# Vue SPA 页面路由(非 API,交给前端 Vue Router 处理)
# 这些路径返回 index.html,浏览器加载后由 Vue Router 渲染对应页面
# ─────────────────────────────────────────────────────────────────
@app.get("/", include_in_schema=False)
@app.get("/chat", include_in_schema=False)
@app.get("/collaboration", include_in_schema=False)
@app.get("/review", include_in_schema=False)
@app.get("/settings", include_in_schema=False)
@app.get("/agent", include_in_schema=False)
async def spa_page():
"""所有前端页面路径返回 Vue SPA index.html。"""
if _INDEX_PATH.exists():
return HTMLResponse(_INDEX_PATH.read_text(encoding="utf-8"))
return HTMLResponse("<h1>端云协同 LLM 系统</h1><p>请构建前端:cd webapp && npm run build</p>")
# ---------------- metrics APIJSON REST 端点)--------------------
# 前端通过 axios GET /api/metrics 调用此端点获取指标数据
@app.get("/metrics", include_in_schema=False)
async def metrics_page():
return await metrics()
@app.get("/api/metrics", tags=["system"])
async def metrics():
out = {
"router": get_router().stats.summary(),
+270
View File
@@ -0,0 +1,270 @@
"""模型池(PoolStore)—— 多价位异构模型注册表。
设计(《实现方案_v4_模型池与工具智能体.md》D1):
- 叙事从"端云分工"泛化为"按价位分工":local(零边际成本,内置 llama.cpp)、
budget(低价 API)、premium(高价 API)。位置只是价位的属性之一。
- 池条目存"端点 + 凭据 + 模型名 + 价位 + 单价($/1M tokens",不存模型权重。
- roles 把池条目指派给三个角色:architect(决策/终审)、worker(实现/自验证)、
agent(智能体工具循环)。角色留空 = 沿用经典单模型设置(向后兼容)。
- 持久化到 config/model_pool.jsongitignore,与 settings.json 同级)。
"""
import json
import re
import threading
from pathlib import Path
from typing import Any, Dict, Optional
_POOL_PATH = Path(__file__).resolve().parent.parent / "config" / "model_pool.json"
# 合法取值
TIERS = ("local", "budget", "premium")
BACKENDS = ("mock", "llama_server", "openai")
ROLES = ("architect", "worker", "agent")
# 池条目允许的字段(其余字段拒绝写入)
ENTRY_FIELDS = {
"id", "name", "tier", "backend", "base_url", "model", "api_key",
"price_in", "price_out", "temperature", "max_tokens", "enabled",
}
# 单价默认值($/1M tokens);local 档为 0
PRICE_DEFAULTS = {"local": 0.0, "budget": 0.1, "premium": 1.0}
def _empty_pool() -> Dict[str, Any]:
return {
"roles": {"architect": "", "worker": "", "agent": ""},
"entries": [],
}
class PoolError(ValueError):
"""池条目/角色配置非法。"""
class PoolStore:
"""模型池注册表(内存 + model_pool.json 持久化,线程安全)。"""
def __init__(self, path: Optional[Path] = None):
self._path = Path(path) if path else _POOL_PATH
self._lock = threading.Lock()
self._data = _empty_pool()
self.load()
# ---------- 持久化 ----------
def load(self) -> None:
if self._path.exists():
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
self._data = {
"roles": {**_empty_pool()["roles"],
**(raw.get("roles") or {})},
"entries": list(raw.get("entries") or []),
}
except Exception:
self._data = _empty_pool()
else:
self._data = _empty_pool()
def save(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
self._path.write_text(
json.dumps(self._data, ensure_ascii=False, indent=2), encoding="utf-8")
# ---------- 条目 CRUD ----------
def list(self) -> Dict[str, Any]:
"""返回完整池(api_key 打码)。"""
with self._lock:
return {
"roles": dict(self._data["roles"]),
"entries": [self._masked(e) for e in self._data["entries"]],
}
def get(self, entry_id: str) -> Optional[Dict[str, Any]]:
with self._lock:
for e in self._data["entries"]:
if e.get("id") == entry_id:
return dict(e)
return None
def upsert(self, entry: Dict[str, Any]) -> Dict[str, Any]:
"""新增或更新条目(按 id)。返回打码后的条目。"""
clean = self._validate(entry)
with self._lock:
entries = self._data["entries"]
for i, e in enumerate(entries):
if e.get("id") == clean["id"]:
# 空 api_key 表示保留原值(前端不回传明文)
if not clean.get("api_key"):
clean["api_key"] = e.get("api_key", "")
entries[i] = clean
self.save()
return self._masked(clean)
entries.append(clean)
self.save()
return self._masked(clean)
def delete(self, entry_id: str) -> bool:
with self._lock:
before = len(self._data["entries"])
self._data["entries"] = [
e for e in self._data["entries"] if e.get("id") != entry_id]
changed = len(self._data["entries"]) != before
if changed:
# 清空指向被删条目的角色指派
for role, rid in self._data["roles"].items():
if rid == entry_id:
self._data["roles"][role] = ""
self.save()
return changed
# ---------- 角色指派 ----------
def set_roles(self, roles: Dict[str, str]) -> Dict[str, str]:
"""指派角色 -> 池条目 id(空串 = 沿用经典设置)。"""
with self._lock:
ids = {e.get("id") for e in self._data["entries"]}
for role, rid in roles.items():
if role not in ROLES:
raise PoolError(f"未知角色: {role}")
if rid and rid not in ids:
raise PoolError(f"角色 {role} 指向不存在的模型条目: {rid}")
self._data["roles"][role] = rid or ""
self.save()
return dict(self._data["roles"])
def resolve(self, role: str) -> Optional[Dict[str, Any]]:
"""解析角色当前生效的池条目(未指派/条目禁用时返回 None = 用经典设置)。"""
if role not in ROLES:
return None
with self._lock:
rid = self._data["roles"].get(role, "")
for e in self._data["entries"]:
if e.get("id") == rid:
return dict(e) if e.get("enabled", True) else None
return None
def find_by_model(self, model: str) -> Optional[Dict[str, Any]]:
"""按模型名找条目(用于按模型计价分账)。"""
with self._lock:
for e in self._data["entries"]:
if e.get("model") == model:
return dict(e)
return None
# ---------- 校验与工具 ----------
def _validate(self, entry: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(entry, dict):
raise PoolError("条目必须是对象")
unknown = set(entry) - ENTRY_FIELDS
if unknown:
raise PoolError(f"非法字段: {sorted(unknown)}")
eid = str(entry.get("id") or "").strip()
if not eid:
# 未提供 id 时按名称生成 slug
base = re.sub(r"[^a-zA-Z0-9_-]+", "-",
str(entry.get("name") or entry.get("model") or "model")).strip("-").lower()
eid = base or "model"
with self._lock:
exist = {e.get("id") for e in self._data["entries"]}
if eid in exist:
n = 2
while f"{eid}-{n}" in exist:
n += 1
eid = f"{eid}-{n}"
elif not re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", eid):
raise PoolError("id 只允许字母/数字/-/_,长度 1-64")
backend = entry.get("backend", "openai")
if backend not in BACKENDS:
raise PoolError(f"backend 必须是 {BACKENDS} 之一")
tier = entry.get("tier", "budget")
if tier not in TIERS:
raise PoolError(f"tier 必须是 {TIERS} 之一")
if backend != "mock" and not str(entry.get("base_url") or "").strip():
raise PoolError("非 mock 后端必须填写 base_url")
if backend != "mock" and not str(entry.get("model") or "").strip():
raise PoolError("非 mock 后端必须填写 model")
try:
price_in = float(entry.get("price_in", PRICE_DEFAULTS[tier]))
price_out = float(entry.get("price_out", PRICE_DEFAULTS[tier]))
except (TypeError, ValueError):
raise PoolError("price_in/price_out 必须是数字")
if price_in < 0 or price_out < 0:
raise PoolError("单价不能为负")
try:
temperature = float(entry.get("temperature", 0.3))
except (TypeError, ValueError):
temperature = 0.3
try:
max_tokens = int(entry.get("max_tokens", 4096))
except (TypeError, ValueError):
max_tokens = 4096
return {
"id": eid,
"name": str(entry.get("name") or entry.get("model") or eid),
"tier": tier,
"backend": backend,
"base_url": str(entry.get("base_url") or "").strip(),
"model": str(entry.get("model") or "").strip(),
"api_key": str(entry.get("api_key") or ""),
"price_in": price_in,
"price_out": price_out,
"temperature": temperature,
"max_tokens": max_tokens,
"enabled": bool(entry.get("enabled", True)),
}
@staticmethod
def _masked(entry: Dict[str, Any]) -> Dict[str, Any]:
out = dict(entry)
key = out.get("api_key") or ""
out["api_key_set"] = bool(key)
out["api_key"] = (key[:6] + "") if key else ""
return out
def entry_to_architect_cfg(entry: Dict[str, Any]) -> Dict[str, Any]:
"""池条目 -> build_architect 配置段。"""
cfg: Dict[str, Any] = {
"model": entry.get("model") or "local",
"base_url": entry.get("base_url") or "http://127.0.0.1:8901/v1",
"temperature": float(entry.get("temperature", 0.2)),
}
if entry.get("api_key"):
cfg["api_key"] = entry["api_key"]
if entry.get("max_tokens"):
cfg["max_tokens"] = int(entry["max_tokens"])
return cfg
def entry_to_worker_cfg(entry: Dict[str, Any]) -> Dict[str, Any]:
"""池条目 -> build_worker 配置段。"""
return {
"backend": entry.get("backend") or "openai",
"base_url": entry.get("base_url") or "",
"model": entry.get("model") or "",
"temperature": float(entry.get("temperature", 0.3)),
}
def compute_cost(entry: Dict[str, Any], input_tokens: int, output_tokens: int) -> float:
"""按条目单价估算成本(USD)。price 单位:$/1M tokens。"""
return (input_tokens / 1e6) * float(entry.get("price_in", 0.0)) + \
(output_tokens / 1e6) * float(entry.get("price_out", 0.0))
# ---------- 全局单例 ----------
_store: Optional[PoolStore] = None
def get_pool() -> PoolStore:
global _store
if _store is None:
_store = PoolStore()
return _store
def reset_pool() -> None:
"""测试用:重置全局池单例。"""
global _store
_store = None
+9 -3
View File
@@ -22,12 +22,13 @@ DEFAULTS: Dict[str, Any] = {
"port": 8901, # llama_server 端口
"temperature": 0.3,
"max_fix_attempts": 2,
"per_step_timeout_s": 15,
"code_timeout_s": 10,
},
"architect": {
"model": "deepseek-chat",
"base_url": "https://api.deepseek.com/v1",
"api_key_env": "DEEPSEEK_API_KEY",
"model": "deepseek-v4-flash",
"base_url": "https://api.deepseek.com",
"api_key": "",
},
"pipeline": {
"fast_path": True,
@@ -35,6 +36,11 @@ DEFAULTS: Dict[str, Any] = {
"api_token_cap": 8000,
"breach_policy": "architect_do",
},
"agent": {
"workspace_dir": "agent_workspace", # 智能体工作区根目录(越界即拒)
"max_rounds": 8, # 工具循环轮数上限
"token_cap": 20000, # 单次智能体任务 token 熔断
},
}
+16
View File
@@ -22,6 +22,7 @@ class V2Stats:
self._api_input_tokens = 0
self._api_output_tokens = 0
self._api_cost_usd = 0.0
self._by_model: Dict[str, Dict[str, Any]] = {}
self._recent: List[Dict[str, Any]] = []
def record(self, result) -> None:
@@ -38,6 +39,17 @@ class V2Stats:
self._api_input_tokens += getattr(result, "api_input_tokens", 0)
self._api_output_tokens += getattr(result, "api_output_tokens", 0)
self._api_cost_usd += getattr(result, "cost_est", 0.0)
# 按模型分账(token / 成本 / 次数)
model = getattr(result, "model_used", None) or "unknown"
in_tok = getattr(result, "api_input_tokens", 0)
out_tok = getattr(result, "api_output_tokens", 0)
bucket = self._by_model.setdefault(model, {
"requests": 0, "input_tokens": 0, "output_tokens": 0, "cost_est_usd": 0.0,
})
bucket["requests"] += 1
bucket["input_tokens"] += in_tok
bucket["output_tokens"] += out_tok
bucket["cost_est_usd"] = round(bucket["cost_est_usd"] + getattr(result, "cost_est", 0.0), 6)
self._recent.append({
"request_id": getattr(result, "request_id", ""),
"status": status,
@@ -69,6 +81,10 @@ class V2Stats:
"total": self._api_input_tokens + self._api_output_tokens,
"cost_est_usd": round(self._api_cost_usd, 6),
},
"by_model": {
m: {**b, "cost_est_usd": round(b["cost_est_usd"], 6)}
for m, b in self._by_model.items()
},
}
+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)