mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-13 04:29:50 +08:00
Merge pull request #581 from zhefox/main
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());
|
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]
|
#[test]
|
||||||
fn applies_codex_defaults_unless_body_rules_handle_the_field() {
|
fn applies_codex_defaults_unless_body_rules_handle_the_field() {
|
||||||
let body_json = json!({
|
let body_json = json!({
|
||||||
@@ -355,7 +396,7 @@ fn injects_codex_prompt_cache_key_for_openai_responses_cross_format_requests() {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
provider_request_body["prompt_cache_key"],
|
provider_request_body["prompt_cache_key"],
|
||||||
"b4dfeb75-b105-544c-a706-39b92f0bddb0"
|
"4ee6ea6e-3ac6-5a18-8cb8-1f8b956419e5"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,13 +130,13 @@ pub fn to_raw(
|
|||||||
let mut output = Map::new();
|
let mut output = Map::new();
|
||||||
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
|
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("instructions".to_string(), instructions);
|
||||||
}
|
}
|
||||||
output.insert(
|
let mut input = canonical_messages_to_responses_input(canonical)?;
|
||||||
"input".to_string(),
|
ensure_json_object_response_input_mentions_json(canonical, instructions.as_ref(), &mut input);
|
||||||
Value::Array(canonical_messages_to_responses_input(canonical)?),
|
output.insert("input".to_string(), Value::Array(input));
|
||||||
);
|
|
||||||
|
|
||||||
if upstream_is_stream && !compact {
|
if upstream_is_stream && !compact {
|
||||||
output.insert("stream".to_string(), Value::Bool(true));
|
output.insert("stream".to_string(), Value::Bool(true));
|
||||||
@@ -265,6 +265,42 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
|
|||||||
Some(input)
|
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>) {
|
fn flush_responses_message(input: &mut Vec<Value>, role: &str, content: &mut Vec<Value>) {
|
||||||
if content.is_empty() {
|
if content.is_empty() {
|
||||||
return;
|
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));
|
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::{
|
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_claude_request, convert_openai_chat_request_to_gemini_request,
|
||||||
convert_openai_chat_request_to_openai_responses_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,
|
normalize_openai_responses_request_to_openai_chat_request,
|
||||||
};
|
};
|
||||||
use aether_ai_formats::{request_conversion_kind, FormatContext, RequestConversionKind};
|
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))
|
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(
|
pub fn build_local_openai_chat_request_body(
|
||||||
body_json: &Value,
|
body_json: &Value,
|
||||||
mapped_model: &str,
|
mapped_model: &str,
|
||||||
@@ -304,12 +325,12 @@ pub fn build_cross_format_openai_responses_request_body_with_model_directives(
|
|||||||
upstream_is_stream: bool,
|
upstream_is_stream: bool,
|
||||||
enable_model_directives: bool,
|
enable_model_directives: bool,
|
||||||
) -> Option<Value> {
|
) -> 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 conversion_kind = request_conversion_kind(client_api_format, provider_api_format)?;
|
||||||
let provider_request_body = match conversion_kind {
|
let provider_request_body = match conversion_kind {
|
||||||
RequestConversionKind::ToOpenAIChat => {
|
RequestConversionKind::ToOpenAIChat => {
|
||||||
build_local_openai_chat_request_body_with_model_directives(
|
build_local_openai_chat_request_body_with_model_directives(
|
||||||
&chat_like_request,
|
chat_like_request.as_ref(),
|
||||||
mapped_model,
|
mapped_model,
|
||||||
upstream_is_stream,
|
upstream_is_stream,
|
||||||
enable_model_directives,
|
enable_model_directives,
|
||||||
@@ -317,19 +338,19 @@ pub fn build_cross_format_openai_responses_request_body_with_model_directives(
|
|||||||
}
|
}
|
||||||
RequestConversionKind::ToOpenAiResponses => {
|
RequestConversionKind::ToOpenAiResponses => {
|
||||||
convert_openai_chat_request_to_openai_responses_request(
|
convert_openai_chat_request_to_openai_responses_request(
|
||||||
&chat_like_request,
|
chat_like_request.as_ref(),
|
||||||
mapped_model,
|
mapped_model,
|
||||||
upstream_is_stream,
|
upstream_is_stream,
|
||||||
false,
|
false,
|
||||||
)?
|
)?
|
||||||
}
|
}
|
||||||
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
|
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
|
||||||
&chat_like_request,
|
chat_like_request.as_ref(),
|
||||||
mapped_model,
|
mapped_model,
|
||||||
upstream_is_stream,
|
upstream_is_stream,
|
||||||
)?,
|
)?,
|
||||||
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
|
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
|
||||||
&chat_like_request,
|
chat_like_request.as_ref(),
|
||||||
mapped_model,
|
mapped_model,
|
||||||
upstream_is_stream,
|
upstream_is_stream,
|
||||||
)?,
|
)?,
|
||||||
@@ -519,6 +540,39 @@ mod tests {
|
|||||||
assert!(provider_request_body.get("messages").is_none());
|
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]
|
#[test]
|
||||||
fn openai_chat_request_body_prefers_messages_when_messages_and_input_are_both_present() {
|
fn openai_chat_request_body_prefers_messages_when_messages_and_input_are_both_present() {
|
||||||
let body_json = json!({
|
let body_json = json!({
|
||||||
|
|||||||
Reference in New Issue
Block a user