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() || 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                    // Truncate base to leave room for suffix (e.g. "_99" = 3 chars)
144                    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
186/// Responses endpoints may normalize omitted `strict` to strict schemas,
187/// promoting optional properties to required ones. These are ordinary Synaps
188/// schemas (including extensions), not transformed strict-output schemas. Opt
189/// out explicitly rather than making the model invent optional argument values.
190/// Chat Completions keeps its existing wire shape.
191pub 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/// Origin labels are provider-visible data, never instructions. Quote metadata
209/// as JSON strings so filenames and tool ids cannot introduce marker lines.
210#[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
269/// Pure wire lowering, not a model/route capability decision. The foreground
270/// validates canonical sources, bounds, and actual model support before send.
271fn 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        // Defensive, metadata-only fallback if a caller bypasses validation.
302        // Never stringify a document/image source into ordinary tool text.
303        _ => 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
376/// Convert Anthropic-shaped message list + optional system prompt into
377/// an OpenAI ChatMessage stream.
378pub 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    // Build a map of tool_use_id → tool_name from assistant messages
392    // so we can populate the name field on tool result messages.
393    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                // A parallel tool batch can span several adjacent canonical
420                // user messages. Emit ALL correlated tool outputs before any
421                // lifted attachment (or accompanying user text) in that turn.
422                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                                // Not representable in OpenAI — drop.
525                            }
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    // Preserve the legacy text-only separator for compatible endpoints. Rich
554    // user content is valid directly after a completed tool batch; do not
555    // invent an assistant turn between results and their lifted attachments.
556    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
572/// Translate an [`OaiEvent`] into a synaps [`StreamEvent`].
573///
574/// Returns `None` for events that are handled at a higher level
575/// (`Done`, `ToolCallsComplete`, `RoleStart`) or purely informational
576/// (`Warning` — logged via tracing).
577pub 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            // prompt_tokens INCLUDES the cached slice (OpenAI semantics);
598            // downstream accounting sums input + cache_read (Anthropic
599            // semantics), so subtract to avoid double-counting the hits.
600            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
619/// Convert an OpenAI tool-call list into Anthropic-shaped `tool_use` content blocks.
620pub 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    /// Regression fixture from session 20260429-235559-094c: the lsrf-manager
842    /// extension exposed `managerApi_cloudfrontInvalidate` whose `paths`
843    /// property was `{"type":"array"}` with no `items`. The Codex Responses
844    /// API rejects every request in such a session:
845    ///   400 "Invalid schema for function 'ext__lsrf-manager__managerApi_cloudfrontInvalidate':
846    ///        In context=('properties', 'paths'), array schema missing items."
847    #[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        // Non-array sibling untouched.
868        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        // Optional fields that a provider may send as null, as an
877        // extension's read tool declares them.
878        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    /// Regression for session 20260427-235907-2185:
982    ///   400 "Invalid 'tools[18].name': string does not match pattern.
983    ///        Expected a string that matches the pattern '^[a-zA-Z0-9_-]+$'."
984    /// MCP/extension names carry `.`/`:` separators; the OAI wire name must
985    /// be sanitized and must round-trip back to the original for execution.
986    #[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}