diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index b873f87e2..8ca5eec89 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -98,7 +98,6 @@ use tracing::warn; const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents."; const IMAGE_GEN_NAMESPACE: &str = "image_gen"; const IMAGEGEN_TOOL_NAME: &str = "imagegen"; -const ACTOR_AUTHORIZATION_HEADER: &str = "x-openai-actor-authorization"; type PlannedRuntime = Arc; @@ -387,7 +386,10 @@ fn image_generation_tool_enabled(turn_context: &TurnContext) -> bool { } fn image_generation_runtime_enabled(turn_context: &TurnContext) -> bool { - (provider_uses_actor_authorization(turn_context) + (turn_context + .provider + .info() + .uses_openai_actor_authorization() || (turn_context.provider.info().requires_openai_auth && turn_context .auth_manager @@ -400,16 +402,6 @@ fn image_generation_runtime_enabled(turn_context: &TurnContext) -> bool { .contains(&InputModality::Image) } -fn provider_uses_actor_authorization(turn_context: &TurnContext) -> bool { - let provider_info = turn_context.provider.info(); - !provider_info.requires_openai_auth - && provider_info.http_headers.as_ref().is_some_and(|headers| { - headers.iter().any(|(name, value)| { - name.eq_ignore_ascii_case(ACTOR_AUTHORIZATION_HEADER) && !value.trim().is_empty() - }) - }) -} - fn standalone_image_generation_model_visible(turn_context: &TurnContext) -> bool { if !image_generation_runtime_enabled(turn_context) || !namespace_tools_enabled(turn_context) { return false; diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 498a690c5..d95cb5ff4 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -275,22 +275,19 @@ fn use_bedrock_provider(turn: &mut TurnContext) { turn.provider = create_model_provider(provider_info, turn.auth_manager.clone()); } -fn use_provider_auth( - turn: &mut TurnContext, - requires_openai_auth: bool, - actor_header: Option<(&str, &str)>, -) { +fn use_actor_authorized_provider(turn: &mut TurnContext) { let mut provider_info = turn.config.model_provider.clone(); - provider_info.requires_openai_auth = requires_openai_auth; - provider_info.http_headers = actor_header.map(|(name, value)| { - HashMap::from([ - (name.to_string(), value.to_string()), - ( - "ChatGPT-Account-ID".to_string(), - "test-account-id".to_string(), - ), - ]) - }); + provider_info.requires_openai_auth = false; + provider_info.http_headers = Some(HashMap::from([ + ( + "x-openai-actor-authorization".to_string(), + "test-actor-authorization".to_string(), + ), + ( + "ChatGPT-Account-ID".to_string(), + "test-account-id".to_string(), + ), + ])); turn.auth_manager = None; update_config(turn, |config| { config.model_provider = provider_info.clone(); @@ -1552,47 +1549,17 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { .await; unrelated_chatgpt_auth.assert_visible_lacks(&["image_generation"]); - let provider_without_actor_auth = probe(|turn| { + let actor_authorized_provider = probe(|turn| { set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); - use_provider_auth( - turn, /*requires_openai_auth*/ false, /*actor_header*/ None, - ); + use_actor_authorized_provider(turn); turn.model_info.input_modalities = vec![InputModality::Image]; }) .await; - provider_without_actor_auth.assert_visible_lacks(&["image_generation"]); - - let provider_authenticated = probe(|turn| { - set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); - use_provider_auth( - turn, - /*requires_openai_auth*/ false, - Some(("X-OpenAI-Actor-Authorization", "test-actor-authorization")), - ); - turn.model_info.input_modalities = vec![InputModality::Image]; - }) - .await; - provider_authenticated.assert_visible_contains(&["image_generation"]); - - let empty_actor_auth = probe(|turn| { - set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); - use_provider_auth( - turn, - /*requires_openai_auth*/ false, - Some(("x-openai-actor-authorization", " ")), - ); - turn.model_info.input_modalities = vec![InputModality::Image]; - }) - .await; - empty_actor_auth.assert_visible_lacks(&["image_generation"]); + actor_authorized_provider.assert_visible_contains(&["image_generation"]); let feature_disabled = probe(|turn| { set_feature(turn, Feature::ImageGeneration, /*enabled*/ false); - use_provider_auth( - turn, - /*requires_openai_auth*/ false, - Some(("x-openai-actor-authorization", "test-actor-authorization")), - ); + use_actor_authorized_provider(turn); turn.model_info.input_modalities = vec![InputModality::Image]; }) .await; @@ -1600,11 +1567,7 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { let text_only_model = probe(|turn| { set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); - use_provider_auth( - turn, - /*requires_openai_auth*/ false, - Some(("x-openai-actor-authorization", "test-actor-authorization")), - ); + use_actor_authorized_provider(turn); turn.model_info.input_modalities = vec![]; }) .await; @@ -1613,34 +1576,12 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { let unsupported_image_generation_provider = probe(|turn| { set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); use_bedrock_provider(turn); - let mut provider_info = turn.provider.info().clone(); - provider_info.requires_openai_auth = false; - provider_info.http_headers = Some(HashMap::from([( - "x-openai-actor-authorization".to_string(), - "test-actor-authorization".to_string(), - )])); - turn.auth_manager = None; - update_config(turn, |config| { - config.model_provider = provider_info.clone(); - }); - turn.provider = create_model_provider(provider_info, /*auth_manager*/ None); + use_actor_authorized_provider(turn); turn.model_info.input_modalities = vec![InputModality::Image]; }) .await; unsupported_image_generation_provider.assert_visible_lacks(&["image_generation"]); - let codex_managed_auth_provider = probe(|turn| { - set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); - use_provider_auth( - turn, - /*requires_openai_auth*/ true, - Some(("x-openai-actor-authorization", "test-actor-authorization")), - ); - turn.model_info.input_modalities = vec![InputModality::Image]; - }) - .await; - codex_managed_auth_provider.assert_visible_lacks(&["image_generation"]); - let image_generation = probe(|turn| { use_chatgpt_auth(turn); set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index 807434411..5f623bd4c 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use anyhow::Context; @@ -56,6 +57,18 @@ fn has_hosted_tool(tools: &[Value], tool_type: &str) -> bool { .any(|tool| tool.get("type").and_then(Value::as_str) == Some(tool_type)) } +fn has_namespaced_tool(tools: &[Value], namespace: &str, tool_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["tools"].as_array().is_some_and(|tools| { + tools + .iter() + .any(|tool| tool.get("name").and_then(Value::as_str) == Some(tool_name)) + }) + }) +} + fn additional_tools(body: &Value) -> Result<&[Value]> { body["input"] .as_array() @@ -227,13 +240,58 @@ async fn responses_lite_uses_standalone_web_search_and_image_generation() -> Res let body = request.body_json(); assert!(body.get("tools").is_none()); let tools = additional_tools(&body)?; - assert!(!tools.is_empty()); + 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_exposes_standalone_tools_for_actor_authorized_provider() -> 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::from_api_key("dummy"); + 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.name = "local".to_string(); + config.model_provider.requires_openai_auth = false; + config.model_provider.http_headers = Some(HashMap::from([( + "x-openai-actor-authorization".to_string(), + "test-actor-authorization".to_string(), + )])); + }); + let test = builder.build(&server).await?; + + test.submit_turn("Use standalone tools").await?; + + let body = response_mock.single_request().body_json(); + let tools = additional_tools(&body)?; + assert!(has_namespaced_tool(tools, "web", "run")); + assert!(has_namespaced_tool(tools, "image_gen", "imagegen")); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_lite_compact_request_uses_lite_transport_contract() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/ext/image-generation/src/extension.rs b/codex-rs/ext/image-generation/src/extension.rs index 8195c08e6..1e3dc3e12 100644 --- a/codex-rs/ext/image-generation/src/extension.rs +++ b/codex-rs/ext/image-generation/src/extension.rs @@ -34,11 +34,11 @@ struct ImageGenerationExtensionConfig { } impl ImageGenerationExtensionConfig { - /// Resolves whether standalone image generation should be available for a thread. + /// Resolves the image provider and save root for a thread. fn from_config(config: &Config, resolve_save_root: &SaveRootResolver) -> Self { Self { - // Core selects this executor per turn using the feature flag or model metadata. - available: config.model_provider.is_openai(), + available: config.model_provider.is_openai() + || config.model_provider.uses_openai_actor_authorization(), provider: config.model_provider.clone(), save_root: resolve_save_root(config), } @@ -46,7 +46,7 @@ impl ImageGenerationExtensionConfig { } impl ThreadLifecycleContributor for ImageGenerationExtension { - /// Seeds image-generation availability when a thread begins. + /// Seeds image-generation configuration when a thread begins. fn on_thread_start<'a>( &'a self, input: ThreadStartInput<'a, Config>, @@ -63,7 +63,7 @@ impl ThreadLifecycleContributor for ImageGenerationExtension { } impl ConfigContributor for ImageGenerationExtension { - /// Refreshes image-generation availability after thread configuration changes. + /// Refreshes image-generation configuration after thread configuration changes. fn on_config_changed( &self, _session_store: &ExtensionData, @@ -88,7 +88,7 @@ impl ToolContributor for ImageGenerationExtension { let Some(config) = thread_store.get::() else { return Vec::new(); }; - if !config.available || !self.auth_manager.current_auth_uses_codex_backend() { + if !config.available { return Vec::new(); } diff --git a/codex-rs/ext/web-search/src/extension.rs b/codex-rs/ext/web-search/src/extension.rs index 7afdfe413..84e3eeef9 100644 --- a/codex-rs/ext/web-search/src/extension.rs +++ b/codex-rs/ext/web-search/src/extension.rs @@ -41,7 +41,8 @@ impl From<&Config> for WebSearchExtensionConfig { let web_search_mode = config.web_search_mode.value(); Self { // Core selects this executor per turn using the feature flag or model metadata. - available: config.model_provider.is_openai() + available: (config.model_provider.is_openai() + || config.model_provider.uses_openai_actor_authorization()) && web_search_mode != WebSearchMode::Disabled, provider: config.model_provider.clone(), settings: search_settings(config, web_search_mode), diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index ca3bf71cc..265e65b25 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -33,6 +33,7 @@ const MAX_STREAM_MAX_RETRIES: u64 = 100; const MAX_REQUEST_MAX_RETRIES: u64 = 100; const OPENAI_PROVIDER_NAME: &str = "OpenAI"; +const OPENAI_ACTOR_AUTHORIZATION_HEADER: &str = "x-openai-actor-authorization"; pub const OPENAI_PROVIDER_ID: &str = "openai"; pub const CHATGPT_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; const AMAZON_BEDROCK_PROVIDER_NAME: &str = "Amazon Bedrock"; @@ -392,6 +393,16 @@ impl ModelProviderInfo { self.name == OPENAI_PROVIDER_NAME } + pub fn uses_openai_actor_authorization(&self) -> bool { + !self.requires_openai_auth + && self.http_headers.as_ref().is_some_and(|headers| { + headers.iter().any(|(name, value)| { + name.eq_ignore_ascii_case(OPENAI_ACTOR_AUTHORIZATION_HEADER) + && !value.trim().is_empty() + }) + }) + } + pub fn is_amazon_bedrock(&self) -> bool { self.name == AMAZON_BEDROCK_PROVIDER_NAME } diff --git a/codex-rs/model-provider-info/src/model_provider_info_tests.rs b/codex-rs/model-provider-info/src/model_provider_info_tests.rs index a303050dc..5949f36bb 100644 --- a/codex-rs/model-provider-info/src/model_provider_info_tests.rs +++ b/codex-rs/model-provider-info/src/model_provider_info_tests.rs @@ -198,6 +198,31 @@ fn test_supports_remote_compaction_for_non_openai_non_azure_provider() { assert!(!provider.supports_remote_compaction()); } +#[test] +fn test_uses_openai_actor_authorization() { + let mut provider = ModelProviderInfo { + http_headers: Some(maplit::hashmap! { + "X-OpenAI-Actor-Authorization".to_string() => "actor-token".to_string(), + }), + ..ModelProviderInfo::default() + }; + assert!(provider.uses_openai_actor_authorization()); + + provider.http_headers = None; + assert!(!provider.uses_openai_actor_authorization()); + + provider.http_headers = Some(maplit::hashmap! { + OPENAI_ACTOR_AUTHORIZATION_HEADER.to_string() => " ".to_string(), + }); + assert!(!provider.uses_openai_actor_authorization()); + + provider.http_headers = Some(maplit::hashmap! { + OPENAI_ACTOR_AUTHORIZATION_HEADER.to_string() => "actor-token".to_string(), + }); + provider.requires_openai_auth = true; + assert!(!provider.uses_openai_actor_authorization()); +} + #[test] fn test_deserialize_provider_auth_config_defaults() { let base_dir = tempdir().unwrap();