diff --git a/server/src/provider/openai_chat.rs b/server/src/provider/openai_chat.rs index acf9614..996fa8e 100644 --- a/server/src/provider/openai_chat.rs +++ b/server/src/provider/openai_chat.rs @@ -95,12 +95,14 @@ impl Provider for OpenAiChatProvider { let mut body = json!({ "model": request.model.model_id, "messages": messages, - "tools": request.prompt.tools.iter().map(|tool| json!({"type":"function","function":{ - "name": tool.name, "description": tool.description, "parameters": tool.parameters - }})).collect::>(), "stream": true, "stream_options": {"include_usage": true} }); + if !request.prompt.tools.is_empty() { + body["tools"] = json!(request.prompt.tools.iter().map(|tool| json!({"type":"function","function":{ + "name": tool.name, "description": tool.description, "parameters": tool.parameters + }})).collect::>()); + } apply_model(&mut body, &request.model, config.max_output_tokens)?; merge_extra_params(&mut body, &request.model.extra_params)?; apply_openai_prompt_cache_key(&mut body, &request.model.model_id)?; @@ -164,42 +166,27 @@ impl Provider for OpenAiChatProvider { let mut loop_iteration: u64 = 0; loop { loop_iteration += 1; - dbg_log!("openai_chat.rs:stream:poll", "Polling SSE event", serde_json::json!({ - "iteration": loop_iteration, - "saw_done": saw_done_marker, - "has_finish": finish.is_some(), - "tool_count": tools.len(), - "text_open": text_open, - "thinking_open": thinking_open, - "reasoning_len": reasoning.len() - })); let event = tokio::select! { _ = cancellation.cancelled() => { dbg_log!("openai_chat.rs:stream", "Stream cancelled by token", serde_json::json!({ "iteration": loop_iteration, "saw_done_marker": saw_done_marker, - "tool_count": tools.len(), - "text_open": text_open + "tool_count": tools.len() })); return; } event = source.next() => event, }; let Some(event) = event else { - dbg_log!("openai_chat.rs:stream:poll", "SSE stream ended (None from source)", serde_json::json!({ + dbg_log!("openai_chat.rs:stream", "SSE stream ended (None)", serde_json::json!({ "iteration": loop_iteration, - "saw_done_marker": saw_done_marker, - "has_finish": finish.is_some(), - "tool_count": tools.len(), - "text_open": text_open, - "thinking_open": thinking_open, - "reasoning_len": reasoning.len() + "saw_done_marker": saw_done_marker })); break; }; let event = event.map_err(|error| { let err_msg = error.to_string(); - dbg_log!("openai_chat.rs:stream:poll", "SSE event error", serde_json::json!({ + dbg_log!("openai_chat.rs:stream", "SSE event error", serde_json::json!({ "iteration": loop_iteration, "error": err_msg.clone() })); @@ -212,7 +199,7 @@ impl Provider for OpenAiChatProvider { } let Some(choice) = value.get("choices").and_then(Value::as_array).and_then(|values| values.first()) else { continue; }; let delta = choice.get("delta").unwrap_or(&Value::Null); - if let Some(reasoning_delta) = delta.get("reasoning_content").and_then(Value::as_str).filter(|text| !text.is_empty()) { + if let Some(reasoning_delta) = delta.get("reasoning_content").or_else(|| delta.get("reasoning")).and_then(Value::as_str).filter(|text| !text.is_empty()) { if !thinking_open { thinking_open = true; yield ModelEvent::ThinkingStart; } reasoning.push_str(reasoning_delta); yield ModelEvent::ThinkingDelta(reasoning_delta.into()); @@ -236,15 +223,6 @@ impl Provider for OpenAiChatProvider { finish = Some(map_finish(reason, !tools.is_empty())); } } - dbg_log!("openai_chat.rs:stream", "SSE loop exited", serde_json::json!({ - "total_iterations": loop_iteration, - "saw_done_marker": saw_done_marker, - "finish_reason": finish.as_ref().map(|f| format!("{:?}", f)), - "reasoning_len": reasoning.len(), - "tool_count": tools.len(), - "text_open": text_open, - "thinking_open": thinking_open - })); if thinking_open { yield ModelEvent::ThinkingEnd; } if text_open { yield ModelEvent::TextEnd; } for (index, tool) in &mut tools { @@ -319,12 +297,27 @@ fn openai_chat_messages(instructions: &str, messages: &[ProjectedMessage]) -> Re calls, .. } => { - value.insert("content".into(), Value::String(text.clone())); let replay_reasoning = replay_state .as_ref() .filter(|state| state.provider_kind == "openai_chat") .and_then(|state| state.value.get("reasoning_content")) - .and_then(Value::as_str); + .and_then(Value::as_str) + .filter(|reasoning| !reasoning.is_empty()); + + // Chat Completions rejects an empty assistant content string. Tool-call + // assistant messages use null content, while an assistant with no visible + // content at all does not need to be sent. + if text.is_empty() && calls.is_empty() && replay_reasoning.is_none() { + continue; + } + value.insert( + "content".into(), + if text.is_empty() { + Value::Null + } else { + Value::String(text.clone()) + }, + ); if let Some(reasoning) = replay_reasoning { value.insert("reasoning_content".into(), Value::String(reasoning.into())); } @@ -487,7 +480,7 @@ mod tests { model::{ContentPart, ProjectedContent, ProjectedMessage, ToolResultContent}, model::{ProviderReplayState, Role, ToolCallContent}, }; - use serde_json::json; + use serde_json::{json, Value}; #[test] fn chat_replay_state_is_encoded_as_reasoning_content() { @@ -519,6 +512,52 @@ mod tests { assert_eq!(messages[0]["tool_calls"][0]["id"], "call-1"); } + #[test] + fn chat_tool_call_assistant_uses_null_content() { + let messages = openai_chat_messages( + "", + &[ProjectedMessage { + message_id: "test".into(), + role: Role::Assistant, + content: ProjectedContent::Assistant { + text: String::new(), + thinking: String::new(), + replay_state: None, + calls: vec![ToolCallContent { + index: 0, + call_id: "call-1".into(), + name: "Read".into(), + arguments: json!({"path": "README.md"}), + }], + }, + }], + ) + .unwrap(); + + assert_eq!(messages[0]["content"], Value::Null); + assert!(messages[0]["tool_calls"].is_array()); + } + + #[test] + fn chat_contentless_assistant_is_omitted() { + let messages = openai_chat_messages( + "", + &[ProjectedMessage { + message_id: "test".into(), + role: Role::Assistant, + content: ProjectedContent::Assistant { + text: String::new(), + thinking: String::new(), + replay_state: None, + calls: vec![], + }, + }], + ) + .unwrap(); + + assert!(messages.is_empty()); + } + #[test] fn another_provider_replay_does_not_invent_chat_reasoning_content() { let messages = openai_chat_messages(