"""zcbot `/v1` 黑盒客户端。""" from __future__ import annotations import ipaddress import os import time from typing import Any from urllib.parse import urljoin, urlparse import httpx from .models import RunObservation class EvalClientError(RuntimeError): """zcbot API 调用失败。""" def require_safe_base_url(base_url: str, *, allow_remote: bool) -> str: normalized = base_url.rstrip("/") + "/" parsed = urlparse(normalized) if parsed.scheme not in {"http", "https"} or not parsed.hostname: raise EvalClientError(f"无效 base_url: {base_url!r}") if allow_remote: return normalized host = parsed.hostname.lower() safe = host in {"localhost", "127.0.0.1", "::1"} if not safe: try: safe = ipaddress.ip_address(host).is_loopback except ValueError: safe = False if not safe: raise EvalClientError( f"默认只允许本机 zcbot,当前是 {host!r};确认测试环境后传 --allow-remote" ) return normalized def _response_text(payload: Any) -> str: if isinstance(payload, str): return payload if isinstance(payload, list): parts: list[str] = [] for item in payload: if isinstance(item, dict) and isinstance(item.get("text"), str): parts.append(item["text"]) elif isinstance(item, str): parts.append(item) return "\n".join(parts) return "" if payload is None else str(payload) class ZcbotClient: def __init__( self, *, base_url: str, token: str, allow_remote: bool = False, request_timeout_s: float = 30.0, ) -> None: self.base_url = require_safe_base_url(base_url, allow_remote=allow_remote) if not token.strip(): raise EvalClientError("缺少 token;请设置配置中 token_env 指向的环境变量") self._client = httpx.Client( base_url=self.base_url, headers={"Authorization": f"Bearer {token.strip()}"}, timeout=request_timeout_s, follow_redirects=False, # 评测 JWT 不得被 HTTP(S)_PROXY 透明转发;部署内网应直连测试实例。 trust_env=False, ) @classmethod def from_config( cls, raw: dict[str, Any], *, allow_remote: bool = False ) -> "ZcbotClient": token_env = str(raw.get("token_env", "ZCBOT_EVAL_TOKEN")) return cls( base_url=str(raw.get("base_url", "http://127.0.0.1:8765")), token=os.getenv(token_env, ""), allow_remote=allow_remote, request_timeout_s=float(raw.get("request_timeout_s", 30)), ) def close(self) -> None: self._client.close() def __enter__(self) -> "ZcbotClient": return self def __exit__(self, *_args: object) -> None: self.close() def _json(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]: response = self._client.request(method, path, **kwargs) if response.status_code >= 400: raise EvalClientError( f"{method} {path} -> {response.status_code}: {response.text[:500]}" ) try: body = response.json() except ValueError as exc: raise EvalClientError(f"{method} {path} 返回了非 JSON") from exc if not isinstance(body, dict): raise EvalClientError(f"{method} {path} 返回 JSON 顶层不是 object") return body def create_task( self, *, name: str, working_dir: str, skill: str, model_profile: str, ) -> dict[str, Any]: return self._json( "POST", "/v1/tasks", json={ "name": name, "working_dir": working_dir, "description": "zcbot evaluation task", "skill": skill, "model_profile": model_profile, }, ) def run_prompt( self, *, task_id: str, prompt: str, working_dir: str, timeout_s: float, ) -> RunObservation: started = time.monotonic() body = self._json( "POST", f"/v1/tasks/{task_id}/messages", json={"content": prompt} ) events_url = str(body.get("events_url", "")) if not events_url: raise EvalClientError("POST messages 未返回 events_url") stream_error = self._consume_events(events_url, timeout_s=timeout_s) # AgentLoop 的 done 事件可能早于 BG worker 最终把 task.run_status 写回 idle; # 短暂轮询终态,避免把已成功任务误记为 running。 terminal_deadline = time.monotonic() + min(15.0, timeout_s) while True: task = self._json("GET", f"/v1/tasks/{task_id}") if task.get("run_status") not in {"running", "cancelling"}: break if time.monotonic() >= terminal_deadline: break time.sleep(0.1) messages = self._json("GET", f"/v1/tasks/{task_id}/messages") response = "" for item in reversed(messages.get("messages", [])): payload = item.get("payload", {}) if isinstance(item, dict) else {} if isinstance(payload, dict) and payload.get("role") == "assistant": response = _response_text(payload.get("content")) break run_error = str(task.get("run_error") or stream_error or "") return RunObservation( response=response, duration_s=time.monotonic() - started, cost_cny=float(task.get("cost_cny") or 0), run_status=str(task.get("run_status") or ""), run_error=run_error, task_id=task_id, working_dir=working_dir, model_profile=str(task.get("model_profile") or ""), artifact_loader=lambda relative: self.download_artifact( working_dir, relative ), ) def _consume_events(self, path: str, *, timeout_s: float) -> str: deadline = time.monotonic() + timeout_s error = "" timeout = httpx.Timeout( connect=min(30.0, timeout_s), read=max(30.0, timeout_s), write=30.0, pool=30.0, ) with self._client.stream("GET", path, timeout=timeout) as response: if response.status_code >= 400: raise EvalClientError( f"GET {path} -> {response.status_code}: {response.read()[:500]!r}" ) event = "" data = "" for line in response.iter_lines(): if time.monotonic() > deadline: raise EvalClientError(f"任务运行超过 {timeout_s:.0f}s") if line.startswith("event:"): event = line[6:].strip() elif line.startswith("data:"): data = line[5:].strip() elif not line: if event == "error": error = data[:1000] if event in {"done", "error"}: return error event, data = "", "" return error def download_artifact(self, working_dir: str, relative_path: str) -> bytes: relative = relative_path.replace("\\", "/").strip("/") if not relative or ".." in relative.split("/"): raise FileNotFoundError(relative_path) path = f"{working_dir.strip('/')}/{relative}" response = self._client.get("/v1/files/download", params={"path": path}) if response.status_code == 404: raise FileNotFoundError(relative_path) if response.status_code >= 400: raise EvalClientError( f"GET /v1/files/download {path!r} -> {response.status_code}: " f"{response.text[:500]}" ) return response.content