From 44ac92621043e93f9634f6c12f98b699d4595afc Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Mon, 6 Apr 2026 23:42:47 +0800 Subject: [PATCH] fix(proxy): align shadow turns to tail on client history truncation --- src-tauri/src/proxy/gemini_url.rs | 22 ++++- .../src/proxy/providers/transform_gemini.rs | 80 ++++++++++++++++++- src-tauri/src/proxy/thinking_rectifier.rs | 27 +++++++ 3 files changed, 124 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/proxy/gemini_url.rs b/src-tauri/src/proxy/gemini_url.rs index 494945fea..f84fe095d 100644 --- a/src-tauri/src/proxy/gemini_url.rs +++ b/src-tauri/src/proxy/gemini_url.rs @@ -65,7 +65,7 @@ fn should_normalize_gemini_full_url(base_url: &str) -> bool { let path = path.trim_end_matches('/'); path.contains("/v1beta/models/") || path.contains("/v1/models/") - || path.contains("/models/") + || matches_bare_gemini_models_path(path) || path.ends_with("/v1beta") || path.ends_with("/v1") || path.ends_with("/v1beta/models") @@ -150,6 +150,15 @@ fn normalize_prefix(prefix: &str) -> String { } } +fn matches_bare_gemini_models_path(path: &str) -> bool { + if let Some(idx) = path.find("/models/") { + let after = &path[idx + "/models/".len()..]; + after.contains(":generateContent") || after.contains(":streamGenerateContent") + } else { + false + } +} + fn merge_queries(base_query: Option<&str>, endpoint_query: Option<&str>) -> Option { let parts: Vec<&str> = [base_query, endpoint_query] .into_iter() @@ -245,4 +254,15 @@ mod tests { assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse"); } + + #[test] + fn preserves_opaque_full_url_containing_models_segment() { + let url = resolve_gemini_native_url( + "https://relay.example/custom/models/invoke", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!(url, "https://relay.example/custom/models/invoke?alt=sse"); + } } diff --git a/src-tauri/src/proxy/providers/transform_gemini.rs b/src-tauri/src/proxy/providers/transform_gemini.rs index 2956a44d9..93d75586d 100644 --- a/src-tauri/src/proxy/providers/transform_gemini.rs +++ b/src-tauri/src/proxy/providers/transform_gemini.rs @@ -268,12 +268,17 @@ fn convert_messages_to_contents( shadow_turns: &[GeminiAssistantTurn], ) -> Result, ProxyError> { let mut contents = Vec::new(); - let mut tool_name_by_id = build_tool_name_map_from_shadow_turns(shadow_turns); let total_assistant_messages = messages .iter() .filter(|message| message.get("role").and_then(|value| value.as_str()) == Some("assistant")) .count(); - let shadow_start_index = total_assistant_messages.saturating_sub(shadow_turns.len()); + let effective_shadow_turns = if shadow_turns.len() > total_assistant_messages { + &shadow_turns[shadow_turns.len() - total_assistant_messages..] + } else { + shadow_turns + }; + let mut tool_name_by_id = build_tool_name_map_from_shadow_turns(shadow_turns); + let shadow_start_index = total_assistant_messages.saturating_sub(effective_shadow_turns.len()); let mut assistant_seen_index = 0usize; for message in messages { @@ -287,11 +292,11 @@ fn convert_messages_to_contents( let parts = if role == "assistant" { let shadow_index = assistant_seen_index .checked_sub(shadow_start_index) - .filter(|index| *index < shadow_turns.len()); + .filter(|index| *index < effective_shadow_turns.len()); assistant_seen_index += 1; if let Some(index) = shadow_index { - let shadow_turn = &shadow_turns[index]; + let shadow_turn = &effective_shadow_turns[index]; merge_tool_names_from_shadow(shadow_turn, &mut tool_name_by_id); if let Some(parts) = shadow_parts(&shadow_turn.assistant_content) { parts @@ -1124,4 +1129,71 @@ mod tests { .unwrap() .contains("SAFETY")); } + + #[test] + fn shadow_replay_aligns_to_latest_turns_after_client_truncation() { + let store = GeminiShadowStore::with_limits(8, 4); + // Record 3 shadow turns (assistant messages 0, 1, 2) + for i in 0..3 { + store.record_assistant_turn( + "prov", + "sess", + json!({ + "parts": [{ + "functionCall": { + "id": format!("call_{i}"), + "name": format!("tool_{i}"), + "args": {} + } + }] + }), + vec![], + ); + } + + // Client truncates history: only sends assistant messages 1 and 2 + let input = json!({ + "messages": [ + { + "role": "assistant", + "content": [ + { "type": "tool_use", "id": "call_1", "name": "tool_1", "input": {} } + ] + }, + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": "call_1", "content": "ok" } + ] + }, + { + "role": "assistant", + "content": [ + { "type": "tool_use", "id": "call_2", "name": "tool_2", "input": {} } + ] + }, + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": "call_2", "content": "ok" } + ] + } + ] + }); + + let result = + anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess")) + .unwrap(); + + // Shadow turns[1] (tool_1) should align with first assistant message, + // shadow turns[2] (tool_2) with the second — not turns[0] and turns[1]. + assert_eq!( + result["contents"][0]["parts"][0]["functionCall"]["name"], + "tool_1" + ); + assert_eq!( + result["contents"][2]["parts"][0]["functionCall"]["name"], + "tool_2" + ); + } } diff --git a/src-tauri/src/proxy/thinking_rectifier.rs b/src-tauri/src/proxy/thinking_rectifier.rs index ce71503c8..b43b4d8ea 100644 --- a/src-tauri/src/proxy/thinking_rectifier.rs +++ b/src-tauri/src/proxy/thinking_rectifier.rs @@ -52,6 +52,14 @@ pub fn should_rectify_thinking_signature( return true; } + // 场景1b: Gemini/第三方渠道返回 "Thought signature is not valid" + // 错误示例: "Unable to submit request because Thought signature is not valid" + if lower.contains("thought signature") + && (lower.contains("not valid") || lower.contains("invalid")) + { + return true; + } + // 场景2: assistant 消息必须以 thinking block 开头 // 错误示例: "must start with a thinking block" if lower.contains("must start with a thinking block") { @@ -280,6 +288,16 @@ mod tests { )); } + #[test] + fn test_detect_invalid_thought_signature_message() { + assert!(should_rectify_thinking_signature( + Some( + "Unable to submit request because Thought signature is not valid.. Learn more: https://example.com/help" + ), + &enabled_config() + )); + } + #[test] fn test_detect_invalid_signature_nested_json() { // 测试嵌套 JSON 格式的错误消息(第三方渠道常见格式) @@ -290,6 +308,15 @@ mod tests { )); } + #[test] + fn test_detect_invalid_thought_signature_nested_json() { + let nested_error = r#"{"error":{"message":"Unable to submit request because Thought signature is not valid.. Learn more: https://example.com/help","type":"upstream_error","param":"","code":400}}"#; + assert!(should_rectify_thinking_signature( + Some(nested_error), + &enabled_config() + )); + } + #[test] fn test_detect_thinking_expected() { assert!(should_rectify_thinking_signature(