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, LastData, Message, OrtResult, PromptOpts, Write, 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 = messages.iter().map(|m| m.size()).sum::<u32>() + 100;
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, \"usage\": {\"include\": 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    w.write_char('}')?;
71
72    Ok(string_buf)
73}
74
75impl LastData {
76    pub fn to_json_writer<W: Write>(&self, writer: W) -> OrtResult<()> {
77        let mut w = writer;
78
79        w.write_str("{\"opts\":{")?;
80        let mut first = true;
81
82        if let Some(ref v) = self.opts.prompt {
83            if !first {
84                w.write_char(',')?;
85            } else {
86                first = false;
87            }
88            w.write_str("\"prompt\":")?;
89            write_json_str(&mut w, v)?;
90        }
91        // TODO: consider multi-model
92        if let Some(v) = self.opts.models.first() {
93            if !first {
94                w.write_char(',')?;
95            } else {
96                first = false;
97            }
98            w.write_str("\"model\":")?;
99            write_json_str(&mut w, v)?;
100        }
101        if let Some(ref v) = self.opts.provider {
102            if !first {
103                w.write_char(',')?;
104            } else {
105                first = false;
106            }
107            w.write_str("\"provider\":")?;
108            write_json_str(&mut w, v)?;
109        }
110        if let Some(ref v) = self.opts.system {
111            if !first {
112                w.write_char(',')?;
113            } else {
114                first = false;
115            }
116            w.write_str("\"system\":")?;
117            write_json_str(&mut w, v)?;
118        }
119        if let Some(ref p) = self.opts.priority {
120            if !first {
121                w.write_char(',')?;
122            } else {
123                first = false;
124            }
125            w.write_str("\"priority\":")?;
126            write_json_str_simple(&mut w, p.as_str())?;
127        }
128        if let Some(ref rc) = self.opts.reasoning {
129            if !first {
130                w.write_char(',')?;
131            } else {
132                first = false;
133            }
134            w.write_str("\"reasoning\":{")?;
135            // always include enabled
136            w.write_str("\"enabled\":")?;
137            write_bool(&mut w, rc.enabled)?;
138            if let Some(ref eff) = rc.effort {
139                w.write_str(",\"effort\":")?;
140                write_json_str_simple(&mut w, eff.as_str())?;
141            }
142            if let Some(tokens) = rc.tokens {
143                w.write_str(",\"tokens\":")?;
144                write_u32(&mut w, tokens)?;
145            }
146            w.write_char('}')?;
147        }
148        if let Some(show) = self.opts.show_reasoning {
149            if !first {
150                w.write_char(',')?;
151            } else {
152                first = false;
153            }
154            w.write_str("\"show_reasoning\":")?;
155            write_bool(&mut w, show)?;
156        }
157        if let Some(quiet) = self.opts.quiet {
158            if !first {
159                w.write_char(',')?;
160            } else {
161                //first = false;
162            }
163            w.write_str("\"quiet\":")?;
164            write_bool(&mut w, quiet)?;
165        }
166
167        // merge_config
168        w.write_char(',')?;
169        w.write_str("\"merge_config\":")?;
170        write_bool(&mut w, self.opts.merge_config)?;
171
172        w.write_str("},\"messages\":")?;
173        Message::write_json_array(&self.messages, &mut w)?;
174
175        w.write_char('}')?;
176        Ok(())
177    }
178}
179
180const HEX: &[u8; 16] = b"0123456789ABCDEF";
181
182fn write_bool<W: Write>(w: &mut W, v: bool) -> OrtResult<usize> {
183    if v {
184        w.write_str("true")
185    } else {
186        w.write_str("false")
187    }
188}
189
190fn write_u32<W: Write>(w: &mut W, mut n: u32) -> OrtResult<usize> {
191    if n == 0 {
192        return w.write_str("0");
193    }
194    let mut buf = [0u8; 10];
195    let mut i = buf.len();
196    while n > 0 {
197        i -= 1;
198        buf[i] = b'0' + (n % 10) as u8;
199        n /= 10;
200    }
201    let s = core::str::from_utf8(&buf[i..]).unwrap();
202    w.write_str(s)
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        (Some(_), Some(_)) | (None, None) => {
224            return Err(ort_error(
225                ErrorKind::InvalidMessageSchema,
226                "Message must have exactly one of 'content' or 'reasoning'.",
227            ));
228        }
229        (Some(content), _) => {
230            w.write_str(",\"content\":")?;
231            write_json_str(w, content)?;
232        }
233        (_, Some(reasoning)) => {
234            w.write_str(",\"reasoning\":")?;
235            write_json_str(w, reasoning)?;
236        }
237    }
238    w.write_char('}')?;
239    Ok(())
240}
241
242/// No escapes or special characters, just write the bytes
243fn write_json_str_simple<W: Write>(w: &mut W, s: &str) -> OrtResult<()> {
244    w.write_char('"')?;
245    w.write_str(s)?;
246    w.write_char('"')?;
247    Ok(())
248}
249
250// Writes a JSON string (with surrounding quotes) with proper escaping, no allocations.
251fn write_json_str<W: Write>(w: &mut W, s: &str) -> OrtResult<()> {
252    w.write_char('"')?;
253    let bytes = s.as_bytes();
254    let mut start = 0;
255
256    for i in 0..bytes.len() {
257        let b = bytes[i];
258        let esc = match b {
259            b'"' => Some(b"\\\""), // as &[u8]),
260            b'\\' => Some(b"\\\\"),
261            b'\n' => Some(b"\\n"),
262            b'\r' => Some(b"\\r"),
263            b'\t' => Some(b"\\t"),
264            0x08 => Some(b"\\b"),
265            0x0C => Some(b"\\f"),
266            0x00..=0x1F => None, // will use \u00XX
267            _ => continue,
268        };
269
270        if start < i {
271            w.write_str(core::str::from_utf8(&bytes[start..i]).unwrap())?;
272        }
273
274        if let Some(e) = esc {
275            w.write_str(core::str::from_utf8(e).unwrap())?;
276        } else {
277            // Generic control char: \u00XX
278            let mut buf = [0u8; 6];
279            buf[0] = b'\\';
280            buf[1] = b'u';
281            buf[2] = b'0';
282            buf[3] = b'0';
283            buf[4] = HEX[((b >> 4) & 0xF) as usize];
284            buf[5] = HEX[(b & 0xF) as usize];
285            w.write_str(core::str::from_utf8(&buf).unwrap())?;
286        }
287
288        start = i + 1;
289    }
290
291    if start < bytes.len() {
292        w.write_str(core::str::from_utf8(&bytes[start..]).unwrap())?;
293    }
294    w.write_char('"')?;
295    Ok(())
296}
297
298#[cfg(test)]
299mod tests {
300    extern crate alloc;
301    use alloc::string::ToString;
302    use alloc::vec;
303
304    use super::*;
305    use crate::ReasoningConfig;
306
307    #[test]
308    fn test_last_data() {
309        let opts = PromptOpts {
310            prompt: None,
311            models: vec!["google/gemma-3n-e4b-it:free".to_string()],
312            provider: Some("google-ai-studio".to_string()),
313            system: Some("System prompt here".to_string()),
314            priority: None,
315            reasoning: Some(ReasoningConfig::off()),
316            show_reasoning: Some(false),
317            quiet: None,
318            merge_config: true,
319        };
320        let messages = vec![
321            Message::user("Hello".to_string()),
322            Message::assistant("Hello there!".to_string()),
323        ];
324        let l = LastData { opts, messages };
325
326        let mut got = String::with_capacity(64);
327        l.to_json_writer(unsafe { got.as_mut_vec() }).unwrap();
328
329        let expected = r#"{"opts":{"model":"google/gemma-3n-e4b-it:free","provider":"google-ai-studio","system":"System prompt here","reasoning":{"enabled":false},"show_reasoning":false,"merge_config":true},"messages":[{"role":"user","content":"Hello"},{"role":"assistant","content":"Hello there!"}]}"#;
330
331        assert_eq!(got, expected);
332    }
333
334    #[test]
335    fn test_build_body() {
336        let opts = PromptOpts {
337            prompt: None,
338            models: vec!["google/gemma-3n-e4b-it:free".to_string()],
339            provider: Some("google-ai-studio".to_string()),
340            system: Some("System prompt here".to_string()),
341            priority: None,
342            reasoning: Some(ReasoningConfig::off()),
343            show_reasoning: Some(false),
344            quiet: None,
345            merge_config: false,
346        };
347        let messages = vec![
348            Message::user("Hello".to_string()),
349            Message::assistant("Hello there!".to_string()),
350        ];
351        let got = build_body(0, &opts, &messages).unwrap();
352
353        let expected = r#"{"stream": true, "usage": {"include": 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!"}]}"#;
354
355        assert_eq!(got, expected);
356    }
357}