feat(codex-image): 封装 GPT Image 2 图片接口并收紧错误处理

- 新增 openai:image 路由、planner 与 finalize,内部通过 Codex responses image_generation tool 执行生图

- 补充 Codex OAuth/header 兼容、图片 success report 本地处理与相关前后端/集成测试

- 禁止 chat/completions 使用 gpt-image-2,图片接口限制 n=1,并移除 Provider 模型页的图片能力开关
This commit is contained in:
Entropy.Xu
2026-04-22 21:09:29 +08:00
committed by fawney19
parent 4374f53315
commit f55f22d2e8
55 changed files with 2676 additions and 52 deletions

View File

@@ -48,10 +48,11 @@ pub use crate::contracts::{
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND, OPENAI_CLI_SYNC_PLAN_KIND,
OPENAI_CLI_SYNC_SUCCESS_REPORT_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
OPENAI_COMPACT_SYNC_ERROR_REPORT_KIND, OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND,
OPENAI_COMPACT_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND,
OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND,
OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
OPENAI_COMPACT_SYNC_PLAN_KIND, OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND,
OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND,
OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND, OPENAI_VIDEO_CONTENT_PLAN_KIND,
OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
};
pub use crate::conversion::request::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
@@ -134,6 +135,7 @@ pub use crate::planner::specialized::{
resolve_stream_spec as resolve_gemini_files_stream_spec,
resolve_sync_spec as resolve_gemini_files_sync_spec, LocalGeminiFilesSpec,
},
image::{resolve_sync_spec as resolve_local_image_sync_spec, LocalOpenAiImageSpec},
video::{
resolve_sync_spec as resolve_local_video_sync_spec, LocalVideoCreateFamily,
LocalVideoCreateSpec,

View File

@@ -23,7 +23,7 @@ pub use plan_kinds::{
GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
OPENAI_COMPACT_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
OPENAI_COMPACT_SYNC_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
};
@@ -43,5 +43,6 @@ pub use report_kinds::{
OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND, OPENAI_CLI_STREAM_SUCCESS_REPORT_KIND,
OPENAI_CLI_SYNC_ERROR_REPORT_KIND, OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND,
OPENAI_CLI_SYNC_SUCCESS_REPORT_KIND, OPENAI_COMPACT_SYNC_ERROR_REPORT_KIND,
OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND, OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND,
OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND, OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND,
OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND, OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND,
};

View File

@@ -3,6 +3,7 @@ pub const GEMINI_FILES_UPLOAD_PLAN_KIND: &str = "gemini_files_upload";
pub const GEMINI_FILES_LIST_PLAN_KIND: &str = "gemini_files_list";
pub const GEMINI_FILES_DELETE_PLAN_KIND: &str = "gemini_files_delete";
pub const GEMINI_FILES_DOWNLOAD_PLAN_KIND: &str = "gemini_files_download";
pub const OPENAI_IMAGE_SYNC_PLAN_KIND: &str = "openai_image_sync";
pub const OPENAI_VIDEO_CONTENT_PLAN_KIND: &str = "openai_video_content";
pub const OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND: &str = "openai_video_cancel_sync";
pub const OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND: &str = "openai_video_remix_sync";

View File

@@ -1,7 +1,7 @@
use crate::contracts::{
CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_SYNC_PLAN_KIND, GEMINI_CHAT_SYNC_PLAN_KIND,
GEMINI_CLI_SYNC_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND,
OPENAI_COMPACT_SYNC_PLAN_KIND,
OPENAI_COMPACT_SYNC_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND,
};
pub const OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND: &str = "openai_chat_sync_finalize";
@@ -9,6 +9,7 @@ pub const CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND: &str = "claude_chat_sync_finali
pub const GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND: &str = "gemini_chat_sync_finalize";
pub const OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND: &str = "openai_cli_sync_finalize";
pub const OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND: &str = "openai_compact_sync_finalize";
pub const OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND: &str = "openai_image_sync_finalize";
pub const CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND: &str = "claude_cli_sync_finalize";
pub const GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND: &str = "gemini_cli_sync_finalize";
pub const OPENAI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND: &str = "openai_video_create_sync_finalize";
@@ -18,6 +19,7 @@ pub const OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "openai_chat_sync_success
pub const CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "claude_chat_sync_success";
pub const GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND: &str = "gemini_chat_sync_success";
pub const OPENAI_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "openai_cli_sync_success";
pub const OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND: &str = "openai_image_sync_success";
pub const CLAUDE_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "claude_cli_sync_success";
pub const GEMINI_CLI_SYNC_SUCCESS_REPORT_KIND: &str = "gemini_cli_sync_success";
@@ -43,6 +45,7 @@ pub fn implicit_sync_finalize_report_kind(plan_kind: &str) -> Option<&'static st
GEMINI_CHAT_SYNC_PLAN_KIND => Some(GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND),
OPENAI_CLI_SYNC_PLAN_KIND => Some(OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND),
OPENAI_COMPACT_SYNC_PLAN_KIND => Some(OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND),
OPENAI_IMAGE_SYNC_PLAN_KIND => Some(OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND),
CLAUDE_CLI_SYNC_PLAN_KIND => Some(CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND),
GEMINI_CLI_SYNC_PLAN_KIND => Some(GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND),
_ => None,
@@ -56,6 +59,7 @@ pub fn core_error_default_client_api_format(report_kind: &str) -> Option<&'stati
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some("gemini:chat"),
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND => Some("openai:cli"),
OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => Some("openai:compact"),
OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND => Some("openai:image"),
CLAUDE_CLI_SYNC_FINALIZE_REPORT_KIND => Some("claude:cli"),
GEMINI_CLI_SYNC_FINALIZE_REPORT_KIND => Some("gemini:cli"),
_ => None,
@@ -80,6 +84,7 @@ pub fn core_success_background_report_kind(report_kind: &str) -> Option<&'static
OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND),
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND),
GEMINI_CHAT_SYNC_FINALIZE_REPORT_KIND => Some(GEMINI_CHAT_SYNC_SUCCESS_REPORT_KIND),
OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND => Some(OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND),
OPENAI_CLI_SYNC_FINALIZE_REPORT_KIND | OPENAI_COMPACT_SYNC_FINALIZE_REPORT_KIND => {
Some(OPENAI_CLI_SYNC_SUCCESS_REPORT_KIND)
}

View File

@@ -8,7 +8,7 @@ use crate::contracts::{
GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND, OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
OPENAI_CLI_STREAM_PLAN_KIND, OPENAI_CLI_SYNC_PLAN_KIND, OPENAI_COMPACT_STREAM_PLAN_KIND,
OPENAI_COMPACT_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
OPENAI_COMPACT_SYNC_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_VIDEO_CANCEL_SYNC_PLAN_KIND,
OPENAI_VIDEO_CONTENT_PLAN_KIND, OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND,
OPENAI_VIDEO_DELETE_SYNC_PLAN_KIND, OPENAI_VIDEO_REMIX_SYNC_PLAN_KIND,
};
@@ -168,6 +168,14 @@ pub fn resolve_execution_runtime_sync_plan_kind(
return Some(OPENAI_CHAT_SYNC_PLAN_KIND);
}
if route_family == Some("openai")
&& route_kind == Some("image")
&& *method == Method::POST
&& matches!(path, "/v1/images/generations" | "/v1/images/edits")
{
return Some(OPENAI_IMAGE_SYNC_PLAN_KIND);
}
if route_family == Some("openai")
&& route_kind == Some("cli")
&& *method == Method::POST
@@ -265,6 +273,7 @@ pub fn supports_sync_scheduler_decision_kind(plan_kind: &str) -> bool {
matches!(
plan_kind,
OPENAI_CHAT_SYNC_PLAN_KIND
| OPENAI_IMAGE_SYNC_PLAN_KIND
| OPENAI_CLI_SYNC_PLAN_KIND
| OPENAI_COMPACT_SYNC_PLAN_KIND
| CLAUDE_CHAT_SYNC_PLAN_KIND
@@ -308,7 +317,9 @@ mod tests {
resolve_execution_runtime_sync_plan_kind, supports_stream_scheduler_decision_kind,
supports_sync_scheduler_decision_kind,
};
use crate::contracts::{OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND};
use crate::contracts::{
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND,
};
#[test]
fn resolves_openai_chat_plan_kinds() {
@@ -353,4 +364,31 @@ mod tests {
OPENAI_CHAT_STREAM_PLAN_KIND
));
}
#[test]
fn resolves_openai_image_sync_plan_kind() {
assert_eq!(
resolve_execution_runtime_sync_plan_kind(
Some("ai_public"),
Some("openai"),
Some("image"),
&Method::POST,
"/v1/images/generations",
),
Some(OPENAI_IMAGE_SYNC_PLAN_KIND)
);
assert_eq!(
resolve_execution_runtime_sync_plan_kind(
Some("ai_public"),
Some("openai"),
Some("image"),
&Method::POST,
"/v1/images/edits",
),
Some(OPENAI_IMAGE_SYNC_PLAN_KIND)
);
assert!(supports_sync_scheduler_decision_kind(
OPENAI_IMAGE_SYNC_PLAN_KIND
));
}
}

View File

@@ -0,0 +1,31 @@
use crate::contracts::OPENAI_IMAGE_SYNC_PLAN_KIND;
#[derive(Debug, Clone, Copy)]
pub struct LocalOpenAiImageSpec {
pub api_format: &'static str,
pub decision_kind: &'static str,
pub report_kind: &'static str,
}
pub fn resolve_sync_spec(plan_kind: &str) -> Option<LocalOpenAiImageSpec> {
match plan_kind {
OPENAI_IMAGE_SYNC_PLAN_KIND => Some(LocalOpenAiImageSpec {
api_format: "openai:image",
decision_kind: OPENAI_IMAGE_SYNC_PLAN_KIND,
report_kind: "openai_image_sync_finalize",
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::resolve_sync_spec;
#[test]
fn resolves_openai_image_sync_spec() {
let spec = resolve_sync_spec("openai_image_sync").expect("spec");
assert_eq!(spec.api_format, "openai:image");
assert_eq!(spec.report_kind, "openai_image_sync_finalize");
}
}

View File

@@ -1,2 +1,3 @@
pub mod files;
pub mod image;
pub mod video;

View File

@@ -8,6 +8,10 @@ use sha2::Sha256;
use uuid::Uuid;
const CODEX_PROMPT_CACHE_NAMESPACE_VERSION: &str = "v3";
const CODEX_DEFAULT_USER_AGENT: &str =
"codex-tui/0.122.0 (Aether; x86_64) vscode/3.0.12 (codex-tui; 0.122.0)";
const CODEX_DEFAULT_VERSION: &str = "0.122.0";
const CODEX_DEFAULT_ORIGINATOR: &str = "codex_cli_rs";
const UUID_NAMESPACE_OID_BYTES: [u8; 16] = [
0x6b, 0xa7, 0xb8, 0x12, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8,
];
@@ -16,7 +20,7 @@ fn is_codex_openai_cli_request(provider_type: &str, provider_api_format: &str) -
provider_type.trim().eq_ignore_ascii_case("codex")
&& matches!(
provider_api_format.trim().to_ascii_lowercase().as_str(),
"openai:cli" | "openai:compact"
"openai:cli" | "openai:compact" | "openai:image"
)
}
@@ -239,6 +243,30 @@ pub fn apply_codex_openai_cli_special_headers(
}
}
if !header_map_has_non_empty_value(original_headers, "user-agent")
&& !btree_map_has_non_empty_value(provider_request_headers, "user-agent")
{
provider_request_headers.insert(
"user-agent".to_string(),
CODEX_DEFAULT_USER_AGENT.to_string(),
);
}
if !header_map_has_non_empty_value(original_headers, "version")
&& !btree_map_has_non_empty_value(provider_request_headers, "version")
{
provider_request_headers.insert("version".to_string(), CODEX_DEFAULT_VERSION.to_string());
}
if !header_map_has_non_empty_value(original_headers, "originator")
&& !btree_map_has_non_empty_value(provider_request_headers, "originator")
{
provider_request_headers.insert(
"originator".to_string(),
CODEX_DEFAULT_ORIGINATOR.to_string(),
);
}
let short_session_id = prompt_cache_key.and_then(build_short_codex_header_id);
if !header_map_has_non_empty_value(original_headers, "session_id")