Merge pull request #635 from zhefox/main

fix(gateway): 支持 OpenAI 图片编辑端点请求
This commit is contained in:
fawney19
2026-06-16 22:31:52 +08:00
committed by GitHub
12 changed files with 399 additions and 6 deletions
@@ -213,6 +213,11 @@ pub(super) async fn resolve_local_openai_image_candidate_payload_parts(
headers: effective_headers,
auth_header: &auth_header,
auth_value: &auth_value,
accept: if is_codex || is_chatgpt_web {
"text/event-stream"
} else {
"application/json"
},
header_rules: transport.endpoint.header_rules.as_ref(),
provider_request_body: &provider_request_body,
original_request_body: body_json,
@@ -1230,6 +1230,7 @@ async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts(
headers: effective_headers,
auth_header: &prepared_candidate.auth_header,
auth_value: &prepared_candidate.auth_value,
accept: "text/event-stream",
header_rules: transport.endpoint.header_rules.as_ref(),
provider_request_body: &converted.body_json,
original_request_body: body_json,
@@ -1056,6 +1056,7 @@ async fn resolve_openai_chat_to_openai_image_payload_parts(
headers: &parts.headers,
auth_header: &prepared_candidate.auth_header,
auth_value: &prepared_candidate.auth_value,
accept: "text/event-stream",
header_rules: transport.endpoint.header_rules.as_ref(),
provider_request_body: &provider_request_body,
original_request_body: body_json,
@@ -1123,6 +1123,7 @@ async fn resolve_openai_responses_to_openai_image_payload_parts(
headers: &parts.headers,
auth_header: &prepared_candidate.auth_header,
auth_value: &prepared_candidate.auth_value,
accept: "text/event-stream",
header_rules: transport.endpoint.header_rules.as_ref(),
provider_request_body: &provider_request_body,
original_request_body: body_json,
@@ -187,6 +187,7 @@ async fn execute_chatgpt_web_image(
request_id = %plan.request_id,
candidate_id = ?plan.candidate_id,
base_url = %base_url,
operation = %request.operation,
image_count = request.images.len(),
size = %request.size,
ratio = %request.ratio,
@@ -311,6 +312,7 @@ async fn execute_chatgpt_web_image(
#[derive(Debug, Clone)]
struct ChatGptWebImageRequest {
operation: String,
model: String,
web_model: String,
prompt: String,
@@ -342,6 +344,7 @@ impl ChatGptWebImageRequest {
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
Ok(Self {
operation: chatgpt_web_image_operation(body.get("operation")),
model: text("model").unwrap_or_else(|| "gpt-image-2".to_string()),
web_model: text("web_model").unwrap_or_else(|| "gpt-5-5-thinking".to_string()),
prompt: text("prompt").unwrap_or_else(|| "Generate a high quality image.".to_string()),
@@ -2434,10 +2437,23 @@ fn json_u64(value: Option<&Value>) -> Option<u64> {
})
}
fn chatgpt_web_image_operation(value: Option<&Value>) -> String {
value
.and_then(Value::as_str)
.map(str::trim)
.map(str::to_ascii_lowercase)
.filter(|value| matches!(value.as_str(), "generate" | "edit"))
.unwrap_or_else(|| "generate".to_string())
}
fn build_failed_sse(request: &ChatGptWebImageRequest, failure: &Value) -> String {
let failed = if failure.get("type").and_then(Value::as_str) == Some("response.failed") {
failure.clone()
} else {
let operation = match request.operation.as_str() {
"edit" => "edit",
_ => "generation",
};
json!({
"type": "response.failed",
"response": {
@@ -2445,7 +2461,7 @@ fn build_failed_sse(request: &ChatGptWebImageRequest, failure: &Value) -> String
"model": request.model,
"error": failure.get("error").cloned().unwrap_or_else(|| json!({
"code": "chatgpt_web_image_failed",
"message": "ChatGPT-Web image generation failed"
"message": format!("ChatGPT-Web image {operation} failed")
}))
}
})
@@ -2641,7 +2657,7 @@ fn chatgpt_web_image_request_context(plan: &ExecutionPlan) -> Option<Value> {
let mut image_request = Map::new();
image_request.insert(
"operation".to_string(),
Value::String("generate".to_string()),
Value::String(chatgpt_web_image_operation(body.get("operation"))),
);
for key in [
"model",
@@ -3223,6 +3239,7 @@ mod tests {
#[test]
fn chatgpt_web_success_sse_includes_estimated_image_usage() {
let request = ChatGptWebImageRequest {
operation: "generate".to_string(),
model: "gpt-image-2".to_string(),
web_model: "gpt-5-5-thinking".to_string(),
prompt: "draw a test image".to_string(),
@@ -3276,6 +3293,7 @@ mod tests {
#[test]
fn chatgpt_web_success_sse_uses_image_dimensions_not_output_text() {
let request = ChatGptWebImageRequest {
operation: "generate".to_string(),
model: "gpt-image-2".to_string(),
web_model: "gpt-5-5-thinking".to_string(),
prompt: "draw a test image".to_string(),
@@ -3334,6 +3352,32 @@ mod tests {
);
}
#[test]
fn chatgpt_web_image_request_context_preserves_edit_operation() {
let plan = sample_plan(
CHATGPT_WEB_DEFAULT_BASE_URL,
json!({
"operation": "edit",
"model": "gpt-image-2",
"web_model": "gpt-5-5-thinking",
"prompt": "adjust this image",
"size": "512x512",
"ratio": "1:1",
"images": ["data:image/png;base64,aW1hZ2U="],
"count": 1,
"output_format": "png"
}),
true,
);
let context = chatgpt_web_stream_observer_context(&plan, None);
assert_eq!(context["image_request"]["operation"], json!("edit"));
assert_eq!(context["image_request"]["model"], json!("gpt-image-2"));
assert_eq!(context["image_request"]["size"], json!("512x512"));
assert_eq!(context["provider_api_format"], json!("openai:image"));
}
#[test]
fn chatgpt_web_image_quota_refresh_plan_uses_conversation_init() {
let plan = sample_plan(
@@ -2183,6 +2183,11 @@ async fn provider_query_execute_openai_image_test_candidate(
headers: &parts.headers,
auth_header: &auth_header,
auth_value: &auth_value,
accept: if is_codex || is_chatgpt_web {
"text/event-stream"
} else {
"application/json"
},
header_rules: transport.endpoint.header_rules.as_ref(),
provider_request_body: &provider_request_body,
original_request_body: &request_body,
@@ -1556,6 +1556,7 @@ impl OpenAIResponsesProviderState {
});
self.finished = true;
}
"keepalive" => {}
event_type if openai_responses_stream_event_is_known_noop(event_type) => {
self.ensure_started(report_context, &mut out);
}
@@ -1577,6 +1577,47 @@ mod tests {
assert!(provider_request_body.get("tools").is_none());
}
#[test]
fn build_image_api_provider_edit_request_body_keeps_images_edit_shape() {
let parts = request_parts("/v1/images/edits", Some("application/json"));
let request = normalize_openai_image_request(
&parts,
&json!({
"model": "gpt-image-2",
"prompt": "replace the background",
"image": "data:image/png;base64,aW1hZ2U=",
"mask": "data:image/png;base64,bWFzaw==",
"input_fidelity": "high",
"output_format": "png",
"response_format": "url",
"user": "user-123"
}),
None,
)
.expect("edit request should normalize");
let provider_request_body =
build_openai_image_api_provider_request_body(&request, Some("mapped-edit-model"));
assert_eq!(provider_request_body["model"], "mapped-edit-model");
assert_eq!(provider_request_body["prompt"], "replace the background");
assert_eq!(provider_request_body["input_fidelity"], "high");
assert_eq!(provider_request_body["output_format"], "png");
assert_eq!(provider_request_body["response_format"], "url");
assert_eq!(provider_request_body["user"], "user-123");
assert_eq!(
provider_request_body["image"]["image_url"],
"data:image/png;base64,aW1hZ2U="
);
assert_eq!(
provider_request_body["mask"]["image_url"],
"data:image/png;base64,bWFzaw=="
);
assert!(provider_request_body.get("input").is_none());
assert!(provider_request_body.get("tools").is_none());
assert!(provider_request_body.get("action").is_none());
}
#[test]
fn chatgpt_web_accepts_1k_tier_and_1024_size() {
let parts = request_parts("/v1/images/generations", Some("application/json"));
@@ -1630,6 +1671,40 @@ mod tests {
assert_eq!(body["output_format"], "png");
}
#[test]
fn chatgpt_web_accepts_openai_image_edit_requests() {
let parts = request_parts("/v1/images/edits", Some("application/json"));
let body = build_chatgpt_web_image_request_body(
&parts,
&json!({
"model": "gpt-image-2",
"prompt": "make the sky brighter",
"image": {
"b64_json": "aW1hZ2U=",
"mime_type": "image/png"
},
"size": "1024x1024",
"quality": "high",
"response_format": "b64_json",
"output_format": "png",
"user": "user-123"
}),
None,
)
.expect("ChatGPT-Web edit request should pass");
assert_eq!(body["operation"], "edit");
assert_eq!(body["model"], "gpt-image-2");
assert_eq!(body["prompt"], "make the sky brighter");
assert_eq!(body["size"], "1024x1024");
assert_eq!(body["quality"], "high");
assert_eq!(body["response_format"], "b64_json");
assert_eq!(body["output_format"], "png");
assert_eq!(body["user"], "user-123");
assert_eq!(body["count"], 1);
assert_eq!(body["images"], json!(["data:image/png;base64,aW1hZ2U="]));
}
#[test]
fn chatgpt_web_rejects_oversized_resolution_or_size() {
let parts = request_parts("/v1/images/generations", Some("application/json"));
@@ -497,7 +497,7 @@ fn ensure_json_object_response_input_mentions_json(
0,
json!({
"type": "message",
"role": "system",
"role": "developer",
"content": [{
"type": "input_text",
"text": "Respond with JSON.",
@@ -1056,7 +1056,7 @@ mod tests {
}
#[test]
fn json_object_response_injects_json_hint_into_input_when_only_instructions_have_it() {
fn json_object_response_injects_json_hint_as_developer_input_when_only_instructions_have_it() {
let request = CanonicalRequest {
model: "gpt-5.5".to_string(),
system: Some("Please answer in JSON.".to_string()),
@@ -1082,7 +1082,7 @@ mod tests {
assert_eq!(body["instructions"], json!("Please answer in JSON."));
let input = body["input"].as_array().expect("input");
assert_eq!(input.len(), 2);
assert_eq!(input[0]["role"], json!("system"));
assert_eq!(input[0]["role"], json!("developer"));
assert!(input[0]["content"][0]["text"]
.as_str()
.expect("hint text")
@@ -715,6 +715,107 @@ mod tests {
assert!(!sse.contains("HelloHello"));
}
#[test]
fn ignores_openai_responses_keepalive_events_for_chat_clients() {
let report_context = report_context("openai:responses", "openai:chat");
let mut matrix = StreamingStandardFormatMatrix::default();
let mut output = Vec::new();
let keepalive = matrix
.transform_line(
&report_context,
data_line(json!({
"type": "keepalive",
"sequence_number": 1,
})),
)
.expect("keepalive should be ignored");
assert!(keepalive.is_empty());
for line in [
data_line(json!({
"type": "response.output_text.delta",
"response_id": "resp_keepalive_123",
"output_index": 0,
"content_index": 0,
"delta": "pong",
})),
data_line(json!({
"type": "response.completed",
"response": {
"id": "resp_keepalive_123",
"object": "response",
"model": "gpt-5.4",
"status": "completed",
"output": [],
},
})),
] {
output.extend(
matrix
.transform_line(&report_context, line)
.expect("keepalive and text should convert"),
);
}
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(!sse.contains("Unsupported provider stream event"), "{sse}");
assert!(!sse.contains("unsupported_stream_event"), "{sse}");
assert!(sse.contains("pong"), "{sse}");
assert!(sse.contains("chat.completion.chunk"), "{sse}");
}
#[test]
fn ignores_openai_responses_keepalive_events_for_responses_clients() {
let mut report_context = report_context("openai:chat", "openai:responses");
report_context["provider_stream_event_api_format"] = json!("openai:responses");
let mut matrix = StreamingStandardFormatMatrix::default();
let mut output = Vec::new();
let keepalive = matrix
.transform_line(
&report_context,
data_line(json!({
"type": "keepalive",
"sequence_number": 1,
})),
)
.expect("keepalive should be ignored");
assert!(keepalive.is_empty());
for line in [
data_line(json!({
"type": "response.output_text.delta",
"response_id": "resp_keepalive_456",
"output_index": 0,
"content_index": 0,
"delta": "pong",
})),
data_line(json!({
"type": "response.completed",
"response": {
"id": "resp_keepalive_456",
"object": "response",
"model": "gpt-5.4",
"status": "completed",
"output": [],
},
})),
] {
output.extend(
matrix
.transform_line(&report_context, line)
.expect("keepalive and text should convert"),
);
}
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(!sse.contains("Unsupported provider stream event"), "{sse}");
assert!(!sse.contains("unsupported_stream_event"), "{sse}");
assert!(sse.contains("pong"), "{sse}");
assert!(sse.contains("event: response.output_text.delta"), "{sse}");
}
#[test]
fn transforms_provider_errors_to_claude_error_events() {
let cases = [
@@ -14,6 +14,7 @@ pub struct ProviderOpenAiImageHeadersInput<'a> {
pub headers: &'a http::HeaderMap,
pub auth_header: &'a str,
pub auth_value: &'a str,
pub accept: &'a str,
pub header_rules: Option<&'a Value>,
pub provider_request_body: &'a Value,
pub original_request_body: &'a Value,
@@ -80,7 +81,7 @@ pub fn build_openai_image_headers(
&BTreeMap::new(),
);
provider_request_headers.insert("content-type".to_string(), "application/json".to_string());
provider_request_headers.insert("accept".to_string(), "text/event-stream".to_string());
provider_request_headers.insert("accept".to_string(), input.accept.to_string());
if !apply_local_header_rules_with_request_headers(
&mut provider_request_headers,
input.header_rules,
@@ -189,6 +190,17 @@ mod tests {
assert_eq!(url, "https://api.openai.com/v1/images/generations?trace=1");
}
#[test]
fn standard_openai_image_url_preserves_edit_surface() {
let mut transport = sample_transport();
transport.provider.provider_type = "openai".to_string();
let url =
build_openai_image_upstream_url(&transport, Some("/v1/images/edits"), Some("trace=1"));
assert_eq!(url, "https://api.openai.com/v1/images/edits?trace=1");
}
#[test]
fn chatgpt_web_is_supported_by_dedicated_openai_image_transport_policy() {
let mut transport = sample_transport();
@@ -245,6 +257,7 @@ mod tests {
headers: &HeaderMap::new(),
auth_header: "authorization",
auth_value: "Bearer secret",
accept: "text/event-stream",
header_rules: Some(&json!([
{"action":"set","key":"x-image-route","value":"codex"}
])),
@@ -267,4 +280,45 @@ mod tests {
);
assert_eq!(headers.get("x-image-route"), Some(&"codex".to_string()));
}
#[test]
fn standard_openai_compatible_image_headers_can_request_json() {
let headers = build_openai_image_headers(ProviderOpenAiImageHeadersInput {
headers: &HeaderMap::new(),
auth_header: "authorization",
auth_value: "Bearer secret",
accept: "application/json",
header_rules: None,
provider_request_body: &json!({
"model": "upstream-image-model",
"prompt": "draw a city",
}),
original_request_body: &json!({"prompt":"draw a city"}),
})
.expect("headers should build");
assert_eq!(headers.get("accept"), Some(&"application/json".to_string()));
assert_eq!(
headers.get("content-type"),
Some(&"application/json".to_string())
);
}
#[test]
fn standard_openai_compatible_image_url_supports_aether_api_root() {
let mut transport = sample_transport();
transport.provider.provider_type = "custom".to_string();
transport.endpoint.base_url = "https://upstream-aether.example/v1".to_string();
let url = build_openai_image_upstream_url(
&transport,
Some("/v1/images/generations"),
Some("trace=1"),
);
assert_eq!(
url,
"https://upstream-aether.example/v1/images/generations?trace=1"
);
}
}
+105
View File
@@ -5847,6 +5847,111 @@ mod tests {
assert!(body_size.get("provider_over_client").is_none());
}
#[test]
fn openai_image_sync_terminal_usage_captures_request_and_response_bodies() {
let plan = ExecutionPlan {
request_id: "req-openai-image-sync-body-1".to_string(),
candidate_id: Some("cand-openai-image-sync-body-1".to_string()),
provider_name: Some("Upstream Aether".to_string()),
provider_id: "provider-aether-1".to_string(),
endpoint_id: "endpoint-aether-1".to_string(),
key_id: "key-aether-1".to_string(),
method: "POST".to_string(),
url: "https://upstream-aether.example/v1/images/generations".to_string(),
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
content_type: Some("application/json".to_string()),
content_encoding: None,
body: RequestBody::from_json(json!({
"model": "gpt-image-2-upstream",
"prompt": "Draw a red kite",
"size": "1024x1024",
"n": 1,
"response_format": "b64_json"
})),
stream: false,
client_api_format: "openai:image".to_string(),
provider_api_format: "openai:image".to_string(),
model_name: Some("gpt-image-2".to_string()),
proxy: None,
transport_profile: None,
timeouts: None,
};
let payload = GatewaySyncReportRequest {
trace_id: "trace-openai-image-sync-body-1".to_string(),
report_kind: "openai_image_sync_finalize".to_string(),
report_context: Some(json!({
"client_api_format": "openai:image",
"provider_api_format": "openai:image",
"needs_conversion": true,
"original_request_body": {
"model": "gpt-image-2",
"prompt": "Draw a red kite",
"size": "1024x1024",
"response_format": "b64_json"
},
"provider_request_body": {
"model": "gpt-image-2-upstream",
"prompt": "Draw a red kite",
"size": "1024x1024",
"n": 1,
"response_format": "b64_json"
}
})),
status_code: 200,
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
body_json: Some(json!({
"created": 1776839946,
"data": [{
"b64_json": "aGVsbG8=",
"revised_prompt": "red kite"
}],
"usage": {
"input_tokens": 11,
"output_tokens": 22,
"total_tokens": 33
}
})),
client_body_json: None,
body_base64: None,
telemetry: None,
};
let event =
build_sync_terminal_usage_event(&plan, payload.report_context.as_ref(), &payload)
.expect("usage event should build");
assert_eq!(
event.data.request_body,
payload
.report_context
.as_ref()
.and_then(|value| value.get("original_request_body"))
.cloned()
);
assert_eq!(
event.data.provider_request_body,
payload
.report_context
.as_ref()
.and_then(|value| value.get("provider_request_body"))
.cloned()
);
assert_eq!(event.data.response_body, payload.body_json);
assert!(event.data.client_response_body.is_none());
assert_eq!(
event.data.request_body_state,
Some(UsageBodyCaptureState::Inline)
);
assert_eq!(
event.data.provider_request_body_state,
Some(UsageBodyCaptureState::Inline)
);
assert_eq!(
event.data.response_body_state,
Some(UsageBodyCaptureState::Inline)
);
}
#[test]
fn sync_terminal_usage_applies_kiro_simulated_cache_context() {
let plan = ExecutionPlan {