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,
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                            let text = r
33                                .content
34                                .iter()
35                                .map(|b| match b {
36                                    ToolResultContent::Text(t) => t.text.clone(),
37                                    ToolResultContent::Image(img) => image_to_data_url(img),
38                                })
39                                .collect::<Vec<_>>()
40                                .join("\n");
41                            tool_msgs.push(json!({
42                                "role": "tool",
43                                "tool_call_id": r.id,
44                                "content": text,
45                            }));
46                        }
47                    }
48                }
49                if tool_msgs.is_empty() {
50                    messages.push(user_message(
51                        &text_parts.join("\n"),
52                        &image_parts,
53                        supports_images,
54                    ));
55                } else {
56                    // Tool results go as their own `tool` messages; any
57                    // co-located text becomes a sibling user message.
58                    if !(text_parts.is_empty() && image_parts.is_empty()) {
59                        messages.push(user_message(
60                            &text_parts.join("\n"),
61                            &image_parts,
62                            supports_images,
63                        ));
64                    }
65                    messages.extend(tool_msgs);
66                }
67            }
68            Message::Assistant { content } => {
69                let mut text = String::new();
70                let mut tool_calls = Vec::new();
71                for c in content {
72                    match c {
73                        AssistantContent::Text(t) => {
74                            if !text.is_empty() {
75                                text.push('\n');
76                            }
77                            text.push_str(&t.text);
78                        }
79                        AssistantContent::ToolCall(tc) => {
80                            tool_calls.push(json!({
81                                "id": tc.id,
82                                "type": "function",
83                                "function": {
84                                    "name": tc.function.name,
85                                    "arguments": arguments_to_string(&tc.function.arguments),
86                                }
87                            }));
88                        }
89                        AssistantContent::Reasoning(_) => {}
90                    }
91                }
92                if tool_calls.is_empty() {
93                    messages.push(json!({"role": "assistant", "content": text}));
94                } else {
95                    messages.push(json!({
96                        "role": "assistant",
97                        "content": if text.is_empty() { Value::Null } else { Value::String(text) },
98                        "tool_calls": tool_calls,
99                    }));
100                }
101            }
102        }
103    }
104
105    (messages, system)
106}
107
108fn user_message(text: &str, images: &[String], supports_images: bool) -> Value {
109    if images.is_empty() || !supports_images {
110        let mut t = text.to_owned();
111        if !images.is_empty() {
112            if !t.is_empty() {
113                t.push('\n');
114            }
115            t.push_str("[image omitted: backend does not accept images]");
116        }
117        return json!({"role": "user", "content": t});
118    }
119    let mut parts = Vec::new();
120    if !text.is_empty() {
121        parts.push(json!({"type": "text", "text": text}));
122    }
123    for url in images {
124        parts.push(json!({"type": "image_url", "image_url": {"url": url}}));
125    }
126    json!({"role": "user", "content": parts})
127}
128
129fn image_to_data_url(img: &Image) -> String {
130    match &img.data {
131        ImageSource::Url(u) => u.clone(),
132        ImageSource::Base64(b) => {
133            let mime = img
134                .media_type
135                .as_ref()
136                .map_or("image/png", ImageMediaType::to_mime_type);
137            format!("data:{mime};base64,{b}")
138        }
139    }
140}
141
142#[must_use]
143pub fn arguments_to_string(v: &Value) -> String {
144    match v {
145        Value::String(s) => s.clone(),
146        _ => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_owned()),
147    }
148}
149
150#[must_use]
151pub fn to_openai_tools(tools: &[ToolDefinition]) -> Vec<Value> {
152    tools
153        .iter()
154        .map(|t| {
155            json!({
156                "type": "function",
157                "function": {
158                    "name": t.name,
159                    "description": t.description,
160                    "parameters": t.parameters,
161                }
162            })
163        })
164        .collect()
165}
166
167/// Classify an HTTP failure into the protocol's retry classes.
168#[must_use]
169pub fn classify_http_status(status: u16, body: &str) -> infinity_provider_protocol::ErrorClass {
170    use infinity_provider_protocol::ErrorClass as C;
171    if status == 429 {
172        return C::Throttled;
173    }
174    if status == 529 || (500..600).contains(&status) {
175        return C::Transient;
176    }
177    if status == 408 || status == 504 {
178        return C::Transient;
179    }
180    if status == 400 || status == 413 || status == 422 {
181        let msg = body.to_ascii_lowercase();
182        if msg.contains("context")
183            || msg.contains("too long")
184            || msg.contains("too large")
185            || msg.contains("token limit")
186            || msg.contains("max_tokens")
187            || msg.contains("input length")
188        {
189            return C::ContextOverflow;
190        }
191        // Some gateways report throttling as 400 with a rate-limit message.
192        if msg.contains("rate limit")
193            || msg.contains("too many requests")
194            || msg.contains("try again")
195        {
196            return C::Throttled;
197        }
198        return C::Fatal;
199    }
200    C::Fatal
201}
202
203/// Extract a human-readable message from a JSON error body.
204#[must_use]
205pub fn error_message_from_body(body: &str) -> String {
206    if let Ok(v) = serde_json::from_str::<Value>(body) {
207        if let Some(m) = v.pointer("/error/message").and_then(Value::as_str) {
208            return m.to_owned();
209        }
210        if let Some(m) = v.pointer("/error").and_then(Value::as_str) {
211            return m.to_owned();
212        }
213        if let Some(m) = v.pointer("/message").and_then(Value::as_str) {
214            return m.to_owned();
215        }
216    }
217    body.trim().chars().take(500).collect()
218}