fix(gemini): resolve 400 stream errors and improve client compatibility
Some checks failed
CI / Check (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Release Build (push) Has been cancelled

- 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.
This commit is contained in:
2026-03-05 15:16:19 +00:00
parent b0bd1fd143
commit a022bd1272
2 changed files with 28 additions and 15 deletions

View File

@@ -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::<Value>(&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

View File

@@ -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,