Files

1 line
125 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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"}}}}