use serde::{Deserialize, Serialize};
use crate::backend::{
ChatMessage, OpenAICompatibleMessageContent, ResponseFormat,
build_openai_compatible_message_content,
};
use crate::error::Result;
#[derive(Debug, Serialize)]
pub(crate) struct OpenAICompatibleChatMessage {
pub role: String,
pub content: OpenAICompatibleMessageContent,
}
pub(crate) fn convert_openai_compatible_chat_messages(
messages: &[ChatMessage],
provider_name: &str,
) -> Result<Vec<OpenAICompatibleChatMessage>> {
messages
.iter()
.map(|msg| {
Ok(OpenAICompatibleChatMessage {
role: msg.role.as_str().to_string(),
content: build_openai_compatible_message_content(msg, provider_name)?,
})
})
.collect()
}
#[derive(Debug, Serialize)]
pub(crate) struct OpenAICompatibleChatCompletionRequest {
pub model: String,
pub messages: Vec<OpenAICompatibleChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<ResponseFormat>,
pub temperature: f32,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub(crate) struct OpenAICompatibleResponseMessage {
pub role: String,
pub content: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub(crate) struct OpenAICompatibleChatCompletionChoice {
pub message: OpenAICompatibleResponseMessage,
pub finish_reason: String,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub(crate) struct OpenAICompatibleUsageInfo {
pub prompt_tokens: u64,
pub completion_tokens: u64,
#[serde(default)]
pub total_tokens: u64,
}
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAICompatibleChatCompletionResponse {
pub choices: Vec<OpenAICompatibleChatCompletionChoice>,
#[serde(default)]
pub usage: Option<OpenAICompatibleUsageInfo>,
pub model: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::MediaFile;
#[test]
fn test_convert_openai_compatible_chat_messages_text_only() {
let messages = vec![ChatMessage::user("hello")];
let converted = convert_openai_compatible_chat_messages(&messages, "OpenAI")
.expect("conversion should succeed");
assert_eq!(converted.len(), 1);
assert_eq!(converted[0].role, "user");
let json = serde_json::to_value(&converted[0]).expect("serialization should succeed");
assert_eq!(json["content"], serde_json::json!("hello"));
}
#[test]
fn test_convert_openai_compatible_chat_messages_with_media() {
let messages = vec![ChatMessage::user_with_media(
"describe image",
vec![MediaFile::from_bytes(b"abc", "image/png")],
)];
let converted = convert_openai_compatible_chat_messages(&messages, "OpenAI")
.expect("conversion should succeed");
assert_eq!(converted.len(), 1);
let json = serde_json::to_value(&converted[0]).expect("serialization should succeed");
assert_eq!(json["content"][0]["type"], "text");
assert_eq!(json["content"][1]["type"], "image_url");
}
fn request_with_none_options() -> OpenAICompatibleChatCompletionRequest {
OpenAICompatibleChatCompletionRequest {
model: "test-model".to_string(),
messages: vec![OpenAICompatibleChatMessage {
role: "user".to_string(),
content: OpenAICompatibleMessageContent::Text("hi".to_string()),
}],
response_format: None,
temperature: 0.0,
max_tokens: None,
reasoning_effort: None,
}
}
#[test]
fn test_request_omits_max_tokens_when_none() {
let req = request_with_none_options();
let json = serde_json::to_value(&req).expect("serialization should succeed");
let obj = json.as_object().expect("request serializes to an object");
assert!(
!obj.contains_key("max_tokens"),
"max_tokens key must be omitted when None, got: {json}"
);
}
#[test]
fn test_request_includes_max_tokens_when_some() {
let mut req = request_with_none_options();
req.max_tokens = Some(1);
let json = serde_json::to_value(&req).expect("serialization should succeed");
assert_eq!(json["max_tokens"], serde_json::json!(1));
}
#[test]
fn test_request_omits_reasoning_effort_when_none() {
let req = request_with_none_options();
let json = serde_json::to_value(&req).expect("serialization should succeed");
let obj = json.as_object().expect("request serializes to an object");
assert!(
!obj.contains_key("reasoning_effort"),
"reasoning_effort key must be omitted when None, got: {json}"
);
}
#[test]
fn test_request_includes_reasoning_effort_when_some() {
let mut req = request_with_none_options();
req.reasoning_effort = Some("high".to_string());
let json = serde_json::to_value(&req).expect("serialization should succeed");
assert_eq!(json["reasoning_effort"], serde_json::json!("high"));
}
#[test]
fn test_request_omits_response_format_when_none() {
let req = request_with_none_options();
let json = serde_json::to_value(&req).expect("serialization should succeed");
let obj = json.as_object().expect("request serializes to an object");
assert!(
!obj.contains_key("response_format"),
"response_format key must be omitted when None, got: {json}"
);
}
#[test]
fn test_request_includes_response_format_when_some() {
let mut req = request_with_none_options();
req.response_format = Some(ResponseFormat::json_schema(
"Movie".to_string(),
serde_json::json!({"type": "object"}),
None,
));
let json = serde_json::to_value(&req).expect("serialization should succeed");
assert_eq!(json["response_format"]["type"], "json_schema");
}
#[test]
fn test_request_required_fields_present_with_all_none() {
let req = request_with_none_options();
let json = serde_json::to_value(&req).expect("serialization should succeed");
let obj = json.as_object().expect("request serializes to an object");
assert!(obj.contains_key("model"), "model must always be present");
assert!(
obj.contains_key("messages"),
"messages must always be present"
);
assert!(
obj.contains_key("temperature"),
"temperature must always be present"
);
}
#[test]
fn test_usage_info_total_tokens_defaults_to_zero_when_missing() {
let json = serde_json::json!({
"prompt_tokens": 3,
"completion_tokens": 5,
});
let usage: OpenAICompatibleUsageInfo =
serde_json::from_value(json).expect("deserialization should succeed");
assert_eq!(usage.prompt_tokens, 3);
assert_eq!(usage.completion_tokens, 5);
assert_eq!(usage.total_tokens, 0);
}
#[test]
fn test_usage_info_total_tokens_preserved_when_present() {
let json = serde_json::json!({
"prompt_tokens": 3,
"completion_tokens": 5,
"total_tokens": 8,
});
let usage: OpenAICompatibleUsageInfo =
serde_json::from_value(json).expect("deserialization should succeed");
assert_eq!(usage.total_tokens, 8);
}
}