From ab761d51ef5a0ac51512f9bdc37d3e8fb3f6fb65 Mon Sep 17 00:00:00 2001 From: Rastislav Papso Date: Mon, 31 Aug 2026 13:48:24 +0100 Subject: [PATCH 01/13] refactor(callouts): consolidate outbound failure policy into shared module Callout filters carried duplicate FailureMode enums and validators in the web-search and OpenAI Responses paths. Move them into a single apis/src/callout_policy.rs, along with OnMissing, CalloutSettings, and the timeout/status validators from the deleted config_validation module. Keep FailureMode (no answer from the callout) and OnMissing (a successful answer that the resource is absent) separate, and document `on_failure` as the canonical key so it does not collide with the pipeline-level `failure_mode`. Callers repointed, no behavior changes. Refs: https://github.com/praxis-proxy/ai/issues/697 Signed-off-by: Rastislav Papso --- apis/src/callout_policy.rs | 245 ++++++++++++++++++ apis/src/lib.rs | 1 + apis/src/openai/responses/compact/config.rs | 6 +- apis/src/openai/responses/compact/mod.rs | 2 +- apis/src/openai/responses/compact/tests.rs | 2 +- .../src/openai/responses/config_validation.rs | 147 ----------- .../openai/responses/file_resolve/config.rs | 21 +- apis/src/openai/responses/file_resolve/mod.rs | 3 +- .../openai/responses/file_resolve/resolve.rs | 8 +- .../openai/responses/file_resolve/tests.rs | 55 ++-- .../responses/file_search_callout/client.rs | 3 +- .../responses/file_search_callout/config.rs | 6 +- .../responses/file_search_callout/mod.rs | 2 +- apis/src/openai/responses/mod.rs | 1 - apis/src/web_search/config.rs | 15 +- apis/src/web_search/provider.rs | 4 +- 16 files changed, 300 insertions(+), 221 deletions(-) create mode 100644 apis/src/callout_policy.rs delete mode 100644 apis/src/openai/responses/config_validation.rs diff --git a/apis/src/callout_policy.rs b/apis/src/callout_policy.rs new file mode 100644 index 0000000000..25b5a26ba5 --- /dev/null +++ b/apis/src/callout_policy.rs @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Shared failure-policy vocabulary for outbound AI callouts. +//! +//! Every filter that calls an external service answers one of +//! two *different* questions. Choose the type that matches the +//! question (the first listed value of each is its default): +//! +//! | Question | Type | YAML key | Values | +//! | --- | --- | --- | --- | +//! | The callout did not produce an answer: DNS, connect, timeout, TLS, non-2xx, unparseable body. Serve the request anyway? | [`FailureMode`] | `on_failure` | `closed`, `open` | +//! | The callout answered successfully, and the answer is that the resource does not exist. Serve the request without it? | [`OnMissing`] | `on_missing` | `continue`, `reject` | +//! +//! These stay separate deliberately. A callout failure is an +//! *unknown*: the proxy cannot tell whether the resource exists, so +//! `on_failure: open` is a decision to serve a request whose +//! enrichment silently did not happen. A missing resource is a +//! *known* answer from a working upstream. +//! +//! # Naming +//! +//! The external key is `on_failure`. Pipeline entries already own a +//! structural `failure_mode` key that governs how the pipeline reacts +//! when a filter returns an error. `on_failure` pairs with `on_missing`, +//! which keeps the two policies reading as one family in configuration. + +use praxis_filter::FilterError; +use serde::Deserialize; + +// ----------------------------------------------------------------------------- +// FailureMode +// ----------------------------------------------------------------------------- + +/// What happens when an outbound callout fails to produce an answer. +/// +/// Covers transport faults (DNS, connect, timeout, TLS), upstream +/// error statuses, and responses that cannot be parsed. 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 enum FailureMode { + /// Reject the request on failure (default). + #[default] + Closed, + + /// Continue without the callout result on failure. + Open, +} + +// ----------------------------------------------------------------------------- +// OnMissing +// ----------------------------------------------------------------------------- + +/// What happens when a callout succeeds and answers that the +/// requested resource does not exist. +/// +/// Configured as `on_missing`. The callout answered successfully, but the +/// resource is absent. For a callout that produced no answer at all, +/// see [`FailureMode`]. +/// +/// A filter may narrow the set of references 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 { + /// Leave the reference unchanged and continue (default). + #[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 struct CalloutSettings { + /// Callout timeout in milliseconds. + pub timeout_ms: u64, + + /// Failure mode for the callout. + pub failure_mode: FailureMode, + + /// HTTP status code to return when rejecting on error. + pub status_on_error: u16, +} + +// ----------------------------------------------------------------------------- +// Validation helpers +// ----------------------------------------------------------------------------- + +/// Validate `timeout_ms`, applying a default and rejecting zero. +/// +/// # Errors +/// +/// Returns [`FilterError`] when the resolved value is zero. +pub fn validate_timeout_ms(filter: &str, raw: Option, default: u64) -> Result { + let value = raw.unwrap_or(default); + if value == 0 { + return Err(format!("{filter}: timeout_ms must be greater than 0").into()); + } + Ok(value) +} + +/// Validate `status_on_error`, applying a default and rejecting +/// values outside the HTTP status range. +/// +/// # Errors +/// +/// Returns [`FilterError`] when the resolved value is not in +/// `100..=599`. +pub fn validate_status_on_error(filter: &str, raw: Option, default: u16) -> Result { + 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()); + } + Ok(value) +} + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow(clippy::unwrap_used, clippy::expect_used, reason = "tests")] +mod tests { + use super::*; + + #[test] + fn timeout_applies_default() { + assert_eq!(validate_timeout_ms("test", None, 5000).unwrap(), 5000); + } + + #[test] + fn timeout_uses_provided_value() { + assert_eq!(validate_timeout_ms("test", Some(10_000), 5000).unwrap(), 10_000); + } + + #[test] + fn timeout_zero_rejected() { + let err = validate_timeout_ms("test", Some(0), 5000).unwrap_err(); + assert!( + err.to_string().contains("greater than 0"), + "zero should be rejected, got: {err}" + ); + } + + #[test] + fn timeout_error_includes_filter_name() { + let err = validate_timeout_ms("my_filter", Some(0), 5000).unwrap_err(); + assert!( + err.to_string().contains("my_filter"), + "error should include filter name, got: {err}" + ); + } + + #[test] + fn status_applies_default() { + assert_eq!(validate_status_on_error("test", None, 502).unwrap(), 502); + } + + #[test] + fn status_uses_provided_value() { + assert_eq!(validate_status_on_error("test", Some(503), 502).unwrap(), 503); + } + + #[test] + fn status_below_range_rejected() { + let err = validate_status_on_error("test", Some(99), 502).unwrap_err(); + assert!( + err.to_string().contains("between 100 and 599"), + "below range should be rejected, got: {err}" + ); + } + + #[test] + fn status_above_range_rejected() { + let err = validate_status_on_error("test", Some(600), 502).unwrap_err(); + assert!( + err.to_string().contains("between 100 and 599"), + "above range should be rejected, got: {err}" + ); + } + + #[test] + fn status_boundaries_accepted() { + validate_status_on_error("test", Some(100), 502).expect("100 should be accepted"); + validate_status_on_error("test", Some(599), 502).expect("599 should be accepted"); + } + + #[test] + fn status_error_includes_filter_name() { + let err = validate_status_on_error("my_filter", Some(0), 502).unwrap_err(); + assert!( + err.to_string().contains("my_filter"), + "error should include filter name, got: {err}" + ); + } + + // ------------------------------------------------------------------------- + // Canonical vocabulary + // ------------------------------------------------------------------------- + + #[test] + fn failure_mode_deserializes_canonical_values() { + assert_eq!( + serde_yaml::from_str::("closed").unwrap(), + FailureMode::Closed + ); + assert_eq!(serde_yaml::from_str::("open").unwrap(), FailureMode::Open); + } + + #[test] + fn failure_mode_defaults_to_closed() { + assert_eq!(FailureMode::default(), FailureMode::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::("continue").unwrap(), + OnMissing::Continue + ); + assert_eq!(serde_yaml::from_str::("reject").unwrap(), OnMissing::Reject); + } + + #[test] + fn failure_mode_rejects_on_missing_vocabulary() { + assert!(serde_yaml::from_str::("continue").is_err()); + assert!(serde_yaml::from_str::("reject").is_err()); + assert!(serde_yaml::from_str::("open").is_err()); + assert!(serde_yaml::from_str::("closed").is_err()); + } +} diff --git a/apis/src/lib.rs b/apis/src/lib.rs index 5857d6885d..6b34b7e69f 100644 --- a/apis/src/lib.rs +++ b/apis/src/lib.rs @@ -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; diff --git a/apis/src/openai/responses/compact/config.rs b/apis/src/openai/responses/compact/config.rs index 423e7cdbda..f4e7ab7cf9 100644 --- a/apis/src/openai/responses/compact/config.rs +++ b/apis/src/openai/responses/compact/config.rs @@ -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, FailureMode}; /// Default callout timeout (30 seconds — summarization can be slow). const DEFAULT_TIMEOUT_MS: u64 = 30_000; @@ -103,9 +103,9 @@ pub(super) fn build_config(raw: &CompactFilterConfig) -> Result, default: u64) -> Result { - let value = raw.unwrap_or(default); - if value == 0 { - return Err(format!("{filter}: timeout_ms must be greater than 0").into()); - } - Ok(value) -} - -/// Validate `status_on_error`, applying a default and rejecting -/// values outside the HTTP status range. -/// -/// # Errors -/// -/// Returns [`FilterError`] when the resolved value is not in -/// `100..=599`. -pub(crate) fn validate_status_on_error(filter: &str, raw: Option, default: u16) -> Result { - 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()); - } - Ok(value) -} - -#[cfg(test)] -#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] -#[allow(clippy::unwrap_used, clippy::expect_used, reason = "tests")] -mod tests { - use super::*; - - #[test] - fn timeout_applies_default() { - assert_eq!(validate_timeout_ms("test", None, 5000).unwrap(), 5000); - } - - #[test] - fn timeout_uses_provided_value() { - assert_eq!(validate_timeout_ms("test", Some(10_000), 5000).unwrap(), 10_000); - } - - #[test] - fn timeout_zero_rejected() { - let err = validate_timeout_ms("test", Some(0), 5000).unwrap_err(); - assert!( - err.to_string().contains("greater than 0"), - "zero should be rejected, got: {err}" - ); - } - - #[test] - fn timeout_error_includes_filter_name() { - let err = validate_timeout_ms("my_filter", Some(0), 5000).unwrap_err(); - assert!( - err.to_string().contains("my_filter"), - "error should include filter name, got: {err}" - ); - } - - #[test] - fn status_applies_default() { - assert_eq!(validate_status_on_error("test", None, 502).unwrap(), 502); - } - - #[test] - fn status_uses_provided_value() { - assert_eq!(validate_status_on_error("test", Some(503), 502).unwrap(), 503); - } - - #[test] - fn status_below_range_rejected() { - let err = validate_status_on_error("test", Some(99), 502).unwrap_err(); - assert!( - err.to_string().contains("between 100 and 599"), - "below range should be rejected, got: {err}" - ); - } - - #[test] - fn status_above_range_rejected() { - let err = validate_status_on_error("test", Some(600), 502).unwrap_err(); - assert!( - err.to_string().contains("between 100 and 599"), - "above range should be rejected, got: {err}" - ); - } - - #[test] - fn status_boundaries_accepted() { - validate_status_on_error("test", Some(100), 502).expect("100 should be accepted"); - validate_status_on_error("test", Some(599), 502).expect("599 should be accepted"); - } - - #[test] - fn status_error_includes_filter_name() { - let err = validate_status_on_error("my_filter", Some(0), 502).unwrap_err(); - assert!( - err.to_string().contains("my_filter"), - "error should include filter name, got: {err}" - ); - } -} diff --git a/apis/src/openai/responses/file_resolve/config.rs b/apis/src/openai/responses/file_resolve/config.rs index 751f64e441..bea80ddc41 100644 --- a/apis/src/openai/responses/file_resolve/config.rs +++ b/apis/src/openai/responses/file_resolve/config.rs @@ -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; @@ -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")] diff --git a/apis/src/openai/responses/file_resolve/mod.rs b/apis/src/openai/responses/file_resolve/mod.rs index 3dd1117515..46a9885af4 100644 --- a/apis/src/openai/responses/file_resolve/mod.rs +++ b/apis/src/openai/responses/file_resolve/mod.rs @@ -75,7 +75,7 @@ use praxis_filter::{ use tracing::{debug, trace, warn}; use self::{ - config::{FileResolveConfig, FileUrlMode, OnMissing, validate_config}, + config::{FileResolveConfig, FileUrlMode, validate_config}, resolve::{ FilesApiClient, FilesApiClientOptions, ResolutionBudget, ResolveError, resolve_input_with_budget, resolve_items, }, @@ -83,6 +83,7 @@ use self::{ }; use super::{openai_responses_proxy::serialized_outbound_body_len, state::ResponsesState}; use crate::{ + callout_policy::OnMissing, classifier::is_responses_create, json_body::serialize_json_body, openai::api_client::{ApiClient, ApiClientConfig}, diff --git a/apis/src/openai/responses/file_resolve/resolve.rs b/apis/src/openai/responses/file_resolve/resolve.rs index 34d313a5d0..f42d4f9608 100644 --- a/apis/src/openai/responses/file_resolve/resolve.rs +++ b/apis/src/openai/responses/file_resolve/resolve.rs @@ -22,11 +22,11 @@ use std::collections::HashMap; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use tracing::{debug, warn}; -use super::{ - config::OnMissing, - resolve_url::{FileUrlResolver, redact_url}, +use super::resolve_url::{FileUrlResolver, redact_url}; +use crate::{ + callout_policy::OnMissing, + openai::api_client::{ApiClient, ApiClientError}, }; -use crate::openai::api_client::{ApiClient, ApiClientError}; /// Files API path prefix used in resource URL construction. const FILES_PATH_PREFIX: &str = "v1/files"; diff --git a/apis/src/openai/responses/file_resolve/tests.rs b/apis/src/openai/responses/file_resolve/tests.rs index e2d0980e8f..0d64dcbe63 100644 --- a/apis/src/openai/responses/file_resolve/tests.rs +++ b/apis/src/openai/responses/file_resolve/tests.rs @@ -845,11 +845,10 @@ fn serve_file_request(mut stream: std::net::TcpStream) { #[tokio::test] async fn file_url_resolved_to_data_uri() { - use crate::openai::responses::file_resolve::{ - config::OnMissing, + use crate::{callout_policy::OnMissing, openai::responses::file_resolve::{ resolve::resolve_input, resolve_url::{FileUrlResolver, NormalizedOrigin}, - }; + }}; // Start TCP stub serving file content let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -1007,7 +1006,10 @@ async fn file_url_oversized_content_length_reports_generic_too_large() { #[tokio::test] async fn file_url_passthrough_when_no_resolver() { - use crate::openai::responses::file_resolve::{config::OnMissing, resolve::resolve_input}; + use crate::{ + callout_policy::OnMissing, + openai::responses::file_resolve::resolve::resolve_input, + }; // Build body with file_url let mut body = json!({ @@ -1036,11 +1038,12 @@ async fn file_url_passthrough_when_no_resolver() { #[tokio::test] async fn file_url_in_shorthand_message_resolved() { - use crate::openai::responses::file_resolve::{ - config::OnMissing, + use crate::{ + callout_policy::OnMissing, + openai::responses::file_resolve::{ resolve::resolve_input, resolve_url::{FileUrlResolver, NormalizedOrigin}, - }; + }}; // Start TCP stub let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -1101,11 +1104,12 @@ async fn file_url_in_shorthand_message_resolved() { #[tokio::test] async fn file_url_in_function_call_output_resolved() { - use crate::openai::responses::file_resolve::{ - config::OnMissing, - resolve::resolve_input, - resolve_url::{FileUrlResolver, NormalizedOrigin}, - }; + use crate::{ + callout_policy::OnMissing, + openai::responses::file_resolve::{ + resolve::resolve_input, + resolve_url::{FileUrlResolver, NormalizedOrigin}, + }}; // Start TCP stub let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -1167,9 +1171,12 @@ async fn file_url_in_function_call_output_resolved() { #[tokio::test] async fn file_url_blocked_is_not_swallowed_by_on_missing_continue() { - use crate::openai::responses::file_resolve::{ - config::OnMissing, resolve::resolve_input, resolve_url::FileUrlResolver, - }; + use crate::{ + callout_policy::OnMissing, + openai::responses::file_resolve::{ + resolve::resolve_input, + resolve_url::FileUrlResolver, + }}; let mut body = json!({ "input": [{ @@ -1205,9 +1212,12 @@ async fn file_url_blocked_is_not_swallowed_by_on_missing_continue() { #[tokio::test] async fn file_url_failed_is_not_swallowed_by_on_missing_continue() { - use crate::openai::responses::file_resolve::{ - config::OnMissing, resolve::resolve_input, resolve_url::FileUrlResolver, - }; + use crate::{ + callout_policy::OnMissing, + openai::responses::file_resolve::{ + resolve::resolve_input, + resolve_url::FileUrlResolver, + }}; // Regression test for #542: simulate an attacker-controlled origin // that redirects to a metadata-style target. Praxis's resolver @@ -1268,9 +1278,12 @@ async fn file_url_failed_is_not_swallowed_by_on_missing_continue() { #[tokio::test] async fn file_url_too_large_is_not_swallowed_by_on_missing_continue() { - use crate::openai::responses::file_resolve::{ - config::OnMissing, resolve::resolve_input, resolve_url::FileUrlResolver, - }; + use crate::{ + callout_policy::OnMissing, + openai::responses::file_resolve::{ + resolve::resolve_input, + resolve_url::FileUrlResolver, + }}; // Regression test for #542: an oversized file_url response must // also reject the request under on_missing: continue, not just diff --git a/apis/src/openai/responses/file_search_callout/client.rs b/apis/src/openai/responses/file_search_callout/client.rs index 9314f8b429..d1600a6ca3 100644 --- a/apis/src/openai/responses/file_search_callout/client.rs +++ b/apis/src/openai/responses/file_search_callout/client.rs @@ -14,7 +14,8 @@ use http::HeaderMap; use serde::{Deserialize, Serialize, de::Visitor}; use serde_json::Value; -use crate::openai::{api_client::ApiClient, responses::config_validation::FailureMode}; +use crate::callout_policy::FailureMode; +use crate::openai::api_client::ApiClient; // ----------------------------------------------------------------------------- // Constants diff --git a/apis/src/openai/responses/file_search_callout/config.rs b/apis/src/openai/responses/file_search_callout/config.rs index cb12c5dd89..43b3a9d964 100644 --- a/apis/src/openai/responses/file_search_callout/config.rs +++ b/apis/src/openai/responses/file_search_callout/config.rs @@ -11,10 +11,8 @@ use serde::Deserialize; use super::client::MAX_CONCURRENT_SEARCHES; use crate::{ - openai::{ - api_client::{self, ApiClient, ApiClientConfig}, - responses::config_validation::FailureMode, - }, + callout_policy::FailureMode, + openai::api_client::{self, ApiClient, ApiClientConfig}, subrequest::SubRequestClient, }; diff --git a/apis/src/openai/responses/file_search_callout/mod.rs b/apis/src/openai/responses/file_search_callout/mod.rs index 4b10977f53..6d7a17bd4b 100644 --- a/apis/src/openai/responses/file_search_callout/mod.rs +++ b/apis/src/openai/responses/file_search_callout/mod.rs @@ -41,9 +41,9 @@ use self::{ model_context::{FormatLimits, FormatTemplates, MODEL_CONTEXT_TEMPLATES, format_search_results}, }; use crate::{ + callout_policy::FailureMode, openai::responses::{ bounded_json_size, - config_validation::FailureMode, error::responses_error_rejection, state::{MAX_CITATION_FILES, ResponsesState}, usage::merge_usage, diff --git a/apis/src/openai/responses/mod.rs b/apis/src/openai/responses/mod.rs index a0e9ba9009..a7ebdbcac7 100644 --- a/apis/src/openai/responses/mod.rs +++ b/apis/src/openai/responses/mod.rs @@ -25,7 +25,6 @@ pub(crate) mod agentic_loop; pub(crate) mod compact; mod config; -pub(crate) mod config_validation; pub(crate) mod doc_extract; pub(crate) mod error; pub(crate) mod file_resolve; diff --git a/apis/src/web_search/config.rs b/apis/src/web_search/config.rs index 722ab0d3af..a8089bc6e0 100644 --- a/apis/src/web_search/config.rs +++ b/apis/src/web_search/config.rs @@ -3,6 +3,7 @@ //! Configuration for protocol-neutral web-search providers. +use crate::callout_policy::FailureMode; use praxis_filter::{ FilterError, body::MAX_JSON_BODY_BYTES, builtins::http::payload_processing::config_validation::validate_max_body_bytes, @@ -87,20 +88,6 @@ impl SearchContextSize { } } -// ----------------------------------------------------------------------------- -// FailureMode -// ----------------------------------------------------------------------------- - -/// What happens when a search callout fails. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum FailureMode { - /// Reject the request on search failure (default). - Closed, - /// Continue without search results on failure. - Open, -} - // ----------------------------------------------------------------------------- // WebSearchFilterConfig (YAML deserialization) // ----------------------------------------------------------------------------- diff --git a/apis/src/web_search/provider.rs b/apis/src/web_search/provider.rs index 0cb93277a6..b9206da568 100644 --- a/apis/src/web_search/provider.rs +++ b/apis/src/web_search/provider.rs @@ -19,9 +19,9 @@ use tracing::{debug, warn}; use super::{ ValidatedConfig, - config::{FailureMode, SearchContextSize, SearchProvider}, + config::{SearchContextSize, SearchProvider}, }; -use crate::subrequest::{self, SubRequest, SubRequestClient, SubRequestError, SubResponse}; +use crate::{callout_policy::FailureMode, subrequest::{self, SubRequest, SubRequestClient, SubRequestError, SubResponse}}; /// Response body cap for search callouts (1 MiB). Distinct from /// `max_body_bytes` which governs inbound request buffering. From 99e6b95d2b939bfdffff018366e8423670b7839c Mon Sep 17 00:00:00 2001 From: Rastislav Papso Date: Mon, 31 Aug 2026 13:59:18 +0100 Subject: [PATCH 02/13] refactor(callouts): rename provider_failure_mode to on_failure Adopt the canonical `on_failure` key from the shared callout policy module in the OpenAI and Anthropic web-search filters, updating config parsing, tests, generated filter docs, examples, and the xtask doc check. Breaking change: `provider_failure_mode` is no longer accepted. Configs must use `on_failure`. Refs: https://github.com/praxis-proxy/ai/issues/697 Signed-off-by: Rastislav Papso --- apis/src/anthropic/web_search/mod.rs | 2 +- apis/src/anthropic/web_search/tests.rs | 4 ++-- apis/src/openai/responses/web_search/mod.rs | 2 +- apis/src/web_search/config.rs | 12 ++++++------ docs/filters/anthropic_web_search.md | 4 ++-- docs/filters/openai_web_search.md | 4 ++-- examples/configs/anthropic/messages-web-search.yaml | 2 +- examples/configs/openai/responses/web-search.yaml | 4 ++-- xtask/src/filter_docs.rs | 2 +- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apis/src/anthropic/web_search/mod.rs b/apis/src/anthropic/web_search/mod.rs index be4f7fe930..654454c1b8 100644 --- a/apis/src/anthropic/web_search/mod.rs +++ b/apis/src/anthropic/web_search/mod.rs @@ -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 /// ``` diff --git a/apis/src/anthropic/web_search/tests.rs b/apis/src/anthropic/web_search/tests.rs index 7a7fba65b1..355e590674 100644 --- a/apis/src/anthropic/web_search/tests.rs +++ b/apis/src/anthropic/web_search/tests.rs @@ -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}" "#, )) diff --git a/apis/src/openai/responses/web_search/mod.rs b/apis/src/openai/responses/web_search/mod.rs index 9d34face9b..bfb68912d7 100644 --- a/apis/src/openai/responses/web_search/mod.rs +++ b/apis/src/openai/responses/web_search/mod.rs @@ -93,7 +93,7 @@ const INCLUDE_ACTION_SOURCES: &str = "web_search_call.action.sources"; /// 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 /// ``` diff --git a/apis/src/web_search/config.rs b/apis/src/web_search/config.rs index a8089bc6e0..90a8c2f329 100644 --- a/apis/src/web_search/config.rs +++ b/apis/src/web_search/config.rs @@ -117,7 +117,7 @@ pub(crate) struct WebSearchFilterConfig { /// Failure mode for search provider callouts. #[serde(default)] - pub(crate) provider_failure_mode: Option, + pub(crate) on_failure: Option, /// HTTP status code to return when rejecting on error. #[serde(default)] @@ -204,7 +204,7 @@ fn build_validated_config( default_context_size: validate_context_size(filter_name, raw.default_context_size.as_deref())?, timeout_ms: validate_timeout_ms(filter_name, raw.timeout_ms)?, max_body_bytes: validate_max_body_bytes_field(filter_name, raw.max_body_bytes)?, - failure_mode: raw.provider_failure_mode.unwrap_or(FailureMode::Closed), + failure_mode: raw.on_failure.unwrap_or(FailureMode::Closed), status_on_error: validate_status_on_error(filter_name, raw.status_on_error)?, base_url: raw.base_url.clone(), }) @@ -293,7 +293,7 @@ mod tests { default_context_size: None, timeout_ms: None, max_body_bytes: None, - provider_failure_mode: None, + on_failure: None, status_on_error: None, base_url: None, } @@ -333,8 +333,8 @@ mod tests { } #[test] - fn parse_config_preserves_provider_failure_mode() { - let yaml = serde_yaml::from_str("\nprovider: brave\napi_key: test-key\nprovider_failure_mode: open\n").unwrap(); + fn parse_config_preserves_on_failure() { + let yaml = serde_yaml::from_str("\nprovider: brave\napi_key: test-key\non_failure: open\n").unwrap(); let raw: WebSearchFilterConfig = parse_filter_config("openai_web_search", &yaml).unwrap(); let validated = build_config("openai_web_search", &raw).unwrap(); @@ -371,7 +371,7 @@ mod tests { let mut cfg = base_config(); cfg.default_context_size = Some("high".into()); cfg.timeout_ms = Some(15_000); - cfg.provider_failure_mode = Some(FailureMode::Open); + cfg.on_failure = Some(FailureMode::Open); cfg.status_on_error = Some(503); let validated = build_config("openai_web_search", &cfg).unwrap(); assert_eq!(validated.default_context_size, SearchContextSize::High); diff --git a/docs/filters/anthropic_web_search.md b/docs/filters/anthropic_web_search.md index e6944118c8..d7d898f882 100644 --- a/docs/filters/anthropic_web_search.md +++ b/docs/filters/anthropic_web_search.md @@ -14,7 +14,7 @@ Executes server-owned `WebSearch` tool calls in an Anthropic Messages loop. | `default_context_size` | string | no | Default search context size when the client omits it. | | `timeout_ms` | integer | no | Callout timeout in milliseconds. | | `max_body_bytes` | integer | no | Maximum request body bytes to buffer. | -| `provider_failure_mode` | `closed` \| `open` | no | Failure mode for search provider callouts. | +| `on_failure` | `closed` \| `open` | no | Failure mode for search provider callouts. | | `status_on_error` | integer | no | HTTP status code to return when rejecting on error. | | `base_url` | string | no | Override the provider's default API base URL. | @@ -36,7 +36,7 @@ provider: you 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 ``` diff --git a/docs/filters/openai_web_search.md b/docs/filters/openai_web_search.md index ebc1424d3f..e8180d3854 100644 --- a/docs/filters/openai_web_search.md +++ b/docs/filters/openai_web_search.md @@ -18,7 +18,7 @@ Detects pending web search calls in the response phase and executes them on re-e | `default_context_size` | string | no | Default search context size when the client omits it. | | `timeout_ms` | integer | no | Callout timeout in milliseconds. | | `max_body_bytes` | integer | no | Maximum request body bytes to buffer. | -| `provider_failure_mode` | `closed` \| `open` | no | Failure mode for search provider callouts. | +| `on_failure` | `closed` \| `open` | no | Failure mode for search provider callouts. | | `status_on_error` | integer | no | HTTP status code to return when rejecting on error. | | `base_url` | string | no | Override the provider's default API base URL. | @@ -40,7 +40,7 @@ provider: brave 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 ``` diff --git a/examples/configs/anthropic/messages-web-search.yaml b/examples/configs/anthropic/messages-web-search.yaml index 9f740810e5..9fb3860cc8 100644 --- a/examples/configs/anthropic/messages-web-search.yaml +++ b/examples/configs/anthropic/messages-web-search.yaml @@ -39,7 +39,7 @@ filter_chains: api_key: ${WEB_SEARCH_API_KEY} default_context_size: medium timeout_ms: 10000 - provider_failure_mode: closed + on_failure: closed - filter: anthropic_messages_protocol default_version: "2023-06-01" - filter: router diff --git a/examples/configs/openai/responses/web-search.yaml b/examples/configs/openai/responses/web-search.yaml index e6ab0bfc10..c5e38ffe48 100644 --- a/examples/configs/openai/responses/web-search.yaml +++ b/examples/configs/openai/responses/web-search.yaml @@ -12,7 +12,7 @@ # api_key: Provider API key (supports ${ENV_VAR} syntax) # default_context_size: How many results to return (low/medium/high) # timeout_ms: Callout timeout in milliseconds -# provider_failure_mode: closed (reject on error) or open (skip on error) +# on_failure: closed (reject on error) or open (skip on error) listeners: - name: ai-gateway @@ -28,7 +28,7 @@ filter_chains: api_key: ${WEB_SEARCH_API_KEY} default_context_size: medium timeout_ms: 10000 - provider_failure_mode: closed + on_failure: closed status_on_error: 502 - filter: router routes: diff --git a/xtask/src/filter_docs.rs b/xtask/src/filter_docs.rs index 96df890aa1..71452bbb31 100644 --- a/xtask/src/filter_docs.rs +++ b/xtask/src/filter_docs.rs @@ -2568,7 +2568,7 @@ mod tests { RequiredKind::Yes, "{filter_name} should document api_key as required" ); - for expected in ["provider", "provider_failure_mode", "status_on_error", "base_url"] { + for expected in ["provider", "on_failure", "status_on_error", "base_url"] { assert!( filter.filter.fields.iter().any(|field| field.name == expected), "{filter_name} should document {expected}" From b18feeee39ad135bb47c790c666147f07ccdbf96 Mon Sep 17 00:00:00 2001 From: Rastislav Papso Date: Mon, 31 Aug 2026 14:04:22 +0100 Subject: [PATCH 03/13] refactor(callouts): rename callout_failure_mode to on_failure Adopt the canonical `on_failure` key in the openai_responses_compact and openai_file_search_callout filters, updating config parsing, tests, generated filter docs, examples, and the vLLM integration config. Breaking change: `callout_failure_mode` is no longer accepted. Configs must use `on_failure`. Refs: https://github.com/praxis-proxy/ai/issues/697 Signed-off-by: Rastislav Papso --- apis/src/openai/responses/compact/config.rs | 20 ++++++++--------- apis/src/openai/responses/compact/mod.rs | 2 +- apis/src/openai/responses/compact/tests.rs | 8 +++---- .../responses/file_search_callout/config.rs | 4 ++-- .../responses/file_search_callout/tests.rs | 22 +++++++++---------- docs/filters/openai_file_search_callout.md | 2 +- docs/filters/openai_responses_compact.md | 4 ++-- .../openai/responses/file-search-callout.yaml | 4 ++-- .../openai/responses/full-flow-agentic.yaml | 2 +- .../sdk/openai/test_openai_responses_vllm.py | 2 +- 10 files changed, 35 insertions(+), 35 deletions(-) diff --git a/apis/src/openai/responses/compact/config.rs b/apis/src/openai/responses/compact/config.rs index f4e7ab7cf9..4f53276b82 100644 --- a/apis/src/openai/responses/compact/config.rs +++ b/apis/src/openai/responses/compact/config.rs @@ -42,7 +42,7 @@ pub(super) struct CompactFilterConfig { /// Failure mode for the inference callout. #[serde(default)] - pub callout_failure_mode: Option, + pub on_failure: Option, /// HTTP status code to return when rejecting on error. #[serde(default)] @@ -117,7 +117,7 @@ pub(super) fn build_config(raw: &CompactFilterConfig) -> Result 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, } } @@ -72,7 +72,7 @@ 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(FailureMode::Open); cfg.status_on_error = Some(503); let validated = build_config(&cfg).unwrap(); assert_eq!(validated.callout.timeout_ms, 60_000); @@ -432,7 +432,7 @@ fn conversation_text_skips_empty_compaction_summary() { fn make_filter(failure_mode: &str) -> CompactFilter { let yaml = serde_yaml::from_str::(&format!( - "inference_url: http://localhost/v1/chat/completions\ncallout_failure_mode: {failure_mode}" + "inference_url: http://localhost/v1/chat/completions\non_failure: {failure_mode}" )) .unwrap(); let cfg: CompactFilterConfig = serde_yaml::from_value(yaml).unwrap(); @@ -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] diff --git a/apis/src/openai/responses/file_search_callout/config.rs b/apis/src/openai/responses/file_search_callout/config.rs index 43b3a9d964..6280815775 100644 --- a/apis/src/openai/responses/file_search_callout/config.rs +++ b/apis/src/openai/responses/file_search_callout/config.rs @@ -57,7 +57,7 @@ pub(crate) struct FileSearchFilterConfig { pub allow_private_url: bool, /// Behaviour when a vector-store callout fails. - pub callout_failure_mode: Option, + pub on_failure: Option, /// Headers to forward from the original request to the /// vector store API for authentication and tenant isolation. @@ -111,7 +111,7 @@ pub(crate) fn build_config_with_client( client: SubRequestClient, ) -> Result { let vector_store_url = parse_vector_store_url(&cfg.vector_store_url, cfg.allow_private_url)?; - let failure_mode = cfg.callout_failure_mode.unwrap_or(FailureMode::Closed); + let failure_mode = cfg.on_failure.unwrap_or(FailureMode::Closed); let (max_response_bytes, max_total_response_bytes) = response_limits(cfg.max_response_bytes, cfg.max_total_response_bytes)?; let max_state_bytes = validated_state_limit(cfg.max_state_bytes)?; diff --git a/apis/src/openai/responses/file_search_callout/tests.rs b/apis/src/openai/responses/file_search_callout/tests.rs index 6e6d089c21..8b456f44bc 100644 --- a/apis/src/openai/responses/file_search_callout/tests.rs +++ b/apis/src/openai/responses/file_search_callout/tests.rs @@ -1173,7 +1173,7 @@ async fn open_and_closed_failure_modes_are_distinct() { body_delay: Duration::ZERO, status, }); - let closed = make_filter(closed_server.port, "callout_failure_mode: closed\n"); + let closed = make_filter(closed_server.port, "on_failure: closed\n"); let mut closed_ctx = make_context(Some(one_pending_state(&["vs-a"]))); assert!(matches!( closed.on_request(&mut closed_ctx).await.unwrap(), @@ -1190,7 +1190,7 @@ async fn open_and_closed_failure_modes_are_distinct() { body_delay: Duration::ZERO, status, }); - let open = make_filter(open_server.port, "callout_failure_mode: open\n"); + let open = make_filter(open_server.port, "on_failure: open\n"); let mut open_ctx = make_context(Some(one_pending_state(&["vs-a"]))); assert!(matches!( open.on_request(&mut open_ctx).await.unwrap(), @@ -1211,7 +1211,7 @@ async fn aggregate_budget_stops_later_searches_and_marks_call_incomplete() { let server = MockServer::json(200, &one_result("file-a", "a.txt", 0.9, "small")); let filter = make_filter( server.port, - "callout_failure_mode: open\nmax_response_bytes: 512\nmax_total_response_bytes: 512\n", + "on_failure: open\nmax_response_bytes: 512\nmax_total_response_bytes: 512\n", ); let mut ctx = make_context(Some(one_pending_state(&["vs-a", "vs-b"]))); @@ -1240,7 +1240,7 @@ async fn malformed_success_bodies_are_charged_to_the_aggregate_budget() { }); let filter = make_filter( server.port, - "callout_failure_mode: open\nmax_response_bytes: 1\nmax_total_response_bytes: 4\n", + "on_failure: open\nmax_response_bytes: 1\nmax_total_response_bytes: 4\n", ); let mut ctx = make_context(Some(one_pending_state(&["vs-a", "vs-b", "vs-c", "vs-d", "vs-e"]))); @@ -1322,7 +1322,7 @@ async fn one_execution_deadline_covers_later_concurrency_chunks() { #[tokio::test] async fn fail_closed_stops_scheduling_after_the_current_chunk() { let server = MockServer::json(500, &json!({"error": "failed"})); - let filter = make_filter(server.port, "callout_failure_mode: closed\n"); + let filter = make_filter(server.port, "on_failure: closed\n"); let store_ids: Vec = (0..=MAX_CONCURRENT_SEARCHES) .map(|index| format!("vs-{index}")) .collect(); @@ -1375,7 +1375,7 @@ async fn aggregate_results_are_score_sorted_and_limited_to_top_k() { Duration::ZERO, ), ]); - let filter = make_filter(server.port, "callout_failure_mode: open\n"); + let filter = make_filter(server.port, "on_failure: open\n"); let mut state = one_pending_state(&["vs-a", "vs-b"]); state.tools[0]["max_num_results"] = json!(3); state.include.push("file_search_call.results".to_owned()); @@ -1405,7 +1405,7 @@ async fn fail_open_retains_successful_results_from_a_partial_fan_out() { ), ("vs-b", 500, json!({"error": "failed"}), Duration::ZERO), ]); - let filter = make_filter(server.port, "callout_failure_mode: open\n"); + let filter = make_filter(server.port, "on_failure: open\n"); let mut state = one_pending_state(&["vs-a", "vs-b"]); state.include.push("file_search_call.results".to_owned()); let mut ctx = make_context(Some(state)); @@ -1424,7 +1424,7 @@ async fn fail_open_retains_successful_results_from_a_partial_fan_out() { #[tokio::test] async fn outbound_query_store_id_and_request_body_are_bounded() { let server = MockServer::json(200, &json!({"data": []})); - let filter = make_filter(server.port, "callout_failure_mode: open\n"); + let filter = make_filter(server.port, "on_failure: open\n"); let oversized_store = "s".repeat(MAX_VECTOR_STORE_ID_BYTES + 1); let mut store_ctx = make_context(Some(one_pending_state(&[&oversized_store]))); @@ -1458,7 +1458,7 @@ async fn outbound_query_store_id_and_request_body_are_bounded() { #[tokio::test] async fn malformed_execution_fields_fail_without_silent_normalization() { let server = MockServer::json(200, &json!({"data": []})); - let filter = make_filter(server.port, "callout_failure_mode: closed\n"); + let filter = make_filter(server.port, "on_failure: closed\n"); let mut invalid_stores = one_pending_state(&["vs-a"]); invalid_stores.tools[0]["vector_store_ids"] = json!(["vs-a", 7]); @@ -1490,7 +1490,7 @@ async fn malformed_execution_fields_fail_without_silent_normalization() { #[tokio::test] async fn missing_file_search_tool_fields_fail_closed() { let server = MockServer::json(200, &json!({"data": []})); - let filter = make_filter(server.port, "callout_failure_mode: closed\n"); + let filter = make_filter(server.port, "on_failure: closed\n"); let mut missing_ids = one_pending_state(&["vs-a"]); missing_ids.tools[0].as_object_mut().unwrap().remove("vector_store_ids"); @@ -1514,7 +1514,7 @@ async fn missing_file_search_tool_fields_fail_closed() { #[tokio::test] async fn fail_open_isolates_a_malformed_pending_call() { let server = MockServer::json(200, &json!({"data": []})); - let filter = make_filter(server.port, "callout_failure_mode: open\n"); + let filter = make_filter(server.port, "on_failure: open\n"); let malformed = json!({ "type":"file_search_call","id":"fs-bad","status":"searching","queries":["valid", 7] }); diff --git a/docs/filters/openai_file_search_callout.md b/docs/filters/openai_file_search_callout.md index 95571099a9..24ff764c88 100644 --- a/docs/filters/openai_file_search_callout.md +++ b/docs/filters/openai_file_search_callout.md @@ -14,7 +14,7 @@ The enclosing iterative router owns model re-entry. Streaming requests are rejec | Field | Type | Required | Description | |-------|------|---------|-------------| | `allow_private_url` | bool | no | Allow URLs that target local-sensitive addresses. DNS names are rejected unless this is enabled because validation cannot pin the address that the HTTP client will eventually dial. | -| `callout_failure_mode` | `closed` \| `open` | no | Behaviour when a vector-store callout fails. | +| `on_failure` | `closed` \| `open` | no | Behaviour when a vector-store callout fails. | | `forward_headers` | string[] | no | Headers to forward from the original request to the vector store API for authentication and tenant isolation. No downstream headers are forwarded by default. | | `max_response_bytes` | integer | no | Maximum response body size in bytes per callout. | | `max_total_response_bytes` | integer | no | Maximum cumulative successful response bytes per filter execution. | diff --git a/docs/filters/openai_responses_compact.md b/docs/filters/openai_responses_compact.md index 20ee97e98e..36439d03ea 100644 --- a/docs/filters/openai_responses_compact.md +++ b/docs/filters/openai_responses_compact.md @@ -19,7 +19,7 @@ Compaction only applies to multi-turn requests where `openai_responses_rehydrate | `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. | | `timeout_ms` | integer | no | Callout timeout in milliseconds. | -| `callout_failure_mode` | `closed` \| `open` | no | Failure mode for the inference callout. | +| `on_failure` | `closed` \| `open` | no | Failure mode for the inference callout. | | `status_on_error` | integer | no | HTTP status code to return when rejecting on error. | ## Examples @@ -40,6 +40,6 @@ inference_url: "http://localhost:11434/v1/chat/completions" default_model: gpt-4o-mini tiktoken_encoding: cl100k_base timeout_ms: 30000 -callout_failure_mode: closed +on_failure: closed status_on_error: 502 ``` diff --git a/examples/configs/openai/responses/file-search-callout.yaml b/examples/configs/openai/responses/file-search-callout.yaml index 0ae8503339..c539ce62ff 100644 --- a/examples/configs/openai/responses/file-search-callout.yaml +++ b/examples/configs/openai/responses/file-search-callout.yaml @@ -15,7 +15,7 @@ # timeout_ms: Whole-call timeout in milliseconds # max_response_bytes: Maximum response bytes retained per callout # max_total_response_bytes: Maximum successful bytes across one fan-out -# callout_failure_mode: closed (fail closed) or open (fail open) +# on_failure: closed (fail closed) or open (fail open) # forward_headers: Headers to forward from the client request # (e.g. Authorization) to the vector store API @@ -50,7 +50,7 @@ filter_chains: # The filter and the enclosing iterative router may use # different values; the smaller limit wins at runtime. max_state_bytes: 136314880 - callout_failure_mode: closed + on_failure: closed forward_headers: - authorization - filter: openai_responses_proxy diff --git a/examples/configs/openai/responses/full-flow-agentic.yaml b/examples/configs/openai/responses/full-flow-agentic.yaml index 9377a4b47f..e87f0e84cd 100644 --- a/examples/configs/openai/responses/full-flow-agentic.yaml +++ b/examples/configs/openai/responses/full-flow-agentic.yaml @@ -152,7 +152,7 @@ filter_chains: max_response_bytes: 10485760 max_total_response_bytes: 67108864 max_state_bytes: 136314880 - callout_failure_mode: closed + on_failure: closed forward_headers: - authorization - filter: openai_responses_proxy diff --git a/tests/integration/sdk/openai/test_openai_responses_vllm.py b/tests/integration/sdk/openai/test_openai_responses_vllm.py index 21b1d7c452..5de7b47d3f 100644 --- a/tests/integration/sdk/openai/test_openai_responses_vllm.py +++ b/tests/integration/sdk/openai/test_openai_responses_vllm.py @@ -789,7 +789,7 @@ def test_client_function_exits_openai_agentic_loop(self, agentic_client): max_response_bytes: 10485760 max_total_response_bytes: 67108864 max_state_bytes: 136314880 - callout_failure_mode: closed + on_failure: closed forward_headers: - authorization - filter: openai_responses_proxy From 3511f9e926e0c3b69218fbbefe493ac53bc41c2b Mon Sep 17 00:00:00 2001 From: Rastislav Papso Date: Mon, 31 Aug 2026 14:14:21 +0100 Subject: [PATCH 04/13] refactor(callouts): reuse shared FailureMode in http_callout Drop the filter-local FailureModeConfig enum and use praxis_ai_apis::callout_policy::FailureMode instead. The filter already exposed the canonical `on_failure` key, so this is an internal type consolidation with no behavior or configuration change. Signed-off-by: Rastislav Papso --- filters/src/callout/config.rs | 19 ++----------------- filters/src/callout/mod.rs | 9 +++++---- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/filters/src/callout/config.rs b/filters/src/callout/config.rs index 30cd468127..4bf6140df3 100644 --- a/filters/src/callout/config.rs +++ b/filters/src/callout/config.rs @@ -5,6 +5,7 @@ use std::{net::IpAddr, time::Duration}; +use praxis_ai_apis::callout_policy::FailureMode; use praxis_filter::FilterError; use serde::Deserialize; use tracing::warn; @@ -37,7 +38,7 @@ pub(crate) struct HttpCalloutConfig { /// structural key before this config is parsed, so it cannot be /// used as an alias here. #[serde(default)] - pub on_failure: FailureModeConfig, + pub on_failure: FailureMode, /// HTTP status code returned when rejecting on failure. pub status_on_error: Option, @@ -184,22 +185,6 @@ pub(crate) enum Phase { RequestBody, } -// ----------------------------------------------------------------------------- -// Failure Mode -// ----------------------------------------------------------------------------- - -/// Behavior when a callout fails. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum FailureModeConfig { - /// Reject the original request (fail-closed). - #[default] - Closed, - - /// Allow the original request to proceed (fail-open). - Open, -} - // ----------------------------------------------------------------------------- // Circuit Breaker // ----------------------------------------------------------------------------- diff --git a/filters/src/callout/mod.rs b/filters/src/callout/mod.rs index 31a950d3d0..0e46a055bf 100644 --- a/filters/src/callout/mod.rs +++ b/filters/src/callout/mod.rs @@ -25,10 +25,11 @@ use std::time::Duration; use async_trait::async_trait; use bytes::Bytes; -use config::{FailureModeConfig, HttpCalloutConfig, Phase, expand_env_vars, validate_callout_url}; +use config::{HttpCalloutConfig, Phase, expand_env_vars, validate_callout_url}; use extract::{BodyShaper, CompiledExtraction}; use http::HeaderMap; use pingora_core::upstreams::peer::HttpPeer; +use praxis_ai_apis::callout_policy::FailureMode; use praxis_core::{ circuit::CircuitBreakerConfig as CoreCircuitBreakerConfig, connectivity::is_private_ip, @@ -97,7 +98,7 @@ pub struct HttpCalloutFilter { extractions: Vec, /// Behavior on callout failure. - failure_mode: FailureModeConfig, + failure_mode: FailureMode, /// Downstream headers to copy into the callout request. forward_headers: Vec, @@ -316,8 +317,8 @@ impl HttpCalloutFilter { /// I/O), per the configured failure mode. fn failure_action(&self) -> FilterAction { match self.failure_mode { - FailureModeConfig::Open => FilterAction::Continue, - FailureModeConfig::Closed => Self::build_rejection(self.status_on_error), + FailureMode::Open => FilterAction::Continue, + FailureMode::Closed => Self::build_rejection(self.status_on_error), } } From 555f5a16821ed27f3e17fcd5740a91dca8a68ec6 Mon Sep 17 00:00:00 2001 From: Rastislav Papso Date: Mon, 31 Aug 2026 14:30:42 +0100 Subject: [PATCH 05/13] chore(callouts): fix up formatting and doc-generation after the rename Apply rustfmt to the import blocks touched by the callout policy move, realign a comment column in the file-search-callout example, and make xtask filter-docs to scan apis/src/callout_policy.rs as a shared config source so FailureMode and OnMissing resolve when generating filter docs. Signed-off-by: Rastislav Papso --- .../openai/responses/file_resolve/tests.rs | 44 +++++++++---------- .../responses/file_search_callout/client.rs | 3 +- apis/src/web_search/config.rs | 3 +- apis/src/web_search/provider.rs | 5 ++- .../openai/responses/file-search-callout.yaml | 2 +- xtask/src/filter_docs.rs | 21 +++++---- 6 files changed, 41 insertions(+), 37 deletions(-) diff --git a/apis/src/openai/responses/file_resolve/tests.rs b/apis/src/openai/responses/file_resolve/tests.rs index 0d64dcbe63..3c2118dfa6 100644 --- a/apis/src/openai/responses/file_resolve/tests.rs +++ b/apis/src/openai/responses/file_resolve/tests.rs @@ -845,10 +845,13 @@ fn serve_file_request(mut stream: std::net::TcpStream) { #[tokio::test] async fn file_url_resolved_to_data_uri() { - use crate::{callout_policy::OnMissing, openai::responses::file_resolve::{ - resolve::resolve_input, - resolve_url::{FileUrlResolver, NormalizedOrigin}, - }}; + use crate::{ + callout_policy::OnMissing, + openai::responses::file_resolve::{ + resolve::resolve_input, + resolve_url::{FileUrlResolver, NormalizedOrigin}, + }, + }; // Start TCP stub serving file content let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -1006,10 +1009,7 @@ async fn file_url_oversized_content_length_reports_generic_too_large() { #[tokio::test] async fn file_url_passthrough_when_no_resolver() { - use crate::{ - callout_policy::OnMissing, - openai::responses::file_resolve::resolve::resolve_input, - }; + use crate::{callout_policy::OnMissing, openai::responses::file_resolve::resolve::resolve_input}; // Build body with file_url let mut body = json!({ @@ -1041,9 +1041,10 @@ async fn file_url_in_shorthand_message_resolved() { use crate::{ callout_policy::OnMissing, openai::responses::file_resolve::{ - resolve::resolve_input, - resolve_url::{FileUrlResolver, NormalizedOrigin}, - }}; + resolve::resolve_input, + resolve_url::{FileUrlResolver, NormalizedOrigin}, + }, + }; // Start TCP stub let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -1109,7 +1110,8 @@ async fn file_url_in_function_call_output_resolved() { openai::responses::file_resolve::{ resolve::resolve_input, resolve_url::{FileUrlResolver, NormalizedOrigin}, - }}; + }, + }; // Start TCP stub let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -1173,10 +1175,8 @@ async fn file_url_in_function_call_output_resolved() { async fn file_url_blocked_is_not_swallowed_by_on_missing_continue() { use crate::{ callout_policy::OnMissing, - openai::responses::file_resolve::{ - resolve::resolve_input, - resolve_url::FileUrlResolver, - }}; + openai::responses::file_resolve::{resolve::resolve_input, resolve_url::FileUrlResolver}, + }; let mut body = json!({ "input": [{ @@ -1214,10 +1214,8 @@ async fn file_url_blocked_is_not_swallowed_by_on_missing_continue() { async fn file_url_failed_is_not_swallowed_by_on_missing_continue() { use crate::{ callout_policy::OnMissing, - openai::responses::file_resolve::{ - resolve::resolve_input, - resolve_url::FileUrlResolver, - }}; + openai::responses::file_resolve::{resolve::resolve_input, resolve_url::FileUrlResolver}, + }; // Regression test for #542: simulate an attacker-controlled origin // that redirects to a metadata-style target. Praxis's resolver @@ -1280,10 +1278,8 @@ async fn file_url_failed_is_not_swallowed_by_on_missing_continue() { async fn file_url_too_large_is_not_swallowed_by_on_missing_continue() { use crate::{ callout_policy::OnMissing, - openai::responses::file_resolve::{ - resolve::resolve_input, - resolve_url::FileUrlResolver, - }}; + openai::responses::file_resolve::{resolve::resolve_input, resolve_url::FileUrlResolver}, + }; // Regression test for #542: an oversized file_url response must // also reject the request under on_missing: continue, not just diff --git a/apis/src/openai/responses/file_search_callout/client.rs b/apis/src/openai/responses/file_search_callout/client.rs index d1600a6ca3..0e176a34c3 100644 --- a/apis/src/openai/responses/file_search_callout/client.rs +++ b/apis/src/openai/responses/file_search_callout/client.rs @@ -14,8 +14,7 @@ use http::HeaderMap; use serde::{Deserialize, Serialize, de::Visitor}; use serde_json::Value; -use crate::callout_policy::FailureMode; -use crate::openai::api_client::ApiClient; +use crate::{callout_policy::FailureMode, openai::api_client::ApiClient}; // ----------------------------------------------------------------------------- // Constants diff --git a/apis/src/web_search/config.rs b/apis/src/web_search/config.rs index 90a8c2f329..b82581a4dd 100644 --- a/apis/src/web_search/config.rs +++ b/apis/src/web_search/config.rs @@ -3,7 +3,6 @@ //! Configuration for protocol-neutral web-search providers. -use crate::callout_policy::FailureMode; use praxis_filter::{ FilterError, body::MAX_JSON_BODY_BYTES, builtins::http::payload_processing::config_validation::validate_max_body_bytes, @@ -11,6 +10,8 @@ use praxis_filter::{ use secrecy::{ExposeSecret as _, SecretString}; use serde::Deserialize; +use crate::callout_policy::FailureMode; + /// Default callout timeout (10 seconds — search APIs can be slow). const DEFAULT_TIMEOUT_MS: u64 = 10_000; diff --git a/apis/src/web_search/provider.rs b/apis/src/web_search/provider.rs index b9206da568..9e37ab68cf 100644 --- a/apis/src/web_search/provider.rs +++ b/apis/src/web_search/provider.rs @@ -21,7 +21,10 @@ use super::{ ValidatedConfig, config::{SearchContextSize, SearchProvider}, }; -use crate::{callout_policy::FailureMode, subrequest::{self, SubRequest, SubRequestClient, SubRequestError, SubResponse}}; +use crate::{ + callout_policy::FailureMode, + subrequest::{self, SubRequest, SubRequestClient, SubRequestError, SubResponse}, +}; /// Response body cap for search callouts (1 MiB). Distinct from /// `max_body_bytes` which governs inbound request buffering. diff --git a/examples/configs/openai/responses/file-search-callout.yaml b/examples/configs/openai/responses/file-search-callout.yaml index c539ce62ff..ba430718a1 100644 --- a/examples/configs/openai/responses/file-search-callout.yaml +++ b/examples/configs/openai/responses/file-search-callout.yaml @@ -15,7 +15,7 @@ # timeout_ms: Whole-call timeout in milliseconds # max_response_bytes: Maximum response bytes retained per callout # max_total_response_bytes: Maximum successful bytes across one fan-out -# on_failure: closed (fail closed) or open (fail open) +# on_failure: closed (fail closed) or open (fail open) # forward_headers: Headers to forward from the client request # (e.g. Authorization) to the vector store API diff --git a/xtask/src/filter_docs.rs b/xtask/src/filter_docs.rs index 71452bbb31..96f9737af9 100644 --- a/xtask/src/filter_docs.rs +++ b/xtask/src/filter_docs.rs @@ -334,16 +334,21 @@ fn parse_shared_config_items(root: &Path) -> ModuleItems { items } +/// Local configuration files whose types are shared by filters in separate +/// API categories. Paths are relative to the workspace root. +const LOCAL_SHARED_CONFIG_FILES: &[&str] = &["apis/src/web_search/config.rs", "apis/src/callout_policy.rs"]; + /// Parse local configuration types shared by filters in separate API categories. fn parse_local_shared_config(root: &Path, items: &mut ModuleItems) { - let path = root.join("apis/src/web_search/config.rs"); - let Ok(source) = fs::read_to_string(path) else { - return; - }; - let Ok(file) = syn::parse_file(&source) else { - return; - }; - parse_file_items(&file, items); + for rel in LOCAL_SHARED_CONFIG_FILES { + let Ok(source) = fs::read_to_string(root.join(rel)) else { + continue; + }; + let Ok(file) = syn::parse_file(&source) else { + continue; + }; + parse_file_items(&file, items); + } } /// Resolve praxis crate source directories from the cargo registry via From c01a09c1f172e2b5f646c8aba4f1e60f02c73710 Mon Sep 17 00:00:00 2001 From: Rastislav Papso Date: Mon, 31 Aug 2026 14:37:20 +0100 Subject: [PATCH 06/13] docs(migrating): document the on_failure rename for 0.2.0 Add an outbound-callout section to the 0.2 migration guide: a per-filter table mapping provider_failure_mode and callout_failure_mode to on_failure, and a note that the old keys are rejected at startup rather than aliased. Signed-off-by: Rastislav Papso --- docs/migrating-to-0.2.md | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/migrating-to-0.2.md b/docs/migrating-to-0.2.md index bd6868f126..0cec7c36d8 100644 --- a/docs/migrating-to-0.2.md +++ b/docs/migrating-to-0.2.md @@ -2,13 +2,36 @@ Version 0.2.0 moves token usage parsing from `praxis-ai-apis` into the private token usage subsystem in -`praxis-ai-filters`. +`praxis-ai-filters`, and normalizes the failure-policy keys +used by outbound callout filters. ## Proxy configuration -No configuration changes are required. The `token_count` and -`token_usage_headers` filter names, provider values, and metadata -keys remain unchanged. +The `token_count` and `token_usage_headers` filter names, +provider values, and metadata keys remain unchanged. + +### Outbound callout failure policy + +Callout filters previously spelled the same fail-open/fail-closed +choice three different ways. They now share one key, `on_failure`, +with unchanged `open` / `closed` values and an unchanged `closed` +default. Rename the key in place: + +| Filter | 0.1.x key | 0.2.0 key | +| --- | --- | --- | +| `anthropic_web_search` | `provider_failure_mode` | `on_failure` | +| `openai_web_search` | `provider_failure_mode` | `on_failure` | +| `openai_responses_compact` | `callout_failure_mode` | `on_failure` | +| `openai_file_search_callout` | `callout_failure_mode` | `on_failure` | +| `http_callout` | `on_failure` | `on_failure` (unchanged) | + +The old keys are not accepted as aliases. Because these filters use +`deny_unknown_fields`, a stale key fails validation at startup with a +message naming the offending field. + +`openai_file_resolve`'s `on_missing` key is **not** part of this +rename and keeps its `continue` / `reject` values, and `continue` +remains its default. ## Rust API From 45dc2c77851be4cf4d3f5d286c1de5b6ab7d703d Mon Sep 17 00:00:00 2001 From: Rastislav Papso Date: Mon, 31 Aug 2026 16:28:58 +0100 Subject: [PATCH 07/13] refactor(web-search): use shared timeout and status validators Drop web_search's local validate_timeout_ms and validate_status_on_error copies in favor of the equivalents in callout_policy, passing the existing DEFAULT_TIMEOUT_MS and DEFAULT_STATUS_ON_ERROR explicitly. Error messages and accepted ranges are identical, behavior is unchanged. Signed-off-by: Rastislav Papso --- apis/src/web_search/config.rs | 32 +++++++------------------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/apis/src/web_search/config.rs b/apis/src/web_search/config.rs index b82581a4dd..f77a9e1d37 100644 --- a/apis/src/web_search/config.rs +++ b/apis/src/web_search/config.rs @@ -10,7 +10,7 @@ use praxis_filter::{ use secrecy::{ExposeSecret as _, SecretString}; use serde::Deserialize; -use crate::callout_policy::FailureMode; +use crate::callout_policy::{self, FailureMode}; /// Default callout timeout (10 seconds — search APIs can be slow). const DEFAULT_TIMEOUT_MS: u64 = 10_000; @@ -203,36 +203,18 @@ fn build_validated_config( provider: raw.provider, api_key: SecretString::from(api_key), default_context_size: validate_context_size(filter_name, raw.default_context_size.as_deref())?, - timeout_ms: validate_timeout_ms(filter_name, raw.timeout_ms)?, + timeout_ms: callout_policy::validate_timeout_ms(filter_name, raw.timeout_ms, DEFAULT_TIMEOUT_MS)?, max_body_bytes: validate_max_body_bytes_field(filter_name, raw.max_body_bytes)?, failure_mode: raw.on_failure.unwrap_or(FailureMode::Closed), - status_on_error: validate_status_on_error(filter_name, raw.status_on_error)?, + status_on_error: callout_policy::validate_status_on_error( + filter_name, + raw.status_on_error, + DEFAULT_STATUS_ON_ERROR, + )?, base_url: raw.base_url.clone(), }) } -/// Validate timeout, applying the default and rejecting zero. -fn validate_timeout_ms(filter_name: &'static str, raw: Option) -> Result { - let value = raw.unwrap_or(DEFAULT_TIMEOUT_MS); - if value == 0 { - return Err(FilterError::from(format!( - "{filter_name}: timeout_ms must be greater than 0" - ))); - } - Ok(value) -} - -/// Validate HTTP status code, applying the default and rejecting out-of-range. -fn validate_status_on_error(filter_name: &'static str, raw: Option) -> Result { - let value = raw.unwrap_or(DEFAULT_STATUS_ON_ERROR); - if !(100..=599).contains(&value) { - return Err(FilterError::from(format!( - "{filter_name}: status_on_error must be between 100 and 599, got {value}" - ))); - } - Ok(value) -} - /// Validate `default_context_size`, defaulting to `Medium` when /// absent and rejecting unknown values. fn validate_context_size(filter_name: &'static str, raw: Option<&str>) -> Result { From c5542aabbd0dd3d1cd627f47da77824b97caa879 Mon Sep 17 00:00:00 2001 From: Rastislav Papso Date: Tue, 1 Sep 2026 12:13:32 +0100 Subject: [PATCH 08/13] docs(callouts): describe callout policy enums as a vocabulary Describe the enums as fixing only the accepted values and the default, with each filter's on_failure / on_missing field docs authoritative for classification. Signed-off-by: Rastislav Papso --- apis/src/callout_policy.rs | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/apis/src/callout_policy.rs b/apis/src/callout_policy.rs index 25b5a26ba5..a05bebcc86 100644 --- a/apis/src/callout_policy.rs +++ b/apis/src/callout_policy.rs @@ -4,19 +4,20 @@ //! Shared failure-policy vocabulary for outbound AI callouts. //! //! Every filter that calls an external service answers one of -//! two *different* questions. Choose the type that matches the -//! question (the first listed value of each is its default): +//! two different questions (the first listed value of each is +//! its default): //! //! | Question | Type | YAML key | Values | //! | --- | --- | --- | --- | -//! | The callout did not produce an answer: DNS, connect, timeout, TLS, non-2xx, unparseable body. Serve the request anyway? | [`FailureMode`] | `on_failure` | `closed`, `open` | -//! | The callout answered successfully, and the answer is that the resource does not exist. Serve the request without it? | [`OnMissing`] | `on_missing` | `continue`, `reject` | +//! | The callout did not produce a usable answer. Serve the request anyway? | [`FailureMode`] | `on_failure` | `closed`, `open` | +//! | The callout answered, and the answer is that the resource does not exist. Serve the request without it? | [`OnMissing`] | `on_missing` | `continue`, `reject` | //! -//! These stay separate deliberately. A callout failure is an -//! *unknown*: the proxy cannot tell whether the resource exists, so -//! `on_failure: open` is a decision to serve a request whose -//! enrichment silently did not happen. A missing resource is a -//! *known* answer from a working upstream. +//! # 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 +//! are authoritative. //! //! # Naming //! @@ -32,11 +33,8 @@ use serde::Deserialize; // FailureMode // ----------------------------------------------------------------------------- -/// What happens when an outbound callout fails to produce an answer. -/// -/// Covers transport faults (DNS, connect, timeout, TLS), upstream -/// error statuses, and responses that cannot be parsed. Configured as -/// `on_failure`. +/// 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`]. @@ -56,11 +54,7 @@ pub enum FailureMode { // ----------------------------------------------------------------------------- /// What happens when a callout succeeds and answers that the -/// requested resource does not exist. -/// -/// Configured as `on_missing`. The callout answered successfully, but the -/// resource is absent. For a callout that produced no answer at all, -/// see [`FailureMode`]. +/// requested resource does not exist. Configured as `on_missing`. /// /// A filter may narrow the set of references this governs, but must never /// widen it to cover failures that carry a security signal (e.g. a file From f9d6d0230156c837e6ea4b89f9d2d65a353f4e90 Mon Sep 17 00:00:00 2001 From: Rastislav Papso Date: Tue, 1 Sep 2026 12:31:42 +0100 Subject: [PATCH 09/13] refactor(callouts): use the shared status_on_error validator in http_callout Drop http_callout's local validate_status_on_error and reuse the equivalent validation function from praxis-ai-apis. Signed-off-by: Rastislav Papso --- filters/src/callout/mod.rs | 34 ++++++---------------------------- 1 file changed, 6 insertions(+), 28 deletions(-) diff --git a/filters/src/callout/mod.rs b/filters/src/callout/mod.rs index 0e46a055bf..79970cc603 100644 --- a/filters/src/callout/mod.rs +++ b/filters/src/callout/mod.rs @@ -29,7 +29,7 @@ use config::{HttpCalloutConfig, Phase, expand_env_vars, validate_callout_url}; use extract::{BodyShaper, CompiledExtraction}; use http::HeaderMap; use pingora_core::upstreams::peer::HttpPeer; -use praxis_ai_apis::callout_policy::FailureMode; +use praxis_ai_apis::callout_policy::{FailureMode, validate_status_on_error}; use praxis_core::{ circuit::CircuitBreakerConfig as CoreCircuitBreakerConfig, connectivity::is_private_ip, @@ -65,6 +65,9 @@ const DISALLOWED_FORWARD_HEADERS: &[http::HeaderName] = &[ http::header::TRAILER, ]; +/// Default HTTP status when the callout fails. +const DEFAULT_STATUS_ON_ERROR: u16 = 403; + // ----------------------------------------------------------------------------- // HttpCalloutFilter // ----------------------------------------------------------------------------- @@ -145,7 +148,7 @@ impl HttpCalloutFilter { validate_callout_url(&cfg.target.url)?; validate_max_body_bytes(cfg.request.max_body_bytes)?; - validate_status_on_error(cfg.status_on_error)?; + let status_on_error = validate_status_on_error(FILTER_NAME, cfg.status_on_error, DEFAULT_STATUS_ON_ERROR)?; let body_shaper = BodyShaper::compile(&cfg.target.body)?; let headers = parse_static_headers(&cfg)?; @@ -169,7 +172,7 @@ impl HttpCalloutFilter { max_body_bytes: cfg.request.max_body_bytes, max_depth: cfg.max_depth.unwrap_or(1), phase: cfg.request.phase, - status_on_error: cfg.status_on_error.unwrap_or(403), + status_on_error, target, timeout: cfg.target.timeout, url: cfg.target.url, @@ -414,31 +417,6 @@ fn validate_max_body_bytes(n: usize) -> Result<(), FilterError> { Ok(()) } -/// Reject a `status_on_error` value outside the valid HTTP status range. -/// -/// `None` (unset) is accepted; the filter then defaults to `403`. A -/// configured value must be a legal HTTP status code (100–599) so the -/// rejection path never emits a nonsensical status like `0` or `65535`. -/// -/// The `100..=599` range check is the established convention across the -/// codebase (`openai_responses_compact`, `web_search`, core builtins), -/// currently duplicated per filter. See the follow-up to promote a shared -/// `validate_status_on_error` helper into `praxis-ai-apis`. -/// -/// # Errors -/// -/// Returns [`FilterError`] if a configured status is outside 100–599. -fn validate_status_on_error(status: Option) -> Result<(), FilterError> { - if let Some(code) = status - && !(100..=599).contains(&code) - { - return Err( - format!("http_callout: status_on_error ({code}) must be a valid HTTP status code (100-599)").into(), - ); - } - Ok(()) -} - /// Parse static header entries with env-var expansion. fn parse_static_headers(cfg: &HttpCalloutConfig) -> Result, FilterError> { cfg.target From 20c792a634427a37caabbf64811df114423ecaa9 Mon Sep 17 00:00:00 2001 From: Rastislav Papso Date: Tue, 1 Sep 2026 16:29:31 +0100 Subject: [PATCH 10/13] docs(callouts): trim the shared policy docs to the vocabulary Drop the question/answer table and the failure-class wording from callout_policy. The module only fixes the accepted values and the default. Which conditions route through on_failure or on_missing varies per filter, so each filter's field docs and behavior remain the authoritative reference. Signed-off-by: Rastislav Papso --- apis/src/callout_policy.rs | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/apis/src/callout_policy.rs b/apis/src/callout_policy.rs index a05bebcc86..82dfc81b2d 100644 --- a/apis/src/callout_policy.rs +++ b/apis/src/callout_policy.rs @@ -3,28 +3,17 @@ //! Shared failure-policy vocabulary for outbound AI callouts. //! -//! Every filter that calls an external service answers one of -//! two different questions (the first listed value of each is -//! its default): -//! -//! | Question | Type | YAML key | Values | -//! | --- | --- | --- | --- | -//! | The callout did not produce a usable answer. Serve the request anyway? | [`FailureMode`] | `on_failure` | `closed`, `open` | -//! | The callout answered, and the answer is that the resource does not exist. Serve the request without it? | [`OnMissing`] | `on_missing` | `continue`, `reject` | -//! //! # 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 -//! are authoritative. +//! and behavior are authoritative. //! //! # Naming //! -//! The external key is `on_failure`. Pipeline entries already own a -//! structural `failure_mode` key that governs how the pipeline reacts -//! when a filter returns an error. `on_failure` pairs with `on_missing`, -//! which keeps the two policies reading as one family in configuration. +//! 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; @@ -53,17 +42,17 @@ pub enum FailureMode { // OnMissing // ----------------------------------------------------------------------------- -/// What happens when a callout succeeds and answers that the -/// requested resource does not exist. Configured as `on_missing`. +/// What happens when a requested resource cannot be fetched. Configured +/// as `on_missing`. /// -/// A filter may narrow the set of references this governs, but must never +/// 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 { - /// Leave the reference unchanged and continue (default). + /// Continue without the resource. #[default] Continue, From bf183fb1d191c5321a88378aecf977f380b0719c Mon Sep 17 00:00:00 2001 From: Rastislav Papso Date: Wed, 2 Sep 2026 09:33:32 +0100 Subject: [PATCH 11/13] refactor(callouts): rename FailureMode to OnFailure The shared callout failure enum was named `FailureMode`, colliding with `praxis_core::config::FailureMode`. Both carry the same `Open`/`Closed` variants but mean different things: Core's governs how the pipeline reacts when a filter returns an error, AI's governs what happens when an outbound callout does not produce a usable answer. Both are in scope in the repo, so the shared name made their usage ambiguous. Rename the type to `OnFailure` and the struct fields carrying it to `on_failure`. This matches the external `on_failure` YAML key and mirrors the existing `OnMissing` / `on_missing` pairing. Variants stay `Open` / `Closed`: that is established fail-open/fail-closed vocabulary and the accepted YAML values are unchanged. No behavior change. The accepted config values and their defaults are unaffected. Signed-off-by: Rastislav Papso --- apis/src/callout_policy.rs | 24 ++++----- apis/src/openai/responses/compact/config.rs | 10 ++-- apis/src/openai/responses/compact/mod.rs | 8 +-- apis/src/openai/responses/compact/tests.rs | 12 ++--- .../responses/file_search_callout/client.rs | 10 ++-- .../responses/file_search_callout/config.rs | 10 ++-- .../responses/file_search_callout/mod.rs | 10 ++-- .../responses/file_search_callout/tests.rs | 8 +-- apis/src/web_search/config.rs | 18 +++---- apis/src/web_search/provider.rs | 52 +++++++++---------- filters/src/callout/config.rs | 4 +- filters/src/callout/mod.rs | 12 ++--- filters/src/callout/tests.rs | 6 +-- server/src/subrequest.rs | 2 +- 14 files changed, 93 insertions(+), 93 deletions(-) diff --git a/apis/src/callout_policy.rs b/apis/src/callout_policy.rs index 768371af67..8cdfa35a19 100644 --- a/apis/src/callout_policy.rs +++ b/apis/src/callout_policy.rs @@ -19,7 +19,7 @@ use praxis_filter::FilterError; use serde::Deserialize; // ----------------------------------------------------------------------------- -// FailureMode +// OnFailure // ----------------------------------------------------------------------------- /// What happens when an outbound callout does not produce a usable @@ -29,7 +29,7 @@ use serde::Deserialize; /// [`OnMissing`]. #[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] -pub enum FailureMode { +pub enum OnFailure { /// Reject the request on failure (default). #[default] Closed, @@ -71,7 +71,7 @@ pub struct CalloutSettings { 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, @@ -191,17 +191,17 @@ mod tests { // ------------------------------------------------------------------------- #[test] - fn failure_mode_deserializes_canonical_values() { + fn on_failure_deserializes_canonical_values() { assert_eq!( - serde_yaml::from_str::("closed").unwrap(), - FailureMode::Closed + serde_yaml::from_str::("closed").unwrap(), + OnFailure::Closed ); - assert_eq!(serde_yaml::from_str::("open").unwrap(), FailureMode::Open); + assert_eq!(serde_yaml::from_str::("open").unwrap(), OnFailure::Open); } #[test] - fn failure_mode_defaults_to_closed() { - assert_eq!(FailureMode::default(), FailureMode::Closed); + fn on_failure_defaults_to_closed() { + assert_eq!(OnFailure::default(), OnFailure::Closed); } #[test] @@ -219,9 +219,9 @@ mod tests { } #[test] - fn failure_mode_rejects_on_missing_vocabulary() { - assert!(serde_yaml::from_str::("continue").is_err()); - assert!(serde_yaml::from_str::("reject").is_err()); + fn on_failure_rejects_on_missing_vocabulary() { + assert!(serde_yaml::from_str::("continue").is_err()); + assert!(serde_yaml::from_str::("reject").is_err()); assert!(serde_yaml::from_str::("open").is_err()); assert!(serde_yaml::from_str::("closed").is_err()); } diff --git a/apis/src/openai/responses/compact/config.rs b/apis/src/openai/responses/compact/config.rs index 3e5d454998..481bea2951 100644 --- a/apis/src/openai/responses/compact/config.rs +++ b/apis/src/openai/responses/compact/config.rs @@ -6,7 +6,7 @@ use praxis_filter::FilterError; use serde::Deserialize; -use crate::callout_policy::{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; @@ -42,7 +42,7 @@ pub(super) struct CompactFilterConfig { /// Failure mode for the inference callout. #[serde(default)] - pub on_failure: Option, + pub on_failure: Option, /// HTTP status code to return when rejecting on error. #[serde(default)] @@ -117,7 +117,7 @@ pub(super) fn build_config(raw: &CompactFilterConfig) -> Result Result, 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, diff --git a/apis/src/openai/responses/compact/tests.rs b/apis/src/openai/responses/compact/tests.rs index d3cebb38e6..21d1f12314 100644 --- a/apis/src/openai/responses/compact/tests.rs +++ b/apis/src/openai/responses/compact/tests.rs @@ -4,7 +4,7 @@ use serde_json::json; use super::*; -use crate::callout_policy::FailureMode; +use crate::callout_policy::OnFailure; // ============================================================================= // Config tests @@ -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); } @@ -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.on_failure = 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); } @@ -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::(&format!( - "inference_url: http://localhost/v1/chat/completions\non_failure: {failure_mode}" + "inference_url: http://localhost/v1/chat/completions\non_failure: {on_failure}" )) .unwrap(); let cfg: CompactFilterConfig = serde_yaml::from_value(yaml).unwrap(); diff --git a/apis/src/openai/responses/file_search_callout/client.rs b/apis/src/openai/responses/file_search_callout/client.rs index 81e8d01807..4c0f7491e7 100644 --- a/apis/src/openai/responses/file_search_callout/client.rs +++ b/apis/src/openai/responses/file_search_callout/client.rs @@ -14,7 +14,7 @@ use http::HeaderMap; use serde::{Deserialize, Serialize, de::Visitor}; use serde_json::Value; -use crate::{callout_policy::FailureMode, openai::api_client::ApiClient}; +use crate::{callout_policy::OnFailure, openai::api_client::ApiClient}; // ----------------------------------------------------------------------------- // Constants @@ -337,7 +337,7 @@ pub(crate) struct FileSearchClientConfig { pub api_client: ApiClient, /// Whether one failed chunk stops scheduling later callouts. - pub failure_mode: FailureMode, + pub on_failure: OnFailure, /// Maximum response body size enforced by the core client. pub max_response_bytes: usize, @@ -355,7 +355,7 @@ pub(crate) struct FileSearchClient { api_client: ApiClient, /// Whether one failed chunk stops scheduling later callouts. - failure_mode: FailureMode, + on_failure: OnFailure, /// Maximum response body size enforced by the core client. max_response_bytes: usize, @@ -372,7 +372,7 @@ impl FileSearchClient { pub fn new(config: FileSearchClientConfig) -> Self { Self { api_client: config.api_client, - failure_mode: config.failure_mode, + on_failure: config.on_failure, max_response_bytes: config.max_response_bytes, max_total_response_bytes: config.max_total_response_bytes, timeout: config.timeout, @@ -440,7 +440,7 @@ impl FileSearchClient { deadline_recorded = true; break; } - if chunk_failed && self.failure_mode == FailureMode::Closed { + if chunk_failed && self.on_failure == OnFailure::Closed { if let Some(remaining_specs) = specs.get(next_spec..) { append_fail_closed_failures(&mut batch.failures, remaining_specs); } diff --git a/apis/src/openai/responses/file_search_callout/config.rs b/apis/src/openai/responses/file_search_callout/config.rs index db2109d61a..10db8583e8 100644 --- a/apis/src/openai/responses/file_search_callout/config.rs +++ b/apis/src/openai/responses/file_search_callout/config.rs @@ -11,7 +11,7 @@ use serde::Deserialize; use super::client::MAX_CONCURRENT_SEARCHES; use crate::{ - callout_policy::FailureMode, + callout_policy::OnFailure, openai::api_client::{self, ApiClient, ApiClientConfig}, subrequest::SubRequestClient, }; @@ -57,7 +57,7 @@ pub(crate) struct FileSearchFilterConfig { pub allow_private_url: bool, /// Behaviour when a vector-store callout fails. - pub on_failure: Option, + pub on_failure: Option, /// Headers to forward from the original request to the /// vector store API for authentication and tenant isolation. @@ -89,7 +89,7 @@ pub(crate) struct ValidatedConfig { pub api_client: ApiClient, /// Search failure handling policy. - pub failure_mode: FailureMode, + pub on_failure: OnFailure, /// Maximum response body size per callout. pub max_response_bytes: usize, @@ -111,7 +111,7 @@ pub(crate) fn build_config_with_client( client: SubRequestClient, ) -> Result { let vector_store_url = parse_vector_store_url(&cfg.vector_store_url, cfg.allow_private_url)?; - let failure_mode = cfg.on_failure.unwrap_or(FailureMode::Closed); + let on_failure = cfg.on_failure.unwrap_or(OnFailure::Closed); let (max_response_bytes, max_total_response_bytes) = response_limits(cfg.max_response_bytes, cfg.max_total_response_bytes)?; let max_state_bytes = validated_state_limit(cfg.max_state_bytes)?; @@ -129,7 +129,7 @@ pub(crate) fn build_config_with_client( Ok(ValidatedConfig { api_client, - failure_mode, + on_failure, max_response_bytes, max_total_response_bytes, max_state_bytes, diff --git a/apis/src/openai/responses/file_search_callout/mod.rs b/apis/src/openai/responses/file_search_callout/mod.rs index deb33f418f..131fa8049a 100644 --- a/apis/src/openai/responses/file_search_callout/mod.rs +++ b/apis/src/openai/responses/file_search_callout/mod.rs @@ -41,7 +41,7 @@ use self::{ model_context::{FormatLimits, FormatTemplates, MODEL_CONTEXT_TEMPLATES, format_search_results}, }; use crate::{ - callout_policy::FailureMode, + callout_policy::OnFailure, openai::responses::{ bounded_json_size, error::responses_error_rejection, @@ -81,7 +81,7 @@ pub struct FileSearchCalloutFilter { max_state_bytes: usize, /// Whether a failed callout rejects or produces an incomplete result. - failure_mode: FailureMode, + on_failure: OnFailure, } /// Request-local marker used to reject streaming before the first subrequest. @@ -125,7 +125,7 @@ impl FileSearchCalloutFilter { fn build(validated: ValidatedConfig) -> Box { let client = FileSearchClient::new(FileSearchClientConfig { api_client: validated.api_client, - failure_mode: validated.failure_mode, + on_failure: validated.on_failure, max_response_bytes: validated.max_response_bytes, max_total_response_bytes: validated.max_total_response_bytes, timeout: validated.timeout, @@ -134,7 +134,7 @@ impl FileSearchCalloutFilter { Box::new(Self { client, max_state_bytes: validated.max_state_bytes, - failure_mode: validated.failure_mode, + on_failure: validated.on_failure, }) } @@ -272,7 +272,7 @@ impl FileSearchCalloutFilter { "vector store search failed" ); } - let failure = (self.failure_mode == FailureMode::Closed) + let failure = (self.on_failure == OnFailure::Closed) .then(|| batch.failures.first()) .flatten()?; Some(FilterAction::Reject(responses_error_rejection( diff --git a/apis/src/openai/responses/file_search_callout/tests.rs b/apis/src/openai/responses/file_search_callout/tests.rs index 7b97c29baf..39a66c9c3c 100644 --- a/apis/src/openai/responses/file_search_callout/tests.rs +++ b/apis/src/openai/responses/file_search_callout/tests.rs @@ -43,7 +43,7 @@ fn minimal_config_uses_safe_defaults() { assert_eq!(config.max_response_bytes, 10_485_760); assert_eq!(config.max_total_response_bytes, 67_108_864); assert_eq!(config.max_state_bytes, 52_428_800); - assert_eq!(config.failure_mode, FailureMode::Closed); + assert_eq!(config.on_failure, OnFailure::Closed); } #[test] @@ -1219,7 +1219,7 @@ async fn ranking_filters_rewrite_policy_and_safe_path_are_sent_to_vector_store() } #[tokio::test] -async fn open_and_closed_failure_modes_are_distinct() { +async fn open_and_closed_on_failures_are_distinct() { for (status, body) in [ (401, json!({"error":"unauthorized"}).to_string()), (403, "not-json".to_owned()), @@ -1925,7 +1925,7 @@ fn make_concrete_filter(port: u16, extra: &str) -> FileSearchCalloutFilter { let validated = build_config(&raw).unwrap(); let client = FileSearchClient::new(FileSearchClientConfig { api_client: validated.api_client, - failure_mode: validated.failure_mode, + on_failure: validated.on_failure, max_response_bytes: validated.max_response_bytes, max_total_response_bytes: validated.max_total_response_bytes, timeout: validated.timeout, @@ -1933,7 +1933,7 @@ fn make_concrete_filter(port: u16, extra: &str) -> FileSearchCalloutFilter { FileSearchCalloutFilter { client, max_state_bytes: validated.max_state_bytes, - failure_mode: validated.failure_mode, + on_failure: validated.on_failure, } } diff --git a/apis/src/web_search/config.rs b/apis/src/web_search/config.rs index f440d830bc..487781f589 100644 --- a/apis/src/web_search/config.rs +++ b/apis/src/web_search/config.rs @@ -10,7 +10,7 @@ use praxis_filter::{ use secrecy::{ExposeSecret as _, SecretString}; use serde::Deserialize; -use crate::callout_policy::{self, FailureMode}; +use crate::callout_policy::{self, OnFailure}; /// Default callout timeout (10 seconds — search APIs can be slow). const DEFAULT_TIMEOUT_MS: u64 = 10_000; @@ -118,7 +118,7 @@ pub(crate) struct WebSearchFilterConfig { /// Failure mode for search provider callouts. #[serde(default)] - pub(crate) on_failure: Option, + pub(crate) on_failure: Option, /// HTTP status code to return when rejecting on error. #[serde(default)] @@ -164,7 +164,7 @@ pub(crate) struct ValidatedConfig { pub max_body_bytes: usize, /// Failure mode for search callouts. - pub failure_mode: FailureMode, + pub on_failure: OnFailure, /// HTTP status on error. pub status_on_error: u16, @@ -181,7 +181,7 @@ impl std::fmt::Debug for ValidatedConfig { .field("default_context_size", &self.default_context_size) .field("timeout_ms", &self.timeout_ms) .field("max_body_bytes", &self.max_body_bytes) - .field("failure_mode", &self.failure_mode) + .field("on_failure", &self.on_failure) .field("status_on_error", &self.status_on_error) .field("base_url", &self.base_url) .finish() @@ -220,7 +220,7 @@ fn build_validated_config( default_context_size: validate_context_size(filter_name, raw.default_context_size.as_deref())?, timeout_ms: callout_policy::validate_timeout_ms(filter_name, raw.timeout_ms, DEFAULT_TIMEOUT_MS)?, max_body_bytes: validate_max_body_bytes_field(filter_name, raw.max_body_bytes)?, - failure_mode: raw.on_failure.unwrap_or(FailureMode::Closed), + on_failure: raw.on_failure.unwrap_or(OnFailure::Closed), status_on_error: callout_policy::validate_status_on_error( filter_name, raw.status_on_error, @@ -306,7 +306,7 @@ mod tests { assert_eq!(cfg.default_context_size, SearchContextSize::Medium); assert_eq!(cfg.timeout_ms, DEFAULT_TIMEOUT_MS); assert_eq!(cfg.max_body_bytes, MAX_JSON_BODY_BYTES); - assert_eq!(cfg.failure_mode, FailureMode::Closed); + assert_eq!(cfg.on_failure, OnFailure::Closed); assert_eq!(cfg.status_on_error, DEFAULT_STATUS_ON_ERROR); } @@ -338,7 +338,7 @@ mod tests { let raw: WebSearchFilterConfig = parse_filter_config("openai_web_search", &yaml).unwrap(); let validated = build_config("openai_web_search", &raw).unwrap(); - assert_eq!(validated.failure_mode, FailureMode::Open); + assert_eq!(validated.on_failure, OnFailure::Open); } #[test] @@ -370,12 +370,12 @@ mod tests { let mut cfg = base_config(); cfg.default_context_size = Some("high".into()); cfg.timeout_ms = Some(15_000); - cfg.on_failure = Some(FailureMode::Open); + cfg.on_failure = Some(OnFailure::Open); cfg.status_on_error = Some(503); let validated = build_config("openai_web_search", &cfg).unwrap(); assert_eq!(validated.default_context_size, SearchContextSize::High); assert_eq!(validated.timeout_ms, 15_000); - assert_eq!(validated.failure_mode, FailureMode::Open); + assert_eq!(validated.on_failure, OnFailure::Open); assert_eq!(validated.status_on_error, 503); } diff --git a/apis/src/web_search/provider.rs b/apis/src/web_search/provider.rs index 2f74c54cf5..af8cac1e06 100644 --- a/apis/src/web_search/provider.rs +++ b/apis/src/web_search/provider.rs @@ -22,7 +22,7 @@ use super::{ config::{SearchContextSize, SearchProvider}, }; use crate::{ - callout_policy::FailureMode, + callout_policy::OnFailure, subrequest::{self, SubRequest, SubRequestClient, SubRequestError, SubResponse}, }; @@ -82,7 +82,7 @@ pub(crate) struct SearchClient { /// Default search context size. default_context_size: SearchContextSize, /// Failure mode governing what happens on errors. - failure_mode: FailureMode, + on_failure: OnFailure, /// HTTP status to return on rejection. status_on_error: u16, /// Override the provider's default API base URL. @@ -97,7 +97,7 @@ impl std::fmt::Debug for SearchClient { .field("provider", &self.provider) .field("api_key", &"[REDACTED]") .field("default_context_size", &self.default_context_size) - .field("failure_mode", &self.failure_mode) + .field("on_failure", &self.on_failure) .field("status_on_error", &self.status_on_error) .field("base_url", &self.base_url) .finish() @@ -124,7 +124,7 @@ impl SearchClient { provider: config.provider, api_key: config.api_key.clone(), default_context_size: config.default_context_size, - failure_mode: config.failure_mode, + on_failure: config.on_failure, status_on_error: config.status_on_error, base_url: config.base_url.clone(), }) @@ -178,11 +178,11 @@ impl SearchClient { /// mode this is a rejection; under open mode search is silently /// skipped. fn transport_failure_outcome(&self) -> SearchOutcome { - match self.failure_mode { - FailureMode::Closed => SearchOutcome::Rejected { + match self.on_failure { + OnFailure::Closed => SearchOutcome::Rejected { status: self.status_on_error, }, - FailureMode::Open => SearchOutcome::Skipped, + OnFailure::Open => SearchOutcome::Skipped, } } @@ -307,11 +307,11 @@ impl SearchClient { /// parsed. Under closed mode this is an error; under open mode /// search is silently skipped. fn parse_failure_outcome(&self) -> SearchOutcome { - match self.failure_mode { - FailureMode::Closed => SearchOutcome::Rejected { + match self.on_failure { + OnFailure::Closed => SearchOutcome::Rejected { status: self.status_on_error, }, - FailureMode::Open => SearchOutcome::Skipped, + OnFailure::Open => SearchOutcome::Skipped, } } } @@ -508,7 +508,7 @@ mod tests { default_context_size: SearchContextSize::Medium, timeout_ms: 5000, max_body_bytes: 64 * 1024 * 1024, - failure_mode: FailureMode::Closed, + on_failure: OnFailure::Closed, status_on_error: 502, base_url: None, }; @@ -563,7 +563,7 @@ mod tests { default_context_size: SearchContextSize::Medium, timeout_ms: 5000, max_body_bytes: 64 * 1024 * 1024, - failure_mode: FailureMode::Closed, + on_failure: OnFailure::Closed, status_on_error: 502, base_url: None, }; @@ -579,7 +579,7 @@ mod tests { default_context_size: SearchContextSize::Medium, timeout_ms: 5000, max_body_bytes: 64 * 1024 * 1024, - failure_mode: FailureMode::Closed, + on_failure: OnFailure::Closed, status_on_error: 502, base_url: None, }; @@ -602,7 +602,7 @@ mod tests { default_context_size: SearchContextSize::Medium, timeout_ms: 5000, max_body_bytes: 64 * 1024 * 1024, - failure_mode: FailureMode::Closed, + on_failure: OnFailure::Closed, status_on_error: 502, base_url: Some("http://localhost:9999".into()), }; @@ -622,7 +622,7 @@ mod tests { default_context_size: SearchContextSize::Medium, timeout_ms: 5000, max_body_bytes: 64 * 1024 * 1024, - failure_mode: FailureMode::Closed, + on_failure: OnFailure::Closed, status_on_error: 502, base_url: Some("http://localhost:9999".into()), }; @@ -642,7 +642,7 @@ mod tests { default_context_size: SearchContextSize::Medium, timeout_ms: 5000, max_body_bytes: 64 * 1024 * 1024, - failure_mode: FailureMode::Closed, + on_failure: OnFailure::Closed, status_on_error: 502, base_url: Some("http://localhost:9999".into()), }; @@ -662,7 +662,7 @@ mod tests { default_context_size: SearchContextSize::Medium, timeout_ms: 5000, max_body_bytes: 64 * 1024 * 1024, - failure_mode: FailureMode::Closed, + on_failure: OnFailure::Closed, status_on_error: 502, base_url: None, }; @@ -682,7 +682,7 @@ mod tests { default_context_size: SearchContextSize::Medium, timeout_ms: 5000, max_body_bytes: 64 * 1024 * 1024, - failure_mode: FailureMode::Open, + on_failure: OnFailure::Open, status_on_error: 502, base_url: None, }; @@ -694,14 +694,14 @@ mod tests { ); } - fn test_search_client(failure_mode: FailureMode) -> SearchClient { + fn test_search_client(on_failure: OnFailure) -> SearchClient { let config = ValidatedConfig { provider: SearchProvider::Brave, api_key: SecretString::from("test-key".to_owned()), default_context_size: SearchContextSize::Medium, timeout_ms: 1000, max_body_bytes: 64 * 1024 * 1024, - failure_mode, + on_failure, status_on_error: 502, base_url: None, }; @@ -735,7 +735,7 @@ mod tests { .to_string(), ); - let client = test_search_client(FailureMode::Closed); + let client = test_search_client(OnFailure::Closed); let url = format!("http://{addr}/res/v1/web/search?q=test&count=5"); let request = SubRequest { method: http::Method::GET, @@ -757,7 +757,7 @@ mod tests { let addr = listener.local_addr().unwrap(); spawn_http_server(listener, 500, "internal error"); - let client = test_search_client(FailureMode::Closed); + let client = test_search_client(OnFailure::Closed); let url = format!("http://{addr}/search"); let request = SubRequest { method: http::Method::GET, @@ -779,7 +779,7 @@ mod tests { let addr = listener.local_addr().unwrap(); spawn_http_server(listener, 429, "rate limited"); - let client = test_search_client(FailureMode::Open); + let client = test_search_client(OnFailure::Open); let url = format!("http://{addr}/search"); let request = SubRequest { method: http::Method::GET, @@ -804,7 +804,7 @@ mod tests { drop(stream); }); - let client = test_search_client(FailureMode::Closed); + let client = test_search_client(OnFailure::Closed); let url = format!("http://{addr}/search"); let request = SubRequest { method: http::Method::GET, @@ -829,7 +829,7 @@ mod tests { tokio::time::sleep(Duration::from_secs(5)).await; }); - let mut client = test_search_client(FailureMode::Open); + let mut client = test_search_client(OnFailure::Open); client.timeout = Duration::from_millis(50); let url = format!("http://{addr}/search"); let request = SubRequest { @@ -863,7 +863,7 @@ mod tests { stream.write_all(&body).unwrap(); }); - let client = test_search_client(FailureMode::Closed); + let client = test_search_client(OnFailure::Closed); let url = format!("http://{addr}/search"); let request = SubRequest { method: http::Method::GET, diff --git a/filters/src/callout/config.rs b/filters/src/callout/config.rs index c5ab3b7d05..798165a020 100644 --- a/filters/src/callout/config.rs +++ b/filters/src/callout/config.rs @@ -5,7 +5,7 @@ use std::{net::IpAddr, time::Duration}; -use praxis_ai_apis::callout_policy::FailureMode; +use praxis_ai_apis::callout_policy::OnFailure; use praxis_filter::FilterError; use serde::Deserialize; use tracing::warn; @@ -38,7 +38,7 @@ pub(crate) struct HttpCalloutConfig { /// structural key before this config is parsed, so it cannot be /// used as an alias here. #[serde(default)] - pub on_failure: FailureMode, + pub on_failure: OnFailure, /// HTTP status code returned when rejecting on failure. pub status_on_error: Option, diff --git a/filters/src/callout/mod.rs b/filters/src/callout/mod.rs index 286a214fd7..9104cb5383 100644 --- a/filters/src/callout/mod.rs +++ b/filters/src/callout/mod.rs @@ -29,7 +29,7 @@ use config::{HttpCalloutConfig, Phase, expand_env_vars, validate_callout_url}; use extract::{BodyShaper, CompiledExtraction}; use http::HeaderMap; use pingora_core::upstreams::peer::HttpPeer; -use praxis_ai_apis::callout_policy::{FailureMode, validate_status_on_error}; +use praxis_ai_apis::callout_policy::{OnFailure, validate_status_on_error}; use praxis_core::{ circuit::CircuitBreakerConfig as CoreCircuitBreakerConfig, connectivity::is_private_ip, @@ -101,7 +101,7 @@ pub struct HttpCalloutFilter { extractions: Vec, /// Behavior on callout failure. - failure_mode: FailureMode, + on_failure: OnFailure, /// Downstream headers to copy into the callout request. forward_headers: Vec, @@ -165,7 +165,7 @@ impl HttpCalloutFilter { body_shaper, client, extractions, - failure_mode: cfg.on_failure, + on_failure: cfg.on_failure, forward_headers, headers, inject_headers, @@ -319,9 +319,9 @@ impl HttpCalloutFilter { /// The action to take when the callout itself fails (DNS, connect, /// I/O), per the configured failure mode. fn failure_action(&self) -> FilterAction { - match self.failure_mode { - FailureMode::Open => FilterAction::Continue, - FailureMode::Closed => Self::build_rejection(self.status_on_error), + match self.on_failure { + OnFailure::Open => FilterAction::Continue, + OnFailure::Closed => Self::build_rejection(self.status_on_error), } } diff --git a/filters/src/callout/tests.rs b/filters/src/callout/tests.rs index 63e86821fd..fc19b5d737 100644 --- a/filters/src/callout/tests.rs +++ b/filters/src/callout/tests.rs @@ -595,7 +595,7 @@ mod filter_tests { // ------------------------------------------------------------------------- #[tokio::test] - async fn failure_mode_closed_rejects() { + async fn on_failure_closed_rejects() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); drop(listener); @@ -630,7 +630,7 @@ mod filter_tests { } #[tokio::test] - async fn failure_mode_open_continues() { + async fn on_failure_open_continues() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); drop(listener); @@ -801,7 +801,7 @@ mod filter_tests { // ------------------------------------------------------------------------- #[tokio::test] - async fn timeout_triggers_failure_mode() { + async fn timeout_triggers_on_failure() { let mock_server = MockServer::start().await; Mock::given(method("POST")) diff --git a/server/src/subrequest.rs b/server/src/subrequest.rs index 18f05a6063..2f34900d28 100644 --- a/server/src/subrequest.rs +++ b/server/src/subrequest.rs @@ -187,7 +187,7 @@ runtime: " vector_store_url: http://127.0.0.1:9 allow_private_url: true -callout_failure_mode: closed +on_failure: closed ", ) .unwrap(); From 57ef807f49a8d84e34288ffb7f180191deb2365c Mon Sep 17 00:00:00 2001 From: Rastislav Papso Date: Wed, 2 Sep 2026 09:40:37 +0100 Subject: [PATCH 12/13] docs(migrating): move the callout key rename to the 0.3.0 guide The `on_failure` normalization was documented in the 0.2.0 migration guide, but 0.2.0 has shipped and the rename is unreleased. Restore that guide to its released content, move the section to a new docs/migrating-to-0.3.md with columns adjusted to 0.2.x -> 0.3.0, and link it from the docs index. Signed-off-by: Rastislav Papso --- docs/README.md | 1 + docs/migrating-to-0.2.md | 31 ++++--------------------------- docs/migrating-to-0.3.md | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 27 deletions(-) create mode 100644 docs/migrating-to-0.3.md diff --git a/docs/README.md b/docs/README.md index e186ba8989..c25afe4510 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,5 +34,6 @@ provider API integrations on top of [Praxis](https://github.com/praxis-proxy/pra - [Release process](release.md) - [Migrating to 0.2.0](migrating-to-0.2.md) +- [Migrating to 0.3.0](migrating-to-0.3.md) - [Security policy](../SECURITY.md) - [Contributing](../CONTRIBUTING.md) diff --git a/docs/migrating-to-0.2.md b/docs/migrating-to-0.2.md index 0cec7c36d8..bd6868f126 100644 --- a/docs/migrating-to-0.2.md +++ b/docs/migrating-to-0.2.md @@ -2,36 +2,13 @@ Version 0.2.0 moves token usage parsing from `praxis-ai-apis` into the private token usage subsystem in -`praxis-ai-filters`, and normalizes the failure-policy keys -used by outbound callout filters. +`praxis-ai-filters`. ## Proxy configuration -The `token_count` and `token_usage_headers` filter names, -provider values, and metadata keys remain unchanged. - -### Outbound callout failure policy - -Callout filters previously spelled the same fail-open/fail-closed -choice three different ways. They now share one key, `on_failure`, -with unchanged `open` / `closed` values and an unchanged `closed` -default. Rename the key in place: - -| Filter | 0.1.x key | 0.2.0 key | -| --- | --- | --- | -| `anthropic_web_search` | `provider_failure_mode` | `on_failure` | -| `openai_web_search` | `provider_failure_mode` | `on_failure` | -| `openai_responses_compact` | `callout_failure_mode` | `on_failure` | -| `openai_file_search_callout` | `callout_failure_mode` | `on_failure` | -| `http_callout` | `on_failure` | `on_failure` (unchanged) | - -The old keys are not accepted as aliases. Because these filters use -`deny_unknown_fields`, a stale key fails validation at startup with a -message naming the offending field. - -`openai_file_resolve`'s `on_missing` key is **not** part of this -rename and keeps its `continue` / `reject` values, and `continue` -remains its default. +No configuration changes are required. The `token_count` and +`token_usage_headers` filter names, provider values, and metadata +keys remain unchanged. ## Rust API diff --git a/docs/migrating-to-0.3.md b/docs/migrating-to-0.3.md new file mode 100644 index 0000000000..6ac6fb7b63 --- /dev/null +++ b/docs/migrating-to-0.3.md @@ -0,0 +1,33 @@ +# Migrating to 0.3.0 + +Version 0.3.0 normalizes the failure-policy keys +used by outbound callout filters. + +## Proxy configuration + +### Outbound callout failure policy + +Callout filters previously spelled the same fail-open/fail-closed +choice three different ways. They now share one key, `on_failure`, +with unchanged `open` / `closed` values and an unchanged `closed` +default. Rename the key in place: + +| Filter | 0.2.x key | 0.3.0 key | +| --- | --- | --- | +| `anthropic_web_search` | `provider_failure_mode` | `on_failure` | +| `openai_web_search` | `provider_failure_mode` | `on_failure` | +| `openai_responses_compact` | `callout_failure_mode` | `on_failure` | +| `openai_file_search_callout` | `callout_failure_mode` | `on_failure` | +| `http_callout` | `on_failure` | `on_failure` (unchanged) | + +The old keys are not accepted as aliases. Because these filters use +`deny_unknown_fields`, a stale key fails validation at startup with a +message naming the offending field. + +`openai_file_resolve`'s `on_missing` key is **not** part of this +rename and keeps its `continue` / `reject` values, and `continue` +remains its default. + +## Rust API + + \ No newline at end of file From 34d031841d7f3e530e17b1f37f693391a4fe2c21 Mon Sep 17 00:00:00 2001 From: Rastislav Papso Date: Wed, 2 Sep 2026 09:48:27 +0100 Subject: [PATCH 13/13] chore(callouts): linting, formatting Run make lint, make fmt. Signed-off-by: Rastislav Papso --- apis/src/callout_policy.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apis/src/callout_policy.rs b/apis/src/callout_policy.rs index 8cdfa35a19..bfb04cfe29 100644 --- a/apis/src/callout_policy.rs +++ b/apis/src/callout_policy.rs @@ -192,10 +192,7 @@ mod tests { #[test] fn on_failure_deserializes_canonical_values() { - assert_eq!( - serde_yaml::from_str::("closed").unwrap(), - OnFailure::Closed - ); + assert_eq!(serde_yaml::from_str::("closed").unwrap(), OnFailure::Closed); assert_eq!(serde_yaml::from_str::("open").unwrap(), OnFailure::Open); }