diff --git a/apis/src/openai/error_response_formatter.rs b/apis/src/openai/error_response_formatter.rs new file mode 100644 index 0000000000..757fa0ca7f --- /dev/null +++ b/apis/src/openai/error_response_formatter.rs @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! OpenAI error response formatter for Praxis fatal proxy failures. +//! +//! Implements [`ErrorResponseFormatter`] to produce the standard +//! `{"error": {...}}` envelope that OpenAI SDKs expect. Installed +//! as a request extension after positive OpenAI classification so +//! that Praxis calls it from `fail_to_proxy` instead of emitting +//! RFC 9457 Problem Details. + +use bytes::Bytes; +use http::HeaderValue; +use praxis_filter::{ErrorResponseContext, ErrorResponseFormatter, FormattedErrorResponse}; + +/// Formats Praxis fatal proxy failures as OpenAI error JSON. +/// +/// Produces `{"error":{"message":"…","type":"…","param":null,"code":"…"}}`. +/// +/// Mapping: +/// - `error.message` ← `context.message` +/// - `error.code` ← `context.code` (Praxis machine-readable code) +/// - `error.type` ← standardized OpenAI error type based on HTTP status code +/// - `error.param` ← always `null` +pub(crate) struct OpenAiErrorFormatter; + +/// Maps an HTTP status code to a standardized OpenAI error type string. +fn map_error_type(status: u16) -> &'static str { + match status { + 500..=599 => "server_error", + 429 => "rate_limit_error", + 401 => "authentication_error", + 403 => "permission_error", + 404 => "not_found_error", + 400 | 422 => "invalid_request_error", + _ => "api_error", + } +} + +impl ErrorResponseFormatter for OpenAiErrorFormatter { + fn format(&self, context: &ErrorResponseContext<'_>) -> FormattedErrorResponse { + let error_type = map_error_type(context.status); + + let body = serde_json::json!({ + "error": { + "message": context.message, + "type": error_type, + "param": null, + "code": context.code, + }, + }); + + FormattedErrorResponse::new( + Bytes::from(body.to_string()), + HeaderValue::from_static("application/json"), + ) + } +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow(clippy::unwrap_used, clippy::indexing_slicing, reason = "tests")] +mod tests { + use super::*; + + #[test] + fn connection_refusal_produces_valid_openai_json() { + let ctx = ErrorResponseContext::new("upstream_connect_refused", "Connection refused", 502); + let response = OpenAiErrorFormatter.format(&ctx); + + let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!(parsed["error"]["message"], "Connection refused"); + assert_eq!(parsed["error"]["type"], "server_error"); + assert_eq!(parsed["error"]["code"], "upstream_connect_refused"); + assert!(parsed["error"]["param"].is_null()); + } + + #[test] + fn timeout_produces_valid_openai_json() { + let ctx = ErrorResponseContext::new("upstream_connect_timeout", "Connection timed out", 504); + let response = OpenAiErrorFormatter.format(&ctx); + + let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!(parsed["error"]["message"], "Connection timed out"); + assert_eq!(parsed["error"]["type"], "server_error"); + assert_eq!(parsed["error"]["code"], "upstream_connect_timeout"); + assert!(parsed["error"]["param"].is_null()); + } + + #[test] + fn content_type_is_application_json() { + let ctx = ErrorResponseContext::new("upstream_connect_error", "Connection refused", 502); + let response = OpenAiErrorFormatter.format(&ctx); + + assert_eq!(response.content_type, HeaderValue::from_static("application/json")); + } + + #[test] + fn json_escaping_handles_special_characters() { + let ctx = ErrorResponseContext::new("server_error", "line1\nline2\"quoted\"\tand\\backslash", 500); + let response = OpenAiErrorFormatter.format(&ctx); + + let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!( + parsed["error"]["message"].as_str().unwrap(), + "line1\nline2\"quoted\"\tand\\backslash" + ); + } + + #[test] + fn json_escaping_handles_unicode() { + let ctx = ErrorResponseContext::new("server_error", "Connection to サーバー failed 🔥 (مرحبا)", 500); + let response = OpenAiErrorFormatter.format(&ctx); + + let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!( + parsed["error"]["message"].as_str().unwrap(), + "Connection to サーバー failed 🔥 (مرحبا)" + ); + + assert!(std::str::from_utf8(&response.body).is_ok()); + } + + #[test] + fn fivex_status_uses_server_error_type() { + for status in [500, 502, 503, 504] { + let ctx = ErrorResponseContext::new("some_code", "some message", status); + let response = OpenAiErrorFormatter.format(&ctx); + + let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!( + parsed["error"]["type"], "server_error", + "status {status} should use server_error type" + ); + assert_eq!( + parsed["error"]["code"], "some_code", + "status {status} should preserve the Praxis code" + ); + } + } + + #[test] + fn fourx_status_maps_to_openai_error_types() { + let cases = [ + (400, "invalid_request_error"), + (401, "authentication_error"), + (403, "permission_error"), + (404, "not_found_error"), + (422, "invalid_request_error"), + (429, "rate_limit_error"), + (418, "api_error"), + ]; + + for (status, expected_type) in cases { + let ctx = ErrorResponseContext::new("custom_code", "test error", status); + let response = OpenAiErrorFormatter.format(&ctx); + + let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!( + parsed["error"]["type"], expected_type, + "status {status} should map to error type '{expected_type}'" + ); + assert_eq!( + parsed["error"]["code"], "custom_code", + "status {status} should preserve the Praxis code" + ); + } + } + + #[test] + fn param_is_always_null() { + for status in [400, 429, 500, 502, 504] { + let ctx = ErrorResponseContext::new("test_code", "test message", status); + let response = OpenAiErrorFormatter.format(&ctx); + + let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + assert!( + parsed["error"]["param"].is_null(), + "param should be null for status {status}" + ); + } + } + + #[test] + fn output_is_valid_json() { + let ctx = ErrorResponseContext::new("upstream_connect_error", "failed", 502); + let response = OpenAiErrorFormatter.format(&ctx); + + let parsed: Result = serde_json::from_slice(&response.body); + assert!(parsed.is_ok(), "output must be valid JSON"); + + let parsed = parsed.unwrap(); + assert!(parsed.get("error").is_some(), "must have top-level error key"); + assert!(parsed["error"].get("message").is_some()); + assert!(parsed["error"].get("type").is_some()); + assert!(parsed["error"].get("param").is_some()); + assert!(parsed["error"].get("code").is_some()); + } +} diff --git a/apis/src/openai/mod.rs b/apis/src/openai/mod.rs index 5eead22980..631f882d4d 100644 --- a/apis/src/openai/mod.rs +++ b/apis/src/openai/mod.rs @@ -10,6 +10,7 @@ )] pub(crate) mod api_client; pub(crate) mod conversations; +pub(crate) mod error_response_formatter; pub(crate) mod include; mod operation; pub(crate) mod responses; diff --git a/apis/src/openai/responses/mod.rs b/apis/src/openai/responses/mod.rs index a0e9ba9009..adfcffb66d 100644 --- a/apis/src/openai/responses/mod.rs +++ b/apis/src/openai/responses/mod.rs @@ -70,7 +70,7 @@ use std::{borrow::Cow, io}; use async_trait::async_trait; use bytes::Bytes; use praxis_filter::{ - BodyAccess, BodyMode, FilterAction, FilterError, HttpFilter, HttpFilterContext, + BodyAccess, BodyMode, ErrorResponseFormatterHandle, FilterAction, FilterError, HttpFilter, HttpFilterContext, builtins::http::payload_processing::OnInvalidBehavior, parse_filter_config, }; use tracing::{debug, trace}; @@ -273,6 +273,8 @@ impl HttpFilter for ResponsesFormatFilter { compute_mode(&classified) }; + install_error_formatter(ctx, classified.format); + write_metadata(ctx, &classified, mode); promote_headers(ctx, &classified, &self.config, mode); promote_filter_results(ctx, &classified, mode)?; @@ -285,6 +287,26 @@ impl HttpFilter for ResponsesFormatFilter { // Helpers // ----------------------------------------------------------------------------- +/// Install the OpenAI error response formatter for positively classified +/// OpenAI requests (Responses and Chat Completions). +/// +/// When installed, Praxis invokes the formatter from `fail_to_proxy` +/// instead of emitting RFC 9457 Problem Details. Non-OpenAI formats +/// (Anthropic, unknown, invalid, non-JSON) are left untouched. +fn install_error_formatter(ctx: &mut HttpFilterContext<'_>, format: AiRequestFormat) { + match format { + AiRequestFormat::Responses | AiRequestFormat::ChatCompletions => { + ctx.extensions.insert(ErrorResponseFormatterHandle::new( + crate::openai::error_response_formatter::OpenAiErrorFormatter, + )); + }, + AiRequestFormat::AnthropicMessages + | AiRequestFormat::UnknownJson + | AiRequestFormat::InvalidJson + | AiRequestFormat::NonJson => {}, + } +} + /// Classify a request from a recognized path/handshake or its body. fn classify_request(ctx: &HttpFilterContext<'_>, bytes: &[u8]) -> (ClassifiedRequest, bool) { let method = &ctx.request.method; diff --git a/apis/src/openai/responses/tests.rs b/apis/src/openai/responses/tests.rs index f56e992359..fc2798d48d 100644 --- a/apis/src/openai/responses/tests.rs +++ b/apis/src/openai/responses/tests.rs @@ -533,6 +533,11 @@ async fn get_v1_responses_with_id_classifies_as_responses() { Some("openai_responses"), "GET /v1/responses/{{id}} should classify as responses" ); + + assert!( + ctx.extensions.get::().is_some(), + "openai error response formatter should be installed for responses path" + ); } #[tokio::test] @@ -872,6 +877,11 @@ async fn get_responses_without_websocket_headers_classifies_body_normally() { Some("non_json"), "an ordinary GET list request must not be promoted as a WebSocket handshake" ); + + assert!( + ctx.extensions.get::().is_none(), + "openai error response formatter should not be installed for non-json" + ); } // ----------------------------------------------------------------------------- @@ -976,6 +986,11 @@ async fn mode_not_set_for_chat_completions() { !headers.contains_key("x-praxis-responses-mode"), "mode header absent for chat_completions" ); + + assert!( + ctx.extensions.get::().is_some(), + "openai error response formatter should be installed for chat completions" + ); } #[tokio::test] diff --git a/tests/integration/tests/suite/openai_responses_format.rs b/tests/integration/tests/suite/openai_responses_format.rs index 43cdd5f2d3..6e0135d0df 100644 --- a/tests/integration/tests/suite/openai_responses_format.rs +++ b/tests/integration/tests/suite/openai_responses_format.rs @@ -5,8 +5,8 @@ use praxis_core::config::Config; use praxis_test_utils::{ - free_port, http_send, json_post, parse_body, parse_status, start_backend_with_shutdown, start_echo_backend, - start_header_echo_backend, start_proxy, + free_port, http_send, json_post, parse_body, parse_header, parse_status, start_backend_with_shutdown, + start_echo_backend, start_header_echo_backend, start_proxy, }; // ----------------------------------------------------------------------------- @@ -675,6 +675,149 @@ fn large_body_over_64k_classified_and_forwarded() { assert_eq!(echoed, body, "large body should be byte-for-byte unchanged"); } +// ----------------------------------------------------------------------------- +// Error Formatter Integration Tests +// ----------------------------------------------------------------------------- + +#[test] +fn proxy_failure_formats_openai_error_for_responses() { + let dead_port = free_port(); + let proxy_port = free_port(); + + let yaml = echo_yaml(proxy_port, dead_port); + let config = Config::from_yaml(&yaml).unwrap(); + let proxy = start_proxy(&config); + + let body = r#"{"model":"gpt-4.1-mini","input":"Hello, world!"}"#; + let raw = http_send(proxy.addr(), &json_post("/v1/responses", body)); + + assert_eq!( + parse_status(&raw), + 502, + "proxy failure on unreachable upstream should return 502" + ); + assert_eq!( + parse_header(&raw, "content-type").as_deref(), + Some("application/json"), + "Content-Type should be application/json" + ); + + let parsed: serde_json::Value = + serde_json::from_str(&parse_body(&raw)).expect("response body should be valid JSON"); + assert_eq!( + parsed["error"]["type"], "server_error", + "OpenAI error type should be server_error for 502" + ); + assert!(parsed["error"]["param"].is_null(), "param should be null"); + assert!( + parsed["error"]["message"].is_string(), + "error message should be a string" + ); + assert!(parsed["error"]["code"].is_string(), "error code should be a string"); +} + +#[test] +fn proxy_failure_formats_openai_error_for_chat_completions() { + let dead_port = free_port(); + let proxy_port = free_port(); + + let yaml = echo_yaml(proxy_port, dead_port); + let config = Config::from_yaml(&yaml).unwrap(); + let proxy = start_proxy(&config); + + let body = r#"{"model":"gpt-4","messages":[{"role":"user","content":"Hi"}]}"#; + let raw = http_send(proxy.addr(), &json_post("/v1/chat/completions", body)); + + assert_eq!( + parse_status(&raw), + 502, + "proxy failure on unreachable upstream should return 502" + ); + assert_eq!( + parse_header(&raw, "content-type").as_deref(), + Some("application/json"), + "Content-Type should be application/json" + ); + + let parsed: serde_json::Value = + serde_json::from_str(&parse_body(&raw)).expect("response body should be valid JSON"); + assert_eq!( + parsed["error"]["type"], "server_error", + "OpenAI error type should be server_error for 502" + ); + assert!(parsed["error"]["param"].is_null(), "param should be null"); + assert!( + parsed["error"]["message"].is_string(), + "error message should be a string" + ); + assert!(parsed["error"]["code"].is_string(), "error code should be a string"); +} + +#[test] +fn proxy_failure_formats_openai_error_for_responses_subresource() { + let dead_port = free_port(); + let proxy_port = free_port(); + + let yaml = echo_yaml(proxy_port, dead_port); + let config = Config::from_yaml(&yaml).unwrap(); + let proxy = start_proxy(&config); + + let request = format!( + "GET /v1/responses/resp_123 HTTP/1.1\r\n\ + Host: localhost:{proxy_port}\r\n\ + Connection: close\r\n\ + \r\n" + ); + let raw = http_send(proxy.addr(), &request); + + assert_eq!( + parse_status(&raw), + 502, + "proxy failure on unreachable upstream should return 502" + ); + assert_eq!( + parse_header(&raw, "content-type").as_deref(), + Some("application/json"), + "Content-Type should be application/json" + ); + + let parsed: serde_json::Value = + serde_json::from_str(&parse_body(&raw)).expect("response body should be valid JSON"); + assert_eq!( + parsed["error"]["type"], "server_error", + "OpenAI error type should be server_error for 502" + ); + assert!(parsed["error"]["param"].is_null(), "param should be null"); +} + +#[test] +fn proxy_failure_does_not_format_openai_error_for_unclassified_request() { + let dead_port = free_port(); + let proxy_port = free_port(); + + let yaml = continue_yaml(proxy_port, dead_port); + let config = Config::from_yaml(&yaml).unwrap(); + let proxy = start_proxy(&config); + + let body = r#"{"unrelated_api":"data"}"#; + let raw = http_send(proxy.addr(), &json_post("/other/endpoint", body)); + + assert_eq!( + parse_status(&raw), + 502, + "proxy failure on unreachable upstream should return 502" + ); + + let body_str = parse_body(&raw); + let parsed: Result = serde_json::from_str(&body_str); + if let Ok(json) = parsed { + assert!( + json.get("error").and_then(|e| e.get("type")).is_none(), + "unclassified request should not receive OpenAI formatted error envelope" + ); + } +} + // ----------------------------------------------------------------------------- // Test Utilities // -----------------------------------------------------------------------------