fix(gateway): preserve JSON mode chat hints in responses normalization

This commit is contained in:
zhefox
2026-05-27 13:35:16 +08:00
parent 674cc85005
commit 41ad422002
3 changed files with 183 additions and 10 deletions
@@ -291,6 +291,47 @@ fn strips_metadata_for_codex_openai_responses_requests() {
assert!(provider_request_body.get("metadata").is_none());
}
#[test]
fn openai_chat_to_codex_responses_preserves_json_mode_chat_messages() {
let body_json = json!({
"model": "gpt-5.5",
"messages": [
{"role": "system", "content": "Return a JSON object."},
{"role": "user", "content": "Why did this JSON request fail?"}
],
"response_format": {"type": "json_object"}
});
let provider_request_body = build_cross_format_openai_responses_request_body(
&body_json,
"gpt-5.5-upstream",
"openai:chat",
"openai:responses",
false,
false,
"codex",
None,
None,
&http::HeaderMap::new(),
false,
)
.expect("openai chat to codex responses request should build");
assert_eq!(
provider_request_body["text"]["format"]["type"],
"json_object"
);
assert_eq!(provider_request_body["input"][0]["role"], "user");
assert_eq!(
provider_request_body["input"][0]["content"][0]["text"],
"Why did this JSON request fail?"
);
assert_eq!(
provider_request_body["instructions"],
"Return a JSON object."
);
}
#[test]
fn applies_codex_defaults_unless_body_rules_handle_the_field() {
let body_json = json!({
@@ -130,13 +130,13 @@ pub fn to_raw(
let mut output = Map::new();
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
if let Some(instructions) = canonical_instructions_to_responses(canonical) {
let instructions = canonical_instructions_to_responses(canonical);
if let Some(instructions) = instructions.clone() {
output.insert("instructions".to_string(), instructions);
}
output.insert(
"input".to_string(),
Value::Array(canonical_messages_to_responses_input(canonical)?),
);
let mut input = canonical_messages_to_responses_input(canonical)?;
ensure_json_object_response_input_mentions_json(canonical, instructions.as_ref(), &mut input);
output.insert("input".to_string(), Value::Array(input));
if upstream_is_stream && !compact {
output.insert("stream".to_string(), Value::Bool(true));
@@ -265,6 +265,42 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
Some(input)
}
fn ensure_json_object_response_input_mentions_json(
canonical: &CanonicalRequest,
instructions: Option<&Value>,
input: &mut Vec<Value>,
) {
if !canonical
.response_format
.as_ref()
.is_some_and(|format| format.format_type.eq_ignore_ascii_case("json_object"))
|| input.iter().any(value_contains_json_word)
|| !instructions.is_some_and(value_contains_json_word)
{
return;
}
input.insert(
0,
json!({
"type": "message",
"role": "system",
"content": [{
"type": "input_text",
"text": "Respond with JSON.",
}],
}),
);
}
fn value_contains_json_word(value: &Value) -> bool {
match value {
Value::String(text) => text.to_ascii_lowercase().contains("json"),
Value::Array(items) => items.iter().any(value_contains_json_word),
Value::Object(object) => object.values().any(value_contains_json_word),
_ => false,
}
}
fn flush_responses_message(input: &mut Vec<Value>, role: &str, content: &mut Vec<Value>) {
if content.is_empty() {
return;
@@ -514,3 +550,45 @@ fn insert_number(output: &mut Map<String, Value>, key: &str, value: Option<f64>)
output.insert(key.to_string(), Value::Number(value));
}
}
#[cfg(test)]
mod tests {
use super::to_raw;
use crate::protocol::canonical::{
CanonicalContentBlock, CanonicalMessage, CanonicalRequest, CanonicalResponseFormat,
CanonicalRole,
};
use serde_json::json;
#[test]
fn json_object_response_injects_json_hint_into_input_when_only_instructions_have_it() {
let request = CanonicalRequest {
model: "gpt-5.5".to_string(),
system: Some("Please answer in JSON.".to_string()),
messages: vec![CanonicalMessage {
role: CanonicalRole::User,
content: vec![CanonicalContentBlock::Text {
text: "hello".to_string(),
extensions: Default::default(),
}],
extensions: Default::default(),
}],
response_format: Some(CanonicalResponseFormat {
format_type: "json_object".to_string(),
json_schema: None,
extensions: Default::default(),
}),
..CanonicalRequest::default()
};
let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body");
assert_eq!(body["text"]["format"]["type"], json!("json_object"));
assert_eq!(body["input"][0]["role"], json!("system"));
assert!(body["input"][0]["content"][0]["text"]
.as_str()
.expect("hint text")
.to_ascii_lowercase()
.contains("json"));
}
}
@@ -3,6 +3,8 @@ use std::borrow::Cow;
use aether_ai_formats::formats::conversion::request::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_responses_request,
normalize_claude_request_to_openai_chat_request,
normalize_gemini_request_to_openai_chat_request,
normalize_openai_responses_request_to_openai_chat_request,
};
use aether_ai_formats::{request_conversion_kind, FormatContext, RequestConversionKind};
@@ -62,6 +64,25 @@ fn chat_compatible_body_for_openai_chat_endpoint(body_json: &Value) -> Option<Co
Some(Cow::Borrowed(body_json))
}
fn chat_compatible_body_for_standard_source<'a>(
body_json: &'a Value,
client_api_format: &str,
) -> Option<Cow<'a, Value>> {
match aether_ai_formats::normalize_api_format_alias(client_api_format).as_str() {
"openai:chat" => chat_compatible_body_for_openai_chat_endpoint(body_json),
"openai:responses" | "openai:responses:compact" => {
normalize_openai_responses_request_to_openai_chat_request(body_json).map(Cow::Owned)
}
"claude:messages" => {
normalize_claude_request_to_openai_chat_request(body_json).map(Cow::Owned)
}
"gemini:generate_content" => {
normalize_gemini_request_to_openai_chat_request(body_json, "").map(Cow::Owned)
}
_ => None,
}
}
pub fn build_local_openai_chat_request_body(
body_json: &Value,
mapped_model: &str,
@@ -304,12 +325,12 @@ pub fn build_cross_format_openai_responses_request_body_with_model_directives(
upstream_is_stream: bool,
enable_model_directives: bool,
) -> Option<Value> {
let chat_like_request = normalize_openai_responses_request_to_openai_chat_request(body_json)?;
let chat_like_request = chat_compatible_body_for_standard_source(body_json, client_api_format)?;
let conversion_kind = request_conversion_kind(client_api_format, provider_api_format)?;
let provider_request_body = match conversion_kind {
RequestConversionKind::ToOpenAIChat => {
build_local_openai_chat_request_body_with_model_directives(
&chat_like_request,
chat_like_request.as_ref(),
mapped_model,
upstream_is_stream,
enable_model_directives,
@@ -317,19 +338,19 @@ pub fn build_cross_format_openai_responses_request_body_with_model_directives(
}
RequestConversionKind::ToOpenAiResponses => {
convert_openai_chat_request_to_openai_responses_request(
&chat_like_request,
chat_like_request.as_ref(),
mapped_model,
upstream_is_stream,
false,
)?
}
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
&chat_like_request,
chat_like_request.as_ref(),
mapped_model,
upstream_is_stream,
)?,
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
&chat_like_request,
chat_like_request.as_ref(),
mapped_model,
upstream_is_stream,
)?,
@@ -519,6 +540,39 @@ mod tests {
assert!(provider_request_body.get("messages").is_none());
}
#[test]
fn cross_format_openai_responses_body_preserves_chat_messages_for_chat_source() {
let body_json = json!({
"model": "gpt-5.5",
"messages": [
{"role": "system", "content": "Return a JSON object."},
{"role": "user", "content": "Explain why this JSON patch failed."}
],
"response_format": {"type": "json_object"}
});
let provider_request_body = build_cross_format_openai_responses_request_body(
&body_json,
"gpt-5.5-upstream",
"openai:chat",
"openai:responses",
false,
)
.expect("openai chat to openai responses body should build");
assert_eq!(provider_request_body["model"], "gpt-5.5-upstream");
assert_eq!(
provider_request_body["text"]["format"]["type"],
"json_object"
);
assert_eq!(provider_request_body["input"][0]["role"], "user");
assert_eq!(
provider_request_body["input"][0]["content"][0]["text"],
"Explain why this JSON patch failed."
);
assert!(provider_request_body.get("messages").is_none());
}
#[test]
fn openai_chat_request_body_prefers_messages_when_messages_and_input_are_both_present() {
let body_json = json!({