mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
Flatten namespace tools for providers without wrappers
This commit is contained in:
@@ -199,7 +199,10 @@ fn has_model_resume_override(
|
||||
.is_some_and(|overrides| overrides.contains_key("model_reasoning_effort"))
|
||||
}
|
||||
|
||||
fn validate_dynamic_tools(tools: &[DynamicToolSpec]) -> Result<(), String> {
|
||||
fn validate_dynamic_tools(
|
||||
tools: &[DynamicToolSpec],
|
||||
namespace_tools_enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
const DYNAMIC_TOOL_NAME_MAX_LEN: usize = 128;
|
||||
const DYNAMIC_TOOL_NAMESPACE_MAX_LEN: usize = 64;
|
||||
const DYNAMIC_TOOL_NAMESPACE_DESCRIPTION_MAX_LEN: usize = 1024;
|
||||
@@ -252,6 +255,8 @@ fn validate_dynamic_tools(tools: &[DynamicToolSpec]) -> Result<(), String> {
|
||||
tool: &'a DynamicToolFunctionSpec,
|
||||
namespace: Option<&str>,
|
||||
seen: &mut HashSet<&'a str>,
|
||||
seen_canonical_flat_names: &mut HashSet<String>,
|
||||
namespace_tools_enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let name = tool.name.trim();
|
||||
if name.is_empty() {
|
||||
@@ -275,6 +280,23 @@ fn validate_dynamic_tools(tools: &[DynamicToolSpec]) -> Result<(), String> {
|
||||
}
|
||||
return Err(format!("duplicate dynamic tool name: {name}"));
|
||||
}
|
||||
if !namespace_tools_enabled {
|
||||
let canonical_flat_name =
|
||||
codex_tools::ToolName::new(namespace.map(str::to_string), name)
|
||||
.canonical_flat_name()
|
||||
.into_owned();
|
||||
validate_dynamic_tool_identifier(
|
||||
&canonical_flat_name,
|
||||
"dynamic tool canonical flat name",
|
||||
DYNAMIC_TOOL_NAME_MAX_LEN,
|
||||
)?;
|
||||
if !seen_canonical_flat_names.insert(canonical_flat_name.clone()) {
|
||||
return Err(format!(
|
||||
"duplicate dynamic tool canonical flat name: {}",
|
||||
escape_identifier_for_error(&canonical_flat_name),
|
||||
));
|
||||
}
|
||||
}
|
||||
if tool.defer_loading && namespace.is_none() {
|
||||
return Err(format!(
|
||||
"deferred dynamic tool must include a namespace: {name}"
|
||||
@@ -291,10 +313,17 @@ fn validate_dynamic_tools(tools: &[DynamicToolSpec]) -> Result<(), String> {
|
||||
|
||||
let mut seen_tools = HashSet::new();
|
||||
let mut seen_namespaces = HashSet::new();
|
||||
let mut seen_canonical_flat_names = HashSet::new();
|
||||
for spec in tools {
|
||||
match spec {
|
||||
DynamicToolSpec::Function(tool) => {
|
||||
validate_dynamic_tool(tool, /*namespace*/ None, &mut seen_tools)?;
|
||||
validate_dynamic_tool(
|
||||
tool,
|
||||
/*namespace*/ None,
|
||||
&mut seen_tools,
|
||||
&mut seen_canonical_flat_names,
|
||||
namespace_tools_enabled,
|
||||
)?;
|
||||
}
|
||||
DynamicToolSpec::Namespace(namespace) => {
|
||||
let name = namespace.name.trim();
|
||||
@@ -338,7 +367,13 @@ fn validate_dynamic_tools(tools: &[DynamicToolSpec]) -> Result<(), String> {
|
||||
let mut seen_namespace_tools = HashSet::new();
|
||||
for tool in &namespace.tools {
|
||||
let DynamicToolNamespaceTool::Function(tool) = tool;
|
||||
validate_dynamic_tool(tool, Some(name), &mut seen_namespace_tools)?;
|
||||
validate_dynamic_tool(
|
||||
tool,
|
||||
Some(name),
|
||||
&mut seen_namespace_tools,
|
||||
&mut seen_canonical_flat_names,
|
||||
namespace_tools_enabled,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1146,7 +1181,12 @@ impl ThreadRequestProcessor {
|
||||
});
|
||||
let dynamic_tools = dynamic_tools.unwrap_or_default();
|
||||
if !dynamic_tools.is_empty() {
|
||||
validate_dynamic_tools(&dynamic_tools).map_err(invalid_request)?;
|
||||
let namespace_tools_enabled =
|
||||
create_model_provider(config.model_provider.clone(), /*auth_manager*/ None)
|
||||
.capabilities()
|
||||
.namespace_tools;
|
||||
validate_dynamic_tools(&dynamic_tools, namespace_tools_enabled)
|
||||
.map_err(invalid_request)?;
|
||||
}
|
||||
// Count callable functions rather than top-level namespace containers.
|
||||
let dynamic_tool_count: usize = dynamic_tools
|
||||
|
||||
@@ -181,7 +181,8 @@ mod thread_processor_behavior_tests {
|
||||
json!({"type": "null"}),
|
||||
/*defer_loading*/ false,
|
||||
)];
|
||||
let err = validate_dynamic_tools(&tools).expect_err("invalid schema");
|
||||
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
|
||||
.expect_err("invalid schema");
|
||||
assert!(err.contains("my_tool"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
@@ -194,7 +195,7 @@ mod thread_processor_behavior_tests {
|
||||
json!({"properties": {}}),
|
||||
/*defer_loading*/ false,
|
||||
)];
|
||||
validate_dynamic_tools(&tools).expect("valid schema");
|
||||
validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true).expect("valid schema");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -212,7 +213,7 @@ mod thread_processor_behavior_tests {
|
||||
}),
|
||||
/*defer_loading*/ false,
|
||||
)];
|
||||
validate_dynamic_tools(&tools).expect("valid schema");
|
||||
validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true).expect("valid schema");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -239,7 +240,67 @@ mod thread_processor_behavior_tests {
|
||||
/*defer_loading*/ true,
|
||||
),
|
||||
];
|
||||
validate_dynamic_tools(&tools).expect("valid schema");
|
||||
validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true).expect("valid schema");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_dynamic_tools_rejects_flat_name_collisions_without_namespace_tools() {
|
||||
let tools = vec![
|
||||
dynamic_tool(
|
||||
Some("a"),
|
||||
"b__c",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
/*defer_loading*/ true,
|
||||
),
|
||||
dynamic_tool(
|
||||
Some("a__b"),
|
||||
"c",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
/*defer_loading*/ true,
|
||||
),
|
||||
];
|
||||
|
||||
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ false)
|
||||
.expect_err("flat name collision");
|
||||
|
||||
assert!(err.contains("a__b__c"), "unexpected error: {err}");
|
||||
assert!(
|
||||
err.contains("canonical flat name"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_dynamic_tools_rejects_overlong_flat_names_without_namespace_tools() {
|
||||
let namespace = "a".repeat(64);
|
||||
let name = "b".repeat(63);
|
||||
let tools = vec![dynamic_tool(
|
||||
Some(&namespace),
|
||||
name,
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
/*defer_loading*/ true,
|
||||
)];
|
||||
|
||||
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ false)
|
||||
.expect_err("flat name too long");
|
||||
|
||||
assert!(
|
||||
err.contains("canonical flat name"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
assert!(err.contains("at most 128"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -254,7 +315,7 @@ mod thread_processor_behavior_tests {
|
||||
}),
|
||||
/*defer_loading*/ true,
|
||||
)];
|
||||
validate_dynamic_tools(&tools).expect("valid schema");
|
||||
validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true).expect("valid schema");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -279,7 +340,8 @@ mod thread_processor_behavior_tests {
|
||||
],
|
||||
},
|
||||
)];
|
||||
let err = validate_dynamic_tools(&tools).expect_err("duplicate name");
|
||||
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
|
||||
.expect_err("duplicate name");
|
||||
assert!(err.contains("codex_app"), "unexpected error: {err}");
|
||||
assert!(err.contains("my_tool"), "unexpected error: {err}");
|
||||
}
|
||||
@@ -336,7 +398,8 @@ mod thread_processor_behavior_tests {
|
||||
}),
|
||||
/*defer_loading*/ false,
|
||||
)];
|
||||
let err = validate_dynamic_tools(&tools).expect_err("empty namespace");
|
||||
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
|
||||
.expect_err("empty namespace");
|
||||
assert!(err.contains("namespace"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
@@ -352,7 +415,8 @@ mod thread_processor_behavior_tests {
|
||||
}),
|
||||
/*defer_loading*/ false,
|
||||
)];
|
||||
let err = validate_dynamic_tools(&tools).expect_err("reserved namespace");
|
||||
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
|
||||
.expect_err("reserved namespace");
|
||||
assert!(err.contains("reserved"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
@@ -368,7 +432,8 @@ mod thread_processor_behavior_tests {
|
||||
}),
|
||||
/*defer_loading*/ false,
|
||||
)];
|
||||
let err = validate_dynamic_tools(&tools).expect_err("invalid name");
|
||||
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
|
||||
.expect_err("invalid name");
|
||||
assert!(err.contains("lookup.ticket"), "unexpected error: {err}");
|
||||
assert!(
|
||||
err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"),
|
||||
@@ -388,7 +453,8 @@ mod thread_processor_behavior_tests {
|
||||
}),
|
||||
/*defer_loading*/ true,
|
||||
)];
|
||||
let err = validate_dynamic_tools(&tools).expect_err("invalid namespace");
|
||||
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
|
||||
.expect_err("invalid namespace");
|
||||
assert!(err.contains("codex.app"), "unexpected error: {err}");
|
||||
assert!(
|
||||
err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"),
|
||||
@@ -409,7 +475,8 @@ mod thread_processor_behavior_tests {
|
||||
}),
|
||||
/*defer_loading*/ false,
|
||||
)];
|
||||
let err = validate_dynamic_tools(&tools).expect_err("name too long");
|
||||
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
|
||||
.expect_err("name too long");
|
||||
assert!(err.contains("at most 128"), "unexpected error: {err}");
|
||||
assert!(err.contains(&long_name), "unexpected error: {err}");
|
||||
}
|
||||
@@ -427,7 +494,8 @@ mod thread_processor_behavior_tests {
|
||||
}),
|
||||
/*defer_loading*/ true,
|
||||
)];
|
||||
let err = validate_dynamic_tools(&tools).expect_err("namespace too long");
|
||||
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
|
||||
.expect_err("namespace too long");
|
||||
assert!(err.contains("at most 64"), "unexpected error: {err}");
|
||||
assert!(err.contains(&long_namespace), "unexpected error: {err}");
|
||||
|
||||
@@ -436,7 +504,8 @@ mod thread_processor_behavior_tests {
|
||||
};
|
||||
namespace.name = "tickets".to_string();
|
||||
namespace.description = "a".repeat(1025);
|
||||
let err = validate_dynamic_tools(&tools).expect_err("namespace description too long");
|
||||
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
|
||||
.expect_err("namespace description too long");
|
||||
assert!(err.contains("at most 1024"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
@@ -452,7 +521,8 @@ mod thread_processor_behavior_tests {
|
||||
}),
|
||||
/*defer_loading*/ true,
|
||||
)];
|
||||
let err = validate_dynamic_tools(&tools).expect_err("reserved Responses namespace");
|
||||
let err = validate_dynamic_tools(&tools, /*namespace_tools_enabled*/ true)
|
||||
.expect_err("reserved Responses namespace");
|
||||
assert!(err.contains("functions"), "unexpected error: {err}");
|
||||
assert!(err.contains("Responses API"), "unexpected error: {err}");
|
||||
}
|
||||
@@ -686,6 +756,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(),
|
||||
|
||||
@@ -45,6 +45,7 @@ pub fn write_mock_responses_config_toml(
|
||||
name = "{provider_name}"
|
||||
base_url = "{server_uri}/v1"
|
||||
wire_api = "responses"
|
||||
namespace_tools = true
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
supports_websockets = false
|
||||
|
||||
@@ -927,6 +927,7 @@ model_provider = "mock_provider"
|
||||
name = "Mock provider for test"
|
||||
base_url = "{server_uri}/v1"
|
||||
wire_api = "responses"
|
||||
namespace_tools = true
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#
|
||||
|
||||
@@ -678,6 +678,7 @@ include_instructions = true
|
||||
name = "Mock provider for test"
|
||||
base_url = "{responses_server_uri}/v1"
|
||||
wire_api = "responses"
|
||||
namespace_tools = true
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#
|
||||
|
||||
@@ -663,6 +663,25 @@ fn test_normalize_tools_disambiguates_sanitized_tool_name_collisions() {
|
||||
assert_eq!(callable_tool_names.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_tools_disambiguates_canonical_flat_name_collisions() {
|
||||
let tools = vec![create_test_tool("a", "b__c"), create_test_tool("a__b", "c")];
|
||||
|
||||
let model_tools =
|
||||
normalize_tools_for_model_with_prefix(tools, /*prefix_mcp_tool_names*/ true);
|
||||
|
||||
assert_eq!(model_tools.len(), 2);
|
||||
let canonical_flat_names = model_tools
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
tool.canonical_tool_name()
|
||||
.canonical_flat_name()
|
||||
.into_owned()
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
assert_eq!(canonical_flat_names.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_filter_allows_by_default() {
|
||||
let filter = ToolFilter::default();
|
||||
|
||||
@@ -376,8 +376,8 @@ fn unique_callable_parts(
|
||||
used_names: &mut HashSet<String>,
|
||||
reserved_len: usize,
|
||||
) -> (String, String) {
|
||||
let model_name = format!("{namespace}{tool_name}");
|
||||
if model_name.len() + reserved_len <= MAX_TOOL_NAME_LENGTH && used_names.insert(model_name) {
|
||||
let model_name = canonical_flat_tool_name(namespace, tool_name);
|
||||
if model_name.len() <= MAX_TOOL_NAME_LENGTH && used_names.insert(model_name) {
|
||||
return (namespace.to_string(), tool_name.to_string());
|
||||
}
|
||||
|
||||
@@ -390,10 +390,16 @@ fn unique_callable_parts(
|
||||
};
|
||||
let (namespace, tool_name) =
|
||||
fit_callable_parts_with_hash(namespace, tool_name, &hash_input, reserved_len);
|
||||
let model_name = format!("{namespace}{tool_name}");
|
||||
let model_name = canonical_flat_tool_name(&namespace, &tool_name);
|
||||
if used_names.insert(model_name) {
|
||||
return (namespace, tool_name);
|
||||
}
|
||||
attempt = attempt.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_flat_tool_name(namespace: &str, tool_name: &str) -> String {
|
||||
ToolName::namespaced(namespace, tool_name)
|
||||
.canonical_flat_name()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1665,6 +1665,11 @@
|
||||
"description": "Friendly display name.",
|
||||
"type": "string"
|
||||
},
|
||||
"namespace_tools": {
|
||||
"default": null,
|
||||
"description": "Whether this provider accepts Codex's `type: \"namespace\"` tool wrapper.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"query_params": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
|
||||
@@ -276,6 +276,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));
|
||||
|
||||
@@ -771,6 +771,30 @@ region = "us-west-2"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_config_applies_amazon_bedrock_namespace_tools_override() {
|
||||
let cfg = toml::from_str::<ConfigToml>(
|
||||
r#"
|
||||
model_provider = "amazon-bedrock"
|
||||
|
||||
[model_providers.amazon-bedrock]
|
||||
namespace_tools = false
|
||||
"#,
|
||||
)
|
||||
.expect("Amazon Bedrock namespace tool override should deserialize");
|
||||
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigOverrides::default(),
|
||||
tempdir().expect("tempdir").abs(),
|
||||
)
|
||||
.await
|
||||
.expect("load config");
|
||||
|
||||
assert_eq!(config.model_provider_id, "amazon-bedrock");
|
||||
assert_eq!(config.model_provider.namespace_tools, Some(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_config_rejects_unsupported_amazon_bedrock_overrides() {
|
||||
let cfg = toml::from_str::<ConfigToml>(
|
||||
@@ -800,7 +824,7 @@ region = "us-west-2"
|
||||
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(err.to_string().contains(
|
||||
"model_providers.amazon-bedrock only supports changing `aws.profile` and `aws.region`; other non-default provider fields are not supported"
|
||||
"model_providers.amazon-bedrock only supports changing `aws.profile`, `aws.region`, and `namespace_tools`; other non-default provider fields are not supported"
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -11,19 +11,21 @@ use bm25::Language;
|
||||
use bm25::SearchEngine;
|
||||
use bm25::SearchEngineBuilder;
|
||||
use codex_tools::LoadableToolSpec;
|
||||
use codex_tools::NamespaceToolSpecMode;
|
||||
use codex_tools::TOOL_SEARCH_DEFAULT_LIMIT;
|
||||
use codex_tools::TOOL_SEARCH_TOOL_NAME;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSearchEntry;
|
||||
use codex_tools::ToolSearchInfo;
|
||||
use codex_tools::ToolSpec;
|
||||
use codex_tools::coalesce_loadable_tool_specs;
|
||||
use codex_tools::serialize_loadable_tool_specs;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use tracing::instrument;
|
||||
|
||||
pub struct ToolSearchHandler {
|
||||
search_infos: Vec<ToolSearchInfo>,
|
||||
namespace_tool_spec_mode: NamespaceToolSpecMode,
|
||||
spec: ToolSpec,
|
||||
search_engine: SearchEngine<usize>,
|
||||
}
|
||||
@@ -35,20 +37,29 @@ pub(crate) struct ToolSearchHandlerCache {
|
||||
|
||||
impl ToolSearchHandlerCache {
|
||||
#[instrument(level = "trace", skip_all, fields(search_info_count = search_infos.len()))]
|
||||
pub(crate) fn get_or_build(&self, search_infos: Vec<ToolSearchInfo>) -> Arc<ToolSearchHandler> {
|
||||
pub(crate) fn get_or_build(
|
||||
&self,
|
||||
search_infos: Vec<ToolSearchInfo>,
|
||||
namespace_tool_spec_mode: NamespaceToolSpecMode,
|
||||
) -> Arc<ToolSearchHandler> {
|
||||
{
|
||||
let cached = self.cached();
|
||||
if let Some(cached) = cached.as_ref()
|
||||
&& cached.search_infos == search_infos
|
||||
&& cached.namespace_tool_spec_mode == namespace_tool_spec_mode
|
||||
{
|
||||
return Arc::clone(cached);
|
||||
}
|
||||
}
|
||||
|
||||
let handler = Arc::new(ToolSearchHandler::new(search_infos));
|
||||
let handler = Arc::new(ToolSearchHandler::new(
|
||||
search_infos,
|
||||
namespace_tool_spec_mode,
|
||||
));
|
||||
let mut cached = self.cached();
|
||||
if let Some(cached) = cached.as_ref()
|
||||
&& cached.search_infos == handler.search_infos
|
||||
&& cached.namespace_tool_spec_mode == handler.namespace_tool_spec_mode
|
||||
{
|
||||
return Arc::clone(cached);
|
||||
}
|
||||
@@ -71,7 +82,10 @@ impl ToolSearchHandler {
|
||||
skip_all,
|
||||
fields(search_info_count = search_infos.len())
|
||||
)]
|
||||
pub(crate) fn new(search_infos: Vec<ToolSearchInfo>) -> Self {
|
||||
pub(crate) fn new(
|
||||
search_infos: Vec<ToolSearchInfo>,
|
||||
namespace_tool_spec_mode: NamespaceToolSpecMode,
|
||||
) -> Self {
|
||||
let search_source_infos = search_infos
|
||||
.iter()
|
||||
.filter_map(|search_info| search_info.source_info.clone())
|
||||
@@ -88,6 +102,7 @@ impl ToolSearchHandler {
|
||||
|
||||
Self {
|
||||
search_infos,
|
||||
namespace_tool_spec_mode,
|
||||
spec,
|
||||
search_engine,
|
||||
}
|
||||
@@ -174,8 +189,9 @@ impl ToolSearchHandler {
|
||||
&self,
|
||||
results: impl IntoIterator<Item = &'a ToolSearchEntry>,
|
||||
) -> Result<Vec<LoadableToolSpec>, FunctionCallError> {
|
||||
Ok(coalesce_loadable_tool_specs(
|
||||
Ok(serialize_loadable_tool_specs(
|
||||
results.into_iter().map(|entry| entry.output.clone()),
|
||||
self.namespace_tool_spec_mode,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -205,17 +221,20 @@ mod tests {
|
||||
.expect("MCP handler should return search info"),
|
||||
];
|
||||
|
||||
let first = cache.get_or_build(search_infos.clone());
|
||||
let second = cache.get_or_build(search_infos.clone());
|
||||
let first = cache.get_or_build(search_infos.clone(), NamespaceToolSpecMode::Preserve);
|
||||
let second = cache.get_or_build(search_infos.clone(), NamespaceToolSpecMode::Preserve);
|
||||
assert!(Arc::ptr_eq(&first, &second));
|
||||
|
||||
let mut changed_search_infos = search_infos;
|
||||
let mut changed_search_infos = search_infos.clone();
|
||||
changed_search_infos[0]
|
||||
.entry
|
||||
.search_text
|
||||
.push_str(" changed");
|
||||
let changed = cache.get_or_build(changed_search_infos);
|
||||
let changed = cache.get_or_build(changed_search_infos, NamespaceToolSpecMode::Preserve);
|
||||
assert!(!Arc::ptr_eq(&first, &changed));
|
||||
|
||||
let flattened = cache.get_or_build(search_infos, NamespaceToolSpecMode::Flatten);
|
||||
assert!(!Arc::ptr_eq(&first, &flattened));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -257,7 +276,7 @@ mod tests {
|
||||
.search_info()
|
||||
.expect("dynamic handler should return search info")
|
||||
}));
|
||||
let handler = ToolSearchHandler::new(search_infos);
|
||||
let handler = ToolSearchHandler::new(search_infos, NamespaceToolSpecMode::Preserve);
|
||||
let results = [
|
||||
&handler.search_infos[0].entry,
|
||||
&handler.search_infos[2].entry,
|
||||
@@ -325,6 +344,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_results_flatten_mcp_namespaces_without_namespace_tools() {
|
||||
let search_info = McpHandler::new(tool_info("calendar", "create_event", "Create events"))
|
||||
.expect("MCP tool should convert")
|
||||
.search_info()
|
||||
.expect("MCP handler should return search info");
|
||||
let handler = ToolSearchHandler::new(vec![search_info], NamespaceToolSpecMode::Flatten);
|
||||
|
||||
let tools = handler
|
||||
.search_output_tools([&handler.search_infos[0].entry])
|
||||
.expect("MCP search output should serialize");
|
||||
|
||||
assert_eq!(
|
||||
tools,
|
||||
vec![LoadableToolSpec::Function(ResponsesApiTool {
|
||||
name: "mcp__calendar__create_event".to_string(),
|
||||
description: "Create events desktop tool".to_string(),
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: codex_tools::JsonSchema::object(
|
||||
Default::default(),
|
||||
/*required*/ None,
|
||||
Some(false.into()),
|
||||
),
|
||||
output_schema: None,
|
||||
})],
|
||||
);
|
||||
}
|
||||
|
||||
fn tool_info(server_name: &str, tool_name: &str, description_prefix: &str) -> ToolInfo {
|
||||
ToolInfo {
|
||||
server_name: server_name.to_string(),
|
||||
|
||||
@@ -29,6 +29,7 @@ use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_rollout::state_db;
|
||||
use codex_tools::NamespaceToolSpecMode;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSearchInfo;
|
||||
use codex_tools::ToolSpec;
|
||||
@@ -321,15 +322,28 @@ impl CoreToolRuntime for ExposureOverride {
|
||||
|
||||
pub struct ToolRegistry {
|
||||
tools: HashMap<ToolName, Arc<dyn CoreToolRuntime>>,
|
||||
flat_tool_aliases: HashMap<ToolName, Arc<dyn CoreToolRuntime>>,
|
||||
}
|
||||
|
||||
impl ToolRegistry {
|
||||
fn new(tools: HashMap<ToolName, Arc<dyn CoreToolRuntime>>) -> Self {
|
||||
Self { tools }
|
||||
Self {
|
||||
tools,
|
||||
flat_tool_aliases: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
pub(crate) fn from_tools(tools: impl IntoIterator<Item = Arc<dyn CoreToolRuntime>>) -> Self {
|
||||
Self::from_tools_with_namespace_tool_spec_mode(tools, NamespaceToolSpecMode::Preserve)
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
pub(crate) fn from_tools(tools: impl IntoIterator<Item = Arc<dyn CoreToolRuntime>>) -> Self {
|
||||
pub(crate) fn from_tools_with_namespace_tool_spec_mode(
|
||||
tools: impl IntoIterator<Item = Arc<dyn CoreToolRuntime>>,
|
||||
namespace_tool_spec_mode: NamespaceToolSpecMode,
|
||||
) -> Self {
|
||||
let mut tools_by_name = HashMap::new();
|
||||
for tool in tools {
|
||||
let name = tool.tool_name();
|
||||
@@ -339,7 +353,12 @@ impl ToolRegistry {
|
||||
}
|
||||
tools_by_name.insert(name, tool);
|
||||
}
|
||||
Self::new(tools_by_name)
|
||||
|
||||
let mut registry = Self::new(tools_by_name);
|
||||
if namespace_tool_spec_mode == NamespaceToolSpecMode::Flatten {
|
||||
registry.add_flat_tool_aliases();
|
||||
}
|
||||
registry
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -357,7 +376,34 @@ impl ToolRegistry {
|
||||
}
|
||||
|
||||
fn tool(&self, name: &ToolName) -> Option<Arc<dyn CoreToolRuntime>> {
|
||||
self.tools.get(name).map(Arc::clone)
|
||||
self.resolved_tool(name).map(|(_, tool)| tool)
|
||||
}
|
||||
|
||||
fn resolved_tool(&self, name: &ToolName) -> Option<(ToolName, Arc<dyn CoreToolRuntime>)> {
|
||||
self.tools
|
||||
.get(name)
|
||||
.map(|tool| (name.clone(), Arc::clone(tool)))
|
||||
.or_else(|| {
|
||||
self.flat_tool_aliases
|
||||
.get(name)
|
||||
.map(|tool| (tool.tool_name(), Arc::clone(tool)))
|
||||
})
|
||||
}
|
||||
|
||||
fn add_flat_tool_aliases(&mut self) {
|
||||
for (name, tool) in &self.tools {
|
||||
if name.namespace.is_none() {
|
||||
continue;
|
||||
}
|
||||
let flat_alias = ToolName::plain(name.canonical_flat_name().into_owned());
|
||||
if self.tools.contains_key(&flat_alias)
|
||||
|| self.flat_tool_aliases.contains_key(&flat_alias)
|
||||
{
|
||||
error_or_panic(format!("tool {flat_alias} already registered"));
|
||||
continue;
|
||||
}
|
||||
self.flat_tool_aliases.insert(flat_alias, Arc::clone(tool));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -439,8 +485,11 @@ impl ToolRegistry {
|
||||
}
|
||||
|
||||
let dispatch_trace = ToolDispatchTrace::start(&invocation);
|
||||
let tool = match self.tool(&tool_name) {
|
||||
Some(tool) => tool,
|
||||
let tool = match self.resolved_tool(&tool_name) {
|
||||
Some((resolved_tool_name, tool)) => {
|
||||
invocation.tool_name = resolved_tool_name;
|
||||
tool
|
||||
}
|
||||
None => {
|
||||
let message = unsupported_tool_call_message(&invocation.payload, &tool_name);
|
||||
let log_payload = invocation.payload.log_payload();
|
||||
|
||||
@@ -179,6 +179,31 @@ fn handler_looks_up_namespaced_aliases_explicitly() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_resolves_flat_aliases_when_namespace_specs_are_flattened() {
|
||||
let namespaced_name = codex_tools::ToolName::namespaced("mcp__calendar", "create_event");
|
||||
let handler = Arc::new(TestHandler {
|
||||
tool_name: namespaced_name.clone(),
|
||||
}) as Arc<dyn CoreToolRuntime>;
|
||||
let registry = ToolRegistry::from_tools_with_namespace_tool_spec_mode(
|
||||
[handler.clone()],
|
||||
codex_tools::NamespaceToolSpecMode::Flatten,
|
||||
);
|
||||
|
||||
let flat_name = codex_tools::ToolName::plain("mcp__calendar__create_event");
|
||||
let flat = registry.tool(&flat_name);
|
||||
let resolved = registry.resolved_tool(&flat_name);
|
||||
|
||||
assert!(
|
||||
flat.as_ref()
|
||||
.is_some_and(|flat| Arc::ptr_eq(flat, &handler))
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.map(|(tool_name, _)| tool_name),
|
||||
Some(namespaced_name)
|
||||
);
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
@@ -60,6 +60,7 @@ use crate::tools::registry::ToolRegistry;
|
||||
use crate::tools::registry::override_tool_exposure;
|
||||
use crate::tools::router::ToolRouter;
|
||||
use crate::tools::router::ToolRouterParams;
|
||||
use crate::util::error_or_panic;
|
||||
use codex_features::Feature;
|
||||
use codex_login::AuthManager;
|
||||
use codex_mcp::ToolInfo;
|
||||
@@ -72,6 +73,7 @@ use codex_protocol::openai_models::ToolMode;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_tools::NamespaceToolSpecMode;
|
||||
use codex_tools::ResponsesApiNamespace;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::TOOL_SEARCH_TOOL_NAME;
|
||||
@@ -87,6 +89,7 @@ use codex_tools::collect_code_mode_exec_prompt_tool_definitions;
|
||||
use codex_tools::collect_request_plugin_install_entries;
|
||||
use codex_tools::default_namespace_description;
|
||||
use codex_tools::request_user_input_available_modes;
|
||||
use codex_tools::serialize_tool_specs;
|
||||
use codex_tools::shell_command_backend_for_features;
|
||||
use codex_tools::shell_type_for_model_and_features;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -261,13 +264,13 @@ 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 namespace_tool_spec_mode = namespace_tool_spec_mode(turn_context);
|
||||
let registry =
|
||||
ToolRegistry::from_tools_with_namespace_tool_spec_mode(runtimes, namespace_tool_spec_mode);
|
||||
let model_visible_specs = dedupe_model_visible_function_names(serialize_tool_specs(
|
||||
merge_into_namespaces(specs),
|
||||
namespace_tool_spec_mode,
|
||||
));
|
||||
|
||||
(model_visible_specs, registry)
|
||||
}
|
||||
@@ -290,6 +293,23 @@ fn spec_for_model_request(
|
||||
}
|
||||
}
|
||||
|
||||
fn dedupe_model_visible_function_names(specs: Vec<ToolSpec>) -> Vec<ToolSpec> {
|
||||
let mut seen_function_names = HashSet::new();
|
||||
specs
|
||||
.into_iter()
|
||||
.filter(|spec| {
|
||||
let ToolSpec::Function(tool) = spec else {
|
||||
return true;
|
||||
};
|
||||
if seen_function_names.insert(tool.name.clone()) {
|
||||
return true;
|
||||
}
|
||||
error_or_panic(format!("tool {} already exposed to the model", tool.name));
|
||||
false
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec<ToolSpec> {
|
||||
let turn_context = context.step_context.turn.as_ref();
|
||||
@@ -329,7 +349,7 @@ fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec<ToolSpec> {
|
||||
}
|
||||
|
||||
pub(crate) fn search_tool_enabled(turn_context: &TurnContext) -> bool {
|
||||
turn_context.model_info.supports_search_tool && namespace_tools_enabled(turn_context)
|
||||
turn_context.model_info.supports_search_tool
|
||||
}
|
||||
|
||||
pub(crate) fn tool_suggest_enabled(turn_context: &TurnContext) -> bool {
|
||||
@@ -343,6 +363,14 @@ fn namespace_tools_enabled(turn_context: &TurnContext) -> bool {
|
||||
turn_context.provider.capabilities().namespace_tools
|
||||
}
|
||||
|
||||
fn namespace_tool_spec_mode(turn_context: &TurnContext) -> NamespaceToolSpecMode {
|
||||
if namespace_tools_enabled(turn_context) {
|
||||
NamespaceToolSpecMode::Preserve
|
||||
} else {
|
||||
NamespaceToolSpecMode::Flatten
|
||||
}
|
||||
}
|
||||
|
||||
fn multi_agent_v2_enabled(turn_context: &TurnContext) -> bool {
|
||||
turn_context.multi_agent_version == MultiAgentVersion::V2
|
||||
}
|
||||
@@ -403,7 +431,7 @@ fn image_generation_runtime_enabled(turn_context: &TurnContext) -> bool {
|
||||
}
|
||||
|
||||
fn standalone_image_generation_model_visible(turn_context: &TurnContext) -> bool {
|
||||
if !image_generation_runtime_enabled(turn_context) || !namespace_tools_enabled(turn_context) {
|
||||
if !image_generation_runtime_enabled(turn_context) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -624,13 +652,12 @@ fn add_tool_sources(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plann
|
||||
}
|
||||
|
||||
fn standalone_web_search_enabled(turn_context: &TurnContext) -> bool {
|
||||
namespace_tools_enabled(turn_context)
|
||||
&& (turn_context.model_info.use_responses_lite
|
||||
|| turn_context
|
||||
.config
|
||||
.features
|
||||
.get()
|
||||
.enabled(Feature::StandaloneWebSearch))
|
||||
turn_context.model_info.use_responses_lite
|
||||
|| turn_context
|
||||
.config
|
||||
.features
|
||||
.get()
|
||||
.enabled(Feature::StandaloneWebSearch)
|
||||
}
|
||||
|
||||
fn tool_environment_mode(step_context: &StepContext) -> ToolEnvironmentMode {
|
||||
@@ -798,9 +825,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
|
||||
} else {
|
||||
ToolExposure::Direct
|
||||
};
|
||||
let tool_namespace = namespace_tools_enabled(turn_context)
|
||||
.then_some(turn_context.config.multi_agent_v2.tool_namespace.as_deref())
|
||||
.flatten();
|
||||
let tool_namespace = turn_context.config.multi_agent_v2.tool_namespace.as_deref();
|
||||
let agent_type_description =
|
||||
agent_type_description(turn_context, context.default_agent_type_description);
|
||||
planned_tools.add_arc(override_tool_exposure(
|
||||
@@ -980,7 +1005,9 @@ fn append_tool_search_executor(
|
||||
return;
|
||||
}
|
||||
|
||||
let handler: PlannedRuntime = context.tool_search_handler_cache.get_or_build(search_infos);
|
||||
let handler: PlannedRuntime = context
|
||||
.tool_search_handler_cache
|
||||
.get_or_build(search_infos, namespace_tool_spec_mode(turn_context));
|
||||
planned_tools.add_arc(handler);
|
||||
}
|
||||
|
||||
|
||||
@@ -295,6 +295,15 @@ fn use_actor_authorized_provider(turn: &mut TurnContext) {
|
||||
turn.provider = create_model_provider(provider_info, /*auth_manager*/ None);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
struct WebRunExtensionTool;
|
||||
|
||||
impl ToolExecutor<ExtensionToolCall> for WebRunExtensionTool {
|
||||
@@ -769,6 +778,17 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() {
|
||||
&["lookup".to_string()]
|
||||
);
|
||||
|
||||
let flattened_direct_mcp = probe_with(
|
||||
disable_namespace_tools,
|
||||
ToolPlanInputs {
|
||||
mcp_tools: Some(vec![mcp_tool("direct", "mcp__direct", "lookup")]),
|
||||
..ToolPlanInputs::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
flattened_direct_mcp.assert_visible_contains(&["mcp__direct__lookup"]);
|
||||
flattened_direct_mcp.assert_visible_lacks(&["mcp__direct"]);
|
||||
|
||||
let searchable_mcp = ToolPlanInputs {
|
||||
deferred_mcp_tools: Some(vec![mcp_tool("searchable", "mcp__searchable", "lookup")]),
|
||||
..ToolPlanInputs::default()
|
||||
@@ -823,6 +843,19 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() {
|
||||
"tool_search",
|
||||
&ToolName::namespaced("mcp__searchable", "lookup").to_string(),
|
||||
]);
|
||||
|
||||
let flattened_searchable_mcp = probe_with(
|
||||
|turn| {
|
||||
turn.model_info.supports_search_tool = true;
|
||||
disable_namespace_tools(turn);
|
||||
},
|
||||
ToolPlanInputs {
|
||||
deferred_mcp_tools: Some(vec![mcp_tool("searchable", "mcp__searchable", "lookup")]),
|
||||
..ToolPlanInputs::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
flattened_searchable_mcp.assert_visible_contains(&["tool_search"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -88,6 +88,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");
|
||||
@@ -222,6 +223,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");
|
||||
@@ -337,6 +339,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");
|
||||
|
||||
@@ -1294,6 +1294,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();
|
||||
@@ -2907,6 +2908,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();
|
||||
@@ -3528,6 +3530,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
|
||||
@@ -3617,6 +3620,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
|
||||
|
||||
@@ -2119,6 +2119,7 @@ fn websocket_provider_with_connect_timeout(
|
||||
websocket_connect_timeout_ms,
|
||||
requires_openai_auth: false,
|
||||
supports_websockets: true,
|
||||
namespace_tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,13 @@ async fn responses_lite_uses_input_items_for_instructions_and_tools() -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_function_tool(tools: &[Value], tool_name: &str) -> bool {
|
||||
tools.iter().any(|tool| {
|
||||
tool.get("type").and_then(Value::as_str) == Some("function")
|
||||
&& tool.get("name").and_then(Value::as_str) == Some(tool_name)
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn responses_lite_prepares_images() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -288,6 +295,51 @@ async fn responses_lite_exposes_standalone_tools_for_actor_authorized_provider()
|
||||
let tools = additional_tools(&body)?;
|
||||
assert!(has_namespaced_tool(tools, "web", "run"));
|
||||
assert!(has_namespaced_tool(tools, "image_gen", "imagegen"));
|
||||
assert!(!has_hosted_tool(tools, "web_search"));
|
||||
assert!(!has_hosted_tool(tools, "image_generation"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn responses_lite_flattens_standalone_tools_without_namespace_tools() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let response_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
let extensions = responses_extensions(&auth);
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_auth(auth)
|
||||
.with_extensions(extensions)
|
||||
.with_model_info_override("gpt-5.4", |model_info| {
|
||||
model_info.use_responses_lite = true;
|
||||
configure_image_capable_model(model_info);
|
||||
})
|
||||
.with_config(|config| {
|
||||
configure_responses_tools(config);
|
||||
config.model_provider.namespace_tools = Some(false);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.submit_turn("Use flat standalone tools").await?;
|
||||
|
||||
let body = response_mock.single_request().body_json();
|
||||
assert!(body.get("tools").is_none());
|
||||
let tools = additional_tools(&body)?;
|
||||
assert!(has_function_tool(tools, "web__run"));
|
||||
assert!(has_function_tool(tools, "image_gen__imagegen"));
|
||||
assert!(!has_hosted_tool(tools, "web_search"));
|
||||
assert!(!has_hosted_tool(tools, "image_generation"));
|
||||
|
||||
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 =
|
||||
|
||||
@@ -138,6 +138,9 @@ pub struct ModelProviderInfo {
|
||||
/// Whether this provider supports the Responses API WebSocket transport.
|
||||
#[serde(default)]
|
||||
pub supports_websockets: bool,
|
||||
/// Whether this provider accepts Codex's `type: "namespace"` tool wrapper.
|
||||
#[serde(default)]
|
||||
pub namespace_tools: Option<bool>,
|
||||
}
|
||||
|
||||
/// AWS SigV4 auth configuration for a model provider.
|
||||
@@ -359,6 +362,7 @@ impl ModelProviderInfo {
|
||||
websocket_connect_timeout_ms: None,
|
||||
requires_openai_auth: true,
|
||||
supports_websockets: true,
|
||||
namespace_tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +393,7 @@ impl ModelProviderInfo {
|
||||
websocket_connect_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
supports_websockets: false,
|
||||
namespace_tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,7 +463,7 @@ pub fn built_in_model_providers(
|
||||
///
|
||||
/// Configured providers extend the built-in set. Built-in providers are not
|
||||
/// generally overridable, but the built-in Amazon Bedrock provider allows the
|
||||
/// user to set `aws.profile` and `aws.region`.
|
||||
/// user to set `aws.profile`, `aws.region`, and `namespace_tools`.
|
||||
pub fn merge_configured_model_providers(
|
||||
mut model_providers: HashMap<String, ModelProviderInfo>,
|
||||
configured_model_providers: HashMap<String, ModelProviderInfo>,
|
||||
@@ -466,22 +471,27 @@ pub fn merge_configured_model_providers(
|
||||
for (key, mut provider) in configured_model_providers {
|
||||
if key == AMAZON_BEDROCK_PROVIDER_ID {
|
||||
let aws_override = provider.aws.take();
|
||||
let namespace_tools_override = provider.namespace_tools.take();
|
||||
if provider != ModelProviderInfo::default() {
|
||||
return Err(format!(
|
||||
"model_providers.{AMAZON_BEDROCK_PROVIDER_ID} only supports changing \
|
||||
`aws.profile` and `aws.region`; other non-default provider fields are not supported"
|
||||
`aws.profile`, `aws.region`, and `namespace_tools`; other non-default provider fields are not supported"
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(aws_override) = aws_override
|
||||
&& let Some(built_in_provider) = model_providers.get_mut(AMAZON_BEDROCK_PROVIDER_ID)
|
||||
&& let Some(built_in_aws) = built_in_provider.aws.as_mut()
|
||||
{
|
||||
if let Some(profile) = aws_override.profile {
|
||||
built_in_aws.profile = Some(profile);
|
||||
if let Some(built_in_provider) = model_providers.get_mut(AMAZON_BEDROCK_PROVIDER_ID) {
|
||||
if namespace_tools_override.is_some() {
|
||||
built_in_provider.namespace_tools = namespace_tools_override;
|
||||
}
|
||||
if let Some(region) = aws_override.region {
|
||||
built_in_aws.region = Some(region);
|
||||
if let Some(aws_override) = aws_override
|
||||
&& let Some(built_in_aws) = built_in_provider.aws.as_mut()
|
||||
{
|
||||
if let Some(profile) = aws_override.profile {
|
||||
built_in_aws.profile = Some(profile);
|
||||
}
|
||||
if let Some(region) = aws_override.region {
|
||||
built_in_aws.region = Some(region);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -530,6 +540,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();
|
||||
@@ -100,6 +102,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: None,
|
||||
};
|
||||
|
||||
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
||||
@@ -168,6 +171,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 +197,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());
|
||||
@@ -301,6 +306,7 @@ fn test_create_amazon_bedrock_provider() {
|
||||
websocket_connect_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
supports_websockets: false,
|
||||
namespace_tools: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -405,7 +411,7 @@ fn test_merge_configured_model_providers_rejects_amazon_bedrock_non_default_fiel
|
||||
configured_model_providers,
|
||||
),
|
||||
Err(
|
||||
"model_providers.amazon-bedrock only supports changing `aws.profile` and `aws.region`; other non-default provider fields are not supported"
|
||||
"model_providers.amazon-bedrock only supports changing `aws.profile`, `aws.region`, and `namespace_tools`; other non-default provider fields are not supported"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
|
||||
@@ -106,7 +106,7 @@ impl ModelProvider for AmazonBedrockModelProvider {
|
||||
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
namespace_tools: true,
|
||||
namespace_tools: self.info.namespace_tools.unwrap_or(true),
|
||||
image_generation: false,
|
||||
web_search: false,
|
||||
}
|
||||
@@ -292,6 +292,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(
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::sync::Arc;
|
||||
use codex_api::ApiError;
|
||||
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;
|
||||
@@ -47,6 +48,13 @@ impl Default for ProviderCapabilities {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -248,6 +256,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()
|
||||
}
|
||||
@@ -399,6 +414,7 @@ mod tests {
|
||||
websocket_connect_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
supports_websockets: false,
|
||||
namespace_tools: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,6 +481,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(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::borrow::Cow;
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
|
||||
const NAMESPACED_TOOL_NAME_DELIMITER: &str = "__";
|
||||
|
||||
/// Identifies a callable tool, preserving the namespace split when the model
|
||||
/// provides one.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||
@@ -32,6 +35,20 @@ impl ToolName {
|
||||
namespace: Some(namespace.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical flat name used when a boundary cannot preserve the namespace split.
|
||||
pub fn canonical_flat_name(&self) -> Cow<'_, str> {
|
||||
match self.namespace.as_deref() {
|
||||
Some(namespace) => Cow::Owned(join_namespaced_tool_name(namespace, &self.name)),
|
||||
None => Cow::Borrowed(self.name.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}")
|
||||
}
|
||||
|
||||
impl fmt::Display for ToolName {
|
||||
@@ -74,3 +91,21 @@ impl From<&str> for ToolName {
|
||||
Self::plain(name)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn canonical_flat_name_inserts_one_namespace_delimiter() {
|
||||
assert_eq!(
|
||||
ToolName::namespaced("a__b", "__c")
|
||||
.canonical_flat_name()
|
||||
.as_ref(),
|
||||
"a__b__c"
|
||||
);
|
||||
assert_eq!(ToolName::plain("plain").canonical_flat_name(), "plain");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,14 +53,17 @@ pub use response_history::truncate_assistant_output_text_to_token_budget;
|
||||
pub use responses_api::FreeformTool;
|
||||
pub use responses_api::FreeformToolFormat;
|
||||
pub use responses_api::LoadableToolSpec;
|
||||
pub use responses_api::NamespaceToolSpecMode;
|
||||
pub use responses_api::ResponsesApiNamespace;
|
||||
pub use responses_api::ResponsesApiNamespaceTool;
|
||||
pub use responses_api::ResponsesApiTool;
|
||||
pub use responses_api::coalesce_loadable_tool_specs;
|
||||
pub use responses_api::default_namespace_description;
|
||||
pub use responses_api::dynamic_tool_to_responses_api_tool;
|
||||
pub use responses_api::flatten_responses_api_namespace;
|
||||
pub use responses_api::mcp_tool_to_deferred_responses_api_tool;
|
||||
pub use responses_api::mcp_tool_to_responses_api_tool;
|
||||
pub use responses_api::serialize_loadable_tool_specs;
|
||||
pub use responses_api::tool_definition_to_responses_api_tool;
|
||||
pub use tool_call::ConversationHistory;
|
||||
pub use tool_call::ExtensionTurnItem;
|
||||
@@ -105,3 +108,4 @@ pub use tool_spec::ResponsesApiWebSearchFilters;
|
||||
pub use tool_spec::ResponsesApiWebSearchUserLocation;
|
||||
pub use tool_spec::ToolSpec;
|
||||
pub use tool_spec::create_tools_json_for_responses_api;
|
||||
pub use tool_spec::serialize_tool_specs;
|
||||
|
||||
@@ -48,6 +48,12 @@ pub enum LoadableToolSpec {
|
||||
Namespace(ResponsesApiNamespace),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NamespaceToolSpecMode {
|
||||
Preserve,
|
||||
Flatten,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct ResponsesApiNamespace {
|
||||
pub name: String,
|
||||
@@ -104,6 +110,43 @@ pub fn coalesce_loadable_tool_specs(
|
||||
coalesced_specs
|
||||
}
|
||||
|
||||
pub fn serialize_loadable_tool_specs(
|
||||
specs: impl IntoIterator<Item = LoadableToolSpec>,
|
||||
namespace_tool_spec_mode: NamespaceToolSpecMode,
|
||||
) -> Vec<LoadableToolSpec> {
|
||||
let specs = coalesce_loadable_tool_specs(specs);
|
||||
match namespace_tool_spec_mode {
|
||||
NamespaceToolSpecMode::Preserve => specs,
|
||||
NamespaceToolSpecMode::Flatten => specs
|
||||
.into_iter()
|
||||
.flat_map(|spec| match spec {
|
||||
LoadableToolSpec::Function(tool) => vec![LoadableToolSpec::Function(tool)],
|
||||
LoadableToolSpec::Namespace(namespace) => {
|
||||
flatten_responses_api_namespace(namespace)
|
||||
.into_iter()
|
||||
.map(LoadableToolSpec::Function)
|
||||
.collect()
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn flatten_responses_api_namespace(namespace: ResponsesApiNamespace) -> Vec<ResponsesApiTool> {
|
||||
namespace
|
||||
.tools
|
||||
.into_iter()
|
||||
.map(|tool| match tool {
|
||||
ResponsesApiNamespaceTool::Function(mut tool) => {
|
||||
tool.name = ToolName::namespaced(namespace.name.as_str(), tool.name)
|
||||
.canonical_flat_name()
|
||||
.into_owned();
|
||||
tool
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn mcp_tool_to_responses_api_tool(
|
||||
tool_name: &ToolName,
|
||||
tool: &rmcp::model::Tool,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use crate::FreeformTool;
|
||||
use crate::JsonSchema;
|
||||
use crate::LoadableToolSpec;
|
||||
use crate::NamespaceToolSpecMode;
|
||||
use crate::ResponsesApiNamespace;
|
||||
use crate::ResponsesApiTool;
|
||||
use crate::flatten_responses_api_namespace;
|
||||
use codex_protocol::config_types::WebSearchContextSize;
|
||||
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
|
||||
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
|
||||
@@ -74,6 +76,25 @@ impl From<LoadableToolSpec> for ToolSpec {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serialize_tool_specs(
|
||||
specs: impl IntoIterator<Item = ToolSpec>,
|
||||
namespace_tool_spec_mode: NamespaceToolSpecMode,
|
||||
) -> Vec<ToolSpec> {
|
||||
match namespace_tool_spec_mode {
|
||||
NamespaceToolSpecMode::Preserve => specs.into_iter().collect(),
|
||||
NamespaceToolSpecMode::Flatten => specs
|
||||
.into_iter()
|
||||
.flat_map(|spec| match spec {
|
||||
ToolSpec::Namespace(namespace) => flatten_responses_api_namespace(namespace)
|
||||
.into_iter()
|
||||
.map(ToolSpec::Function)
|
||||
.collect(),
|
||||
spec => vec![spec],
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns JSON values that are compatible with Function Calling in the
|
||||
/// Responses API:
|
||||
/// https://platform.openai.com/docs/guides/function-calling?api-mode=responses
|
||||
|
||||
@@ -6,9 +6,11 @@ use crate::AdditionalProperties;
|
||||
use crate::FreeformTool;
|
||||
use crate::FreeformToolFormat;
|
||||
use crate::JsonSchema;
|
||||
use crate::NamespaceToolSpecMode;
|
||||
use crate::ResponsesApiNamespaceTool;
|
||||
use crate::ResponsesApiTool;
|
||||
use crate::create_tools_json_for_responses_api;
|
||||
use crate::serialize_tool_specs;
|
||||
use codex_protocol::config_types::WebSearchContextSize;
|
||||
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
|
||||
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
|
||||
@@ -195,6 +197,37 @@ fn namespace_tool_spec_serializes_expected_wire_shape() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_tool_specs_flatten_to_canonical_function_names() {
|
||||
let specs = serialize_tool_specs(
|
||||
[ToolSpec::Namespace(ResponsesApiNamespace {
|
||||
name: "mcp__demo__".to_string(),
|
||||
description: "Demo tools".to_string(),
|
||||
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
|
||||
name: "__lookup_order".to_string(),
|
||||
description: "Look up an order".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::default(),
|
||||
output_schema: None,
|
||||
})],
|
||||
})],
|
||||
NamespaceToolSpecMode::Flatten,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
specs,
|
||||
vec![ToolSpec::Function(ResponsesApiTool {
|
||||
name: "mcp__demo__lookup_order".to_string(),
|
||||
description: "Look up an order".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::default(),
|
||||
output_schema: None,
|
||||
})]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_search_tool_spec_serializes_expected_wire_shape() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user