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