feat(image): 接入 ChatGPT Web 生图反代

This commit is contained in:
Entropy.Xu
2026-05-06 02:29:17 +08:00
parent beee7a76d2
commit 4baee436ba
47 changed files with 3872 additions and 141 deletions

View File

@@ -94,12 +94,14 @@ pub use crate::request::specialized::{
resolve_sync_spec as resolve_gemini_files_sync_spec, LocalGeminiFilesSpec,
},
image::{
build_openai_image_provider_request_body, default_model_for_openai_image_operation,
is_openai_image_stream_request, normalize_openai_image_request,
openai_image_operation_from_path, resolve_requested_openai_image_model_for_request,
build_chatgpt_web_image_request_body, build_openai_image_provider_request_body,
default_model_for_openai_image_operation, is_openai_image_stream_request,
normalize_openai_image_request, openai_image_operation_from_path,
resolve_requested_openai_image_model_for_request,
resolve_stream_spec as resolve_local_image_stream_spec,
resolve_sync_spec as resolve_local_image_sync_spec, LocalOpenAiImageSpec,
NormalizedOpenAiImageRequest, OpenAiImageOperation, OpenAiImageResponseFormat,
resolve_sync_spec as resolve_local_image_sync_spec, ChatGptWebImageRequestError,
LocalOpenAiImageSpec, NormalizedOpenAiImageRequest, OpenAiImageOperation,
OpenAiImageResponseFormat,
},
video::{
resolve_sync_spec as resolve_local_video_sync_spec, LocalVideoCreateFamily,

View File

@@ -83,6 +83,171 @@ pub struct NormalizedOpenAiImageRequest {
user: Option<String>,
}
pub const CHATGPT_WEB_IMAGE_MAX_AREA: u64 = 1_500_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChatGptWebImageRequestError {
pub status_code: u16,
pub error_type: &'static str,
pub code: &'static str,
pub message: String,
}
impl ChatGptWebImageRequestError {
fn invalid_request(message: impl Into<String>) -> Self {
Self {
status_code: 400,
error_type: "invalid_request_error",
code: "chatgpt_web_image_unsupported",
message: message.into(),
}
}
pub fn to_error_json(&self) -> Value {
json!({
"error": {
"message": self.message,
"type": self.error_type,
"code": self.code,
"param": Value::Null,
}
})
}
}
#[derive(Debug, Clone, Default)]
struct ChatGptWebRawImageFields {
size: Option<String>,
resolution: Option<String>,
size_tier: Option<String>,
ratio: Option<String>,
aspect_ratio: Option<String>,
web_model: Option<String>,
}
#[derive(Debug, Clone)]
struct ChatGptWebResolvedSize {
size: String,
ratio: String,
best_effort: bool,
}
pub fn build_chatgpt_web_image_request_body(
parts: &http::request::Parts,
body_json: &Value,
body_base64: Option<&str>,
) -> Result<Value, ChatGptWebImageRequestError> {
let request = normalize_openai_image_request(parts, body_json, body_base64).ok_or_else(|| {
ChatGptWebImageRequestError::invalid_request(
"ChatGPT-Web image proxy only supports OpenAI image requests with prompt, n=1, supported image inputs, and supported output options",
)
})?;
if request.tool.contains_key("input_image_mask") {
return Err(ChatGptWebImageRequestError::invalid_request(
"ChatGPT-Web image proxy does not support mask inputs",
));
}
let raw_fields = resolve_chatgpt_web_raw_image_fields(parts, body_json, body_base64)?;
let resolved_size = resolve_chatgpt_web_size(&raw_fields)?;
let prompt = request
.prompt
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(match request.operation {
OpenAiImageOperation::Variation => "Create a faithful variation of the provided image.",
OpenAiImageOperation::Generate | OpenAiImageOperation::Edit => {
"Generate a high quality image."
}
});
let prompt = chatgpt_web_prompt_with_ratio(prompt, resolved_size.ratio.as_str());
let mut image_urls = Vec::new();
for image in &request.images {
let Some(object) = image.as_object() else {
continue;
};
if object
.get("file_id")
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
return Err(ChatGptWebImageRequestError::invalid_request(
"ChatGPT-Web image proxy does not support file_id image inputs; use URL, data URL, or multipart file inputs",
));
}
if let Some(image_url) = object
.get("image_url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
image_urls.push(Value::String(image_url.to_string()));
}
}
let requested_model = request
.requested_model
.clone()
.unwrap_or_else(|| default_model_for_openai_image_operation(request.operation).to_string());
let mut body = Map::new();
body.insert(
"operation".to_string(),
Value::String(request.operation.as_str().to_string()),
);
body.insert("model".to_string(), Value::String(requested_model));
body.insert(
"web_model".to_string(),
Value::String(
raw_fields
.web_model
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("gpt-5-5-thinking")
.to_string(),
),
);
body.insert("prompt".to_string(), Value::String(prompt));
body.insert("size".to_string(), Value::String(resolved_size.size));
body.insert("ratio".to_string(), Value::String(resolved_size.ratio));
body.insert(
"size_best_effort".to_string(),
Value::Bool(resolved_size.best_effort),
);
body.insert("images".to_string(), Value::Array(image_urls));
body.insert("count".to_string(), Value::Number(Number::from(1)));
if let Some(user) = request.user.as_ref() {
body.insert("user".to_string(), Value::String(user.clone()));
}
if let Some(output_format) = request
.summary_json
.get("output_format")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
body.insert(
"output_format".to_string(),
Value::String(output_format.to_string()),
);
}
if let Some(response_format) = request
.summary_json
.get("response_format")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
body.insert(
"response_format".to_string(),
Value::String(response_format.to_string()),
);
}
Ok(Value::Object(body))
}
pub fn is_openai_image_stream_request(
parts: &http::request::Parts,
body_json: &Value,
@@ -117,6 +282,170 @@ pub fn openai_image_operation_from_path(path: &str) -> Option<OpenAiImageOperati
}
}
fn resolve_chatgpt_web_raw_image_fields(
parts: &http::request::Parts,
body_json: &Value,
body_base64: Option<&str>,
) -> Result<ChatGptWebRawImageFields, ChatGptWebImageRequestError> {
if let Some(body_base64) = body_base64 {
let fields = parse_multipart_fields_from_base64(parts, body_base64).ok_or_else(|| {
ChatGptWebImageRequestError::invalid_request(
"ChatGPT-Web image proxy could not parse multipart image request",
)
})?;
return Ok(ChatGptWebRawImageFields {
size: find_multipart_text_field(&fields, "size"),
resolution: find_multipart_text_field(&fields, "resolution"),
size_tier: find_multipart_text_field(&fields, "size_tier"),
ratio: find_multipart_text_field(&fields, "ratio"),
aspect_ratio: find_multipart_text_field(&fields, "aspect_ratio"),
web_model: find_multipart_text_field(&fields, "web_model"),
});
}
let Some(object) = body_json.as_object() else {
return Ok(ChatGptWebRawImageFields::default());
};
Ok(ChatGptWebRawImageFields {
size: json_text_field(object, "size"),
resolution: json_text_field(object, "resolution"),
size_tier: json_text_field(object, "size_tier"),
ratio: json_text_field(object, "ratio"),
aspect_ratio: json_text_field(object, "aspect_ratio"),
web_model: json_text_field(object, "web_model"),
})
}
fn json_text_field(object: &Map<String, Value>, key: &str) -> Option<String> {
object
.get(key)
.and_then(|value| {
value
.as_str()
.map(ToOwned::to_owned)
.or_else(|| value.as_u64().map(|number| number.to_string()))
.or_else(|| value.as_i64().map(|number| number.to_string()))
})
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn resolve_chatgpt_web_size(
fields: &ChatGptWebRawImageFields,
) -> Result<ChatGptWebResolvedSize, ChatGptWebImageRequestError> {
let tier = fields
.resolution
.as_deref()
.or(fields.size_tier.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(tier) = tier {
let normalized = tier.to_ascii_uppercase();
if normalized != "1K" && normalized != "1" {
return Err(unsupported_chatgpt_web_resolution_error());
}
}
let fallback_ratio = fields
.ratio
.as_deref()
.or(fields.aspect_ratio.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("1:1");
let explicit_size = fields
.size
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let size = explicit_size
.map(ToOwned::to_owned)
.unwrap_or_else(|| chatgpt_web_1k_size_for_ratio(fallback_ratio).to_string());
let (width, height) = parse_image_size_or_default(size.as_str());
if width.saturating_mul(height) > CHATGPT_WEB_IMAGE_MAX_AREA {
return Err(unsupported_chatgpt_web_resolution_error());
}
let ratio = chatgpt_web_ratio_from_size(size.as_str(), fallback_ratio).to_string();
let best_effort = explicit_size.is_some_and(|_| !chatgpt_web_is_exact_1k_size(size.as_str()));
Ok(ChatGptWebResolvedSize {
size,
ratio,
best_effort,
})
}
fn unsupported_chatgpt_web_resolution_error() -> ChatGptWebImageRequestError {
ChatGptWebImageRequestError::invalid_request(
"ChatGPT-Web image proxy does not support the requested resolution; use resolution=1K, size_tier=1K, or a size area <= 1,500,000 pixels",
)
}
fn chatgpt_web_1k_size_for_ratio(ratio: &str) -> &'static str {
match ratio.trim() {
"3:2" => "1216x832",
"2:3" => "832x1216",
"4:3" => "1152x864",
"3:4" => "864x1152",
"5:4" => "1120x896",
"4:5" => "896x1120",
"16:9" => "1344x768",
"9:16" => "768x1344",
"21:9" => "1536x640",
_ => "1024x1024",
}
}
fn chatgpt_web_ratio_from_size<'a>(size: &str, fallback: &'a str) -> &'a str {
match size.trim() {
"1024x1024" | "1248x1248" | "2480x2480" | "512x512" => "1:1",
"1216x832" | "1536x1024" | "3056x2032" => "3:2",
"832x1216" | "1024x1536" | "2032x3056" => "2:3",
"1152x864" | "1440x1088" | "2880x2160" => "4:3",
"864x1152" | "1088x1440" | "2160x2880" => "3:4",
"1120x896" | "1392x1120" | "2784x2224" => "5:4",
"896x1120" | "1120x1392" | "2224x2784" => "4:5",
"1344x768" | "1664x928" | "3312x1872" => "16:9",
"768x1344" | "928x1664" | "1872x3312" => "9:16",
"1536x640" | "1904x816" | "3808x1632" => "21:9",
_ => fallback.trim(),
}
}
fn chatgpt_web_is_exact_1k_size(size: &str) -> bool {
matches!(
size.trim(),
"1024x1024"
| "1216x832"
| "832x1216"
| "1152x864"
| "864x1152"
| "1120x896"
| "896x1120"
| "1344x768"
| "768x1344"
| "1536x640"
)
}
fn parse_image_size_or_default(size: &str) -> (u64, u64) {
let Some((width, height)) = size.trim().split_once('x') else {
return (1024, 1024);
};
let width = width.trim().parse::<u64>().ok().filter(|value| *value > 0);
let height = height.trim().parse::<u64>().ok().filter(|value| *value > 0);
(width.unwrap_or(1024), height.unwrap_or(1024))
}
fn chatgpt_web_prompt_with_ratio(prompt: &str, ratio: &str) -> String {
let prompt = prompt.trim();
let ratio = ratio.trim();
if ratio.is_empty() || ratio == "1:1" {
return prompt.to_string();
}
format!("{prompt}\n\nSet the image aspect ratio to {ratio}.")
}
pub fn resolve_requested_openai_image_model_for_request(
parts: &http::request::Parts,
body_json: &Value,
@@ -804,9 +1133,9 @@ mod tests {
use serde_json::json;
use super::{
build_openai_image_provider_request_body, is_openai_image_stream_request,
normalize_openai_image_request, resolve_stream_spec, resolve_sync_spec,
OpenAiImageOperation,
build_chatgpt_web_image_request_body, build_openai_image_provider_request_body,
is_openai_image_stream_request, normalize_openai_image_request, resolve_stream_spec,
resolve_sync_spec, OpenAiImageOperation,
};
use crate::request::standard::{
apply_codex_openai_responses_special_body_edits, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
@@ -1124,4 +1453,100 @@ mod tests {
Some("image_generation")
);
}
#[test]
fn chatgpt_web_accepts_1k_tier_and_1024_size() {
let parts = request_parts("/v1/images/generations", Some("application/json"));
let by_tier = build_chatgpt_web_image_request_body(
&parts,
&json!({
"model": "gpt-image-2",
"prompt": "draw",
"resolution": "1K",
"aspect_ratio": "16:9"
}),
None,
)
.expect("1K should pass");
assert_eq!(by_tier["size"], "1344x768");
assert_eq!(by_tier["ratio"], "16:9");
assert_eq!(by_tier["size_best_effort"], false);
let by_size = build_chatgpt_web_image_request_body(
&parts,
&json!({
"model": "gpt-image-2",
"prompt": "draw",
"size": "1024x1024"
}),
None,
)
.expect("1024x1024 should pass");
assert_eq!(by_size["size"], "1024x1024");
}
#[test]
fn chatgpt_web_rejects_oversized_resolution_or_size() {
let parts = request_parts("/v1/images/generations", Some("application/json"));
for body in [
json!({"prompt":"draw","resolution":"2K"}),
json!({"prompt":"draw","size_tier":"4K"}),
json!({"prompt":"draw","size":"2048x2048"}),
] {
let err = build_chatgpt_web_image_request_body(&parts, &body, None)
.expect_err("oversized request should fail");
assert_eq!(err.status_code, 400);
assert_eq!(err.error_type, "invalid_request_error");
assert!(err.message.contains("ChatGPT-Web"));
assert!(err.message.contains("resolution"));
}
}
#[test]
fn chatgpt_web_accepts_smaller_size_as_best_effort() {
let parts = request_parts("/v1/images/generations", Some("application/json"));
let body = build_chatgpt_web_image_request_body(
&parts,
&json!({
"model": "gpt-image-2",
"prompt": "draw",
"size": "512x512"
}),
None,
)
.expect("smaller size should pass");
assert_eq!(body["size"], "512x512");
assert_eq!(body["ratio"], "1:1");
assert_eq!(body["size_best_effort"], true);
}
#[test]
fn chatgpt_web_rejects_file_id_and_mask_inputs() {
let edit_parts = request_parts("/v1/images/edits", Some("application/json"));
let file_id_err = build_chatgpt_web_image_request_body(
&edit_parts,
&json!({
"prompt": "edit",
"image": {"file_id": "file_123"}
}),
None,
)
.expect_err("file_id should not be supported");
assert!(file_id_err.message.contains("file_id"));
let mask_err = build_chatgpt_web_image_request_body(
&edit_parts,
&json!({
"prompt": "edit",
"image": "data:image/png;base64,aW1hZ2U=",
"mask": "data:image/png;base64,bWFzaw=="
}),
None,
)
.expect_err("mask should not be supported");
assert!(mask_err.message.contains("mask"));
}
}