diff --git a/src/api/completions.rs b/src/api/completions.rs index 0e09b47..c2924bf 100644 --- a/src/api/completions.rs +++ b/src/api/completions.rs @@ -18,20 +18,26 @@ use super::AppState; // ─── Input extraction ──────────────────────────────────────────────────────── /// Extract user text from Chat Completions messages array. +/// +/// When tool results are present, builds the full conversation including +/// tool call results so the model can continue after tool use. fn extract_chat_input(messages: &[CompletionMessage]) -> String { + let has_tool_results = messages.iter().any(|m| m.role == "tool"); + + if has_tool_results { + // Build full conversation context including tool results + return build_conversation_with_tools(messages); + } + + // Simple path: no tools, just extract system + last user message let mut system_parts = Vec::new(); let mut user_parts = Vec::new(); for msg in messages { - let text = match &msg.content { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Array(arr) => arr - .iter() - .filter_map(|item| item["text"].as_str()) - .collect::>() - .join("\n"), - _ => continue, - }; + let text = extract_message_text(&msg.content); + if text.is_empty() { + continue; + } match msg.role.as_str() { "system" | "developer" => system_parts.push(text), "user" => user_parts.push(text), @@ -44,13 +50,87 @@ fn extract_chat_input(messages: &[CompletionMessage]) -> String { result.push_str(&system_parts.join("\n")); result.push_str("\n\n"); } - // Use the last user message if let Some(last) = user_parts.last() { result.push_str(last); } result.trim().to_string() } +/// Extract text content from a message's content field (string or array). +fn extract_message_text(content: &serde_json::Value) -> String { + match content { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Array(arr) => arr + .iter() + .filter_map(|item| item["text"].as_str()) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +/// Build conversation text that includes tool call results. +/// +/// Format: +/// [system prompt] +/// [user message] +/// [assistant called tool X with args Y] +/// [tool result: Z] +/// [user followup if any] +fn build_conversation_with_tools(messages: &[CompletionMessage]) -> String { + let mut parts = Vec::new(); + + for msg in messages { + match msg.role.as_str() { + "system" | "developer" => { + let text = extract_message_text(&msg.content); + if !text.is_empty() { + parts.push(text); + } + } + "user" => { + let text = extract_message_text(&msg.content); + if !text.is_empty() { + parts.push(text); + } + } + "assistant" => { + // Include assistant text if any + let text = extract_message_text(&msg.content); + if !text.is_empty() { + parts.push(text); + } + // Include tool calls as context + if let Some(ref tool_calls) = msg.tool_calls { + for tc in tool_calls { + if let Some(func) = tc.get("function") { + let name = func["name"].as_str().unwrap_or("unknown"); + let args = func["arguments"].as_str().unwrap_or("{}"); + parts.push(format!( + "[Tool call: {}({})]", + name, args + )); + } + } + } + } + "tool" => { + let text = extract_message_text(&msg.content); + let tool_id = msg.tool_call_id.as_deref().unwrap_or("unknown"); + if !text.is_empty() { + parts.push(format!( + "[Tool result ({})]:\n{}", + tool_id, text + )); + } + } + _ => {} + } + } + + parts.join("\n\n") +} + // ─── Handler ───────────────────────────────────────────────────────────────── /// POST /v1/chat/completions — OpenAI Chat Completions API compatibility shim. diff --git a/src/api/types.rs b/src/api/types.rs index 87c429f..8bdb016 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -58,7 +58,12 @@ pub(crate) struct CompletionRequest { #[derive(Deserialize)] pub(crate) struct CompletionMessage { pub role: String, + #[serde(default)] pub content: serde_json::Value, + /// Tool calls made by the assistant (for assistant messages) + pub tool_calls: Option>, + /// Tool call ID this message is responding to (for tool messages) + pub tool_call_id: Option, } fn default_timeout() -> u64 {