mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
ecb41fcb64
## Why Add a standalone image generation path that can be exercised independently of hosted Responses image generation, while retaining the hosted tool as fallback unless the extension is actually available to the model. ## What changed - Added the `codex-image-generation-extension` crate with standalone generate/edit execution, prior-image selection for edits, model-visible image output, and local generated-image persistence. - Installed the extension in app-server behind the disabled-by-default `imagegenext` feature and backend eligibility checks. - Updated core tool planning so eligible `image_gen.imagegen` exposure replaces hosted `image_generation`, while unavailable configurations retain hosted fallback. - Added coverage for extension behavior, edit history reuse, feature gating, auth eligibility, and hosted-tool replacement. - The extension is installed through app-server only in this PR; other execution paths retain hosted image generation because hosted replacement occurs only when the standalone executor is actually registered and model-visible. - The initial extension contract intentionally fixes the image model to `gpt-image-2` and uses automatic image parameters. - Native generated-image history/card parity and rollout persistence cleanup are intentionally deferred follow-up work. ## Validation - `just test -p codex-image-generation-extension` - `just test -p codex-features` - `just test -p codex-core hosted_tools_follow_provider_auth_model_and_config_gates` - `just test -p codex-app-server` - `just fix -p codex-image-generation-extension -p codex-features -p codex-core -p codex-app-server` - `just fmt` - `just bazel-lock-update` - `just bazel-lock-check` --------- Co-authored-by: jif-oai <jif@openai.com>
342 lines
11 KiB
Rust
342 lines
11 KiB
Rust
use codex_api::ImageBackground;
|
|
use codex_api::ImageEditRequest;
|
|
use codex_api::ImageGenerationRequest;
|
|
use codex_api::ImageQuality;
|
|
use codex_api::ImageUrl;
|
|
use codex_extension_api::ToolOutput;
|
|
use codex_extension_api::ToolPayload;
|
|
use codex_extension_api::ToolSpec;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
|
|
use codex_protocol::models::FunctionCallOutputBody;
|
|
use codex_protocol::models::FunctionCallOutputContentItem;
|
|
use codex_protocol::models::FunctionCallOutputPayload;
|
|
use codex_protocol::models::ResponseInputItem;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_tools::ResponsesApiNamespaceTool;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
use super::GeneratedImageOutput;
|
|
use super::ImageRequest;
|
|
use super::ImagegenAction;
|
|
use super::ImagegenArgs;
|
|
use super::generated_image_output_dir;
|
|
use super::imagegen_tool_spec;
|
|
use super::persist_generated_image;
|
|
use super::request_for_action;
|
|
use crate::IMAGE_GEN_NAMESPACE;
|
|
use crate::IMAGEGEN_TOOL_NAME;
|
|
|
|
const RESULT: &str = "cG5n";
|
|
|
|
#[test]
|
|
fn uses_reserved_image_gen_namespace() {
|
|
let ToolSpec::Namespace(spec) = imagegen_tool_spec() else {
|
|
panic!("imagegen should advertise a namespace tool");
|
|
};
|
|
assert_eq!(spec.name, IMAGE_GEN_NAMESPACE);
|
|
let ResponsesApiNamespaceTool::Function(function) = &spec.tools[0];
|
|
assert_eq!(function.name, IMAGEGEN_TOOL_NAME);
|
|
}
|
|
|
|
#[test]
|
|
fn generate_uses_fixed_request_defaults() {
|
|
assert_eq!(
|
|
request_for_action(&args(ImagegenAction::Generate, "paint a moonlit lake"), &[])
|
|
.expect("generation request should build"),
|
|
ImageRequest::Generate(ImageGenerationRequest {
|
|
prompt: "paint a moonlit lake".to_string(),
|
|
background: Some(ImageBackground::Auto),
|
|
model: "gpt-image-2".to_string(),
|
|
n: None,
|
|
quality: Some(ImageQuality::Auto),
|
|
size: Some("auto".to_string()),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn generated_output_returns_image_input_and_persists_artifact() {
|
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
|
let output_hint = persist_generated_image(tempdir.path(), "call-1", RESULT)
|
|
.await
|
|
.expect("generated image should persist");
|
|
let output = GeneratedImageOutput {
|
|
result: RESULT.to_string(),
|
|
output_hint: Some(output_hint),
|
|
};
|
|
|
|
let ResponseInputItem::FunctionCallOutput {
|
|
output: response_output,
|
|
..
|
|
} = output.to_response_item("call-1", &function_payload())
|
|
else {
|
|
panic!("imagegen should return function tool output");
|
|
};
|
|
let FunctionCallOutputBody::ContentItems(content_items) = response_output.body else {
|
|
panic!("imagegen output should contain generated image bytes");
|
|
};
|
|
assert_eq!(
|
|
content_items,
|
|
vec![
|
|
FunctionCallOutputContentItem::InputImage {
|
|
image_url: format!("data:image/png;base64,{RESULT}"),
|
|
detail: Some(DEFAULT_IMAGE_DETAIL),
|
|
},
|
|
FunctionCallOutputContentItem::InputText {
|
|
text: format!(
|
|
"Generated images are saved to {} as {} by default.\n\
|
|
If 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.",
|
|
tempdir.path().display(),
|
|
tempdir.path().join("call-1.png").display(),
|
|
),
|
|
},
|
|
]
|
|
);
|
|
assert_eq!(
|
|
std::fs::read(tempdir.path().join("call-1.png")).expect("saved generated image"),
|
|
b"png"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn edit_matches_context_selector_for_generated_images_after_latest_user_anchor() {
|
|
let history = vec![
|
|
generated_item("g1"),
|
|
generated_item("g2"),
|
|
generated_item("g3"),
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![
|
|
ContentItem::InputImage {
|
|
image_url: "data:image/png;base64,u1".to_string(),
|
|
detail: None,
|
|
},
|
|
ContentItem::InputImage {
|
|
image_url: "data:image/png;base64,u2".to_string(),
|
|
detail: None,
|
|
},
|
|
],
|
|
phase: None,
|
|
},
|
|
generated_item("g4"),
|
|
generated_item("g5"),
|
|
generated_item("g6"),
|
|
generated_item("g7"),
|
|
];
|
|
|
|
assert_eq!(
|
|
edit_request("change the lighting", &history),
|
|
expected_edit_request(
|
|
"change the lighting",
|
|
&[
|
|
"data:image/png;base64,u1",
|
|
"data:image/png;base64,u2",
|
|
"data:image/png;base64,g5",
|
|
"data:image/png;base64,g6",
|
|
"data:image/png;base64,g7",
|
|
]
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn edit_preserves_a_generated_image_when_user_anchor_fills_the_limit() {
|
|
let history = vec![
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: ["a", "b", "c", "d", "e"]
|
|
.into_iter()
|
|
.map(|image| ContentItem::InputImage {
|
|
image_url: format!("data:image/png;base64,{image}"),
|
|
detail: None,
|
|
})
|
|
.collect(),
|
|
phase: None,
|
|
},
|
|
generated_item("generated"),
|
|
];
|
|
|
|
assert_eq!(
|
|
edit_request("edit the last generated image", &history),
|
|
expected_edit_request(
|
|
"edit the last generated image",
|
|
&[
|
|
"data:image/png;base64,b",
|
|
"data:image/png;base64,c",
|
|
"data:image/png;base64,d",
|
|
"data:image/png;base64,e",
|
|
"data:image/png;base64,generated",
|
|
]
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn edit_uses_latest_user_upload_before_a_text_only_follow_up() {
|
|
let history = vec![
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputImage {
|
|
image_url: "data:image/png;base64,user".to_string(),
|
|
detail: None,
|
|
}],
|
|
phase: None,
|
|
},
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "edit this image".to_string(),
|
|
}],
|
|
phase: None,
|
|
},
|
|
];
|
|
|
|
assert_eq!(
|
|
edit_request("change the lighting", &history),
|
|
expected_edit_request("change the lighting", &["data:image/png;base64,user"])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn edit_reuses_images_from_prior_standalone_imagegen_calls() {
|
|
let history = vec![
|
|
ResponseItem::FunctionCall {
|
|
id: None,
|
|
name: IMAGEGEN_TOOL_NAME.to_string(),
|
|
namespace: Some(IMAGE_GEN_NAMESPACE.to_string()),
|
|
arguments: "{}".to_string(),
|
|
call_id: "imagegen-1".to_string(),
|
|
},
|
|
generated_function_output("imagegen-1", "standalone"),
|
|
];
|
|
|
|
assert_eq!(
|
|
edit_request("change the lighting", &history),
|
|
expected_edit_request("change the lighting", &["data:image/png;base64,standalone"])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn edit_keeps_newest_standalone_generated_images_when_over_limit() {
|
|
let history = (1..=6)
|
|
.flat_map(|index| {
|
|
let call_id = format!("imagegen-{index}");
|
|
vec![
|
|
ResponseItem::FunctionCall {
|
|
id: None,
|
|
name: IMAGEGEN_TOOL_NAME.to_string(),
|
|
namespace: Some(IMAGE_GEN_NAMESPACE.to_string()),
|
|
arguments: "{}".to_string(),
|
|
call_id: call_id.clone(),
|
|
},
|
|
generated_function_output(&call_id, &index.to_string()),
|
|
]
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
edit_request("change the lighting", &history),
|
|
expected_edit_request(
|
|
"change the lighting",
|
|
&[
|
|
"data:image/png;base64,2",
|
|
"data:image/png;base64,3",
|
|
"data:image/png;base64,4",
|
|
"data:image/png;base64,5",
|
|
"data:image/png;base64,6",
|
|
]
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn edit_without_image_history_returns_tool_error() {
|
|
let error = request_for_action(&args(ImagegenAction::Edit, "change the lighting"), &[])
|
|
.expect_err("edit should require image context");
|
|
|
|
assert_eq!(
|
|
error.to_string(),
|
|
"image edit requested without any usable image in conversation history"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn generated_image_output_dir_is_scoped_to_sanitized_thread_id() {
|
|
assert_eq!(
|
|
generated_image_output_dir(std::path::Path::new("/tmp/codex-home"), "thread/1"),
|
|
std::path::PathBuf::from("/tmp/codex-home/generated_images/thread_1")
|
|
);
|
|
}
|
|
|
|
fn args(action: ImagegenAction, prompt: &str) -> ImagegenArgs {
|
|
ImagegenArgs {
|
|
prompt: prompt.to_string(),
|
|
action,
|
|
}
|
|
}
|
|
|
|
fn edit_request(prompt: &str, history: &[ResponseItem]) -> ImageEditRequest {
|
|
let ImageRequest::Edit(request) =
|
|
request_for_action(&args(ImagegenAction::Edit, prompt), history)
|
|
.expect("edit request should build")
|
|
else {
|
|
panic!("expected edit request");
|
|
};
|
|
request
|
|
}
|
|
|
|
fn expected_edit_request(prompt: &str, images: &[&str]) -> ImageEditRequest {
|
|
ImageEditRequest {
|
|
images: images
|
|
.iter()
|
|
.map(|image_url| ImageUrl {
|
|
image_url: (*image_url).to_string(),
|
|
})
|
|
.collect(),
|
|
prompt: prompt.to_string(),
|
|
background: Some(ImageBackground::Auto),
|
|
model: "gpt-image-2".to_string(),
|
|
n: None,
|
|
quality: Some(ImageQuality::Auto),
|
|
size: Some("auto".to_string()),
|
|
}
|
|
}
|
|
|
|
fn generated_item(result: &str) -> ResponseItem {
|
|
ResponseItem::ImageGenerationCall {
|
|
id: format!("id-{result}"),
|
|
status: "completed".to_string(),
|
|
revised_prompt: None,
|
|
result: result.to_string(),
|
|
}
|
|
}
|
|
|
|
fn generated_function_output(call_id: &str, result: &str) -> ResponseItem {
|
|
ResponseItem::FunctionCallOutput {
|
|
call_id: call_id.to_string(),
|
|
output: FunctionCallOutputPayload {
|
|
body: FunctionCallOutputBody::ContentItems(vec![
|
|
FunctionCallOutputContentItem::InputImage {
|
|
image_url: format!("data:image/png;base64,{result}"),
|
|
detail: Some(DEFAULT_IMAGE_DETAIL),
|
|
},
|
|
FunctionCallOutputContentItem::InputText {
|
|
text: "generated image save hint".to_string(),
|
|
},
|
|
]),
|
|
success: Some(true),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn function_payload() -> ToolPayload {
|
|
ToolPayload::Function {
|
|
arguments: "{}".to_string(),
|
|
}
|
|
}
|