diff --git a/src-tauri/src/proxy/handlers/gemini.rs b/src-tauri/src/proxy/handlers/gemini.rs index 77c7c0e73..62b1c0e82 100644 --- a/src-tauri/src/proxy/handlers/gemini.rs +++ b/src-tauri/src/proxy/handlers/gemini.rs @@ -5,7 +5,7 @@ use axum::{ http::StatusCode, response::IntoResponse, }; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use tracing::{debug, error, info}; use crate::proxy::common::client_adapter::CLIENT_ADAPTERS; @@ -44,20 +44,14 @@ pub async fn handle_generate( let debug_cfg = state.debug_logging.read().await.clone(); // [NEW] Detect Client Adapter - let client_adapter = CLIENT_ADAPTERS - .iter() - .find(|a| a.matches(&headers)) - .cloned(); + let client_adapter = CLIENT_ADAPTERS.iter().find(|a| a.matches(&headers)).cloned(); if client_adapter.is_some() { debug!("[{}] Client Adapter detected", trace_id); } // 1. 验证方法 if method != "generateContent" && method != "streamGenerateContent" { - return Err(( - StatusCode::BAD_REQUEST, - format!("Unsupported method: {}", method), - )); + return Err((StatusCode::BAD_REQUEST, format!("Unsupported method: {}", method))); } if debug_logger::is_enabled(&debug_cfg) { let original_payload = json!({ @@ -105,9 +99,8 @@ pub async fn handle_generate( body.get("tools").and_then(|t| t.as_array()).map(|arr| { let mut flattened = Vec::new(); for tool_entry in arr { - if let Some(decls) = tool_entry - .get("functionDeclarations") - .and_then(|v| v.as_array()) + if let Some(decls) = + tool_entry.get("functionDeclarations").and_then(|v| v.as_array()) { flattened.extend(decls.iter().cloned()); } else { @@ -133,26 +126,17 @@ pub async fn handle_generate( // 关键:在重试尝试 (attempt > 0) 时强制轮换账号 let (access_token, project_id, email, account_id, _wait_ms) = match token_manager - .get_token( - &config.request_type, - attempt > 0, - Some(&session_id), - &config.final_model, - ) + .get_token(&config.request_type, attempt > 0, Some(&session_id), &config.final_model) .await { Ok(t) => t, Err(e) => { - return Err(( - StatusCode::SERVICE_UNAVAILABLE, - format!("Token error: {}", e), - )); - } + return Err((StatusCode::SERVICE_UNAVAILABLE, format!("Token error: {}", e))); + }, }; - let mapped_model = token_manager - .resolve_dynamic_model_for_account(&account_id, &mapped_model) - .await; + let mapped_model = + token_manager.resolve_dynamic_model_for_account(&account_id, &mapped_model).await; last_email = Some(email.clone()); info!("✓ Using account: {} (type: {})", email, config.request_type); @@ -161,7 +145,14 @@ pub async fn handle_generate( // [FIX #765] Pass session_id to wrap_request for signature injection // [NEW] 获取完整 Token 对象以注入动态规格 (dynamic > static default > 65535) let token_obj = token_manager.get_token_by_id(&account_id); - let wrapped_body = wrap_request(&body, &project_id, &mapped_model, Some(account_id.as_str()), Some(&session_id), token_obj.as_ref()); + let wrapped_body = wrap_request( + &body, + &project_id, + &mapped_model, + Some(account_id.as_str()), + Some(&session_id), + token_obj.as_ref(), + ); if debug_logger::is_enabled(&debug_cfg) { let payload = json!({ @@ -185,11 +176,7 @@ pub async fn handle_generate( // 5. 上游调用 let query_string = if is_stream { Some("alt=sse") } else { None }; - let upstream_method = if is_stream { - "streamGenerateContent" - } else { - "generateContent" - }; + let upstream_method = if is_stream { "streamGenerateContent" } else { "generateContent" }; // [FIX #1522] Inject Anthropic Beta Headers for Claude models let mut extra_headers = std::collections::HashMap::new(); @@ -215,14 +202,9 @@ pub async fn handle_generate( Ok(r) => r, Err(e) => { last_error = e.clone(); - debug!( - "Gemini Request failed on attempt {}/{}: {}", - attempt + 1, - max_attempts, - e - ); + debug!("Gemini Request failed on attempt {}/{}: {}", attempt + 1, max_attempts, e); continue; - } + }, }; // [NEW] 记录端点降级日志到 debug 文件 @@ -306,22 +288,22 @@ pub async fn handle_generate( } else { first_chunk = Some(bytes); } - } + }, Ok(Some(Err(e))) => { tracing::warn!("[Gemini] Stream error during peek: {}, retrying...", e); last_error = format!("Stream error: {}", e); retry_gemini = true; - } + }, Ok(None) => { tracing::warn!("[Gemini] Stream ended immediately, retrying..."); last_error = "Empty response".to_string(); retry_gemini = true; - } + }, Err(_) => { tracing::warn!("[Gemini] Timeout waiting for first chunk, retrying..."); last_error = "Timeout".to_string(); retry_gemini = true; - } + }, } if retry_gemini { @@ -343,11 +325,16 @@ pub async fn handle_generate( Some(Ok(b)) => b, Some(Err(e)) => { error!("[Gemini-SSE] Connection error: {}", e); + // Wrap error as a valid Gemini candidate so downstream + // OpenAI mappers can translate it into choices[0].delta.content let error_json = serde_json::json!({ - "error": { - "message": format!("Stream error: {}", e), - "type": "stream_error" - } + "candidates": [{ + "content": { + "parts": [{"text": format!("\n\n[Antigravity Error: {}]", e)}], + "role": "model" + }, + "finishReason": "STOP" + }] }); yield Ok::(Bytes::from(format!("data: {}\n\n", error_json))); break; @@ -455,7 +442,7 @@ pub async fn handle_generate( Json(unwrapped), ) .into_response()); - } + }, Err(e) => { error!("Stream collection error: {}", e); return Ok(( @@ -463,7 +450,7 @@ pub async fn handle_generate( format!("Stream collection error: {}", e), ) .into_response()); - } + }, } } } @@ -503,7 +490,11 @@ pub async fn handle_generate( sig.to_string(), 1, ); - debug!("[Gemini-Response] Cached signature (len: {}) for session: {}", sig.len(), session_id); + debug!( + "[Gemini-Response] Cached signature (len: {}) for session: {}", + sig.len(), + session_id + ); } } } @@ -514,10 +505,7 @@ pub async fn handle_generate( let unwrapped = unwrap_response(&gemini_resp); return Ok(( StatusCode::OK, - [ - ("X-Account-Email", email.as_str()), - ("X-Mapped-Model", mapped_model.as_str()), - ], + [("X-Account-Email", email.as_str()), ("X-Mapped-Model", mapped_model.as_str())], Json(unwrapped), ) .into_response()); @@ -525,10 +513,7 @@ pub async fn handle_generate( // 处理错误并重试 let status_code = status.as_u16(); - let error_text = response - .text() - .await - .unwrap_or_else(|_| format!("HTTP {}", status_code)); + let error_text = response.text().await.unwrap_or_else(|_| format!("HTTP {}", status_code)); last_error = format!("HTTP {}: {}", status_code, error_text); if debug_logger::is_enabled(&debug_cfg) { let payload = json!({ @@ -610,16 +595,10 @@ pub async fn handle_generate( } // 404 等由于模型配置或路径错误的 HTTP 异常,直接报错,不进行无效轮换 - error!( - "Gemini Upstream non-retryable error {}: {}", - status_code, error_text - ); + error!("Gemini Upstream non-retryable error {}: {}", status_code, error_text); return Ok(( status, - [ - ("X-Account-Email", email.as_str()), - ("X-Mapped-Model", mapped_model.as_str()), - ], + [("X-Account-Email", email.as_str()), ("X-Mapped-Model", mapped_model.as_str())], // [FIX] Return JSON error Json(json!({ "error": { @@ -695,12 +674,7 @@ pub async fn handle_count_tokens( .token_manager .get_token(model_group, false, None, "gemini") .await - .map_err(|e| { - ( - StatusCode::SERVICE_UNAVAILABLE, - format!("Token error: {}", e), - ) - })?; + .map_err(|e| (StatusCode::SERVICE_UNAVAILABLE, format!("Token error: {}", e)))?; Ok(Json(json!({"totalTokens": 0}))) } diff --git a/src-tauri/src/proxy/mappers/claude/collector.rs b/src-tauri/src/proxy/mappers/claude/collector.rs index ee5e188f8..2c38dcd4a 100644 --- a/src-tauri/src/proxy/mappers/claude/collector.rs +++ b/src-tauri/src/proxy/mappers/claude/collector.rs @@ -4,7 +4,7 @@ use super::models::*; use bytes::Bytes; use futures::StreamExt; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::io; /// SSE 事件类型 @@ -29,9 +29,7 @@ fn parse_sse_line(line: &str) -> Option<(String, String)> { /// /// 此函数接收一个 SSE 字节流,解析所有事件,并重建完整的 ClaudeResponse 对象。 /// 这使得非 Stream 客户端可以透明地享受 Stream 模式的配额优势。 -pub async fn collect_stream_to_json( - mut stream: S, -) -> Result +pub async fn collect_stream_to_json(mut stream: S) -> Result where S: futures::Stream> + Unpin, { @@ -49,10 +47,7 @@ where // 空行表示事件结束 if !current_data.is_empty() { if let Ok(data) = serde_json::from_str::(¤t_data) { - events.push(SseEvent { - event_type: current_event_type.clone(), - data, - }); + events.push(SseEvent { event_type: current_event_type.clone(), data }); } current_event_type.clear(); current_data.clear(); @@ -61,7 +56,7 @@ where match key.as_str() { "event" => current_event_type = value, "data" => current_data = value, - _ => {} + _ => {}, } } } @@ -109,7 +104,7 @@ where } } } - } + }, "content_block_start" => { if let Some(content_block) = event.data.get("content_block") { @@ -119,19 +114,20 @@ where "thinking" => { current_thinking.clear(); // Extract signature from content_block - current_signature = content_block.get("signature") + current_signature = content_block + .get("signature") .and_then(|v| v.as_str()) .map(|s| s.to_string()); - } + }, "tool_use" => { current_tool_use = Some(content_block.clone()); current_tool_input.clear(); - } - _ => {} + }, + _ => {}, } } } - } + }, "content_block_delta" => { if let Some(delta) = event.data.get("delta") { @@ -141,33 +137,35 @@ where if let Some(text) = delta.get("text").and_then(|v| v.as_str()) { current_text.push_str(text); } - } + }, "thinking_delta" => { - if let Some(thinking) = delta.get("thinking").and_then(|v| v.as_str()) { + if let Some(thinking) = + delta.get("thinking").and_then(|v| v.as_str()) + { current_thinking.push_str(thinking); } // In case signature comes in delta (less likely but possible update) if let Some(sig) = delta.get("signature").and_then(|v| v.as_str()) { current_signature = Some(sig.to_string()); } - } + }, "input_json_delta" => { - if let Some(partial_json) = delta.get("partial_json").and_then(|v| v.as_str()) { + if let Some(partial_json) = + delta.get("partial_json").and_then(|v| v.as_str()) + { current_tool_input.push_str(partial_json); } - } - _ => {} + }, + _ => {}, } } } - } + }, "content_block_stop" => { // 完成当前块 if !current_text.is_empty() { - response.content.push(ContentBlock::Text { - text: current_text.clone(), - }); + response.content.push(ContentBlock::Text { text: current_text.clone() }); current_text.clear(); } else if !current_thinking.is_empty() { response.content.push(ContentBlock::Thinking { @@ -178,8 +176,16 @@ where current_thinking.clear(); } else if let Some(tool_use) = current_tool_use.take() { // 构建 tool_use 块 - let id = tool_use.get("id").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); - let name = tool_use.get("name").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); + let id = tool_use + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let name = tool_use + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); let input = if !current_tool_input.is_empty() { serde_json::from_str(¤t_tool_input).unwrap_or(json!({})) } else { @@ -195,7 +201,7 @@ where }); current_tool_input.clear(); } - } + }, "message_delta" => { if let Some(delta) = event.data.get("delta") { @@ -208,23 +214,26 @@ where response.usage = u; } } - } + }, "message_stop" => { // Stream 结束 break; - } + }, "error" => { // 错误事件 let error_data = event.data.get("error").unwrap_or(&event.data); - let message = error_data.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown stream error"); + let message = error_data + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown stream error"); return Err(message.to_string()); - } + }, _ => { // 忽略未知事件类型 - } + }, } } @@ -249,9 +258,8 @@ mod tests { "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", ]; - let byte_stream = stream::iter( - sse_data.into_iter().map(|s| Ok::(Bytes::from(s))) - ); + let byte_stream = + stream::iter(sse_data.into_iter().map(|s| Ok::(Bytes::from(s)))); let result = collect_stream_to_json(byte_stream).await; assert!(result.is_ok()); @@ -260,7 +268,7 @@ mod tests { assert_eq!(response.id, "msg_123"); assert_eq!(response.model, "claude-3-5-sonnet"); assert_eq!(response.content.len(), 1); - + if let ContentBlock::Text { text } = &response.content[0] { assert_eq!(text, "Hello World"); } else { @@ -282,15 +290,14 @@ mod tests { "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", ]; - let byte_stream = stream::iter( - sse_data.into_iter().map(|s| Ok::(Bytes::from(s))) - ); + let byte_stream = + stream::iter(sse_data.into_iter().map(|s| Ok::(Bytes::from(s)))); let result = collect_stream_to_json(byte_stream).await; assert!(result.is_ok()); let response = result.unwrap(); - + if let ContentBlock::Thinking { thinking, signature, .. } = &response.content[0] { assert_eq!(thinking, "I am thinking"); // 验证签名是否被正确提取 diff --git a/src-tauri/src/proxy/mappers/claude/mod.rs b/src-tauri/src/proxy/mappers/claude/mod.rs index 6d78e368a..1e1786698 100644 --- a/src-tauri/src/proxy/mappers/claude/mod.rs +++ b/src-tauri/src/proxy/mappers/claude/mod.rs @@ -1,21 +1,25 @@ // Claude mapper 模块 // 负责 Claude ↔ Gemini 协议转换 +pub mod collector; pub mod models; pub mod request; pub mod response; pub mod streaming; -pub mod utils; pub mod thinking_utils; -pub mod collector; +pub mod utils; +use crate::proxy::common::client_adapter::ClientAdapter; +pub use collector::collect_stream_to_json; pub use models::*; -pub use request::{transform_claude_request_in, clean_cache_control_from_messages, merge_consecutive_messages}; +pub use request::{ + clean_cache_control_from_messages, merge_consecutive_messages, transform_claude_request_in, +}; pub use response::transform_response; pub use streaming::{PartProcessor, StreamingState}; -pub use thinking_utils::{close_tool_loop_for_thinking, filter_invalid_thinking_blocks_with_family}; -pub use collector::collect_stream_to_json; -use crate::proxy::common::client_adapter::ClientAdapter; // [NEW] +pub use thinking_utils::{ + close_tool_loop_for_thinking, filter_invalid_thinking_blocks_with_family, +}; // [NEW] use bytes::Bytes; use futures::Stream; @@ -27,13 +31,13 @@ pub fn create_claude_sse_stream( trace_id: String, email: String, session_id: Option, // [NEW v3.3.17] Session ID for signature caching - scaling_enabled: bool, // [NEW] Flag for context usage scaling + scaling_enabled: bool, // [NEW] Flag for context usage scaling context_limit: u32, estimated_prompt_tokens: Option, // [FIX] Estimated tokens for calibrator learning - message_count: usize, // [NEW v4.0.0] Message count for rewind detection + message_count: usize, // [NEW v4.0.0] Message count for rewind detection client_adapter: Option>, // [NEW] Adapter reference - registered_tool_names: Vec, // [FIX #MCP] Tool names for fuzzy matching -) -> Pin> + Send>> + registered_tool_names: Vec, // [FIX #MCP] Tool names for fuzzy matching +) -> Pin> + Send>> where S: Stream> + Send + ?Sized + 'static, E: std::fmt::Display + Send + 'static, @@ -82,13 +86,18 @@ where } } Err(e) => { - let error_json = serde_json::json!({ - "error": { - "message": format!("Stream error: {}", e), - "type": "stream_error" - } - }); - yield Ok(Bytes::from(format!("data: {}\n\n", error_json))); + // Wrap stream error as valid Claude SSE content so IDE + // parsers don't crash on non-standard JSON shape + let error_msg = format!( + "\n\n[Antigravity Error: Stream error: {}]", e + ); + let start_chunks = state.start_block( + crate::proxy::mappers::claude::streaming::BlockType::Text, + serde_json::json!({ "type": "text", "text": error_msg }), + ); + for chunk in start_chunks { yield Ok(chunk); } + let stop_chunks = state.end_block(); + for chunk in stop_chunks { yield Ok(chunk); } break; } } @@ -100,7 +109,7 @@ where } } } - + // [FIX #1732] Mandatory Flush remaining buffer on stream termination // Prevents hangs when the last SSE chunk doesn't end with a newline (network fragmentation) if !buffer.is_empty() { @@ -123,7 +132,7 @@ where // we must provide a fallback to prevent 0-token errors on client side. if state.has_thinking && !state.has_content { tracing::warn!("[{}] Stream interrupted after thinking (No Content). Triggering recovery...", trace_id); - + // 1. Force close thinking block if open if state.current_block_type() == crate::proxy::mappers::claude::streaming::BlockType::Thinking { let close_chunks = state.end_block(); @@ -136,11 +145,11 @@ where // We use a new text block for this. let recovery_msg = "\n\n[System] Upstream model interrupted after thinking. (Recovered by Antigravity)"; let start_chunks = state.start_block( - crate::proxy::mappers::claude::streaming::BlockType::Text, + crate::proxy::mappers::claude::streaming::BlockType::Text, serde_json::json!({ "type": "text", "text": recovery_msg }) ); for chunk in start_chunks { yield Ok(chunk); } - + let stop_chunks = state.end_block(); for chunk in stop_chunks { yield Ok(chunk); } @@ -174,7 +183,12 @@ where } /// 处理单行 SSE 数据 -fn process_sse_line(line: &str, state: &mut StreamingState, trace_id: &str, email: &str) -> Option> { +fn process_sse_line( + line: &str, + state: &mut StreamingState, + trace_id: &str, + email: &str, +) -> Option> { if !line.starts_with("data: ") { return None; } @@ -212,7 +226,8 @@ fn process_sse_line(line: &str, state: &mut StreamingState, trace_id: &str, emai if let Some(candidate) = raw_json.get("candidates").and_then(|c| c.get(0)) { if let Some(grounding) = candidate.get("groundingMetadata") { // 提取搜索词 - if let Some(query) = grounding.get("webSearchQueries") + if let Some(query) = grounding + .get("webSearchQueries") .and_then(|v| v.as_array()) .and_then(|arr| arr.get(0)) .and_then(|v| v.as_str()) @@ -223,7 +238,11 @@ fn process_sse_line(line: &str, state: &mut StreamingState, trace_id: &str, emai // 提取结果块 if let Some(chunks_arr) = grounding.get("groundingChunks").and_then(|v| v.as_array()) { state.grounding_chunks = Some(chunks_arr.clone()); - } else if let Some(chunks_arr) = grounding.get("grounding_metadata").and_then(|m| m.get("groundingChunks")).and_then(|v| v.as_array()) { + } else if let Some(chunks_arr) = grounding + .get("grounding_metadata") + .and_then(|m| m.get("groundingChunks")) + .and_then(|v| v.as_array()) + { state.grounding_chunks = Some(chunks_arr.clone()); } } @@ -280,25 +299,21 @@ fn process_sse_line(line: &str, state: &mut StreamingState, trace_id: &str, emai } else { String::new() }; - - tracing::info!( - "[{}] ✓ Stream completed | Account: {} | In: {} tokens | Out: {} tokens{}", - trace_id, - email, - u.prompt_token_count.unwrap_or(0).saturating_sub(cached_tokens), - u.candidates_token_count.unwrap_or(0), - cache_info - ); + + tracing::info!( + "[{}] ✓ Stream completed | Account: {} | In: {} tokens | Out: {} tokens{}", + trace_id, + email, + u.prompt_token_count.unwrap_or(0).saturating_sub(cached_tokens), + u.candidates_token_count.unwrap_or(0), + cache_info + ); } chunks.extend(state.emit_finish(Some(finish_reason), usage.as_ref())); } - if chunks.is_empty() { - None - } else { - Some(chunks) - } + if chunks.is_empty() { None } else { Some(chunks) } } /// 发送强制结束事件 @@ -306,9 +321,7 @@ pub fn emit_force_stop(state: &mut StreamingState) -> Vec { if !state.message_stop_sent { let mut chunks = state.emit_finish(None, None); if chunks.is_empty() { - chunks.push(Bytes::from( - "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", - )); + chunks.push(Bytes::from("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")); state.message_stop_sent = true; } return chunks; @@ -338,19 +351,13 @@ fn process_grounding_metadata( } // Generate a unique tool_use_id - let tool_use_id = format!( - "srvtoolu_{}", - crate::proxy::common::utils::generate_random_id() - ); + let tool_use_id = format!("srvtoolu_{}", crate::proxy::common::utils::generate_random_id()); // Build search results array let mut search_results = Vec::new(); for chunk in grounding_chunks.iter() { if let Some(web) = chunk.get("web") { - let title = web - .get("title") - .and_then(|t| t.as_str()) - .unwrap_or("Source"); + let title = web.get("title").and_then(|t| t.as_str()).unwrap_or("Source"); let uri = web.get("uri").and_then(|u| u.as_str()).unwrap_or(""); if !uri.is_empty() { search_results.push(json!({ @@ -367,10 +374,7 @@ fn process_grounding_metadata( return None; } - let search_query = search_queries - .first() - .map(|s| s.to_string()) - .unwrap_or_default(); + let search_query = search_queries.first().map(|s| s.to_string()).unwrap_or_default(); tracing::debug!( "[Grounding] Emitting {} search results for query: {}", @@ -419,20 +423,15 @@ fn process_grounding_metadata( "content": search_results } }); - chunks.push(Bytes::from(format!( - "event: content_block_start\ndata: {}\n\n", - tool_result_start - ))); + chunks + .push(Bytes::from(format!("event: content_block_start\ndata: {}\n\n", tool_result_start))); // web_search_tool_result block stop let tool_result_stop = json!({ "type": "content_block_stop", "index": state.block_index }); - chunks.push(Bytes::from(format!( - "event: content_block_stop\ndata: {}\n\n", - tool_result_stop - ))); + chunks.push(Bytes::from(format!("event: content_block_stop\ndata: {}\n\n", tool_result_stop))); state.block_index += 1; Some(chunks) @@ -450,10 +449,8 @@ mod tests { let chunks = result.unwrap(); assert!(!chunks.is_empty()); - let all_text: String = chunks - .iter() - .map(|b| String::from_utf8(b.to_vec()).unwrap_or_default()) - .collect(); + let all_text: String = + chunks.iter().map(|b| String::from_utf8(b.to_vec()).unwrap_or_default()).collect(); assert!(all_text.contains("message_stop")); } @@ -462,7 +459,7 @@ mod tests { let mut state = StreamingState::new(); let test_data = r#"data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]}}],"usageMetadata":{},"modelVersion":"test","responseId":"123"}"#; - + let result = process_sse_line(test_data, &mut state, "test_id", "test@example.com"); assert!(result.is_some()); @@ -470,10 +467,8 @@ mod tests { assert!(!chunks.is_empty()); // 应该包含 message_start 和 text delta - let all_text: String = chunks - .iter() - .map(|b| String::from_utf8(b.to_vec()).unwrap_or_default()) - .collect(); + let all_text: String = + chunks.iter().map(|b| String::from_utf8(b.to_vec()).unwrap_or_default()).collect(); assert!(all_text.contains("message_start")); assert!(all_text.contains("content_block_start")); @@ -483,7 +478,7 @@ mod tests { #[tokio::test] async fn test_thinking_only_interruption_recovery() { use futures::StreamExt; - + // 1. 模拟一个只发送 Thinking 然后就结束的流 let mock_stream = async_stream::stream! { // 发送 Thinking 块 @@ -497,7 +492,7 @@ mod tests { "responseId": "msg_interrupted" }); yield Ok::<_, String>(bytes::Bytes::from(format!("data: {}\n\n", thinking_json))); - + // 然后突然结束 (没有 Text, 没有 Usage, 直接 None) }; @@ -510,8 +505,8 @@ mod tests { false, 1_000, None, - 1, // message_count - None, // client_adapter + 1, // message_count + None, // client_adapter Vec::new(), // registered_tool_names ); @@ -527,10 +522,10 @@ mod tests { // 4. 验证恢复逻辑 // 必须包含 Thinking assert!(output.contains("Thinking...")); - + // 必须包含恢复的系统提示 assert!(output.contains("Recovered by Antigravity")); - + // 必须包含模拟的 Usage assert!(output.contains("\"usage\":")); assert!(output.contains("\"output_tokens\":100")); // Should contain the recovery usage diff --git a/src-tauri/src/proxy/mappers/claude/models.rs b/src-tauri/src/proxy/mappers/claude/models.rs index 545b3208f..5a5387f59 100644 --- a/src-tauri/src/proxy/mappers/claude/models.rs +++ b/src-tauri/src/proxy/mappers/claude/models.rs @@ -48,7 +48,6 @@ pub struct ThinkingConfig { pub effort: Option, // "low", "high", or "max" } - /// System Prompt #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -131,17 +130,10 @@ pub enum ContentBlock { }, #[serde(rename = "server_tool_use")] - ServerToolUse { - id: String, - name: String, - input: serde_json::Value, - }, + ServerToolUse { id: String, name: String, input: serde_json::Value }, #[serde(rename = "web_search_tool_result")] - WebSearchToolResult { - tool_use_id: String, - content: serde_json::Value, - }, + WebSearchToolResult { tool_use_id: String, content: serde_json::Value }, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/proxy/mappers/claude/request.rs b/src-tauri/src/proxy/mappers/claude/request.rs index 880461d0e..981f40e2f 100644 --- a/src-tauri/src/proxy/mappers/claude/request.rs +++ b/src-tauri/src/proxy/mappers/claude/request.rs @@ -5,7 +5,7 @@ use super::models::*; use crate::proxy::mappers::signature_store::get_thought_signature; // Deprecated, kept for fallback use crate::proxy::mappers::tool_result_compressor; use crate::proxy::session_manager::SessionManager; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::collections::HashMap; // ===== Safety Settings Configuration ===== @@ -75,10 +75,7 @@ fn build_safety_settings() -> Value { /// /// [FIX #593] 增强版本:添加详细日志用于调试 MCP 工具兼容性问题 pub fn clean_cache_control_from_messages(messages: &mut [Message]) { - tracing::info!( - "[DEBUG-593] Starting cache_control cleanup for {} messages", - messages.len() - ); + tracing::info!("[DEBUG-593] Starting cache_control cleanup for {} messages", messages.len()); let mut total_cleaned = 0; @@ -97,7 +94,7 @@ pub fn clean_cache_control_from_messages(messages: &mut [Message]) { *cache_control = None; total_cleaned += 1; } - } + }, ContentBlock::Image { cache_control, .. } => { if cache_control.is_some() { tracing::debug!( @@ -108,7 +105,7 @@ pub fn clean_cache_control_from_messages(messages: &mut [Message]) { *cache_control = None; total_cleaned += 1; } - } + }, ContentBlock::Document { cache_control, .. } => { if cache_control.is_some() { tracing::debug!( @@ -119,7 +116,7 @@ pub fn clean_cache_control_from_messages(messages: &mut [Message]) { *cache_control = None; total_cleaned += 1; } - } + }, ContentBlock::ToolUse { cache_control, .. } => { if cache_control.is_some() { tracing::debug!( @@ -130,8 +127,8 @@ pub fn clean_cache_control_from_messages(messages: &mut [Message]) { *cache_control = None; total_cleaned += 1; } - } - _ => {} + }, + _ => {}, } } } @@ -160,13 +157,13 @@ fn deep_clean_cache_control(value: &mut Value) { for (_, v) in map.iter_mut() { deep_clean_cache_control(v); } - } + }, Value::Array(arr) => { for item in arr.iter_mut() { deep_clean_cache_control(item); } - } - _ => {} + }, + _ => {}, } } @@ -198,14 +195,14 @@ fn sort_thinking_blocks_first(messages: &mut [Message]) { if saw_non_thinking { needs_reorder = true; } - } + }, ContentBlock::Text { .. } => { saw_non_thinking = true; - } + }, ContentBlock::ToolUse { .. } => { saw_non_thinking = true; // Check if tool is after text (this is normal, but we want a strict group order) - } + }, _ => saw_non_thinking = true, } } @@ -218,19 +215,19 @@ fn sort_thinking_blocks_first(messages: &mut [Message]) { ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => { thinking_blocks.push(block); - } + }, ContentBlock::Text { text } => { // Filter out purely empty or structural text like "(no content)" if !text.trim().is_empty() && text != "(no content)" { text_blocks.push(block); } - } + }, ContentBlock::ToolUse { .. } => { tool_blocks.push(block); - } + }, _ => { other_blocks.push(block); - } + }, } } @@ -272,20 +269,19 @@ pub fn merge_consecutive_messages(messages: &mut Vec) { match (&mut current.content, next.content) { (MessageContent::Array(current_blocks), MessageContent::Array(next_blocks)) => { current_blocks.extend(next_blocks); - } + }, (MessageContent::Array(current_blocks), MessageContent::String(next_text)) => { current_blocks.push(ContentBlock::Text { text: next_text }); - } + }, (MessageContent::String(current_text), MessageContent::String(next_text)) => { *current_text = format!("{}\n\n{}", current_text, next_text); - } + }, (MessageContent::String(current_text), MessageContent::Array(next_blocks)) => { - let mut new_blocks = vec![ContentBlock::Text { - text: current_text.clone(), - }]; + let mut new_blocks = + vec![ContentBlock::Text { text: current_text.clone() }]; new_blocks.extend(next_blocks); current.content = MessageContent::Array(new_blocks); - } + }, } } else { merged.push(current); @@ -361,7 +357,8 @@ pub fn transform_claude_request_in( // ThinkingConfig was provided by the client, inject a default config with a budget // to prevent 'thinking requires a budget' errors from upstream APIs. if cleaned_req.thinking.is_none() && should_enable_thinking_by_default(&cleaned_req.model) { - let default_budget = crate::proxy::model_specs::get_thinking_budget(&cleaned_req.model, token); + let default_budget = + crate::proxy::model_specs::get_thinking_budget(&cleaned_req.model, token); tracing::info!( "[Thinking-Mode] Injecting default ThinkingConfig (budget={}) for model: {}", default_budget, @@ -404,12 +401,7 @@ pub fn transform_claude_request_in( .tools .as_ref() .map(|tools| { - tools.iter().any(|t| { - t.name - .as_deref() - .map(|n| n.starts_with("mcp__")) - .unwrap_or(false) - }) + tools.iter().any(|t| t.name.as_deref().map(|n| n.starts_with("mcp__")).unwrap_or(false)) }) .unwrap_or(false); @@ -431,14 +423,14 @@ pub fn transform_claude_request_in( // [IMPROVED] 提取 web search 模型为常量,便于维护 const WEB_SEARCH_FALLBACK_MODEL: &str = "gemini-2.5-flash"; - let mapped_model = crate::proxy::common::model_mapping::map_claude_model_to_gemini(&claude_req.model); + let mapped_model = + crate::proxy::common::model_mapping::map_claude_model_to_gemini(&claude_req.model); // 将 Claude 工具转为 Value 数组以便探测联网 - let tools_val: Option> = claude_req.tools.as_ref().map(|list| { - list.iter() - .map(|t| serde_json::to_value(t).unwrap_or(json!({}))) - .collect() - }); + let tools_val: Option> = claude_req + .tools + .as_ref() + .map(|list| list.iter().map(|t| serde_json::to_value(t).unwrap_or(json!({}))).collect()); // Resolve grounding config let config = crate::proxy::mappers::common_utils::resolve_request_config( @@ -459,7 +451,8 @@ pub fn transform_claude_request_in( // Check if thinking is enabled in the request let thinking_type = claude_req.thinking.as_ref().map(|t| t.type_.as_str()); - let mut is_thinking_enabled = thinking_type == Some("enabled") || thinking_type == Some("adaptive") + let mut is_thinking_enabled = thinking_type == Some("enabled") + || thinking_type == Some("adaptive") || (thinking_type.is_none() && should_enable_thinking_by_default(&claude_req.model)); // [NEW FIX] Check if target model supports thinking @@ -487,7 +480,6 @@ pub fn transform_claude_request_in( // 原因: 该检查过于激进,会导致 Claude Code CLI 在历史记录不完美时永久禁用思考模式 (Issue #2006) // 现在的策略是依赖 thinking_utils.rs 中的 Recovery 机制来修复历史,而不是禁用思考。 - // [FIX #295 & #298] If thinking enabled but no signature available, // disable thinking to prevent Gemini 3 Pro rejection if is_thinking_enabled { @@ -497,9 +489,7 @@ pub fn transform_claude_request_in( let has_thinking_history = claude_req.messages.iter().any(|m| { if m.role == "assistant" { if let MessageContent::Array(blocks) = &m.content { - return blocks - .iter() - .any(|b| matches!(b, ContentBlock::Thinking { .. })); + return blocks.iter().any(|b| matches!(b, ContentBlock::Thinking { .. })); } } false @@ -508,9 +498,7 @@ pub fn transform_claude_request_in( // Check if there are function calls in the request let has_function_calls = claude_req.messages.iter().any(|m| { if let MessageContent::Array(blocks) = &m.content { - blocks - .iter() - .any(|b| matches!(b, ContentBlock::ToolUse { .. })) + blocks.iter().any(|b| matches!(b, ContentBlock::ToolUse { .. })) } else { false } @@ -608,13 +596,14 @@ pub fn transform_claude_request_in( }); } - // 深度清理 [undefined] 字符串 (Cherry Studio 等客户端常见注入) crate::proxy::mappers::common_utils::deep_clean_undefined(&mut inner_request, 0); - if config.inject_google_search && !has_web_search_tool { - crate::proxy::mappers::common_utils::inject_google_search_tool(&mut inner_request, Some(&mapped_model)); + crate::proxy::mappers::common_utils::inject_google_search_tool( + &mut inner_request, + Some(&mapped_model), + ); } // Inject imageConfig if present (for image generation models) @@ -653,12 +642,14 @@ pub fn transform_claude_request_in( // [ADDED v4.1.24] 注入稳定 sessionId 对齐官方规范 if let Some(account_id) = account_id { - inner_request["sessionId"] = json!(crate::proxy::common::session::derive_session_id(account_id)); + inner_request["sessionId"] = + json!(crate::proxy::common::session::derive_session_id(account_id)); } // 生成 requestId // [CHANGED v4.1.24] Structured requestId to match official format - let request_id = format!("agent/antigravity/{}/{}", &session_id[..session_id.len().min(8)], message_count); + let request_id = + format!("agent/antigravity/{}/{}", &session_id[..session_id.len().min(8)], message_count); // 构建最终请求体 let mut body = json!({ @@ -686,8 +677,6 @@ pub fn transform_claude_request_in( Ok(body) } - - /// Check if thinking mode should be enabled by default for a given model /// /// Claude Code v2.0.67+ enables thinking by default for Opus 4.5 models. @@ -702,10 +691,7 @@ fn should_enable_thinking_by_default(model: &str) -> bool { || model_lower.contains("opus-4-6") || model_lower.contains("opus-4.6") { - tracing::debug!( - "[Thinking-Mode] Auto-enabling thinking for Opus model: {}", - model - ); + tracing::debug!("[Thinking-Mode] Auto-enabling thinking for Opus model: {}", model); return true; } @@ -721,20 +707,14 @@ fn should_enable_thinking_by_default(model: &str) -> bool { || model_lower.contains("gemini-3-pro") || model_lower.contains("gemini-3.1-pro") { - tracing::debug!( - "[Thinking-Mode] Auto-enabling thinking for Gemini Pro model: {}", - model - ); + tracing::debug!("[Thinking-Mode] Auto-enabling thinking for Gemini Pro model: {}", model); return true; } // [FEATURE] 为 gemini-3-flash / gemini-3.1-flash 自动开启 thinking // 让 Cherry Studio 等客户端即使未显式传 thinking.type 也能获取思维链内容 if model_lower.contains("gemini-3-flash") || model_lower.contains("gemini-3.1-flash") { - tracing::debug!( - "[Thinking-Mode] Auto-enabling thinking for Flash model: {}", - model - ); + tracing::debug!("[Thinking-Mode] Auto-enabling thinking for Flash model: {}", model); return true; } @@ -782,11 +762,7 @@ fn has_valid_signature_for_function_calls( if msg.role == "assistant" { if let MessageContent::Array(blocks) = &msg.content { for block in blocks { - if let ContentBlock::Thinking { - signature: Some(sig), - .. - } = block - { + if let ContentBlock::Thinking { signature: Some(sig), .. } = block { if sig.len() >= MIN_SIGNATURE_LENGTH { tracing::debug!( "[Signature-Check] Found valid signature in message history (len: {})", @@ -829,7 +805,7 @@ fn build_system_instruction( if text.contains("You are Antigravity") { user_has_antigravity = true; } - } + }, SystemPrompt::Array(blocks) => { for block in blocks { if block.block_type == "text" && block.text.contains("You are Antigravity") { @@ -837,7 +813,7 @@ fn build_system_instruction( break; } } - } + }, } } @@ -859,7 +835,7 @@ fn build_system_instruction( // [MODIFIED] No longer filter "You are an interactive CLI tool" // We pass everything through to ensure Flash/Lite models get full instructions parts.push(json!({"text": text})); - } + }, SystemPrompt::Array(blocks) => { for block in blocks { if block.block_type == "text" { @@ -867,7 +843,7 @@ fn build_system_instruction( parts.push(json!({"text": block.text})); } } - } + }, } } @@ -929,7 +905,7 @@ fn build_contents( parts.push(json!({"text": trimmed})); } } - } + }, MessageContent::Array(blocks) => { for item in blocks { match item { @@ -944,7 +920,10 @@ fn build_contents( if !current_normalized.is_empty() && current_normalized == *last_task { - tracing::info!("[Claude-Request] Dropping duplicated task text echo (len: {})", text.len()); + tracing::info!( + "[Claude-Request] Dropping duplicated task text echo (len: {})", + text.len() + ); continue; } } @@ -960,12 +939,8 @@ fn build_contents( } *previous_was_tool_result = false; } - } - ContentBlock::Thinking { - thinking, - signature, - .. - } => { + }, + ContentBlock::Thinking { thinking, signature, .. } => { tracing::debug!( "[DEBUG-TRANSFORM] Processing thinking block. Sig: {:?}", signature @@ -974,7 +949,10 @@ fn build_contents( // [HOTFIX] Gemini Protocol Enforcement: Thinking block MUST be the first block. // If we already have content (like Text), we must downgrade this thinking block to Text. if saw_non_thinking || !parts.is_empty() { - tracing::warn!("[Claude-Request] Thinking block found at non-zero index (prev parts: {}). Downgrading to Text.", parts.len()); + tracing::warn!( + "[Claude-Request] Thinking block found at non-zero index (prev parts: {}). Downgrading to Text.", + parts.len() + ); if !thinking.trim().is_empty() { parts.push(json!({ "text": thinking.trim() @@ -987,7 +965,9 @@ fn build_contents( // [FIX] If thinking is disabled (smart downgrade), convert ALL thinking blocks to text // to avoid "thinking is disabled but message contains thinking" error if !is_thinking_enabled { - tracing::warn!("[Claude-Request] Thinking disabled. Downgrading thinking block to text."); + tracing::warn!( + "[Claude-Request] Thinking disabled. Downgrading thinking block to text." + ); if !thinking.trim().is_empty() { parts.push(json!({ "text": thinking.trim() @@ -1000,7 +980,9 @@ fn build_contents( // [FIX] Empty thinking blocks cause "Field required" errors. // We downgrade them to Text to avoid structural errors and signature mismatch. if thinking.is_empty() { - tracing::warn!("[Claude-Request] Empty thinking block detected. Downgrading to Text."); + tracing::warn!( + "[Claude-Request] Empty thinking block detected. Downgrading to Text." + ); parts.push(json!({ "text": "..." })); @@ -1014,7 +996,8 @@ fn build_contents( if sig.len() < MIN_SIGNATURE_LENGTH { tracing::warn!( "[Thinking-Signature] Signature too short (len: {} < {}), downgrading to text.", - sig.len(), MIN_SIGNATURE_LENGTH + sig.len(), + MIN_SIGNATURE_LENGTH ); parts.push(json!({"text": thinking})); saw_non_thinking = true; @@ -1035,8 +1018,13 @@ fn build_contents( if !compatible { tracing::warn!( "[Thinking-Signature] {} signature (Family: {}, Target: {}). Downgrading to text.", - if is_retry { "Stripping historical" } else { "Incompatible" }, - family, mapped_model + if is_retry { + "Stripping historical" + } else { + "Incompatible" + }, + family, + mapped_model ); parts.push(json!({"text": thinking})); saw_non_thinking = true; @@ -1051,7 +1039,7 @@ fn build_contents( }); crate::proxy::common::json_schema::clean_json_schema(&mut part); parts.push(part); - } + }, None => { // For JSON tool calling compatibility, if signature is long enough but unknown, // we should trust it rather than downgrade to text @@ -1080,7 +1068,7 @@ fn build_contents( saw_non_thinking = true; continue; } - } + }, } } else { // No signature: downgrade to text @@ -1090,7 +1078,7 @@ fn build_contents( parts.push(json!({"text": thinking})); saw_non_thinking = true; } - } + }, ContentBlock::RedactedThinking { data } => { // [FIX] 将 RedactedThinking 作为普通文本处理,保留上下文 tracing::debug!("[Claude-Request] Degrade RedactedThinking to text"); @@ -1099,7 +1087,7 @@ fn build_contents( })); saw_non_thinking = true; continue; - } + }, ContentBlock::Image { source, .. } => { if source.source_type == "base64" { parts.push(json!({ @@ -1110,7 +1098,7 @@ fn build_contents( })); saw_non_thinking = true; } - } + }, ContentBlock::Document { source, .. } => { if source.source_type == "base64" { parts.push(json!({ @@ -1121,14 +1109,8 @@ fn build_contents( })); saw_non_thinking = true; } - } - ContentBlock::ToolUse { - id, - name, - input, - signature, - .. - } => { + }, + ContentBlock::ToolUse { id, name, input, signature, .. } => { let mut final_input = input.clone(); // [New] 利用通用引擎修正参数类型 (替代以前硬编码的 shell 工具修复逻辑) @@ -1200,13 +1182,18 @@ fn build_contents( if let Some(sig) = final_sig { // [NEW] If this is a retry, do NOT backfill signatures to avoid issues. if is_retry && signature.is_none() { - tracing::warn!("[Tool-Signature] Skipping signature backfill for tool_use: {} during retry.", id); + tracing::warn!( + "[Tool-Signature] Skipping signature backfill for tool_use: {} during retry.", + id + ); } else { // Check signature length first - if it's too short, it's definitely invalid if sig.len() < MIN_SIGNATURE_LENGTH { tracing::warn!( "[Tool-Signature] Signature too short for tool_use: {} (len: {} < {}), skipping.", - id, sig.len(), MIN_SIGNATURE_LENGTH + id, + sig.len(), + MIN_SIGNATURE_LENGTH ); } else { // Check signature compatibility (optional for tool_use) @@ -1221,18 +1208,21 @@ fn build_contents( } else { tracing::warn!( "[Tool-Signature] Incompatible signature for tool_use: {} (Family: {}, Target: {})", - id, family, mapped_model + id, + family, + mapped_model ); false } - } + }, None => { // For JSON tool calling compatibility, if signature is long enough but unknown, // we should trust it rather than drop it if sig.len() >= MIN_SIGNATURE_LENGTH { tracing::debug!( "[Tool-Signature] Unknown signature origin but valid length (len: {}) for tool_use: {}, using as-is for JSON tool calling.", - sig.len(), id + sig.len(), + id ); true } else { @@ -1240,7 +1230,8 @@ fn build_contents( if is_thinking_enabled { tracing::warn!( "[Tool-Signature] Unknown signature origin and too short for tool_use: {} (len: {}). Dropping in thinking mode.", - id, sig.len() + id, + sig.len() ); false } else { @@ -1248,7 +1239,7 @@ fn build_contents( true } } - } + }, }; if should_use_sig { part["thoughtSignature"] = json!(sig); @@ -1260,19 +1251,17 @@ fn build_contents( // Use skip_thought_signature_validator as a sentinel value let is_google_cloud = mapped_model.starts_with("projects/"); if is_thinking_enabled && !is_google_cloud { - tracing::debug!("[Tool-Signature] Adding GEMINI_SKIP_SIGNATURE for tool_use: {}", id); + tracing::debug!( + "[Tool-Signature] Adding GEMINI_SKIP_SIGNATURE for tool_use: {}", + id + ); part["thoughtSignature"] = json!("skip_thought_signature_validator"); } } parts.push(part); - } - ContentBlock::ToolResult { - tool_use_id, - content, - is_error, - .. - } => { + }, + ContentBlock::ToolResult { tool_use_id, content, is_error, .. } => { // Mark this tool ID as resolved in this turn current_turn_tool_result_ids.insert(tool_use_id.clone()); // 优先使用之前记录的 name,否则用 tool_use_id @@ -1300,11 +1289,13 @@ fn build_contents( if let Some(text) = block.get("text").and_then(|v| v.as_str()) { texts.push(text.to_string()); } else if block.get("source").is_some() { - if block.get("type").and_then(|v| v.as_str()) == Some("image") { + if block.get("type").and_then(|v| v.as_str()) + == Some("image") + { let source = block.get("source").unwrap(); if let (Some(media_type), Some(data)) = ( source.get("media_type").and_then(|v| v.as_str()), - source.get("data").and_then(|v| v.as_str()) + source.get("data").and_then(|v| v.as_str()), ) { extra_parts.push(json!({ "inlineData": { @@ -1317,7 +1308,7 @@ fn build_contents( } } texts.join("\n") - } + }, _ => content.to_string(), }; @@ -1369,16 +1360,16 @@ fn build_contents( // 标记状态,用于下一条 User 消息的去重判断 *previous_was_tool_result = true; - } + }, // ContentBlock::RedactedThinking handled above at line 583 ContentBlock::ServerToolUse { .. } | ContentBlock::WebSearchToolResult { .. } => { // 搜索结果 block 不应由客户端发回给上游 (已由 tool_result 替代) continue; - } + }, } } - } + }, } // If this is a User message, check if we need to inject missing tool results @@ -1390,7 +1381,11 @@ fn build_contents( .collect(); if !missing_ids.is_empty() { - tracing::warn!("[Elastic-Recovery] Injecting {} missing tool results into User message (IDs: {:?})", missing_ids.len(), missing_ids); + tracing::warn!( + "[Elastic-Recovery] Injecting {} missing tool results into User message (IDs: {:?})", + missing_ids.len(), + missing_ids + ); for id in missing_ids.iter().rev() { // Insert in reverse order to maintain order at index 0? No, just insert at 0. let name = tool_id_to_name.get(id).cloned().unwrap_or(id.clone()); @@ -1451,7 +1446,10 @@ fn build_contents( "thought": true }), ); - tracing::debug!("First part of model message at {} is not a valid thought block. Prepending dummy.", parts.len()); + tracing::debug!( + "First part of model message at {} is not a valid thought block. Prepending dummy.", + parts.len() + ); } else { // 确保首项包含了 thought: true (防止只有 signature 的情况) if let Some(p0) = parts.get_mut(0) { @@ -1484,18 +1482,17 @@ fn build_google_content( previous_was_tool_result: &mut bool, existing_tool_result_ids: &std::collections::HashSet, ) -> Result { - let role = if msg.role == "assistant" { - "model" - } else { - &msg.role - }; + let role = if msg.role == "assistant" { "model" } else { &msg.role }; // Proactive Tool Chain Repair: // If we are about to process an Assistant message, but we still have pending tool_use_ids, // it means the previous turn was interrupted or the user ignored the tool. // We MUST inject a synthetic User message with error results to close the loop. if role == "model" && !pending_tool_use_ids.is_empty() { - tracing::warn!("[Elastic-Recovery] Detected interrupted tool chain (Assistant -> Assistant). Injecting synthetic User message for IDs: {:?}", pending_tool_use_ids); + tracing::warn!( + "[Elastic-Recovery] Detected interrupted tool chain (Assistant -> Assistant). Injecting synthetic User message for IDs: {:?}", + pending_tool_use_ids + ); let synthetic_parts: Vec = pending_tool_use_ids .iter() @@ -1693,10 +1690,7 @@ fn build_tools( // 2. Detect by name if let Some(name) = &tool.name { - if name == "web_search" - || name == "google_search" - || name == "builtin_web_search" - { + if name == "web_search" || name == "google_search" || name == "builtin_web_search" { has_google_search = true; continue; } @@ -1727,10 +1721,7 @@ fn build_tools( if !function_declarations.is_empty() { let mut func_obj = serde_json::Map::new(); - func_obj.insert( - "functionDeclarations".to_string(), - json!(function_declarations), - ); + func_obj.insert("functionDeclarations".to_string(), json!(function_declarations)); tool_list.push(json!(func_obj)); if has_google_search { @@ -1780,13 +1771,13 @@ fn build_generation_config( let user_thinking_type = claude_req.thinking.as_ref().map(|t| t.type_.as_str()); let user_is_adaptive = user_thinking_type == Some("adaptive"); - let budget_tokens = claude_req - .thinking - .as_ref() - .and_then(|t| t.budget_tokens) - .unwrap_or_else(|| crate::proxy::model_specs::get_thinking_budget(mapped_model, token) as u32); + let budget_tokens = + claude_req.thinking.as_ref().and_then(|t| t.budget_tokens).unwrap_or_else(|| { + crate::proxy::model_specs::get_thinking_budget(mapped_model, token) as u32 + }); - let thinking_budget_cap = crate::proxy::model_specs::get_thinking_budget(mapped_model, token); + let thinking_budget_cap = + crate::proxy::model_specs::get_thinking_budget(mapped_model, token); let tb_config = crate::proxy::config::get_thinking_budget_config(); let budget = match tb_config.mode { @@ -1795,43 +1786,55 @@ fn build_generation_config( let mut custom_value = tb_config.custom_value as u64; // [FIX #1602] 针对 Gemini 系列模型,在自定义模式下也强制执行动态限额 let model_lower = mapped_model.to_lowercase(); - let is_gemini_limited = (model_lower.contains("gemini") && !model_lower.contains("-image")) + let is_gemini_limited = (model_lower.contains("gemini") + && !model_lower.contains("-image")) || model_lower.contains("flash") || model_lower.ends_with("-thinking"); if is_gemini_limited && custom_value > thinking_budget_cap { tracing::warn!( "[Claude-Request] Custom mode: capping thinking_budget from {} to {} for Gemini model {}", - custom_value, thinking_budget_cap, mapped_model + custom_value, + thinking_budget_cap, + mapped_model ); custom_value = thinking_budget_cap; } custom_value - } + }, crate::proxy::config::ThinkingBudgetMode::Auto => { // [FIX #1592] Use mapped model for robust detection, same as OpenAI protocol let model_lower = mapped_model.to_lowercase(); - let is_gemini_limited = (model_lower.contains("gemini") && !model_lower.contains("-image")) + let is_gemini_limited = (model_lower.contains("gemini") + && !model_lower.contains("-image")) || model_lower.contains("flash") || model_lower.ends_with("-thinking"); if is_gemini_limited && budget_tokens as u64 > thinking_budget_cap { tracing::info!( - "[Claude-Request] Auto mode: capping thinking_budget from {} to {} for Gemini model {}", - budget_tokens, thinking_budget_cap, mapped_model + "[Claude-Request] Auto mode: capping thinking_budget from {} to {} for Gemini model {}", + budget_tokens, + thinking_budget_cap, + mapped_model ); thinking_budget_cap } else { budget_tokens as u64 } - } + }, crate::proxy::config::ThinkingBudgetMode::Adaptive => budget_tokens as u64, // Adaptive 模式透传原始预算(但不作为限制),用于后续逻辑判断 }; - let global_mode_is_adaptive = matches!(tb_config.mode, crate::proxy::config::ThinkingBudgetMode::Adaptive); + let global_mode_is_adaptive = + matches!(tb_config.mode, crate::proxy::config::ThinkingBudgetMode::Adaptive); // 只要用户指定 adaptive 或者全局配置为 adaptive,且是支持的思维模型,就启用自适应 - let should_use_adaptive = (user_is_adaptive || global_mode_is_adaptive) && (mapped_model.to_lowercase().contains("claude") || mapped_model.to_lowercase().contains("gemini-3")); + let should_use_adaptive = (user_is_adaptive || global_mode_is_adaptive) + && (mapped_model.to_lowercase().contains("claude") + || mapped_model.to_lowercase().contains("gemini-3")); - let effort = claude_req.output_config.as_ref().and_then(|c| c.effort.as_ref()) + let effort = claude_req + .output_config + .as_ref() + .and_then(|c| c.effort.as_ref()) .or_else(|| claude_req.thinking.as_ref().and_then(|t| t.effort.as_ref())); if should_use_adaptive { @@ -1848,7 +1851,10 @@ fn build_generation_config( Some("high") | Some("max") => "high", _ => "high", }; - tracing::debug!("[Claude-Request] Mapping adaptive mode to thinkingLevel: {} for Claude model", mapped_level); + tracing::debug!( + "[Claude-Request] Mapping adaptive mode to thinkingLevel: {} for Claude model", + mapped_level + ); thinking_config["thinkingLevel"] = json!(mapped_level); // Claude using thinkingLevel must NOT have thinkingBudget to avoid conflict thinking_config.as_object_mut().unwrap().remove("thinkingBudget"); @@ -1858,10 +1864,12 @@ fn build_generation_config( // Gemini 1.5/2.0 models via Vertex AI often reject thinkingBudget: -1 (Adaptive) with 400 Invalid Argument // especially when maxOutputTokens is high. // We align with OpenAI mapper behavior: use 24576 as safe adaptive budget. - tracing::debug!("[Claude-Request] Mapping adaptive mode to safe budget (24576) for Gemini model (thinkingLevel not supported)"); + tracing::debug!( + "[Claude-Request] Mapping adaptive mode to safe budget (24576) for Gemini model (thinkingLevel not supported)" + ); thinking_config["thinkingBudget"] = json!(24576); } - + // 针对自适应模式,如果没有显式设置,确保 maxOutputTokens 给足空间 // OpenAI mapper uses 57344 (24576 + 32768), we normally use 64k limit. if config.get("maxOutputTokens").is_none() { @@ -1871,13 +1879,15 @@ fn build_generation_config( // [FIX #2007] Opus 4.6 Thinking Alignment (OpenAI Protocol Recipe) // Explicitly set fixed budget for Opus 4.6 to match successful OpenAI pattern if mapped_model.to_lowercase().contains("claude-opus-4-6-thinking") { - tracing::debug!("[Opus-Alignment] Enforcing fixed thinkingBudget 24576 for Opus 4.6"); + tracing::debug!( + "[Opus-Alignment] Enforcing fixed thinkingBudget 24576 for Opus 4.6" + ); thinking_config["thinkingBudget"] = json!(24576); } else { thinking_config["thinkingBudget"] = json!(budget); } } - + config["thinkingConfig"] = thinking_config; } @@ -1896,7 +1906,6 @@ fn build_generation_config( config["topK"] = json!(40); // [ADDED v4.1.24] Default topK=40 to match official client } - // web_search 强制 candidateCount=1 /*if has_web_search { config["candidateCount"] = json!(1); @@ -1912,9 +1921,10 @@ fn build_generation_config( // 重新计算 should_use_adaptive (因为上面定义的作用域仅在其 if 块内有效,或者我们可以假设在这里也需要同样的逻辑) // 但为了简洁和解耦,我们这里重新从 config 读取 let tb_config_chk = crate::proxy::config::get_thinking_budget_config(); - let global_adaptive = matches!(tb_config_chk.mode, crate::proxy::config::ThinkingBudgetMode::Adaptive); + let global_adaptive = + matches!(tb_config_chk.mode, crate::proxy::config::ThinkingBudgetMode::Adaptive); let req_adaptive = claude_req.thinking.as_ref().map(|t| t.type_ == "adaptive").unwrap_or(false); - + let is_adaptive_effective = (req_adaptive || global_adaptive) && model_lower.contains("claude"); // [FIX] Lower default overhead to keep total under 65536 let final_overhead = if is_adaptive_effective { 64000 } else { 32768 }; @@ -1927,10 +1937,7 @@ fn build_generation_config( } if let Some(thinking_config) = config.get("thinkingConfig") { - if let Some(budget) = thinking_config - .get("thinkingBudget") - .and_then(|t| t.as_u64()) - { + if let Some(budget) = thinking_config.get("thinkingBudget").and_then(|t| t.as_u64()) { let current = final_max_tokens.unwrap_or(0); if current <= budget as i64 { // [FIX #1675] 针对图像模型使用更小的增量 (2048) @@ -1938,15 +1945,16 @@ fn build_generation_config( let boosted = (budget + overhead).min(65536); // [FIX] Never exceed hard limit final_max_tokens = Some(boosted as i64); tracing::info!( - "[Generation-Config] Bumping maxOutputTokens to {} due to thinking budget of {}", - boosted, budget + "[Generation-Config] Bumping maxOutputTokens to {} due to thinking budget of {}", + boosted, + budget ); } } else if is_adaptive_effective { - // [FIX] Adaptive mode (no budget set in thinkingConfig), apply default maxOutputTokens - if final_max_tokens.is_none() { - final_max_tokens = Some(final_overhead as i64); - } + // [FIX] Adaptive mode (no budget set in thinkingConfig), apply default maxOutputTokens + if final_max_tokens.is_none() { + final_max_tokens = Some(final_overhead as i64); + } } } else { // No thinkingConfig @@ -1955,7 +1963,6 @@ fn build_generation_config( } } - if let Some(val) = final_max_tokens { // [FIX] Cap maxOutputTokens to 65536 to avoid INVALID_ARGUMENT (Cherry Studio sends 128000) // Gemini models typically support max 8192 or 65536 output tokens. 128k is usually invalid. @@ -1963,7 +1970,8 @@ fn build_generation_config( if val > safe_limit { tracing::warn!( "[Generation-Config] Capping maxOutputTokens from {} to {} to prevent 400 Invalid Argument", - val, safe_limit + val, + safe_limit ); config["maxOutputTokens"] = json!(safe_limit); } else { @@ -1977,7 +1985,9 @@ fn build_generation_config( if !(model_lower.contains("claude-opus-4-6-thinking") && is_thinking_enabled) { config["stopSequences"] = json!(["<|user|>", "<|end_of_turn|>", "\n\nHuman:"]); } else { - tracing::debug!("[Opus-Alignment] Skipping stopSequences for Opus 4.6 to match OpenAI protocol"); + tracing::debug!( + "[Opus-Alignment] Skipping stopSequences for Opus 4.6 to match OpenAI protocol" + ); } config @@ -1993,13 +2003,13 @@ pub fn clean_thinking_fields_recursive(val: &mut Value) { for (_, v) in map.iter_mut() { clean_thinking_fields_recursive(v); } - } + }, Value::Array(arr) => { for v in arr.iter_mut() { clean_thinking_fields_recursive(v); } - } - _ => {} + }, + _ => {}, } } @@ -2088,9 +2098,7 @@ mod tests { // Now test serialization let serialized = serde_json::to_value(&req).unwrap(); println!("Serialized: {}", serialized); - assert!(serialized["messages"][0]["content"][0] - .get("cache_control") - .is_none()); + assert!(serialized["messages"][0]["content"][0].get("cache_control").is_none()); } #[test] @@ -2115,7 +2123,8 @@ mod tests { quality: None, }; - let result = transform_claude_request_in(&req, "test-project", false, None, "test_session", None); + let result = + transform_claude_request_in(&req, "test-project", false, None, "test_session", None); assert!(result.is_ok()); let body = result.unwrap(); @@ -2212,7 +2221,8 @@ mod tests { quality: None, }; - let result = transform_claude_request_in(&req, "test-project", false, None, "test_session", None); + let result = + transform_claude_request_in(&req, "test-project", false, None, "test_session", None); assert!(result.is_ok()); let body = result.unwrap(); @@ -2251,9 +2261,7 @@ mod tests { signature: Some("sig123".to_string()), cache_control: Some(json!({"type": "ephemeral"})), // 这个应该被清理 }, - ContentBlock::Text { - text: "Here is my response".to_string(), - }, + ContentBlock::Text { text: "Here is my response".to_string() }, ]), }, Message { @@ -2282,7 +2290,8 @@ mod tests { quality: None, }; - let result = transform_claude_request_in(&req, "test-project", false, None, "test_session", None); + let result = + transform_claude_request_in(&req, "test-project", false, None, "test_session", None); assert!(result.is_ok()); // 验证请求成功转换 @@ -2309,9 +2318,7 @@ mod tests { Message { role: "assistant".to_string(), content: MessageContent::Array(vec![ - ContentBlock::Text { - text: "Checking...".to_string(), - }, + ContentBlock::Text { text: "Checking...".to_string() }, ContentBlock::ToolUse { id: "tool_1".to_string(), name: "list_files".to_string(), @@ -2356,7 +2363,8 @@ mod tests { quality: None, }; - let result = transform_claude_request_in(&req, "test-project", false, None, "test_session", None); + let result = + transform_claude_request_in(&req, "test-project", false, None, "test_session", None); assert!(result.is_ok()); let body = result.unwrap(); @@ -2406,17 +2414,14 @@ mod tests { quality: None, }; - let result = transform_claude_request_in(&req, "test-project", false, None, "test_session", None); + let result = + transform_claude_request_in(&req, "test-project", false, None, "test_session", None); assert!(result.is_ok()); let body = result.unwrap(); let contents = body["request"]["contents"].as_array().unwrap(); - let last_model_msg = contents - .iter() - .rev() - .find(|c| c["role"] == "model") - .unwrap(); + let last_model_msg = contents.iter().rev().find(|c| c["role"] == "model").unwrap(); let parts = last_model_msg["parts"].as_array().unwrap(); @@ -2439,9 +2444,7 @@ mod tests { signature: Some("sig".to_string()), cache_control: None, }, - ContentBlock::Text { - text: "Hi".to_string(), - }, + ContentBlock::Text { text: "Hi".to_string() }, ]), }], system: None, @@ -2462,21 +2465,16 @@ mod tests { quality: None, }; - let result = transform_claude_request_in(&req, "test-project", false, None, "test_session", None); + let result = + transform_claude_request_in(&req, "test-project", false, None, "test_session", None); assert!(result.is_ok(), "Transformation failed"); let body = result.unwrap(); let contents = body["request"]["contents"].as_array().unwrap(); let parts = contents[0]["parts"].as_array().unwrap(); // 验证 thinking 块 - assert_eq!( - parts[0]["text"], "...", - "Empty thinking should be filled with ..." - ); - assert!( - parts[0].get("thought").is_none(), - "Empty thinking should be downgraded to text" - ); + assert_eq!(parts[0]["text"], "...", "Empty thinking should be filled with ..."); + assert!(parts[0].get("thought").is_none(), "Empty thinking should be downgraded to text"); } #[test] @@ -2488,12 +2486,8 @@ mod tests { messages: vec![Message { role: "assistant".to_string(), content: MessageContent::Array(vec![ - ContentBlock::RedactedThinking { - data: "some data".to_string(), - }, - ContentBlock::Text { - text: "Hi".to_string(), - }, + ContentBlock::RedactedThinking { data: "some data".to_string() }, + ContentBlock::Text { text: "Hi".to_string() }, ]), }], system: None, @@ -2510,7 +2504,8 @@ mod tests { quality: None, }; - let result = transform_claude_request_in(&req, "test-project", false, None, "test_session", None); + let result = + transform_claude_request_in(&req, "test-project", false, None, "test_session", None); assert!(result.is_ok()); let body = result.unwrap(); let parts = body["request"]["contents"][0]["parts"].as_array().unwrap(); @@ -2534,9 +2529,7 @@ mod tests { role: "assistant".to_string(), content: MessageContent::Array(vec![ // Wrong order: Text before Thinking (simulates kilo compression) - ContentBlock::Text { - text: "Some regular text".to_string(), - }, + ContentBlock::Text { text: "Some regular text".to_string() }, ContentBlock::Thinking { thinking: "My thinking process".to_string(), signature: Some( @@ -2544,9 +2537,7 @@ mod tests { ), cache_control: None, }, - ContentBlock::Text { - text: "More text".to_string(), - }, + ContentBlock::Text { text: "More text".to_string() }, ]), }]; @@ -2556,18 +2547,9 @@ mod tests { // Verify thinking is now first if let MessageContent::Array(blocks) = &messages[0].content { assert_eq!(blocks.len(), 3, "Should still have 3 blocks"); - assert!( - matches!(blocks[0], ContentBlock::Thinking { .. }), - "Thinking should be first" - ); - assert!( - matches!(blocks[1], ContentBlock::Text { .. }), - "Text should be second" - ); - assert!( - matches!(blocks[2], ContentBlock::Text { .. }), - "Text should be third" - ); + assert!(matches!(blocks[0], ContentBlock::Thinking { .. }), "Thinking should be first"); + assert!(matches!(blocks[1], ContentBlock::Text { .. }), "Text should be second"); + assert!(matches!(blocks[2], ContentBlock::Text { .. }), "Text should be third"); // Verify content preserved if let ContentBlock::Thinking { thinking, .. } = &blocks[0] { @@ -2589,9 +2571,7 @@ mod tests { signature: Some("sig123".to_string()), cache_control: None, }, - ContentBlock::Text { - text: "Some text".to_string(), - }, + ContentBlock::Text { text: "Some text".to_string() }, ]), }]; @@ -2604,10 +2584,7 @@ mod tests { matches!(blocks[0], ContentBlock::Thinking { .. }), "Thinking should still be first" ); - assert!( - matches!(blocks[1], ContentBlock::Text { .. }), - "Text should still be second" - ); + assert!(matches!(blocks[1], ContentBlock::Text { .. }), "Text should still be second"); } } @@ -2701,7 +2678,8 @@ mod tests { quality: None, }; - let result = transform_claude_request_in(&req, "test-v", false, None, "test_session", None).unwrap(); + let result = + transform_claude_request_in(&req, "test-v", false, None, "test_session", None).unwrap(); // [FIX] Since we removed the default 81920, maxOutputTokens should NOT be present // when max_tokens is None and thinking is disabled let gen_config = &result["request"]["generationConfig"]; @@ -2738,7 +2716,8 @@ mod tests { quality: None, }; - let result = transform_claude_request_in(&req, "proj", false, None, "test_session", None).unwrap(); + let result = + transform_claude_request_in(&req, "proj", false, None, "test_session", None).unwrap(); let budget = result["request"]["generationConfig"]["thinkingConfig"]["thinkingBudget"] .as_u64() .unwrap(); @@ -2767,8 +2746,13 @@ mod tests { }; // Should cap - let result_pro = transform_claude_request_in(&req_pro, "proj", false, None, "test_session", None).unwrap(); - assert_eq!(result_pro["request"]["generationConfig"]["thinkingConfig"]["thinkingBudget"], 24576); + let result_pro = + transform_claude_request_in(&req_pro, "proj", false, None, "test_session", None) + .unwrap(); + assert_eq!( + result_pro["request"]["generationConfig"]["thinkingConfig"]["thinkingBudget"], + 24576 + ); } #[test] @@ -2799,7 +2783,8 @@ mod tests { }; // Transform - let result = transform_claude_request_in(&req, "proj", false, None, "test_session", None).unwrap(); + let result = + transform_claude_request_in(&req, "proj", false, None, "test_session", None).unwrap(); let gen_config = &result["request"]["generationConfig"]; // thinkingConfig should be present (not forced disabled) @@ -2808,9 +2793,7 @@ mod tests { "thinkingConfig should be preserved for gemini-3-pro" ); - let budget = gen_config["thinkingConfig"]["thinkingBudget"] - .as_u64() - .unwrap(); + let budget = gen_config["thinkingConfig"]["thinkingBudget"].as_u64().unwrap(); // [FIX #1592] Since it's < 24576, it should be kept as 16000 assert_eq!(budget, 16000); } @@ -2839,7 +2822,8 @@ mod tests { }; // Transform - let result = transform_claude_request_in(&req, "proj", false, None, "test_session", None).unwrap(); + let result = + transform_claude_request_in(&req, "proj", false, None, "test_session", None).unwrap(); let gen_config = &result["request"]["generationConfig"]; // thinkingConfig SHOULD be injected because of default-on logic @@ -2876,14 +2860,21 @@ mod tests { }; // 3. Transform request - let result = transform_claude_request_in(&req, "test-proj", false, None, "test_session", None).unwrap(); + let result = + transform_claude_request_in(&req, "test-proj", false, None, "test_session", None) + .unwrap(); // 4. Verify thinkingConfig has includeThoughts: false - let gen_config = result["request"]["generationConfig"].as_object().expect("Should have generationConfig"); - let thinking_config = gen_config.get("thinkingConfig").and_then(|t| t.as_object()).expect("Should have thinkingConfig (explicitly disabled)"); - + let gen_config = result["request"]["generationConfig"] + .as_object() + .expect("Should have generationConfig"); + let thinking_config = gen_config + .get("thinkingConfig") + .and_then(|t| t.as_object()) + .expect("Should have thinkingConfig (explicitly disabled)"); + assert_eq!(thinking_config["includeThoughts"], false); - + // 5. Reset global mode crate::proxy::config::update_image_thinking_mode(Some("enabled".to_string())); } @@ -2920,8 +2911,10 @@ mod tests { }; // Transform - let result = transform_claude_request_in(&req, "test-proj", false, None, "test_session", None).unwrap(); - + let result = + transform_claude_request_in(&req, "test-proj", false, None, "test_session", None) + .unwrap(); + let gen_config = result["request"]["generationConfig"].as_object().unwrap(); let thinking_config = gen_config["thinkingConfig"].as_object().unwrap(); @@ -2975,17 +2968,17 @@ mod tests { // 模拟映射到 Gemini 2.0 let mapped_model = "gemini-2.0-flash-exp"; - + // 这里我们直接测试 build_tools 函数 (它是 pub(crate) 且在同模块下) let result = build_tools(&req.tools, true, mapped_model); assert!(result.is_ok()); - + let tools_val = result.unwrap().expect("Should have tools"); let tools_arr = tools_val.as_array().expect("Tools should be an array"); - + let has_google_search = tools_arr.iter().any(|t| t.get("googleSearch").is_some()); let has_functions = tools_arr.iter().any(|t| t.get("functionDeclarations").is_some()); - + assert!(has_google_search, "Gemini 2.0 should support mixed Google Search"); assert!(has_functions, "Gemini 2.0 should support mixed function declarations"); } @@ -3026,17 +3019,17 @@ mod tests { // 模拟映射到 Gemini 1.5 let mapped_model = "gemini-1.5-flash-002"; - + // 测试 build_tools 函数 let result = build_tools(&req.tools, true, mapped_model); assert!(result.is_ok()); - + let tools_val = result.unwrap().expect("Should have tools"); let tools_arr = tools_val.as_array().expect("Tools should be an array"); - + let has_google_search = tools_arr.iter().any(|t| t.get("googleSearch").is_some()); let has_functions = tools_arr.iter().any(|t| t.get("functionDeclarations").is_some()); - + assert!(!has_google_search, "Older Gemini models should NOT have mixed tools"); assert!(has_functions); } diff --git a/src-tauri/src/proxy/mappers/claude/response.rs b/src-tauri/src/proxy/mappers/claude/response.rs index d5ed97475..a7df0039c 100644 --- a/src-tauri/src/proxy/mappers/claude/response.rs +++ b/src-tauri/src/proxy/mappers/claude/response.rs @@ -38,10 +38,7 @@ fn remap_function_call_args(tool_name: &str, args: &mut serde_json::Value) { if !obj.contains_key("path") { if let Some(paths) = obj.remove("paths") { let path_str = if let Some(arr) = paths.as_array() { - arr.get(0) - .and_then(|v| v.as_str()) - .unwrap_or(".") - .to_string() + arr.get(0).and_then(|v| v.as_str()).unwrap_or(".").to_string() } else if let Some(s) = paths.as_str() { s.to_string() } else { @@ -57,7 +54,7 @@ fn remap_function_call_args(tool_name: &str, args: &mut serde_json::Value) { } // Note: We keep "-n" and "output_mode" if present as they are valid in Grep schema - } + }, "glob" => { // [FIX] Gemini hallucination: maps parameter description to "description" field if let Some(desc) = obj.remove("description") { @@ -79,10 +76,7 @@ fn remap_function_call_args(tool_name: &str, args: &mut serde_json::Value) { if !obj.contains_key("path") { if let Some(paths) = obj.remove("paths") { let path_str = if let Some(arr) = paths.as_array() { - arr.get(0) - .and_then(|v| v.as_str()) - .unwrap_or(".") - .to_string() + arr.get(0).and_then(|v| v.as_str()).unwrap_or(".").to_string() } else if let Some(s) = paths.as_str() { s.to_string() } else { @@ -96,7 +90,7 @@ fn remap_function_call_args(tool_name: &str, args: &mut serde_json::Value) { tracing::debug!("[Response] Added default path: \".\""); } } - } + }, "read" => { // Gemini might use "path" vs "file_path" if let Some(path) = obj.remove("path") { @@ -105,14 +99,14 @@ fn remap_function_call_args(tool_name: &str, args: &mut serde_json::Value) { tracing::debug!("[Response] Remapped Read: path → file_path"); } } - } + }, "ls" => { // LS tool: ensure "path" parameter exists if !obj.contains_key("path") { obj.insert("path".to_string(), serde_json::json!(".")); tracing::debug!("[Response] Remapped LS: default path → \".\""); } - } + }, other => { // [NEW] [Issue #785] Generic Property Mapping for all tools // If a tool has "paths" (array of 1) but no "path", convert it. @@ -140,7 +134,7 @@ fn remap_function_call_args(tool_name: &str, args: &mut serde_json::Value) { other, obj.keys() ); - } + }, } } } @@ -239,10 +233,10 @@ impl NonStreamingProcessor { decoded_str.len() ); decoded_str - } + }, Err(_) => sig.clone(), // Not valid UTF-8, keep as is } - } + }, Err(_) => sig.clone(), // Not base64, keep as is } }); @@ -250,8 +244,11 @@ impl NonStreamingProcessor { // [FIX #765] Cache signature in NonStreamingProcessor if let Some(sig) = &signature { if let Some(s_id) = &self.session_id { - crate::proxy::SignatureCache::global() - .cache_session_signature(s_id, sig.to_string(), self.message_count); + crate::proxy::SignatureCache::global().cache_session_signature( + s_id, + sig.to_string(), + self.message_count, + ); crate::proxy::SignatureCache::global() .cache_thinking_family(sig.to_string(), self.model_name.clone()); tracing::debug!( @@ -280,11 +277,7 @@ impl NonStreamingProcessor { // 生成 tool_use id let tool_id = fc.id.clone().unwrap_or_else(|| { - format!( - "{}-{}", - fc.name, - crate::proxy::common::utils::generate_random_id() - ) + format!("{}-{}", fc.name, crate::proxy::common::utils::generate_random_id()) }); let mut tool_name = fc.name.clone(); @@ -471,8 +464,7 @@ impl NonStreamingProcessor { } if !current_text.is_empty() { - self.content_blocks - .push(ContentBlock::Text { text: current_text }); + self.content_blocks.push(ContentBlock::Text { text: current_text }); } } @@ -600,7 +592,7 @@ mod tests { match &claude_resp.content[0] { ContentBlock::Text { text } => { assert_eq!(text, "Hello, world!"); - } + }, _ => panic!("Expected Text block"), } } @@ -653,21 +645,17 @@ mod tests { assert_eq!(claude_resp.content.len(), 2); match &claude_resp.content[0] { - ContentBlock::Thinking { - thinking, - signature, - .. - } => { + ContentBlock::Thinking { thinking, signature, .. } => { assert_eq!(thinking, "Let me think..."); assert_eq!(signature.as_deref(), Some("sig123")); - } + }, _ => panic!("Expected Thinking block"), } match &claude_resp.content[1] { ContentBlock::Text { text } => { assert_eq!(text, "The answer is 42"); - } + }, _ => panic!("Expected Text block"), } } diff --git a/src-tauri/src/proxy/mappers/claude/streaming.rs b/src-tauri/src/proxy/mappers/claude/streaming.rs index 0aaac016f..4dd1491f4 100644 --- a/src-tauri/src/proxy/mappers/claude/streaming.rs +++ b/src-tauri/src/proxy/mappers/claude/streaming.rs @@ -8,7 +8,7 @@ use crate::proxy::mappers::estimation_calibrator::get_calibrator; use crate::proxy::SignatureCache; use crate::proxy::common::client_adapter::{ClientAdapter, SignatureBufferStrategy}; // [NEW] use bytes::Bytes; -use serde_json::{json, Value}; +use serde_json::{Value, json}; /// Known parameter remappings for Gemini → Claude compatibility /// [FIX] Gemini sometimes uses different parameter names than specified in tool schema @@ -51,10 +51,7 @@ pub fn remap_function_call_args(name: &str, args: &mut Value) { if !obj.contains_key("path") { if let Some(paths) = obj.remove("paths") { let path_str = if let Some(arr) = paths.as_array() { - arr.get(0) - .and_then(|v| v.as_str()) - .unwrap_or(".") - .to_string() + arr.get(0).and_then(|v| v.as_str()).unwrap_or(".").to_string() } else if let Some(s) = paths.as_str() { s.to_string() } else { @@ -73,7 +70,7 @@ pub fn remap_function_call_args(name: &str, args: &mut Value) { } // Note: We keep "-n" and "output_mode" if present as they are valid in Grep schema - } + }, "glob" => { // [FIX] Gemini hallucination: maps parameter description to "description" field if let Some(desc) = obj.remove("description") { @@ -95,10 +92,7 @@ pub fn remap_function_call_args(name: &str, args: &mut Value) { if !obj.contains_key("path") { if let Some(paths) = obj.remove("paths") { let path_str = if let Some(arr) = paths.as_array() { - arr.get(0) - .and_then(|v| v.as_str()) - .unwrap_or(".") - .to_string() + arr.get(0).and_then(|v| v.as_str()).unwrap_or(".").to_string() } else if let Some(s) = paths.as_str() { s.to_string() } else { @@ -115,7 +109,7 @@ pub fn remap_function_call_args(name: &str, args: &mut Value) { tracing::debug!("[Streaming] Added default path: \".\""); } } - } + }, "read" => { // Gemini might use "path" vs "file_path" if let Some(path) = obj.remove("path") { @@ -124,14 +118,14 @@ pub fn remap_function_call_args(name: &str, args: &mut Value) { tracing::debug!("[Streaming] Remapped Read: path → file_path"); } } - } + }, "ls" => { // LS tool: ensure "path" parameter exists if !obj.contains_key("path") { obj.insert("path".to_string(), json!(".")); tracing::debug!("[Streaming] Remapped LS: default path → \".\""); } - } + }, other => { // [NEW] [Issue #785] Generic Property Mapping for all tools // If a tool has "paths" (array of 1) but no "path", convert it. @@ -159,7 +153,7 @@ pub fn remap_function_call_args(name: &str, args: &mut Value) { other, obj.keys() ); - } + }, } } } @@ -446,10 +440,7 @@ impl StreamingState { let mut links = Vec::new(); for (i, chunk) in chunks.iter().enumerate() { if let Some(web) = chunk.get("web") { - let title = web - .get("title") - .and_then(|v| v.as_str()) - .unwrap_or("网页来源"); + let title = web.get("title").and_then(|v| v.as_str()).unwrap_or("网页来源"); let uri = web.get("uri").and_then(|v| v.as_str()).unwrap_or("#"); links.push(format!("[{}] [{}]({})", i + 1, title, uri)); } @@ -527,9 +518,7 @@ impl StreamingState { )); if !self.message_stop_sent { - chunks.push(Bytes::from( - "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", - )); + chunks.push(Bytes::from("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")); self.message_stop_sent = true; } @@ -668,10 +657,10 @@ impl<'a> PartProcessor<'a> { decoded_str.len() ); decoded_str - } + }, Err(_) => sig.clone(), // Not valid UTF-8, keep as is } - } + }, Err(_) => sig.clone(), // Not base64, keep as is } }); @@ -690,10 +679,7 @@ impl<'a> PartProcessor<'a> { "content_block": { "type": "thinking", "thinking": "" } }), )); - chunks.push( - self.state - .emit_delta("thinking_delta", json!({ "thinking": "" })), - ); + chunks.push(self.state.emit_delta("thinking_delta", json!({ "thinking": "" }))); chunks.push( self.state .emit_delta("signature_delta", json!({ "signature": trailing_sig })), @@ -748,13 +734,9 @@ impl<'a> PartProcessor<'a> { "content_block": { "type": "thinking", "thinking": "" } }), )); + chunks.push(self.state.emit_delta("thinking_delta", json!({ "thinking": "" }))); chunks.push( - self.state - .emit_delta("thinking_delta", json!({ "thinking": "" })), - ); - chunks.push( - self.state - .emit_delta("signature_delta", json!({ "signature": trailing_sig })), + self.state.emit_delta("signature_delta", json!({ "signature": trailing_sig })), ); chunks.extend(self.state.end_block()); } @@ -762,24 +744,26 @@ impl<'a> PartProcessor<'a> { // 开始或继续 thinking 块 if self.state.current_block_type() != BlockType::Thinking { - chunks.extend(self.state.start_block( - BlockType::Thinking, - json!({ "type": "thinking", "thinking": "" }), - )); + chunks.extend( + self.state.start_block( + BlockType::Thinking, + json!({ "type": "thinking", "thinking": "" }), + ), + ); } // [FIX #859] Mark that we have received thinking content self.state.has_thinking = true; if !text.is_empty() { - chunks.push( - self.state - .emit_delta("thinking_delta", json!({ "thinking": text })), - ); + chunks.push(self.state.emit_delta("thinking_delta", json!({ "thinking": text }))); } // [NEW] Apply Client Adapter Strategy - let use_fifo = self.state.client_adapter.as_ref() + let use_fifo = self + .state + .client_adapter + .as_ref() .map(|a| a.signature_buffer_strategy() == SignatureBufferStrategy::Fifo) .unwrap_or(false); @@ -796,11 +780,11 @@ impl<'a> PartProcessor<'a> { // However, our cache implementation currently keys by session_id. // For FIFO, we might just rely on the fact that we are processing in order. // But specifically for opencode, it might be calling tools in parallel or sequence. - + SignatureCache::global().cache_session_signature( - session_id, - sig.clone(), - self.state.message_count + session_id, + sig.clone(), + self.state.message_count, ); tracing::debug!( "[Claude-SSE] Cached signature to session {} (length: {}) [FIFO: {}]", @@ -817,8 +801,8 @@ impl<'a> PartProcessor<'a> { } // 暂存签名 (for local block handling) - // If FIFO, we strictly follow the sequence. The default logic is effectively LIFO for a single turn - // (store latest, consume at end). + // If FIFO, we strictly follow the sequence. The default logic is effectively LIFO for a single turn + // (store latest, consume at end). // For opencode, we just want to ensure we capture IT. self.state.store_signature(signature); @@ -852,13 +836,9 @@ impl<'a> PartProcessor<'a> { "content_block": { "type": "thinking", "thinking": "" } }), )); + chunks.push(self.state.emit_delta("thinking_delta", json!({ "thinking": "" }))); chunks.push( - self.state - .emit_delta("thinking_delta", json!({ "thinking": "" })), - ); - chunks.push( - self.state - .emit_delta("signature_delta", json!({ "signature": trailing_sig })), + self.state.emit_delta("signature_delta", json!({ "signature": trailing_sig })), ); chunks.extend(self.state.end_block()); } @@ -872,8 +852,7 @@ impl<'a> PartProcessor<'a> { self.state.store_signature(signature); chunks.extend( - self.state - .start_block(BlockType::Text, json!({ "type": "text", "text": "" })), + self.state.start_block(BlockType::Text, json!({ "type": "text", "text": "" })), ); chunks.push(self.state.emit_delta("text_delta", json!({ "text": text }))); chunks.extend(self.state.end_block()); @@ -954,8 +933,7 @@ impl<'a> PartProcessor<'a> { if self.state.current_block_type() != BlockType::Text { chunks.extend( - self.state - .start_block(BlockType::Text, json!({ "type": "text", "text": "" })), + self.state.start_block(BlockType::Text, json!({ "type": "text", "text": "" })), ); } @@ -975,11 +953,7 @@ impl<'a> PartProcessor<'a> { self.state.mark_tool_used(); let tool_id = fc.id.clone().unwrap_or_else(|| { - format!( - "{}-{}", - fc.name, - crate::proxy::common::utils::generate_random_id() - ) + format!("{}-{}", fc.name, crate::proxy::common::utils::generate_random_id()) }); let mut tool_name = fc.name.clone(); @@ -994,10 +968,13 @@ impl<'a> PartProcessor<'a> { // We attempt to find the closest registered tool name. if tool_name.starts_with("mcp__") && !self.state.registered_tool_names.is_empty() { if !self.state.registered_tool_names.contains(&tool_name) { - if let Some(matched) = fuzzy_match_mcp_tool(&tool_name, &self.state.registered_tool_names) { + if let Some(matched) = + fuzzy_match_mcp_tool(&tool_name, &self.state.registered_tool_names) + { tracing::warn!( "[FIX #MCP] Corrected MCP tool name: '{}' → '{}'", - tool_name, matched + tool_name, + matched ); tool_name = matched; } else { @@ -1026,9 +1003,9 @@ impl<'a> PartProcessor<'a> { // 3. [NEW v3.3.17] Cache to session-based storage if let Some(session_id) = &self.state.session_id { SignatureCache::global().cache_session_signature( - session_id, + session_id, sig.clone(), - self.state.message_count + self.state.message_count, ); } @@ -1058,8 +1035,7 @@ impl<'a> PartProcessor<'a> { let json_str = serde_json::to_string(&remapped_args).unwrap_or_else(|_| "{}".to_string()); chunks.push( - self.state - .emit_delta("input_json_delta", json!({ "partial_json": json_str })), + self.state.emit_delta("input_json_delta", json!({ "partial_json": json_str })), ); } @@ -1083,9 +1059,8 @@ impl<'a> PartProcessor<'a> { /// 2. Suffix contained: if the hallucinated name (without `mcp__`) is contained in a registered tool name /// 3. Longest common subsequence scoring: picks the registered tool with the best LCS ratio fn fuzzy_match_mcp_tool(hallucinated: &str, registered: &[String]) -> Option { - let mcp_tools: Vec<&String> = registered.iter() - .filter(|name| name.starts_with("mcp__")) - .collect(); + let mcp_tools: Vec<&String> = + registered.iter().filter(|name| name.starts_with("mcp__")).collect(); if mcp_tools.is_empty() { return None; @@ -1125,11 +1100,9 @@ fn fuzzy_match_mcp_tool(hallucinated: &str, registered: &[String]) -> Option = hallucinated_suffix - .split(|c: char| c == '_') - .filter(|s| !s.is_empty()) - .collect(); + // Split both names into tokens by '_' and '__', compute overlap ratio + let hall_tokens: Vec<&str> = + hallucinated_suffix.split(|c: char| c == '_').filter(|s| !s.is_empty()).collect(); if hall_tokens.is_empty() { return None; @@ -1141,10 +1114,8 @@ fn fuzzy_match_mcp_tool(hallucinated: &str, registered: &[String]) -> Option = tool_after_mcp - .split(|c: char| c == '_') - .filter(|s| !s.is_empty()) - .collect(); + let tool_tokens: Vec<&str> = + tool_after_mcp.split(|c: char| c == '_').filter(|s| !s.is_empty()).collect(); if tool_tokens.is_empty() { continue; @@ -1171,7 +1142,9 @@ fn fuzzy_match_mcp_tool(hallucinated: &str, registered: &[String]) -> Option= threshold { tracing::debug!( "[FIX #MCP] Fuzzy match score for '{}': {:.2} -> {:?}", - hallucinated, best_score, best_match + hallucinated, + best_score, + best_match ); best_match } else { @@ -1266,9 +1239,7 @@ mod tests { #[test] fn test_fuzzy_match_mcp_tool_exact_match_no_correction() { - let registered = vec![ - "mcp__puppeteer__puppeteer_navigate".to_string(), - ]; + let registered = vec!["mcp__puppeteer__puppeteer_navigate".to_string()]; // Already correct - should not be called (the caller checks contains first) // But if called, should find it @@ -1304,9 +1275,7 @@ mod tests { #[test] fn test_fuzzy_match_mcp_tool_no_match() { - let registered = vec![ - "mcp__puppeteer__puppeteer_navigate".to_string(), - ]; + let registered = vec!["mcp__puppeteer__puppeteer_navigate".to_string()]; // Completely unrelated name let result = fuzzy_match_mcp_tool("mcp__totally_unrelated_xyz", ®istered); @@ -1315,10 +1284,7 @@ mod tests { #[test] fn test_fuzzy_match_mcp_tool_no_mcp_tools() { - let registered = vec![ - "regular_tool".to_string(), - "another_tool".to_string(), - ]; + let registered = vec!["regular_tool".to_string(), "another_tool".to_string()]; // No MCP tools in registry let result = fuzzy_match_mcp_tool("mcp__puppeteer_navigate", ®istered); diff --git a/src-tauri/src/proxy/mappers/claude/thinking_utils.rs b/src-tauri/src/proxy/mappers/claude/thinking_utils.rs index 8e738db20..20963c5f2 100644 --- a/src-tauri/src/proxy/mappers/claude/thinking_utils.rs +++ b/src-tauri/src/proxy/mappers/claude/thinking_utils.rs @@ -31,9 +31,7 @@ pub fn analyze_conversation_state(messages: &[Message]) -> ConversationState { let has_tool_use = if let Some(idx) = state.last_assistant_idx { if let Some(msg) = messages.get(idx) { if let MessageContent::Array(blocks) = &msg.content { - blocks - .iter() - .any(|b| matches!(b, ContentBlock::ToolUse { .. })) + blocks.iter().any(|b| matches!(b, ContentBlock::ToolUse { .. })) } else { false } @@ -53,10 +51,7 @@ pub fn analyze_conversation_state(messages: &[Message]) -> ConversationState { if last_msg.role == "user" { if let MessageContent::Array(blocks) = &last_msg.content { // Case 1: Final message is ToolResult -> Active Tool Loop - if blocks - .iter() - .any(|b| matches!(b, ContentBlock::ToolResult { .. })) - { + if blocks.iter().any(|b| matches!(b, ContentBlock::ToolResult { .. })) { state.in_tool_loop = true; debug!( "[Thinking-Recovery] Active tool loop detected (last msg is ToolResult)." @@ -105,12 +100,7 @@ pub fn close_tool_loop_for_thinking(messages: &mut Vec) { if let Some(msg) = messages.get(idx) { if let MessageContent::Array(blocks) = &msg.content { for block in blocks { - if let ContentBlock::Thinking { - thinking, - signature, - .. - } = block - { + if let ContentBlock::Thinking { thinking, signature, .. } = block { if !thinking.is_empty() && signature .as_ref() @@ -128,7 +118,9 @@ pub fn close_tool_loop_for_thinking(messages: &mut Vec) { if !has_valid_thinking { if state.in_tool_loop { - info!("[Thinking-Recovery] Broken tool loop (ToolResult without preceding Thinking). Recovery triggered."); + info!( + "[Thinking-Recovery] Broken tool loop (ToolResult without preceding Thinking). Recovery triggered." + ); // Insert acknowledging message to "close" the history turn messages.push(Message { @@ -228,9 +220,7 @@ pub fn filter_invalid_thinking_blocks_with_family( // SAFETY: Claude API requires at least one block if blocks.is_empty() && original_len > 0 { - blocks.push(ContentBlock::Text { - text: ".".to_string(), - }); + blocks.push(ContentBlock::Text { text: ".".to_string() }); } } } diff --git a/src-tauri/src/proxy/mappers/claude/utils.rs b/src-tauri/src/proxy/mappers/claude/utils.rs index c7c54c74f..52389d7dd 100644 --- a/src-tauri/src/proxy/mappers/claude/utils.rs +++ b/src-tauri/src/proxy/mappers/claude/utils.rs @@ -18,7 +18,11 @@ pub fn get_context_limit_for_model(model: &str) -> u32 { } } -pub fn to_claude_usage(usage_metadata: &super::models::UsageMetadata, scaling_enabled: bool, context_limit: u32) -> super::models::Usage { +pub fn to_claude_usage( + usage_metadata: &super::models::UsageMetadata, + scaling_enabled: bool, + context_limit: u32, +) -> super::models::Usage { let prompt_tokens = usage_metadata.prompt_token_count.unwrap_or(0); let cached_tokens = usage_metadata.cached_content_token_count.unwrap_or(0); @@ -75,11 +79,14 @@ pub fn to_claude_usage(usage_metadata: &super::models::UsageMetadata, scaling_en let display_ratio = scaled_total as f64 / 195_000.0; tracing::debug!( "[Claude-Scaling] Raw: {} ({:.1}%), Display: {} ({:.1}%), Compression: {:.1}x", - total_raw, ratio * 100.0, scaled_total, display_ratio * 100.0, + total_raw, + ratio * 100.0, + scaled_total, + display_ratio * 100.0, total_raw as f64 / scaled_total as f64 ); } - + // 按比例分配缩放后的总量到 input 和 cache_read let (reported_input, reported_cache) = if total_raw > 0 { let cache_ratio = (cached_tokens as f64) / (total_raw as f64); @@ -88,7 +95,7 @@ pub fn to_claude_usage(usage_metadata: &super::models::UsageMetadata, scaling_en } else { (scaled_total, None) }; - + super::models::Usage { input_tokens: reported_input, output_tokens: usage_metadata.candidates_token_count.unwrap_or(0), diff --git a/src-tauri/src/proxy/mappers/openai/streaming.rs b/src-tauri/src/proxy/mappers/openai/streaming.rs index 2717be358..2003165c6 100644 --- a/src-tauri/src/proxy/mappers/openai/streaming.rs +++ b/src-tauri/src/proxy/mappers/openai/streaming.rs @@ -3,24 +3,24 @@ use bytes::{Bytes, BytesMut}; use chrono::Utc; use futures::{Stream, StreamExt}; use rand::Rng; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::pin::Pin; use tracing::debug; use uuid::Uuid; - - /// 保存 thoughtSignature 到会话缓存 pub fn store_thought_signature(sig: &str, session_id: &str, message_count: usize) { if sig.is_empty() { return; } - - // 2. [CRITICAL] 存储到 Session 隔离缓存 (对齐 Claude 协议) - crate::proxy::SignatureCache::global().cache_session_signature(session_id, sig.to_string(), message_count); - + crate::proxy::SignatureCache::global().cache_session_signature( + session_id, + sig.to_string(), + message_count, + ); + tracing::debug!( "[ThoughtSig] 存储 Session 签名 (sid: {}, len: {}, msg_count: {})", session_id, @@ -29,36 +29,22 @@ pub fn store_thought_signature(sig: &str, session_id: &str, message_count: usize ); } - - /// Extract and convert Gemini usageMetadata to OpenAI usage format fn extract_usage_metadata(u: &Value) -> Option { use super::models::{OpenAIUsage, PromptTokensDetails}; - let prompt_tokens = u - .get("promptTokenCount") - .and_then(|v| v.as_u64()) - .unwrap_or(0) as u32; - let completion_tokens = u - .get("candidatesTokenCount") - .and_then(|v| v.as_u64()) - .unwrap_or(0) as u32; - let total_tokens = u - .get("totalTokenCount") - .and_then(|v| v.as_u64()) - .unwrap_or(0) as u32; - let cached_tokens = u - .get("cachedContentTokenCount") - .and_then(|v| v.as_u64()) - .map(|v| v as u32); + let prompt_tokens = u.get("promptTokenCount").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let completion_tokens = + u.get("candidatesTokenCount").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let total_tokens = u.get("totalTokenCount").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let cached_tokens = u.get("cachedContentTokenCount").and_then(|v| v.as_u64()).map(|v| v as u32); Some(OpenAIUsage { prompt_tokens, completion_tokens, total_tokens, - prompt_tokens_details: cached_tokens.map(|ct| PromptTokensDetails { - cached_tokens: Some(ct), - }), + prompt_tokens_details: cached_tokens + .map(|ct| PromptTokensDetails { cached_tokens: Some(ct) }), completion_tokens_details: None, }) } @@ -68,7 +54,7 @@ pub fn create_openai_sse_stream( model: String, session_id: String, message_count: usize, -) -> Pin> + Send>> +) -> Pin> + Send>> where S: Stream> + Send + ?Sized + 'static, E: std::fmt::Display + Send + 'static, @@ -139,7 +125,7 @@ where emitted_tool_calls.insert(call_key); let name = func_call.get("name").and_then(|v| v.as_str()).unwrap_or("unknown"); let mut args = func_call.get("args").unwrap_or(&json!({})).clone(); - + // [FIX #1575] 标准化 shell 工具参数名称 // Gemini 可能使用 cmd/code/script 等替代参数名,统一为 command if name == "shell" || name == "bash" || name == "local_shell" { @@ -155,13 +141,13 @@ where } } } - + let args_str = serde_json::to_string(&args).unwrap_or_default(); let mut hasher = std::collections::hash_map::DefaultHasher::new(); use std::hash::{Hash, Hasher}; serde_json::to_string(func_call).unwrap_or_default().hash(&mut hasher); let call_id = format!("call_{:x}", hasher.finish()); - + let tool_call_chunk = json!({ "id": &stream_id, "object": "chat.completion.chunk", @@ -277,11 +263,22 @@ where } Some(Err(e)) => { use crate::proxy::mappers::error_classifier::classify_stream_error; - let (error_type, user_msg, i18n_key) = classify_stream_error(&e); + let (_error_type, user_msg, _i18n_key) = classify_stream_error(&e); tracing::error!("OpenAI Stream Error: {}", e); + // Wrap error as a valid OpenAI SSE chunk with assistant content + // so IDE parsers (Cursor, etc.) don't crash on unexpected JSON shape let error_chunk = json!({ - "id": &stream_id, "object": "chat.completion.chunk", "created": created_ts, "model": &model, "choices": [], - "error": { "type": error_type, "message": user_msg, "code": "stream_error", "i18n_key": i18n_key } + "id": &stream_id, + "object": "chat.completion.chunk", + "created": created_ts, + "model": &model, + "choices": [{ + "index": 0, + "delta": { + "content": format!("\n\n[Antigravity Error: {}]", user_msg) + }, + "finish_reason": "stop" + }] }); yield Ok(Bytes::from(format!("data: {}\n\n", serde_json::to_string(&error_chunk).unwrap_or_default()))); yield Ok(Bytes::from("data: [DONE]\n\n")); @@ -305,7 +302,7 @@ where let json_part = line.trim_start_matches("data: ").trim(); if json_part != "[DONE]" { // Re-use logic for processing the last line - // (Note: In a more complex refactor we'd extract this to a function, + // (Note: In a more complex refactor we'd extract this to a function, // but for a targeted fix, processing the terminal data chunk is safer) tracing::debug!("[OpenAI-SSE] Flushing remaining {} bytes in buffer", buffer.len()); } @@ -325,7 +322,7 @@ pub fn create_legacy_sse_stream( model: String, session_id: String, message_count: usize, -) -> Pin> + Send>> +) -> Pin> + Send>> where S: Stream> + Send + ?Sized + 'static, E: std::fmt::Display + Send + 'static, @@ -333,10 +330,12 @@ where let mut buffer = BytesMut::new(); let charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let mut rng = rand::thread_rng(); - let random_str: String = (0..28).map(|_| { - let idx = rng.gen_range(0..charset.len()); - charset.chars().nth(idx).unwrap() - }).collect(); + let random_str: String = (0..28) + .map(|_| { + let idx = rng.gen_range(0..charset.len()); + charset.chars().nth(idx).unwrap() + }) + .collect(); let stream_id = format!("cmpl-{}", random_str); let created_ts = Utc::now().timestamp(); @@ -398,11 +397,22 @@ where } Some(Err(e)) => { use crate::proxy::mappers::error_classifier::classify_stream_error; - let (error_type, user_msg, i18n_key) = classify_stream_error(&e); + let (_error_type, user_msg, _i18n_key) = classify_stream_error(&e); tracing::error!("Legacy Stream Error: {}", e); + // Wrap error as a valid OpenAI SSE chunk with assistant content + // so IDE parsers (Cursor, etc.) don't crash on unexpected JSON shape let error_chunk = json!({ - "id": &stream_id, "object": "text_completion", "created": created_ts, "model": &model, "choices": [], - "error": { "type": error_type, "message": user_msg, "code": "stream_error", "i18n_key": i18n_key } + "id": &stream_id, + "object": "text_completion", + "created": created_ts, + "model": &model, + "choices": [{ + "index": 0, + "delta": { + "content": format!("\n\n[Antigravity Error: {}]", user_msg) + }, + "finish_reason": "stop" + }] }); yield Ok::(Bytes::from(format!("data: {}\n\n", serde_json::to_string(&error_chunk).unwrap_or_default()))); yield Ok::(Bytes::from("data: [DONE]\n\n")); @@ -427,7 +437,7 @@ pub fn create_codex_sse_stream( _model: String, session_id: String, message_count: usize, -) -> Pin> + Send>> +) -> Pin> + Send>> where S: Stream> + Send + ?Sized + 'static, E: std::fmt::Display + Send + 'static, @@ -435,10 +445,12 @@ where let mut buffer = BytesMut::new(); let charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let mut rng = rand::thread_rng(); - let random_str: String = (0..24).map(|_| { - let idx = rng.gen_range(0..charset.len()); - charset.chars().nth(idx).unwrap() - }).collect(); + let random_str: String = (0..24) + .map(|_| { + let idx = rng.gen_range(0..charset.len()); + charset.chars().nth(idx).unwrap() + }) + .collect(); let response_id = format!("resp-{}", random_str); let item_id = format!("item-{}", &random_str[..16]); @@ -671,7 +683,7 @@ mod tests { } }] }); - + // Chunk 2: Finish reason + Usage metadata let chunk2_json = json!({ "candidates": [{ @@ -699,7 +711,7 @@ mod tests { gemini_stream, "gemini-1.5-flash".to_string(), "test-session".to_string(), - 0 + 0, ); let mut chunks = Vec::new(); @@ -722,7 +734,11 @@ mod tests { let json: Value = serde_json::from_str(json_str).unwrap(); if i < chunks.len() - 1 { - assert!(json.get("usage").is_none(), "Usage should not be in intermediate chunks. Found in chunk {}", i); + assert!( + json.get("usage").is_none(), + "Usage should not be in intermediate chunks. Found in chunk {}", + i + ); } else { if let Some(usage) = json.get("usage") { found_usage = true; @@ -730,12 +746,12 @@ mod tests { assert_eq!(usage["completion_tokens"], 2); assert_eq!(usage["total_tokens"], 7); } - if let Some(choices) = json.get("choices") { + if let Some(choices) = json.get("choices") { if let Some(choice) = choices.get(0) { if let Some(finish_reason) = choice.get("finish_reason") { - if finish_reason.as_str() == Some("stop") { - found_finish = true; - } + if finish_reason.as_str() == Some("stop") { + found_finish = true; + } } } }