Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 54 additions & 7 deletions apis/src/anthropic/messages_format/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@

use praxis_filter::{
FilterError,
builtins::http::payload_processing::{
OnInvalidBehavior,
config_validation::{validate_header_name, validate_max_body_bytes},
},
builtins::http::payload_processing::{OnInvalidBehavior, config_validation::validate_max_body_bytes},
};
use serde::Deserialize;

Expand All @@ -33,18 +30,29 @@ const DEFAULT_MAX_BODY_BYTES: usize = 1_048_576; // 1 MiB
// -----------------------------------------------------------------------------

/// Configurable header names for promoted classification facts.
///
/// Transport, credential, and unrelated internal `x-praxis-*` names are rejected.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct AnthropicMessagesFormatHeaders {
/// Header name for the detected format.
///
/// Must not be a hop-by-hop, framing, Host, credential, or unrelated
/// internal `x-praxis-*` header.
#[serde(default = "default_format_header")]
pub format: Option<String>,

/// Header name for the extracted model value.
///
/// Must not be a hop-by-hop, framing, Host, credential, or unrelated
/// internal `x-praxis-*` header.
#[serde(default = "default_model_header")]
pub model: Option<String>,

/// Header name for the extracted stream flag.
///
/// Must not be a hop-by-hop, framing, Host, credential, or unrelated
/// internal `x-praxis-*` header.
#[serde(default = "default_stream_header")]
pub stream: Option<String>,
}
Expand Down Expand Up @@ -105,6 +113,9 @@ pub(crate) struct AnthropicMessagesFormatConfig {
pub max_body_bytes: usize,

/// Header names for promoted classification facts.
///
/// Must not be hop-by-hop, framing, Host, credential, or unrelated
/// internal `x-praxis-*` names.
#[serde(default)]
pub headers: AnthropicMessagesFormatHeaders,
}
Expand All @@ -122,9 +133,9 @@ fn default_max_body_bytes() -> usize {
pub(crate) fn build_config(cfg: AnthropicMessagesFormatConfig) -> Result<AnthropicMessagesFormatConfig, FilterError> {
validate_max_body_bytes("anthropic_messages_format", cfg.max_body_bytes)?;

validate_header_name("anthropic_messages_format", "format", cfg.headers.format.as_deref())?;
validate_header_name("anthropic_messages_format", "model", cfg.headers.model.as_deref())?;
validate_header_name("anthropic_messages_format", "stream", cfg.headers.stream.as_deref())?;
crate::promotion::validate_promotion_header("anthropic_messages_format", "format", cfg.headers.format.as_deref())?;
crate::promotion::validate_promotion_header("anthropic_messages_format", "model", cfg.headers.model.as_deref())?;
crate::promotion::validate_promotion_header("anthropic_messages_format", "stream", cfg.headers.stream.as_deref())?;

Ok(cfg)
}
Expand Down Expand Up @@ -245,6 +256,42 @@ extra: true
assert!(build_config(cfg).is_ok());
}

#[test]
fn build_config_authorization_header_rejected() {
let cfg = AnthropicMessagesFormatConfig {
on_invalid: OnInvalidBehavior::default_continue(),
max_body_bytes: DEFAULT_MAX_BODY_BYTES,
headers: AnthropicMessagesFormatHeaders {
format: default_format_header(),
model: Some("authorization".into()),
stream: default_stream_header(),
},
};
let err = build_config(cfg).unwrap_err();
assert!(
err.to_string().contains("authorization"),
"authorization promotion header should be rejected: {err}"
);
}

#[test]
fn build_config_unrelated_internal_header_rejected() {
let cfg = AnthropicMessagesFormatConfig {
on_invalid: OnInvalidBehavior::default_continue(),
max_body_bytes: DEFAULT_MAX_BODY_BYTES,
headers: AnthropicMessagesFormatHeaders {
format: Some("x-praxis-route".into()),
model: default_model_header(),
stream: default_stream_header(),
},
};
let err = build_config(cfg).unwrap_err();
assert!(
err.to_string().contains("x-praxis-route"),
"unrelated x-praxis-* promotion header should be rejected: {err}"
);
}

// -- null header disables promotion ---------------------------------------

#[test]
Expand Down
4 changes: 1 addition & 3 deletions apis/src/openai/api_client/url.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,7 @@ fn is_blocked_forward_header(name: &str) -> bool {
|| name.starts_with("x-ext-agent-")
|| name.starts_with("x-mcp-")
|| name.starts_with("x-a2a-")
|| name == "content-length"
|| name == "host"
|| crate::http_hop::is_hop_by_hop(name)
|| crate::promotion::is_transport_controlled_header(name)
}

// -----------------------------------------------------------------------------
Expand Down
66 changes: 58 additions & 8 deletions apis/src/openai/responses/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@

//! Configuration types for the Responses format classifier filter.

use praxis_filter::{
FilterError,
builtins::http::payload_processing::{OnInvalidBehavior, config_validation::validate_header_name},
};
use praxis_filter::{FilterError, builtins::http::payload_processing::OnInvalidBehavior};
use serde::Deserialize;

// -----------------------------------------------------------------------------
Expand All @@ -18,22 +15,36 @@ use serde::Deserialize;
// -----------------------------------------------------------------------------

/// Configurable header names for promoted classification facts.
///
/// Transport, credential, and unrelated internal `x-praxis-*` names are rejected.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ResponsesFormatHeaders {
/// Header name for the detected format (e.g. `openai_responses`, `openai_chat_completions`).
///
/// Must not be a hop-by-hop, framing, Host, credential, or unrelated
/// internal `x-praxis-*` header.
#[serde(default = "default_format_header")]
pub format: Option<String>,

/// Header name for the extracted model value.
///
/// Must not be a hop-by-hop, framing, Host, credential, or unrelated
/// internal `x-praxis-*` header.
#[serde(default = "default_model_header")]
pub model: Option<String>,

/// Header name for the extracted stream flag.
///
/// Must not be a hop-by-hop, framing, Host, credential, or unrelated
/// internal `x-praxis-*` header.
#[serde(default = "default_stream_header")]
pub stream: Option<String>,

/// Header name for the computed mode (`stateless` or `stateful`).
///
/// Must not be a hop-by-hop, framing, Host, credential, or unrelated
/// internal `x-praxis-*` header.
#[serde(default = "default_mode_header")]
pub mode: Option<String>,
}
Expand Down Expand Up @@ -100,6 +111,9 @@ pub(crate) struct ResponsesFormatConfig {
pub on_invalid: OnInvalidBehavior,

/// Header names for promoted classification facts.
///
/// Must not be hop-by-hop, framing, Host, credential, or unrelated
/// internal `x-praxis-*` names.
#[serde(default)]
pub headers: ResponsesFormatHeaders,
}
Expand All @@ -110,10 +124,10 @@ pub(crate) struct ResponsesFormatConfig {

/// Validate the parsed configuration.
pub(crate) fn build_config(cfg: ResponsesFormatConfig) -> Result<ResponsesFormatConfig, FilterError> {
validate_header_name("openai_responses_format", "format", cfg.headers.format.as_deref())?;
validate_header_name("openai_responses_format", "model", cfg.headers.model.as_deref())?;
validate_header_name("openai_responses_format", "stream", cfg.headers.stream.as_deref())?;
validate_header_name("openai_responses_format", "mode", cfg.headers.mode.as_deref())?;
crate::promotion::validate_promotion_header("openai_responses_format", "format", cfg.headers.format.as_deref())?;
crate::promotion::validate_promotion_header("openai_responses_format", "model", cfg.headers.model.as_deref())?;
crate::promotion::validate_promotion_header("openai_responses_format", "stream", cfg.headers.stream.as_deref())?;
crate::promotion::validate_promotion_header("openai_responses_format", "mode", cfg.headers.mode.as_deref())?;

Ok(cfg)
}
Expand Down Expand Up @@ -215,6 +229,42 @@ extra: true
assert!(build_config(cfg).is_ok());
}

#[test]
fn build_config_authorization_header_rejected() {
let cfg = ResponsesFormatConfig {
on_invalid: OnInvalidBehavior::default_continue(),
headers: ResponsesFormatHeaders {
format: default_format_header(),
model: Some("authorization".into()),
stream: default_stream_header(),
mode: default_mode_header(),
},
};
let err = build_config(cfg).unwrap_err();
assert!(
err.to_string().contains("authorization"),
"authorization promotion header should be rejected: {err}"
);
}

#[test]
fn build_config_unrelated_internal_header_rejected() {
let cfg = ResponsesFormatConfig {
on_invalid: OnInvalidBehavior::default_continue(),
headers: ResponsesFormatHeaders {
format: Some("x-praxis-route".into()),
model: default_model_header(),
stream: default_stream_header(),
mode: default_mode_header(),
},
};
let err = build_config(cfg).unwrap_err();
assert!(
err.to_string().contains("x-praxis-route"),
"unrelated x-praxis-* promotion header should be rejected: {err}"
);
}

// -- null header disables promotion ---------------------------------------

#[test]
Expand Down
Loading
Loading