Skip to main content

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}