feat: add tool-calling passthrough for all providers
Implement full OpenAI-compatible tool-calling support across the proxy, enabling OpenCode to use llm-proxy as its sole LLM backend. - Add 9 tool-calling types (Tool, FunctionDef, ToolChoice, ToolCall, etc.) - Update ChatCompletionRequest/ChatMessage/ChatStreamDelta with tool fields - Update UnifiedRequest/UnifiedMessage to carry tool data through the pipeline - Shared helpers: messages_to_openai_json handles tool messages, build_openai_body includes tools/tool_choice, parse/stream extract tool_calls from responses - Gemini: full OpenAI<->Gemini format translation (functionDeclarations, functionCall/functionResponse, synthetic call IDs, tool_config mapping) - Gemini: extract duplicated message-conversion into shared convert_messages() - Server: SSE streams include tool_calls deltas, finish_reason='tool_calls' - AggregatingStream: accumulate tool call deltas across stream chunks - OpenAI provider: add o4- prefix to supports_model()
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
use super::{ProviderResponse, ProviderStreamChunk};
|
||||
use crate::errors::AppError;
|
||||
use crate::models::{ContentPart, UnifiedMessage, UnifiedRequest};
|
||||
use crate::models::{ContentPart, ToolCall, ToolCallDelta, UnifiedMessage, UnifiedRequest};
|
||||
use futures::stream::{BoxStream, StreamExt};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -8,9 +8,37 @@ use serde_json::Value;
|
||||
///
|
||||
/// This avoids the deadlock caused by `futures::executor::block_on` inside a
|
||||
/// Tokio async context. All image base64 conversions are awaited properly.
|
||||
/// Handles tool-calling messages: assistant messages with tool_calls, and
|
||||
/// tool-role messages with tool_call_id/name.
|
||||
pub async fn messages_to_openai_json(messages: &[UnifiedMessage]) -> Result<Vec<serde_json::Value>, AppError> {
|
||||
let mut result = Vec::new();
|
||||
for m in messages {
|
||||
// Tool-role messages: { role: "tool", content: "...", tool_call_id: "...", name: "..." }
|
||||
if m.role == "tool" {
|
||||
let text_content = m
|
||||
.content
|
||||
.first()
|
||||
.map(|p| match p {
|
||||
ContentPart::Text { text } => text.clone(),
|
||||
ContentPart::Image(_) => "[Image]".to_string(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut msg = serde_json::json!({
|
||||
"role": "tool",
|
||||
"content": text_content
|
||||
});
|
||||
if let Some(tool_call_id) = &m.tool_call_id {
|
||||
msg["tool_call_id"] = serde_json::json!(tool_call_id);
|
||||
}
|
||||
if let Some(name) = &m.name {
|
||||
msg["name"] = serde_json::json!(name);
|
||||
}
|
||||
result.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build content parts for non-tool messages
|
||||
let mut parts = Vec::new();
|
||||
for p in &m.content {
|
||||
match p {
|
||||
@@ -29,10 +57,26 @@ pub async fn messages_to_openai_json(messages: &[UnifiedMessage]) -> Result<Vec<
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push(serde_json::json!({
|
||||
"role": m.role,
|
||||
"content": parts
|
||||
}));
|
||||
|
||||
let mut msg = serde_json::json!({ "role": m.role });
|
||||
|
||||
// For assistant messages with tool_calls, content can be null
|
||||
if let Some(tool_calls) = &m.tool_calls {
|
||||
if parts.is_empty() {
|
||||
msg["content"] = serde_json::Value::Null;
|
||||
} else {
|
||||
msg["content"] = serde_json::json!(parts);
|
||||
}
|
||||
msg["tool_calls"] = serde_json::json!(tool_calls);
|
||||
} else {
|
||||
msg["content"] = serde_json::json!(parts);
|
||||
}
|
||||
|
||||
if let Some(name) = &m.name {
|
||||
msg["name"] = serde_json::json!(name);
|
||||
}
|
||||
|
||||
result.push(msg);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -65,6 +109,7 @@ pub async fn messages_to_openai_json_text_only(
|
||||
}
|
||||
|
||||
/// Build an OpenAI-compatible request body from a UnifiedRequest and pre-converted messages.
|
||||
/// Includes tools and tool_choice when present.
|
||||
pub fn build_openai_body(
|
||||
request: &UnifiedRequest,
|
||||
messages_json: Vec<serde_json::Value>,
|
||||
@@ -82,11 +127,18 @@ pub fn build_openai_body(
|
||||
if let Some(max_tokens) = request.max_tokens {
|
||||
body["max_tokens"] = serde_json::json!(max_tokens);
|
||||
}
|
||||
if let Some(tools) = &request.tools {
|
||||
body["tools"] = serde_json::json!(tools);
|
||||
}
|
||||
if let Some(tool_choice) = &request.tool_choice {
|
||||
body["tool_choice"] = serde_json::json!(tool_choice);
|
||||
}
|
||||
|
||||
body
|
||||
}
|
||||
|
||||
/// Parse an OpenAI-compatible chat completion response JSON into a ProviderResponse.
|
||||
/// Extracts tool_calls from the message when present.
|
||||
pub fn parse_openai_response(resp_json: &Value, model: String) -> Result<ProviderResponse, AppError> {
|
||||
let choice = resp_json["choices"]
|
||||
.get(0)
|
||||
@@ -96,6 +148,11 @@ pub fn parse_openai_response(resp_json: &Value, model: String) -> Result<Provide
|
||||
let content = message["content"].as_str().unwrap_or_default().to_string();
|
||||
let reasoning_content = message["reasoning_content"].as_str().map(|s| s.to_string());
|
||||
|
||||
// Parse tool_calls from the response message
|
||||
let tool_calls: Option<Vec<ToolCall>> = message
|
||||
.get("tool_calls")
|
||||
.and_then(|tc| serde_json::from_value(tc.clone()).ok());
|
||||
|
||||
let usage = &resp_json["usage"];
|
||||
let prompt_tokens = usage["prompt_tokens"].as_u64().unwrap_or(0) as u32;
|
||||
let completion_tokens = usage["completion_tokens"].as_u64().unwrap_or(0) as u32;
|
||||
@@ -104,6 +161,7 @@ pub fn parse_openai_response(resp_json: &Value, model: String) -> Result<Provide
|
||||
Ok(ProviderResponse {
|
||||
content,
|
||||
reasoning_content,
|
||||
tool_calls,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens,
|
||||
@@ -115,6 +173,7 @@ pub fn parse_openai_response(resp_json: &Value, model: String) -> Result<Provide
|
||||
///
|
||||
/// The optional `reasoning_field` allows overriding the field name for
|
||||
/// reasoning content (e.g., "thought" for Ollama).
|
||||
/// Parses tool_calls deltas from streaming chunks when present.
|
||||
pub fn create_openai_stream(
|
||||
es: reqwest_eventsource::EventSource,
|
||||
model: String,
|
||||
@@ -143,10 +202,16 @@ pub fn create_openai_stream(
|
||||
.map(|s| s.to_string());
|
||||
let finish_reason = choice["finish_reason"].as_str().map(|s| s.to_string());
|
||||
|
||||
// Parse tool_calls deltas from the stream chunk
|
||||
let tool_calls: Option<Vec<ToolCallDelta>> = delta
|
||||
.get("tool_calls")
|
||||
.and_then(|tc| serde_json::from_value(tc.clone()).ok());
|
||||
|
||||
yield ProviderStreamChunk {
|
||||
content,
|
||||
reasoning_content,
|
||||
finish_reason,
|
||||
tool_calls,
|
||||
model: model.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user