use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tracing::{debug, info};
use crate::types::{
ChatMessage, ChatRequest, ChatResponse, LlmCapabilities, ResponseFormat, RunnerError,
StreamChunk, TokenUsage, ToolCallRequest, ToolChoice, ToolDefinition,
};
const FUNCTION_TYPE: &str = "function";
#[derive(Debug, Serialize)]
pub(super) struct CompletionRequest {
pub(super) model: String,
pub(super) messages: Vec<WireMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
stop: Option<Vec<String>>,
pub(super) stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) tools: Option<Vec<WireTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
response_format: Option<Value>,
}
impl CompletionRequest {
pub(super) fn new(
request: &ChatRequest,
default_model: &str,
stream: bool,
capabilities: LlmCapabilities,
) -> Self {
let vision = capabilities.supports_vision();
let tools: Option<Vec<WireTool>> = request
.tools
.as_deref()
.filter(|t| !t.is_empty())
.map(|defs| defs.iter().map(WireTool::from).collect());
let tool_choice = tools.as_ref().map(|_| {
request
.tool_choice
.as_ref()
.map_or_else(|| Value::String("auto".to_owned()), tool_choice_value)
});
Self {
model: request.model.as_deref().unwrap_or(default_model).to_owned(),
messages: request
.messages
.iter()
.map(|msg| WireMessage::new(msg, vision))
.collect(),
temperature: request.temperature,
max_tokens: request.max_tokens,
top_p: request.top_p.filter(|_| capabilities.supports_top_p()),
stop: request
.stop
.clone()
.filter(|_| capabilities.supports_stop_sequences()),
stream,
tools,
tool_choice,
response_format: request
.response_format
.as_ref()
.filter(|_| capabilities.supports_response_format())
.map(response_format_value),
}
}
}
#[derive(Debug, Serialize)]
pub(super) struct WireMessage {
pub(super) role: &'static str,
content: Value,
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<WireToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
}
impl WireMessage {
fn new(msg: &ChatMessage, vision: bool) -> Self {
let images = msg
.images
.as_deref()
.filter(|imgs| vision && !imgs.is_empty());
let content = if msg.content.is_empty() && msg.tool_calls.is_some() {
Value::Null
} else if let Some(images) = images {
let mut parts = vec![json!({ "type": "text", "text": msg.content })];
parts.extend(images.iter().map(|img| {
json!({
"type": "image_url",
"image_url": { "url": format!("data:{};base64,{}", img.mime_type, img.data) },
})
}));
Value::Array(parts)
} else {
Value::String(msg.content.clone())
};
Self {
role: msg.role.as_str(),
content,
tool_calls: msg
.tool_calls
.as_ref()
.map(|calls| calls.iter().map(WireToolCall::from).collect()),
tool_call_id: msg.tool_call_id.clone(),
}
}
}
#[derive(Debug, Serialize)]
struct WireToolCall {
id: String,
#[serde(rename = "type")]
call_type: &'static str,
function: WireFunctionCall,
}
impl From<&ToolCallRequest> for WireToolCall {
fn from(call: &ToolCallRequest) -> Self {
Self {
id: call.id.clone(),
call_type: FUNCTION_TYPE,
function: WireFunctionCall {
name: call.function_name.clone(),
arguments: serde_json::to_string(&call.arguments).unwrap_or_default(),
},
}
}
}
#[derive(Debug, Serialize)]
struct WireFunctionCall {
name: String,
arguments: String,
}
#[derive(Debug, Serialize)]
pub(super) struct WireTool {
#[serde(rename = "type")]
tool_type: &'static str,
function: WireFunctionDef,
}
impl From<&ToolDefinition> for WireTool {
fn from(def: &ToolDefinition) -> Self {
Self {
tool_type: FUNCTION_TYPE,
function: WireFunctionDef {
name: def.name.clone(),
description: def.description.clone(),
parameters: def.parameters.clone(),
},
}
}
}
#[derive(Debug, Serialize)]
struct WireFunctionDef {
name: String,
description: String,
#[serde(skip_serializing_if = "Option::is_none")]
parameters: Option<Value>,
}
fn tool_choice_value(choice: &ToolChoice) -> Value {
match choice {
ToolChoice::Auto => Value::String("auto".to_owned()),
ToolChoice::None => Value::String("none".to_owned()),
ToolChoice::Required => Value::String("required".to_owned()),
ToolChoice::Specific { name } => {
json!({ "type": FUNCTION_TYPE, "function": { "name": name } })
}
}
}
fn response_format_value(format: &ResponseFormat) -> Value {
match format {
ResponseFormat::Text => json!({ "type": "text" }),
ResponseFormat::JsonObject => json!({ "type": "json_object" }),
ResponseFormat::JsonSchema { name, schema } => json!({
"type": "json_schema",
"json_schema": { "name": name, "schema": schema },
}),
}
}
#[derive(Debug, Deserialize)]
struct CompletionResponse {
model: String,
choices: Vec<Choice>,
#[serde(default)]
usage: Option<Usage>,
}
#[derive(Debug, Deserialize)]
struct Choice {
message: ResponseMessage,
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ResponseMessage {
content: Option<String>,
#[serde(default)]
tool_calls: Option<Vec<ToolCall>>,
}
#[derive(Debug, Deserialize)]
struct ToolCall {
id: String,
#[serde(rename = "type", default)]
call_type: Option<String>,
function: FunctionCall,
}
#[derive(Debug, Deserialize)]
struct FunctionCall {
name: String,
arguments: String,
}
#[derive(Debug, Deserialize)]
pub(super) struct Usage {
#[serde(rename = "prompt_tokens")]
prompt: u32,
#[serde(rename = "completion_tokens")]
completion: u32,
#[serde(rename = "total_tokens")]
total: u32,
#[serde(default)]
prompt_tokens_details: Option<PromptTokensDetails>,
#[serde(default)]
completion_tokens_details: Option<CompletionTokensDetails>,
}
#[derive(Debug, Deserialize)]
struct PromptTokensDetails {
#[serde(default)]
cached_tokens: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct CompletionTokensDetails {
#[serde(default)]
reasoning_tokens: Option<u32>,
}
impl Usage {
pub(super) fn with_details(self) -> TokenUsage {
TokenUsage::new(self.prompt, self.completion, self.total)
.with_cache(
self.prompt_tokens_details.and_then(|d| d.cached_tokens),
None,
)
.with_reasoning(
self.completion_tokens_details
.and_then(|d| d.reasoning_tokens),
)
}
pub(super) fn counts_only(self) -> TokenUsage {
TokenUsage::new(self.prompt, self.completion, self.total)
}
}
pub(super) fn parse_response(
provider: &'static str,
body: &str,
usage: fn(Usage) -> TokenUsage,
) -> Result<ChatResponse, RunnerError> {
let response: CompletionResponse = serde_json::from_str(body).map_err(|e| {
RunnerError::external_service(provider, format!("Failed to parse response: {e}"))
})?;
let choice = response
.choices
.into_iter()
.next()
.ok_or_else(|| RunnerError::external_service(provider, "API returned no choices"))?;
let content = choice.message.content.unwrap_or_default();
let tool_calls = choice.message.tool_calls.map(|calls| {
info!(provider, count = calls.len(), "response carries tool calls");
tool_call_requests(provider, &calls)
});
debug!(
provider,
content_len = content.len(),
tool_calls = tool_calls.as_ref().map(Vec::len),
finish_reason = ?choice.finish_reason,
"Received response"
);
Ok(ChatResponse {
content,
model: response.model,
usage: response.usage.map(usage),
finish_reason: choice.finish_reason,
warnings: None,
tool_calls,
})
}
fn tool_call_requests(provider: &'static str, calls: &[ToolCall]) -> Vec<ToolCallRequest> {
calls
.iter()
.map(|call| {
debug!(
provider,
tool_call_id = %call.id,
tool_call_type = ?call.call_type,
function_name = %call.function.name,
"Converting tool call"
);
ToolCallRequest {
id: call.id.clone(),
function_name: call.function.name.clone(),
arguments: serde_json::from_str(&call.function.arguments).unwrap_or_default(),
}
})
.collect()
}
#[derive(Debug, Deserialize)]
struct StreamFrame {
choices: Vec<StreamChoice>,
}
#[derive(Debug, Deserialize)]
struct StreamChoice {
#[serde(default)]
delta: Delta,
finish_reason: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct Delta {
#[serde(default)]
content: Option<String>,
}
pub(super) fn parse_stream_frame(
provider: &'static str,
json: &str,
) -> Option<Result<StreamChunk, RunnerError>> {
match serde_json::from_str::<StreamFrame>(json) {
Ok(frame) => {
let choice = frame.choices.into_iter().next()?;
Some(Ok(StreamChunk {
delta: choice.delta.content.unwrap_or_default(),
is_final: choice.finish_reason.is_some(),
finish_reason: choice.finish_reason,
}))
}
Err(e) => Some(Err(RunnerError::external_service(
provider,
format!("SSE parse error: {e}"),
))),
}
}
#[derive(Debug, Deserialize)]
struct ErrorEnvelope {
error: ErrorDetail,
}
#[derive(Debug, Deserialize)]
pub(super) struct ErrorDetail {
pub(super) message: String,
#[serde(rename = "type")]
error_type: Option<String>,
}
impl ErrorDetail {
pub(super) fn typed_message(&self) -> String {
format!(
"{} - {}",
self.error_type.as_deref().unwrap_or("unknown"),
self.message
)
}
}
pub(super) fn error_detail(body: &str) -> Option<ErrorDetail> {
serde_json::from_str::<ErrorEnvelope>(body)
.ok()
.map(|envelope| envelope.error)
}
pub(super) fn describe_error(provider: &'static str, status: StatusCode, body: &str) -> String {
error_detail(body).map_or_else(
|| {
debug!(
provider,
status = status.as_u16(),
body_preview = %body.chars().take(200).collect::<String>(),
"non-JSON error response"
);
format!("HTTP {status}")
},
|detail| detail.typed_message(),
)
}