1use serde_json::{Map, Value, json};
31use thiserror::Error;
32
33use crate::driver_registry::{LlmContentPart, Message, MessageContent, MessageRole};
34use crate::tool_types::{ToolCall, ToolDefinition};
35
36#[derive(Debug, Clone, Error, PartialEq, Eq)]
41#[non_exhaustive]
42pub enum OpenAiWireError {
43 #[error("missing `{field}`")]
45 Missing {
46 field: &'static str,
48 },
49 #[error("`{field}` is not {expected}")]
51 Invalid {
52 field: &'static str,
54 expected: &'static str,
56 },
57 #[error("unsupported message role: {role}")]
59 UnsupportedRole {
60 role: String,
62 },
63}
64
65impl From<OpenAiWireError> for crate::error::AgentLoopError {
66 fn from(error: OpenAiWireError) -> Self {
67 crate::error::AgentLoopError::config(error.to_string())
68 }
69}
70
71type Result<T> = std::result::Result<T, OpenAiWireError>;
72
73pub fn message_from_openai(value: &Value) -> Result<Message> {
92 let object = value.as_object().ok_or(OpenAiWireError::Invalid {
93 field: "message",
94 expected: "an object",
95 })?;
96 let role = object
97 .get("role")
98 .and_then(Value::as_str)
99 .ok_or(OpenAiWireError::Missing { field: "role" })?;
100 let role = match role {
101 "system" | "developer" => MessageRole::System,
102 "user" => MessageRole::User,
103 "assistant" => MessageRole::Assistant,
104 "tool" | "function" => MessageRole::Tool,
105 other => {
106 return Err(OpenAiWireError::UnsupportedRole {
107 role: other.to_owned(),
108 });
109 }
110 };
111
112 let content = match object.get("content") {
113 None | Some(Value::Null) => MessageContent::Text(String::new()),
114 Some(Value::String(text)) => MessageContent::Text(text.clone()),
115 Some(Value::Array(parts)) => {
116 MessageContent::Parts(parts.iter().map(content_part_from_openai).collect())
117 }
118 Some(_) => {
119 return Err(OpenAiWireError::Invalid {
120 field: "content",
121 expected: "a string, an array of parts, or null",
122 });
123 }
124 };
125
126 let mut message = Message {
127 content,
128 ..Message::text(role, "")
129 };
130
131 if let Some(calls) = object.get("tool_calls") {
132 let calls = calls.as_array().ok_or(OpenAiWireError::Invalid {
133 field: "tool_calls",
134 expected: "an array",
135 })?;
136 let parsed = calls
137 .iter()
138 .map(tool_call_from_openai)
139 .collect::<Result<Vec<_>>>()?;
140 if !parsed.is_empty() {
141 message.tool_calls = Some(parsed);
142 }
143 }
144
145 match object.get("tool_call_id").and_then(Value::as_str) {
146 Some(id) => message.tool_call_id = Some(id.to_owned()),
147 None if message.role == MessageRole::Tool => {
150 return Err(OpenAiWireError::Missing {
151 field: "tool_call_id",
152 });
153 }
154 None => {}
155 }
156
157 Ok(message)
158}
159
160pub fn messages_from_openai(values: &[Value]) -> Result<Vec<Message>> {
167 values.iter().map(message_from_openai).collect()
168}
169
170pub fn message_to_openai(message: &Message) -> Value {
178 let mut object = Map::new();
179 object.insert(
180 "role".to_owned(),
181 Value::String(
182 match message.role {
183 MessageRole::System => "system",
184 MessageRole::User => "user",
185 MessageRole::Assistant => "assistant",
186 MessageRole::Tool => "tool",
187 }
188 .to_owned(),
189 ),
190 );
191 let content = match &message.content {
192 MessageContent::Text(text) => Value::String(text.clone()),
193 MessageContent::Parts(parts) => {
194 Value::Array(parts.iter().filter_map(content_part_to_openai).collect())
195 }
196 };
197 object.insert("content".to_owned(), content);
198 if let Some(calls) = &message.tool_calls {
199 object.insert(
200 "tool_calls".to_owned(),
201 Value::Array(calls.iter().map(tool_call_to_openai).collect()),
202 );
203 }
204 if let Some(id) = &message.tool_call_id {
205 object.insert("tool_call_id".to_owned(), Value::String(id.clone()));
206 }
207 Value::Object(object)
208}
209
210pub fn tool_from_openai(value: &Value) -> Result<ToolDefinition> {
222 let function = value.get("function").unwrap_or(value);
223 let name = function
224 .get("name")
225 .and_then(Value::as_str)
226 .ok_or(OpenAiWireError::Missing {
227 field: "function.name",
228 })?;
229 let description = function
230 .get("description")
231 .and_then(Value::as_str)
232 .unwrap_or_default();
233 let parameters = function
234 .get("parameters")
235 .cloned()
236 .unwrap_or_else(empty_parameters);
237 Ok(ToolDefinition::function(name, description, parameters))
238}
239
240pub fn tools_from_openai(values: &[Value]) -> Result<Vec<ToolDefinition>> {
246 values.iter().map(tool_from_openai).collect()
247}
248
249pub fn tool_to_openai(tool: &ToolDefinition) -> Value {
251 json!({
252 "type": "function",
253 "function": {
254 "name": tool.name(),
255 "description": tool.description(),
256 "parameters": tool.parameters().clone(),
257 },
258 })
259}
260
261pub fn tool_call_from_openai(value: &Value) -> Result<ToolCall> {
267 let function = value.get("function").unwrap_or(value);
270 let name = function
271 .get("name")
272 .and_then(Value::as_str)
273 .or_else(|| value.get("name").and_then(Value::as_str))
274 .ok_or(OpenAiWireError::Missing { field: "name" })?;
275 let arguments = function
276 .get("arguments")
277 .or_else(|| value.get("arguments"))
278 .map(parse_arguments)
279 .unwrap_or_else(|| json!({}));
280 Ok(ToolCall {
281 id: value
282 .get("id")
283 .and_then(Value::as_str)
284 .unwrap_or_default()
285 .to_owned(),
286 name: name.to_owned(),
287 arguments,
288 })
289}
290
291pub fn tool_call_to_openai(call: &ToolCall) -> Value {
294 json!({
295 "id": call.id,
296 "type": "function",
297 "function": {
298 "name": call.name,
299 "arguments": serde_json::to_string(&call.arguments).unwrap_or_else(|_| "{}".to_owned()),
300 },
301 })
302}
303
304fn parse_arguments(value: &Value) -> Value {
308 match value {
309 Value::String(raw) => serde_json::from_str(raw).unwrap_or_else(|_| value.clone()),
310 other => other.clone(),
311 }
312}
313
314fn content_part_from_openai(value: &Value) -> LlmContentPart {
318 let kind = value.get("type").and_then(Value::as_str).unwrap_or("");
319 match kind {
320 "text" | "input_text" | "output_text" => LlmContentPart::text(
321 value
322 .get("text")
323 .and_then(Value::as_str)
324 .unwrap_or_default(),
325 ),
326 "image_url" | "input_image" => {
327 let url = value
328 .pointer("/image_url/url")
329 .or_else(|| value.get("image_url"))
330 .or_else(|| value.get("url"))
331 .and_then(Value::as_str)
332 .unwrap_or_default();
333 LlmContentPart::image(url)
334 }
335 "input_audio" | "audio" => {
336 let url = value
337 .pointer("/input_audio/data")
338 .or_else(|| value.pointer("/audio/url"))
339 .or_else(|| value.get("url"))
340 .and_then(Value::as_str)
341 .unwrap_or_default();
342 LlmContentPart::audio(url)
343 }
344 "file" | "input_file" => {
345 let file = value.get("file").or_else(|| value.get("input_file"));
346 let url = file
347 .and_then(|file| file.get("file_data").or_else(|| file.get("file_url")))
348 .or_else(|| value.get("url"))
349 .and_then(Value::as_str)
350 .unwrap_or_default();
351 let filename = file
352 .and_then(|file| file.get("filename"))
353 .and_then(Value::as_str)
354 .map(str::to_owned);
355 LlmContentPart::file(url, filename)
356 }
357 _ => LlmContentPart::text(
358 value
359 .get("text")
360 .and_then(Value::as_str)
361 .map_or_else(|| value.to_string(), str::to_owned),
362 ),
363 }
364}
365
366fn content_part_to_openai(part: &LlmContentPart) -> Option<Value> {
367 match part {
368 LlmContentPart::Text { text } => Some(json!({"type": "text", "text": text})),
369 LlmContentPart::Image { url } => {
370 Some(json!({"type": "image_url", "image_url": {"url": url}}))
371 }
372 LlmContentPart::Audio { url } => {
373 Some(json!({"type": "input_audio", "input_audio": {"data": url}}))
374 }
375 LlmContentPart::File { url, filename } => {
376 let mut file = Map::new();
377 file.insert("file_data".to_owned(), Value::String(url.clone()));
378 if let Some(filename) = filename {
379 file.insert("filename".to_owned(), Value::String(filename.clone()));
380 }
381 Some(json!({"type": "file", "file": Value::Object(file)}))
382 }
383 LlmContentPart::ProviderOpaque(_) => None,
384 }
385}
386
387pub(crate) fn empty_parameters() -> Value {
389 json!({"type": "object", "properties": {}})
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
397 fn a_plain_exchange_round_trips() {
398 let wire = json!([
399 {"role": "system", "content": "be brief"},
400 {"role": "user", "content": "hi"},
401 ]);
402 let messages = messages_from_openai(wire.as_array().unwrap()).unwrap();
403 assert_eq!(messages[0].role, MessageRole::System);
404 assert_eq!(messages[1].content.to_text(), "hi");
405 let back: Vec<Value> = messages.iter().map(message_to_openai).collect();
406 assert_eq!(Value::Array(back), wire);
407 }
408
409 #[test]
410 fn provider_opaque_content_is_not_written_to_openai_messages() {
411 let message = Message::parts(
412 MessageRole::Assistant,
413 vec![
414 LlmContentPart::ProviderOpaque(crate::message::ProviderOpaqueContent::new(
415 "anthropic",
416 json!([{"signature": "PRIVATE-SIGNATURE"}]),
417 )),
418 LlmContentPart::text("answer"),
419 ],
420 );
421
422 let wire = message_to_openai(&message);
423 assert_eq!(wire["content"], json!([{"type": "text", "text": "answer"}]));
424 assert!(!wire.to_string().contains("PRIVATE-SIGNATURE"));
425 }
426
427 #[test]
428 fn developer_is_read_as_a_system_message() {
429 let message = message_from_openai(&json!({"role": "developer", "content": "rules"}))
430 .expect("the newer system role name is still a system message");
431 assert_eq!(message.role, MessageRole::System);
432 }
433
434 #[test]
435 fn both_assistant_tool_call_shapes_are_read() {
436 let nested = message_from_openai(&json!({
437 "role": "assistant",
438 "content": null,
439 "tool_calls": [{
440 "id": "call_1",
441 "type": "function",
442 "function": {"name": "search", "arguments": "{\"q\":\"rust\"}"},
443 }],
444 }))
445 .unwrap();
446 let flat = message_from_openai(&json!({
447 "role": "assistant",
448 "tool_calls": [{"id": "call_1", "name": "search", "arguments": {"q": "rust"}}],
449 }))
450 .unwrap();
451 for message in [&nested, &flat] {
452 let calls = message.tool_calls.as_ref().expect("a tool call");
453 assert_eq!(calls[0].id, "call_1");
454 assert_eq!(calls[0].name, "search");
455 assert_eq!(calls[0].arguments, json!({"q": "rust"}));
456 }
457 }
458
459 #[test]
460 fn tool_call_arguments_go_back_out_json_encoded() {
461 let call = ToolCall {
462 id: "call_1".into(),
463 name: "search".into(),
464 arguments: json!({"q": "rust"}),
465 };
466 let wire = tool_call_to_openai(&call);
467 assert_eq!(
468 wire.pointer("/function/arguments").unwrap(),
469 &json!("{\"q\":\"rust\"}"),
470 "the API takes arguments as a string, not an object"
471 );
472 assert_eq!(tool_call_from_openai(&wire).unwrap(), call);
473 }
474
475 #[test]
476 fn arguments_that_are_not_json_survive_as_text() {
477 let call =
478 tool_call_from_openai(&json!({"name": "note", "arguments": "not json"})).unwrap();
479 assert_eq!(call.arguments, json!("not json"));
480 }
481
482 #[test]
483 fn a_tool_message_without_its_correlation_id_is_refused() {
484 let error = message_from_openai(&json!({"role": "tool", "content": "42"}))
485 .expect_err("the provider would reject this");
486 assert_eq!(
487 error,
488 OpenAiWireError::Missing {
489 field: "tool_call_id"
490 }
491 );
492 let ok = message_from_openai(&json!({
493 "role": "tool", "content": "42", "tool_call_id": "call_1",
494 }))
495 .unwrap();
496 assert_eq!(ok.tool_call_id.as_deref(), Some("call_1"));
497 }
498
499 #[test]
500 fn an_unknown_role_names_itself() {
501 let error = message_from_openai(&json!({"role": "narrator", "content": "…"}))
502 .expect_err("there is no such role");
503 assert_eq!(
504 error,
505 OpenAiWireError::UnsupportedRole {
506 role: "narrator".into()
507 }
508 );
509 }
510
511 #[test]
512 fn multimodal_parts_convert_instead_of_flattening_to_json_text() {
513 let wire = json!({
514 "role": "user",
515 "content": [
516 {"type": "text", "text": "what is this?"},
517 {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
518 {"type": "input_audio", "input_audio": {"data": "data:audio/wav;base64,BBBB"}},
519 {"type": "file", "file": {"file_data": "data:application/pdf;base64,CCCC",
520 "filename": "report.pdf"}},
521 ],
522 });
523 let message = message_from_openai(&wire).unwrap();
524 let MessageContent::Parts(parts) = &message.content else {
525 panic!("the parts array must stay parts");
526 };
527 assert_eq!(parts.len(), 4);
528 assert_eq!(
529 parts[1],
530 LlmContentPart::image("data:image/png;base64,AAAA")
531 );
532 assert_eq!(
533 parts[3],
534 LlmContentPart::file(
535 "data:application/pdf;base64,CCCC",
536 Some("report.pdf".into())
537 )
538 );
539 assert_eq!(message_to_openai(&message), wire);
540 }
541
542 #[test]
543 fn an_unrecognized_part_keeps_its_content_rather_than_vanishing() {
544 let message = message_from_openai(&json!({
545 "role": "user",
546 "content": [{"type": "video", "url": "https://example.com/clip.mp4"}],
547 }))
548 .unwrap();
549 assert!(
550 message.content.to_text().contains("clip.mp4"),
551 "an unknown part must not silently drop its content"
552 );
553 }
554
555 #[test]
556 fn tool_definitions_read_both_wrapped_and_bare() {
557 let wrapped = json!({
558 "type": "function",
559 "function": {
560 "name": "search",
561 "description": "look things up",
562 "parameters": {"type": "object", "properties": {"q": {"type": "string"}}},
563 },
564 });
565 let tool = tool_from_openai(&wrapped).unwrap();
566 assert_eq!(tool.name(), "search");
567 assert_eq!(tool.description(), "look things up");
568 assert_eq!(tool_to_openai(&tool), wrapped);
569
570 let bare = tool_from_openai(&json!({"name": "ping"})).unwrap();
571 assert_eq!(bare.name(), "ping");
572 assert_eq!(bare.parameters(), &empty_parameters());
573 }
574
575 #[test]
576 fn a_nameless_tool_is_refused() {
577 assert_eq!(
578 tool_from_openai(&json!({"description": "no name"})).unwrap_err(),
579 OpenAiWireError::Missing {
580 field: "function.name"
581 }
582 );
583 }
584}