diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b80e8bc9d0c0..31642835b255 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2755,6 +2755,7 @@ dependencies = [ "codex-extension-api", "codex-features", "codex-home", + "codex-image-generation-extension", "codex-login", "codex-model-provider-info", "codex-models-manager", @@ -3437,6 +3438,7 @@ dependencies = [ "codex-exec-server", "codex-extension-api", "codex-home", + "codex-image-generation-extension", "codex-login", "codex-protocol", "codex-shell-command", diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index 0c3799ca11b8..df6d390d0162 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -588,7 +588,6 @@ model_provider = "openai-custom" chatgpt_base_url = "{server_uri}" [features] -imagegenext = true {code_mode_only} [model_providers.openai-custom] diff --git a/codex-rs/core-api/Cargo.toml b/codex-rs/core-api/Cargo.toml index f4c89e5640cc..2a5913d1a8cd 100644 --- a/codex-rs/core-api/Cargo.toml +++ b/codex-rs/core-api/Cargo.toml @@ -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 } diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index a5257ae44fed..df15483dccb6 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -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; diff --git a/codex-rs/core/src/context/image_generation_instructions.rs b/codex-rs/core/src/context/image_generation_instructions.rs index 6986a7c64f25..52ca59353c1c 100644 --- a/codex-rs/core/src/context/image_generation_instructions.rs +++ b/codex-rs/core/src/context/image_generation_instructions.rs @@ -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) - } -} diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 20b35e7af3e0..56c463714137 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -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; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index ac8f9d049e22..ebd2cbde675c 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8512,49 +8512,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; @@ -8762,102 +8719,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(), - "", - ); - 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; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index e231f6af8671..56fa57163b95 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -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, @@ -2141,7 +2140,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, diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index b07c180b1656..d2b87a9330d8 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -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 { - 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 { - 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, ""); - 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 { 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, } } diff --git a/codex-rs/core/src/stream_events_utils_tests.rs b/codex-rs/core/src/stream_events_utils_tests.rs index b3312ad49d28..80966b11fbea 100644 --- a/codex-rs/core/src/stream_events_utils_tests.rs +++ b/codex-rs/core/src/stream_events_utils_tests.rs @@ -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\nMEMORY.md:1-2|note=[x]\n\n\n019cc2ea-1dff-7902-8d40-c8f6e5d83cc4\n 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,", - ) - .await - .expect_err("non-base64 data url should error"); - assert!(matches!(err, CodexErr::InvalidRequest(_))); -} diff --git a/codex-rs/core/src/tools/hosted_spec.rs b/codex-rs/core/src/tools/hosted_spec.rs index 83996afccaf1..af7cdf801364 100644 --- a/codex-rs/core/src/tools/hosted_spec.rs +++ b/codex-rs/core/src/tools/hosted_spec.rs @@ -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 { let (external_web_access, indexed_web_access) = match options.web_search_mode { Some(WebSearchMode::Cached) => (false, None), diff --git a/codex-rs/core/src/tools/hosted_spec_tests.rs b/codex-rs/core/src/tools/hosted_spec_tests.rs index cec4b86871ad..05fc159abc5a 100644 --- a/codex-rs/core/src/tools/hosted_spec_tests.rs +++ b/codex-rs/core/src/tools/hosted_spec_tests.rs @@ -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!( diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 62391395f826..0f2532da1d74 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -473,7 +473,6 @@ fn namespace_function_names(specs: &[ToolSpec], namespace_name: &str) -> Vec None, }) diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 8ca5eec898ff..9d9b7629a18a 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -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 { }) { 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>], -) -> 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 { diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index d19744be4606..7fd4c69cd5f5 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -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 for WebRunExtensionTool { +impl ToolExecutor 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]; - }) - .await; - api_key_auth.assert_visible_lacks(&["image_generation"]); - - 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]; - }) - .await; - unrelated_chatgpt_auth.assert_visible_lacks(&["image_generation"]); - - 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]; - }) - .await; - actor_authorized_provider.assert_visible_contains(&["image_generation"]); - - 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]; - }) - .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![]; - }) +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; - text_only_model.assert_visible_lacks(&["image_generation"]); + image_generation.assert_visible_contains(&["image_gen"]); - 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]; - }) + 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; - unsupported_image_generation_provider.assert_visible_lacks(&["image_generation"]); + extension_disabled.assert_visible_lacks(&["image_gen"]); - let image_generation = probe(|turn| { - use_chatgpt_auth(turn); - set_feature(turn, Feature::ImageGeneration, /*enabled*/ true); - 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; - image_generation.assert_visible_contains(&["image_generation"]); + text_only_model.assert_visible_lacks(&["image_gen"]); - 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]; - }) + 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; - 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() }, ) diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 9293b506efd4..deb230bb05ac 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -706,8 +706,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() ] ); diff --git a/codex-rs/core/tests/suite/extension_sandbox.rs b/codex-rs/core/tests/suite/extension_sandbox.rs index 6d998a4693aa..c8bef5de7cdd 100644 --- a/codex-rs/core/tests/suite/extension_sandbox.rs +++ b/codex-rs/core/tests/suite/extension_sandbox.rs @@ -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 diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 196277394a50..608019b6f9d3 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -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(())); @@ -348,155 +320,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(())); diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index 0c6a87605de5..d186eec1d1cd 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -198,9 +198,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(), ] ); diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 9ef3675b3ed2..a79db095da63 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -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, model: String) -> Op { @@ -68,30 +65,6 @@ fn read_only_user_turn(test: &TestCodex, items: Vec, 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, @@ -644,12 +617,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(RefreshStrategy::OnlineIfUncached) @@ -699,15 +666,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(()) } @@ -758,12 +716,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(RefreshStrategy::OnlineIfUncached) @@ -823,22 +775,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; @@ -876,12 +817,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(RefreshStrategy::OnlineIfUncached) @@ -930,21 +865,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(()) } diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index 5f623bd4c605..83cc6208be73 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -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(()) } diff --git a/codex-rs/ext/image-generation/src/extension.rs b/codex-rs/ext/image-generation/src/extension.rs index 1e3dc3e127b5..18abc2528900 100644 --- a/codex-rs/ext/image-generation/src/extension.rs +++ b/codex-rs/ext/image-generation/src/extension.rs @@ -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), diff --git a/codex-rs/features/src/legacy.rs b/codex-rs/features/src/legacy.rs index 1a8b3d24dc18..3e6df58ed185 100644 --- a/codex-rs/features/src/legacy.rs +++ b/codex-rs/features/src/legacy.rs @@ -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, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 166dc912fdaa..4a478aa6ef48 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -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", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 7291c4e1b1af..d5c2951b328d 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -253,16 +253,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] diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 9d568722e8a7..40cd510a6a11 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -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 } diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index d211ff0a32fc..98bb84b899f3 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -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::::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()), diff --git a/codex-rs/rollout-trace/src/tool_dispatch.rs b/codex-rs/rollout-trace/src/tool_dispatch.rs index 36c6651b107b..94dcb3750723 100644 --- a/codex-rs/rollout-trace/src/tool_dispatch.rs +++ b/codex-rs/rollout-trace/src/tool_dispatch.rs @@ -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, diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 4237a33fefe8..c35daaa497fa 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -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::::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), diff --git a/codex-rs/tools/src/code_mode.rs b/codex-rs/tools/src/code_mode.rs index ae1848948fd1..f9a88f83d090 100644 --- a/codex-rs/tools/src/code_mode.rs +++ b/codex-rs/tools/src/code_mode.rs @@ -148,9 +148,7 @@ fn code_mode_tool_definitions_for_spec(spec: &ToolSpec) -> Vec Vec::new(), + ToolSpec::ToolSearch { .. } | ToolSpec::WebSearch { .. } => Vec::new(), } } diff --git a/codex-rs/tools/src/tool_search.rs b/codex-rs/tools/src/tool_search.rs index 9a1c8852dd80..2bde910199ea 100644 --- a/codex-rs/tools/src/tool_search.rs +++ b/codex-rs/tools/src/tool_search.rs @@ -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()); } diff --git a/codex-rs/tools/src/tool_spec.rs b/codex-rs/tools/src/tool_spec.rs index 48ccd476b6f2..8d2ac1c73fff 100644 --- a/codex-rs/tools/src/tool_spec.rs +++ b/codex-rs/tools/src/tool_spec.rs @@ -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(), } diff --git a/codex-rs/tools/src/tool_spec_tests.rs b/codex-rs/tools/src/tool_spec_tests.rs index 406f92c52e8d..cfb069a87f2b 100644 --- a/codex-rs/tools/src/tool_spec_tests.rs +++ b/codex-rs/tools/src/tool_spec_tests.rs @@ -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),