eval_core/upload.rs
1//! Automatic upload of a finished eval run to the EvalForge API (evalforge.ai) so results show up
2//! in the online dashboard with no manual export/import.
3//!
4//! This is the engine behind [`RunMeta::upload_to`](crate::RunMeta::upload_to): when a run carries an
5//! [`Upload`] target, [`run_eval_with_meta`](crate::run_eval_with_meta) POSTs the assembled
6//! [`RunRecord`] to `https://evalforge.ai/api/projects/{project_id}/runs` once the run finishes. The
7//! record's serde shape IS the API's ingest DTO, so the body is reused as-is — no separate wire type.
8//! The module is always compiled in; uploading happens only at runtime when a host configures an
9//! [`Upload`] target on the run metadata (nothing is sent otherwise).
10//!
11//! The base URL is a hardcoded crate constant (`EVALFORGE_BASE_URL`); end users only ever configure a
12//! project id + API key. (A `#[cfg(test)]`-only override exists so unit tests can point at a local mock;
13//! it is NOT part of the public API.)
14
15use std::time::Duration;
16
17use serde::Deserialize;
18
19use crate::report::RunRecord;
20
21/// The single hardcoded EvalForge host. End users never supply a URL; uploads always go here.
22const EVALFORGE_BASE_URL: &str = "https://evalforge.ai";
23
24/// Where and how to upload a run, attached to a [`RunMeta`](crate::RunMeta) via
25/// [`RunMeta::upload_to`](crate::RunMeta::upload_to). When present on the meta passed to a run, the
26/// runner POSTs the assembled [`RunRecord`] to the EvalForge API after the cases finish.
27///
28/// `#[non_exhaustive]`: build it through the `RunMeta` builder (`upload_to` / `upload_from_env` +
29/// optional `upload_model` / `upload_cases_dir`), never a struct literal, so new fields stay
30/// non-breaking.
31#[derive(Debug, Clone)]
32#[non_exhaustive]
33pub struct Upload {
34 /// The EvalForge Project UUID the run is uploaded under (the URL path param). User-supplied.
35 pub project_id: String,
36 /// The account-level API key, sent as `Authorization: Bearer {api_key}`. User-supplied.
37 pub api_key: String,
38 /// The model / grouping key recorded on the uploaded [`RunRecord`]. Used ONLY when the run has no
39 /// [`Persist`](crate::Persist) target; when persist is set, persist's identity is reused so the
40 /// saved file and the uploaded record share one dedup key. Set via
41 /// [`upload_model`](crate::RunMeta::upload_model).
42 pub model: String,
43 /// The backend KIND recorded on the uploaded record (the report's Backend column). Used ONLY when
44 /// persist is absent; when empty the record falls back to the report's descriptive backend label.
45 pub backend: String,
46 /// The case directory recorded on the uploaded record. Used ONLY when persist is absent. Set via
47 /// [`upload_cases_dir`](crate::RunMeta::upload_cases_dir).
48 pub cases_dir: String,
49 /// The API base URL. Always `EVALFORGE_BASE_URL` in production — crate-private with NO public
50 /// setter; only a `#[cfg(test)]` helper can override it (so unit tests can point at a local mock).
51 base_url: String,
52}
53
54impl Upload {
55 /// An upload target POSTing to EvalForge under `project_id`, authenticating with `api_key`. The
56 /// record identity (model / backend / cases dir) defaults to empty (set them via the `RunMeta`
57 /// builder for the upload-only case); the base URL is fixed to `EVALFORGE_BASE_URL`.
58 pub(crate) fn new(project_id: String, api_key: String) -> Self {
59 Self {
60 project_id,
61 api_key,
62 model: String::new(),
63 backend: String::new(),
64 cases_dir: String::new(),
65 base_url: EVALFORGE_BASE_URL.to_owned(),
66 }
67 }
68
69 /// Test-only override of the otherwise-fixed base URL, so a unit test can point an upload at a local
70 /// mock server. This is the ONLY way to change `base_url` and it is not part of the public API.
71 #[cfg(test)]
72 pub(crate) fn with_base_url(mut self, base_url: String) -> Self {
73 self.base_url = base_url;
74 self
75 }
76}
77
78/// The EvalForge ingest URL for `project_id`: `{base_url}/api/projects/{project_id}/runs`. A free fn so
79/// it is unit-testable; a trailing slash on `base_url` is trimmed defensively to avoid a double `//`.
80fn endpoint_url(base_url: &str, project_id: &str) -> String {
81 let base = base_url.trim_end_matches('/');
82 format!("{base}/api/projects/{project_id}/runs")
83}
84
85/// The EvalForge ingest success body: `{ "runId": string, "deduped": bool }`. `deduped` is `true` when
86/// the server replaced a prior run with the same `(projectId, model, timestamp_file)` (re-uploads are
87/// safe / idempotent).
88#[derive(Debug, Clone, Deserialize)]
89pub struct UploadResponse {
90 /// The server-assigned id of the (created or replaced) run.
91 #[serde(rename = "runId")]
92 pub run_id: String,
93 /// `true` if this upload replaced an existing run with the same dedup key, `false` if newly created.
94 #[serde(default)]
95 pub deduped: bool,
96}
97
98/// POST `record` to the EvalForge ingest endpoint for `upload`, returning the parsed [`UploadResponse`].
99///
100/// Uses a blocking `ureq` agent with a ~30s timeout so a hung network can't block the run return. The
101/// body is the JSON-serialized [`RunRecord`] (its serde shape IS the API DTO); auth is
102/// `Authorization: Bearer {api_key}`. A non-2xx status, a transport error, or an unparseable body all
103/// map to a descriptive [`anyhow::Error`] — the caller "warns, doesn't fail", so an upload error never
104/// drops the eval signal.
105//
106// NOTE (v1): no retries. The server dedups on `(projectId, model, timestamp_file)`, so a retry/backoff
107// on 5xx / transport errors would be safe to add as a follow-up — kept minimal here.
108pub fn upload_record(upload: &Upload, record: &RunRecord) -> anyhow::Result<UploadResponse> {
109 let url = endpoint_url(&upload.base_url, &upload.project_id);
110 let agent = ureq::AgentBuilder::new()
111 .timeout(Duration::from_secs(30))
112 .build();
113 let response = agent
114 .post(&url)
115 .set("Authorization", &format!("Bearer {}", upload.api_key))
116 .set("Content-Type", "application/json")
117 .send_json(record);
118 match response {
119 Ok(response) => response
120 .into_json::<UploadResponse>()
121 .map_err(|e| anyhow::anyhow!("parsing upload response: {e}")),
122 Err(ureq::Error::Status(code, response)) => {
123 let body = response.into_string().unwrap_or_default();
124 Err(anyhow::anyhow!(
125 "evalforge upload failed: HTTP {code}: {body}"
126 ))
127 }
128 Err(ureq::Error::Transport(t)) => {
129 Err(anyhow::anyhow!("evalforge upload transport error: {t}"))
130 }
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use std::io::{Read, Write};
137 use std::net::TcpListener;
138 use std::thread;
139
140 use super::*;
141 use crate::report::{EvalReport, RunRecord};
142
143 #[test]
144 fn endpoint_url_is_evalforge_projects_runs() {
145 assert_eq!(
146 endpoint_url("https://evalforge.ai", "abc"),
147 "https://evalforge.ai/api/projects/abc/runs"
148 );
149 }
150
151 #[test]
152 fn endpoint_url_trims_trailing_slash() {
153 assert_eq!(
154 endpoint_url("https://evalforge.ai/", "abc"),
155 "https://evalforge.ai/api/projects/abc/runs"
156 );
157 }
158
159 /// Full HTTP round-trip against a single-shot, `std`-only mock server (no extra deps): proves
160 /// [`upload_record`] POSTs to `/api/projects/{project_id}/runs` with `Authorization: Bearer
161 /// {api_key}`, serializes the [`RunRecord`] as the JSON body, and parses a `201 Created`
162 /// `{"runId","deduped"}` response into an [`UploadResponse`]. This is also the sole user of the
163 /// `#[cfg(test)]` [`Upload::with_base_url`] override (which retargets the otherwise-fixed
164 /// `EVALFORGE_BASE_URL` at the local mock), so it keeps that helper from being dead code.
165 #[test]
166 fn upload_record_posts_to_evalforge_and_parses_201() {
167 // Bind to an ephemeral port and read it back, so the test is self-contained and parallel-safe.
168 let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
169 let addr = listener.local_addr().expect("local_addr");
170
171 // One handler thread, one accept. It captures what the client actually sent and returns the
172 // assertions out via the JoinHandle so a failed assertion can't be silently swallowed: the
173 // returned tuple is `(method_path_ok, auth_ok)`, and any `.expect`/panic here surfaces on join.
174 let handle = thread::spawn(move || {
175 let (mut stream, _peer) = listener.accept().expect("accept connection");
176
177 // Read until the end of the headers (`\r\n\r\n`), tolerant of partial reads.
178 let mut buf: Vec<u8> = Vec::new();
179 let mut chunk = [0u8; 1024];
180 let header_end = loop {
181 if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") {
182 break pos + 4;
183 }
184 let n = stream.read(&mut chunk).expect("read request headers");
185 if n == 0 {
186 panic!("connection closed before headers completed");
187 }
188 buf.extend_from_slice(&chunk[..n]);
189 };
190
191 let header_text = String::from_utf8_lossy(&buf[..header_end]).into_owned();
192
193 // Parse Content-Length case-insensitively; default to 0 when absent.
194 let content_length = header_text
195 .lines()
196 .find_map(|line| {
197 let (name, value) = line.split_once(':')?;
198 if name.trim().eq_ignore_ascii_case("content-length") {
199 value.trim().parse::<usize>().ok()
200 } else {
201 None
202 }
203 })
204 .unwrap_or(0);
205
206 // Read exactly the declared body length (some may already be buffered after the headers).
207 let mut body = buf[header_end..].to_vec();
208 while body.len() < content_length {
209 let n = stream.read(&mut chunk).expect("read request body");
210 if n == 0 {
211 break;
212 }
213 body.extend_from_slice(&chunk[..n]);
214 }
215
216 // Method + path: the first header line is the request line `POST /api/.../runs HTTP/1.1`.
217 let request_line = header_text.lines().next().unwrap_or_default().to_owned();
218 let method_path_ok = request_line.starts_with("POST ")
219 && request_line.contains("/api/projects/proj-xyz/runs");
220
221 // Auth: a `Authorization: Bearer {api_key}` header must be present (case-insensitive name).
222 let auth_ok = header_text.lines().any(|line| {
223 line.split_once(':')
224 .map(|(name, value)| {
225 name.trim().eq_ignore_ascii_case("authorization")
226 && value.trim() == "Bearer sk-eval-testkey"
227 })
228 .unwrap_or(false)
229 });
230
231 // Sanity: the body is the serialized RunRecord, so it must carry the model we uploaded.
232 let body_text = String::from_utf8_lossy(&body);
233 assert!(
234 body_text.contains("\"model\":\"mock-model\""),
235 "request body should be the serialized RunRecord, got: {body_text}"
236 );
237
238 // Write a valid 201 response with the dedup-shaped JSON the client parses.
239 let json = r#"{"runId":"run_abc123","deduped":false}"#;
240 let response = format!(
241 "HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
242 json.len(),
243 json
244 );
245 stream
246 .write_all(response.as_bytes())
247 .expect("write response");
248 stream.flush().expect("flush response");
249
250 (method_path_ok, auth_ok)
251 });
252
253 // Point an upload at the local mock (plain http) and POST a minimal record.
254 let upload = Upload::new("proj-xyz".into(), "sk-eval-testkey".into())
255 .with_base_url(format!("http://{addr}"));
256 let report = EvalReport::new(Vec::new(), 0.0, "local: m".into(), "sys".into());
257 let record = RunRecord {
258 model: "mock-model".into(),
259 timestamp_display: "2026-06-18 14:03:21".into(),
260 timestamp_file: "20260618-140321".into(),
261 backend: "local".into(),
262 cases_dir: "cases".into(),
263 system_prompt: "sys".into(),
264 report,
265 };
266
267 let resp = upload_record(&upload, &record).expect("upload");
268 assert_eq!(resp.run_id, "run_abc123");
269 assert!(!resp.deduped);
270
271 // Now drain the server-side assertions; a panic in the thread re-raises here.
272 let (method_path_ok, auth_ok) = handle.join().expect("handler thread");
273 assert!(method_path_ok, "POST to /api/projects/proj-xyz/runs");
274 assert!(
275 auth_ok,
276 "Authorization: Bearer sk-eval-testkey header present"
277 );
278 }
279
280 /// First index of `needle` within `haystack`, or `None`. Small `std`-only helper for the mock above
281 /// (no `memchr`/extra deps) so we can find the `\r\n\r\n` header terminator in the raw request bytes.
282 fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
283 if needle.is_empty() || haystack.len() < needle.len() {
284 return None;
285 }
286 haystack
287 .windows(needle.len())
288 .position(|window| window == needle)
289 }
290}