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                                                b.get("text")
259                                                    .and_then(|t| t.as_str())
260                                                    .map(String::from)
261                                            })
262                                            .collect::<Vec<_>>()
263                                            .join(""),
264                                        Some(other) => other.to_string(),
265                                        None => String::new(),
266                                    };
267                                    let tool_name =
268                                        tool_name_map.get(&tool_id).cloned().unwrap_or_default();
269                                    out.push(ChatMessage::tool_result(
270                                        tool_id,
271                                        tool_name,
272                                        result_content,
273                                    ));
274                                }
275                                _ => {}
276                            }
277                        }
278                        if !text_buf.is_empty() {
279                            out.push(ChatMessage::user(text_buf));
280                        }
281                    }
282                    _ => {}
283                }
284            }
285            "assistant" => {
286                let mut text_buf = String::new();
287                let mut tool_calls: Vec<ToolCall> = Vec::new();
288
289                if let Some(Value::Array(blocks)) = content {
290                    for block in blocks {
291                        let btype = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
292                        match btype {
293                            "text" => {
294                                if let Some(t) = block.get("text").and_then(|t| t.as_str()) {
295                                    text_buf.push_str(t);
296                                }
297                            }
298                            "tool_use" => {
299                                let id = block
300                                    .get("id")
301                                    .and_then(|v| v.as_str())
302                                    .unwrap_or("")
303                                    .to_string();
304                                let name = block
305                                    .get("name")
306                                    .and_then(|v| v.as_str())
307                                    .map(|n| name_map.to_oai(n).to_string())
308                                    .unwrap_or_default();
309                                let arguments = block
310                                    .get("input")
311                                    .map(|v| v.to_string())
312                                    .unwrap_or_else(|| "{}".to_string());
313                                tool_calls.push(ToolCall {
314                                    id,
315                                    kind: "function".to_string(),
316                                    function: FunctionCall { name, arguments },
317                                });
318                            }
319                            "thinking" => {
320                                // Not representable in OpenAI — drop.
321                            }
322                            _ => {}
323                        }
324                    }
325                } else if let Some(Value::String(s)) = content {
326                    text_buf.push_str(s);
327                }
328
329                let has_text = !text_buf.is_empty();
330                let has_tools = !tool_calls.is_empty();
331                match (has_text, has_tools) {
332                    (true, false) => out.push(ChatMessage::assistant(text_buf)),
333                    (false, true) => out.push(ChatMessage::assistant_tool_calls(tool_calls)),
334                    (true, true) => out.push(ChatMessage {
335                        role: "assistant".into(),
336                        content: Some(text_buf),
337                        tool_calls: Some(tool_calls),
338                        tool_call_id: None,
339                        name: None,
340                    }),
341                    (false, false) => {}
342                }
343            }
344            _ => {}
345        }
346    }
347
348    // Fixup: OpenAI requires that a 'tool' message is never followed
349    // directly by a 'user' message. Insert an empty assistant message if needed.
350    let mut fixed = Vec::with_capacity(out.len());
351    for msg in out {
352        if msg.role == "user"
353            && fixed
354                .last()
355                .map(|m: &ChatMessage| m.role == "tool")
356                .unwrap_or(false)
357        {
358            fixed.push(ChatMessage::assistant(" ".to_string()));
359        }
360        fixed.push(msg);
361    }
362    fixed
363}
364
365/// Translate an [`OaiEvent`] into a synaps [`StreamEvent`].
366///
367/// Returns `None` for events that are handled at a higher level
368/// (`Done`, `ToolCallsComplete`, `RoleStart`) or purely informational
369/// (`Warning` — logged via tracing).
370pub fn oai_event_to_llm(event: &OaiEvent) -> Option<StreamEvent> {
371    match event {
372        OaiEvent::TextDelta(t) => Some(StreamEvent::Llm(LlmEvent::Text(t.clone()))),
373        OaiEvent::ToolCallStart { name, id, .. } => {
374            Some(StreamEvent::Llm(LlmEvent::ToolUseStart {
375                tool_name: name.clone(),
376                tool_id: id.clone(),
377            }))
378        }
379        OaiEvent::ToolCallArgumentsDelta { delta, id, .. } => {
380            Some(StreamEvent::Llm(LlmEvent::ToolUseDelta {
381                tool_id: id.clone(),
382                delta: delta.clone(),
383            }))
384        }
385        OaiEvent::Usage {
386            prompt_tokens,
387            completion_tokens,
388            cached_tokens,
389        } => {
390            // prompt_tokens INCLUDES the cached slice (OpenAI semantics);
391            // downstream accounting sums input + cache_read (Anthropic
392            // semantics), so subtract to avoid double-counting the hits.
393            let cached = (*cached_tokens).min(*prompt_tokens) as u64;
394            Some(StreamEvent::Session(SessionEvent::Usage {
395                input_tokens: *prompt_tokens as u64 - cached,
396                output_tokens: *completion_tokens as u64,
397                cache_read_input_tokens: cached,
398                cache_creation_input_tokens: 0,
399                cache_creation_5m: None,
400                cache_creation_1h: None,
401                model: None,
402            }))
403        }
404        OaiEvent::Warning(s) => {
405            tracing::warn!("openai stream warning: {}", s);
406            None
407        }
408        OaiEvent::RoleStart(_) | OaiEvent::Done | OaiEvent::ToolCallsComplete { .. } => None,
409    }
410}
411
412/// Convert an OpenAI tool-call list into Anthropic-shaped `tool_use` content blocks.
413pub fn tool_calls_to_content_blocks(calls: &[ToolCall], name_map: &ToolNameMap) -> Vec<Value> {
414    calls
415        .iter()
416        .map(|c| {
417            let input: Value =
418                serde_json::from_str(&c.function.arguments).unwrap_or_else(|_| json!({}));
419            json!({
420                "type": "tool_use",
421                "id": c.id,
422                "name": name_map.to_original(&c.function.name),
423                "input": input,
424            })
425        })
426        .collect()
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    /// Regression fixture from session 20260429-235559-094c: the lsrf-manager
434    /// extension exposed `managerApi_cloudfrontInvalidate` whose `paths`
435    /// property was `{"type":"array"}` with no `items`. The Codex Responses
436    /// API rejects every request in such a session:
437    ///   400 "Invalid schema for function 'ext__lsrf-manager__managerApi_cloudfrontInvalidate':
438    ///        In context=('properties', 'paths'), array schema missing items."
439    #[test]
440    fn array_property_missing_items_is_backfilled_for_oai_wire() {
441        let schema = vec![json!({
442            "name": "ext__lsrf-manager__managerApi_cloudfrontInvalidate",
443            "description": "Invalidate CloudFront paths",
444            "input_schema": {
445                "type": "object",
446                "properties": {
447                    "paths": { "type": "array" },
448                    "distribution_id": { "type": "string" }
449                }
450            }
451        })];
452        let (tools, _map) = tools_to_oai(&schema);
453        let params = &tools[0].function.parameters;
454        assert_eq!(
455            params["properties"]["paths"]["items"],
456            json!({}),
457            "array schema without items must be backfilled: {params}"
458        );
459        // Non-array sibling untouched.
460        assert_eq!(
461            params["properties"]["distribution_id"],
462            json!({"type": "string"})
463        );
464    }
465
466    #[test]
467    fn nested_and_composed_array_schemas_are_sanitized() {
468        let schema = vec![json!({
469            "name": "t",
470            "input_schema": {
471                "type": "object",
472                "properties": {
473                    "matrix": {
474                        "type": "array",
475                        "items": { "type": "array" }
476                    },
477                    "either": {
478                        "anyOf": [
479                            { "type": "array" },
480                            { "type": "string" }
481                        ]
482                    },
483                    "nullable": { "type": ["array", "null"] },
484                    "obj": {
485                        "type": "object",
486                        "additionalProperties": { "type": "array" },
487                        "properties": {
488                            "inner": { "type": "array" }
489                        }
490                    }
491                },
492                "$defs": {
493                    "aliasList": { "type": "array" }
494                }
495            }
496        })];
497        let (tools, _map) = tools_to_oai(&schema);
498        let p = &tools[0].function.parameters;
499        assert_eq!(p["properties"]["matrix"]["items"]["items"], json!({}));
500        assert_eq!(p["properties"]["either"]["anyOf"][0]["items"], json!({}));
501        assert!(p["properties"]["either"]["anyOf"][1].get("items").is_none());
502        assert_eq!(p["properties"]["nullable"]["items"], json!({}));
503        assert_eq!(
504            p["properties"]["obj"]["additionalProperties"]["items"],
505            json!({})
506        );
507        assert_eq!(
508            p["properties"]["obj"]["properties"]["inner"]["items"],
509            json!({})
510        );
511        assert_eq!(p["$defs"]["aliasList"]["items"], json!({}));
512    }
513
514    #[test]
515    fn array_schema_with_existing_items_is_untouched() {
516        let schema = vec![json!({
517            "name": "t",
518            "input_schema": {
519                "type": "object",
520                "properties": {
521                    "ops": { "type": "array", "items": { "type": "object" } }
522                }
523            }
524        })];
525        let (tools, _map) = tools_to_oai(&schema);
526        assert_eq!(
527            tools[0].function.parameters["properties"]["ops"]["items"],
528            json!({"type": "object"})
529        );
530    }
531
532    /// Regression for session 20260427-235907-2185:
533    ///   400 "Invalid 'tools[18].name': string does not match pattern.
534    ///        Expected a string that matches the pattern '^[a-zA-Z0-9_-]+$'."
535    /// MCP/extension names carry `.`/`:` separators; the OAI wire name must
536    /// be sanitized and must round-trip back to the original for execution.
537    #[test]
538    fn mcp_tool_names_are_sanitized_for_oai_wire_and_round_trip() {
539        let schema = vec![json!({
540            "name": "ext__lsrf-manager__managerApi.cloudfrontInvalidate",
541            "input_schema": {"type": "object", "properties": {}}
542        })];
543        let (tools, map) = tools_to_oai(&schema);
544        let wire = &tools[0].function.name;
545        assert!(
546            wire.chars()
547                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
548            "wire name must match ^[a-zA-Z0-9_-]+$: {wire}"
549        );
550        assert_eq!(
551            map.to_original(wire),
552            "ext__lsrf-manager__managerApi.cloudfrontInvalidate",
553            "sanitized wire name must map back to the executable tool name"
554        );
555    }
556}