fix(gateway): OpenAI 图像 finalize 统一返回 b64_json 并对齐 codex CLI 候选流式上游

- finalize 移除 response_format=url 的 data URL 分支,统一输出 b64_json
- image planner decision 依据请求体中的 stream 字段决定 upstream_is_stream
- openai chat sync plan 对 codex+openai:cli 候选强制上游流式
- 扩充 conversion registry 与 standard matrix 的全量 surface 对端测试
This commit is contained in:
fawney19
2026-04-24 19:01:11 +08:00
parent 4618184516
commit d0d71aa51a
6 changed files with 363 additions and 41 deletions

View File

@@ -106,13 +106,6 @@ fn maybe_build_local_openai_image_sync_finalize_response(
let Some(body_base64) = payload.body_base64.as_deref() else {
return Ok(None);
};
let response_format = report_context
.get("image_request")
.and_then(|value| value.get("response_format"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("url");
let default_output_format = report_context
.get("image_request")
.and_then(|value| value.get("output_format"))
@@ -227,21 +220,10 @@ fn maybe_build_local_openai_image_sync_finalize_response(
.get("output_format")
.and_then(serde_json::Value::as_str)
.unwrap_or(default_output_format);
if response_format.eq_ignore_ascii_case("b64_json") {
serde_json::json!({
"b64_json": b64_json,
"revised_prompt": revised_prompt,
})
} else {
serde_json::json!({
"url": format!(
"data:{};base64,{}",
image_output_mime_type(output_format),
b64_json
),
"revised_prompt": revised_prompt,
})
}
serde_json::json!({
"b64_json": b64_json,
"revised_prompt": revised_prompt,
})
})
.collect::<Vec<_>>();
let client_body_json = serde_json::json!({
@@ -259,14 +241,6 @@ fn maybe_build_local_openai_image_sync_finalize_response(
)?))
}
fn image_output_mime_type(output_format: &str) -> &'static str {
match output_format.trim().to_ascii_lowercase().as_str() {
"jpeg" | "jpg" => "image/jpeg",
"webp" => "image/webp",
_ => "image/png",
}
}
#[cfg(test)]
#[path = "../tests_sync.rs"]
mod tests;

View File

@@ -1945,14 +1945,14 @@ async fn local_finalize_handles_openai_image_stream_response_from_output_item_do
}
#[tokio::test]
async fn local_finalize_handles_openai_image_stream_response_with_url_response_format() {
async fn local_finalize_returns_b64_json_even_when_url_response_format_requested() {
let payload = GatewaySyncReportRequest {
trace_id: "trace-openai-image-finalize-url-123".to_string(),
report_kind: "openai_image_sync_finalize".to_string(),
report_context: Some(json!({
"client_api_format": "openai:image",
"provider_api_format": "openai:image",
"model": "gpt-image-1",
"model": "dall-e-3",
"mapped_model": "gpt-5.4",
"image_request": {
"operation": "generate",
@@ -1994,12 +1994,113 @@ async fn local_finalize_handles_openai_image_stream_response_with_url_response_f
.expect("response body should read");
let response_json: serde_json::Value =
serde_json::from_slice(&response_body).expect("response should be json");
assert_eq!(
response_json["data"][0]["url"],
"data:image/webp;base64,aGVsbG8="
);
assert_eq!(response_json["data"][0]["b64_json"], "aGVsbG8=");
assert!(response_json["data"][0].get("url").is_none());
assert_eq!(
response_json["data"][0]["revised_prompt"],
"revised webp prompt"
);
}
#[tokio::test]
async fn local_finalize_defaults_gpt_image_stream_response_to_b64_json() {
let payload = GatewaySyncReportRequest {
trace_id: "trace-openai-image-finalize-default-b64-123".to_string(),
report_kind: "openai_image_sync_finalize".to_string(),
report_context: Some(json!({
"client_api_format": "openai:image",
"provider_api_format": "openai:image",
"model": "gpt-image-2",
"mapped_model": "gpt-5.4",
"image_request": {
"operation": "generate",
"output_format": "png"
}
})),
status_code: 200,
headers: BTreeMap::from([(
"content-type".to_string(),
"text/event-stream".to_string(),
)]),
body_json: None,
client_body_json: None,
body_base64: Some(base64::engine::general_purpose::STANDARD.encode(
concat!(
"event: response.output_item.done\n",
"data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"id\":\"ig_default_123\",\"type\":\"image_generation_call\",\"output_format\":\"png\",\"result\":\"aGVsbG8=\"}}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_img_default_123\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[]}}\n\n"
)
.as_bytes(),
)),
telemetry: None,
};
let outcome = maybe_build_local_core_sync_finalize_response(
"trace-openai-image-finalize-default-b64-123",
&test_decision(),
&payload,
)
.expect("image finalize should succeed")
.expect("image finalize should match");
let response_body = to_bytes(outcome.response.into_body(), usize::MAX)
.await
.expect("response body should read");
let response_json: serde_json::Value =
serde_json::from_slice(&response_body).expect("response should be json");
assert_eq!(response_json["data"][0]["b64_json"], "aGVsbG8=");
assert!(response_json["data"][0].get("url").is_none());
}
#[tokio::test]
async fn local_finalize_forces_gpt_image_stream_response_to_b64_json_even_when_url_requested() {
let payload = GatewaySyncReportRequest {
trace_id: "trace-openai-image-finalize-force-b64-123".to_string(),
report_kind: "openai_image_sync_finalize".to_string(),
report_context: Some(json!({
"client_api_format": "openai:image",
"provider_api_format": "openai:image",
"model": "gpt-image-2",
"mapped_model": "gpt-5.4",
"image_request": {
"operation": "generate",
"response_format": "url",
"output_format": "png"
}
})),
status_code: 200,
headers: BTreeMap::from([(
"content-type".to_string(),
"text/event-stream".to_string(),
)]),
body_json: None,
client_body_json: None,
body_base64: Some(base64::engine::general_purpose::STANDARD.encode(
concat!(
"event: response.output_item.done\n",
"data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"id\":\"ig_force_123\",\"type\":\"image_generation_call\",\"output_format\":\"png\",\"result\":\"aGVsbG8=\"}}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_img_force_123\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[]}}\n\n"
)
.as_bytes(),
)),
telemetry: None,
};
let outcome = maybe_build_local_core_sync_finalize_response(
"trace-openai-image-finalize-force-b64-123",
&test_decision(),
&payload,
)
.expect("image finalize should succeed")
.expect("image finalize should match");
let response_body = to_bytes(outcome.response.into_body(), usize::MAX)
.await
.expect("response body should read");
let response_json: serde_json::Value =
serde_json::from_slice(&response_body).expect("response should be json");
assert_eq!(response_json["data"][0]["b64_json"], "aGVsbG8=");
assert!(response_json["data"][0].get("url").is_none());
}

View File

@@ -57,6 +57,11 @@ pub(super) async fn maybe_build_local_openai_image_decision_payload_for_candidat
extra_fields.insert("proxy".to_string(), proxy_value);
}
extra_fields.insert("image_request".to_string(), resolved.input_summary.clone());
let upstream_is_stream = resolved
.provider_request_body
.get("stream")
.and_then(serde_json::Value::as_bool)
.unwrap_or(spec_metadata.require_streaming);
let report_context = build_local_execution_report_context(LocalExecutionReportContextParts {
auth_context: &input.auth_context,
request_id: trace_id,
@@ -82,7 +87,7 @@ pub(super) async fn maybe_build_local_openai_image_decision_payload_for_candidat
original_request_body_json: Some(body_json),
original_request_body_base64: body_base64,
client_requested_stream: spec_metadata.require_streaming,
upstream_is_stream: spec_metadata.require_streaming,
upstream_is_stream,
has_envelope: false,
needs_conversion: false,
extra_fields,
@@ -117,7 +122,7 @@ pub(super) async fn maybe_build_local_openai_image_decision_payload_for_candidat
proxy,
tls_profile,
timeouts: resolve_transport_execution_timeouts(&transport),
upstream_is_stream: spec_metadata.require_streaming,
upstream_is_stream,
report_kind: spec_metadata.report_kind.map(ToOwned::to_owned),
report_context: Some(report_context),
auth_context: input.auth_context.clone(),

View File

@@ -10,12 +10,21 @@ use super::diagnostic::{
set_local_openai_chat_candidate_evaluation_diagnostic, set_local_openai_chat_miss_diagnostic,
};
use super::resolve::resolve_local_openai_chat_decision_input;
use crate::ai_pipeline::planner::common::OPENAI_CHAT_SYNC_PLAN_KIND;
use crate::ai_pipeline::planner::common::{
force_upstream_streaming_for_provider, OPENAI_CHAT_SYNC_PLAN_KIND,
};
use crate::ai_pipeline::planner::plan_builders::{
build_openai_chat_sync_plan_from_decision, LocalSyncPlanAndReport,
};
use crate::ai_pipeline::planner::runtime_miss::apply_local_runtime_candidate_terminal_reason;
fn openai_chat_sync_upstream_is_stream_for_candidate(
provider_type: &str,
provider_api_format: &str,
) -> bool {
force_upstream_streaming_for_provider(provider_type, provider_api_format)
}
pub(crate) async fn build_local_openai_chat_sync_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,
@@ -88,6 +97,10 @@ pub(crate) async fn build_local_openai_chat_sync_plan_and_reports(
let mut plans = Vec::new();
for attempt in attempts {
let upstream_is_stream = openai_chat_sync_upstream_is_stream_for_candidate(
attempt.eligible.transport.provider.provider_type.as_str(),
attempt.eligible.provider_api_format.as_str(),
);
let Some(payload) = maybe_build_local_openai_chat_decision_payload_for_candidate(
state,
parts,
@@ -97,7 +110,7 @@ pub(crate) async fn build_local_openai_chat_sync_plan_and_reports(
attempt,
OPENAI_CHAT_SYNC_PLAN_KIND,
"openai_chat_sync_success",
false,
upstream_is_stream,
)
.await
else {
@@ -121,3 +134,24 @@ pub(crate) async fn build_local_openai_chat_sync_plan_and_reports(
Ok(plans)
}
#[cfg(test)]
mod tests {
use super::openai_chat_sync_upstream_is_stream_for_candidate;
#[test]
fn openai_chat_sync_forces_streaming_for_codex_openai_cli_candidates() {
assert!(openai_chat_sync_upstream_is_stream_for_candidate(
"codex",
"openai:cli"
));
assert!(!openai_chat_sync_upstream_is_stream_for_candidate(
"openai",
"openai:cli"
));
assert!(!openai_chat_sync_upstream_is_stream_for_candidate(
"codex",
"openai:chat"
));
}
}