1use super::types::{
4 ChatContentPart, ChatFile, ChatImageUrl, ChatMessage, FunctionCall, FunctionDefinition,
5 OaiEvent, ToolCall, ToolDefinition,
6};
7use crate::runtime::types::{LlmEvent, SessionEvent, StreamEvent};
8use serde_json::{json, Value};
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Default)]
12pub struct ToolNameMap {
13 original_to_oai: HashMap<String, String>,
14 oai_to_original: HashMap<String, String>,
15}
16
17impl ToolNameMap {
18 pub fn to_oai<'a>(&'a self, name: &'a str) -> &'a str {
19 self.original_to_oai
20 .get(name)
21 .map(String::as_str)
22 .unwrap_or(name)
23 }
24
25 pub fn to_original<'a>(&'a self, name: &'a str) -> &'a str {
26 self.oai_to_original
27 .get(name)
28 .map(String::as_str)
29 .unwrap_or(name)
30 }
31
32 pub fn renamed_originals(&self) -> Vec<&str> {
37 let mut names: Vec<&str> = self.original_to_oai.keys().map(String::as_str).collect();
38 names.sort_unstable();
39 names
40 }
41
42 fn insert(&mut self, original: &str, oai: &str) {
43 if original != oai {
44 self.original_to_oai
45 .insert(original.to_string(), oai.to_string());
46 self.oai_to_original
47 .insert(oai.to_string(), original.to_string());
48 }
49 }
50}
51
52fn sanitize_oai_tool_name(name: &str) -> String {
56 let mut out = String::with_capacity(name.len());
57 for ch in name.chars() {
58 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
59 out.push(ch);
60 } else {
61 out.push('_');
62 }
63 }
64 if out.is_empty() {
65 "tool".to_string()
66 } else {
67 if out.len() > 128 {
68 out.truncate(128);
69 }
70 out
71 }
72}
73
74fn sanitize_oai_parameters(schema: &mut Value) {
81 let Some(obj) = schema.as_object_mut() else {
82 return;
83 };
84
85 let is_array_type = match obj.get("type") {
86 Some(Value::String(t)) => t == "array",
87 Some(Value::Array(ts)) => ts.iter().any(|t| t.as_str() == Some("array")),
88 _ => false,
89 };
90 if is_array_type && !obj.contains_key("items") {
91 obj.insert("items".into(), json!({}));
92 }
93
94 for key in [
95 "items",
96 "additionalProperties",
97 "contains",
98 "propertyNames",
99 "not",
100 "if",
101 "then",
102 "else",
103 ] {
104 if let Some(sub) = obj.get_mut(key) {
105 sanitize_oai_parameters(sub);
106 }
107 }
108 for key in ["properties", "patternProperties", "$defs", "definitions"] {
109 if let Some(Value::Object(map)) = obj.get_mut(key) {
110 for sub in map.values_mut() {
111 sanitize_oai_parameters(sub);
112 }
113 }
114 }
115 for key in ["anyOf", "oneOf", "allOf", "prefixItems"] {
116 if let Some(Value::Array(list)) = obj.get_mut(key) {
117 for sub in list.iter_mut() {
118 sanitize_oai_parameters(sub);
119 }
120 }
121 }
122}
123
124pub fn tools_to_oai(schema: &[Value]) -> (Vec<ToolDefinition>, ToolNameMap) {
129 let mut name_map = ToolNameMap::default();
130 let mut used_names: HashMap<String, String> = HashMap::new();
131
132 let tools = schema
133 .iter()
134 .filter_map(|t| {
135 let name = t.get("name")?.as_str()?.to_string();
136 if name.is_empty()
138 || name == "respond"
139 || name == "send_channel"
140 || name == "watcher_exit"
141 {
142 return None;
143 }
144 let mut oai_name = sanitize_oai_tool_name(&name);
145 if let Some(existing) = used_names.get(&oai_name) {
146 if existing != &name {
147 let max_base = 128_usize.saturating_sub(4);
149 let base = if oai_name.len() > max_base {
150 oai_name[..max_base].to_string()
151 } else {
152 oai_name.clone()
153 };
154 let mut suffix = 2;
155 while used_names.contains_key(&oai_name) {
156 oai_name = format!("{base}_{suffix}");
157 suffix += 1;
158 }
159 }
160 }
161 used_names.insert(oai_name.clone(), name.clone());
162 name_map.insert(&name, &oai_name);
163
164 let description = t
165 .get("description")
166 .and_then(|d| d.as_str())
167 .map(|s| s.to_string());
168 let parameters = t
169 .get("input_schema")
170 .cloned()
171 .map(|mut schema| {
172 sanitize_oai_parameters(&mut schema);
173 schema
174 })
175 .unwrap_or_else(|| json!({"type": "object", "properties": {}}));
176 Some(ToolDefinition {
177 kind: "function".to_string(),
178 function: FunctionDefinition {
179 name: oai_name,
180 description,
181 parameters,
182 },
183 })
184 })
185 .collect();
186
187 (tools, name_map)
188}
189
190pub fn tools_to_responses(schema: &[Value]) -> (Vec<Value>, ToolNameMap) {
196 let (tools, names) = tools_to_oai(schema);
197 let tools = tools
198 .into_iter()
199 .map(|tool| {
200 json!({
201 "type": "function",
202 "name": tool.function.name,
203 "description": tool.function.description.unwrap_or_default(),
204 "parameters": tool.function.parameters,
205 "strict": false,
206 })
207 })
208 .collect();
209 (tools, names)
210}
211
212#[derive(Clone, Copy)]
215enum AttachmentOrigin<'a> {
216 User,
217 ToolResult { id: &'a str, name: &'a str },
218}
219
220fn is_attachment(block: &Value) -> bool {
221 matches!(block["type"].as_str(), Some("image" | "document"))
222}
223
224fn has_attachment(content: &Value) -> bool {
225 if let Some(blocks) = content.as_array() {
226 blocks.iter().any(has_attachment)
227 } else {
228 is_attachment(content)
229 || (content["type"] == "tool_result"
230 && content.get("content").is_some_and(has_attachment))
231 }
232}
233
234fn attachment_filename(block: &Value) -> &str {
235 block["title"].as_str().filter(|s| !s.is_empty()).unwrap_or(
236 if block["source"]["media_type"] == "application/pdf" {
237 "attachment.pdf"
238 } else {
239 "attachment.txt"
240 },
241 )
242}
243
244fn attachment_label(block: &Value, origin: AttachmentOrigin<'_>, lifted: bool) -> String {
245 let mut label = match origin {
246 AttachmentOrigin::User => "[attachment origin=user".to_string(),
247 AttachmentOrigin::ToolResult { id, name } => format!(
248 "[attachment origin=tool_result tool_use_id={} tool_name={}",
249 json!(id),
250 json!(name),
251 ),
252 };
253 label.push_str(&format!(
254 " type={} media_type={}",
255 if block["type"] == "image" {
256 "image"
257 } else {
258 "document"
259 },
260 json!(block["source"]["media_type"].as_str().unwrap_or("unknown")),
261 ));
262 if block["type"] == "document" {
263 label.push_str(&format!(" filename={}", json!(attachment_filename(block))));
264 }
265 label.push_str("; lower-authority data");
266 if lifted {
267 label.push_str("; supplied in following user content");
268 }
269 label.push_str("]\n");
270 label
271}
272
273fn attachment_parts(block: &Value, origin: AttachmentOrigin<'_>) -> Vec<ChatContentPart> {
276 let mut parts = vec![ChatContentPart::text(attachment_label(
277 block, origin, false,
278 ))];
279 let source = &block["source"];
280 let part = match (
281 block["type"].as_str(),
282 source["type"].as_str(),
283 source["media_type"].as_str(),
284 source["data"].as_str(),
285 ) {
286 (Some("image"), Some("base64"), Some(media_type), Some(data)) => {
287 ChatContentPart::ImageUrl {
288 image_url: ChatImageUrl {
289 url: format!("data:{media_type};base64,{data}"),
290 detail: None,
291 },
292 }
293 }
294 (Some("document"), Some("text"), Some("text/plain"), Some(text)) => {
295 ChatContentPart::text(text)
296 }
297 (Some("document"), Some("base64"), Some("application/pdf"), Some(data)) => {
298 ChatContentPart::File {
299 file: ChatFile {
300 filename: attachment_filename(block).to_string(),
301 file_data: format!("data:application/pdf;base64,{data}"),
302 },
303 }
304 }
305 _ => ChatContentPart::text("[attachment unavailable: unsupported source representation]\n"),
308 };
309 parts.push(part);
310 parts.push(ChatContentPart::text("[/attachment]\n"));
311 parts
312}
313
314#[derive(Default)]
315struct UserContent {
316 text: String,
317 parts: Vec<ChatContentPart>,
318}
319
320impl UserContent {
321 fn append_parts(&mut self, parts: Vec<ChatContentPart>) {
322 if !self.text.is_empty() {
323 self.parts
324 .push(ChatContentPart::text(std::mem::take(&mut self.text)));
325 }
326 self.parts.extend(parts);
327 }
328
329 fn flush(&mut self, out: &mut Vec<ChatMessage>) {
330 if !self.parts.is_empty() {
331 if !self.text.is_empty() {
332 self.parts
333 .push(ChatContentPart::text(std::mem::take(&mut self.text)));
334 }
335 out.push(ChatMessage::user_parts(std::mem::take(&mut self.parts)));
336 } else if !self.text.is_empty() {
337 out.push(ChatMessage::user(std::mem::take(&mut self.text)));
338 }
339 }
340}
341
342fn tool_result_content(
343 content: Option<&Value>,
344 origin: AttachmentOrigin<'_>,
345 user: &mut UserContent,
346) -> String {
347 fn append(
348 block: &Value,
349 origin: AttachmentOrigin<'_>,
350 text: &mut String,
351 user: &mut UserContent,
352 ) {
353 if is_attachment(block) {
354 text.push('\n');
355 text.push_str(&attachment_label(block, origin, true));
356 user.append_parts(attachment_parts(block, origin));
357 } else if let Some(t) = block.get("text").and_then(Value::as_str) {
358 text.push_str(t);
359 }
360 }
361 match content {
362 Some(Value::String(text)) => text.clone(),
363 Some(Value::Array(blocks)) => {
364 let mut text = String::new();
365 for block in blocks {
366 append(block, origin, &mut text, user);
367 }
368 text
369 }
370 Some(block) if is_attachment(block) => {
371 let mut text = String::new();
372 append(block, origin, &mut text, user);
373 text
374 }
375 Some(other) => other.to_string(),
376 None => String::new(),
377 }
378}
379
380pub fn messages_to_oai(
383 anthropic_messages: &[crate::SharedMessage],
384 system_prompt: &Option<String>,
385 name_map: &ToolNameMap,
386) -> Vec<ChatMessage> {
387 let mut out: Vec<ChatMessage> = Vec::new();
388
389 if let Some(sp) = system_prompt.as_ref() {
390 if !sp.is_empty() {
391 out.push(ChatMessage::system(sp.clone()));
392 }
393 }
394
395 let mut tool_name_map: std::collections::HashMap<String, String> =
398 std::collections::HashMap::new();
399 for msg in anthropic_messages {
400 if msg.get("role").and_then(|r| r.as_str()) == Some("assistant") {
401 if let Some(Value::Array(blocks)) = msg.get("content") {
402 for block in blocks {
403 if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
404 if let (Some(id), Some(name)) = (
405 block.get("id").and_then(|v| v.as_str()),
406 block.get("name").and_then(|v| v.as_str()),
407 ) {
408 tool_name_map.insert(id.to_string(), name_map.to_oai(name).to_string());
409 }
410 }
411 }
412 }
413 }
414 }
415
416 let mut messages = anthropic_messages.iter().peekable();
417 while let Some(msg) = messages.next() {
418 let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
419 let content = msg.get("content");
420
421 match role {
422 "user" => {
423 let mut user_messages = vec![msg];
427 while messages.peek().is_some_and(|next| next["role"] == "user") {
428 user_messages.push(messages.next().expect("peeked user message"));
429 }
430 let has_tools = user_messages.iter().any(|msg| {
431 msg["content"].as_array().is_some_and(|blocks| {
432 blocks.iter().any(|block| block["type"] == "tool_result")
433 })
434 });
435 let defer_user = has_tools
436 && user_messages
437 .iter()
438 .any(|msg| has_attachment(&msg["content"]));
439 let mut deferred = Vec::new();
440 for msg in user_messages {
441 let mut user = UserContent::default();
442 match msg.get("content") {
443 Some(Value::String(text)) => {
444 let target = if defer_user { &mut deferred } else { &mut out };
445 target.push(ChatMessage::user(text.clone()));
446 }
447 Some(Value::Array(blocks)) => {
448 for block in blocks {
449 match block["type"].as_str().unwrap_or("") {
450 "text" => {
451 if let Some(text) = block["text"].as_str() {
452 user.text.push_str(text);
453 }
454 }
455 "image" | "document" => {
456 user.append_parts(attachment_parts(
457 block,
458 AttachmentOrigin::User,
459 ));
460 }
461 "tool_result" => {
462 if !defer_user {
463 user.flush(&mut out);
464 }
465 let tool_id = block["tool_use_id"].as_str().unwrap_or("");
466 let tool_name = tool_name_map
467 .get(tool_id)
468 .map(String::as_str)
469 .unwrap_or("");
470 let result = tool_result_content(
471 block.get("content"),
472 AttachmentOrigin::ToolResult {
473 id: tool_id,
474 name: name_map.to_original(tool_name),
475 },
476 &mut user,
477 );
478 out.push(ChatMessage::tool_result(
479 tool_id, tool_name, result,
480 ));
481 }
482 _ => {}
483 }
484 }
485 let target = if defer_user { &mut deferred } else { &mut out };
486 user.flush(target);
487 }
488 _ => {}
489 }
490 }
491 out.extend(deferred);
492 }
493 "assistant" => {
494 let mut text_buf = String::new();
495 let mut tool_calls: Vec<ToolCall> = Vec::new();
496
497 if let Some(Value::Array(blocks)) = content {
498 for block in blocks {
499 let btype = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
500 match btype {
501 "text" => {
502 if let Some(t) = block.get("text").and_then(|t| t.as_str()) {
503 text_buf.push_str(t);
504 }
505 }
506 "tool_use" => {
507 let id = block
508 .get("id")
509 .and_then(|v| v.as_str())
510 .unwrap_or("")
511 .to_string();
512 let name = block
513 .get("name")
514 .and_then(|v| v.as_str())
515 .map(|n| name_map.to_oai(n).to_string())
516 .unwrap_or_default();
517 let arguments = block
518 .get("input")
519 .map(|v| v.to_string())
520 .unwrap_or_else(|| "{}".to_string());
521 tool_calls.push(ToolCall {
522 id,
523 kind: "function".to_string(),
524 function: FunctionCall { name, arguments },
525 });
526 }
527 "thinking" => {
528 }
530 _ => {}
531 }
532 }
533 } else if let Some(Value::String(s)) = content {
534 text_buf.push_str(s);
535 }
536
537 let has_text = !text_buf.is_empty();
538 let has_tools = !tool_calls.is_empty();
539 match (has_text, has_tools) {
540 (true, false) => out.push(ChatMessage::assistant(text_buf)),
541 (false, true) => out.push(ChatMessage::assistant_tool_calls(tool_calls)),
542 (true, true) => out.push(ChatMessage {
543 role: "assistant".into(),
544 content: Some(text_buf),
545 content_parts: None,
546 tool_calls: Some(tool_calls),
547 tool_call_id: None,
548 name: None,
549 }),
550 (false, false) => {}
551 }
552 }
553 _ => {}
554 }
555 }
556
557 let mut fixed = Vec::with_capacity(out.len());
561 for msg in out {
562 if msg.role == "user"
563 && msg.content_parts.is_none()
564 && fixed
565 .last()
566 .map(|m: &ChatMessage| m.role == "tool")
567 .unwrap_or(false)
568 {
569 fixed.push(ChatMessage::assistant(" ".to_string()));
570 }
571 fixed.push(msg);
572 }
573 fixed
574}
575
576pub fn oai_event_to_llm(event: &OaiEvent) -> Option<StreamEvent> {
582 match event {
583 OaiEvent::TextDelta(t) => Some(StreamEvent::Llm(LlmEvent::Text(t.clone()))),
584 OaiEvent::ToolCallStart { name, id, .. } => {
585 Some(StreamEvent::Llm(LlmEvent::ToolUseStart {
586 tool_name: name.clone(),
587 tool_id: id.clone(),
588 }))
589 }
590 OaiEvent::ToolCallArgumentsDelta { delta, id, .. } => {
591 Some(StreamEvent::Llm(LlmEvent::ToolUseDelta {
592 tool_id: id.clone(),
593 delta: delta.clone(),
594 }))
595 }
596 OaiEvent::Usage {
597 prompt_tokens,
598 completion_tokens,
599 cached_tokens,
600 } => {
601 let cached = (*cached_tokens).min(*prompt_tokens) as u64;
605 Some(StreamEvent::Session(SessionEvent::Usage {
606 input_tokens: *prompt_tokens as u64 - cached,
607 output_tokens: *completion_tokens as u64,
608 cache_read_input_tokens: cached,
609 cache_creation_input_tokens: 0,
610 cache_creation_5m: None,
611 cache_creation_1h: None,
612 model: None,
613 }))
614 }
615 OaiEvent::Warning(s) => {
616 tracing::warn!("openai stream warning: {}", s);
617 None
618 }
619 OaiEvent::RoleStart(_) | OaiEvent::Done | OaiEvent::ToolCallsComplete { .. } => None,
620 }
621}
622
623pub fn tool_calls_to_content_blocks(calls: &[ToolCall], name_map: &ToolNameMap) -> Vec<Value> {
625 calls
626 .iter()
627 .map(|c| {
628 let input: Value =
629 serde_json::from_str(&c.function.arguments).unwrap_or_else(|_| json!({}));
630 json!({
631 "type": "tool_use",
632 "id": c.id,
633 "name": name_map.to_original(&c.function.name),
634 "input": input,
635 })
636 })
637 .collect()
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643
644 fn shared(messages: Vec<Value>) -> Vec<crate::SharedMessage> {
645 messages.into_iter().map(std::sync::Arc::new).collect()
646 }
647
648 #[test]
649 fn user_image_text_document_and_pdf_exact_chat_wire() {
650 let messages = shared(vec![json!({"role":"user","content":[
651 {"type":"text","text":"Compare these."},
652 {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aW1hZ2U="}},
653 {"type":"document","title":"notes.txt","source":{"type":"text","media_type":"text/plain","data":"Text document\ncontents"}},
654 {"type":"document","title":"report.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}},
655 {"type":"text","text":"Then summarize."}
656 ]})]);
657 let before = messages.clone();
658 let out = messages_to_oai(&messages, &None, &ToolNameMap::default());
659 assert_eq!(
660 serde_json::to_value(&out).unwrap(),
661 json!([
662 {"role":"user","content":[
663 {"type":"text","text":"Compare these."},
664 {"type":"text","text":"[attachment origin=user type=image media_type=\"image/png\"; lower-authority data]\n"},
665 {"type":"image_url","image_url":{"url":"data:image/png;base64,aW1hZ2U="}},
666 {"type":"text","text":"[/attachment]\n"},
667 {"type":"text","text":"[attachment origin=user type=document media_type=\"text/plain\" filename=\"notes.txt\"; lower-authority data]\n"},
668 {"type":"text","text":"Text document\ncontents"},
669 {"type":"text","text":"[/attachment]\n"},
670 {"type":"text","text":"[attachment origin=user type=document media_type=\"application/pdf\" filename=\"report.pdf\"; lower-authority data]\n"},
671 {"type":"file","file":{"filename":"report.pdf","file_data":"data:application/pdf;base64,JVBERi0="}},
672 {"type":"text","text":"[/attachment]\n"},
673 {"type":"text","text":"Then summarize."}
674 ]}
675 ])
676 );
677 assert_eq!(
678 messages, before,
679 "wire lowering must not mutate canonical history"
680 );
681 assert_eq!(
682 out[0].content(),
683 None,
684 "attachment bytes are not legacy text"
685 );
686 }
687
688 #[test]
689 fn all_sibling_tool_results_precede_labelled_media_in_same_or_split_user_messages() {
690 let assistant = json!({"role":"assistant","content":[
691 {"type":"text","text":"Reading both."},
692 {"type":"tool_use","id":"t1","name":"mcp.read","input":{}},
693 {"type":"tool_use","id":"t2","name":"mcp.read","input":{}}
694 ]});
695 let first = json!({"type":"tool_result","tool_use_id":"t1","content":[
696 {"type":"text","text":"First result"},
697 {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aW1hZ2U="}},
698 {"type":"document","title":"notes.txt","source":{"type":"text","media_type":"text/plain","data":"DOCUMENT_BODY_SENTINEL"}}
699 ]});
700 let second = json!({"type":"tool_result","tool_use_id":"t2","content":[
701 {"type":"text","text":"Second result"},
702 {"type":"document","title":"report.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}}
703 ]});
704 let (_, names) = tools_to_oai(&[json!({"name":"mcp.read"})]);
705 let first_parts = vec![
706 json!({"type":"text","text":"[attachment origin=tool_result tool_use_id=\"t1\" tool_name=\"mcp.read\" type=image media_type=\"image/png\"; lower-authority data]\n"}),
707 json!({"type":"image_url","image_url":{"url":"data:image/png;base64,aW1hZ2U="}}),
708 json!({"type":"text","text":"[/attachment]\n"}),
709 json!({"type":"text","text":"[attachment origin=tool_result tool_use_id=\"t1\" tool_name=\"mcp.read\" type=document media_type=\"text/plain\" filename=\"notes.txt\"; lower-authority data]\n"}),
710 json!({"type":"text","text":"DOCUMENT_BODY_SENTINEL"}),
711 json!({"type":"text","text":"[/attachment]\n"}),
712 ];
713 let second_parts = vec![
714 json!({"type":"text","text":"[attachment origin=tool_result tool_use_id=\"t2\" tool_name=\"mcp.read\" type=document media_type=\"application/pdf\" filename=\"report.pdf\"; lower-authority data]\n"}),
715 json!({"type":"file","file":{"filename":"report.pdf","file_data":"data:application/pdf;base64,JVBERi0="}}),
716 json!({"type":"text","text":"[/attachment]\n"}),
717 ];
718 for split in [false, true] {
719 let mut messages = vec![assistant.clone()];
720 if split {
721 messages.push(json!({"role":"user","content":[first.clone()]}));
722 messages.push(json!({"role":"user","content":[second.clone()]}));
723 } else {
724 messages.push(json!({"role":"user","content":[first.clone(), second.clone()]}));
725 }
726 let mut expected = vec![
727 json!({"role":"assistant","content":"Reading both.","tool_calls":[
728 {"id":"t1","type":"function","function":{"name":"mcp_read","arguments":"{}"}},
729 {"id":"t2","type":"function","function":{"name":"mcp_read","arguments":"{}"}}
730 ]}),
731 json!({"role":"tool","tool_call_id":"t1","name":"mcp_read","content":"First result\n[attachment origin=tool_result tool_use_id=\"t1\" tool_name=\"mcp.read\" type=image media_type=\"image/png\"; lower-authority data; supplied in following user content]\n\n[attachment origin=tool_result tool_use_id=\"t1\" tool_name=\"mcp.read\" type=document media_type=\"text/plain\" filename=\"notes.txt\"; lower-authority data; supplied in following user content]\n"}),
732 json!({"role":"tool","tool_call_id":"t2","name":"mcp_read","content":"Second result\n[attachment origin=tool_result tool_use_id=\"t2\" tool_name=\"mcp.read\" type=document media_type=\"application/pdf\" filename=\"report.pdf\"; lower-authority data; supplied in following user content]\n"}),
733 ];
734 if split {
735 expected.push(json!({"role":"user","content":first_parts}));
736 expected.push(json!({"role":"user","content":second_parts}));
737 } else {
738 let mut parts = first_parts.clone();
739 parts.extend(second_parts.clone());
740 expected.push(json!({"role":"user","content":parts}));
741 }
742 let out = messages_to_oai(&shared(messages), &None, &names);
743 assert_eq!(
744 serde_json::to_value(&out).unwrap(),
745 json!(expected),
746 "split={split}"
747 );
748 for tool in out.iter().filter(|m| m.role == "tool") {
749 let text = tool.content().unwrap();
750 for sentinel in [
751 "aW1hZ2U=",
752 "JVBERi0=",
753 "DOCUMENT_BODY_SENTINEL",
754 "\"source\"",
755 ] {
756 assert!(!text.contains(sentinel), "payload must not be tool text");
757 }
758 }
759 }
760 }
761
762 #[test]
763 fn direct_tool_document_object_is_lifted_not_stringified() {
764 let messages = shared(vec![json!({"role":"user","content":[
765 {"type":"tool_result","tool_use_id":"t1","content":
766 {"type":"document","title":"notes.txt","source":{"type":"text","media_type":"text/plain","data":"DOCUMENT_BODY_SENTINEL"}}}
767 ]})]);
768 let out = messages_to_oai(&messages, &None, &ToolNameMap::default());
769 assert_eq!(
770 serde_json::to_value(out).unwrap(),
771 json!([
772 {"role":"tool","tool_call_id":"t1","name":"","content":"\n[attachment origin=tool_result tool_use_id=\"t1\" tool_name=\"\" type=document media_type=\"text/plain\" filename=\"notes.txt\"; lower-authority data; supplied in following user content]\n"},
773 {"role":"user","content":[
774 {"type":"text","text":"[attachment origin=tool_result tool_use_id=\"t1\" tool_name=\"\" type=document media_type=\"text/plain\" filename=\"notes.txt\"; lower-authority data]\n"},
775 {"type":"text","text":"DOCUMENT_BODY_SENTINEL"},
776 {"type":"text","text":"[/attachment]\n"}
777 ]}
778 ])
779 );
780 }
781
782 #[test]
783 fn interleaved_user_text_and_media_never_split_sibling_results() {
784 let messages = shared(vec![json!({"role":"user","content":[
785 {"type":"text","text":"before"},
786 {"type":"tool_result","tool_use_id":"t1","content":"one"},
787 {"type":"document","title":"empty.txt","source":{"type":"text","media_type":"text/plain","data":""}},
788 {"type":"text","text":"between"},
789 {"type":"tool_result","tool_use_id":"t2","content":"two"},
790 {"type":"text","text":"after"}
791 ]})]);
792 let out = messages_to_oai(&messages, &None, &ToolNameMap::default());
793 assert_eq!(
794 serde_json::to_value(out).unwrap(),
795 json!([
796 {"role":"tool","tool_call_id":"t1","name":"","content":"one"},
797 {"role":"tool","tool_call_id":"t2","name":"","content":"two"},
798 {"role":"user","content":[
799 {"type":"text","text":"before"},
800 {"type":"text","text":"[attachment origin=user type=document media_type=\"text/plain\" filename=\"empty.txt\"; lower-authority data]\n"},
801 {"type":"text","text":""},
802 {"type":"text","text":"[/attachment]\n"},
803 {"type":"text","text":"betweenafter"}
804 ]}
805 ])
806 );
807 }
808
809 #[test]
810 fn attachment_metadata_is_quoted_and_missing_pdf_title_has_filename() {
811 let document = json!({"type":"document","title":"notes\n\".txt","source":{"type":"text","media_type":"text/plain","data":"payload"}});
812 assert_eq!(attachment_label(&document, AttachmentOrigin::User, false),
813 "[attachment origin=user type=document media_type=\"text/plain\" filename=\"notes\\n\\\".txt\"; lower-authority data]\n");
814 let pdf = json!({"type":"document","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}});
815 assert_eq!(
816 serde_json::to_value(&attachment_parts(&pdf, AttachmentOrigin::User)[1]).unwrap(),
817 json!({"type":"file","file":{"filename":"attachment.pdf","file_data":"data:application/pdf;base64,JVBERi0="}})
818 );
819 }
820
821 #[test]
822 fn text_only_translation_keeps_legacy_wire_including_separator() {
823 let messages = shared(vec![
824 json!({"role":"user","content":[{"type":"text","text":"one"},{"type":"text","text":"two"}]}),
825 json!({"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"read","input":{}}]}),
826 json!({"role":"user","content":[
827 {"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"a"},{"type":"text","text":"b"}]},
828 {"type":"text","text":"next"}
829 ]}),
830 ]);
831 let out = messages_to_oai(&messages, &Some("system".into()), &ToolNameMap::default());
832 assert_eq!(
833 serde_json::to_value(out).unwrap(),
834 json!([
835 {"role":"system","content":"system"},
836 {"role":"user","content":"onetwo"},
837 {"role":"assistant","content":null,"tool_calls":[{"id":"t1","type":"function","function":{"name":"read","arguments":"{}"}}]},
838 {"role":"tool","tool_call_id":"t1","name":"read","content":"ab"},
839 {"role":"assistant","content":" "},
840 {"role":"user","content":"next"}
841 ])
842 );
843 }
844
845 #[test]
852 fn array_property_missing_items_is_backfilled_for_oai_wire() {
853 let schema = vec![json!({
854 "name": "ext__lsrf-manager__managerApi_cloudfrontInvalidate",
855 "description": "Invalidate CloudFront paths",
856 "input_schema": {
857 "type": "object",
858 "properties": {
859 "paths": { "type": "array" },
860 "distribution_id": { "type": "string" }
861 }
862 }
863 })];
864 let (tools, _map) = tools_to_oai(&schema);
865 let params = &tools[0].function.parameters;
866 assert_eq!(
867 params["properties"]["paths"]["items"],
868 json!({}),
869 "array schema without items must be backfilled: {params}"
870 );
871 assert_eq!(
873 params["properties"]["distribution_id"],
874 json!({"type": "string"})
875 );
876 }
877
878 #[test]
879 fn responses_opt_out_of_implicit_strict_without_promoting_optional_fields() {
880 use crate::tools::{
881 forum::{ForumForgetTool, ForumPostTool, ForumReadTool},
882 Tool,
883 };
884 let mut schemas = vec![];
885 for tool in [
886 &ForumPostTool as &dyn Tool,
887 &ForumReadTool,
888 &ForumForgetTool,
889 ] {
890 schemas.push(json!({"name":tool.name(),"description":tool.description(),"input_schema":tool.parameters()}));
891 }
892 schemas.push(json!({"name":"ext:optional","input_schema":{"type":"object","properties":{"required_value":{"type":"string"},"optional_value":{"type":"string"}},"required":["required_value"]}}));
893 let (responses, names) = tools_to_responses(&schemas);
894 let (chat, _) = tools_to_oai(&schemas);
895 for (i, response) in responses.iter().enumerate() {
896 assert_eq!(response["strict"], false);
897 assert_eq!(response["parameters"], schemas[i]["input_schema"]);
898 assert_eq!(chat[i].function.parameters, response["parameters"]);
899 assert!(serde_json::to_value(&chat[i]).unwrap()["function"]
900 .get("strict")
901 .is_none());
902 }
903 assert_eq!(names.to_original("ext_optional"), "ext:optional");
904 let p = &responses[1]["parameters"];
905 assert_eq!(p["required"], json!([]));
906 assert_eq!(
907 p["properties"]["thread_id"]["type"],
908 json!(["string", "null"])
909 );
910 assert_eq!(p["properties"]["after"]["type"], json!(["object", "null"]));
911 assert_eq!(
912 p["properties"]["after"]["properties"]["id"]["type"],
913 "string"
914 );
915 assert_eq!(
916 p["properties"]["after"]["required"],
917 json!(["timestamp_ms", "id"])
918 );
919 }
920
921 #[test]
922 fn nested_and_composed_array_schemas_are_sanitized() {
923 let schema = vec![json!({
924 "name": "t",
925 "input_schema": {
926 "type": "object",
927 "properties": {
928 "matrix": {
929 "type": "array",
930 "items": { "type": "array" }
931 },
932 "either": {
933 "anyOf": [
934 { "type": "array" },
935 { "type": "string" }
936 ]
937 },
938 "nullable": { "type": ["array", "null"] },
939 "obj": {
940 "type": "object",
941 "additionalProperties": { "type": "array" },
942 "properties": {
943 "inner": { "type": "array" }
944 }
945 }
946 },
947 "$defs": {
948 "aliasList": { "type": "array" }
949 }
950 }
951 })];
952 let (tools, _map) = tools_to_oai(&schema);
953 let p = &tools[0].function.parameters;
954 assert_eq!(p["properties"]["matrix"]["items"]["items"], json!({}));
955 assert_eq!(p["properties"]["either"]["anyOf"][0]["items"], json!({}));
956 assert!(p["properties"]["either"]["anyOf"][1].get("items").is_none());
957 assert_eq!(p["properties"]["nullable"]["items"], json!({}));
958 assert_eq!(
959 p["properties"]["obj"]["additionalProperties"]["items"],
960 json!({})
961 );
962 assert_eq!(
963 p["properties"]["obj"]["properties"]["inner"]["items"],
964 json!({})
965 );
966 assert_eq!(p["$defs"]["aliasList"]["items"], json!({}));
967 }
968
969 #[test]
970 fn array_schema_with_existing_items_is_untouched() {
971 let schema = vec![json!({
972 "name": "t",
973 "input_schema": {
974 "type": "object",
975 "properties": {
976 "ops": { "type": "array", "items": { "type": "object" } }
977 }
978 }
979 })];
980 let (tools, _map) = tools_to_oai(&schema);
981 assert_eq!(
982 tools[0].function.parameters["properties"]["ops"]["items"],
983 json!({"type": "object"})
984 );
985 }
986
987 #[test]
993 fn mcp_tool_names_are_sanitized_for_oai_wire_and_round_trip() {
994 let schema = vec![json!({
995 "name": "ext__lsrf-manager__managerApi.cloudfrontInvalidate",
996 "input_schema": {"type": "object", "properties": {}}
997 })];
998 let (tools, map) = tools_to_oai(&schema);
999 let wire = &tools[0].function.name;
1000 assert!(
1001 wire.chars()
1002 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
1003 "wire name must match ^[a-zA-Z0-9_-]+$: {wire}"
1004 );
1005 assert_eq!(
1006 map.to_original(wire),
1007 "ext__lsrf-manager__managerApi.cloudfrontInvalidate",
1008 "sanitized wire name must map back to the executable tool name"
1009 );
1010 }
1011}