mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
merge(main): sync latest main into security branch
This commit is contained in:
@@ -104,10 +104,25 @@ impl GeminiProviderState {
|
||||
let Some(candidate_object) = candidate.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let (response_id, response_model) = self.identity(report_context);
|
||||
let terminal_error = gemini_stream_terminal_error_payload(
|
||||
candidate_object,
|
||||
response_id.as_str(),
|
||||
response_model.as_str(),
|
||||
event_object.get("usageMetadata"),
|
||||
);
|
||||
let Some(content) = candidate_object.get("content").and_then(Value::as_object) else {
|
||||
if let Some(payload) = terminal_error {
|
||||
out.push(self.unknown_frame(report_context, payload));
|
||||
self.finished = true;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let Some(parts) = content.get("parts").and_then(Value::as_array) else {
|
||||
if let Some(payload) = terminal_error {
|
||||
out.push(self.unknown_frame(report_context, payload));
|
||||
self.finished = true;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if !parts.is_empty() {
|
||||
@@ -306,6 +321,11 @@ impl GeminiProviderState {
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(payload) = terminal_error {
|
||||
out.push(self.unknown_frame(report_context, payload));
|
||||
self.finished = true;
|
||||
continue;
|
||||
}
|
||||
if let Some(finish_reason) =
|
||||
candidate_object.get("finishReason").and_then(Value::as_str)
|
||||
{
|
||||
@@ -350,6 +370,60 @@ impl GeminiProviderState {
|
||||
}
|
||||
}
|
||||
|
||||
fn gemini_stream_terminal_error_payload(
|
||||
candidate: &Map<String, Value>,
|
||||
response_id: &str,
|
||||
model: &str,
|
||||
usage_metadata: Option<&Value>,
|
||||
) -> Option<Value> {
|
||||
let finish_reason = candidate
|
||||
.get("finishReason")
|
||||
.or_else(|| candidate.get("finish_reason"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| {
|
||||
matches!(
|
||||
*value,
|
||||
"MALFORMED_FUNCTION_CALL"
|
||||
| "UNEXPECTED_TOOL_CALL"
|
||||
| "TOO_MANY_TOOL_CALLS"
|
||||
| "MISSING_THOUGHT_SIGNATURE"
|
||||
| "MALFORMED_RESPONSE"
|
||||
)
|
||||
})?;
|
||||
let message = candidate
|
||||
.get("finishMessage")
|
||||
.or_else(|| candidate.get("finish_message"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("Gemini stream ended with {finish_reason}"));
|
||||
|
||||
let mut response = json!({
|
||||
"id": response_id,
|
||||
"object": "response",
|
||||
"model": model,
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"type": "upstream_gemini_finish_error",
|
||||
"code": finish_reason,
|
||||
"message": message,
|
||||
"upstream_status": 200
|
||||
}
|
||||
});
|
||||
if let Some(usage) = canonical_usage_from_gemini_usage(usage_metadata)
|
||||
.map(|usage| openai_responses_usage_from_usage(&usage))
|
||||
{
|
||||
response["usage"] = usage;
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
"type": "response.failed",
|
||||
"response": response
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_gemini_stream_finish_reason(value: &str) -> Option<&str> {
|
||||
match value {
|
||||
"STOP" => Some("stop"),
|
||||
@@ -1011,6 +1085,63 @@ mod tests {
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_provider_state_emits_terminal_error_for_malformed_function_call() {
|
||||
let mut state = GeminiProviderState::default();
|
||||
let report_context = json!({});
|
||||
let frames = state
|
||||
.push_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"response": {
|
||||
"responseId": "resp_malformed_tool_call",
|
||||
"modelVersion": "gemini-3.7-flash-tiered",
|
||||
"candidates": [{
|
||||
"index": 0,
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": [{
|
||||
"text": "",
|
||||
"thoughtSignature": "opaque-thought-signature"
|
||||
}]
|
||||
},
|
||||
"finishReason": "MALFORMED_FUNCTION_CALL",
|
||||
"finishMessage": "Malformed function call: Function call is empty - no input to parse."
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 206744,
|
||||
"cachedContentTokenCount": 203947,
|
||||
"thoughtsTokenCount": 1130,
|
||||
"totalTokenCount": 207874
|
||||
}
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect("malformed function call terminal should parse");
|
||||
|
||||
assert!(frames.iter().any(|frame| matches!(
|
||||
&frame.event,
|
||||
CanonicalStreamEvent::UnknownEvent(payload)
|
||||
if payload["type"] == "response.failed"
|
||||
&& payload["response"]["status"] == "failed"
|
||||
&& payload["response"]["id"] == "resp_malformed_tool_call"
|
||||
&& payload["response"]["model"] == "gemini-3.7-flash-tiered"
|
||||
&& payload["response"]["error"]["code"] == "MALFORMED_FUNCTION_CALL"
|
||||
&& payload["response"]["error"]["message"]
|
||||
== "Malformed function call: Function call is empty - no input to parse."
|
||||
&& payload["response"]["usage"]["input_tokens"] == 206744
|
||||
&& payload["response"]["usage"]["output_tokens"] == 1130
|
||||
&& payload["response"]["usage"]["total_tokens"] == 207874
|
||||
)));
|
||||
assert!(!frames
|
||||
.iter()
|
||||
.any(|frame| matches!(frame.event, CanonicalStreamEvent::Finish { .. })));
|
||||
assert!(state
|
||||
.finish(&report_context)
|
||||
.expect("finished error stream should not synthesize success")
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_provider_state_parses_function_response_as_tool_result() {
|
||||
let mut state = GeminiProviderState::default();
|
||||
|
||||
@@ -381,10 +381,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn finalization_strips_non_replayable_responses_reasoning_history() {
|
||||
let gemini_carrier =
|
||||
crate::formats::openai::responses::encode_gemini_tool_signature_carrier(
|
||||
"opaque-gemini-thought-signature",
|
||||
)
|
||||
.expect("Gemini signature carrier");
|
||||
let mut body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"input": [
|
||||
{"type": "reasoning", "id": "rs_provider_123", "summary": []},
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "rs_aether_55070860f6d45c6b8f6fa11efd9dff8a",
|
||||
"summary": [],
|
||||
"encrypted_content": gemini_carrier
|
||||
},
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "item_72d3bd8d367d01977ace23f1",
|
||||
|
||||
@@ -167,9 +167,10 @@ pub fn normalize_openai_responses_message_item_ids(body: &mut Value) -> usize {
|
||||
/// Removes reasoning history items that cannot be replayed against an OpenAI Responses backend.
|
||||
///
|
||||
/// Reasoning IDs are opaque provider references and must never be repaired by changing their
|
||||
/// prefix. Foreign IDs (for example `item_...`) are therefore removed. Aether-synthesized
|
||||
/// reasoning summaries are also removed unless they carry encrypted reasoning state that can be
|
||||
/// replayed statelessly.
|
||||
/// prefix. Foreign IDs (for example `item_...`) are therefore removed. Aether's Gemini signature
|
||||
/// carriers are also removed: they are intentionally transported through the Responses
|
||||
/// `encrypted_content` field so they can be restored on a later Gemini tool turn, but they are not
|
||||
/// OpenAI ciphertext and must never be replayed to an OpenAI/Codex backend.
|
||||
pub fn strip_incompatible_openai_responses_reasoning_items(
|
||||
body: &mut Value,
|
||||
provider_api_format: &str,
|
||||
@@ -221,6 +222,13 @@ fn openai_responses_reasoning_item_is_replayable(
|
||||
if object.get("type").and_then(Value::as_str) != Some("reasoning") {
|
||||
return true;
|
||||
}
|
||||
if object
|
||||
.get("encrypted_content")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.starts_with(GEMINI_TOOL_SIGNATURE_CARRIER_PREFIX))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if policy == OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque
|
||||
&& deepseek_opaque_reasoning_item_is_replayable(object)
|
||||
{
|
||||
@@ -475,6 +483,43 @@ mod tests {
|
||||
assert_eq!(input[2]["id"], "item_message_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_gemini_signature_carriers_before_openai_replay() {
|
||||
let gemini_item_id = openai_responses_synthetic_reasoning_item_id("resp_gemini", 0);
|
||||
let openai_item_id = openai_responses_synthetic_reasoning_item_id("resp_openai", 0);
|
||||
let carrier = encode_gemini_tool_signature_carrier_with_direction(
|
||||
"opaque-gemini-thought-signature",
|
||||
GeminiToolSignatureCarrierDirection::Next,
|
||||
)
|
||||
.expect("Gemini signature carrier");
|
||||
let mut body = json!({
|
||||
"input": [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": gemini_item_id,
|
||||
"summary": [],
|
||||
"encrypted_content": carrier
|
||||
},
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": openai_item_id,
|
||||
"summary": [],
|
||||
"encrypted_content": "provider-encrypted-state"
|
||||
},
|
||||
{"type": "reasoning", "id": "rs_provider_123", "summary": []}
|
||||
]
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
strip_incompatible_openai_responses_reasoning_items(&mut body, "openai:responses"),
|
||||
1
|
||||
);
|
||||
let input = body["input"].as_array().expect("input array");
|
||||
assert_eq!(input.len(), 2);
|
||||
assert_eq!(input[0]["encrypted_content"], "provider-encrypted-state");
|
||||
assert_eq!(input[1]["id"], "rs_provider_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_item_sanitizer_is_scoped_to_responses_targets() {
|
||||
let mut body = json!({
|
||||
|
||||
@@ -17,8 +17,9 @@ use crate::formats::shared::error_body::{
|
||||
};
|
||||
use crate::formats::shared::sse::encode_json_sse;
|
||||
use crate::formats::shared::stream_core::common::{
|
||||
decode_json_data_line, openai_stream_terminal_error_body, openai_stream_terminal_error_message,
|
||||
unsupported_stream_event_message, CanonicalStreamEvent, CanonicalStreamFrame, CanonicalUsage,
|
||||
canonical_usage_from_openai_usage, decode_json_data_line, openai_stream_terminal_error_body,
|
||||
openai_stream_terminal_error_message, unsupported_stream_event_message, CanonicalStreamEvent,
|
||||
CanonicalStreamFrame, CanonicalUsage,
|
||||
};
|
||||
use crate::formats::shared::AiSurfaceFinalizeError;
|
||||
|
||||
@@ -134,7 +135,11 @@ impl StreamingStandardFormatMatrix {
|
||||
}
|
||||
if let CanonicalStreamEvent::UnknownEvent(payload) = &frame.event {
|
||||
self.terminated = true;
|
||||
out.extend(client.emit_unknown_event(payload)?);
|
||||
if openai_stream_terminal_error_body(payload).is_some() {
|
||||
out.extend(client.emit_terminal_error_frame(frame)?);
|
||||
} else {
|
||||
out.extend(client.emit_unknown_event(payload)?);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if let CanonicalStreamEvent::OpenAiResponsesOutputItem { raw_event, .. } = &frame.event
|
||||
@@ -368,6 +373,10 @@ impl StreamingStandardTerminalObserver {
|
||||
summary.observed_finish = true;
|
||||
summary.finish_reason = Some("error".to_string());
|
||||
summary.parser_error = openai_stream_terminal_error_message(&payload);
|
||||
summary.standardized_usage = payload
|
||||
.pointer("/response/usage")
|
||||
.and_then(|usage| canonical_usage_from_openai_usage(Some(usage)))
|
||||
.map(standardized_usage_from_canonical);
|
||||
}
|
||||
CanonicalStreamEvent::UnknownEvent(_) => {
|
||||
summary.unknown_event_count = summary.unknown_event_count.saturating_add(1);
|
||||
@@ -613,6 +622,44 @@ impl ClientStreamEmitter {
|
||||
self.emit_error(error_body)
|
||||
}
|
||||
|
||||
fn emit_terminal_error_frame(
|
||||
&mut self,
|
||||
frame: CanonicalStreamFrame,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
if matches!(
|
||||
self,
|
||||
ClientStreamEmitter::OpenAIChat(_) | ClientStreamEmitter::OpenAIResponses(_)
|
||||
) {
|
||||
return self.emit(frame);
|
||||
}
|
||||
let CanonicalStreamEvent::UnknownEvent(payload) = frame.event else {
|
||||
return self.emit(frame);
|
||||
};
|
||||
let Some(source_error_body) = openai_stream_terminal_error_body(&payload) else {
|
||||
return self.emit_unknown_event(&payload);
|
||||
};
|
||||
let Some(error) = source_error_body.get("error") else {
|
||||
return self.emit_unknown_event(&payload);
|
||||
};
|
||||
let message = error
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Upstream stream ended with an error");
|
||||
let code = error.get("code").and_then(|value| match value {
|
||||
Value::String(value) => Some(value.as_str()),
|
||||
_ => None,
|
||||
});
|
||||
let Some(error_body) = build_core_error_body_for_client_format(
|
||||
self.api_format(),
|
||||
message,
|
||||
code,
|
||||
LocalCoreSyncErrorKind::ServerError,
|
||||
) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
self.emit_error(error_body)
|
||||
}
|
||||
|
||||
fn emit_unsupported_finish_reason(
|
||||
&mut self,
|
||||
finish_reason: &str,
|
||||
@@ -811,7 +858,13 @@ mod tests {
|
||||
},
|
||||
"finishReason": "MALFORMED_FUNCTION_CALL",
|
||||
"finishMessage": "Malformed function call: Function call is empty - no input to parse."
|
||||
}]
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 206744,
|
||||
"cachedContentTokenCount": 203947,
|
||||
"thoughtsTokenCount": 1130,
|
||||
"totalTokenCount": 207874
|
||||
}
|
||||
},
|
||||
"responseId": "resp_malformed_tool_call"
|
||||
})),
|
||||
@@ -824,14 +877,105 @@ mod tests {
|
||||
.expect("Gemini terminal frame should produce a summary");
|
||||
|
||||
assert!(summary.observed_finish);
|
||||
assert_eq!(
|
||||
summary.finish_reason.as_deref(),
|
||||
Some("MALFORMED_FUNCTION_CALL")
|
||||
);
|
||||
assert_eq!(summary.finish_reason.as_deref(), Some("error"));
|
||||
assert_eq!(
|
||||
summary.parser_error.as_deref(),
|
||||
Some("unsupported provider stream finish reason: MALFORMED_FUNCTION_CALL")
|
||||
Some("Malformed function call: Function call is empty - no input to parse.")
|
||||
);
|
||||
let usage = summary
|
||||
.standardized_usage
|
||||
.expect("failed Gemini terminal should preserve usage");
|
||||
assert_eq!(usage.input_tokens, 206744);
|
||||
assert_eq!(usage.output_tokens, 1130);
|
||||
assert_eq!(usage.cache_read_tokens, 203947);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streams_gemini_thought_text_to_openai_responses_immediately() {
|
||||
let context = report_context("gemini:generate_content", "openai:responses");
|
||||
let mut matrix = StreamingStandardFormatMatrix::default();
|
||||
let output = matrix
|
||||
.transform_line(
|
||||
&context,
|
||||
data_line(json!({
|
||||
"response": {
|
||||
"responseId": "resp_reasoning_123",
|
||||
"modelVersion": "gemini-3.7-flash-tiered",
|
||||
"candidates": [{
|
||||
"index": 0,
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": [{"thought": true, "text": "checking"}]
|
||||
}
|
||||
}]
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect("first Gemini thought chunk should transform");
|
||||
let sse = String::from_utf8(output).expect("reasoning SSE should be utf8");
|
||||
|
||||
assert!(
|
||||
sse.contains("event: response.reasoning_summary_text.delta\n"),
|
||||
"{sse}"
|
||||
);
|
||||
assert!(sse.contains("\"delta\":\"checking\""), "{sse}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_malformed_gemini_function_call_to_responses_failed() {
|
||||
let context = report_context("gemini:generate_content", "openai:responses");
|
||||
let mut matrix = StreamingStandardFormatMatrix::default();
|
||||
let output = matrix
|
||||
.transform_line(
|
||||
&context,
|
||||
data_line(json!({
|
||||
"response": {
|
||||
"responseId": "resp_malformed_tool_call",
|
||||
"modelVersion": "gemini-3.7-flash-tiered",
|
||||
"candidates": [{
|
||||
"index": 0,
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": [{
|
||||
"text": "",
|
||||
"thoughtSignature": "opaque-thought-signature"
|
||||
}]
|
||||
},
|
||||
"finishReason": "MALFORMED_FUNCTION_CALL",
|
||||
"finishMessage": "Malformed function call: Function call is empty - no input to parse."
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 206744,
|
||||
"cachedContentTokenCount": 203947,
|
||||
"thoughtsTokenCount": 1130,
|
||||
"totalTokenCount": 207874
|
||||
}
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect("malformed Gemini terminal should transform to a stream error");
|
||||
let sse = String::from_utf8(output).expect("failed response SSE should be utf8");
|
||||
|
||||
assert!(sse.contains("event: response.failed\n"), "{sse}");
|
||||
assert!(sse.contains("\"type\":\"response.failed\""), "{sse}");
|
||||
assert!(
|
||||
sse.contains("\"code\":\"MALFORMED_FUNCTION_CALL\""),
|
||||
"{sse}"
|
||||
);
|
||||
assert!(
|
||||
sse.contains(
|
||||
"\"message\":\"Malformed function call: Function call is empty - no input to parse.\""
|
||||
),
|
||||
"{sse}"
|
||||
);
|
||||
assert!(sse.contains("\"input_tokens\":206744"), "{sse}");
|
||||
assert!(sse.contains("\"output_tokens\":1130"), "{sse}");
|
||||
assert!(sse.contains("\"cached_tokens\":203947"), "{sse}");
|
||||
assert!(!sse.contains("unsupported_finish_reason"), "{sse}");
|
||||
assert!(matrix
|
||||
.finish(&context)
|
||||
.expect("failed matrix should stay terminated")
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -471,6 +471,20 @@ pub fn maybe_build_standard_sync_finalize_product_from_normalized_payload(
|
||||
};
|
||||
let body_base64 = body_base64.or(capture_stream_body_base64.as_deref());
|
||||
|
||||
// Cross-format sync attempts can contain raw bytes because the plan requested a stream even
|
||||
// though the provider returned one complete JSON response. Do not feed that response into an
|
||||
// SSE aggregator. Capture envelopes and same-format responses retain their existing precedence.
|
||||
let non_stream_capture_body_json =
|
||||
if capture_envelope_used || !sync_finalize_needs_conversion(report_context) {
|
||||
None
|
||||
} else {
|
||||
body_base64.and_then(decode_non_stream_sync_capture_body)
|
||||
};
|
||||
let (body_json, body_base64) = match non_stream_capture_body_json.as_ref() {
|
||||
Some(capture_body_json) => (body_json.or(Some(capture_body_json)), None),
|
||||
None => (body_json, body_base64),
|
||||
};
|
||||
|
||||
if let Some(body_json) = maybe_build_standard_same_format_sync_body_from_normalized_payload(
|
||||
report_kind,
|
||||
status_code,
|
||||
@@ -1011,6 +1025,48 @@ fn maybe_build_openai_cross_format_provider_body_from_normalized_payload(
|
||||
}))
|
||||
}
|
||||
|
||||
fn sync_finalize_needs_conversion(report_context: Option<&Value>) -> bool {
|
||||
report_context
|
||||
.and_then(|report_context| report_context.get("needs_conversion"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn decode_non_stream_sync_capture_body(body_base64: &str) -> Option<Value> {
|
||||
let body_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(body_base64)
|
||||
.ok()?;
|
||||
serde_json::from_slice::<Value>(&body_bytes)
|
||||
.ok()
|
||||
.filter(Value::is_object)
|
||||
.filter(|body_json| !is_stream_event_object(body_json))
|
||||
}
|
||||
|
||||
/// Unframed JSON events are accepted by the stream parsers and must not be mistaken for complete
|
||||
/// provider response bodies merely because the entire capture parses as one JSON object.
|
||||
fn is_stream_event_object(value: &Value) -> bool {
|
||||
let Some(object) = value.as_object() else {
|
||||
return false;
|
||||
};
|
||||
if object
|
||||
.get("object")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|object| object.ends_with(".chunk"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|event_type| {
|
||||
event_type.contains('.')
|
||||
|| ["response", "message", "item", "delta", "content_block"]
|
||||
.iter()
|
||||
.any(|nested| object.contains_key(*nested))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_error_like_sync_body(value: &Value) -> bool {
|
||||
let Some(object) = value.as_object() else {
|
||||
return false;
|
||||
@@ -3979,7 +4035,8 @@ mod tests {
|
||||
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
|
||||
aggregate_openai_chat_stream_sync_response,
|
||||
aggregate_openai_responses_stream_sync_response, convert_standard_chat_response,
|
||||
convert_standard_cli_response, materialize_openai_responses_reasoning_item,
|
||||
convert_standard_cli_response, decode_non_stream_sync_capture_body,
|
||||
materialize_openai_responses_reasoning_item,
|
||||
maybe_build_openai_chat_cross_format_sync_product_from_normalized_payload,
|
||||
maybe_build_openai_responses_cross_format_sync_product_from_normalized_payload,
|
||||
maybe_build_openai_responses_same_family_sync_body_from_normalized_payload,
|
||||
@@ -4527,6 +4584,132 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unframed_stream_events_are_not_mistaken_for_provider_bodies() {
|
||||
for event in [
|
||||
json!({"type": "response.completed", "response": {"status": "completed"}}),
|
||||
json!({"type": "response.output_text.delta", "delta": "hi"}),
|
||||
json!({"type": "message_start", "message": {"id": "msg_1"}}),
|
||||
json!({"type": "content_block_delta", "index": 0, "delta": {"text": "hi"}}),
|
||||
json!({"object": "chat.completion.chunk", "choices": []}),
|
||||
] {
|
||||
let body_base64 = base64::engine::general_purpose::STANDARD
|
||||
.encode(serde_json::to_vec(&event).expect("serialize event"));
|
||||
assert!(
|
||||
decode_non_stream_sync_capture_body(&body_base64).is_none(),
|
||||
"stream events belong to the aggregators: {event}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_provider_bodies_are_recovered_from_cross_format_captures() {
|
||||
for body in [
|
||||
json!({"id": "resp_1", "object": "response", "status": "completed", "output": []}),
|
||||
json!({"id": "chatcmpl_1", "object": "chat.completion", "choices": []}),
|
||||
json!({"id": "msg_1", "type": "message", "role": "assistant", "content": []}),
|
||||
json!({"candidates": [], "modelVersion": "probe-model"}),
|
||||
] {
|
||||
let body_base64 = base64::engine::general_purpose::STANDARD
|
||||
.encode(serde_json::to_vec(&body).expect("serialize provider body"));
|
||||
assert_eq!(
|
||||
decode_non_stream_sync_capture_body(&body_base64),
|
||||
Some(body.clone()),
|
||||
"a complete provider body is not a stream: {body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_cross_format_capture_that_is_a_complete_json_body() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "openai:responses",
|
||||
"client_api_format": "claude:messages",
|
||||
"needs_conversion": true,
|
||||
"upstream_is_stream": true,
|
||||
});
|
||||
let provider_body_json = json!({
|
||||
"id": "resp_1",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"error": null,
|
||||
"model": "probe-model",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "hello"}]
|
||||
}],
|
||||
"usage": {"input_tokens": 5, "output_tokens": 7, "total_tokens": 12}
|
||||
});
|
||||
let body_base64 = base64::engine::general_purpose::STANDARD
|
||||
.encode(serde_json::to_vec(&provider_body_json).expect("serialize provider body"));
|
||||
|
||||
let product = maybe_build_standard_sync_finalize_product_from_normalized_payload(
|
||||
"claude_chat_sync_finalize",
|
||||
200,
|
||||
Some(&report_context),
|
||||
None,
|
||||
Some(&body_base64),
|
||||
)
|
||||
.expect("a complete provider body must not fail the stream aggregator")
|
||||
.expect("product should exist");
|
||||
|
||||
let StandardSyncFinalizeNormalizedProduct::CrossFormat(product) = product else {
|
||||
panic!("cross-format attempt should produce a cross-format product");
|
||||
};
|
||||
assert_eq!(product.provider_body_json, provider_body_json);
|
||||
assert_eq!(product.client_body_json["type"], "message");
|
||||
assert_eq!(product.client_body_json["content"][0]["text"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_unframed_stream_event_on_the_aggregation_path() {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "openai:responses",
|
||||
"client_api_format": "claude:messages",
|
||||
"needs_conversion": true,
|
||||
"upstream_is_stream": true,
|
||||
});
|
||||
let provider_body_json = json!({
|
||||
"id": "resp_1",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"model": "probe-model",
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "hello"}]
|
||||
}],
|
||||
"usage": {"input_tokens": 5, "output_tokens": 7, "total_tokens": 12}
|
||||
});
|
||||
let event = json!({
|
||||
"type": "response.completed",
|
||||
"response": provider_body_json.clone(),
|
||||
});
|
||||
let body_base64 = base64::engine::general_purpose::STANDARD
|
||||
.encode(serde_json::to_vec(&event).expect("serialize stream event"));
|
||||
|
||||
let product = maybe_build_standard_sync_finalize_product_from_normalized_payload(
|
||||
"claude_chat_sync_finalize",
|
||||
200,
|
||||
Some(&report_context),
|
||||
None,
|
||||
Some(&body_base64),
|
||||
)
|
||||
.expect("unframed stream event should aggregate")
|
||||
.expect("product should exist");
|
||||
|
||||
let StandardSyncFinalizeNormalizedProduct::CrossFormat(product) = product else {
|
||||
panic!("cross-format attempt should produce a cross-format product");
|
||||
};
|
||||
assert_eq!(product.provider_body_json["id"], provider_body_json["id"]);
|
||||
assert_eq!(product.provider_body_json["object"], "response");
|
||||
assert!(product.provider_body_json.get("response").is_none());
|
||||
assert_eq!(product.client_body_json["type"], "message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_standard_same_format_body_from_stream_payload() {
|
||||
let body = concat!(
|
||||
|
||||
Reference in New Issue
Block a user