1use super::types::{
4 ChatMessage, FunctionCall, FunctionDefinition, OaiEvent, ToolCall, ToolDefinition,
5};
6use crate::runtime::types::{LlmEvent, SessionEvent, StreamEvent};
7use serde_json::{json, Value};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, Default)]
11pub struct ToolNameMap {
12 original_to_oai: HashMap<String, String>,
13 oai_to_original: HashMap<String, String>,
14}
15
16impl ToolNameMap {
17 pub fn to_oai<'a>(&'a self, name: &'a str) -> &'a str {
18 self.original_to_oai
19 .get(name)
20 .map(String::as_str)
21 .unwrap_or(name)
22 }
23
24 pub fn to_original<'a>(&'a self, name: &'a str) -> &'a str {
25 self.oai_to_original
26 .get(name)
27 .map(String::as_str)
28 .unwrap_or(name)
29 }
30
31 pub fn renamed_originals(&self) -> Vec<&str> {
36 let mut names: Vec<&str> = self.original_to_oai.keys().map(String::as_str).collect();
37 names.sort_unstable();
38 names
39 }
40
41 fn insert(&mut self, original: &str, oai: &str) {
42 if original != oai {
43 self.original_to_oai
44 .insert(original.to_string(), oai.to_string());
45 self.oai_to_original
46 .insert(oai.to_string(), original.to_string());
47 }
48 }
49}
50
51fn sanitize_oai_tool_name(name: &str) -> String {
55 let mut out = String::with_capacity(name.len());
56 for ch in name.chars() {
57 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
58 out.push(ch);
59 } else {
60 out.push('_');
61 }
62 }
63 if out.is_empty() {
64 "tool".to_string()
65 } else {
66 if out.len() > 128 {
67 out.truncate(128);
68 }
69 out
70 }
71}
72
73fn sanitize_oai_parameters(schema: &mut Value) {
80 let Some(obj) = schema.as_object_mut() else {
81 return;
82 };
83
84 let is_array_type = match obj.get("type") {
85 Some(Value::String(t)) => t == "array",
86 Some(Value::Array(ts)) => ts.iter().any(|t| t.as_str() == Some("array")),
87 _ => false,
88 };
89 if is_array_type && !obj.contains_key("items") {
90 obj.insert("items".into(), json!({}));
91 }
92
93 for key in [
94 "items",
95 "additionalProperties",
96 "contains",
97 "propertyNames",
98 "not",
99 "if",
100 "then",
101 "else",
102 ] {
103 if let Some(sub) = obj.get_mut(key) {
104 sanitize_oai_parameters(sub);
105 }
106 }
107 for key in ["properties", "patternProperties", "$defs", "definitions"] {
108 if let Some(Value::Object(map)) = obj.get_mut(key) {
109 for sub in map.values_mut() {
110 sanitize_oai_parameters(sub);
111 }
112 }
113 }
114 for key in ["anyOf", "oneOf", "allOf", "prefixItems"] {
115 if let Some(Value::Array(list)) = obj.get_mut(key) {
116 for sub in list.iter_mut() {
117 sanitize_oai_parameters(sub);
118 }
119 }
120 }
121}
122
123pub fn tools_to_oai(schema: &[Value]) -> (Vec<ToolDefinition>, ToolNameMap) {
128 let mut name_map = ToolNameMap::default();
129 let mut used_names: HashMap<String, String> = HashMap::new();
130
131 let tools = schema
132 .iter()
133 .filter_map(|t| {
134 let name = t.get("name")?.as_str()?.to_string();
135 if name.is_empty()
137 || name == "respond"
138 || name == "send_channel"
139 || name == "watcher_exit"
140 {
141 return None;
142 }
143 let mut oai_name = sanitize_oai_tool_name(&name);
144 if let Some(existing) = used_names.get(&oai_name) {
145 if existing != &name {
146 let max_base = 128_usize.saturating_sub(4);
148 let base = if oai_name.len() > max_base {
149 oai_name[..max_base].to_string()
150 } else {
151 oai_name.clone()
152 };
153 let mut suffix = 2;
154 while used_names.contains_key(&oai_name) {
155 oai_name = format!("{base}_{suffix}");
156 suffix += 1;
157 }
158 }
159 }
160 used_names.insert(oai_name.clone(), name.clone());
161 name_map.insert(&name, &oai_name);
162
163 let description = t
164 .get("description")
165 .and_then(|d| d.as_str())
166 .map(|s| s.to_string());
167 let parameters = t
168 .get("input_schema")
169 .cloned()
170 .map(|mut schema| {
171 sanitize_oai_parameters(&mut schema);
172 schema
173 })
174 .unwrap_or_else(|| json!({"type": "object", "properties": {}}));
175 Some(ToolDefinition {
176 kind: "function".to_string(),
177 function: FunctionDefinition {
178 name: oai_name,
179 description,
180 parameters,
181 },
182 })
183 })
184 .collect();
185
186 (tools, name_map)
187}
188
189pub fn messages_to_oai(
192 anthropic_messages: &[crate::SharedMessage],
193 system_prompt: &Option<String>,
194 name_map: &ToolNameMap,
195) -> Vec<ChatMessage> {
196 let mut out: Vec<ChatMessage> = Vec::new();
197
198 if let Some(sp) = system_prompt.as_ref() {
199 if !sp.is_empty() {
200 out.push(ChatMessage::system(sp.clone()));
201 }
202 }
203
204 let mut tool_name_map: std::collections::HashMap<String, String> =
207 std::collections::HashMap::new();
208 for msg in anthropic_messages {
209 if msg.get("role").and_then(|r| r.as_str()) == Some("assistant") {
210 if let Some(Value::Array(blocks)) = msg.get("content") {
211 for block in blocks {
212 if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
213 if let (Some(id), Some(name)) = (
214 block.get("id").and_then(|v| v.as_str()),
215 block.get("name").and_then(|v| v.as_str()),
216 ) {
217 tool_name_map.insert(id.to_string(), name_map.to_oai(name).to_string());
218 }
219 }
220 }
221 }
222 }
223 }
224
225 for msg in anthropic_messages {
226 let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
227 let content = msg.get("content");
228
229 match role {
230 "user" => {
231 match content {
232 Some(Value::String(s)) => out.push(ChatMessage::user(s.clone())),
233 Some(Value::Array(blocks)) => {
234 let mut text_buf = String::new();
235 for block in blocks {
236 let btype = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
237 match btype {
238 "text" => {
239 if let Some(t) = block.get("text").and_then(|t| t.as_str()) {
240 text_buf.push_str(t);
241 }
242 }
243 "tool_result" => {
244 if !text_buf.is_empty() {
246 out.push(ChatMessage::user(std::mem::take(&mut text_buf)));
247 }
248 let tool_id = block
249 .get("tool_use_id")
250 .and_then(|v| v.as_str())
251 .unwrap_or("")
252 .to_string();
253 let result_content = match block.get("content") {
254 Some(Value::String(s)) => s.clone(),
255 Some(Value::Array(arr)) => arr
256 .iter()
257 .filter_map(|b| {
258 if b["type"] == "image" {
259 let mt = b["source"]["media_type"]
260 .as_str()
261 .unwrap_or("image");
262 return Some(format!(
263 "\n[image omitted: {mt} — this provider does not accept images in tool results]"
264 ));
265 }
266 b.get("text")
267 .and_then(|t| t.as_str())
268 .map(String::from)
269 })
270 .collect::<Vec<_>>()
271 .join(""),
272 Some(other) => other.to_string(),
273 None => String::new(),
274 };
275 let tool_name =
276 tool_name_map.get(&tool_id).cloned().unwrap_or_default();
277 out.push(ChatMessage::tool_result(
278 tool_id,
279 tool_name,
280 result_content,
281 ));
282 }
283 _ => {}
284 }
285 }
286 if !text_buf.is_empty() {
287 out.push(ChatMessage::user(text_buf));
288 }
289 }
290 _ => {}
291 }
292 }
293 "assistant" => {
294 let mut text_buf = String::new();
295 let mut tool_calls: Vec<ToolCall> = Vec::new();
296
297 if let Some(Value::Array(blocks)) = content {
298 for block in blocks {
299 let btype = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
300 match btype {
301 "text" => {
302 if let Some(t) = block.get("text").and_then(|t| t.as_str()) {
303 text_buf.push_str(t);
304 }
305 }
306 "tool_use" => {
307 let id = block
308 .get("id")
309 .and_then(|v| v.as_str())
310 .unwrap_or("")
311 .to_string();
312 let name = block
313 .get("name")
314 .and_then(|v| v.as_str())
315 .map(|n| name_map.to_oai(n).to_string())
316 .unwrap_or_default();
317 let arguments = block
318 .get("input")
319 .map(|v| v.to_string())
320 .unwrap_or_else(|| "{}".to_string());
321 tool_calls.push(ToolCall {
322 id,
323 kind: "function".to_string(),
324 function: FunctionCall { name, arguments },
325 });
326 }
327 "thinking" => {
328 }
330 _ => {}
331 }
332 }
333 } else if let Some(Value::String(s)) = content {
334 text_buf.push_str(s);
335 }
336
337 let has_text = !text_buf.is_empty();
338 let has_tools = !tool_calls.is_empty();
339 match (has_text, has_tools) {
340 (true, false) => out.push(ChatMessage::assistant(text_buf)),
341 (false, true) => out.push(ChatMessage::assistant_tool_calls(tool_calls)),
342 (true, true) => out.push(ChatMessage {
343 role: "assistant".into(),
344 content: Some(text_buf),
345 tool_calls: Some(tool_calls),
346 tool_call_id: None,
347 name: None,
348 }),
349 (false, false) => {}
350 }
351 }
352 _ => {}
353 }
354 }
355
356 let mut fixed = Vec::with_capacity(out.len());
359 for msg in out {
360 if msg.role == "user"
361 && fixed
362 .last()
363 .map(|m: &ChatMessage| m.role == "tool")
364 .unwrap_or(false)
365 {
366 fixed.push(ChatMessage::assistant(" ".to_string()));
367 }
368 fixed.push(msg);
369 }
370 fixed
371}
372
373pub fn oai_event_to_llm(event: &OaiEvent) -> Option<StreamEvent> {
379 match event {
380 OaiEvent::TextDelta(t) => Some(StreamEvent::Llm(LlmEvent::Text(t.clone()))),
381 OaiEvent::ToolCallStart { name, id, .. } => {
382 Some(StreamEvent::Llm(LlmEvent::ToolUseStart {
383 tool_name: name.clone(),
384 tool_id: id.clone(),
385 }))
386 }
387 OaiEvent::ToolCallArgumentsDelta { delta, id, .. } => {
388 Some(StreamEvent::Llm(LlmEvent::ToolUseDelta {
389 tool_id: id.clone(),
390 delta: delta.clone(),
391 }))
392 }
393 OaiEvent::Usage {
394 prompt_tokens,
395 completion_tokens,
396 cached_tokens,
397 } => {
398 let cached = (*cached_tokens).min(*prompt_tokens) as u64;
402 Some(StreamEvent::Session(SessionEvent::Usage {
403 input_tokens: *prompt_tokens as u64 - cached,
404 output_tokens: *completion_tokens as u64,
405 cache_read_input_tokens: cached,
406 cache_creation_input_tokens: 0,
407 cache_creation_5m: None,
408 cache_creation_1h: None,
409 model: None,
410 }))
411 }
412 OaiEvent::Warning(s) => {
413 tracing::warn!("openai stream warning: {}", s);
414 None
415 }
416 OaiEvent::RoleStart(_) | OaiEvent::Done | OaiEvent::ToolCallsComplete { .. } => None,
417 }
418}
419
420pub fn tool_calls_to_content_blocks(calls: &[ToolCall], name_map: &ToolNameMap) -> Vec<Value> {
422 calls
423 .iter()
424 .map(|c| {
425 let input: Value =
426 serde_json::from_str(&c.function.arguments).unwrap_or_else(|_| json!({}));
427 json!({
428 "type": "tool_use",
429 "id": c.id,
430 "name": name_map.to_original(&c.function.name),
431 "input": input,
432 })
433 })
434 .collect()
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440
441 #[test]
442 fn image_tool_result_degrades_to_text_with_omission_note() {
443 let b64 = "iVBORw0KGgo".repeat(200);
444 let msgs: Vec<crate::SharedMessage> = vec![
445 std::sync::Arc::new(json!({"role":"user","content":"look"})),
446 std::sync::Arc::new(json!({"role":"assistant","content":[
447 {"type":"tool_use","id":"t1","name":"read","input":{"path":"x.png"}}
448 ]})),
449 std::sync::Arc::new(json!({"role":"user","content":[
450 {"type":"tool_result","tool_use_id":"t1","content":[
451 {"type":"text","text":"Image: x"},
452 {"type":"image","source":{"type":"base64","media_type":"image/png","data":b64}}
453 ]}
454 ]})),
455 ];
456 let out = messages_to_oai(&msgs, &None, &ToolNameMap::default());
457 let tool_msg = out.iter().find(|m| m.role == "tool").expect("tool message");
458 let content = tool_msg.content.as_deref().unwrap();
459 assert_eq!(
460 content,
461 "Image: x\n[image omitted: image/png — this provider does not accept images in tool results]"
462 );
463 assert!(!content.contains("iVBORw0KGgo"));
464 }
465
466 #[test]
473 fn array_property_missing_items_is_backfilled_for_oai_wire() {
474 let schema = vec![json!({
475 "name": "ext__lsrf-manager__managerApi_cloudfrontInvalidate",
476 "description": "Invalidate CloudFront paths",
477 "input_schema": {
478 "type": "object",
479 "properties": {
480 "paths": { "type": "array" },
481 "distribution_id": { "type": "string" }
482 }
483 }
484 })];
485 let (tools, _map) = tools_to_oai(&schema);
486 let params = &tools[0].function.parameters;
487 assert_eq!(
488 params["properties"]["paths"]["items"],
489 json!({}),
490 "array schema without items must be backfilled: {params}"
491 );
492 assert_eq!(
494 params["properties"]["distribution_id"],
495 json!({"type": "string"})
496 );
497 }
498
499 #[test]
500 fn nested_and_composed_array_schemas_are_sanitized() {
501 let schema = vec![json!({
502 "name": "t",
503 "input_schema": {
504 "type": "object",
505 "properties": {
506 "matrix": {
507 "type": "array",
508 "items": { "type": "array" }
509 },
510 "either": {
511 "anyOf": [
512 { "type": "array" },
513 { "type": "string" }
514 ]
515 },
516 "nullable": { "type": ["array", "null"] },
517 "obj": {
518 "type": "object",
519 "additionalProperties": { "type": "array" },
520 "properties": {
521 "inner": { "type": "array" }
522 }
523 }
524 },
525 "$defs": {
526 "aliasList": { "type": "array" }
527 }
528 }
529 })];
530 let (tools, _map) = tools_to_oai(&schema);
531 let p = &tools[0].function.parameters;
532 assert_eq!(p["properties"]["matrix"]["items"]["items"], json!({}));
533 assert_eq!(p["properties"]["either"]["anyOf"][0]["items"], json!({}));
534 assert!(p["properties"]["either"]["anyOf"][1].get("items").is_none());
535 assert_eq!(p["properties"]["nullable"]["items"], json!({}));
536 assert_eq!(
537 p["properties"]["obj"]["additionalProperties"]["items"],
538 json!({})
539 );
540 assert_eq!(
541 p["properties"]["obj"]["properties"]["inner"]["items"],
542 json!({})
543 );
544 assert_eq!(p["$defs"]["aliasList"]["items"], json!({}));
545 }
546
547 #[test]
548 fn array_schema_with_existing_items_is_untouched() {
549 let schema = vec![json!({
550 "name": "t",
551 "input_schema": {
552 "type": "object",
553 "properties": {
554 "ops": { "type": "array", "items": { "type": "object" } }
555 }
556 }
557 })];
558 let (tools, _map) = tools_to_oai(&schema);
559 assert_eq!(
560 tools[0].function.parameters["properties"]["ops"]["items"],
561 json!({"type": "object"})
562 );
563 }
564
565 #[test]
571 fn mcp_tool_names_are_sanitized_for_oai_wire_and_round_trip() {
572 let schema = vec![json!({
573 "name": "ext__lsrf-manager__managerApi.cloudfrontInvalidate",
574 "input_schema": {"type": "object", "properties": {}}
575 })];
576 let (tools, map) = tools_to_oai(&schema);
577 let wire = &tools[0].function.name;
578 assert!(
579 wire.chars()
580 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
581 "wire name must match ^[a-zA-Z0-9_-]+$: {wire}"
582 );
583 assert_eq!(
584 map.to_original(wire),
585 "ext__lsrf-manager__managerApi.cloudfrontInvalidate",
586 "sanitized wire name must map back to the executable tool name"
587 );
588 }
589}