Skip to main content

infinity_provider_models_dev/
convert.rs

1//! Shared conversion helpers between the infinity protocol `Message` types and
2//! the `OpenAI` / `Anthropic` wire formats.
3
4use infinity_provider_protocol::{
5    AssistantContent, Image, ImageMediaType, ImageSource, Message, ToolDefinition, ToolResult,
6    ToolResultContent, UserContent,
7};
8use serde_json::{Value, json};
9
10/// Render user/assistant history as `OpenAI` `messages`, returning
11/// `(messages, system_prompt)`.
12#[must_use]
13pub fn to_openai_messages(
14    preamble: Option<&str>,
15    history: &[Message],
16    supports_images: bool,
17) -> (Vec<Value>, Option<String>) {
18    let mut messages = Vec::new();
19    let system: Option<String> = preamble.map(str::to_owned);
20
21    for msg in history {
22        match msg {
23            Message::User { content } => {
24                let mut text_parts = Vec::new();
25                let mut image_parts = Vec::new();
26                let mut tool_msgs = Vec::new();
27                for c in content {
28                    match c {
29                        UserContent::Text(t) => text_parts.push(t.text.clone()),
30                        UserContent::Image(img) => image_parts.push(image_to_data_url(img)),
31                        UserContent::ToolResult(r) => {
32                            tool_msgs.push(tool_result_message(r, supports_images));
33                        }
34                    }
35                }
36                if tool_msgs.is_empty() {
37                    messages.push(user_message(
38                        &text_parts.join("\n"),
39                        &image_parts,
40                        supports_images,
41                    ));
42                } else {
43                    // Tool results go as their own `tool` messages; any
44                    // co-located text becomes a sibling user message.
45                    if !(text_parts.is_empty() && image_parts.is_empty()) {
46                        messages.push(user_message(
47                            &text_parts.join("\n"),
48                            &image_parts,
49                            supports_images,
50                        ));
51                    }
52                    messages.extend(tool_msgs);
53                }
54            }
55            Message::Assistant { content } => {
56                let mut text = String::new();
57                let mut reasoning_text = String::new();
58                let mut tool_calls = Vec::new();
59                for c in content {
60                    match c {
61                        AssistantContent::Text(t) => {
62                            if !text.is_empty() {
63                                text.push('\n');
64                            }
65                            text.push_str(&t.text);
66                        }
67                        AssistantContent::ToolCall(tc) => {
68                            tool_calls.push(json!({
69                                "id": tc.id,
70                                "type": "function",
71                                "function": {
72                                    "name": tc.function.name,
73                                    "arguments": arguments_to_string(&tc.function.arguments),
74                                }
75                            }));
76                        }
77                        // Reasoning blocks round-trip so reasoning-model
78                        // endpoints (DeepSeek-R1 style) see prior thinking.
79                        // Models that do not understand the field ignore it;
80                        // it is only ever sent when this same backend
81                        // produced it.
82                        AssistantContent::Reasoning(r) => {
83                            let thinking = r.display_text();
84                            if !thinking.is_empty() {
85                                if !reasoning_text.is_empty() {
86                                    reasoning_text.push('\n');
87                                }
88                                reasoning_text.push_str(&thinking);
89                            }
90                        }
91                    }
92                }
93                messages.push(assistant_message(text, reasoning_text, tool_calls));
94            }
95        }
96    }
97
98    (messages, system)
99}
100
101fn assistant_message(text: String, reasoning: String, tool_calls: Vec<Value>) -> Value {
102    let mut map = serde_json::Map::new();
103    map.insert("role".to_owned(), json!("assistant"));
104    // An empty-string body keeps the message well-formed on endpoints
105    // that reject null content.
106    map.insert("content".to_owned(), Value::String(text));
107    if !reasoning.is_empty() {
108        map.insert("reasoning_content".to_owned(), Value::String(reasoning));
109    }
110    if !tool_calls.is_empty() {
111        map.insert("tool_calls".to_owned(), Value::Array(tool_calls));
112    }
113    Value::Object(map)
114}
115
116/// A tool result as an `OpenAI` `tool` message. Images become real
117/// `image_url` parts only when the model accepts them; otherwise they are
118/// replaced with a placeholder (a base64 data URL inlined as text would
119/// burn context without being visible to the model).
120fn tool_result_message(result: &ToolResult, supports_images: bool) -> Value {
121    let mut text_parts = Vec::new();
122    let mut image_parts = Vec::new();
123    for block in &result.content {
124        match block {
125            ToolResultContent::Text(t) => text_parts.push(t.text.clone()),
126            ToolResultContent::Image(img) => {
127                if supports_images {
128                    image_parts.push(image_to_data_url(img));
129                } else {
130                    text_parts.push("[image omitted: model does not accept images]".to_owned());
131                }
132            }
133        }
134    }
135    let text = text_parts.join("\n");
136    if image_parts.is_empty() {
137        return json!({
138            "role": "tool",
139            "tool_call_id": result.id,
140            "content": text,
141        });
142    }
143    let mut parts = Vec::new();
144    if !text.is_empty() {
145        parts.push(json!({"type": "text", "text": text}));
146    }
147    for url in image_parts {
148        parts.push(json!({"type": "image_url", "image_url": {"url": url}}));
149    }
150    json!({
151        "role": "tool",
152        "tool_call_id": result.id,
153        "content": parts,
154    })
155}
156
157fn user_message(text: &str, images: &[String], supports_images: bool) -> Value {
158    if images.is_empty() || !supports_images {
159        let mut t = text.to_owned();
160        if !images.is_empty() {
161            if !t.is_empty() {
162                t.push('\n');
163            }
164            t.push_str("[image omitted: backend does not accept images]");
165        }
166        return json!({"role": "user", "content": t});
167    }
168    let mut parts = Vec::new();
169    if !text.is_empty() {
170        parts.push(json!({"type": "text", "text": text}));
171    }
172    for url in images {
173        parts.push(json!({"type": "image_url", "image_url": {"url": url}}));
174    }
175    json!({"role": "user", "content": parts})
176}
177
178fn image_to_data_url(img: &Image) -> String {
179    match &img.data {
180        ImageSource::Url(u) => u.clone(),
181        ImageSource::Base64(b) => {
182            let mime = img
183                .media_type
184                .as_ref()
185                .map_or("image/png", ImageMediaType::to_mime_type);
186            format!("data:{mime};base64,{b}")
187        }
188    }
189}
190
191#[must_use]
192pub fn arguments_to_string(v: &Value) -> String {
193    match v {
194        Value::String(s) => s.clone(),
195        _ => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_owned()),
196    }
197}
198
199#[must_use]
200pub fn to_openai_tools(tools: &[ToolDefinition]) -> Vec<Value> {
201    tools
202        .iter()
203        .map(|t| {
204            json!({
205                "type": "function",
206                "function": {
207                    "name": t.name,
208                    "description": t.description,
209                    "parameters": t.parameters,
210                }
211            })
212        })
213        .collect()
214}
215
216/// Classify an HTTP failure into the protocol's retry classes.
217#[must_use]
218pub fn classify_http_status(status: u16, body: &str) -> infinity_provider_protocol::ErrorClass {
219    use infinity_provider_protocol::ErrorClass as C;
220    if status == 429 {
221        return C::Throttled;
222    }
223    if status == 529 || (500..600).contains(&status) {
224        return C::Transient;
225    }
226    if status == 408 || status == 504 {
227        return C::Transient;
228    }
229    if status == 400 || status == 413 || status == 422 {
230        let msg = body.to_ascii_lowercase();
231        if msg.contains("context")
232            || msg.contains("too long")
233            || msg.contains("too large")
234            || msg.contains("token limit")
235            || msg.contains("max_tokens")
236            || msg.contains("input length")
237        {
238            return C::ContextOverflow;
239        }
240        // Some gateways report throttling as 400 with a rate-limit message.
241        if msg.contains("rate limit")
242            || msg.contains("too many requests")
243            || msg.contains("try again")
244        {
245            return C::Throttled;
246        }
247        // Overloaded / out-of-capacity failures are transient: the same
248        // request may succeed on retry once load sheds.
249        if msg.contains("overloaded") || msg.contains("capacity") {
250            return C::Transient;
251        }
252        return C::Fatal;
253    }
254    C::Fatal
255}
256
257/// Split complete SSE frames off the front of `buf`, leaving any partial
258/// tail in place for the next read.
259///
260/// Handles both LF (`\n\n`) and CRLF (`\r\n\r\n`) framing; the earliest
261/// complete delimiter wins and is consumed in full. A blank line is a
262/// dispatch point in either convention, so mixed framing stays aligned.
263#[must_use]
264pub fn take_frames(buf: &mut String) -> Vec<String> {
265    let mut frames = Vec::new();
266    loop {
267        let lf = buf.find("\n\n");
268        let crlf = buf.find("\r\n\r\n");
269        let (end, len) = match (lf, crlf) {
270            (Some(a), Some(b)) if b < a => (b, 4),
271            (Some(a), _) => (a, 2),
272            (None, Some(b)) => (b, 4),
273            (None, None) => break,
274        };
275        let frame: String = buf.drain(..end.saturating_add(len)).collect();
276        frames.push(frame);
277    }
278    frames
279}
280
281/// Buffer raw response bytes into `out`, holding back split UTF-8.
282///
283/// TCP segments can split multi-byte chars; decoding per chunk would corrupt
284/// them into replacement characters, so a trailing partial sequence waits in
285/// `pending` until its remaining bytes arrive.
286pub fn push_bytes(out: &mut String, pending: &mut Vec<u8>, bytes: &[u8]) {
287    pending.extend_from_slice(bytes);
288    let valid_up_to = match std::str::from_utf8(pending) {
289        Ok(_) => pending.len(),
290        Err(error) => error.valid_up_to(),
291    };
292    if valid_up_to > 0 {
293        // Valid UTF-8 by construction, so `from_utf8_lossy` is a no-op
294        // that avoids fallible conversions here.
295        let valid: Vec<u8> = pending.drain(..valid_up_to).collect();
296        out.push_str(&String::from_utf8_lossy(&valid));
297    }
298}
299
300/// Extract a human-readable message from a JSON error body.
301#[must_use]
302pub fn error_message_from_body(body: &str) -> String {
303    if let Ok(v) = serde_json::from_str::<Value>(body) {
304        if let Some(m) = v.pointer("/error/message").and_then(Value::as_str) {
305            return m.to_owned();
306        }
307        if let Some(m) = v.pointer("/error").and_then(Value::as_str) {
308            return m.to_owned();
309        }
310        if let Some(m) = v.pointer("/message").and_then(Value::as_str) {
311            return m.to_owned();
312        }
313    }
314    body.trim().chars().take(500).collect()
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use infinity_provider_protocol::ErrorClass;
321
322    #[test]
323    fn overloaded_and_capacity_are_transient() {
324        assert_eq!(
325            classify_http_status(400, "the engine is overloaded"),
326            ErrorClass::Transient
327        );
328        assert_eq!(
329            classify_http_status(400, "no capacity available right now"),
330            ErrorClass::Transient
331        );
332        // Explicit throttle language still wins with a longer backoff.
333        assert_eq!(
334            classify_http_status(400, "overloaded, please try again later"),
335            ErrorClass::Throttled
336        );
337        // Context overflow still wins over generic capacity wording.
338        assert_eq!(
339            classify_http_status(400, "input length exceeds context capacity"),
340            ErrorClass::ContextOverflow
341        );
342        assert_eq!(
343            classify_http_status(400, "invalid request"),
344            ErrorClass::Fatal
345        );
346    }
347
348    #[test]
349    fn splits_lf_frames_and_keeps_tail() {
350        let mut buf = "data: 1\n\ndata: 2\n\npartial".to_owned();
351        assert_eq!(
352            take_frames(&mut buf),
353            vec!["data: 1\n\n".to_owned(), "data: 2\n\n".to_owned()]
354        );
355        assert_eq!(buf, "partial");
356    }
357
358    #[test]
359    fn splits_crlf_frames() {
360        let mut buf = "data: 1\r\n\r\ndata: 2\r\n\r\n".to_owned();
361        assert_eq!(
362            take_frames(&mut buf),
363            vec!["data: 1\r\n\r\n".to_owned(), "data: 2\r\n\r\n".to_owned()]
364        );
365        assert_eq!(buf, String::new());
366    }
367
368    #[test]
369    fn earliest_delimiter_wins_and_partial_tail_waits() {
370        let mut buf = "data: 1\r\n\r\ndata:".to_owned();
371        assert_eq!(take_frames(&mut buf), vec!["data: 1\r\n\r\n".to_owned()]);
372        assert_eq!(buf, "data:");
373        // Completing the tail later yields the next frame.
374        buf.push_str(" 2\n\n");
375        assert_eq!(take_frames(&mut buf), vec!["data: 2\n\n".to_owned()]);
376        assert_eq!(buf, String::new());
377    }
378
379    #[test]
380    fn no_delimiter_yields_nothing() {
381        let mut buf = "data: incomplete".to_owned();
382        assert_eq!(take_frames(&mut buf), Vec::<String>::new());
383        assert_eq!(buf, "data: incomplete");
384    }
385}