synaps_cli/runtime/openai/
translate.rs1use 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
31fn 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
53pub 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 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 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
111pub 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 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 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 }
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 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
274pub 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
311pub 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}