From cae4ebb89d6685e581259b5754c1fd4a565d95a3 Mon Sep 17 00:00:00 2001 From: Graham King Date: Tue, 22 Sep 2026 16:20:00 -0400 Subject: [PATCH] fix(anthropic): Include api_error type in stream error events Previously `switchyard-server` emitted Anthropic SSE error events without the required nested `error.type`. Fixes: https://linear.app/nvidia/issue/SWITCH-1548 Assisted-by: Pi:GPT 6 Astra medium Reviewed-by: Claude:Opus 5.5 medium Signed-off-by: Graham King --- crates/switchyard-server/src/sse.rs | 30 +++++++++++++- .../src/codecs/anthropic/stream.rs | 2 +- .../tests/stream_translation.rs | 39 +++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/switchyard-server/src/sse.rs b/crates/switchyard-server/src/sse.rs index e685ba5e5..10b526790 100644 --- a/crates/switchyard-server/src/sse.rs +++ b/crates/switchyard-server/src/sse.rs @@ -99,13 +99,18 @@ fn error_event(target_format: WireFormat, message: String, redactor: &Redactor) ), ), WireFormat::AnthropicMessages | WireFormat::OpenAiResponses => { + let error_type = if target_format == WireFormat::AnthropicMessages { + "api_error" + } else { + "SwitchyardError" + }; Event::default().event("error").data( redactor.json( json!({ "type": "error", "error": { "message": message, - "type": "SwitchyardError", + "type": error_type, } }) .to_string(), @@ -181,4 +186,27 @@ mod tests { assert!(!body.contains("[DONE]")); Ok(()) } + + #[tokio::test] + async fn anthropic_stream_error_uses_api_error_type() -> TestResult { + let failure = LlmClientError::General("boom".to_string()); + let stream: RawEventStream = + Box::pin(stream::iter(vec![Err(LlmStreamError::Client(failure))])); + let response = frame_stream( + stream, + WireFormat::AnthropicMessages, + Arc::new(Redactor::default()), + ) + .into_response(); + let body = String::from_utf8(to_bytes(response.into_body(), usize::MAX).await?.to_vec())?; + let data = body + .lines() + .find_map(|line| line.strip_prefix("data: ")) + .ok_or("missing data line")?; + assert_eq!( + serde_json::from_str::(data)?, + json!({"type": "error", "error": {"type": "api_error", "message": "boom"}}) + ); + Ok(()) + } } diff --git a/crates/switchyard-translation/src/codecs/anthropic/stream.rs b/crates/switchyard-translation/src/codecs/anthropic/stream.rs index cc3955f9c..908941a89 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/stream.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/stream.rs @@ -242,7 +242,7 @@ fn encode_anthropic_stream( // An in-band error is terminal: emit the error, then nothing further. state.finished = true; // finish() adds no success events state.errored = true; // the entry guard drops any later chunk - vec![json!({"type": "error", "error": {"message": message}})] + vec![json!({"type": "error", "error": {"type": "api_error", "message": message}})] } } } diff --git a/crates/switchyard-translation/tests/stream_translation.rs b/crates/switchyard-translation/tests/stream_translation.rs index e22511c59..01e9cc0da 100644 --- a/crates/switchyard-translation/tests/stream_translation.rs +++ b/crates/switchyard-translation/tests/stream_translation.rs @@ -203,6 +203,45 @@ fn preserved_same_format_replay_stops_after_an_error() -> TestResult { Ok(()) } +#[test] +fn anthropic_errors_include_type_and_terminate_stream() -> TestResult { + let engine = TranslationEngine::default(); + let target = WireFormat::AnthropicMessages; + let message = "stream failed"; + for chunk in [ + LlmResponseChunk::StreamError { + message: message.into(), + }, + LlmResponseChunk::DecodeError { + message: message.into(), + }, + ] { + let mut state = StreamTranslationState::new(WireFormat::OpenAiChat, target); + let events = engine.encode_stream_event( + &mut state, + target, + LlmResponseStreamEvent::new(vec![chunk]), + )?; + assert_eq!( + events, + vec![json!({"type": "error", "error": {"type": "api_error", "message": message}})] + ); + assert!( + engine + .encode_stream_event( + &mut state, + target, + LlmResponseStreamEvent::new(vec![LlmResponseChunk::MessageStop { + reason: None + }]), + )? + .is_empty() + ); + assert!(engine.finish_stream(&mut state, target)?.is_empty()); + } + Ok(()) +} + #[test] fn same_format_decode_failure_emits_terminal_error() -> TestResult { let cases = [