diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 50dcff8e7..021e1cb02 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -28,6 +28,7 @@ pub use mock_model_server::create_mock_responses_server_repeating_assistant; pub use mock_model_server::create_mock_responses_server_sequence; pub use mock_model_server::create_mock_responses_server_sequence_unchecked; pub use models_cache::write_mock_provider_models_cache; +pub use models_cache::write_mock_provider_models_cache_with_auth; pub use models_cache::write_models_cache; pub use models_cache::write_models_cache_with_models; pub use responses::create_apply_patch_sse_response; diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 902bc376f..6b4021896 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -1,6 +1,8 @@ use chrono::DateTime; use chrono::Utc; +use codex_config::types::AuthCredentialsStoreMode; use codex_core::test_support::all_model_presets; +use codex_login::AuthManager; use codex_model_provider::model_cache_key_for_provider; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::OPENAI_PROVIDER_ID; @@ -135,6 +137,29 @@ pub fn write_mock_provider_models_cache(codex_home: &Path) -> std::io::Result<() write_models_cache_for_provider(codex_home, &provider_cache_key) } +pub async fn write_mock_provider_models_cache_with_auth(codex_home: &Path) -> std::io::Result<()> { + let provider_info = mock_provider_info_from_config(codex_home); + let auth_manager = AuthManager::new( + codex_home.to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let provider_cache_key = + model_cache_key_for_provider("mock_provider", &provider_info, Some(&auth_manager)); + let mut models = bundled_models_response() + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))? + .models; + if let Some(mut lite_model) = models.iter().find(|model| model.slug == "gpt-5.4").cloned() { + lite_model.slug = "imagegen-lite-model".to_string(); + lite_model.display_name = "Imagegen Lite Model".to_string(); + lite_model.use_responses_lite = true; + models.push(lite_model); + } + write_models_cache_with_models_for_provider(codex_home, models, &provider_cache_key) +} + fn mock_provider_cache_key(codex_home: &Path) -> String { let provider_info = mock_provider_info_from_config(codex_home); model_cache_key_for_provider("mock_provider", &provider_info, /*auth_manager*/ None) diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index b6593bc82..abc5493d6 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -7,6 +7,7 @@ use app_test_support::ChatGptAuthFixture; use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; +use app_test_support::write_mock_provider_models_cache_with_auth; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -17,6 +18,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_config::types::AuthCredentialsStoreMode; +use codex_models_manager::bundled_models_response; use core_test_support::responses; use pretty_assertions::assert_eq; use serde_json::json; @@ -47,6 +49,7 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Result<()> { let call_id = "image-run-1"; let server = responses::start_mock_server().await; + mount_imagegen_models(&server).await?; mount_image_response(&server).await; let response_mock = responses::mount_sse_sequence( @@ -81,9 +84,17 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul ChatGptAuthFixture::new("access-chatgpt"), AuthCredentialsStoreMode::File, )?; + write_mock_provider_models_cache_with_auth(codex_home.path()).await?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ("CODEWITH_AUTH_PROFILE", None), + ("CODEX_AUTH_PROFILE", None), + ], + ) + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; start_image_generation_turn(&mut mcp).await?; @@ -145,6 +156,7 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul #[tokio::test] async fn standalone_image_generation_is_exposed_in_code_mode_only() -> Result<()> { let server = responses::start_mock_server().await; + mount_imagegen_models(&server).await?; let response_mock = responses::mount_sse_once( &server, responses::sse(vec![ @@ -165,9 +177,17 @@ async fn standalone_image_generation_is_exposed_in_code_mode_only() -> Result<() ChatGptAuthFixture::new("access-chatgpt"), AuthCredentialsStoreMode::File, )?; + write_mock_provider_models_cache_with_auth(codex_home.path()).await?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ("CODEWITH_AUTH_PROFILE", None), + ("CODEX_AUTH_PROFILE", None), + ], + ) + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; start_image_generation_turn(&mut mcp).await?; timeout( @@ -190,6 +210,7 @@ async fn standalone_image_generation_is_exposed_in_code_mode_only() -> Result<() async fn standalone_image_generation_is_callable_from_code_mode_only() -> Result<()> { let call_id = "code-mode-image-run-1"; let server = responses::start_mock_server().await; + mount_imagegen_models(&server).await?; mount_image_response(&server).await; let response_mock = responses::mount_sse_sequence( @@ -229,9 +250,17 @@ generatedImage(result); ChatGptAuthFixture::new("access-chatgpt"), AuthCredentialsStoreMode::File, )?; + write_mock_provider_models_cache_with_auth(codex_home.path()).await?; - let mut mcp = - TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ("CODEWITH_AUTH_PROFILE", None), + ("CODEX_AUTH_PROFILE", None), + ], + ) + .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; start_image_generation_turn(&mut mcp).await?; timeout( @@ -324,30 +353,48 @@ async fn mount_image_response(server: &MockServer) { .await; } +async fn mount_imagegen_models(server: &MockServer) -> Result<()> { + let mut models = bundled_models_response()?; + let Some(mut lite_model) = models + .models + .iter() + .find(|model| model.slug == "gpt-5.4") + .cloned() + else { + anyhow::bail!("expected bundled gpt-5.4 model metadata"); + }; + lite_model.slug = "imagegen-lite-model".to_string(); + lite_model.display_name = "Imagegen Lite Model".to_string(); + lite_model.use_responses_lite = true; + models.models.push(lite_model); + responses::mount_models_once(server, models).await; + Ok(()) +} + fn create_config_toml( codex_home: &Path, server_uri: &str, mode: ImagegenTestMode, ) -> std::io::Result<()> { - let code_mode_only = match mode { - ImagegenTestMode::Direct => "", - ImagegenTestMode::CodeModeOnly => "code_mode_only = true", + let (model, code_mode_only) = match mode { + ImagegenTestMode::Direct => ("imagegen-lite-model", ""), + ImagegenTestMode::CodeModeOnly => ("gpt-5.4", "code_mode_only = true"), }; std::fs::write( codex_home.join("config.toml"), format!( r#" -model = "mock-model" +model = "{model}" approval_policy = "never" sandbox_mode = "read-only" -model_provider = "openai-custom" +model_provider = "mock_provider" chatgpt_base_url = "{server_uri}" [features] imagegenext = true {code_mode_only} -[model_providers.openai-custom] +[model_providers.mock_provider] name = "OpenAI" base_url = "{server_uri}/api/codex" wire_api = "responses" diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index c19367138..1f59a4749 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -208,8 +208,7 @@ fn build_model_visible_specs_and_registry( continue; } let exposure = runtime.exposure(); - if exposure.is_direct() && !is_hidden_by_code_mode_only(turn_context, &tool_name, exposure) - { + if exposure.is_direct() && !is_hidden_by_code_mode(turn_context, &tool_name, exposure) { let spec = runtime.spec(); specs.push(spec_for_model_request( turn_context, @@ -364,27 +363,51 @@ 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) { - return false; - } - - if turn_context.model_info.use_responses_lite { - return true; - } - - turn_context.features.get().enabled(Feature::ImageGenExt) + standalone_image_generation_runtime_available(turn_context) + && turn_context.model_info.use_responses_lite } fn standalone_image_generation_available( turn_context: &TurnContext, extension_tools: &[Arc>], ) -> bool { - standalone_image_generation_model_visible(turn_context) + if image_generation_excluded_from_code_mode(turn_context) { + return false; + } + + standalone_image_generation_extension_registerable(turn_context) && extension_tools.iter().any(|executor| { executor.tool_name() == ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME) }) } +fn standalone_image_generation_runtime_available(turn_context: &TurnContext) -> bool { + image_generation_runtime_enabled(turn_context) + && namespace_tools_enabled(turn_context) + && turn_context.features.get().enabled(Feature::ImageGenExt) +} + +fn standalone_image_generation_extension_registerable(turn_context: &TurnContext) -> bool { + standalone_image_generation_model_visible(turn_context) + || (standalone_image_generation_runtime_available(turn_context) + && matches!( + turn_context.tool_mode, + ToolMode::CodeMode | ToolMode::CodeModeOnly + )) +} + +fn image_generation_excluded_from_code_mode(turn_context: &TurnContext) -> bool { + matches!( + turn_context.tool_mode, + ToolMode::CodeMode | ToolMode::CodeModeOnly + ) && turn_context + .config + .code_mode + .excluded_tool_namespaces + .iter() + .any(|namespace| namespace == IMAGE_GEN_NAMESPACE) +} + fn wait_agent_timeout_options(turn_context: &TurnContext) -> WaitAgentTimeoutOptions { if multi_agent_v2_enabled(turn_context) { return WaitAgentTimeoutOptions { @@ -423,16 +446,25 @@ fn agent_type_description( } } -fn is_hidden_by_code_mode_only( +fn is_hidden_by_code_mode( turn_context: &TurnContext, tool_name: &ToolName, exposure: ToolExposure, ) -> bool { - turn_context.tool_mode == ToolMode::CodeModeOnly - && exposure != ToolExposure::DirectModelOnly - && codex_code_mode::is_code_mode_nested_tool(&codex_tools::code_mode_name_for_tool_name( - tool_name, - )) + if exposure == ToolExposure::DirectModelOnly { + return false; + } + + match turn_context.tool_mode { + ToolMode::CodeMode => { + tool_name == &ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME) + && standalone_image_generation_extension_registerable(turn_context) + } + ToolMode::CodeModeOnly => codex_code_mode::is_code_mode_nested_tool( + &codex_tools::code_mode_name_for_tool_name(tool_name), + ), + ToolMode::Direct => false, + } } fn is_excluded_from_code_mode(turn_context: &TurnContext, tool_name: &ToolName) -> bool { @@ -936,7 +968,8 @@ fn append_extension_tool_executors( continue; } if tool_name == ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME) - && !standalone_image_generation_model_visible(turn_context) + && (!standalone_image_generation_extension_registerable(turn_context) + || image_generation_excluded_from_code_mode(turn_context)) { continue; } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 1499ec9c1..88bf2b0f8 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -10,6 +10,7 @@ use codex_model_provider::create_model_provider_with_id; use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID; use codex_model_provider_info::ANTHROPIC_PROVIDER_ID; use codex_model_provider_info::ModelProviderInfo; +use codex_model_provider_info::NVIDIA_PROVIDER_ID; use codex_model_provider_info::OPENROUTER_PROVIDER_ID; use codex_model_provider_info::QWEN_PROVIDER_ID; use codex_model_provider_info::XAI_PROVIDER_ID; @@ -36,6 +37,7 @@ use codex_tools::ToolName; use codex_tools::ToolOutput; use codex_tools::ToolSpec; use pretty_assertions::assert_eq; +use serde_json::Value; use serde_json::json; use crate::session::tests::make_session_and_context; @@ -53,6 +55,8 @@ struct ToolPlanInputs { dynamic_tools: Vec, } +type TurnConfigurator = Box; + struct ToolPlanProbe { visible_specs: Vec, visible_names: Vec, @@ -181,6 +185,11 @@ impl ToolPlanProbe { .get(name) .unwrap_or_else(|| panic!("expected registered tool `{name}`")) } + + fn serialized_tools(&self) -> Vec { + codex_tools::create_tools_json_for_responses_api(&self.visible_specs) + .expect("visible specs should serialize for the Responses API") + } } async fn probe_with( @@ -318,6 +327,14 @@ fn use_openrouter_provider(turn: &mut TurnContext) { ); } +fn use_nvidia_provider(turn: &mut TurnContext) { + use_provider_with_id( + turn, + NVIDIA_PROVIDER_ID, + ModelProviderInfo::create_nvidia_provider(), + ); +} + struct WebRunExtensionTool; #[async_trait::async_trait] @@ -501,6 +518,33 @@ fn has_parameter(spec: &ToolSpec, parameter_name: &str) -> bool { .is_some() } +fn has_serialized_tool_type(tools: &[Value], tool_type: &str) -> bool { + tools + .iter() + .any(|tool| tool.get("type").and_then(Value::as_str) == Some(tool_type)) +} + +fn has_serialized_namespace_function( + tools: &[Value], + namespace: &str, + function_name: &str, +) -> bool { + tools.iter().any(|tool| { + tool.get("type").and_then(Value::as_str) == Some("namespace") + && tool.get("name").and_then(Value::as_str) == Some(namespace) + && tool + .get("tools") + .and_then(Value::as_array) + .is_some_and(|namespace_tools| { + namespace_tools.iter().any(|namespace_tool| { + namespace_tool.get("type").and_then(Value::as_str) == Some("function") + && namespace_tool.get("name").and_then(Value::as_str) + == Some(function_name) + }) + }) + }) +} + fn apply_patch_accepts_environment_id(spec: &ToolSpec) -> bool { match spec { ToolSpec::Freeform(tool) if tool.name == "apply_patch" => { @@ -1402,7 +1446,7 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { extension_flag_without_imagegen_tool.assert_visible_contains(&["image_generation"]); extension_flag_without_imagegen_tool.assert_visible_lacks(&["image_gen"]); - let standalone_imagegen = probe_with( + let non_lite_imagegen_extension = probe_with( |turn| { use_chatgpt_auth(turn); set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); @@ -1418,12 +1462,52 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { }, ) .await; - standalone_imagegen.assert_visible_lacks(&["image_generation"]); + non_lite_imagegen_extension.assert_visible_contains(&["image_generation"]); + non_lite_imagegen_extension.assert_visible_lacks(&["image_gen"]); + non_lite_imagegen_extension.assert_registered_lacks(&["image_genimagegen"]); + + let responses_lite_imagegen_flag_disabled = probe_with( + |turn| { + use_chatgpt_auth(turn); + set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); + set_feature(turn, Feature::ImageGenExt, /*enabled*/ false); + turn.model_info.input_modalities = vec![InputModality::Text, InputModality::Image]; + turn.model_info.use_responses_lite = true; + turn.model_info.tool_mode = Some(ToolMode::Direct); + turn.tool_mode = ToolMode::Direct; + }, + ToolPlanInputs { + extension_tool_executors: vec![Arc::new(ImagegenExtensionTool)], + ..Default::default() + }, + ) + .await; + responses_lite_imagegen_flag_disabled.assert_visible_lacks(&["image_generation", "image_gen"]); + responses_lite_imagegen_flag_disabled.assert_registered_lacks(&["image_genimagegen"]); + + let responses_lite_standalone_imagegen = probe_with( + |turn| { + use_chatgpt_auth(turn); + set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); + set_feature(turn, Feature::ImageGenExt, /*enabled*/ true); + turn.model_info.input_modalities = vec![InputModality::Text, InputModality::Image]; + turn.model_info.use_responses_lite = true; + turn.model_info.tool_mode = Some(ToolMode::Direct); + turn.tool_mode = ToolMode::Direct; + }, + ToolPlanInputs { + extension_tool_executors: vec![Arc::new(ImagegenExtensionTool)], + ..Default::default() + }, + ) + .await; + responses_lite_standalone_imagegen.assert_visible_lacks(&["image_generation"]); assert_eq!( - standalone_imagegen.namespace_function_names("image_gen"), + responses_lite_standalone_imagegen.namespace_function_names("image_gen"), &["imagegen".to_string()] ); - let ToolSpec::Namespace(imagegen_namespace) = standalone_imagegen.visible_spec("image_gen") + let ToolSpec::Namespace(imagegen_namespace) = + responses_lite_standalone_imagegen.visible_spec("image_gen") else { panic!("expected image_gen namespace"); }; @@ -1440,6 +1524,173 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { ); assert!(!imagegen_function.strict); + let code_mode_only_standalone_imagegen = probe_with( + |turn| { + use_chatgpt_auth(turn); + set_features( + turn, + &[ + Feature::CodeMode, + Feature::CodeModeOnly, + Feature::ImageGeneration, + Feature::ImageGenExt, + ], + ); + turn.model_info.input_modalities = vec![InputModality::Text, InputModality::Image]; + turn.model_info.use_responses_lite = false; + turn.model_info.tool_mode = Some(ToolMode::CodeModeOnly); + turn.tool_mode = ToolMode::CodeModeOnly; + }, + ToolPlanInputs { + extension_tool_executors: vec![Arc::new(ImagegenExtensionTool)], + ..Default::default() + }, + ) + .await; + code_mode_only_standalone_imagegen.assert_visible_contains(&["exec", "wait"]); + code_mode_only_standalone_imagegen.assert_visible_lacks(&["image_generation", "image_gen"]); + code_mode_only_standalone_imagegen.assert_registered_contains(&["image_genimagegen"]); + + let code_mode_standalone_imagegen = probe_with( + |turn| { + use_chatgpt_auth(turn); + set_features( + turn, + &[ + Feature::CodeMode, + Feature::ImageGeneration, + Feature::ImageGenExt, + ], + ); + turn.model_info.input_modalities = vec![InputModality::Text, InputModality::Image]; + turn.model_info.use_responses_lite = false; + turn.model_info.tool_mode = Some(ToolMode::CodeMode); + turn.tool_mode = ToolMode::CodeMode; + }, + ToolPlanInputs { + extension_tool_executors: vec![Arc::new(ImagegenExtensionTool)], + ..Default::default() + }, + ) + .await; + code_mode_standalone_imagegen.assert_visible_contains(&["exec", "wait"]); + code_mode_standalone_imagegen.assert_visible_lacks(&["image_generation", "image_gen"]); + code_mode_standalone_imagegen.assert_registered_contains(&["image_genimagegen"]); + let serialized_tools = code_mode_standalone_imagegen.serialized_tools(); + assert!( + !has_serialized_tool_type(&serialized_tools, "image_generation"), + "normal CodeMode should not expose hosted image generation when nested imagegen is registered: {serialized_tools:?}" + ); + assert!( + !has_serialized_namespace_function(&serialized_tools, "image_gen", "imagegen"), + "normal CodeMode should not expose reserved image_gen.imagegen top-level: {serialized_tools:?}" + ); + + let excluded_code_mode_imagegen = probe_with( + |turn| { + use_chatgpt_auth(turn); + set_features( + turn, + &[ + Feature::CodeMode, + Feature::ImageGeneration, + Feature::ImageGenExt, + ], + ); + update_config(turn, |config| { + config.code_mode.excluded_tool_namespaces = vec!["image_gen".to_string()]; + }); + turn.model_info.input_modalities = vec![InputModality::Text, InputModality::Image]; + turn.model_info.use_responses_lite = false; + turn.model_info.tool_mode = Some(ToolMode::CodeMode); + turn.tool_mode = ToolMode::CodeMode; + }, + ToolPlanInputs { + extension_tool_executors: vec![Arc::new(ImagegenExtensionTool)], + ..Default::default() + }, + ) + .await; + excluded_code_mode_imagegen.assert_visible_contains(&[ + codex_code_mode::PUBLIC_TOOL_NAME, + codex_code_mode::WAIT_TOOL_NAME, + "image_generation", + ]); + excluded_code_mode_imagegen.assert_visible_lacks(&["image_gen"]); + excluded_code_mode_imagegen.assert_registered_lacks(&["image_genimagegen"]); + let ToolSpec::Freeform(exec) = + excluded_code_mode_imagegen.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME) + else { + panic!("expected code mode exec tool"); + }; + assert!( + !exec.description.contains("image_gen") && !exec.description.contains("imagegen"), + "excluded imagegen should not be registered as a CodeMode nested tool: {}", + exec.description + ); + let serialized_tools = excluded_code_mode_imagegen.serialized_tools(); + assert!( + has_serialized_tool_type(&serialized_tools, "image_generation"), + "mixed CodeMode with excluded image_gen should fall back to hosted image generation: {serialized_tools:?}" + ); + assert!( + !has_serialized_namespace_function(&serialized_tools, "image_gen", "imagegen"), + "mixed CodeMode with excluded image_gen should not expose reserved image_gen.imagegen: {serialized_tools:?}" + ); + + let excluded_code_mode_only_imagegen = probe_with( + |turn| { + use_chatgpt_auth(turn); + set_features( + turn, + &[ + Feature::CodeMode, + Feature::CodeModeOnly, + Feature::ImageGeneration, + Feature::ImageGenExt, + ], + ); + update_config(turn, |config| { + config.code_mode.excluded_tool_namespaces = vec!["image_gen".to_string()]; + }); + turn.model_info.input_modalities = vec![InputModality::Text, InputModality::Image]; + turn.model_info.use_responses_lite = false; + turn.model_info.tool_mode = Some(ToolMode::CodeModeOnly); + turn.tool_mode = ToolMode::CodeModeOnly; + }, + ToolPlanInputs { + extension_tool_executors: vec![Arc::new(ImagegenExtensionTool)], + ..Default::default() + }, + ) + .await; + excluded_code_mode_only_imagegen.assert_visible_contains(&[ + codex_code_mode::PUBLIC_TOOL_NAME, + codex_code_mode::WAIT_TOOL_NAME, + "image_generation", + ]); + excluded_code_mode_only_imagegen.assert_visible_lacks(&["image_gen"]); + excluded_code_mode_only_imagegen.assert_registered_lacks(&["image_genimagegen"]); + let ToolSpec::Freeform(exec) = + excluded_code_mode_only_imagegen.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME) + else { + panic!("expected code mode exec tool"); + }; + assert!( + !exec.description.contains("image_gen") && !exec.description.contains("imagegen"), + "excluded imagegen should not be registered as a CodeModeOnly nested tool: {}", + exec.description + ); + let serialized_tools = excluded_code_mode_only_imagegen.serialized_tools(); + assert!( + has_serialized_tool_type(&serialized_tools, "image_generation"), + "CodeModeOnly with excluded image_gen should fall back to hosted image generation: {serialized_tools:?}" + ); + assert!( + !has_serialized_namespace_function(&serialized_tools, "image_gen", "imagegen"), + "CodeModeOnly with excluded image_gen should not expose reserved image_gen.imagegen: {serialized_tools:?}" + ); + let live_web_search = probe(|turn| { set_web_search_mode(turn, WebSearchMode::Live); turn.model_info.supports_search_tool = true; @@ -1619,3 +1870,231 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { &ToolSpec::ZaiWebSearch { .. } )); } + +#[derive(Clone, Copy)] +enum ImageGenerationAuthMode { + ApiKey, + ChatGpt, +} + +#[derive(Clone, Copy)] +enum NamespaceToolSupport { + Supported, + Unsupported, +} + +struct ImageGenerationMatrixCase { + name: &'static str, + auth_mode: ImageGenerationAuthMode, + responses_lite: bool, + imagegen_ext_enabled: bool, + namespace_tools: NamespaceToolSupport, + extension_present: bool, + expected_hosted: bool, + expected_standalone: bool, +} + +#[tokio::test] +async fn image_generation_serialized_tool_matrix_hides_reserved_namespace_when_unsupported() { + let cases = [ + ImageGenerationMatrixCase { + name: "api key auth cannot expose hosted or standalone image generation", + auth_mode: ImageGenerationAuthMode::ApiKey, + responses_lite: false, + imagegen_ext_enabled: true, + namespace_tools: NamespaceToolSupport::Supported, + extension_present: true, + expected_hosted: false, + expected_standalone: false, + }, + ImageGenerationMatrixCase { + name: "chatgpt non-lite without extension uses hosted image generation", + auth_mode: ImageGenerationAuthMode::ChatGpt, + responses_lite: false, + imagegen_ext_enabled: false, + namespace_tools: NamespaceToolSupport::Supported, + extension_present: false, + expected_hosted: true, + expected_standalone: false, + }, + ImageGenerationMatrixCase { + name: "chatgpt non-lite hides installed imagegen extension and uses hosted", + auth_mode: ImageGenerationAuthMode::ChatGpt, + responses_lite: false, + imagegen_ext_enabled: true, + namespace_tools: NamespaceToolSupport::Supported, + extension_present: true, + expected_hosted: true, + expected_standalone: false, + }, + ImageGenerationMatrixCase { + name: "chatgpt non-lite feature flag without extension uses hosted", + auth_mode: ImageGenerationAuthMode::ChatGpt, + responses_lite: false, + imagegen_ext_enabled: true, + namespace_tools: NamespaceToolSupport::Supported, + extension_present: false, + expected_hosted: true, + expected_standalone: false, + }, + ImageGenerationMatrixCase { + name: "chatgpt lite with installed extension uses standalone namespace", + auth_mode: ImageGenerationAuthMode::ChatGpt, + responses_lite: true, + imagegen_ext_enabled: true, + namespace_tools: NamespaceToolSupport::Supported, + extension_present: true, + expected_hosted: false, + expected_standalone: true, + }, + ImageGenerationMatrixCase { + name: "chatgpt lite with imagegen flag disabled hides standalone namespace", + auth_mode: ImageGenerationAuthMode::ChatGpt, + responses_lite: true, + imagegen_ext_enabled: false, + namespace_tools: NamespaceToolSupport::Supported, + extension_present: true, + expected_hosted: false, + expected_standalone: false, + }, + ImageGenerationMatrixCase { + name: "chatgpt lite without extension fails closed", + auth_mode: ImageGenerationAuthMode::ChatGpt, + responses_lite: true, + imagegen_ext_enabled: true, + namespace_tools: NamespaceToolSupport::Supported, + extension_present: false, + expected_hosted: false, + expected_standalone: false, + }, + ImageGenerationMatrixCase { + name: "namespace-unsupported provider hides standalone namespace", + auth_mode: ImageGenerationAuthMode::ChatGpt, + responses_lite: true, + imagegen_ext_enabled: true, + namespace_tools: NamespaceToolSupport::Unsupported, + extension_present: true, + expected_hosted: false, + expected_standalone: false, + }, + ]; + + for case in cases { + let extension_tool_executors = if case.extension_present { + vec![Arc::new(ImagegenExtensionTool) as Arc>] + } else { + Vec::new() + }; + let plan = probe_with( + |turn| { + match case.auth_mode { + ImageGenerationAuthMode::ApiKey => {} + ImageGenerationAuthMode::ChatGpt => use_chatgpt_auth(turn), + } + if matches!(case.namespace_tools, NamespaceToolSupport::Unsupported) { + use_nvidia_provider(turn); + } + set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); + set_feature(turn, Feature::ImageGenExt, case.imagegen_ext_enabled); + turn.model_info.input_modalities = vec![InputModality::Text, InputModality::Image]; + turn.model_info.use_responses_lite = case.responses_lite; + turn.model_info.tool_mode = Some(ToolMode::Direct); + turn.tool_mode = ToolMode::Direct; + }, + ToolPlanInputs { + extension_tool_executors, + ..Default::default() + }, + ) + .await; + + let serialized_tools = plan.serialized_tools(); + assert_eq!( + has_serialized_tool_type(&serialized_tools, "image_generation"), + case.expected_hosted, + "hosted image_generation mismatch for {}: {serialized_tools:?}", + case.name + ); + assert_eq!( + has_serialized_namespace_function(&serialized_tools, "image_gen", "imagegen"), + case.expected_standalone, + "standalone image_gen.imagegen mismatch for {}: {serialized_tools:?}", + case.name + ); + } +} + +#[tokio::test] +async fn code_mode_excluded_imagegen_follows_hosted_negative_gates() { + for tool_mode in [ToolMode::CodeMode, ToolMode::CodeModeOnly] { + let mut cases: Vec<(&str, TurnConfigurator)> = vec![ + ("api key auth", Box::new(|_| {})), + ( + "image generation feature disabled", + Box::new(|turn| { + use_chatgpt_auth(turn); + set_feature(turn, Feature::ImageGeneration, /*enabled*/ false); + }), + ), + ( + "model without image input", + Box::new(|turn| { + use_chatgpt_auth(turn); + turn.model_info.input_modalities = vec![InputModality::Text]; + }), + ), + ( + "responses lite", + Box::new(|turn| { + use_chatgpt_auth(turn); + turn.model_info.use_responses_lite = true; + }), + ), + ( + "provider without hosted image generation", + Box::new(|turn| { + use_chatgpt_auth(turn); + use_nvidia_provider(turn); + }), + ), + ]; + + for (case_name, configure_gate) in cases.drain(..) { + let plan = probe_with( + |turn| { + set_feature(turn, Feature::CodeMode, /*enabled*/ true); + if tool_mode == ToolMode::CodeModeOnly { + set_feature(turn, Feature::CodeModeOnly, /*enabled*/ true); + } + set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); + set_feature(turn, Feature::ImageGenExt, /*enabled*/ true); + update_config(turn, |config| { + config.code_mode.excluded_tool_namespaces = vec!["image_gen".to_string()]; + }); + turn.model_info.input_modalities = + vec![InputModality::Text, InputModality::Image]; + turn.model_info.use_responses_lite = false; + turn.model_info.tool_mode = Some(tool_mode); + turn.tool_mode = tool_mode; + configure_gate(turn); + }, + ToolPlanInputs { + extension_tool_executors: vec![Arc::new(ImagegenExtensionTool)], + ..Default::default() + }, + ) + .await; + + let serialized_tools = plan.serialized_tools(); + assert!( + !has_serialized_tool_type(&serialized_tools, "image_generation"), + "hosted image_generation should remain gated for {tool_mode:?} {case_name}: {serialized_tools:?}" + ); + assert!( + !has_serialized_namespace_function(&serialized_tools, "image_gen", "imagegen"), + "reserved image_gen.imagegen should stay hidden for {tool_mode:?} {case_name}: {serialized_tools:?}" + ); + plan.assert_registered_lacks(&["image_genimagegen"]); + } + } +} diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index 60db4c064..c72269018 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -8,6 +8,7 @@ use codex_extension_api::ExtensionRegistryBuilder; use codex_features::Feature; use codex_image_generation_extension::install as install_image_generation_extension; use codex_login::CodexAuth; +use codex_model_provider_info::OPENAI_PROVIDER_ID; use codex_protocol::config_types::WebSearchMode; use codex_protocol::openai_models::InputModality; use codex_protocol::protocol::EventMsg; @@ -31,6 +32,10 @@ fn responses_extensions(auth: &CodexAuth) -> Arc> { } fn configure_responses_tools(config: &mut Config) { + config.model_provider_id = OPENAI_PROVIDER_ID.to_string(); + // Keep the fixture's dummy ChatGPT auth active even when a developer + // environment exports an auth profile. Hosted tool gates depend on auth mode. + config.selected_auth_profile = None; assert!(config.web_search_mode.set(WebSearchMode::Live).is_ok()); assert!( config @@ -53,7 +58,8 @@ fn has_hosted_tool(tools: &[Value], tool_type: &str) -> bool { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_lite_uses_standalone_web_search_and_image_generation() -> Result<()> { +async fn responses_lite_uses_standalone_web_search_and_hides_unavailable_image_generation() +-> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -86,14 +92,11 @@ async fn responses_lite_uses_standalone_web_search_and_image_generation() -> Res request.header(RESPONSES_LITE_HEADER).as_deref(), Some("true") ); + let body = request.body_json(); request .tool_by_name("web", "run") .context("Responses Lite should expose standalone web search")?; - request - .tool_by_name("image_gen", "imagegen") - .context("Responses Lite should expose standalone image generation")?; - - let body = request.body_json(); + assert!(request.tool_by_name("image_gen", "imagegen").is_none()); let tools = body["tools"] .as_array() .context("Responses request tools should be an array")?; @@ -223,7 +226,11 @@ async fn non_lite_uses_hosted_tools_when_standalone_features_are_disabled() -> R .as_array() .context("Responses request tools should be an array")?; assert!(has_hosted_tool(tools, "web_search")); - assert!(has_hosted_tool(tools, "image_generation")); + assert!( + has_hosted_tool(tools, "image_generation"), + "expected hosted image_generation for model {:?} in tools: {tools:?}", + body.get("model") + ); Ok(()) }