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
38 changes: 35 additions & 3 deletions apis/src/openai/responses/compact/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ const DEFAULT_STATUS_ON_ERROR: u16 = 502;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct CompactFilterConfig {
/// Allow summarization callouts from the `StreamBuffer` pre-read
/// phase, before header-phase security filters execute.
///
/// This must be explicitly enabled only when an outer trust
/// boundary authenticates and authorizes requests before they
/// reach this listener.
#[serde(default)]
pub allow_pre_security_callout: bool,

/// URL of the inference backend for summarization calls.
/// E.g., `"http://localhost:11434/v1/chat/completions"`
pub inference_url: String,
Expand Down Expand Up @@ -86,10 +95,12 @@ const SUPPORTED_ENCODINGS: &[&str] = &["cl100k_base", "o200k_base"];
///
/// # Errors
///
/// Returns [`FilterError`] if `inference_url` is empty,
/// `tiktoken_encoding` is not a supported encoding name,
/// `timeout_ms` is zero, or `status_on_error` is out of range.
/// Returns [`FilterError`] if `allow_pre_security_callout` is not
/// `true`, `inference_url` is empty, `tiktoken_encoding` is not a
/// supported encoding name, `timeout_ms` is zero, or
/// `status_on_error` is out of range.
pub(super) fn build_config(raw: &CompactFilterConfig) -> Result<ValidatedConfig, FilterError> {
validate_pre_security_callout(raw)?;
if raw.inference_url.is_empty() {
return Err(FilterError::from("openai_responses_compact: inference_url is empty"));
}
Expand Down Expand Up @@ -123,6 +134,17 @@ pub(super) fn build_config(raw: &CompactFilterConfig) -> Result<ValidatedConfig,
})
}

/// Require explicit acknowledgement of the pre-read security boundary.
fn validate_pre_security_callout(cfg: &CompactFilterConfig) -> Result<(), FilterError> {
if !cfg.allow_pre_security_callout {
return Err(
"openai_responses_compact: 'allow_pre_security_callout' must be true because StreamBuffer body callouts run before header-phase security filters; place authentication and authorization in an outer trust boundary"
.into(),
);
}
Ok(())
}

#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(clippy::expect_used, clippy::unwrap_used, reason = "tests")]
Expand Down Expand Up @@ -151,4 +173,14 @@ mod yaml_tests {
serde_yaml::from_str("inference_url: http://localhost/v1/chat/completions").expect("should deserialize");
assert_eq!(cfg.callout_failure_mode, None);
}

#[test]
fn pre_security_callout_defaults_to_false() {
let cfg: CompactFilterConfig =
serde_yaml::from_str("inference_url: http://localhost/v1/chat/completions").expect("should deserialize");
assert!(
!cfg.allow_pre_security_callout,
"pre-security callouts must be disabled until explicitly acknowledged"
);
}
}
12 changes: 12 additions & 0 deletions apis/src/openai/responses/compact/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
//! `conversation`. Single-turn requests (no stored history, even with
//! `context_management` set) are released without compaction because
//! there is no prior history to summarize.
//!
//! Praxis runs `StreamBuffer` body hooks before header-phase request
//! filters. Configuration therefore requires an explicit
//! `allow_pre_security_callout: true` acknowledgement and should only
//! be used behind an outer authentication and authorization boundary.

pub(super) mod config;

Expand Down Expand Up @@ -93,10 +98,16 @@ struct CompactionParams {
/// `openai_responses_rehydrate` has loaded stored conversation
/// history. Single-turn requests are released without compaction.
///
/// Praxis runs `StreamBuffer` body hooks before header-phase request
/// filters. This filter therefore requires
/// `allow_pre_security_callout: true` and should only be used behind
/// an outer authentication and authorization boundary.
///
/// # YAML
///
/// ```yaml
/// filter: openai_responses_compact
/// allow_pre_security_callout: true
/// inference_url: "http://localhost:11434/v1/chat/completions"
/// default_model: llama3.2:1b
/// ```
Expand All @@ -105,6 +116,7 @@ struct CompactionParams {
///
/// ```yaml
/// filter: openai_responses_compact
/// allow_pre_security_callout: true
/// inference_url: "http://localhost:11434/v1/chat/completions"
/// default_model: gpt-4o-mini
/// tiktoken_encoding: cl100k_base
Expand Down
39 changes: 38 additions & 1 deletion apis/src/openai/responses/compact/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use crate::openai::responses::config_validation::FailureMode;

fn base_config() -> CompactFilterConfig {
CompactFilterConfig {
allow_pre_security_callout: true,
inference_url: "http://localhost:11434/v1/chat/completions".to_owned(),
default_model: "gpt-4o-mini".to_owned(),
tiktoken_encoding: "cl100k_base".to_owned(),
Expand All @@ -32,6 +33,42 @@ fn build_config_applies_defaults() {
assert_eq!(cfg.callout.status_on_error, 502);
}

#[test]
fn build_config_rejects_missing_pre_security_ack() {
let mut cfg = base_config();
cfg.allow_pre_security_callout = false;
let err = build_config(&cfg).unwrap_err();
assert!(
err.to_string().contains("allow_pre_security_callout"),
"should mention allow_pre_security_callout: {err}"
);
}

#[test]
fn from_config_missing_pre_security_ack() {
let yaml =
serde_yaml::from_str::<serde_yaml::Value>("inference_url: http://localhost/v1/chat/completions").unwrap();
let err = CompactFilter::from_config(&yaml)
.err()
.expect("should fail without allow_pre_security_callout");
assert!(
err.to_string().contains("allow_pre_security_callout"),
"should mention allow_pre_security_callout: {err}"
);
}

#[test]
fn from_config_accepts_pre_security_ack() {
let yaml = serde_yaml::from_str::<serde_yaml::Value>(
"allow_pre_security_callout: true\ninference_url: http://localhost/v1/chat/completions",
)
.unwrap();
assert!(
CompactFilter::from_config(&yaml).is_ok(),
"explicit allow_pre_security_callout should construct"
);
}

#[test]
fn build_config_rejects_empty_inference_url() {
let mut cfg = base_config();
Expand Down Expand Up @@ -432,7 +469,7 @@ fn conversation_text_skips_empty_compaction_summary() {

fn make_filter(failure_mode: &str) -> CompactFilter {
let yaml = serde_yaml::from_str::<serde_yaml::Value>(&format!(
"inference_url: http://localhost/v1/chat/completions\ncallout_failure_mode: {failure_mode}"
"allow_pre_security_callout: true\ninference_url: http://localhost/v1/chat/completions\ncallout_failure_mode: {failure_mode}"
))
.unwrap();
let cfg: CompactFilterConfig = serde_yaml::from_value(yaml).unwrap();
Expand Down
5 changes: 5 additions & 0 deletions docs/filters/openai_responses_compact.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ Summarizes conversation history when the token count exceeds a configured thresh

Compaction only applies to multi-turn requests where `openai_responses_rehydrate` has loaded stored conversation history. Single-turn requests are released without compaction.

Praxis runs `StreamBuffer` body hooks before header-phase request filters. This filter therefore requires `allow_pre_security_callout: true` and should only be used behind an outer authentication and authorization boundary.

## Configuration

| Field | Type | Required | Description |
|-------|------|---------|-------------|
| `allow_pre_security_callout` | bool | no | Allow summarization callouts from the `StreamBuffer` pre-read phase, before header-phase security filters execute. This must be explicitly enabled only when an outer trust boundary authenticates and authorizes requests before they reach this listener. |
| `inference_url` | string | yes | URL of the inference backend for summarization calls. E.g., `"http://localhost:11434/v1/chat/completions"` |
| `default_model` | string | no | Default model for summarization when not overridden in the request's `context_management`. |
| `tiktoken_encoding` | string | no | Tiktoken encoding name for local token estimation of the conversation text. |
Expand All @@ -28,6 +31,7 @@ Compaction only applies to multi-turn requests where `openai_responses_rehydrate

```yaml
filter: openai_responses_compact
allow_pre_security_callout: true
inference_url: "http://localhost:11434/v1/chat/completions"
default_model: llama3.2:1b
```
Expand All @@ -36,6 +40,7 @@ default_model: llama3.2:1b

```yaml
filter: openai_responses_compact
allow_pre_security_callout: true
inference_url: "http://localhost:11434/v1/chat/completions"
default_model: gpt-4o-mini
tiktoken_encoding: cl100k_base
Expand Down
6 changes: 6 additions & 0 deletions examples/configs/openai/responses/compact.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
# Demonstrates the compaction flow: store a response, rehydrate it
# on the next turn, and count tokens to check if compaction is needed.
#
# Security: StreamBuffer body callouts run before this listener's
# header-phase filters. Deploy this example behind an outer
# authentication and authorization boundary before enabling the
# required allow_pre_security_callout acknowledgement below.
#
# Usage:
# cargo run -p praxis-ai-proxy -- -c examples/configs/openai/responses/compact.yaml
#
Expand Down Expand Up @@ -51,6 +56,7 @@ filter_chains:
- filter: openai_responses_rehydrate

- filter: openai_responses_compact
allow_pre_security_callout: true
inference_url: "http://localhost:11434/v1/chat/completions"
default_model: llama3.2:1b
timeout_ms: 60000
Expand Down
Loading