diff --git a/src-tauri/src/proxy/providers/streaming_gemini.rs b/src-tauri/src/proxy/providers/streaming_gemini.rs index 2a22bc237..885d9668c 100644 --- a/src-tauri/src/proxy/providers/streaming_gemini.rs +++ b/src-tauri/src/proxy/providers/streaming_gemini.rs @@ -5,7 +5,7 @@ use super::gemini_shadow::{GeminiShadowStore, GeminiToolCallMeta}; use super::transform_gemini::{rectify_tool_call_parts, AnthropicToolSchemaHints}; -use crate::proxy::sse::{strip_sse_field, take_sse_block}; +use crate::proxy::sse::{append_utf8_safe, strip_sse_field, take_sse_block}; use bytes::Bytes; use futures::stream::{Stream, StreamExt}; use serde_json::{json, Value}; @@ -185,6 +185,7 @@ pub fn create_anthropic_sse_stream_from_gemini impl Stream> + Send { async_stream::stream! { let mut buffer = String::new(); + let mut utf8_remainder = Vec::new(); let mut message_id: Option = None; let mut current_model: Option = None; let mut has_sent_message_start = false; @@ -202,8 +203,7 @@ pub fn create_anthropic_sse_stream_from_gemini { - let text = String::from_utf8_lossy(&bytes); - buffer.push_str(&text); + append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes); while let Some(block) = take_sse_block(&mut buffer) { if block.trim().is_empty() { @@ -584,6 +584,44 @@ mod tests { assert!(output.contains("event: message_stop")); } + #[test] + fn preserves_utf8_boundaries_when_json_payload_spans_chunks() { + let payload = json!({ + "responseId": "resp_utf8", + "modelVersion": "gemini-2.5-pro", + "candidates": [{ + "finishReason": "STOP", + "content": { + "parts": [{ "text": "你好,Gemini" }] + } + }], + "usageMetadata": { + "promptTokenCount": 4, + "totalTokenCount": 8 + } + }); + let chunk = format!("data: {}\n\n", serde_json::to_string(&payload).unwrap()); + let split_at = chunk.find("你好").unwrap() + 1; + let chunk_bytes = chunk.into_bytes(); + let stream = futures::stream::iter([ + Ok::(Bytes::from(chunk_bytes[..split_at].to_vec())), + Ok::(Bytes::from(chunk_bytes[split_at..].to_vec())), + ]); + let converted = create_anthropic_sse_stream_from_gemini(stream, None, None, None, None); + let output = futures::executor::block_on(async move { + converted + .collect::>() + .await + .into_iter() + .map(|item| String::from_utf8(item.unwrap().to_vec()).unwrap()) + .collect::>() + .join("") + }); + + assert!(output.contains("你好,Gemini")); + assert!(!output.contains('\u{fffd}')); + } + #[test] fn stores_full_text_for_shadow_replay_across_delta_chunks() { let store = Arc::new(GeminiShadowStore::with_limits(8, 4)); diff --git a/src-tauri/src/proxy/providers/transform_gemini.rs b/src-tauri/src/proxy/providers/transform_gemini.rs index 93d75586d..c4a1f9c1c 100644 --- a/src-tauri/src/proxy/providers/transform_gemini.rs +++ b/src-tauri/src/proxy/providers/transform_gemini.rs @@ -590,22 +590,35 @@ pub fn rectify_tool_call_args( } } - if let Some(parameters_value) = args_object.remove("parameters") { - if let Some(parameters_object) = parameters_value.as_object() { - for expected_key in &hint.expected_keys { - if args_object.contains_key(expected_key) { - continue; - } - let Some(value) = parameters_object.get(expected_key) else { - continue; - }; - let normalized_value = match value { - Value::Array(values) if values.len() == 1 => values[0].clone(), - _ => value.clone(), - }; - args_object.insert(expected_key.clone(), normalized_value); - changed = true; + let expects_parameters_key = hint.expected_keys.iter().any(|key| key == "parameters"); + if !expects_parameters_key { + let extracted_parameters = args_object + .get("parameters") + .and_then(|value| value.as_object()) + .map(|parameters_object| { + hint.expected_keys + .iter() + .filter_map(|expected_key| { + if args_object.contains_key(expected_key) { + return None; + } + let value = parameters_object.get(expected_key)?; + let normalized_value = match value { + Value::Array(values) if values.len() == 1 => values[0].clone(), + _ => value.clone(), + }; + Some((expected_key.clone(), normalized_value)) + }) + .collect::>() + }) + .unwrap_or_default(); + + if !extracted_parameters.is_empty() { + for (expected_key, normalized_value) in extracted_parameters { + args_object.insert(expected_key, normalized_value); } + args_object.remove("parameters"); + changed = true; } } @@ -1109,6 +1122,56 @@ mod tests { assert!(result["content"][0]["input"].get("parameters").is_none()); } + #[test] + fn gemini_to_anthropic_preserves_legitimate_parameters_arg() { + let input = json!({ + "responseId": "resp_params", + "modelVersion": "gemini-2.5-pro", + "candidates": [{ + "finishReason": "STOP", + "content": { + "parts": [{ + "functionCall": { + "id": "call_1", + "name": "ConfigTool", + "args": { + "parameters": { + "mode": "safe", + "retries": 2 + } + } + } + }] + } + }] + }); + let hints = extract_anthropic_tool_schema_hints(&json!({ + "tools": [{ + "name": "ConfigTool", + "input_schema": { + "type": "object", + "properties": { + "parameters": { + "type": "object", + "properties": { + "mode": { "type": "string" }, + "retries": { "type": "integer" } + } + } + }, + "required": ["parameters"] + } + }] + })); + + let result = + gemini_to_anthropic_with_shadow_and_hints(input, None, None, None, Some(&hints)) + .unwrap(); + + assert_eq!(result["content"][0]["input"]["parameters"]["mode"], "safe"); + assert_eq!(result["content"][0]["input"]["parameters"]["retries"], 2); + } + #[test] fn gemini_to_anthropic_maps_blocked_prompt_to_refusal() { let input = json!({