Use the image generation extension by default (#31596)

This commit is contained in:
Won Park
2026-07-09 12:25:19 +09:00
committed by GitHub
parent 2342b2c2a6
commit a7c72aee8b
33 changed files with 158 additions and 861 deletions
+2
View File
@@ -2760,6 +2760,7 @@ dependencies = [
"codex-extension-api",
"codex-features",
"codex-home",
"codex-image-generation-extension",
"codex-login",
"codex-model-provider-info",
"codex-models-manager",
@@ -3443,6 +3444,7 @@ dependencies = [
"codex-exec-server",
"codex-extension-api",
"codex-home",
"codex-image-generation-extension",
"codex-login",
"codex-protocol",
"codex-shell-command",
@@ -589,7 +589,6 @@ model_provider = "openai-custom"
chatgpt_base_url = "{server_uri}"
[features]
imagegenext = true
{code_mode_only}
[model_providers.openai-custom]
+1
View File
@@ -21,6 +21,7 @@ codex-config = { workspace = true }
codex-core = { workspace = true }
codex-extension-api = { workspace = true }
codex-home = { workspace = true }
codex-image-generation-extension = { workspace = true }
codex-exec-server = { workspace = true }
codex-features = { workspace = true }
codex-login = { workspace = true }
+2
View File
@@ -61,6 +61,7 @@ pub use codex_exec_server::NoiseChannelIdentity;
pub use codex_exec_server::NoiseChannelPublicKey;
pub use codex_exec_server::NoiseRendezvousConnectBundle;
pub use codex_exec_server::NoiseRendezvousConnectProvider;
pub use codex_extension_api::ExtensionRegistryBuilder;
pub use codex_extension_api::LoadUserInstructionsFuture;
pub use codex_extension_api::LoadedUserInstructions;
pub use codex_extension_api::UserInstructions;
@@ -69,6 +70,7 @@ pub use codex_extension_api::empty_extension_registry;
pub use codex_features::Feature;
pub use codex_features::Features;
pub use codex_home::CodexHomeUserInstructionsProvider;
pub use codex_image_generation_extension::install as install_image_generation_extension;
pub use codex_login::AuthHeaders;
pub use codex_login::AuthManager;
pub use codex_login::CodexAuth;
@@ -1,4 +1,3 @@
use super::ContextualUserFragment;
use std::fmt::Display;
/// Maximum size of the extension's model-facing generated-image path hint.
@@ -21,36 +20,3 @@ fn image_generation_hint(
"Generated images are saved to {image_output_dir} as {image_output_path} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it."
)
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ImageGenerationInstructions {
image_output_dir: String,
image_output_path: String,
}
impl ImageGenerationInstructions {
pub(crate) fn new(image_output_dir: impl Display, image_output_path: impl Display) -> Self {
Self {
image_output_dir: image_output_dir.to_string(),
image_output_path: image_output_path.to_string(),
}
}
}
impl ContextualUserFragment for ImageGenerationInstructions {
fn role(&self) -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
image_generation_hint(&self.image_output_dir, &self.image_output_path)
}
}
-1
View File
@@ -50,7 +50,6 @@ pub(crate) use contextual_user_message::parse_visible_hook_prompt_message;
pub(crate) use current_time_reminder::CurrentTimeReminder;
pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder;
pub(crate) use hook_additional_context::HookAdditionalContext;
pub(crate) use image_generation_instructions::ImageGenerationInstructions;
pub use image_generation_instructions::extension_image_generation_output_hint;
pub(crate) use inter_agent_completion_message::InterAgentCompletionMessage;
pub use internal_model_context::InternalContextSource;
-139
View File
@@ -8572,49 +8572,6 @@ async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_hint_is_emp
);
}
#[tokio::test]
async fn build_initial_context_omits_default_image_save_location_with_image_history() {
let (session, turn_context) = make_session_and_context().await;
session
.replace_history(
vec![ResponseItem::ImageGenerationCall {
id: Some("ig-test".to_string()),
status: "completed".to_string(),
revised_prompt: Some("a tiny blue square".to_string()),
result: "Zm9v".to_string(),
internal_chat_message_metadata_passthrough: None,
}],
/*reference_context_item*/ None,
)
.await;
let turn_context = Arc::new(turn_context);
let initial_context = build_initial_context(&session, &turn_context).await;
let developer_texts = developer_input_texts(&initial_context);
assert!(
!developer_texts
.iter()
.any(|text| text.contains("Generated images are saved to")),
"expected initial context to omit image save instructions even with image history, got {developer_texts:?}"
);
}
#[tokio::test]
async fn build_initial_context_omits_default_image_save_location_without_image_history() {
let (session, turn_context) = make_session_and_context().await;
let turn_context = Arc::new(turn_context);
let initial_context = build_initial_context(&session, &turn_context).await;
let developer_texts = developer_input_texts(&initial_context);
assert!(
!developer_texts
.iter()
.any(|text| text.contains("Generated images are saved to")),
"expected initial context to omit image save instructions without image history, got {developer_texts:?}"
);
}
#[tokio::test]
async fn build_initial_context_trims_skill_metadata_from_context_window_budget() {
let (session, mut turn_context) = make_session_and_context().await;
@@ -8822,102 +8779,6 @@ async fn build_initial_context_emits_thread_start_skill_warning_on_repeated_buil
));
}
#[tokio::test]
async fn handle_output_item_done_records_image_save_history_message() {
let (session, turn_context) = make_session_and_context().await;
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let call_id = "ig_history_records_message";
let expected_saved_path = crate::stream_events_utils::image_generation_artifact_path(
&turn_context.config.codex_home,
&session.thread_id.to_string(),
call_id,
);
let _ = std::fs::remove_file(&expected_saved_path);
let item = ResponseItem::ImageGenerationCall {
id: Some(call_id.to_string()),
status: "completed".to_string(),
revised_prompt: Some("a tiny blue square".to_string()),
result: "Zm9v".to_string(),
internal_chat_message_metadata_passthrough: None,
};
let mut ctx = HandleOutputCtx {
sess: Arc::clone(&session),
turn_context: Arc::clone(&turn_context),
turn_store: Arc::new(codex_extension_api::ExtensionData::new(
turn_context.sub_id.clone(),
)),
tool_runtime: test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context)),
cancellation_token: CancellationToken::new(),
};
handle_output_item_done(&mut ctx, item.clone(), /*previously_active_item*/ None)
.await
.expect("image generation item should succeed");
let history = session.clone_history().await;
let image_output_path = crate::stream_events_utils::image_generation_artifact_path(
&turn_context.config.codex_home,
&session.thread_id.to_string(),
"<image_id>",
);
let image_output_dir = image_output_path
.parent()
.expect("generated image path should have a parent");
let image_message: ResponseItem = crate::context::ContextualUserFragment::into(
crate::context::ImageGenerationInstructions::new(
image_output_dir.display(),
image_output_path.display(),
),
);
let expected = vec![image_message, item];
assert_eq!(strip_metadata_from_items(history.raw_items()), expected);
assert_eq!(
std::fs::read(&expected_saved_path).expect("saved file"),
b"foo"
);
let _ = std::fs::remove_file(&expected_saved_path);
}
#[tokio::test]
async fn handle_output_item_done_skips_image_save_message_when_save_fails() {
let (session, turn_context) = make_session_and_context().await;
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let call_id = "ig_history_no_message";
let expected_saved_path = crate::stream_events_utils::image_generation_artifact_path(
&turn_context.config.codex_home,
&session.thread_id.to_string(),
call_id,
);
let _ = std::fs::remove_file(&expected_saved_path);
let item = ResponseItem::ImageGenerationCall {
id: Some(call_id.to_string()),
status: "completed".to_string(),
revised_prompt: Some("broken payload".to_string()),
result: "_-8".to_string(),
internal_chat_message_metadata_passthrough: None,
};
let mut ctx = HandleOutputCtx {
sess: Arc::clone(&session),
turn_context: Arc::clone(&turn_context),
turn_store: Arc::new(codex_extension_api::ExtensionData::new(
turn_context.sub_id.clone(),
)),
tool_runtime: test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context)),
cancellation_token: CancellationToken::new(),
};
handle_output_item_done(&mut ctx, item.clone(), /*previously_active_item*/ None)
.await
.expect("image generation item should still complete");
let history = session.clone_history().await;
let expected = vec![item];
assert_eq!(strip_metadata_from_items(history.raw_items()), expected);
assert!(!expected_saved_path.exists());
}
#[tokio::test]
async fn build_initial_context_uses_previous_turn_settings_for_realtime_end() {
let (session, turn_context) = make_session_and_context().await;
-2
View File
@@ -1853,7 +1853,6 @@ async fn handle_assistant_item_done_in_plan_mode(
let mut finalized_facts = None;
if let Some(finalized_turn_item) = finalize_non_tool_response_item(
sess,
turn_context,
TurnItemContributorPolicy::Run(turn_store),
item,
/*plan_mode*/ true,
@@ -2162,7 +2161,6 @@ async fn try_run_sampling_request(
}
if let Some(turn_item) = handle_non_tool_response_item(
sess.as_ref(),
turn_context.as_ref(),
TurnItemContributorPolicy::Skip,
&item,
plan_mode,
+4 -118
View File
@@ -1,17 +1,12 @@
use std::pin::Pin;
use std::sync::Arc;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_extension_api::ExtensionData;
use codex_protocol::config_types::ModeKind;
use codex_protocol::items::ImageGenerationItem;
use codex_protocol::items::TurnItem;
use codex_utils_stream_parser::strip_citations;
use tokio_util::sync::CancellationToken;
use crate::context::ContextualUserFragment;
use crate::context::ImageGenerationInstructions;
use crate::function_tool::FunctionCallError;
use crate::parse_turn_item;
use crate::session::session::Session;
@@ -108,85 +103,6 @@ pub(crate) fn raw_assistant_output_text_from_item(item: &ResponseItem) -> Option
None
}
async fn save_image_generation_result(
codex_home: &AbsolutePathBuf,
session_id: &str,
call_id: &str,
result: &str,
) -> Result<AbsolutePathBuf> {
let bytes = BASE64_STANDARD
.decode(result.trim().as_bytes())
.map_err(|err| {
CodexErr::InvalidRequest(format!("invalid image generation payload: {err}"))
})?;
let path = image_generation_artifact_path(codex_home, session_id, call_id);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(&path, bytes).await?;
Ok(path)
}
pub(crate) async fn persist_image_generation_item(
sess: &Session,
turn_context: &TurnContext,
image_item: &mut ImageGenerationItem,
) -> Option<AbsolutePathBuf> {
image_item.saved_path = None;
let session_id = sess.thread_id.to_string();
match save_image_generation_result(
&turn_context.config.codex_home,
&session_id,
&image_item.id,
&image_item.result,
)
.await
{
Ok(path) => {
image_item.saved_path = Some(path.clone());
Some(path)
}
Err(err) => {
let output_path = image_generation_artifact_path(
&turn_context.config.codex_home,
&session_id,
&image_item.id,
);
let output_dir = output_path
.parent()
.unwrap_or_else(|| turn_context.config.codex_home.clone());
tracing::warn!(
call_id = %image_item.id,
output_dir = %output_dir.display(),
"failed to save generated image: {err}"
);
None
}
}
}
async fn record_image_generation_instructions(
sess: &Session,
turn_context: &TurnContext,
image_item: &ImageGenerationItem,
) {
if image_item.saved_path.is_none() {
return;
}
let session_id = sess.thread_id.to_string();
let image_output_path =
image_generation_artifact_path(&turn_context.config.codex_home, &session_id, "<image_id>");
let image_output_dir = image_output_path
.parent()
.unwrap_or_else(|| turn_context.config.codex_home.clone());
let message: ResponseItem = ContextualUserFragment::into(ImageGenerationInstructions::new(
image_output_dir.display(),
image_output_path.display(),
));
sess.record_conversation_items(turn_context, &[message])
.await;
}
/// Persist a completed model response item and record any cited memory usage.
pub(crate) async fn record_completed_response_item(
sess: &Session,
@@ -356,14 +272,12 @@ pub(crate) struct FinalizedTurnItemFacts {
pub(crate) async fn finalize_non_tool_response_item(
sess: &Session,
turn_context: &TurnContext,
contributor_policy: TurnItemContributorPolicy<'_>,
item: &ResponseItem,
plan_mode: bool,
) -> Option<FinalizedTurnItem> {
let turn_item =
handle_non_tool_response_item(sess, turn_context, contributor_policy, item, plan_mode)
.await?;
handle_non_tool_response_item(sess, contributor_policy, item, plan_mode).await?;
let (memory_citation, last_agent_message, defers_mailbox_delivery_to_next_turn) =
match &turn_item {
TurnItem::AgentMessage(agent_message) => {
@@ -388,7 +302,6 @@ pub(crate) async fn finalize_non_tool_response_item(
defers_mailbox_delivery_to_next_turn,
)
}
TurnItem::ImageGeneration(_) => (None, None, true),
_ => (None, None, false),
};
Some(FinalizedTurnItem {
@@ -446,7 +359,6 @@ pub(crate) async fn handle_output_item_done(
Ok(None) => {
let finalized_turn_item = finalize_non_tool_response_item(
ctx.sess.as_ref(),
ctx.turn_context.as_ref(),
TurnItemContributorPolicy::Run(ctx.turn_store.as_ref()),
&item,
plan_mode,
@@ -457,15 +369,8 @@ pub(crate) async fn handle_output_item_done(
.map(|finalized| finalized.facts.clone());
if let Some(finalized_turn_item) = finalized_turn_item {
if previously_active_item.is_none() {
let mut started_item = finalized_turn_item.turn_item.clone();
if let TurnItem::ImageGeneration(item) = &mut started_item {
item.status = "in_progress".to_string();
item.revised_prompt = None;
item.result.clear();
item.saved_path = None;
}
ctx.sess
.emit_turn_item_started(&ctx.turn_context, &started_item)
.emit_turn_item_started(&ctx.turn_context, &finalized_turn_item.turn_item)
.await;
}
@@ -516,7 +421,6 @@ pub(crate) async fn handle_output_item_done(
pub(crate) async fn handle_non_tool_response_item(
sess: &Session,
turn_context: &TurnContext,
contributor_policy: TurnItemContributorPolicy<'_>,
item: &ResponseItem,
plan_mode: bool,
@@ -526,20 +430,9 @@ pub(crate) async fn handle_non_tool_response_item(
match item {
ResponseItem::Message { .. }
| ResponseItem::Reasoning { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. } => {
| ResponseItem::WebSearchCall { .. } => {
let mut turn_item = parse_turn_item(item)?;
finalize_turn_item(
sess,
turn_context,
contributor_policy,
&mut turn_item,
plan_mode,
)
.await;
if let TurnItem::ImageGeneration(image_item) = &turn_item {
record_image_generation_instructions(sess, turn_context, image_item).await;
}
finalize_turn_item(sess, contributor_policy, &mut turn_item, plan_mode).await;
Some(turn_item)
}
ResponseItem::FunctionCallOutput { .. }
@@ -554,7 +447,6 @@ pub(crate) async fn handle_non_tool_response_item(
pub(crate) async fn finalize_turn_item(
sess: &Session,
turn_context: &TurnContext,
contributor_policy: TurnItemContributorPolicy<'_>,
turn_item: &mut TurnItem,
plan_mode: bool,
@@ -578,11 +470,6 @@ pub(crate) async fn finalize_turn_item(
agent_message.memory_citation = memory_citation;
}
}
if let TurnItem::ImageGeneration(image_item) = &mut *turn_item
&& !image_item.result.is_empty()
{
persist_image_generation_item(sess, turn_context, image_item).await;
}
}
pub(crate) fn last_assistant_message_from_item(
@@ -615,7 +502,6 @@ fn completed_item_defers_mailbox_delivery_to_next_turn(
// to the safer "defer mailbox mail" behavior.
last_assistant_message_from_item(item, plan_mode).is_some()
}
ResponseItem::ImageGenerationCall { .. } => true,
_ => false,
}
}
+1 -117
View File
@@ -4,10 +4,8 @@ use super::completed_item_defers_mailbox_delivery_to_next_turn;
use super::finalize_non_tool_response_item;
use super::handle_non_tool_response_item;
use super::handle_output_item_done;
use super::image_generation_artifact_path;
use super::last_assistant_message_from_item;
use super::response_item_may_include_external_context;
use super::save_image_generation_result;
use crate::session::step_context::StepContext;
use crate::session::tests::make_session_and_context;
use crate::tools::ToolRouter;
@@ -15,7 +13,6 @@ use crate::tools::parallel::ToolCallRuntime;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_extension_api::ExtensionData;
use codex_extension_api::TurnItemContributor;
use codex_protocol::error::CodexErr;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::TurnItem;
use codex_protocol::memory_citation::MemoryCitation;
@@ -26,7 +23,6 @@ use codex_protocol::models::LocalShellExecAction;
use codex_protocol::models::LocalShellStatus;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::ResponseItem;
use codex_utils_absolute_path::test_support::PathExt;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
@@ -139,14 +135,13 @@ fn external_context_pollution_items_exclude_local_tool_calls() {
#[tokio::test]
async fn handle_non_tool_response_item_strips_citations_from_assistant_message() {
let (session, turn_context) = make_session_and_context().await;
let (session, _) = make_session_and_context().await;
let item = assistant_output_text(
"hello<oai-mem-citation><citation_entries>\nMEMORY.md:1-2|note=[x]\n</citation_entries>\n<rollout_ids>\n019cc2ea-1dff-7902-8d40-c8f6e5d83cc4\n</rollout_ids></oai-mem-citation> world",
);
let turn_item = handle_non_tool_response_item(
&session,
&turn_context,
TurnItemContributorPolicy::Skip,
&item,
/*plan_mode*/ false,
@@ -234,7 +229,6 @@ async fn handle_non_tool_response_item_runs_turn_item_contributors_only_when_req
let provisional_turn_item = handle_non_tool_response_item(
&session,
&turn_context,
TurnItemContributorPolicy::Skip,
&item,
/*plan_mode*/ false,
@@ -250,7 +244,6 @@ async fn handle_non_tool_response_item_runs_turn_item_contributors_only_when_req
let turn_item = handle_non_tool_response_item(
&session,
&turn_context,
TurnItemContributorPolicy::Run(&turn_store),
&item,
/*plan_mode*/ false,
@@ -325,7 +318,6 @@ async fn finalized_turn_item_defers_mailbox_for_contributed_visible_text() {
let finalized = finalize_non_tool_response_item(
&session,
&turn_context,
TurnItemContributorPolicy::Run(&turn_store),
&item,
/*plan_mode*/ false,
@@ -351,7 +343,6 @@ async fn finalized_turn_item_keeps_mailbox_open_for_commentary_text() {
let finalized = finalize_non_tool_response_item(
&session,
&turn_context,
TurnItemContributorPolicy::Run(&turn_store),
&item,
/*plan_mode*/ false,
@@ -415,110 +406,3 @@ fn completed_item_keeps_mailbox_delivery_open_for_commentary_messages() {
&item, /*plan_mode*/ false,
));
}
#[test]
fn completed_item_defers_mailbox_delivery_for_image_generation_calls() {
let item = ResponseItem::ImageGenerationCall {
id: Some("ig-1".to_string()),
status: "completed".to_string(),
revised_prompt: None,
result: "Zm9v".to_string(),
internal_chat_message_metadata_passthrough: None,
};
assert!(completed_item_defers_mailbox_delivery_to_next_turn(
&item, /*plan_mode*/ false,
));
}
#[tokio::test]
async fn save_image_generation_result_saves_base64_to_png_in_codex_home() {
let codex_home = tempfile::tempdir().expect("create codex home");
let codex_home = codex_home.path().abs();
let expected_path = image_generation_artifact_path(&codex_home, "session-1", "ig_save_base64");
let _ = std::fs::remove_file(&expected_path);
let saved_path =
save_image_generation_result(&codex_home, "session-1", "ig_save_base64", "Zm9v")
.await
.expect("image should be saved");
assert_eq!(saved_path, expected_path);
assert_eq!(std::fs::read(&saved_path).expect("saved file"), b"foo");
let _ = std::fs::remove_file(&saved_path);
}
#[tokio::test]
async fn save_image_generation_result_rejects_data_url_payload() {
let result = "data:image/jpeg;base64,Zm9v";
let codex_home = tempfile::tempdir().expect("create codex home");
let codex_home = codex_home.path().abs();
let err = save_image_generation_result(&codex_home, "session-1", "ig_456", result)
.await
.expect_err("data url payload should error");
assert!(matches!(err, CodexErr::InvalidRequest(_)));
}
#[tokio::test]
async fn save_image_generation_result_overwrites_existing_file() {
let codex_home = tempfile::tempdir().expect("create codex home");
let codex_home = codex_home.path().abs();
let existing_path = image_generation_artifact_path(&codex_home, "session-1", "ig_overwrite");
std::fs::create_dir_all(
existing_path
.parent()
.expect("generated image path should have a parent"),
)
.expect("create image output dir");
std::fs::write(&existing_path, b"existing").expect("seed existing image");
let saved_path = save_image_generation_result(&codex_home, "session-1", "ig_overwrite", "Zm9v")
.await
.expect("image should be saved");
assert_eq!(saved_path, existing_path);
assert_eq!(std::fs::read(&saved_path).expect("saved file"), b"foo");
let _ = std::fs::remove_file(&saved_path);
}
#[tokio::test]
async fn save_image_generation_result_sanitizes_call_id_for_codex_home_output_path() {
let codex_home = tempfile::tempdir().expect("create codex home");
let codex_home = codex_home.path().abs();
let expected_path = image_generation_artifact_path(&codex_home, "session-1", "../ig/..");
let _ = std::fs::remove_file(&expected_path);
let saved_path = save_image_generation_result(&codex_home, "session-1", "../ig/..", "Zm9v")
.await
.expect("image should be saved");
assert_eq!(saved_path, expected_path);
assert_eq!(std::fs::read(&saved_path).expect("saved file"), b"foo");
let _ = std::fs::remove_file(&saved_path);
}
#[tokio::test]
async fn save_image_generation_result_rejects_non_standard_base64() {
let codex_home = tempfile::tempdir().expect("create codex home");
let codex_home = codex_home.path().abs();
let err = save_image_generation_result(&codex_home, "session-1", "ig_urlsafe", "_-8")
.await
.expect_err("non-standard base64 should error");
assert!(matches!(err, CodexErr::InvalidRequest(_)));
}
#[tokio::test]
async fn save_image_generation_result_rejects_non_base64_data_urls() {
let codex_home = tempfile::tempdir().expect("create codex home");
let codex_home = codex_home.path().abs();
let err = save_image_generation_result(
&codex_home,
"session-1",
"ig_svg",
"data:image/svg+xml,<svg/>",
)
.await
.expect_err("non-base64 data url should error");
assert!(matches!(err, CodexErr::InvalidRequest(_)));
}
-6
View File
@@ -11,12 +11,6 @@ pub struct WebSearchToolOptions<'a> {
pub web_search_tool_type: WebSearchToolType,
}
pub fn create_image_generation_tool(output_format: &str) -> ToolSpec {
ToolSpec::ImageGeneration {
output_format: output_format.to_string(),
}
}
pub fn create_web_search_tool(options: WebSearchToolOptions<'_>) -> Option<ToolSpec> {
let (external_web_access, indexed_web_access) = match options.web_search_mode {
Some(WebSearchMode::Cached) => (false, None),
@@ -7,16 +7,6 @@ use codex_tools::ResponsesApiWebSearchFilters;
use codex_tools::ResponsesApiWebSearchUserLocation;
use pretty_assertions::assert_eq;
#[test]
fn image_generation_tool_matches_expected_spec() {
assert_eq!(
create_image_generation_tool("png"),
ToolSpec::ImageGeneration {
output_format: "png".to_string(),
}
);
}
#[test]
fn web_search_tool_preserves_configured_options() {
assert_eq!(
-1
View File
@@ -473,7 +473,6 @@ fn namespace_function_names(specs: &[ToolSpec], namespace_name: &str) -> Vec<Str
ToolSpec::Function(_)
| ToolSpec::Freeform(_)
| ToolSpec::ToolSearch { .. }
| ToolSpec::ImageGeneration { .. }
| ToolSpec::WebSearch { .. }
| ToolSpec::Namespace(_) => None,
})
+1 -31
View File
@@ -52,7 +52,6 @@ use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHand
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
use crate::tools::handlers::view_image_spec::ViewImageToolOptions;
use crate::tools::hosted_spec::WebSearchToolOptions;
use crate::tools::hosted_spec::create_image_generation_tool;
use crate::tools::hosted_spec::create_web_search_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExposure;
@@ -319,12 +318,6 @@ fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec<ToolSpec> {
}) {
specs.push(hosted_web_search_tool);
}
// TODO: Remove hosted image generation once the standalone extension is ready.
if image_generation_tool_enabled(turn_context)
&& !standalone_image_generation_available(turn_context, context.extension_tool_executors)
{
specs.push(create_image_generation_tool("png"));
}
specs
}
@@ -376,15 +369,6 @@ fn agent_jobs_worker_tools_enabled(turn_context: &TurnContext) -> bool {
)
}
fn image_generation_tool_enabled(turn_context: &TurnContext) -> bool {
image_generation_runtime_enabled(turn_context)
&& turn_context
.config
.features
.get()
.enabled(Feature::ImageGeneration)
}
fn image_generation_runtime_enabled(turn_context: &TurnContext) -> bool {
(turn_context
.provider
@@ -407,25 +391,11 @@ fn standalone_image_generation_model_visible(turn_context: &TurnContext) -> bool
return false;
}
if turn_context.model_info.use_responses_lite {
return true;
}
turn_context
.config
.features
.get()
.enabled(Feature::ImageGenExt)
}
fn standalone_image_generation_available(
turn_context: &TurnContext,
extension_tools: &[Arc<dyn ToolExecutor<ExtensionToolCall>>],
) -> bool {
standalone_image_generation_model_visible(turn_context)
&& extension_tools.iter().any(|executor| {
executor.tool_name() == ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME)
})
.enabled(Feature::ImageGeneration)
}
fn wait_agent_timeout_options(turn_context: &TurnContext) -> WaitAgentTimeoutOptions {
+64 -97
View File
@@ -1,5 +1,4 @@
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::sync::Arc;
use codex_features::Feature;
@@ -83,7 +82,6 @@ impl ToolPlanProbe {
)),
ToolSpec::Function(_)
| ToolSpec::ToolSearch { .. }
| ToolSpec::ImageGeneration { .. }
| ToolSpec::WebSearch { .. }
| ToolSpec::Freeform(_) => None,
})
@@ -275,40 +273,23 @@ fn use_bedrock_provider(turn: &mut TurnContext) {
turn.provider = create_model_provider(provider_info, turn.auth_manager.clone());
}
fn use_actor_authorized_provider(turn: &mut TurnContext) {
let mut provider_info = turn.config.model_provider.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(),
),
(
"ChatGPT-Account-ID".to_string(),
"test-account-id".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);
struct TestNamespaceExtensionTool {
namespace: &'static str,
tool_name: &'static str,
}
struct WebRunExtensionTool;
impl ToolExecutor<ExtensionToolCall> for WebRunExtensionTool {
impl ToolExecutor<ExtensionToolCall> for TestNamespaceExtensionTool {
fn tool_name(&self) -> ToolName {
ToolName::namespaced("web", "run")
ToolName::namespaced(self.namespace, self.tool_name)
}
fn spec(&self) -> ToolSpec {
ToolSpec::Namespace(codex_tools::ResponsesApiNamespace {
name: "web".to_string(),
description: "Test web namespace.".to_string(),
name: self.namespace.to_string(),
description: "Test namespace.".to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "run".to_string(),
description: "Test standalone web search tool.".to_string(),
name: self.tool_name.to_string(),
description: "Test namespace tool.".to_string(),
strict: false,
defer_loading: None,
parameters: codex_tools::JsonSchema::default(),
@@ -1497,7 +1478,7 @@ async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() {
"wait",
"request_user_input",
"agents",
// Hosted Responses tools.
// Hosted Responses tool.
"web_search",
]
);
@@ -1526,79 +1507,63 @@ async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() {
}
#[tokio::test]
async fn hosted_tools_follow_provider_auth_model_and_config_gates() {
let api_key_auth = probe(|turn| {
set_feature(turn, Feature::ImageGeneration, /*enabled*/ true);
turn.model_info.input_modalities = vec![InputModality::Image];
})
async fn hosted_web_search_and_standalone_image_generation_follow_runtime_gates() {
let image_generation_tool = Arc::new(TestNamespaceExtensionTool {
namespace: "image_gen",
tool_name: "imagegen",
});
let image_generation = probe_with(
|turn| {
use_chatgpt_auth(turn);
turn.model_info.input_modalities = vec![InputModality::Image];
},
ToolPlanInputs {
extension_tool_executors: vec![image_generation_tool.clone()],
..Default::default()
},
)
.await;
api_key_auth.assert_visible_lacks(&["image_generation"]);
image_generation.assert_visible_contains(&["image_gen"]);
let unrelated_chatgpt_auth = probe(|turn| {
use_chatgpt_auth(turn);
set_feature(turn, Feature::ImageGeneration, /*enabled*/ true);
let mut provider_info = turn.provider.info().clone();
provider_info.requires_openai_auth = false;
provider_info.http_headers = None;
update_config(turn, |config| {
config.model_provider = provider_info.clone();
});
turn.provider = create_model_provider(provider_info, turn.auth_manager.clone());
turn.model_info.input_modalities = vec![InputModality::Image];
})
let extension_disabled = probe_with(
|turn| {
use_chatgpt_auth(turn);
set_feature(turn, Feature::ImageGeneration, /*enabled*/ false);
turn.model_info.input_modalities = vec![InputModality::Image];
},
ToolPlanInputs {
extension_tool_executors: vec![image_generation_tool.clone()],
..Default::default()
},
)
.await;
unrelated_chatgpt_auth.assert_visible_lacks(&["image_generation"]);
extension_disabled.assert_visible_lacks(&["image_gen"]);
let actor_authorized_provider = probe(|turn| {
set_feature(turn, Feature::ImageGeneration, /*enabled*/ true);
use_actor_authorized_provider(turn);
turn.model_info.input_modalities = vec![InputModality::Image];
})
let text_only_model = probe_with(
|turn| {
use_chatgpt_auth(turn);
turn.model_info.input_modalities = vec![];
},
ToolPlanInputs {
extension_tool_executors: vec![image_generation_tool.clone()],
..Default::default()
},
)
.await;
actor_authorized_provider.assert_visible_contains(&["image_generation"]);
text_only_model.assert_visible_lacks(&["image_gen"]);
let feature_disabled = probe(|turn| {
set_feature(turn, Feature::ImageGeneration, /*enabled*/ false);
use_actor_authorized_provider(turn);
turn.model_info.input_modalities = vec![InputModality::Image];
})
let unsupported_provider = probe_with(
|turn| {
use_bedrock_provider(turn);
turn.model_info.input_modalities = vec![InputModality::Image];
},
ToolPlanInputs {
extension_tool_executors: vec![image_generation_tool],
..Default::default()
},
)
.await;
feature_disabled.assert_visible_lacks(&["image_generation"]);
let text_only_model = probe(|turn| {
set_feature(turn, Feature::ImageGeneration, /*enabled*/ true);
use_actor_authorized_provider(turn);
turn.model_info.input_modalities = vec![];
})
.await;
text_only_model.assert_visible_lacks(&["image_generation"]);
let unsupported_image_generation_provider = probe(|turn| {
set_feature(turn, Feature::ImageGeneration, /*enabled*/ true);
use_bedrock_provider(turn);
use_actor_authorized_provider(turn);
turn.model_info.input_modalities = vec![InputModality::Image];
})
.await;
unsupported_image_generation_provider.assert_visible_lacks(&["image_generation"]);
let image_generation = probe(|turn| {
use_chatgpt_auth(turn);
set_feature(turn, Feature::ImageGeneration, /*enabled*/ true);
turn.model_info.input_modalities = vec![InputModality::Image];
})
.await;
image_generation.assert_visible_contains(&["image_generation"]);
let extension_flag_without_imagegen_tool = probe(|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::Image];
})
.await;
extension_flag_without_imagegen_tool.assert_visible_contains(&["image_generation"]);
extension_flag_without_imagegen_tool.assert_visible_lacks(&["image_gen"]);
unsupported_provider.assert_visible_lacks(&["image_gen"]);
let live_web_search = probe(|turn| {
set_web_search_mode(turn, WebSearchMode::Live);
@@ -1635,7 +1600,6 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() {
MULTI_AGENT_V2_NAMESPACE,
// Hosted Responses tools.
"web_search",
"image_generation",
]
);
@@ -1652,7 +1616,10 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() {
set_web_search_mode(turn, WebSearchMode::Live);
},
ToolPlanInputs {
extension_tool_executors: vec![Arc::new(WebRunExtensionTool)],
extension_tool_executors: vec![Arc::new(TestNamespaceExtensionTool {
namespace: "web",
tool_name: "run",
})],
..Default::default()
},
)
+1 -2
View File
@@ -720,8 +720,7 @@ if (!tool) {
"exec".to_string(),
"wait".to_string(),
"request_user_input".to_string(),
"web_search".to_string(),
"image_generation".to_string()
"web_search".to_string()
]
);
@@ -75,8 +75,6 @@ async fn extension_tool_receives_turn_environment_sandbox() -> Result<()> {
})
.with_config(|config| {
assert!(config.web_search_mode.set(WebSearchMode::Live).is_ok());
assert!(config.features.enable(Feature::ImageGeneration).is_ok());
assert!(config.features.disable(Feature::ImageGenExt).is_ok());
});
let test = builder.build(&server).await?;
let denied_path = test.config.cwd.join("denied.png");
@@ -183,8 +181,6 @@ async fn extension_tool_uses_granted_turn_permissions_without_local_persistence(
.set_permission_profile(permission_profile_for_config)
.expect("set permission profile");
assert!(config.web_search_mode.set(WebSearchMode::Live).is_ok());
assert!(config.features.enable(Feature::ImageGeneration).is_ok());
assert!(config.features.disable(Feature::ImageGenExt).is_ok());
assert!(
config
.features
-177
View File
@@ -17,11 +17,9 @@ use codex_protocol::protocol::Op;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::PathBufExt;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_image_generation_call;
use core_test_support::responses::ev_message_item_added;
use core_test_support::responses::ev_output_text_delta;
use core_test_support::responses::ev_reasoning_item;
@@ -42,8 +40,6 @@ use core_test_support::test_codex::turn_permission_fields;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use pretty_assertions::assert_eq;
use std::path::Path;
use std::path::PathBuf;
fn disabled_plan_turn(
text: &str,
@@ -72,30 +68,6 @@ fn disabled_plan_turn(
})
}
fn image_generation_artifact_path(codex_home: &Path, session_id: &str, call_id: &str) -> PathBuf {
fn sanitize(value: &str) -> String {
let mut sanitized: String = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'_'
}
})
.collect();
if sanitized.is_empty() {
sanitized = "generated_image".to_string();
}
sanitized
}
codex_home
.join("generated_images")
.join(sanitize(session_id))
.join(format!("{}.png", sanitize(call_id)))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn user_message_item_is_emitted() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
@@ -418,155 +390,6 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn builtin_image_generation_call_persisted() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let TestCodex {
codex,
config,
session_configured,
..
} = test_codex().build(&server).await?;
let call_id = "ig_image_saved_to_temp_dir_default";
let expected_saved_path = image_generation_artifact_path(
config.codex_home.as_path(),
&session_configured.thread_id.to_string(),
call_id,
);
let _ = std::fs::remove_file(&expected_saved_path);
let first_response = sse(vec![
ev_response_created("resp-1"),
ev_image_generation_call(call_id, "generating", "A tiny blue square", "Zm9v"),
ev_completed("resp-1"),
]);
mount_sse_once(&server, first_response).await;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "generate a tiny blue square".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
.await?;
let started = wait_for_event_match(&codex, |ev| match ev {
EventMsg::ItemStarted(ItemStartedEvent {
item: TurnItem::ImageGeneration(item),
started_at_ms,
..
}) => Some((item.clone(), *started_at_ms)),
_ => None,
})
.await;
let begin = wait_for_event_match(&codex, |ev| match ev {
EventMsg::ImageGenerationBegin(event) => Some(event.clone()),
_ => None,
})
.await;
let completed = wait_for_event_match(&codex, |ev| match ev {
EventMsg::ItemCompleted(ItemCompletedEvent {
item: TurnItem::ImageGeneration(item),
completed_at_ms,
..
}) => Some((item.clone(), *completed_at_ms)),
_ => None,
})
.await;
let end = wait_for_event_match(&codex, |ev| match ev {
EventMsg::ImageGenerationEnd(event) => Some(event.clone()),
_ => None,
})
.await;
assert_eq!(begin.call_id, call_id);
assert_eq!(started.0.id, call_id);
assert!(started.1 > 0);
assert_eq!(completed.0.id, call_id);
assert!(completed.1 > 0);
assert_eq!(end.call_id, call_id);
assert_eq!(end.status, "generating");
assert_eq!(end.revised_prompt, Some("A tiny blue square".to_string()));
assert_eq!(end.result, "Zm9v");
assert_eq!(
end.saved_path.as_ref().map(AbsolutePathBuf::as_path),
Some(expected_saved_path.as_path())
);
assert_eq!(std::fs::read(&expected_saved_path)?, b"foo");
let _ = std::fs::remove_file(&expected_saved_path);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let TestCodex {
codex,
config,
session_configured,
..
} = test_codex().build(&server).await?;
let expected_saved_path = image_generation_artifact_path(
config.codex_home.as_path(),
&session_configured.thread_id.to_string(),
"ig_invalid",
);
let _ = std::fs::remove_file(&expected_saved_path);
let first_response = sse(vec![
ev_response_created("resp-1"),
ev_image_generation_call("ig_invalid", "completed", "broken payload", "_-8"),
ev_completed("resp-1"),
]);
mount_sse_once(&server, first_response).await;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "generate an image".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
.await?;
let begin = wait_for_event_match(&codex, |ev| match ev {
EventMsg::ImageGenerationBegin(event) => Some(event.clone()),
_ => None,
})
.await;
let end = wait_for_event_match(&codex, |ev| match ev {
EventMsg::ImageGenerationEnd(event) => Some(event.clone()),
_ => None,
})
.await;
assert_eq!(begin.call_id, "ig_invalid");
assert_eq!(end.call_id, "ig_invalid");
assert_eq!(end.status, "completed");
assert_eq!(end.revised_prompt, Some("broken payload".to_string()));
assert_eq!(end.result, "_-8");
assert_eq!(end.saved_path, None);
assert!(!expected_saved_path.exists());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
@@ -201,9 +201,8 @@ async fn remote_tool_mode_selector_overrides_feature_flags() -> Result<()> {
codex_code_mode::PUBLIC_TOOL_NAME.to_string(),
codex_code_mode::WAIT_TOOL_NAME.to_string(),
"request_user_input".to_string(),
// Hosted Responses tools.
// Hosted Responses tool.
"web_search".to_string(),
"image_generation".to_string(),
]
);
@@ -31,15 +31,12 @@ use core_test_support::responses::sse;
use core_test_support::responses::sse_completed;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_wine_exec;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::local_selections;
use core_test_support::test_codex::test_codex;
use core_test_support::test_codex::turn_permission_fields;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use std::path::Path;
use std::path::PathBuf;
use wiremock::MockServer;
fn read_only_user_turn(test: &TestCodex, items: Vec<UserInput>, model: String) -> Op {
@@ -68,30 +65,6 @@ fn read_only_user_turn(test: &TestCodex, items: Vec<UserInput>, model: String) -
}
}
fn image_generation_artifact_path(codex_home: &Path, session_id: &str, call_id: &str) -> PathBuf {
fn sanitize(value: &str) -> String {
let mut sanitized: String = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'_'
}
})
.collect();
if sanitized.is_empty() {
sanitized = "generated_image".to_string();
}
sanitized
}
codex_home
.join("generated_images")
.join(sanitize(session_id))
.join(format!("{}.png", sanitize(call_id)))
}
fn test_model_info(
slug: &str,
display_name: &str,
@@ -645,12 +618,6 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> {
config.model = Some(image_model_slug.to_string());
});
let test = builder.build(&server).await?;
let saved_path = image_generation_artifact_path(
test.codex_home_path(),
&test.session_configured.thread_id.to_string(),
"ig_123",
);
let _ = std::fs::remove_file(&saved_path);
let models_manager = test.thread_manager.get_models_manager();
let _ = models_manager
.list_models(
@@ -703,15 +670,6 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> {
Some("Zm9v"),
"expected the original generated image payload to be preserved"
);
assert!(
second_request
.message_input_texts("developer")
.iter()
.any(|text| text.contains("Generated images are saved to")),
"second request should include the saved-path note in model-visible history"
);
let _ = std::fs::remove_file(&saved_path);
Ok(())
}
@@ -762,12 +720,6 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima
config.model = Some(image_model_slug.to_string());
});
let test = builder.build(&server).await?;
let saved_path = image_generation_artifact_path(
test.codex_home_path(),
&test.session_configured.thread_id.to_string(),
"ig_123",
);
let _ = std::fs::remove_file(&saved_path);
let models_manager = test.thread_manager.get_models_manager();
let _ = models_manager
.list_models(
@@ -830,22 +782,11 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima
.all(|text| text != "image content omitted because you do not support image input"),
"second request should not inject the image-omitted placeholder text"
);
assert!(
second_request
.message_input_texts("developer")
.iter()
.any(|text| text.contains("Generated images are saved to")),
"second request should include the saved-path note in model-visible history"
);
let _ = std::fs::remove_file(&saved_path);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() -> Result<()> {
// TODO(anp): Remove after generated-image artifacts use target-native paths.
skip_if_wine_exec!(Ok(()), "uses host-native generated-image artifact paths");
skip_if_no_network!(Ok(()));
let server = MockServer::start().await;
@@ -883,12 +824,6 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history()
config.model = Some(image_model_slug.to_string());
});
let test = builder.build(&server).await?;
let saved_path = image_generation_artifact_path(
test.codex_home_path(),
&test.session_configured.thread_id.to_string(),
"ig_rollback",
);
let _ = std::fs::remove_file(&saved_path);
let models_manager = test.thread_manager.get_models_manager();
let _ = models_manager
.list_models(
@@ -940,21 +875,12 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history()
.any(|text| text == "generate a lobster"),
"rollback should remove the rolled-back image-generation user turn"
);
assert!(
!second_request
.message_input_texts("developer")
.iter()
.any(|text| text.contains("Generated images are saved to")),
"rollback should remove the generated-image save note with the rolled-back turn"
);
assert!(
second_request
.inputs_of_type("image_generation_call")
.is_empty(),
"rollback should remove the generated image call with the rolled-back turn"
);
let _ = std::fs::remove_file(&saved_path);
Ok(())
}
+4 -6
View File
@@ -43,8 +43,6 @@ fn configure_responses_tools(config: &mut Config) {
.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) {
@@ -382,7 +380,7 @@ async fn responses_lite_omits_hosted_tools_without_standalone_extensions() -> Re
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn non_lite_uses_hosted_tools_when_standalone_features_are_disabled() -> Result<()> {
async fn non_lite_uses_standalone_image_generation_by_default() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
@@ -404,18 +402,18 @@ async fn non_lite_uses_hosted_tools_when_standalone_features_are_disabled() -> R
.with_config(configure_responses_tools);
let test = builder.build(&server).await?;
test.submit_turn("Use hosted tools").await?;
test.submit_turn("Use image generation").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());
assert!(request.tool_by_name("image_gen", "imagegen").is_some());
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"));
assert!(!has_hosted_tool(tools, "image_generation"));
Ok(())
}
@@ -38,6 +38,7 @@ impl ImageGenerationExtensionConfig {
fn from_config(config: &Config, resolve_save_root: &SaveRootResolver) -> Self {
Self {
available: config.model_provider.is_openai()
|| config.model_provider.requires_openai_auth
|| config.model_provider.uses_openai_actor_authorization(),
provider: config.model_provider.clone(),
save_root: resolve_save_root(config),
+4
View File
@@ -29,6 +29,10 @@ const ALIASES: &[Alias] = &[
legacy_key: "web_search",
feature: Feature::WebSearchRequest,
},
Alias {
legacy_key: "imagegenext",
feature: Feature::ImageGeneration,
},
Alias {
legacy_key: "collab",
feature: Feature::Collab,
+5 -9
View File
@@ -196,10 +196,8 @@ pub enum Feature {
PluginSharing,
/// Removed compatibility flag retained as a no-op.
ExternalMigration,
/// Allow the model to invoke the built-in image generation tool.
/// Enable extension-backed image generation.
ImageGeneration,
/// Replace hosted image generation with the standalone image-generation extension.
ImageGenExt,
/// Removed compatibility flag for always-on centralized image preparation.
ResizeAllImages,
/// Generate Responses API item IDs for client-created history items.
@@ -497,6 +495,10 @@ impl Features {
}
_ => {}
}
if k == "imagegenext" && m.contains_key(Feature::ImageGeneration.key()) {
self.record_legacy_usage(k, Feature::ImageGeneration);
continue;
}
match feature_for_key(k) {
Some(feat) => {
if matches!(feat, Feature::TuiAppServer) {
@@ -1168,12 +1170,6 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::ImageGenExt,
key: "imagegenext",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::ResizeAllImages,
key: "resize_all_images",
+33 -5
View File
@@ -263,16 +263,44 @@ fn use_linux_sandbox_bwrap_is_a_removed_feature_key() {
}
#[test]
fn image_generation_is_stable_and_enabled_by_default() {
fn image_generation_is_stable_and_extension_alias_is_supported() {
assert_eq!(Feature::ImageGeneration.stage(), Stage::Stable);
assert_eq!(Feature::ImageGeneration.default_enabled(), true);
assert_eq!(
feature_for_key("image_generation"),
Some(Feature::ImageGeneration)
);
assert_eq!(
feature_for_key("imagegenext"),
Some(Feature::ImageGeneration)
);
}
#[test]
fn image_generation_extension_is_under_development_and_disabled_by_default() {
assert_eq!(Feature::ImageGenExt.stage(), Stage::UnderDevelopment);
assert_eq!(Feature::ImageGenExt.default_enabled(), false);
assert_eq!(feature_for_key("imagegenext"), Some(Feature::ImageGenExt));
fn image_generation_toggle_controls_extension_backed_generation() {
let mut entries = BTreeMap::new();
entries.insert("image_generation".to_string(), false);
let mut features = Features::with_defaults();
features.apply_map(&entries);
assert!(!features.enabled(Feature::ImageGeneration));
entries.insert("image_generation".to_string(), true);
features.disable(Feature::ImageGeneration);
features.apply_map(&entries);
assert!(features.enabled(Feature::ImageGeneration));
}
#[test]
fn canonical_image_generation_toggle_wins_over_extension_alias() {
for (canonical, alias) in [(false, true), (true, false)] {
let entries = BTreeMap::from([
("image_generation".to_string(), canonical),
("imagegenext".to_string(), alias),
]);
let mut features = Features::with_defaults();
features.apply_map(&entries);
assert_eq!(features.enabled(Feature::ImageGeneration), canonical);
}
}
#[test]
+1
View File
@@ -22,6 +22,7 @@ codex-arg0 = { workspace = true }
codex-config = { workspace = true }
codex-core = { workspace = true }
codex-home = { workspace = true }
codex-image-generation-extension = { workspace = true }
codex-exec-server = { workspace = true }
codex-extension-api = { workspace = true }
codex-login = { workspace = true }
+8 -2
View File
@@ -6,7 +6,7 @@ use codex_core::StateDbHandle;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_exec_server::EnvironmentManager;
use codex_extension_api::empty_extension_registry;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_home::CodexHomeUserInstructionsProvider;
use codex_login::AuthManager;
use codex_login::default_client::USER_AGENT_SUFFIX;
@@ -66,12 +66,18 @@ impl MessageProcessor {
let user_instructions_provider = Arc::new(CodexHomeUserInstructionsProvider::new(
config.codex_home.clone(),
));
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
codex_image_generation_extension::install(
&mut extensions,
auth_manager.clone(),
|config: &Config| Some(config.codex_home.clone()),
);
let thread_manager = Arc::new(ThreadManager::new(
config.as_ref(),
auth_manager,
SessionSource::Mcp,
environment_manager,
empty_extension_registry(),
Arc::new(extensions.build()),
user_instructions_provider,
/*analytics_events_client*/ None,
codex_core::thread_store_from_config(config.as_ref(), state_db.clone()),
+14 -1
View File
@@ -264,7 +264,7 @@ fn dispatched_tool_kind(tool_name: &str, _payload: &ToolDispatchPayload) -> Tool
"write_stdin" => ToolCallKind::WriteStdin,
"apply_patch" => ToolCallKind::ApplyPatch,
"web_search" | "web_search_preview" => ToolCallKind::Web,
"image_generation" | "image_query" => ToolCallKind::ImageGeneration,
"image_generation" | "image_query" | "imagegen" => ToolCallKind::ImageGeneration,
"spawn_agent" => ToolCallKind::SpawnAgent,
"send_message" => ToolCallKind::SendMessage,
"followup_task" | "assign_task" => ToolCallKind::AssignAgentTask,
@@ -438,6 +438,19 @@ mod tests {
);
}
#[test]
fn classifies_imagegen_as_image_generation() {
assert_eq!(
dispatched_tool_kind(
"imagegen",
&ToolDispatchPayload::Function {
arguments: String::new(),
},
),
ToolCallKind::ImageGeneration
);
}
fn invocation(
tool_name: &str,
tool_namespace: Option<String>,
+7 -2
View File
@@ -24,6 +24,7 @@ use codex_core_api::Constrained;
use codex_core_api::EnvironmentManager;
use codex_core_api::EventMsg;
use codex_core_api::ExecServerRuntimePaths;
use codex_core_api::ExtensionRegistryBuilder;
use codex_core_api::Features;
use codex_core_api::GhostSnapshotConfig;
use codex_core_api::History;
@@ -55,9 +56,9 @@ use codex_core_api::UserInput;
use codex_core_api::WebSearchMode;
use codex_core_api::arg0_dispatch_or_else;
use codex_core_api::built_in_model_providers;
use codex_core_api::empty_extension_registry;
use codex_core_api::find_codex_home;
use codex_core_api::init_state_db;
use codex_core_api::install_image_generation_extension;
use codex_core_api::item_event_to_server_notification;
use codex_core_api::local_agent_graph_store_from_state_db;
use codex_core_api::resolve_installation_id;
@@ -125,12 +126,16 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
let user_instructions_provider = Arc::new(CodexHomeUserInstructionsProvider::new(
config.codex_home.clone(),
));
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
install_image_generation_extension(&mut extensions, auth_manager.clone(), |config: &Config| {
Some(config.codex_home.clone())
});
let thread_manager = ThreadManager::new(
&config,
auth_manager,
SessionSource::Exec,
environment_manager,
empty_extension_registry(),
Arc::new(extensions.build()),
user_instructions_provider,
/*analytics_events_client*/ None,
Arc::clone(&thread_store),
+1 -3
View File
@@ -148,9 +148,7 @@ fn code_mode_tool_definitions_for_spec(spec: &ToolSpec) -> Vec<CodeModeToolDefin
}
})
.collect(),
ToolSpec::ImageGeneration { .. }
| ToolSpec::ToolSearch { .. }
| ToolSpec::WebSearch { .. } => Vec::new(),
ToolSpec::ToolSearch { .. } | ToolSpec::WebSearch { .. } => Vec::new(),
}
}
+3 -7
View File
@@ -49,10 +49,9 @@ impl ToolSearchInfo {
}
LoadableToolSpec::Namespace(namespace)
}
ToolSpec::ToolSearch { .. }
| ToolSpec::ImageGeneration { .. }
| ToolSpec::WebSearch { .. }
| ToolSpec::Freeform(_) => return None,
ToolSpec::ToolSearch { .. } | ToolSpec::WebSearch { .. } | ToolSpec::Freeform(_) => {
return None;
}
};
Some(Self {
@@ -81,9 +80,6 @@ fn default_tool_search_text(spec: &ToolSpec) -> String {
ToolSpec::ToolSearch { description, .. } => {
push_search_part(&mut parts, description.clone());
}
ToolSpec::ImageGeneration { .. } => {
push_search_part(&mut parts, "image generation".to_string());
}
ToolSpec::WebSearch { .. } => {
push_search_part(&mut parts, "web search".to_string());
}
-3
View File
@@ -25,8 +25,6 @@ pub enum ToolSpec {
description: String,
parameters: JsonSchema,
},
#[serde(rename = "image_generation")]
ImageGeneration { output_format: String },
// TODO: Understand why we get an error on web_search although the API docs
// say it's supported.
// https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C
@@ -58,7 +56,6 @@ impl ToolSpec {
ToolSpec::Function(tool) => tool.name.as_str(),
ToolSpec::Namespace(namespace) => namespace.name.as_str(),
ToolSpec::ToolSearch { .. } => "tool_search",
ToolSpec::ImageGeneration { .. } => "image_generation",
ToolSpec::WebSearch { .. } => "web_search",
ToolSpec::Freeform(tool) => tool.name.as_str(),
}
-7
View File
@@ -57,13 +57,6 @@ fn tool_spec_name_covers_all_variants() {
.name(),
"tool_search"
);
assert_eq!(
ToolSpec::ImageGeneration {
output_format: "png".to_string(),
}
.name(),
"image_generation"
);
assert_eq!(
ToolSpec::WebSearch {
external_web_access: Some(true),