PicoBot/src/providers/openai.rs

1876 lines
71 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use async_trait::async_trait;
use futures_util::StreamExt;
use reqwest::Client;
use serde::Deserialize;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::time::Duration;
use super::traits::{StreamCallback, StreamDelta, Usage};
use super::{ChatCompletionRequest, ChatCompletionResponse, LLMProvider, ToolCall};
use crate::domain::messages::ContentBlock;
use crate::utils::format_error_chain;
const INTERNAL_MODEL_EXTRA_KEYS: &[&str] = &[
"tool_call_arguments_json",
"mock_response_content",
"supported_content_types",
];
/// 流式响应中的工具调用增量
#[derive(Debug, Default)]
struct StreamingToolCall {
id: String,
name: String,
arguments: String,
}
/// 流式响应累积器
#[derive(Debug, Default)]
struct StreamingAccumulator {
content: String,
reasoning_content: Option<String>,
tool_calls: BTreeMap<usize, StreamingToolCall>,
response_id: String,
/// 流式末帧返回的 usage需要 stream_options.include_usage=true
usage: Option<OpenAIUsage>,
}
impl StreamingAccumulator {
fn new() -> Self {
Self::default()
}
/// 添加内容增量
fn add_content(&mut self, delta: &str) {
self.content.push_str(delta);
}
/// 添加推理内容增量
fn add_reasoning_content(&mut self, delta: &str) {
if self.reasoning_content.is_none() {
self.reasoning_content = Some(String::new());
}
self.reasoning_content.as_mut().unwrap().push_str(delta);
}
/// 添加工具调用增量
fn add_tool_call(
&mut self,
index: usize,
id: Option<&str>,
name: Option<&str>,
arguments: Option<&str>,
) {
let entry = self
.tool_calls
.entry(index)
.or_insert_with(StreamingToolCall::default);
// 只在 id 非空时才更新,防止流式响应中后续 chunk 的空 id 覆盖之前的值
if let Some(id) = id {
if !id.is_empty() {
entry.id = id.to_string();
}
}
// 只在 name 非空时才更新,防止流式响应中后续 chunk 的 None 覆盖之前的值
if let Some(name) = name {
if !name.is_empty() {
entry.name = name.to_string();
}
}
if let Some(args) = arguments {
entry.arguments.push_str(args);
}
}
/// 设置响应 ID
fn set_response_id(&mut self, id: String) {
if self.response_id.is_empty() {
self.response_id = id;
}
}
/// 设置 usage来自流式末帧的 usage 字段)
/// 跳过 total_tokens=0 的占位帧,避免覆盖真实值。
fn set_usage(&mut self, usage: OpenAIUsage) {
if usage.total_tokens > 0 {
self.usage = Some(usage);
}
}
/// 构建最终的 ChatCompletionResponse
fn build_response(self, model: String) -> ChatCompletionResponse {
let tool_calls: Vec<ToolCall> = self
.tool_calls
.into_iter()
.filter(|(_, call)| !call.id.is_empty() && !call.name.is_empty())
.map(|(_, call)| {
let arguments = serde_json::from_str(&call.arguments)
.unwrap_or_else(|_| serde_json::Value::Null);
ToolCall {
id: call.id,
name: call.name,
arguments,
}
})
.collect();
ChatCompletionResponse {
id: if self.response_id.is_empty() {
format!("stream-{}", uuid::Uuid::new_v4())
} else {
self.response_id
},
model,
content: self.content,
reasoning_content: self.reasoning_content,
tool_calls,
usage: self
.usage
.clone()
.map(|u| Usage {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
})
.unwrap_or(Usage {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
}),
}
}
}
fn format_transport_error_context(
provider_name: &str,
model_id: &str,
url: &str,
timeout_secs: u64,
error: &(dyn std::error::Error + 'static),
) -> String {
format!(
"transport error: provider={} model={} url={} timeout_secs={} details={}",
provider_name,
model_id,
url,
timeout_secs,
format_error_chain(error)
)
}
fn convert_content_blocks(
supports_images: bool,
provider_name: &str,
model_id: &str,
blocks: &[ContentBlock],
message_idx: usize,
) -> Value {
// 检查是否有图片且模型不支持
if !supports_images {
let has_images = blocks
.iter()
.any(|b| matches!(b, ContentBlock::ImageUrl { .. }));
if has_images {
let image_count = blocks
.iter()
.filter(|b| matches!(b, ContentBlock::ImageUrl { .. }))
.count();
tracing::warn!(
provider = %provider_name,
model = %model_id,
filtered_images = image_count,
message_idx,
"模型不支持图片;将图片转换为通知文本"
);
// 复用通知格式,将图片转换为文本通知
let mut converted_blocks: Vec<Value> = Vec::new();
let mut notices: Vec<String> = Vec::new();
let mut image_idx = 0;
for block in blocks.iter() {
match block {
ContentBlock::Text { text } => {
converted_blocks.push(json!({ "type": "text", "text": text }));
}
ContentBlock::ImageUrl { .. } => {
image_idx += 1;
notices.push(format!(
"- 第 {} 张图片:当前模型不支持图片输入,该图片未能成功入模,请直接告知用户。",
image_idx
));
}
}
}
// 添加通知文本块
if !notices.is_empty() {
let notice_text =
format!("[系统提示] 以下图片未能成功入模:\n{}", notices.join("\n"));
converted_blocks.push(json!({ "type": "text", "text": notice_text }));
}
// 如果只有一个文本块且没有通知,返回字符串形式
if converted_blocks.len() == 1 {
if let Some(block) = converted_blocks.first() {
if block.get("type").and_then(|t| t.as_str()) == Some("text") {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
return Value::String(text.to_string());
}
}
}
}
return Value::Array(converted_blocks);
}
}
// 原有逻辑 - 模型支持图片,正常转换
if blocks.len() == 1 {
if let ContentBlock::Text { text } = &blocks[0] {
return Value::String(text.clone());
}
}
Value::Array(
blocks
.iter()
.map(|b| match b {
ContentBlock::Text { text } => json!({ "type": "text", "text": text }),
ContentBlock::ImageUrl { image_url } => {
json!({ "type": "image_url", "image_url": { "url": image_url.url } })
}
})
.collect(),
)
}
pub struct OpenAIProvider {
client: Client,
name: String,
api_key: String,
base_url: String,
extra_headers: HashMap<String, String>,
llm_timeout_secs: u64,
model_id: String,
temperature: Option<f32>,
max_tokens: Option<u32>,
model_extra: HashMap<String, serde_json::Value>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum OAIFunctionArguments {
Json(Value),
String(String),
}
impl OpenAIProvider {
pub fn new(
name: String,
api_key: String,
base_url: String,
extra_headers: HashMap<String, String>,
llm_timeout_secs: u64,
model_id: String,
temperature: Option<f32>,
max_tokens: Option<u32>,
model_extra: HashMap<String, serde_json::Value>,
) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(llm_timeout_secs))
.build()
.unwrap_or_else(|_| Client::new());
// 兼容带末尾斜杠的 base_url如 https://opencode.ai/zen/go/v1/
// 否则 format!("{}/chat/completions", base_url) 会产生双斜杠导致 404
let base_url = base_url.trim_end_matches('/').to_string();
Self {
client,
name,
api_key,
base_url,
extra_headers,
llm_timeout_secs,
model_id,
temperature,
max_tokens,
model_extra,
}
}
fn uses_json_tool_arguments(&self) -> bool {
self.model_extra
.get("tool_call_arguments_json")
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
/// 检查是否启用流式输出,默认启用
fn is_streaming_enabled(&self) -> bool {
self.model_extra
.get("enable_streaming")
.and_then(|value| value.as_bool())
.unwrap_or(true)
}
/// 检查模型是否支持指定内容类型
/// 默认支持所有类型text, image
fn supports_content_type(&self, content_type: &str) -> bool {
self.model_extra
.get("supported_content_types")
.and_then(|value| value.as_array())
.map(|types| types.iter().any(|t| t.as_str() == Some(content_type)))
.unwrap_or(true)
}
/// 检查模型是否支持图片
fn supports_images(&self) -> bool {
self.supports_content_type("image")
}
fn normalize_tool_arguments(&self, arguments: &Value) -> Value {
match arguments {
Value::String(raw) => serde_json::from_str(raw).unwrap_or_else(|_| arguments.clone()),
_ => arguments.clone(),
}
}
fn serialize_tool_arguments(&self, arguments: &Value) -> Value {
let normalized = self.normalize_tool_arguments(arguments);
if self.uses_json_tool_arguments() {
// Model expects JSON object format (e.g., some code models)
normalized
} else {
// Standard OpenAI format: arguments as JSON string
// But ensure we serialize valid JSON, not null
match normalized {
Value::Null => Value::String("{}".to_string()),
Value::String(raw) => {
// If the string is already valid JSON, keep it as-is
// Otherwise, ensure it's a proper JSON string
if serde_json::from_str::<Value>(&raw).is_ok() {
Value::String(raw)
} else {
// Invalid JSON string - wrap it as a proper JSON string
Value::String(
serde_json::to_string(&raw).unwrap_or_else(|_| "null".to_string()),
)
}
}
value => Value::String(
serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string()),
),
}
}
}
fn request_model_extra(&self) -> impl Iterator<Item = (&String, &Value)> {
self.model_extra.iter().filter(|(key, _)| {
!INTERNAL_MODEL_EXTRA_KEYS
.iter()
.any(|internal| internal == &key.as_str())
})
}
/// 内部流式聊天实现,可选传入流式回调
async fn chat_streaming_internal(
&self,
request: &ChatCompletionRequest,
stream_callback: Option<&StreamCallback>,
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
tracing::debug!(provider = %self.name, model = %self.model_id, "Starting streaming chat");
let url = format!("{}/chat/completions", self.base_url);
let mut body = self.build_request_body(request);
// 启用流式输出
body["stream"] = json!(true);
// 请求在流式末帧返回 usageDeepSeek/OpenAI 兼容协议)
body["stream_options"] = json!({ "include_usage": true });
let mut req_builder = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream");
for (key, value) in &self.extra_headers {
req_builder = req_builder.header(key.as_str(), value.as_str());
}
let resp = req_builder.json(&body).send().await.map_err(|err| {
format_transport_error_context(
&self.name,
&self.model_id,
&url,
self.llm_timeout_secs,
&err,
)
})?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
let sequence = format_message_sequence(&body);
tracing::error!(
provider = %self.name,
model = %self.model_id,
url = %url,
status = %status,
response_len = text.len(),
response_body = %text,
sequence = ?sequence,
"OpenAI-compatible streaming API request failed"
);
return Err(format!("API error {}: {}", status, text).into());
}
let mut accumulator = StreamingAccumulator::new();
// 读取 SSE 流
let mut stream = resp.bytes_stream();
let mut buffer = String::new();
let mut raw_body = String::new(); // 完整原始响应,用于非 SSE JSON 回退
let mut done_received = false;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result?;
let text = String::from_utf8_lossy(&chunk);
raw_body.push_str(&text);
buffer.push_str(&text);
// 处理缓冲区中的完整行
while let Some(newline_pos) = buffer.find('\n') {
let line = buffer[..newline_pos].to_string();
buffer = buffer[newline_pos + 1..].to_string();
let line_trimmed = line.trim();
if line_trimmed.is_empty() || line_trimmed.starts_with(':') {
continue;
}
// SSE 格式: data: {...} 或 data:{...}(某些 API 如 139 云没有空格)
let data_opt = line_trimmed
.strip_prefix("data: ")
.or_else(|| line_trimmed.strip_prefix("data:"));
if let Some(data) = data_opt {
if data == "[DONE]" {
// 流结束
done_received = true;
break;
}
// 解析 JSON
match serde_json::from_str::<Value>(data) {
Ok(json) => {
// 提取响应 ID
if let Some(id) = json.get("id").and_then(|v| v.as_str()) {
accumulator.set_response_id(id.to_string());
}
// 提取流式末帧的 usagestream_options.include_usage=true 时返回)
if let Some(usage_val) = json.get("usage") {
if !usage_val.is_null() {
if let Ok(u) =
serde_json::from_value::<OpenAIUsage>(usage_val.clone())
{
accumulator.set_usage(u);
}
}
}
// 提取 choices
if let Some(choices) = json.get("choices").and_then(|c| c.as_array()) {
for choice in choices {
// 尝试从 delta 提取(标准 OpenAI 流式格式)
if let Some(delta) = choice.get("delta") {
// 提取内容增量
if let Some(content) =
delta.get("content").and_then(|c| c.as_str())
{
accumulator.add_content(content);
if let Some(cb) = &stream_callback {
cb(StreamDelta {
content: content.to_string(),
reasoning_content: None,
});
}
}
// 提取推理内容增量
if let Some(reasoning) =
delta.get("reasoning_content").and_then(|r| r.as_str())
{
accumulator.add_reasoning_content(reasoning);
if let Some(cb) = &stream_callback {
cb(StreamDelta {
content: String::new(),
reasoning_content: Some(reasoning.to_string()),
});
}
}
// 提取工具调用增量
if let Some(tool_calls) =
delta.get("tool_calls").and_then(|t| t.as_array())
{
for tool_call in tool_calls {
let index = tool_call
.get("index")
.and_then(|i| i.as_u64())
.unwrap_or(0)
as usize;
let id =
tool_call.get("id").and_then(|v| v.as_str());
let name = tool_call
.get("function")
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str());
let arguments = tool_call
.get("function")
.and_then(|f| f.get("arguments"))
.and_then(|a| a.as_str());
accumulator
.add_tool_call(index, id, name, arguments);
}
}
}
// 尝试从 message 提取(某些非标准 API 格式)
else if let Some(message) = choice.get("message") {
if let Some(content) =
message.get("content").and_then(|c| c.as_str())
{
accumulator.add_content(content);
}
if let Some(reasoning) = message
.get("reasoning_content")
.and_then(|r| r.as_str())
{
accumulator.add_reasoning_content(reasoning);
}
}
}
}
}
Err(e) => {
tracing::debug!(
error = %e,
data = %data,
"Failed to parse SSE data"
);
}
}
}
}
if done_received {
break;
}
}
// 处理缓冲区中剩余的内容
for line in buffer.lines() {
let line_trimmed = line.trim();
if line_trimmed.is_empty() || line_trimmed.starts_with(':') {
continue;
}
// 同样支持 data: {...} 和 data:{...} 两种格式
let data_opt = line_trimmed
.strip_prefix("data: ")
.or_else(|| line_trimmed.strip_prefix("data:"));
if let Some(data) = data_opt {
if data == "[DONE]" {
break;
}
if let Ok(json) = serde_json::from_str::<Value>(data) {
if let Some(id) = json.get("id").and_then(|v| v.as_str()) {
accumulator.set_response_id(id.to_string());
}
// 提取流式末帧的 usage与主循环一致
if let Some(usage_val) = json.get("usage") {
if !usage_val.is_null() {
if let Ok(u) = serde_json::from_value::<OpenAIUsage>(usage_val.clone())
{
accumulator.set_usage(u);
}
}
}
if let Some(choices) = json.get("choices").and_then(|c| c.as_array()) {
for choice in choices {
// 尝试从 delta 提取
if let Some(delta) = choice.get("delta") {
if let Some(content) = delta.get("content").and_then(|c| c.as_str())
{
accumulator.add_content(content);
if let Some(cb) = &stream_callback {
cb(StreamDelta {
content: content.to_string(),
reasoning_content: None,
});
}
}
if let Some(reasoning) =
delta.get("reasoning_content").and_then(|r| r.as_str())
{
accumulator.add_reasoning_content(reasoning);
if let Some(cb) = &stream_callback {
cb(StreamDelta {
content: String::new(),
reasoning_content: Some(reasoning.to_string()),
});
}
}
if let Some(tool_calls) =
delta.get("tool_calls").and_then(|t| t.as_array())
{
for tool_call in tool_calls {
let index = tool_call
.get("index")
.and_then(|i| i.as_u64())
.unwrap_or(0)
as usize;
let id = tool_call.get("id").and_then(|v| v.as_str());
let name = tool_call
.get("function")
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str());
let arguments = tool_call
.get("function")
.and_then(|f| f.get("arguments"))
.and_then(|a| a.as_str());
accumulator.add_tool_call(index, id, name, arguments);
}
}
}
// 尝试从 message 提取(某些非标准 API 格式)
else if let Some(message) = choice.get("message") {
if let Some(content) =
message.get("content").and_then(|c| c.as_str())
{
accumulator.add_content(content);
}
if let Some(reasoning) =
message.get("reasoning_content").and_then(|r| r.as_str())
{
accumulator.add_reasoning_content(reasoning);
}
}
}
}
}
}
}
let mut response = accumulator.build_response(self.model_id.clone());
// 回退:当流式解析未获取到任何内容且无 tool call 时,
// 服务器可能返回的是非 SSE 格式的纯 JSON尝试直接反序列化整个响应体
if response.content.is_empty() && response.tool_calls.is_empty() {
if let Ok(openai_resp) = serde_json::from_str::<OpenAIResponse>(&raw_body) {
let fallback_content = openai_resp
.choices
.first()
.and_then(|c| c.message.content.as_deref())
.unwrap_or("")
.to_string();
if !fallback_content.is_empty() {
tracing::debug!(
model = %self.model_id,
"Streaming accumulator empty, falling back to non-SSE JSON parsing"
);
response.content = fallback_content;
response.reasoning_content = openai_resp
.choices
.first()
.and_then(|c| c.message.reasoning_content.clone());
response.tool_calls = openai_resp
.choices
.first()
.map(|c| {
c.message
.tool_calls
.iter()
.map(|tc| ToolCall {
id: tc.id.clone(),
name: tc.function.name.clone(),
arguments: match &tc.function.arguments {
OAIFunctionArguments::Json(args) => args.clone(),
OAIFunctionArguments::String(args) => {
serde_json::from_str(args)
.unwrap_or(serde_json::Value::Null)
}
},
})
.collect()
})
.unwrap_or_default();
// 回退场景下也从非流式响应提取 usage
response.usage = Usage {
prompt_tokens: openai_resp.usage.prompt_tokens,
completion_tokens: openai_resp.usage.completion_tokens,
total_tokens: openai_resp.usage.total_tokens,
};
}
}
}
tracing::debug!(
content_len = response.content.len(),
tool_calls_count = response.tool_calls.len(),
has_reasoning = response.reasoning_content.is_some(),
"Streaming response built"
);
Ok(response)
}
fn build_request_body(&self, request: &ChatCompletionRequest) -> Value {
let supports_images = self.supports_images();
// --- Final defense: position-aware tool_call / tool result validation ---
//
// Scan right-to-left (matching sanitize_incomplete_tool_call_sequences)
// so that only tool results appearing AFTER an assistant message count
// as "resolved". A simple global scan would incorrectly accept a tool
// result that precedes its parent assistant (e.g. after compaction
// boundary splits), leading to API 400 errors:
// "insufficient tool messages following tool_calls message".
let mut resolved_tool_ids: std::collections::HashSet<&str> =
std::collections::HashSet::new();
let mut with_parent: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut skip_assistant_indices: std::collections::HashSet<usize> =
std::collections::HashSet::new();
for (i, m) in request.messages.iter().enumerate().rev() {
if m.role == "tool" {
if let Some(ref tc_id) = m.tool_call_id {
resolved_tool_ids.insert(tc_id.as_str());
}
}
if m.role == "assistant" {
if let Some(ref calls) = m.tool_calls {
if !calls.is_empty() {
let all_resolved = calls
.iter()
.all(|tc| resolved_tool_ids.contains(tc.id.as_str()));
if all_resolved {
for tc in calls {
with_parent.insert(tc.id.as_str());
}
} else {
skip_assistant_indices.insert(i);
}
}
}
}
}
// Forward-order check: verify tool messages IMMEDIATELY follow the
// assistant(tool_calls). If any non-tool message appears between the
// assistant and its tool results, the API rejects with
// "insufficient tool messages following tool_calls message".
//
// The reverse scan above only checks existence (tool result appears
// somewhere after assistant), NOT immediacy. This forward pass catches:
// [assistant(tool_calls=[A]), user, tool(A)]
// ^ reverse scan sees tool(A) after assistant → "resolved"
// but API requires tool(A) to be IMMEDIATELY after assistant
{
let mut pending_tool_ids: std::collections::HashSet<&str> =
std::collections::HashSet::new();
let mut pending_assistant_idx: Option<usize> = None;
for (i, m) in request.messages.iter().enumerate() {
// If we have pending tool_ids and encounter a non-tool message,
// the assistant's tool results were NOT immediately following.
if !pending_tool_ids.is_empty() && m.role != "tool" {
if let Some(idx) = pending_assistant_idx {
skip_assistant_indices.insert(idx);
tracing::warn!(
message_index = idx,
interrupted_by_index = i,
interrupted_by_role = %m.role,
pending_tool_call_count = pending_tool_ids.len(),
"build_request_body: assistant tool_calls not immediately \
followed by tool results — stripping tool_calls"
);
// Remove this assistant's tool_call_ids from with_parent
// so orphaned tool messages are dropped during serialization
if let Some(calls) = &request.messages[idx].tool_calls {
for tc in calls.iter() {
with_parent.remove(tc.id.as_str());
}
}
}
pending_tool_ids.clear();
pending_assistant_idx = None;
}
if m.role == "assistant" {
if let Some(ref calls) = m.tool_calls {
if !calls.is_empty() && !skip_assistant_indices.contains(&i) {
pending_tool_ids = calls.iter().map(|tc| tc.id.as_str()).collect();
pending_assistant_idx = Some(i);
}
}
} else if m.role == "tool" {
if let Some(ref tc_id) = m.tool_call_id {
pending_tool_ids.remove(tc_id.as_str());
if pending_tool_ids.is_empty() {
pending_assistant_idx = None;
}
}
}
}
// Handle trailing assistant with unresolved immediate tool results
if !pending_tool_ids.is_empty() {
if let Some(idx) = pending_assistant_idx {
skip_assistant_indices.insert(idx);
tracing::warn!(
message_index = idx,
pending_tool_call_count = pending_tool_ids.len(),
"build_request_body: trailing assistant tool_calls without \
immediately following tool results — stripping tool_calls"
);
if let Some(calls) = &request.messages[idx].tool_calls {
for tc in calls.iter() {
with_parent.remove(tc.id.as_str());
}
}
}
}
}
// valid_tool_call_parent_ids = with_parent (assistant tool_call_ids
// whose parent assistant has ALL results after it)
let valid_tool_call_parent_ids = &with_parent;
let mut body = json!({
"model": self.model_id,
"messages": request.messages.iter().enumerate().filter_map(|(i, m)| {
// Skip assistant messages identified as having incomplete tool_calls
// (position-aware scan found missing tool results after this assistant).
if skip_assistant_indices.contains(&i) {
tracing::warn!(
message_index = i,
"build_request_body: skipping assistant with incomplete tool call sequence \
(tool results missing after this position)"
);
// Serialize as plain assistant message (strip tool_calls)
let mut message = json!({
"role": m.role,
"content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i)
});
if let Some(reasoning_content) = &m.reasoning_content {
message["reasoning_content"] = Value::String(reasoning_content.clone());
}
return Some(message);
}
if m.role == "tool" {
// Skip orphaned tool results (no matching assistant tool_call)
let is_orphaned = match &m.tool_call_id {
Some(tc_id) => !valid_tool_call_parent_ids.contains(tc_id.as_str()),
None => true,
};
if is_orphaned {
tracing::warn!(
tool_call_id = ?m.tool_call_id,
message_index = i,
"build_request_body: skipping orphaned tool result message"
);
return None;
}
Some(json!({
"role": m.role,
"content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i),
"tool_call_id": m.tool_call_id,
"name": m.name,
}))
} else if m.role == "assistant" && m.tool_calls.is_some() {
let calls = m.tool_calls.as_ref().unwrap();
// Filter to only valid tool_calls (all have results)
let valid_calls: Vec<&ToolCall> = calls.iter()
.filter(|tc| valid_tool_call_parent_ids.contains(tc.id.as_str()))
.collect();
if valid_calls.is_empty() {
// All tool_calls are orphaned — serialize as plain assistant message
tracing::warn!(
orphaned_tool_call_count = calls.len(),
message_index = i,
"build_request_body: stripping all orphaned tool_calls from assistant message"
);
let mut message = json!({
"role": m.role,
"content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i)
});
if let Some(reasoning_content) = &m.reasoning_content {
message["reasoning_content"] = Value::String(reasoning_content.clone());
}
Some(message)
} else {
let mut message = json!({
"role": m.role,
"content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i),
"tool_calls": valid_calls.iter().map(|call| json!({
"id": call.id,
"type": "function",
"function": {
"name": call.name,
"arguments": self.serialize_tool_arguments(&call.arguments)
}
})).collect::<Vec<_>>()
});
if let Some(reasoning_content) = &m.reasoning_content {
message["reasoning_content"] = Value::String(reasoning_content.clone());
}
Some(message)
}
} else {
let mut message = json!({
"role": m.role,
"content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i)
});
if m.role == "assistant" {
if let Some(reasoning_content) = &m.reasoning_content {
message["reasoning_content"] = Value::String(reasoning_content.clone());
}
}
Some(message)
}
}).collect::<Vec<_>>(),
});
// 只有配置了才添加 temperature否则让模型使用默认值
if let Some(temp) = request.temperature.or(self.temperature) {
body["temperature"] = json!(temp);
}
// 只有配置了才添加 max_tokens
if let Some(tokens) = request.max_tokens.or(self.max_tokens) {
body["max_tokens"] = json!(tokens);
}
for (key, value) in self.request_model_extra() {
body[key] = value.clone();
}
if let Some(tools) = &request.tools {
body["tools"] = json!(tools);
}
body
}
}
/// Builds a compact, human-readable summary of the message sequence in `body`
/// for diagnostic logging. Only emitted on API errors (e.g. 400 responses) to
/// avoid flooding logs on every request — see callers in `chat` and
/// `chat_streaming_internal`.
fn format_message_sequence(body: &Value) -> Vec<String> {
body["messages"]
.as_array()
.map(|msgs| {
msgs.iter()
.enumerate()
.map(|(i, m)| {
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?");
match role {
"assistant" => {
let tc_count = m
.get("tool_calls")
.and_then(|t| t.as_array())
.map(|a| a.len())
.unwrap_or(0);
if tc_count > 0 {
format!("[{}] assistant(tool_calls={})", i, tc_count)
} else {
format!("[{}] assistant", i)
}
}
"tool" => {
let tcid = m
.get("tool_call_id")
.and_then(|t| t.as_str())
.unwrap_or("??");
format!("[{}] tool(id={})", i, tcid)
}
_ => format!("[{}] {}", i, role),
}
})
.collect()
})
.unwrap_or_default()
}
#[derive(Deserialize)]
struct OpenAIResponse {
id: String,
model: String,
choices: Vec<OpenAIChoice>,
#[serde(default)]
usage: OpenAIUsage,
}
#[derive(Deserialize)]
struct OpenAIChoice {
message: OpenAIMessage,
}
#[derive(Deserialize)]
struct OpenAIMessage {
#[serde(default)]
content: Option<String>,
#[serde(default)]
reasoning_content: Option<String>,
#[allow(dead_code)]
#[serde(default)]
name: Option<String>,
#[serde(default)]
tool_calls: Vec<OpenAIToolCall>,
}
#[derive(Deserialize)]
struct OpenAIToolCall {
id: String,
#[serde(rename = "function")]
function: OAIFunction,
#[allow(dead_code)]
#[serde(default)]
index: Option<u32>,
}
#[derive(Deserialize)]
struct OAIFunction {
name: String,
arguments: OAIFunctionArguments,
}
#[derive(Deserialize, Default, Clone, Debug)]
struct OpenAIUsage {
#[serde(default)]
prompt_tokens: u32,
#[serde(default)]
completion_tokens: u32,
#[serde(default)]
total_tokens: u32,
}
#[async_trait]
impl LLMProvider for OpenAIProvider {
async fn chat(
&self,
request: ChatCompletionRequest,
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
// 检查是否启用流式输出
if self.is_streaming_enabled() {
// 优先尝试流式输出(无回调)
match self.chat_streaming_internal(&request, None).await {
Ok(response) => return Ok(response),
Err(e) => {
tracing::debug!(
provider = %self.name,
model = %self.model_id,
error = %e,
"Streaming failed, falling back to non-streaming"
);
// 流式失败,回退到非流式
}
}
} else {
tracing::debug!(provider = %self.name, model = %self.model_id, "Streaming disabled, using non-streaming");
}
// 非流式回退实现
let url = format!("{}/chat/completions", self.base_url);
let body = self.build_request_body(&request);
// Debug: Log LLM request summary (only in debug builds)
#[cfg(debug_assertions)]
{
// Log messages summary
let msg_count = body["messages"].as_array().map(|a| a.len()).unwrap_or(0);
tracing::debug!(msg_count = msg_count, "LLM request messages count");
// Log first 20 bytes of base64 images (don't log full base64)
if let Some(msgs) = body["messages"].as_array() {
for (i, msg) in msgs.iter().enumerate() {
if let Some(content) = msg.get("content").and_then(|c| c.as_array()) {
for (j, item) in content.iter().enumerate() {
if item.get("type").and_then(|t| t.as_str()) == Some("image_url") {
if let Some(url_str) = item
.get("image_url")
.and_then(|u| u.get("url"))
.and_then(|v| v.as_str())
{
let prefix: String = url_str.chars().take(20).collect();
tracing::debug!(msg_idx = i, item_idx = j, image_prefix = %prefix, image_url_len = %url_str.len(), "Image in LLM request (first 20 bytes shown)");
}
}
}
}
}
}
}
let mut req_builder = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json");
for (key, value) in &self.extra_headers {
req_builder = req_builder.header(key.as_str(), value.as_str());
}
let resp = req_builder.json(&body).send().await.map_err(|err| {
let error_context = format_transport_error_context(
&self.name,
&self.model_id,
&url,
self.llm_timeout_secs,
&err,
);
tracing::error!(
provider = %self.name,
model = %self.model_id,
url = %url,
base_url = %self.base_url,
timeout_secs = self.llm_timeout_secs,
error = %error_context,
"OpenAI-compatible API transport request failed"
);
error_context
})?;
let status = resp.status();
let text = resp.text().await?;
// Debug: Log LLM response (only in debug builds)
if !status.is_success() {
let sequence = format_message_sequence(&body);
tracing::error!(
provider = %self.name,
model = %self.model_id,
url = %url,
status = %status,
response_len = text.len(),
response_body = %text,
sequence = ?sequence,
"OpenAI-compatible API request failed"
);
return Err(format!("API error {}: {}", status, text).into());
}
#[cfg(debug_assertions)]
{
let resp_preview: String = text.chars().take(100).collect();
tracing::debug!(status = %status, response_preview = %resp_preview, response_len = %text.len(), timeout_secs = self.llm_timeout_secs, "LLM response (first 100 chars shown)");
}
let openai_resp: OpenAIResponse = serde_json::from_str(&text).map_err(|e| {
tracing::error!(
provider = %self.name,
model = %self.model_id,
url = %url,
error = %format_error_chain(&e),
response_len = text.len(),
response_body = %text,
"Failed to decode OpenAI-compatible API response"
);
format!("decode error: {} | body: {}", e, &text)
})?;
let content = openai_resp.choices[0]
.message
.content
.as_ref()
.unwrap_or(&String::new())
.clone();
let tool_calls: Vec<ToolCall> = openai_resp.choices[0]
.message
.tool_calls
.iter()
.map(|tc| ToolCall {
id: tc.id.clone(),
name: tc.function.name.clone(),
arguments: match &tc.function.arguments {
OAIFunctionArguments::Json(arguments) => arguments.clone(),
OAIFunctionArguments::String(arguments) => {
serde_json::from_str(arguments).unwrap_or(serde_json::Value::Null)
}
},
})
.collect();
Ok(ChatCompletionResponse {
id: openai_resp.id,
model: openai_resp.model,
content,
reasoning_content: openai_resp.choices[0].message.reasoning_content.clone(),
tool_calls,
usage: Usage {
prompt_tokens: openai_resp.usage.prompt_tokens,
completion_tokens: openai_resp.usage.completion_tokens,
total_tokens: openai_resp.usage.total_tokens,
},
})
}
async fn chat_with_streaming(
&self,
request: ChatCompletionRequest,
callback: StreamCallback,
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
if self.is_streaming_enabled() {
match self
.chat_streaming_internal(&request, Some(&callback))
.await
{
Ok(response) => return Ok(response),
Err(e) => {
tracing::debug!(
provider = %self.name,
model = %self.model_id,
error = %e,
"Streaming (with callback) failed, falling back to non-streaming"
);
}
}
}
// 回退到非流式
self.chat(request).await
}
fn ptype(&self) -> &str {
"openai"
}
fn name(&self) -> &str {
&self.name
}
fn model_id(&self) -> &str {
&self.model_id
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::Message;
#[test]
fn test_build_request_body_includes_assistant_tool_calls() {
let provider = OpenAIProvider::new(
"test".to_string(),
"key".to_string(),
"https://example.com/v1".to_string(),
HashMap::new(),
120,
"gpt-test".to_string(),
None,
None,
HashMap::new(),
);
let request = ChatCompletionRequest {
messages: vec![
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::text("calling tool")],
reasoning_content: None,
tool_call_id: None,
name: None,
tool_calls: Some(vec![ToolCall {
id: "call_1".to_string(),
name: "calculator".to_string(),
arguments: json!({"expression": "1+1"}),
}]),
},
Message {
role: "tool".to_string(),
content: vec![ContentBlock::text("2")],
reasoning_content: None,
tool_call_id: Some("call_1".to_string()),
name: Some("calculator".to_string()),
tool_calls: None,
},
],
temperature: None,
max_tokens: None,
tools: None,
};
let body = provider.build_request_body(&request);
let messages = body["messages"].as_array().unwrap();
let tool_calls = messages[0]["tool_calls"].as_array().unwrap();
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0]["id"], "call_1");
assert_eq!(tool_calls[0]["type"], "function");
assert_eq!(tool_calls[0]["function"]["name"], "calculator");
assert_eq!(
tool_calls[0]["function"]["arguments"],
"{\"expression\":\"1+1\"}"
);
}
#[test]
fn test_build_request_body_uses_json_tool_arguments_when_enabled() {
let provider = OpenAIProvider::new(
"test".to_string(),
"key".to_string(),
"https://example.com/v1".to_string(),
HashMap::new(),
120,
"gpt-test".to_string(),
None,
None,
HashMap::from([("tool_call_arguments_json".to_string(), Value::Bool(true))]),
);
let request = ChatCompletionRequest {
messages: vec![
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::text("calling tool")],
reasoning_content: None,
tool_call_id: None,
name: None,
tool_calls: Some(vec![ToolCall {
id: "call_1".to_string(),
name: "calculator".to_string(),
arguments: json!({"expression": "1+1"}),
}]),
},
Message {
role: "tool".to_string(),
content: vec![ContentBlock::text("2")],
reasoning_content: None,
tool_call_id: Some("call_1".to_string()),
name: Some("calculator".to_string()),
tool_calls: None,
},
],
temperature: None,
max_tokens: None,
tools: None,
};
let body = provider.build_request_body(&request);
let messages = body["messages"].as_array().unwrap();
let tool_calls = messages[0]["tool_calls"].as_array().unwrap();
assert_eq!(
tool_calls[0]["function"]["arguments"],
json!({"expression": "1+1"})
);
assert!(body.get("tool_call_arguments_json").is_none());
}
#[test]
fn test_build_request_body_preserves_raw_json_string_arguments() {
let provider = OpenAIProvider::new(
"test".to_string(),
"key".to_string(),
"https://example.com/v1".to_string(),
HashMap::new(),
120,
"gpt-test".to_string(),
None,
None,
HashMap::new(),
);
let request = ChatCompletionRequest {
messages: vec![
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::text("calling tool")],
reasoning_content: None,
tool_call_id: None,
name: None,
tool_calls: Some(vec![ToolCall {
id: "call_1".to_string(),
name: "calculator".to_string(),
arguments: Value::String("{\"expression\":\"1+1\"}".to_string()),
}]),
},
Message {
role: "tool".to_string(),
content: vec![ContentBlock::text("2")],
reasoning_content: None,
tool_call_id: Some("call_1".to_string()),
name: Some("calculator".to_string()),
tool_calls: None,
},
],
temperature: None,
max_tokens: None,
tools: None,
};
let body = provider.build_request_body(&request);
let messages = body["messages"].as_array().unwrap();
let tool_calls = messages[0]["tool_calls"].as_array().unwrap();
assert_eq!(
tool_calls[0]["function"]["arguments"],
"{\"expression\":\"1+1\"}"
);
}
#[test]
fn test_build_request_body_omits_internal_model_extra_keys() {
let provider = OpenAIProvider::new(
"test".to_string(),
"key".to_string(),
"https://example.com/v1".to_string(),
HashMap::new(),
120,
"gpt-test".to_string(),
None,
None,
HashMap::from([
("tool_call_arguments_json".to_string(), Value::Bool(true)),
(
"mock_response_content".to_string(),
Value::String("stub".to_string()),
),
("parallel_tool_calls".to_string(), Value::Bool(true)),
]),
);
let request = ChatCompletionRequest {
messages: vec![Message::user("hello")],
temperature: None,
max_tokens: None,
tools: None,
};
let body = provider.build_request_body(&request);
assert!(body.get("tool_call_arguments_json").is_none());
assert!(body.get("mock_response_content").is_none());
assert_eq!(body["parallel_tool_calls"], Value::Bool(true));
}
#[test]
fn test_build_request_body_includes_assistant_reasoning_content() {
let provider = OpenAIProvider::new(
"test".to_string(),
"key".to_string(),
"https://example.com/v1".to_string(),
HashMap::new(),
120,
"gpt-test".to_string(),
None,
None,
HashMap::new(),
);
let request = ChatCompletionRequest {
messages: vec![Message {
role: "assistant".to_string(),
content: vec![ContentBlock::text("final answer")],
reasoning_content: Some("step by step".to_string()),
tool_call_id: None,
name: None,
tool_calls: None,
}],
temperature: None,
max_tokens: None,
tools: None,
};
let body = provider.build_request_body(&request);
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages[0]["reasoning_content"], "step by step");
}
#[test]
fn test_openai_response_parses_reasoning_content() {
let response: OpenAIResponse = serde_json::from_value(json!({
"id": "resp_1",
"model": "gpt-test",
"choices": [{
"message": {
"content": "final answer",
"reasoning_content": "hidden reasoning",
"tool_calls": []
}
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15
}
}))
.unwrap();
assert_eq!(
response.choices[0].message.reasoning_content.as_deref(),
Some("hidden reasoning")
);
}
#[test]
fn test_openai_response_parses_json_tool_arguments() {
let response: OpenAIResponse = serde_json::from_value(json!({
"id": "resp_1",
"model": "gpt-test",
"choices": [{
"message": {
"content": "",
"tool_calls": [{
"id": "call_1",
"function": {
"name": "scheduler_manage",
"arguments": {"action": "list"}
}
}]
}
}],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2
}
}))
.unwrap();
match &response.choices[0].message.tool_calls[0].function.arguments {
OAIFunctionArguments::Json(arguments) => {
assert_eq!(arguments, &json!({"action": "list"}));
}
OAIFunctionArguments::String(_) => panic!("expected JSON tool arguments"),
}
}
#[test]
fn test_streaming_accumulator_preserves_tool_call_id_with_empty_subsequent_chunks() {
// 模拟阿里云等云服务商的流式响应行为:
// 第一个 chunk 包含 id 和 name后续 chunk 的 id 为空字符串、name 为 None
let mut accumulator = StreamingAccumulator::new();
// 第一个 chunk包含完整的 id 和 name
accumulator.add_tool_call(
0,
Some("call_abc123"),
Some("memory_search"),
Some("{\"action\":\""),
);
// 第二个 chunk只有参数增量
accumulator.add_tool_call(0, None, None, Some("list"));
// 第三个 chunk参数继续
accumulator.add_tool_call(0, None, None, Some("\""));
// 第四个 chunkid 为空字符串(某些云服务商的行为)
accumulator.add_tool_call(0, Some(""), None, Some(", \"limit\": 20"));
// 最后一个 chunkname 为 None
accumulator.add_tool_call(0, None, None, Some("}"));
let response = accumulator.build_response("test-model".to_string());
// 验证工具调用被正确保留id 没有被空字符串覆盖
assert_eq!(response.tool_calls.len(), 1);
assert_eq!(response.tool_calls[0].id, "call_abc123");
assert_eq!(response.tool_calls[0].name, "memory_search");
assert_eq!(
response.tool_calls[0].arguments,
json!({"action":"list", "limit": 20})
);
}
#[test]
fn test_streaming_accumulator_handles_multiple_tool_calls() {
let mut accumulator = StreamingAccumulator::new();
// 第一个工具调用
accumulator.add_tool_call(
0,
Some("call_1"),
Some("calculator"),
Some("{\"expr\": \"1+1\"}"),
);
// 第二个工具调用id 和 name 只在第一个 chunk 出现)
accumulator.add_tool_call(1, Some("call_2"), Some("get_time"), Some("{}"));
let response = accumulator.build_response("test-model".to_string());
assert_eq!(response.tool_calls.len(), 2);
assert_eq!(response.tool_calls[0].id, "call_1");
assert_eq!(response.tool_calls[0].name, "calculator");
assert_eq!(response.tool_calls[1].id, "call_2");
assert_eq!(response.tool_calls[1].name, "get_time");
}
#[test]
fn test_supports_images_default_true() {
let provider = OpenAIProvider::new(
"test".to_string(),
"key".to_string(),
"https://example.com/v1".to_string(),
HashMap::new(),
120,
"gpt-test".to_string(),
None,
None,
HashMap::new(),
);
assert!(provider.supports_images());
}
#[test]
fn test_supports_images_disabled_via_config() {
let provider = OpenAIProvider::new(
"test".to_string(),
"key".to_string(),
"https://example.com/v1".to_string(),
HashMap::new(),
120,
"gpt-test".to_string(),
None,
None,
HashMap::from([(
"supported_content_types".to_string(),
Value::Array(vec![Value::String("text".to_string())]),
)]),
);
assert!(!provider.supports_images());
}
#[test]
fn test_convert_content_blocks_converts_images_to_notice_when_disabled() {
let blocks = vec![
ContentBlock::text("hello"),
ContentBlock::image_url("data:image/png;base64,abc123"),
ContentBlock::text("world"),
];
let result = convert_content_blocks(false, "test", "test-model", &blocks, 0);
// 应该是数组形式
let arr = result.as_array().unwrap();
assert_eq!(arr.len(), 3); // 两个文本块 + 一个通知块
// 检查通知内容
let notice_block = arr[2].as_object().unwrap();
assert_eq!(notice_block["type"], "text");
let notice_text = notice_block["text"].as_str().unwrap();
assert!(notice_text.contains("[系统提示] 以下图片未能成功入模"));
assert!(notice_text.contains("第 1 张图片"));
assert!(notice_text.contains("当前模型不支持图片输入"));
}
#[test]
fn test_convert_content_blocks_keeps_images_when_enabled() {
let blocks = vec![
ContentBlock::text("hello"),
ContentBlock::image_url("data:image/png;base64,abc123"),
];
let result = convert_content_blocks(true, "test", "test-model", &blocks, 0);
// 应该是数组形式,包含文本和图片
let arr = result.as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["type"], "text");
assert_eq!(arr[1]["type"], "image_url");
}
#[test]
fn test_build_request_body_omits_supported_content_types_from_api() {
let provider = OpenAIProvider::new(
"test".to_string(),
"key".to_string(),
"https://example.com/v1".to_string(),
HashMap::new(),
120,
"gpt-test".to_string(),
None,
None,
HashMap::from([
(
"supported_content_types".to_string(),
Value::Array(vec![Value::String("text".to_string())]),
),
(
"custom_param".to_string(),
Value::String("value".to_string()),
),
]),
);
let request = ChatCompletionRequest {
messages: vec![Message::user("hello")],
temperature: None,
max_tokens: None,
tools: None,
};
let body = provider.build_request_body(&request);
// supported_content_types 不应该发送到 API
assert!(body.get("supported_content_types").is_none());
// custom_param 应该保留
assert_eq!(body["custom_param"], Value::String("value".to_string()));
}
#[test]
fn test_build_request_body_strips_tool_calls_when_not_immediately_followed() {
// [assistant(tool_calls=[A]), user, tool(A)] → should strip tool_calls
// The tool result exists but a user message interrupts between assistant
// and tool result. The API would reject this with
// "insufficient tool messages following tool_calls message".
let provider = OpenAIProvider::new(
"test".to_string(),
"key".to_string(),
"https://example.com/v1".to_string(),
HashMap::new(),
120,
"gpt-test".to_string(),
None,
None,
HashMap::new(),
);
let request = ChatCompletionRequest {
messages: vec![
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::text("calling tool")],
reasoning_content: None,
tool_call_id: None,
name: None,
tool_calls: Some(vec![ToolCall {
id: "call_A".to_string(),
name: "search".to_string(),
arguments: json!({}),
}]),
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::text("interrupting message")],
reasoning_content: None,
tool_call_id: None,
name: None,
tool_calls: None,
},
Message {
role: "tool".to_string(),
content: vec![ContentBlock::text("result")],
reasoning_content: None,
tool_call_id: Some("call_A".to_string()),
name: Some("search".to_string()),
tool_calls: None,
},
],
temperature: None,
max_tokens: None,
tools: None,
};
let body = provider.build_request_body(&request);
let messages = body["messages"].as_array().unwrap();
// Assistant should NOT have tool_calls (stripped because not immediately followed)
assert!(
messages[0].get("tool_calls").is_none(),
"tool_calls should be stripped when tool results are not immediately following"
);
// Tool message should be dropped (orphaned after stripping)
assert_eq!(
messages.len(),
2,
"tool message should be dropped as orphaned, got {} messages",
messages.len()
);
}
#[test]
fn test_build_request_body_preserves_tool_calls_when_immediately_followed() {
// [assistant(tool_calls=[A]), tool(A)] → should keep tool_calls (valid sequence)
let provider = OpenAIProvider::new(
"test".to_string(),
"key".to_string(),
"https://example.com/v1".to_string(),
HashMap::new(),
120,
"gpt-test".to_string(),
None,
None,
HashMap::new(),
);
let request = ChatCompletionRequest {
messages: vec![
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::text("calling tool")],
reasoning_content: None,
tool_call_id: None,
name: None,
tool_calls: Some(vec![ToolCall {
id: "call_A".to_string(),
name: "search".to_string(),
arguments: json!({}),
}]),
},
Message {
role: "tool".to_string(),
content: vec![ContentBlock::text("result")],
reasoning_content: None,
tool_call_id: Some("call_A".to_string()),
name: Some("search".to_string()),
tool_calls: None,
},
],
temperature: None,
max_tokens: None,
tools: None,
};
let body = provider.build_request_body(&request);
let messages = body["messages"].as_array().unwrap();
// Assistant should keep tool_calls (valid immediate sequence)
let tool_calls = messages[0]
.get("tool_calls")
.and_then(|t| t.as_array())
.expect("tool_calls should be preserved when immediately followed");
assert_eq!(tool_calls.len(), 1);
// Tool message should be present
assert_eq!(messages.len(), 2);
assert_eq!(messages[1]["role"], "tool");
}
}