mirror of
https://github.com/openai/codex.git
synced 2026-10-25 18:28:38 +00:00
Flatten MCP namespace tools for unsupported providers
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -76,6 +76,7 @@ mod tests {
|
||||
websocket_connect_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
supports_websockets: false,
|
||||
namespace_tools: None,
|
||||
};
|
||||
|
||||
let telemetry =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user