mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 06:00:20 +08:00
fix(gateway): preserve JSON mode chat hints in responses normalization
This commit is contained in:
@@ -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!({
|
||||
|
||||
Reference in New Issue
Block a user