Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """统一端到端测试入口 —— 加载 scenarios/ 下的 JSON 场景文件并执行 | |
| 用法: | |
| uv run python runner.py scenarios/basic # 全部 basic(两端点) | |
| uv run python runner.py scenarios/basic --endpoint openai # 仅 OpenAI | |
| uv run python runner.py scenarios/repair # 全部 repair | |
| uv run python runner.py scenarios/basic --filter 流式 # 按名称过滤 | |
| """ | |
| import argparse | |
| import json | |
| import sys | |
| import time | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| from datetime import datetime | |
| from pathlib import Path | |
| from typing import Any | |
| import httpx | |
| from openai import OpenAI | |
| from anthropic import Anthropic | |
| from config import load_config | |
| def load_scenarios(scenario_dir: str, endpoint: str | None, filter_names: list[str] | None) -> list[dict]: | |
| """加载场景 JSON 文件""" | |
| base = Path(scenario_dir) | |
| if not base.exists(): | |
| print(f"[错误] 场景目录不存在: {scenario_dir}") | |
| sys.exit(1) | |
| if base.name == "basic": | |
| dirs = [] | |
| if endpoint in (None, "openai"): | |
| dirs.append(base / "openai") | |
| if endpoint in (None, "anthropic"): | |
| dirs.append(base / "anthropic") | |
| else: | |
| dirs = [base] | |
| scenarios: list[dict] = [] | |
| for d in dirs: | |
| if not d.exists(): | |
| continue | |
| for fpath in sorted(d.glob("*.json")): | |
| with open(fpath) as f: | |
| sc = json.load(f) | |
| if filter_names and not any(f.lower() in sc.get("name", "").lower() for f in filter_names): | |
| continue | |
| scenarios.append(sc) | |
| if not scenarios: | |
| print(f"[错误] 未找到匹配的场景") | |
| sys.exit(1) | |
| return scenarios | |
| def _resolve_scenario(scenario: dict, model: str) -> dict[str, Any]: | |
| """将场景定义解析为 OpenAI API 参数""" | |
| # messages 可在顶层或 request 内 | |
| messages = scenario.get("messages") or scenario["request"]["messages"] | |
| system = scenario.get("system", "") | |
| if system: | |
| messages = [{"role": "system", "content": system}, *messages] | |
| kwargs: dict[str, Any] = dict(model=model, messages=messages) | |
| # 合并 request 中除 stream 外的参数 | |
| req = scenario.get("request", {}) | |
| kwargs.update({k: v for k, v in req.items() if k != "stream"}) | |
| if "tools" in scenario: | |
| kwargs["tools"] = scenario["tools"] | |
| if "tool_choice" in scenario: | |
| kwargs["tool_choice"] = scenario["tool_choice"] | |
| return kwargs | |
| def run_openai(client: OpenAI, scenario: dict, model: str) -> dict[str, Any]: | |
| """执行一次 OpenAI 端点场景""" | |
| name = scenario["name"] | |
| req_conf = scenario.get("request", {}) | |
| stream = req_conf.get("stream", False) | |
| start = time.time() | |
| result: dict[str, Any] = { | |
| "name": name, "model": model, "endpoint": "openai", | |
| "passed": False, "duration": 0.0, "error": None, | |
| } | |
| try: | |
| kwargs = _resolve_scenario(scenario, model) | |
| if stream: | |
| collected = _openai_stream_collect(client, **kwargs) | |
| choice = collected["choices"][0] | |
| else: | |
| resp = client.chat.completions.create(**kwargs) | |
| choice = resp.choices[0] | |
| result["duration"] = time.time() - start | |
| result["finish_reason"] = choice.finish_reason | |
| msg = choice.message | |
| result["content"] = msg.content or "" | |
| result["tool_calls"] = [ | |
| {"name": tc.function.name, "arguments": tc.function.arguments} | |
| for tc in (msg.tool_calls or []) | |
| ] | |
| result["has_tool_calls"] = len(result["tool_calls"]) > 0 | |
| # 执行 checks | |
| checks = scenario.get("checks", {}) | |
| errors = _check_openai(checks, result) | |
| if errors: | |
| result["error"] = "; ".join(errors) | |
| else: | |
| result["passed"] = True | |
| except Exception as e: | |
| result["duration"] = time.time() - start | |
| result["error"] = str(e) | |
| return result | |
| def _openai_stream_collect(client: OpenAI, **kwargs: Any) -> dict: | |
| """流式请求:收集所有 chunks 并组装为 quasi-Response dict""" | |
| kwargs["stream"] = True | |
| stream = client.chat.completions.create(**kwargs) | |
| content_parts: list[str] = [] | |
| tool_call_acc: dict[int, dict] = {} | |
| finish_reason: str | None = None | |
| for chunk in stream: | |
| if not chunk.choices: | |
| continue | |
| choice = chunk.choices[0] | |
| if choice.finish_reason: | |
| finish_reason = choice.finish_reason | |
| if choice.delta.content: | |
| content_parts.append(choice.delta.content) | |
| if choice.delta.tool_calls: | |
| for tc in choice.delta.tool_calls: | |
| idx = tc.index | |
| if idx not in tool_call_acc: | |
| tool_call_acc[idx] = { | |
| "id": tc.id or "", | |
| "function": {"name": "", "arguments": ""}, | |
| } | |
| if tc.id: | |
| tool_call_acc[idx]["id"] = tc.id | |
| if tc.function: | |
| if tc.function.name: | |
| tool_call_acc[idx]["function"]["name"] += tc.function.name | |
| if tc.function.arguments: | |
| tool_call_acc[idx]["function"]["arguments"] += tc.function.arguments | |
| tool_calls_list = sorted(tool_call_acc.values(), key=lambda x: list(tool_call_acc.keys())[list(tool_call_acc.values()).index(x)]) | |
| class FakeChoice: | |
| def __init__(self, finish: str | None, content: str | None, tcs: list): | |
| self.finish_reason = finish | |
| self.message = type("Msg", (), { | |
| "content": content, | |
| "tool_calls": [type("TC", (), {"function": type("Fn", (), tc["function"])}) for tc in tcs] if tcs else None, | |
| })() | |
| return {"choices": [FakeChoice(finish_reason, "".join(content_parts) or None, tool_calls_list)]} | |
| def _check_openai(checks: dict, result: dict) -> list[str]: | |
| errors: list[str] = [] | |
| if checks.get("content_not_empty") and not result.get("content"): | |
| errors.append("内容为空") | |
| if checks.get("has_tool_calls") and not result.get("has_tool_calls"): | |
| errors.append("未触发工具调用") | |
| if checks.get("finish_reason") and result.get("finish_reason") != checks["finish_reason"]: | |
| errors.append(f"finish_reason={result.get('finish_reason')}, 期望={checks['finish_reason']}") | |
| if checks.get("tool_names"): | |
| actual = {tc["name"] for tc in result.get("tool_calls", [])} | |
| expected = set(checks["tool_names"]) | |
| if not expected.issubset(actual): | |
| errors.append(f"工具名不匹配: 期望{expected}, 实际{actual}") | |
| return errors | |
| def run_anthropic(client: Anthropic, scenario: dict, model: str) -> dict[str, Any]: | |
| """执行一次 Anthropic 端点场景""" | |
| name = scenario["name"] | |
| req_conf = scenario.get("request", {}) | |
| start = time.time() | |
| result: dict[str, Any] = { | |
| "name": name, "model": model, "endpoint": "anthropic", | |
| "passed": False, "duration": 0.0, "error": None, | |
| } | |
| try: | |
| # Anthropic 的 messages 始终在 request 内 | |
| kwargs: dict[str, Any] = dict( | |
| model=model, | |
| **{k: v for k, v in req_conf.items() if k != "stream"}, | |
| ) | |
| stream = req_conf.get("stream", False) | |
| if stream: | |
| msg = _anthropic_stream_collect(client, **kwargs) | |
| else: | |
| msg = client.messages.create(**kwargs) | |
| result["duration"] = time.time() - start | |
| result["stop_reason"] = msg.stop_reason | |
| text_blocks = [] | |
| tool_uses = [] | |
| for block in msg.content: | |
| if block.type == "text": | |
| text_blocks.append(block.text) | |
| elif block.type == "tool_use": | |
| tool_uses.append({"name": block.name, "input": block.input}) | |
| result["content"] = "".join(text_blocks) | |
| result["tool_uses"] = tool_uses | |
| result["has_tool_use"] = len(tool_uses) > 0 | |
| checks = scenario.get("checks", {}) | |
| errors = _check_anthropic(checks, result) | |
| if errors: | |
| result["error"] = "; ".join(errors) | |
| else: | |
| result["passed"] = True | |
| except Exception as e: | |
| result["duration"] = time.time() - start | |
| result["error"] = str(e) | |
| return result | |
| def _anthropic_stream_collect(client: Anthropic, **kwargs: Any) -> Any: | |
| """流式请求:收集 Anthropic stream events""" | |
| kwargs = {k: v for k, v in kwargs.items() if v is not None} | |
| with client.messages.stream(**kwargs) as stream: | |
| return stream.get_final_message() | |
| def _check_anthropic(checks: dict, result: dict) -> list[str]: | |
| errors: list[str] = [] | |
| if checks.get("content_not_empty") and not result.get("content"): | |
| errors.append("内容为空") | |
| if checks.get("has_tool_use") and not result.get("has_tool_use"): | |
| errors.append("未触发工具调用") | |
| if checks.get("stop_reason") and result.get("stop_reason") != checks["stop_reason"]: | |
| errors.append(f"stop_reason={result.get('stop_reason')}, 期望={checks['stop_reason']}") | |
| if checks.get("tool_names"): | |
| actual = {tu["name"] for tu in result.get("tool_uses", [])} | |
| expected = set(checks["tool_names"]) | |
| if not expected.issubset(actual): | |
| errors.append(f"工具名不匹配: 期望{expected}, 实际{actual}") | |
| return errors | |
| def _print_output(result: dict) -> None: | |
| """打印模型输出内容(用于 --show-output)""" | |
| content = (result.get("content") or "")[:300].replace("\n", "\\n") | |
| if content: | |
| print(f" ├ 回复: {content}") | |
| if result.get("has_tool_calls") or result.get("has_tool_use"): | |
| calls = result.get("tool_calls") or result.get("tool_uses") or [] | |
| for tc in calls: | |
| name = tc.get("name", "?") | |
| args = tc.get("arguments") or tc.get("input") or {} | |
| args_str = json.dumps(args, ensure_ascii=False)[:120] | |
| print(f" ├ 工具: {name}({args_str})") | |
| fr = result.get("finish_reason") or result.get("stop_reason") or "" | |
| if fr: | |
| print(f" └ 结束: {fr}") | |
| if result.get("error"): | |
| print(f" └ 错误: {result['error']}") | |
| def format_duration(seconds: float) -> str: | |
| if seconds < 60: | |
| return f"{seconds:.1f}s" | |
| return f"{seconds / 60:.1f}m" | |
| def print_report(results: list[dict[str, Any]], suite_name: str, parallel: int): | |
| total = len(results) | |
| passed = sum(1 for r in results if r["passed"]) | |
| duration = sum(r["duration"] for r in results) | |
| print(f"\n{'=' * 60}") | |
| print(f" {suite_name}") | |
| print(f" 时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") | |
| print(f" 并行: {parallel}") | |
| print(f"{'=' * 60}") | |
| print(f" 总计: {total} | 通过: {passed} | 失败: {total - passed} | 总耗时: {format_duration(duration)}") | |
| ep_label = {"openai": "OAI", "anthropic": "ANT"} | |
| for r in sorted(results, key=lambda x: (x["name"], x.get("endpoint", ""), x["model"])): | |
| status = "✓" if r["passed"] else "✗" | |
| ep = ep_label.get(r.get("endpoint", ""), "?") | |
| err = f" | {r['error'][:60]}" if r["error"] else "" | |
| print(f" {status} {ep} | {r['name']} | {r['model']} | {r['duration']:6.1f}s{err}") | |
| if total - passed > 0: | |
| print(f"\n {'─' * 48}") | |
| print(f" 失败详情:") | |
| for r in results: | |
| if not r["passed"]: | |
| print(f" [{r['endpoint']}] {r['name']} ({r['model']}): {r['error']}") | |
| print(f"{'=' * 60}\n") | |
| return {"total": total, "passed": passed, "failed": total - passed, "duration": duration} | |
| def main(): | |
| config = load_config() | |
| safe_concurrency = config["safe_concurrency"] | |
| api_key = config["api_key"] | |
| parser = argparse.ArgumentParser(description="端到端测试统一入口") | |
| parser.add_argument("scenario_dir", help="场景目录 (如 scenarios/basic 或 scenarios/repair)") | |
| parser.add_argument("--endpoint", choices=["openai", "anthropic"], default=None, help="端点过滤") | |
| parser.add_argument("--model", type=str, default=None, help="模型过滤") | |
| parser.add_argument("--filter", type=str, nargs="*", default=None, help="场景名称关键字过滤(多个用空格分隔)") | |
| parser.add_argument("--parallel", type=int, default=safe_concurrency, help=f"并行数 (默认: {safe_concurrency})") | |
| parser.add_argument("--report", type=str, default=None, help="输出 JSON 报告路径") | |
| parser.add_argument("--show-output", action="store_true", help="显示模型输出内容") | |
| args = parser.parse_args() | |
| scenarios = load_scenarios(args.scenario_dir, args.endpoint, args.filter) | |
| # 模型来源:--model 参数优先,否则从 config.toml 动态获取 | |
| models = [args.model] if args.model else config.get("models", ["deepseek-default"]) | |
| port = config["port"] | |
| oai_client = OpenAI(base_url=f"http://127.0.0.1:{port}/v1", api_key=api_key) | |
| anth_client = Anthropic( | |
| base_url=f"http://127.0.0.1:{port}/anthropic", api_key=api_key, | |
| default_headers={"Authorization": f"Bearer {api_key}"}, | |
| http_client=httpx.Client(timeout=120), | |
| ) | |
| suite_name = f"{Path(args.scenario_dir).name} 测试" | |
| print(f"\n{suite_name}") | |
| print(f" 场景: {len(scenarios)} 个, 模型: {', '.join(models)}, 并行: {args.parallel}") | |
| tasks: list[tuple[str, str, dict]] = [] | |
| for model in models: | |
| for sc in scenarios: | |
| tasks.append((sc["endpoint"], model, sc)) | |
| all_results: list[dict[str, Any]] = [None] * len(tasks) # type: ignore[list-item] | |
| # 记录每个任务的标签用于进度展示 | |
| ep_label = {"openai": "OAI", "anthropic": "ANT"} | |
| task_labels: dict[int, str] = {} | |
| for i, (ep, model, sc) in enumerate(tasks): | |
| task_labels[i] = f"{ep_label.get(ep, '?')} | {sc['name']} | {model}" | |
| with ThreadPoolExecutor(max_workers=args.parallel) as executor: | |
| def run_task(endpoint: str, model: str, sc: dict) -> tuple[int, dict]: | |
| if endpoint == "openai": | |
| return (0, run_openai(oai_client, sc, model)) | |
| return (0, run_anthropic(anth_client, sc, model)) | |
| future_map = {} | |
| for i, (ep, model, sc) in enumerate(tasks): | |
| future = executor.submit(run_task, ep, model, sc) | |
| future_map[future] = i | |
| done = 0 | |
| passed = 0 | |
| for future in as_completed(future_map): | |
| idx = future_map[future] | |
| _, result = future.result() | |
| all_results[idx] = result | |
| done += 1 | |
| if result["passed"]: | |
| passed += 1 | |
| label = task_labels[idx] | |
| status = "✓" if result["passed"] else "✗" | |
| err = f" | {result['error'][:60]}" if result["error"] else "" | |
| print(f" [{done}/{len(tasks)}] {status} | {label} | {result['duration']:.1f}s{err}") | |
| if args.show_output: | |
| _print_output(result) | |
| report = print_report(all_results, suite_name, args.parallel) | |
| if args.report: | |
| with open(args.report, "w", encoding="utf-8") as f: | |
| json.dump({ | |
| "suite": suite_name, | |
| "started_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), | |
| "config": {"parallel": args.parallel, "accounts": config["accounts"]}, | |
| "summary": report, | |
| "results": all_results, | |
| }, f, ensure_ascii=False, indent=2) | |
| print(f" 报告已输出: {args.report}") | |
| sys.exit(0 if report["failed"] == 0 else 1) | |
| if __name__ == "__main__": | |
| main() | |