use serde_json::json;
use crate::error::Error;
use crate::schema::{to_json_schema, Schema};
use crate::structured::{StructuredRequest, StructuredResponse};
use crate::text::request::{TextRequest, ToolChoice};
use crate::text::response::Step;
use crate::tool::Tool;
use crate::value_objects::{
FinishReason, Media, MediaData, MediaPart, Message, Meta, ToolCall, ToolOutcome, ToolResult,
Usage, UserMessage,
};
use super::wire::{
ChatChoice, ChatMessage, ChatRequest, ChatResponse, ChatTool, ChatToolCall,
ChatToolCallFunction, ChatToolFunction, ImageUrl, UserContent, UserContentPart,
};
pub fn build_request(request: &TextRequest) -> ChatRequest {
let mut messages = Vec::new();
for system_prompt in &request.system_prompts {
messages.push(ChatMessage::System {
content: system_prompt.clone(),
});
}
for message in &request.messages {
push_message(&mut messages, message);
}
ChatRequest {
model: request.model.clone(),
messages,
max_tokens: request.max_tokens,
temperature: request.temperature,
top_p: request.top_p,
tools: request
.tools
.iter()
.map(|tool| to_wire_tool(tool.as_ref()))
.collect(),
tool_choice: to_wire_tool_choice(&request.tool_choice, request.tools.is_empty()),
stream: None,
stream_options: None,
response_format: None,
reasoning_effort: request.reasoning_effort.clone(),
stop: request.stop_sequences.clone(),
seed: request.seed,
}
}
pub fn build_structured_request(request: &StructuredRequest) -> ChatRequest {
let mut messages = Vec::new();
for system_prompt in &request.system_prompts {
messages.push(ChatMessage::System {
content: system_prompt.clone(),
});
}
for message in &request.messages {
push_message(&mut messages, message);
}
ChatRequest {
model: request.model.clone(),
messages,
max_tokens: request.max_tokens,
temperature: request.temperature,
top_p: request.top_p,
tools: Vec::new(),
tool_choice: None,
stream: None,
stream_options: None,
response_format: Some(json!({
"type": "json_schema",
"json_schema": {
"name": request.schema.name,
"schema": to_json_schema(&Schema::Object(request.schema.clone())),
"strict": true,
}
})),
reasoning_effort: request.reasoning_effort.clone(),
stop: Vec::new(),
seed: request.seed,
}
}
fn push_message(messages: &mut Vec<ChatMessage>, message: &Message) {
match message {
Message::System(system) => messages.push(ChatMessage::System {
content: system.content.clone(),
}),
Message::User(user) => messages.push(ChatMessage::User {
content: user_content(user),
}),
Message::Assistant(assistant) => messages.push(ChatMessage::Assistant {
content: assistant.content.clone(),
tool_calls: assistant.tool_calls.iter().map(to_wire_tool_call).collect(),
}),
Message::ToolResult(tool_result) => {
for result in &tool_result.tool_results {
messages.push(ChatMessage::Tool {
tool_call_id: result.tool_call_id.clone(),
content: tool_result_text(result),
});
}
}
}
}
pub(crate) fn user_content(user: &UserMessage) -> UserContent {
let has_image = user
.content
.iter()
.any(|part| matches!(part, MediaPart::Image(_)));
if !has_image {
return UserContent::Text(user_text(user));
}
let parts = user
.content
.iter()
.filter_map(|part| match part {
MediaPart::Text(text) => Some(UserContentPart::Text { text: text.clone() }),
MediaPart::Image(media) => Some(UserContentPart::ImageUrl {
image_url: ImageUrl {
url: image_url_string(media),
},
}),
MediaPart::Document(_) | MediaPart::Audio(_) | MediaPart::Video(_) => None,
})
.collect();
UserContent::Parts(parts)
}
fn image_url_string(media: &Media) -> String {
match &media.data {
MediaData::Url(url) => url.clone(),
MediaData::Base64(data) => {
let mime_type = media.mime_type.as_deref().unwrap_or("image/png");
format!("data:{mime_type};base64,{data}")
}
}
}
fn user_text(user: &UserMessage) -> String {
user.content
.iter()
.filter_map(|part| match part {
MediaPart::Text(text) => Some(text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
}
fn tool_result_text(result: &ToolResult) -> String {
match &result.result {
ToolOutcome::Output(output) => output.content.clone(),
ToolOutcome::Error(message) => message.clone(),
}
}
fn to_wire_tool_call(call: &ToolCall) -> ChatToolCall {
ChatToolCall {
id: call.id.clone(),
kind: "function".to_string(),
function: ChatToolCallFunction {
name: call.name.clone(),
arguments: call.arguments.to_string(),
},
}
}
fn to_wire_tool(tool: &dyn Tool) -> ChatTool {
ChatTool {
kind: "function",
function: ChatToolFunction {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: to_json_schema(&crate::schema::Schema::Object(tool.parameters().clone())),
},
}
}
fn to_wire_tool_choice(choice: &ToolChoice, no_tools: bool) -> Option<serde_json::Value> {
if no_tools {
return None;
}
match choice {
ToolChoice::Auto => Some(json!("auto")),
ToolChoice::None => Some(json!("none")),
ToolChoice::Any => Some(json!("required")),
ToolChoice::Tool(name) => Some(json!({"type": "function", "function": {"name": name}})),
}
}
pub fn parse_response(response: ChatResponse) -> Step {
let ChatResponse {
id,
model,
choices,
usage,
} = response;
let choice: ChatChoice = choices
.into_iter()
.next()
.expect("chat completion response returns at least one choice");
let tool_calls: Vec<ToolCall> = choice
.message
.tool_calls
.iter()
.map(from_wire_tool_call)
.collect();
let finish_reason = map_finish_reason(&choice.finish_reason);
let usage = usage.map(map_usage).unwrap_or_default();
Step {
text: choice.message.content,
tool_calls,
finish_reason,
usage,
meta: Meta {
id: Some(id),
model: Some(model),
rate_limits: Vec::new(),
},
}
}
pub fn parse_structured_response(
response: ChatResponse,
provider_name: &str,
) -> Result<StructuredResponse, Error> {
let ChatResponse {
id,
model,
choices,
usage,
} = response;
let choice: ChatChoice = choices
.into_iter()
.next()
.expect("chat completion response returns at least one choice");
if let Some(refusal) = choice.message.refusal {
return Err(Error::Provider {
provider: provider_name.to_string(),
status: 0,
kind: Some("refusal".to_string()),
message: refusal,
});
}
let finish_reason = map_finish_reason(&choice.finish_reason);
let response_usage = usage.map(map_usage).unwrap_or_default();
let meta = Meta {
id: Some(id),
model: Some(model),
rate_limits: Vec::new(),
};
let content = choice
.message
.content
.ok_or_else(|| Error::StructuredDecode {
provider: provider_name.to_string(),
message: "response contained no content".to_string(),
context: Box::new(crate::error::StructuredDecodeContext {
raw: String::new(),
finish_reason,
usage: response_usage,
meta: meta.clone(),
}),
})?;
let data = serde_json::from_str(&content).map_err(|e| Error::StructuredDecode {
provider: provider_name.to_string(),
message: e.to_string(),
context: Box::new(crate::error::StructuredDecodeContext {
raw: content.clone(),
finish_reason,
usage: response_usage,
meta: meta.clone(),
}),
})?;
Ok(StructuredResponse {
data,
finish_reason,
usage: response_usage,
meta,
})
}
fn from_wire_tool_call(call: &ChatToolCall) -> ToolCall {
ToolCall {
id: call.id.clone(),
name: call.function.name.clone(),
arguments: serde_json::from_str(&call.function.arguments)
.unwrap_or(serde_json::Value::Null),
}
}
pub(crate) fn map_finish_reason(finish_reason: &str) -> FinishReason {
match finish_reason {
"stop" => FinishReason::Stop,
"length" => FinishReason::Length,
"tool_calls" => FinishReason::ToolCalls,
"content_filter" => FinishReason::ContentFilter,
_ => FinishReason::Other,
}
}
pub(crate) fn map_usage(usage: super::wire::ChatUsage) -> Usage {
Usage {
prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_tokens,
cache_write_tokens: None,
cache_read_tokens: usage.prompt_tokens_details.and_then(|d| d.cached_tokens),
thought_tokens: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_content_stays_a_plain_string_for_text_only_messages() {
let user = UserMessage::text("hello");
assert!(matches!(user_content(&user), UserContent::Text(text) if text == "hello"));
}
#[test]
fn user_content_switches_to_parts_when_an_image_is_present() {
let user = UserMessage {
content: vec![
MediaPart::Text("What's in this image?".to_string()),
MediaPart::Image(Media {
mime_type: None,
data: MediaData::Base64("aGVsbG8=".to_string()),
}),
MediaPart::Document(Media {
mime_type: Some("application/pdf".to_string()),
data: MediaData::Url("https://example.com/doc.pdf".to_string()),
}),
],
};
let UserContent::Parts(parts) = user_content(&user) else {
panic!("expected UserContent::Parts once an image is present");
};
assert_eq!(parts.len(), 2);
assert!(matches!(parts[0], UserContentPart::Text { .. }));
assert!(matches!(
&parts[1],
UserContentPart::ImageUrl { image_url } if image_url.url == "data:image/png;base64,aGVsbG8="
));
}
#[test]
fn image_url_string_passes_a_real_url_through_unchanged() {
let media = Media {
mime_type: None,
data: MediaData::Url("https://example.com/cat.png".to_string()),
};
assert_eq!(image_url_string(&media), "https://example.com/cat.png");
}
#[test]
fn build_request_omits_reasoning_effort_by_default() {
let request = TextRequest::new("gpt-4o-mini");
let wire_request = build_request(&request);
assert!(wire_request.reasoning_effort.is_none());
}
#[test]
fn build_request_passes_reasoning_effort_through_when_set() {
let mut request = TextRequest::new("o3-mini");
request.reasoning_effort = Some("high".to_string());
let wire_request = build_request(&request);
assert_eq!(wire_request.reasoning_effort.as_deref(), Some("high"));
}
#[test]
fn build_request_passes_stop_sequences_and_seed_through() {
let mut request = TextRequest::new("gpt-4o-mini");
request.stop_sequences = vec!["STOP".to_string(), "END".to_string()];
request.seed = Some(42);
let wire_request = build_request(&request);
assert_eq!(wire_request.stop, vec!["STOP", "END"]);
assert_eq!(wire_request.seed, Some(42));
}
}