Skip to main content

ort_openrouter_cli/input/
to_json.rs

1//! ort: Open Router CLI
2//! https://github.com/grahamking/ort
3//!
4//! MIT License
5//! Copyright (c) 2025 Graham King
6
7extern crate alloc;
8use alloc::string::String;
9
10use crate::{ErrorKind, Message, OrtResult, PromptOpts, Write, common::data::Content, ort_error};
11
12/// Build the POST body
13/// The system and user prompts must already by in messages.
14pub fn build_body(idx: usize, opts: &PromptOpts, messages: &[Message]) -> OrtResult<String> {
15    let capacity: u32 = 1024 + messages.iter().map(|m| m.size()).sum::<u32>();
16    let mut string_buf = String::with_capacity(capacity as usize);
17    let mut w = unsafe { string_buf.as_mut_vec() };
18
19    w.write_str("{\"stream\": true, \"model\": ")?;
20    write_json_str(&mut w, opts.models.get(idx).expect("Missing model"))?;
21
22    if opts.priority.is_some() || opts.provider.is_some() {
23        w.write_str(", \"provider\": {")?;
24        let mut is_first = true;
25        if let Some(p) = opts.priority {
26            w.write_str("\"sort\":")?;
27            write_json_str_simple(&mut w, p.as_str())?;
28            is_first = false;
29        }
30        if let Some(pr) = &opts.provider {
31            if !is_first {
32                w.write_str(", ")?;
33            }
34            w.write_str("\"order\": [")?;
35            write_json_str(&mut w, pr)?;
36            w.write_char(']')?;
37        }
38        w.write_char('}')?;
39    }
40
41    w.write_str(", \"reasoning\": ")?;
42    match &opts.reasoning {
43        // No -r and nothing in config file
44        None => {
45            w.write_str("{\"enabled\": false}")?;
46        }
47        // cli "-r off" or config file '"enabled": false'
48        Some(r_cfg) if !r_cfg.enabled => {
49            w.write_str("{\"enabled\": false}")?;
50        }
51        // Reasoning on
52        Some(r_cfg) => match (r_cfg.effort, r_cfg.tokens) {
53            (Some(effort), _) => {
54                w.write_str("{\"exclude\": false, \"enabled\": true, \"effort\":")?;
55                write_json_str_simple(&mut w, effort.as_str())?;
56                w.write_char('}')?;
57            }
58            (_, Some(tokens)) => {
59                w.write_str("{\"exclude\": false, \"enabled\": true, \"max_tokens\":")?;
60                write_u32(&mut w, tokens)?;
61                w.write_char('}')?;
62            }
63            _ => unreachable!("Reasoning effort and tokens cannot both be null"),
64        },
65    };
66
67    w.write_str(", \"messages\":")?;
68    Message::write_json_array(messages, &mut w)?;
69
70    // I think PDFs are not sent natively to the model, they are pre-parsed by open router.
71    // This disables that parsing. Experimental, does not help.
72    // w.write_str(", \"plugins\": [{\"id\": \"file-parser\", \"pdf\": { \"engine\": \"native\" } }]")?;
73
74    w.write_char('}')?;
75
76    Ok(string_buf)
77}
78
79impl PromptOpts {
80    pub fn to_json_writer<W: Write>(&self, writer: &mut W) -> OrtResult<()> {
81        let w = writer;
82
83        w.write_char('{')?;
84        let mut first = true;
85
86        if let Some(ref v) = self.prompt {
87            if !first {
88                w.write_char(',')?;
89            } else {
90                first = false;
91            }
92            w.write_str("\"prompt\":")?;
93            write_json_str(w, v)?;
94        }
95        // TODO: consider multi-model
96        if let Some(v) = self.models.first() {
97            if !first {
98                w.write_char(',')?;
99            } else {
100                first = false;
101            }
102            w.write_str("\"model\":")?;
103            write_json_str(w, v)?;
104        }
105        if let Some(ref v) = self.provider {
106            if !first {
107                w.write_char(',')?;
108            } else {
109                first = false;
110            }
111            w.write_str("\"provider\":")?;
112            write_json_str(w, v)?;
113        }
114        if let Some(ref v) = self.system {
115            if !first {
116                w.write_char(',')?;
117            } else {
118                first = false;
119            }
120            w.write_str("\"system\":")?;
121            write_json_str(w, v)?;
122        }
123        if let Some(ref p) = self.priority {
124            if !first {
125                w.write_char(',')?;
126            } else {
127                first = false;
128            }
129            w.write_str("\"priority\":")?;
130            write_json_str_simple(w, p.as_str())?;
131        }
132        if let Some(ref rc) = self.reasoning {
133            if !first {
134                w.write_char(',')?;
135            } else {
136                first = false;
137            }
138            w.write_str("\"reasoning\":{")?;
139            // always include enabled
140            w.write_str("\"enabled\":")?;
141            write_bool(w, rc.enabled)?;
142            if let Some(ref eff) = rc.effort {
143                w.write_str(",\"effort\":")?;
144                write_json_str_simple(w, eff.as_str())?;
145            }
146            if let Some(tokens) = rc.tokens {
147                w.write_str(",\"tokens\":")?;
148                write_u32(w, tokens)?;
149            }
150            w.write_char('}')?;
151        }
152        if let Some(show) = self.show_reasoning {
153            if !first {
154                w.write_char(',')?;
155            } else {
156                first = false;
157            }
158            w.write_str("\"show_reasoning\":")?;
159            write_bool(w, show)?;
160        }
161        if let Some(quiet) = self.quiet {
162            if !first {
163                w.write_char(',')?;
164            } else {
165                //first = false;
166            }
167            w.write_str("\"quiet\":")?;
168            write_bool(w, quiet)?;
169        }
170
171        // merge_config
172        w.write_char(',')?;
173        w.write_str("\"merge_config\":")?;
174        write_bool(w, self.merge_config)?;
175
176        w.write_char('}')?;
177        Ok(())
178    }
179}
180
181const HEX: &[u8; 16] = b"0123456789ABCDEF";
182
183fn write_bool<W: Write>(w: &mut W, v: bool) -> OrtResult<usize> {
184    if v {
185        w.write_str("true")
186    } else {
187        w.write_str("false")
188    }
189}
190
191fn write_u32<W: Write>(w: &mut W, mut n: u32) -> OrtResult<usize> {
192    if n == 0 {
193        return w.write_str("0");
194    }
195    let mut buf = [0u8; 10];
196    let mut i = buf.len();
197    while n > 0 {
198        i -= 1;
199        buf[i] = b'0' + (n % 10) as u8;
200        n /= 10;
201    }
202    w.write(&buf[i..])
203}
204
205impl Message {
206    pub fn write_json_array<W: Write>(msgs: &[Message], w: &mut W) -> OrtResult<()> {
207        w.write_char('[')?;
208        for (i, msg) in msgs.iter().enumerate() {
209            if i != 0 {
210                w.write_char(',')?;
211            }
212            write_json(msg, w)?;
213        }
214        w.write_char(']')?;
215        Ok(())
216    }
217}
218
219pub fn write_json<W: Write>(data: &Message, w: &mut W) -> OrtResult<()> {
220    w.write_str("{\"role\":")?;
221    write_json_str_simple(w, data.role.as_str())?;
222    match (&data.content, &data.reasoning) {
223        (content, Some(_)) if !content.is_empty() => {
224            return Err(ort_error(
225                ErrorKind::InvalidMessageSchema,
226                "Message must have exactly one of 'content' or 'reasoning'.",
227            ));
228        }
229        (content, None) if content.is_empty() => {
230            return Err(ort_error(
231                ErrorKind::InvalidMessageSchema,
232                "Message must have exactly one of 'content' or 'reasoning'.",
233            ));
234        }
235        (_, Some(reasoning)) => {
236            w.write_str(",\"reasoning\":")?;
237            write_json_str(w, reasoning)?;
238        }
239        (content, _) => {
240            w.write_str(",\"content\":")?;
241            match content.as_slice() {
242                [Content::Text(text)] => write_json_str(w, text)?,
243                _ => {
244                    w.write_char('[')?;
245                    for (i, item) in content.iter().enumerate() {
246                        if i != 0 {
247                            w.write_char(',')?;
248                        }
249                        item.to_json(w)?;
250                    }
251                    w.write_char(']')?;
252                }
253            }
254        }
255    }
256    w.write_char('}')?;
257    Ok(())
258}
259
260impl Content {
261    #[allow(dead_code)]
262    pub fn to_json<W: Write>(&self, w: &mut W) -> OrtResult<()> {
263        w.write_str("{\"type\":")?;
264        use Content::*;
265        match self {
266            Text(s) => {
267                write_json_str(w, "text")?;
268                w.write_str(", \"text\": ")?;
269                write_json_str(w, s.as_str())?;
270            }
271            Image { base64, mime_type } => {
272                write_json_str(w, "image_url")?;
273                w.write_str(", \"image_url\": { \"url\": \"data:")?;
274                w.write_str(mime_type)?;
275                w.write_str(";base64,")?; // end of the data: URL prefix
276                w.write_str(base64.as_str())?;
277                w.write_str("\"}")?;
278            }
279            ImageUrl(url) => {
280                write_json_str(w, "image_url")?;
281                w.write_str(", \"image_url\": { \"url\": \"")?;
282                w.write_str(url)?;
283                w.write_str("\"}")?;
284            }
285            File(f) => {
286                write_json_str(w, "file")?;
287                w.write_str(", \"file\": {\"filename\": ")?;
288                write_json_str(w, &f.filename)?;
289                // TODO: Support non-PDF, or restrict -f to PDF
290                w.write_str(", \"file_data\": \"data:application/pdf;base64,")?;
291                w.write_str(&f.base64)?;
292                w.write_str("\"}")?;
293            }
294        }
295        w.write_char('}')?;
296        Ok(())
297    }
298}
299
300/// No escapes or special characters, just write the bytes
301pub(crate) fn write_json_str_simple<W: Write>(w: &mut W, s: &str) -> OrtResult<()> {
302    w.write_char('"')?;
303    w.write_str(s)?;
304    w.write_char('"')?;
305    Ok(())
306}
307
308// Writes a JSON string (with surrounding quotes) with proper escaping, no allocations.
309fn write_json_str<W: Write>(w: &mut W, s: &str) -> OrtResult<()> {
310    w.write_char('"')?;
311    write_encoded_bytes(w, s.as_bytes())?;
312    w.write_char('"')?;
313
314    Ok(())
315}
316
317pub fn write_encoded_bytes<W: Write>(w: &mut W, bytes: &[u8]) -> OrtResult<()> {
318    let mut start = 0;
319
320    for i in 0..bytes.len() {
321        let b = bytes[i];
322        let esc = match b {
323            b'"' => Some(b"\\\""), // as &[u8]),
324            b'\\' => Some(b"\\\\"),
325            b'\n' => Some(b"\\n"),
326            b'\r' => Some(b"\\r"),
327            b'\t' => Some(b"\\t"),
328            0x08 => Some(b"\\b"),
329            0x0C => Some(b"\\f"),
330            0x00..=0x1F => None, // will use \u00XX
331            _ => continue,
332        };
333
334        if start < i {
335            w.write(&bytes[start..i])?;
336        }
337
338        if let Some(e) = esc {
339            w.write(e)?;
340        } else {
341            // Generic control char: \u00XX
342            let mut buf = [0u8; 6];
343            buf[0] = b'\\';
344            buf[1] = b'u';
345            buf[2] = b'0';
346            buf[3] = b'0';
347            buf[4] = HEX[((b >> 4) & 0xF) as usize];
348            buf[5] = HEX[(b & 0xF) as usize];
349            w.write(&buf)?;
350        }
351
352        start = i + 1;
353    }
354
355    if start < bytes.len() {
356        w.write(&bytes[start..])?;
357    }
358    Ok(())
359}
360
361#[cfg(test)]
362mod tests {
363    extern crate alloc;
364    use alloc::string::ToString;
365    use alloc::vec;
366
367    use super::*;
368    use crate::ReasoningConfig;
369
370    #[test]
371    fn test_build_body() {
372        let opts = PromptOpts {
373            prompt: None,
374            models: vec!["google/gemma-3n-e4b-it:free".to_string()],
375            provider: Some("google-ai-studio".to_string()),
376            system: Some("System prompt here".to_string()),
377            priority: None,
378            reasoning: Some(ReasoningConfig::off()),
379            show_reasoning: Some(false),
380            quiet: None,
381            merge_config: false,
382            files: vec![], // TODO
383        };
384        let messages = vec![
385            Message::user("Hello".to_string()),
386            Message::assistant("Hello there!".to_string()),
387        ];
388        let got = match build_body(0, &opts, &messages) {
389            Ok(got) => got,
390            Err(err) => {
391                panic!("{}", err.as_string());
392            }
393        };
394
395        let expected = r#"{"stream": true, "model": "google/gemma-3n-e4b-it:free", "provider": {"order": ["google-ai-studio"]}, "reasoning": {"enabled": false}, "messages":[{"role":"user","content":"Hello"},{"role":"assistant","content":"Hello there!"}]}"#;
396
397        assert_eq!(got, expected);
398    }
399}