diff --git a/crates/goose/src/providers/formats/google.rs b/crates/goose/src/providers/formats/google.rs index 380fbd882907..278459288ac2 100644 --- a/crates/goose/src/providers/formats/google.rs +++ b/crates/goose/src/providers/formats/google.rs @@ -3,11 +3,12 @@ use crate::providers::base::Usage; use crate::providers::errors::ProviderError; use crate::providers::utils::{is_valid_function_name, sanitize_function_name}; use anyhow::Result; -use rand::{distributions::Alphanumeric, Rng}; use rmcp::model::{ object, AnnotateAble, CallToolRequestParam, ErrorCode, ErrorData, RawContent, Role, Tool, }; +use serde::Serialize; use std::borrow::Cow; +use uuid::Uuid; use crate::conversation::message::{Message, MessageContent, ProviderMetadata}; use serde_json::{json, Map, Value}; @@ -306,87 +307,125 @@ pub fn process_map(map: &Map, parent_key: Option<&str>) -> Value Value::Object(filtered_map) } +#[derive(Clone, Copy)] +enum SignedTextHandling { + SkipSignedText, + SignedTextAsThinking, + SignedTextAsRegularText, +} + +pub fn process_response_part( + part: &Value, + last_signature: &mut Option, +) -> Option { + // For streaming: skip text with signatures (matches Anthropic/OpenAI behavior) + process_response_part_impl(part, last_signature, SignedTextHandling::SkipSignedText) +} + +fn process_response_part_non_streaming( + part: &Value, + last_signature: &mut Option, + has_function_calls: bool, +) -> Option { + // For non-streaming: signed text is thinking only if there are function calls + let handling = if has_function_calls { + SignedTextHandling::SignedTextAsThinking + } else { + SignedTextHandling::SignedTextAsRegularText + }; + process_response_part_impl(part, last_signature, handling) +} + +fn process_response_part_impl( + part: &Value, + last_signature: &mut Option, + signed_text_handling: SignedTextHandling, +) -> Option { + let signature = part.get(THOUGHT_SIGNATURE_KEY).and_then(|v| v.as_str()); + + if let Some(sig) = signature { + *last_signature = Some(sig.to_string()); + } + + let text_value = part.get("text"); + if let Some(text) = text_value.and_then(|v| v.as_str()) { + if text.is_empty() { + return None; + } + match (signature, signed_text_handling) { + (Some(_), SignedTextHandling::SkipSignedText) => None, + (Some(sig), SignedTextHandling::SignedTextAsThinking) => { + Some(MessageContent::thinking(text.to_string(), sig.to_string())) + } + _ => Some(MessageContent::text(text.to_string())), + } + } else if text_value.is_some() { + tracing::warn!( + "Google response part has 'text' field but it's not a string: {:?}", + text_value + ); + None + } else if let Some(function_call) = part.get("functionCall") { + let id = Uuid::new_v4().to_string(); + let name = function_call["name"].as_str().unwrap_or_default(); + + if !is_valid_function_name(name) { + let error = ErrorData { + code: ErrorCode::INVALID_REQUEST, + message: Cow::from(format!( + "The provided function name '{}' had invalid characters, it must match this regex [a-zA-Z0-9_-]+", + name + )), + data: None, + }; + Some(MessageContent::tool_request(id, Err(error))) + } else { + let arguments = function_call + .get("args") + .map(|params| object(params.clone())); + let effective_signature = signature.or(last_signature.as_deref()); + let metadata = effective_signature.map(metadata_with_signature); + + Some(MessageContent::tool_request_with_metadata( + id, + Ok(CallToolRequestParam { + name: name.to_string().into(), + arguments, + }), + metadata.as_ref(), + )) + } + } else { + None + } +} + pub fn response_to_message(response: Value) -> Result { - let mut content = Vec::new(); - let binding = vec![]; - let candidates: &Vec = response - .get("candidates") - .and_then(|v| v.as_array()) - .unwrap_or(&binding); - let candidate = candidates.first(); let role = Role::Assistant; let created = chrono::Utc::now().timestamp(); - if candidate.is_none() { - return Ok(Message::new(role, created, content)); - } - let candidate = candidate.unwrap(); - let parts = candidate - .get("content") - .and_then(|content| content.get("parts")) - .and_then(|parts| parts.as_array()) - .unwrap_or(&binding); - - // Track the last seen thought signature to use as fallback for function calls without one - // This handles cases where Google's API returns multiple function calls but only includes - // thoughtSignature on some of them - let mut last_signature: Option = None; - let has_function_calls = parts.iter().any(|p| p.get("functionCall").is_some()); + let parts = response + .get("candidates") + .and_then(|v| v.as_array()) + .and_then(|c| c.first()) + .and_then(|c| c.get("content")) + .and_then(|c| c.get("parts")) + .and_then(|p| p.as_array()); - for part in parts { - let signature = part - .get(THOUGHT_SIGNATURE_KEY) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); + let Some(parts) = parts else { + return Ok(Message::new(role, created, Vec::new())); + }; - if signature.is_some() { - last_signature = signature.clone(); - } + let has_function_calls = parts.iter().any(|p| p.get("functionCall").is_some()); - if let Some(text) = part.get("text").and_then(|v| v.as_str()) { - // Text is "thinking" only if: - // 1. It has a signature AND - // 2. The response also contains function calls (meaning this is reasoning before acting) - // If there are no function calls, this is the final response and should be shown - if let (Some(sig), true) = (&signature, has_function_calls) { - content.push(MessageContent::thinking(text.to_string(), sig.clone())); - } else { - content.push(MessageContent::text(text.to_string())); - } - } else if let Some(function_call) = part.get("functionCall") { - let id: String = rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(8) - .map(char::from) - .collect(); - let name = function_call["name"] - .as_str() - .unwrap_or_default() - .to_string(); - if !is_valid_function_name(&name) { - let error = ErrorData { - code: ErrorCode::INVALID_REQUEST, - message: Cow::from(format!( - "The provided function name '{}' had invalid characters, it must match this regex [a-zA-Z0-9_-]+", - name - )), - data: None, - }; - content.push(MessageContent::tool_request(id, Err(error))); - } else { - let parameters = function_call.get("args"); - let arguments = parameters.map(|params| object(params.clone())); - let effective_signature = signature.as_deref().or(last_signature.as_deref()); - let metadata = effective_signature.map(metadata_with_signature); - content.push(MessageContent::tool_request_with_metadata( - id, - Ok(CallToolRequestParam { - name: name.into(), - arguments, - }), - metadata.as_ref(), - )); - } + let mut content = Vec::new(); + let mut last_signature: Option = None; + + for part in parts { + if let Some(msg_content) = + process_response_part_non_streaming(part, &mut last_signature, has_function_calls) + { + content.push(msg_content); } } Ok(Message::new(role, created, content)) @@ -418,37 +457,197 @@ pub fn get_usage(data: &Value) -> Result { } } -/// Create a complete request payload for Google's API +pub fn response_to_streaming_message( + mut stream: S, +) -> impl futures::Stream< + Item = anyhow::Result<( + Option, + Option, + )>, +> + 'static +where + S: futures::Stream> + Unpin + Send + 'static, +{ + use async_stream::try_stream; + use futures::StreamExt; + + try_stream! { + let mut final_usage: Option = None; + let mut last_signature: Option = None; + let stream_id = Uuid::new_v4().to_string(); + let mut incomplete_data: Option = None; + + while let Some(line_result) = stream.next().await { + let line = line_result?; + + if line.trim().is_empty() { + continue; + } + + let data_part = if line.starts_with("data: ") { + line.strip_prefix("data: ").unwrap() + } else if line.starts_with("event:") || line.starts_with("id:") || line.starts_with("retry:") { + continue; + } else if incomplete_data.is_some() { + &line + } else { + continue; + }; + + if data_part.trim() == "[DONE]" { + break; + } + + let chunk: Value = if let Some(ref mut incomplete) = incomplete_data { + incomplete.push_str(data_part); + match serde_json::from_str(incomplete) { + Ok(v) => { + incomplete_data = None; + v + } + Err(e) => { + if e.is_eof() { + continue; + } + tracing::warn!("Failed to parse streaming chunk: {}", e); + incomplete_data = None; + continue; + } + } + } else { + match serde_json::from_str(data_part) { + Ok(v) => v, + Err(e) => { + if e.is_eof() { + incomplete_data = Some(data_part.to_string()); + continue; + } + tracing::warn!("Failed to parse streaming chunk: {}", e); + continue; + } + } + }; + + if let Some(error) = chunk.get("error") { + let message = error + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error"); + let status = error + .get("status") + .and_then(|s| s.as_str()) + .unwrap_or("UNKNOWN"); + Err(anyhow::anyhow!("Google API error ({}): {}", status, message))?; + } + + if let Ok(usage) = get_usage(&chunk) { + if usage.input_tokens.is_some() || usage.output_tokens.is_some() { + let model = chunk.get("modelVersion") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + final_usage = Some(crate::providers::base::ProviderUsage::new(model, usage)); + } + } + + let parts = chunk + .get("candidates") + .and_then(|v| v.as_array()) + .and_then(|c| c.first()) + .and_then(|c| c.get("content")) + .and_then(|c| c.get("parts")) + .and_then(|p| p.as_array()); + + if let Some(parts) = parts { + for part in parts { + if let Some(content) = process_response_part(part, &mut last_signature) { + let message = Message::new( + Role::Assistant, + chrono::Utc::now().timestamp(), + vec![content], + ).with_id(stream_id.clone()); + yield (Some(message), None); + } + } + } + } + + if let Some(usage) = final_usage { + yield (None, Some(usage)); + } + } +} + +#[derive(Serialize)] +struct TextPart<'a> { + text: &'a str, +} + +#[derive(Serialize)] +struct SystemInstruction<'a> { + parts: [TextPart<'a>; 1], +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ToolsWrapper { + function_declarations: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GenerationConfig { + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_output_tokens: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GoogleRequest<'a> { + system_instruction: SystemInstruction<'a>, + contents: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option, + #[serde(skip_serializing_if = "Option::is_none")] + generation_config: Option, +} + pub fn create_request( model_config: &ModelConfig, system: &str, messages: &[Message], tools: &[Tool], ) -> Result { - let mut payload = Map::new(); - payload.insert( - "system_instruction".to_string(), - json!({"parts": [{"text": system}]}), - ); - payload.insert("contents".to_string(), json!(format_messages(messages))); - if !tools.is_empty() { - payload.insert( - "tools".to_string(), - json!({"functionDeclarations": format_tools(tools)}), - ); - } - let mut generation_config = Map::new(); - if let Some(temp) = model_config.temperature { - generation_config.insert("temperature".to_string(), json!(temp as f64)); - } - if let Some(tokens) = model_config.max_tokens { - generation_config.insert("maxOutputTokens".to_string(), json!(tokens)); - } - if !generation_config.is_empty() { - payload.insert("generationConfig".to_string(), json!(generation_config)); - } + let tools_wrapper = if tools.is_empty() { + None + } else { + Some(ToolsWrapper { + function_declarations: format_tools(tools), + }) + }; - Ok(json!(payload)) + let generation_config = + if model_config.temperature.is_some() || model_config.max_tokens.is_some() { + Some(GenerationConfig { + temperature: model_config.temperature.map(|t| t as f64), + max_output_tokens: model_config.max_tokens, + }) + } else { + None + }; + + let request = GoogleRequest { + system_instruction: SystemInstruction { + parts: [TextPart { text: system }], + }, + contents: format_messages(messages), + tools: tools_wrapper, + generation_config, + }; + + Ok(serde_json::to_value(request)?) } #[cfg(test)] @@ -1033,12 +1232,201 @@ mod tests { assert_eq!(google_out[0]["parts"][0]["thoughtSignature"], SIG); assert_eq!(google_out[1]["parts"][0]["thoughtSignature"], SIG); - let final_response = + // Text-only response WITH signature but WITHOUT function calls should be regular text + // (per original behavior: thinking is only when reasoning before tool calls) + let final_response_with_sig = google_response(vec![json!({"text": "Done!", "thoughtSignature": SIG})]); - let final_native = response_to_message(final_response).unwrap(); + let final_native_with_sig = response_to_message(final_response_with_sig).unwrap(); assert!( - final_native.content[0].as_text().is_some(), - "Text-only = final answer" + final_native_with_sig.content[0].as_text().is_some(), + "Text with signature but no function calls should be regular text (final response)" ); + + let final_response_no_sig = google_response(vec![json!({"text": "Done!"})]); + let final_native_no_sig = response_to_message(final_response_no_sig).unwrap(); + assert!( + final_native_no_sig.content[0].as_text().is_some(), + "Text without signature is regular text" + ); + } + + const GOOGLE_TEXT_STREAM: &str = concat!( + r#"data: {"candidates": [{"content": {"role": "model", "#, + r#""parts": [{"text": "Hello"}]}}]}"#, + "\n", + r#"data: {"candidates": [{"content": {"role": "model", "#, + r#""parts": [{"text": " world"}]}}]}"#, + "\n", + r#"data: {"candidates": [{"content": {"role": "model", "#, + r#""parts": [{"text": "!"}]}}], "#, + r#""usageMetadata": {"promptTokenCount": 10, "#, + r#""candidatesTokenCount": 3, "totalTokenCount": 13}}"# + ); + + const GOOGLE_FUNCTION_STREAM: &str = concat!( + r#"data: {"candidates": [{"content": {"role": "model", "#, + r#""parts": [{"functionCall": {"name": "test_tool", "#, + r#""args": {"param": "value"}}}]}}], "#, + r#""usageMetadata": {"promptTokenCount": 5, "#, + r#""candidatesTokenCount": 2, "totalTokenCount": 7}}"# + ); + + #[tokio::test] + async fn test_streaming_text_response() { + use futures::StreamExt; + + let lines: Vec> = GOOGLE_TEXT_STREAM + .lines() + .map(|l| Ok(l.to_string())) + .collect(); + let stream = Box::pin(futures::stream::iter(lines)); + let mut message_stream = std::pin::pin!(response_to_streaming_message(stream)); + + let mut text_parts = Vec::new(); + let mut message_ids: Vec> = Vec::new(); + let mut final_usage = None; + + while let Some(result) = message_stream.next().await { + let (message, usage) = result.unwrap(); + if let Some(msg) = message { + message_ids.push(msg.id.clone()); + if let Some(MessageContent::Text(text)) = msg.content.first() { + text_parts.push(text.text.clone()); + } + } + if usage.is_some() { + final_usage = usage; + } + } + + assert_eq!(text_parts, vec!["Hello", " world", "!"]); + let usage = final_usage.unwrap(); + assert_eq!(usage.usage.input_tokens, Some(10)); + assert_eq!(usage.usage.output_tokens, Some(3)); + + // Verify all streaming messages have consistent IDs for UI aggregation + assert!( + message_ids.iter().all(|id| id.is_some()), + "All streaming messages should have an ID" + ); + let first_id = message_ids.first().unwrap(); + assert!( + message_ids.iter().all(|id| id == first_id), + "All streaming messages should have the same ID" + ); + } + + #[tokio::test] + async fn test_streaming_function_call() { + use futures::StreamExt; + + let lines: Vec> = GOOGLE_FUNCTION_STREAM + .lines() + .map(|l| Ok(l.to_string())) + .collect(); + let stream = Box::pin(futures::stream::iter(lines)); + let mut message_stream = std::pin::pin!(response_to_streaming_message(stream)); + + let mut tool_calls = Vec::new(); + + while let Some(result) = message_stream.next().await { + let (message, _usage) = result.unwrap(); + if let Some(msg) = message { + if let Some(MessageContent::ToolRequest(req)) = msg.content.first() { + if let Ok(tool_call) = &req.tool_call { + tool_calls.push(tool_call.name.to_string()); + } + } + } + } + + assert_eq!(tool_calls, vec!["test_tool"]); + } + + #[tokio::test] + async fn test_streaming_error_response() { + use futures::StreamExt; + + let error_stream = concat!( + r#"data: {"error": {"code": 400, "#, + r#""message": "Invalid request", "status": "INVALID_ARGUMENT"}}"# + ); + let lines: Vec> = + error_stream.lines().map(|l| Ok(l.to_string())).collect(); + let stream = Box::pin(futures::stream::iter(lines)); + let mut message_stream = std::pin::pin!(response_to_streaming_message(stream)); + + let result = message_stream.next().await; + assert!(result.is_some()); + let err = result.unwrap(); + assert!(err.is_err()); + let error_msg = err.unwrap_err().to_string(); + assert!(error_msg.contains("INVALID_ARGUMENT")); + assert!(error_msg.contains("Invalid request")); + } + + #[tokio::test] + async fn test_streaming_with_sse_event_lines() { + use futures::StreamExt; + + // SSE format can include event: lines which should be skipped + let sse_stream = r#"event: message +data: {"candidates": [{"content": {"role": "model", "parts": [{"text": "Hello"}]}}]} + +event: message +data: {"candidates": [{"content": {"role": "model", "parts": [{"text": " world"}]}}]} + +data: [DONE]"#; + let lines: Vec> = + sse_stream.lines().map(|l| Ok(l.to_string())).collect(); + let stream = Box::pin(futures::stream::iter(lines)); + let mut message_stream = std::pin::pin!(response_to_streaming_message(stream)); + + let mut text_parts = Vec::new(); + + while let Some(result) = message_stream.next().await { + let (message, _usage) = result.unwrap(); + if let Some(msg) = message { + if let Some(MessageContent::Text(text)) = msg.content.first() { + text_parts.push(text.text.clone()); + } + } + } + + assert_eq!(text_parts, vec!["Hello", " world"]); + } + + #[tokio::test] + async fn test_streaming_handles_done_signal() { + use futures::StreamExt; + + let stream_with_done = concat!( + r#"data: {"candidates": [{"content": {"role": "model", "#, + r#""parts": [{"text": "Complete"}]}}]}"#, + "\n", + "data: [DONE]\n", + r#"data: {"candidates": [{"content": {"role": "model", "#, + r#""parts": [{"text": "Should not appear"}]}}]}"# + ); + let lines: Vec> = stream_with_done + .lines() + .map(|l| Ok(l.to_string())) + .collect(); + let stream = Box::pin(futures::stream::iter(lines)); + let mut message_stream = std::pin::pin!(response_to_streaming_message(stream)); + + let mut text_parts = Vec::new(); + + while let Some(result) = message_stream.next().await { + let (message, _usage) = result.unwrap(); + if let Some(msg) = message { + if let Some(MessageContent::Text(text)) = msg.content.first() { + text_parts.push(text.text.clone()); + } + } + } + + // Only "Complete" should be captured, stream should stop at [DONE] + assert_eq!(text_parts, vec!["Complete"]); } } diff --git a/crates/goose/src/providers/google.rs b/crates/goose/src/providers/google.rs index 6278fd40f003..eb907313fef6 100644 --- a/crates/goose/src/providers/google.rs +++ b/crates/goose/src/providers/google.rs @@ -1,16 +1,28 @@ use super::api_client::{ApiClient, AuthMethod}; +use super::base::MessageStream; use super::errors::ProviderError; use super::retry::ProviderRetry; -use super::utils::{handle_response_google_compat, unescape_json_values, RequestLog}; +use super::utils::{ + handle_response_google_compat, handle_status_openai_compat, unescape_json_values, RequestLog, +}; use crate::conversation::message::Message; use crate::model::ModelConfig; use crate::providers::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage}; -use crate::providers::formats::google::{create_request, get_usage, response_to_message}; +use crate::providers::formats::google::{ + create_request, get_usage, response_to_message, response_to_streaming_message, +}; use anyhow::Result; +use async_stream::try_stream; use async_trait::async_trait; +use futures::TryStreamExt; use rmcp::model::Tool; use serde_json::Value; +use std::io; +use tokio::pin; +use tokio_stream::StreamExt; +use tokio_util::codec::{FramedRead, LinesCodec}; +use tokio_util::io::StreamReader; pub const GOOGLE_API_HOST: &str = "https://generativelanguage.googleapis.com"; pub const GOOGLE_DEFAULT_MODEL: &str = "gemini-2.5-pro"; @@ -84,6 +96,16 @@ impl GoogleProvider { let response = self.api_client.response_post(&path, payload).await?; handle_response_google_compat(response).await } + + async fn post_stream( + &self, + model_name: &str, + payload: &Value, + ) -> Result { + let path = format!("v1beta/models/{}:streamGenerateContent?alt=sse", model_name); + let response = self.api_client.response_post(&path, payload).await?; + handle_status_openai_compat(response).await + } } #[async_trait] @@ -126,10 +148,7 @@ impl Provider for GoogleProvider { let mut log = RequestLog::start(model_config, &payload)?; let response = self - .with_retry(|| async { - let payload_clone = payload.clone(); - self.post(&model_config.model_name, &payload_clone).await - }) + .with_retry(|| async { self.post(&model_config.model_name, &payload).await }) .await?; let message = response_to_message(unescape_json_values(&response))?; @@ -158,4 +177,45 @@ impl Provider for GoogleProvider { models.sort(); Ok(Some(models)) } + + fn supports_streaming(&self) -> bool { + true + } + + async fn stream( + &self, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result { + let payload = create_request(&self.model, system, messages, tools)?; + let mut log = RequestLog::start(&self.model, &payload)?; + + let response = self + .with_retry(|| async { self.post_stream(&self.model.model_name, &payload).await }) + .await + .inspect_err(|e| { + let _ = log.error(e); + })?; + + let stream = response.bytes_stream().map_err(io::Error::other); + + Ok(Box::pin(try_stream! { + let stream_reader = StreamReader::new(stream); + let framed = FramedRead::new(stream_reader, LinesCodec::new()) + .map_err(anyhow::Error::from); + + let message_stream = response_to_streaming_message(framed); + pin!(message_stream); + while let Some(message) = message_stream.next().await { + let (message, usage) = message.map_err(|e| + ProviderError::RequestFailed(format!("Stream decode error: {}", e)) + )?; + if message.is_some() || usage.is_some() { + log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?; + } + yield (message, usage); + } + })) + } } diff --git a/crates/goose/src/providers/utils.rs b/crates/goose/src/providers/utils.rs index 6a2d25f87d08..403d60266df9 100644 --- a/crates/goose/src/providers/utils.rs +++ b/crates/goose/src/providers/utils.rs @@ -12,12 +12,13 @@ use regex::Regex; use reqwest::{Response, StatusCode}; use rmcp::model::{AnnotateAble, ImageContent, RawImageContent}; use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; +use serde_json::{json, Value}; use std::fmt::Display; use std::fs::File; use std::io; use std::io::{BufWriter, Read, Write}; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; use std::time::Duration; use tokio::pin; use tokio_stream::StreamExt; @@ -324,12 +325,14 @@ pub async fn handle_response_google_compat(response: Response) -> Result String { - let re = Regex::new(r"[^a-zA-Z0-9_-]").unwrap(); + static RE: OnceLock = OnceLock::new(); + let re = RE.get_or_init(|| Regex::new(r"[^a-zA-Z0-9_-]").unwrap()); re.replace_all(name, "_").to_string() } pub fn is_valid_function_name(name: &str) -> bool { - let re = Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap(); + static RE: OnceLock = OnceLock::new(); + let re = RE.get_or_init(|| Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap()); re.is_match(name) } @@ -435,31 +438,37 @@ pub fn load_image_file(path: &str) -> Result { } pub fn unescape_json_values(value: &Value) -> Value { + let mut cloned = value.clone(); + unescape_json_values_in_place(&mut cloned); + cloned +} + +fn unescape_json_values_in_place(value: &mut Value) { match value { Value::Object(map) => { - let new_map: Map = map - .iter() - .map(|(k, v)| (k.clone(), unescape_json_values(v))) // Process each value - .collect(); - Value::Object(new_map) + for v in map.values_mut() { + unescape_json_values_in_place(v); + } } Value::Array(arr) => { - let new_array: Vec = arr.iter().map(unescape_json_values).collect(); - Value::Array(new_array) + for v in arr.iter_mut() { + unescape_json_values_in_place(v); + } } Value::String(s) => { - let unescaped = s - .replace("\\\\n", "\n") - .replace("\\\\t", "\t") - .replace("\\\\r", "\r") - .replace("\\\\\"", "\"") - .replace("\\n", "\n") - .replace("\\t", "\t") - .replace("\\r", "\r") - .replace("\\\"", "\""); - Value::String(unescaped) + if s.contains('\\') { + *s = s + .replace("\\\\n", "\n") + .replace("\\\\t", "\t") + .replace("\\\\r", "\r") + .replace("\\\\\"", "\"") + .replace("\\n", "\n") + .replace("\\t", "\t") + .replace("\\r", "\r") + .replace("\\\"", "\""); + } } - _ => value.clone(), + _ => {} } }