Skip to main content

agent_engine/runtime/openai/
translate.rs

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