fix(conversion): improve stream failures and diagnostic exports

This commit is contained in:
elky
2026-09-08 21:04:22 +08:00
parent 17d01d7fe0
commit 99f6499b2b
19 changed files with 1378 additions and 66 deletions
@@ -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()