use serde_json::{json, Map, Value};
use crate::canonical::{
CanonicalError, CanonicalRequest, Content, DocumentSource, ErrorKind, ImageSource, Role,
};
use crate::protocol::json::to_json_string;
pub(super) fn messages_value(req: &CanonicalRequest) -> Result<Value, CanonicalError> {
let mut out = Vec::new();
if let Some(system) = req.system.as_ref().filter(|s| !s.is_empty()) {
out.push(json!({"role": "system", "content": content_value(system, false, "system")?}));
}
for m in &req.messages {
match m.role {
Role::System => out.push(
json!({"role": "system", "content": content_value(&m.content, false, "system")?}),
),
Role::User => out
.push(json!({"role": "user", "content": content_value(&m.content, true, "user")?})),
Role::Assistant => out.push(assistant_message(&m.content)?),
Role::Tool => tool_messages(&m.content, &mut out)?,
}
}
Ok(Value::Array(out))
}
fn assistant_message(content: &[Content]) -> Result<Value, CanonicalError> {
let mut parts = Vec::new();
let mut calls = Vec::new();
for c in content {
match c {
Content::ToolUse {
id, name, input, ..
} => calls.push(json!({
"id": id, "type": "function",
"function": {"name": name, "arguments": to_json_string(input)},
})),
Content::Thinking { .. } | Content::RedactedThinking { .. } => {} other => parts.push(other.clone()),
}
}
let mut obj = Map::new();
obj.insert("role".into(), json!("assistant"));
if !calls.is_empty() {
obj.insert("tool_calls".into(), Value::Array(calls));
}
if !parts.is_empty() {
obj.insert("content".into(), content_value(&parts, true, "assistant")?);
} else if obj.get("tool_calls").is_none() {
obj.insert("content".into(), json!("")); }
Ok(Value::Object(obj))
}
fn tool_messages(content: &[Content], out: &mut Vec<Value>) -> Result<(), CanonicalError> {
for c in content {
let Content::ToolResult {
tool_use_id,
content,
is_error,
} = c
else {
return Err(slot_err("tool"));
};
let mut text = tool_result_text(content)?;
if *is_error {
text = format!("[error] {text}");
}
out.push(json!({"role": "tool", "tool_call_id": tool_use_id, "content": text}));
}
Ok(())
}
fn content_value(
parts: &[Content],
allow_media: bool,
slot: &str,
) -> Result<Value, CanonicalError> {
if let [Content::Text(t)] = parts {
return Ok(json!(t)); }
let mut arr = Vec::new();
for p in parts {
match p {
Content::Text(t) => arr.push(json!({"type": "text", "text": t})),
Content::Image { source } if allow_media => {
arr.push(json!({"type": "image_url", "image_url": image_url(source)}))
}
Content::Document { source } if allow_media => arr.push(document_file(source)?),
_ => return Err(slot_err(slot)),
}
}
Ok(Value::Array(arr))
}
fn tool_result_text(content: &[Content]) -> Result<String, CanonicalError> {
let mut text = String::new();
for c in content {
match c {
Content::Text(t) => text.push_str(t),
_ => return Err(slot_err("tool_result")),
}
}
Ok(text)
}
fn image_url(source: &ImageSource) -> Value {
match source {
ImageSource::Base64 { media_type, data } => {
json!({"url": format!("data:{media_type};base64,{data}")})
}
ImageSource::Url { url } => json!({"url": url}),
}
}
fn document_file(source: &DocumentSource) -> Result<Value, CanonicalError> {
match source {
DocumentSource::Base64 { media_type, data } => Ok(json!({
"type": "file",
"file": {
"filename": doc_filename(media_type),
"file_data": format!("data:{media_type};base64,{data}"),
},
})),
DocumentSource::Url { .. } => Err(url_document_err()),
}
}
fn doc_filename(media_type: &str) -> String {
format!(
"document.{}",
media_type.rsplit('/').next().unwrap_or("bin")
)
}
fn url_document_err() -> CanonicalError {
CanonicalError {
kind: ErrorKind::ParseInput,
message: "openai chat: document URLs are not supported — Chat Completions file inputs \
accept only base64 file_data or an uploaded file_id, not a web URL; use the \
Responses API or send the document as a base64 document"
.to_string(),
provider_detail: None,
retry_after_seconds: None,
}
}
fn slot_err(slot: &str) -> CanonicalError {
CanonicalError {
kind: ErrorKind::ParseInput,
message: format!("{slot} accepts only text content"),
provider_detail: None,
retry_after_seconds: None,
}
}