diff --git a/apis/src/anthropic/to_openai/mod.rs b/apis/src/anthropic/to_openai/mod.rs index eef7f6b11d..3513a965a3 100644 --- a/apis/src/anthropic/to_openai/mod.rs +++ b/apis/src/anthropic/to_openai/mod.rs @@ -114,9 +114,9 @@ impl HttpFilter for AnthropicToOpenaiFilter { .as_ref() .map_or(500, |response| response.status.as_u16()); ctx.set_metadata(RESPONSE_STATUS_KEY, status.to_string()); - if let Some(request_id) = request_id { - ctx.set_metadata(RESPONSE_REQUEST_ID_KEY, request_id); - } + } + if let Some(request_id) = request_id { + ctx.set_metadata(RESPONSE_REQUEST_ID_KEY, request_id); } ctx.set_response_body_mode(BodyMode::StreamBuffer { @@ -184,9 +184,11 @@ impl HttpFilter for AnthropicToOpenaiFilter { let request_model = ctx .filter_metadata .get("anthropic_to_openai.model") - .cloned() - .unwrap_or_default(); - transform_non_streaming_body(ctx, body, &request_model); + .map_or("", String::as_str); + let request_id = ctx.get_metadata(RESPONSE_REQUEST_ID_KEY); + if let Some(finish_reason) = transform_non_streaming_body(body, request_model, request_id) { + ctx.set_metadata("openai.finish_reason", finish_reason); + } } Ok(FilterAction::Continue) @@ -333,32 +335,33 @@ fn transform_error_body(body: &mut Option, status: http::StatusCode, requ } /// Apply non-streaming JSON transformation to the response body. -fn transform_non_streaming_body(ctx: &mut HttpFilterContext<'_>, body: &mut Option, request_model: &str) { - let bytes = match body.as_ref() { - Some(b) => b.as_ref(), - None => return, - }; - - if bytes.is_empty() { - return; - } - - match response::transform_response(bytes, request_model) { +fn transform_non_streaming_body( + body: &mut Option, + request_model: &str, + request_id: Option<&str>, +) -> Option { + match response::transform_response(body.as_deref().unwrap_or_default(), request_model) { Ok(result) => { debug!( - original_len = bytes.len(), + original_len = body.as_ref().map_or(0, Bytes::len), transformed_len = result.body.len(), original_finish_reason = result.original_finish_reason.as_str(), "transformed Chat Completions-compatible response to Anthropic" ); - ctx.set_metadata("openai.finish_reason", result.original_finish_reason); *body = Some(Bytes::from(result.body)); + Some(result.original_finish_reason) }, Err(msg) => { warn!( error = msg.as_str(), "failed to transform Chat Completions-compatible response" ); + *body = Some(Bytes::from(wire::error_body( + "api_error", + "upstream response could not be transformed", + request_id, + ))); + None }, } } @@ -819,56 +822,73 @@ mod tests { // --- transform_non_streaming_body --- #[test] - fn transform_non_streaming_body_none_is_noop() { - let request = make_request(Method::POST, "/v1/messages"); - let mut ctx = make_filter_context(&request); + fn transform_non_streaming_body_missing_body_returns_api_error() { let mut body: Option = None; - transform_non_streaming_body(&mut ctx, &mut body, "gpt-4"); + let finish_reason = transform_non_streaming_body(&mut body, "gpt-4", None); + let parsed: serde_json::Value = serde_json::from_slice(body.as_deref().unwrap()).unwrap(); - assert!(body.is_none()); + assert!(finish_reason.is_none()); + assert_eq!(parsed["type"], "error"); + assert_eq!(parsed["error"]["type"], "api_error"); + assert!(parsed["request_id"].is_null()); } #[test] - fn transform_non_streaming_body_empty_bytes_is_noop() { - let request = make_request(Method::POST, "/v1/messages"); - let mut ctx = make_filter_context(&request); + fn transform_non_streaming_body_empty_bytes_returns_api_error() { let mut body = Some(Bytes::new()); - transform_non_streaming_body(&mut ctx, &mut body, "gpt-4"); + let finish_reason = transform_non_streaming_body(&mut body, "gpt-4", None); + let parsed: serde_json::Value = serde_json::from_slice(body.as_deref().unwrap()).unwrap(); - assert_eq!(body.as_ref().unwrap().len(), 0, "empty bytes should not be transformed"); + assert!(finish_reason.is_none()); + assert_eq!(parsed["type"], "error"); + assert_eq!(parsed["error"]["type"], "api_error"); } #[test] fn transform_non_streaming_body_success() { - let request = make_request(Method::POST, "/v1/messages"); - let mut ctx = make_filter_context(&request); let response_json = br#"{"id":"chatcmpl-1","model":"gpt-4","choices":[{"message":{"role":"assistant","content":"Hello!"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}"#; let mut body = Some(Bytes::from(response_json.to_vec())); - transform_non_streaming_body(&mut ctx, &mut body, "gpt-4"); + let finish_reason = transform_non_streaming_body(&mut body, "gpt-4", None); assert!(body.is_some()); let parsed: serde_json::Value = serde_json::from_slice(body.unwrap().as_ref()).unwrap(); assert_eq!(parsed["type"], "message"); assert_eq!(parsed["content"][0]["text"], "Hello!"); - assert_eq!( - ctx.filter_metadata.get("openai.finish_reason").unwrap(), - "stop", - "finish_reason should be stored in metadata" - ); + assert_eq!(finish_reason.as_deref(), Some("stop")); } - #[test] - fn transform_non_streaming_body_invalid_json_preserves_body() { + #[tokio::test] + async fn malformed_non_streaming_success_returns_anthropic_api_error() { + let yaml: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let filter = AnthropicToOpenaiFilter::from_config(&yaml).unwrap(); let request = make_request(Method::POST, "/v1/messages"); let mut ctx = make_filter_context(&request); - let original = Bytes::from_static(b"not json"); - let mut body = Some(original.clone()); + ctx.set_metadata("anthropic_to_openai.streaming", "false"); + ctx.set_metadata("anthropic_to_openai.model", "gpt-4"); + let mut response = make_response(); + response + .headers + .insert("x-request-id", "req_malformed".parse().unwrap()); + ctx.response_header = Some(&mut response); + + let action = filter.on_response(&mut ctx).await.unwrap(); + + assert!(matches!(action, FilterAction::Continue)); + assert!(matches!(ctx.response_body_mode, BodyMode::StreamBuffer { .. })); + ctx.response_header = None; - transform_non_streaming_body(&mut ctx, &mut body, "gpt-4"); + let mut body = Some(Bytes::from_static(b"not json")); + let action = filter.on_response_body(&mut ctx, &mut body, true).unwrap(); + let parsed: serde_json::Value = serde_json::from_slice(body.as_deref().unwrap()).unwrap(); - assert_eq!(body, Some(original), "body should not be modified on error"); + assert!(matches!(action, FilterAction::Continue)); + assert_eq!(parsed["type"], "error"); + assert_eq!(parsed["error"]["type"], "api_error"); + assert_eq!(parsed["error"]["message"], "upstream response could not be transformed"); + assert_eq!(parsed["request_id"], "req_malformed"); + assert!(!ctx.filter_metadata.contains_key("openai.finish_reason")); } } diff --git a/tests/integration/fixtures/inference/README.md b/tests/integration/fixtures/inference/README.md index 13831e1152..20f9a73f26 100644 --- a/tests/integration/fixtures/inference/README.md +++ b/tests/integration/fixtures/inference/README.md @@ -19,13 +19,14 @@ than editing the table. -The manifest declares **13 features** across **5 scopes**, linked to **11 scenarios**. +The manifest declares **14 features** across **5 scopes**, linked to **12 scenarios**. | Scope | Feature | Status | Scenarios | Provider coverage | | --- | --- | --- | --- | --- | | `messages_to_chat_completions` | `messages.request.minimal` | `live_covered` | `messages/basic-nonstream`
`messages/basic-stream` | `openai`: `covered`
`vllm`: `live_covered` | | `messages_to_chat_completions` | `messages.response.text` | `live_covered` | `messages/basic-nonstream`
`messages/basic-stream` | `openai`: `covered`
`vllm`: `live_covered` | | `messages_to_chat_completions` | `messages.error.upstream` | `synthetic_only` | `messages/upstream-error` | `synthetic`: `synthetic_only` | +| `messages_to_chat_completions` | `messages.error.malformed_success` | `synthetic_only` | `messages/malformed-success` | `synthetic`: `synthetic_only` | | `messages_native_passthrough` | `messages.native.request` | `live_covered` | `messages/native-basic-nonstream`
`messages/native-basic-stream`
`messages/native-tool-use` | `anthropic`: `live_covered` | | `messages_native_passthrough` | `messages.native.response.text` | `live_covered` | `messages/native-basic-nonstream`
`messages/native-basic-stream` | `anthropic`: `live_covered` | | `messages_native_passthrough` | `messages.native.tool_use` | `live_covered` | `messages/native-tool-use` | `anthropic`: `live_covered` | diff --git a/tests/integration/fixtures/inference/coverage.yaml b/tests/integration/fixtures/inference/coverage.yaml index 99b53c819d..aabb83562b 100644 --- a/tests/integration/fixtures/inference/coverage.yaml +++ b/tests/integration/fixtures/inference/coverage.yaml @@ -39,6 +39,15 @@ features: providers: synthetic: status: synthetic_only + - id: messages.error.malformed_success + scopes: + - messages_to_chat_completions + status: synthetic_only + scenarios: + - messages/malformed-success + providers: + synthetic: + status: synthetic_only - id: messages.native.request scopes: - messages_native_passthrough diff --git a/tests/integration/fixtures/inference/recordings/synthetic/messages/malformed-success.json b/tests/integration/fixtures/inference/recordings/synthetic/messages/malformed-success.json new file mode 100644 index 0000000000..13f25f7eb7 --- /dev/null +++ b/tests/integration/fixtures/inference/recordings/synthetic/messages/malformed-success.json @@ -0,0 +1,107 @@ +{ + "version": 1, + "scenario_id": "messages/malformed-success", + "protocol": "anthropic_messages", + "provenance": { + "kind": "synthetic", + "provider": "synthetic", + "model": "synthetic-malformed-response-model", + "source_id": "controlled-malformed-200-json" + }, + "normalization": { + "version": 1, + "linked_ids": {} + }, + "turns": [ + { + "name": "initial", + "client": { + "request": { + "method": "POST", + "path": "/v1/messages", + "headers": { + "content-type": [ + "application/json" + ] + }, + "body": { + "kind": "json", + "value": { + "max_tokens": 64, + "messages": [ + { + "content": "What is 2+2? Reply with just the number.", + "role": "user" + } + ], + "model": "synthetic-malformed-response-model", + "stream": false + } + } + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json" + ], + "request-id": [ + "req_malformed" + ] + }, + "body": { + "kind": "json", + "value": { + "error": { + "message": "upstream response could not be transformed", + "type": "api_error" + }, + "request_id": "req_malformed", + "type": "error" + } + } + } + }, + "upstream": { + "request": { + "method": "POST", + "path": "/v1/chat/completions", + "headers": { + "content-type": [ + "application/json" + ] + }, + "body": { + "kind": "json", + "value": { + "max_completion_tokens": 64, + "messages": [ + { + "content": "What is 2+2? Reply with just the number.", + "role": "user" + } + ], + "model": "synthetic-malformed-response-model", + "stream": false + } + } + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json" + ], + "request-id": [ + "req_malformed" + ] + }, + "body": { + "kind": "base64", + "data": "bm90IGpzb24=" + } + } + } + } + ] +} diff --git a/tests/integration/fixtures/inference/scenarios/messages/malformed-success.yaml b/tests/integration/fixtures/inference/scenarios/messages/malformed-success.yaml new file mode 100644 index 0000000000..e8f67a468f --- /dev/null +++ b/tests/integration/fixtures/inference/scenarios/messages/malformed-success.yaml @@ -0,0 +1,32 @@ +version: 1 +id: messages/malformed-success +description: Malformed Chat Completions success converted to an Anthropic API error envelope. +protocol: anthropic_messages +example_config: anthropic/messages-to-openai.yaml +upstream_authority: 127.0.0.1:8000 +features: + - messages.error.malformed_success +turns: + - name: initial + request: + method: POST + path: /v1/messages + headers: + content-type: + - application/json + body: + kind: json + value: + model: ${MODEL} + max_tokens: 64 + stream: false + messages: + - role: user + content: What is 2+2? Reply with just the number. + expect: + client_status: 200 + client_body_kind: json + upstream_path: /v1/chat/completions + upstream_body_kind: json + client_sse_events: [] + upstream_sse_events: [] diff --git a/tests/integration/tests/suite/examples/anthropic_messages.rs b/tests/integration/tests/suite/examples/anthropic_messages.rs index be35fa63ea..8d50effca0 100644 --- a/tests/integration/tests/suite/examples/anthropic_messages.rs +++ b/tests/integration/tests/suite/examples/anthropic_messages.rs @@ -166,6 +166,34 @@ fn anthropic_to_openai_transforms_response_body() { ); } +#[test] +fn anthropic_to_openai_replaces_malformed_success_body() { + let backend = Backend::fixed("not json") + .header("content-type", "application/json") + .header("x-request-id", "req_malformed") + .start_with_shutdown(); + let proxy_port = free_port(); + let config = load_example_config( + "anthropic/messages-to-openai.yaml", + proxy_port, + HashMap::from([("127.0.0.1:8000", backend.port())]), + ); + let proxy = start_proxy(&config); + let request_body = serde_json::json!({ + "model": "claude-opus-4-8", + "max_tokens": 64, + "messages": [{"role": "user", "content": "Hello"}], + }); + + let raw = http_send(proxy.addr(), &json_post("/v1/messages", &request_body.to_string())); + let parsed: serde_json::Value = serde_json::from_str(&parse_body(&raw)).expect("fallback response should be JSON"); + + assert_eq!(parsed["type"], "error"); + assert_eq!(parsed["error"]["type"], "api_error"); + assert_eq!(parsed["error"]["message"], "upstream response could not be transformed"); + assert_eq!(parsed["request_id"], "req_malformed"); +} + fn run_anthropic_to_openai_error(status: u16, response_body: &str, stream: bool) -> (u16, serde_json::Value) { let backend = Backend::status(status, response_body) .header("content-type", "application/json") diff --git a/tests/utils/src/inference_fixture/coverage.rs b/tests/utils/src/inference_fixture/coverage.rs index 9d734128e6..88393c39a2 100644 --- a/tests/utils/src/inference_fixture/coverage.rs +++ b/tests/utils/src/inference_fixture/coverage.rs @@ -1227,6 +1227,7 @@ mod tests { vec!["messages_to_chat_completions"], vec!["messages_to_chat_completions"], vec!["messages_to_chat_completions"], + vec!["messages_to_chat_completions"], vec!["messages_native_passthrough"], vec!["messages_native_passthrough"], vec!["messages_native_passthrough"], @@ -1249,6 +1250,7 @@ mod tests { CoverageStatus::LiveCovered, CoverageStatus::LiveCovered, CoverageStatus::SyntheticOnly, + CoverageStatus::SyntheticOnly, CoverageStatus::LiveCovered, CoverageStatus::LiveCovered, CoverageStatus::LiveCovered, @@ -1261,14 +1263,15 @@ mod tests { CoverageStatus::SyntheticOnly, ] ); - assert_eq!(report.features_total, 13); - assert_eq!(report.scenarios_total, 11); - assert_eq!(report.recordings_total, 16); + assert_eq!(report.features_total, 14); + assert_eq!(report.scenarios_total, 12); + assert_eq!(report.recordings_total, 17); assert_eq!( scenarios.keys().collect::>(), vec![ "messages/basic-nonstream", "messages/basic-stream", + "messages/malformed-success", "messages/native-basic-nonstream", "messages/native-basic-stream", "messages/native-tool-use", @@ -1280,7 +1283,7 @@ mod tests { "responses/native-tool-call", ] ); - assert_eq!(manifest.features.len(), 13); + assert_eq!(manifest.features.len(), 14); assert_eq!(manifest.version, 1); assert_eq!( manifest @@ -1307,6 +1310,10 @@ mod tests { &"messages.error.upstream".to_owned(), &vec!["messages/upstream-error".to_owned()] ), + ( + &"messages.error.malformed_success".to_owned(), + &vec!["messages/malformed-success".to_owned()] + ), ( &"messages.native.request".to_owned(), &vec![ @@ -1393,7 +1400,15 @@ mod tests { .collect::>(), vec![("synthetic", CoverageStatus::SyntheticOnly)] ); - for feature in &manifest.features[3..6] { + assert_eq!( + manifest.features[3] + .providers + .iter() + .map(|(provider, coverage)| (provider.as_str(), coverage.status.clone())) + .collect::>(), + vec![("synthetic", CoverageStatus::SyntheticOnly)] + ); + for feature in &manifest.features[4..7] { assert_eq!( feature .providers @@ -1403,7 +1418,7 @@ mod tests { vec![("anthropic", CoverageStatus::LiveCovered)] ); } - for feature in &manifest.features[6..9] { + for feature in &manifest.features[7..10] { assert_eq!( feature .providers @@ -1416,7 +1431,7 @@ mod tests { ] ); } - for feature in &manifest.features[9..] { + for feature in &manifest.features[10..] { assert_eq!( feature .providers @@ -1473,6 +1488,18 @@ mod tests { 429, &[], ); + let malformed = InferenceScenario::load(&root.join("scenarios/messages/malformed-success.yaml")).unwrap(); + assert_scenario( + &malformed, + "messages/malformed-success", + "Malformed Chat Completions success converted to an Anthropic API error envelope.", + &["messages.error.malformed_success"], + "What is 2+2? Reply with just the number.", + false, + BodyKind::Json, + 200, + &[], + ); let native_nonstream = InferenceScenario::load(&root.join("scenarios/messages/native-basic-nonstream.yaml")).unwrap();