From 8bdc4d6326ded10a0cea8dc3f9779137d4c89a90 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Thu, 16 Apr 2026 10:57:54 +0800 Subject: [PATCH] Prevent Gemini review regressions in streaming and tool rectification PR #1918 review feedback exposed two correctness issues in the Gemini Native adapter path. Gemini SSE buffering was still using lossy UTF-8 decoding, which could corrupt split multibyte payloads and drop streamed output. Tool arg rectification also removed top-level parameters eagerly, which broke tools that legitimately define a parameters field. This change moves Gemini SSE buffering onto the existing append_utf8_safe path and makes parameters flattening conditional on the schema actually expecting nested extraction. The old Skill rectification path stays intact, and new regression tests cover both the preserved parameters case and UTF-8-split JSON payloads. Constraint: Existing PR #1918 review feedback must be fixed without staging unrelated local docs and artifact files Rejected: Keep String::from_utf8_lossy in Gemini SSE buffering | corrupts split multibyte payloads and can drop JSON chunks Rejected: Always preserve the parameters wrapper | regresses the existing nested-parameters rectification path for Skill-style tools Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep Gemini SSE buffering on the UTF-8-safe accumulator path and only unwrap parameters when the target schema does not declare it as a legitimate field Tested: cargo fmt --manifest-path src-tauri/Cargo.toml --all; cargo test --manifest-path src-tauri/Cargo.toml preserves_utf8_boundaries_when_json_payload_spans_chunks; cargo test --manifest-path src-tauri/Cargo.toml gemini_to_anthropic_rectifies_tool_args_from_schema_hints; cargo test --manifest-path src-tauri/Cargo.toml rectifies_streamed_skill_args_from_nested_parameters; cargo test --manifest-path src-tauri/Cargo.toml gemini_to_anthropic_preserves_legitimate_parameters_arg Not-tested: Full src-tauri test suite; live end-to-end Gemini relay traffic against upstream services --- .../src/proxy/providers/streaming_gemini.rs | 44 ++++++++- .../src/proxy/providers/transform_gemini.rs | 93 ++++++++++++++++--- 2 files changed, 119 insertions(+), 18 deletions(-) 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!({