From 53ab26d6d724ca28d3256c7481874e7017b1dc87 Mon Sep 17 00:00:00 2001 From: Sumanth Kamenani Date: Fri, 21 Aug 2026 17:00:06 -0400 Subject: [PATCH 1/6] feat(anthropic): install Anthropic error response formatter for fatal proxy failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an ErrorResponseFormatter implementation that produces the standard Anthropic error envelope when Praxis synthesizes a fatal proxy response (connection refusal, timeout, etc.). The formatter is installed as a request extension after positive Anthropic Messages classification. Praxis invokes it from fail_to_proxy instead of emitting RFC 9457 Problem Details. Mapping: - 504 → timeout_error - 529 → overloaded_error - 429 → rate_limit_error - other → api_error - Top-level type is always "error" - request_id captured from x-request-id header or generated - Content-Type: application/json Known gap: FormattedErrorResponse in praxis-filter 0.5.3 carries only body + content_type. The matching request-id response header cannot be set until Praxis extends the contract. The request ID is included in the JSON body only. See Seb's confirmation in AI #683. Non-Anthropic classifications do not install the formatter. Closes: RHAIENG-6941 Ref: praxis-proxy/ai#683 Signed-off-by: Sumanth Kamenani --- .../src/anthropic/error_response_formatter.rs | 221 ++++++++++++++++++ apis/src/anthropic/messages_format/mod.rs | 43 +++- apis/src/anthropic/mod.rs | 1 + 3 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 apis/src/anthropic/error_response_formatter.rs diff --git a/apis/src/anthropic/error_response_formatter.rs b/apis/src/anthropic/error_response_formatter.rs new file mode 100644 index 0000000000..8240d0fef7 --- /dev/null +++ b/apis/src/anthropic/error_response_formatter.rs @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Anthropic error response formatter for Praxis fatal proxy failures. +//! +//! Implements [`ErrorResponseFormatter`] to produce the schema-valid +//! Anthropic error envelope that Anthropic SDKs expect. Installed as +//! a request extension after positive Anthropic classification. +//! +//! # Known gap +//! +//! `FormattedErrorResponse` in praxis-filter 0.5.3 carries only `body` +//! and `content_type` — there is no `response_headers` field. Seb +//! confirmed the Anthropic `request-id` response header must use the +//! same value as the JSON `request_id`, but the Praxis contract needs +//! to be extended first. The request ID is included in the JSON body; +//! the response header is deferred until Praxis adds support. + +use praxis_filter::{ErrorResponseContext, ErrorResponseFormatter, FormattedErrorResponse}; + +use super::wire; + +/// Formats Praxis fatal proxy failures as Anthropic error JSON. +/// +/// Produces `{"type":"error","error":{"type":"…","message":"…"},"request_id":"…"}`. +/// +/// The request ID is captured at construction time — either from the +/// incoming `x-request-id` header or generated when the formatter is +/// installed. +pub(crate) struct AnthropicErrorFormatter { + /// Captured request identifier for the JSON body. + request_id: String, +} + +impl AnthropicErrorFormatter { + /// Create a formatter with a captured request identifier. + pub(crate) fn new(request_id: String) -> Self { + Self { request_id } + } +} + +impl ErrorResponseFormatter for AnthropicErrorFormatter { + fn format(&self, context: &ErrorResponseContext<'_>) -> FormattedErrorResponse { + let error_type = anthropic_error_type(context.status); + let body = wire::error_body(error_type, context.message, Some(&self.request_id)); + + FormattedErrorResponse::new(body, http::HeaderValue::from_static("application/json")) + } +} + +/// Map a Praxis-selected HTTP status to an Anthropic error type. +/// +/// Only statuses that Praxis may synthesize for fatal proxy failures +/// are mapped. The mapping is consistent with the existing +/// `error_type_for_status` in `to_openai/response.rs`. +fn anthropic_error_type(status: u16) -> &'static str { + match status { + 504 => "timeout_error", + 529 => "overloaded_error", + 429 => "rate_limit_error", + _ => "api_error", + } +} + +// ----------------------------------------------------------------------------- +// 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_anthropic_json() { + let formatter = AnthropicErrorFormatter::new("req_test_001".to_owned()); + let ctx = ErrorResponseContext::new("upstream_connect_error", "Connection refused", 502); + let response = formatter.format(&ctx); + + let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!(parsed["type"], "error"); + assert_eq!(parsed["error"]["type"], "api_error"); + assert_eq!(parsed["error"]["message"], "Connection refused"); + assert_eq!(parsed["request_id"], "req_test_001"); + } + + #[test] + fn timeout_produces_timeout_error_type() { + let formatter = AnthropicErrorFormatter::new("req_timeout".to_owned()); + let ctx = ErrorResponseContext::new("upstream_connect_timeout", "Connection timed out", 504); + let response = formatter.format(&ctx); + + let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!(parsed["type"], "error"); + assert_eq!(parsed["error"]["type"], "timeout_error"); + assert_eq!(parsed["error"]["message"], "Connection timed out"); + } + + #[test] + fn overloaded_status_produces_overloaded_error_type() { + let formatter = AnthropicErrorFormatter::new("req_529".to_owned()); + let ctx = ErrorResponseContext::new("upstream_overloaded", "Service overloaded", 529); + let response = formatter.format(&ctx); + + let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!(parsed["error"]["type"], "overloaded_error"); + } + + #[test] + fn generic_fivex_produces_api_error_type() { + for status in [500, 502, 503] { + let formatter = AnthropicErrorFormatter::new("req_5xx".to_owned()); + let ctx = ErrorResponseContext::new("some_code", "some message", status); + let response = formatter.format(&ctx); + + let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!( + parsed["error"]["type"], "api_error", + "status {status} should map to api_error" + ); + } + } + + #[test] + fn rate_limit_produces_rate_limit_error_type() { + let formatter = AnthropicErrorFormatter::new("req_429".to_owned()); + let ctx = ErrorResponseContext::new("rate_limit_exceeded", "Too many requests", 429); + let response = formatter.format(&ctx); + + let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!(parsed["error"]["type"], "rate_limit_error"); + } + + #[test] + fn content_type_is_application_json() { + let formatter = AnthropicErrorFormatter::new("req_ct".to_owned()); + let ctx = ErrorResponseContext::new("upstream_connect_error", "Connection refused", 502); + let response = formatter.format(&ctx); + + assert_eq!( + response.content_type, + http::HeaderValue::from_static("application/json") + ); + } + + #[test] + fn top_level_type_is_always_error() { + for status in [400, 429, 500, 502, 504, 529] { + let formatter = AnthropicErrorFormatter::new("req_type".to_owned()); + let ctx = ErrorResponseContext::new("test", "test", status); + let response = formatter.format(&ctx); + + let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!(parsed["type"], "error", "top-level type must be 'error' for {status}"); + } + } + + #[test] + fn nested_error_type_is_always_in_allowed_vocabulary() { + let allowed = [ + "invalid_request_error", + "authentication_error", + "billing_error", + "permission_error", + "not_found_error", + "conflict_error", + "request_too_large", + "rate_limit_error", + "timeout_error", + "api_error", + "overloaded_error", + ]; + + for status in [400, 429, 500, 502, 503, 504, 529] { + let formatter = AnthropicErrorFormatter::new("req_vocab".to_owned()); + let ctx = ErrorResponseContext::new("test_code", "test message", status); + let response = formatter.format(&ctx); + + let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + let error_type = parsed["error"]["type"].as_str().unwrap(); + assert!( + allowed.contains(&error_type), + "error type '{error_type}' for status {status} must be in allowed vocabulary" + ); + } + } + + #[test] + fn request_id_present_in_body() { + let formatter = AnthropicErrorFormatter::new("req_abc123".to_owned()); + let ctx = ErrorResponseContext::new("upstream_error", "failed", 502); + let response = formatter.format(&ctx); + + let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!(parsed["request_id"], "req_abc123"); + } + + #[test] + fn json_escaping_handles_special_characters() { + let formatter = AnthropicErrorFormatter::new("req_escape".to_owned()); + let ctx = ErrorResponseContext::new("server_error", "line1\nline2\"quoted\"\tand\\backslash", 500); + let response = formatter.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 unknown_fourx_defaults_to_api_error() { + let formatter = AnthropicErrorFormatter::new("req_4xx".to_owned()); + let ctx = ErrorResponseContext::new("unknown_code", "unknown error", 418); + let response = formatter.format(&ctx); + + let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!(parsed["error"]["type"], "api_error"); + } +} diff --git a/apis/src/anthropic/messages_format/mod.rs b/apis/src/anthropic/messages_format/mod.rs index af4d955db0..55c93d8373 100644 --- a/apis/src/anthropic/messages_format/mod.rs +++ b/apis/src/anthropic/messages_format/mod.rs @@ -32,7 +32,7 @@ use std::borrow::Cow; 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}; @@ -154,6 +154,8 @@ impl HttpFilter for AnthropicMessagesFormatFilter { return Ok(action); } + install_error_formatter(ctx, classified.format); + write_metadata(ctx, &classified); promote_headers(ctx, &classified, &self.config); promote_filter_results(ctx, &classified)?; @@ -166,6 +168,33 @@ impl HttpFilter for AnthropicMessagesFormatFilter { // Helpers // ----------------------------------------------------------------------------- +/// Install the Anthropic error response formatter for positively +/// classified Anthropic Messages requests. +/// +/// Captures the request ID from the incoming `x-request-id` header, +/// or generates a UUID when no header is present. The same ID is +/// used in the JSON body `request_id` field. +/// +/// **Known gap:** The matching `request-id` response header cannot +/// be set through `FormattedErrorResponse` until Praxis adds a +/// `response_headers` field. Only the JSON body carries the ID today. +fn install_error_formatter(ctx: &mut HttpFilterContext<'_>, format: AiRequestFormat) { + if format != AiRequestFormat::AnthropicMessages { + return; + } + + let request_id = ctx + .request + .headers + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map_or_else(generate_request_id, ToOwned::to_owned); + + ctx.extensions.insert(ErrorResponseFormatterHandle::new( + crate::anthropic::error_response_formatter::AnthropicErrorFormatter::new(request_id), + )); +} + /// Check whether the format requires rejection. fn handle_invalid_format(format: AiRequestFormat, config: &AnthropicMessagesFormatConfig) -> Option { match config.on_invalid { @@ -240,6 +269,18 @@ fn promote_headers( } } +/// Generate a request identifier when the client did not send one. +/// +/// Uses a timestamp-based hex string prefixed with `req_` to match +/// the Anthropic convention without adding a UUID dependency. +fn generate_request_id() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!("req_{nanos:032x}") +} + /// Check whether the path is the Anthropic Messages endpoint, /// normalizing a trailing slash. fn is_anthropic_messages_path(path: &str) -> bool { diff --git a/apis/src/anthropic/mod.rs b/apis/src/anthropic/mod.rs index 58c210cf92..ac8561b825 100644 --- a/apis/src/anthropic/mod.rs +++ b/apis/src/anthropic/mod.rs @@ -3,6 +3,7 @@ //! Anthropic protocol filters. +pub(crate) mod error_response_formatter; mod messages_format; mod protocol; mod stream_events; From 987135e2234189d7f8e6aac3536ff4d5814ceeb1 Mon Sep 17 00:00:00 2001 From: Sumanth Kamenani Date: Mon, 31 Aug 2026 16:17:56 -0400 Subject: [PATCH 2/6] refactor(anthropic): generalize response header doc comment Signed-off-by: Sumanth Kamenani --- apis/src/anthropic/error_response_formatter.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/apis/src/anthropic/error_response_formatter.rs b/apis/src/anthropic/error_response_formatter.rs index 8240d0fef7..a3dfcaa436 100644 --- a/apis/src/anthropic/error_response_formatter.rs +++ b/apis/src/anthropic/error_response_formatter.rs @@ -7,14 +7,12 @@ //! Anthropic error envelope that Anthropic SDKs expect. Installed as //! a request extension after positive Anthropic classification. //! -//! # Known gap +//! # Note on response headers //! -//! `FormattedErrorResponse` in praxis-filter 0.5.3 carries only `body` -//! and `content_type` — there is no `response_headers` field. Seb -//! confirmed the Anthropic `request-id` response header must use the -//! same value as the JSON `request_id`, but the Praxis contract needs -//! to be extended first. The request ID is included in the JSON body; -//! the response header is deferred until Praxis adds support. +//! `FormattedErrorResponse` provides `body` and `content_type`. When Praxis +//! supports custom response headers on formatted error responses, the matching +//! `request-id` response header can be emitted alongside the JSON body. +//! Currently, the request ID is included in the JSON body. use praxis_filter::{ErrorResponseContext, ErrorResponseFormatter, FormattedErrorResponse}; From 8097933202ee27394a85159a8a88336fc8026cae Mon Sep 17 00:00:00 2001 From: Sumanth Kamenani Date: Mon, 31 Aug 2026 17:41:25 -0400 Subject: [PATCH 3/6] feat(anthropic): expand error type mapping, add Unicode test, and add integration tests Signed-off-by: Sumanth Kamenani --- .../src/anthropic/error_response_formatter.rs | 65 +++++--- .../tests/suite/anthropic_messages.rs | 153 +++++++++++++++++- 2 files changed, 199 insertions(+), 19 deletions(-) diff --git a/apis/src/anthropic/error_response_formatter.rs b/apis/src/anthropic/error_response_formatter.rs index a3dfcaa436..ca299549cc 100644 --- a/apis/src/anthropic/error_response_formatter.rs +++ b/apis/src/anthropic/error_response_formatter.rs @@ -46,17 +46,23 @@ impl ErrorResponseFormatter for AnthropicErrorFormatter { } } -/// Map a Praxis-selected HTTP status to an Anthropic error type. +/// Map an HTTP error status to an Anthropic error type. /// -/// Only statuses that Praxis may synthesize for fatal proxy failures -/// are mapped. The mapping is consistent with the existing -/// `error_type_for_status` in `to_openai/response.rs`. +/// The mapping is consistent with the existing `error_type_for_status` in +/// `to_openai/response.rs`. fn anthropic_error_type(status: u16) -> &'static str { match status { + 401 => "authentication_error", + 402 => "billing_error", + 403 => "permission_error", + 404 => "not_found_error", + 409 => "conflict_error", + 413 => "request_too_large", + 429 => "rate_limit_error", 504 => "timeout_error", 529 => "overloaded_error", - 429 => "rate_limit_error", - _ => "api_error", + 500..=599 => "api_error", + _ => "invalid_request_error", } } @@ -121,13 +127,31 @@ mod tests { } #[test] - fn rate_limit_produces_rate_limit_error_type() { - let formatter = AnthropicErrorFormatter::new("req_429".to_owned()); - let ctx = ErrorResponseContext::new("rate_limit_exceeded", "Too many requests", 429); - let response = formatter.format(&ctx); + fn fourx_status_maps_to_anthropic_error_types() { + let cases = [ + (400, "invalid_request_error"), + (401, "authentication_error"), + (402, "billing_error"), + (403, "permission_error"), + (404, "not_found_error"), + (409, "conflict_error"), + (413, "request_too_large"), + (422, "invalid_request_error"), + (429, "rate_limit_error"), + (418, "invalid_request_error"), + ]; - let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); - assert_eq!(parsed["error"]["type"], "rate_limit_error"); + for (status, expected_type) in cases { + let formatter = AnthropicErrorFormatter::new("req_4xx".to_owned()); + let ctx = ErrorResponseContext::new("test_code", "test message", status); + let response = formatter.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}'" + ); + } } #[test] @@ -144,7 +168,7 @@ mod tests { #[test] fn top_level_type_is_always_error() { - for status in [400, 429, 500, 502, 504, 529] { + for status in [400, 401, 403, 404, 413, 429, 500, 502, 504, 529] { let formatter = AnthropicErrorFormatter::new("req_type".to_owned()); let ctx = ErrorResponseContext::new("test", "test", status); let response = formatter.format(&ctx); @@ -170,7 +194,7 @@ mod tests { "overloaded_error", ]; - for status in [400, 429, 500, 502, 503, 504, 529] { + for status in [400, 401, 402, 403, 404, 409, 413, 422, 429, 500, 502, 503, 504, 529] { let formatter = AnthropicErrorFormatter::new("req_vocab".to_owned()); let ctx = ErrorResponseContext::new("test_code", "test message", status); let response = formatter.format(&ctx); @@ -208,12 +232,17 @@ mod tests { } #[test] - fn unknown_fourx_defaults_to_api_error() { - let formatter = AnthropicErrorFormatter::new("req_4xx".to_owned()); - let ctx = ErrorResponseContext::new("unknown_code", "unknown error", 418); + fn json_escaping_handles_unicode() { + let formatter = AnthropicErrorFormatter::new("req_unicode".to_owned()); + let ctx = ErrorResponseContext::new("server_error", "Connection to サーバー failed 🔥 (مرحبا)", 500); let response = formatter.format(&ctx); let parsed: serde_json::Value = serde_json::from_slice(&response.body).unwrap(); - assert_eq!(parsed["error"]["type"], "api_error"); + assert_eq!( + parsed["error"]["message"].as_str().unwrap(), + "Connection to サーバー failed 🔥 (مرحبا)" + ); + + assert!(std::str::from_utf8(&response.body).is_ok()); } } diff --git a/tests/integration/tests/suite/anthropic_messages.rs b/tests/integration/tests/suite/anthropic_messages.rs index 31b14c2ba8..d81959c5f2 100644 --- a/tests/integration/tests/suite/anthropic_messages.rs +++ b/tests/integration/tests/suite/anthropic_messages.rs @@ -9,7 +9,8 @@ use praxis_core::config::Config; use praxis_test_utils::{ - Backend, Recording, free_port, http_send, parse_body, parse_status, start_backend_with_shutdown, start_proxy, + Backend, Recording, free_port, http_send, json_post, parse_body, parse_header, parse_status, + start_backend_with_shutdown, start_proxy, }; // ----------------------------------------------------------------------------- @@ -407,10 +408,146 @@ fn content_block_array() { ); } +// ----------------------------------------------------------------------------- +// Error Formatter Integration Tests +// ----------------------------------------------------------------------------- + +#[test] +fn proxy_failure_formats_anthropic_error_for_messages() { + let dead_port = free_port(); + let proxy_port = free_port(); + + let yaml = error_formatter_yaml(proxy_port, dead_port); + let config = Config::from_yaml(&yaml).unwrap(); + let proxy = start_proxy(&config); + + let body = + r#"{"model":"claude-3-5-sonnet-20241022","max_tokens":1024,"messages":[{"role":"user","content":"Hi"}]}"#; + let raw = http_send(proxy.addr(), &anthropic_post("/v1/messages", 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["type"], "error", "Anthropic top-level type should be error"); + assert_eq!( + parsed["error"]["type"], "api_error", + "Anthropic error type should be api_error for 502" + ); + assert!( + parsed["error"]["message"].is_string(), + "error message should be a string" + ); + assert!( + parsed["request_id"].as_str().unwrap().starts_with("req_"), + "request_id should be present with req_ prefix" + ); +} + +#[test] +fn proxy_failure_formats_anthropic_error_with_custom_request_id() { + let dead_port = free_port(); + let proxy_port = free_port(); + + let yaml = error_formatter_yaml(proxy_port, dead_port); + let config = Config::from_yaml(&yaml).unwrap(); + let proxy = start_proxy(&config); + + let body = + r#"{"model":"claude-3-5-sonnet-20241022","max_tokens":1024,"messages":[{"role":"user","content":"Hi"}]}"#; + let raw = http_send( + proxy.addr(), + &anthropic_post_with_request_id("/v1/messages", body, "req_custom_123"), + ); + + 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["type"], "error", "Anthropic top-level type should be error"); + assert_eq!( + parsed["request_id"], "req_custom_123", + "request_id should match client x-request-id header" + ); +} + +#[test] +fn proxy_failure_does_not_format_anthropic_error_for_unclassified_request() { + let dead_port = free_port(); + let proxy_port = free_port(); + + let yaml = error_formatter_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("type").and_then(|t| t.as_str()) != Some("error") + || json.get("error").and_then(|e| e.get("type")).is_none(), + "unclassified request should not receive Anthropic formatted error envelope" + ); + } +} + // ----------------------------------------------------------------------------- // Test Utilities // ----------------------------------------------------------------------------- +fn error_formatter_yaml(proxy_port: u16, backend_port: u16) -> String { + format!( + r#" +listeners: + - name: test + address: "127.0.0.1:{proxy_port}" + filter_chains: [passthrough] + +filter_chains: + - name: passthrough + filters: + - filter: anthropic_messages_format + on_invalid: continue + - filter: router + routes: + - path_prefix: "/" + cluster: mock + - filter: load_balancer + clusters: + - name: mock + endpoints: + - "127.0.0.1:{backend_port}" +"# + ) +} + fn passthrough_yaml(proxy_port: u16, backend_port: u16) -> String { format!( r#" @@ -456,6 +593,20 @@ fn anthropic_post(path: &str, body: &str) -> String { ) } +fn anthropic_post_with_request_id(path: &str, body: &str, request_id: &str) -> String { + format!( + "POST {path} HTTP/1.1\r\n\ + Host: localhost\r\n\ + Content-Type: application/json\r\n\ + anthropic-version: 2023-06-01\r\n\ + x-request-id: {request_id}\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\r\n\ + {body}", + body.len() + ) +} + fn parse_sse_events(body: &str) -> Vec { let mut events = Vec::new(); let mut current_event_type = None; From 561106f1ef7d05c65738642538e67b0309dbcae8 Mon Sep 17 00:00:00 2001 From: Sumanth Kamenani Date: Tue, 1 Sep 2026 09:10:16 -0400 Subject: [PATCH 4/6] test(anthropic): allow private endpoints in error formatter integration test yaml Signed-off-by: Sumanth Kamenani --- tests/integration/tests/suite/anthropic_messages.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/tests/suite/anthropic_messages.rs b/tests/integration/tests/suite/anthropic_messages.rs index d81959c5f2..36f100e6c0 100644 --- a/tests/integration/tests/suite/anthropic_messages.rs +++ b/tests/integration/tests/suite/anthropic_messages.rs @@ -544,6 +544,9 @@ filter_chains: - name: mock endpoints: - "127.0.0.1:{backend_port}" + +insecure_options: + allow_private_endpoints: true "# ) } From 9f560519a32e2e7b954eced8136cd683634d3d5c Mon Sep 17 00:00:00 2001 From: Sumanth Kamenani Date: Wed, 2 Sep 2026 09:52:07 -0400 Subject: [PATCH 5/6] fix(anthropic): support messages subresource paths in path classifier Signed-off-by: Sumanth Kamenani --- apis/src/anthropic/messages_format/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apis/src/anthropic/messages_format/mod.rs b/apis/src/anthropic/messages_format/mod.rs index 55c93d8373..bd1d3666f3 100644 --- a/apis/src/anthropic/messages_format/mod.rs +++ b/apis/src/anthropic/messages_format/mod.rs @@ -285,7 +285,7 @@ fn generate_request_id() -> String { /// normalizing a trailing slash. fn is_anthropic_messages_path(path: &str) -> bool { let normalized = path.strip_suffix('/').unwrap_or(path); - normalized == "/v1/messages" + normalized == "/v1/messages" || normalized.starts_with("/v1/messages/") } /// Promote classification facts to filter results for branch conditions. From 08fa57cb5e0ea222ecdb33bcdbeae06815d5f8d3 Mon Sep 17 00:00:00 2001 From: Sumanth Kamenani Date: Wed, 2 Sep 2026 09:58:30 -0400 Subject: [PATCH 6/6] chore(license): update error_response_formatter SPDX header to Apache-2.0 Signed-off-by: Sumanth Kamenani --- apis/src/anthropic/error_response_formatter.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apis/src/anthropic/error_response_formatter.rs b/apis/src/anthropic/error_response_formatter.rs index ca299549cc..3c9611e126 100644 --- a/apis/src/anthropic/error_response_formatter.rs +++ b/apis/src/anthropic/error_response_formatter.rs @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: Apache-2.0 // Copyright (c) 2026 Praxis Contributors //! Anthropic error response formatter for Praxis fatal proxy failures.