mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
+44
@@ -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.
|
||||
@@ -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,
|
||||
))]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}];
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user