fix(formats): preserve chat-backed Responses metadata

This commit is contained in:
elky
2026-07-16 16:38:30 +08:00
parent 7dc919e8e3
commit e369e4aab1
5 changed files with 451 additions and 20 deletions
@@ -678,11 +678,30 @@ impl OpenAIResponsesProviderState {
if item.get("type").and_then(Value::as_str) != Some("function_call") {
return;
}
const MAPPED_FIELDS: &[&str] = &["type", "id", "call_id", "status", "name", "arguments"];
if item
.keys()
.any(|field| !MAPPED_FIELDS.contains(&field.as_str()))
{
// Chat-backed Responses implementations may attach transport-only metadata to
// function-call items. It does not change the executable tool call, and the
// sync Responses aggregator retains the original item verbatim. Treat these
// sidecars as recognized while continuing to fail closed for semantic fields
// (for example `caller`) that the canonical tool-call events cannot represent.
const EXECUTION_FIELDS: &[&str] = &["type", "id", "call_id", "status", "name", "arguments"];
let has_chat_metadata_passthrough =
item.contains_key("internal_chat_message_metadata_passthrough");
let chat_metadata_target_supported = report_context
.get("client_api_format")
.and_then(Value::as_str)
.map(aether_ai_formats::normalize_api_format_alias)
.is_none_or(|client_api_format| {
matches!(
client_api_format.as_str(),
"openai:chat" | "openai:responses" | "openai:responses:compact"
)
});
if item.keys().any(|field| {
!EXECUTION_FIELDS.contains(&field.as_str())
&& !(chat_metadata_target_supported
&& (field == "internal_chat_message_metadata_passthrough"
|| (field == "metadata" && has_chat_metadata_passthrough)))
}) {
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
return;
}
@@ -3718,6 +3737,119 @@ mod tests {
)));
}
#[test]
fn openai_responses_provider_state_accepts_chat_backed_function_call_metadata() {
let mut state = OpenAIResponsesProviderState::default();
let report_context = json!({});
let frames = state
.push_line(
&report_context,
data_line(json!({
"type": "response.output_item.added",
"response_id": "resp_chat_metadata_123",
"output_index": 0,
"item": {
"type": "function_call",
"id": "fc_chat_metadata_123",
"call_id": "call_chat_metadata_123",
"status": "completed",
"name": "lookup",
"arguments": "{\"query\":\"aether\"}",
"internal_chat_message_metadata_passthrough": {
"turn_id": "turn_123"
},
"metadata": {
"source": "chat"
}
}
})),
)
.expect("chat-backed function call event should parse");
assert!(!frames
.iter()
.any(|frame| matches!(frame.event, CanonicalStreamEvent::UnknownEvent(_))));
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallStart {
index: 0,
ref call_id,
ref name,
} if call_id == "call_chat_metadata_123" && name == "lookup"
)));
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallArgumentsDelta {
index: 0,
ref arguments,
} if arguments == "{\"query\":\"aether\"}"
)));
}
#[test]
fn openai_responses_provider_state_rejects_unpaired_function_call_metadata() {
let mut state = OpenAIResponsesProviderState::default();
let frames = state
.push_line(
&json!({}),
data_line(json!({
"type": "response.output_item.added",
"output_index": 0,
"item": {
"type": "function_call",
"id": "fc_metadata_only_123",
"call_id": "call_metadata_only_123",
"status": "completed",
"name": "lookup",
"arguments": "{}",
"metadata": {"semantic": true}
}
})),
)
.expect("function call event should parse");
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::UnknownEvent(ref payload)
if payload.get("metadata").is_some()
)));
assert!(!frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallStart { .. }
| CanonicalStreamEvent::ToolCallArgumentsDelta { .. }
)));
}
#[test]
fn openai_responses_provider_state_rejects_chat_metadata_for_non_openai_clients() {
let mut state = OpenAIResponsesProviderState::default();
let frames = state
.push_line(
&json!({"client_api_format": "claude:messages"}),
data_line(json!({
"type": "response.output_item.added",
"output_index": 0,
"item": {
"type": "function_call",
"id": "fc_chat_metadata_123",
"call_id": "call_chat_metadata_123",
"status": "completed",
"name": "lookup",
"arguments": "{}",
"metadata": {"source": "chat"},
"internal_chat_message_metadata_passthrough": {
"turn_id": "turn_123"
}
}
})),
)
.expect("function call event should parse");
assert!(frames
.iter()
.any(|frame| matches!(frame.event, CanonicalStreamEvent::UnknownEvent(_))));
}
#[test]
fn openai_responses_provider_state_treats_failed_event_as_terminal() {
let mut state = OpenAIResponsesProviderState::default();
@@ -680,6 +680,12 @@ fn validate_openai_responses_cross_format_response_extensions(
}
CanonicalContentBlock::Unknown { .. } => continue,
};
let has_chat_metadata_passthrough = extensions.iter().any(|(namespace, value)| {
matches!(namespace.as_str(), "openai_responses" | "openai_cli")
&& value.as_object().is_some_and(|object| {
object.contains_key("internal_chat_message_metadata_passthrough")
})
});
validate_openai_responses_response_extension_namespace(
source,
target,
@@ -693,7 +699,13 @@ fn validate_openai_responses_cross_format_response_extensions(
}
CanonicalContentBlock::Thinking { .. } => key == "item_type",
CanonicalContentBlock::ToolUse { .. } => {
// Codex may attach a private Responses-to-Chat transport sidecar.
// Only a Chat target may intentionally consume the tool call while
// discarding that paired transport metadata.
matches!(key, "item_id" | "item_type" | "status")
|| (target == FormatId::OpenAiChat
&& (key == "internal_chat_message_metadata_passthrough"
|| (key == "metadata" && has_chat_metadata_passthrough)))
}
CanonicalContentBlock::ToolResult { .. } => key == "item_type",
CanonicalContentBlock::Image { .. }
@@ -5141,6 +5153,63 @@ mod tests {
}
}
#[test]
fn chat_backed_function_call_metadata_is_only_droppable_for_chat_targets() {
let body = json!({
"id": "resp_chat_metadata",
"object": "response",
"model": "gpt-5",
"status": "completed",
"output": [{
"type": "function_call",
"id": "fc_chat_metadata",
"call_id": "call_chat_metadata",
"status": "completed",
"name": "lookup",
"arguments": "{}",
"metadata": {"source": "chat"},
"internal_chat_message_metadata_passthrough": {
"turn_id": "turn_123"
}
}]
});
let chat = convert_response_pure("openai:responses", "openai:chat", &body)
.expect("Chat-backed metadata should be droppable for a Chat client")
.value;
assert_eq!(
chat["choices"][0]["message"]["tool_calls"][0]["id"],
"call_chat_metadata"
);
for target in ["claude:messages", "gemini:generate_content"] {
let error = convert_response_pure("openai:responses", target, &body)
.expect_err("non-Chat targets must reject Chat-private metadata");
assert!(
matches!(
error,
super::FormatError::LossyConversionBlocked { ref field, .. }
if field.ends_with(".metadata")
|| field.contains("internal_chat_message_metadata_passthrough")
),
"{target}: {error:?}"
);
}
let mut unpaired = body;
unpaired["output"][0]
.as_object_mut()
.expect("function call should be an object")
.remove("internal_chat_message_metadata_passthrough");
let error = convert_response_pure("openai:responses", "openai:chat", &unpaired)
.expect_err("generic metadata without the Chat-private marker must be rejected");
assert!(matches!(
error,
super::FormatError::LossyConversionBlocked { ref field, .. }
if field.ends_with(".metadata")
));
}
#[test]
fn pure_openai_responses_response_cross_format_blocks_raw_output_items() {
let body = json!({
@@ -784,6 +784,58 @@ mod tests {
assert!(!sse.contains("HelloHello"));
}
#[test]
fn transforms_chat_backed_function_call_metadata_to_chat_tool_calls() {
let report_context = report_context("openai:responses", "openai:chat");
let mut matrix = StreamingStandardFormatMatrix::default();
let mut output = Vec::new();
for line in [
data_line(json!({
"type": "response.output_item.added",
"response_id": "resp_chat_metadata_123",
"output_index": 0,
"item": {
"type": "function_call",
"id": "fc_chat_metadata_123",
"call_id": "call_chat_metadata_123",
"status": "completed",
"arguments": "{\"query\":\"aether\"}",
"name": "lookup",
"metadata": {"source": "chat"},
"internal_chat_message_metadata_passthrough": {
"turn_id": "turn_123"
}
}
})),
data_line(json!({
"type": "response.completed",
"response": {
"id": "resp_chat_metadata_123",
"object": "response",
"model": "gpt-5",
"status": "completed",
"output": [],
},
})),
] {
output.extend(
matrix
.transform_line(&report_context, line)
.expect("chat-backed function call should convert"),
);
}
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(!sse.contains("unsupported_stream_event"), "{sse}");
assert!(!sse.contains("Unsupported provider stream event"), "{sse}");
assert!(sse.contains("\"id\":\"call_chat_metadata_123\""), "{sse}");
assert!(sse.contains("\"name\":\"lookup\""), "{sse}");
assert!(sse.contains("\\\"query\\\":\\\"aether\\\""), "{sse}");
assert!(!sse.contains("internal_chat_message_metadata_passthrough"));
assert!(!sse.contains("\"metadata\""));
}
#[test]
fn ignores_openai_responses_keepalive_events_for_chat_clients() {
let report_context = report_context("openai:responses", "openai:chat");
@@ -49,12 +49,25 @@ pub fn resolve_finalize_stream_rewrite_mode(
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let provider_stream_event_api_format = report_context
.get("provider_stream_event_api_format")
.or_else(|| report_context.get("provider_stream_api_format"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_ascii_lowercase)
.unwrap_or_else(|| provider_api_format.clone());
let client_api_format = report_context
.get("client_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let stream_needs_conversion = needs_conversion
|| !is_same_format_family(
provider_stream_event_api_format.as_str(),
client_api_format.as_str(),
);
if !needs_conversion
&& client_consumes_same_private_stream_envelope(
@@ -87,25 +100,30 @@ pub fn resolve_finalize_stream_rewrite_mode(
return Some(FinalizeStreamRewriteMode::OpenAiImage);
}
if needs_conversion {
if stream_needs_conversion {
// CPA strategy: when provider and client share the same wire format
// (exact match or same family), pass through the stream verbatim.
// Parsing→rebuilding only adds overhead and may lose information
// (encrypted_content, original item IDs, etc.).
if is_same_format_family(provider_api_format.as_str(), client_api_format.as_str()) {
if is_openai_responses_family(provider_api_format.as_str())
if is_same_format_family(
provider_stream_event_api_format.as_str(),
client_api_format.as_str(),
) {
if is_openai_responses_family(provider_stream_event_api_format.as_str())
&& is_openai_responses_family(client_api_format.as_str())
{
return Some(FinalizeStreamRewriteMode::OpenAiResponsesCompat);
}
if provider_api_format == "claude:messages" && client_api_format == "claude:messages" {
if provider_stream_event_api_format == "claude:messages"
&& client_api_format == "claude:messages"
{
return Some(FinalizeStreamRewriteMode::ClaudeReadToolSanitize);
}
return model_directive_display_model_from_report_context(report_context)
.map(|_| FinalizeStreamRewriteMode::ModelDirectiveDisplay);
}
return supports_standard_stream_rewrite(
provider_api_format.as_str(),
provider_stream_event_api_format.as_str(),
client_api_format.as_str(),
)
.then_some(FinalizeStreamRewriteMode::Standard);
@@ -118,25 +136,30 @@ pub fn resolve_finalize_stream_rewrite_mode(
}
if model_directive_display_model_from_report_context(report_context).is_some()
&& provider_api_format == client_api_format
&& is_standard_provider_api_format(provider_api_format.as_str())
&& provider_stream_event_api_format == client_api_format
&& is_standard_provider_api_format(provider_stream_event_api_format.as_str())
&& !provider_adaptation_should_unwrap_stream_envelope(
envelope_name.as_str(),
provider_api_format.as_str(),
)
{
if provider_api_format == "claude:messages" {
if provider_stream_event_api_format == "claude:messages" {
return Some(FinalizeStreamRewriteMode::ClaudeReadToolSanitize);
}
return Some(FinalizeStreamRewriteMode::ModelDirectiveDisplay);
}
if provider_api_format == "claude:messages" && client_api_format == "claude:messages" {
if provider_stream_event_api_format == "claude:messages"
&& client_api_format == "claude:messages"
{
return Some(FinalizeStreamRewriteMode::ClaudeReadToolSanitize);
}
if provider_api_format == client_api_format
&& is_openai_responses_family(provider_api_format.as_str())
if is_same_format_family(
provider_stream_event_api_format.as_str(),
client_api_format.as_str(),
) && is_openai_responses_family(provider_stream_event_api_format.as_str())
&& is_openai_responses_family(client_api_format.as_str())
{
return Some(FinalizeStreamRewriteMode::OpenAiResponsesCompat);
}
@@ -1011,6 +1034,61 @@ data: {\"type\":\"response.reasoning_summary_text.delta\",\"response_id\":\"resp
assert!(!output.contains("data: [DONE]"));
}
#[test]
fn explicit_responses_stream_format_preserves_function_call_metadata() {
let report_context = json!({
"provider_api_format": "openai:chat",
"provider_stream_event_api_format": "openai:responses",
"client_api_format": "openai:responses",
"needs_conversion": true,
});
assert_eq!(
resolve_finalize_stream_rewrite_mode(&report_context),
Some(FinalizeStreamRewriteMode::OpenAiResponsesCompat)
);
let mut rewriter = maybe_build_ai_surface_stream_rewriter(Some(&report_context))
.expect("responses compat rewriter should exist");
let output = rewriter
.push_chunk(
b"event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"id\":\"fc_chat_metadata_123\",\"call_id\":\"call_chat_metadata_123\",\"status\":\"completed\",\"arguments\":\"{}\",\"name\":\"lookup\",\"metadata\":{\"source\":\"chat\"},\"internal_chat_message_metadata_passthrough\":{\"turn_id\":\"turn_123\"}}}\n\n",
)
.expect("responses event should pass through");
let output = String::from_utf8(output).expect("output should be utf8");
assert!(output.contains("event: response.output_item.added"));
assert!(output.contains("\"metadata\":{\"source\":\"chat\"}"));
assert!(output
.contains("\"internal_chat_message_metadata_passthrough\":{\"turn_id\":\"turn_123\"}"));
}
#[test]
fn explicit_responses_stream_format_converts_to_chat_even_without_request_conversion() {
let report_context = json!({
"provider_api_format": "openai:chat",
"provider_stream_event_api_format": "openai:responses",
"client_api_format": "openai:chat",
"needs_conversion": false,
});
assert_eq!(
resolve_finalize_stream_rewrite_mode(&report_context),
Some(FinalizeStreamRewriteMode::Standard)
);
let mut rewriter = maybe_build_ai_surface_stream_rewriter(Some(&report_context))
.expect("standard rewriter should exist");
let output = rewriter
.push_chunk(
b"event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"id\":\"fc_chat_metadata_123\",\"call_id\":\"call_chat_metadata_123\",\"status\":\"completed\",\"arguments\":\"{}\",\"name\":\"lookup\",\"metadata\":{\"source\":\"chat\"},\"internal_chat_message_metadata_passthrough\":{\"turn_id\":\"turn_123\"}}}\n\n",
)
.expect("responses event should convert to chat");
let output = String::from_utf8(output).expect("output should be utf8");
assert!(output.contains("\"object\":\"chat.completion.chunk\""));
assert!(output.contains("\"id\":\"call_chat_metadata_123\""));
assert!(!output.contains("unsupported_stream_event"));
}
#[test]
fn same_family_responses_passthrough_preserves_encrypted_content() {
// When provider and client are both OpenAI Responses family,
@@ -684,13 +684,17 @@ fn maybe_build_openai_responses_same_family_stream_sync_body(
.get("needs_conversion")
.and_then(Value::as_bool)
.unwrap_or(false);
let provider_stream_event_api_format =
provider_stream_event_api_format_for_report_context(report_context, &provider_api_format);
let normalized_provider_api_format =
normalize_openai_responses_family_api_format(&provider_api_format);
normalize_openai_responses_family_api_format(&provider_stream_event_api_format);
let normalized_client_api_format =
normalize_openai_responses_family_api_format(&client_api_format);
if !is_openai_responses_family_api_format(&provider_api_format)
if !is_openai_responses_family_api_format(&provider_stream_event_api_format)
|| !is_openai_responses_family_api_format(&client_api_format)
|| (normalized_provider_api_format == normalized_client_api_format && needs_conversion)
|| (is_openai_responses_family_api_format(&provider_api_format)
&& normalized_provider_api_format == normalized_client_api_format
&& needs_conversion)
{
return Ok(None);
}
@@ -3628,7 +3632,7 @@ mod tests {
maybe_build_standard_cross_format_sync_product_from_normalized_payload,
maybe_build_standard_same_format_sync_body_from_normalized_payload,
maybe_build_standard_sync_finalize_product_from_normalized_payload,
StandardSyncFinalizeNormalizedProduct,
try_aggregate_openai_responses_stream_sync_response, StandardSyncFinalizeNormalizedProduct,
};
use aether_ai_formats::formats::conversion::response::{
convert_claude_chat_response_to_openai_chat, convert_gemini_chat_response_to_openai_chat,
@@ -4870,6 +4874,28 @@ mod tests {
assert_eq!(result["output"][0]["arguments"], r#"{"location": "Tokyo"}"#);
}
#[test]
fn validates_and_preserves_chat_backed_function_call_metadata() {
let body = concat!(
"event: response.output_item.added\n",
"data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"id\":\"fc_chat_metadata_123\",\"call_id\":\"call_chat_metadata_123\",\"status\":\"completed\",\"arguments\":\"{\\\"query\\\":\\\"aether\\\"}\",\"name\":\"lookup\",\"metadata\":{\"source\":\"chat\"},\"internal_chat_message_metadata_passthrough\":{\"turn_id\":\"turn_123\"}}}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_chat_metadata_123\",\"object\":\"response\",\"model\":\"gpt-5\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}\n\n",
);
let result = try_aggregate_openai_responses_stream_sync_response(body.as_bytes())
.expect("chat-backed function call metadata should pass stream validation")
.expect("chat-backed function call stream should aggregate");
assert_eq!(result["output"][0]["type"], "function_call");
assert_eq!(result["output"][0]["call_id"], "call_chat_metadata_123");
assert_eq!(result["output"][0]["metadata"]["source"], "chat");
assert_eq!(
result["output"][0]["internal_chat_message_metadata_passthrough"]["turn_id"],
"turn_123"
);
}
#[test]
fn custom_tool_call_input_events_materialize_custom_tool_call() {
let body = concat!(
@@ -5863,6 +5889,80 @@ mod tests {
assert_eq!(body_json["choices"][0]["message"]["content"], "pong");
}
#[test]
fn standard_sync_finalize_converts_chat_backed_function_call_metadata() {
let body = concat!(
"event: response.output_item.added\n",
"data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"id\":\"fc_chat_metadata_123\",\"call_id\":\"call_chat_metadata_123\",\"status\":\"completed\",\"arguments\":\"{\\\"query\\\":\\\"aether\\\"}\",\"name\":\"lookup\",\"metadata\":{\"source\":\"chat\"},\"internal_chat_message_metadata_passthrough\":{\"turn_id\":\"turn_123\"}}}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_chat_metadata_123\",\"object\":\"response\",\"model\":\"gpt-5\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}\n\n",
);
let report_context = json!({
"provider_api_format": "openai:responses",
"client_api_format": "openai:chat",
"needs_conversion": true,
});
let product = maybe_build_standard_sync_finalize_product_from_normalized_payload(
"openai_chat_sync_finalize",
200,
Some(&report_context),
None,
Some(&base64::engine::general_purpose::STANDARD.encode(body)),
)
.expect("chat-backed function call metadata should convert")
.expect("dispatch should produce a body");
let StandardSyncFinalizeNormalizedProduct::CrossFormat(product) = product else {
panic!("Chat-to-Responses conversion should produce a cross-format body");
};
let body_json = product.client_body_json;
let tool_call = &body_json["choices"][0]["message"]["tool_calls"][0];
assert_eq!(tool_call["id"], "call_chat_metadata_123");
assert_eq!(tool_call["function"]["name"], "lookup");
assert_eq!(tool_call["function"]["arguments"], "{\"query\":\"aether\"}");
assert!(tool_call.get("metadata").is_none());
assert!(tool_call
.get("internal_chat_message_metadata_passthrough")
.is_none());
}
#[test]
fn standard_sync_finalize_preserves_responses_wire_metadata_for_responses_client() {
let body = concat!(
"event: response.output_item.added\n",
"data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"id\":\"fc_chat_metadata_123\",\"call_id\":\"call_chat_metadata_123\",\"status\":\"completed\",\"arguments\":\"{\\\"query\\\":\\\"aether\\\"}\",\"name\":\"lookup\",\"metadata\":{\"source\":\"chat\"},\"internal_chat_message_metadata_passthrough\":{\"turn_id\":\"turn_123\"}}}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_chat_metadata_123\",\"object\":\"response\",\"model\":\"gpt-5\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}\n\n",
);
let report_context = json!({
"provider_api_format": "openai:chat",
"provider_stream_event_api_format": "openai:responses",
"client_api_format": "openai:responses",
"needs_conversion": true,
});
let product = maybe_build_standard_sync_finalize_product_from_normalized_payload(
"openai_responses_sync_finalize",
200,
Some(&report_context),
None,
Some(&base64::engine::general_purpose::STANDARD.encode(body)),
)
.expect("Responses wire metadata should aggregate")
.expect("dispatch should produce a body");
let StandardSyncFinalizeNormalizedProduct::SuccessBody(body_json) = product else {
panic!("matching Responses wire format should produce a success body");
};
assert_eq!(body_json["output"][0]["type"], "function_call");
assert_eq!(body_json["output"][0]["metadata"]["source"], "chat");
assert_eq!(
body_json["output"][0]["internal_chat_message_metadata_passthrough"]["turn_id"],
"turn_123"
);
}
#[test]
fn standard_same_format_gemini_finalize_rejects_usage_only_body() {
let report_context = json!({