From 4ba3f46848c4ae73708b493dfb7a4343e9e6e2e0 Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Sun, 22 Feb 2026 11:08:16 +0100 Subject: [PATCH 01/15] Address review feedback: document serde tag behavior, fix tests, clean up client - Add comment on FunctionToolCall.r#type explaining why serde default is needed (tagged enum consumes the field during deserialization) - Replace misleading deserialization test with two focused tests: variant selection and serialize round-trip - Inline unnecessary messages_clone variable in generate_completion - Document partial-failure semantics on process_parallel_function_calls --- README.md | 2 +- macros/src/common.rs | 12 +++ macros/src/lib.rs | 1 + macros/src/tool.rs | 12 +-- macros/src/tools.rs | 20 ++--- src/completions/client.rs | 4 +- src/core/builder.rs | 88 ++++++-------------- src/core/error.rs | 2 +- src/core/http.rs | 45 ++++++----- src/core/tool_guard.rs | 6 +- src/provider/gemini.rs | 160 ++++++++++++++----------------------- src/provider/openai.rs | 48 +++-------- src/provider/openrouter.rs | 35 +------- src/responses/client.rs | 147 ++++++++++++++++++++-------------- src/responses/response.rs | 80 +++++++++++++------ src/responses/types.rs | 9 ++- 16 files changed, 300 insertions(+), 371 deletions(-) create mode 100644 macros/src/common.rs diff --git a/README.md b/README.md index f47132d..125fbfa 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ See `examples/` for more runnable examples. ## Known Issues -- .. +- **TODO**: Evaluate whether `ToolCallingConfig` should be required rather than optional. Currently providers default to a guard with sensible limits when no config is set, but allowing `None` suggests users can opt out of loop protection entirely. We may want to be opinionated here and always require a `ToolCallingConfig`. ## License diff --git a/macros/src/common.rs b/macros/src/common.rs new file mode 100644 index 0000000..61b42e2 --- /dev/null +++ b/macros/src/common.rs @@ -0,0 +1,12 @@ +/// Convert a snake_case name to PascalCase. +pub fn to_pascal_case(name: &str) -> String { + name.split('_') + .map(|s| { + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } + }) + .collect() +} diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 03d711a..4a92cc1 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -37,6 +37,7 @@ use proc_macro::TokenStream; use quote::quote; +mod common; mod tool; mod tools; diff --git a/macros/src/tool.rs b/macros/src/tool.rs index da7c96e..cd5b8c3 100644 --- a/macros/src/tool.rs +++ b/macros/src/tool.rs @@ -31,17 +31,7 @@ pub fn tool_impl(attr: TokenStream, item: TokenStream) -> Result { // Generate the wrapper struct name let wrapper_name = quote::format_ident!( "{}Tool", - fn_name - .to_string() - .split('_') - .map(|s| { - let mut c = s.chars(); - match c.next() { - None => String::new(), - Some(f) => f.to_uppercase().collect::() + c.as_str(), - } - }) - .collect::() + crate::common::to_pascal_case(&fn_name.to_string()) ); // Check if function is async diff --git a/macros/src/tools.rs b/macros/src/tools.rs index 0e6e141..94bcb28 100644 --- a/macros/src/tools.rs +++ b/macros/src/tools.rs @@ -46,19 +46,6 @@ impl Parse for ToolsList { } } -/// Convert a snake_case function name to PascalCase struct name -fn to_pascal_case(name: &str) -> String { - name.split('_') - .map(|s| { - let mut c = s.chars(); - match c.next() { - None => String::new(), - Some(f) => f.to_uppercase().collect::() + c.as_str(), - } - }) - .collect() -} - pub fn tools_impl(input: TokenStream) -> Result { let tools_list = syn::parse2::(input)?; @@ -73,7 +60,12 @@ pub fn tools_impl(input: TokenStream) -> Result { let wrapper_names: Vec<_> = tools_list .tools .iter() - .map(|tool_name| quote::format_ident!("{}Tool", to_pascal_case(&tool_name.to_string()))) + .map(|tool_name| { + quote::format_ident!( + "{}Tool", + crate::common::to_pascal_case(&tool_name.to_string()) + ) + }) .collect(); // Generate different code based on whether context is present diff --git a/src/completions/client.rs b/src/completions/client.rs index 239df0c..b0754e7 100644 --- a/src/completions/client.rs +++ b/src/completions/client.rs @@ -211,7 +211,7 @@ impl CompletionClient

{ result: result.clone(), }); - // If not parallel, process one at a time + // In sequential mode process one call per model turn. if !is_parallel { break; } @@ -225,7 +225,7 @@ impl CompletionClient

{ } /// Convert core messages to conversation items. -fn convert_messages_to_conversation( +pub(crate) fn convert_messages_to_conversation( messages: &[crate::core::ConversationMessage], ) -> Result, LlmError> { messages diff --git a/src/core/builder.rs b/src/core/builder.rs index 7eb5d74..ec85e54 100644 --- a/src/core/builder.rs +++ b/src/core/builder.rs @@ -402,84 +402,44 @@ impl LlmBuilder = messages + .into_iter() + .map(ConversationMessage::Chat) + .collect(); + + let req = StructuredRequest { + model: model_string, + messages: conversation_messages, + tool_config: tool_schemas.map(|tools| ToolConfig { + tools: Some(tools), + tool_choice: self.fields.tool_choice.clone(), + parallel_tool_calls: self.fields.parallel_tool_calls, + }), + generation_config: Some(GenerationConfig { + max_tokens: self.fields.max_tokens, + temperature: self.fields.temperature, + top_p: self.fields.top_p, + }), + }; + + let tool_registry = self.fields.tool_registry.as_ref(); match provider { Provider::OpenAI => { - let conversation_messages: Vec = messages - .into_iter() - .map(ConversationMessage::Chat) - .collect(); - - let req = StructuredRequest { - model: model_string, - messages: conversation_messages, - tool_config: tool_schemas.map(|tools| ToolConfig { - tools: Some(tools), - tool_choice: self.fields.tool_choice.clone(), - parallel_tool_calls: self.fields.parallel_tool_calls, - }), - generation_config: Some(GenerationConfig { - max_tokens: self.fields.max_tokens, - temperature: self.fields.temperature, - top_p: self.fields.top_p, - }), - }; let client = openai::create_openai_client_from_builder(&self)?; client - .generate_completion::( - req, - format.clone(), - self.fields.tool_registry.as_ref(), - ) + .generate_completion::(req, format, tool_registry) .await } Provider::OpenRouter => { - let conversation_messages: Vec = messages - .into_iter() - .map(ConversationMessage::Chat) - .collect(); - - let req = StructuredRequest { - model: model_string, - messages: conversation_messages, - tool_config: tool_schemas.map(|tools| ToolConfig { - tools: Some(tools), - tool_choice: self.fields.tool_choice.clone(), - parallel_tool_calls: self.fields.parallel_tool_calls, - }), - generation_config: Some(GenerationConfig { - max_tokens: self.fields.max_tokens, - temperature: self.fields.temperature, - top_p: self.fields.top_p, - }), - }; let client = openrouter::create_openrouter_client_from_builder(&self)?; client - .generate_completion::(req, format, self.fields.tool_registry.as_ref()) + .generate_completion::(req, format, tool_registry) .await } Provider::Gemini => { - let conversation_messages: Vec = messages - .into_iter() - .map(ConversationMessage::Chat) - .collect(); - - let req = StructuredRequest { - model: model_string, - messages: conversation_messages, - tool_config: tool_schemas.map(|tools| ToolConfig { - tools: Some(tools), - tool_choice: self.fields.tool_choice.clone(), - parallel_tool_calls: self.fields.parallel_tool_calls, - }), - generation_config: Some(GenerationConfig { - max_tokens: self.fields.max_tokens, - temperature: self.fields.temperature, - top_p: self.fields.top_p, - }), - }; let client = gemini::create_gemini_client_from_builder(&self)?; client - .generate_completion::(req, format, self.fields.tool_registry.as_ref()) + .generate_completion::(req, format, tool_registry) .await } } diff --git a/src/core/error.rs b/src/core/error.rs index 654f610..ac9a77f 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -56,6 +56,6 @@ pub enum LlmError { #[error("Tool call processing timeout exceeded: {timeout:?}")] ToolCallTimeout { timeout: std::time::Duration }, - #[error("Toll registration failed for {tool_name}: {message}")] + #[error("Tool registration failed for {tool_name}: {message}")] ToolRegistration { tool_name: String, message: String }, } diff --git a/src/core/http.rs b/src/core/http.rs index d8d043a..ef6958c 100644 --- a/src/core/http.rs +++ b/src/core/http.rs @@ -82,16 +82,14 @@ impl HttpClient { Req: Serialize, Res: DeserializeOwned, { - // Serialize request to Value for inspection - let body_value = serde_json::to_value(body).map_err(|e| LlmError::Parse { - message: "Failed to serialize request for inspection".to_string(), - source: Box::new(e), - })?; - - // Call request inspector + // Only serialize to Value if we need to inspect the request if let Some(ref config) = self.inspector_config && let Some(ref inspector) = config.request_inspector { + let body_value = serde_json::to_value(body).map_err(|e| LlmError::Parse { + message: "Failed to serialize request for inspection".to_string(), + source: Box::new(e), + })?; inspector(&body_value); } @@ -99,7 +97,7 @@ impl HttpClient { for attempt in 0..=self.config.max_retries { // Build request (must be rebuilt each attempt since .send() consumes it) - let mut req_builder = self.client.post(url).json(&body_value); + let mut req_builder = self.client.post(url).json(body); // Add headers for (name, value) in headers { @@ -125,31 +123,34 @@ impl HttpClient { if status.is_success() { debug!(status = %status, "HTTP request successful"); - // Parse response to text first, then to Value for inspection let response_text = res.text().await.map_err(|e| LlmError::Parse { message: "Failed to read response body".to_string(), source: Box::new(e), })?; - let response_value: serde_json::Value = - serde_json::from_str(&response_text).map_err(|e| LlmError::Parse { - message: "Failed to parse response as JSON".to_string(), - source: Box::new(e), - })?; - - // Call response inspector + // Only go through intermediate Value if we need to inspect if let Some(ref config) = self.inspector_config && let Some(ref inspector) = config.response_inspector { + let response_value: serde_json::Value = + serde_json::from_str(&response_text).map_err(|e| { + LlmError::Parse { + message: "Failed to parse response as JSON".to_string(), + source: Box::new(e), + } + })?; inspector(&response_value); + return serde_json::from_value(response_value).map_err(|e| { + LlmError::Parse { + message: "Failed to parse API response".to_string(), + source: Box::new(e), + } + }); } - // Deserialize to target type - return serde_json::from_value(response_value).map_err(|e| { - LlmError::Parse { - message: "Failed to parse API response".to_string(), - source: Box::new(e), - } + return serde_json::from_str(&response_text).map_err(|e| LlmError::Parse { + message: "Failed to parse API response".to_string(), + source: Box::new(e), }); } diff --git a/src/core/tool_guard.rs b/src/core/tool_guard.rs index 4b0fe47..d969356 100644 --- a/src/core/tool_guard.rs +++ b/src/core/tool_guard.rs @@ -61,11 +61,7 @@ impl ToolCallingGuard { /// Create a new ToolCallingGuard from a config pub fn from_config(config: &ToolCallingConfig) -> Self { - Self { - max_iterations: config.max_iterations, - timeout: config.timeout, - current_iteration: 0, - } + Self::with_limits(config.max_iterations, config.timeout) } /// Increment iteration count and check if limit is exceeded diff --git a/src/provider/gemini.rs b/src/provider/gemini.rs index e9302a7..422b499 100644 --- a/src/provider/gemini.rs +++ b/src/provider/gemini.rs @@ -342,16 +342,7 @@ impl CompletionRequestBuilder for GeminiRequestBuilder { let candidate = response.candidates.as_ref()?.first()?; let content = candidate.content.as_ref()?; - let mut calls = Vec::new(); - for (idx, part) in content.parts.iter().enumerate() { - if let Part::FunctionCall(FunctionCallPart { function_call }) = part { - calls.push(FunctionCallData { - id: format!("call_{}", idx), - name: function_call.name.clone(), - arguments: function_call.args.clone(), - }); - } - } + let calls: Vec = extract_function_calls_from_parts(&content.parts); if calls.is_empty() { None } else { Some(calls) } } @@ -565,28 +556,42 @@ fn build_tools_config( ) } -fn parse_parts_to_content(parts: &[Part]) -> Result { - let mut text_parts = Vec::new(); - let mut function_calls = Vec::new(); - - for (idx, part) in parts.iter().enumerate() { - match part { - Part::Text(TextPart { text }) => text_parts.push(text.clone()), - Part::FunctionCall(FunctionCallPart { function_call }) => { - function_calls.push(FunctionCallData { - id: format!("call_{}", idx), +/// Extract function calls from Gemini response parts with unique IDs. +fn extract_function_calls_from_parts(parts: &[Part]) -> Vec { + parts + .iter() + .enumerate() + .filter_map(|(idx, part)| { + if let Part::FunctionCall(FunctionCallPart { function_call }) = part { + Some(FunctionCallData { + id: format!("call_{}_{:08x}", idx, rand::random::()), name: function_call.name.clone(), arguments: function_call.args.clone(), - }); + }) + } else { + None } - Part::FunctionResponse(_) => {} - } - } + }) + .collect() +} +fn parse_parts_to_content(parts: &[Part]) -> Result { + let function_calls = extract_function_calls_from_parts(parts); if !function_calls.is_empty() { - Ok(ResponseContent::FunctionCalls(function_calls)) - } else if !text_parts.is_empty() { - Ok(ResponseContent::Text(text_parts.join(""))) + return Ok(ResponseContent::FunctionCalls(function_calls)); + } + + let text: String = parts + .iter() + .filter_map(|p| match p { + Part::Text(TextPart { text }) => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join(""); + + if !text.is_empty() { + Ok(ResponseContent::Text(text)) } else { Err(LlmError::Provider { message: "Empty response from Gemini".to_string(), @@ -601,34 +606,24 @@ fn parse_parts_to_content(parts: &[Part]) -> Result { pub struct GeminiClient { completion_client: CompletionClient, - config: GeminiConfig, } impl GeminiClient { pub fn new(api_key: String) -> Result { - let config = GeminiConfig::new(api_key.clone()); - let completion_client = CompletionClient::new(GeminiConfig::new(api_key))?; - + let config = GeminiConfig::new(api_key); Ok(Self { - completion_client, - config, + completion_client: CompletionClient::new(config)?, }) } pub fn with_base_url(mut self, base_url: String) -> Result { + let config = &self.completion_client.config; let new_config = GeminiConfig { - api_key: self.config.api_key.clone(), - base_url: base_url.clone(), - tool_calling_config: self.config.tool_calling_config.clone(), - http_config: self.config.http_config.clone(), - inspector_config: self.config.inspector_config.clone(), - }; - self.config = GeminiConfig { - api_key: self.config.api_key.clone(), + api_key: config.api_key.clone(), base_url, - tool_calling_config: self.config.tool_calling_config.clone(), - http_config: self.config.http_config.clone(), - inspector_config: self.config.inspector_config.clone(), + tool_calling_config: config.tool_calling_config.clone(), + http_config: config.http_config.clone(), + inspector_config: config.inspector_config.clone(), }; self.completion_client = CompletionClient::new(new_config)?; Ok(self) @@ -638,27 +633,27 @@ impl GeminiClient { mut self, tool_config: ToolCallingConfig, ) -> Result { + let config = &self.completion_client.config; let new_config = GeminiConfig { - api_key: self.config.api_key.clone(), - base_url: self.config.base_url.clone(), - tool_calling_config: Some(tool_config.clone()), - http_config: self.config.http_config.clone(), - inspector_config: self.config.inspector_config.clone(), + api_key: config.api_key.clone(), + base_url: config.base_url.clone(), + tool_calling_config: Some(tool_config), + http_config: config.http_config.clone(), + inspector_config: config.inspector_config.clone(), }; - self.config.tool_calling_config = Some(tool_config); self.completion_client = CompletionClient::new(new_config)?; Ok(self) } pub fn with_http_config(mut self, http_config: HttpClientConfig) -> Result { + let config = &self.completion_client.config; let new_config = GeminiConfig { - api_key: self.config.api_key.clone(), - base_url: self.config.base_url.clone(), - tool_calling_config: self.config.tool_calling_config.clone(), - http_config: http_config.clone(), - inspector_config: self.config.inspector_config.clone(), + api_key: config.api_key.clone(), + base_url: config.base_url.clone(), + tool_calling_config: config.tool_calling_config.clone(), + http_config, + inspector_config: config.inspector_config.clone(), }; - self.config.http_config = http_config; self.completion_client = CompletionClient::new(new_config)?; Ok(self) } @@ -667,14 +662,14 @@ impl GeminiClient { mut self, inspector_config: InspectorConfig, ) -> Result { + let config = &self.completion_client.config; let new_config = GeminiConfig { - api_key: self.config.api_key.clone(), - base_url: self.config.base_url.clone(), - tool_calling_config: self.config.tool_calling_config.clone(), - http_config: self.config.http_config.clone(), - inspector_config: Some(inspector_config.clone()), + api_key: config.api_key.clone(), + base_url: config.base_url.clone(), + tool_calling_config: config.tool_calling_config.clone(), + http_config: config.http_config.clone(), + inspector_config: Some(inspector_config), }; - self.config.inspector_config = Some(inspector_config); self.completion_client = CompletionClient::new(new_config)?; Ok(self) } @@ -701,14 +696,14 @@ impl LlmProvider for GeminiClient { .and_then(|tc| tc.tools.as_ref()) .is_some(); - if has_tools && tool_registry.is_some() { - let mut guard = self.config.get_tool_calling_guard(); + if has_tools && let Some(tool_registry) = tool_registry { + let mut guard = self.completion_client.config.get_tool_calling_guard(); let provider_response = self .completion_client .handle_tool_calling_loop::<_, Ctx>( &builder, request, - tool_registry.unwrap(), + tool_registry, &mut guard, format, ) @@ -717,7 +712,8 @@ impl LlmProvider for GeminiClient { } // Single request without tool calling loop - let conversation = convert_messages_to_conversation(&request.messages)?; + let conversation = + crate::completions::client::convert_messages_to_conversation(&request.messages)?; let api_request = builder.build_request(&request, &format, &conversation)?; let api_response = self .completion_client @@ -728,38 +724,6 @@ impl LlmProvider for GeminiClient { } } -fn convert_messages_to_conversation( - messages: &[crate::core::ConversationMessage], -) -> Result, LlmError> { - messages - .iter() - .map(|msg| match msg { - crate::core::ConversationMessage::Chat(m) => { - let role = match m.role { - crate::core::ChatRole::System => "system", - crate::core::ChatRole::User => "user", - crate::core::ChatRole::Assistant => "assistant", - }; - Ok(ConversationItem::Message { - role: role.to_string(), - content: m.content.clone(), - }) - } - crate::core::ConversationMessage::ToolCall(tc) => Ok(ConversationItem::FunctionCall { - id: tc.call_id.clone(), - name: tc.name.clone(), - arguments: tc.arguments.clone(), - }), - crate::core::ConversationMessage::ToolCallResult(tr) => { - Ok(ConversationItem::FunctionResult { - call_id: tr.tool_call_id.clone(), - result: tr.content.clone(), - }) - } - }) - .collect() -} - // ============================================================================ // Builder Integration // ============================================================================ diff --git a/src/provider/openai.rs b/src/provider/openai.rs index 87fc38c..7eb2e42 100644 --- a/src/provider/openai.rs +++ b/src/provider/openai.rs @@ -57,18 +57,17 @@ impl OpenAiConfig { self } - pub fn get_tool_calling_guard(&self) -> ToolCallingGuard { - if let Some(ref config) = self.tool_calling_config { - ToolCallingGuard::with_limits(config.max_iterations, config.timeout) - } else { - ToolCallingGuard::new() - } - } - pub fn with_http_config(mut self, config: HttpClientConfig) -> Self { self.http_config = config; self } + + pub fn get_tool_calling_guard(&self) -> ToolCallingGuard { + match self.tool_calling_config { + Some(ref config) => ToolCallingGuard::from_config(config), + None => ToolCallingGuard::default(), + } + } } impl ResponsesProviderConfig for OpenAiConfig { @@ -176,35 +175,10 @@ impl LlmProvider for OpenAiClient { T: crate::CompletionTarget + Send, Ctx: Send + Sync + 'static, { - // If tools are present and we have a registry, handle automatic tool calling - let has_tools = request - .tool_config - .as_ref() - .and_then(|tc| tc.tools.as_ref()) - .is_some(); - - if has_tools && let Some(tool_registry) = tool_registry { - let mut guard = self.responses_client.config.get_tool_calling_guard(); - return self - .responses_client - .handle_tool_calling_loop::(request, tool_registry, &mut guard, format) - .await; - } - - // Otherwise, make a single request expecting the configured completion output - let messages_clone = request.messages.clone(); - let responses_request = self.responses_client.build_request_with_format( - &request, - &crate::responses::convert_messages_to_responses_format(messages_clone)?, - format, - )?; - let api_response = self - .responses_client - .make_api_request(responses_request) - .await?; - let provider_response = - crate::responses::convert_to_provider_response(api_response, super::Provider::OpenAI)?; - T::parse_response(provider_response) + let guard = self.responses_client.config.get_tool_calling_guard(); + self.responses_client + .generate_completion::(request, format, tool_registry, guard) + .await } } diff --git a/src/provider/openrouter.rs b/src/provider/openrouter.rs index 839ea5e..e869dea 100644 --- a/src/provider/openrouter.rs +++ b/src/provider/openrouter.rs @@ -227,37 +227,10 @@ impl LlmProvider for OpenRouterClient { T: crate::CompletionTarget + Send, Ctx: Send + Sync + 'static, { - // If tools are present and we have a registry, handle automatic tool calling - let has_tools = request - .tool_config - .as_ref() - .and_then(|tc| tc.tools.as_ref()) - .is_some(); - - if has_tools && let Some(tool_registry) = tool_registry { - let mut guard = self.responses_client.config.get_tool_calling_guard(); - return self - .responses_client - .handle_tool_calling_loop::(request, tool_registry, &mut guard, format) - .await; - } - - // Otherwise, make a single request expecting the configured completion output - let messages_clone = request.messages.clone(); - let responses_request = self.responses_client.build_request_with_format( - &request, - &crate::responses::convert_messages_to_responses_format(messages_clone)?, - format, - )?; - let api_response = self - .responses_client - .make_api_request(responses_request) - .await?; - let provider_response = crate::responses::convert_to_provider_response( - api_response, - super::Provider::OpenRouter, - )?; - T::parse_response(provider_response) + let guard = self.responses_client.config.get_tool_calling_guard(); + self.responses_client + .generate_completion::(request, format, tool_registry, guard) + .await } } diff --git a/src/responses/client.rs b/src/responses/client.rs index 92c79c5..e3d5a77 100644 --- a/src/responses/client.rs +++ b/src/responses/client.rs @@ -112,7 +112,6 @@ impl ResponsesClient

{ { let timeout_duration = guard.timeout; - // Use tokio::time::timeout to add timeout protection match tokio::time::timeout( timeout_duration, self.handle_tool_calling_loop_internal::(request, tool_registry, guard, format), @@ -156,7 +155,6 @@ impl ResponsesClient

{ .unwrap_or(true); loop { - // Check iteration limit before processing guard.increment_iteration()?; let iteration_span = @@ -236,7 +234,9 @@ impl ResponsesClient

{ } } - /// Process function calls in parallel (all calls first, then all results) + /// Process function calls in parallel (all calls added first, tools executed concurrently, then all results added). + /// + /// On failure, completed tools may have produced side-effects but no results are added to `responses_input`. pub async fn process_parallel_function_calls( &self, function_calls: &[&FunctionToolCall], @@ -246,33 +246,31 @@ impl ResponsesClient

{ where Ctx: Send + Sync + 'static, { - let mut pending_executions = Vec::new(); - - // Add all function calls to input and prepare for execution + // Add all function calls to input first + let mut tool_calls = Vec::with_capacity(function_calls.len()); for function_call in function_calls { responses_input.push(InputItem::FunctionCall((*function_call).clone())); let arguments = self.parse_function_arguments(&function_call.arguments)?; - pending_executions.push(( - function_call.id.clone(), - function_call.call_id.clone(), - function_call.name.clone(), + tool_calls.push(ToolCall { + id: function_call.id.clone(), + call_id: function_call.call_id.clone(), + name: function_call.name.clone(), arguments, - )); + }); } - // Execute all tools and add their results - for (id, call_id, name, arguments) in pending_executions { - let tool_call = ToolCall { - id, - call_id: call_id.clone(), - name, - arguments, - }; - let result = tool_registry.execute(&tool_call).await?; + // Execute all tools concurrently + let futures: Vec<_> = tool_calls + .iter() + .map(|tc| tool_registry.execute(tc)) + .collect(); + let results = futures::future::try_join_all(futures).await?; + // Add all results in order + for (tool_call, result) in tool_calls.iter().zip(results) { responses_input.push(InputItem::FunctionCallOutput(FunctionToolCallOutput { - call_id, + call_id: tool_call.call_id.clone(), output: result, r#type: "function_call_output".to_string(), })); @@ -314,6 +312,41 @@ impl ResponsesClient

{ Ok(()) } + /// Shared generate_completion for responses-API providers (OpenAI, OpenRouter). + /// + /// Handles the tool calling loop when tools are present, or makes a single + /// request otherwise. + pub async fn generate_completion( + &self, + request: StructuredRequest, + format: crate::responses::request::Format, + tool_registry: Option<&ToolRegistry>, + mut guard: ToolCallingGuard, + ) -> Result + where + T: CompletionTarget + Send, + Ctx: Send + Sync + 'static, + { + let has_tools = request + .tool_config + .as_ref() + .and_then(|tc| tc.tools.as_ref()) + .is_some(); + + if has_tools && let Some(tool_registry) = tool_registry { + return self + .handle_tool_calling_loop::(request, tool_registry, &mut guard, format) + .await; + } + + let responses_input = convert_messages_to_responses_format(request.messages.clone())?; + let responses_request = + self.build_request_with_format(&request, &responses_input, format)?; + let api_response = self.make_api_request(responses_request).await?; + let provider_response = convert_to_provider_response(api_response, self.config.provider())?; + T::parse_response(provider_response) + } + /// Parse function arguments from JSON value pub fn parse_function_arguments( &self, @@ -510,58 +543,52 @@ pub(crate) fn create_text_format() -> Format { } } -/// Convert OpenAI API response to provider-agnostic ProviderResponse +/// Convert OpenAI API response to provider-agnostic ProviderResponse. +/// +/// Aggregates all output items: collects function calls across all items, +/// concatenates text from all messages, and surfaces refusals. +/// Function calls take priority over text if both are present. pub fn convert_to_provider_response( res: Response, provider: crate::provider::Provider, ) -> Result { use crate::core::{FunctionCallData, LanguageModelUsage, ProviderResponse, ResponseContent}; - let output_content = res.output.first().ok_or_else(|| LlmError::Provider { - message: "No output in response".to_string(), - source: None, - })?; - - let content = match output_content { - OutputContent::OutputMessage(message) => { - let msg_content = message.content.first().ok_or_else(|| LlmError::Provider { - message: "No content in message".to_string(), - source: None, - })?; - - match msg_content { - MessageContent::OutputText(output) => ResponseContent::Text(output.text.clone()), - MessageContent::Refusal(refusal) => { - ResponseContent::Refusal(refusal.refusal.clone()) + let mut function_calls = Vec::new(); + let mut text_parts = Vec::new(); + let mut refusal = None; + + for output in &res.output { + match output { + OutputContent::OutputMessage(message) => { + for content in &message.content { + match content { + MessageContent::OutputText(text) => text_parts.push(text.text.clone()), + MessageContent::Refusal(r) => refusal = Some(r.refusal.clone()), + } } } - } - OutputContent::FunctionCall(fc) => { - // Collect all function calls from the output - let function_calls: Vec = res - .output - .iter() - .filter_map(|o| match o { - OutputContent::FunctionCall(fc) => Some(FunctionCallData { - id: fc.call_id.clone(), - name: fc.name.clone(), - arguments: fc.arguments.clone(), - }), - _ => None, - }) - .collect(); - - if function_calls.is_empty() { - // This shouldn't happen since we matched FunctionCall, but handle it - ResponseContent::FunctionCalls(vec![FunctionCallData { + OutputContent::FunctionCall(fc) => { + function_calls.push(FunctionCallData { id: fc.call_id.clone(), name: fc.name.clone(), arguments: fc.arguments.clone(), - }]) - } else { - ResponseContent::FunctionCalls(function_calls) + }); } } + } + + let content = if !function_calls.is_empty() { + ResponseContent::FunctionCalls(function_calls) + } else if let Some(refusal) = refusal { + ResponseContent::Refusal(refusal) + } else if !text_parts.is_empty() { + ResponseContent::Text(text_parts.join("")) + } else { + return Err(LlmError::Provider { + message: "No output in response".to_string(), + source: None, + }); }; Ok(ProviderResponse { diff --git a/src/responses/response.rs b/src/responses/response.rs index d065f04..fe23f3b 100644 --- a/src/responses/response.rs +++ b/src/responses/response.rs @@ -11,9 +11,11 @@ pub struct Response { } #[derive(Debug, Deserialize)] -#[serde(untagged)] +#[serde(tag = "type")] pub enum OutputContent { + #[serde(rename = "message")] OutputMessage(OutputMessage), + #[serde(rename = "function_call")] FunctionCall(FunctionToolCall), } @@ -24,22 +26,12 @@ pub struct Usage { pub total_tokens: i32, } -// TODO: Remove this, once text input is supported #[allow(dead_code)] #[derive(Debug, Deserialize)] pub struct OutputMessage { pub id: String, - - #[allow(dead_code)] - /// This is always `message` - #[serde(rename = "type")] - pub r#type: String, - pub status: Status, - pub content: Vec, - - /// This is always `assistant` pub role: String, } @@ -52,31 +44,71 @@ pub enum Status { } #[derive(Debug, Deserialize)] -#[serde(untagged, rename_all = "snake_case")] +#[serde(tag = "type")] pub enum MessageContent { + #[serde(rename = "output_text")] OutputText(OutputText), + #[serde(rename = "refusal")] Refusal(Refusal), } #[derive(Debug, Deserialize)] pub struct OutputText { - #[allow(dead_code)] - /// Always `output_text` - #[serde(rename = "type")] - pub r#type: String, - pub text: String, - // TODO - // annotations } #[derive(Debug, Deserialize)] pub struct Refusal { - /// The refusal explanation from the model. pub refusal: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn tagged_function_call_deserializes() { + let payload = json!({ + "type": "function_call", + "id": "tool_1", + "call_id": "tool_1", + "name": "lookup_weather", + "arguments": "{\"city\":\"Lisbon\"}" + }); + + let parsed: OutputContent = + serde_json::from_value(payload).expect("function_call should deserialize"); + + match parsed { + OutputContent::FunctionCall(call) => { + assert_eq!(call.name, "lookup_weather"); + assert_eq!(call.call_id, "tool_1"); + } + _ => panic!("expected function_call output content"), + } + } + + /// The `type` field is consumed by the tagged enum during deserialization, + /// so `FunctionToolCall.r#type` is populated by its serde default. + /// Verify it still serializes correctly for use as an API input item. + #[test] + fn function_call_round_trips_with_type_field() { + let payload = json!({ + "type": "function_call", + "id": "tool_1", + "call_id": "tool_1", + "name": "lookup_weather", + "arguments": "{\"city\":\"Lisbon\"}" + }); + + let parsed: OutputContent = serde_json::from_value(payload).expect("should deserialize"); + + let OutputContent::FunctionCall(call) = parsed else { + panic!("expected function_call"); + }; - #[allow(dead_code)] - /// Always `refusal` - #[serde(rename = "type")] - pub r#type: String, + let serialized = serde_json::to_value(&call).expect("should serialize"); + assert_eq!(serialized["type"], "function_call"); + } } diff --git a/src/responses/types.rs b/src/responses/types.rs index eef3c9d..a4449fa 100644 --- a/src/responses/types.rs +++ b/src/responses/types.rs @@ -1,8 +1,15 @@ use serde::{Deserialize, Serialize}; +fn default_function_call_type() -> String { + "function_call".to_string() +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct FunctionToolCall { - #[serde(rename = "type")] + /// Required by the API when serialized as an input item. + /// The `default` is necessary because `OutputContent`'s `#[serde(tag = "type")]` + /// consumes this field during deserialization, so serde never sees it on the struct. + #[serde(rename = "type", default = "default_function_call_type")] pub r#type: String, pub id: String, pub call_id: String, From 72b852fe3f66cdeb25fe0e6c48e9558c980cda44 Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Tue, 24 Mar 2026 15:06:46 +0100 Subject: [PATCH 02/15] Track Rust stable locally and declare MSRV 1.85 --- Cargo.toml | 1 + macros/Cargo.toml | 1 + rust-toolchain.toml | 3 +++ 3 files changed, 5 insertions(+) create mode 100644 rust-toolchain.toml diff --git a/Cargo.toml b/Cargo.toml index d126785..45d46d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ name = "rsai" version = "0.3.0" authors = ["Constantin Luckenbach "] edition = "2024" +rust-version = "1.85" description = "Predictable development for unpredictable Gen-AI models. Let the compiler handle the chaos." homepage = "https://rsai.dev" repository = "https://github.com/caluckenbach/rsai" diff --git a/macros/Cargo.toml b/macros/Cargo.toml index ac05770..1312d3d 100644 --- a/macros/Cargo.toml +++ b/macros/Cargo.toml @@ -3,6 +3,7 @@ name = "rsai-macros" version = "0.2.0" authors = ["Constantin Luckenbach "] edition = "2024" +rust-version = "1.85" description = "Macros for the rsai crate providing structured AI generation capabilities" homepage = "https://rsai.dev" repository = "https://github.com/caluckenbach/rsai" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..73cb934 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] From 71b4b08d751faedeb8ed84b6a17168f13e3c103f Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Tue, 24 Mar 2026 15:19:54 +0100 Subject: [PATCH 03/15] docs: add repository contributor guide --- AGENTS.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fb2fd9a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This is a Rust workspace with the main `rsai` crate at the repository root and a procedural macro crate in `macros/`. Core library code lives in `src/`: `core/` contains builder, trait, error, HTTP, and type abstractions; `provider/` contains OpenAI, OpenRouter, Gemini, and provider constants; `responses/` and `completions/` contain request/response client flows. Integration tests are in `tests/`; macro tests, including `trybuild` UI cases, are in `macros/tests/`. Runnable usage examples are in `examples/`. + +## Build, Test, and Development Commands + +- `cargo build` builds the full workspace. +- `cargo test` runs unit, integration, and macro crate tests. +- `cargo test test_name` runs a focused test by name. +- `cargo run --example function-calling` runs an example; replace the name with any example listed in `Cargo.toml`. +- `cargo fmt` formats Rust code with the toolchain-pinned `rustfmt`. +- `cargo clippy --all --all-features -- -Dwarnings` runs the stricter pre-commit lint pass. + +The repository pins stable Rust with `rust-toolchain.toml` and declares MSRV `1.85`. + +## Coding Style & Naming Conventions + +Use Rust 2024 idioms and standard `rustfmt` formatting. Prefer strong typed APIs, enums, `Option`, and the existing builder and trait patterns over ad hoc configuration. Use `snake_case` for functions, modules, variables, and test names; use `CamelCase` for types, traits, and enum variants. Public APIs should have doc comments. Inline comments should explain only non-obvious behavior. Errors should use the existing `LlmError` style with `thiserror`. + +## Testing Guidelines + +Add or update tests near the affected behavior. Use root-level `tests/*.rs` for public API and provider behavior, and `macros/tests/` for procedural macro behavior. UI compile-fail cases belong in `macros/tests/ui/` with matching `.stderr` files. Before submitting, run `cargo test`, then `cargo clippy --all --all-features -- -Dwarnings`. + +## Commit & Pull Request Guidelines + +Recent history uses concise imperative subjects such as `Track Rust stable locally and declare MSRV 1.85`, with occasional prefixes like `fix:`, `feature:`, and `chore:`. Keep commits focused and describe the user-visible change or maintenance task. Pull requests should include a short summary, tests run, linked issue when applicable, and notes for API or behavior changes. + +## Security & Configuration Tips + +Do not commit API keys, `.env` files, or broad local agent permission files. Keep Claude or Codex local settings narrow; avoid blanket command approvals such as unrestricted `cargo:*`, because builds, tests, examples, build scripts, and proc macros can execute code. From 176b5cde2c08908962b36cdff27e8362a212b96f Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Sun, 26 Apr 2026 13:03:51 +0200 Subject: [PATCH 04/15] chore: restrict Claude local permissions --- .claude/settings.local.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..8a8de0b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,20 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo clippy:*)", + "mcp__git__git_checkout", + "mcp__git__git_log", + "mcp__git__git_diff", + "Bash(cargo check:*)", + "Bash(rg:*)", + "Bash(find:*)", + "Bash(ls:*)", + "mcp__git__git_show", + "mcp__git__git_diff_unstaged", + "WebFetch(domain:platform.openai.com)", + "Bash(grep:*)", + "Bash(gh issue view:*)" + ], + "deny": [] + } +} From ff5847eaab220bc73a57869a5f87786788320338 Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Sun, 26 Apr 2026 13:48:32 +0200 Subject: [PATCH 05/15] perf: reuse pooled HTTP clients Cache reqwest clients by timeout and user agent so repeated high-level completions can reuse connection pools. Trim unused and overly broad dependency features. --- Cargo.toml | 7 ++- macros/Cargo.toml | 1 - src/core/http.rs | 120 +++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 111 insertions(+), 17 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 45d46d9..3cbfece 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,20 +24,19 @@ schemars = "1.0.4" [dependencies] rsai-macros = { path = "macros", version = "0.2.0" } async-trait = "0.1.87" -bytes = "1.10.1" futures = "0.3.31" rand = "0.9.0" -reqwest = { version = "0.12.12", features = ["json", "stream"] } +reqwest = { version = "0.12.12", features = ["json"] } schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = "2.0.12" -tokio = { version = "1.43.0", features = ["full"] } -tokio-stream = "0.1.17" +tokio = { version = "1.43.0", features = ["time"] } tracing = "0.1.41" [dev-dependencies] dotenv = "0.15.0" +tokio = { version = "1.43.0", features = ["macros", "rt-multi-thread", "time"] } tracing-subscriber = { version = "0.3.20", features = ["env-filter", "fmt"] } wiremock = "0.6.5" diff --git a/macros/Cargo.toml b/macros/Cargo.toml index 1312d3d..2c41bbe 100644 --- a/macros/Cargo.toml +++ b/macros/Cargo.toml @@ -18,7 +18,6 @@ proc-macro = true proc-macro2 = "1.0" quote = "1.0" syn = { version = "2.0", features = ["full", "extra-traits"] } -darling = "0.21.3" schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/src/core/http.rs b/src/core/http.rs index ef6958c..87b7a59 100644 --- a/src/core/http.rs +++ b/src/core/http.rs @@ -1,6 +1,10 @@ //! Shared HTTP client with retry logic for all providers. -use std::time::Duration; +use std::{ + collections::HashMap, + sync::{Arc, Mutex, OnceLock}, + time::Duration, +}; use serde::{Serialize, de::DeserializeOwned}; use tracing::{debug, warn}; @@ -32,11 +36,54 @@ impl Default for HttpClientConfig { /// Shared HTTP client with retry logic and exponential backoff. pub struct HttpClient { - client: reqwest::Client, + client: Arc, config: HttpClientConfig, inspector_config: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ClientPoolKey { + timeout: Duration, + user_agent: String, +} + +static CLIENT_POOL: OnceLock>>> = OnceLock::new(); + +fn client_pool() -> &'static Mutex>> { + CLIENT_POOL.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn pooled_reqwest_client( + timeout: Duration, + user_agent: String, +) -> Result, LlmError> { + let key = ClientPoolKey { + timeout, + user_agent, + }; + + let mut clients = client_pool().lock().map_err(|_| { + LlmError::ProviderConfiguration("Failed to access HTTP client pool".to_string()) + })?; + + if let Some(client) = clients.get(&key) { + return Ok(Arc::clone(client)); + } + + let client = Arc::new( + reqwest::Client::builder() + .timeout(key.timeout) + .user_agent(key.user_agent.as_str()) + .build() + .map_err(|e| { + LlmError::ProviderConfiguration(format!("Failed to build reqwest client: {e}")) + })?, + ); + + clients.insert(key, Arc::clone(&client)); + Ok(client) +} + impl HttpClient { /// Create a new HTTP client with the given configuration. pub fn new( @@ -44,16 +91,11 @@ impl HttpClient { user_agent: Option<&str>, inspector_config: Option, ) -> Result { - let default_ua = format!("rsai/{}", env!("CARGO_PKG_VERSION")); - let ua = user_agent.unwrap_or(&default_ua); - - let client = reqwest::Client::builder() - .timeout(config.timeout) - .user_agent(ua) - .build() - .map_err(|e| { - LlmError::ProviderConfiguration(format!("Failed to build reqwest client: {e}")) - })?; + let user_agent = user_agent.map_or_else( + || format!("rsai/{}", env!("CARGO_PKG_VERSION")), + str::to_owned, + ); + let client = pooled_reqwest_client(config.timeout, user_agent)?; Ok(Self { client, @@ -222,3 +264,57 @@ impl HttpClient { })) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn config_with_timeout(timeout: Duration) -> HttpClientConfig { + HttpClientConfig { + timeout, + ..HttpClientConfig::default() + } + } + + #[test] + fn clients_with_same_timeout_and_user_agent_share_pool_entry() { + let config = config_with_timeout(Duration::from_secs(7)); + + let first = HttpClient::new(config.clone(), Some("rsai-test-shared"), None) + .expect("first client should build"); + let second = HttpClient::new(config, Some("rsai-test-shared"), None) + .expect("second client should build"); + + assert!(Arc::ptr_eq(&first.client, &second.client)); + } + + #[test] + fn clients_with_different_timeouts_use_distinct_pool_entries() { + let first = HttpClient::new( + config_with_timeout(Duration::from_secs(11)), + Some("rsai-test-timeout"), + None, + ) + .expect("first client should build"); + let second = HttpClient::new( + config_with_timeout(Duration::from_secs(12)), + Some("rsai-test-timeout"), + None, + ) + .expect("second client should build"); + + assert!(!Arc::ptr_eq(&first.client, &second.client)); + } + + #[test] + fn clients_with_different_user_agents_use_distinct_pool_entries() { + let config = config_with_timeout(Duration::from_secs(13)); + + let first = HttpClient::new(config.clone(), Some("rsai-test-agent-a"), None) + .expect("first client should build"); + let second = HttpClient::new(config, Some("rsai-test-agent-b"), None) + .expect("second client should build"); + + assert!(!Arc::ptr_eq(&first.client, &second.client)); + } +} From 1c26c8c9df78c37486abce7d45c9b457326ff9bf Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Sun, 26 Apr 2026 13:53:33 +0200 Subject: [PATCH 06/15] fix: redact tool arguments from tracing --- src/core/types.rs | 69 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/src/core/types.rs b/src/core/types.rs index e594536..0f08724 100644 --- a/src/core/types.rs +++ b/src/core/types.rs @@ -296,7 +296,7 @@ impl ToolRegistry { err )] pub async fn execute(&self, tool_call: &ToolCall) -> Result { - tracing::trace!(arguments = ?tool_call.arguments, "Executing tool with arguments"); + tracing::trace!("Executing tool"); let tool = { let r_tools = self @@ -477,7 +477,36 @@ impl CompletionTarget for TextResponse { #[cfg(test)] mod tests { use super::*; - use std::sync::Arc; + use std::io::{self, Write}; + use std::sync::{Arc, Mutex}; + use tracing_subscriber::fmt::writer::MakeWriter; + + #[derive(Clone)] + struct CapturedLogs(Arc>>); + + impl<'a> MakeWriter<'a> for CapturedLogs { + type Writer = CapturedLogWriter; + + fn make_writer(&'a self) -> Self::Writer { + CapturedLogWriter(self.0.clone()) + } + } + + struct CapturedLogWriter(Arc>>); + + impl Write for CapturedLogWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0 + .lock() + .expect("captured log lock poisoned") + .extend(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } struct ObjectTool; @@ -532,4 +561,40 @@ mod tests { assert_eq!(result["value"], 42); assert_eq!(result["active"], true); } + + #[tokio::test] + async fn tool_execution_traces_do_not_include_arguments() { + let logs = Arc::new(Mutex::new(Vec::new())); + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::TRACE) + .with_writer(CapturedLogs(logs.clone())) + .with_ansi(false) + .without_time() + .finish(); + let dispatch = tracing::Dispatch::new(subscriber); + let _guard = tracing::dispatcher::set_default(&dispatch); + + let registry = ToolRegistry::new(); + registry + .register(Arc::new(ObjectTool)) + .expect("Failed to register object_tool"); + + let secret = "sk-test-secret-tool-argument"; + let tool_call = ToolCall { + id: "test_Id".to_string(), + call_id: "call_123".to_string(), + name: "object_tool".to_string(), + arguments: serde_json::json!({ "api_key": secret }), + }; + + registry.execute(&tool_call).await.unwrap(); + drop(_guard); + + let logs = String::from_utf8(logs.lock().expect("captured log lock poisoned").clone()) + .expect("captured logs should be valid UTF-8"); + + assert!(logs.contains("Executing tool")); + assert!(!logs.contains(secret)); + assert!(!logs.contains("api_key")); + } } From dc7f5c1e90d6631278dbc202cb06f5f8e879878e Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Sun, 26 Apr 2026 14:04:16 +0200 Subject: [PATCH 07/15] fix: require HTTPS for custom provider base URLs --- src/completions/client.rs | 9 +++++ src/core.rs | 2 +- src/core/http.rs | 79 ++++++++++++++++++++++++++++++++++++++ src/lib.rs | 3 +- src/provider/gemini.rs | 65 +++++++++++++++++++++++++++++-- src/provider/openai.rs | 60 ++++++++++++++++++++++++++++- src/provider/openrouter.rs | 69 ++++++++++++++++++++++++++++++++- src/responses/client.rs | 15 +++++++- tests/client_loop_tests.rs | 2 +- tests/integration_tests.rs | 50 +++++++++++++++++++++++- 10 files changed, 340 insertions(+), 14 deletions(-) diff --git a/src/completions/client.rs b/src/completions/client.rs index b0754e7..8d07d22 100644 --- a/src/completions/client.rs +++ b/src/completions/client.rs @@ -4,10 +4,12 @@ use serde::{Serialize, de::DeserializeOwned}; +pub use crate::core::BaseUrlSecurity; use crate::{ core::{ FunctionCallData, HttpClient, HttpClientConfig, InspectorConfig, LlmError, ProviderResponse, StructuredRequest, ToolCall, ToolCallingGuard, ToolRegistry, + http::validate_provider_base_url, }, responses::Format, }; @@ -64,6 +66,11 @@ pub trait CompletionProviderConfig { /// Get the base URL for the API fn base_url(&self) -> &str; + /// Security policy for custom provider base URLs. + fn base_url_security(&self) -> BaseUrlSecurity { + BaseUrlSecurity::HttpsOnly + } + /// Get the authentication header as (name, value) tuple fn auth_header(&self) -> (String, String); @@ -97,6 +104,8 @@ pub struct CompletionClient { impl CompletionClient

{ /// Create a new completion client with the given configuration. pub fn new(config: P) -> Result { + validate_provider_base_url(config.base_url(), config.base_url_security())?; + let http_config = config.http_config(); let user_agent = config.user_agent(); let inspector_config = config.inspector_config().cloned(); diff --git a/src/core.rs b/src/core.rs index 0c3664e..accc98c 100644 --- a/src/core.rs +++ b/src/core.rs @@ -8,7 +8,7 @@ mod types; pub use builder::{ApiKey, Inspector, InspectorConfig, LlmBuilder, llm}; pub use error::LlmError; -pub use http::{HttpClient, HttpClientConfig}; +pub use http::{BaseUrlSecurity, HttpClient, HttpClientConfig}; pub use tool_guard::{ToolCallingConfig, ToolCallingGuard}; pub use traits::{CompletionTarget, LlmProvider, ToolFunction}; diff --git a/src/core/http.rs b/src/core/http.rs index 87b7a59..148adab 100644 --- a/src/core/http.rs +++ b/src/core/http.rs @@ -12,6 +12,16 @@ use tracing::{debug, warn}; use super::builder::InspectorConfig; use super::error::LlmError; +/// Security policy for provider base URLs. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum BaseUrlSecurity { + /// Require custom provider base URLs to use HTTPS. + #[default] + HttpsOnly, + /// Allow HTTP base URLs for trusted local or proxy endpoints. + AllowInsecureHttp, +} + /// Configuration for HTTP client resilience #[derive(Debug, Clone)] pub struct HttpClientConfig { @@ -34,6 +44,40 @@ impl Default for HttpClientConfig { } } +pub(crate) fn validate_provider_base_url( + base_url: &str, + security: BaseUrlSecurity, +) -> Result<(), LlmError> { + let url = reqwest::Url::parse(base_url).map_err(|e| { + LlmError::ProviderConfiguration(format!("Invalid provider base URL '{base_url}': {e}")) + })?; + + match url.scheme() { + "https" => {} + "http" if security == BaseUrlSecurity::AllowInsecureHttp => {} + "http" => { + return Err(LlmError::ProviderConfiguration( + "Insecure provider base URL rejected: custom base URLs receive provider API keys. \ + Use HTTPS or call with_insecure_base_url for a trusted local/proxy endpoint." + .to_string(), + )); + } + scheme => { + return Err(LlmError::ProviderConfiguration(format!( + "Invalid provider base URL scheme '{scheme}': expected https" + ))); + } + } + + if url.host_str().is_none() { + return Err(LlmError::ProviderConfiguration( + "Invalid provider base URL: expected an absolute URL with a host".to_string(), + )); + } + + Ok(()) +} + /// Shared HTTP client with retry logic and exponential backoff. pub struct HttpClient { client: Arc, @@ -317,4 +361,39 @@ mod tests { assert!(!Arc::ptr_eq(&first.client, &second.client)); } + + #[test] + fn provider_base_url_validation_allows_https() { + validate_provider_base_url("https://api.openai.com/v1", BaseUrlSecurity::HttpsOnly) + .expect("https base URL should be accepted"); + } + + #[test] + fn provider_base_url_validation_rejects_invalid_url() { + assert!(validate_provider_base_url("not a url", BaseUrlSecurity::HttpsOnly).is_err()); + } + + #[test] + fn provider_base_url_validation_rejects_unsupported_scheme() { + assert!( + validate_provider_base_url("ftp://example.com/v1", BaseUrlSecurity::HttpsOnly).is_err() + ); + } + + #[test] + fn provider_base_url_validation_rejects_http_by_default() { + assert!( + validate_provider_base_url("http://localhost:8080/v1", BaseUrlSecurity::HttpsOnly) + .is_err() + ); + } + + #[test] + fn provider_base_url_validation_allows_http_with_explicit_opt_out() { + validate_provider_base_url( + "http://localhost:8080/v1", + BaseUrlSecurity::AllowInsecureHttp, + ) + .expect("explicit insecure opt-out should accept http"); + } } diff --git a/src/lib.rs b/src/lib.rs index ca1cce2..70e3d07 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,7 +63,8 @@ pub use core::{ToolCallingConfig, ToolCallingGuard}; // Configuration types pub use core::{ - ApiKey, GenerationConfig, Inspector, InspectorConfig, LlmBuilder, ToolChoice, ToolConfig, + ApiKey, BaseUrlSecurity, GenerationConfig, Inspector, InspectorConfig, LlmBuilder, ToolChoice, + ToolConfig, }; pub use responses::{Format, HttpClientConfig}; diff --git a/src/provider/gemini.rs b/src/provider/gemini.rs index 422b499..55791d4 100644 --- a/src/provider/gemini.rs +++ b/src/provider/gemini.rs @@ -11,9 +11,9 @@ use crate::completions::{ CompletionClient, CompletionProviderConfig, CompletionRequestBuilder, ConversationItem, }; use crate::core::{ - FunctionCallData, HttpClientConfig, InspectorConfig, LanguageModelUsage, LlmBuilder, LlmError, - LlmProvider, ProviderResponse, ResponseContent, StructuredRequest, ToolCallingConfig, - ToolCallingGuard, ToolRegistry, + BaseUrlSecurity, FunctionCallData, HttpClientConfig, InspectorConfig, LanguageModelUsage, + LlmBuilder, LlmError, LlmProvider, ProviderResponse, ResponseContent, StructuredRequest, + ToolCallingConfig, ToolCallingGuard, ToolRegistry, }; use crate::provider::constants::gemini; use crate::responses::{Format, request::FormatType}; @@ -189,6 +189,7 @@ pub struct UsageMetadata { pub struct GeminiConfig { pub api_key: String, pub base_url: String, + pub base_url_security: BaseUrlSecurity, pub tool_calling_config: Option, pub http_config: HttpClientConfig, /// Configuration for request/response inspection @@ -200,14 +201,36 @@ impl GeminiConfig { Self { api_key, base_url: gemini::API_BASE.to_string(), + base_url_security: BaseUrlSecurity::HttpsOnly, tool_calling_config: Some(ToolCallingConfig::default()), http_config: HttpClientConfig::default(), inspector_config: None, } } + /// Set a custom Gemini-compatible base URL. + /// + /// # Security + /// + /// Requests to this URL include the Gemini API key in the `x-goog-api-key` header. This method + /// requires HTTPS; use [`Self::with_insecure_base_url`] only for trusted local or proxy + /// endpoints that intentionally use HTTP. pub fn with_base_url(mut self, base_url: String) -> Self { self.base_url = base_url; + self.base_url_security = BaseUrlSecurity::HttpsOnly; + self + } + + /// Set a custom Gemini-compatible HTTP base URL. + /// + /// # Security + /// + /// Requests to this URL include the Gemini API key in the `x-goog-api-key` header. Use this + /// only for trusted local or proxy endpoints because the API key may be sent over plaintext + /// HTTP. + pub fn with_insecure_base_url(mut self, base_url: String) -> Self { + self.base_url = base_url; + self.base_url_security = BaseUrlSecurity::AllowInsecureHttp; self } @@ -240,6 +263,10 @@ impl CompletionProviderConfig for GeminiConfig { &self.base_url } + fn base_url_security(&self) -> BaseUrlSecurity { + self.base_url_security + } + fn auth_header(&self) -> (String, String) { ("x-goog-api-key".to_string(), self.api_key.clone()) } @@ -616,11 +643,40 @@ impl GeminiClient { }) } + /// Set a custom Gemini-compatible base URL. + /// + /// # Security + /// + /// Requests to this URL include the Gemini API key in the `x-goog-api-key` header. This method + /// requires HTTPS; use [`Self::with_insecure_base_url`] only for trusted local or proxy + /// endpoints that intentionally use HTTP. pub fn with_base_url(mut self, base_url: String) -> Result { let config = &self.completion_client.config; let new_config = GeminiConfig { api_key: config.api_key.clone(), base_url, + base_url_security: BaseUrlSecurity::HttpsOnly, + tool_calling_config: config.tool_calling_config.clone(), + http_config: config.http_config.clone(), + inspector_config: config.inspector_config.clone(), + }; + self.completion_client = CompletionClient::new(new_config)?; + Ok(self) + } + + /// Set a custom Gemini-compatible HTTP base URL. + /// + /// # Security + /// + /// Requests to this URL include the Gemini API key in the `x-goog-api-key` header. Use this + /// only for trusted local or proxy endpoints because the API key may be sent over plaintext + /// HTTP. + pub fn with_insecure_base_url(mut self, base_url: String) -> Result { + let config = &self.completion_client.config; + let new_config = GeminiConfig { + api_key: config.api_key.clone(), + base_url, + base_url_security: BaseUrlSecurity::AllowInsecureHttp, tool_calling_config: config.tool_calling_config.clone(), http_config: config.http_config.clone(), inspector_config: config.inspector_config.clone(), @@ -637,6 +693,7 @@ impl GeminiClient { let new_config = GeminiConfig { api_key: config.api_key.clone(), base_url: config.base_url.clone(), + base_url_security: config.base_url_security, tool_calling_config: Some(tool_config), http_config: config.http_config.clone(), inspector_config: config.inspector_config.clone(), @@ -650,6 +707,7 @@ impl GeminiClient { let new_config = GeminiConfig { api_key: config.api_key.clone(), base_url: config.base_url.clone(), + base_url_security: config.base_url_security, tool_calling_config: config.tool_calling_config.clone(), http_config, inspector_config: config.inspector_config.clone(), @@ -666,6 +724,7 @@ impl GeminiClient { let new_config = GeminiConfig { api_key: config.api_key.clone(), base_url: config.base_url.clone(), + base_url_security: config.base_url_security, tool_calling_config: config.tool_calling_config.clone(), http_config: config.http_config.clone(), inspector_config: Some(inspector_config), diff --git a/src/provider/openai.rs b/src/provider/openai.rs index 7eb2e42..6a9f122 100644 --- a/src/provider/openai.rs +++ b/src/provider/openai.rs @@ -14,8 +14,8 @@ use crate::provider::constants::openai; use crate::core::{ - InspectorConfig, LlmBuilder, LlmError, LlmProvider, StructuredRequest, ToolCallingConfig, - ToolCallingGuard, ToolRegistry, + BaseUrlSecurity, InspectorConfig, LlmBuilder, LlmError, LlmProvider, StructuredRequest, + ToolCallingConfig, ToolCallingGuard, ToolRegistry, }; use crate::responses::{HttpClientConfig, ResponsesClient, ResponsesProviderConfig}; use async_trait::async_trait; @@ -24,6 +24,7 @@ use async_trait::async_trait; pub struct OpenAiConfig { pub api_key: String, pub base_url: String, + pub base_url_security: BaseUrlSecurity, /// Configuration for tool calling limits pub tool_calling_config: Option, pub http_config: HttpClientConfig, @@ -36,6 +37,7 @@ impl OpenAiConfig { Self { api_key, base_url: openai::API_BASE.to_string(), + base_url_security: BaseUrlSecurity::HttpsOnly, tool_calling_config: Some(ToolCallingConfig::default()), http_config: HttpClientConfig::default(), inspector_config: None, @@ -47,8 +49,28 @@ impl OpenAiConfig { self } + /// Set a custom OpenAI-compatible base URL. + /// + /// # Security + /// + /// Requests to this URL include the OpenAI API key in the `Authorization` header. This method + /// requires HTTPS; use [`Self::with_insecure_base_url`] only for trusted local or proxy + /// endpoints that intentionally use HTTP. pub fn with_base_url(mut self, base_url: String) -> Self { self.base_url = base_url; + self.base_url_security = BaseUrlSecurity::HttpsOnly; + self + } + + /// Set a custom OpenAI-compatible HTTP base URL. + /// + /// # Security + /// + /// Requests to this URL include the OpenAI API key in the `Authorization` header. Use this only + /// for trusted local or proxy endpoints because the API key may be sent over plaintext HTTP. + pub fn with_insecure_base_url(mut self, base_url: String) -> Self { + self.base_url = base_url; + self.base_url_security = BaseUrlSecurity::AllowInsecureHttp; self } @@ -75,6 +97,10 @@ impl ResponsesProviderConfig for OpenAiConfig { &self.base_url } + fn base_url_security(&self) -> BaseUrlSecurity { + self.base_url_security + } + fn endpoint(&self) -> &str { openai::RESPONSES_ENDPOINT } @@ -118,12 +144,40 @@ impl OpenAiClient { }) } + /// Set a custom OpenAI-compatible base URL. + /// + /// # Security + /// + /// Requests to this URL include the OpenAI API key in the `Authorization` header. This method + /// requires HTTPS; use [`Self::with_insecure_base_url`] only for trusted local or proxy + /// endpoints that intentionally use HTTP. pub fn with_base_url(mut self, base_url: String) -> Result { // Create a new config with the updated base_url using the current API key let current_api_key = &self.responses_client.config.api_key; let new_config = OpenAiConfig { api_key: current_api_key.clone(), base_url, + base_url_security: BaseUrlSecurity::HttpsOnly, + tool_calling_config: self.responses_client.config.tool_calling_config.clone(), + http_config: self.responses_client.config.http_config.clone(), + inspector_config: self.responses_client.config.inspector_config.clone(), + }; + self.responses_client = ResponsesClient::new(new_config)?; + Ok(self) + } + + /// Set a custom OpenAI-compatible HTTP base URL. + /// + /// # Security + /// + /// Requests to this URL include the OpenAI API key in the `Authorization` header. Use this only + /// for trusted local or proxy endpoints because the API key may be sent over plaintext HTTP. + pub fn with_insecure_base_url(mut self, base_url: String) -> Result { + let current_api_key = &self.responses_client.config.api_key; + let new_config = OpenAiConfig { + api_key: current_api_key.clone(), + base_url, + base_url_security: BaseUrlSecurity::AllowInsecureHttp, tool_calling_config: self.responses_client.config.tool_calling_config.clone(), http_config: self.responses_client.config.http_config.clone(), inspector_config: self.responses_client.config.inspector_config.clone(), @@ -138,6 +192,7 @@ impl OpenAiClient { let new_config = OpenAiConfig { api_key: current_api_key.clone(), base_url: base_url.clone(), + base_url_security: self.responses_client.config.base_url_security, tool_calling_config: Some(config), http_config: self.responses_client.config.http_config.clone(), inspector_config: self.responses_client.config.inspector_config.clone(), @@ -154,6 +209,7 @@ impl OpenAiClient { let new_config = OpenAiConfig { api_key: current_api_key.clone(), base_url: base_url.clone(), + base_url_security: self.responses_client.config.base_url_security, tool_calling_config: tool_config.clone(), http_config: config, inspector_config: self.responses_client.config.inspector_config.clone(), diff --git a/src/provider/openrouter.rs b/src/provider/openrouter.rs index e869dea..b6c13cd 100644 --- a/src/provider/openrouter.rs +++ b/src/provider/openrouter.rs @@ -15,8 +15,8 @@ use crate::provider::constants::openrouter; use crate::responses::{HttpClientConfig, ResponsesClient, ResponsesProviderConfig}; use crate::core::{ - InspectorConfig, LlmBuilder, LlmError, LlmProvider, StructuredRequest, ToolCallingConfig, - ToolCallingGuard, ToolRegistry, + BaseUrlSecurity, InspectorConfig, LlmBuilder, LlmError, LlmProvider, StructuredRequest, + ToolCallingConfig, ToolCallingGuard, ToolRegistry, }; use async_trait::async_trait; @@ -24,6 +24,7 @@ use async_trait::async_trait; pub struct OpenRouterConfig { pub api_key: String, pub base_url: String, + pub base_url_security: BaseUrlSecurity, pub http_referer: Option, pub x_title: Option, pub http_config: HttpClientConfig, @@ -38,6 +39,7 @@ impl OpenRouterConfig { Self { api_key, base_url: openrouter::API_BASE.to_string(), + base_url_security: BaseUrlSecurity::HttpsOnly, http_referer: None, x_title: None, tool_calling_config: Some(ToolCallingConfig::default()), @@ -46,8 +48,29 @@ impl OpenRouterConfig { } } + /// Set a custom OpenRouter-compatible base URL. + /// + /// # Security + /// + /// Requests to this URL include the OpenRouter API key in the `Authorization` header. This + /// method requires HTTPS; use [`Self::with_insecure_base_url`] only for trusted local or proxy + /// endpoints that intentionally use HTTP. pub fn with_base_url(mut self, base_url: String) -> Self { self.base_url = base_url; + self.base_url_security = BaseUrlSecurity::HttpsOnly; + self + } + + /// Set a custom OpenRouter-compatible HTTP base URL. + /// + /// # Security + /// + /// Requests to this URL include the OpenRouter API key in the `Authorization` header. Use this + /// only for trusted local or proxy endpoints because the API key may be sent over plaintext + /// HTTP. + pub fn with_insecure_base_url(mut self, base_url: String) -> Self { + self.base_url = base_url; + self.base_url_security = BaseUrlSecurity::AllowInsecureHttp; self } @@ -90,6 +113,10 @@ impl ResponsesProviderConfig for OpenRouterConfig { &self.base_url } + fn base_url_security(&self) -> BaseUrlSecurity { + self.base_url_security + } + fn endpoint(&self) -> &str { openrouter::RESPONSES_ENDPOINT } @@ -147,6 +174,13 @@ impl OpenRouterClient { }) } + /// Set a custom OpenRouter-compatible base URL. + /// + /// # Security + /// + /// Requests to this URL include the OpenRouter API key in the `Authorization` header. This + /// method requires HTTPS; use [`Self::with_insecure_base_url`] only for trusted local or proxy + /// endpoints that intentionally use HTTP. pub fn with_base_url(mut self, base_url: String) -> Result { // Create a new config with the updated base_url using the current API key let current_api_key = &self.responses_client.config.api_key; @@ -158,6 +192,35 @@ impl OpenRouterClient { let new_config = OpenRouterConfig { api_key: current_api_key.clone(), base_url, + base_url_security: BaseUrlSecurity::HttpsOnly, + http_referer, + x_title, + tool_calling_config: self.responses_client.config.tool_calling_config.clone(), + http_config, + inspector_config, + }; + self.responses_client = ResponsesClient::new(new_config)?; + Ok(self) + } + + /// Set a custom OpenRouter-compatible HTTP base URL. + /// + /// # Security + /// + /// Requests to this URL include the OpenRouter API key in the `Authorization` header. Use this + /// only for trusted local or proxy endpoints because the API key may be sent over plaintext + /// HTTP. + pub fn with_insecure_base_url(mut self, base_url: String) -> Result { + let current_api_key = &self.responses_client.config.api_key; + let http_referer = self.responses_client.config.http_referer.clone(); + let x_title = self.responses_client.config.x_title.clone(); + let http_config = self.responses_client.config.http_config.clone(); + let inspector_config = self.responses_client.config.inspector_config.clone(); + + let new_config = OpenRouterConfig { + api_key: current_api_key.clone(), + base_url, + base_url_security: BaseUrlSecurity::AllowInsecureHttp, http_referer, x_title, tool_calling_config: self.responses_client.config.tool_calling_config.clone(), @@ -189,6 +252,7 @@ impl OpenRouterClient { let new_config = OpenRouterConfig { api_key: current_api_key.clone(), base_url: base_url.clone(), + base_url_security: self.responses_client.config.base_url_security, http_referer, x_title, tool_calling_config: Some(config), @@ -204,6 +268,7 @@ impl OpenRouterClient { let new_config = OpenRouterConfig { api_key: current_config.api_key.clone(), base_url: current_config.base_url.clone(), + base_url_security: current_config.base_url_security, http_referer: current_config.http_referer.clone(), x_title: current_config.x_title.clone(), tool_calling_config: current_config.tool_calling_config.clone(), diff --git a/src/responses/client.rs b/src/responses/client.rs index e3d5a77..8329579 100644 --- a/src/responses/client.rs +++ b/src/responses/client.rs @@ -11,7 +11,7 @@ use crate::{ CompletionTarget, Provider, core::{ ChatRole, ConversationMessage, HttpClient, InspectorConfig, LlmError, StructuredRequest, - Tool, ToolCall, ToolCallingGuard, ToolRegistry, + Tool, ToolCall, ToolCallingGuard, ToolRegistry, http::validate_provider_base_url, }, responses::{ Format, FormatType, FunctionToolCall, FunctionToolCallOutput, JsonSchema, JsonSchemaType, @@ -24,7 +24,7 @@ use schemars::schema_for; use tracing; // Re-export HttpClientConfig from core for backwards compatibility -pub use crate::core::HttpClientConfig; +pub use crate::core::{BaseUrlSecurity, HttpClientConfig}; /// Configuration trait for providers that use the OpenAI-style responses API pub trait ResponsesProviderConfig { @@ -34,6 +34,11 @@ pub trait ResponsesProviderConfig { /// Base URL for the API (e.g., `https://api.openai.com`) fn base_url(&self) -> &str; + /// Security policy for custom provider base URLs. + fn base_url_security(&self) -> BaseUrlSecurity { + BaseUrlSecurity::HttpsOnly + } + /// API endpoint for responses (e.g., `/v1/responses`) fn endpoint(&self) -> &str; @@ -69,6 +74,8 @@ pub struct ResponsesClient { impl ResponsesClient

{ /// Create a new responses client with the given configuration pub fn new(config: P) -> Result { + validate_provider_base_url(config.base_url(), config.base_url_security())?; + let http_config = config.http_config(); let user_agent = config.user_agent(); let inspector_config = config.inspector_config().cloned(); @@ -642,6 +649,10 @@ mod tests { &self.base_url } + fn base_url_security(&self) -> BaseUrlSecurity { + BaseUrlSecurity::AllowInsecureHttp + } + fn endpoint(&self) -> &str { "/responses" } diff --git a/tests/client_loop_tests.rs b/tests/client_loop_tests.rs index 7d0ec78..0c52c77 100644 --- a/tests/client_loop_tests.rs +++ b/tests/client_loop_tests.rs @@ -262,7 +262,7 @@ fn client_for(server: &MockServer, config: Option) -> OpenAiC let base_url = format!("{}/v1", server.uri()); let client = OpenAiClient::new("test-key".to_string()) .unwrap() - .with_base_url(base_url) + .with_insecure_base_url(base_url) .unwrap(); if let Some(cfg) = config { diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 74a1a63..c6b3de3 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1,6 +1,7 @@ use rsai::{ - LlmError, OpenAiConfig, OpenRouterConfig, ToolCall, ToolCallingConfig, ToolChoice, ToolConfig, - ToolRegistry, completion_schema, tool, toolset, + GeminiClient, LlmError, OpenAiClient, OpenAiConfig, OpenRouterClient, OpenRouterConfig, + ToolCall, ToolCallingConfig, ToolChoice, ToolConfig, ToolRegistry, completion_schema, tool, + toolset, }; use serde_json::json; use std::sync::Arc; @@ -308,3 +309,48 @@ fn provider_configs_share_default_tool_calling_limits() { assert_eq!(custom_router_guard.max_iterations, 10); assert_eq!(custom_router_guard.timeout, Duration::from_secs(60)); } + +#[test] +fn provider_clients_reject_http_base_urls_by_default() { + assert_insecure_base_url_error( + OpenAiClient::new("test-key".to_string()) + .expect("default OpenAI client") + .with_base_url("http://localhost:8080/v1".to_string()), + ); + assert_insecure_base_url_error( + OpenRouterClient::new("test-key".to_string()) + .expect("default OpenRouter client") + .with_base_url("http://localhost:8080/api/v1".to_string()), + ); + assert_insecure_base_url_error( + GeminiClient::new("test-key".to_string()) + .expect("default Gemini client") + .with_base_url("http://localhost:8080/v1beta".to_string()), + ); +} + +#[test] +fn provider_clients_allow_http_base_urls_with_explicit_opt_out() { + OpenAiClient::new("test-key".to_string()) + .expect("default OpenAI client") + .with_insecure_base_url("http://localhost:8080/v1".to_string()) + .expect("explicit OpenAI HTTP opt-out"); + OpenRouterClient::new("test-key".to_string()) + .expect("default OpenRouter client") + .with_insecure_base_url("http://localhost:8080/api/v1".to_string()) + .expect("explicit OpenRouter HTTP opt-out"); + GeminiClient::new("test-key".to_string()) + .expect("default Gemini client") + .with_insecure_base_url("http://localhost:8080/v1beta".to_string()) + .expect("explicit Gemini HTTP opt-out"); +} + +fn assert_insecure_base_url_error(result: Result) { + match result { + Err(LlmError::ProviderConfiguration(message)) => { + assert!(message.contains("Insecure provider base URL rejected")); + } + Err(other) => panic!("expected insecure base URL configuration error, got {other:?}"), + Ok(_) => panic!("expected insecure base URL configuration error"), + } +} From 206a15f5f03ad262581def22a7f3c7b557f52f9b Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Sun, 26 Apr 2026 14:28:50 +0200 Subject: [PATCH 08/15] chore: limit automatic tool execution --- .github/workflows/rust-ci.yml | 2 +- src/completions/client.rs | 23 ++- src/core/builder.rs | 56 ++++++- src/core/error.rs | 9 ++ src/core/tool_guard.rs | 119 +++++++++++++-- src/provider/gemini.rs | 6 +- src/provider/openai.rs | 4 + src/provider/openrouter.rs | 6 +- src/responses/client.rs | 66 +++++++-- src/responses/request.rs | 4 - tests/client_loop_tests.rs | 203 +++++++++++++++++++++++++- tests/integration_tests.rs | 23 ++- tests/tool_calling_safeguards_test.rs | 28 +++- 13 files changed, 502 insertions(+), 47 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index b33a796..353f030 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -28,7 +28,7 @@ jobs: run: cargo fmt --all -- --check - name: Run Clippy lints - run: cargo clippy --all-targets --all-features + run: cargo clippy --workspace --all-targets --all-features test: name: Test diff --git a/src/completions/client.rs b/src/completions/client.rs index 8d07d22..230a1f8 100644 --- a/src/completions/client.rs +++ b/src/completions/client.rs @@ -196,6 +196,7 @@ impl CompletionClient

{ if let Some(calls) = function_calls.filter(|c| !c.is_empty()) { tracing::info!(count = calls.len(), "Model requested tool execution"); + guard.check_tool_calls_for_turn(calls.len())?; for call in &calls { // Add function call to conversation @@ -212,7 +213,9 @@ impl CompletionClient

{ name: call.name.clone(), arguments: call.arguments.clone(), }; - let result = tool_registry.execute(&tool_call).await?; + let result = self + .execute_tool_with_timeout(tool_registry, &tool_call, guard.tool_timeout) + .await?; // Add result to conversation conversation.push(ConversationItem::FunctionResult { @@ -231,6 +234,24 @@ impl CompletionClient

{ } } } + + async fn execute_tool_with_timeout( + &self, + tool_registry: &ToolRegistry, + tool_call: &ToolCall, + timeout: std::time::Duration, + ) -> Result + where + Ctx: Send + Sync + 'static, + { + match tokio::time::timeout(timeout, tool_registry.execute(tool_call)).await { + Ok(result) => result, + Err(_) => Err(LlmError::ToolExecutionTimeout { + tool_name: tool_call.name.clone(), + timeout, + }), + } + } } /// Convert core messages to conversation items. diff --git a/src/core/builder.rs b/src/core/builder.rs index ec85e54..1ccc672 100644 --- a/src/core/builder.rs +++ b/src/core/builder.rs @@ -21,6 +21,7 @@ use crate::{ use super::{ error::LlmError, + tool_guard::ToolCallingConfig, traits::LlmProvider, types::{ ConversationMessage, GenerationConfig, Message, StructuredRequest, ToolChoice, ToolConfig, @@ -56,6 +57,7 @@ struct BuilderFields { // Tool configuration tool_choice: Option, parallel_tool_calls: Option, + tool_calling_config: Option, tool_registry: Option>, // Generation parameters @@ -76,6 +78,7 @@ impl BuilderFields<()> { messages: None, tool_choice: None, parallel_tool_calls: None, + tool_calling_config: None, tool_registry: None, max_tokens: None, temperature: None, @@ -100,6 +103,7 @@ impl BuilderFields { messages: self.messages, tool_choice: self.tool_choice, parallel_tool_calls: self.parallel_tool_calls, + tool_calling_config: self.tool_calling_config, tool_registry, max_tokens: self.max_tokens, temperature: self.temperature, @@ -165,6 +169,10 @@ impl LlmBuilder { pub(crate) fn get_inspector_config(&self) -> Option<&InspectorConfig> { self.fields.inspector_config.as_ref() } + + pub(crate) fn get_tool_calling_config(&self) -> Option<&ToolCallingConfig> { + self.fields.tool_calling_config.as_ref() + } } /// Configuration for API key source @@ -448,7 +456,7 @@ impl LlmBuilder { /// Set the tools for the LLM request with automatic execution support. - /// This transitions to the ToolsSet state where tool_choice and parallel_tool_calls can be configured. + /// This transitions to the ToolsSet state where tool behavior can be configured. /// /// By default parallel tool calling is enabled. This can be changed by calling `parallel_tool_calls` with `false`. pub fn tools( @@ -476,6 +484,12 @@ impl LlmBuilder { self.fields.parallel_tool_calls = Some(enabled); self } + + /// Set local safety limits for automatic tool execution. + pub fn tool_calling_config(mut self, config: ToolCallingConfig) -> Self { + self.fields.tool_calling_config = Some(config); + self + } } /// Module containing the main entry point for building LLM requests @@ -510,6 +524,7 @@ pub mod llm { mod tests { use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; #[test] fn test_inspect_request_is_chainable() { @@ -584,6 +599,45 @@ mod tests { assert!(config.response_inspector.is_some()); } + #[test] + fn test_tool_calling_config_is_chainable_after_tools() { + let toolset = super::super::types::ToolSet { + registry: ToolRegistry::new(), + }; + let config = ToolCallingConfig::new(2, Duration::from_secs(10)) + .with_max_tool_calls_per_turn(3) + .with_max_concurrent_tool_calls(2) + .with_tool_timeout(Duration::from_secs(1)); + + let builder = llm::with(Provider::OpenAI) + .api_key(ApiKey::Custom("test".into())) + .unwrap() + .model("gpt-4o-mini") + .messages(vec![Message { + role: super::super::types::ChatRole::User, + content: "test".to_string(), + }]) + .tools(toolset) + .tool_calling_config(config.clone()); + + let stored = builder + .fields + .tool_calling_config + .as_ref() + .expect("tool calling config"); + assert_eq!(stored.max_iterations, config.max_iterations); + assert_eq!(stored.timeout, config.timeout); + assert_eq!( + stored.max_tool_calls_per_turn, + config.max_tool_calls_per_turn + ); + assert_eq!( + stored.max_concurrent_tool_calls, + config.max_concurrent_tool_calls + ); + assert_eq!(stored.tool_timeout, config.tool_timeout); + } + #[test] fn test_inspector_config_is_cloneable() { let config = InspectorConfig { diff --git a/src/core/error.rs b/src/core/error.rs index ac9a77f..e8de797 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -53,9 +53,18 @@ pub enum LlmError { #[error("Tool call iteration limit exceeded: {limit} iterations")] ToolCallIterationLimit { limit: u32 }, + #[error("Tool call limit exceeded: requested {requested} calls, limit is {limit}")] + ToolCallLimit { requested: usize, limit: usize }, + #[error("Tool call processing timeout exceeded: {timeout:?}")] ToolCallTimeout { timeout: std::time::Duration }, + #[error("Tool execution timeout exceeded for {tool_name}: {timeout:?}")] + ToolExecutionTimeout { + tool_name: String, + timeout: std::time::Duration, + }, + #[error("Tool registration failed for {tool_name}: {message}")] ToolRegistration { tool_name: String, message: String }, } diff --git a/src/core/tool_guard.rs b/src/core/tool_guard.rs index d969356..6fc5ab2 100644 --- a/src/core/tool_guard.rs +++ b/src/core/tool_guard.rs @@ -8,6 +8,12 @@ pub struct ToolCallingConfig { pub max_iterations: u32, /// Timeout for tool calling loop (default: 5 minutes) pub timeout: Duration, + /// Maximum number of tool calls accepted from one model response (default: 8) + pub max_tool_calls_per_turn: usize, + /// Maximum number of tools executed at the same time (default: 4) + pub max_concurrent_tool_calls: usize, + /// Timeout for each individual tool execution (default: 30 seconds) + pub tool_timeout: Duration, } impl Default for ToolCallingConfig { @@ -15,6 +21,9 @@ impl Default for ToolCallingConfig { Self { max_iterations: 50, timeout: Duration::from_secs(300), + max_tool_calls_per_turn: 8, + max_concurrent_tool_calls: 4, + tool_timeout: Duration::from_secs(30), } } } @@ -25,8 +34,29 @@ impl ToolCallingConfig { Self { max_iterations, timeout, + ..Self::default() } } + + /// Set the maximum number of tool calls accepted from one model response. + pub fn with_max_tool_calls_per_turn(mut self, max_tool_calls: usize) -> Self { + self.max_tool_calls_per_turn = max_tool_calls; + self + } + + /// Set the maximum number of tools executed at the same time. + /// + /// A value of `0` is treated as `1` when executing tools. + pub fn with_max_concurrent_tool_calls(mut self, max_concurrent_tool_calls: usize) -> Self { + self.max_concurrent_tool_calls = max_concurrent_tool_calls; + self + } + + /// Set the timeout for each individual tool execution. + pub fn with_tool_timeout(mut self, timeout: Duration) -> Self { + self.tool_timeout = timeout; + self + } } /// Guard for tracking tool call processing limits and preventing infinite loops @@ -36,6 +66,12 @@ pub struct ToolCallingGuard { pub max_iterations: u32, /// Timeout duration for the entire tool calling loop pub timeout: Duration, + /// Maximum number of tool calls accepted from one model response + pub max_tool_calls_per_turn: usize, + /// Maximum number of tools executed at the same time + pub max_concurrent_tool_calls: usize, + /// Timeout duration for each individual tool execution + pub tool_timeout: Duration, /// Current iteration count current_iteration: u32, } @@ -43,25 +79,24 @@ pub struct ToolCallingGuard { impl ToolCallingGuard { /// Create a new ToolCallingGuard with default limits pub fn new() -> Self { - Self { - max_iterations: 50, - timeout: Duration::from_secs(300), // 5 minutes default - current_iteration: 0, - } + Self::from_config(&ToolCallingConfig::default()) } /// Create a new ToolCallingGuard with custom limits pub fn with_limits(max_iterations: u32, timeout: Duration) -> Self { - Self { - max_iterations, - timeout, - current_iteration: 0, - } + Self::from_config(&ToolCallingConfig::new(max_iterations, timeout)) } /// Create a new ToolCallingGuard from a config pub fn from_config(config: &ToolCallingConfig) -> Self { - Self::with_limits(config.max_iterations, config.timeout) + Self { + max_iterations: config.max_iterations, + timeout: config.timeout, + max_tool_calls_per_turn: config.max_tool_calls_per_turn, + max_concurrent_tool_calls: config.max_concurrent_tool_calls, + tool_timeout: config.tool_timeout, + current_iteration: 0, + } } /// Increment iteration count and check if limit is exceeded @@ -75,6 +110,22 @@ impl ToolCallingGuard { Ok(()) } + /// Check if the current model turn requested too many tool calls. + pub fn check_tool_calls_for_turn(&self, requested: usize) -> Result<(), LlmError> { + if requested > self.max_tool_calls_per_turn { + return Err(LlmError::ToolCallLimit { + requested, + limit: self.max_tool_calls_per_turn, + }); + } + Ok(()) + } + + /// Get the effective concurrency limit for tool execution. + pub fn max_concurrent_tool_calls(&self) -> usize { + self.max_concurrent_tool_calls.max(1) + } + /// Get current iteration count pub fn current_iteration(&self) -> u32 { self.current_iteration @@ -96,6 +147,9 @@ mod tests { let guard = ToolCallingGuard::default(); assert_eq!(guard.max_iterations, 50); assert_eq!(guard.timeout, Duration::from_secs(300)); + assert_eq!(guard.max_tool_calls_per_turn, 8); + assert_eq!(guard.max_concurrent_tool_calls, 4); + assert_eq!(guard.tool_timeout, Duration::from_secs(30)); assert_eq!(guard.current_iteration(), 0); } @@ -104,6 +158,9 @@ mod tests { let guard = ToolCallingGuard::with_limits(100, Duration::from_secs(600)); assert_eq!(guard.max_iterations, 100); assert_eq!(guard.timeout, Duration::from_secs(600)); + assert_eq!(guard.max_tool_calls_per_turn, 8); + assert_eq!(guard.max_concurrent_tool_calls, 4); + assert_eq!(guard.tool_timeout, Duration::from_secs(30)); } #[test] @@ -128,15 +185,53 @@ mod tests { let config = ToolCallingConfig::default(); assert_eq!(config.max_iterations, 50); assert_eq!(config.timeout, Duration::from_secs(300)); + assert_eq!(config.max_tool_calls_per_turn, 8); + assert_eq!(config.max_concurrent_tool_calls, 4); + assert_eq!(config.tool_timeout, Duration::from_secs(30)); } #[test] fn test_tool_calling_guard_from_config() { - let config = ToolCallingConfig::new(75, Duration::from_secs(450)); + let config = ToolCallingConfig::new(75, Duration::from_secs(450)) + .with_max_tool_calls_per_turn(3) + .with_max_concurrent_tool_calls(2) + .with_tool_timeout(Duration::from_secs(10)); let guard = ToolCallingGuard::from_config(&config); assert_eq!(guard.max_iterations, 75); assert_eq!(guard.timeout, Duration::from_secs(450)); + assert_eq!(guard.max_tool_calls_per_turn, 3); + assert_eq!(guard.max_concurrent_tool_calls, 2); + assert_eq!(guard.tool_timeout, Duration::from_secs(10)); assert_eq!(guard.current_iteration(), 0); } + + #[test] + fn test_tool_calling_guard_checks_calls_per_turn() { + let guard = ToolCallingGuard::from_config( + &ToolCallingConfig::default().with_max_tool_calls_per_turn(2), + ); + + assert!(guard.check_tool_calls_for_turn(2).is_ok()); + let err = guard + .check_tool_calls_for_turn(3) + .expect_err("too many calls should fail"); + + match err { + LlmError::ToolCallLimit { requested, limit } => { + assert_eq!(requested, 3); + assert_eq!(limit, 2); + } + other => panic!("expected ToolCallLimit, got {other:?}"), + } + } + + #[test] + fn test_zero_concurrency_limit_runs_as_one() { + let guard = ToolCallingGuard::from_config( + &ToolCallingConfig::default().with_max_concurrent_tool_calls(0), + ); + + assert_eq!(guard.max_concurrent_tool_calls(), 1); + } } diff --git a/src/provider/gemini.rs b/src/provider/gemini.rs index 55791d4..e2ccfd4 100644 --- a/src/provider/gemini.rs +++ b/src/provider/gemini.rs @@ -251,7 +251,7 @@ impl GeminiConfig { pub fn get_tool_calling_guard(&self) -> ToolCallingGuard { if let Some(ref config) = self.tool_calling_config { - ToolCallingGuard::with_limits(config.max_iterations, config.timeout) + ToolCallingGuard::from_config(config) } else { ToolCallingGuard::new() } @@ -801,6 +801,10 @@ pub fn create_gemini_client_from_builder( client = client.with_http_config(http_config.clone())?; } + if let Some(tool_calling_config) = builder.get_tool_calling_config() { + client = client.with_tool_calling_config(tool_calling_config.clone())?; + } + if let Some(inspector_config) = builder.get_inspector_config() { client = client.with_inspector_config(inspector_config.clone())?; } diff --git a/src/provider/openai.rs b/src/provider/openai.rs index 6a9f122..ce4f445 100644 --- a/src/provider/openai.rs +++ b/src/provider/openai.rs @@ -252,6 +252,10 @@ pub fn create_openai_client_from_builder( config = config.with_http_config(http_config.clone()); } + if let Some(tool_calling_config) = builder.get_tool_calling_config() { + config = config.with_tool_calling_config(tool_calling_config.clone()); + } + if let Some(inspector_config) = builder.get_inspector_config() { config = config.with_inspector_config(inspector_config.clone()); } diff --git a/src/provider/openrouter.rs b/src/provider/openrouter.rs index b6c13cd..d7b8e00 100644 --- a/src/provider/openrouter.rs +++ b/src/provider/openrouter.rs @@ -101,7 +101,7 @@ impl OpenRouterConfig { pub fn get_tool_calling_guard(&self) -> ToolCallingGuard { if let Some(ref config) = self.tool_calling_config { - ToolCallingGuard::with_limits(config.max_iterations, config.timeout) + ToolCallingGuard::from_config(config) } else { ToolCallingGuard::new() } @@ -313,6 +313,10 @@ pub fn create_openrouter_client_from_builder( config = config.with_http_config(http_config.clone()); } + if let Some(tool_calling_config) = builder.get_tool_calling_config() { + config = config.with_tool_calling_config(tool_calling_config.clone()); + } + if let Some(inspector_config) = builder.get_inspector_config() { config = config.with_inspector_config(inspector_config.clone()); } diff --git a/src/responses/client.rs b/src/responses/client.rs index 8329579..c3c7975 100644 --- a/src/responses/client.rs +++ b/src/responses/client.rs @@ -20,6 +20,7 @@ use crate::{ response::{MessageContent, OutputContent, Response}, }, }; +use futures::{StreamExt, TryStreamExt, stream}; use schemars::schema_for; use tracing; @@ -186,11 +187,14 @@ impl ResponsesClient

{ "Model requested tool execution" ); + guard.check_tool_calls_for_turn(function_calls.len())?; + self.process_function_calls( &function_calls, &mut responses_input, tool_registry, is_parallel, + guard, ) .await?; } @@ -228,16 +232,27 @@ impl ResponsesClient

{ responses_input: &mut Vec, tool_registry: &ToolRegistry, is_parallel: bool, + guard: &ToolCallingGuard, ) -> Result<(), LlmError> where Ctx: Send + Sync + 'static, { if is_parallel && function_calls.len() > 1 { - self.process_parallel_function_calls(function_calls, responses_input, tool_registry) - .await + self.process_parallel_function_calls( + function_calls, + responses_input, + tool_registry, + guard, + ) + .await } else { - self.process_sequential_function_calls(function_calls, responses_input, tool_registry) - .await + self.process_sequential_function_calls( + function_calls, + responses_input, + tool_registry, + guard, + ) + .await } } @@ -249,6 +264,7 @@ impl ResponsesClient

{ function_calls: &[&FunctionToolCall], responses_input: &mut Vec, tool_registry: &ToolRegistry, + guard: &ToolCallingGuard, ) -> Result<(), LlmError> where Ctx: Send + Sync + 'static, @@ -267,12 +283,17 @@ impl ResponsesClient

{ }); } - // Execute all tools concurrently - let futures: Vec<_> = tool_calls - .iter() - .map(|tc| tool_registry.execute(tc)) - .collect(); - let results = futures::future::try_join_all(futures).await?; + // Execute tools with bounded concurrency, preserving result order. + let tool_timeout = guard.tool_timeout; + let max_concurrent_tool_calls = guard.max_concurrent_tool_calls(); + let results: Vec = stream::iter(tool_calls.iter().cloned()) + .map(|tool_call| async move { + self.execute_tool_with_timeout(tool_registry, &tool_call, tool_timeout) + .await + }) + .buffered(max_concurrent_tool_calls) + .try_collect() + .await?; // Add all results in order for (tool_call, result) in tool_calls.iter().zip(results) { @@ -292,6 +313,7 @@ impl ResponsesClient

{ function_calls: &[&FunctionToolCall], responses_input: &mut Vec, tool_registry: &ToolRegistry, + guard: &ToolCallingGuard, ) -> Result<(), LlmError> where Ctx: Send + Sync + 'static, @@ -307,7 +329,9 @@ impl ResponsesClient

{ arguments, }; - let result = tool_registry.execute(&tool_call).await?; + let result = self + .execute_tool_with_timeout(tool_registry, &tool_call, guard.tool_timeout) + .await?; responses_input.push(InputItem::FunctionCallOutput(FunctionToolCallOutput { call_id: function_call.call_id.clone(), @@ -319,6 +343,24 @@ impl ResponsesClient

{ Ok(()) } + async fn execute_tool_with_timeout( + &self, + tool_registry: &ToolRegistry, + tool_call: &ToolCall, + timeout: std::time::Duration, + ) -> Result + where + Ctx: Send + Sync + 'static, + { + match tokio::time::timeout(timeout, tool_registry.execute(tool_call)).await { + Ok(result) => result, + Err(_) => Err(LlmError::ToolExecutionTimeout { + tool_name: tool_call.name.clone(), + timeout, + }), + } + } + /// Shared generate_completion for responses-API providers (OpenAI, OpenRouter). /// /// Handles the tool calling loop when tools are present, or makes a single @@ -386,7 +428,6 @@ pub(crate) fn build_request_payload_with_format( tool_choice: None, instructions: None, max_output_tokens: None, - max_tool_calls: None, store: None, top_logprobs: None, top_p: None, @@ -689,7 +730,6 @@ mod tests { tool_choice: None, instructions: None, max_output_tokens: None, - max_tool_calls: None, store: None, top_logprobs: None, top_p: None, diff --git a/src/responses/request.rs b/src/responses/request.rs index e9e3919..13535f6 100644 --- a/src/responses/request.rs +++ b/src/responses/request.rs @@ -18,10 +18,6 @@ pub struct Request { #[serde(skip_serializing_if = "Option::is_none")] pub max_output_tokens: Option, - /// Maximum number of total calls to built-in tools - #[serde(skip_serializing_if = "Option::is_none")] - pub max_tool_calls: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub store: Option, diff --git a/tests/client_loop_tests.rs b/tests/client_loop_tests.rs index 0c52c77..a495d17 100644 --- a/tests/client_loop_tests.rs +++ b/tests/client_loop_tests.rs @@ -1,9 +1,15 @@ -use std::time::Duration; +use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; use rsai::{ - ChatRole, CompletionTarget, ConversationMessage, LlmError, LlmProvider, Message, OpenAiClient, - StructuredRequest, ToolCallingConfig, ToolChoice, ToolConfig, ToolSet, completion_schema, tool, - toolset, + BoxFuture, ChatRole, CompletionTarget, ConversationMessage, LlmError, LlmProvider, Message, + OpenAiClient, StructuredRequest, Tool, ToolCallingConfig, ToolChoice, ToolConfig, ToolFunction, + ToolRegistry, ToolSet, completion_schema, tool, toolset, }; use serde_json::{Value, json}; use wiremock::{ @@ -22,6 +28,45 @@ struct MultiplyResponse { product: i64, } +struct TrackedTool { + executions: Arc, + active: Arc, + max_active: Arc, + delay: Duration, +} + +impl ToolFunction for TrackedTool { + fn schema(&self) -> Tool { + Tool { + name: "tracked_tool".to_string(), + description: Some("Track execution limits".to_string()), + parameters: json!({ + "type": "object", + "properties": { + "value": { "type": "integer" } + }, + "required": ["value"] + }), + strict: Some(true), + } + } + + fn execute<'a>( + &'a self, + _ctx: &'a (), + params: Value, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.executions.fetch_add(1, Ordering::SeqCst); + let active = self.active.fetch_add(1, Ordering::SeqCst) + 1; + self.max_active.fetch_max(active, Ordering::SeqCst); + tokio::time::sleep(self.delay).await; + self.active.fetch_sub(1, Ordering::SeqCst); + Ok(json!({ "ok": true, "value": params["value"].clone() })) + }) + } +} + #[tool] /// Add two integers and return the sum. /// a: First addend. @@ -185,6 +230,130 @@ async fn parallel_tool_calls_submit_all_results_together() { assert_eq!(second_input[4]["output"]["product"], 6); } +#[tokio::test] +async fn guard_rejects_too_many_tool_calls_before_execution() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(tool_call_response(vec![ + function_call("call_one", "tracked_tool", json!({ "value": 1 })), + function_call("call_two", "tracked_tool", json!({ "value": 2 })), + ])) + .mount(&server) + .await; + + let (registry, executions, _) = tracked_tool_registry(Duration::ZERO); + let tool_config = tool_config_for_registry(®istry, Some(true)); + let request = build_request("call twice", tool_config); + + let guard_config = + ToolCallingConfig::new(3, Duration::from_secs(5)).with_max_tool_calls_per_turn(1); + let client = client_for(&server, Some(guard_config)); + let err = client + .generate_completion::( + request, + ::format().expect("format"), + Some(®istry), + ) + .await + .expect_err("tool call limit should trip"); + + match err { + LlmError::ToolCallLimit { requested, limit } => { + assert_eq!(requested, 2); + assert_eq!(limit, 1); + } + other => panic!("expected ToolCallLimit, got {other:?}"), + } + assert_eq!(executions.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn parallel_tool_calls_respect_concurrency_limit() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/responses")) + .and(BodyNotContains("function_call_output")) + .respond_with(tool_call_response(vec![ + function_call("call_one", "tracked_tool", json!({ "value": 1 })), + function_call("call_two", "tracked_tool", json!({ "value": 2 })), + function_call("call_three", "tracked_tool", json!({ "value": 3 })), + function_call("call_four", "tracked_tool", json!({ "value": 4 })), + ])) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/v1/responses")) + .and(BodyContains("function_call_output")) + .respond_with(final_response(json!({ "sum": 10 }))) + .mount(&server) + .await; + + let (registry, executions, max_active) = tracked_tool_registry(Duration::from_millis(50)); + let tool_config = tool_config_for_registry(®istry, Some(true)); + let request = build_request("call four tools", tool_config); + + let guard_config = ToolCallingConfig::new(3, Duration::from_secs(5)) + .with_max_concurrent_tool_calls(2) + .with_tool_timeout(Duration::from_secs(1)); + let client = client_for(&server, Some(guard_config)); + let response = client + .generate_completion::( + request, + ::format().expect("format"), + Some(®istry), + ) + .await + .expect("structured response"); + + assert_eq!(response.content.sum, 10); + assert_eq!(executions.load(Ordering::SeqCst), 4); + assert_eq!(max_active.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn tool_execution_timeout_triggers_error() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(tool_call_response(vec![function_call( + "call_slow", + "tracked_tool", + json!({ "value": 1 }), + )])) + .mount(&server) + .await; + + let (registry, executions, _) = tracked_tool_registry(Duration::from_millis(200)); + let tool_config = tool_config_for_registry(®istry, Some(false)); + let request = build_request("call slow tool", tool_config); + + let guard_config = ToolCallingConfig::new(3, Duration::from_secs(5)) + .with_tool_timeout(Duration::from_millis(50)); + let client = client_for(&server, Some(guard_config.clone())); + let err = client + .generate_completion::( + request, + ::format().expect("format"), + Some(®istry), + ) + .await + .expect_err("tool timeout should trip"); + + match err { + LlmError::ToolExecutionTimeout { tool_name, timeout } => { + assert_eq!(tool_name, "tracked_tool"); + assert_eq!(timeout, guard_config.tool_timeout); + } + other => panic!("expected ToolExecutionTimeout, got {other:?}"), + } + assert_eq!(executions.load(Ordering::SeqCst), 1); +} + #[tokio::test] async fn guard_stops_iteration_after_max_limit() { let server = MockServer::start().await; @@ -280,6 +449,32 @@ fn tool_config_for(toolset: &ToolSet, parallel: Option) -> ToolConfig { } } +fn tool_config_for_registry(registry: &ToolRegistry, parallel: Option) -> ToolConfig { + ToolConfig { + tools: Some(registry.get_schemas().expect("schemas").into_boxed_slice()), + tool_choice: Some(ToolChoice::Auto), + parallel_tool_calls: parallel, + } +} + +fn tracked_tool_registry(delay: Duration) -> (ToolRegistry, Arc, Arc) { + let executions = Arc::new(AtomicUsize::new(0)); + let active = Arc::new(AtomicUsize::new(0)); + let max_active = Arc::new(AtomicUsize::new(0)); + let registry = ToolRegistry::new(); + + registry + .register(Arc::new(TrackedTool { + executions: executions.clone(), + active, + max_active: max_active.clone(), + delay, + })) + .expect("tracked tool registration"); + + (registry, executions, max_active) +} + fn sum_toolset() -> ToolSet { toolset![calculate_sum] } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index c6b3de3..bab3e14 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -293,21 +293,42 @@ fn provider_configs_share_default_tool_calling_limits() { let openrouter_guard = openrouter.get_tool_calling_guard(); assert_eq!(openai_guard.max_iterations, 50); assert_eq!(openai_guard.timeout, Duration::from_secs(300)); + assert_eq!(openai_guard.max_tool_calls_per_turn, 8); + assert_eq!(openai_guard.max_concurrent_tool_calls, 4); + assert_eq!(openai_guard.tool_timeout, Duration::from_secs(30)); assert_eq!(openai_guard.max_iterations, openrouter_guard.max_iterations); assert_eq!(openai_guard.timeout, openrouter_guard.timeout); + assert_eq!( + openai_guard.max_tool_calls_per_turn, + openrouter_guard.max_tool_calls_per_turn + ); + assert_eq!( + openai_guard.max_concurrent_tool_calls, + openrouter_guard.max_concurrent_tool_calls + ); + assert_eq!(openai_guard.tool_timeout, openrouter_guard.tool_timeout); - let custom_config = ToolCallingConfig::new(10, Duration::from_secs(60)); + let custom_config = ToolCallingConfig::new(10, Duration::from_secs(60)) + .with_max_tool_calls_per_turn(5) + .with_max_concurrent_tool_calls(2) + .with_tool_timeout(Duration::from_secs(20)); let custom_guard = openai .with_tool_calling_config(custom_config.clone()) .get_tool_calling_guard(); assert_eq!(custom_guard.max_iterations, 10); assert_eq!(custom_guard.timeout, Duration::from_secs(60)); + assert_eq!(custom_guard.max_tool_calls_per_turn, 5); + assert_eq!(custom_guard.max_concurrent_tool_calls, 2); + assert_eq!(custom_guard.tool_timeout, Duration::from_secs(20)); let custom_router_guard = openrouter .with_tool_calling_config(custom_config) .get_tool_calling_guard(); assert_eq!(custom_router_guard.max_iterations, 10); assert_eq!(custom_router_guard.timeout, Duration::from_secs(60)); + assert_eq!(custom_router_guard.max_tool_calls_per_turn, 5); + assert_eq!(custom_router_guard.max_concurrent_tool_calls, 2); + assert_eq!(custom_router_guard.tool_timeout, Duration::from_secs(20)); } #[test] diff --git a/tests/tool_calling_safeguards_test.rs b/tests/tool_calling_safeguards_test.rs index 138dc95..0d19f36 100644 --- a/tests/tool_calling_safeguards_test.rs +++ b/tests/tool_calling_safeguards_test.rs @@ -106,12 +106,15 @@ async fn test_openai_config_tool_calling() { // Should have default values assert_eq!(guard.max_iterations, 50); assert_eq!(guard.timeout, Duration::from_secs(300)); + assert_eq!(guard.max_tool_calls_per_turn, 8); + assert_eq!(guard.max_concurrent_tool_calls, 4); + assert_eq!(guard.tool_timeout, Duration::from_secs(30)); // Test custom config - let custom_config = ToolCallingConfig { - max_iterations: 75, - timeout: Duration::from_secs(600), - }; + let custom_config = ToolCallingConfig::new(75, Duration::from_secs(600)) + .with_max_tool_calls_per_turn(6) + .with_max_concurrent_tool_calls(2) + .with_tool_timeout(Duration::from_secs(15)); let config_with_custom = OpenAiConfig::new("test-key".to_string()).with_tool_calling_config(custom_config); @@ -119,6 +122,9 @@ async fn test_openai_config_tool_calling() { assert_eq!(custom_guard.max_iterations, 75); assert_eq!(custom_guard.timeout, Duration::from_secs(600)); + assert_eq!(custom_guard.max_tool_calls_per_turn, 6); + assert_eq!(custom_guard.max_concurrent_tool_calls, 2); + assert_eq!(custom_guard.tool_timeout, Duration::from_secs(15)); } #[tokio::test] @@ -129,12 +135,15 @@ async fn test_openrouter_config_tool_calling() { // Should have default values assert_eq!(guard.max_iterations, 50); assert_eq!(guard.timeout, Duration::from_secs(300)); + assert_eq!(guard.max_tool_calls_per_turn, 8); + assert_eq!(guard.max_concurrent_tool_calls, 4); + assert_eq!(guard.tool_timeout, Duration::from_secs(30)); // Test custom config - let custom_config = ToolCallingConfig { - max_iterations: 100, - timeout: Duration::from_secs(900), - }; + let custom_config = ToolCallingConfig::new(100, Duration::from_secs(900)) + .with_max_tool_calls_per_turn(7) + .with_max_concurrent_tool_calls(3) + .with_tool_timeout(Duration::from_secs(20)); let config_with_custom = OpenRouterConfig::new("test-key".to_string()).with_tool_calling_config(custom_config); @@ -142,4 +151,7 @@ async fn test_openrouter_config_tool_calling() { assert_eq!(custom_guard.max_iterations, 100); assert_eq!(custom_guard.timeout, Duration::from_secs(900)); + assert_eq!(custom_guard.max_tool_calls_per_turn, 7); + assert_eq!(custom_guard.max_concurrent_tool_calls, 3); + assert_eq!(custom_guard.tool_timeout, Duration::from_secs(20)); } From 6e262a75f3b089ebafe3a07e93cdb2dd24551888 Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Sun, 26 Apr 2026 14:36:44 +0200 Subject: [PATCH 09/15] fix: bound HTTP client pool --- src/core/http.rs | 82 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/src/core/http.rs b/src/core/http.rs index 148adab..c296c0c 100644 --- a/src/core/http.rs +++ b/src/core/http.rs @@ -91,6 +91,8 @@ struct ClientPoolKey { user_agent: String, } +const MAX_CLIENT_POOL_ENTRIES: usize = 32; + static CLIENT_POOL: OnceLock>>> = OnceLock::new(); fn client_pool() -> &'static Mutex>> { @@ -114,18 +116,28 @@ fn pooled_reqwest_client( return Ok(Arc::clone(client)); } - let client = Arc::new( + let client = build_reqwest_client(key.timeout, &key.user_agent)?; + + if clients.len() < MAX_CLIENT_POOL_ENTRIES { + clients.insert(key, Arc::clone(&client)); + } + + Ok(client) +} + +fn build_reqwest_client( + timeout: Duration, + user_agent: &str, +) -> Result, LlmError> { + Ok(Arc::new( reqwest::Client::builder() - .timeout(key.timeout) - .user_agent(key.user_agent.as_str()) + .timeout(timeout) + .user_agent(user_agent) .build() .map_err(|e| { LlmError::ProviderConfiguration(format!("Failed to build reqwest client: {e}")) })?, - ); - - clients.insert(key, Arc::clone(&client)); - Ok(client) + )) } impl HttpClient { @@ -313,6 +325,20 @@ impl HttpClient { mod tests { use super::*; + static CLIENT_POOL_TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn lock_client_pool_for_test() -> std::sync::MutexGuard<'static, ()> { + CLIENT_POOL_TEST_LOCK.lock().expect("client pool test lock") + } + + fn clear_client_pool() { + client_pool().lock().expect("client pool lock").clear(); + } + + fn client_pool_len() -> usize { + client_pool().lock().expect("client pool lock").len() + } + fn config_with_timeout(timeout: Duration) -> HttpClientConfig { HttpClientConfig { timeout, @@ -322,6 +348,8 @@ mod tests { #[test] fn clients_with_same_timeout_and_user_agent_share_pool_entry() { + let _guard = lock_client_pool_for_test(); + clear_client_pool(); let config = config_with_timeout(Duration::from_secs(7)); let first = HttpClient::new(config.clone(), Some("rsai-test-shared"), None) @@ -334,6 +362,8 @@ mod tests { #[test] fn clients_with_different_timeouts_use_distinct_pool_entries() { + let _guard = lock_client_pool_for_test(); + clear_client_pool(); let first = HttpClient::new( config_with_timeout(Duration::from_secs(11)), Some("rsai-test-timeout"), @@ -352,6 +382,8 @@ mod tests { #[test] fn clients_with_different_user_agents_use_distinct_pool_entries() { + let _guard = lock_client_pool_for_test(); + clear_client_pool(); let config = config_with_timeout(Duration::from_secs(13)); let first = HttpClient::new(config.clone(), Some("rsai-test-agent-a"), None) @@ -362,6 +394,42 @@ mod tests { assert!(!Arc::ptr_eq(&first.client, &second.client)); } + #[test] + fn client_pool_does_not_grow_past_cap() { + let _guard = lock_client_pool_for_test(); + clear_client_pool(); + + for index in 0..MAX_CLIENT_POOL_ENTRIES { + HttpClient::new( + config_with_timeout(Duration::from_secs(index as u64 + 1)), + Some("rsai-test-capped"), + None, + ) + .expect("pooled client should build"); + } + + assert_eq!(client_pool_len(), MAX_CLIENT_POOL_ENTRIES); + + let overflow = HttpClient::new( + config_with_timeout(Duration::from_secs(10_000)), + Some("rsai-test-capped-overflow"), + None, + ) + .expect("overflow client should build"); + + assert_eq!(client_pool_len(), MAX_CLIENT_POOL_ENTRIES); + + let second_overflow = HttpClient::new( + config_with_timeout(Duration::from_secs(10_000)), + Some("rsai-test-capped-overflow"), + None, + ) + .expect("second overflow client should build"); + + assert!(!Arc::ptr_eq(&overflow.client, &second_overflow.client)); + assert_eq!(client_pool_len(), MAX_CLIENT_POOL_ENTRIES); + } + #[test] fn provider_base_url_validation_allows_https() { validate_provider_base_url("https://api.openai.com/v1", BaseUrlSecurity::HttpsOnly) From aae155f464259c703be0f40c37a893bcf8216197 Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Sun, 26 Apr 2026 16:06:50 +0200 Subject: [PATCH 10/15] fix: honor Gemini tool concurrency --- src/completions/client.rs | 38 ++-- src/provider/gemini.rs | 59 +++++- tests/gemini_client_loop_tests.rs | 342 ++++++++++++++++++++++++++++++ 3 files changed, 416 insertions(+), 23 deletions(-) create mode 100644 tests/gemini_client_loop_tests.rs diff --git a/src/completions/client.rs b/src/completions/client.rs index 230a1f8..cc0aff5 100644 --- a/src/completions/client.rs +++ b/src/completions/client.rs @@ -2,6 +2,7 @@ //! //! This module provides reusable infrastructure for completion-style APIs. +use futures::{StreamExt, TryStreamExt, stream}; use serde::{Serialize, de::DeserializeOwned}; pub use crate::core::BaseUrlSecurity; @@ -198,35 +199,42 @@ impl CompletionClient

{ tracing::info!(count = calls.len(), "Model requested tool execution"); guard.check_tool_calls_for_turn(calls.len())?; + let mut tool_calls = Vec::with_capacity(calls.len()); for call in &calls { - // Add function call to conversation conversation.push(ConversationItem::FunctionCall { id: call.id.clone(), name: call.name.clone(), arguments: call.arguments.clone(), }); - // Execute the tool - let tool_call = ToolCall { + tool_calls.push(ToolCall { id: call.id.clone(), call_id: call.id.clone(), name: call.name.clone(), arguments: call.arguments.clone(), - }; - let result = self - .execute_tool_with_timeout(tool_registry, &tool_call, guard.tool_timeout) - .await?; + }); + } - // Add result to conversation + let max_concurrent_tool_calls = if is_parallel { + guard.max_concurrent_tool_calls() + } else { + 1 + }; + let tool_timeout = guard.tool_timeout; + let results: Vec = stream::iter(tool_calls.iter().cloned()) + .map(|tool_call| async move { + self.execute_tool_with_timeout(tool_registry, &tool_call, tool_timeout) + .await + }) + .buffered(max_concurrent_tool_calls) + .try_collect() + .await?; + + for (tool_call, result) in tool_calls.iter().zip(results) { conversation.push(ConversationItem::FunctionResult { - call_id: call.id.clone(), - result: result.clone(), + call_id: tool_call.call_id.clone(), + result, }); - - // In sequential mode process one call per model turn. - if !is_parallel { - break; - } } } else { tracing::debug!("No more tool calls, returning final response"); diff --git a/src/provider/gemini.rs b/src/provider/gemini.rs index e2ccfd4..b73ca48 100644 --- a/src/provider/gemini.rs +++ b/src/provider/gemini.rs @@ -384,10 +384,43 @@ fn build_contents_from_conversation( ) -> Result<(Option, Vec), LlmError> { let mut system_instruction: Option = None; let mut contents: Vec = Vec::new(); + let mut pending_role: Option = None; + let mut pending_parts: Vec = Vec::new(); + + fn flush_pending_function_parts( + contents: &mut Vec, + pending_role: &mut Option, + pending_parts: &mut Vec, + ) { + if let Some(role) = pending_role.take() { + if !pending_parts.is_empty() { + contents.push(Content { + role: Some(role), + parts: std::mem::take(pending_parts), + }); + } + } + } + + fn push_grouped_function_part( + contents: &mut Vec, + pending_role: &mut Option, + pending_parts: &mut Vec, + role: &str, + part: Part, + ) { + if pending_role.as_deref() != Some(role) { + flush_pending_function_parts(contents, pending_role, pending_parts); + *pending_role = Some(role.to_string()); + } + pending_parts.push(part); + } for item in conversation { match item { ConversationItem::Message { role, content } => { + flush_pending_function_parts(&mut contents, &mut pending_role, &mut pending_parts); + if role == "system" { system_instruction = Some(Content { role: None, @@ -408,10 +441,13 @@ fn build_contents_from_conversation( ConversationItem::FunctionCall { name, arguments, .. } => { - contents.push(Content { - role: Some("model".to_string()), - parts: vec![Part::function_call(name.clone(), arguments.clone())], - }); + push_grouped_function_part( + &mut contents, + &mut pending_role, + &mut pending_parts, + "model", + Part::function_call(name.clone(), arguments.clone()), + ); } ConversationItem::FunctionResult { call_id, result } => { // For Gemini, we need to find the function name from previous calls @@ -425,14 +461,19 @@ fn build_contents_from_conversation( other => serde_json::json!({ "result": other }), }; - contents.push(Content { - role: Some("user".to_string()), - parts: vec![Part::function_response(name, response_value)], - }); + push_grouped_function_part( + &mut contents, + &mut pending_role, + &mut pending_parts, + "user", + Part::function_response(name, response_value), + ); } } } + flush_pending_function_parts(&mut contents, &mut pending_role, &mut pending_parts); + Ok((system_instruction, contents)) } @@ -553,6 +594,8 @@ fn build_tools_config( function_declarations, }]; + // Gemini exposes no native parallel_tool_calls request flag; the shared + // completion loop enforces local sequential or bounded-concurrent execution. let mode = match &tool_config.tool_choice { Some(crate::core::ToolChoice::None) => "NONE", Some(crate::core::ToolChoice::Auto) => "AUTO", diff --git a/tests/gemini_client_loop_tests.rs b/tests/gemini_client_loop_tests.rs new file mode 100644 index 0000000..44b500a --- /dev/null +++ b/tests/gemini_client_loop_tests.rs @@ -0,0 +1,342 @@ +use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use rsai::{ + BoxFuture, ChatRole, CompletionTarget, ConversationMessage, GeminiClient, LlmError, + LlmProvider, Message, StructuredRequest, TextResponse, Tool, ToolCallingConfig, ToolChoice, + ToolConfig, ToolFunction, ToolRegistry, +}; +use serde_json::{Value, json}; +use wiremock::{ + Match, Mock, MockServer, Request as WiremockRequest, ResponseTemplate, + matchers::{method, path}, +}; + +struct TrackedTool { + executions: Arc, + active: Arc, + max_active: Arc, + delay: Duration, +} + +impl ToolFunction for TrackedTool { + fn schema(&self) -> Tool { + Tool { + name: "tracked_tool".to_string(), + description: Some("Track execution limits".to_string()), + parameters: json!({ + "type": "object", + "properties": { + "value": { "type": "integer" } + }, + "required": ["value"] + }), + strict: Some(true), + } + } + + fn execute<'a>( + &'a self, + _ctx: &'a (), + params: Value, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.executions.fetch_add(1, Ordering::SeqCst); + let active = self.active.fetch_add(1, Ordering::SeqCst) + 1; + self.max_active.fetch_max(active, Ordering::SeqCst); + tokio::time::sleep(self.delay).await; + self.active.fetch_sub(1, Ordering::SeqCst); + Ok(json!({ "ok": true, "value": params["value"].clone() })) + }) + } +} + +#[derive(Clone)] +struct BodyContains(&'static str); + +impl Match for BodyContains { + fn matches(&self, request: &WiremockRequest) -> bool { + std::str::from_utf8(&request.body) + .map(|body| body.contains(self.0)) + .unwrap_or(false) + } +} + +#[derive(Clone)] +struct BodyNotContains(&'static str); + +impl Match for BodyNotContains { + fn matches(&self, request: &WiremockRequest) -> bool { + !BodyContains(self.0).matches(request) + } +} + +#[tokio::test] +async fn gemini_parallel_tool_calls_respect_concurrency_limit() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/models/mock-model:generateContent")) + .and(BodyNotContains("functionResponse")) + .respond_with(gemini_tool_call_response(vec![ + gemini_function_call(1), + gemini_function_call(2), + gemini_function_call(3), + gemini_function_call(4), + ])) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/models/mock-model:generateContent")) + .and(BodyContains("functionResponse")) + .respond_with(gemini_final_response("done")) + .mount(&server) + .await; + + let (registry, executions, max_active) = tracked_tool_registry(Duration::from_millis(50)); + let request = build_request(tool_config_for_registry(®istry, Some(true))); + let guard_config = ToolCallingConfig::new(3, Duration::from_secs(5)) + .with_max_concurrent_tool_calls(2) + .with_tool_timeout(Duration::from_secs(1)); + let client = client_for(&server, Some(guard_config)); + + let response = client + .generate_completion::( + request, + TextResponse::format().expect("format"), + Some(®istry), + ) + .await + .expect("text response"); + + assert_eq!(response.text, "done"); + assert_eq!(executions.load(Ordering::SeqCst), 4); + assert_eq!(max_active.load(Ordering::SeqCst), 2); + + let requests = server + .received_requests() + .await + .expect("mock server should record requests"); + assert_eq!(requests.len(), 2); + + let second_body = parse_body(&requests[1]); + let contents = second_body["contents"].as_array().expect("contents"); + assert_eq!(contents[1]["role"], "model"); + assert_eq!( + contents[1]["parts"].as_array().expect("model parts").len(), + 4 + ); + assert_eq!(contents[2]["role"], "user"); + assert_eq!( + contents[2]["parts"].as_array().expect("user parts").len(), + 4 + ); +} + +#[tokio::test] +async fn gemini_parallel_tool_calls_false_executes_batch_sequentially() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/models/mock-model:generateContent")) + .and(BodyNotContains("functionResponse")) + .respond_with(gemini_tool_call_response(vec![ + gemini_function_call(1), + gemini_function_call(2), + gemini_function_call(3), + ])) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/models/mock-model:generateContent")) + .and(BodyContains("functionResponse")) + .respond_with(gemini_final_response("done")) + .mount(&server) + .await; + + let (registry, executions, max_active) = tracked_tool_registry(Duration::from_millis(50)); + let request = build_request(tool_config_for_registry(®istry, Some(false))); + let guard_config = ToolCallingConfig::new(3, Duration::from_secs(5)) + .with_max_concurrent_tool_calls(3) + .with_tool_timeout(Duration::from_secs(1)); + let client = client_for(&server, Some(guard_config)); + + let response = client + .generate_completion::( + request, + TextResponse::format().expect("format"), + Some(®istry), + ) + .await + .expect("text response"); + + assert_eq!(response.text, "done"); + assert_eq!(executions.load(Ordering::SeqCst), 3); + assert_eq!(max_active.load(Ordering::SeqCst), 1); + + let requests = server + .received_requests() + .await + .expect("mock server should record requests"); + assert_eq!(requests.len(), 2); + + let first_body = String::from_utf8(requests[0].body.clone()).expect("utf8 body"); + assert!(first_body.contains("functionCallingConfig")); + assert!(!first_body.contains("parallelToolCalls")); + assert!(!first_body.contains("parallel_tool_calls")); + + let second_body = parse_body(&requests[1]); + let contents = second_body["contents"].as_array().expect("contents"); + assert_eq!( + contents[1]["parts"].as_array().expect("model parts").len(), + 3 + ); + assert_eq!( + contents[2]["parts"].as_array().expect("user parts").len(), + 3 + ); +} + +#[tokio::test] +async fn gemini_guard_rejects_too_many_tool_calls_before_execution() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/models/mock-model:generateContent")) + .respond_with(gemini_tool_call_response(vec![ + gemini_function_call(1), + gemini_function_call(2), + ])) + .mount(&server) + .await; + + let (registry, executions, _) = tracked_tool_registry(Duration::ZERO); + let request = build_request(tool_config_for_registry(®istry, Some(true))); + let guard_config = + ToolCallingConfig::new(3, Duration::from_secs(5)).with_max_tool_calls_per_turn(1); + let client = client_for(&server, Some(guard_config)); + + let err = client + .generate_completion::( + request, + TextResponse::format().expect("format"), + Some(®istry), + ) + .await + .expect_err("tool call limit should trip"); + + match err { + LlmError::ToolCallLimit { requested, limit } => { + assert_eq!(requested, 2); + assert_eq!(limit, 1); + } + other => panic!("expected ToolCallLimit, got {other:?}"), + } + + assert_eq!(executions.load(Ordering::SeqCst), 0); +} + +fn client_for(server: &MockServer, config: Option) -> GeminiClient { + let client = GeminiClient::new("test-key".to_string()) + .unwrap() + .with_insecure_base_url(server.uri()) + .unwrap(); + + if let Some(cfg) = config { + client.with_tool_calling_config(cfg).unwrap() + } else { + client + } +} + +fn tool_config_for_registry(registry: &ToolRegistry, parallel: Option) -> ToolConfig { + ToolConfig { + tools: Some(registry.get_schemas().expect("schemas").into_boxed_slice()), + tool_choice: Some(ToolChoice::Auto), + parallel_tool_calls: parallel, + } +} + +fn tracked_tool_registry(delay: Duration) -> (ToolRegistry, Arc, Arc) { + let executions = Arc::new(AtomicUsize::new(0)); + let active = Arc::new(AtomicUsize::new(0)); + let max_active = Arc::new(AtomicUsize::new(0)); + let registry = ToolRegistry::new(); + + registry + .register(Arc::new(TrackedTool { + executions: executions.clone(), + active, + max_active: max_active.clone(), + delay, + })) + .expect("tracked tool registration"); + + (registry, executions, max_active) +} + +fn build_request(tool_config: ToolConfig) -> StructuredRequest { + StructuredRequest { + model: "mock-model".to_string(), + messages: vec![ConversationMessage::Chat(Message { + role: ChatRole::User, + content: "call tools".to_string(), + })], + tool_config: Some(tool_config), + generation_config: None, + } +} + +fn gemini_tool_call_response(parts: Vec) -> ResponseTemplate { + ResponseTemplate::new(200).set_body_json(json!({ + "candidates": [{ + "content": { + "role": "model", + "parts": parts, + }, + }], + "usageMetadata": usage_metadata(), + "modelVersion": "mock-model", + })) +} + +fn gemini_function_call(value: i64) -> Value { + json!({ + "functionCall": { + "name": "tracked_tool", + "args": { "value": value }, + } + }) +} + +fn gemini_final_response(text: &str) -> ResponseTemplate { + ResponseTemplate::new(200).set_body_json(json!({ + "candidates": [{ + "content": { + "role": "model", + "parts": [{ "text": text }], + }, + }], + "usageMetadata": usage_metadata(), + "modelVersion": "mock-model", + })) +} + +fn usage_metadata() -> Value { + json!({ + "promptTokenCount": 10, + "candidatesTokenCount": 5, + "totalTokenCount": 15, + }) +} + +fn parse_body(request: &WiremockRequest) -> Value { + serde_json::from_slice(&request.body).expect("json body") +} From 3c3190665ed4a2596db7c48f7e93c7f915d24c33 Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Mon, 27 Apr 2026 11:34:38 +0200 Subject: [PATCH 11/15] fix: reject unknown completion fields --- macros/src/lib.rs | 10 ++++++---- macros/tests/integration_test.rs | 15 ++++++++++++++- src/responses/client.rs | 33 ++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 4a92cc1..db17335 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -50,7 +50,8 @@ mod tools; /// # What it does /// /// - Adds `#[derive(serde::Deserialize, schemars::JsonSchema)]` -/// - Adds `#[schemars(deny_unknown_fields)]` for strict field validation +/// - Adds `#[serde(deny_unknown_fields)]` for strict local deserialization +/// - Adds `#[schemars(deny_unknown_fields)]` for strict schema generation /// - Enables automatic JSON schema generation for LLM providers /// /// # Example @@ -74,9 +75,9 @@ mod tools; /// /// # Field Validation /// -/// The `deny_unknown_fields` attribute ensures that responses must exactly match -/// your struct definition. Any extra fields will cause a deserialization error, -/// providing predictable and safe responses. +/// The `deny_unknown_fields` attributes ensure that responses must exactly match +/// your struct definition. Any extra fields will cause a local deserialization +/// error and are also rejected in the generated JSON schema. /// /// # Supported Types /// @@ -93,6 +94,7 @@ pub fn completion_schema(_attr: TokenStream, item: TokenStream) -> TokenStream { let expanded = quote! { #[derive(serde::Deserialize, schemars::JsonSchema)] + #[serde(deny_unknown_fields)] #[schemars(deny_unknown_fields)] #item_tokens }; diff --git a/macros/tests/integration_test.rs b/macros/tests/integration_test.rs index 1c7fb32..83993bc 100644 --- a/macros/tests/integration_test.rs +++ b/macros/tests/integration_test.rs @@ -1,4 +1,17 @@ -use rsai_macros::tool; +use rsai_macros::{completion_schema, tool}; + +#[completion_schema] +#[derive(Debug, PartialEq)] +struct StrictResponse { + value: String, +} + +#[test] +fn test_completion_schema_rejects_unknown_fields_locally() { + let parsed: Result = serde_json::from_str(r#"{"value":"ok","extra":true}"#); + + assert!(parsed.is_err()); +} #[tool] /// Get current temperature for a given location. diff --git a/src/responses/client.rs b/src/responses/client.rs index c3c7975..188006f 100644 --- a/src/responses/client.rs +++ b/src/responses/client.rs @@ -960,6 +960,39 @@ mod tests { assert_eq!(result.unwrap().content, "wrapped_success"); } + #[tokio::test] + async fn test_response_parsing_rejects_unknown_fields() { + let server = MockServer::start().await; + + let output_json = serde_json::json!({ + "value": "ok", + "extra": true + }); + + let response = serde_json::json!({ + "id": "resp_extra_field", + "model": "test-model", + "output": [{ + "id": "msg_extra_field", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": output_json.to_string() + }] + }], + "usage": { "input_tokens": 0, "output_tokens": 0, "total_tokens": 0 } + }); + + let result = run_parsing_test::(&server, response).await; + + match result { + Err(LlmError::Parse { .. }) => (), + _ => panic!("Expected Parse Error, got {:?}", result), + } + } + #[tokio::test] async fn test_response_parsing_empty() { let server = MockServer::start().await; From 7e2fbfd0a61d66fbc8ea32da012ef0498d9b62b9 Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Mon, 27 Apr 2026 12:30:19 +0200 Subject: [PATCH 12/15] fix: avoid logging raw tool arguments --- src/responses/client.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/responses/client.rs b/src/responses/client.rs index 188006f..b7def15 100644 --- a/src/responses/client.rs +++ b/src/responses/client.rs @@ -403,7 +403,7 @@ impl ResponsesClient

{ ) -> Result { match arguments { serde_json::Value::String(s) => serde_json::from_str(s).map_err(|e| LlmError::Parse { - message: format!("Failed to parse tool arguments: {s}"), + message: "Failed to parse tool arguments".to_string(), source: Box::new(e), }), other => Ok(other.clone()), @@ -738,6 +738,28 @@ mod tests { } } + #[test] + fn test_parse_function_arguments_error_does_not_include_raw_arguments() { + let client = ResponsesClient::new(TestProviderConfig::new("http://localhost".to_string())) + .expect("client"); + let raw_arguments = "{\"secret\":\"do-not-log\""; + let result = + client.parse_function_arguments(&serde_json::Value::String(raw_arguments.to_string())); + + match result { + Err(LlmError::Parse { message, source }) => { + assert_eq!(message, "Failed to parse tool arguments"); + assert!(!message.contains(raw_arguments)); + assert!(source.is::()); + assert!(!source.to_string().contains(raw_arguments)); + + let display = LlmError::Parse { message, source }.to_string(); + assert!(!display.contains(raw_arguments)); + } + other => panic!("Expected Parse Error, got {other:?}"), + } + } + // --- Tests: HTTP Resilience --- #[tokio::test] From 415eb23a7cdba939280d43270fb8b3de7ab9381a Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Mon, 27 Apr 2026 12:30:19 +0200 Subject: [PATCH 13/15] ci: add dependency audit --- .github/workflows/rust-ci.yml | 15 + .gitignore | 1 - Cargo.lock | 2071 +++++++++++++++++++++++++++++++++ 3 files changed, 2086 insertions(+), 1 deletion(-) create mode 100644 Cargo.lock diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 353f030..dede3a7 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -14,6 +14,21 @@ env: RUSTFLAGS: "-Dwarnings" jobs: + audit: + name: Audit Dependencies + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-audit + run: cargo install cargo-audit --locked + + - name: Run dependency security audit + run: cargo audit + check: name: Check runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index e551aa3..fedaa2b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ /target -Cargo.lock .env diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..38e1d7d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2071 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsai" +version = "0.3.0" +dependencies = [ + "async-trait", + "dotenv", + "futures", + "rand", + "reqwest", + "rsai-macros", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", + "wiremock", +] + +[[package]] +name = "rsai-macros" +version = "0.2.0" +dependencies = [ + "proc-macro2", + "quote", + "rsai", + "schemars", + "serde", + "serde_json", + "syn", + "tokio", + "trybuild", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301858a4023d78debd2353c7426dc486001bddc91ae31a76fb1f55132f7e2633" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "target-triple" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "trybuild" +version = "1.0.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "559b6a626c0815c942ac98d434746138b4f89ddd6a1b8cbb168c6845fb3376c5" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] From 3bbb27d2981a4376719a8ccf05894ff9f1d20609 Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Mon, 27 Apr 2026 14:06:38 +0200 Subject: [PATCH 14/15] fix: offload generated sync tools --- Cargo.toml | 2 +- macros/README.md | 2 +- macros/src/tool.rs | 68 +++++++++++++++++++++++-- src/core/tool_guard.rs | 10 +++- src/core/traits.rs | 15 ++++++ src/core/types.rs | 2 +- src/lib.rs | 26 ++++++++++ tests/gemini_client_loop_tests.rs | 36 +++++++++++++ tests/tool_registry_tests.rs | 85 ++++++++++++++++++++++++++++++- 9 files changed, 238 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3cbfece..16f8956 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = "2.0.12" -tokio = { version = "1.43.0", features = ["time"] } +tokio = { version = "1.43.0", features = ["rt", "time"] } tracing = "0.1.41" [dev-dependencies] diff --git a/macros/README.md b/macros/README.md index 74915af..5af5072 100644 --- a/macros/README.md +++ b/macros/README.md @@ -64,7 +64,7 @@ Transforms Rust functions into AI-callable tools with automatic JSON schema gene - **Parameter Validation**: Ensures all docstring parameters exist in the function signature - **Optional Parameters**: Automatically detects `Option` types and marks them as non-required - **Type Mapping**: Converts Rust types to JSON schema types -- **Async Support**: Handles both sync and async functions +- **Async Support**: Handles both sync and async functions; generated sync tools are offloaded to Tokio's blocking pool when run through `ToolRegistry` - **Error Handling**: Maps errors to `LlmError` with proper context #### Syntax diff --git a/macros/src/tool.rs b/macros/src/tool.rs index cd5b8c3..21ffe8e 100644 --- a/macros/src/tool.rs +++ b/macros/src/tool.rs @@ -38,7 +38,52 @@ pub fn tool_impl(attr: TokenStream, item: TokenStream) -> Result { let is_async = input.sig.asyncness.is_some(); // Generate the execution code - let execute_impl = generate_execute_impl(fn_name, &context_param, ¶ms, is_async)?; + let execute_impl = generate_execute_impl( + fn_name, + &context_param, + ¶ms, + is_async, + ContextAccess::Borrowed, + )?; + let execute_owned_impl = if is_async { + quote! {} + } else { + let owned_execute_impl = generate_execute_impl( + fn_name, + &context_param, + ¶ms, + is_async, + ContextAccess::OwnedArc, + )?; + let unused_owned_ctx = if context_param.is_none() { + quote! { + let _ = __ctx; + } + } else { + quote! {} + }; + + quote! { + fn execute_owned( + self: ::std::sync::Arc, + __ctx: ::std::sync::Arc<__Ctx>, + params: ::serde_json::Value, + ) -> rsai::BoxFuture<'static, Result<::serde_json::Value, rsai::LlmError>> + where + Self: 'static, + __Ctx: Send + Sync + 'static, + { + use rsai::{BoxFuture, LlmError}; + let _ = self; + #unused_owned_ctx + Box::pin(async move { + rsai::__private::spawn_blocking_tool(move || { + #owned_execute_impl + }).await + }) + } + } + }; // Generate inherent impl with schema() method. // This allows calling .schema() without type annotations since Rust prefers @@ -76,6 +121,8 @@ pub fn tool_impl(attr: TokenStream, item: TokenStream) -> Result { #execute_impl }) } + + #execute_owned_impl } } } else { @@ -93,6 +140,8 @@ pub fn tool_impl(attr: TokenStream, item: TokenStream) -> Result { #execute_impl }) } + + #execute_owned_impl } } }; @@ -387,11 +436,18 @@ fn type_to_json_type(ty: &Type) -> Result<&'static str> { } } +#[derive(Clone, Copy)] +enum ContextAccess { + Borrowed, + OwnedArc, +} + fn generate_execute_impl( fn_name: &syn::Ident, context_param: &Option, params: &[Parameter], is_async: bool, + context_access: ContextAccess, ) -> Result { let param_extractions = params.iter().map(|param| { let name = ¶m.name; @@ -448,8 +504,14 @@ fn generate_execute_impl( // Generate context extraction if needed let context_extraction = if let Some(ctx) = context_param { let ctx_name = quote::format_ident!("{}", ctx.name); - quote! { - let #ctx_name = rsai::Ctx(__ctx.as_ref()); + let ctx_inner_ty = &ctx.inner_ty; + match context_access { + ContextAccess::Borrowed => quote! { + let #ctx_name = rsai::Ctx(::std::convert::AsRef::<#ctx_inner_ty>::as_ref(__ctx)); + }, + ContextAccess::OwnedArc => quote! { + let #ctx_name = rsai::Ctx(::std::convert::AsRef::<#ctx_inner_ty>::as_ref(__ctx.as_ref())); + }, } } else { quote! {} diff --git a/src/core/tool_guard.rs b/src/core/tool_guard.rs index 6fc5ab2..90c85a1 100644 --- a/src/core/tool_guard.rs +++ b/src/core/tool_guard.rs @@ -12,7 +12,11 @@ pub struct ToolCallingConfig { pub max_tool_calls_per_turn: usize, /// Maximum number of tools executed at the same time (default: 4) pub max_concurrent_tool_calls: usize, - /// Timeout for each individual tool execution (default: 30 seconds) + /// Timeout for each individual tool execution (default: 30 seconds). + /// + /// Generated synchronous `#[tool]` functions are run on Tokio's blocking pool so this timeout + /// can fire while they are blocked. Async tools and manual `ToolFunction` implementations must + /// remain cooperative and avoid blocking Tokio worker threads. pub tool_timeout: Duration, } @@ -53,6 +57,10 @@ impl ToolCallingConfig { } /// Set the timeout for each individual tool execution. + /// + /// This timeout can stop waiting for generated synchronous `#[tool]` functions while their + /// blocking work finishes on Tokio's blocking pool. It does not preempt blocking work inside + /// async tools or manual `ToolFunction` implementations. pub fn with_tool_timeout(mut self, timeout: Duration) -> Self { self.tool_timeout = timeout; self diff --git a/src/core/traits.rs b/src/core/traits.rs index 0d6de93..c87e3ff 100644 --- a/src/core/traits.rs +++ b/src/core/traits.rs @@ -1,4 +1,5 @@ use async_trait::async_trait; +use std::sync::Arc; use crate::responses::request::Format; @@ -22,11 +23,25 @@ pub trait LlmProvider { pub trait ToolFunction: Send + Sync { fn schema(&self) -> Tool; + fn execute<'a>( &'a self, ctx: &'a Ctx, params: serde_json::Value, ) -> BoxFuture<'a, Result>; + + #[doc(hidden)] + fn execute_owned( + self: Arc, + ctx: Arc, + params: serde_json::Value, + ) -> BoxFuture<'static, Result> + where + Self: 'static, + Ctx: Send + Sync + 'static, + { + Box::pin(async move { self.execute(ctx.as_ref(), params).await }) + } } pub trait CompletionTarget: Sized + Send { diff --git a/src/core/types.rs b/src/core/types.rs index 0f08724..5177943 100644 --- a/src/core/types.rs +++ b/src/core/types.rs @@ -309,7 +309,7 @@ impl ToolRegistry { }; let result = if let Some(tool) = tool { - tool.execute(&self.context, tool_call.arguments.clone()) + tool.execute_owned(self.context.clone(), tool_call.arguments.clone()) .await } else { Err(LlmError::ToolNotFound(tool_call.name.clone())) diff --git a/src/lib.rs b/src/lib.rs index 70e3d07..6406c84 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -80,6 +80,32 @@ pub use core::BoxFuture; pub use core::LlmError; pub type Result = std::result::Result; +#[doc(hidden)] +pub mod __private { + pub async fn spawn_blocking_tool(f: F) -> crate::Result + where + F: FnOnce() -> crate::Result + Send + 'static, + { + let Ok(handle) = tokio::runtime::Handle::try_current() else { + return f(); + }; + + match handle.spawn_blocking(f).await { + Ok(result) => result, + Err(err) => { + if err.is_panic() { + std::panic::resume_unwind(err.into_panic()); + } + + Err(crate::LlmError::ToolExecution { + message: "Blocking tool task failed".to_string(), + source: Some(Box::new(err)), + }) + } + } + } +} + // Gen AI request builders pub use core::llm; diff --git a/tests/gemini_client_loop_tests.rs b/tests/gemini_client_loop_tests.rs index 44b500a..9c3890c 100644 --- a/tests/gemini_client_loop_tests.rs +++ b/tests/gemini_client_loop_tests.rs @@ -204,6 +204,42 @@ async fn gemini_parallel_tool_calls_false_executes_batch_sequentially() { ); } +#[tokio::test] +async fn gemini_tool_execution_timeout_triggers_error() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/models/mock-model:generateContent")) + .respond_with(gemini_tool_call_response(vec![gemini_function_call(1)])) + .mount(&server) + .await; + + let (registry, executions, _) = tracked_tool_registry(Duration::from_millis(200)); + let request = build_request(tool_config_for_registry(®istry, Some(false))); + let guard_config = ToolCallingConfig::new(3, Duration::from_secs(5)) + .with_tool_timeout(Duration::from_millis(50)); + let client = client_for(&server, Some(guard_config.clone())); + + let err = client + .generate_completion::( + request, + TextResponse::format().expect("format"), + Some(®istry), + ) + .await + .expect_err("tool timeout should trip"); + + match err { + LlmError::ToolExecutionTimeout { tool_name, timeout } => { + assert_eq!(tool_name, "tracked_tool"); + assert_eq!(timeout, guard_config.tool_timeout); + } + other => panic!("expected ToolExecutionTimeout, got {other:?}"), + } + + assert_eq!(executions.load(Ordering::SeqCst), 1); +} + #[tokio::test] async fn gemini_guard_rejects_too_many_tool_calls_before_execution() { let server = MockServer::start().await; diff --git a/tests/tool_registry_tests.rs b/tests/tool_registry_tests.rs index 2b4a3e7..b09fd6e 100644 --- a/tests/tool_registry_tests.rs +++ b/tests/tool_registry_tests.rs @@ -1,4 +1,4 @@ -use rsai::{BoxFuture, LlmError, Tool, ToolCall, ToolFunction, ToolRegistry, tool}; +use rsai::{BoxFuture, Ctx, LlmError, Tool, ToolCall, ToolFunction, ToolRegistry, tool}; use serde_json::json; use std::sync::Arc; use std::time::Duration; @@ -21,6 +21,39 @@ async fn test_tool_b(value: f64) -> serde_json::Value { json!({ "result": "success_b" }) } +#[tool] +/// Sleep synchronously before returning. +/// delay_ms: Sleep duration in milliseconds. +fn blocking_sync_sleep(delay_ms: u64) -> serde_json::Value { + std::thread::sleep(Duration::from_millis(delay_ms)); + json!({ "delay_ms": delay_ms }) +} + +struct BlockingSleepConfig { + delay: Duration, +} + +struct BlockingSleepContext { + config: BlockingSleepConfig, +} + +impl AsRef for BlockingSleepContext { + fn as_ref(&self) -> &BlockingSleepConfig { + &self.config + } +} + +#[tool] +/// Sleep synchronously using context before returning. +/// label: Label to return after the sleep. +fn context_blocking_sync_sleep( + config: Ctx<&BlockingSleepConfig>, + label: String, +) -> serde_json::Value { + std::thread::sleep(config.delay); + json!({ "label": label }) +} + fn tool_a() -> Arc> { Arc::new(TestToolATool) } @@ -150,6 +183,56 @@ async fn test_overwrite_functionality() { ); } +#[tokio::test(flavor = "current_thread")] +async fn generated_sync_tool_timeout_fires_while_blocking_work_runs() { + let registry = ToolRegistry::new(); + registry + .register(Arc::new(BlockingSyncSleepTool)) + .expect("blocking tool registration"); + + let tool_call = ToolCall { + id: "call_blocking".to_string(), + call_id: "call_blocking".to_string(), + name: "blocking_sync_sleep".to_string(), + arguments: json!({ "delay_ms": 200 }), + }; + + let result = + tokio::time::timeout(Duration::from_millis(25), registry.execute(&tool_call)).await; + + assert!( + result.is_err(), + "generated sync tools should not block Tokio timeout polling" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn generated_context_sync_tool_timeout_fires_while_blocking_work_runs() { + let registry = ToolRegistry::with_context(BlockingSleepContext { + config: BlockingSleepConfig { + delay: Duration::from_millis(200), + }, + }); + registry + .register(Arc::new(ContextBlockingSyncSleepTool)) + .expect("context blocking tool registration"); + + let tool_call = ToolCall { + id: "call_context_blocking".to_string(), + call_id: "call_context_blocking".to_string(), + name: "context_blocking_sync_sleep".to_string(), + arguments: json!({ "label": "ctx" }), + }; + + let result = + tokio::time::timeout(Duration::from_millis(25), registry.execute(&tool_call)).await; + + assert!( + result.is_err(), + "generated context sync tools should not block Tokio timeout polling" + ); +} + // ============================================================================ // CONCURRENCY TESTS // ============================================================================ From 221742f52c36945b043b00c1f675792ffde51007 Mon Sep 17 00:00:00 2001 From: Constantin Luckenbach Date: Mon, 27 Apr 2026 14:35:15 +0200 Subject: [PATCH 15/15] fix: update audited transitive dependencies --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 38e1d7d..4c7eb9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,9 +58,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" @@ -1040,9 +1040,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types",