Skip to main content

codex_relay/
translate.rs

1use serde_json::{json, Value};
2
3use crate::{session::SessionStore, types::*};
4
5/// Convert a Responses API request + prior history into a Chat Completions request.
6pub fn to_chat_request(req: &ResponsesRequest, history: Vec<ChatMessage>, sessions: &SessionStore) -> ChatRequest {
7    let mut messages = history;
8
9    // Prefer `instructions` (Codex CLI) over `system` (other clients).
10    let system_text = req.instructions.as_ref().or(req.system.as_ref());
11    if let Some(system) = system_text {
12        if messages.is_empty() || messages[0].role != "system" {
13            messages.insert(
14                0,
15                ChatMessage {
16                    role: "system".into(),
17                    content: Some(system.clone()),
18                    reasoning_content: None,
19                    tool_calls: None,
20                    tool_call_id: None,
21                    name: None,
22                },
23            );
24        }
25    }
26
27    // Append new input, mapping Responses API roles to Chat Completions roles.
28    match &req.input {
29        ResponsesInput::Text(text) => {
30            messages.push(ChatMessage {
31                role: "user".into(),
32                content: Some(text.clone()),
33                reasoning_content: None,
34                tool_calls: None,
35                tool_call_id: None,
36                name: None,
37            });
38        }
39        ResponsesInput::Messages(items) => {
40            // Process items with index so we can group consecutive function_call
41            // entries into a single assistant message. Providers require all tool
42            // calls from one turn to live in one message with a tool_calls array.
43            let mut i = 0;
44            while i < items.len() {
45                let item = &items[i];
46                let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
47
48                if item_type == "function_call" {
49                    // Collect this and all immediately following function_call items
50                    // into one assistant message with multiple tool_calls entries.
51                    let mut grouped: Vec<Value> = Vec::new();
52                    let mut reasoning_content: Option<String> = None;
53
54                    while i < items.len() {
55                        let cur = &items[i];
56                        if cur.get("type").and_then(|v| v.as_str()).unwrap_or("") != "function_call" {
57                            break;
58                        }
59                        let call_id = cur.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
60                        let name    = cur.get("name").and_then(|v| v.as_str()).unwrap_or("");
61                        let args    = cur.get("arguments").and_then(|v| v.as_str()).unwrap_or("{}");
62                        if reasoning_content.is_none() {
63                            reasoning_content = sessions.get_reasoning(call_id);
64                        }
65                        grouped.push(json!({
66                            "id": call_id,
67                            "type": "function",
68                            "function": { "name": name, "arguments": args }
69                        }));
70                        i += 1;
71                    }
72
73                    let mut msg = ChatMessage {
74                        role: "assistant".into(),
75                        content: None,
76                        reasoning_content,
77                        tool_calls: Some(grouped),
78                        tool_call_id: None,
79                        name: None,
80                    };
81                    // Fallback: try turn-level fingerprint if call_id lookup missed
82                    if msg.reasoning_content.is_none() {
83                        msg.reasoning_content = sessions.get_turn_reasoning(&messages, &msg);
84                    }
85                    messages.push(msg);
86                } else {
87                    match item_type {
88                        "function_call_output" => {
89                            let call_id = item.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
90                            let output  = item.get("output").and_then(|v| v.as_str()).unwrap_or("");
91                            messages.push(ChatMessage {
92                                role: "tool".into(),
93                                content: Some(output.to_string()),
94                                reasoning_content: None,
95                                tool_calls: None,
96                                tool_call_id: Some(call_id.to_string()),
97                                name: None,
98                            });
99                        }
100                        _ => {
101                            // Regular user/assistant/developer message
102                            let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("user");
103                            let role = match role {
104                                "developer" => "system",
105                                other => other,
106                            }
107                            .to_string();
108                            let content = value_to_text(item.get("content"));
109                            let mut msg = ChatMessage {
110                                role,
111                                content: Some(content),
112                                reasoning_content: None,
113                                tool_calls: None,
114                                tool_call_id: None,
115                                name: None,
116                            };
117                            // For assistant messages, try to recover reasoning_content
118                            // from the turn-level index (needed for thinking models like
119                            // DeepSeek that require reasoning_content to be passed back).
120                            if msg.role == "assistant" {
121                                msg.reasoning_content = sessions.get_turn_reasoning(&messages, &msg);
122                            }
123                            messages.push(msg);
124                        }
125                    }
126                    i += 1;
127                }
128            }
129        }
130    }
131
132    ChatRequest {
133        model: req.model.clone(),
134        messages,
135        // Keep only `function` tools; providers like DeepSeek don't accept
136        // OpenAI-proprietary built-ins (web_search, computer, file_search, …).
137        tools: req.tools.iter()
138            .filter(|t| t.get("type").and_then(Value::as_str) == Some("function"))
139            .map(convert_tool)
140            .collect(),
141        temperature: req.temperature,
142        max_tokens: req.max_output_tokens,
143        stream: req.stream,
144    }
145}
146
147/// Responses API tool format → Chat Completions tool format.
148///
149/// Responses API (flat):
150///   {"type":"function","name":"foo","description":"...","parameters":{...},"strict":false}
151///
152/// Chat Completions (nested):
153///   {"type":"function","function":{"name":"foo","description":"...","parameters":{...}}}
154fn convert_tool(tool: &Value) -> Value {
155    let Some(obj) = tool.as_object() else {
156        return tool.clone();
157    };
158    // Already in Chat Completions format if it has a "function" sub-object.
159    if obj.contains_key("function") {
160        return tool.clone();
161    }
162    // Convert from Responses API flat format.
163    if obj.get("type").and_then(Value::as_str) == Some("function") {
164        let mut func = serde_json::Map::new();
165        if let Some(v) = obj.get("name") { func.insert("name".into(), v.clone()); }
166        if let Some(v) = obj.get("description") { func.insert("description".into(), v.clone()); }
167        if let Some(v) = obj.get("parameters") { func.insert("parameters".into(), v.clone()); }
168        if let Some(v) = obj.get("strict") { func.insert("strict".into(), v.clone()); }
169        return json!({"type": "function", "function": func});
170    }
171    tool.clone()
172}
173
174/// Convert a Chat Completions response into a Responses API response.
175pub fn from_chat_response(
176    id: String,
177    model: &str,
178    chat: ChatResponse,
179) -> (ResponsesResponse, Vec<ChatMessage>) {
180    let choice = chat.choices.into_iter().next().unwrap_or_else(|| ChatChoice {
181        message: ChatMessage {
182            role: "assistant".into(),
183            content: Some(String::new()),
184            reasoning_content: None,
185            tool_calls: None,
186            tool_call_id: None,
187            name: None,
188        },
189    });
190
191    let text = choice.message.content.clone().unwrap_or_default();
192    let usage = chat.usage.unwrap_or(ChatUsage {
193        prompt_tokens: 0,
194        completion_tokens: 0,
195        total_tokens: 0,
196    });
197
198    let response = ResponsesResponse {
199        id,
200        object: "response",
201        model: model.to_string(),
202        output: vec![ResponsesOutputItem {
203            kind: "message".into(),
204            role: "assistant".into(),
205            content: vec![ContentPart {
206                kind: "output_text".into(),
207                text: Some(text),
208            }],
209        }],
210        usage: ResponsesUsage {
211            input_tokens: usage.prompt_tokens,
212            output_tokens: usage.completion_tokens,
213            total_tokens: usage.total_tokens,
214        },
215    };
216
217    (response, vec![choice.message])
218}
219
220/// Collapse a Responses API content value (string or parts array) to plain text.
221fn value_to_text(v: Option<&Value>) -> String {
222    match v {
223        None => String::new(),
224        Some(Value::String(s)) => s.clone(),
225        Some(Value::Array(parts)) => parts
226            .iter()
227            .filter_map(|p| p.get("text").and_then(|t| t.as_str()))
228            .collect::<Vec<_>>()
229            .join(""),
230        Some(other) => other.to_string(),
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use serde_json::json;
238
239    fn base_req(input: ResponsesInput) -> ResponsesRequest {
240        ResponsesRequest {
241            model: "test".into(),
242            input,
243            previous_response_id: None,
244            tools: vec![],
245            stream: false,
246            temperature: None,
247            max_output_tokens: None,
248            system: None,
249            instructions: None,
250        }
251    }
252
253    #[test]
254    fn test_text_input_becomes_user_message() {
255        let sessions = SessionStore::new();
256        let req = base_req(ResponsesInput::Text("hello".into()));
257        let chat = to_chat_request(&req, vec![], &sessions);
258        assert_eq!(chat.messages.len(), 1);
259        assert_eq!(chat.messages[0].role, "user");
260        assert_eq!(chat.messages[0].content.as_deref(), Some("hello"));
261    }
262
263    #[test]
264    fn test_system_prompt_from_instructions() {
265        let sessions = SessionStore::new();
266        let mut req = base_req(ResponsesInput::Text("hi".into()));
267        req.instructions = Some("be helpful".into());
268        let chat = to_chat_request(&req, vec![], &sessions);
269        assert_eq!(chat.messages[0].role, "system");
270        assert_eq!(chat.messages[0].content.as_deref(), Some("be helpful"));
271    }
272
273    #[test]
274    fn test_developer_role_mapped_to_system() {
275        let sessions = SessionStore::new();
276        let req = base_req(ResponsesInput::Messages(vec![
277            json!({"type": "message", "role": "developer", "content": "secret instructions"}),
278        ]));
279        let chat = to_chat_request(&req, vec![], &sessions);
280        assert_eq!(chat.messages[0].role, "system");
281        assert_eq!(chat.messages[0].content.as_deref(), Some("secret instructions"));
282    }
283
284    #[test]
285    fn test_function_call_grouping() {
286        let sessions = SessionStore::new();
287        let req = base_req(ResponsesInput::Messages(vec![
288            json!({"type": "function_call", "call_id": "c1", "name": "fn_a", "arguments": "{}"}),
289            json!({"type": "function_call", "call_id": "c2", "name": "fn_b", "arguments": "{}"}),
290        ]));
291        let chat = to_chat_request(&req, vec![], &sessions);
292        assert_eq!(chat.messages.len(), 1);
293        assert_eq!(chat.messages[0].role, "assistant");
294        let calls = chat.messages[0].tool_calls.as_ref().unwrap();
295        assert_eq!(calls.len(), 2);
296        assert_eq!(calls[0]["id"], "c1");
297        assert_eq!(calls[1]["id"], "c2");
298    }
299
300    #[test]
301    fn test_function_call_output_becomes_tool_message() {
302        let sessions = SessionStore::new();
303        let req = base_req(ResponsesInput::Messages(vec![
304            json!({"type": "function_call_output", "call_id": "c1", "output": "result"}),
305        ]));
306        let chat = to_chat_request(&req, vec![], &sessions);
307        assert_eq!(chat.messages[0].role, "tool");
308        assert_eq!(chat.messages[0].content.as_deref(), Some("result"));
309        assert_eq!(chat.messages[0].tool_call_id.as_deref(), Some("c1"));
310    }
311
312    #[test]
313    fn test_convert_tool_flat_to_nested() {
314        let flat = json!({
315            "type": "function",
316            "name": "my_fn",
317            "description": "does stuff",
318            "parameters": {"type": "object"}
319        });
320        let nested = convert_tool(&flat);
321        assert_eq!(nested["type"], "function");
322        assert_eq!(nested["function"]["name"], "my_fn");
323        assert_eq!(nested["function"]["description"], "does stuff");
324    }
325
326    #[test]
327    fn test_convert_tool_already_nested() {
328        let already = json!({
329            "type": "function",
330            "function": {"name": "my_fn", "description": "does stuff"}
331        });
332        let result = convert_tool(&already);
333        assert_eq!(result, already);
334    }
335
336    #[test]
337    fn test_value_to_text_string() {
338        let sessions = SessionStore::new();
339        let req = base_req(ResponsesInput::Messages(vec![
340            json!({"type": "message", "role": "user", "content": "plain text"}),
341        ]));
342        let chat = to_chat_request(&req, vec![], &sessions);
343        assert_eq!(chat.messages[0].content.as_deref(), Some("plain text"));
344    }
345
346    #[test]
347    fn test_value_to_text_parts_array() {
348        let sessions = SessionStore::new();
349        let req = base_req(ResponsesInput::Messages(vec![
350            json!({"type": "message", "role": "user", "content": [
351                {"type": "input_text", "text": "hello "},
352                {"type": "input_text", "text": "world"}
353            ]}),
354        ]));
355        let chat = to_chat_request(&req, vec![], &sessions);
356        assert_eq!(chat.messages[0].content.as_deref(), Some("hello world"));
357    }
358}