Skip to main content

agent_engine/runtime/openai/
translate.rs

1//! Anthropic ↔ OpenAI translation layer.
2
3use 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    /// Original names that were rewritten for the OpenAI wire, in
33    /// deterministic (sorted) order. Feeds the metadata-only translation
34    /// report — a rename is a semantic rewrite and must never be silently
35    /// claimed lossless (spec §6.3).
36    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
52/// OpenAI function names must match `^[a-zA-Z0-9_-]+$`. Synaps/MCP names may
53/// contain namespace separators like `:` or `.`, so sanitize only for the
54/// OpenAI wire format and map back before tool execution.
55fn 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
74/// OpenAI's function-schema validator rejects any array schema without
75/// `items` ("array schema missing items"), which turns one loosely-specified
76/// MCP/extension tool into a hard 400 on *every* request of the session
77/// (observed live: `ext__lsrf-manager__managerApi_cloudfrontInvalidate`,
78/// session 20260429-235559-094c). Backfill `"items": {}` — the
79/// accept-anything schema — recursively, preserving all author intent.
80fn 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
124/// Convert Anthropic tool schema entries to OpenAI ToolDefinitions.
125///
126/// Anthropic shape: `{"name", "description", "input_schema", optional cache_control}`.
127/// OpenAI shape:    `{"type": "function", "function": {"name", "description", "parameters"}}`.
128pub 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            // Skip empty names and internal-only tools
137            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                    // Truncate base to leave room for suffix (e.g. "_99" = 3 chars)
148                    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
190/// Responses endpoints may normalize omitted `strict` to strict schemas,
191/// promoting optional properties to required ones. These are ordinary Synaps
192/// schemas (including extensions), not transformed strict-output schemas. Opt
193/// out explicitly rather than making the model invent optional argument values.
194/// Chat Completions keeps its existing wire shape.
195pub 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/// Origin labels are provider-visible data, never instructions. Quote metadata
213/// as JSON strings so filenames and tool ids cannot introduce marker lines.
214#[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
273/// Pure wire lowering, not a model/route capability decision. The foreground
274/// validates canonical sources, bounds, and actual model support before send.
275fn 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        // Defensive, metadata-only fallback if a caller bypasses validation.
306        // Never stringify a document/image source into ordinary tool text.
307        _ => 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
380/// Convert Anthropic-shaped message list + optional system prompt into
381/// an OpenAI ChatMessage stream.
382pub 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    // Build a map of tool_use_id → tool_name from assistant messages
396    // so we can populate the name field on tool result messages.
397    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                // A parallel tool batch can span several adjacent canonical
424                // user messages. Emit ALL correlated tool outputs before any
425                // lifted attachment (or accompanying user text) in that turn.
426                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                                // Not representable in OpenAI — drop.
529                            }
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    // Preserve the legacy text-only separator for compatible endpoints. Rich
558    // user content is valid directly after a completed tool batch; do not
559    // invent an assistant turn between results and their lifted attachments.
560    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
576/// Translate an [`OaiEvent`] into a synaps [`StreamEvent`].
577///
578/// Returns `None` for events that are handled at a higher level
579/// (`Done`, `ToolCallsComplete`, `RoleStart`) or purely informational
580/// (`Warning` — logged via tracing).
581pub 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            // prompt_tokens INCLUDES the cached slice (OpenAI semantics);
602            // downstream accounting sums input + cache_read (Anthropic
603            // semantics), so subtract to avoid double-counting the hits.
604            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
623/// Convert an OpenAI tool-call list into Anthropic-shaped `tool_use` content blocks.
624pub 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    /// Regression fixture from session 20260429-235559-094c: the lsrf-manager
846    /// extension exposed `managerApi_cloudfrontInvalidate` whose `paths`
847    /// property was `{"type":"array"}` with no `items`. The Codex Responses
848    /// API rejects every request in such a session:
849    ///   400 "Invalid schema for function 'ext__lsrf-manager__managerApi_cloudfrontInvalidate':
850    ///        In context=('properties', 'paths'), array schema missing items."
851    #[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        // Non-array sibling untouched.
872        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    /// Regression for session 20260427-235907-2185:
988    ///   400 "Invalid 'tools[18].name': string does not match pattern.
989    ///        Expected a string that matches the pattern '^[a-zA-Z0-9_-]+$'."
990    /// MCP/extension names carry `.`/`:` separators; the OAI wire name must
991    /// be sanitized and must round-trip back to the original for execution.
992    #[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}