[codex] allow CCA image generation and web search extensions (#29909)

## Summary

- allow the standalone image-generation and web-search extensions for
the actor-authorized provider shape used by CCA
- preserve builtin `image_generation` and `web_search` for older models
and existing flows
- keep ordinary non-OpenAI providers excluded from both extensions
- remove only the image extension local managed-AuthManager requirement
that CCA cannot satisfy
- share actor-authorization detection through `ModelProviderInfo`
- keep Core tests focused on routing behavior and cover header-shape
edge cases in `model-provider-info`
- add a Responses Lite regression that verifies both
`image_gen.imagegen` and `web.run`

## Why

CCA uses a provider named `local` with `requires_openai_auth: false` and
a non-empty `x-openai-actor-authorization` header. Core accepts that
provider shape, but both extension provider-name gates rejected it;
image generation additionally required a Codex-managed login.

The standalone paths must coexist with existing builtin tools. New
Responses Lite models can receive `image_gen.imagegen` and `web.run`,
while older models continue using builtin tools.

## Impact

This enables both standalone extensions for CCA once installed
downstream, without removing or changing builtin-tool compatibility for
older models.

## Validation

- `just test -p codex-core
responses_lite_exposes_standalone_tools_for_actor_authorized_provider`
- `just test -p codex-core
responses_lite_uses_standalone_web_search_and_image_generation`
- `just test -p codex-core
hosted_tools_follow_provider_auth_model_and_config_gates`
- `just test -p codex-image-generation-extension`
- `just test -p codex-web-search-extension`
- `just test -p codex-model-provider-info`
- `just fmt`
- `git diff --check`
This commit is contained in:
Won Park
2026-06-25 18:34:35 -07:00
committed by GitHub
Unverified
parent ec300bc7bd
commit 0d4351c1b8
7 changed files with 125 additions and 97 deletions
+4 -12
View File
@@ -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<dyn CoreToolRuntime>;
@@ -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;
+18 -77
View File
@@ -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);
+59 -1
View File
@@ -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(()));