1 line
125 KiB
JSON
1 line
125 KiB
JSON
{"repo": "canwhite/agenteval", "files": {"docs/proxy.md": {"fdfb98d942969e64f374dbc3dfe15bd6b22ecc89": {"t": 1785674078.3625402, "n": 16973, "h": "c5a5761edaa97c39", "c": "# agenteval 实现细节\n\n## 一句话\n\n**不劫持 TLS,劫持 URL。** 让客户端以为 `localhost:57633` 就是 API 服务器,把它发出的请求引到本地代理,由代理转发到真实上游。\n\n---\n\n## 流量转化全流程\n\n这是整个系统最核心的链路,每一步都精确描述流量是如何被\"偏转\"的:\n\n### 第 0 步:你设置了什么\n\n```bash\nANTHROPIC_BASE_URL=http://127.0.0.1:57633\n```\n\n一行为什么能抓到所有包?\n\nClaude Code、OpenAI SDK、langchain 等客户端,内部都用一个 HTTP client,向 `BASE_URL` 发请求。正常情况:\n\n```\n客户端 ── HTTPS ──► api.anthropic.com:443\n```\n\n你把 `BASE_URL` 改成 `http://127.0.0.1:57633` 后:\n\n```\n客户端 ── HTTP ──► 127.0.0.1:57633\n```\n\n客户端是\"被骗\"的一方 —— 它根本不知道对面不是真正的 API 服务器。它老老实实地把自己的请求(包括 Authorization header、JSON body)用 HTTP 明文发给本地的 57633 端口。\n\n### 第 1 步:请求到达代理\n\n**代理是一个 axum HTTP server,只绑定 127.0.0.1,不对外暴露。**\n\n```\n客户端 agenteval (127.0.0.1:57633)\n │ │\n │ POST /v1/messages │\n │ Host: 127.0.0.1:57633 │\n │ Authorization: sk-ant-... │\n │ Content-Type: application/json\n │ {\"model\":\"claude-4.6\",...} │\n │ ──────────────────────────► │\n │ │\n```\n\naxum 的 `Router::fallback(any(proxy_handler))` 接收**所有** HTTP 方法(GET/POST/PATCH/...)、**所有**路径(`/v1/messages`、`/v1/models`、`/`)。\n\n### 第 2 步:拼接上游 URL\n\n```rust\n// proxy_handler 中\nlet path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or(\"/\");\nlet upstream_url = format!(\"{}{}\", state.upstream_base, path_and_query);\n```\n\n**关键:只有 path + query 被拼接,Host 被替换。** 这是流量转移的本质。\n\n一个具体例子:\n\n| 客户端请求 | upstream 配置 | 拼接结果 |\n|---|---|---|\n| `http://127.0.0.1:57633/v1/messages` | `https://api.anthropic.com` | `https://api.anthropic.com/v1/messages` |\n| `http://127.0.0.1:57633/v1/messages?beta=true` | `https://api.openai.com` | `https://api.openai.com/v1/messages?beta=true` |\n| `http://127.0.0.1:8080/` | `https://httpbin.org` | `https://httpbin.org/` |\n\n原始的 `Host: 127.0.0.1:57633` 被**丢弃**,替换为上游的 host。客户端以为自己发给了本地,实际上请求的 path 和 body 被完整打包,发往了真正的 API。\n\n### 第 3 步:Header 清洗与转发\n\n```\n客户端 ──► proxy upstream ◄── proxy\n │ │\n │ Host: 127.0.0.1:57633 ── 丢弃 │\n │ Content-Length: 1234 ── 丢弃(reqwest 自算)│\n │ Transfer-Encoding: chunked ── 丢弃 │\n │ Authorization: sk-ant-... ── 原样转发 ────────► │\n │ Content-Type: application/json── 原样转发 ────────► │\n │ anthropic-version: 2023-06-01 ── 原样转发 ────────► │\n │ x-api-key: sk-... ── 原样转发 ────────► │\n```\n\n```rust\nfor (key, value) in headers.iter() {\n let k = key.as_str().to_lowercase();\n if k == \"host\" || k == \"content-length\" || k == \"transfer-encoding\" {\n continue; // 跳过这 3 个\n }\n req_builder = req_builder.header(key.as_str(), value);\n}\n```\n\n**为什么跳过这三个?**\n\n- **`Host`**:客户端写的是 `127.0.0.1:57633`,但我们要发给 `api.anthropic.com`。reqwest 会自动根据目标 URL 填入正确的 Host header 和 SNI。\n- **`Content-Length` / `Transfer-Encoding`**:reqwest 会根据实际 body 大小重新计算。原样转发会导致长度不匹配。\n\n**那这三个 header 在哪里重新生成?**\n\n不在 AgentEval 代码里,在依赖链的深处:\n\n```\nproxy.rs req_builder.body(body.to_vec())\n req_builder.send().await\n │\n ▼\nreqwest Client::execute()\n │ 根据 URL scheme 选 connector(HTTPS → TLS)\n ▼\nhyper proto::h1::conn 或 h2::client\n │ 构造真正的 HTTP 请求字节流:\n │ POST /v1/chat/completions HTTP/1.1\n │ Host: api.edgefn.net ← 从 upstream_url 提取\n │ Content-Length: 1234 ← 从 body.len() 计算\n │ Authorization: Bearer sk-xxx ← 你转发的自定义 header\n │ ...\n ▼\ntokio-rustls TLS 加密\n ▼\nTCP socket ────► 上游服务器\n```\n\n关键在 **hyper**。reqwest 是 hyper 的 wrapper,当你给 `.body(Vec<u8>)` 时 hyper 知道 body 长度,自动写 `Content-Length`;当给 stream body 时用 `Transfer-Encoding: chunked`。`Host` 从 URL 的 host 部分提取。\n\n清洗的本质:**扔掉旧连接的信息,让 reqwest / hyper 按新连接重新生成**。\n\n### 第 4 步:代理发出 HTTPS 请求\n\n```rust\nstate.client.request(method, &upstream_url) // reqwest Client\n .headers(cleaned_headers)\n .body(body_bytes)\n .send()\n```\n\n**这里是流量从\"明文\"变回\"加密\"的地方。**\n\nreqwest 是一个完整的 HTTPS 客户端。当它看到 `upstream_url = \"https://api.anthropic.com/v1/messages\"` 时:\n\n1. 通过系统 DNS 解析 `api.anthropic.com` 的真实 IP\n2. 与真实 IP 的 443 端口建立 TCP 连接\n3. 执行标准 TLS 1.3 握手(验证证书链、SNI)\n4. 在加密通道中发送 HTTP 请求\n\n所以从上游 API 的视角,这个请求和客户端直接发出的**没有任何区别**。它就是一次正常的 HTTPS 请求。\n\n```\n agenteval 内部\n客户端 ◄── HTTP ──► [axum server] ── 内存 ──► [reqwest client] ◄── HTTPS ──► api.anthropic.com\n │ │\n plaintext buffer TLS 1.3 + 证书验证\n 127.0.0.1:57633 真实 IP:443\n```\n\n### 第 5 步:响应流式透传\n\n上游的 HTTPS 响应到达后,代理把它拆成字节流,逐个 chunk 推回给客户端:\n\n```\napi.anthropic.com agenteval 客户端\n │ │ │\n │ HTTPS response │ │\n │ (SSE: data: {\"delta\": │ │\n │ {\"text\":\"Hello\"}} ) │ │\n │ ──────────────────────────► │ │\n │ │ HTTP response │\n │ │ (same bytes, no TLS) │\n │ │ ──────────────────────────► │\n │ │ │\n │ SSE: data: {\"delta\": │ │\n │ {\"text\":\" world\"}} ) │ │\n │ ──────────────────────────► │ │\n │ │ SSE: data: {\"delta\": │\n │ │ {\"text\":\" world\"}} ) │\n │ │ ──────────────────────────► │\n```\n\n流式透传的实现:\n\n```rust\nlet frame_stream = upstream_resp.bytes_stream().map(|result| {\n match result {\n Ok(bytes) => Ok(Frame::data(bytes)),\n Err(e) => Err(Box::new(e) as ...),\n }\n});\nlet axum_body = Body::new(StreamBody::new(frame_stream));\n```\n\n- `reqwest::Response::bytes_stream()` 返回一个异步流,每收到一个 TCP segment 就 yield 一个 `Bytes` chunk\n- `Frame::data(bytes)` 把裸字节包装成 HTTP body frame\n- `StreamBody` 把 frame 流转成 axum 可发送的 response body\n- **不做 buffering** —— 上游发一段,代理就转发一段。客户端不需要等整个响应完成,可以看到 token-by-token 的实时输出\n\n### 第 6 步:写入审计日志\n\n每个请求完成后,代理将完整记录写入 `~/.agenteval/logs/0001.json`:\n\n```json\n{\n \"id\": 1,\n \"ts\": 1779805904142,\n \"method\": \"POST\",\n \"path\": \"/v1/messages\",\n \"upstream\": \"https://api.anthropic.com/v1/messages\",\n \"request_headers\": { \"authorization\": \"sk-ant-...\", \"content-type\": \"application/json\" },\n \"request_body\": { \"model\": \"claude-4.6\", \"messages\": [...] },\n \"response_status\": 200,\n \"response_headers\": { \"content-type\": \"text/event-stream\", ... },\n \"duration_ms\": 3421,\n \"streaming\": true\n}\n```\n\n---\n\n## 为什么这个方案能工作,而传统方案不行\n\n| 传统 MITM 方案 | agenteval |\n|---|---|\n| 代理伪装成 API 服务器,**自己签发 TLS 证书**,需要系统信任该 CA | 代理是**纯 HTTP server**,TLS 由 reqwest 在出站侧处理,使用系统认可的证书 |\n| 依赖 `HTTP_PROXY` / `HTTPS_PROXY` 环境变量 | 利用各 CLI 的 `BASE_URL` 环境变量 |\n| CLI 工具通常不读 `HTTP_PROXY`,直接无代理连接 | CLI 总是读 `BASE_URL`,这是 SDK 级别的参数 |\n| 证书绑定(certificate pinning)直接失效 | 客户端看到的是真实 API 的证书,绑定逻辑正常 |\n| 每次原始报文,人工解析 | 按 API 格式结构化存储 request body |\n\n**核心优势:客户端自己完成 TLS 连接的事实检查(证书链、SNI、OCSP)**。代理只做它擅长的事 —— 在中间那个唯一的明文 hop 上坐下来,不声不响地记录一切。\n\n---\n\n## 怎么用\n\n### 1. 配置\n\n三种方式,自由混用。优先级:**CLI 参数 > 环境变量 > 默认值**。\n\n**方式 A:`.env` 文件(无需每次敲参数)**\n\n```bash\ncp .env.example .env\n```\n\n```bash\n# .env\nAGENTEVAL_UPSTREAM=https://api.anthropic.com\nAGENTEVAL_PORT=57633\n# AGENTEVAL_VERBOSE=1\n```\n\n**方式 B:环境变量(shell export)**\n\n```bash\nexport AGENTEVAL_UPSTREAM=https://api.openai.com\n```\n\n**方式 C:CLI 参数(一次性覆盖)**\n\n```bash\ncargo run -- --upstream https://api.openai.com --port 8080 --verbose\n```\n\n完整的参数列表:\n\n```\n$ cargo run -- --help\n\nUsage: AgentEval [OPTIONS]\n\nOptions:\n -u, --upstream <UPSTREAM> 上游 API 地址 [env: AGENTEVAL_UPSTREAM=] [default: https://api.anthropic.com]\n -p, --port <PORT> 本地监听端口 [env: AGENTEVAL_PORT=] [default: 57633]\n --log-dir <LOG_DIR> 日志存放目录 [env: AGENTEVAL_LOG_DIR=] [default: ~/.agenteval/logs]\n -v, --verbose 详细模式 [env: AGENTEVAL_VERBOSE=]\n -h, --help 打印帮助\n```\n\n### 2. 常见组合\n\n```bash\n# 日常使用:.env 配好 upstream,直接跑\ncargo run\n\n# 临时抓 OpenAI:CLI 覆盖 upstream\ncargo run -- --upstream https://api.openai.com\n\n# 抓 Kimi(带路径前缀)\ncargo run -- --upstream https://api.moonshot.cn/anthropic\n\n# 详细模式:打印每个请求的 JSON body\ncargo run -- --verbose\n\n# 自定义端口 + 详细模式\ncargo run -- --port 6789 --verbose\n\n# 纯环境变量(不依赖 .env)\nAGENTEVAL_UPSTREAM=https://api.deepseek.com cargo run\n```\n\n### 3. 让客户端\"认错门\"\n\n```bash\n# Claude Code\nANTHROPIC_BASE_URL=http://127.0.0.1:57633 claude\n\n# OpenAI / Codex\nOPENAI_BASE_URL=http://127.0.0.1:57633 codex\n\n# 或 export 到当前 shell\nexport ANTHROPIC_BASE_URL=http://127.0.0.1:57633\nclaude\n```\n\n客户端把所有请求发给 `127.0.0.1:57633`,代理再转发到配置的上游。\n\n### 4. 看日志\n\n```bash\nls ~/.agenteval/logs/\n# 0001.json 0002.json 0003.json ...\n\ncat ~/.agenteval/logs/0001.json | python3 -m json.tool\n```\n\n每条记录结构见上方「第 6 步:写入审计日志」。\n\n---\n\n## 关键实现 (`src/proxy.rs`)\n\n### AppState\n\n```rust\npub struct AppState {\n pub upstream_base: String, // 上游 API 地址\n pub client: Client, // reqwest HTTPS 客户端(no_proxy)\n pub trace_file: String, // session_{ts}.jsonl 路径\n pub trace_lock: Mutex<()>, // JSONL 写入互斥锁\n pub verbose: bool,\n pub counter: AtomicU64, // 自增请求 ID\n pub eval_tx: UnboundedSender<TurnRecord>, // 发给 eval 模块\n}\n\nimpl AppState {\n pub fn new(config: &Config, eval_tx: UnboundedSender<TurnRecord>) -> Self {\n let client = Client::builder()\n .no_proxy() // 避免代理自身被拦截\n .build()\n .expect(\"Failed to create HTTP client\");\n\n let now = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap_or_default()\n .as_secs();\n let trace_file = format!(\"{}/session_{}.jsonl\", config.log_dir, now);\n\n Self { upstream_base: config.upstream.clone(), client, trace_file,\n trace_lock: Mutex::new(()), verbose: config.verbose,\n counter: AtomicU64::new(1), eval_tx }\n }\n}\n```\n\n### handler 核心流程\n\nhandler 处理四个阶段:**① 拼接上游 URL → ② 清洗 header → ③ 转发请求 → ④ 后台 tee 响应 + 写 JSONL + 发 eval**。\n\n```rust\npub async fn handler(\n State(state): State<Arc<AppState>>,\n method: Method,\n uri: Uri,\n headers: HeaderMap,\n body: Bytes,\n) -> Result<Response, (StatusCode, String)> {\n let start = Instant::now();\n let id = state.counter.fetch_add(1, Ordering::SeqCst);\n\n // ① 拼接上游 URL:客户端请求的 path + query 原样接到 upstream_base\n let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or(\"/\");\n let upstream_url = format!(\"{}{}\", state.upstream_base, path_and_query);\n\n // 预处理 body(用于后续日志)\n let streaming = is_streaming_request(&body);\n let req_body_json = body_to_json(&body);\n\n // ② 清洗 header → 转发到上游\n let mut req_builder = state.client.request(method.clone(), &upstream_url);\n for (key, value) in headers.iter() {\n let k = key.as_str().to_lowercase();\n if k == \"host\" || k == \"content-length\" || k == \"transfer-encoding\" {\n continue; // 跳过 3 个会被 reqwest 重新计算的 header\n }\n req_builder = req_builder.header(key.as_str(), value);\n }\n if !body.is_empty() {\n req_builder = req_builder.body(body.to_vec());\n }\n\n\n //用于接收上游返回值\n let upstream_resp = req_builder.send().await\n .map_err(|e| { eprintln!(\"[{:04}] upstream error: {}\", id, e);\n (StatusCode::BAD_GATEWAY, e.to_string()) })?;\n\n let status = upstream_resp.status();\n let resp_headers = upstream_resp.headers().clone();\n //这个事为了标记结束时间吗\n let elapsed = start.elapsed();\n\n // ③ 流式透传:bytes_stream → Frame → StreamBody\n // 同时通过 channel tee 一份数据,用于后台拼完整 response body\n // uboundeded channel 是不阻塞,需要针对那些知道有间隔的业务\n let (tx, mut rx) = mpsc::unbounded_channel::<Vec<u8>>();\n\n //主要是为了将bytes stream转frame\n let frame_stream = upstream_resp.bytes_stream().map(move |result| match result {\n Ok(bytes) => {\n tx.send(bytes.to_vec()).ok(); // tee 到 channel\n Ok(Frame::data(bytes))\n }\n\n Err(e) => Err(Box::new(e) as Box<dyn Error + Send + Sync>),\n });\n\n //这里是封装成streamBody\n let axum_body = Body::new(StreamBody::new(frame_stream));\n \n //这里相当于先有body再有状态和头\n let mut response = Response::new(axum_body);\n *response.status_mut() = status;\n *response.headers_mut() = resp_headers;\n\n // ④ 后台任务:accumulate 完整 response body → 写 JSONL → 发 eval\n let st = state.clone();\n tokio::spawn(async move {\n let mut resp_buf = Vec::new();\n while let Some(chunk) = rx.recv().await {\n resp_buf.extend_from_slice(&chunk);\n }\n\n let resp_body_json = body_to_json(&resp_buf);\n\n // 写 JSONL 行\n let entry = serde_json::json!({\n \"id\": id, \"ts\": timestamp_ms(), \"method\": method.to_string(),\n \"path\": path_and_query, \"upstream\": upstream_url,\n \"request_headers\": headers_to_value(&headers),\n \"request_body\": req_body_json.clone(),\n \"response_status\": status.as_u16(),\n \"response_headers\": headers_to_value(&resp_headers),\n \"response_body\": resp_body_json.clone(),\n \"duration_ms\": elapsed.as_millis() as u64,\n \"streaming\": streaming,\n });\n\n let _guard = st.trace_lock.lock().unwrap();\n let mut file = std::fs::OpenOptions::new()\n .create(true).append(true).open(&st.trace_file).unwrap();\n writeln!(file, \"{}\", serde_json::to_string(&entry).unwrap()).ok();\n drop(_guard);\n\n // 发给 eval 模块(异步构建 SessionView + 评分)\n st.eval_tx.send(TurnRecord {\n id,\n request_body: req_body_json,\n response_body: resp_body_json,\n duration_ms: elapsed.as_millis() as u64,\n }).ok();\n });\n\n Ok(response)\n}\n```\n\n**设计要点**:\n- 后台 `tokio::spawn` 让响应字节一到就流式推给客户端,JSONL 写入和 eval 不阻塞响应\n- `trace_lock` 保证多请求并发时 JSONL 写入不交错\n- `eval_tx` 是无界 channel,proxy 侧不会因 eval 处理慢而反压\n\n### 辅助函数\n\n```rust\n/// JSON body → Value;非 JSON → \"字符串\" 或 \"<binary N bytes>\"\nfn body_to_json(body: &[u8]) -> Value {\n if body.is_empty() { return Value::Null; }\n serde_json::from_slice(body).unwrap_or_else(|_| match str::from_utf8(body) {\n Ok(s) => Value::String(s.to_string()),\n Err(_) => Value::String(format!(\"<binary {} bytes>\", body.len())),\n })\n}\n\n/// 所有 header → { key: \"value\", ... }\nfn headers_to_value(headers: &HeaderMap) -> Value { ... }\n\n/// 检测请求 body 是否带 \"stream\": true\nfn is_streaming_request(body: &[u8]) -> bool { ... }\n```\n\n## 项目结构\n\n```\nAgentEval/\n├── .env # 本地配置(gitignore)\n├── Cargo.toml # 依赖:axum, reqwest, tokio, serde_json, dotenvy\n├── src/\n│ ├── main.rs # 入口:加载配置,启动 server\n│ ├── config.rs # Config + GraderConfig,读环境变量\n│ ├── proxy.rs # proxy::handler() — 代理核心\n│ ├── eval/ # SessionView 构建 + 边界检测\n│ │ ├── mod.rs # eval::run() 主循环\n│ │ └── types.rs # SessionView, Turn, Step, TurnRecord\n│ ├── grader/ # 自动评分\n│ │ ├── mod.rs # run_pipeline() 流水线编排\n│ │ ├── rules.rs # 规则统计 + 打分\n│ │ ├── prompt.rs # SessionView → LLM prompt\n│ │ ├── judge.rs # 调评测 LLM API\n│ │ └── types.rs # GradeReport, DimensionScore\n│ └── format/ # 请求/响应解析\n│ └── openai.rs # OpenAI 格式解析\n└── docs/\n ├── proxy.md # 你正在看的这个文件\n ├── grader-design.md # Grader 方案设计\n └── grader-impl.md # Grader 实现细节\n```\n\n## 关键依赖选型\n\n| 组件 | 选型 | 原因 |\n|---|---|---|\n| HTTP server | axum 0.7 | 基于 hyper/tower,async 原生,生态成熟 |\n| HTTPS 客户端 | reqwest 0.12 | 系统 TLS 支持,stream body 原生支持 |\n| 异步运行时 | tokio | axum 和 reqwest 的共同运行时 |\n| body 流式 | http-body-util::StreamBody | hyper 1.0 的 Frame 模型,零拷贝 chunk 转发 |\n| 配置 | dotenvy + clap | dotenvy 加载 `.env` → 环境变量 → clap 自动绑定,CLI 优先 |\n\n## 配置优先级\n\n```\nCLI 参数 > 环境变量 (AGENTEVAL_*) > .env 文件 > 默认值\n │ │ │ │\n │ cargo run ─│─ --upstream https://x.com --verbose\n │ │ │ │\n │ export AGENTEVAL_UPSTREAM=https://x.com │\n │ │ │ │\n │ │ .env 中写的值 │ │\n │ │ │ │\n │ │ 代码中的 default_value_t\n```\n\nclap 的 `#[arg(env = \"...\")]` 让每个参数自动从同名环境变量取值,dotenvy 在 clap 解析前把 `.env` 注入到进程环境。所以 `.env` 和环境变量对 clap 来说是一回事,后者覆盖前者。\n\n## 边界情况\n\n- **非 JSON body**(如二进制流、FormData):日志记录为 `<binary N bytes>`,转发不受影响\n- **上游不可达**:返回 502 Bad Gateway,错误信息写入日志\n- **请求不带 stream: true**(非流式响应):正常代理,完整响应缓冲后落盘\n- **upstream 带 path 前缀**(如 `https://api.moonshot.ai/anthropic`):`trim_end_matches('/')` 保证拼接正确\n"}, "11fd1162fa70fc5eecf83e4485635fd3072a60d8": {"t": 1785760700.1194222, "n": 16973, "h": "c5a5761edaa97c39", "c": "# agenteval 实现细节\n\n## 一句话\n\n**不劫持 TLS,劫持 URL。** 让客户端以为 `localhost:57633` 就是 API 服务器,把它发出的请求引到本地代理,由代理转发到真实上游。\n\n---\n\n## 流量转化全流程\n\n这是整个系统最核心的链路,每一步都精确描述流量是如何被\"偏转\"的:\n\n### 第 0 步:你设置了什么\n\n```bash\nANTHROPIC_BASE_URL=http://127.0.0.1:57633\n```\n\n一行为什么能抓到所有包?\n\nClaude Code、OpenAI SDK、langchain 等客户端,内部都用一个 HTTP client,向 `BASE_URL` 发请求。正常情况:\n\n```\n客户端 ── HTTPS ──► api.anthropic.com:443\n```\n\n你把 `BASE_URL` 改成 `http://127.0.0.1:57633` 后:\n\n```\n客户端 ── HTTP ──► 127.0.0.1:57633\n```\n\n客户端是\"被骗\"的一方 —— 它根本不知道对面不是真正的 API 服务器。它老老实实地把自己的请求(包括 Authorization header、JSON body)用 HTTP 明文发给本地的 57633 端口。\n\n### 第 1 步:请求到达代理\n\n**代理是一个 axum HTTP server,只绑定 127.0.0.1,不对外暴露。**\n\n```\n客户端 agenteval (127.0.0.1:57633)\n │ │\n │ POST /v1/messages │\n │ Host: 127.0.0.1:57633 │\n │ Authorization: sk-ant-... │\n │ Content-Type: application/json\n │ {\"model\":\"claude-4.6\",...} │\n │ ──────────────────────────► │\n │ │\n```\n\naxum 的 `Router::fallback(any(proxy_handler))` 接收**所有** HTTP 方法(GET/POST/PATCH/...)、**所有**路径(`/v1/messages`、`/v1/models`、`/`)。\n\n### 第 2 步:拼接上游 URL\n\n```rust\n// proxy_handler 中\nlet path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or(\"/\");\nlet upstream_url = format!(\"{}{}\", state.upstream_base, path_and_query);\n```\n\n**关键:只有 path + query 被拼接,Host 被替换。** 这是流量转移的本质。\n\n一个具体例子:\n\n| 客户端请求 | upstream 配置 | 拼接结果 |\n|---|---|---|\n| `http://127.0.0.1:57633/v1/messages` | `https://api.anthropic.com` | `https://api.anthropic.com/v1/messages` |\n| `http://127.0.0.1:57633/v1/messages?beta=true` | `https://api.openai.com` | `https://api.openai.com/v1/messages?beta=true` |\n| `http://127.0.0.1:8080/` | `https://httpbin.org` | `https://httpbin.org/` |\n\n原始的 `Host: 127.0.0.1:57633` 被**丢弃**,替换为上游的 host。客户端以为自己发给了本地,实际上请求的 path 和 body 被完整打包,发往了真正的 API。\n\n### 第 3 步:Header 清洗与转发\n\n```\n客户端 ──► proxy upstream ◄── proxy\n │ │\n │ Host: 127.0.0.1:57633 ── 丢弃 │\n │ Content-Length: 1234 ── 丢弃(reqwest 自算)│\n │ Transfer-Encoding: chunked ── 丢弃 │\n │ Authorization: sk-ant-... ── 原样转发 ────────► │\n │ Content-Type: application/json── 原样转发 ────────► │\n │ anthropic-version: 2023-06-01 ── 原样转发 ────────► │\n │ x-api-key: sk-... ── 原样转发 ────────► │\n```\n\n```rust\nfor (key, value) in headers.iter() {\n let k = key.as_str().to_lowercase();\n if k == \"host\" || k == \"content-length\" || k == \"transfer-encoding\" {\n continue; // 跳过这 3 个\n }\n req_builder = req_builder.header(key.as_str(), value);\n}\n```\n\n**为什么跳过这三个?**\n\n- **`Host`**:客户端写的是 `127.0.0.1:57633`,但我们要发给 `api.anthropic.com`。reqwest 会自动根据目标 URL 填入正确的 Host header 和 SNI。\n- **`Content-Length` / `Transfer-Encoding`**:reqwest 会根据实际 body 大小重新计算。原样转发会导致长度不匹配。\n\n**那这三个 header 在哪里重新生成?**\n\n不在 AgentEval 代码里,在依赖链的深处:\n\n```\nproxy.rs req_builder.body(body.to_vec())\n req_builder.send().await\n │\n ▼\nreqwest Client::execute()\n │ 根据 URL scheme 选 connector(HTTPS → TLS)\n ▼\nhyper proto::h1::conn 或 h2::client\n │ 构造真正的 HTTP 请求字节流:\n │ POST /v1/chat/completions HTTP/1.1\n │ Host: api.edgefn.net ← 从 upstream_url 提取\n │ Content-Length: 1234 ← 从 body.len() 计算\n │ Authorization: Bearer sk-xxx ← 你转发的自定义 header\n │ ...\n ▼\ntokio-rustls TLS 加密\n ▼\nTCP socket ────► 上游服务器\n```\n\n关键在 **hyper**。reqwest 是 hyper 的 wrapper,当你给 `.body(Vec<u8>)` 时 hyper 知道 body 长度,自动写 `Content-Length`;当给 stream body 时用 `Transfer-Encoding: chunked`。`Host` 从 URL 的 host 部分提取。\n\n清洗的本质:**扔掉旧连接的信息,让 reqwest / hyper 按新连接重新生成**。\n\n### 第 4 步:代理发出 HTTPS 请求\n\n```rust\nstate.client.request(method, &upstream_url) // reqwest Client\n .headers(cleaned_headers)\n .body(body_bytes)\n .send()\n```\n\n**这里是流量从\"明文\"变回\"加密\"的地方。**\n\nreqwest 是一个完整的 HTTPS 客户端。当它看到 `upstream_url = \"https://api.anthropic.com/v1/messages\"` 时:\n\n1. 通过系统 DNS 解析 `api.anthropic.com` 的真实 IP\n2. 与真实 IP 的 443 端口建立 TCP 连接\n3. 执行标准 TLS 1.3 握手(验证证书链、SNI)\n4. 在加密通道中发送 HTTP 请求\n\n所以从上游 API 的视角,这个请求和客户端直接发出的**没有任何区别**。它就是一次正常的 HTTPS 请求。\n\n```\n agenteval 内部\n客户端 ◄── HTTP ──► [axum server] ── 内存 ──► [reqwest client] ◄── HTTPS ──► api.anthropic.com\n │ │\n plaintext buffer TLS 1.3 + 证书验证\n 127.0.0.1:57633 真实 IP:443\n```\n\n### 第 5 步:响应流式透传\n\n上游的 HTTPS 响应到达后,代理把它拆成字节流,逐个 chunk 推回给客户端:\n\n```\napi.anthropic.com agenteval 客户端\n │ │ │\n │ HTTPS response │ │\n │ (SSE: data: {\"delta\": │ │\n │ {\"text\":\"Hello\"}} ) │ │\n │ ──────────────────────────► │ │\n │ │ HTTP response │\n │ │ (same bytes, no TLS) │\n │ │ ──────────────────────────► │\n │ │ │\n │ SSE: data: {\"delta\": │ │\n │ {\"text\":\" world\"}} ) │ │\n │ ──────────────────────────► │ │\n │ │ SSE: data: {\"delta\": │\n │ │ {\"text\":\" world\"}} ) │\n │ │ ──────────────────────────► │\n```\n\n流式透传的实现:\n\n```rust\nlet frame_stream = upstream_resp.bytes_stream().map(|result| {\n match result {\n Ok(bytes) => Ok(Frame::data(bytes)),\n Err(e) => Err(Box::new(e) as ...),\n }\n});\nlet axum_body = Body::new(StreamBody::new(frame_stream));\n```\n\n- `reqwest::Response::bytes_stream()` 返回一个异步流,每收到一个 TCP segment 就 yield 一个 `Bytes` chunk\n- `Frame::data(bytes)` 把裸字节包装成 HTTP body frame\n- `StreamBody` 把 frame 流转成 axum 可发送的 response body\n- **不做 buffering** —— 上游发一段,代理就转发一段。客户端不需要等整个响应完成,可以看到 token-by-token 的实时输出\n\n### 第 6 步:写入审计日志\n\n每个请求完成后,代理将完整记录写入 `~/.agenteval/logs/0001.json`:\n\n```json\n{\n \"id\": 1,\n \"ts\": 1779805904142,\n \"method\": \"POST\",\n \"path\": \"/v1/messages\",\n \"upstream\": \"https://api.anthropic.com/v1/messages\",\n \"request_headers\": { \"authorization\": \"sk-ant-...\", \"content-type\": \"application/json\" },\n \"request_body\": { \"model\": \"claude-4.6\", \"messages\": [...] },\n \"response_status\": 200,\n \"response_headers\": { \"content-type\": \"text/event-stream\", ... },\n \"duration_ms\": 3421,\n \"streaming\": true\n}\n```\n\n---\n\n## 为什么这个方案能工作,而传统方案不行\n\n| 传统 MITM 方案 | agenteval |\n|---|---|\n| 代理伪装成 API 服务器,**自己签发 TLS 证书**,需要系统信任该 CA | 代理是**纯 HTTP server**,TLS 由 reqwest 在出站侧处理,使用系统认可的证书 |\n| 依赖 `HTTP_PROXY` / `HTTPS_PROXY` 环境变量 | 利用各 CLI 的 `BASE_URL` 环境变量 |\n| CLI 工具通常不读 `HTTP_PROXY`,直接无代理连接 | CLI 总是读 `BASE_URL`,这是 SDK 级别的参数 |\n| 证书绑定(certificate pinning)直接失效 | 客户端看到的是真实 API 的证书,绑定逻辑正常 |\n| 每次原始报文,人工解析 | 按 API 格式结构化存储 request body |\n\n**核心优势:客户端自己完成 TLS 连接的事实检查(证书链、SNI、OCSP)**。代理只做它擅长的事 —— 在中间那个唯一的明文 hop 上坐下来,不声不响地记录一切。\n\n---\n\n## 怎么用\n\n### 1. 配置\n\n三种方式,自由混用。优先级:**CLI 参数 > 环境变量 > 默认值**。\n\n**方式 A:`.env` 文件(无需每次敲参数)**\n\n```bash\ncp .env.example .env\n```\n\n```bash\n# .env\nAGENTEVAL_UPSTREAM=https://api.anthropic.com\nAGENTEVAL_PORT=57633\n# AGENTEVAL_VERBOSE=1\n```\n\n**方式 B:环境变量(shell export)**\n\n```bash\nexport AGENTEVAL_UPSTREAM=https://api.openai.com\n```\n\n**方式 C:CLI 参数(一次性覆盖)**\n\n```bash\ncargo run -- --upstream https://api.openai.com --port 8080 --verbose\n```\n\n完整的参数列表:\n\n```\n$ cargo run -- --help\n\nUsage: AgentEval [OPTIONS]\n\nOptions:\n -u, --upstream <UPSTREAM> 上游 API 地址 [env: AGENTEVAL_UPSTREAM=] [default: https://api.anthropic.com]\n -p, --port <PORT> 本地监听端口 [env: AGENTEVAL_PORT=] [default: 57633]\n --log-dir <LOG_DIR> 日志存放目录 [env: AGENTEVAL_LOG_DIR=] [default: ~/.agenteval/logs]\n -v, --verbose 详细模式 [env: AGENTEVAL_VERBOSE=]\n -h, --help 打印帮助\n```\n\n### 2. 常见组合\n\n```bash\n# 日常使用:.env 配好 upstream,直接跑\ncargo run\n\n# 临时抓 OpenAI:CLI 覆盖 upstream\ncargo run -- --upstream https://api.openai.com\n\n# 抓 Kimi(带路径前缀)\ncargo run -- --upstream https://api.moonshot.cn/anthropic\n\n# 详细模式:打印每个请求的 JSON body\ncargo run -- --verbose\n\n# 自定义端口 + 详细模式\ncargo run -- --port 6789 --verbose\n\n# 纯环境变量(不依赖 .env)\nAGENTEVAL_UPSTREAM=https://api.deepseek.com cargo run\n```\n\n### 3. 让客户端\"认错门\"\n\n```bash\n# Claude Code\nANTHROPIC_BASE_URL=http://127.0.0.1:57633 claude\n\n# OpenAI / Codex\nOPENAI_BASE_URL=http://127.0.0.1:57633 codex\n\n# 或 export 到当前 shell\nexport ANTHROPIC_BASE_URL=http://127.0.0.1:57633\nclaude\n```\n\n客户端把所有请求发给 `127.0.0.1:57633`,代理再转发到配置的上游。\n\n### 4. 看日志\n\n```bash\nls ~/.agenteval/logs/\n# 0001.json 0002.json 0003.json ...\n\ncat ~/.agenteval/logs/0001.json | python3 -m json.tool\n```\n\n每条记录结构见上方「第 6 步:写入审计日志」。\n\n---\n\n## 关键实现 (`src/proxy.rs`)\n\n### AppState\n\n```rust\npub struct AppState {\n pub upstream_base: String, // 上游 API 地址\n pub client: Client, // reqwest HTTPS 客户端(no_proxy)\n pub trace_file: String, // session_{ts}.jsonl 路径\n pub trace_lock: Mutex<()>, // JSONL 写入互斥锁\n pub verbose: bool,\n pub counter: AtomicU64, // 自增请求 ID\n pub eval_tx: UnboundedSender<TurnRecord>, // 发给 eval 模块\n}\n\nimpl AppState {\n pub fn new(config: &Config, eval_tx: UnboundedSender<TurnRecord>) -> Self {\n let client = Client::builder()\n .no_proxy() // 避免代理自身被拦截\n .build()\n .expect(\"Failed to create HTTP client\");\n\n let now = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap_or_default()\n .as_secs();\n let trace_file = format!(\"{}/session_{}.jsonl\", config.log_dir, now);\n\n Self { upstream_base: config.upstream.clone(), client, trace_file,\n trace_lock: Mutex::new(()), verbose: config.verbose,\n counter: AtomicU64::new(1), eval_tx }\n }\n}\n```\n\n### handler 核心流程\n\nhandler 处理四个阶段:**① 拼接上游 URL → ② 清洗 header → ③ 转发请求 → ④ 后台 tee 响应 + 写 JSONL + 发 eval**。\n\n```rust\npub async fn handler(\n State(state): State<Arc<AppState>>,\n method: Method,\n uri: Uri,\n headers: HeaderMap,\n body: Bytes,\n) -> Result<Response, (StatusCode, String)> {\n let start = Instant::now();\n let id = state.counter.fetch_add(1, Ordering::SeqCst);\n\n // ① 拼接上游 URL:客户端请求的 path + query 原样接到 upstream_base\n let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or(\"/\");\n let upstream_url = format!(\"{}{}\", state.upstream_base, path_and_query);\n\n // 预处理 body(用于后续日志)\n let streaming = is_streaming_request(&body);\n let req_body_json = body_to_json(&body);\n\n // ② 清洗 header → 转发到上游\n let mut req_builder = state.client.request(method.clone(), &upstream_url);\n for (key, value) in headers.iter() {\n let k = key.as_str().to_lowercase();\n if k == \"host\" || k == \"content-length\" || k == \"transfer-encoding\" {\n continue; // 跳过 3 个会被 reqwest 重新计算的 header\n }\n req_builder = req_builder.header(key.as_str(), value);\n }\n if !body.is_empty() {\n req_builder = req_builder.body(body.to_vec());\n }\n\n\n //用于接收上游返回值\n let upstream_resp = req_builder.send().await\n .map_err(|e| { eprintln!(\"[{:04}] upstream error: {}\", id, e);\n (StatusCode::BAD_GATEWAY, e.to_string()) })?;\n\n let status = upstream_resp.status();\n let resp_headers = upstream_resp.headers().clone();\n //这个事为了标记结束时间吗\n let elapsed = start.elapsed();\n\n // ③ 流式透传:bytes_stream → Frame → StreamBody\n // 同时通过 channel tee 一份数据,用于后台拼完整 response body\n // uboundeded channel 是不阻塞,需要针对那些知道有间隔的业务\n let (tx, mut rx) = mpsc::unbounded_channel::<Vec<u8>>();\n\n //主要是为了将bytes stream转frame\n let frame_stream = upstream_resp.bytes_stream().map(move |result| match result {\n Ok(bytes) => {\n tx.send(bytes.to_vec()).ok(); // tee 到 channel\n Ok(Frame::data(bytes))\n }\n\n Err(e) => Err(Box::new(e) as Box<dyn Error + Send + Sync>),\n });\n\n //这里是封装成streamBody\n let axum_body = Body::new(StreamBody::new(frame_stream));\n \n //这里相当于先有body再有状态和头\n let mut response = Response::new(axum_body);\n *response.status_mut() = status;\n *response.headers_mut() = resp_headers;\n\n // ④ 后台任务:accumulate 完整 response body → 写 JSONL → 发 eval\n let st = state.clone();\n tokio::spawn(async move {\n let mut resp_buf = Vec::new();\n while let Some(chunk) = rx.recv().await {\n resp_buf.extend_from_slice(&chunk);\n }\n\n let resp_body_json = body_to_json(&resp_buf);\n\n // 写 JSONL 行\n let entry = serde_json::json!({\n \"id\": id, \"ts\": timestamp_ms(), \"method\": method.to_string(),\n \"path\": path_and_query, \"upstream\": upstream_url,\n \"request_headers\": headers_to_value(&headers),\n \"request_body\": req_body_json.clone(),\n \"response_status\": status.as_u16(),\n \"response_headers\": headers_to_value(&resp_headers),\n \"response_body\": resp_body_json.clone(),\n \"duration_ms\": elapsed.as_millis() as u64,\n \"streaming\": streaming,\n });\n\n let _guard = st.trace_lock.lock().unwrap();\n let mut file = std::fs::OpenOptions::new()\n .create(true).append(true).open(&st.trace_file).unwrap();\n writeln!(file, \"{}\", serde_json::to_string(&entry).unwrap()).ok();\n drop(_guard);\n\n // 发给 eval 模块(异步构建 SessionView + 评分)\n st.eval_tx.send(TurnRecord {\n id,\n request_body: req_body_json,\n response_body: resp_body_json,\n duration_ms: elapsed.as_millis() as u64,\n }).ok();\n });\n\n Ok(response)\n}\n```\n\n**设计要点**:\n- 后台 `tokio::spawn` 让响应字节一到就流式推给客户端,JSONL 写入和 eval 不阻塞响应\n- `trace_lock` 保证多请求并发时 JSONL 写入不交错\n- `eval_tx` 是无界 channel,proxy 侧不会因 eval 处理慢而反压\n\n### 辅助函数\n\n```rust\n/// JSON body → Value;非 JSON → \"字符串\" 或 \"<binary N bytes>\"\nfn body_to_json(body: &[u8]) -> Value {\n if body.is_empty() { return Value::Null; }\n serde_json::from_slice(body).unwrap_or_else(|_| match str::from_utf8(body) {\n Ok(s) => Value::String(s.to_string()),\n Err(_) => Value::String(format!(\"<binary {} bytes>\", body.len())),\n })\n}\n\n/// 所有 header → { key: \"value\", ... }\nfn headers_to_value(headers: &HeaderMap) -> Value { ... }\n\n/// 检测请求 body 是否带 \"stream\": true\nfn is_streaming_request(body: &[u8]) -> bool { ... }\n```\n\n## 项目结构\n\n```\nAgentEval/\n├── .env # 本地配置(gitignore)\n├── Cargo.toml # 依赖:axum, reqwest, tokio, serde_json, dotenvy\n├── src/\n│ ├── main.rs # 入口:加载配置,启动 server\n│ ├── config.rs # Config + GraderConfig,读环境变量\n│ ├── proxy.rs # proxy::handler() — 代理核心\n│ ├── eval/ # SessionView 构建 + 边界检测\n│ │ ├── mod.rs # eval::run() 主循环\n│ │ └── types.rs # SessionView, Turn, Step, TurnRecord\n│ ├── grader/ # 自动评分\n│ │ ├── mod.rs # run_pipeline() 流水线编排\n│ │ ├── rules.rs # 规则统计 + 打分\n│ │ ├── prompt.rs # SessionView → LLM prompt\n│ │ ├── judge.rs # 调评测 LLM API\n│ │ └── types.rs # GradeReport, DimensionScore\n│ └── format/ # 请求/响应解析\n│ └── openai.rs # OpenAI 格式解析\n└── docs/\n ├── proxy.md # 你正在看的这个文件\n ├── grader-design.md # Grader 方案设计\n └── grader-impl.md # Grader 实现细节\n```\n\n## 关键依赖选型\n\n| 组件 | 选型 | 原因 |\n|---|---|---|\n| HTTP server | axum 0.7 | 基于 hyper/tower,async 原生,生态成熟 |\n| HTTPS 客户端 | reqwest 0.12 | 系统 TLS 支持,stream body 原生支持 |\n| 异步运行时 | tokio | axum 和 reqwest 的共同运行时 |\n| body 流式 | http-body-util::StreamBody | hyper 1.0 的 Frame 模型,零拷贝 chunk 转发 |\n| 配置 | dotenvy + clap | dotenvy 加载 `.env` → 环境变量 → clap 自动绑定,CLI 优先 |\n\n## 配置优先级\n\n```\nCLI 参数 > 环境变量 (AGENTEVAL_*) > .env 文件 > 默认值\n │ │ │ │\n │ cargo run ─│─ --upstream https://x.com --verbose\n │ │ │ │\n │ export AGENTEVAL_UPSTREAM=https://x.com │\n │ │ │ │\n │ │ .env 中写的值 │ │\n │ │ │ │\n │ │ 代码中的 default_value_t\n```\n\nclap 的 `#[arg(env = \"...\")]` 让每个参数自动从同名环境变量取值,dotenvy 在 clap 解析前把 `.env` 注入到进程环境。所以 `.env` 和环境变量对 clap 来说是一回事,后者覆盖前者。\n\n## 边界情况\n\n- **非 JSON body**(如二进制流、FormData):日志记录为 `<binary N bytes>`,转发不受影响\n- **上游不可达**:返回 502 Bad Gateway,错误信息写入日志\n- **请求不带 stream: true**(非流式响应):正常代理,完整响应缓冲后落盘\n- **upstream 带 path 前缀**(如 `https://api.moonshot.ai/anthropic`):`trim_end_matches('/')` 保证拼接正确\n"}, "c268ecaf3906f2dece4135eb12596869b7e0e7ec": {"t": 1785760701.4318516, "n": 10035, "h": "8845f093f13a3250", "c": "# agenteval 实现细节\n\n## 一句话\n\n**不劫持 TLS,劫持 URL。** 让客户端以为 `localhost:57633` 就是 API 服务器,把它发出的请求引到本地代理,由代理转发到真实上游。\n\n---\n\n## 流量转化全流程\n\n这是整个系统最核心的链路,每一步都精确描述流量是如何被\"偏转\"的:\n\n### 第 0 步:你设置了什么\n\n```bash\nANTHROPIC_BASE_URL=http://127.0.0.1:57633\n```\n\n一行为什么能抓到所有包?\n\nClaude Code、OpenAI SDK、langchain 等客户端,内部都用一个 HTTP client,向 `BASE_URL` 发请求。正常情况:\n\n```\n客户端 ── HTTPS ──► api.anthropic.com:443\n```\n\n你把 `BASE_URL` 改成 `http://127.0.0.1:57633` 后:\n\n```\n客户端 ── HTTP ──► 127.0.0.1:57633\n```\n\n客户端是\"被骗\"的一方 —— 它根本不知道对面不是真正的 API 服务器。它老老实实地把自己的请求(包括 Authorization header、JSON body)用 HTTP 明文发给本地的 57633 端口。\n\n### 第 1 步:请求到达代理\n\n**代理是一个 axum HTTP server,只绑定 127.0.0.1,不对外暴露。**\n\n```\n客户端 agenteval (127.0.0.1:57633)\n │ │\n │ POST /v1/messages │\n │ Host: 127.0.0.1:57633 │\n │ Authorization: sk-ant-... │\n │ Content-Type: application/json\n │ {\"model\":\"claude-4.6\",...} │\n │ ──────────────────────────► │\n │ │\n```\n\naxum 的 `Router::fallback(any(proxy_handler))` 接收**所有** HTTP 方法(GET/POST/PATCH/...)、**所有**路径(`/v1/messages`、`/v1/models`、`/`)。\n\n### 第 2 步:拼接上游 URL\n\n```rust\n// proxy_handler 中\nlet path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or(\"/\");\nlet upstream_url = format!(\"{}{}\", state.upstream_base, path_and_query);\n```\n\n**关键:只有 path + query 被拼接,Host 被替换。** 这是流量转移的本质。\n\n一个具体例子:\n\n| 客户端请求 | upstream 配置 | 拼接结果 |\n|---|---|---|\n| `http://127.0.0.1:57633/v1/messages` | `https://api.anthropic.com` | `https://api.anthropic.com/v1/messages` |\n| `http://127.0.0.1:57633/v1/messages?beta=true` | `https://api.openai.com` | `https://api.openai.com/v1/messages?beta=true` |\n| `http://127.0.0.1:8080/` | `https://httpbin.org` | `https://httpbin.org/` |\n\n原始的 `Host: 127.0.0.1:57633` 被**丢弃**,替换为上游的 host。客户端以为自己发给了本地,实际上请求的 path 和 body 被完整打包,发往了真正的 API。\n\n### 第 3 步:Header 清洗与转发\n\n```\n客户端 ──► proxy upstream ◄── proxy\n │ │\n │ Host: 127.0.0.1:57633 ── 丢弃 │\n │ Content-Length: 1234 ── 丢弃(reqwest 自算)│\n │ Transfer-Encoding: chunked ── 丢弃 │\n │ Authorization: sk-ant-... ── 原样转发 ────────► │\n │ Content-Type: application/json── 原样转发 ────────► │\n │ anthropic-version: 2023-06-01 ── 原样转发 ────────► │\n │ x-api-key: sk-... ── 原样转发 ────────► │\n```\n\n```rust\nfor (key, value) in headers.iter() {\n let k = key.as_str().to_lowercase();\n if k == \"host\" || k == \"content-length\" || k == \"transfer-encoding\" {\n continue; // 跳过这 3 个\n }\n req_builder = req_builder.header(key.as_str(), value);\n}\n```\n\n**为什么跳过这三个?**\n\n- **`Host`**:客户端写的是 `127.0.0.1:57633`,但我们要发给 `api.anthropic.com`。reqwest 会自动根据目标 URL 填入正确的 Host header 和 SNI。\n- **`Content-Length` / `Transfer-Encoding`**:reqwest 会根据实际 body 大小重新计算。原样转发会导致长度不匹配。\n\n### 第 4 步:代理发出 HTTPS 请求\n\n```rust\nstate.client.request(method, &upstream_url) // reqwest Client\n .headers(cleaned_headers)\n .body(body_bytes)\n .send()\n```\n\n**这里是流量从\"明文\"变回\"加密\"的地方。**\n\nreqwest 是一个完整的 HTTPS 客户端。当它看到 `upstream_url = \"https://api.anthropic.com/v1/messages\"` 时:\n\n1. 通过系统 DNS 解析 `api.anthropic.com` 的真实 IP\n2. 与真实 IP 的 443 端口建立 TCP 连接\n3. 执行标准 TLS 1.3 握手(验证证书链、SNI)\n4. 在加密通道中发送 HTTP 请求\n\n所以从上游 API 的视角,这个请求和客户端直接发出的**没有任何区别**。它就是一次正常的 HTTPS 请求。\n\n```\n agenteval 内部\n客户端 ◄── HTTP ──► [axum server] ── 内存 ──► [reqwest client] ◄── HTTPS ──► api.anthropic.com\n │ │\n plaintext buffer TLS 1.3 + 证书验证\n 127.0.0.1:57633 真实 IP:443\n```\n\n### 第 5 步:响应流式透传\n\n上游的 HTTPS 响应到达后,代理把它拆成字节流,逐个 chunk 推回给客户端:\n\n```\napi.anthropic.com agenteval 客户端\n │ │ │\n │ HTTPS response │ │\n │ (SSE: data: {\"delta\": │ │\n │ {\"text\":\"Hello\"}} ) │ │\n │ ──────────────────────────► │ │\n │ │ HTTP response │\n │ │ (same bytes, no TLS) │\n │ │ ──────────────────────────► │\n │ │ │\n │ SSE: data: {\"delta\": │ │\n │ {\"text\":\" world\"}} ) │ │\n │ ──────────────────────────► │ │\n │ │ SSE: data: {\"delta\": │\n │ │ {\"text\":\" world\"}} ) │\n │ │ ──────────────────────────► │\n```\n\n流式透传的实现:\n\n```rust\nlet frame_stream = upstream_resp.bytes_stream().map(|result| {\n match result {\n Ok(bytes) => Ok(Frame::data(bytes)),\n Err(e) => Err(Box::new(e) as ...),\n }\n});\nlet axum_body = Body::new(StreamBody::new(frame_stream));\n```\n\n- `reqwest::Response::bytes_stream()` 返回一个异步流,每收到一个 TCP segment 就 yield 一个 `Bytes` chunk\n- `Frame::data(bytes)` 把裸字节包装成 HTTP body frame\n- `StreamBody` 把 frame 流转成 axum 可发送的 response body\n- **不做 buffering** —— 上游发一段,代理就转发一段。客户端不需要等整个响应完成,可以看到 token-by-token 的实时输出\n\n### 第 6 步:写入审计日志\n\n每个请求完成后,代理将完整记录写入 `~/.agenteval/logs/0001.json`:\n\n```json\n{\n \"id\": 1,\n \"ts\": 1779805904142,\n \"method\": \"POST\",\n \"path\": \"/v1/messages\",\n \"upstream\": \"https://api.anthropic.com/v1/messages\",\n \"request_headers\": { \"authorization\": \"sk-ant-...\", \"content-type\": \"application/json\" },\n \"request_body\": { \"model\": \"claude-4.6\", \"messages\": [...] },\n \"response_status\": 200,\n \"response_headers\": { \"content-type\": \"text/event-stream\", ... },\n \"duration_ms\": 3421,\n \"streaming\": true\n}\n```\n\n---\n\n## 为什么这个方案能工作,而传统方案不行\n\n| 传统 MITM 方案 | agenteval |\n|---|---|\n| 代理伪装成 API 服务器,**自己签发 TLS 证书**,需要系统信任该 CA | 代理是**纯 HTTP server**,TLS 由 reqwest 在出站侧处理,使用系统认可的证书 |\n| 依赖 `HTTP_PROXY` / `HTTPS_PROXY` 环境变量 | 利用各 CLI 的 `BASE_URL` 环境变量 |\n| CLI 工具通常不读 `HTTP_PROXY`,直接无代理连接 | CLI 总是读 `BASE_URL`,这是 SDK 级别的参数 |\n| 证书绑定(certificate pinning)直接失效 | 客户端看到的是真实 API 的证书,绑定逻辑正常 |\n| 每次原始报文,人工解析 | 按 API 格式结构化存储 request body |\n\n**核心优势:客户端自己完成 TLS 连接的事实检查(证书链、SNI、OCSP)**。代理只做它擅长的事 —— 在中间那个唯一的明文 hop 上坐下来,不声不响地记录一切。\n\n---\n\n## 怎么用\n\n### 1. 配置\n\n三种方式,自由混用。优先级:**CLI 参数 > 环境变量 > 默认值**。\n\n**方式 A:`.env` 文件(无需每次敲参数)**\n\n```bash\ncp .env.example .env\n```\n\n```bash\n# .env\nAGENTEVAL_UPSTREAM=https://api.anthropic.com\nAGENTEVAL_PORT=57633\n# AGENTEVAL_VERBOSE=1\n```\n\n**方式 B:环境变量(shell export)**\n\n```bash\nexport AGENTEVAL_UPSTREAM=https://api.openai.com\n```\n\n**方式 C:CLI 参数(一次性覆盖)**\n\n```bash\ncargo run -- --upstream https://api.openai.com --port 8080 --verbose\n```\n\n完整的参数列表:\n\n```\n$ cargo run -- --help\n\nUsage: AgentEval [OPTIONS]\n\nOptions:\n -u, --upstream <UPSTREAM> 上游 API 地址 [env: AGENTEVAL_UPSTREAM=] [default: https://api.anthropic.com]\n -p, --port <PORT> 本地监听端口 [env: AGENTEVAL_PORT=] [default: 57633]\n --log-dir <LOG_DIR> 日志存放目录 [env: AGENTEVAL_LOG_DIR=] [default: ~/.agenteval/logs]\n -v, --verbose 详细模式 [env: AGENTEVAL_VERBOSE=]\n -h, --help 打印帮助\n```\n\n### 2. 常见组合\n\n```bash\n# 日常使用:.env 配好 upstream,直接跑\ncargo run\n\n# 临时抓 OpenAI:CLI 覆盖 upstream\ncargo run -- --upstream https://api.openai.com\n\n# 抓 Kimi(带路径前缀)\ncargo run -- --upstream https://api.moonshot.cn/anthropic\n\n# 详细模式:打印每个请求的 JSON body\ncargo run -- --verbose\n\n# 自定义端口 + 详细模式\ncargo run -- --port 6789 --verbose\n\n# 纯环境变量(不依赖 .env)\nAGENTEVAL_UPSTREAM=https://api.deepseek.com cargo run\n```\n\n### 3. 让客户端\"认错门\"\n\n```bash\n# Claude Code\nANTHROPIC_BASE_URL=http://127.0.0.1:57633 claude\n\n# OpenAI / Codex\nOPENAI_BASE_URL=http://127.0.0.1:57633 codex\n\n# 或 export 到当前 shell\nexport ANTHROPIC_BASE_URL=http://127.0.0.1:57633\nclaude\n```\n\n客户端把所有请求发给 `127.0.0.1:57633`,代理再转发到配置的上游。\n\n### 4. 看日志\n\n```bash\nls ~/.agenteval/logs/\n# 0001.json 0002.json 0003.json ...\n\ncat ~/.agenteval/logs/0001.json | python3 -m json.tool\n```\n\n每条记录结构见上方「第 6 步:写入审计日志」。\n\n---\n\n## 项目结构\n\n```\nAgentEval/\n├── .env.example # 环境变量模板(提交 git)\n├── .env # 你的本地配置(gitignore)\n├── Cargo.toml # 依赖:axum, reqwest, tokio, serde_json, dotenvy, clap\n├── src/\n│ ├── main.rs # 入口:加载 .env,启动 server(~30 行)\n│ ├── config.rs # Config::load(),读环境变量\n│ └── proxy.rs # proxy::handler() + 辅助函数\n└── docs/\n └── proxy.md # 你正在看的这个文件\n```\n\n| 文件 | 职责 |\n|---|---|\n| `config.rs` | `Config::load()` → `dotenvy::dotenv()` + `clap::Parser`,从 `.env` / 环境变量 / CLI 读配置 |\n| `proxy.rs` | `proxy::handler()` → 接收请求→清洗 header→转发上游→流式透传→写日志 |\n| `main.rs` | `main()` → `Config::load()` + `axum::serve()` |\n\n## 关键依赖选型\n\n| 组件 | 选型 | 原因 |\n|---|---|---|\n| HTTP server | axum 0.7 | 基于 hyper/tower,async 原生,生态成熟 |\n| HTTPS 客户端 | reqwest 0.12 | 系统 TLS 支持,stream body 原生支持 |\n| 异步运行时 | tokio | axum 和 reqwest 的共同运行时 |\n| body 流式 | http-body-util::StreamBody | hyper 1.0 的 Frame 模型,零拷贝 chunk 转发 |\n| 配置 | dotenvy + clap | dotenvy 加载 `.env` → 环境变量 → clap 自动绑定,CLI 优先 |\n\n## 配置优先级\n\n```\nCLI 参数 > 环境变量 (AGENTEVAL_*) > .env 文件 > 默认值\n │ │ │ │\n │ cargo run ─│─ --upstream https://x.com --verbose\n │ │ │ │\n │ export AGENTEVAL_UPSTREAM=https://x.com │\n │ │ │ │\n │ │ .env 中写的值 │ │\n │ │ │ │\n │ │ 代码中的 default_value_t\n```\n\nclap 的 `#[arg(env = \"...\")]` 让每个参数自动从同名环境变量取值,dotenvy 在 clap 解析前把 `.env` 注入到进程环境。所以 `.env` 和环境变量对 clap 来说是一回事,后者覆盖前者。\n\n## 边界情况\n\n- **非 JSON body**(如二进制流、FormData):日志记录为 `<binary N bytes>`,转发不受影响\n- **上游不可达**:返回 502 Bad Gateway,错误信息写入日志\n- **请求不带 stream: true**(非流式响应):正常代理,完整响应缓冲后落盘\n- **upstream 带 path 前缀**(如 `https://api.moonshot.ai/anthropic`):`trim_end_matches('/')` 保证拼接正确\n"}, "768fe625f0184cc879d5163a273cca12f95d1f59": {"t": 1785760703.8106492, "n": 7178, "h": "8b174758d94795dc", "c": "# agenteval 实现细节\n\n## 一句话\n\n**不劫持 TLS,劫持 URL。** 让客户端以为 `localhost:57633` 就是 API 服务器,把它发出的请求引到本地代理,由代理转发到真实上游。\n\n---\n\n## 流量转化全流程\n\n这是整个系统最核心的链路,每一步都精确描述流量是如何被\"偏转\"的:\n\n### 第 0 步:你设置了什么\n\n```bash\nANTHROPIC_BASE_URL=http://127.0.0.1:57633\n```\n\n一行为什么能抓到所有包?\n\nClaude Code、OpenAI SDK、langchain 等客户端,内部都用一个 HTTP client,向 `BASE_URL` 发请求。正常情况:\n\n```\n客户端 ── HTTPS ──► api.anthropic.com:443\n```\n\n你把 `BASE_URL` 改成 `http://127.0.0.1:57633` 后:\n\n```\n客户端 ── HTTP ──► 127.0.0.1:57633\n```\n\n客户端是\"被骗\"的一方 —— 它根本不知道对面不是真正的 API 服务器。它老老实实地把自己的请求(包括 Authorization header、JSON body)用 HTTP 明文发给本地的 57633 端口。\n\n### 第 1 步:请求到达代理\n\n**代理是一个 axum HTTP server,只绑定 127.0.0.1,不对外暴露。**\n\n```\n客户端 agenteval (127.0.0.1:57633)\n │ │\n │ POST /v1/messages │\n │ Host: 127.0.0.1:57633 │\n │ Authorization: sk-ant-... │\n │ Content-Type: application/json\n │ {\"model\":\"claude-4.6\",...} │\n │ ──────────────────────────► │\n │ │\n```\n\naxum 的 `Router::fallback(any(proxy_handler))` 接收**所有** HTTP 方法(GET/POST/PATCH/...)、**所有**路径(`/v1/messages`、`/v1/models`、`/`)。\n\n### 第 2 步:拼接上游 URL\n\n```rust\n// proxy_handler 中\nlet path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or(\"/\");\nlet upstream_url = format!(\"{}{}\", state.upstream_base, path_and_query);\n```\n\n**关键:只有 path + query 被拼接,Host 被替换。** 这是流量转移的本质。\n\n一个具体例子:\n\n| 客户端请求 | upstream 配置 | 拼接结果 |\n|---|---|---|\n| `http://127.0.0.1:57633/v1/messages` | `https://api.anthropic.com` | `https://api.anthropic.com/v1/messages` |\n| `http://127.0.0.1:57633/v1/messages?beta=true` | `https://api.openai.com` | `https://api.openai.com/v1/messages?beta=true` |\n| `http://127.0.0.1:8080/` | `https://httpbin.org` | `https://httpbin.org/` |\n\n原始的 `Host: 127.0.0.1:57633` 被**丢弃**,替换为上游的 host。客户端以为自己发给了本地,实际上请求的 path 和 body 被完整打包,发往了真正的 API。\n\n### 第 3 步:Header 清洗与转发\n\n```\n客户端 ──► proxy upstream ◄── proxy\n │ │\n │ Host: 127.0.0.1:57633 ── 丢弃 │\n │ Content-Length: 1234 ── 丢弃(reqwest 自算)│\n │ Transfer-Encoding: chunked ── 丢弃 │\n │ Authorization: sk-ant-... ── 原样转发 ────────► │\n │ Content-Type: application/json── 原样转发 ────────► │\n │ anthropic-version: 2023-06-01 ── 原样转发 ────────► │\n │ x-api-key: sk-... ── 原样转发 ────────► │\n```\n\n```rust\nfor (key, value) in headers.iter() {\n let k = key.as_str().to_lowercase();\n if k == \"host\" || k == \"content-length\" || k == \"transfer-encoding\" {\n continue; // 跳过这 3 个\n }\n req_builder = req_builder.header(key.as_str(), value);\n}\n```\n\n**为什么跳过这三个?**\n\n- **`Host`**:客户端写的是 `127.0.0.1:57633`,但我们要发给 `api.anthropic.com`。reqwest 会自动根据目标 URL 填入正确的 Host header 和 SNI。\n- **`Content-Length` / `Transfer-Encoding`**:reqwest 会根据实际 body 大小重新计算。原样转发会导致长度不匹配。\n\n### 第 4 步:代理发出 HTTPS 请求\n\n```rust\nstate.client.request(method, &upstream_url) // reqwest Client\n .headers(cleaned_headers)\n .body(body_bytes)\n .send()\n```\n\n**这里是流量从\"明文\"变回\"加密\"的地方。**\n\nreqwest 是一个完整的 HTTPS 客户端。当它看到 `upstream_url = \"https://api.anthropic.com/v1/messages\"` 时:\n\n1. 通过系统 DNS 解析 `api.anthropic.com` 的真实 IP\n2. 与真实 IP 的 443 端口建立 TCP 连接\n3. 执行标准 TLS 1.3 握手(验证证书链、SNI)\n4. 在加密通道中发送 HTTP 请求\n\n所以从上游 API 的视角,这个请求和客户端直接发出的**没有任何区别**。它就是一次正常的 HTTPS 请求。\n\n```\n agenteval 内部\n客户端 ◄── HTTP ──► [axum server] ── 内存 ──► [reqwest client] ◄── HTTPS ──► api.anthropic.com\n │ │\n plaintext buffer TLS 1.3 + 证书验证\n 127.0.0.1:57633 真实 IP:443\n```\n\n### 第 5 步:响应流式透传\n\n上游的 HTTPS 响应到达后,代理把它拆成字节流,逐个 chunk 推回给客户端:\n\n```\napi.anthropic.com agenteval 客户端\n │ │ │\n │ HTTPS response │ │\n │ (SSE: data: {\"delta\": │ │\n │ {\"text\":\"Hello\"}} ) │ │\n │ ──────────────────────────► │ │\n │ │ HTTP response │\n │ │ (same bytes, no TLS) │\n │ │ ──────────────────────────► │\n │ │ │\n │ SSE: data: {\"delta\": │ │\n │ {\"text\":\" world\"}} ) │ │\n │ ──────────────────────────► │ │\n │ │ SSE: data: {\"delta\": │\n │ │ {\"text\":\" world\"}} ) │\n │ │ ──────────────────────────► │\n```\n\n流式透传的实现:\n\n```rust\nlet frame_stream = upstream_resp.bytes_stream().map(|result| {\n match result {\n Ok(bytes) => Ok(Frame::data(bytes)),\n Err(e) => Err(Box::new(e) as ...),\n }\n});\nlet axum_body = Body::new(StreamBody::new(frame_stream));\n```\n\n- `reqwest::Response::bytes_stream()` 返回一个异步流,每收到一个 TCP segment 就 yield 一个 `Bytes` chunk\n- `Frame::data(bytes)` 把裸字节包装成 HTTP body frame\n- `StreamBody` 把 frame 流转成 axum 可发送的 response body\n- **不做 buffering** —— 上游发一段,代理就转发一段。客户端不需要等整个响应完成,可以看到 token-by-token 的实时输出\n\n### 第 6 步:写入审计日志\n\n每个请求完成后,代理将完整记录写入 `~/.agenteval/logs/0001.json`:\n\n```json\n{\n \"id\": 1,\n \"ts\": 1779805904142,\n \"method\": \"POST\",\n \"path\": \"/v1/messages\",\n \"upstream\": \"https://api.anthropic.com/v1/messages\",\n \"request_headers\": { \"authorization\": \"sk-ant-...\", \"content-type\": \"application/json\" },\n \"request_body\": { \"model\": \"claude-4.6\", \"messages\": [...] },\n \"response_status\": 200,\n \"response_headers\": { \"content-type\": \"text/event-stream\", ... },\n \"duration_ms\": 3421,\n \"streaming\": true\n}\n```\n\n---\n\n## 为什么这个方案能工作,而传统方案不行\n\n| 传统 MITM 方案 | agenteval |\n|---|---|\n| 代理伪装成 API 服务器,**自己签发 TLS 证书**,需要系统信任该 CA | 代理是**纯 HTTP server**,TLS 由 reqwest 在出站侧处理,使用系统认可的证书 |\n| 依赖 `HTTP_PROXY` / `HTTPS_PROXY` 环境变量 | 利用各 CLI 的 `BASE_URL` 环境变量 |\n| CLI 工具通常不读 `HTTP_PROXY`,直接无代理连接 | CLI 总是读 `BASE_URL`,这是 SDK 级别的参数 |\n| 证书绑定(certificate pinning)直接失效 | 客户端看到的是真实 API 的证书,绑定逻辑正常 |\n| 每次原始报文,人工解析 | 按 API 格式结构化存储 request body |\n\n**核心优势:客户端自己完成 TLS 连接的事实检查(证书链、SNI、OCSP)**。代理只做它擅长的事 —— 在中间那个唯一的明文 hop 上坐下来,不声不响地记录一切。\n\n---\n\n## 项目结构\n\n```\nAgentEval/\n├── Cargo.toml # 依赖:axum, reqwest, tokio, clap, serde_json\n├── src/\n│ └── main.rs # 全部实现在一个文件(~250 行)\n└── docs/\n └── implementation.md # 你正在看的这个文件\n```\n\n## 关键依赖选型\n\n| 组件 | 选型 | 原因 |\n|---|---|---|\n| HTTP server | axum 0.7 | 基于 hyper/tower,async 原生,生态成熟 |\n| HTTPS 客户端 | reqwest 0.12 | 系统 TLS 支持,stream body 原生支持 |\n| 异步运行时 | tokio | axum 和 reqwest 的共同运行时 |\n| body 流式 | http-body-util::StreamBody | hyper 1.0 的 Frame 模型,零拷贝 chunk 转发 |\n| CLI | clap 4 | Rust 社区标准 CLI 框架 |\n\n## 边界情况\n\n- **非 JSON body**(如二进制流、FormData):日志记录为 `<binary N bytes>`,转发不受影响\n- **上游不可达**:返回 502 Bad Gateway,错误信息写入日志\n- **请求不带 stream: true**(非流式响应):正常代理,完整响应缓冲后落盘\n- **upstream 带 path 前缀**(如 `https://api.moonshot.ai/anthropic`):`trim_end_matches('/')` 保证拼接正确\n"}}, "src/main.rs": {"fdfb98d942969e64f374dbc3dfe15bd6b22ecc89": {"t": 1785746220.1043904, "n": 6688, "h": "aa9f93f4568ce78b", "c": "mod config;\nmod diagnose;\nmod eval;\nmod format;\nmod grader;\nmod probe;\nmod proxy;\nmod web;\n\nuse std::path::Path;\nuse std::sync::Arc;\n\nuse axum::{routing::{any, get, post}, Router};\n\nuse config::{Config, GraderConfig, ProbeConfig};\nuse eval::types::TurnRecord;\nuse proxy::AppState;\n\n#[tokio::main]\nasync fn main() {\n // CLI: diagnose / probe subcommands\n let args: Vec<String> = std::env::args().collect();\n if args.len() >= 2 && args[1] == \"diagnose\" {\n run_diagnose_cli(&args).await;\n return;\n }\n if args.len() >= 2 && args[1] == \"probe\" {\n run_probe_cli(&args).await;\n return;\n }\n\n let config = Config::load();\n\n std::fs::create_dir_all(&config.log_dir).expect(\"Failed to create log directory\");\n\n let (eval_tx, eval_rx) = tokio::sync::mpsc::unbounded_channel::<TurnRecord>();\n let log_dir = config.log_dir.clone();\n\n let grader_config = GraderConfig::load(&config.upstream);\n let probe_config = ProbeConfig::load();\n\n let state = Arc::new(AppState::new(&config, eval_tx, grader_config.clone(), probe_config));\n\n // 从 trace_file 中提取 jsonl stem,用于 session 文件命名\n let jsonl_stem = Path::new(&state.trace_file)\n .file_stem()\n .and_then(|s| s.to_str())\n .unwrap_or(\"session_unknown\")\n .to_string();\n\n // Spawn eval consumer: 异步构建结构化会话视图 + 评分\n tokio::spawn(eval::run(eval_rx, log_dir, jsonl_stem, grader_config));\n\n let app = if config.ui_enabled {\n Router::new()\n .route(\"/dashboard/\", get(web::serve_ui))\n .route(\"/dashboard/api/sessions\", get(web::list_sessions))\n .route(\"/dashboard/api/sessions/{session_id}\", get(web::get_session))\n .route(\"/dashboard/api/sessions/{session_id}/grade\", post(web::grade_session))\n .route(\"/dashboard/api/sessions/{session_id}/diagnose\", post(web::diagnose_session).get(web::get_diagnose))\n .route(\"/dashboard/api/sessions/{session_id}/probe\", post(web::probe_session).get(web::get_probe))\n .route(\"/dashboard/api/raw/{jsonl_stem}\", get(web::get_raw_jsonl))\n .fallback(any(proxy::handler))\n .with_state(state)\n } else {\n Router::new()\n .fallback(any(proxy::handler))\n .with_state(state)\n };\n\n let addr = format!(\"127.0.0.1:{}\", config.port);\n let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();\n\n println!(\"listening http://{} -> {}\", addr, config.upstream);\n if config.ui_enabled {\n println!(\"dashboard http://{}/dashboard/\", addr);\n }\n\n axum::serve(listener, app).await.unwrap();\n}\n\nasync fn run_diagnose_cli(args: &[String]) {\n let session_id = match args.get(2) {\n Some(id) => id.as_str(),\n None => {\n eprintln!(\"Usage: cargo run -- diagnose <session_id> [--format terminal|json]\");\n std::process::exit(1);\n }\n };\n\n let format = if args.len() >= 4 && args[3] == \"--format\" {\n args.get(4).map(|s| s.as_str()).unwrap_or(\"json\")\n } else {\n \"json\"\n };\n\n let log_dir = std::env::var(\"AGENTEVAL_LOG_DIR\").unwrap_or_else(|_| \"./logs\".to_string());\n let grader_config = config::GraderConfig::load(\"https://api.deepseek.com\");\n\n match diagnose::run(session_id, &log_dir, &grader_config).await {\n Ok(report) => match format {\n \"terminal\" => {\n println!(\"=== Diagnose Report: {} ===\", report.session_id);\n println!(\"Diagnosed at: {}\", report.diagnosed_at);\n println!(\n \"Issues: {} total ({} errors, {} warnings, {} infos)\",\n report.summary.total_issues,\n report.summary.errors,\n report.summary.warnings,\n report.summary.infos\n );\n if report.issues.is_empty() {\n println!(\"✓ No issues found.\");\n } else {\n for (_i, issue) in report.issues.iter().enumerate() {\n let icon = match issue.severity {\n diagnose::types::Severity::Error => \"🔴\",\n diagnose::types::Severity::Warn => \"🟡\",\n diagnose::types::Severity::Info => \"🔵\",\n };\n println!();\n println!(\n \"{} [{:?}] {}\",\n icon, issue.category, issue.title\n );\n println!(\" {}\", issue.detail);\n if !issue.evidence.is_empty() {\n println!(\" Evidence: {}\", issue.evidence);\n }\n println!(\n \" Location: jsonl_id={:?}, turn={:?}, step={:?}\",\n issue.location.jsonl_id,\n issue.location.turn_id,\n issue.location.step_index\n );\n }\n if let Some(ref summary) = report.llm_summary {\n println!();\n println!(\"--- LLM Summary ---\");\n println!(\"{}\", summary);\n }\n }\n }\n _ => {\n println!(\n \"{}\",\n serde_json::to_string_pretty(&report).unwrap_or_default()\n );\n }\n },\n Err(e) => {\n eprintln!(\"Diagnose failed: {}\", e);\n std::process::exit(1);\n }\n }\n}\n\nasync fn run_probe_cli(args: &[String]) {\n let session_id = match args.get(2) {\n Some(id) => id.as_str(),\n None => {\n eprintln!(\"Usage: cargo run -- probe <session_id>\");\n std::process::exit(1);\n }\n };\n\n let log_dir = std::env::var(\"AGENTEVAL_LOG_DIR\").unwrap_or_else(|_| \"./logs\".to_string());\n let probe_config = config::ProbeConfig::load();\n let grader_config = config::GraderConfig::load(\"https://api.deepseek.com\");\n\n eprintln!(\"Running probe for session: {}\", session_id);\n eprintln!(\"Source project: {}\", probe_config.source_project_dir);\n\n match probe::run(session_id, &log_dir, &probe_config, &grader_config).await {\n Ok(report) => {\n println!(\"{}\", serde_json::to_string_pretty(&report).unwrap_or_default());\n if report.parse_error.is_some() {\n eprintln!(\"Warning: probe output parse error, raw content preserved\");\n }\n }\n Err(e) => {\n eprintln!(\"Probe failed: {}\", e);\n std::process::exit(1);\n }\n }\n}\n"}, "bce476f83d538a4d8c495c3c9ec46b2e1949629e": {"t": 1785760700.8483908, "n": 6688, "h": "aa9f93f4568ce78b", "c": "mod config;\nmod diagnose;\nmod eval;\nmod format;\nmod grader;\nmod probe;\nmod proxy;\nmod web;\n\nuse std::path::Path;\nuse std::sync::Arc;\n\nuse axum::{routing::{any, get, post}, Router};\n\nuse config::{Config, GraderConfig, ProbeConfig};\nuse eval::types::TurnRecord;\nuse proxy::AppState;\n\n#[tokio::main]\nasync fn main() {\n // CLI: diagnose / probe subcommands\n let args: Vec<String> = std::env::args().collect();\n if args.len() >= 2 && args[1] == \"diagnose\" {\n run_diagnose_cli(&args).await;\n return;\n }\n if args.len() >= 2 && args[1] == \"probe\" {\n run_probe_cli(&args).await;\n return;\n }\n\n let config = Config::load();\n\n std::fs::create_dir_all(&config.log_dir).expect(\"Failed to create log directory\");\n\n let (eval_tx, eval_rx) = tokio::sync::mpsc::unbounded_channel::<TurnRecord>();\n let log_dir = config.log_dir.clone();\n\n let grader_config = GraderConfig::load(&config.upstream);\n let probe_config = ProbeConfig::load();\n\n let state = Arc::new(AppState::new(&config, eval_tx, grader_config.clone(), probe_config));\n\n // 从 trace_file 中提取 jsonl stem,用于 session 文件命名\n let jsonl_stem = Path::new(&state.trace_file)\n .file_stem()\n .and_then(|s| s.to_str())\n .unwrap_or(\"session_unknown\")\n .to_string();\n\n // Spawn eval consumer: 异步构建结构化会话视图 + 评分\n tokio::spawn(eval::run(eval_rx, log_dir, jsonl_stem, grader_config));\n\n let app = if config.ui_enabled {\n Router::new()\n .route(\"/dashboard/\", get(web::serve_ui))\n .route(\"/dashboard/api/sessions\", get(web::list_sessions))\n .route(\"/dashboard/api/sessions/{session_id}\", get(web::get_session))\n .route(\"/dashboard/api/sessions/{session_id}/grade\", post(web::grade_session))\n .route(\"/dashboard/api/sessions/{session_id}/diagnose\", post(web::diagnose_session).get(web::get_diagnose))\n .route(\"/dashboard/api/sessions/{session_id}/probe\", post(web::probe_session).get(web::get_probe))\n .route(\"/dashboard/api/raw/{jsonl_stem}\", get(web::get_raw_jsonl))\n .fallback(any(proxy::handler))\n .with_state(state)\n } else {\n Router::new()\n .fallback(any(proxy::handler))\n .with_state(state)\n };\n\n let addr = format!(\"127.0.0.1:{}\", config.port);\n let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();\n\n println!(\"listening http://{} -> {}\", addr, config.upstream);\n if config.ui_enabled {\n println!(\"dashboard http://{}/dashboard/\", addr);\n }\n\n axum::serve(listener, app).await.unwrap();\n}\n\nasync fn run_diagnose_cli(args: &[String]) {\n let session_id = match args.get(2) {\n Some(id) => id.as_str(),\n None => {\n eprintln!(\"Usage: cargo run -- diagnose <session_id> [--format terminal|json]\");\n std::process::exit(1);\n }\n };\n\n let format = if args.len() >= 4 && args[3] == \"--format\" {\n args.get(4).map(|s| s.as_str()).unwrap_or(\"json\")\n } else {\n \"json\"\n };\n\n let log_dir = std::env::var(\"AGENTEVAL_LOG_DIR\").unwrap_or_else(|_| \"./logs\".to_string());\n let grader_config = config::GraderConfig::load(\"https://api.deepseek.com\");\n\n match diagnose::run(session_id, &log_dir, &grader_config).await {\n Ok(report) => match format {\n \"terminal\" => {\n println!(\"=== Diagnose Report: {} ===\", report.session_id);\n println!(\"Diagnosed at: {}\", report.diagnosed_at);\n println!(\n \"Issues: {} total ({} errors, {} warnings, {} infos)\",\n report.summary.total_issues,\n report.summary.errors,\n report.summary.warnings,\n report.summary.infos\n );\n if report.issues.is_empty() {\n println!(\"✓ No issues found.\");\n } else {\n for (_i, issue) in report.issues.iter().enumerate() {\n let icon = match issue.severity {\n diagnose::types::Severity::Error => \"🔴\",\n diagnose::types::Severity::Warn => \"🟡\",\n diagnose::types::Severity::Info => \"🔵\",\n };\n println!();\n println!(\n \"{} [{:?}] {}\",\n icon, issue.category, issue.title\n );\n println!(\" {}\", issue.detail);\n if !issue.evidence.is_empty() {\n println!(\" Evidence: {}\", issue.evidence);\n }\n println!(\n \" Location: jsonl_id={:?}, turn={:?}, step={:?}\",\n issue.location.jsonl_id,\n issue.location.turn_id,\n issue.location.step_index\n );\n }\n if let Some(ref summary) = report.llm_summary {\n println!();\n println!(\"--- LLM Summary ---\");\n println!(\"{}\", summary);\n }\n }\n }\n _ => {\n println!(\n \"{}\",\n serde_json::to_string_pretty(&report).unwrap_or_default()\n );\n }\n },\n Err(e) => {\n eprintln!(\"Diagnose failed: {}\", e);\n std::process::exit(1);\n }\n }\n}\n\nasync fn run_probe_cli(args: &[String]) {\n let session_id = match args.get(2) {\n Some(id) => id.as_str(),\n None => {\n eprintln!(\"Usage: cargo run -- probe <session_id>\");\n std::process::exit(1);\n }\n };\n\n let log_dir = std::env::var(\"AGENTEVAL_LOG_DIR\").unwrap_or_else(|_| \"./logs\".to_string());\n let probe_config = config::ProbeConfig::load();\n let grader_config = config::GraderConfig::load(\"https://api.deepseek.com\");\n\n eprintln!(\"Running probe for session: {}\", session_id);\n eprintln!(\"Source project: {}\", probe_config.source_project_dir);\n\n match probe::run(session_id, &log_dir, &probe_config, &grader_config).await {\n Ok(report) => {\n println!(\"{}\", serde_json::to_string_pretty(&report).unwrap_or_default());\n if report.parse_error.is_some() {\n eprintln!(\"Warning: probe output parse error, raw content preserved\");\n }\n }\n Err(e) => {\n eprintln!(\"Probe failed: {}\", e);\n std::process::exit(1);\n }\n }\n}\n"}, "ea37daba64d60a8a1a2801726959797a4486219c": {"t": 1785760703.0500958, "n": 6342, "h": "ab3fe14048674210", "c": "mod config;\nmod diagnose;\nmod eval;\nmod format;\nmod grader;\nmod probe;\nmod proxy;\nmod web;\n\nuse std::path::Path;\nuse std::sync::Arc;\n\nuse axum::{routing::{any, get, post}, Router};\n\nuse config::{Config, GraderConfig, ProbeConfig};\nuse eval::types::TurnRecord;\nuse proxy::AppState;\n\n#[tokio::main]\nasync fn main() {\n // CLI: diagnose / probe subcommands\n let args: Vec<String> = std::env::args().collect();\n if args.len() >= 2 && args[1] == \"diagnose\" {\n run_diagnose_cli(&args);\n return;\n }\n if args.len() >= 2 && args[1] == \"probe\" {\n run_probe_cli(&args).await;\n return;\n }\n\n let config = Config::load();\n\n std::fs::create_dir_all(&config.log_dir).expect(\"Failed to create log directory\");\n\n let (eval_tx, eval_rx) = tokio::sync::mpsc::unbounded_channel::<TurnRecord>();\n let log_dir = config.log_dir.clone();\n\n let grader_config = GraderConfig::load(&config.upstream);\n let probe_config = ProbeConfig::load();\n\n let state = Arc::new(AppState::new(&config, eval_tx, grader_config.clone(), probe_config));\n\n // 从 trace_file 中提取 jsonl stem,用于 session 文件命名\n let jsonl_stem = Path::new(&state.trace_file)\n .file_stem()\n .and_then(|s| s.to_str())\n .unwrap_or(\"session_unknown\")\n .to_string();\n\n // Spawn eval consumer: 异步构建结构化会话视图 + 评分\n tokio::spawn(eval::run(eval_rx, log_dir, jsonl_stem, grader_config));\n\n let app = if config.ui_enabled {\n Router::new()\n .route(\"/dashboard/\", get(web::serve_ui))\n .route(\"/dashboard/api/sessions\", get(web::list_sessions))\n .route(\"/dashboard/api/sessions/{session_id}\", get(web::get_session))\n .route(\"/dashboard/api/sessions/{session_id}/grade\", post(web::grade_session))\n .route(\"/dashboard/api/sessions/{session_id}/diagnose\", post(web::diagnose_session).get(web::get_diagnose))\n .route(\"/dashboard/api/sessions/{session_id}/probe\", post(web::probe_session).get(web::get_probe))\n .route(\"/dashboard/api/raw/{jsonl_stem}\", get(web::get_raw_jsonl))\n .fallback(any(proxy::handler))\n .with_state(state)\n } else {\n Router::new()\n .fallback(any(proxy::handler))\n .with_state(state)\n };\n\n let addr = format!(\"127.0.0.1:{}\", config.port);\n let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();\n\n println!(\"listening http://{} -> {}\", addr, config.upstream);\n if config.ui_enabled {\n println!(\"dashboard http://{}/dashboard/\", addr);\n }\n\n axum::serve(listener, app).await.unwrap();\n}\n\nfn run_diagnose_cli(args: &[String]) {\n let session_id = match args.get(2) {\n Some(id) => id.as_str(),\n None => {\n eprintln!(\"Usage: cargo run -- diagnose <session_id> [--format terminal|json]\");\n std::process::exit(1);\n }\n };\n\n let format = if args.len() >= 4 && args[3] == \"--format\" {\n args.get(4).map(|s| s.as_str()).unwrap_or(\"json\")\n } else {\n \"json\"\n };\n\n let log_dir = std::env::var(\"AGENTEVAL_LOG_DIR\").unwrap_or_else(|_| \"./logs\".to_string());\n\n match diagnose::run(session_id, &log_dir) {\n Ok(report) => match format {\n \"terminal\" => {\n println!(\"=== Diagnose Report: {} ===\", report.session_id);\n println!(\"Diagnosed at: {}\", report.diagnosed_at);\n println!(\n \"Issues: {} total ({} errors, {} warnings, {} infos)\",\n report.summary.total_issues,\n report.summary.errors,\n report.summary.warnings,\n report.summary.infos\n );\n if report.issues.is_empty() {\n println!(\"✓ No issues found.\");\n } else {\n for (_i, issue) in report.issues.iter().enumerate() {\n let icon = match issue.severity {\n diagnose::types::Severity::Error => \"🔴\",\n diagnose::types::Severity::Warn => \"🟡\",\n diagnose::types::Severity::Info => \"🔵\",\n };\n println!();\n println!(\n \"{} [{:?}] {}\",\n icon, issue.category, issue.title\n );\n println!(\" {}\", issue.detail);\n if !issue.evidence.is_empty() {\n println!(\" Evidence: {}\", issue.evidence);\n }\n println!(\n \" Location: jsonl_id={:?}, turn={:?}, step={:?}\",\n issue.location.jsonl_id,\n issue.location.turn_id,\n issue.location.step_index\n );\n }\n }\n }\n _ => {\n println!(\n \"{}\",\n serde_json::to_string_pretty(&report).unwrap_or_default()\n );\n }\n },\n Err(e) => {\n eprintln!(\"Diagnose failed: {}\", e);\n std::process::exit(1);\n }\n }\n}\n\nasync fn run_probe_cli(args: &[String]) {\n let session_id = match args.get(2) {\n Some(id) => id.as_str(),\n None => {\n eprintln!(\"Usage: cargo run -- probe <session_id>\");\n std::process::exit(1);\n }\n };\n\n let log_dir = std::env::var(\"AGENTEVAL_LOG_DIR\").unwrap_or_else(|_| \"./logs\".to_string());\n let probe_config = config::ProbeConfig::load();\n let grader_config = config::GraderConfig::load(\"https://api.deepseek.com\");\n\n eprintln!(\"Running probe for session: {}\", session_id);\n eprintln!(\"Source project: {}\", probe_config.source_project_dir);\n\n match probe::run(session_id, &log_dir, &probe_config, &grader_config).await {\n Ok(report) => {\n println!(\"{}\", serde_json::to_string_pretty(&report).unwrap_or_default());\n if report.parse_error.is_some() {\n eprintln!(\"Warning: probe output parse error, raw content preserved\");\n }\n }\n Err(e) => {\n eprintln!(\"Probe failed: {}\", e);\n std::process::exit(1);\n }\n }\n}\n"}, "0837d4a22c73f35c41e6aea90d4c419d66b87736": {"t": 1785760704.2657728, "n": 4954, "h": "18ad637ed6b406b6", "c": "mod config;\nmod diagnose;\nmod eval;\nmod format;\nmod grader;\nmod proxy;\nmod web;\n\nuse std::path::Path;\nuse std::sync::Arc;\n\nuse axum::{routing::{any, get, post}, Router};\n\nuse config::{Config, GraderConfig};\nuse eval::types::TurnRecord;\nuse proxy::AppState;\n\n#[tokio::main]\nasync fn main() {\n // CLI: diagnose subcommand\n let args: Vec<String> = std::env::args().collect();\n if args.len() >= 2 && args[1] == \"diagnose\" {\n run_diagnose_cli(&args);\n return;\n }\n\n let config = Config::load();\n\n std::fs::create_dir_all(&config.log_dir).expect(\"Failed to create log directory\");\n\n let (eval_tx, eval_rx) = tokio::sync::mpsc::unbounded_channel::<TurnRecord>();\n let log_dir = config.log_dir.clone();\n\n let grader_config = GraderConfig::load(&config.upstream);\n\n let state = Arc::new(AppState::new(&config, eval_tx, grader_config.clone()));\n\n // 从 trace_file 中提取 jsonl stem,用于 session 文件命名\n let jsonl_stem = Path::new(&state.trace_file)\n .file_stem()\n .and_then(|s| s.to_str())\n .unwrap_or(\"session_unknown\")\n .to_string();\n\n // Spawn eval consumer: 异步构建结构化会话视图 + 评分\n tokio::spawn(eval::run(eval_rx, log_dir, jsonl_stem, grader_config));\n\n let app = if config.ui_enabled {\n Router::new()\n .route(\"/dashboard/\", get(web::serve_ui))\n .route(\"/dashboard/api/sessions\", get(web::list_sessions))\n .route(\"/dashboard/api/sessions/{session_id}\", get(web::get_session))\n .route(\"/dashboard/api/sessions/{session_id}/grade\", post(web::grade_session))\n .route(\"/dashboard/api/sessions/{session_id}/diagnose\", post(web::diagnose_session).get(web::get_diagnose))\n .route(\"/dashboard/api/raw/{jsonl_stem}\", get(web::get_raw_jsonl))\n .fallback(any(proxy::handler))\n .with_state(state)\n } else {\n Router::new()\n .fallback(any(proxy::handler))\n .with_state(state)\n };\n\n let addr = format!(\"127.0.0.1:{}\", config.port);\n let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();\n\n println!(\"listening http://{} -> {}\", addr, config.upstream);\n if config.ui_enabled {\n println!(\"dashboard http://{}/dashboard/\", addr);\n }\n\n axum::serve(listener, app).await.unwrap();\n}\n\nfn run_diagnose_cli(args: &[String]) {\n let session_id = match args.get(2) {\n Some(id) => id.as_str(),\n None => {\n eprintln!(\"Usage: cargo run -- diagnose <session_id> [--format terminal|json]\");\n std::process::exit(1);\n }\n };\n\n let format = if args.len() >= 4 && args[3] == \"--format\" {\n args.get(4).map(|s| s.as_str()).unwrap_or(\"json\")\n } else {\n \"json\"\n };\n\n let log_dir = std::env::var(\"AGENTEVAL_LOG_DIR\").unwrap_or_else(|_| \"./logs\".to_string());\n\n match diagnose::run(session_id, &log_dir) {\n Ok(report) => match format {\n \"terminal\" => {\n println!(\"=== Diagnose Report: {} ===\", report.session_id);\n println!(\"Diagnosed at: {}\", report.diagnosed_at);\n println!(\n \"Issues: {} total ({} errors, {} warnings, {} infos)\",\n report.summary.total_issues,\n report.summary.errors,\n report.summary.warnings,\n report.summary.infos\n );\n if report.issues.is_empty() {\n println!(\"✓ No issues found.\");\n } else {\n for (_i, issue) in report.issues.iter().enumerate() {\n let icon = match issue.severity {\n diagnose::types::Severity::Error => \"🔴\",\n diagnose::types::Severity::Warn => \"🟡\",\n diagnose::types::Severity::Info => \"🔵\",\n };\n println!();\n println!(\n \"{} [{:?}] {}\",\n icon, issue.category, issue.title\n );\n println!(\" {}\", issue.detail);\n if !issue.evidence.is_empty() {\n println!(\" Evidence: {}\", issue.evidence);\n }\n println!(\n \" Location: jsonl_id={:?}, turn={:?}, step={:?}\",\n issue.location.jsonl_id,\n issue.location.turn_id,\n issue.location.step_index\n );\n }\n }\n }\n _ => {\n println!(\n \"{}\",\n serde_json::to_string_pretty(&report).unwrap_or_default()\n );\n }\n },\n Err(e) => {\n eprintln!(\"Diagnose failed: {}\", e);\n std::process::exit(1);\n }\n }\n}\n"}, "2cc413ffdacbb815f421c12b8b87422ed20dcf71": {"t": 1785760706.239019, "n": 1891, "h": "a32089dbbba95e85", "c": "mod config;\nmod eval;\nmod format;\nmod grader;\nmod proxy;\nmod web;\n\nuse std::path::Path;\nuse std::sync::Arc;\n\nuse axum::{routing::{any, get, post}, Router};\n\nuse config::{Config, GraderConfig};\nuse eval::types::TurnRecord;\nuse proxy::AppState;\n\n#[tokio::main]\nasync fn main() {\n let config = Config::load();\n\n std::fs::create_dir_all(&config.log_dir).expect(\"Failed to create log directory\");\n\n let (eval_tx, eval_rx) = tokio::sync::mpsc::unbounded_channel::<TurnRecord>();\n let log_dir = config.log_dir.clone();\n\n let grader_config = GraderConfig::load(&config.upstream);\n\n let state = Arc::new(AppState::new(&config, eval_tx, grader_config.clone()));\n\n // 从 trace_file 中提取 jsonl stem,用于 session 文件命名\n let jsonl_stem = Path::new(&state.trace_file)\n .file_stem()\n .and_then(|s| s.to_str())\n .unwrap_or(\"session_unknown\")\n .to_string();\n\n // Spawn eval consumer: 异步构建结构化会话视图 + 评分\n tokio::spawn(eval::run(eval_rx, log_dir, jsonl_stem, grader_config));\n\n let app = if config.ui_enabled {\n Router::new()\n .route(\"/dashboard/\", get(web::serve_ui))\n .route(\"/dashboard/api/sessions\", get(web::list_sessions))\n .route(\"/dashboard/api/sessions/{session_id}\", get(web::get_session))\n .route(\"/dashboard/api/sessions/{session_id}/grade\", post(web::grade_session))\n .fallback(any(proxy::handler))\n .with_state(state)\n } else {\n Router::new()\n .fallback(any(proxy::handler))\n .with_state(state)\n };\n\n let addr = format!(\"127.0.0.1:{}\", config.port);\n let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();\n\n println!(\"listening http://{} -> {}\", addr, config.upstream);\n if config.ui_enabled {\n println!(\"dashboard http://{}/dashboard/\", addr);\n }\n\n axum::serve(listener, app).await.unwrap();\n}\n"}, "54dc0f66c6e51537b0956d2115242d38f564f72c": {"t": 1785760707.5339289, "n": 1771, "h": "09326cd8208efd8f", "c": "mod config;\nmod eval;\nmod format;\nmod grader;\nmod proxy;\nmod web;\n\nuse std::path::Path;\nuse std::sync::Arc;\n\nuse axum::{routing::{any, get}, Router};\n\nuse config::{Config, GraderConfig};\nuse eval::types::TurnRecord;\nuse proxy::AppState;\n\n#[tokio::main]\nasync fn main() {\n let config = Config::load();\n\n std::fs::create_dir_all(&config.log_dir).expect(\"Failed to create log directory\");\n\n let (eval_tx, eval_rx) = tokio::sync::mpsc::unbounded_channel::<TurnRecord>();\n let log_dir = config.log_dir.clone();\n\n let state = Arc::new(AppState::new(&config, eval_tx));\n\n // 从 trace_file 中提取 jsonl stem,用于 session 文件命名\n let jsonl_stem = Path::new(&state.trace_file)\n .file_stem()\n .and_then(|s| s.to_str())\n .unwrap_or(\"session_unknown\")\n .to_string();\n\n let grader_config = GraderConfig::load(&config.upstream);\n\n // Spawn eval consumer: 异步构建结构化会话视图 + 评分\n tokio::spawn(eval::run(eval_rx, log_dir, jsonl_stem, grader_config));\n\n let app = if config.ui_enabled {\n Router::new()\n .route(\"/dashboard/\", get(web::serve_ui))\n .route(\"/dashboard/api/sessions\", get(web::list_sessions))\n .route(\"/dashboard/api/sessions/{session_id}\", get(web::get_session))\n .fallback(any(proxy::handler))\n .with_state(state)\n } else {\n Router::new()\n .fallback(any(proxy::handler))\n .with_state(state)\n };\n\n let addr = format!(\"127.0.0.1:{}\", config.port);\n let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();\n\n println!(\"listening http://{} -> {}\", addr, config.upstream);\n if config.ui_enabled {\n println!(\"dashboard http://{}/dashboard/\", addr);\n }\n\n axum::serve(listener, app).await.unwrap();\n}\n"}, "26b8a010c8e56a357b0806a4cc097503072e0f7b": {"t": 1785760709.249029, "n": 1303, "h": "ae54222b3dd3ee4b", "c": "mod config;\nmod eval;\nmod format;\nmod grader;\nmod proxy;\n\nuse std::path::Path;\nuse std::sync::Arc;\n\nuse axum::{routing::any, Router};\n\nuse config::{Config, GraderConfig};\nuse eval::types::TurnRecord;\nuse proxy::AppState;\n\n#[tokio::main]\nasync fn main() {\n let config = Config::load();\n\n std::fs::create_dir_all(&config.log_dir).expect(\"Failed to create log directory\");\n\n let (eval_tx, eval_rx) = tokio::sync::mpsc::unbounded_channel::<TurnRecord>();\n let log_dir = config.log_dir.clone();\n\n let state = Arc::new(AppState::new(&config, eval_tx));\n\n // 从 trace_file 中提取 jsonl stem,用于 session 文件命名\n let jsonl_stem = Path::new(&state.trace_file)\n .file_stem()\n .and_then(|s| s.to_str())\n .unwrap_or(\"session_unknown\")\n .to_string();\n\n let grader_config = GraderConfig::load(&config.upstream);\n\n // Spawn eval consumer: 异步构建结构化会话视图 + 评分\n tokio::spawn(eval::run(eval_rx, log_dir, jsonl_stem, grader_config));\n\n let app = Router::new()\n .fallback(any(proxy::handler))\n .with_state(state);\n\n let addr = format!(\"127.0.0.1:{}\", config.port);\n let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();\n\n println!(\"listening http://{} -> {}\", addr, config.upstream);\n\n axum::serve(listener, app).await.unwrap();\n}\n"}, "92825c9aa4b002038c3ef331c070e654211767e1": {"t": 1785760710.8828127, "n": 942, "h": "1325f8da6824fc76", "c": "mod config;\nmod eval;\nmod format;\nmod proxy;\n\nuse std::sync::Arc;\n\nuse axum::{routing::any, Router};\n\nuse config::Config;\nuse eval::types::TurnRecord;\nuse proxy::AppState;\n\n#[tokio::main]\nasync fn main() {\n let config = Config::load();\n\n std::fs::create_dir_all(&config.log_dir).expect(\"Failed to create log directory\");\n\n let (eval_tx, eval_rx) = tokio::sync::mpsc::unbounded_channel::<TurnRecord>();\n let log_dir = config.log_dir.clone();\n\n let state = Arc::new(AppState::new(&config, eval_tx));\n\n // Spawn eval consumer: 异步构建结构化会话视图\n tokio::spawn(eval::run(eval_rx, log_dir));\n\n let app = Router::new()\n .fallback(any(proxy::handler))\n .with_state(state);\n\n let addr = format!(\"127.0.0.1:{}\", config.port);\n let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();\n\n println!(\"listening http://{} -> {}\", addr, config.upstream);\n\n axum::serve(listener, app).await.unwrap();\n}\n"}, "89f7f9c008c77c771a7a1ca1c380e5c6591c925c": {"t": 1785760712.3171155, "n": 710, "h": "adf2e5c911e2de9e", "c": "mod config;\nmod proxy;\n\nuse std::sync::Arc;\n\nuse axum::{routing::any, Router};\n\nuse config::Config;\nuse proxy::AppState;\n\n#[tokio::main]\nasync fn main() {\n let config = Config::load();\n\n std::fs::create_dir_all(&config.log_dir).expect(\"Failed to create log directory\");\n\n let state = Arc::new(AppState::new(&config));\n\n //创建app,所有请求都交给proxy::handler处理,并共享状态\n let app = Router::new()\n .fallback(any(proxy::handler))\n .with_state(state);\n\n let addr = format!(\"127.0.0.1:{}\", config.port);\n let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();\n\n println!(\"listening http://{} -> {}\", addr, config.upstream);\n\n axum::serve(listener, app).await.unwrap();\n}\n"}, "c268ecaf3906f2dece4135eb12596869b7e0e7ec": {"t": 1785760714.7055566, "n": 668, "h": "e2babb1431c3bf02", "c": "mod config;\nmod proxy;\n\nuse std::sync::Arc;\n\nuse axum::{routing::any, Router};\n\nuse config::Config;\nuse proxy::AppState;\n\n#[tokio::main]\nasync fn main() {\n let config = Config::load();\n\n std::fs::create_dir_all(&config.log_dir).expect(\"Failed to create log directory\");\n\n let state = Arc::new(AppState::new(&config));\n\n let app = Router::new()\n .fallback(any(proxy::handler))\n .with_state(state);\n\n let addr = format!(\"127.0.0.1:{}\", config.port);\n let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();\n\n println!(\"listening http://{} -> {}\", addr, config.upstream);\n\n axum::serve(listener, app).await.unwrap();\n}\n"}, "768fe625f0184cc879d5163a273cca12f95d1f59": {"t": 1785760716.2644632, "n": 7286, "h": "7a288beabd4de21e", "c": "use std::env;\nuse std::sync::atomic::{AtomicU64, Ordering};\nuse std::sync::Arc;\nuse std::time::{Instant, SystemTime, UNIX_EPOCH};\n\nuse axum::{\n body::Body,\n extract::State,\n http::{HeaderMap, Method, StatusCode, Uri},\n response::Response,\n routing::any,\n Router,\n};\nuse clap::Parser;\nuse http_body::Frame;\nuse http_body_util::StreamBody;\nuse futures::StreamExt;\nuse reqwest::Client;\nuse serde_json::Value;\n\nconst DEFAULT_PORT: u16 = 57633;\n\n#[derive(Parser)]\n#[command(\n name = \"agenteval\",\n about = \"Transparent API proxy — no TLS hijack, just URL redirect\"\n)]\nstruct Cli {\n /// Upstream API base URL (e.g., https://api.anthropic.com)\n #[arg(short, long, default_value = \"https://api.anthropic.com\")]\n upstream: String,\n\n /// Port to listen on\n #[arg(short, long, default_value_t = DEFAULT_PORT)]\n port: u16,\n\n /// Directory for request logs\n #[arg(long)]\n log_dir: Option<String>,\n\n /// Verbose output (print request/response bodies)\n #[arg(short, long)]\n verbose: bool,\n}\n\nstruct AppState {\n upstream_base: String,\n client: Client,\n log_dir: String,\n verbose: bool,\n counter: AtomicU64,\n}\n\nfn default_log_dir() -> String {\n env::var(\"HOME\")\n .map(|h| format!(\"{}/.agenteval/logs\", h))\n .unwrap_or_else(|_| \"/tmp/agenteval/logs\".into())\n}\n\nfn timestamp_ms() -> u64 {\n SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap_or_default()\n .as_millis() as u64\n}\n\nfn headers_to_value(headers: &HeaderMap) -> Value {\n let mut map = serde_json::Map::new();\n for (key, value) in headers.iter() {\n let k = key.as_str().to_string();\n let v = value.to_str().unwrap_or(\"<binary>\").to_string();\n map.insert(k, Value::String(v));\n }\n Value::Object(map)\n}\n\nfn body_to_json(body: &[u8]) -> Value {\n if body.is_empty() {\n return Value::Null;\n }\n serde_json::from_slice(body).unwrap_or_else(|_| match std::str::from_utf8(body) {\n Ok(s) => Value::String(s.to_string()),\n Err(_) => Value::String(format!(\"<binary {} bytes>\", body.len())),\n })\n}\n\nfn is_streaming_request(body: &[u8]) -> bool {\n serde_json::from_slice::<Value>(body)\n .ok()\n .and_then(|v| v.get(\"stream\").and_then(|s| s.as_bool()))\n .unwrap_or(false)\n}\n\n#[tokio::main]\nasync fn main() {\n let cli = Cli::parse();\n let log_dir = cli.log_dir.unwrap_or_else(default_log_dir);\n std::fs::create_dir_all(&log_dir).expect(\"Failed to create log directory\");\n\n let client = Client::builder()\n .no_proxy()\n .build()\n .expect(\"Failed to create HTTP client\");\n\n let upstream_base = cli.upstream.trim_end_matches('/').to_string();\n let state = Arc::new(AppState {\n upstream_base,\n client,\n log_dir,\n verbose: cli.verbose,\n counter: AtomicU64::new(1),\n });\n\n let app = Router::new()\n .fallback(any(proxy_handler))\n .with_state(state);\n\n let addr = format!(\"127.0.0.1:{}\", cli.port);\n let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();\n\n println!(\"listening http://{} -> {}\", addr, cli.upstream);\n\n axum::serve(listener, app).await.unwrap();\n}\n\nasync fn proxy_handler(\n State(state): State<Arc<AppState>>,\n method: Method,\n uri: Uri,\n headers: HeaderMap,\n body: axum::body::Bytes,\n) -> Result<Response, (StatusCode, String)> {\n let start = Instant::now();\n let id = state.counter.fetch_add(1, Ordering::SeqCst);\n\n let path_and_query = uri\n .path_and_query()\n .map(|pq| pq.as_str())\n .unwrap_or(\"/\");\n let upstream_url = format!(\"{}{}\", state.upstream_base, path_and_query);\n\n let streaming = is_streaming_request(&body);\n let req_body_json = body_to_json(&body);\n\n // Build log entry\n let mut log = serde_json::Map::new();\n log.insert(\"id\".into(), Value::Number(id.into()));\n log.insert(\"ts\".into(), Value::Number(timestamp_ms().into()));\n log.insert(\"method\".into(), Value::String(method.to_string()));\n log.insert(\"path\".into(), Value::String(path_and_query.to_string()));\n log.insert(\"upstream\".into(), Value::String(upstream_url.clone()));\n log.insert(\"request_headers\".into(), headers_to_value(&headers));\n log.insert(\"request_body\".into(), req_body_json.clone());\n log.insert(\"streaming\".into(), Value::Bool(streaming));\n\n // Forward to upstream\n let mut req_builder = state.client.request(method.clone(), &upstream_url);\n\n for (key, value) in headers.iter() {\n let k = key.as_str().to_lowercase();\n if k == \"host\" || k == \"content-length\" || k == \"transfer-encoding\" {\n continue;\n }\n req_builder = req_builder.header(key.as_str(), value);\n }\n\n if !body.is_empty() {\n req_builder = req_builder.body(body.to_vec());\n }\n\n let upstream_resp = match req_builder.send().await {\n Ok(r) => r,\n Err(e) => {\n let elapsed = start.elapsed();\n log.insert(\"error\".into(), Value::String(e.to_string()));\n log.insert(\"duration_ms\".into(), Value::Number((elapsed.as_millis() as u64).into()));\n write_log(&state.log_dir, id, &log);\n eprintln!(\"[{:04}] upstream error: {}\", id, e);\n return Err((StatusCode::BAD_GATEWAY, e.to_string()));\n }\n };\n\n let status = upstream_resp.status();\n let resp_headers = upstream_resp.headers().clone();\n let elapsed = start.elapsed();\n\n log.insert(\"response_status\".into(), Value::Number(status.as_u16().into()));\n log.insert(\n \"response_headers\".into(),\n headers_to_value(&resp_headers),\n );\n log.insert(\n \"duration_ms\".into(),\n Value::Number((elapsed.as_millis() as u64).into()),\n );\n\n // Print to console\n if state.verbose {\n println!(\n \"[{:04}] {} {} -> {} status={} streaming={} {}ms\",\n id,\n method,\n path_and_query,\n state.upstream_base,\n status.as_u16(),\n streaming,\n elapsed.as_millis()\n );\n if !body.is_empty() {\n println!(\n \" req: {}\",\n serde_json::to_string_pretty(&req_body_json).unwrap_or_default()\n );\n }\n } else {\n println!(\n \"[{:04}] {} {} {} {}ms\",\n id,\n method,\n path_and_query,\n status.as_u16(),\n elapsed.as_millis()\n );\n }\n\n // Stream response back (wrap bytes in http_body Frames)\n let frame_stream = upstream_resp.bytes_stream().map(|result| {\n match result {\n Ok(bytes) => Ok(Frame::data(bytes)),\n Err(e) => Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>),\n }\n });\n\n let axum_body = Body::new(StreamBody::new(frame_stream));\n\n let mut response = Response::new(axum_body);\n *response.status_mut() = status;\n *response.headers_mut() = resp_headers;\n\n write_log(&state.log_dir, id, &log);\n\n Ok(response)\n}\n\nfn write_log(log_dir: &str, id: u64, log: &serde_json::Map<String, Value>) {\n let path = format!(\"{}/{:04}.json\", log_dir, id);\n if let Ok(json) = serde_json::to_string_pretty(log) {\n std::fs::write(&path, json).ok();\n }\n}\n"}, "f9e7d64c1175e34729229ad1b1bcbe86aa57968c": {"t": 1785760717.8296802, "n": 45, "h": "b1713adaf16c3f6c", "c": "fn main() {\n println!(\"Hello, world!\");\n}\n"}}, "src/config.rs": {"fdfb98d942969e64f374dbc3dfe15bd6b22ecc89": {"t": 1785746221.7595665, "n": 2386, "h": "70794bb3675a03ac", "c": "// 配置模块,负责加载环境变量并提供配置结构体\nuse std::env;\n\nconst DEFAULT_PORT: u16 = 57633;\n\npub struct Config {\n pub upstream: String,\n pub port: u16,\n pub log_dir: String,\n pub verbose: bool,\n pub ui_enabled: bool,\n}\n\nimpl Config {\n // 加载配置,优先从环境变量获取,如果没有则使用默认值\n pub fn load() -> Self {\n // 加载环境变量,支持从 .env 文件加载\n dotenvy::dotenv().ok();\n\n let upstream = env::var(\"AGENTEVAL_UPSTREAM\")\n .unwrap_or_else(|_| \"https://api.deepseek.com\".into())\n .trim_end_matches('/') // 去掉末尾的斜杠,保持一致性\n .to_string();\n\n let port = env::var(\"AGENTEVAL_PORT\")\n .ok()//\n .and_then(|s| s.parse().ok())\n .unwrap_or(DEFAULT_PORT);\n\n let log_dir = env::var(\"AGENTEVAL_LOG_DIR\").unwrap_or_else(|_| {\n env::var(\"HOME\")\n .map(|h| format!(\"{}/.agenteval/logs\", h))\n .unwrap_or_else(|_| \"/tmp/agenteval/logs\".into())\n });\n\n let verbose = env::var(\"AGENTEVAL_VERBOSE\")\n .map(|v| v == \"1\" || v.to_lowercase() == \"true\")\n .unwrap_or(false);\n\n let ui_enabled = env::var(\"AGENTEVAL_UI_ENABLED\")\n .map(|v| v == \"1\" || v.to_lowercase() == \"true\")\n .unwrap_or(true);\n\n Self { upstream, port, log_dir, verbose, ui_enabled }\n }\n}\n\n/// grader 评测 LLM 配置\n#[derive(Clone)]\npub struct GraderConfig {\n pub judge_api_base: String,\n pub judge_model: String,\n pub judge_api_key: String,\n}\n\nimpl GraderConfig {\n pub fn load(upstream: &str) -> Self {\n dotenvy::dotenv().ok();\n\n let judge_api_base = env::var(\"AGENTEVAL_JUDGE_API_BASE\")\n .unwrap_or_else(|_| upstream.to_string())\n .trim_end_matches('/')\n .to_string();\n\n let judge_model = env::var(\"AGENTEVAL_JUDGE_MODEL\")\n .unwrap_or_else(|_| \"MiniMax-M2.5\".to_string());\n\n let judge_api_key = env::var(\"AGENTEVAL_JUDGE_API_KEY\")\n .unwrap_or_default();\n\n Self { judge_api_base, judge_model, judge_api_key }\n }\n}\n\n/// Probe agent 配置(LLM 复用 GraderConfig 的 judge 配置)\n#[derive(Clone)]\npub struct ProbeConfig {\n pub source_project_dir: String,\n}\n\nimpl ProbeConfig {\n pub fn load() -> Self {\n dotenvy::dotenv().ok();\n let source_project_dir = std::env::var(\"PROBE_SOURCE_PROJECT_DIR\").unwrap_or_default();\n Self { source_project_dir }\n }\n}\n"}, "ea37daba64d60a8a1a2801726959797a4486219c": {"t": 1785760700.1241088, "n": 2386, "h": "70794bb3675a03ac", "c": "// 配置模块,负责加载环境变量并提供配置结构体\nuse std::env;\n\nconst DEFAULT_PORT: u16 = 57633;\n\npub struct Config {\n pub upstream: String,\n pub port: u16,\n pub log_dir: String,\n pub verbose: bool,\n pub ui_enabled: bool,\n}\n\nimpl Config {\n // 加载配置,优先从环境变量获取,如果没有则使用默认值\n pub fn load() -> Self {\n // 加载环境变量,支持从 .env 文件加载\n dotenvy::dotenv().ok();\n\n let upstream = env::var(\"AGENTEVAL_UPSTREAM\")\n .unwrap_or_else(|_| \"https://api.deepseek.com\".into())\n .trim_end_matches('/') // 去掉末尾的斜杠,保持一致性\n .to_string();\n\n let port = env::var(\"AGENTEVAL_PORT\")\n .ok()//\n .and_then(|s| s.parse().ok())\n .unwrap_or(DEFAULT_PORT);\n\n let log_dir = env::var(\"AGENTEVAL_LOG_DIR\").unwrap_or_else(|_| {\n env::var(\"HOME\")\n .map(|h| format!(\"{}/.agenteval/logs\", h))\n .unwrap_or_else(|_| \"/tmp/agenteval/logs\".into())\n });\n\n let verbose = env::var(\"AGENTEVAL_VERBOSE\")\n .map(|v| v == \"1\" || v.to_lowercase() == \"true\")\n .unwrap_or(false);\n\n let ui_enabled = env::var(\"AGENTEVAL_UI_ENABLED\")\n .map(|v| v == \"1\" || v.to_lowercase() == \"true\")\n .unwrap_or(true);\n\n Self { upstream, port, log_dir, verbose, ui_enabled }\n }\n}\n\n/// grader 评测 LLM 配置\n#[derive(Clone)]\npub struct GraderConfig {\n pub judge_api_base: String,\n pub judge_model: String,\n pub judge_api_key: String,\n}\n\nimpl GraderConfig {\n pub fn load(upstream: &str) -> Self {\n dotenvy::dotenv().ok();\n\n let judge_api_base = env::var(\"AGENTEVAL_JUDGE_API_BASE\")\n .unwrap_or_else(|_| upstream.to_string())\n .trim_end_matches('/')\n .to_string();\n\n let judge_model = env::var(\"AGENTEVAL_JUDGE_MODEL\")\n .unwrap_or_else(|_| \"MiniMax-M2.5\".to_string());\n\n let judge_api_key = env::var(\"AGENTEVAL_JUDGE_API_KEY\")\n .unwrap_or_default();\n\n Self { judge_api_base, judge_model, judge_api_key }\n }\n}\n\n/// Probe agent 配置(LLM 复用 GraderConfig 的 judge 配置)\n#[derive(Clone)]\npub struct ProbeConfig {\n pub source_project_dir: String,\n}\n\nimpl ProbeConfig {\n pub fn load() -> Self {\n dotenvy::dotenv().ok();\n let source_project_dir = std::env::var(\"PROBE_SOURCE_PROJECT_DIR\").unwrap_or_default();\n Self { source_project_dir }\n }\n}\n"}, "54dc0f66c6e51537b0956d2115242d38f564f72c": {"t": 1785760701.3630471, "n": 2034, "h": "2b80aa699cf99dec", "c": "// 配置模块,负责加载环境变量并提供配置结构体\nuse std::env;\n\nconst DEFAULT_PORT: u16 = 57633;\n\npub struct Config {\n pub upstream: String,\n pub port: u16,\n pub log_dir: String,\n pub verbose: bool,\n pub ui_enabled: bool,\n}\n\nimpl Config {\n // 加载配置,优先从环境变量获取,如果没有则使用默认值\n pub fn load() -> Self {\n // 加载环境变量,支持从 .env 文件加载\n dotenvy::dotenv().ok();\n\n let upstream = env::var(\"AGENTEVAL_UPSTREAM\")\n .unwrap_or_else(|_| \"https://api.deepseek.com\".into())\n .trim_end_matches('/') // 去掉末尾的斜杠,保持一致性\n .to_string();\n\n let port = env::var(\"AGENTEVAL_PORT\")\n .ok()//\n .and_then(|s| s.parse().ok())\n .unwrap_or(DEFAULT_PORT);\n\n let log_dir = env::var(\"AGENTEVAL_LOG_DIR\").unwrap_or_else(|_| {\n env::var(\"HOME\")\n .map(|h| format!(\"{}/.agenteval/logs\", h))\n .unwrap_or_else(|_| \"/tmp/agenteval/logs\".into())\n });\n\n let verbose = env::var(\"AGENTEVAL_VERBOSE\")\n .map(|v| v == \"1\" || v.to_lowercase() == \"true\")\n .unwrap_or(false);\n\n let ui_enabled = env::var(\"AGENTEVAL_UI_ENABLED\")\n .map(|v| v == \"1\" || v.to_lowercase() == \"true\")\n .unwrap_or(true);\n\n Self { upstream, port, log_dir, verbose, ui_enabled }\n }\n}\n\n/// grader 评测 LLM 配置\n#[derive(Clone)]\npub struct GraderConfig {\n pub judge_api_base: String,\n pub judge_model: String,\n pub judge_api_key: String,\n}\n\nimpl GraderConfig {\n pub fn load(upstream: &str) -> Self {\n dotenvy::dotenv().ok();\n\n let judge_api_base = env::var(\"AGENTEVAL_JUDGE_API_BASE\")\n .unwrap_or_else(|_| upstream.to_string())\n .trim_end_matches('/')\n .to_string();\n\n let judge_model = env::var(\"AGENTEVAL_JUDGE_MODEL\")\n .unwrap_or_else(|_| \"MiniMax-M2.5\".to_string());\n\n let judge_api_key = env::var(\"AGENTEVAL_JUDGE_API_KEY\")\n .unwrap_or_default();\n\n Self { judge_api_base, judge_model, judge_api_key }\n }\n}\n"}, "26b8a010c8e56a357b0806a4cc097503072e0f7b": {"t": 1785760702.832216, "n": 1846, "h": "37d2438553cf16fd", "c": "// 配置模块,负责加载环境变量并提供配置结构体\nuse std::env;\n\nconst DEFAULT_PORT: u16 = 57633;\n\npub struct Config {\n pub upstream: String,\n pub port: u16,\n pub log_dir: String,\n pub verbose: bool,\n}\n\nimpl Config {\n // 加载配置,优先从环境变量获取,如果没有则使用默认值\n pub fn load() -> Self {\n // 加载环境变量,支持从 .env 文件加载\n dotenvy::dotenv().ok();\n\n let upstream = env::var(\"AGENTEVAL_UPSTREAM\")\n .unwrap_or_else(|_| \"https://api.deepseek.com\".into())\n .trim_end_matches('/') // 去掉末尾的斜杠,保持一致性\n .to_string();\n\n let port = env::var(\"AGENTEVAL_PORT\")\n .ok()//\n .and_then(|s| s.parse().ok())\n .unwrap_or(DEFAULT_PORT);\n\n let log_dir = env::var(\"AGENTEVAL_LOG_DIR\").unwrap_or_else(|_| {\n env::var(\"HOME\")\n .map(|h| format!(\"{}/.agenteval/logs\", h))\n .unwrap_or_else(|_| \"/tmp/agenteval/logs\".into())\n });\n\n let verbose = env::var(\"AGENTEVAL_VERBOSE\")\n .map(|v| v == \"1\" || v.to_lowercase() == \"true\")\n .unwrap_or(false);\n\n Self { upstream, port, log_dir, verbose }\n }\n}\n\n/// grader 评测 LLM 配置\n#[derive(Clone)]\npub struct GraderConfig {\n pub judge_api_base: String,\n pub judge_model: String,\n pub judge_api_key: String,\n}\n\nimpl GraderConfig {\n pub fn load(upstream: &str) -> Self {\n dotenvy::dotenv().ok();\n\n let judge_api_base = env::var(\"AGENTEVAL_JUDGE_API_BASE\")\n .unwrap_or_else(|_| upstream.to_string())\n .trim_end_matches('/')\n .to_string();\n\n let judge_model = env::var(\"AGENTEVAL_JUDGE_MODEL\")\n .unwrap_or_else(|_| \"MiniMax-M2.5\".to_string());\n\n let judge_api_key = env::var(\"AGENTEVAL_JUDGE_API_KEY\")\n .unwrap_or_default();\n\n Self { judge_api_base, judge_model, judge_api_key }\n }\n}\n"}, "89f7f9c008c77c771a7a1ca1c380e5c6591c925c": {"t": 1785760705.2640603, "n": 1120, "h": "da69d2284f6eae81", "c": "// 配置模块,负责加载环境变量并提供配置结构体\nuse std::env;\n\nconst DEFAULT_PORT: u16 = 57633;\n\npub struct Config {\n pub upstream: String,\n pub port: u16,\n pub log_dir: String,\n pub verbose: bool,\n}\n\nimpl Config {\n // 加载配置,优先从环境变量获取,如果没有则使用默认值\n pub fn load() -> Self {\n // 加载环境变量,支持从 .env 文件加载\n dotenvy::dotenv().ok();\n\n let upstream = env::var(\"AGENTEVAL_UPSTREAM\")\n .unwrap_or_else(|_| \"https://api.deepseek.com\".into())\n .trim_end_matches('/') // 去掉末尾的斜杠,保持一致性\n .to_string();\n\n let port = env::var(\"AGENTEVAL_PORT\")\n .ok()//\n .and_then(|s| s.parse().ok())\n .unwrap_or(DEFAULT_PORT);\n\n let log_dir = env::var(\"AGENTEVAL_LOG_DIR\").unwrap_or_else(|_| {\n env::var(\"HOME\")\n .map(|h| format!(\"{}/.agenteval/logs\", h))\n .unwrap_or_else(|_| \"/tmp/agenteval/logs\".into())\n });\n\n let verbose = env::var(\"AGENTEVAL_VERBOSE\")\n .map(|v| v == \"1\" || v.to_lowercase() == \"true\")\n .unwrap_or(false);\n\n Self { upstream, port, log_dir, verbose }\n }\n}\n"}, "dcd9eebf2fa3b4ee274436a3efe6835a948a9137": {"t": 1785760706.528747, "n": 1011, "h": "729cec80c877f211", "c": "use std::env;\n\nconst DEFAULT_PORT: u16 = 57633;\n\npub struct Config {\n pub upstream: String,\n pub port: u16,\n pub log_dir: String,\n pub verbose: bool,\n}\n\nimpl Config {\n pub fn load() -> Self {\n dotenvy::dotenv().ok();\n\n let upstream = env::var(\"AGENTEVAL_UPSTREAM\")\n .unwrap_or_else(|_| \"https://api.deepseek.com\".into())\n .trim_end_matches('/')\n .to_string();\n\n let port = env::var(\"AGENTEVAL_PORT\")\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(DEFAULT_PORT);\n\n let log_dir = env::var(\"AGENTEVAL_LOG_DIR\").unwrap_or_else(|_| {\n env::var(\"HOME\")\n .map(|h| format!(\"{}/.agenteval/logs\", h))\n .unwrap_or_else(|_| \"/tmp/agenteval/logs\".into())\n });\n\n let verbose = env::var(\"AGENTEVAL_VERBOSE\")\n .map(|v| v == \"1\" || v.to_lowercase() == \"true\")\n .unwrap_or(false);\n\n Self { upstream, port, log_dir, verbose }\n }\n}\n"}, "c268ecaf3906f2dece4135eb12596869b7e0e7ec": {"t": 1785760711.5611148, "n": 1139, "h": "48b9a292330d0d2a", "c": "use clap::Parser;\n\nconst DEFAULT_PORT: u16 = 57633;\n\nfn default_log_dir() -> String {\n std::env::var(\"HOME\")\n .map(|h| format!(\"{}/.agenteval/logs\", h))\n .unwrap_or_else(|_| \"/tmp/agenteval/logs\".into())\n}\n\n/// 透明 API 代理 —— 不改 TLS,劫持 URL\n#[derive(Parser, Debug)]\n#[command(name = \"agenteval\")]\npub struct Config {\n /// 上游 API 地址\n #[arg(short, long, env = \"AGENTEVAL_UPSTREAM\", default_value = \"https://api.anthropic.com\")]\n pub upstream: String,\n\n /// 本地监听端口\n #[arg(short, long, env = \"AGENTEVAL_PORT\", default_value_t = DEFAULT_PORT)]\n pub port: u16,\n\n /// 日志存放目录\n #[arg(long, env = \"AGENTEVAL_LOG_DIR\", default_value_t = default_log_dir())]\n pub log_dir: String,\n\n /// 详细模式(打印 request body)\n #[arg(short, long, env = \"AGENTEVAL_VERBOSE\", default_value_t = false)]\n pub verbose: bool,\n}\n\nimpl Config {\n pub fn load() -> Self {\n // .env 加载到进程环境,clap 通过 env = \"...\" 自动读取\n dotenvy::dotenv().ok();\n let mut config = Self::parse();\n // 去掉 upstream 尾部斜杠,避免双斜杠\n config.upstream = config.upstream.trim_end_matches('/').to_string();\n config\n }\n}\n"}}, ".env.example": {"fdfb98d942969e64f374dbc3dfe15bd6b22ecc89": {"t": 1785746743.0744772, "n": 222, "h": "a5aad3d5e1bb991f", "c": "# 上游 API 地址\nAGENTEVAL_UPSTREAM=https://api.deepseek.com\n\n# 代理监听端口(默认 57633)\nAGENTEVAL_PORT=57633\n\n# 日志存放目录(默认 ~/.agenteval/logs)\n# AGENTEVAL_LOG_DIR=/path/to/logs\n\n# 详细模式:1 或 true 开启(打印 request body)\n# AGENTEVAL_VERBOSE=1\n"}, "dcd9eebf2fa3b4ee274436a3efe6835a948a9137": {"t": 1785760700.0319905, "n": 222, "h": "a5aad3d5e1bb991f", "c": "# 上游 API 地址\nAGENTEVAL_UPSTREAM=https://api.deepseek.com\n\n# 代理监听端口(默认 57633)\nAGENTEVAL_PORT=57633\n\n# 日志存放目录(默认 ~/.agenteval/logs)\n# AGENTEVAL_LOG_DIR=/path/to/logs\n\n# 详细模式:1 或 true 开启(打印 request body)\n# AGENTEVAL_VERBOSE=1\n"}, "c268ecaf3906f2dece4135eb12596869b7e0e7ec": {"t": 1785760702.223214, "n": 253, "h": "02f25e8814db3f99", "c": "# 上游 API 地址(默认 https://api.anthropic.com)\nAGENTEVAL_UPSTREAM=https://api.anthropic.com\n\n# 代理监听端口(默认 57633)\nAGENTEVAL_PORT=57633\n\n# 日志存放目录(默认 ~/.agenteval/logs)\n# AGENTEVAL_LOG_DIR=/path/to/logs\n\n# 详细模式:1 或 true 开启(打印 request body)\n# AGENTEVAL_VERBOSE=1\n"}}}} |