fix(streaming): skip metadata-only SSE frames instead of failing the turn (#10942)

Signed-off-by: Alexis Rohou <arohou@users.noreply.github.com>
Co-authored-by: Alexis Rohou <a.rohou@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alexis Rohou
2026-08-09 21:32:23 -07:00
committed by GitHub
parent e282631cbc
commit 1844d3fb4a
@@ -1056,7 +1056,101 @@ fn strip_data_prefix(line: &str) -> Option<&str> {
.map(|s| s.trim())
}
fn parse_streaming_chunk(line: &str) -> Result<StreamingChunk, ProviderError> {
/// Longest error text pulled out of a stream frame, so a pathological payload cannot be
/// pasted wholesale into a user-facing message.
const MAX_STREAM_ERROR_LEN: usize = 500;
/// Best-effort human-readable text for an error payload that may not be a plain string.
///
/// FastAPI reports `HTTPException` as `{"detail": "..."}` but `RequestValidationError` as
/// `{"detail": [{"loc": [...], "msg": "field required", ...}]}`, so a string-only read would
/// drop the commoner validation shape entirely.
fn stream_error_text(value: &Value) -> Option<String> {
fn one(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Object(_) => value
.get("msg")
.or_else(|| value.get("message"))
.and_then(|m| m.as_str().map(String::from))
.or_else(|| Some(value.to_string())),
Value::Null => None,
other => Some(other.to_string()),
}
}
let text = match value {
Value::Array(items) => {
let parts: Vec<String> = items.iter().filter_map(one).collect();
if parts.is_empty() {
return None;
}
parts.join("; ")
}
other => one(other)?,
};
if text.is_empty() {
return None;
}
if text.chars().count() > MAX_STREAM_ERROR_LEN {
let truncated: String = text.chars().take(MAX_STREAM_ERROR_LEN).collect();
return Some(format!("{truncated}"));
}
Some(text)
}
/// Decide whether a choice-less SSE frame reports an in-stream failure.
///
/// Returns `Some(err)` when it does, `None` when it is gateway metadata that can be skipped.
///
/// Requires an actual error *signal* — a `status`/`statusCode`/`code` of 400 or above, a
/// `type` of `"error"`, or a `detail` field, which has no benign meaning in this position.
/// Mere prose is not enough: gateways also emit informational frames, and treating
/// `{"message": "processing"}` as a failure would kill a healthy stream, which is the very
/// bug this skip exists to avoid. The converse matters just as much — a gateway that
/// rate-limits with a bare `{"statusCode": 429, "message": …}` on an HTTP 200 must not be
/// silently skipped, or a failed turn is reported as an empty successful one.
fn classify_choiceless_frame(value: &Value) -> Option<ProviderError> {
let status = ["status", "statusCode", "code"].iter().find_map(|key| {
let raw = value.get(*key)?;
raw.as_i64()
.or_else(|| raw.as_str().and_then(|s| s.parse::<i64>().ok()))
});
let has_error_signal = status.is_some_and(|s| s >= 400)
|| value.get("type").and_then(|t| t.as_str()) == Some("error")
|| value.get("detail").is_some_and(|d| !d.is_null());
if !has_error_signal {
return None;
}
let details = value
.get("message")
.and_then(stream_error_text)
.or_else(|| value.get("detail").and_then(stream_error_text))
.or_else(|| value.get("error").and_then(stream_error_text))
// A status with no recoverable text must still be loud rather than vanish.
.unwrap_or_else(|| match status {
Some(s) => format!("Gateway returned status {s} mid-stream"),
None => "Unknown server error".to_string(),
});
Some(ProviderError::ServerError(details))
}
/// Parse one SSE `data:` payload.
///
/// Returns `Ok(None)` for a metadata-only frame — a JSON object with no `choices` key at
/// all. Gateways interleave these with the real chunks: Portkey/Azure APIM and friends
/// emit trace/guardrail objects such as `{"hook_results": {...}}` before the first token.
/// They carry nothing this parser consumes, so they are skipped rather than failed on;
/// treating them as decode errors kills the whole turn on an otherwise healthy stream.
///
/// A frame with `"choices": []` is NOT metadata — that is the standard usage-only chunk,
/// so it still deserializes and flows through the empty-choices paths below.
///
/// A choice-less frame that reports an in-stream failure is NOT metadata either — see
/// `classify_choiceless_frame`.
fn parse_streaming_chunk(line: &str) -> Result<Option<StreamingChunk>, ProviderError> {
let value: Value = serde_json::from_str(line).map_err(|e| {
ProviderError::stream_decode_error(format!(
"Failed to parse streaming chunk: {e}: {line:?}"
@@ -1079,7 +1173,17 @@ fn parse_streaming_chunk(line: &str) -> Result<StreamingChunk, ProviderError> {
return Err(ProviderError::ServerError(message.to_string()));
}
serde_json::from_value(value).map_err(|e| {
if value
.as_object()
.is_some_and(|o| !o.contains_key("choices"))
{
if let Some(err) = classify_choiceless_frame(&value) {
return Err(err);
}
return Ok(None);
}
serde_json::from_value(value).map(Some).map_err(|e| {
ProviderError::stream_decode_error(format!(
"Failed to parse streaming chunk: {e}: {line:?}"
))
@@ -1131,9 +1235,11 @@ where
continue
}
let chunk: StreamingChunk = parse_streaming_chunk(
let Some(chunk) = parse_streaming_chunk(
line.ok_or_else(|| anyhow!("unexpected stream format"))?
)?;
)? else {
continue // metadata-only frame
};
if let Some(model) = &chunk.model {
last_seen_model = Some(model.clone());
}
@@ -1190,7 +1296,12 @@ where
break 'outer;
}
let tool_chunk: StreamingChunk = parse_streaming_chunk(line)?;
// A metadata frame here must NOT fall through to the
// empty-choices branch below, which ends accumulation and
// would truncate this tool call's arguments.
let Some(tool_chunk) = parse_streaming_chunk(line)? else {
continue
};
if let Some(model) = &tool_chunk.model {
last_seen_model = Some(model.clone());
}
@@ -4235,6 +4346,224 @@ data: [DONE]"#;
Ok(())
}
// ---- metadata-only SSE frames (gateway trace/guardrail objects) -----------------------
//
// Some OpenAI-compatible gateways interleave objects that have no `choices` key at all
// with the real chunks. A Portkey gateway sends a `hook_results` guardrail trace as the
// FIRST frame whenever strict-openai-compliance is off. Failing on such a frame killed
// the whole turn.
/// A guardrail trace frame, in the shape a Portkey gateway emits it.
const METADATA_FRAME: &str = concat!(
r#"data: {"hook_results":{"before_request_hooks":[{"verdict":true,"#,
r#""id":"guardrail-1","type":"guardrail","deny":false}]}}"#
);
fn content_chunk(content: &str) -> String {
format!(
concat!(
r#"data: {{"id":"x","object":"chat.completion.chunk","created":1,"model":"m","#,
r#""choices":[{{"index":0,"delta":{{"content":"{}"}},"finish_reason":null}}]}}"#
),
content
)
}
#[tokio::test]
async fn test_metadata_only_first_frame_does_not_abort_stream() -> anyhow::Result<()> {
// The reported bug: a metadata-only opening frame made the whole turn fail with
// "Failed to parse streaming chunk: missing field `choices`".
let response_lines = format!("{METADATA_FRAME}\n{}\ndata: [DONE]", content_chunk("hello"));
assert_eq!(run_streaming_test(&response_lines).await?.text, "hello");
Ok(())
}
#[tokio::test]
async fn test_metadata_only_frame_interleaved_mid_stream_is_skipped() -> anyhow::Result<()> {
let response_lines = format!(
"{}\n{METADATA_FRAME}\n{}\ndata: [DONE]",
content_chunk("hel"),
content_chunk("lo")
);
assert_eq!(run_streaming_test(&response_lines).await?.text, "hello");
Ok(())
}
#[tokio::test]
async fn test_metadata_only_frame_mid_tool_call_keeps_arguments_intact() -> anyhow::Result<()> {
// A metadata frame arriving between two tool_calls argument deltas must not end
// argument accumulation. Merely defaulting `choices` to an empty vec would route this
// frame into the inner loop's empty-choices branch (`done = true`) and silently
// truncate the arguments to `{"city":"Pa` — a quiet corruption instead of a loud error.
let response_lines = concat!(
r#"data: {"id":"x","object":"chat.completion.chunk","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Pa"}}]},"finish_reason":null}]}"#,
"\n",
r#"data: {"hook_results":{"before_request_hooks":[{"verdict":true,"type":"guardrail","deny":false}]}}"#,
"\n",
r#"data: {"id":"x","object":"chat.completion.chunk","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ris\"}"}}]},"finish_reason":null}]}"#,
"\n",
r#"data: {"id":"x","object":"chat.completion.chunk","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#,
"\n",
"data: [DONE]"
);
let lines: Vec<String> = response_lines.lines().map(|s| s.to_string()).collect();
let response_stream = tokio_stream::iter(lines.into_iter().map(Ok));
let mut messages = std::pin::pin!(response_to_streaming_message(response_stream));
let mut tool_calls = Vec::new();
while let Some(result) = messages.next().await {
let (message, _usage) = result?;
if let Some(msg) = message {
for content in &msg.content {
if let MessageContentBlock::ToolRequest(req) = content {
if let Ok(call) = &req.tool_call {
tool_calls.push(call.clone());
}
}
}
}
}
assert_eq!(tool_calls.len(), 1, "expected exactly one tool call");
assert_eq!(tool_calls[0].name, "get_weather");
assert_eq!(
tool_calls[0]
.arguments
.as_ref()
.and_then(|a| a.get("city"))
.and_then(Value::as_str),
Some("Paris"),
"arguments must survive the interleaved metadata frame intact"
);
Ok(())
}
#[tokio::test]
async fn test_empty_choices_array_is_not_treated_as_metadata() -> anyhow::Result<()> {
// `"choices": []` is the standard usage-only chunk, NOT a metadata frame: it must
// still deserialize and still surface its usage.
let response_lines = concat!(
r#"data: {"id":"x","object":"chat.completion.chunk","model":"m","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":"stop"}]}"#,
"\n",
r#"data: {"id":"x","object":"chat.completion.chunk","model":"m","choices":[],"usage":{"prompt_tokens":7,"completion_tokens":3,"total_tokens":10}}"#,
"\n",
"data: [DONE]"
);
let result = run_streaming_test(response_lines).await?;
assert_eq!(result.usage_count, 1, "the usage-only chunk must be kept");
let usage = result.usage.expect("usage should be reported");
assert_eq!(usage.usage.output_tokens, Some(3));
assert!(result.has_text_content);
Ok(())
}
#[test]
fn test_error_frames_still_surface_as_server_error() {
// Skipping choice-less frames must not swallow gateway error frames, which are also
// choice-less. Every one of these is handled ahead of the metadata skip.
for line in [
r#"{"error":{"message":"upstream exploded"}}"#,
r#"{"object":"error","message":"upstream exploded"}"#,
// No `error` key and no `object`: the shape Azure APIM rate-limits with, on an
// HTTP 200. Skipping this would report the failed turn as an empty success.
r#"{"statusCode":429,"message":"upstream exploded"}"#,
// Some gateways stringify the status.
r#"{"status":"503","message":"upstream exploded"}"#,
// FastAPI's HTTPException shape.
r#"{"detail":"upstream exploded"}"#,
// FastAPI's RequestValidationError shape: `detail` is a LIST, so a string-only
// read would drop it and silently skip the frame.
r#"{"detail":[{"loc":["body"],"msg":"upstream exploded","type":"value_error"}]}"#,
// A non-string `message` must not be dropped either.
r#"{"statusCode":500,"message":{"text":"upstream exploded"}}"#,
// `type: "error"` is a third error marker some gateways use.
r#"{"type":"error","message":"upstream exploded"}"#,
] {
match parse_streaming_chunk(line) {
Err(ProviderError::ServerError(msg)) => assert!(
msg.contains("upstream exploded"),
"message preserved for {line}, got {msg:?}"
),
other => panic!("expected ServerError for {line}, got {other:?}"),
}
}
}
#[tokio::test]
async fn test_informational_choiceless_frame_is_still_skipped() -> anyhow::Result<()> {
// Prose alone is not an error signal. A keepalive/progress frame carrying a message
// but no status must NOT abort the turn — doing so would reintroduce exactly the bug
// the metadata skip exists to fix.
let response_lines = format!(
"{}\n{}\ndata: [DONE]",
r#"data: {"message":"processing"}"#,
content_chunk("hello")
);
assert_eq!(run_streaming_test(&response_lines).await?.text, "hello");
Ok(())
}
#[test]
fn test_status_only_error_frame_still_fails_loudly() {
// No message text anywhere: the frame must still surface rather than vanish.
match parse_streaming_chunk(r#"{"statusCode":503}"#) {
Err(ProviderError::ServerError(msg)) => {
assert!(msg.contains("503"), "status should reach the caller: {msg}")
}
other => panic!("expected ServerError, got {other:?}"),
}
}
#[test]
fn test_choiceless_frame_error_text_is_capped() {
let long = "x".repeat(5_000);
let line = format!(r#"{{"statusCode":500,"message":"{long}"}}"#);
match parse_streaming_chunk(&line) {
Err(ProviderError::ServerError(msg)) => assert!(
msg.chars().count() <= MAX_STREAM_ERROR_LEN + 1,
"error text should be truncated, got {} chars",
msg.chars().count()
),
other => panic!("expected ServerError, got {other:?}"),
}
}
#[tokio::test]
async fn test_in_stream_error_frame_aborts_rather_than_ending_empty() {
// End-to-end counterpart: the turn must fail, not complete with no content. A silent
// skip here tells the user to resend — the worst possible advice into a 429.
let response_lines = concat!(
r#"data: {"statusCode":429,"message":"rate limited"}"#,
"\n",
"data: [DONE]"
);
let lines: Vec<String> = response_lines.lines().map(|s| s.to_string()).collect();
let response_stream = tokio_stream::iter(lines.into_iter().map(Ok));
let mut messages = std::pin::pin!(response_to_streaming_message(response_stream));
let first = messages.next().await.expect("stream should yield an item");
let err = first.expect_err("an in-stream error frame must not be skipped");
assert!(
err.to_string().contains("rate limited"),
"the gateway's message must reach the caller: {err}"
);
}
#[test]
fn test_parse_streaming_chunk_returns_none_for_metadata_frames() {
// Unit-level counterpart to the streaming tests above.
let metadata = parse_streaming_chunk(r#"{"hook_results":{"before_request_hooks":[]}}"#)
.expect("metadata frame must not be an error");
assert!(metadata.is_none(), "metadata frame should be skipped");
let real = parse_streaming_chunk(r#"{"choices":[],"usage":{"completion_tokens":1}}"#)
.expect("usage-only chunk must parse");
assert!(
real.is_some(),
"`choices: []` is a real chunk, not metadata"
);
}
#[tokio::test]
async fn test_streaming_chunk_with_only_reasoning_content() -> anyhow::Result<()> {
let response_lines = "data: {\"id\":\"x\",\"object\":\"chat.completion.chunk\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"hi\"},\"finish_reason\":null}]}\ndata: [DONE]";