Skip to main content

synaps_cli/runtime/openai/
translate.rs

1//! Anthropic ↔ OpenAI translation layer.
2
3use super::types::{ChatMessage, FunctionCall, FunctionDefinition, OaiEvent, ToolCall, ToolDefinition};
4use crate::runtime::types::{LlmEvent, SessionEvent, StreamEvent};
5use serde_json::{json, Value};
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Default)]
9pub struct ToolNameMap {
10    original_to_oai: HashMap<String, String>,
11    oai_to_original: HashMap<String, String>,
12}
13
14impl ToolNameMap {
15    pub fn to_oai<'a>(&'a self, name: &'a str) -> &'a str {
16        self.original_to_oai.get(name).map(String::as_str).unwrap_or(name)
17    }
18
19    pub fn to_original<'a>(&'a self, name: &'a str) -> &'a str {
20        self.oai_to_original.get(name).map(String::as_str).unwrap_or(name)
21    }
22
23    fn insert(&mut self, original: &str, oai: &str) {
24        if original != oai {
25            self.original_to_oai.insert(original.to_string(), oai.to_string());
26            self.oai_to_original.insert(oai.to_string(), original.to_string());
27        }
28    }
29}
30
31/// OpenAI function names must match `^[a-zA-Z0-9_-]+$`. Synaps/MCP names may
32/// contain namespace separators like `:` or `.`, so sanitize only for the
33/// OpenAI wire format and map back before tool execution.
34fn sanitize_oai_tool_name(name: &str) -> String {
35    let mut out = String::with_capacity(name.len());
36    for ch in name.chars() {
37        if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
38            out.push(ch);
39        } else {
40            out.push('_');
41        }
42    }
43    if out.is_empty() {
44        "tool".to_string()
45    } else {
46        if out.len() > 128 {
47            out.truncate(128);
48        }
49        out
50    }
51}
52
53/// Convert Anthropic tool schema entries to OpenAI ToolDefinitions.
54///
55/// Anthropic shape: `{"name", "description", "input_schema", optional cache_control}`.
56/// OpenAI shape:    `{"type": "function", "function": {"name", "description", "parameters"}}`.
57pub fn tools_to_oai(schema: &[Value]) -> (Vec<ToolDefinition>, ToolNameMap) {
58    let mut name_map = ToolNameMap::default();
59    let mut used_names: HashMap<String, String> = HashMap::new();
60
61    let tools = schema
62        .iter()
63        .filter_map(|t| {
64            let name = t.get("name")?.as_str()?.to_string();
65            // Skip empty names and internal-only tools
66            if name.is_empty() || name == "respond" || name == "send_channel" || name == "watcher_exit" {
67                return None;
68            }
69            let mut oai_name = sanitize_oai_tool_name(&name);
70            if let Some(existing) = used_names.get(&oai_name) {
71                if existing != &name {
72                    // Truncate base to leave room for suffix (e.g. "_99" = 3 chars)
73                    let max_base = 128_usize.saturating_sub(4);
74                    let base = if oai_name.len() > max_base {
75                        oai_name[..max_base].to_string()
76                    } else {
77                        oai_name.clone()
78                    };
79                    let mut suffix = 2;
80                    while used_names.contains_key(&oai_name) {
81                        oai_name = format!("{base}_{suffix}");
82                        suffix += 1;
83                    }
84                }
85            }
86            used_names.insert(oai_name.clone(), name.clone());
87            name_map.insert(&name, &oai_name);
88
89            let description = t
90                .get("description")
91                .and_then(|d| d.as_str())
92                .map(|s| s.to_string());
93            let parameters = t
94                .get("input_schema")
95                .cloned()
96                .unwrap_or_else(|| json!({"type": "object", "properties": {}}));
97            Some(ToolDefinition {
98                kind: "function".to_string(),
99                function: FunctionDefinition {
100                    name: oai_name,
101                    description,
102                    parameters,
103                },
104            })
105        })
106        .collect();
107
108    (tools, name_map)
109}
110
111/// Convert Anthropic-shaped message list + optional system prompt into
112/// an OpenAI ChatMessage stream.
113pub fn messages_to_oai(
114    anthropic_messages: &[Value],
115    system_prompt: &Option<String>,
116    name_map: &ToolNameMap,
117) -> Vec<ChatMessage> {
118    let mut out: Vec<ChatMessage> = Vec::new();
119
120    if let Some(sp) = system_prompt.as_ref() {
121        if !sp.is_empty() {
122            out.push(ChatMessage::system(sp.clone()));
123        }
124    }
125
126    // Build a map of tool_use_id → tool_name from assistant messages
127    // so we can populate the name field on tool result messages.
128    let mut tool_name_map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
129    for msg in anthropic_messages {
130        if msg.get("role").and_then(|r| r.as_str()) == Some("assistant") {
131            if let Some(Value::Array(blocks)) = msg.get("content") {
132                for block in blocks {
133                    if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
134                        if let (Some(id), Some(name)) = (
135                            block.get("id").and_then(|v| v.as_str()),
136                            block.get("name").and_then(|v| v.as_str()),
137                        ) {
138                            tool_name_map.insert(id.to_string(), name_map.to_oai(name).to_string());
139                        }
140                    }
141                }
142            }
143        }
144    }
145
146    for msg in anthropic_messages {
147        let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
148        let content = msg.get("content");
149
150        match role {
151            "user" => {
152                match content {
153                    Some(Value::String(s)) => out.push(ChatMessage::user(s.clone())),
154                    Some(Value::Array(blocks)) => {
155                        let mut text_buf = String::new();
156                        for block in blocks {
157                            let btype = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
158                            match btype {
159                                "text" => {
160                                    if let Some(t) = block.get("text").and_then(|t| t.as_str()) {
161                                        text_buf.push_str(t);
162                                    }
163                                }
164                                "tool_result" => {
165                                    // Flush pending text first
166                                    if !text_buf.is_empty() {
167                                        out.push(ChatMessage::user(std::mem::take(&mut text_buf)));
168                                    }
169                                    let tool_id = block
170                                        .get("tool_use_id")
171                                        .and_then(|v| v.as_str())
172                                        .unwrap_or("")
173                                        .to_string();
174                                    let result_content = match block.get("content") {
175                                        Some(Value::String(s)) => s.clone(),
176                                        Some(Value::Array(arr)) => arr
177                                            .iter()
178                                            .filter_map(|b| {
179                                                b.get("text").and_then(|t| t.as_str()).map(String::from)
180                                            })
181                                            .collect::<Vec<_>>()
182                                            .join(""),
183                                        Some(other) => other.to_string(),
184                                        None => String::new(),
185                                    };
186                                    let tool_name = tool_name_map.get(&tool_id).cloned().unwrap_or_default();
187                                    out.push(ChatMessage::tool_result(tool_id, tool_name, result_content));
188                                }
189                                _ => {}
190                            }
191                        }
192                        if !text_buf.is_empty() {
193                            out.push(ChatMessage::user(text_buf));
194                        }
195                    }
196                    _ => {}
197                }
198            }
199            "assistant" => {
200                let mut text_buf = String::new();
201                let mut tool_calls: Vec<ToolCall> = Vec::new();
202
203                if let Some(Value::Array(blocks)) = content {
204                    for block in blocks {
205                        let btype = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
206                        match btype {
207                            "text" => {
208                                if let Some(t) = block.get("text").and_then(|t| t.as_str()) {
209                                    text_buf.push_str(t);
210                                }
211                            }
212                            "tool_use" => {
213                                let id = block
214                                    .get("id")
215                                    .and_then(|v| v.as_str())
216                                    .unwrap_or("")
217                                    .to_string();
218                                let name = block
219                                    .get("name")
220                                    .and_then(|v| v.as_str())
221                                    .map(|n| name_map.to_oai(n).to_string())
222                                    .unwrap_or_default();
223                                let arguments = block
224                                    .get("input")
225                                    .map(|v| v.to_string())
226                                    .unwrap_or_else(|| "{}".to_string());
227                                tool_calls.push(ToolCall {
228                                    id,
229                                    kind: "function".to_string(),
230                                    function: FunctionCall { name, arguments },
231                                });
232                            }
233                            "thinking" => {
234                                // Not representable in OpenAI — drop.
235                            }
236                            _ => {}
237                        }
238                    }
239                } else if let Some(Value::String(s)) = content {
240                    text_buf.push_str(s);
241                }
242
243                let has_text = !text_buf.is_empty();
244                let has_tools = !tool_calls.is_empty();
245                match (has_text, has_tools) {
246                    (true, false) => out.push(ChatMessage::assistant(text_buf)),
247                    (false, true) => out.push(ChatMessage::assistant_tool_calls(tool_calls)),
248                    (true, true) => out.push(ChatMessage {
249                        role: "assistant".into(),
250                        content: Some(text_buf),
251                        tool_calls: Some(tool_calls),
252                        tool_call_id: None,
253                        name: None,
254                    }),
255                    (false, false) => {}
256                }
257            }
258            _ => {}
259        }
260    }
261
262    // Fixup: OpenAI requires that a 'tool' message is never followed
263    // directly by a 'user' message. Insert an empty assistant message if needed.
264    let mut fixed = Vec::with_capacity(out.len());
265    for msg in out {
266        if msg.role == "user" && fixed.last().map(|m: &ChatMessage| m.role == "tool").unwrap_or(false) {
267            fixed.push(ChatMessage::assistant(" ".to_string()));
268        }
269        fixed.push(msg);
270    }
271    fixed
272}
273
274/// Translate an [`OaiEvent`] into a synaps [`StreamEvent`].
275///
276/// Returns `None` for events that are handled at a higher level
277/// (`Done`, `ToolCallsComplete`, `RoleStart`) or purely informational
278/// (`Warning` — logged via tracing).
279pub fn oai_event_to_llm(event: &OaiEvent) -> Option<StreamEvent> {
280    match event {
281        OaiEvent::TextDelta(t) => Some(StreamEvent::Llm(LlmEvent::Text(t.clone()))),
282        OaiEvent::ToolCallStart { name, id, .. } => {
283            Some(StreamEvent::Llm(LlmEvent::ToolUseStart {
284                tool_name: name.clone(),
285                tool_id: id.clone(),
286            }))
287        }
288        OaiEvent::ToolCallArgumentsDelta { delta, id, .. } => {
289            Some(StreamEvent::Llm(LlmEvent::ToolUseDelta {
290                tool_id: id.clone(),
291                delta: delta.clone(),
292            }))
293        }
294        OaiEvent::Usage { prompt_tokens, completion_tokens, cached_tokens } => {
295            Some(StreamEvent::Session(SessionEvent::Usage {
296                input_tokens: *prompt_tokens as u64,
297                output_tokens: *completion_tokens as u64,
298                cache_read_input_tokens: *cached_tokens as u64,
299                cache_creation_input_tokens: 0,
300                model: None,
301            }))
302        }
303        OaiEvent::Warning(s) => {
304            tracing::warn!("openai stream warning: {}", s);
305            None
306        }
307        OaiEvent::RoleStart(_) | OaiEvent::Done | OaiEvent::ToolCallsComplete { .. } => None,
308    }
309}
310
311/// Convert an OpenAI tool-call list into Anthropic-shaped `tool_use` content blocks.
312pub fn tool_calls_to_content_blocks(calls: &[ToolCall], name_map: &ToolNameMap) -> Vec<Value> {
313    calls
314        .iter()
315        .map(|c| {
316            let input: Value = serde_json::from_str(&c.function.arguments).unwrap_or_else(|_| json!({}));
317            json!({
318                "type": "tool_use",
319                "id": c.id,
320                "name": name_map.to_original(&c.function.name),
321                "input": input,
322            })
323        })
324        .collect()
325}