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
This commit is contained in:
YoVinchen
2026-04-16 10:57:54 +08:00
parent a186f6baca
commit 8bdc4d6326
2 changed files with 119 additions and 18 deletions
@@ -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<E: std::error::Error + Send + 'st
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
async_stream::stream! {
let mut buffer = String::new();
let mut utf8_remainder = Vec::new();
let mut message_id: Option<String> = None;
let mut current_model: Option<String> = None;
let mut has_sent_message_start = false;
@@ -202,8 +203,7 @@ pub fn create_anthropic_sse_stream_from_gemini<E: std::error::Error + Send + 'st
while let Some(chunk) = stream.next().await {
match chunk {
Ok(bytes) => {
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, std::io::Error>(Bytes::from(chunk_bytes[..split_at].to_vec())),
Ok::<Bytes, std::io::Error>(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::<Vec<_>>()
.await
.into_iter()
.map(|item| String::from_utf8(item.unwrap().to_vec()).unwrap())
.collect::<Vec<_>>()
.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));
@@ -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::<Vec<_>>()
})
.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!({