openrouter-batch 0.1.2

OpenRouter Batch API client with structured-output and embeddings support, live-wire verified
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
//! The production transport: a real HTTP client against a configurable API
//! root. The root is configuration, not a constant (context/05): the default
//! is the public endpoint; e2e points it at the local fake batch server.

use super::facade::{Message, StructuredParams, structured_body};
use super::transport::{
    BatchObject, BatchTransport, EmbeddingRequest, EmbeddingTransport, RawChatResponse,
    RawEmbeddingResponse, TransportError,
};
use async_trait::async_trait;
use serde_json::Value;
use std::time::Duration;

/// Path under the API root where the Batch API lives.
const BATCHES_PATH: &str = "/beta/batches";
const EMBEDDINGS_PATH: &str = "/v1/embeddings";
/// The synchronous chat-completions endpoint (same path the Batch API wraps
/// per item; called directly here with `model` inline).
const CHAT_COMPLETIONS_PATH: &str = "/v1/chat/completions";

pub struct HttpTransport {
    http: reqwest::Client,
    api_root: String,
    api_key: String,
}

impl HttpTransport {
    /// Build the transport. `api_root` is the OpenRouter API root (for
    /// example `https://openrouter.ai/api`); `api_key` is the bearer token, read
    /// once at process start and never logged (AGENTS.md secrets rules).
    pub fn new(api_root: &str, api_key: &str) -> Result<Self, TransportError> {
        Self::with_timeout(api_root, api_key, Duration::from_secs(30))
    }

    /// Build the transport with an explicit total request timeout. The
    /// synchronous chat-completions route can run far longer than the default
    /// 30s (measured: a deepseek-v4-flash sync call at 44.4s completed
    /// successfully; under `new` the same call dies mid-body as a reqwest
    /// decode error). Batch calls stay on the short default.
    pub fn with_timeout(
        api_root: &str,
        api_key: &str,
        timeout: Duration,
    ) -> Result<Self, TransportError> {
        let http = reqwest::Client::builder()
            .connect_timeout(Duration::from_secs(5))
            .timeout(timeout)
            .build()?;
        Ok(Self {
            http,
            api_root: api_root.trim_end_matches('/').to_string(),
            api_key: api_key.to_string(),
        })
    }

    /// POST/GET helper: any 2xx with a parseable batch body is success; any
    /// other status becomes a typed `TransportError::Api`.
    async fn send(&self, request: reqwest::RequestBuilder) -> Result<BatchObject, TransportError> {
        let response = request.send().await?;
        let status = response.status();
        let provider_request_id = response
            .headers()
            .get("x-request-id")
            .and_then(|value| value.to_str().ok())
            .map(str::to_string);
        let body = response
            .text()
            .await
            .map_err(|source| TransportError::Http {
                source,
                provider_request_id: provider_request_id.clone(),
            })?;
        if !status.is_success() {
            return Err(TransportError::Api {
                status: status.as_u16(),
                message: error_message(&body),
                provider_request_id,
            });
        }
        let mut batch: BatchObject =
            serde_json::from_str(&body).map_err(|source| TransportError::Decode {
                source,
                provider_request_id: provider_request_id.clone(),
            })?;
        batch.provider_request_id = provider_request_id;
        Ok(batch)
    }

    /// One synchronous structured chat-completions call (eval harness and
    /// other read-only tooling; the production normalization path stays on
    /// the Batch API). The body is built by the shared `structured_body`, so
    /// the sync wire shape cannot drift from a batch item; `model` is inline.
    /// The request timeout is the transport's: build with `with_timeout` when
    /// generation time exceeds the 30s `new` default (measured 2026-09-01: a
    /// glm-5.3-flash structured call ran 242s; an earlier hardcoded 120s
    /// per-request override silently capped every sync call and surfaced the
    /// deadline hit mid-body as a reqwest decode error).
    pub async fn chat_completion(
        &self,
        model: &str,
        params: &StructuredParams,
        messages: Vec<Message>,
    ) -> Result<RawChatResponse, TransportError> {
        let mut body = match structured_body(params, messages)? {
            Value::Object(map) => map,
            _ => unreachable!("structured_body always returns a JSON object"),
        };
        body.insert("model".to_string(), model.to_string().into());
        let response = self
            .http
            .post(format!("{}{CHAT_COMPLETIONS_PATH}", self.api_root))
            .bearer_auth(&self.api_key)
            .json(&body)
            .send()
            .await?;
        let status = response.status();
        let provider_request_id = response
            .headers()
            .get("x-request-id")
            .and_then(|value| value.to_str().ok())
            .map(str::to_string);
        let raw_text = response
            .text()
            .await
            .map_err(|source| TransportError::Http {
                source,
                provider_request_id: provider_request_id.clone(),
            })?;
        if !status.is_success() {
            return Err(TransportError::Api {
                status: status.as_u16(),
                message: error_message(&raw_text),
                provider_request_id,
            });
        }
        // Preserve an unparseable body verbatim for evidence instead of
        // failing the decode: the caller scores schema validity downstream.
        let raw: Value = serde_json::from_str(&raw_text)
            .unwrap_or_else(|_| serde_json::json!({"unparseable_body": raw_text}));
        Ok(RawChatResponse {
            raw,
            provider_request_id,
        })
    }
}

#[async_trait]
impl EmbeddingTransport for HttpTransport {
    async fn embed(
        &self,
        model: &str,
        inputs: &[String],
        idempotency_key: &str,
    ) -> Result<RawEmbeddingResponse, TransportError> {
        let response = self
            .http
            .post(format!("{}{EMBEDDINGS_PATH}", self.api_root))
            .bearer_auth(&self.api_key)
            .header("Idempotency-Key", idempotency_key)
            .json(&EmbeddingRequest::new(model, inputs.to_vec()))
            .send()
            .await?;
        let status = response.status();
        let provider_request_id = response
            .headers()
            .get("x-request-id")
            .and_then(|value| value.to_str().ok())
            .map(str::to_string);
        let body = response
            .text()
            .await
            .map_err(|source| TransportError::Http {
                source,
                provider_request_id: provider_request_id.clone(),
            })?;
        if !status.is_success() {
            return Err(TransportError::Api {
                status: status.as_u16(),
                message: error_message(&body),
                provider_request_id,
            });
        }
        let raw = serde_json::from_str(&body)
            .unwrap_or_else(|_| serde_json::json!({"unparseable_body": body}));
        Ok(RawEmbeddingResponse {
            raw,
            provider_request_id,
        })
    }
}

#[async_trait]
impl BatchTransport for HttpTransport {
    async fn submit(
        &self,
        batch: &super::transport::SubmitBatch,
        idempotency_key: &str,
    ) -> Result<BatchObject, TransportError> {
        // `batch` serializes endpoint, then model, then requests (declaration
        // order, pinned by the serialization test); reqwest's `.json` uses
        // serde_json, which preserves it.
        self.send(
            self.http
                .post(format!("{}{BATCHES_PATH}", self.api_root))
                .bearer_auth(&self.api_key)
                .header("Idempotency-Key", idempotency_key)
                .json(batch),
        )
        .await
    }

    async fn get(&self, batch_id: &str) -> Result<BatchObject, TransportError> {
        self.send(
            self.http
                .get(format!("{}{BATCHES_PATH}/{batch_id}", self.api_root))
                .bearer_auth(&self.api_key),
        )
        .await
    }
}

/// Extract `error.message` from a provider error body; fall back to a
/// truncated raw body so operators always see something.
fn error_message(body: &str) -> String {
    let parsed: Option<Value> = serde_json::from_str(body).ok();
    match parsed.as_ref().and_then(|v| {
        v.get("error")
            .and_then(|error| error.get("message"))
            .and_then(Value::as_str)
            .map(str::to_string)
    }) {
        Some(message) => message,
        None => {
            let mut truncated: String = body.chars().take(200).collect();
            if body.chars().count() > 200 {
                truncated.push('…');
            }
            truncated
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tokio::io::AsyncWriteExt;

    #[test]
    fn extracts_provider_error_message() {
        let body = r#"{"error":{"message":"Model 'x/y' does not have a :batch endpoint."}}"#;
        assert_eq!(
            error_message(body),
            "Model 'x/y' does not have a :batch endpoint."
        );
    }

    #[test]
    fn falls_back_to_truncated_body_on_non_json_errors() {
        let message = error_message("<html>bad gateway</html>");
        assert!(message.contains("bad gateway"));
    }

    #[tokio::test]
    async fn truncated_response_body_is_retryable() {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let address = listener.local_addr().unwrap();
        let server = tokio::spawn(async move {
            let (mut socket, _) = listener.accept().await.unwrap();
            socket
                .write_all(
                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-Request-Id: req-truncated\r\nContent-Length: 100\r\n\r\n{\"data\":[",
                )
                .await
                .unwrap();
        });
        let transport = HttpTransport::new(&format!("http://{address}"), "test-key").unwrap();

        let error = transport
            .embed("test-model", &["input".to_string()], "stable-key")
            .await
            .unwrap_err();

        server.await.unwrap();
        assert!(
            matches!(&error, TransportError::Http { source, .. } if source.is_body() || source.is_decode()),
            "unexpected transport error: {error:?}"
        );
        assert!(error.retryable());
        assert_eq!(error.provider_request_id(), Some("req-truncated"));
    }

    #[tokio::test]
    async fn sync_chat_completion_decodes_content_and_request_id() {
        let body = r#"{"id":"chatcmpl-1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"{\"title\":\"X\"}"},"finish_reason":"stop"}]}"#;
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let address = listener.local_addr().unwrap();
        let server = tokio::spawn(async move {
            let (mut socket, _) = listener.accept().await.unwrap();
            let response = format!(
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-Request-Id: req-sync\r\nContent-Length: {}\r\n\r\n{}",
                body.len(),
                body
            );
            socket.write_all(response.as_bytes()).await.unwrap();
        });
        let transport = HttpTransport::new(&format!("http://{address}"), "test-key").unwrap();
        let params = StructuredParams {
            schema: serde_json::json!({"type": "object"}),
            schema_name: "normalized_job".to_string(),
            max_tokens: 300,
            disable_reasoning: false,
            require_parameters: false,
            reasoning_max_tokens: None,
        };

        let response = transport
            .chat_completion("test/model", &params, vec![Message::user("hi")])
            .await
            .unwrap();

        server.await.unwrap();
        assert_eq!(response.content().unwrap(), "{\"title\":\"X\"}");
        assert_eq!(response.provider_request_id.as_deref(), Some("req-sync"));
    }

    #[tokio::test]
    async fn sync_chat_completion_preserves_api_error_status() {
        let body = r#"{"error":{"message":"no such route"}}"#;
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let address = listener.local_addr().unwrap();
        let server = tokio::spawn(async move {
            let (mut socket, _) = listener.accept().await.unwrap();
            let response = format!(
                "HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nX-Request-Id: req-404\r\nContent-Length: {}\r\n\r\n{}",
                body.len(),
                body
            );
            socket.write_all(response.as_bytes()).await.unwrap();
        });
        let transport = HttpTransport::new(&format!("http://{address}"), "test-key").unwrap();
        let params = StructuredParams {
            schema: serde_json::json!({"type": "object"}),
            schema_name: "normalized_job".to_string(),
            max_tokens: 300,
            disable_reasoning: false,
            require_parameters: false,
            reasoning_max_tokens: None,
        };
        let error = transport
            .chat_completion("test/model", &params, vec![Message::user("hi")])
            .await
            .unwrap_err();

        server.await.unwrap();
        assert!(
            matches!(&error, TransportError::Api { status: 404, .. }),
            "unexpected transport error: {error:?}"
        );
        assert_eq!(error.provider_request_id(), Some("req-404"));
    }

    #[tokio::test]
    async fn batch_decode_failure_preserves_provider_request_id() {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let address = listener.local_addr().unwrap();
        let server = tokio::spawn(async move {
            let (mut socket, _) = listener.accept().await.unwrap();
            socket
                .write_all(
                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-Request-Id: req-decode\r\nContent-Length: 8\r\n\r\nnot-json",
                )
                .await
                .unwrap();
        });
        let transport = HttpTransport::new(&format!("http://{address}"), "test-key").unwrap();

        let error = transport
            .get("batch-test")
            .await
            .expect_err("malformed batch response must fail");

        server.await.unwrap();
        assert!(matches!(&error, TransportError::Decode { .. }));
        assert_eq!(error.provider_request_id(), Some("req-decode"));
    }

    #[tokio::test]
    async fn sync_chat_completion_honors_transport_timeout() {
        // The constructor timeout must govern the sync route: the server
        // answers after 2s, the transport deadline is 1s, so the call must
        // fail with a retryable Http error instead of the deadline silently
        // extending to a hardcoded per-request override.
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let address = listener.local_addr().unwrap();
        let server = tokio::spawn(async move {
            let (mut socket, _) = listener.accept().await.unwrap();
            tokio::time::sleep(Duration::from_secs(2)).await;
            socket
                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}")
                .await
                .unwrap();
        });
        let transport = HttpTransport::with_timeout(
            &format!("http://{address}"),
            "test-key",
            Duration::from_secs(1),
        )
        .unwrap();
        let params = StructuredParams {
            schema: serde_json::json!({"type": "object"}),
            schema_name: "normalized_job".to_string(),
            max_tokens: 300,
            disable_reasoning: false,
            require_parameters: false,
            reasoning_max_tokens: None,
        };

        let error = transport
            .chat_completion("test/model", &params, vec![Message::user("hi")])
            .await
            .unwrap_err();

        server.await.unwrap();
        assert!(
            matches!(&error, TransportError::Http { source, .. } if source.is_timeout()),
            "unexpected transport error: {error:?}"
        );
        assert!(error.retryable());
    }
}