[codex] Use standalone tools for Responses Lite (#26490)

## Summary

Responses Lite does not execute hosted Responses tools, so models using
it must route web search and image generation through Codex-owned
executors & standalone Response's API endpoints.

This PR is stacked on #26487.

## Validation

- `cargo test -p codex-core responses_lite_ --lib`
- `cargo test -p codex-core
standalone_executors_remain_hidden_without_flags_or_responses_lite
--lib`
- `cargo test -p codex-core
hosted_tools_follow_provider_auth_model_and_config_gates --lib`
- `cargo test -p codex-web-search-extension -p
codex-image-generation-extension`
- `cargo test -p codex-app-server --test all standalone_`
- `cargo fmt --all -- --check`
This commit is contained in:
rka-oai
2026-06-05 17:23:40 -07:00
committed by GitHub
Unverified
parent 4f655bc3b7
commit ffe90cb5c3
11 changed files with 259 additions and 50 deletions
+2 -2
View File
@@ -2549,6 +2549,7 @@ dependencies = [
"codex-feedback",
"codex-git-utils",
"codex-hooks",
"codex-image-generation-extension",
"codex-install-context",
"codex-login",
"codex-mcp",
@@ -2584,6 +2585,7 @@ dependencies = [
"codex-utils-pty",
"codex-utils-stream-parser",
"codex-utils-string",
"codex-web-search-extension",
"codex-windows-sandbox",
"core_test_support",
"csv",
@@ -3048,7 +3050,6 @@ dependencies = [
"codex-api",
"codex-core",
"codex-extension-api",
"codex-features",
"codex-login",
"codex-model-provider",
"codex-model-provider-info",
@@ -4208,7 +4209,6 @@ dependencies = [
"codex-api",
"codex-core",
"codex-extension-api",
"codex-features",
"codex-login",
"codex-model-provider",
"codex-model-provider-info",
+2
View File
@@ -133,9 +133,11 @@ codex-shell-escalation = { workspace = true }
[dev-dependencies]
assert_cmd = { workspace = true }
assert_matches = { workspace = true }
codex-image-generation-extension = { workspace = true }
codex-otel = { workspace = true }
codex-test-binary-support = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
codex-web-search-extension = { workspace = true }
core_test_support = { workspace = true }
ctor = { workspace = true }
insta = { workspace = true }
+44 -20
View File
@@ -56,6 +56,7 @@ use crate::tools::router::ToolRouterParams;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_mcp::ToolInfo;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::InputModality;
@@ -246,22 +247,31 @@ fn spec_for_model_request(
fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec<ToolSpec> {
let turn_context = context.turn_context;
// Responses Lite accepts schemas for client-executed tools, not hosted Responses tools.
if turn_context.model_info.use_responses_lite {
return Vec::new();
}
let mut specs = Vec::new();
let provider_capabilities = turn_context.provider.capabilities();
let web_search_mode = (!standalone_web_run_available(context.extension_tool_executors)
&& provider_capabilities.web_search)
let standalone_web_search_available = standalone_web_search_enabled(turn_context)
&& context
.extension_tool_executors
.iter()
.any(|executor| executor.tool_name() == ToolName::namespaced("web", "run"));
// `Some(Cached/Live/Disabled)` are the options for mode when standalone search is unavailable
// and the provider supports hosted search. `None` prevents emitting a hosted search tool.
let web_search_mode = (!standalone_web_search_available
&& turn_context.provider.capabilities().web_search)
.then_some(turn_context.config.web_search_mode.value());
let web_search_config = if provider_capabilities.web_search {
turn_context.config.web_search_config.as_ref()
} else {
None
};
if let Some(web_search_tool) = create_web_search_tool(WebSearchToolOptions {
let web_search_config = web_search_mode
.as_ref()
.and(turn_context.config.web_search_config.as_ref());
if let Some(hosted_web_search_tool) = create_web_search_tool(WebSearchToolOptions {
web_search_mode,
web_search_config,
web_search_tool_type: turn_context.model_info.web_search_tool_type,
}) {
specs.push(web_search_tool);
specs.push(hosted_web_search_tool);
}
// TODO: Remove hosted image generation once the standalone extension is ready.
if image_generation_tool_enabled(turn_context)
@@ -336,9 +346,15 @@ fn image_generation_runtime_enabled(turn_context: &TurnContext) -> bool {
}
fn standalone_image_generation_model_visible(turn_context: &TurnContext) -> bool {
image_generation_runtime_enabled(turn_context)
&& turn_context.features.get().enabled(Feature::ImageGenExt)
&& namespace_tools_enabled(turn_context)
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)
}
fn standalone_image_generation_available(
@@ -554,13 +570,13 @@ fn add_tool_sources(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plann
}
}
fn standalone_web_run_available(
extension_tools: &[Arc<dyn ToolExecutor<ExtensionToolCall>>],
) -> bool {
let web_run = ToolName::namespaced("web", "run");
extension_tools
.iter()
.any(|executor| executor.tool_name() == web_run)
fn standalone_web_search_enabled(turn_context: &TurnContext) -> bool {
namespace_tools_enabled(turn_context)
&& (turn_context.model_info.use_responses_lite
|| turn_context
.features
.get()
.enabled(Feature::StandaloneWebSearch))
}
fn add_shell_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) {
@@ -883,8 +899,16 @@ fn append_extension_tool_executors(
reserved_tool_names.insert(ToolName::plain(TOOL_SEARCH_TOOL_NAME));
}
let standalone_web_search_enabled = standalone_web_search_enabled(turn_context);
let web_search_mode_on = turn_context.config.web_search_mode.value() != WebSearchMode::Disabled;
for executor in executors.iter().cloned() {
let tool_name = executor.tool_name();
if tool_name == ToolName::namespaced("web", "run")
&& (!standalone_web_search_enabled || !web_search_mode_on)
{
continue;
}
if tool_name == ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME)
&& !standalone_image_generation_model_visible(turn_context)
{
+30 -1
View File
@@ -23,12 +23,14 @@ use codex_core::thread_store_from_config;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::RemoveOptions;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::empty_extension_registry;
use codex_login::CodexAuth;
use codex_model_provider_info::ModelProviderInfo;
use codex_model_provider_info::built_in_model_providers;
use codex_models_manager::bundled_models_response;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
@@ -216,6 +218,7 @@ pub struct TestCodexBuilder {
cloud_config_bundle: Option<CloudConfigBundleLoader>,
user_shell_override: Option<Shell>,
exec_server_url: Option<String>,
extensions: Arc<ExtensionRegistry<Config>>,
}
impl TestCodexBuilder {
@@ -239,6 +242,26 @@ impl TestCodexBuilder {
})
}
pub fn with_model_info_override<T>(self, model: &str, override_model_info: T) -> Self
where
T: FnOnce(&mut ModelInfo) + Send + 'static,
{
let model = model.to_string();
self.with_config(move |config| {
let model_catalog = config.model_catalog.get_or_insert_with(|| {
bundled_models_response()
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"))
});
let model_info = model_catalog
.models
.iter_mut()
.find(|model_info| model_info.slug == model)
.unwrap_or_else(|| panic!("{model} should exist in the configured model catalog"));
override_model_info(model_info);
config.model = Some(model);
})
}
pub fn with_pre_build_hook<F>(mut self, hook: F) -> Self
where
F: FnOnce(&Path) + Send + 'static,
@@ -280,6 +303,11 @@ impl TestCodexBuilder {
self
}
pub fn with_extensions(mut self, extensions: Arc<ExtensionRegistry<Config>>) -> Self {
self.extensions = extensions;
self
}
pub fn with_windows_cmd_shell(self) -> Self {
if cfg!(windows) {
self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe")))
@@ -473,7 +501,7 @@ impl TestCodexBuilder {
codex_core::test_support::auth_manager_from_auth(auth.clone()),
SessionSource::Exec,
Arc::clone(&environment_manager),
empty_extension_registry(),
Arc::clone(&self.extensions),
/*analytics_events_client*/ None,
thread_store,
state_db.clone(),
@@ -1041,6 +1069,7 @@ pub fn test_codex() -> TestCodexBuilder {
cloud_config_bundle: None,
user_shell_override: None,
exec_server_url: None,
extensions: empty_extension_registry(),
}
}
+3 -13
View File
@@ -1894,20 +1894,10 @@ async fn responses_lite_sets_all_turns_context_and_disables_parallel_tool_calls(
)
.await;
let mut model_catalog = bundled_models_response()
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
let model = model_catalog
.models
.iter_mut()
.find(|model| model.slug == "gpt-5.4")
.expect("gpt-5.4 exists in bundled models.json");
model.use_responses_lite = true;
model.supports_parallel_tool_calls = true;
let TestCodex { codex, .. } = test_codex()
.with_model("gpt-5.4")
.with_config(move |config| {
config.model_catalog = Some(model_catalog);
.with_model_info_override("gpt-5.4", |model_info| {
model_info.use_responses_lite = true;
model_info.supports_parallel_tool_calls = true;
})
.build(&server)
.await?;
+1
View File
@@ -91,6 +91,7 @@ mod request_permissions_tool;
mod request_plugin_install;
mod request_user_input;
mod responses_api_proxy_headers;
mod responses_lite;
mod resume;
mod resume_warning;
mod review;
+167
View File
@@ -0,0 +1,167 @@
use std::sync::Arc;
use anyhow::Context;
use anyhow::Result;
use codex_core::config::Config;
use codex_extension_api::ExtensionRegistry;
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_protocol::config_types::WebSearchMode;
use codex_protocol::openai_models::InputModality;
use codex_web_search_extension::install as install_web_search_extension;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use serde_json::Value;
fn responses_extensions(auth: &CodexAuth) -> Arc<ExtensionRegistry<Config>> {
let auth_manager = codex_core::test_support::auth_manager_from_auth(auth.clone());
let mut extension_builder = ExtensionRegistryBuilder::<Config>::new();
install_web_search_extension(&mut extension_builder, Arc::clone(&auth_manager));
install_image_generation_extension(&mut extension_builder, auth_manager);
Arc::new(extension_builder.build())
}
fn configure_responses_tools(config: &mut Config) {
assert!(config.web_search_mode.set(WebSearchMode::Live).is_ok());
assert!(
config
.features
.disable(Feature::StandaloneWebSearch)
.is_ok()
);
assert!(config.features.enable(Feature::ImageGeneration).is_ok());
assert!(config.features.disable(Feature::ImageGenExt).is_ok());
}
fn configure_image_capable_model(model_info: &mut codex_protocol::openai_models::ModelInfo) {
model_info.input_modalities = vec![InputModality::Text, InputModality::Image];
}
fn has_hosted_tool(tools: &[Value], tool_type: &str) -> bool {
tools
.iter()
.any(|tool| tool.get("type").and_then(Value::as_str) == Some(tool_type))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_lite_uses_standalone_web_search_and_image_generation() -> 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(configure_responses_tools);
let test = builder.build(&server).await?;
test.submit_turn("Use standalone tools").await?;
let request = response_mock.single_request();
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();
let tools = body["tools"]
.as_array()
.context("Responses request tools should be an array")?;
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_omits_hosted_tools_without_standalone_extensions() -> 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 mut builder = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_model_info_override("gpt-5.4", |model_info| {
model_info.use_responses_lite = true;
configure_image_capable_model(model_info);
})
.with_config(configure_responses_tools);
let test = builder.build(&server).await?;
test.submit_turn("Do not use hosted tools").await?;
let body = response_mock.single_request().body_json();
let tools = body["tools"]
.as_array()
.context("Responses request tools should be an array")?;
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 non_lite_uses_hosted_tools_when_standalone_features_are_disabled() -> 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", configure_image_capable_model)
.with_config(configure_responses_tools);
let test = builder.build(&server).await?;
test.submit_turn("Use hosted tools").await?;
let request = response_mock.single_request();
assert!(request.tool_by_name("web", "run").is_none());
assert!(request.tool_by_name("image_gen", "imagegen").is_none());
let body = request.body_json();
let tools = body["tools"]
.as_array()
.context("Responses request tools should be an array")?;
assert!(has_hosted_tool(tools, "web_search"));
assert!(has_hosted_tool(tools, "image_generation"));
Ok(())
}
-1
View File
@@ -17,7 +17,6 @@ async-trait = { workspace = true }
codex-api = { workspace = true }
codex-core = { workspace = true }
codex-extension-api = { workspace = true }
codex-features = { workspace = true }
codex-login = { workspace = true }
codex-model-provider = { workspace = true }
codex-model-provider-info = { workspace = true }
@@ -9,7 +9,6 @@ use codex_extension_api::ThreadStartInput;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolContributor;
use codex_extension_api::ToolExecutor;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_model_provider::create_model_provider;
use codex_model_provider_info::ModelProviderInfo;
@@ -25,7 +24,7 @@ struct ImageGenerationExtension {
#[derive(Clone)]
struct ImageGenerationExtensionConfig {
enabled: bool,
available: bool,
provider: ModelProviderInfo,
codex_home: AbsolutePathBuf,
}
@@ -34,8 +33,8 @@ impl From<&Config> for ImageGenerationExtensionConfig {
/// Resolves whether standalone image generation should be available for a thread.
fn from(config: &Config) -> Self {
Self {
enabled: config.features.enabled(Feature::ImageGenExt)
&& config.model_provider.is_openai(),
// Core selects this executor per turn using the feature flag or model metadata.
available: config.model_provider.is_openai(),
provider: config.model_provider.clone(),
codex_home: config.codex_home.clone(),
}
@@ -75,7 +74,7 @@ impl ToolContributor for ImageGenerationExtension {
let Some(config) = thread_store.get::<ImageGenerationExtensionConfig>() else {
return Vec::new();
};
if !config.enabled || !self.auth_manager.current_auth_uses_codex_backend() {
if !config.available || !self.auth_manager.current_auth_uses_codex_backend() {
return Vec::new();
}
@@ -90,7 +89,7 @@ impl ToolContributor for ImageGenerationExtension {
}
}
/// Installs the feature-gated standalone image-generation extension contributors.
/// Installs the standalone image-generation extension contributors.
pub fn install(registry: &mut ExtensionRegistryBuilder<Config>, auth_manager: Arc<AuthManager>) {
let extension = Arc::new(ImageGenerationExtension { auth_manager });
registry.thread_lifecycle_contributor(extension.clone());
-1
View File
@@ -17,7 +17,6 @@ async-trait = { workspace = true }
codex-api = { workspace = true }
codex-core = { workspace = true }
codex-extension-api = { workspace = true }
codex-features = { workspace = true }
codex-login = { workspace = true }
codex-model-provider = { workspace = true }
codex-model-provider-info = { workspace = true }
+5 -6
View File
@@ -13,7 +13,6 @@ use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ThreadLifecycleContributor;
use codex_extension_api::ThreadStartInput;
use codex_extension_api::ToolContributor;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_model_provider::create_model_provider;
use codex_model_provider_info::ModelProviderInfo;
@@ -29,7 +28,7 @@ struct WebSearchExtension {
#[derive(Clone)]
struct WebSearchExtensionConfig {
enabled: bool,
available: bool,
provider: ModelProviderInfo,
settings: SearchSettings,
}
@@ -38,8 +37,8 @@ impl From<&Config> for WebSearchExtensionConfig {
fn from(config: &Config) -> Self {
let web_search_mode = config.web_search_mode.value();
Self {
enabled: config.features.enabled(Feature::StandaloneWebSearch)
&& config.model_provider.is_openai()
// Core selects this executor per turn using the feature flag or model metadata.
available: config.model_provider.is_openai()
&& web_search_mode != WebSearchMode::Disabled,
provider: config.model_provider.clone(),
settings: search_settings(config, web_search_mode),
@@ -111,7 +110,7 @@ impl ToolContributor for WebSearchExtension {
let Some(config) = thread_store.get::<WebSearchExtensionConfig>() else {
return Vec::new();
};
if !config.enabled {
if !config.available {
return Vec::new();
}
@@ -160,7 +159,7 @@ mod tests {
let session_store = ExtensionData::new("session");
let thread_store = ExtensionData::new("11111111-1111-4111-8111-111111111111");
thread_store.insert(WebSearchExtensionConfig {
enabled: true,
available: true,
provider: ModelProviderInfo::create_openai_provider(/*base_url*/ None),
settings: Default::default(),
});