diff --git a/apis/src/anthropic/web_search/mod.rs b/apis/src/anthropic/web_search/mod.rs index 2e211b3ea6..9c003bc94c 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 616dff2781..f85eafce62 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}" allow_private_base_url: true "#, diff --git a/apis/src/openai/responses/config_validation.rs b/apis/src/callout_policy.rs similarity index 56% rename from apis/src/openai/responses/config_validation.rs rename to apis/src/callout_policy.rs index 9822bb0d66..bfb04cfe29 100644 --- a/apis/src/openai/responses/config_validation.rs +++ b/apis/src/callout_policy.rs @@ -1,41 +1,82 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) 2026 Praxis Contributors -//! Shared config validation helpers for Responses API filters. +//! Shared failure-policy vocabulary for outbound AI callouts. +//! +//! # Classification is filter-specific +//! +//! These enums are a vocabulary: they fix the accepted values +//! and the default, not which conditions a filter routes through +//! which key. Each filter's `on_failure` / `on_missing` field docs +//! and behavior are authoritative. +//! +//! # Naming +//! +//! The external keys are `on_failure` and `on_missing`. A structural +//! `failure_mode` key is already owned by Core's pipeline entries. use praxis_filter::FilterError; use serde::Deserialize; // ----------------------------------------------------------------------------- -// FailureMode +// OnFailure // ----------------------------------------------------------------------------- -/// What happens when a callout to an external service fails. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +/// What happens when an outbound callout does not produce a usable +/// answer. Configured as `on_failure`. +/// +/// For a callout that succeeds but reports an absent resource, use +/// [`OnMissing`]. +#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] -pub(crate) enum FailureMode { +pub enum OnFailure { /// Reject the request on failure (default). + #[default] Closed, + /// Continue without the callout result on failure. Open, } +// ----------------------------------------------------------------------------- +// OnMissing +// ----------------------------------------------------------------------------- + +/// What happens when a requested resource cannot be fetched. Configured +/// as `on_missing`. +/// +/// A filter may narrow the set of resources this governs, but must never +/// widen it to cover failures that carry a security signal (e.g. a file +/// URL that cannot be resolved - the target may be malicious or unreachable +/// for policy reasons). +#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OnMissing { + /// Continue without the resource. + #[default] + Continue, + + /// Return an error response to the client. + Reject, +} + // ----------------------------------------------------------------------------- // CalloutSettings // ----------------------------------------------------------------------------- /// Common callout fields shared by filters that make HTTP callouts. #[derive(Debug, Clone, Copy)] -pub(crate) struct CalloutSettings { +pub struct CalloutSettings { /// Callout timeout in milliseconds. pub timeout_ms: u64, + /// Failure mode for the callout. - pub failure_mode: FailureMode, + pub on_failure: OnFailure, + /// HTTP status code to return when rejecting on error. pub status_on_error: u16, } - // ----------------------------------------------------------------------------- // Validation helpers // ----------------------------------------------------------------------------- @@ -45,7 +86,7 @@ pub(crate) struct CalloutSettings { /// # Errors /// /// Returns [`FilterError`] when the resolved value is zero. -pub(crate) fn validate_timeout_ms(filter: &str, raw: Option, default: u64) -> Result { +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()); @@ -60,7 +101,7 @@ pub(crate) fn validate_timeout_ms(filter: &str, raw: Option, default: u64) /// /// 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 { +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()); @@ -144,4 +185,41 @@ mod tests { "error should include filter name, got: {err}" ); } + + // ------------------------------------------------------------------------- + // Canonical vocabulary + // ------------------------------------------------------------------------- + + #[test] + fn on_failure_deserializes_canonical_values() { + assert_eq!(serde_yaml::from_str::("closed").unwrap(), OnFailure::Closed); + assert_eq!(serde_yaml::from_str::("open").unwrap(), OnFailure::Open); + } + + #[test] + fn on_failure_defaults_to_closed() { + assert_eq!(OnFailure::default(), OnFailure::Closed); + } + + #[test] + fn on_missing_defaults_to_continue() { + assert_eq!(OnMissing::default(), OnMissing::Continue); + } + + #[test] + fn on_missing_deserializes_canonical_values() { + assert_eq!( + serde_yaml::from_str::("continue").unwrap(), + OnMissing::Continue + ); + assert_eq!(serde_yaml::from_str::("reject").unwrap(), OnMissing::Reject); + } + + #[test] + 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/lib.rs b/apis/src/lib.rs index 02788223bf..fe2bd0746b 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 afe9c8f069..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::openai::responses::config_validation::{self, CalloutSettings, FailureMode}; +use crate::callout_policy::{self, CalloutSettings, OnFailure}; /// Default callout timeout (30 seconds — summarization can be slow). const DEFAULT_TIMEOUT_MS: u64 = 30_000; @@ -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)] @@ -103,9 +103,9 @@ pub(super) fn build_config(raw: &CompactFilterConfig) -> Result 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 c96ca77c4a..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::openai::responses::config_validation::FailureMode; +use crate::callout_policy::OnFailure; // ============================================================================= // Config tests @@ -16,7 +16,7 @@ fn base_config() -> CompactFilterConfig { default_model: "gpt-4o-mini".to_owned(), tiktoken_encoding: "cl100k_base".to_owned(), timeout_ms: None, - callout_failure_mode: None, + on_failure: None, status_on_error: None, } } @@ -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.callout_failure_mode = Some(FailureMode::Open); + cfg.on_failure = Some(OnFailure::Open); cfg.status_on_error = Some(503); let validated = build_config(&cfg).unwrap(); assert_eq!(validated.callout.timeout_ms, 60_000); - assert_eq!(validated.callout.failure_mode, FailureMode::Open); + assert_eq!(validated.callout.on_failure, OnFailure::Open); assert_eq!(validated.callout.status_on_error, 503); } @@ -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\ncallout_failure_mode: {failure_mode}" + "inference_url: http://localhost/v1/chat/completions\non_failure: {on_failure}" )) .unwrap(); let cfg: CompactFilterConfig = serde_yaml::from_value(yaml).unwrap(); @@ -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_resolve/config.rs b/apis/src/openai/responses/file_resolve/config.rs index 6962b33abd..232c1afff7 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 3a11710ab2..0535046435 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 c3ee1cf8a6..020efdc700 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 4f1b9e7b59..c1454b87f2 100644 --- a/apis/src/openai/responses/file_resolve/tests.rs +++ b/apis/src/openai/responses/file_resolve/tests.rs @@ -845,10 +845,12 @@ 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, - 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 @@ -1007,7 +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::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,10 +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, - 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 @@ -1101,10 +1105,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 @@ -1167,8 +1173,9 @@ 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!({ @@ -1205,8 +1212,9 @@ 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 @@ -1268,8 +1276,9 @@ 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 diff --git a/apis/src/openai/responses/file_search_callout/client.rs b/apis/src/openai/responses/file_search_callout/client.rs index a3d1b2fddd..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::openai::{api_client::ApiClient, responses::config_validation::FailureMode}; +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 52df709eea..10db8583e8 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::OnFailure, + openai::api_client::{self, ApiClient, ApiClientConfig}, subrequest::SubRequestClient, }; @@ -59,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. @@ -91,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, @@ -113,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 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)?; @@ -131,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 22ee48baf4..131fa8049a 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::OnFailure, openai::responses::{ bounded_json_size, - config_validation::FailureMode, error::responses_error_rejection, state::{MAX_CITATION_FILES, ResponsesState}, usage::merge_usage, @@ -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 f53c9cf4ad..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()), @@ -1233,7 +1233,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(), @@ -1250,7 +1250,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(), @@ -1271,7 +1271,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"]))); @@ -1300,7 +1300,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"]))); @@ -1382,7 +1382,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(); @@ -1435,7 +1435,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()); @@ -1465,7 +1465,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)); @@ -1484,7 +1484,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]))); @@ -1518,7 +1518,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]); @@ -1550,7 +1550,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"); @@ -1574,7 +1574,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] }); @@ -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/openai/responses/mod.rs b/apis/src/openai/responses/mod.rs index e52ced22a1..bbfe5a4bc8 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/openai/responses/web_search/mod.rs b/apis/src/openai/responses/web_search/mod.rs index a274000512..fb1d04f4be 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 6494e9a1df..487781f589 100644 --- a/apis/src/web_search/config.rs +++ b/apis/src/web_search/config.rs @@ -10,6 +10,8 @@ use praxis_filter::{ use secrecy::{ExposeSecret as _, SecretString}; use serde::Deserialize; +use crate::callout_policy::{self, OnFailure}; + /// Default callout timeout (10 seconds — search APIs can be slow). const DEFAULT_TIMEOUT_MS: u64 = 10_000; @@ -87,20 +89,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) // ----------------------------------------------------------------------------- @@ -130,7 +118,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)] @@ -176,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, @@ -193,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() @@ -230,36 +218,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.provider_failure_mode.unwrap_or(FailureMode::Closed), - status_on_error: validate_status_on_error(filter_name, raw.status_on_error)?, + on_failure: raw.on_failure.unwrap_or(OnFailure::Closed), + 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 { @@ -321,7 +291,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, allow_private_base_url: false, @@ -336,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); } @@ -362,13 +332,13 @@ 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(); - assert_eq!(validated.failure_mode, FailureMode::Open); + assert_eq!(validated.on_failure, OnFailure::Open); } #[test] @@ -400,12 +370,12 @@ 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(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 935bece23a..af8cac1e06 100644 --- a/apis/src/web_search/provider.rs +++ b/apis/src/web_search/provider.rs @@ -19,9 +19,12 @@ use tracing::{debug, warn}; use super::{ ValidatedConfig, - config::{FailureMode, SearchContextSize, SearchProvider}, + config::{SearchContextSize, SearchProvider}, +}; +use crate::{ + callout_policy::OnFailure, + subrequest::{self, SubRequest, SubRequestClient, SubRequestError, SubResponse}, }; -use crate::subrequest::{self, SubRequest, SubRequestClient, SubRequestError, SubResponse}; /// Response body cap for search callouts (1 MiB). Distinct from /// `max_body_bytes` which governs inbound request buffering. @@ -79,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. @@ -94,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() @@ -121,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(), }) @@ -175,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, } } @@ -304,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, } } } @@ -505,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, }; @@ -560,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, }; @@ -576,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, }; @@ -599,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()), }; @@ -619,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()), }; @@ -639,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()), }; @@ -659,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, }; @@ -679,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, }; @@ -691,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, }; @@ -732,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, @@ -754,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, @@ -776,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, @@ -801,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, @@ -826,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 { @@ -860,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/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/filters/anthropic_web_search.md b/docs/filters/anthropic_web_search.md index e5aae4be7d..9b7c6c490b 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. | | `allow_private_base_url` | bool | no | Allow a `base_url` that targets local-sensitive addresses. DNS targets are unsupported in protected mode (the default): validation cannot pin the address the HTTP client will eventually dial, so a `base_url` host must be a public IP literal. Enabling `allow_private_base_url` also permits DNS results resolving to local-sensitive addresses, so a hostile or rebound resolution can send the provider credential to a loopback, private, or cloud-metadata endpoint. | @@ -37,7 +37,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_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/docs/filters/openai_web_search.md b/docs/filters/openai_web_search.md index 62b7c066f0..f6ebb1b8d7 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. | | `allow_private_base_url` | bool | no | Allow a `base_url` that targets local-sensitive addresses. DNS targets are unsupported in protected mode (the default): validation cannot pin the address the HTTP client will eventually dial, so a `base_url` host must be a public IP literal. Enabling `allow_private_base_url` also permits DNS results resolving to local-sensitive addresses, so a hostile or rebound resolution can send the provider credential to a loopback, private, or cloud-metadata endpoint. | @@ -41,7 +41,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/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 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/file-search-callout.yaml b/examples/configs/openai/responses/file-search-callout.yaml index de636d491c..31e2a9ddaa 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 # @@ -59,7 +59,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/examples/configs/openai/responses/web-search.yaml b/examples/configs/openai/responses/web-search.yaml index fef7f3b9a9..c65141b4ee 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) # base_url: Override the provider API base URL. Must be a # public IP literal unless allow_private_base_url # is set (SSRF/credential-disclosure guard). @@ -36,7 +36,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/filters/src/callout/config.rs b/filters/src/callout/config.rs index 4229affeb2..798165a020 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::OnFailure; 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: OnFailure, /// 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 34e83c5f01..9104cb5383 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::{OnFailure, validate_status_on_error}; use praxis_core::{ circuit::CircuitBreakerConfig as CoreCircuitBreakerConfig, connectivity::is_private_ip, @@ -64,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 // ----------------------------------------------------------------------------- @@ -97,7 +101,7 @@ pub struct HttpCalloutFilter { extractions: Vec, /// Behavior on callout failure. - failure_mode: FailureModeConfig, + on_failure: OnFailure, /// Downstream headers to copy into the callout request. forward_headers: Vec, @@ -144,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)?; @@ -161,14 +165,14 @@ impl HttpCalloutFilter { body_shaper, client, extractions, - failure_mode: cfg.on_failure, + on_failure: cfg.on_failure, forward_headers, headers, inject_headers, 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, @@ -315,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 { - FailureModeConfig::Open => FilterAction::Continue, - FailureModeConfig::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), } } @@ -413,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 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(); diff --git a/tests/integration/sdk/openai/test_openai_responses_vllm.py b/tests/integration/sdk/openai/test_openai_responses_vllm.py index bdb93326a3..96d38e6dc3 100644 --- a/tests/integration/sdk/openai/test_openai_responses_vllm.py +++ b/tests/integration/sdk/openai/test_openai_responses_vllm.py @@ -790,7 +790,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 diff --git a/xtask/src/filter_docs.rs b/xtask/src/filter_docs.rs index c25a79afb5..a227f61243 100644 --- a/xtask/src/filter_docs.rs +++ b/xtask/src/filter_docs.rs @@ -337,16 +337,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 @@ -2674,7 +2679,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}"