mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(image): 接入 ChatGPT Web 生图反代
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user