Flatten MCP namespace tools for unsupported providers

This commit is contained in:
Mitsuhiro Kotake
2026-06-15 14:51:31 +09:00
parent dfd03ea01b
commit 6219b01856
24 changed files with 325 additions and 21 deletions
@@ -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(),
+1
View File
@@ -316,6 +316,7 @@ mod tests {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: true,
namespace_tools: None,
}
}
}
@@ -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 {
@@ -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<bool>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct StringMap {
@@ -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),
}
}
+5
View File
@@ -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 `<namespace>__<tool>` 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"
+1
View File
@@ -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));
+4 -13
View File
@@ -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))
}
}
+19
View File
@@ -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 `<namespace>__<tool>` 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 {
+12 -1
View File
@@ -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<Arc<dyn CoreToolRuntime>> {
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
// `<namespace>__<tool>` 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)]
+26
View File
@@ -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<dyn CoreToolRuntime>;
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;
+30 -6
View File
@@ -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 `<namespace>__<tool>`.
/// The canonical names match hooks and proxies, so the registry resolves them.
fn flatten_namespace_spec(namespace: ResponsesApiNamespace) -> Vec<ToolSpec> {
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,
@@ -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(
+3
View File
@@ -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");
+4
View File
@@ -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
@@ -2109,6 +2109,7 @@ fn websocket_provider_with_connect_timeout(
websocket_connect_timeout_ms,
requires_openai_auth: false,
supports_websockets: true,
namespace_tools: None,
}
}
+90
View File
@@ -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(()));
@@ -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()
@@ -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()
+1
View File
@@ -76,6 +76,7 @@ mod tests {
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
namespace_tools: None,
};
let telemetry =
+10
View File
@@ -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 `<namespace>__<tool>`
/// 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<bool>,
}
/// 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,
}
}
@@ -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,
}
);
}
@@ -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(
+50
View File
@@ -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<Arc<AuthManager>> {
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(