mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-17 00:17:46 +08:00
fix(conversion): improve stream failures and diagnostic exports
This commit is contained in:
@@ -238,3 +238,124 @@ impl fmt::Display for FormatError {
|
||||
}
|
||||
|
||||
impl Error for FormatError {}
|
||||
|
||||
impl FormatError {
|
||||
pub fn diagnostic(&self) -> Value {
|
||||
let (code, operation, field, reason) = match self {
|
||||
Self::UnsupportedFormat(_) => ("unsupported_format", "select_format", None, None),
|
||||
Self::RequestParseFailed { .. } => {
|
||||
("request_parse_failed", "parse_request", None, None)
|
||||
}
|
||||
Self::RequestEmitFailed { .. } => ("request_emit_failed", "emit_request", None, None),
|
||||
Self::ResponseParseFailed { .. } => {
|
||||
("response_parse_failed", "parse_response", None, None)
|
||||
}
|
||||
Self::ResponseEmitFailed { .. } => {
|
||||
("response_emit_failed", "emit_response", None, None)
|
||||
}
|
||||
Self::UnsupportedField { field, reason, .. } => {
|
||||
("unsupported_field", "validate", Some(field), Some(reason))
|
||||
}
|
||||
Self::UnauditedField { field, reason, .. } => {
|
||||
("unaudited_field", "validate", Some(field), Some(reason))
|
||||
}
|
||||
Self::InvalidEnumValue { field, .. } => {
|
||||
("invalid_enum_value", "validate", Some(field), None)
|
||||
}
|
||||
Self::LossyConversionBlocked { field, reason, .. } => (
|
||||
"lossy_conversion_blocked",
|
||||
"convert",
|
||||
Some(field),
|
||||
Some(reason),
|
||||
),
|
||||
Self::InvalidTargetField { field, reason, .. } => (
|
||||
"invalid_target_field",
|
||||
"validate_target",
|
||||
Some(field),
|
||||
Some(reason),
|
||||
),
|
||||
};
|
||||
let path = field.map(|field| {
|
||||
let path = if field.starts_with('$') {
|
||||
field.clone()
|
||||
} else {
|
||||
format!("$.{field}")
|
||||
};
|
||||
path.replace("[]", "[*]")
|
||||
});
|
||||
let mut diagnostic = json!({
|
||||
"code": code,
|
||||
"operation": operation,
|
||||
"path": path.as_deref().unwrap_or("$"),
|
||||
"path_source": if path.is_some() { "structured" } else { "unavailable" },
|
||||
"reason": reason,
|
||||
"expected": reason,
|
||||
"actual": null,
|
||||
"missing_context": if path.is_some() { vec![] } else { vec!["field_path", "underlying_cause"] }
|
||||
});
|
||||
if let Self::InvalidEnumValue { value, .. } = self {
|
||||
diagnostic["actual"] = json!(value);
|
||||
}
|
||||
match self {
|
||||
Self::UnsupportedFormat(format)
|
||||
| Self::RequestParseFailed { format }
|
||||
| Self::RequestEmitFailed { format }
|
||||
| Self::ResponseParseFailed { format }
|
||||
| Self::ResponseEmitFailed { format }
|
||||
| Self::UnsupportedField { format, .. }
|
||||
| Self::InvalidEnumValue { format, .. }
|
||||
| Self::InvalidTargetField { format, .. } => diagnostic["format"] = json!(format),
|
||||
_ => {}
|
||||
}
|
||||
if let Self::UnauditedField {
|
||||
source_format,
|
||||
target_format,
|
||||
..
|
||||
}
|
||||
| Self::LossyConversionBlocked {
|
||||
source_format,
|
||||
target_format,
|
||||
..
|
||||
} = self
|
||||
{
|
||||
diagnostic["source_format"] = json!(source_format);
|
||||
diagnostic["target_format"] = json!(target_format);
|
||||
}
|
||||
diagnostic
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod diagnostic_tests {
|
||||
use super::FormatError;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn enum_diagnostic_retains_full_path_and_actual_value() {
|
||||
let diagnostic = FormatError::InvalidEnumValue {
|
||||
format: "openai:chat".to_string(),
|
||||
field: "choices[].finish_reason".to_string(),
|
||||
value: "future_reason".to_string(),
|
||||
}
|
||||
.diagnostic();
|
||||
assert_eq!(diagnostic["code"], "invalid_enum_value");
|
||||
assert_eq!(diagnostic["path"], "$.choices[*].finish_reason");
|
||||
assert_eq!(diagnostic["actual"], "future_reason");
|
||||
assert_eq!(diagnostic["format"], "openai:chat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_parse_failure_reports_missing_cause_without_a_fake_path() {
|
||||
let diagnostic = FormatError::ResponseParseFailed {
|
||||
format: "claude:messages".to_string(),
|
||||
}
|
||||
.diagnostic();
|
||||
assert_eq!(diagnostic["code"], "response_parse_failed");
|
||||
assert_eq!(diagnostic["operation"], "parse_response");
|
||||
assert_eq!(diagnostic["path_source"], "unavailable");
|
||||
assert_eq!(
|
||||
diagnostic["missing_context"],
|
||||
json!(["field_path", "underlying_cause"])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ use crate::formats::shared::stream_core::common::{
|
||||
};
|
||||
use crate::formats::shared::AiSurfaceFinalizeError;
|
||||
|
||||
const PROVIDER_STREAM_FINISH_ERROR_MESSAGE: &str =
|
||||
"Upstream stream ended with finish reason: error";
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct StreamingStandardFormatMatrix {
|
||||
provider: Option<ProviderStreamParser>,
|
||||
@@ -129,7 +132,7 @@ impl StreamingStandardFormatMatrix {
|
||||
{
|
||||
if !canonical_stream_finish_reason_is_supported(finish_reason) {
|
||||
self.terminated = true;
|
||||
out.extend(client.emit_unsupported_finish_reason(finish_reason)?);
|
||||
out.extend(client.emit_finish_reason_error(finish_reason)?);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -388,7 +391,13 @@ impl StreamingStandardTerminalObserver {
|
||||
if let Some(parser_error) = finish_reason
|
||||
.as_deref()
|
||||
.filter(|reason| !canonical_stream_finish_reason_is_supported(reason))
|
||||
.map(|reason| format!("unsupported provider stream finish reason: {reason}"))
|
||||
.map(|reason| {
|
||||
if reason.trim() == "error" {
|
||||
PROVIDER_STREAM_FINISH_ERROR_MESSAGE.to_string()
|
||||
} else {
|
||||
format!("unsupported provider stream finish reason: {reason}")
|
||||
}
|
||||
})
|
||||
{
|
||||
summary.parser_error.get_or_insert(parser_error);
|
||||
}
|
||||
@@ -660,17 +669,28 @@ impl ClientStreamEmitter {
|
||||
self.emit_error(error_body)
|
||||
}
|
||||
|
||||
fn emit_unsupported_finish_reason(
|
||||
fn emit_finish_reason_error(
|
||||
&mut self,
|
||||
finish_reason: &str,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
let (message, code) = if finish_reason.trim() == "error" {
|
||||
(
|
||||
PROVIDER_STREAM_FINISH_ERROR_MESSAGE.to_string(),
|
||||
"stream_terminal_error",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"Unsupported provider stream finish reason cannot be converted losslessly: field $.finish_reason = {}",
|
||||
serde_json::json!(finish_reason)
|
||||
),
|
||||
"unsupported_finish_reason",
|
||||
)
|
||||
};
|
||||
let Some(error_body) = build_core_error_body_for_client_format(
|
||||
self.api_format(),
|
||||
&format!(
|
||||
"Unsupported provider stream finish reason cannot be converted losslessly: field $.finish_reason = {}",
|
||||
serde_json::json!(finish_reason)
|
||||
),
|
||||
Some("unsupported_finish_reason"),
|
||||
&message,
|
||||
Some(code),
|
||||
LocalCoreSyncErrorKind::ServerError,
|
||||
) else {
|
||||
return Ok(Vec::new());
|
||||
@@ -2121,6 +2141,163 @@ mod tests {
|
||||
assert!(sse.contains("\"stop_reason\":\"tool_use\""), "{sse}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_observer_treats_claude_error_finish_reason_as_upstream_failure() {
|
||||
for upstream_message in [None, Some("Provider temporarily overloaded")] {
|
||||
let context = report_context("claude:messages", "claude:messages");
|
||||
let mut observer = StreamingStandardTerminalObserver::default();
|
||||
observer
|
||||
.push_line(
|
||||
&context,
|
||||
data_line(json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": "msg_error_finish",
|
||||
"model": "claude-sonnet-4-5",
|
||||
"usage": {
|
||||
"input_tokens": 22,
|
||||
"cache_read_input_tokens": 7,
|
||||
"cache_creation_input_tokens": 3,
|
||||
"cache_creation": { "ephemeral_5m_input_tokens": 3 }
|
||||
}
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect("message start should be observed");
|
||||
if let Some(message) = upstream_message {
|
||||
observer
|
||||
.push_line(
|
||||
&context,
|
||||
data_line(json!({
|
||||
"type": "error",
|
||||
"error": { "type": "overloaded_error", "message": message }
|
||||
})),
|
||||
)
|
||||
.expect("upstream error should be observed");
|
||||
}
|
||||
observer
|
||||
.push_line(
|
||||
&context,
|
||||
data_line(json!({
|
||||
"type": "message_delta",
|
||||
"delta": { "stop_reason": "error" },
|
||||
"usage": { "output_tokens": 5 }
|
||||
})),
|
||||
)
|
||||
.expect("error finish reason should be observed");
|
||||
let summary = observer
|
||||
.finish(&context)
|
||||
.expect("terminal observation should finish")
|
||||
.expect("failed stream should have a summary");
|
||||
|
||||
assert!(summary.observed_finish);
|
||||
assert_eq!(summary.finish_reason.as_deref(), Some("error"));
|
||||
assert_eq!(
|
||||
summary.parser_error.as_deref(),
|
||||
Some(upstream_message.unwrap_or("Upstream stream ended with finish reason: error"))
|
||||
);
|
||||
let usage = summary
|
||||
.standardized_usage
|
||||
.expect("usage should be retained");
|
||||
assert_eq!(usage.input_tokens, 22);
|
||||
assert_eq!(usage.output_tokens, 5);
|
||||
assert_eq!(usage.cache_read_tokens, 7);
|
||||
assert_eq!(usage.cache_creation_tokens, 3);
|
||||
assert_eq!(usage.cache_creation_ephemeral_5m_tokens, 3);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_claude_error_finish_reason_to_terminal_errors() {
|
||||
let cases = [
|
||||
(
|
||||
"openai:chat",
|
||||
"data: {\"error\":",
|
||||
"\"code\":\"stream_terminal_error\"",
|
||||
),
|
||||
(
|
||||
"openai:responses",
|
||||
"event: response.failed\n",
|
||||
"\"code\":\"stream_terminal_error\"",
|
||||
),
|
||||
(
|
||||
"claude:messages",
|
||||
"event: error\n",
|
||||
"\"code\":\"stream_terminal_error\"",
|
||||
),
|
||||
(
|
||||
"gemini:generate_content",
|
||||
"data: {\"error\":",
|
||||
"\"status\":\"INTERNAL\"",
|
||||
),
|
||||
];
|
||||
for (client_api_format, prefix, marker) in cases {
|
||||
let context = report_context("claude:messages", client_api_format);
|
||||
let mut matrix = StreamingStandardFormatMatrix::default();
|
||||
let mut output = matrix
|
||||
.transform_line(
|
||||
&context,
|
||||
data_line(json!({
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": { "type": "text_delta", "text": "Partial answer" }
|
||||
})),
|
||||
)
|
||||
.expect("partial response should be emitted");
|
||||
output.extend(
|
||||
matrix
|
||||
.transform_line(
|
||||
&context,
|
||||
data_line(json!({
|
||||
"type": "message_delta",
|
||||
"delta": { "stop_reason": "error" },
|
||||
"usage": { "output_tokens": 5 }
|
||||
})),
|
||||
)
|
||||
.expect("error finish reason should emit a terminal error"),
|
||||
);
|
||||
let sse = String::from_utf8(output).expect("sse should be utf8");
|
||||
|
||||
assert!(sse.contains("Partial answer"), "{client_api_format}: {sse}");
|
||||
assert!(sse.contains(prefix), "{client_api_format}: {sse}");
|
||||
assert!(sse.contains(marker), "{client_api_format}: {sse}");
|
||||
assert!(
|
||||
sse.contains("Upstream stream ended with finish reason: error"),
|
||||
"{client_api_format}: {sse}"
|
||||
);
|
||||
assert!(
|
||||
!sse.contains("unsupported_finish_reason"),
|
||||
"{client_api_format}: {sse}"
|
||||
);
|
||||
assert!(
|
||||
!sse.contains("response.completed"),
|
||||
"{client_api_format}: {sse}"
|
||||
);
|
||||
assert!(
|
||||
!sse.contains("\"stop_reason\":\"end_turn\""),
|
||||
"{client_api_format}: {sse}"
|
||||
);
|
||||
assert!(
|
||||
!sse.contains("\"finish_reason\":\"stop\""),
|
||||
"{client_api_format}: {sse}"
|
||||
);
|
||||
assert!(matrix
|
||||
.transform_line(
|
||||
&context,
|
||||
data_line(json!({
|
||||
"type": "message_delta",
|
||||
"delta": { "stop_reason": "end_turn" }
|
||||
}))
|
||||
)
|
||||
.expect("events after the error should be ignored")
|
||||
.is_empty());
|
||||
assert!(matrix
|
||||
.finish(&context)
|
||||
.expect("failed matrix should stay terminated")
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_unknown_stream_finish_reasons_to_visible_client_errors() {
|
||||
let cases = [
|
||||
|
||||
@@ -34,6 +34,7 @@ pub struct CandidateFailureDiagnostic {
|
||||
client_api_format: Option<String>,
|
||||
provider_api_format: Option<String>,
|
||||
safe_to_show: bool,
|
||||
details: Option<Value>,
|
||||
}
|
||||
|
||||
impl CandidateFailureDiagnostic {
|
||||
@@ -50,6 +51,7 @@ impl CandidateFailureDiagnostic {
|
||||
client_api_format: None,
|
||||
provider_api_format: None,
|
||||
safe_to_show: true,
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +60,11 @@ impl CandidateFailureDiagnostic {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn details(mut self, details: Value) -> Self {
|
||||
self.details = Some(details);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn formats(
|
||||
mut self,
|
||||
client_api_format: impl Into<String>,
|
||||
@@ -219,6 +226,10 @@ impl CandidateFailureDiagnostic {
|
||||
"client_api_format": self.client_api_format,
|
||||
"provider_api_format": self.provider_api_format,
|
||||
"safe_to_show": self.safe_to_show,
|
||||
"details": self.details,
|
||||
"stage": "request",
|
||||
"source_format": self.client_api_format,
|
||||
"target_format": self.provider_api_format,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +224,7 @@ fn diagnostic_from_format_error(
|
||||
format_error_path(error),
|
||||
format_error_message(error, client_api_format, provider_api_format),
|
||||
)
|
||||
.details(error.diagnostic())
|
||||
}
|
||||
|
||||
fn format_error_path(error: &FormatError) -> String {
|
||||
@@ -982,6 +983,23 @@ mod tests {
|
||||
"request_conversion"
|
||||
);
|
||||
assert_eq!(diagnostic["failure_diagnostic"]["path"], "$.n");
|
||||
assert_eq!(diagnostic["failure_diagnostic"]["stage"], "request");
|
||||
assert_eq!(
|
||||
diagnostic["failure_diagnostic"]["details"]["code"],
|
||||
"lossy_conversion_blocked"
|
||||
);
|
||||
assert_eq!(
|
||||
diagnostic["failure_diagnostic"]["details"]["path_source"],
|
||||
"structured"
|
||||
);
|
||||
assert_eq!(
|
||||
diagnostic["failure_diagnostic"]["source_format"],
|
||||
"openai:chat"
|
||||
);
|
||||
assert_eq!(
|
||||
diagnostic["failure_diagnostic"]["target_format"],
|
||||
"openai:responses"
|
||||
);
|
||||
assert_eq!(diagnostic["request_conversion_error"]["path"], "$.n");
|
||||
assert!(diagnostic["failure_diagnostic"]["message"]
|
||||
.as_str()
|
||||
|
||||
Reference in New Issue
Block a user