use serde_json::{Map, Value, json};
use thiserror::Error;
use crate::driver_registry::{LlmContentPart, Message, MessageContent, MessageRole};
use crate::tool_types::{ToolCall, ToolDefinition};
#[derive(Debug, Clone, Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum OpenAiWireError {
#[error("missing `{field}`")]
Missing {
field: &'static str,
},
#[error("`{field}` is not {expected}")]
Invalid {
field: &'static str,
expected: &'static str,
},
#[error("unsupported message role: {role}")]
UnsupportedRole {
role: String,
},
}
impl From<OpenAiWireError> for crate::error::AgentLoopError {
fn from(error: OpenAiWireError) -> Self {
crate::error::AgentLoopError::config(error.to_string())
}
}
type Result<T> = std::result::Result<T, OpenAiWireError>;
pub fn message_from_openai(value: &Value) -> Result<Message> {
let object = value.as_object().ok_or(OpenAiWireError::Invalid {
field: "message",
expected: "an object",
})?;
let role = object
.get("role")
.and_then(Value::as_str)
.ok_or(OpenAiWireError::Missing { field: "role" })?;
let role = match role {
"system" | "developer" => MessageRole::System,
"user" => MessageRole::User,
"assistant" => MessageRole::Assistant,
"tool" | "function" => MessageRole::Tool,
other => {
return Err(OpenAiWireError::UnsupportedRole {
role: other.to_owned(),
});
}
};
let content = match object.get("content") {
None | Some(Value::Null) => MessageContent::Text(String::new()),
Some(Value::String(text)) => MessageContent::Text(text.clone()),
Some(Value::Array(parts)) => {
MessageContent::Parts(parts.iter().map(content_part_from_openai).collect())
}
Some(_) => {
return Err(OpenAiWireError::Invalid {
field: "content",
expected: "a string, an array of parts, or null",
});
}
};
let mut message = Message {
content,
..Message::text(role, "")
};
if let Some(calls) = object.get("tool_calls") {
let calls = calls.as_array().ok_or(OpenAiWireError::Invalid {
field: "tool_calls",
expected: "an array",
})?;
let parsed = calls
.iter()
.map(tool_call_from_openai)
.collect::<Result<Vec<_>>>()?;
if !parsed.is_empty() {
message.tool_calls = Some(parsed);
}
}
match object.get("tool_call_id").and_then(Value::as_str) {
Some(id) => message.tool_call_id = Some(id.to_owned()),
None if message.role == MessageRole::Tool => {
return Err(OpenAiWireError::Missing {
field: "tool_call_id",
});
}
None => {}
}
Ok(message)
}
pub fn messages_from_openai(values: &[Value]) -> Result<Vec<Message>> {
values.iter().map(message_from_openai).collect()
}
pub fn message_to_openai(message: &Message) -> Value {
let mut object = Map::new();
object.insert(
"role".to_owned(),
Value::String(
match message.role {
MessageRole::System => "system",
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
MessageRole::Tool => "tool",
}
.to_owned(),
),
);
let content = match &message.content {
MessageContent::Text(text) => Value::String(text.clone()),
MessageContent::Parts(parts) => {
Value::Array(parts.iter().filter_map(content_part_to_openai).collect())
}
};
object.insert("content".to_owned(), content);
if let Some(calls) = &message.tool_calls {
object.insert(
"tool_calls".to_owned(),
Value::Array(calls.iter().map(tool_call_to_openai).collect()),
);
}
if let Some(id) = &message.tool_call_id {
object.insert("tool_call_id".to_owned(), Value::String(id.clone()));
}
Value::Object(object)
}
pub fn tool_from_openai(value: &Value) -> Result<ToolDefinition> {
let function = value.get("function").unwrap_or(value);
let name = function
.get("name")
.and_then(Value::as_str)
.ok_or(OpenAiWireError::Missing {
field: "function.name",
})?;
let description = function
.get("description")
.and_then(Value::as_str)
.unwrap_or_default();
let parameters = function
.get("parameters")
.cloned()
.unwrap_or_else(empty_parameters);
Ok(ToolDefinition::function(name, description, parameters))
}
pub fn tools_from_openai(values: &[Value]) -> Result<Vec<ToolDefinition>> {
values.iter().map(tool_from_openai).collect()
}
pub fn tool_to_openai(tool: &ToolDefinition) -> Value {
json!({
"type": "function",
"function": {
"name": tool.name(),
"description": tool.description(),
"parameters": tool.parameters().clone(),
},
})
}
pub fn tool_call_from_openai(value: &Value) -> Result<ToolCall> {
let function = value.get("function").unwrap_or(value);
let name = function
.get("name")
.and_then(Value::as_str)
.or_else(|| value.get("name").and_then(Value::as_str))
.ok_or(OpenAiWireError::Missing { field: "name" })?;
let arguments = function
.get("arguments")
.or_else(|| value.get("arguments"))
.map(parse_arguments)
.unwrap_or_else(|| json!({}));
Ok(ToolCall {
id: value
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
name: name.to_owned(),
arguments,
})
}
pub fn tool_call_to_openai(call: &ToolCall) -> Value {
json!({
"id": call.id,
"type": "function",
"function": {
"name": call.name,
"arguments": serde_json::to_string(&call.arguments).unwrap_or_else(|_| "{}".to_owned()),
},
})
}
fn parse_arguments(value: &Value) -> Value {
match value {
Value::String(raw) => serde_json::from_str(raw).unwrap_or_else(|_| value.clone()),
other => other.clone(),
}
}
fn content_part_from_openai(value: &Value) -> LlmContentPart {
let kind = value.get("type").and_then(Value::as_str).unwrap_or("");
match kind {
"text" | "input_text" | "output_text" => LlmContentPart::text(
value
.get("text")
.and_then(Value::as_str)
.unwrap_or_default(),
),
"image_url" | "input_image" => {
let url = value
.pointer("/image_url/url")
.or_else(|| value.get("image_url"))
.or_else(|| value.get("url"))
.and_then(Value::as_str)
.unwrap_or_default();
LlmContentPart::image(url)
}
"input_audio" | "audio" => {
let url = value
.pointer("/input_audio/data")
.or_else(|| value.pointer("/audio/url"))
.or_else(|| value.get("url"))
.and_then(Value::as_str)
.unwrap_or_default();
LlmContentPart::audio(url)
}
"file" | "input_file" => {
let file = value.get("file").or_else(|| value.get("input_file"));
let url = file
.and_then(|file| file.get("file_data").or_else(|| file.get("file_url")))
.or_else(|| value.get("url"))
.and_then(Value::as_str)
.unwrap_or_default();
let filename = file
.and_then(|file| file.get("filename"))
.and_then(Value::as_str)
.map(str::to_owned);
LlmContentPart::file(url, filename)
}
_ => LlmContentPart::text(
value
.get("text")
.and_then(Value::as_str)
.map_or_else(|| value.to_string(), str::to_owned),
),
}
}
fn content_part_to_openai(part: &LlmContentPart) -> Option<Value> {
match part {
LlmContentPart::Text { text } => Some(json!({"type": "text", "text": text})),
LlmContentPart::Image { url } => {
Some(json!({"type": "image_url", "image_url": {"url": url}}))
}
LlmContentPart::Audio { url } => {
Some(json!({"type": "input_audio", "input_audio": {"data": url}}))
}
LlmContentPart::File { url, filename } => {
let mut file = Map::new();
file.insert("file_data".to_owned(), Value::String(url.clone()));
if let Some(filename) = filename {
file.insert("filename".to_owned(), Value::String(filename.clone()));
}
Some(json!({"type": "file", "file": Value::Object(file)}))
}
LlmContentPart::ProviderOpaque(_) => None,
}
}
pub(crate) fn empty_parameters() -> Value {
json!({"type": "object", "properties": {}})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plain_exchange_round_trips() {
let wire = json!([
{"role": "system", "content": "be brief"},
{"role": "user", "content": "hi"},
]);
let messages = messages_from_openai(wire.as_array().unwrap()).unwrap();
assert_eq!(messages[0].role, MessageRole::System);
assert_eq!(messages[1].content.to_text(), "hi");
let back: Vec<Value> = messages.iter().map(message_to_openai).collect();
assert_eq!(Value::Array(back), wire);
}
#[test]
fn provider_opaque_content_is_not_written_to_openai_messages() {
let message = Message::parts(
MessageRole::Assistant,
vec![
LlmContentPart::ProviderOpaque(crate::message::ProviderOpaqueContent::new(
"anthropic",
json!([{"signature": "PRIVATE-SIGNATURE"}]),
)),
LlmContentPart::text("answer"),
],
);
let wire = message_to_openai(&message);
assert_eq!(wire["content"], json!([{"type": "text", "text": "answer"}]));
assert!(!wire.to_string().contains("PRIVATE-SIGNATURE"));
}
#[test]
fn developer_is_read_as_a_system_message() {
let message = message_from_openai(&json!({"role": "developer", "content": "rules"}))
.expect("the newer system role name is still a system message");
assert_eq!(message.role, MessageRole::System);
}
#[test]
fn both_assistant_tool_call_shapes_are_read() {
let nested = message_from_openai(&json!({
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "search", "arguments": "{\"q\":\"rust\"}"},
}],
}))
.unwrap();
let flat = message_from_openai(&json!({
"role": "assistant",
"tool_calls": [{"id": "call_1", "name": "search", "arguments": {"q": "rust"}}],
}))
.unwrap();
for message in [&nested, &flat] {
let calls = message.tool_calls.as_ref().expect("a tool call");
assert_eq!(calls[0].id, "call_1");
assert_eq!(calls[0].name, "search");
assert_eq!(calls[0].arguments, json!({"q": "rust"}));
}
}
#[test]
fn tool_call_arguments_go_back_out_json_encoded() {
let call = ToolCall {
id: "call_1".into(),
name: "search".into(),
arguments: json!({"q": "rust"}),
};
let wire = tool_call_to_openai(&call);
assert_eq!(
wire.pointer("/function/arguments").unwrap(),
&json!("{\"q\":\"rust\"}"),
"the API takes arguments as a string, not an object"
);
assert_eq!(tool_call_from_openai(&wire).unwrap(), call);
}
#[test]
fn arguments_that_are_not_json_survive_as_text() {
let call =
tool_call_from_openai(&json!({"name": "note", "arguments": "not json"})).unwrap();
assert_eq!(call.arguments, json!("not json"));
}
#[test]
fn a_tool_message_without_its_correlation_id_is_refused() {
let error = message_from_openai(&json!({"role": "tool", "content": "42"}))
.expect_err("the provider would reject this");
assert_eq!(
error,
OpenAiWireError::Missing {
field: "tool_call_id"
}
);
let ok = message_from_openai(&json!({
"role": "tool", "content": "42", "tool_call_id": "call_1",
}))
.unwrap();
assert_eq!(ok.tool_call_id.as_deref(), Some("call_1"));
}
#[test]
fn an_unknown_role_names_itself() {
let error = message_from_openai(&json!({"role": "narrator", "content": "…"}))
.expect_err("there is no such role");
assert_eq!(
error,
OpenAiWireError::UnsupportedRole {
role: "narrator".into()
}
);
}
#[test]
fn multimodal_parts_convert_instead_of_flattening_to_json_text() {
let wire = json!({
"role": "user",
"content": [
{"type": "text", "text": "what is this?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
{"type": "input_audio", "input_audio": {"data": "data:audio/wav;base64,BBBB"}},
{"type": "file", "file": {"file_data": "data:application/pdf;base64,CCCC",
"filename": "report.pdf"}},
],
});
let message = message_from_openai(&wire).unwrap();
let MessageContent::Parts(parts) = &message.content else {
panic!("the parts array must stay parts");
};
assert_eq!(parts.len(), 4);
assert_eq!(
parts[1],
LlmContentPart::image("data:image/png;base64,AAAA")
);
assert_eq!(
parts[3],
LlmContentPart::file(
"data:application/pdf;base64,CCCC",
Some("report.pdf".into())
)
);
assert_eq!(message_to_openai(&message), wire);
}
#[test]
fn an_unrecognized_part_keeps_its_content_rather_than_vanishing() {
let message = message_from_openai(&json!({
"role": "user",
"content": [{"type": "video", "url": "https://example.com/clip.mp4"}],
}))
.unwrap();
assert!(
message.content.to_text().contains("clip.mp4"),
"an unknown part must not silently drop its content"
);
}
#[test]
fn tool_definitions_read_both_wrapped_and_bare() {
let wrapped = json!({
"type": "function",
"function": {
"name": "search",
"description": "look things up",
"parameters": {"type": "object", "properties": {"q": {"type": "string"}}},
},
});
let tool = tool_from_openai(&wrapped).unwrap();
assert_eq!(tool.name(), "search");
assert_eq!(tool.description(), "look things up");
assert_eq!(tool_to_openai(&tool), wrapped);
let bare = tool_from_openai(&json!({"name": "ping"})).unwrap();
assert_eq!(bare.name(), "ping");
assert_eq!(bare.parameters(), &empty_parameters());
}
#[test]
fn a_nameless_tool_is_refused() {
assert_eq!(
tool_from_openai(&json!({"description": "no name"})).unwrap_err(),
OpenAiWireError::Missing {
field: "function.name"
}
);
}
}