From b817a6f730e5f1de19b633ec3433a937d561fd8b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 21:39:25 +0000 Subject: [PATCH 1/6] feat(provider-utils): add create_tool_name_mapping and strip_file_extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC-0046 R1 (§4.5 item 4): both follow @ai-sdk/provider-utils 5.0.55. create_tool_name_mapping maps the caller's names of provider tools to the provider's wire names and back, from a table of provider tool ids; strip_file_extension drops everything from the first dot. Their upstream tests are ported under src/tests/. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0116HPMoqaLfFr1TyqDq1hrW --- .../src/create_tool_name_mapping.rs | 67 +++++++++ aimux-provider-utils/src/lib.rs | 6 + .../src/strip_file_extension.rs | 13 ++ .../src/tests/create_tool_name_mapping.rs | 142 ++++++++++++++++++ aimux-provider-utils/src/tests/mod.rs | 5 + .../src/tests/strip_file_extension.rs | 28 ++++ 6 files changed, 261 insertions(+) create mode 100644 aimux-provider-utils/src/create_tool_name_mapping.rs create mode 100644 aimux-provider-utils/src/strip_file_extension.rs create mode 100644 aimux-provider-utils/src/tests/create_tool_name_mapping.rs create mode 100644 aimux-provider-utils/src/tests/mod.rs create mode 100644 aimux-provider-utils/src/tests/strip_file_extension.rs diff --git a/aimux-provider-utils/src/create_tool_name_mapping.rs b/aimux-provider-utils/src/create_tool_name_mapping.rs new file mode 100644 index 000000000..44b233183 --- /dev/null +++ b/aimux-provider-utils/src/create_tool_name_mapping.rs @@ -0,0 +1,67 @@ +//! `createToolNameMapping` from `@ai-sdk/provider-utils` +//! (`create-tool-name-mapping.ts`). +//! +//! A [`ProviderTool`](aimux_core::tool::ProviderTool) carries both an `id` +//! (`anthropic.web_search_20250305`) and a `name`, the name the caller uses +//! for it in this call. The provider's wire format uses its own fixed name +//! (`web_search`), so the two differ whenever the caller renames a provider +//! tool: prompts map the caller's names to the provider's, responses map them +//! back. + +use std::collections::HashMap; + +use aimux_core::tool::Tool; + +/// Maps between custom tool names and provider tool names. +#[derive(Debug, Default, Clone)] +pub struct ToolNameMapping { + custom_tool_name_to_provider_tool_name: HashMap, + provider_tool_name_to_custom_tool_name: HashMap, +} + +impl ToolNameMapping { + /// Maps a custom tool name (used by the client) to the provider's tool + /// name. A name without a mapping is returned as given. + #[must_use] + pub fn to_provider_tool_name<'a>(&'a self, custom_tool_name: &'a str) -> &'a str { + self.custom_tool_name_to_provider_tool_name + .get(custom_tool_name) + .map_or(custom_tool_name, String::as_str) + } + + /// Maps a provider tool name to the custom tool name used by the client. + /// A name without a mapping is returned as given. + #[must_use] + pub fn to_custom_tool_name<'a>(&'a self, provider_tool_name: &'a str) -> &'a str { + self.provider_tool_name_to_custom_tool_name + .get(provider_tool_name) + .map_or(provider_tool_name, String::as_str) + } +} + +/// Builds the mapping for the provider tools of one call. +/// +/// `tools` are the tools passed to the language model; `provider_tool_names` +/// maps provider tool ids (`provider.tool`) to the provider tool names. +/// Function tools and provider tools whose id is not listed are not mapped. +#[must_use] +pub fn create_tool_name_mapping( + tools: Option<&[Tool]>, + provider_tool_names: &[(&str, &str)], +) -> ToolNameMapping { + let mut mapping = ToolNameMapping::default(); + for tool in tools.unwrap_or_default() { + if let Tool::Provider(tool) = tool + && let Some((_, provider_tool_name)) = + provider_tool_names.iter().find(|(id, _)| tool.id == *id) + { + mapping + .custom_tool_name_to_provider_tool_name + .insert(tool.name.clone(), (*provider_tool_name).to_owned()); + mapping + .provider_tool_name_to_custom_tool_name + .insert((*provider_tool_name).to_owned(), tool.name.clone()); + } + } + mapping +} diff --git a/aimux-provider-utils/src/lib.rs b/aimux-provider-utils/src/lib.rs index ac202f3a1..da51777ad 100644 --- a/aimux-provider-utils/src/lib.rs +++ b/aimux-provider-utils/src/lib.rs @@ -11,6 +11,7 @@ mod aisdk_pins; pub mod api_key; +pub mod create_tool_name_mapping; pub mod decision_language_model; pub mod download_blob; pub mod extract_response_headers; @@ -29,6 +30,9 @@ pub mod read_response_with_size_limit; pub mod resolvable; pub mod response_handler; pub mod sigv4_fetch; +pub mod strip_file_extension; +#[cfg(test)] +mod tests; pub mod transform_stream; pub mod url; /// WebSocket client for realtime provider APIs (RFC-0028). Empty unless the @@ -38,6 +42,7 @@ pub mod ws; pub use aimux_core::download_guard::same_origin; pub use api_key::{load_api_key, load_optional_setting, load_setting}; +pub use create_tool_name_mapping::{ToolNameMapping, create_tool_name_mapping}; pub use decision_language_model::DecisionLanguageModel; pub use download_blob::download_blob; pub use fetch::{ @@ -66,5 +71,6 @@ pub use response_handler::{ create_status_code_error_response_handler, invalid_response_api_call, stream_error_api_call, }; pub use sigv4_fetch::{AwsCredentials, SigV4Fetch, SigV4Signing}; +pub use strip_file_extension::strip_file_extension; pub use transform_stream::{PipeThrough, TransformStreamController, Transformer, pipe_through}; pub use url::{validate_base_url, without_trailing_slash, without_trailing_slash_opt}; diff --git a/aimux-provider-utils/src/strip_file_extension.rs b/aimux-provider-utils/src/strip_file_extension.rs new file mode 100644 index 000000000..afe36597d --- /dev/null +++ b/aimux-provider-utils/src/strip_file_extension.rs @@ -0,0 +1,13 @@ +//! `stripFileExtension` from `@ai-sdk/provider-utils` +//! (`strip-file-extension.ts`). + +/// Strips every extension segment from a filename: everything from the first +/// dot on. +/// +/// - `"report.pdf"` -> `"report"` +/// - `"archive.tar.gz"` -> `"archive"` +/// - `"filename"` -> `"filename"` +#[must_use] +pub fn strip_file_extension(filename: &str) -> &str { + filename.split_once('.').map_or(filename, |(name, _)| name) +} diff --git a/aimux-provider-utils/src/tests/create_tool_name_mapping.rs b/aimux-provider-utils/src/tests/create_tool_name_mapping.rs new file mode 100644 index 000000000..d8ce18fac --- /dev/null +++ b/aimux-provider-utils/src/tests/create_tool_name_mapping.rs @@ -0,0 +1,142 @@ +//! Rust translation of `packages/provider-utils/src/create-tool-name-mapping.test.ts` +//! (@ai-sdk/provider-utils). + +use aimux_core::tool::{FunctionTool, ProviderTool, Tool}; +use aimux_core::types::NamespacedKind; +use serde_json::{Map, json}; + +use crate::create_tool_name_mapping; + +fn provider_tool(id: &str, name: &str) -> Tool { + Tool::Provider(ProviderTool { + id: NamespacedKind::new(id).unwrap(), + name: name.to_owned(), + args: Map::new(), + supports_deferred_results: None, + }) +} + +fn function_tool(name: &str) -> Tool { + Tool::Function( + FunctionTool::new(name, json!({ "type": "object" })).with_description("A function tool"), + ) +} + +/// upstream: @ai-sdk/provider-utils src/create-tool-name-mapping.test.ts › createToolNameMapping › should create mappings for provider-defined tools +#[test] +fn should_create_mappings_for_provider_defined_tools() { + let tools = [ + provider_tool("anthropic.computer-use", "custom-computer-tool"), + provider_tool("openai.code-interpreter", "custom-code-tool"), + ]; + let provider_tool_names = [ + ("anthropic.computer-use", "computer_use"), + ("openai.code-interpreter", "code_interpreter"), + ]; + + let mapping = create_tool_name_mapping(Some(&tools), &provider_tool_names); + + assert_eq!( + mapping.to_provider_tool_name("custom-computer-tool"), + "computer_use" + ); + assert_eq!( + mapping.to_provider_tool_name("custom-code-tool"), + "code_interpreter" + ); + assert_eq!( + mapping.to_custom_tool_name("computer_use"), + "custom-computer-tool" + ); + assert_eq!( + mapping.to_custom_tool_name("code_interpreter"), + "custom-code-tool" + ); +} + +/// upstream: @ai-sdk/provider-utils src/create-tool-name-mapping.test.ts › createToolNameMapping › should ignore function tools +#[test] +fn should_ignore_function_tools() { + let tools = [function_tool("my-function-tool")]; + + let mapping = create_tool_name_mapping(Some(&tools), &[]); + + assert_eq!( + mapping.to_provider_tool_name("my-function-tool"), + "my-function-tool" + ); + assert_eq!( + mapping.to_custom_tool_name("my-function-tool"), + "my-function-tool" + ); +} + +/// upstream: @ai-sdk/provider-utils src/create-tool-name-mapping.test.ts › createToolNameMapping › should return input name when tool is not in providerToolNames +#[test] +fn should_return_input_name_when_tool_is_not_in_provider_tool_names() { + let tools = [provider_tool("unknown.tool", "custom-tool")]; + + let mapping = create_tool_name_mapping(Some(&tools), &[]); + + assert_eq!(mapping.to_provider_tool_name("custom-tool"), "custom-tool"); + assert_eq!(mapping.to_custom_tool_name("unknown-name"), "unknown-name"); +} + +/// upstream: @ai-sdk/provider-utils src/create-tool-name-mapping.test.ts › createToolNameMapping › should return input name when mapping does not exist +#[test] +fn should_return_input_name_when_mapping_does_not_exist() { + let tools = [provider_tool( + "anthropic.computer-use", + "custom-computer-tool", + )]; + let provider_tool_names = [("anthropic.computer-use", "computer_use")]; + + let mapping = create_tool_name_mapping(Some(&tools), &provider_tool_names); + + assert_eq!( + mapping.to_provider_tool_name("non-existent-tool"), + "non-existent-tool" + ); + assert_eq!( + mapping.to_custom_tool_name("non-existent-provider-tool"), + "non-existent-provider-tool" + ); +} + +/// upstream: @ai-sdk/provider-utils src/create-tool-name-mapping.test.ts › createToolNameMapping › should handle empty tools array +#[test] +fn should_handle_empty_tools_array() { + let mapping = create_tool_name_mapping(Some(&[]), &[]); + + assert_eq!(mapping.to_provider_tool_name("any-tool"), "any-tool"); + assert_eq!(mapping.to_custom_tool_name("any-tool"), "any-tool"); +} + +/// upstream: @ai-sdk/provider-utils src/create-tool-name-mapping.test.ts › createToolNameMapping › should handle mixed function and provider-defined tools +#[test] +fn should_handle_mixed_function_and_provider_defined_tools() { + let tools = [ + function_tool("function-tool"), + provider_tool("anthropic.computer-use", "provider-tool"), + ]; + let provider_tool_names = [("anthropic.computer-use", "computer_use")]; + + let mapping = create_tool_name_mapping(Some(&tools), &provider_tool_names); + + // Function tool should not be mapped + assert_eq!( + mapping.to_provider_tool_name("function-tool"), + "function-tool" + ); + assert_eq!( + mapping.to_custom_tool_name("function-tool"), + "function-tool" + ); + + // Provider-defined tool should be mapped + assert_eq!( + mapping.to_provider_tool_name("provider-tool"), + "computer_use" + ); + assert_eq!(mapping.to_custom_tool_name("computer_use"), "provider-tool"); +} diff --git a/aimux-provider-utils/src/tests/mod.rs b/aimux-provider-utils/src/tests/mod.rs new file mode 100644 index 000000000..68d07fec2 --- /dev/null +++ b/aimux-provider-utils/src/tests/mod.rs @@ -0,0 +1,5 @@ +//! Ported upstream `@ai-sdk/provider-utils` tests, one file per upstream +//! `.test.ts` (RFC-0044 L6). + +mod create_tool_name_mapping; +mod strip_file_extension; diff --git a/aimux-provider-utils/src/tests/strip_file_extension.rs b/aimux-provider-utils/src/tests/strip_file_extension.rs new file mode 100644 index 000000000..0b46d9974 --- /dev/null +++ b/aimux-provider-utils/src/tests/strip_file_extension.rs @@ -0,0 +1,28 @@ +//! Rust translation of `packages/provider-utils/src/strip-file-extension.test.ts` +//! (@ai-sdk/provider-utils). + +use crate::strip_file_extension; + +/// upstream: @ai-sdk/provider-utils src/strip-file-extension.test.ts › stripFileExtension › should strip the extension from a filename +#[test] +fn should_strip_the_extension_from_a_filename() { + assert_eq!(strip_file_extension("report.pdf"), "report"); +} + +/// upstream: @ai-sdk/provider-utils src/strip-file-extension.test.ts › stripFileExtension › should return the input when there is no extension +#[test] +fn should_return_the_input_when_there_is_no_extension() { + assert_eq!(strip_file_extension("report"), "report"); +} + +/// upstream: @ai-sdk/provider-utils src/strip-file-extension.test.ts › stripFileExtension › should strip all extension segments for multi-dot filenames +#[test] +fn should_strip_all_extension_segments_for_multi_dot_filenames() { + assert_eq!(strip_file_extension("archive.tar.gz"), "archive"); +} + +/// upstream: @ai-sdk/provider-utils src/strip-file-extension.test.ts › stripFileExtension › should strip a trailing dot +#[test] +fn should_strip_a_trailing_dot() { + assert_eq!(strip_file_extension("report."), "report"); +} From f36b1e594a4365366ce0e4ce0b9e2b715ebf819c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 21:39:25 +0000 Subject: [PATCH 2/6] refactor(anthropic): build the tool name mapping with create_tool_name_mapping The anthropic::tool_name_mapping module is removed. The language model builds the shared ToolNameMapping from upstream's providerToolNames table, the toolset names with get_anthropic_toolset_names and the dynamic code execution flag with has_dynamic_filtering_web_tool_without_code_execution, and passes the toolset names to the prompt converter as upstream's convertToAnthropicPrompt takes them. The converter tests build the mapping and toolset names as the upstream tests do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0116HPMoqaLfFr1TyqDq1hrW --- .../src/anthropic/convert_to_prompt.rs | 46 +++--- .../src/anthropic/language_model.rs | 113 +++++++++++++-- aimux-providers/src/anthropic/mod.rs | 1 - .../src/anthropic/tests/convert_to_prompt.rs | 77 +++++----- aimux-providers/src/anthropic/tests/mod.rs | 1 - .../src/anthropic/tests/tool_name_mapping.rs | 23 --- .../src/anthropic/tool_name_mapping.rs | 131 ------------------ 7 files changed, 175 insertions(+), 217 deletions(-) delete mode 100644 aimux-providers/src/anthropic/tests/tool_name_mapping.rs delete mode 100644 aimux-providers/src/anthropic/tool_name_mapping.rs diff --git a/aimux-providers/src/anthropic/convert_to_prompt.rs b/aimux-providers/src/anthropic/convert_to_prompt.rs index 88458fa62..296dd6f14 100644 --- a/aimux-providers/src/anthropic/convert_to_prompt.rs +++ b/aimux-providers/src/anthropic/convert_to_prompt.rs @@ -9,7 +9,7 @@ //! PDF beta and top-level media-type sniffing), and provider-referenced files. use indexmap::IndexSet; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use aimux_core::error::AiMuxError; use aimux_core::language_model_message::{ @@ -26,7 +26,7 @@ use serde_json::{Value, json}; use crate::anthropic::get_cache_control::CacheControlValidator; use crate::anthropic::language_model_options::{CANONICAL, anthropic_options}; -use crate::anthropic::tool_name_mapping::ToolNameMapping; +use aimux_provider_utils::ToolNameMapping; /// Beta header emitted when a PDF file part is present. const BETA_PDFS: &str = "pdfs-2024-09-25"; @@ -71,7 +71,12 @@ pub(super) fn convert_prompt_to_anthropic_full_fallible( prompt: &LanguageModelPrompt, send_reasoning: bool, ) -> Result { - convert_prompt_to_anthropic_full_with_tools(prompt, send_reasoning, &ToolNameMapping::default()) + convert_prompt_to_anthropic_full_with_tools( + prompt, + send_reasoning, + &ToolNameMapping::default(), + &HashMap::new(), + ) } /// [`convert_prompt_to_anthropic_full_fallible`] with the call's tool-name @@ -94,8 +99,9 @@ pub(super) fn convert_prompt_to_anthropic_full_with_tools( prompt: &LanguageModelPrompt, send_reasoning: bool, tool_names: &ToolNameMapping, + toolset_names: &HashMap, ) -> Result { - convert_prompt_for(prompt, send_reasoning, tool_names, CANONICAL) + convert_prompt_for(prompt, send_reasoning, tool_names, toolset_names, CANONICAL) } /// [`convert_prompt_to_anthropic_full_with_tools`] for a provider whose @@ -111,6 +117,7 @@ pub(super) fn convert_prompt_for( prompt: &LanguageModelPrompt, send_reasoning: bool, tool_names: &ToolNameMapping, + toolset_names: &HashMap, options_name: &str, ) -> Result { let mut validator = CacheControlValidator::for_options_name(options_name); @@ -118,6 +125,7 @@ pub(super) fn convert_prompt_for( prompt, send_reasoning, tool_names, + toolset_names, options_name, &mut validator, ) @@ -127,6 +135,7 @@ pub(super) fn convert_prompt_with_validator( prompt: &LanguageModelPrompt, send_reasoning: bool, tool_names: &ToolNameMapping, + toolset_names: &HashMap, options_name: &str, validator: &mut CacheControlValidator, ) -> Result { @@ -361,7 +370,7 @@ pub(super) fn convert_prompt_with_validator( idx + 1 == content.len(), provider_options.as_ref(), options_name, - tool_names, + toolset_names, )?); } } @@ -386,6 +395,7 @@ pub(super) fn convert_prompt_with_validator( part, send_reasoning, tool_names, + toolset_names, &mut betas, &mut warnings, validator, @@ -671,7 +681,7 @@ fn convert_tool_result( is_last_part: bool, message_provider_options: Option<&SharedProviderOptions>, options_name: &str, - tool_names: &ToolNameMapping, + toolset_names: &HashMap, ) -> Result { let ToolResultPart { tool_call_id, @@ -688,7 +698,7 @@ fn convert_tool_result( "type": "tool_result", "tool_use_id": tool_call_id, }); - if let Some(name) = toolset_name(tool_names, tool_name, provider_options.as_ref()) { + if let Some(name) = toolset_name(toolset_names, tool_name, provider_options.as_ref()) { block["toolset_name"] = json!(name); } block["content"] = content; @@ -724,6 +734,7 @@ fn convert_assistant_part( part: &AssistantPart, send_reasoning: bool, tool_names: &ToolNameMapping, + toolset_names: &HashMap, betas: &mut IndexSet, warnings: &mut Vec, validator: &mut CacheControlValidator, @@ -890,7 +901,7 @@ fn convert_assistant_part( }; let caller = tool_caller(provider_options.as_ref(), options_name); if let Some(toolset_name) = - toolset_name(tool_names, tool_name, provider_options.as_ref()) + toolset_name(toolset_names, tool_name, provider_options.as_ref()) { // A toolset member call: the `action` is the member tool // name. The input goes back as it is, `action` included, and @@ -1012,20 +1023,17 @@ fn collect_mcp_tool_use_ids<'a>( /// the canonical `anthropic` key is read, where the response side writes it /// (`getAnthropicToolsetName`, convert-to-anthropic-prompt.ts:1624-1641). fn toolset_name( - tool_names: &ToolNameMapping, + toolset_names: &HashMap, tool_name: &str, provider_options: Option<&SharedProviderOptions>, ) -> Option { - tool_names - .toolset_name(tool_name) - .map(str::to_string) - .or_else(|| { - provider_options? - .get(CANONICAL)? - .get("toolsetName")? - .as_str() - .map(str::to_string) - }) + toolset_names.get(tool_name).cloned().or_else(|| { + provider_options? + .get(CANONICAL)? + .get("toolsetName")? + .as_str() + .map(str::to_string) + }) } fn tool_caller( diff --git a/aimux-providers/src/anthropic/language_model.rs b/aimux-providers/src/anthropic/language_model.rs index 6db7bb0be..90056a246 100644 --- a/aimux-providers/src/anthropic/language_model.rs +++ b/aimux-providers/src/anthropic/language_model.rs @@ -31,7 +31,9 @@ use aimux_core::types::{ FinishReason, FinishReasonUnified, NamespacedKind, ProviderMetadata, ReasoningEffort, ResponseMetadata, Warning, }; -use aimux_provider_utils::{HttpRequest, Transformer, pipe_through}; +use aimux_provider_utils::{ + HttpRequest, ToolNameMapping, Transformer, create_tool_name_mapping, pipe_through, +}; use super::api::{ AnthropicResponse, AnthropicUsage, ContentBlock, DeltaBlock, FallbackModel, MessageDeltaBody, @@ -45,9 +47,92 @@ use super::language_model_options::{ }; use super::map_stop_reason::parse_stop_reason; use super::prepare_tools::{AnthropicTool, prepare_tools_with_validator}; -use super::tool_name_mapping::ToolNameMapping; use crate::stream::{Controller, Emit, FINISH_OTHER, StreamContext}; +/// The call's tool name mapping, with the provider tool names of +/// `doGenerate`/`doStream` in anthropic-language-model.ts. +pub(super) fn anthropic_tool_name_mapping(tools: Option<&[Tool]>) -> ToolNameMapping { + create_tool_name_mapping( + tools, + &[ + ("anthropic.code_execution_20250522", "code_execution"), + ("anthropic.code_execution_20250825", "code_execution"), + ("anthropic.code_execution_20260120", "code_execution"), + ("anthropic.computer_20241022", "computer"), + ("anthropic.computer_20250124", "computer"), + ("anthropic.computer_20251124", "computer"), + ("anthropic.computer_toolset_20260801", "computer"), + ("anthropic.text_editor_20241022", "str_replace_editor"), + ("anthropic.text_editor_20250124", "str_replace_editor"), + ( + "anthropic.text_editor_20250429", + "str_replace_based_edit_tool", + ), + ( + "anthropic.text_editor_20250728", + "str_replace_based_edit_tool", + ), + ("anthropic.bash_20241022", "bash"), + ("anthropic.bash_20250124", "bash"), + ("anthropic.memory_20250818", "memory"), + ("anthropic.web_search_20250305", "web_search"), + ("anthropic.web_search_20260209", "web_search"), + ("anthropic.web_search_20260318", "web_search"), + ("anthropic.web_fetch_20250910", "web_fetch"), + ("anthropic.web_fetch_20260209", "web_fetch"), + ("anthropic.web_fetch_20260318", "web_fetch"), + ( + "anthropic.tool_search_regex_20251119", + "tool_search_tool_regex", + ), + ( + "anthropic.tool_search_bm25_20251119", + "tool_search_tool_bm25", + ), + ("anthropic.advisor_20260301", "advisor"), + ], + ) +} + +/// `getAnthropicToolsetNames`: maps the custom tool names of toolset tools +/// (the computer toolset) to the toolset name that the Anthropic API uses in +/// `toolset_name`. +fn get_anthropic_toolset_names(tools: Option<&[Tool]>) -> HashMap { + let mut toolset_names = HashMap::new(); + for tool in tools.unwrap_or_default() { + if let Tool::Provider(tool) = tool + && tool.id == "anthropic.computer_toolset_20260801" + { + toolset_names.insert(tool.name.clone(), "computer".to_owned()); + } + } + toolset_names +} + +/// `hasDynamicFilteringWebToolWithoutCodeExecution`: a 2026 web search or +/// fetch tool may invoke code execution implicitly, so its calls are marked +/// dynamic unless the caller also passed a code execution tool. +fn has_dynamic_filtering_web_tool_without_code_execution(tools: Option<&[Tool]>) -> bool { + let provider_tool_ids = || { + tools + .unwrap_or_default() + .iter() + .filter_map(|tool| match tool { + Tool::Provider(tool) => Some(tool.id.as_str()), + Tool::Function(_) => None, + }) + }; + provider_tool_ids().any(|id| { + matches!( + id, + "anthropic.web_search_20260209" + | "anthropic.web_fetch_20260209" + | "anthropic.web_search_20260318" + | "anthropic.web_fetch_20260318" + ) + }) && !provider_tool_ids().any(|id| id.starts_with("anthropic.code_execution_")) +} + /// One call, ready to send. struct PreparedCall { http: HttpRequest, @@ -179,7 +264,8 @@ impl LanguageModel for AnthropicMessagesModel { call.body, call.warnings, &self.config, - &ToolNameMapping::new(options.tools.as_deref()), + &anthropic_tool_name_mapping(options.tools.as_deref()), + has_dynamic_filtering_web_tool_without_code_execution(options.tools.as_deref()), self.uses_custom_options(options), call.uses_json_response_tool, Self::citation_documents(options), @@ -875,6 +961,7 @@ pub(crate) fn parse_anthropic_content( _options_name: &str, blocks: &[ContentBlock], names: &ToolNameMapping, + mark_code_execution_dynamic: bool, uses_json_response_tool: bool, mut citation_documents: Vec, generate_id: &(dyn Fn() -> String + Send + Sync), @@ -1002,9 +1089,8 @@ pub(crate) fn parse_anthropic_content( tool_name: names.to_custom_tool_name(provider_name).to_string(), input: normalized_server_tool_input(name, input).to_string(), provider_executed: Some(true), - dynamic: (provider_name == "code_execution" - && names.mark_code_execution_dynamic()) - .then_some(true), + dynamic: (provider_name == "code_execution" && mark_code_execution_dynamic) + .then_some(true), provider_metadata: tool_call_caller_metadata(caller.as_ref(), CANONICAL), })); } @@ -1238,6 +1324,7 @@ pub(crate) async fn anthropic_generate_core( warnings: Vec, config: &AnthropicModelConfig, tool_names: &ToolNameMapping, + mark_code_execution_dynamic: bool, used_custom_options_key: bool, uses_json_response_tool: bool, citation_documents: Vec, @@ -1263,6 +1350,7 @@ pub(crate) async fn anthropic_generate_core( &config.provider_options_name, &data.content, tool_names, + mark_code_execution_dynamic, uses_json_response_tool, citation_documents, generate_id.as_ref(), @@ -1357,7 +1445,9 @@ pub(crate) async fn anthropic_stream_core( citation_documents: Vec, generate_id: std::sync::Arc String + Send + Sync>, ) -> Result { - let tool_names = ToolNameMapping::new(options.tools.as_deref()); + let tool_names = anthropic_tool_name_mapping(options.tools.as_deref()); + let mark_code_execution_dynamic = + has_dynamic_filtering_web_tool_without_code_execution(options.tools.as_deref()); let include_raw_chunks = options.include_raw_chunks == Some(true); let endpoint = request.url.clone(); let options_name = config.provider_options_name.clone(); @@ -1410,6 +1500,7 @@ pub(crate) async fn anthropic_stream_core( }, options_name, tool_names, + mark_code_execution_dynamic, used_custom_options_key, uses_json_response_tool, citation_documents, @@ -1434,6 +1525,7 @@ pub(super) struct AnthropicMessagesStream { ctx: StreamContext, options_name: String, tool_names: ToolNameMapping, + mark_code_execution_dynamic: bool, used_custom_options_key: bool, uses_json_response_tool: bool, citation_documents: Vec, @@ -1653,8 +1745,8 @@ impl AnthropicMessagesStream { .to_custom_tool_name(provider_name) .to_string(); let dynamic = (provider_name == "code_execution" - && self.tool_names.mark_code_execution_dynamic()) - .then_some(true); + && self.mark_code_execution_dynamic) + .then_some(true); let initial_input = initial_tool_input(&input); controller.enqueue(StreamPart::ToolInputStart { id: id.clone(), @@ -3066,7 +3158,8 @@ pub(crate) fn build_request_body_for( let conversion = convert_prompt_with_validator( &options.prompt, send_input_reasoning, - &ToolNameMapping::new(options.tools.as_deref()), + &anthropic_tool_name_mapping(options.tools.as_deref()), + &get_anthropic_toolset_names(options.tools.as_deref()), &profile.options_name, &mut cache_validator, )?; diff --git a/aimux-providers/src/anthropic/mod.rs b/aimux-providers/src/anthropic/mod.rs index c6d2f66f1..8c0f4ffb1 100644 --- a/aimux-providers/src/anthropic/mod.rs +++ b/aimux-providers/src/anthropic/mod.rs @@ -27,7 +27,6 @@ pub mod prepare_tools; mod provider; pub mod sanitize_json_schema; pub mod skills; -pub mod tool_name_mapping; pub use files::AnthropicFiles; pub use language_model::AnthropicMessagesModel; diff --git a/aimux-providers/src/anthropic/tests/convert_to_prompt.rs b/aimux-providers/src/anthropic/tests/convert_to_prompt.rs index aa0fd4093..a9d939c6b 100644 --- a/aimux-providers/src/anthropic/tests/convert_to_prompt.rs +++ b/aimux-providers/src/anthropic/tests/convert_to_prompt.rs @@ -9,7 +9,7 @@ //! into `LanguageModelPrompt`; `new URL(x)` is the URL string and //! `undefined`-valued keys are omitted. -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; use aimux_core::error::AiMuxError; use aimux_core::language_model_message::LanguageModelPrompt; @@ -20,8 +20,9 @@ use serde_json::{Value, json}; use crate::anthropic::convert_to_prompt::{ AnthropicPromptConversion, convert_prompt_to_anthropic_full_with_tools, }; +use crate::anthropic::language_model::anthropic_tool_name_mapping; use crate::anthropic::language_model_options::CANONICAL; -use crate::anthropic::tool_name_mapping::ToolNameMapping; +use aimux_provider_utils::ToolNameMapping; /// The V4 prompt written as upstream's JSON. fn prompt(value: Value) -> LanguageModelPrompt { @@ -29,25 +30,36 @@ fn prompt(value: Value) -> LanguageModelPrompt { } /// `createToolNameMapping({ tools, providerToolNames })` for the given V4 -/// tools (the provider tool names are aimux's built-in Anthropic table). +/// tools (the provider tool names are the language model's Anthropic table). fn mapping(tools: Value) -> ToolNameMapping { let tools: Vec = serde_json::from_value(tools).expect("valid tools"); - ToolNameMapping::new(Some(&tools)) + anthropic_tool_name_mapping(Some(&tools)) } fn try_convert_with( prompt_json: Value, send_reasoning: bool, tool_names: &ToolNameMapping, + toolset_names: &HashMap, ) -> Result { - convert_prompt_to_anthropic_full_with_tools(&prompt(prompt_json), send_reasoning, tool_names) + convert_prompt_to_anthropic_full_with_tools( + &prompt(prompt_json), + send_reasoning, + tool_names, + toolset_names, + ) } fn try_convert( prompt_json: Value, send_reasoning: bool, ) -> Result { - try_convert_with(prompt_json, send_reasoning, &ToolNameMapping::default()) + try_convert_with( + prompt_json, + send_reasoning, + &ToolNameMapping::default(), + &HashMap::new(), + ) } /// `convertToAnthropicPrompt` with the default (empty) tool name mapping. @@ -59,8 +71,10 @@ fn convert_with( prompt_json: Value, send_reasoning: bool, tool_names: &ToolNameMapping, + toolset_names: &HashMap, ) -> AnthropicPromptConversion { - try_convert_with(prompt_json, send_reasoning, tool_names).expect("conversion succeeds") + try_convert_with(prompt_json, send_reasoning, tool_names, toolset_names) + .expect("conversion succeeds") } /// A `Set` of betas. Upstream compares them with `toEqual(new Set(...))`, @@ -191,6 +205,7 @@ mod orphaned_programmatic_callers { &input, false, &ToolNameMapping::default(), + &HashMap::new(), ) .unwrap(); @@ -341,6 +356,7 @@ mod orphaned_programmatic_callers { "name": "run_code", "args": {}, }])), + &HashMap::new(), ); let content = &result.messages[0]["content"]; @@ -4334,24 +4350,24 @@ mod citations { mod toolsets { use super::*; - /// Upstream's `toolsetToolNameMapping` plus `toolsetNames: { [name]: - /// 'computer' }`. aimux derives the toolset names from the call's tools, - /// so each toolset name is a `computer_toolset_20260801` tool of that - /// name. - fn toolset_mapping(names: &[&str]) -> ToolNameMapping { - mapping(Value::Array( - names - .iter() - .map(|name| { - json!({ - "type": "provider", - "id": "anthropic.computer_toolset_20260801", - "name": name, - "args": {}, - }) - }) - .collect(), - )) + /// Upstream's `toolsetToolNameMapping`. + fn toolset_tool_name_mapping() -> ToolNameMapping { + let tools: Vec = serde_json::from_value(json!([{ + "type": "provider", + "id": "anthropic.computer_toolset_20260801", + "name": "computer", + "args": {}, + }])) + .expect("valid tools"); + aimux_provider_utils::create_tool_name_mapping( + Some(&tools), + &[("anthropic.computer_toolset_20260801", "computer")], + ) + } + + /// `toolsetNames: { [name]: 'computer' }`. + fn toolset_names(name: &str) -> HashMap { + HashMap::from([(name.to_owned(), "computer".to_owned())]) } /// upstream: @ai-sdk/anthropic src/convert-to-anthropic-prompt.test.ts › toolsets › should serialize toolset tool calls and results with toolset_name @@ -4379,7 +4395,8 @@ mod toolsets { }, ]), true, - &toolset_mapping(&["computer"]), + &toolset_tool_name_mapping(), + &toolset_names("computer"), ); assert_eq!( @@ -4467,11 +4484,6 @@ mod toolsets { (json!("invalid JSON"), "computer"), (json!({ "action": null }), "desktop"), ] { - let names: Vec<&str> = if tool_name == "computer" { - vec!["computer"] - } else { - vec!["computer", tool_name] - }; let result = convert_with( json!([ { @@ -4494,7 +4506,8 @@ mod toolsets { }, ]), true, - &toolset_mapping(&names), + &toolset_tool_name_mapping(), + &toolset_names(tool_name), ); let expected_input = if input.is_string() { diff --git a/aimux-providers/src/anthropic/tests/mod.rs b/aimux-providers/src/anthropic/tests/mod.rs index 02260006f..39f6d3005 100644 --- a/aimux-providers/src/anthropic/tests/mod.rs +++ b/aimux-providers/src/anthropic/tests/mod.rs @@ -13,4 +13,3 @@ mod remaining_support; #[allow(clippy::module_inception)] mod sanitize_json_schema; mod skills; -mod tool_name_mapping; diff --git a/aimux-providers/src/anthropic/tests/tool_name_mapping.rs b/aimux-providers/src/anthropic/tests/tool_name_mapping.rs deleted file mode 100644 index fc526c1fb..000000000 --- a/aimux-providers/src/anthropic/tests/tool_name_mapping.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! The unit tests of `tool_name_mapping.rs` (previously inline there). The -//! mapping is aimux's form of `createToolNameMapping` from -//! `@ai-sdk/provider-utils`, so no upstream test file of this package covers it. - -use crate::anthropic::tool_name_mapping::ToolNameMapping; -use aimux_core::tool::FunctionTool; -use aimux_core::tool::Tool; -use serde_json::json; - -#[test] -fn unmapped_names_pass_through() { - let mapping = ToolNameMapping::new(None); - assert_eq!(mapping.to_custom_tool_name("web_search"), "web_search"); - assert_eq!(mapping.to_provider_tool_name("get_weather"), "get_weather"); -} - -#[test] -fn function_tools_are_ignored() { - let tools = vec![Tool::Function(FunctionTool::new("web_search", json!({})))]; - let mapping = ToolNameMapping::new(Some(&tools)); - // A function tool named `web_search` must not hijack the provider name. - assert_eq!(mapping.to_custom_tool_name("web_search"), "web_search"); -} diff --git a/aimux-providers/src/anthropic/tool_name_mapping.rs b/aimux-providers/src/anthropic/tool_name_mapping.rs deleted file mode 100644 index bba9e910e..000000000 --- a/aimux-providers/src/anthropic/tool_name_mapping.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! Bidirectional mapping between user-facing tool names and Anthropic's -//! provider tool names. -//! -//! A [`ProviderTool`](aimux_core::tool::ProviderTool) carries both an `id` (`anthropic.web_search_20250305`) -//! and a `name` — the name the caller uses to refer to that tool in this -//! request. Anthropic's wire format uses its own fixed name (`web_search`), so -//! the two differ whenever the caller renames a provider tool. -//! -//! Both directions are needed: -//! - **response side** — a `web_search_tool_result` block names the tool -//! `web_search`; the caller expects to see their own name on the resulting -//! `ToolResult`. -//! - **prompt side** — a replayed `ToolResult` carries the caller's name; the -//! converter must recover the provider name to pick the right result block -//! type (`web_search_tool_result` vs `code_execution_tool_result` vs …). -//! -//! Mirrors `createToolNameMapping` in -//! `reference/vercel-ai/provider-utils/src/create-tool-name-mapping.ts`, with -//! the Anthropic id→name table from `anthropic-language-model.ts`. - -use std::collections::HashMap; - -use aimux_core::tool::Tool; - -/// Anthropic provider tool id → the tool name Anthropic uses on the wire. -/// -/// Kept in sync with the `name` field each id produces in -/// [`prepare_tools`](super::prepare_tools). -fn provider_tool_name(id: &str) -> Option<&'static str> { - Some(match id { - "anthropic.code_execution_20250522" - | "anthropic.code_execution_20250825" - | "anthropic.code_execution_20260120" => "code_execution", - "anthropic.computer_20241022" - | "anthropic.computer_20250124" - | "anthropic.computer_20251124" - | "anthropic.computer_toolset_20260801" => "computer", - "anthropic.text_editor_20241022" | "anthropic.text_editor_20250124" => "str_replace_editor", - "anthropic.text_editor_20250429" | "anthropic.text_editor_20250728" => { - "str_replace_based_edit_tool" - } - "anthropic.bash_20241022" | "anthropic.bash_20250124" => "bash", - "anthropic.memory_20250818" => "memory", - "anthropic.web_search_20250305" - | "anthropic.web_search_20260209" - | "anthropic.web_search_20260318" => "web_search", - "anthropic.web_fetch_20250910" - | "anthropic.web_fetch_20260209" - | "anthropic.web_fetch_20260318" => "web_fetch", - "anthropic.tool_search_regex_20251119" => "tool_search_tool_regex", - "anthropic.tool_search_bm25_20251119" => "tool_search_tool_bm25", - "anthropic.advisor_20260301" => "advisor", - _ => return None, - }) -} - -/// Bidirectional tool-name mapping for one model call. -/// -/// Only provider tools that the caller actually passed are mapped; every other -/// name passes through unchanged. -#[derive(Debug, Default, Clone)] -pub struct ToolNameMapping { - custom_to_provider: HashMap, - provider_to_custom: HashMap, - mark_code_execution_dynamic: bool, - toolset_names: HashMap, -} - -impl ToolNameMapping { - /// Build the mapping from the tools passed on this call. - #[must_use] - pub fn new(tools: Option<&[Tool]>) -> Self { - let mut mapping = ToolNameMapping::default(); - let mut has_web_tool_20260209 = false; - let mut has_code_execution = false; - for tool in tools.unwrap_or(&[]) { - if let Tool::Provider(pt) = tool { - if pt.id == "anthropic.computer_toolset_20260801" { - mapping - .toolset_names - .insert(pt.name.clone(), "computer".to_string()); - } - has_web_tool_20260209 |= matches!( - pt.id.as_str(), - "anthropic.web_search_20260209" - | "anthropic.web_fetch_20260209" - | "anthropic.web_search_20260318" - | "anthropic.web_fetch_20260318" - ); - has_code_execution |= pt.id.as_str().starts_with("anthropic.code_execution_"); - - if let Some(provider_name) = provider_tool_name(pt.id.as_str()) { - mapping - .custom_to_provider - .insert(pt.name.clone(), provider_name.to_string()); - mapping - .provider_to_custom - .insert(provider_name.to_string(), pt.name.clone()); - } - } - } - mapping.mark_code_execution_dynamic = has_web_tool_20260209 && !has_code_execution; - mapping - } - - pub(crate) fn toolset_name(&self, custom_name: &str) -> Option<&str> { - self.toolset_names.get(custom_name).map(String::as_str) - } - - /// Caller's name → Anthropic's wire name. Unmapped names pass through. - pub fn to_provider_tool_name<'a>(&'a self, custom_name: &'a str) -> &'a str { - self.custom_to_provider - .get(custom_name) - .map(String::as_str) - .unwrap_or(custom_name) - } - - /// Anthropic's wire name → caller's name. Unmapped names pass through. - pub fn to_custom_tool_name<'a>(&'a self, provider_name: &'a str) -> &'a str { - self.provider_to_custom - .get(provider_name) - .map(String::as_str) - .unwrap_or(provider_name) - } - - /// Whether code execution may be invoked implicitly by a 2026 web tool. - #[must_use] - pub fn mark_code_execution_dynamic(&self) -> bool { - self.mark_code_execution_dynamic - } -} From 350ed9b580f933a0a019d1adb27ee4b807b9360e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 21:39:25 +0000 Subject: [PATCH 3/6] refactor(providers): map OpenAI Responses and Google tool names with create_tool_name_mapping OpenAI Responses replaces its name HashMap with the shared mapping plus webSearchToolName, as openai-responses-language-model.ts does; Google's code execution tool name comes from the mapping with the google.code_execution entry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0116HPMoqaLfFr1TyqDq1hrW --- aimux-providers/src/google/language_model.rs | 24 ++-- .../responses/responses_language_model.rs | 130 ++++++++++-------- 2 files changed, 80 insertions(+), 74 deletions(-) diff --git a/aimux-providers/src/google/language_model.rs b/aimux-providers/src/google/language_model.rs index 50c96067c..f14fdb334 100644 --- a/aimux-providers/src/google/language_model.rs +++ b/aimux-providers/src/google/language_model.rs @@ -1702,20 +1702,18 @@ fn resolve_thinking( )])) } -/// Resolve the caller-facing name for Google's code execution provider tool. -/// Gemini always uses `code_execution` on the response wire, while callers -/// may rename the provider tool for a particular request. +/// Resolve the caller-facing name for Google's code execution provider tool: +/// `toolNameMapping.toCustomToolName('code_execution')` with the +/// `providerToolNames` of google-language-model.ts. Gemini always uses +/// `code_execution` on the response wire, while callers may rename the +/// provider tool for a particular request. pub(crate) fn code_execution_tool_name(tools: Option<&[Tool]>) -> String { - tools - .unwrap_or_default() - .iter() - .find_map(|tool| match tool { - Tool::Provider(provider) if provider.id == "google.code_execution" => { - Some(provider.name.clone()) - } - _ => None, - }) - .unwrap_or_else(|| "code_execution".to_string()) + aimux_provider_utils::create_tool_name_mapping( + tools, + &[("google.code_execution", "code_execution")], + ) + .to_custom_tool_name("code_execution") + .to_owned() } // ── source extraction ──────────────────────────────────────────────────────── diff --git a/aimux-providers/src/openai/responses/responses_language_model.rs b/aimux-providers/src/openai/responses/responses_language_model.rs index 5bda758e0..eb34a7896 100644 --- a/aimux-providers/src/openai/responses/responses_language_model.rs +++ b/aimux-providers/src/openai/responses/responses_language_model.rs @@ -47,7 +47,7 @@ use aimux_core::types::{ FinishReason, FinishReasonUnified, NamespacedKind, ProviderMetadata, ReasoningEffort, ResponseMetadata, Warning, }; -use aimux_provider_utils::{Transformer, pipe_through}; +use aimux_provider_utils::{ToolNameMapping, Transformer, create_tool_name_mapping, pipe_through}; use async_trait::async_trait; use futures::{Stream, StreamExt}; use serde_json::{Value, json}; @@ -315,7 +315,7 @@ impl LanguageModel for OpenAIResponsesModel { endpoint, body, response_headers, - tool_name_mapping(options), + ResponsesToolNames::new(options.tools.as_deref()), prompt_approval_tool_call_ids(options), ) } @@ -363,7 +363,7 @@ impl LanguageModel for OpenAIResponsesModel { body.clone(), response_headers.clone(), options.include_raw_chunks == Some(true), - tool_name_mapping(options), + ResponsesToolNames::new(options.tools.as_deref()), prompt_approval_tool_call_ids(options), )?; @@ -377,47 +377,66 @@ impl LanguageModel for OpenAIResponsesModel { } } -/// The provider tools whose names the AI SDK maps back to the caller's tool -/// names (`providerToolNames` in openai-responses-language-model.ts). -const PROVIDER_TOOL_NAMES: &[&str] = &[ - "code_interpreter", - "computer", - "file_search", - "image_generation", - "local_shell", - "shell", - "web_search", - "web_search_preview", - "mcp", - "apply_patch", - "tool_search", - "programmatic_tool_calling", -]; +/// The tool names of one call: the AI SDK's `toolNameMapping` (with the +/// `providerToolNames` of openai-responses-language-model.ts) and +/// `webSearchToolName`, the caller's name for the web search tool. +#[derive(Debug, Default, Clone)] +pub(crate) struct ResponsesToolNames { + tool_name_mapping: ToolNameMapping, + web_search_tool_name: Option, +} -fn tool_name_mapping(options: &CallOptions) -> HashMap { - let mut names = HashMap::new(); - for tool in options.tools.iter().flatten() { - if let aimux_core::tool::Tool::Provider(tool) = tool - && let Some(name) = tool.id.as_str().strip_prefix("openai.") - && PROVIDER_TOOL_NAMES.contains(&name) - { - names.insert(name.to_owned(), tool.name.clone()); +impl ResponsesToolNames { + fn new(tools: Option<&[Tool]>) -> Self { + let tool_name_mapping = create_tool_name_mapping( + tools, + &[ + ("openai.code_interpreter", "code_interpreter"), + ("openai.computer", "computer"), + ("openai.file_search", "file_search"), + ("openai.image_generation", "image_generation"), + ("openai.local_shell", "local_shell"), + ("openai.shell", "shell"), + ("openai.web_search", "web_search"), + ("openai.web_search_preview", "web_search_preview"), + ("openai.mcp", "mcp"), + ("openai.apply_patch", "apply_patch"), + ("openai.tool_search", "tool_search"), + ( + "openai.programmatic_tool_calling", + "programmatic_tool_calling", + ), + ], + ); + let web_search_tool_name = tools + .unwrap_or_default() + .iter() + .find_map(|tool| match tool { + Tool::Provider(tool) + if matches!( + tool.id.as_str(), + "openai.web_search" | "openai.web_search_preview" + ) => + { + Some(tool.name.clone()) + } + _ => None, + }); + Self { + tool_name_mapping, + web_search_tool_name, } } - if let Some(name) = options.tools.iter().flatten().find_map(|tool| match tool { - aimux_core::tool::Tool::Provider(tool) - if matches!( - tool.id.as_str(), - "openai.web_search" | "openai.web_search_preview" - ) => - { - Some(tool.name.clone()) - } - _ => None, - }) { - names.insert("web_search".into(), name); + + /// `toolNameMapping.toCustomToolName(name)`; a web search item asks for + /// `webSearchToolName ?? 'web_search'`. + fn to_custom_tool_name(&self, provider_tool_name: &str) -> String { + let name = match provider_tool_name { + "web_search" => self.web_search_tool_name.as_deref().unwrap_or("web_search"), + name => name, + }; + self.tool_name_mapping.to_custom_tool_name(name).to_owned() } - names } fn prompt_approval_tool_call_ids(options: &CallOptions) -> HashMap { @@ -1135,7 +1154,7 @@ pub fn build_responses_generate_result( request_url, body, response_headers, - HashMap::new(), + ResponsesToolNames::default(), HashMap::new(), ) } @@ -1149,7 +1168,7 @@ pub(crate) fn build_responses_generate_result_with_tools( request_url: String, body: Value, response_headers: HashMap, - tool_names: HashMap, + tool_names: ResponsesToolNames, prompt_approval_tool_call_ids: HashMap, ) -> Result { // Top-level error field. @@ -1921,7 +1940,7 @@ where request_body, response_headers, include_raw_chunks, - HashMap::new(), + ResponsesToolNames::default(), HashMap::new(), ) } @@ -1937,7 +1956,7 @@ pub(crate) fn build_responses_event_stream_with_tools( request_body: Value, response_headers: HashMap, include_raw_chunks: bool, - tool_names: HashMap, + tool_names: ResponsesToolNames, prompt_approval_tool_call_ids: HashMap, ) -> Result where @@ -1985,7 +2004,7 @@ struct OpenAIResponsesStream { ctx: StreamContext, provider_key: String, store_flag: bool, - tool_names: HashMap, + tool_names: ResponsesToolNames, prompt_approval_tool_call_ids: HashMap, shell_provider_executed: bool, /// Ongoing tool calls keyed by output_index. @@ -2246,10 +2265,7 @@ impl Transformer for OpenAIResponsesStream { "response.image_generation_call.partial_image" => { controller.enqueue(StreamPart::ToolResult(aimux_core::tool::ToolResult { tool_call_id: parsed["item_id"].as_str().unwrap_or_default().to_owned(), - tool_name: tool_names - .get("image_generation") - .map_or("image_generation", String::as_str) - .to_owned(), + tool_name: tool_names.to_custom_tool_name("image_generation"), result: json!({"result": parsed["partial_image_b64"]}), preliminary: Some(true), is_error: None, @@ -2940,7 +2956,7 @@ fn escaped_delta(value: &str) -> String { value[1..value.len() - 1].to_owned() } -fn provider_tool_start(item: &Value, names: &HashMap) -> Vec { +fn provider_tool_start(item: &Value, names: &ResponsesToolNames) -> Vec { let kind = item["type"].as_str().unwrap_or_default(); let canonical = match kind { "web_search_call" => "web_search", @@ -2952,10 +2968,7 @@ fn provider_tool_start(item: &Value, names: &HashMap) -> Vec "tool_search", _ => return Vec::new(), }; - let name = names - .get(canonical) - .map_or(canonical, String::as_str) - .to_owned(); + let name = names.to_custom_tool_name(canonical); let id = item .get("call_id") .filter(|v| !v.is_null()) @@ -3052,7 +3065,7 @@ fn hosted_shell(body: &Value) -> bool { pub(crate) fn provider_tool_content( part: &Value, provider_key: &str, - tool_names: &HashMap, + tool_names: &ResponsesToolNames, shell_provider_executed: bool, mcp_tool_call_id: Option<&str>, ) -> Vec { @@ -3065,12 +3078,7 @@ pub(crate) fn provider_tool_content( .expect("provider metadata must be an object"), ) }; - let name = |canonical: &str| { - tool_names - .get(canonical) - .cloned() - .unwrap_or_else(|| canonical.to_string()) - }; + let name = |canonical: &str| tool_names.to_custom_tool_name(canonical); let call = |id: &str, tool_name: String, input: String, executed, dynamic, meta| { GenerateContent::ToolCall(RawToolCall { tool_call_id: id.to_string(), From fa55883c0c5aa00ccd4a8800fe748c7f96b5f6c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 21:39:25 +0000 Subject: [PATCH 4/6] fix(bedrock): strip document name extensions from the first dot sanitizeDocumentName uses provider-utils stripFileExtension, which cuts at the first dot; the Rust copy cut at the last, so report.v2.pdf was sent as reportv2. The new test fails on master. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0116HPMoqaLfFr1TyqDq1hrW --- .../src/bedrock/convert_to_chat_messages.rs | 9 +------- .../bedrock/tests/convert_to_chat_messages.rs | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/aimux-providers/src/bedrock/convert_to_chat_messages.rs b/aimux-providers/src/bedrock/convert_to_chat_messages.rs index 6426d8ce8..a711210f1 100644 --- a/aimux-providers/src/bedrock/convert_to_chat_messages.rs +++ b/aimux-providers/src/bedrock/convert_to_chat_messages.rs @@ -481,13 +481,6 @@ fn sanitize_tool_name(name: &str) -> String { } } -fn strip_file_extension(name: &str) -> String { - match name.rfind('.') { - Some(i) => name[..i].to_string(), - _ => name.to_string(), - } -} - fn mime_to_image_format(media_type: &str) -> &'static str { match media_type { "image/jpeg" => "jpeg", @@ -539,7 +532,7 @@ fn flush_assistant(messages: &mut Vec, content: &mut Vec) { } fn sanitize_document_name(name: &str) -> String { - strip_file_extension(name) + aimux_provider_utils::strip_file_extension(name) .split_whitespace() .collect::>() .join(" ") diff --git a/aimux-providers/src/bedrock/tests/convert_to_chat_messages.rs b/aimux-providers/src/bedrock/tests/convert_to_chat_messages.rs index 37d15a571..466f4171a 100644 --- a/aimux-providers/src/bedrock/tests/convert_to_chat_messages.rs +++ b/aimux-providers/src/bedrock/tests/convert_to_chat_messages.rs @@ -311,6 +311,28 @@ mod user_messages { ); } + // Not an upstream case: `sanitizeDocumentName` cuts at the first dot + // (provider-utils `stripFileExtension`), so a multi-dot filename loses + // every extension segment. + #[test] + fn strips_every_extension_segment_of_a_multi_dot_filename() { + let result = convert(json!([{ + "role": "user", + "content": [{ + "type": "file", + "data": { "type": "data", "data": "AAECAw==" }, + "mediaType": "application/pdf", + "filename": "report.v2.final.pdf", + }], + }])) + .unwrap(); + + assert_eq!( + result["messages"][0]["content"][0]["document"]["name"], + "report" + ); + } + /// upstream: @ai-sdk/amazon-bedrock src/convert-to-amazon-bedrock-chat-messages.test.ts › user messages › should preserve filename without extension when provided #[test] fn should_preserve_filename_without_extension_when_provided() { From b82eaee54a3377156e42d5d0380076c6b0571904 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 21:39:25 +0000 Subject: [PATCH 5/6] docs(changelog): list the tool name mapping and strip_file_extension changes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0116HPMoqaLfFr1TyqDq1hrW --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03fbe94f5..7ffcb4a5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1333,6 +1333,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `speech::google_speech_input` become crate-internal, with no public replacement. `google::files` and `google::transcription` keep their paths. +- `aimux_providers::anthropic::tool_name_mapping` is removed. Its + `ToolNameMapping` is `aimux_provider_utils::ToolNameMapping`, built with + `aimux_provider_utils::create_tool_name_mapping(tools, provider_tool_names)` + and the provider tool id table, as the AI SDK's `createToolNameMapping`; + `to_provider_tool_name` and `to_custom_tool_name` are unchanged. + `mark_code_execution_dynamic` has no public replacement (RFC-0046 R1). + - `aimux_providers::mistral` follows the upstream `@ai-sdk/mistral` file layout (RFC-0046). The sub-modules `mistral::convert` and `mistral::embedding` are no longer public; use `MistralEmbeddingModel`, @@ -1398,9 +1405,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 built-in provider reads its key from when `create_provider` gets none, and `provider_default_base_url(name)`, the endpoint it calls when given no base URL (`None` when that is built from settings). +- `aimux_provider_utils::create_tool_name_mapping` with `ToolNameMapping`, + and `aimux_provider_utils::strip_file_extension`, as + `@ai-sdk/provider-utils` 5.0.55 (RFC-0046 R1). The Anthropic, OpenAI + Responses and Google models map provider tool names with the first, as + their upstream models do. ### Fixed +- Bedrock document names drop everything from the first dot of the filename, + as the AI SDK's `stripFileExtension` does: `report.v2.pdf` is sent as + `report`, not `report.v2`. - Anthropic AWS models are named `anthropic-aws.messages` by default, the provider name the AI SDK gives them, instead of `anthropic-aws`. A `name` setting still replaces it, and the providerOptions key stays `anthropic`. From 040c9eb1c332d6933397d21407f0f8ddbddd5452 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 21:39:39 +0000 Subject: [PATCH 6/6] docs(changelog): give the Bedrock name master sent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0116HPMoqaLfFr1TyqDq1hrW --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ffcb4a5a..371021174 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1415,7 +1415,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bedrock document names drop everything from the first dot of the filename, as the AI SDK's `stripFileExtension` does: `report.v2.pdf` is sent as - `report`, not `report.v2`. + `report`, not `reportv2`. - Anthropic AWS models are named `anthropic-aws.messages` by default, the provider name the AI SDK gives them, instead of `anthropic-aws`. A `name` setting still replaces it, and the providerOptions key stays `anthropic`.