mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
image-gen-core (#13290)
Core tool-calling for image-gen, handles requesting and receiving logic for images using response API
This commit is contained in:
@@ -567,7 +567,6 @@ impl ModelClientSession {
|
||||
) -> ApiResponsesOptions {
|
||||
let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header);
|
||||
let conversation_id = self.client.state.conversation_id.to_string();
|
||||
|
||||
ApiResponsesOptions {
|
||||
conversation_id: Some(conversation_id),
|
||||
session_source: Some(self.client.state.session_source.clone()),
|
||||
|
||||
@@ -166,6 +166,8 @@ pub(crate) mod tools {
|
||||
Function(ResponsesApiTool),
|
||||
#[serde(rename = "local_shell")]
|
||||
LocalShell {},
|
||||
#[serde(rename = "image_generation")]
|
||||
ImageGeneration {},
|
||||
// TODO: Understand why we get an error on web_search although the API docs say it's supported.
|
||||
// https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C
|
||||
// The `external_web_access` field determines whether the web search is over cached or live content.
|
||||
@@ -184,6 +186,7 @@ pub(crate) mod tools {
|
||||
match self {
|
||||
ToolSpec::Function(tool) => tool.name.as_str(),
|
||||
ToolSpec::LocalShell {} => "local_shell",
|
||||
ToolSpec::ImageGeneration {} => "image_generation",
|
||||
ToolSpec::WebSearch { .. } => "web_search",
|
||||
ToolSpec::Freeform(tool) => tool.name.as_str(),
|
||||
}
|
||||
|
||||
@@ -206,6 +206,7 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Other => false,
|
||||
}
|
||||
|
||||
@@ -344,6 +344,9 @@ impl ContextManager {
|
||||
// all outputs must have a corresponding function/tool call
|
||||
normalize::remove_orphan_outputs(&mut self.items);
|
||||
|
||||
//rewrite image_gen_calls to messages to support stateless input
|
||||
normalize::rewrite_image_generation_calls_for_stateless_input(&mut self.items);
|
||||
|
||||
// strip images when model does not support them
|
||||
normalize::strip_images_when_unsupported(input_modalities, &mut self.items);
|
||||
}
|
||||
@@ -374,6 +377,7 @@ impl ContextManager {
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
@@ -402,7 +406,8 @@ fn truncate_function_output_payload(
|
||||
}
|
||||
|
||||
/// API messages include every non-system item (user/assistant messages, reasoning,
|
||||
/// tool calls, tool outputs, shell calls, and web-search calls).
|
||||
/// tool calls, tool outputs, shell calls, web-search calls, and image-generation
|
||||
/// calls).
|
||||
fn is_api_message(message: &ResponseItem) -> bool {
|
||||
match message {
|
||||
ResponseItem::Message { role, .. } => role.as_str() != "system",
|
||||
@@ -413,6 +418,7 @@ fn is_api_message(message: &ResponseItem) -> bool {
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::Compaction { .. } => true,
|
||||
ResponseItem::GhostSnapshot { .. } => false,
|
||||
ResponseItem::Other => false,
|
||||
@@ -600,6 +606,7 @@ fn is_model_generated_item(item: &ResponseItem) -> bool {
|
||||
ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::Compaction { .. } => true,
|
||||
|
||||
@@ -395,6 +395,97 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_prompt_rewrites_image_generation_calls_when_images_are_supported() {
|
||||
let history = create_history_with_items(vec![
|
||||
ResponseItem::ImageGenerationCall {
|
||||
id: "ig_123".to_string(),
|
||||
status: "generating".to_string(),
|
||||
revised_prompt: Some("lobster".to_string()),
|
||||
result: "Zm9v".to_string(),
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "hi".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
history.for_prompt(&default_input_modalities()),
|
||||
vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,Zm9v".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "hi".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_prompt_rewrites_image_generation_calls_when_images_are_unsupported() {
|
||||
let history = create_history_with_items(vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "generate a lobster".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::ImageGenerationCall {
|
||||
id: "ig_123".to_string(),
|
||||
status: "completed".to_string(),
|
||||
revised_prompt: Some("lobster".to_string()),
|
||||
result: "Zm9v".to_string(),
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
history.for_prompt(&[InputModality::Text]),
|
||||
vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "generate a lobster".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "image content omitted because you do not support image input"
|
||||
.to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_history_for_prompt_drops_ghost_commits() {
|
||||
let items = vec![ResponseItem::GhostSnapshot {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::util::error_or_panic;
|
||||
use tracing::info;
|
||||
@@ -211,6 +210,31 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn rewrite_image_generation_calls_for_stateless_input(items: &mut Vec<ResponseItem>) {
|
||||
let original_items = std::mem::take(items);
|
||||
*items = original_items
|
||||
.into_iter()
|
||||
.map(|item| match item {
|
||||
ResponseItem::ImageGenerationCall { result, .. } => {
|
||||
let image_url = if result.starts_with("data:") {
|
||||
result
|
||||
} else {
|
||||
format!("data:image/png;base64,{result}")
|
||||
};
|
||||
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputImage { image_url }],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
_ => item,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Strip image content from messages and tool outputs when the model does not support images.
|
||||
/// When `input_modalities` contains `InputModality::Image`, no stripping is performed.
|
||||
pub(crate) fn strip_images_when_unsupported(
|
||||
|
||||
@@ -131,6 +131,8 @@ pub enum Feature {
|
||||
Apps,
|
||||
/// Enable plugins.
|
||||
Plugins,
|
||||
/// Allow the model to invoke the built-in image generation tool.
|
||||
ImageGeneration,
|
||||
/// Route apps MCP calls through the configured gateway.
|
||||
AppsMcpGateway,
|
||||
/// Allow prompting and installing missing MCP dependencies.
|
||||
@@ -649,6 +651,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ImageGeneration,
|
||||
key: "image_generation",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::AppsMcpGateway,
|
||||
key: "apps_mcp_gateway",
|
||||
@@ -868,6 +876,12 @@ mod tests {
|
||||
assert_eq!(Feature::JsRepl.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_generation_is_under_development() {
|
||||
assert_eq!(Feature::ImageGeneration.stage(), Stage::UnderDevelopment);
|
||||
assert_eq!(Feature::ImageGeneration.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collab_is_legacy_alias_for_multi_agent() {
|
||||
assert_eq!(feature_for_key("multi_agent"), Some(Feature::Collab));
|
||||
|
||||
@@ -35,6 +35,7 @@ pub(crate) fn should_persist_response_item(item: &ResponseItem) -> bool {
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Compaction { .. } => true,
|
||||
ResponseItem::Other => false,
|
||||
@@ -53,6 +54,7 @@ pub(crate) fn should_persist_response_item_for_memories(item: &ResponseItem) ->
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. } => true,
|
||||
ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::Other => false,
|
||||
|
||||
@@ -23,6 +23,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::VIEW_IMAGE_TOOL_NAME;
|
||||
use codex_protocol::openai_models::ApplyPatchToolType;
|
||||
use codex_protocol::openai_models::ConfigShellToolType;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
@@ -35,7 +36,6 @@ use std::collections::HashMap;
|
||||
|
||||
const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str =
|
||||
include_str!("../../templates/search_tool/tool_description.md");
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum ShellCommandBackendConfig {
|
||||
Classic,
|
||||
@@ -49,6 +49,7 @@ pub(crate) struct ToolsConfig {
|
||||
pub allow_login_shell: bool,
|
||||
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
|
||||
pub web_search_mode: Option<WebSearchMode>,
|
||||
pub image_gen_tool: bool,
|
||||
pub agent_roles: BTreeMap<String, AgentRoleConfig>,
|
||||
pub search_tool: bool,
|
||||
pub request_permission_enabled: bool,
|
||||
@@ -86,6 +87,8 @@ impl ToolsConfig {
|
||||
features.enabled(Feature::DefaultModeRequestUserInput);
|
||||
let include_search_tool = features.enabled(Feature::Apps);
|
||||
let include_artifact_tools = features.enabled(Feature::Artifact);
|
||||
let include_image_gen_tool =
|
||||
features.enabled(Feature::ImageGeneration) && supports_image_generation(model_info);
|
||||
let include_agent_jobs = include_collab_tools && features.enabled(Feature::Sqlite);
|
||||
let request_permission_enabled = features.enabled(Feature::RequestPermissions);
|
||||
let shell_command_backend =
|
||||
@@ -135,6 +138,7 @@ impl ToolsConfig {
|
||||
allow_login_shell: true,
|
||||
apply_patch_tool_type,
|
||||
web_search_mode: *web_search_mode,
|
||||
image_gen_tool: include_image_gen_tool,
|
||||
agent_roles: BTreeMap::new(),
|
||||
search_tool: include_search_tool,
|
||||
request_permission_enabled,
|
||||
@@ -160,6 +164,10 @@ impl ToolsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_image_generation(model_info: &ModelInfo) -> bool {
|
||||
model_info.input_modalities.contains(&InputModality::Image)
|
||||
}
|
||||
|
||||
/// Generic JSON‑Schema subset needed for our tool definitions
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
@@ -1917,6 +1925,10 @@ pub(crate) fn build_specs(
|
||||
Some(WebSearchMode::Disabled) | None => {}
|
||||
}
|
||||
|
||||
if config.image_gen_tool {
|
||||
builder.push_spec(ToolSpec::ImageGeneration {});
|
||||
}
|
||||
|
||||
builder.push_spec_with_parallel_support(create_view_image_tool(), true);
|
||||
builder.register_handler("view_image", view_image_handler);
|
||||
|
||||
@@ -1995,6 +2007,7 @@ mod tests {
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
use crate::models_manager::model_info::with_config_overrides;
|
||||
use crate::tools::registry::ConfiguredToolSpec;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -2047,6 +2060,7 @@ mod tests {
|
||||
match tool {
|
||||
ToolSpec::Function(ResponsesApiTool { name, .. }) => name,
|
||||
ToolSpec::LocalShell {} => "local_shell",
|
||||
ToolSpec::ImageGeneration {} => "image_generation",
|
||||
ToolSpec::WebSearch { .. } => "web_search",
|
||||
ToolSpec::Freeform(FreeformTool { name, .. }) => name,
|
||||
}
|
||||
@@ -2125,7 +2139,10 @@ mod tests {
|
||||
ToolSpec::Function(ResponsesApiTool { parameters, .. }) => {
|
||||
strip_descriptions_schema(parameters);
|
||||
}
|
||||
ToolSpec::Freeform(_) | ToolSpec::LocalShell {} | ToolSpec::WebSearch { .. } => {}
|
||||
ToolSpec::Freeform(_)
|
||||
| ToolSpec::LocalShell {}
|
||||
| ToolSpec::ImageGeneration {}
|
||||
| ToolSpec::WebSearch { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2374,6 +2391,56 @@ mod tests {
|
||||
assert_contains_tool_names(&tools, &["js_repl", "js_repl_reset"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_generation_tools_require_feature_and_supported_model() {
|
||||
let config = test_config();
|
||||
let mut supported_model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests("gpt-5.2", &config);
|
||||
supported_model_info.slug = "custom/gpt-5.2-variant".to_string();
|
||||
let mut unsupported_model_info = supported_model_info.clone();
|
||||
unsupported_model_info.input_modalities = vec![InputModality::Text];
|
||||
let default_features = Features::with_defaults();
|
||||
let mut image_generation_features = default_features.clone();
|
||||
image_generation_features.enable(Feature::ImageGeneration);
|
||||
|
||||
let default_tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &supported_model_info,
|
||||
features: &default_features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
});
|
||||
let (default_tools, _) = build_specs(&default_tools_config, None, None, &[]).build();
|
||||
assert!(
|
||||
!default_tools
|
||||
.iter()
|
||||
.any(|tool| tool.spec.name() == "image_generation"),
|
||||
"image_generation should be disabled by default"
|
||||
);
|
||||
|
||||
let supported_tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &supported_model_info,
|
||||
features: &image_generation_features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
});
|
||||
let (supported_tools, _) = build_specs(&supported_tools_config, None, None, &[]).build();
|
||||
assert_contains_tool_names(&supported_tools, &["image_generation"]);
|
||||
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &unsupported_model_info,
|
||||
features: &image_generation_features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
||||
assert!(
|
||||
!tools
|
||||
.iter()
|
||||
.any(|tool| tool.spec.name() == "image_generation"),
|
||||
"image_generation should be disabled for unsupported models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_repl_freeform_grammar_blocks_common_non_js_prefixes() {
|
||||
let ToolSpec::Freeform(FreeformTool { format, .. }) = create_js_repl_tool() else {
|
||||
|
||||
Reference in New Issue
Block a user