diff --git a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs index c6cdcfba5..8ba1c5929 100644 --- a/crates/switchyard-translation/src/codecs/anthropic/buffered.rs +++ b/crates/switchyard-translation/src/codecs/anthropic/buffered.rs @@ -321,6 +321,17 @@ impl FormatCodec for AnthropicMessagesCodec { _policy: &TranslationPolicy, ) -> Result { let body = crate::util::object(body, "$")?; + // 200 with an error object and no content blocks fails the turn. + if let Some(error) = body.get("error").filter(|error| error.is_object()) + && body + .get("content") + .and_then(Value::as_array) + .is_none_or(Vec::is_empty) + { + return Err(TranslationError::UpstreamFailure { + error: error.clone(), + }); + } let mut content = Vec::new(); if let Some(blocks) = body.get("content").and_then(Value::as_array) { for (index, block) in blocks.iter().enumerate() { diff --git a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs index d90423518..16fde0cef 100644 --- a/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs +++ b/crates/switchyard-translation/src/codecs/openai_chat/buffered.rs @@ -269,6 +269,21 @@ impl FormatCodec for OpenAiChatCodec { _policy: &TranslationPolicy, ) -> Result { let object = object(body, "$")?; + // 200 with no choices, or an object at choices[0].error, fails the turn. + // A top-level error beside choices does not. + let error = match object + .get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + { + Some(choice) => choice.get("error"), + None => object.get("error"), + }; + if let Some(error) = error.filter(|error| error.is_object()) { + return Err(TranslationError::UpstreamFailure { + error: error.clone(), + }); + } let mut response = AggLlmResponse { id: object .get("id") diff --git a/crates/switchyard-translation/tests/response_translation.rs b/crates/switchyard-translation/tests/response_translation.rs index 4280fa3f1..aa8130ce5 100644 --- a/crates/switchyard-translation/tests/response_translation.rs +++ b/crates/switchyard-translation/tests/response_translation.rs @@ -1057,6 +1057,49 @@ fn failed_responses_return_upstream_failure_with_provider_message() -> TestResul Ok(()) } +#[test] +fn error_bodies_return_upstream_failure_with_provider_message() -> TestResult { + let engine = TranslationEngine::default(); + let policy = TranslationPolicy::default(); + let cases = [ + ( + WireFormat::OpenAiChat, + json!({"choices": [], "error": {"message": "deterministic upstream failure"}}), + json!({"choices": [{"message": {"role": "assistant", "content": "hi"}}], "error": {}}), + ), + ( + WireFormat::OpenAiChat, + json!({"choices": [{"message": {"role": "assistant", "content": "partial"}, "finish_reason": null, "error": {"message": "deterministic upstream failure"}}]}), + json!({"choices": [{"message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop", "error": null}]}), + ), + ( + WireFormat::AnthropicMessages, + json!({"type": "error", "error": {"type": "api_error", "message": "deterministic upstream failure"}}), + json!({"type": "message", "content": [{"type": "text", "text": "hi"}], "error": {}}), + ), + ]; + for (source, error_body, output_body) in cases { + let error = engine + .translate_response(source, WireFormat::OpenAiChat, &error_body, &policy) + .err() + .ok_or_else(|| format!("error body decoded as a completion: {error_body}"))?; + assert_eq!(error.kind(), "UpstreamFailure", "input: {error_body}"); + assert!( + error.to_string().contains("deterministic upstream failure"), + "input: {error_body}" + ); + + let output = engine + .translate_response(source, WireFormat::OpenAiChat, &output_body, &policy)? + .body; + assert_eq!( + output["choices"][0]["message"]["content"], "hi", + "input: {output_body}" + ); + } + Ok(()) +} + // Verifies a moderation stop stays distinguishable from a normal turn in both // directions, and that a named refusal category survives re-encoding. #[test]