Skip to main content

openrouter_batch/
facade.rs

1//! Typed facades over the transport. The engine loop never touches wire
2//! types: it submits structured completion batches by item list, and
3//! decodes stored embedding payloads into input-ordered vectors.
4
5use super::transport::{
6    BatchEndpoint, BatchItem, BatchTransport, EmbeddingResponseBody, SubmitBatch, TransportError,
7};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11/// One chat-completions message.
12#[derive(Debug, Clone, Serialize)]
13pub struct Message {
14    pub role: String,
15    pub content: String,
16}
17
18impl Message {
19    pub fn system(content: impl Into<String>) -> Self {
20        Self {
21            role: "system".to_string(),
22            content: content.into(),
23        }
24    }
25
26    pub fn user(content: impl Into<String>) -> Self {
27        Self {
28            role: "user".to_string(),
29            content: content.into(),
30        }
31    }
32}
33
34/// Parameters shared by every request in one structured batch. One schema per
35/// batch is mandatory on Google-backed routes (quickstart), so the schema
36/// lives here, at batch level, not per item.
37#[derive(Debug, Clone)]
38pub struct StructuredParams {
39    /// The strict JSON Schema for `response_format`.
40    pub schema: serde_json::Value,
41    /// The `response_format` schema name.
42    pub schema_name: String,
43    pub max_tokens: u32,
44    /// Emit `"reasoning": {"enabled": false}` in every body. Reasoner models
45    /// otherwise burn the token budget on thinking and answer with
46    /// `content: null` and `finish_reason: "length"` (verified gotcha).
47    pub disable_reasoning: bool,
48    /// Emit `"provider": {"require_parameters": true}` in every body so
49    /// OpenRouter routes only to providers that honor every request
50    /// parameter, including `response_format` strict mode. Opt-in: the eval
51    /// harness uses it to pin strict-mode behavior; production leaves it off.
52    pub require_parameters: bool,
53    /// Cap the model's reasoning tokens via `"reasoning": {"max_tokens": N}`,
54    /// leaving the rest of `max_tokens` for the JSON body. Counter to the
55    /// measured reasoning squeeze where reasoner slugs burn the whole budget
56    /// before emitting (isolated replay V1-cap512: wire-verified).
57    pub reasoning_max_tokens: Option<u32>,
58}
59
60/// One structured chat-completions request body: the exact body the Batch
61/// API wraps per item and the synchronous chat-completions endpoint accepts.
62/// Shared by both paths so their wire shapes cannot drift.
63pub fn structured_body(
64    params: &StructuredParams,
65    messages: Vec<Message>,
66) -> Result<Value, serde_json::Error> {
67    let mut schema = serde_json::Map::new();
68    schema.insert("name".to_string(), params.schema_name.clone().into());
69    schema.insert("strict".to_string(), true.into());
70    schema.insert("schema".to_string(), params.schema.clone());
71
72    let mut response_format = serde_json::Map::new();
73    response_format.insert("type".to_string(), "json_schema".into());
74    response_format.insert("json_schema".to_string(), Value::Object(schema));
75
76    let mut body = serde_json::Map::new();
77    body.insert("messages".to_string(), serde_json::to_value(messages)?);
78    body.insert("max_tokens".to_string(), params.max_tokens.into());
79    body.insert(
80        "response_format".to_string(),
81        Value::Object(response_format),
82    );
83    if params.disable_reasoning {
84        body.insert(
85            "reasoning".to_string(),
86            serde_json::json!({"enabled": false}),
87        );
88    }
89    if params.require_parameters {
90        body.insert(
91            "provider".to_string(),
92            serde_json::json!({"require_parameters": true}),
93        );
94    }
95    if let Some(cap) = params.reasoning_max_tokens {
96        body.insert(
97            "reasoning".to_string(),
98            serde_json::json!({"max_tokens": cap}),
99        );
100    }
101    Ok(Value::Object(body))
102}
103
104/// Submit one structured chat-completions batch: every item carries the same
105/// `response_format` json_schema with `strict: true`.
106///
107/// `items` pairs each deterministic `custom_id` with its prompt messages.
108/// The returned batch object is `202 Accepted`-equivalent: `validating`,
109/// queued — never treated as complete.
110pub async fn submit_structured(
111    transport: &dyn BatchTransport,
112    model: &str,
113    idempotency_key: &str,
114    params: &StructuredParams,
115    items: Vec<(String, Vec<Message>)>,
116) -> Result<super::transport::BatchObject, TransportError> {
117    // The provider rejects the whole batch with 422 when any request key
118    // exceeds 64 characters (observed live 2026-08-30). Fail locally with
119    // the offending key instead of a remote round-trip.
120    for (custom_id, _) in &items {
121        if custom_id.len() > 64 {
122            return Err(TransportError::CustomIdTooLong {
123                custom_id: custom_id.clone(),
124            });
125        }
126    }
127
128    let mut schema = serde_json::Map::new();
129    schema.insert("name".to_string(), params.schema_name.clone().into());
130    schema.insert("strict".to_string(), true.into());
131    schema.insert("schema".to_string(), params.schema.clone());
132
133    let mut response_format = serde_json::Map::new();
134    response_format.insert("type".to_string(), "json_schema".into());
135    response_format.insert("json_schema".to_string(), Value::Object(schema));
136
137    let requests = items
138        .into_iter()
139        .map(|(custom_id, messages)| {
140            Ok(BatchItem {
141                custom_id,
142                body: structured_body(params, messages)?,
143            })
144        })
145        .collect::<Result<Vec<_>, serde_json::Error>>()?;
146
147    transport
148        .submit(
149            &SubmitBatch::new(BatchEndpoint::ChatCompletions, model, requests),
150            idempotency_key,
151        )
152        .await
153}
154
155/// A decode failure of one stored embedding payload. The `Display` strings
156/// are the durable `last_error` texts operators see in Postgres.
157#[derive(Debug, thiserror::Error)]
158pub enum EmbeddingDecodeError {
159    #[error("embedding response decode: {source}")]
160    Envelope {
161        #[from]
162        source: serde_json::Error,
163    },
164    #[error("embedding response count: expected {expected}, received {actual}")]
165    CountMismatch { expected: usize, actual: usize },
166    #[error("embedding response index {0} is negative")]
167    NegativeIndex(i64),
168    #[error("embedding response index {0} exceeds input count {1}")]
169    IndexOutOfRange(usize, usize),
170    #[error("embedding response has duplicate index {0}")]
171    DuplicateIndex(usize),
172    #[error("embedding response has no index {0}")]
173    MissingIndex(usize),
174}
175
176/// Decode one stored raw embedding payload and return the vectors ordered
177/// to match the request inputs: `vectors[i]` belongs to input `i`. Validates
178/// the envelope, the count, the index range, duplicates, and completeness.
179///
180/// (`raw_provider_results.raw_line`), so a crash between submit and
181/// finalize never needs the provider again.
182pub fn embedding_vectors(
183    raw: &serde_json::Value,
184    expected: usize,
185) -> Result<Vec<Vec<f32>>, EmbeddingDecodeError> {
186    let response = EmbeddingResponseBody::deserialize(raw)?;
187    if response.data.len() != expected {
188        return Err(EmbeddingDecodeError::CountMismatch {
189            expected,
190            actual: response.data.len(),
191        });
192    }
193    let mut indexed: Vec<Option<Vec<f32>>> = vec![None; expected];
194    for data in response.data {
195        let index = usize::try_from(data.index)
196            .map_err(|_| EmbeddingDecodeError::NegativeIndex(data.index))?;
197        if index >= expected {
198            return Err(EmbeddingDecodeError::IndexOutOfRange(index, expected));
199        }
200        if indexed[index].replace(data.embedding).is_some() {
201            return Err(EmbeddingDecodeError::DuplicateIndex(index));
202        }
203    }
204    indexed
205        .into_iter()
206        .enumerate()
207        .map(|(index, vector)| vector.ok_or(EmbeddingDecodeError::MissingIndex(index)))
208        .collect()
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn structured_body_caps_reasoning_tokens_only_when_set() {
217        let base = StructuredParams {
218            schema: serde_json::json!({"type": "object"}),
219            schema_name: "reply".to_string(),
220            max_tokens: 100,
221            disable_reasoning: false,
222            require_parameters: false,
223            reasoning_max_tokens: Some(1024),
224        };
225        let body = structured_body(&base, vec![Message::user("hi")]).unwrap();
226        assert_eq!(
227            body.get("reasoning"),
228            Some(&serde_json::json!({"max_tokens": 1024}))
229        );
230
231        let off = StructuredParams {
232            reasoning_max_tokens: None,
233            ..base
234        };
235        let body = structured_body(&off, vec![Message::user("hi")]).unwrap();
236        assert!(body.get("reasoning").is_none());
237    }
238
239    #[test]
240    fn structured_body_requires_provider_parameters_only_when_opted_in() {
241        let base = StructuredParams {
242            schema: serde_json::json!({"type": "object"}),
243            schema_name: "reply".to_string(),
244            max_tokens: 100,
245            disable_reasoning: false,
246            require_parameters: true,
247            reasoning_max_tokens: None,
248        };
249        let body = structured_body(&base, vec![Message::user("hi")]).unwrap();
250        assert_eq!(
251            body.get("provider"),
252            Some(&serde_json::json!({"require_parameters": true}))
253        );
254
255        let off = StructuredParams {
256            require_parameters: false,
257            ..base
258        };
259        let body = structured_body(&off, vec![Message::user("hi")]).unwrap();
260        assert!(body.get("provider").is_none());
261    }
262
263    #[test]
264    fn vectors_are_reordered_to_match_input_indexes() {
265        let raw = serde_json::json!({
266            "id": "embed-request",
267            "data": [
268                {"index": 1, "embedding": [2.0]},
269                {"index": 0, "embedding": [1.0]},
270            ]
271        });
272        let vectors = embedding_vectors(&raw, 2).unwrap();
273        assert_eq!(vectors, vec![vec![1.0], vec![2.0]]);
274    }
275
276    #[test]
277    fn vector_decode_rejects_count_and_index_defects() {
278        let short = serde_json::json!({"data": [{"index": 0, "embedding": [1.0]}]});
279        assert!(matches!(
280            embedding_vectors(&short, 2),
281            Err(EmbeddingDecodeError::CountMismatch {
282                expected: 2,
283                actual: 1
284            })
285        ));
286        let duplicate = serde_json::json!({
287            "data": [
288                {"index": 0, "embedding": [1.0]},
289                {"index": 0, "embedding": [2.0]},
290            ]
291        });
292        assert!(matches!(
293            embedding_vectors(&duplicate, 2),
294            Err(EmbeddingDecodeError::DuplicateIndex(0))
295        ));
296    }
297}