mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-12 04:09:48 +08:00
fix(formats): preserve chat-backed Responses metadata
This commit is contained in:
@@ -678,11 +678,30 @@ impl OpenAIResponsesProviderState {
|
|||||||
if item.get("type").and_then(Value::as_str) != Some("function_call") {
|
if item.get("type").and_then(Value::as_str) != Some("function_call") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const MAPPED_FIELDS: &[&str] = &["type", "id", "call_id", "status", "name", "arguments"];
|
// Chat-backed Responses implementations may attach transport-only metadata to
|
||||||
if item
|
// function-call items. It does not change the executable tool call, and the
|
||||||
.keys()
|
// sync Responses aggregator retains the original item verbatim. Treat these
|
||||||
.any(|field| !MAPPED_FIELDS.contains(&field.as_str()))
|
// 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())));
|
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
|
||||||
return;
|
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]
|
#[test]
|
||||||
fn openai_responses_provider_state_treats_failed_event_as_terminal() {
|
fn openai_responses_provider_state_treats_failed_event_as_terminal() {
|
||||||
let mut state = OpenAIResponsesProviderState::default();
|
let mut state = OpenAIResponsesProviderState::default();
|
||||||
|
|||||||
@@ -680,6 +680,12 @@ fn validate_openai_responses_cross_format_response_extensions(
|
|||||||
}
|
}
|
||||||
CanonicalContentBlock::Unknown { .. } => continue,
|
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(
|
validate_openai_responses_response_extension_namespace(
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
@@ -693,7 +699,13 @@ fn validate_openai_responses_cross_format_response_extensions(
|
|||||||
}
|
}
|
||||||
CanonicalContentBlock::Thinking { .. } => key == "item_type",
|
CanonicalContentBlock::Thinking { .. } => key == "item_type",
|
||||||
CanonicalContentBlock::ToolUse { .. } => {
|
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")
|
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::ToolResult { .. } => key == "item_type",
|
||||||
CanonicalContentBlock::Image { .. }
|
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]
|
#[test]
|
||||||
fn pure_openai_responses_response_cross_format_blocks_raw_output_items() {
|
fn pure_openai_responses_response_cross_format_blocks_raw_output_items() {
|
||||||
let body = json!({
|
let body = json!({
|
||||||
|
|||||||
@@ -784,6 +784,58 @@ mod tests {
|
|||||||
assert!(!sse.contains("HelloHello"));
|
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]
|
#[test]
|
||||||
fn ignores_openai_responses_keepalive_events_for_chat_clients() {
|
fn ignores_openai_responses_keepalive_events_for_chat_clients() {
|
||||||
let report_context = report_context("openai:responses", "openai:chat");
|
let report_context = report_context("openai:responses", "openai:chat");
|
||||||
|
|||||||
@@ -49,12 +49,25 @@ pub fn resolve_finalize_stream_rewrite_mode(
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.trim()
|
.trim()
|
||||||
.to_ascii_lowercase();
|
.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
|
let client_api_format = report_context
|
||||||
.get("client_api_format")
|
.get("client_api_format")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.trim()
|
.trim()
|
||||||
.to_ascii_lowercase();
|
.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
|
if !needs_conversion
|
||||||
&& client_consumes_same_private_stream_envelope(
|
&& client_consumes_same_private_stream_envelope(
|
||||||
@@ -87,25 +100,30 @@ pub fn resolve_finalize_stream_rewrite_mode(
|
|||||||
return Some(FinalizeStreamRewriteMode::OpenAiImage);
|
return Some(FinalizeStreamRewriteMode::OpenAiImage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if needs_conversion {
|
if stream_needs_conversion {
|
||||||
// CPA strategy: when provider and client share the same wire format
|
// CPA strategy: when provider and client share the same wire format
|
||||||
// (exact match or same family), pass through the stream verbatim.
|
// (exact match or same family), pass through the stream verbatim.
|
||||||
// Parsing→rebuilding only adds overhead and may lose information
|
// Parsing→rebuilding only adds overhead and may lose information
|
||||||
// (encrypted_content, original item IDs, etc.).
|
// (encrypted_content, original item IDs, etc.).
|
||||||
if is_same_format_family(provider_api_format.as_str(), client_api_format.as_str()) {
|
if is_same_format_family(
|
||||||
if is_openai_responses_family(provider_api_format.as_str())
|
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())
|
&& is_openai_responses_family(client_api_format.as_str())
|
||||||
{
|
{
|
||||||
return Some(FinalizeStreamRewriteMode::OpenAiResponsesCompat);
|
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 Some(FinalizeStreamRewriteMode::ClaudeReadToolSanitize);
|
||||||
}
|
}
|
||||||
return model_directive_display_model_from_report_context(report_context)
|
return model_directive_display_model_from_report_context(report_context)
|
||||||
.map(|_| FinalizeStreamRewriteMode::ModelDirectiveDisplay);
|
.map(|_| FinalizeStreamRewriteMode::ModelDirectiveDisplay);
|
||||||
}
|
}
|
||||||
return supports_standard_stream_rewrite(
|
return supports_standard_stream_rewrite(
|
||||||
provider_api_format.as_str(),
|
provider_stream_event_api_format.as_str(),
|
||||||
client_api_format.as_str(),
|
client_api_format.as_str(),
|
||||||
)
|
)
|
||||||
.then_some(FinalizeStreamRewriteMode::Standard);
|
.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()
|
if model_directive_display_model_from_report_context(report_context).is_some()
|
||||||
&& provider_api_format == client_api_format
|
&& provider_stream_event_api_format == client_api_format
|
||||||
&& is_standard_provider_api_format(provider_api_format.as_str())
|
&& is_standard_provider_api_format(provider_stream_event_api_format.as_str())
|
||||||
&& !provider_adaptation_should_unwrap_stream_envelope(
|
&& !provider_adaptation_should_unwrap_stream_envelope(
|
||||||
envelope_name.as_str(),
|
envelope_name.as_str(),
|
||||||
provider_api_format.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::ClaudeReadToolSanitize);
|
||||||
}
|
}
|
||||||
return Some(FinalizeStreamRewriteMode::ModelDirectiveDisplay);
|
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);
|
return Some(FinalizeStreamRewriteMode::ClaudeReadToolSanitize);
|
||||||
}
|
}
|
||||||
|
|
||||||
if provider_api_format == client_api_format
|
if is_same_format_family(
|
||||||
&& is_openai_responses_family(provider_api_format.as_str())
|
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);
|
return Some(FinalizeStreamRewriteMode::OpenAiResponsesCompat);
|
||||||
}
|
}
|
||||||
@@ -1011,6 +1034,61 @@ data: {\"type\":\"response.reasoning_summary_text.delta\",\"response_id\":\"resp
|
|||||||
assert!(!output.contains("data: [DONE]"));
|
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]
|
#[test]
|
||||||
fn same_family_responses_passthrough_preserves_encrypted_content() {
|
fn same_family_responses_passthrough_preserves_encrypted_content() {
|
||||||
// When provider and client are both OpenAI Responses family,
|
// 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")
|
.get("needs_conversion")
|
||||||
.and_then(Value::as_bool)
|
.and_then(Value::as_bool)
|
||||||
.unwrap_or(false);
|
.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 =
|
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 =
|
let normalized_client_api_format =
|
||||||
normalize_openai_responses_family_api_format(&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)
|
|| !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);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -3628,7 +3632,7 @@ mod tests {
|
|||||||
maybe_build_standard_cross_format_sync_product_from_normalized_payload,
|
maybe_build_standard_cross_format_sync_product_from_normalized_payload,
|
||||||
maybe_build_standard_same_format_sync_body_from_normalized_payload,
|
maybe_build_standard_same_format_sync_body_from_normalized_payload,
|
||||||
maybe_build_standard_sync_finalize_product_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::{
|
use aether_ai_formats::formats::conversion::response::{
|
||||||
convert_claude_chat_response_to_openai_chat, convert_gemini_chat_response_to_openai_chat,
|
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"}"#);
|
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]
|
#[test]
|
||||||
fn custom_tool_call_input_events_materialize_custom_tool_call() {
|
fn custom_tool_call_input_events_materialize_custom_tool_call() {
|
||||||
let body = concat!(
|
let body = concat!(
|
||||||
@@ -5863,6 +5889,80 @@ mod tests {
|
|||||||
assert_eq!(body_json["choices"][0]["message"]["content"], "pong");
|
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]
|
#[test]
|
||||||
fn standard_same_format_gemini_finalize_rejects_usage_only_body() {
|
fn standard_same_format_gemini_finalize_rejects_usage_only_body() {
|
||||||
let report_context = json!({
|
let report_context = json!({
|
||||||
|
|||||||
Reference in New Issue
Block a user