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
2 changes: 1 addition & 1 deletion apis/src/anthropic/web_search/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ struct ResponseEnvelope<'a> {
/// api_key: ${WEB_SEARCH_API_KEY}
/// default_context_size: medium
/// timeout_ms: 10000
/// provider_failure_mode: closed
/// on_failure: closed
/// status_on_error: 502
/// max_body_bytes: 67108864
/// ```
Expand Down
4 changes: 2 additions & 2 deletions apis/src/anthropic/web_search/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ default_context_size: medium
AnthropicWebSearchFilter::from_config(&config).unwrap()
}

fn test_filter_impl_with_base_url(base_url: &str, provider_failure_mode: &str) -> AnthropicWebSearchFilter {
fn test_filter_impl_with_base_url(base_url: &str, on_failure: &str) -> AnthropicWebSearchFilter {
let config = serde_yaml::from_str(&format!(
r#"
provider: you
api_key: test-key
default_context_size: medium
provider_failure_mode: {provider_failure_mode}
on_failure: {on_failure}
base_url: "{base_url}"
allow_private_base_url: true
"#,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,41 +1,82 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Praxis Contributors

//! Shared config validation helpers for Responses API filters.
//! Shared failure-policy vocabulary for outbound AI callouts.
//!
//! # Classification is filter-specific
//!
//! These enums are a vocabulary: they fix the accepted values
//! and the default, not which conditions a filter routes through
//! which key. Each filter's `on_failure` / `on_missing` field docs
//! and behavior are authoritative.
//!
//! # Naming
//!
//! The external keys are `on_failure` and `on_missing`. A structural
//! `failure_mode` key is already owned by Core's pipeline entries.

use praxis_filter::FilterError;
use serde::Deserialize;

// -----------------------------------------------------------------------------
// FailureMode
// OnFailure
// -----------------------------------------------------------------------------

/// What happens when a callout to an external service fails.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
/// What happens when an outbound callout does not produce a usable
/// answer. Configured as `on_failure`.
///
/// For a callout that succeeds but reports an absent resource, use
/// [`OnMissing`].
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum FailureMode {
pub enum OnFailure {
/// Reject the request on failure (default).
#[default]
Closed,

/// Continue without the callout result on failure.
Open,
}

// -----------------------------------------------------------------------------
// OnMissing
// -----------------------------------------------------------------------------

/// What happens when a requested resource cannot be fetched. Configured
/// as `on_missing`.
///

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(ai): "callout_policy.rs describes OnMissing as applying when a callout succeeds and reports a missing resource. openai_file_resolve resolve_reference at apis/src/openai/responses/file_resolve/resolve.rs:666-668 treats on_missing: continue as pass-through for any file_id resolution error except TooManyReferences, including transport, timeout, non-2xx, parsing, and size failures—not only a successful 404-style absence. Filter docs (openai_file_resolve.md line 24) say cannot be fetched, which is closer to behavior than the shared enum doc. The shared vocabulary should either document this widening explicitly or narrow the enum description to filter-specific semantics with a cross-reference to file_resolve."

@r-papso r-papso Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right. I updated the docs to state that OnMissing controls "What happens when a requested resource cannot be fetched". That indeed more closely maps to file_resolve.rs which is for now only filter using it. The enum's purpose and docs description can be widened in the future, if needed.

/// A filter may narrow the set of resources this governs, but must never
/// widen it to cover failures that carry a security signal (e.g. a file
/// URL that cannot be resolved - the target may be malicious or unreachable
/// for policy reasons).
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum OnMissing {
/// Continue without the resource.
#[default]
Continue,

/// Return an error response to the client.
Reject,
}

// -----------------------------------------------------------------------------
// CalloutSettings
// -----------------------------------------------------------------------------

/// Common callout fields shared by filters that make HTTP callouts.
#[derive(Debug, Clone, Copy)]
pub(crate) struct CalloutSettings {
pub struct CalloutSettings {
/// Callout timeout in milliseconds.
pub timeout_ms: u64,

/// Failure mode for the callout.
pub failure_mode: FailureMode,
pub on_failure: OnFailure,

/// HTTP status code to return when rejecting on error.
pub status_on_error: u16,
}


// -----------------------------------------------------------------------------
// Validation helpers
// -----------------------------------------------------------------------------
Expand All @@ -45,7 +86,7 @@ pub(crate) struct CalloutSettings {
/// # Errors
///
/// Returns [`FilterError`] when the resolved value is zero.
pub(crate) fn validate_timeout_ms(filter: &str, raw: Option<u64>, default: u64) -> Result<u64, FilterError> {
pub fn validate_timeout_ms(filter: &str, raw: Option<u64>, default: u64) -> Result<u64, FilterError> {
let value = raw.unwrap_or(default);
if value == 0 {
return Err(format!("{filter}: timeout_ms must be greater than 0").into());
Expand All @@ -60,7 +101,7 @@ pub(crate) fn validate_timeout_ms(filter: &str, raw: Option<u64>, default: u64)
///
/// Returns [`FilterError`] when the resolved value is not in
/// `100..=599`.
pub(crate) fn validate_status_on_error(filter: &str, raw: Option<u16>, default: u16) -> Result<u16, FilterError> {
pub fn validate_status_on_error(filter: &str, raw: Option<u16>, default: u16) -> Result<u16, FilterError> {
let value = raw.unwrap_or(default);
if !(100..=599).contains(&value) {
return Err(format!("{filter}: status_on_error must be between 100 and 599, got {value}").into());
Expand Down Expand Up @@ -144,4 +185,41 @@ mod tests {
"error should include filter name, got: {err}"
);
}

// -------------------------------------------------------------------------
// Canonical vocabulary
// -------------------------------------------------------------------------

#[test]
fn on_failure_deserializes_canonical_values() {
assert_eq!(serde_yaml::from_str::<OnFailure>("closed").unwrap(), OnFailure::Closed);
assert_eq!(serde_yaml::from_str::<OnFailure>("open").unwrap(), OnFailure::Open);
}

#[test]
fn on_failure_defaults_to_closed() {
assert_eq!(OnFailure::default(), OnFailure::Closed);
}

#[test]
fn on_missing_defaults_to_continue() {
assert_eq!(OnMissing::default(), OnMissing::Continue);
}

#[test]
fn on_missing_deserializes_canonical_values() {
assert_eq!(
serde_yaml::from_str::<OnMissing>("continue").unwrap(),
OnMissing::Continue
);
assert_eq!(serde_yaml::from_str::<OnMissing>("reject").unwrap(), OnMissing::Reject);
}

#[test]
fn on_failure_rejects_on_missing_vocabulary() {
assert!(serde_yaml::from_str::<OnFailure>("continue").is_err());
assert!(serde_yaml::from_str::<OnFailure>("reject").is_err());
assert!(serde_yaml::from_str::<OnMissing>("open").is_err());
assert!(serde_yaml::from_str::<OnMissing>("closed").is_err());
}
}
1 change: 1 addition & 0 deletions apis/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
//! response storage backends.

pub mod anthropic;
pub mod callout_policy;
pub mod classifier;
pub mod json_body;
pub(crate) mod mcp_client;
Expand Down
26 changes: 13 additions & 13 deletions apis/src/openai/responses/compact/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use praxis_filter::FilterError;
use serde::Deserialize;

use crate::openai::responses::config_validation::{self, CalloutSettings, FailureMode};
use crate::callout_policy::{self, CalloutSettings, OnFailure};

/// Default callout timeout (30 seconds — summarization can be slow).
const DEFAULT_TIMEOUT_MS: u64 = 30_000;
Expand Down Expand Up @@ -42,7 +42,7 @@ pub(super) struct CompactFilterConfig {

/// Failure mode for the inference callout.
#[serde(default)]
pub callout_failure_mode: Option<FailureMode>,
pub on_failure: Option<OnFailure>,

/// HTTP status code to return when rejecting on error.
#[serde(default)]
Expand Down Expand Up @@ -103,9 +103,9 @@ pub(super) fn build_config(raw: &CompactFilterConfig) -> Result<ValidatedConfig,
}

let timeout_ms =
config_validation::validate_timeout_ms("openai_responses_compact", raw.timeout_ms, DEFAULT_TIMEOUT_MS)?;
callout_policy::validate_timeout_ms("openai_responses_compact", raw.timeout_ms, DEFAULT_TIMEOUT_MS)?;

let status_on_error = config_validation::validate_status_on_error(
let status_on_error = callout_policy::validate_status_on_error(
"openai_responses_compact",
raw.status_on_error,
DEFAULT_STATUS_ON_ERROR,
Expand All @@ -117,7 +117,7 @@ pub(super) fn build_config(raw: &CompactFilterConfig) -> Result<ValidatedConfig,
tiktoken_encoding: raw.tiktoken_encoding.clone(),
callout: CalloutSettings {
timeout_ms,
failure_mode: raw.callout_failure_mode.unwrap_or(FailureMode::Closed),
on_failure: raw.on_failure.unwrap_or(OnFailure::Closed),
status_on_error,
},
})
Expand All @@ -130,25 +130,25 @@ mod yaml_tests {
use super::*;

#[test]
fn callout_failure_mode_open_deserializes_from_yaml() {
fn on_failure_open_deserializes_from_yaml() {
let cfg: CompactFilterConfig =
serde_yaml::from_str("inference_url: http://localhost/v1/chat/completions\ncallout_failure_mode: open")
serde_yaml::from_str("inference_url: http://localhost/v1/chat/completions\non_failure: open")
.expect("should deserialize");
assert_eq!(cfg.callout_failure_mode, Some(FailureMode::Open));
assert_eq!(cfg.on_failure, Some(OnFailure::Open));
}

#[test]
fn callout_failure_mode_closed_deserializes_from_yaml() {
fn on_failure_closed_deserializes_from_yaml() {
let cfg: CompactFilterConfig =
serde_yaml::from_str("inference_url: http://localhost/v1/chat/completions\ncallout_failure_mode: closed")
serde_yaml::from_str("inference_url: http://localhost/v1/chat/completions\non_failure: closed")
.expect("should deserialize");
assert_eq!(cfg.callout_failure_mode, Some(FailureMode::Closed));
assert_eq!(cfg.on_failure, Some(OnFailure::Closed));
}

#[test]
fn callout_failure_mode_absent_defaults_to_none() {
fn on_failure_absent_defaults_to_none() {
let cfg: CompactFilterConfig =
serde_yaml::from_str("inference_url: http://localhost/v1/chat/completions").expect("should deserialize");
assert_eq!(cfg.callout_failure_mode, None);
assert_eq!(cfg.on_failure, None);
}
}
10 changes: 5 additions & 5 deletions apis/src/openai/responses/compact/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ use tracing::{debug, warn};
use self::config::{CompactFilterConfig, ValidatedConfig, build_config};
use super::{error::responses_error_rejection, state::ResponsesState};
use crate::{
openai::responses::config_validation::FailureMode,
callout_policy::OnFailure,
subrequest::{self, SubRequest, SubRequestClient},
};

Expand Down Expand Up @@ -109,7 +109,7 @@ struct CompactionParams {
/// default_model: gpt-4o-mini
/// tiktoken_encoding: cl100k_base
/// timeout_ms: 30000
/// callout_failure_mode: closed
/// on_failure: closed
/// status_on_error: 502
/// ```
pub struct CompactFilter {
Expand Down Expand Up @@ -206,9 +206,9 @@ impl CompactFilter {

/// Apply the configured open/closed policy on a callout error.
fn on_callout_error(&self, message: &str, streaming: bool) -> Result<Option<String>, FilterAction> {
match self.config.callout.failure_mode {
FailureMode::Open => Ok(None),
FailureMode::Closed => Err(FilterAction::Reject(responses_error_rejection(
match self.config.callout.on_failure {
OnFailure::Open => Ok(None),
OnFailure::Closed => Err(FilterAction::Reject(responses_error_rejection(
self.config.callout.status_on_error,
"server_error",
message,
Expand Down
16 changes: 8 additions & 8 deletions apis/src/openai/responses/compact/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
use serde_json::json;

use super::*;
use crate::openai::responses::config_validation::FailureMode;
use crate::callout_policy::OnFailure;

// =============================================================================
// Config tests
Expand All @@ -16,7 +16,7 @@ fn base_config() -> CompactFilterConfig {
default_model: "gpt-4o-mini".to_owned(),
tiktoken_encoding: "cl100k_base".to_owned(),
timeout_ms: None,
callout_failure_mode: None,
on_failure: None,
status_on_error: None,
}
}
Expand All @@ -28,7 +28,7 @@ fn build_config_applies_defaults() {
assert_eq!(cfg.default_model, "gpt-4o-mini");
assert_eq!(cfg.tiktoken_encoding, "cl100k_base");
assert_eq!(cfg.callout.timeout_ms, 30_000);
assert_eq!(cfg.callout.failure_mode, FailureMode::Closed);
assert_eq!(cfg.callout.on_failure, OnFailure::Closed);
assert_eq!(cfg.callout.status_on_error, 502);
}

Expand Down Expand Up @@ -72,11 +72,11 @@ fn build_config_accepts_o200k_base_encoding() {
fn build_config_custom_values() {
let mut cfg = base_config();
cfg.timeout_ms = Some(60_000);
cfg.callout_failure_mode = Some(FailureMode::Open);
cfg.on_failure = Some(OnFailure::Open);
cfg.status_on_error = Some(503);
let validated = build_config(&cfg).unwrap();
assert_eq!(validated.callout.timeout_ms, 60_000);
assert_eq!(validated.callout.failure_mode, FailureMode::Open);
assert_eq!(validated.callout.on_failure, OnFailure::Open);
assert_eq!(validated.callout.status_on_error, 503);
}

Expand Down Expand Up @@ -430,9 +430,9 @@ fn conversation_text_skips_empty_compaction_summary() {
// on_callout_error: open/closed failure mode
// =============================================================================

fn make_filter(failure_mode: &str) -> CompactFilter {
fn make_filter(on_failure: &str) -> CompactFilter {
let yaml = serde_yaml::from_str::<serde_yaml::Value>(&format!(
"inference_url: http://localhost/v1/chat/completions\ncallout_failure_mode: {failure_mode}"
"inference_url: http://localhost/v1/chat/completions\non_failure: {on_failure}"
))
.unwrap();
let cfg: CompactFilterConfig = serde_yaml::from_value(yaml).unwrap();
Expand Down Expand Up @@ -480,7 +480,7 @@ fn parse_failure_closed_mode_rejects_request() {
}

// =============================================================================
// non-2xx summarization response respects callout_failure_mode
// non-2xx summarization response respects on_failure
// =============================================================================

#[test]
Expand Down
21 changes: 1 addition & 20 deletions apis/src/openai/responses/file_resolve/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use praxis_filter::{FilterError, body::MAX_JSON_BODY_BYTES};
use serde::Deserialize;

use super::resolve_url::NormalizedOrigin;
use crate::openai::api_client;
use crate::{callout_policy::OnMissing, openai::api_client};

/// Default HTTP timeout for Files API callout requests (30 000 ms).
const DEFAULT_TIMEOUT_MS: u64 = 30_000;
Expand All @@ -21,25 +21,6 @@ const MAX_CONFIGURABLE_FILE_REFERENCES: usize = 128;
/// Maximum allowed timeout (300 000 ms / 5 minutes).
const MAX_TIMEOUT_MS: u64 = 300_000;

/// Behavior when a `file_id` reference cannot be fetched.
///
/// Applies only to `file_id` (Files API availability). `file_url`
/// resolution failures are always rejected regardless of this
/// setting: a failed `file_url` fetch is a security-relevant signal
/// (the target may be malicious or unreachable for policy reasons),
/// not a simple availability gap, so it must never be downgraded to
/// an implicit passthrough of the original URL to the backend.
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum OnMissing {
/// Leave the `file_id` reference unchanged and continue.
#[default]
Continue,

/// Return an error response to the client.
Reject,
}

/// Mode for handling `file_url` content parts.
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
Expand Down
Loading
Loading