diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index cc93600f2d..9d8fc7d75b 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -670,6 +670,7 @@ mod thread_processor_behavior_tests { websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: true, + namespace_tools: None, }; let config_manager = ConfigManager::new( temp_dir.path().to_path_buf(), diff --git a/codex-rs/config/src/thread_config.rs b/codex-rs/config/src/thread_config.rs index 273680d5ec..3bfe2a8b56 100644 --- a/codex-rs/config/src/thread_config.rs +++ b/codex-rs/config/src/thread_config.rs @@ -316,6 +316,7 @@ mod tests { websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: true, + namespace_tools: None, } } } diff --git a/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.proto b/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.proto index 1efccfd1bf..1f57be5a7d 100644 --- a/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.proto +++ b/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.proto @@ -48,6 +48,7 @@ message ModelProvider { optional uint64 websocket_connect_timeout_ms = 15; bool requires_openai_auth = 16; bool supports_websockets = 17; + optional bool namespace_tools = 18; } message StringMap { diff --git a/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.rs b/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.rs index 30a76bc6b2..d8840ca39e 100644 --- a/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.rs +++ b/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.rs @@ -75,6 +75,8 @@ pub struct ModelProvider { pub requires_openai_auth: bool, #[prost(bool, tag = "17")] pub supports_websockets: bool, + #[prost(bool, optional, tag = "18")] + pub namespace_tools: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct StringMap { diff --git a/codex-rs/config/src/thread_config/remote.rs b/codex-rs/config/src/thread_config/remote.rs index 7a8e4ce88f..92fba4ca75 100644 --- a/codex-rs/config/src/thread_config/remote.rs +++ b/codex-rs/config/src/thread_config/remote.rs @@ -190,6 +190,7 @@ fn model_provider_from_proto( websocket_connect_timeout_ms: provider.websocket_connect_timeout_ms, requires_openai_auth: provider.requires_openai_auth, supports_websockets: provider.supports_websockets, + namespace_tools: provider.namespace_tools, }; Ok((id, info)) } @@ -217,6 +218,7 @@ fn model_provider_to_proto( websocket_connect_timeout_ms, requires_openai_auth, supports_websockets, + namespace_tools, } = provider; proto::ModelProvider { @@ -237,6 +239,7 @@ fn model_provider_to_proto( websocket_connect_timeout_ms, requires_openai_auth, supports_websockets, + namespace_tools, } } @@ -473,6 +476,7 @@ mod tests { websocket_connect_timeout_ms: Some(10_000), requires_openai_auth: false, supports_websockets: true, + namespace_tools: Some(false), }], features: HashMap::from([ ("plugins".to_string(), false), @@ -537,6 +541,7 @@ mod tests { requires_openai_auth: false, supports_websockets: true, aws: None, + namespace_tools: Some(false), } } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 31daf2feb4..da989aeab9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1531,6 +1531,11 @@ "description": "Friendly display name.", "type": "string" }, + "namespace_tools": { + "default": null, + "description": "Whether this provider understands Codex's `type: \"namespace\"` tool wrapper. When false, namespace tools are flattened to plain `__` functions. When unset, provider-specific defaults apply: built-in OpenAI, Azure, and Amazon Bedrock providers keep namespaces while other configured providers flatten them.", + "type": "boolean" + }, "query_params": { "additionalProperties": { "type": "string" diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index 550a7ec846..9c2b234875 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -235,6 +235,7 @@ fn should_use_remote_compact_task_for_azure_provider() { websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, }; assert!(should_use_remote_compact_task(&provider)); diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index efb32aab94..cd346fa39d 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -4,6 +4,7 @@ use std::time::Instant; use crate::function_tool::FunctionCallError; use crate::mcp_tool_call::handle_mcp_tool_call; use crate::original_image_detail::can_request_original_image_detail; +use crate::tools::canonical_flat_tool_name; use crate::tools::context::McpToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -27,7 +28,6 @@ use serde_json::Map; use serde_json::Value; const LEGACY_MCP_TOOL_NAME_PREFIX: &str = "mcp__"; -const MCP_TOOL_NAME_DELIMITER: &str = "__"; pub struct McpHandler { tool_info: ToolInfo, @@ -41,18 +41,9 @@ impl McpHandler { } fn hook_tool_name(&self) -> HookToolName { - HookToolName::new(ensure_mcp_prefix(&join_tool_name(&self.tool_name()))) - } -} - -fn join_tool_name(tool_name: &ToolName) -> String { - match tool_name.namespace.as_deref() { - Some(namespace) => { - let namespace = namespace.trim_end_matches('_'); - let name = tool_name.name.trim_start_matches('_'); - format!("{namespace}{MCP_TOOL_NAME_DELIMITER}{name}") - } - None => tool_name.name.clone(), + let tool_name = self.tool_name(); + let canonical = canonical_flat_tool_name(&tool_name); + HookToolName::new(ensure_mcp_prefix(&canonical)) } } diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index cd10d246db..60edc1968a 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -45,6 +45,25 @@ pub(crate) fn flat_tool_name(tool_name: &ToolName) -> Cow<'_, str> { } } +const NAMESPACED_TOOL_NAME_DELIMITER: &str = "__"; + +/// Joins a namespace and tool name into the canonical `__` form, +/// trimming stray underscores so the `__` delimiter is never doubled. +pub(in crate::tools) fn join_namespaced_tool_name(namespace: &str, name: &str) -> String { + let namespace = namespace.trim_end_matches('_'); + let name = name.trim_start_matches('_'); + format!("{namespace}{NAMESPACED_TOOL_NAME_DELIMITER}{name}") +} + +/// Canonical flat name with the `__` delimiter, matching what hooks emit and +/// proxies expect. Unlike `flat_tool_name`, namespace and tool are not joined raw. +pub(in crate::tools) fn canonical_flat_tool_name(tool_name: &ToolName) -> Cow<'_, str> { + match tool_name.namespace.as_deref() { + Some(namespace) => Cow::Owned(join_namespaced_tool_name(namespace, &tool_name.name)), + None => Cow::Borrowed(tool_name.name.as_str()), + } +} + pub(crate) fn tool_user_shell_type( user_shell: &crate::shell::Shell, ) -> codex_tools::ToolUserShellType { diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 76e3f5f341..f418d6a8d2 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -13,6 +13,7 @@ use crate::memory_usage::emit_metric_for_tool_read; use crate::sandbox_tags::permission_profile_policy_tag; use crate::sandbox_tags::permission_profile_sandbox_tag; use crate::session::turn_context::TurnContext; +use crate::tools::canonical_flat_tool_name; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; @@ -355,7 +356,17 @@ impl ToolRegistry { } fn tool(&self, name: &ToolName) -> Option> { - self.tools.get(name).map(Arc::clone) + if let Some(tool) = self.tools.get(name) { + return Some(Arc::clone(tool)); + } + // Fall back to matching by canonical flattened name so a flat + // `__` call (emitted for providers without namespace + // support, or flattened by a proxy) resolves to its namespaced runtime. + let canonical = canonical_flat_tool_name(name); + self.tools + .iter() + .find(|(key, _)| canonical_flat_tool_name(key) == canonical) + .map(|(_, tool)| Arc::clone(tool)) } #[cfg(test)] diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index 67613f1d08..a9b36fb0be 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -178,6 +178,32 @@ fn handler_looks_up_namespaced_aliases_explicitly() { ); } +#[test] +fn handler_resolves_flat_name_to_namespaced_runtime() { + // Runtime namespace shape: `mcp__mini` with no trailing `__`. + let namespaced_name = codex_tools::ToolName::namespaced("mcp__mini", "record_note"); + let handler = Arc::new(TestHandler { + tool_name: namespaced_name.clone(), + }) as Arc; + let registry = ToolRegistry::new(HashMap::from([( + namespaced_name.clone(), + Arc::clone(&handler), + )])); + + // The canonical flat name (what flattening emits, and what a proxy produces) + // resolves to the namespaced runtime. + let canonical = registry.tool(&codex_tools::ToolName::plain("mcp__mini__record_note")); + assert!(canonical.as_ref().is_some_and(|h| Arc::ptr_eq(h, &handler))); + + // Exact namespaced lookup still works, and unrelated names do not resolve. + assert!(registry.tool(&namespaced_name).is_some()); + assert!( + registry + .tool(&codex_tools::ToolName::plain("mcp__other__record_note")) + .is_none() + ); +} + #[tokio::test] async fn function_tools_expose_default_hook_payloads_and_rewrites() -> anyhow::Result<()> { let (session, turn) = crate::session::tests::make_session_and_context().await; diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 76c1e4389c..4492c95ff5 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -49,6 +49,7 @@ use crate::tools::handlers::view_image_spec::ViewImageToolOptions; use crate::tools::hosted_spec::WebSearchToolOptions; use crate::tools::hosted_spec::create_image_generation_tool; use crate::tools::hosted_spec::create_web_search_tool; +use crate::tools::join_namespaced_tool_name; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolRegistry; @@ -221,16 +222,39 @@ fn build_model_visible_specs_and_registry( specs.extend(hosted_specs); let registry = ToolRegistry::from_tools(runtimes); - let model_visible_specs = merge_into_namespaces(specs) - .into_iter() - .filter(|spec| { - namespace_tools_enabled(turn_context) || !matches!(spec, ToolSpec::Namespace(_)) - }) - .collect(); + let merged_specs = merge_into_namespaces(specs); + // Providers that do not understand the `type: "namespace"` wrapper get the + // namespaced tools flattened into plain functions rather than dropped. + let model_visible_specs = if namespace_tools_enabled(turn_context) { + merged_specs + } else { + merged_specs + .into_iter() + .flat_map(|spec| match spec { + ToolSpec::Namespace(namespace) => flatten_namespace_spec(namespace), + other => vec![other], + }) + .collect() + }; (model_visible_specs, registry) } +/// Flattens a namespace into plain `function` specs named `__`. +/// The canonical names match hooks and proxies, so the registry resolves them. +fn flatten_namespace_spec(namespace: ResponsesApiNamespace) -> Vec { + namespace + .tools + .into_iter() + .map(|tool| match tool { + ResponsesApiNamespaceTool::Function(mut function) => { + function.name = join_namespaced_tool_name(&namespace.name, &function.name); + ToolSpec::Function(function) + } + }) + .collect() +} + fn spec_for_model_request( turn_context: &TurnContext, exposure: ToolExposure, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 7a1e5d8aa9..02bbcce05a 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -264,6 +264,15 @@ fn set_web_search_mode(turn: &mut TurnContext, mode: WebSearchMode) { }); } +fn disable_namespace_tools(turn: &mut TurnContext) { + let mut provider_info = turn.config.model_provider.clone(); + provider_info.namespace_tools = Some(false); + update_config(turn, |config| { + config.model_provider = provider_info.clone(); + }); + turn.provider = create_model_provider(provider_info, turn.auth_manager.clone()); +} + fn use_chatgpt_auth(turn: &mut TurnContext) { turn.auth_manager = Some(AuthManager::from_auth_for_testing( CodexAuth::create_dummy_chatgpt_auth_for_testing(), @@ -735,6 +744,34 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() { ]); } +#[tokio::test] +async fn namespace_tools_disabled_flattens_mcp_namespace_into_function() { + // Use the runtime namespace shape (`mcp__mini`, no trailing `__`). + let probe = probe_with( + disable_namespace_tools, + ToolPlanInputs { + mcp_tools: Some(vec![mcp_tool("mini", "mcp__mini", "record_note")]), + ..ToolPlanInputs::default() + }, + ) + .await; + + // The MCP tool is exposed as a flat function with the canonical `__` + // delimiter, not a namespace wrapper and not a delimiter-less concatenation. + probe.assert_visible_contains(&["mcp__mini__record_note"]); + probe.assert_visible_lacks(&["mcp__minirecord_note"]); + assert!(probe.namespace_function_names("mcp__mini").is_empty()); + assert!(matches!( + probe.visible_spec("mcp__mini__record_note"), + ToolSpec::Function(_) + )); + + // The runtime is still registered under its namespaced identity. + probe.assert_registered_contains(&[ + &ToolName::namespaced("mcp__mini", "record_note").to_string() + ]); +} + #[tokio::test] async fn deferred_extension_tools_are_discoverable_with_tool_search() { let plan = probe_with( diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index cda5ffcdb0..b9cb1d2152 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -87,6 +87,7 @@ async fn responses_stream_includes_subagent_header_on_review() { websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, }; let codex_home = TempDir::new().expect("failed to create TempDir"); @@ -217,6 +218,7 @@ async fn responses_stream_includes_subagent_header_on_other() { websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, }; let codex_home = TempDir::new().expect("failed to create TempDir"); @@ -328,6 +330,7 @@ async fn responses_respects_model_info_overrides_from_config() { websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, }; let codex_home = TempDir::new().expect("failed to create TempDir"); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index c41593f516..0cf46abae9 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -927,6 +927,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, }; let codex_home = TempDir::new().unwrap(); @@ -2421,6 +2422,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, }; let codex_home = TempDir::new().unwrap(); @@ -3028,6 +3030,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, }; // Init session @@ -3117,6 +3120,7 @@ async fn env_var_overrides_loaded_auth() { websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, }; // Init session diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 4aac3dcbd8..c19e0f62b8 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -2109,6 +2109,7 @@ fn websocket_provider_with_connect_timeout( websocket_connect_timeout_ms, requires_openai_auth: false, supports_websockets: true, + namespace_tools: None, } } diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 961c66079a..f841f1788d 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -830,6 +830,96 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn stdio_mcp_tool_call_flattens_namespace_when_provider_disables_it() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + + let call_id = "flat-mcp-call"; + let server_name = "rmcp"; + let flat_tool_name = format!("mcp__{server_name}__echo"); + let call_mock = mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, &flat_tool_name, r#"{"message":"ping"}"#), + responses::ev_completed("resp-1"), + ]), + ) + .await; + let final_mock = mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "rmcp flat tool completed successfully."), + responses::ev_completed("resp-2"), + ]), + ) + .await; + + let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; + let fixture = test_codex() + .with_config(move |config| { + config.model_provider.namespace_tools = Some(false); + insert_mcp_server( + config, + server_name, + stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()), + TestMcpServerOptions { + environment_id: remote_aware_environment_id(), + ..Default::default() + }, + ); + }) + .build_with_remote_env(&server) + .await?; + + wait_for_mcp_server(&fixture.codex, server_name).await?; + + fixture + .submit_turn_with_permission_profile( + "call the flat rmcp echo tool", + PermissionProfile::read_only(), + ) + .await?; + + let request = call_mock.single_request(); + let request_body = request.body_json(); + let tools = request_body + .get("tools") + .and_then(Value::as_array) + .expect("Responses request should include tools"); + assert!( + tools.iter().any(|tool| { + tool.get("type").and_then(Value::as_str) == Some("function") + && tool.get("name").and_then(Value::as_str) == Some(flat_tool_name.as_str()) + }), + "MCP tool should be sent as a flat function: {:?}", + request_body + ); + assert!( + tools.iter().all(|tool| { + tool.get("type").and_then(Value::as_str) != Some("namespace") + || tool.get("name").and_then(Value::as_str) != Some("mcp__rmcp") + }), + "MCP namespace wrapper should be omitted: {:?}", + request_body + ); + + let output = final_mock + .single_request() + .function_call_output_text(call_id) + .expect("function_call_output should be present for flat MCP call"); + assert!( + output.contains("ECHOING: ping"), + "flat MCP call should execute against the namespaced runtime: {output}" + ); + + server.verify().await; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index 1870feab9f..5a6de14580 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -81,6 +81,7 @@ async fn continue_after_stream_error() { websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, }; let TestCodex { codex, .. } = test_codex() diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 6e028279b1..d2251afde0 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -64,6 +64,7 @@ async fn retries_on_early_close() { websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, }; let TestCodex { codex, .. } = test_codex() diff --git a/codex-rs/login/src/auth_env_telemetry.rs b/codex-rs/login/src/auth_env_telemetry.rs index 3cec5a4ce3..5f6d9ae642 100644 --- a/codex-rs/login/src/auth_env_telemetry.rs +++ b/codex-rs/login/src/auth_env_telemetry.rs @@ -76,6 +76,7 @@ mod tests { websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, }; let telemetry = diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index 5f0f840508..c7084832d8 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -134,6 +134,13 @@ pub struct ModelProviderInfo { /// Whether this provider supports the Responses API WebSocket transport. #[serde(default)] pub supports_websockets: bool, + /// Whether this provider understands Codex's `type: "namespace"` tool wrapper. + /// When false, namespace tools are flattened to plain `__` + /// functions. When unset, provider-specific defaults apply: built-in + /// OpenAI, Azure, and Amazon Bedrock providers keep namespaces while other + /// configured providers flatten them. + #[serde(default)] + pub namespace_tools: Option, } /// AWS SigV4 auth configuration for a model provider. @@ -355,6 +362,7 @@ impl ModelProviderInfo { websocket_connect_timeout_ms: None, requires_openai_auth: true, supports_websockets: true, + namespace_tools: None, } } @@ -385,6 +393,7 @@ impl ModelProviderInfo { websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, } } @@ -516,6 +525,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> M websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, } } diff --git a/codex-rs/model-provider-info/src/model_provider_info_tests.rs b/codex-rs/model-provider-info/src/model_provider_info_tests.rs index a303050dc1..8a5dbf4b6c 100644 --- a/codex-rs/model-provider-info/src/model_provider_info_tests.rs +++ b/codex-rs/model-provider-info/src/model_provider_info_tests.rs @@ -29,6 +29,7 @@ base_url = "http://localhost:11434/v1" websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); @@ -63,6 +64,7 @@ query_params = { api-version = "2025-04-01-preview" } websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); @@ -77,6 +79,7 @@ base_url = "https://example.com" env_key = "API_KEY" http_headers = { "X-Example-Header" = "example-value" } env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } +namespace_tools = false "#; let expected_provider = ModelProviderInfo { name: "Example".into(), @@ -100,6 +103,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: Some(false), }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); @@ -168,6 +172,7 @@ fn test_supports_remote_compaction_for_azure_name() { websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, }; assert!(provider.supports_remote_compaction()); @@ -193,6 +198,7 @@ fn test_supports_remote_compaction_for_non_openai_non_azure_provider() { websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, }; assert!(!provider.supports_remote_compaction()); @@ -276,6 +282,7 @@ fn test_create_amazon_bedrock_provider() { websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, } ); } diff --git a/codex-rs/model-provider/src/amazon_bedrock/mod.rs b/codex-rs/model-provider/src/amazon_bedrock/mod.rs index d44e7cbd02..5a1f4cae4a 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mod.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mod.rs @@ -101,8 +101,11 @@ impl ModelProvider for AmazonBedrockModelProvider { } fn capabilities(&self) -> ProviderCapabilities { + // Bedrock hosts OpenAI models that expand the namespace wrapper, so it + // defaults on. Mantle endpoints that reject the wrapper can opt out with + // an explicit `namespace_tools = false`. ProviderCapabilities { - namespace_tools: true, + namespace_tools: self.info.namespace_tools.unwrap_or(true), image_generation: false, web_search: false, } @@ -257,6 +260,15 @@ mod tests { ); } + #[test] + fn capabilities_namespace_tools_honors_explicit_opt_out() { + let mut info = ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None); + info.namespace_tools = Some(false); + let provider = AmazonBedrockModelProvider::new(info, /*auth_manager*/ None); + + assert!(!provider.capabilities().namespace_tools); + } + #[test] fn approval_review_preferred_model_uses_bedrock_gpt_5_4() { let provider = AmazonBedrockModelProvider::new( diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index f794d49f42..0f9a9e3fc1 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use codex_api::Provider; use codex_api::SharedAuthProvider; +use codex_api::is_azure_responses_provider; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_model_provider_info::ModelProviderInfo; @@ -42,6 +43,15 @@ impl Default for ProviderCapabilities { } } +/// Whether a provider advertises the `type: "namespace"` tool wrapper: an +/// explicit `namespace_tools` if set, otherwise providers known to support it. +fn resolve_namespace_tools(info: &ModelProviderInfo) -> bool { + info.namespace_tools.unwrap_or_else(|| { + info.requires_openai_auth + || is_azure_responses_provider(&info.name, info.base_url.as_deref()) + }) +} + /// Current app-visible account state for a model provider. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProviderAccountState { @@ -218,6 +228,13 @@ impl ModelProvider for ConfiguredModelProvider { &self.info } + fn capabilities(&self) -> ProviderCapabilities { + ProviderCapabilities { + namespace_tools: resolve_namespace_tools(&self.info), + ..ProviderCapabilities::default() + } + } + fn auth_manager(&self) -> Option> { self.auth_manager.clone() } @@ -367,6 +384,7 @@ mod tests { websocket_connect_timeout_ms: None, requires_openai_auth: false, supports_websockets: false, + namespace_tools: None, } } @@ -414,6 +432,38 @@ mod tests { assert_eq!(provider.capabilities(), ProviderCapabilities::default()); } + #[test] + fn configured_non_openai_provider_flattens_namespace_tools() { + let provider = create_model_provider( + provider_for("https://example.test/v1".to_string()), + /*auth_manager*/ None, + ); + + assert!(!provider.capabilities().namespace_tools); + } + + #[test] + fn configured_azure_provider_keeps_namespace_tools() { + let mut azure = provider_for("https://xxxxx.openai.azure.com/openai".to_string()); + azure.name = "azure".to_string(); + let provider = create_model_provider(azure, /*auth_manager*/ None); + + assert!(provider.capabilities().namespace_tools); + } + + #[test] + fn configured_provider_namespace_tools_honors_explicit_override() { + let mut non_openai = provider_for("https://example.test/v1".to_string()); + non_openai.namespace_tools = Some(true); + let provider = create_model_provider(non_openai, /*auth_manager*/ None); + assert!(provider.capabilities().namespace_tools); + + let mut openai = ModelProviderInfo::create_openai_provider(/*base_url*/ None); + openai.namespace_tools = Some(false); + let provider = create_model_provider(openai, /*auth_manager*/ None); + assert!(!provider.capabilities().namespace_tools); + } + #[test] fn configured_provider_uses_default_approval_review_preferred_model() { let provider = create_model_provider(