mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
0d4351c1b8
## 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`
422 lines
14 KiB
Rust
422 lines
14 KiB
Rust
use std::collections::HashMap;
|
|
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::models::ImageDetail;
|
|
use codex_protocol::openai_models::InputModality;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::Op;
|
|
use codex_protocol::user_input::UserInput;
|
|
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 core_test_support::wait_for_event;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
|
|
const RESPONSES_LITE_HEADER: &str = "x-openai-internal-codex-responses-lite";
|
|
|
|
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, |config| {
|
|
Some(config.codex_home.clone())
|
|
});
|
|
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))
|
|
}
|
|
|
|
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()
|
|
.context("Responses request input should be an array")?
|
|
.first()
|
|
.filter(|item| item.get("type").and_then(Value::as_str) == Some("additional_tools"))
|
|
.context("Responses request should start with additional_tools")?["tools"]
|
|
.as_array()
|
|
.map(Vec::as_slice)
|
|
.context("additional_tools tools should be an array")
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_lite_uses_input_items_for_instructions_and_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 mut builder = test_codex()
|
|
.with_model_info_override("gpt-5.4", |model_info| {
|
|
model_info.use_responses_lite = true;
|
|
})
|
|
.with_config(|config| {
|
|
config.base_instructions = Some("test instructions".to_string());
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("hello").await?;
|
|
|
|
let body = response_mock.single_request().body_json();
|
|
assert!(body.get("instructions").is_none());
|
|
assert!(body.get("tools").is_none());
|
|
|
|
let input = body["input"]
|
|
.as_array()
|
|
.context("Responses request input should be an array")?;
|
|
assert_eq!(input[0]["type"], "additional_tools");
|
|
assert_eq!(input[0]["role"], "developer");
|
|
assert_eq!(
|
|
input[1],
|
|
serde_json::json!({
|
|
"type": "message",
|
|
"role": "developer",
|
|
"content": [{
|
|
"type": "input_text",
|
|
"text": "test instructions",
|
|
}],
|
|
})
|
|
);
|
|
|
|
let tools = additional_tools(&body)?;
|
|
assert!(!tools.is_empty());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_lite_prepares_images() -> 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 image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
|
|
let remote_image_url = "https://example.com/image.png";
|
|
let mut builder = test_codex().with_model_info_override("gpt-5.4", |model_info| {
|
|
model_info.use_responses_lite = true;
|
|
configure_image_capable_model(model_info);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.codex
|
|
.submit(Op::UserInput {
|
|
items: vec![
|
|
UserInput::Image {
|
|
image_url: image_url.to_string(),
|
|
detail: Some(ImageDetail::Original),
|
|
},
|
|
UserInput::Image {
|
|
image_url: remote_image_url.to_string(),
|
|
detail: Some(ImageDetail::High),
|
|
},
|
|
],
|
|
final_output_json_schema: None,
|
|
responsesapi_client_metadata: None,
|
|
additional_context: Default::default(),
|
|
thread_settings: Default::default(),
|
|
})
|
|
.await?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
|
|
let request = response_mock.single_request();
|
|
let user_content = request
|
|
.input()
|
|
.into_iter()
|
|
.rev()
|
|
.find(|item| item.get("role").and_then(Value::as_str) == Some("user"))
|
|
.and_then(|item| item.get("content").and_then(Value::as_array).cloned())
|
|
.context("request should contain user content")?;
|
|
assert_eq!(
|
|
user_content,
|
|
vec![
|
|
serde_json::json!({
|
|
"type": "input_image",
|
|
"image_url": image_url
|
|
}),
|
|
serde_json::json!({
|
|
"type": "input_text",
|
|
"text": "image content omitted because remote image URLs are not supported"
|
|
}),
|
|
]
|
|
);
|
|
assert!(!request.body_json().to_string().contains(remote_image_url));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[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();
|
|
assert_eq!(
|
|
request.header(RESPONSES_LITE_HEADER).as_deref(),
|
|
Some("true")
|
|
);
|
|
let body = request.body_json();
|
|
assert!(body.get("tools").is_none());
|
|
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_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(()));
|
|
|
|
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 compact_mock =
|
|
responses::mount_compact_json_once(&server, serde_json::json!({ "output": [] })).await;
|
|
|
|
let mut builder = test_codex()
|
|
.with_model_info_override("gpt-5.4", |model_info| {
|
|
model_info.use_responses_lite = true;
|
|
model_info.supports_parallel_tool_calls = true;
|
|
})
|
|
.with_config(|config| {
|
|
let _ = config.features.disable(Feature::RemoteCompactionV2);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("Compact this conversation").await?;
|
|
test.codex.submit(Op::Compact).await?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
|
|
response_mock.single_request();
|
|
let compact_request = compact_mock.single_request();
|
|
assert_eq!(
|
|
compact_request.header(RESPONSES_LITE_HEADER).as_deref(),
|
|
Some("true")
|
|
);
|
|
let compact_body = compact_request.body_json();
|
|
assert_eq!(
|
|
compact_body
|
|
.get("reasoning")
|
|
.and_then(|reasoning| reasoning.get("context"))
|
|
.and_then(Value::as_str),
|
|
Some("all_turns")
|
|
);
|
|
assert_eq!(
|
|
compact_body.get("parallel_tool_calls"),
|
|
Some(&Value::Bool(false))
|
|
);
|
|
|
|
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();
|
|
assert!(body.get("tools").is_none());
|
|
let tools = additional_tools(&body)?;
|
|
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_eq!(request.header(RESPONSES_LITE_HEADER), None);
|
|
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(())
|
|
}
|