From a022bd12727af65e9d6146100bea3d1068972bcb Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Thu, 5 Mar 2026 15:16:19 +0000 Subject: [PATCH] fix(gemini): resolve 400 stream errors and improve client compatibility - Filter out empty text parts in Gemini requests to avoid 400 errors. - Inject 'assistant' role into the first streaming chunk for better compatibility with clients like opencode. - Fallback to tool_call_id for Gemini function responses when name is missing. --- src/providers/gemini.rs | 33 +++++++++++++++++++-------------- src/server/mod.rs | 10 +++++++++- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/providers/gemini.rs b/src/providers/gemini.rs index 1c48baf8..a6487934 100644 --- a/src/providers/gemini.rs +++ b/src/providers/gemini.rs @@ -200,12 +200,14 @@ impl GeminiProvider { if msg.role == "system" { for part in msg.content { if let ContentPart::Text { text } = part { - system_parts.push(GeminiPart { - text: Some(text), - inline_data: None, - function_call: None, - function_response: None, - }); + if !text.trim().is_empty() { + system_parts.push(GeminiPart { + text: Some(text), + inline_data: None, + function_call: None, + function_response: None, + }); + } } } continue; @@ -230,7 +232,8 @@ impl GeminiProvider { }) .unwrap_or_default(); - let name = msg.name.clone().unwrap_or_default(); + // Gemini function response MUST have a name. Fallback to tool_call_id if name is missing. + let name = msg.name.clone().or_else(|| msg.tool_call_id.clone()).unwrap_or_else(|| "unknown_function".to_string()); let response_value = serde_json::from_str::(&text_content) .unwrap_or_else(|_| serde_json::json!({ "result": text_content })); @@ -249,7 +252,7 @@ impl GeminiProvider { // Include text content if present for p in &msg.content { if let ContentPart::Text { text } = p { - if !text.is_empty() { + if !text.trim().is_empty() { parts.push(GeminiPart { text: Some(text.clone()), inline_data: None, @@ -279,12 +282,14 @@ impl GeminiProvider { for part in msg.content { match part { ContentPart::Text { text } => { - parts.push(GeminiPart { - text: Some(text), - inline_data: None, - function_call: None, - function_response: None, - }); + if !text.trim().is_empty() { + parts.push(GeminiPart { + text: Some(text), + inline_data: None, + function_call: None, + function_response: None, + }); + } } ContentPart::Image(image_input) => { let (base64_data, mime_type) = image_input diff --git a/src/server/mod.rs b/src/server/mod.rs index 9c7d7ab5..2b6b68e1 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -250,10 +250,18 @@ async fn chat_completions( // Build stream that yields events wrapped in Result let stream = async_stream::stream! { let mut aggregator = Box::pin(aggregating_stream); + let mut first_chunk = true; while let Some(chunk_result) = aggregator.next().await { match chunk_result { Ok(chunk) => { + let role = if first_chunk { + first_chunk = false; + Some("assistant".to_string()) + } else { + None + }; + let response = ChatCompletionStreamResponse { id: stream_id_sse.clone(), object: "chat.completion.chunk".to_string(), @@ -262,7 +270,7 @@ async fn chat_completions( choices: vec![ChatStreamChoice { index: 0, delta: ChatStreamDelta { - role: None, + role, content: Some(chunk.content), reasoning_content: chunk.reasoning_content, tool_calls: chunk.tool_calls,