agent_engine/runtime/openai/
translate.rs1use 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 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
51fn 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
73fn 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
123pub 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 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 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
189pub 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 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 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 }
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 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
365pub 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 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
412pub 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 #[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 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 #[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}