Add optional animated GIF output to image generation (#59073)

## What changed

- Gate GIF output behind `features.image_generation_animation`, disabled by default.
- Accept `gif` options for generation and editing, with optional `frame_count` and `frame_duration_ms` controls. Use `gif: {}` for API defaults.
- Preserve animated output with `.gif` filenames and `image/gif` data URLs while providing a still preview to the model.
- Add guidance for simple animations and spritesheet-based GIF edits.

## Testing

Add integration coverage for GIF generation and editing, explicit frame controls and API defaults, saved animation bytes, and still-preview replay across follow-up turns.

GitOrigin-RevId: 08afa0316ae3675921652157836191a649f563cf
This commit is contained in:
Gan Tu
2026-11-03 06:24:24 +00:00
committed by copyberry
parent 29eed28082
commit f9e7b19df8
14 changed files with 548 additions and 43 deletions
@@ -7,6 +7,7 @@ use app_test_support::ChatGptAuthFixture;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::write_chatgpt_auth;
use base64::Engine;
use codex_app_server_protocol::GeneratedImageRef;
use codex_app_server_protocol::ImageGenerationFailure;
use codex_app_server_protocol::ImageGenerationInputImage;
@@ -46,6 +47,8 @@ use super::analytics::mount_analytics_capture;
use super::analytics::wait_for_analytics_event;
const RESULT: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
// Two one-pixel frames, red then blue, with a 200 ms frame duration.
const ANIMATED_RESULT: &str = "R0lGODlhAQABAIEAAP8AAAAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQAFAAAACwAAAAAAQABAAAIBAABBAQAIfkEARQAAQAsAAAAAAEAAQCBAAD/AAAAAAAAAAAACAQAAQQEADs=";
const TINY_PNG_BYTES: &[u8] = &[
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0,
0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 207, 192, 240, 31, 0,
@@ -66,15 +69,22 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
#[cfg(not(any(target_os = "macos", windows)))]
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
#[test_case(Some(true), "transparent"; "transparent")]
#[test_case(Some(false), "opaque"; "opaque")]
#[test_case(None, "opaque"; "omitted_defaults_to_opaque")]
#[test_case(Some(true), "transparent", false; "transparent")]
#[test_case(Some(false), "opaque", false; "opaque")]
#[test_case(None, "opaque", false; "omitted_defaults_to_opaque")]
#[test_case(None, "opaque", true; "gif")]
#[tokio::test]
async fn standalone_image_generation_returns_saved_path_hint_to_model(
requested_transparent_background: Option<bool>,
expected_background: &str,
gif_enabled: bool,
) -> Result<()> {
let call_id = "image-run-1";
let (result, extension) = if gif_enabled {
(ANIMATED_RESULT, "gif")
} else {
(RESULT, "png")
};
let server = responses::start_mock_server().await;
Mock::given(method("POST"))
.and(path("/api/codex/images/generations"))
@@ -85,7 +95,7 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model(
"created": 1,
"background": "opaque",
"data": [
{"b64_json": RESULT, "generation_id": "gen-image-123"},
{"b64_json": result, "generation_id": "gen-image-123"},
{"b64_json": "ignored", "generation_id": "gen-other"},
],
})),
@@ -93,7 +103,11 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model(
.expect(1)
.mount(&server)
.await;
let arguments = imagegen_arguments("paint a blue whale", requested_transparent_background);
let mut arguments = imagegen_arguments("paint a blue whale", requested_transparent_background);
if gif_enabled {
arguments["gif"] = json!({"frame_count": 2, "frame_duration_ms": 200});
}
let response_mock = responses::mount_sse_sequence(
&server,
@@ -125,19 +139,23 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model(
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let mut config = std::collections::HashMap::from([(
"responses_api_metadata".to_string(),
json!({
"display_name": "Image test 🚀",
}),
)]);
if gif_enabled {
config.insert(
"features.image_generation_animation".to_string(),
json!(true),
);
}
let turn_id = start_turn(
&mut mcp,
ThreadStartParams {
service_name: Some("chatgpt_cca".to_string()),
config: Some(
[(
"responses_api_metadata".to_string(),
json!({
"display_name": "Image test 🚀",
}),
)]
.into(),
),
config: Some(config),
..Default::default()
},
TurnStartParams {
@@ -180,11 +198,18 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model(
assert_eq!(
image,
GeneratedImageRef::Inline {
result: RESULT.to_string(),
result: result.to_string(),
}
);
assert_eq!(transparent_background, Some(false));
assert_eq!(std::fs::read(&saved_path)?, TINY_PNG_BYTES);
assert_eq!(
std::fs::read(&saved_path)?,
base64::engine::general_purpose::STANDARD.decode(result)?
);
assert_eq!(
saved_path.extension().and_then(|ext| ext.to_str()),
Some(extension)
);
let image_request = server
.received_requests()
@@ -193,15 +218,21 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model(
.into_iter()
.find(|request| request.url.path() == "/api/codex/images/generations")
.context("image generation request should be sent")?;
let mut expected_request = json!({
"prompt": "paint a blue whale",
"background": expected_background,
"model": "gpt-image-2",
"quality": "auto",
"size": "auto",
});
if gif_enabled {
expected_request["output_format"] = json!("gif");
expected_request["frame_count"] = json!(2);
expected_request["frame_duration_ms"] = json!(200);
}
assert_eq!(
image_request.body_json::<serde_json::Value>()?,
json!({
"prompt": "paint a blue whale",
"background": expected_background,
"model": "gpt-image-2",
"quality": "auto",
"size": "auto",
})
expected_request
);
assert_eq!(
image_request
@@ -226,14 +257,16 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model(
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
let output = requests[1].function_call_output(call_id);
assert_eq!(
output["output"][0],
json!({
"type": "input_image",
"image_url": format!("data:image/png;base64,{RESULT}"),
"detail": "high",
})
);
if !gif_enabled {
assert_eq!(
output["output"][0],
json!({
"type": "input_image",
"image_url": format!("data:image/png;base64,{RESULT}"),
"detail": "high",
})
);
}
let output_hint = output["output"][1]["text"]
.as_str()
.context("image output should include model-visible path hint")?;
@@ -1073,6 +1106,176 @@ generatedImage(result);
Ok(())
}
#[cfg(not(windows))]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn gif_edit_from_code_mode_preserves_animation_and_api_defaults() -> Result<()> {
let call_id = "code-mode-gif-edit";
let server = responses::start_mock_server().await;
Mock::given(method("POST"))
.and(path("/api/codex/images/edits"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"created": 1,
"background": "opaque",
"data": [{"b64_json": ANIMATED_RESULT}],
})))
.expect(1)
.mount(&server)
.await;
let response_mock = responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_custom_tool_call(
call_id,
"exec",
r#"
const result = await tools.image_gen__imagegen({
prompt: "animate the attached image",
gif: {},
num_last_images_to_include: 1,
});
text(result.image_url);
generatedImage(result);
"#,
),
responses::ev_completed("resp-1"),
]),
responses::sse(vec![
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-2"),
]),
],
)
.await;
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
&server.uri(),
ImagegenTestMode::CodeModeOnly,
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
start_turn(
&mut mcp,
ThreadStartParams {
config: Some(
[(
"features.image_generation_animation".to_string(),
json!(true),
)]
.into(),
),
..Default::default()
},
TurnStartParams {
input: vec![
V2UserInput::Text {
text: "Animate the attached image".to_string(),
text_elements: Vec::new(),
},
V2UserInput::Image {
image: V2ImageReference::Inline {
url: TINY_PNG_DATA_URL.to_string(),
},
detail: None,
},
],
..Default::default()
},
)
.await?;
let completed = wait_for_image_generation_completed(&mut mcp).await?;
mcp.read_stream_until_notification_message("turn/completed")
.await?;
let ThreadItemPayload::ImageGeneration(image) = completed.item.payload else {
panic!("expected a completed image generation item");
};
let saved_path = image.saved_path.clone().context("GIF should be saved")?;
assert_eq!(
image,
ImageGenerationItem {
id: image.id.clone(),
status: "completed".to_string(),
revised_prompt: Some("animate the attached image".to_string()),
image: GeneratedImageRef::Inline {
result: ANIMATED_RESULT.to_string(),
},
transparent_background: Some(false),
failure: None,
saved_path: Some(saved_path.clone()),
input_images: Vec::new(),
imagegen_request_id: None,
generation_id: None,
}
);
assert_eq!(
saved_path.extension().and_then(|ext| ext.to_str()),
Some("gif")
);
assert_eq!(
std::fs::read(&saved_path)?,
base64::engine::general_purpose::STANDARD.decode(ANIMATED_RESULT)?
);
let image_request = server
.received_requests()
.await
.context("failed to fetch received requests")?
.into_iter()
.find(|request| request.url.path() == "/api/codex/images/edits")
.context("image edit request should be sent")?;
// Empty GIF options leave frame count and duration to the Images API defaults.
assert_eq!(
image_request.body_json::<serde_json::Value>()?,
json!({
"prompt": "animate the attached image",
"images": [{"image_url": TINY_PNG_DATA_URL}],
"output_format": "gif",
"background": "opaque",
"model": "gpt-image-2",
"quality": "auto",
"size": "auto",
})
);
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
assert!(requests[0].body_contains_text("gif?:"));
let output = requests[1].custom_tool_call_output(call_id);
assert_eq!(
output["output"][1],
json!({
"type": "input_text",
"text": format!("data:image/gif;base64,{ANIMATED_RESULT}"),
})
);
// generatedImage() retains the animation for the user and gives the model a still preview.
assert_eq!(output["output"][2]["type"], "input_image");
assert!(
output["output"][2]["image_url"]
.as_str()
.is_some_and(|url| url.starts_with("data:image/png;base64,"))
);
assert!(
output["output"][3]["text"]
.as_str()
.is_some_and(|hint| hint.contains(&saved_path.display().to_string()))
);
assert_eq!(output["output"].as_array().map(Vec::len), Some(4));
Ok(())
}
fn imagegen_arguments(prompt: &str, transparent_background: Option<bool>) -> serde_json::Value {
let mut arguments = json!({"prompt": prompt});
if let Some(transparent_background) = transparent_background {
@@ -262,6 +262,7 @@ mod tests {
let (response, imagegen_request_id) = client
.generate(
&ImageGenerationRequest {
output: None,
prompt: "a red fox in a field".to_string(),
background: Some(ImageBackground::Opaque),
model: "gpt-image-1.5".to_string(),
@@ -311,6 +312,7 @@ mod tests {
let (response, _) = client
.generate(
&ImageGenerationRequest {
output: None,
prompt: "test".to_string(),
background: None,
model: "gpt-image-2".to_string(),
@@ -341,6 +343,7 @@ mod tests {
let (response, imagegen_request_id) = client
.edit(
&ImageEditRequest {
output: None,
images: vec![
ImageReference::Inline {
image_url: "data:image/png;base64,Zm9v".to_string(),
@@ -389,6 +392,7 @@ mod tests {
let error = client
.generate(
&ImageGenerationRequest {
output: None,
prompt: "a red fox in a field".to_string(),
background: None,
model: "gpt-image-1.5".to_string(),
+25
View File
@@ -1,10 +1,13 @@
use codex_protocol::models::ImageReference;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ImageGenerationRequest {
pub prompt: String,
#[serde(flatten)]
pub output: Option<ImageOutput>,
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<ImageBackground>,
pub model: String,
@@ -20,6 +23,8 @@ pub struct ImageGenerationRequest {
pub struct ImageEditRequest {
pub images: Vec<ImageReference>,
pub prompt: String,
#[serde(flatten)]
pub output: Option<ImageOutput>,
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<ImageBackground>,
pub model: String,
@@ -31,6 +36,26 @@ pub struct ImageEditRequest {
pub size: Option<String>,
}
/// Optional GIF controls accepted by the Images generation and edit endpoints.
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)]
pub struct GifOptions {
/// Number of frames (2–16). Defaults to 9.
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 2, max = 16))]
pub frame_count: Option<u32>,
/// Duration of each frame in milliseconds (20–2000). Defaults to 200.
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 20, max = 2000))]
pub frame_duration_ms: Option<u32>,
}
/// The Images API expects the format and its controls as sibling fields.
#[derive(Debug, Clone, Copy, Serialize, PartialEq)]
#[serde(tag = "output_format", rename_all = "lowercase")]
pub enum ImageOutput {
Gif(GifOptions),
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ImageBackground {
+2
View File
@@ -87,10 +87,12 @@ pub use crate::files::HostedFileUploadContext;
pub use crate::files::OPENAI_FILE_UPLOAD_LIMIT_BYTES;
pub use crate::files::upload_openai_file;
pub use crate::guardian_assessment::GuardianAssessment;
pub use crate::images::GifOptions;
pub use crate::images::ImageBackground;
pub use crate::images::ImageData;
pub use crate::images::ImageEditRequest;
pub use crate::images::ImageGenerationRequest;
pub use crate::images::ImageOutput;
pub use crate::images::ImageQuality;
pub use crate::images::ImageResponse;
pub use crate::provider::Provider;
+6
View File
@@ -1113,6 +1113,9 @@
"image_generation": {
"type": "boolean"
},
"image_generation_animation": {
"type": "boolean"
},
"image_resize_notice": {
"type": "boolean"
},
@@ -7873,6 +7876,9 @@
"image_generation": {
"type": "boolean"
},
"image_generation_animation": {
"type": "boolean"
},
"image_resize_notice": {
"type": "boolean"
},
+110
View File
@@ -40,6 +40,7 @@ use codex_extension_api::ExtensionRegistry;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_features::Feature;
use codex_history::RolloutItem;
use codex_image_generation_extension::install as install_image_generation_extension;
use codex_login::CodexAuth;
use codex_models_manager::bundled_models_response;
use codex_protocol::capabilities::CapabilityRootLocation;
@@ -55,6 +56,7 @@ use codex_protocol::models::ImageDetail;
use codex_protocol::models::ImageReference;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::CodeModeToolMessages;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ToolMessage;
use codex_protocol::openai_models::ToolMode;
@@ -382,6 +384,114 @@ fn configure_catalog_plugin_scenario(config: &mut Config) {
.with_cloud_config_binding(stack.cloud_config_binding().cloned());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn gif_generation_preserves_animation_and_replays_a_still_preview() -> Result<()> {
skip_if_no_network!(Ok(()));
// Two one-pixel frames, red then blue, with a 200 ms frame duration.
let gif = "R0lGODlhAQABAIEAAP8AAAAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQAFAAAACwAAAAAAQABAAAIBAABBAQAIfkEARQAAQAsAAAAAAEAAQCBAAD/AAAAAAAAAAAACAQAAQQEADs=";
let server = start_mock_server().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/v1/images/generations"))
.and(wiremock::matchers::body_json(json!({
"prompt": "A pixel alternating between red and blue.",
"background": "opaque",
"model": "gpt-image-2",
"quality": "auto",
"size": "auto",
"output_format": "gif",
"frame_count": 2,
"frame_duration_ms": 200,
})))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
"created": 1,
"data": [{"b64_json": gif}],
})))
.expect(1)
.mount(&server)
.await;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
install_image_generation_extension(
&mut extensions,
codex_core::test_support::auth_manager_from_auth(auth.clone()),
|_config| None,
);
let image_store = Arc::new(RecordingFileAttachmentStore::default());
let test = test_codex()
.with_auth(auth)
.with_extensions(Arc::new(extensions.build()))
.with_image_store(image_store.clone())
.with_config(|config| {
configure_scenario_catalog(config);
config
.features
.enable(Feature::ImageGenerationAnimation)
.unwrap();
})
.with_model_info_override("gpt-5.4", |model| {
model.use_responses_lite = true;
model.input_modalities = vec![InputModality::Text, InputModality::Image];
})
.build_with_auto_env(&server)
.await?;
let mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_function_call_with_namespace(
"gif-generation",
"image_gen",
"imagegen",
&json!({
"prompt": "A pixel alternating between red and blue.",
"gif": {"frame_count": 2, "frame_duration_ms": 200},
})
.to_string(),
),
ev_completed("generated"),
]),
sse(vec![
ev_assistant_message("preview", "Here is the two-frame GIF."),
ev_completed("previewed"),
]),
sse(vec![
ev_assistant_message("timing", "Each frame lasts 200 ms."),
ev_completed("answered"),
]),
],
)
.await;
test.submit_text_turn("Generate a two-frame GIF of a pixel alternating red and blue.")
.await?;
test.submit_text_turn("How long does each frame last?")
.await?;
let uploads = image_store.uploads.lock().expect("uploads lock");
let [animation, preview] = uploads.as_slice() else {
panic!("expected an animated artifact and a separate model preview");
};
assert_eq!(animation.data, BASE64_STANDARD.decode(gif)?);
assert_eq!(image::guess_format(&preview.data)?, image::ImageFormat::Png);
assert_eq!(
image::load_from_memory(&preview.data)?
.to_rgba8()
.get_pixel(0, 0)
.0,
[255, 0, 0, 255]
);
let requests = mock.requests();
assert_eq!(requests.len(), 3);
insta::assert_snapshot!(context_snapshot::format_request_history_snapshot(
"GIF generation exposes animation controls and guidance, preserves the animated artifact, and replays a still preview through a follow-up turn.",
&requests,
&ContextSnapshotOptions::default()
.rewrite_known_segments()
.include_request_settings(),
));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn admitted_prompt_id_reaches_responses() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -0,0 +1,44 @@
---
source: core/tests/suite/scenarios.rs
expression: "context_snapshot::format_request_history_snapshot(\"GIF generation exposes animation controls and guidance, preserves the animated artifact, and replays a still preview through a follow-up turn.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
---
Scenario: GIF generation exposes animation controls and guidance, preserves the animated artifact, and replays a still preview through a follow-up turn.
## Window 1
Settings:
include: ["reasoning.encrypted_content"]
model: "gpt-5.4"
parallel_tool_calls: false
prompt_cache_key: "<PROMPT_CACHE_KEY 1>"
reasoning: {"context":"all_turns","effort":"medium"}
store: false
stream: true
text: {"verbosity":"low"}
tool_choice: "auto"
-- request 1 (turn) --
00:additional_tools/developer (3; hash=650d9374d11dab9f):
- namespace/functions; hash=101d32aa59ea258b
- function/exec_command
- function/write_stdin
- function/request_user_input
- custom/apply_patch
- function/view_image
- namespace/image_gen: Tools in the image_gen namespace.; hash=7f9a214246396279
- function/imagegen
- tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9
01:message/developer:
<MODEL_INSTRUCTIONS>
02:message/developer:
<PERMISSIONS_INSTRUCTIONS>
03:message/user:
<ENVIRONMENT_CONTEXT>
04:message/user:
Generate a two-frame GIF of a pixel alternating red and blue.
-- request 2 (turn) --
05:function_call/image_gen.imagegen:{"gif":{"frame_count":2,"frame_duration_ms":200},"prompt":"A pixel alternating between red and blue."}
06:function_call_output:<input_image>
-- request 3 (turn) --
07:message/assistant:
Here is the two-frame GIF.
08:message/user:
How long does each frame last?
@@ -4,6 +4,7 @@ codex_rust_crate(
name = "image-generation",
compile_data = [
"imagegen_description.md",
"imagegen_gif_description.md",
],
crate_name = "codex_image_generation_extension",
)
@@ -0,0 +1,4 @@
GIF guidelines:
- Use imagegen only for simple GIFs with 2–16 frames. For more complex animations or more than 16 frames, use other tools instead.
- Set `gif` to request GIF output. Use `{}` for the defaults (9 frames, 200 ms per frame), or set `frame_count` and `frame_duration_ms`.
- To edit a previously generated GIF, first inspect its frames, then create a spritesheet with equal-sized cells in a regular grid. Pass that spritesheet through `referenced_image_paths` and set `gif` to generate the new GIF. Do not pass the GIF itself as an edit input.
+31 -1
View File
@@ -5,11 +5,37 @@ use codex_utils_absolute_path::AbsolutePathBuf;
const GENERATED_IMAGE_ARTIFACTS_DIR: &str = "generated_images";
const MAX_IMAGE_GENERATION_OUTPUT_HINT_BYTES: usize = 1024;
/// Identify GIFs from their encoded header so transport and filenames preserve animation.
#[derive(Clone, Copy)]
pub(crate) enum GeneratedImageFormat {
Png,
Gif,
}
impl GeneratedImageFormat {
pub(crate) fn from_base64(result: &str) -> Self {
let result = result.trim();
if result.starts_with("R0lGODdh") || result.starts_with("R0lGODlh") {
Self::Gif
} else {
Self::Png
}
}
pub(crate) fn mime_type(self) -> &'static str {
match self {
Self::Png => "image/png",
Self::Gif => "image/gif",
}
}
}
/// Returns the extension-owned artifact path for a generated image.
pub(crate) fn image_generation_artifact_path(
save_root: &AbsolutePathBuf,
session_id: &str,
call_id: &str,
format: GeneratedImageFormat,
) -> AbsolutePathBuf {
let sanitize = |value: &str| {
let mut sanitized: String = value
@@ -28,10 +54,14 @@ pub(crate) fn image_generation_artifact_path(
sanitized
};
let extension = match format {
GeneratedImageFormat::Png => "png",
GeneratedImageFormat::Gif => "gif",
};
save_root
.join(GENERATED_IMAGE_ARTIFACTS_DIR)
.join(sanitize(session_id))
.join(format!("{}.png", sanitize(call_id)))
.join(format!("{}.{extension}", sanitize(call_id)))
}
/// Returns the model-facing generated-image path hint, or omits it if it is too large.
@@ -31,6 +31,7 @@ type SaveRootResolver = dyn Fn(&Config) -> Option<AbsolutePathBuf> + Send + Sync
#[derive(Clone)]
struct ImageGenerationExtensionConfig {
available: bool,
gif_enabled: bool,
http_client_factory: HttpClientFactory,
provider: ModelProviderInfo,
save_root: Option<AbsolutePathBuf>,
@@ -43,6 +44,7 @@ impl ImageGenerationExtensionConfig {
available: config.model_provider.is_openai()
|| config.model_provider.requires_openai_auth
|| config.model_provider.uses_openai_actor_authorization(),
gif_enabled: config.features.image_generation_animation_enabled(),
http_client_factory: config.http_client_factory(),
provider: config.model_provider.clone(),
save_root: resolve_save_root(config),
@@ -107,6 +109,7 @@ impl ToolContributor for ImageGenerationExtension {
),
config.save_root.clone(),
thread_store.level_id().to_string(),
config.gif_enabled,
))]
}
}
+23 -4
View File
@@ -86,6 +86,7 @@ async fn referenced_files_record_the_exact_bytes_read_for_each_request() {
let environment =
ToolEnvironment::new("test".to_string(), PathUri::from_abs_path(&root), &access);
let args = ImagegenArgs {
gif: None,
prompt: "combine the references".to_string(),
transparent_background: false,
referenced_image_paths: Some(vec![second.clone(), first.clone()]),
@@ -110,6 +111,7 @@ async fn referenced_files_record_the_exact_bytes_read_for_each_request() {
let inputs = [(&second, &second_bytes), (&first, &first_bytes)];
let expected = PreparedImageRequest {
request: ImageRequest::Edit(ImageEditRequest {
output: None,
images: inputs
.iter()
.map(|(_, bytes)| ImageReference::Inline {
@@ -154,6 +156,7 @@ async fn unreadable_or_invalid_reference_does_not_build_an_edit_request() {
ToolEnvironment::new("test".to_string(), PathUri::from_abs_path(&root), &access);
for path in [root.join("missing.png"), invalid] {
let args = ImagegenArgs {
gif: None,
prompt: "edit the reference".to_string(),
transparent_background: false,
referenced_image_paths: Some(vec![path]),
@@ -172,7 +175,12 @@ fn artifact_path_sanitizes_session_and_call_ids() {
let save_root = AbsolutePathBuf::current_dir().expect("current directory should be absolute");
assert_eq!(
image_generation_artifact_path(&save_root, "../session", "../call"),
image_generation_artifact_path(
&save_root,
"../session",
"../call",
super::GeneratedImageFormat::Png
),
save_root
.join("generated_images")
.join("___session")
@@ -182,7 +190,7 @@ fn artifact_path_sanitizes_session_and_call_ids() {
#[test]
fn uses_reserved_image_gen_namespace() {
let ToolSpec::Namespace(spec) = imagegen_tool_spec() else {
let ToolSpec::Namespace(spec) = imagegen_tool_spec(/*gif_enabled*/ false) else {
panic!("imagegen should advertise a namespace tool");
};
assert_eq!(spec.name, IMAGE_GEN_NAMESPACE);
@@ -194,7 +202,7 @@ fn uses_reserved_image_gen_namespace() {
#[test]
fn imagegen_code_mode_declaration_describes_generated_image_result() {
let spec = imagegen_tool_spec();
let spec = imagegen_tool_spec(/*gif_enabled*/ false);
let definitions = codex_tools::collect_code_mode_tool_definitions(
[&spec],
/*code_mode_input_schema_max_bytes*/ None,
@@ -214,7 +222,7 @@ fn imagegen_code_mode_declaration_describes_generated_image_result() {
#[test]
fn imagegen_output_schema_does_not_change_direct_api_serialization() {
let spec = imagegen_tool_spec();
let spec = imagegen_tool_spec(/*gif_enabled*/ false);
let mut without_output_schema = spec.clone();
let ToolSpec::Namespace(namespace) = &mut without_output_schema else {
panic!("imagegen should advertise a namespace tool");
@@ -235,6 +243,7 @@ async fn omitted_references_generate_with_fixed_defaults() {
assert_eq!(
request_for_call_args(
&ImagegenArgs {
gif: None,
prompt: "paint a moonlit lake".to_string(),
transparent_background: false,
referenced_image_paths: None,
@@ -247,6 +256,7 @@ async fn omitted_references_generate_with_fixed_defaults() {
.expect("generation request should build"),
PreparedImageRequest {
request: ImageRequest::Generate(ImageGenerationRequest {
output: None,
prompt: "paint a moonlit lake".to_string(),
background: Some(ImageBackground::Opaque),
model: "gpt-image-2".to_string(),
@@ -334,6 +344,7 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() {
assert_eq!(
request_for_call_args(
&ImagegenArgs {
gif: None,
prompt: "change the lighting".to_string(),
transparent_background: false,
referenced_image_paths: None,
@@ -358,6 +369,7 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() {
async fn recent_image_fallback_passes_file_backed_image_to_edit_request() {
let request = request_for_call_args(
&ImagegenArgs {
gif: None,
prompt: "change the lighting".to_string(),
transparent_background: false,
referenced_image_paths: None,
@@ -389,6 +401,7 @@ async fn recent_image_fallback_passes_file_backed_image_to_edit_request() {
request,
PreparedImageRequest {
request: ImageRequest::Edit(ImageEditRequest {
output: None,
images: vec![ImageReference::File {
file_id: "newer-file-backed-image".to_string(),
}],
@@ -409,6 +422,7 @@ async fn recent_image_fallback_passes_file_backed_image_to_edit_request() {
async fn recent_image_fallback_passes_file_backed_tool_output_to_edit_request() {
let request = request_for_call_args(
&ImagegenArgs {
gif: None,
prompt: "change the lighting".to_string(),
transparent_background: false,
referenced_image_paths: None,
@@ -447,6 +461,7 @@ async fn recent_image_fallback_passes_file_backed_tool_output_to_edit_request()
request,
PreparedImageRequest {
request: ImageRequest::Edit(ImageEditRequest {
output: None,
images: vec![ImageReference::File {
file_id: "newer-file-backed-image".to_string(),
}],
@@ -466,6 +481,7 @@ async fn recent_image_fallback_passes_file_backed_tool_output_to_edit_request()
async fn conflicting_image_selectors_return_tool_error() {
let error = request_for_call_args(
&ImagegenArgs {
gif: None,
prompt: "change the lighting".to_string(),
transparent_background: false,
referenced_image_paths: Some(vec![
@@ -491,6 +507,7 @@ async fn conflicting_image_selectors_return_tool_error() {
async fn too_many_referenced_image_paths_return_tool_error() {
let error = request_for_call_args(
&ImagegenArgs {
gif: None,
prompt: "change the lighting".to_string(),
transparent_background: false,
referenced_image_paths: Some(
@@ -520,6 +537,7 @@ async fn too_many_referenced_image_paths_return_tool_error() {
async fn recent_image_fallback_requires_requested_count() {
let error = request_for_call_args(
&ImagegenArgs {
gif: None,
prompt: "change the lighting".to_string(),
transparent_background: false,
referenced_image_paths: None,
@@ -650,6 +668,7 @@ fn image_output(image: &str) -> FunctionCallOutputPayload {
fn expected_edit_request(prompt: &str, images: &[&str]) -> ImageEditRequest {
ImageEditRequest {
output: None,
images: images
.iter()
.map(|image| ImageReference::Inline {
+48 -7
View File
@@ -3,9 +3,11 @@ use std::io;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_api::GifOptions;
use codex_api::ImageBackground;
use codex_api::ImageEditRequest;
use codex_api::ImageGenerationRequest;
use codex_api::ImageOutput;
use codex_api::ImageQuality;
use codex_attachment_store::UploadRequest;
use codex_attachment_store::UploadResult;
@@ -58,6 +60,7 @@ use sha2::Sha256;
use crate::IMAGE_GEN_NAMESPACE;
use crate::IMAGEGEN_TOOL_NAME;
use crate::artifact::GeneratedImageFormat;
use crate::artifact::image_generation_artifact_path;
use crate::artifact::image_generation_output_hint;
use crate::backend::CodexImagesBackend;
@@ -68,12 +71,14 @@ const MAX_EXECUTOR_GENERATED_IMAGE_BYTES: usize = 32 * 1024 * 1024;
const MAX_EXECUTOR_GENERATED_IMAGE_BASE64_BYTES: usize =
MAX_EXECUTOR_GENERATED_IMAGE_BYTES.div_ceil(3) * 4;
const IMAGEGEN_DESCRIPTION: &str = include_str!("../imagegen_description.md");
const IMAGEGEN_GIF_DESCRIPTION: &str = include_str!("../imagegen_gif_description.md");
#[derive(Clone)]
pub(crate) struct ImageGenerationTool {
backend: CodexImagesBackend,
save_root: Option<AbsolutePathBuf>,
thread_id: String,
gif_enabled: bool,
}
impl ImageGenerationTool {
@@ -82,11 +87,13 @@ impl ImageGenerationTool {
backend: CodexImagesBackend,
save_root: Option<AbsolutePathBuf>,
thread_id: String,
gif_enabled: bool,
) -> Self {
Self {
backend,
save_root,
thread_id,
gif_enabled,
}
}
}
@@ -95,6 +102,8 @@ impl ImageGenerationTool {
#[serde(deny_unknown_fields)]
struct ImagegenArgs {
prompt: String,
/// Set this to create a simple looping GIF with 2–16 frames. Use {} for defaults.
gif: Option<GifOptions>,
/// Whether the output should have a transparent background. Defaults to false.
#[serde(default)]
transparent_background: bool,
@@ -133,7 +142,7 @@ impl<'call> ToolExecutor<ToolCall<'call>> for ImageGenerationTool {
/// Advertises a rewritten prompt, background choice, and optional edit references.
fn spec(&self) -> ToolSpec {
imagegen_tool_spec()
imagegen_tool_spec(self.gif_enabled)
}
/// Exposes image generation directly and through the nested code-mode tool surface.
@@ -156,6 +165,11 @@ impl ImageGenerationTool {
call: ToolCall<'_>,
) -> Result<Box<dyn ToolOutput>, FunctionCallError> {
let args = parse_args(&call)?;
if args.gif.is_some() && !self.gif_enabled {
return Err(FunctionCallError::RespondToModel(
"GIF output is unavailable in this session.".to_string(),
));
}
let PreparedImageRequest {
request,
input_images,
@@ -354,7 +368,12 @@ async fn save_image_generation_result(
) -> Option<AbsolutePathBuf> {
let (output_dir, save_result) = match save_root {
Some(save_root) => {
let path = image_generation_artifact_path(save_root, session_id, call_id);
let path = image_generation_artifact_path(
save_root,
session_id,
call_id,
GeneratedImageFormat::from_base64(result),
);
let output_dir = path.parent().unwrap_or_else(|| save_root.clone());
let save_result: io::Result<AbsolutePathBuf> = async {
let bytes = BASE64_STANDARD
@@ -408,7 +427,12 @@ async fn save_image_generation_result(
));
}
let artifact_path = image_generation_artifact_path(&cwd, session_id, call_id);
let artifact_path = image_generation_artifact_path(
&cwd,
session_id,
call_id,
GeneratedImageFormat::from_base64(result),
);
let path = output_dir.join(artifact_path.as_path().file_name().unwrap_or_default());
if let Some(parent) = path.parent() {
let parent_uri = PathUri::from_abs_path(&parent);
@@ -504,6 +528,7 @@ async fn request_for_call_args(
return Ok(PreparedImageRequest {
request: ImageRequest::Generate(ImageGenerationRequest {
prompt: args.prompt.clone(),
output: args.gif.map(ImageOutput::Gif),
background: Some(background),
model: IMAGE_MODEL.to_string(),
n: None,
@@ -553,6 +578,7 @@ async fn request_for_call_args(
request: ImageRequest::Edit(ImageEditRequest {
images,
prompt: args.prompt.clone(),
output: args.gif.map(ImageOutput::Gif),
background: Some(background),
model: IMAGE_MODEL.to_string(),
n: None,
@@ -678,7 +704,7 @@ fn parse_args(call: &ToolCall<'_>) -> Result<ImagegenArgs, FunctionCallError> {
}
/// Builds the namespace function schema exposed to the model.
fn imagegen_tool_spec() -> ToolSpec {
fn imagegen_tool_spec(gif_enabled: bool) -> ToolSpec {
let mut schema_value = serde_json::to_value(
SchemaSettings::draft2019_09()
.with(|settings| settings.inline_subschemas = true)
@@ -689,6 +715,9 @@ fn imagegen_tool_spec() -> ToolSpec {
let Value::Object(ref mut schema) = schema_value else {
unreachable!("imagegen root schema must be an object");
};
if !gif_enabled && let Some(Value::Object(properties)) = schema.get_mut("properties") {
properties.remove("gif");
}
let mut input_schema = Map::new();
for key in ["properties", "required", "type", "additionalProperties"] {
if let Some(value) = schema.remove(key) {
@@ -700,7 +729,11 @@ fn imagegen_tool_spec() -> ToolSpec {
description: default_namespace_description(IMAGE_GEN_NAMESPACE),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: IMAGEGEN_TOOL_NAME.to_string(),
description: IMAGEGEN_DESCRIPTION.to_string(),
description: if gif_enabled {
format!("{IMAGEGEN_DESCRIPTION}\n{IMAGEGEN_GIF_DESCRIPTION}")
} else {
IMAGEGEN_DESCRIPTION.to_string()
},
strict: false,
parameters: parse_tool_input_schema(&Value::Object(input_schema))
.unwrap_or_else(|err| panic!("imagegen input schema should parse: {err}")),
@@ -741,7 +774,11 @@ impl ToolOutput for GeneratedImageOutput {
fn code_mode_result(&self, _payload: &ToolPayload) -> Value {
let mut result = Map::from_iter([(
"image_url".to_string(),
Value::String(format!("data:image/png;base64,{}", self.result)),
Value::String(format!(
"data:{};base64,{}",
GeneratedImageFormat::from_base64(&self.result).mime_type(),
self.result
)),
)]);
if let Some(output_hint) = &self.output_hint {
result.insert(
@@ -756,7 +793,11 @@ impl ToolOutput for GeneratedImageOutput {
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
let mut content = vec![FunctionCallOutputContentItem::InputImage {
image: ImageReference::Inline {
image_url: format!("data:image/png;base64,{}", self.result),
image_url: format!(
"data:{};base64,{}",
GeneratedImageFormat::from_base64(&self.result).mime_type(),
self.result
),
},
detail: Some(DEFAULT_IMAGE_DETAIL),
}];
+13
View File
@@ -384,6 +384,8 @@ pub enum Feature {
ExternalMigration,
/// Enable extension-backed image generation.
ImageGeneration,
/// Allow the image-generation tool to request a backend-assembled GIF.
ImageGenerationAnimation,
/// Omit inline image and audio content from app-server item notifications.
OmitAppServerNotificationMedia,
/// Tell the model when a prompt image was resized and include its dimensions.
@@ -632,6 +634,11 @@ impl Features {
self.enabled.contains(&f)
}
/// Whether to advertise GIF output in the image-generation tool.
pub fn image_generation_animation_enabled(&self) -> bool {
self.enabled(Feature::ImageGenerationAnimation)
}
/// Whether a routing tier is enabled, independently of model catalog support.
pub fn service_tier_enabled(&self, service_tier: &str) -> bool {
match service_tier {
@@ -1962,6 +1969,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::ImageGenerationAnimation,
key: "image_generation_animation",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::OmitAppServerNotificationMedia,
key: "omit_app_server_notification_media",