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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 43 additions & 8 deletions apis/src/openai/responses/file_resolve/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,21 @@ pub(crate) struct FileResolveConfig {
/// Client-controlled `file_url` downloads never traverse this
/// chain; they stay on the credential-free hardened resolver.
///
/// May be an inline chain (`name` + `filters`) or a reference to a
/// top-level named chain. The field is optional at the struct level
/// so programmatic constructors can omit it, but the filter
/// registration fails the build when the chain is missing or cannot
/// be bound.
#[serde(default)]
pub outbound_chain: Option<ChainRef>,
/// Optional. Configured `file_id` callouts always run through the
/// bound outbound pipeline; this chain only adds filters along the
/// way. When omitted it defaults to an empty inline chain (pure
/// passthrough) via `default_outbound_chain`, so registration never
/// fails for a missing chain — matching `openai_file_search_callout`.
/// Provide it only to attach cross-cutting concerns such as
/// credential injection, tracing, or request tagging.
///
/// May be defined inline (`name` + `filters`) or reference a
/// top-level named chain; a named reference resolves because this
/// filter runs at the top pipeline level, not nested inside an
/// `iterative_request_router` step. Registration still fails the
/// build when a provided chain cannot be bound.
#[serde(default = "default_outbound_chain")]
pub outbound_chain: ChainRef,

/// Allow Files API callouts from the `StreamBuffer` pre-read
/// phase, before header-phase security filters execute.
Expand Down Expand Up @@ -135,6 +143,20 @@ pub(crate) struct FileResolveConfig {
pub allowed_file_url_origins: Vec<String>,
}

/// Default `outbound_chain` when the field is omitted: an empty inline chain.
///
/// Configured `file_id` callouts always run through the bound outbound
/// pipeline; an empty chain simply applies no extra filters (pure passthrough).
/// Operators supply a chain only to attach cross-cutting concerns such as
/// credential injection, tracing, or request tagging. The name is a label only
/// — inline chains are not looked up, so it never needs to be globally unique.
fn default_outbound_chain() -> ChainRef {
ChainRef::Inline {
name: "openai_file_resolve_outbound".to_owned(),
filters: Vec::new(),
}
}

/// Default max rewritten body bytes (64 MiB).
fn default_max_rewritten_body_bytes() -> usize {
MAX_JSON_BODY_BYTES
Expand Down Expand Up @@ -271,6 +293,19 @@ files_api_url: "http://files-api:8321"
allow_pre_security_callout: true
"#;

#[test]
fn config_defaults_omitted_outbound_chain_to_empty_inline() {
// `outbound_chain` is optional: omitting it yields an empty inline chain
// (pure passthrough) rather than a config error, matching
// `openai_file_search_callout`. Registration binds this empty chain, so a
// missing `outbound_chain` never fails the build.
let cfg: FileResolveConfig = serde_yaml::from_str(MINIMAL_YAML).unwrap();
assert!(
matches!(&cfg.outbound_chain, ChainRef::Inline { filters, .. } if filters.is_empty()),
"omitted outbound_chain should default to an empty inline chain"
);
}

#[test]
fn minimal_config_parses() {
let cfg: FileResolveConfig = serde_yaml::from_str(MINIMAL_YAML).unwrap();
Expand Down Expand Up @@ -440,7 +475,7 @@ timeout_ms: 300001"#;
#[test]
fn valid_config_passes() {
let cfg = FileResolveConfig {
outbound_chain: None,
outbound_chain: default_outbound_chain(),
allow_pre_security_callout: true,
files_api_url: "http://files-api:8321".to_owned(),
forward_headers: Vec::new(),
Expand Down
11 changes: 8 additions & 3 deletions apis/src/openai/responses/file_resolve/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@ use crate::{
/// value: file-resolve
/// ```
///
/// `outbound_chain` may be defined inline or reference a top-level named chain.
/// `outbound_chain` is optional and may be defined inline or reference a
/// top-level named chain. When omitted it defaults to an empty inline chain
/// (pure passthrough); configured `file_id` callouts still run through the
/// bound outbound pipeline.
///
/// # Full YAML
///
Expand Down Expand Up @@ -249,14 +252,16 @@ impl FileResolveFilter {
/// The chain-binding registration path calls this to resolve and bind
/// the chain (via [`ChainBindingContext::bind_chain`]) before
/// constructing the filter, keeping the private config type inside this
/// module. Returns `None` when no `outbound_chain` is configured.
/// module. `outbound_chain` is optional; when omitted the config layer
/// substitutes an empty inline chain (pure passthrough), so this always
/// yields a bindable reference and never signals "missing".
///
/// # Errors
///
/// Returns [`FilterError`] if the YAML config cannot be parsed.
///
/// [`ChainBindingContext::bind_chain`]: praxis_filter::ChainBindingContext::bind_chain
pub fn outbound_chain_ref(config: &serde_yaml::Value) -> Result<Option<ChainRef>, FilterError> {
pub fn outbound_chain_ref(config: &serde_yaml::Value) -> Result<ChainRef, FilterError> {
let cfg: FileResolveConfig = parse_filter_config("openai_file_resolve", config)?;
Ok(cfg.outbound_chain)
}
Expand Down
2 changes: 1 addition & 1 deletion docs/filters/openai_file_resolve.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ This filter resolves references inside Responses requests; it does not proxy cli

| Field | Type | Required | Description |
|-------|------|---------|-------------|
| `outbound_chain` | ChainRef | no | Outbound filter chain applied to configured Files API (`file_id`) metadata and content requests. The chain runs through the `FilteredSubrequestExecutor`, so its filters observe and can mutate the outbound callout before it is dialed. SSRF protection for `files_api_url` derives from the pipeline's `allow_private_upstreams`, enforced both when the callout target is pinned and at connect time. Client-controlled `file_url` downloads never traverse this chain; they stay on the credential-free hardened resolver. May be an inline chain (`name` + `filters`) or a reference to a top-level named chain. The field is optional at the struct level so programmatic constructors can omit it, but the filter registration fails the build when the chain is missing or cannot be bound. |
| `outbound_chain` | ChainRef | no | Outbound filter chain applied to configured Files API (`file_id`) metadata and content requests. The chain runs through the `FilteredSubrequestExecutor`, so its filters observe and can mutate the outbound callout before it is dialed. SSRF protection for `files_api_url` derives from the pipeline's `allow_private_upstreams`, enforced both when the callout target is pinned and at connect time. Client-controlled `file_url` downloads never traverse this chain; they stay on the credential-free hardened resolver. Optional. Configured `file_id` callouts always run through the bound outbound pipeline; this chain only adds filters along the way. When omitted it defaults to an empty inline chain (pure passthrough) via `default_outbound_chain`, so registration never fails for a missing chain — matching `openai_file_search_callout`. Provide it only to attach cross-cutting concerns such as credential injection, tracing, or request tagging. May be defined inline (`name` + `filters`) or reference a top-level named chain; a named reference resolves because this filter runs at the top pipeline level, not nested inside an `iterative_request_router` step. Registration still fails the build when a provided chain cannot be bound. |
| `allow_pre_security_callout` | bool | no | Allow Files API callouts from the `StreamBuffer` pre-read phase, before header-phase security filters execute. This must be explicitly enabled only when an outer trust boundary authenticates and authorizes requests before they reach this listener. Forwarded headers are the original downstream values, not mutations from request filters. |
| `files_api_url` | string | yes | Base URL of the Files API endpoint. Example: `http://files-api:8321` |
| `forward_headers` | string[] | no | Headers to forward from the original request to the Files API for authentication and tenant isolation. No downstream headers are forwarded by default. |
Expand Down
64 changes: 57 additions & 7 deletions filters/src/register.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,10 +400,13 @@ fn register_anthropic_web_search(registry: &mut FilterRegistry, subrequest_clien
///
/// Configured Files API (`file_id`) callouts run through the
/// `outbound_chain` filter pipeline, which is resolved and validated at
/// build/hot-reload time via [`ChainBindingContext::bind_chain`]. The chain
/// is required: registration fails the build when it is missing or cannot be
/// bound. The shared [`SubRequestClient`] is captured when available;
/// otherwise the filter falls back to an isolated per-filter connector.
/// build/hot-reload time via [`ChainBindingContext::bind_chain`]. The chain is
/// optional: when omitted the config layer substitutes an empty inline chain
/// (pure passthrough), so registration binds it and callouts still route
/// through the bound pipeline — matching `openai_file_search_callout`.
/// Registration only fails the build when a provided chain cannot be bound.
/// The shared [`SubRequestClient`] is captured when available; otherwise the
/// filter falls back to an isolated per-filter connector.
///
/// [`ChainBindingContext::bind_chain`]: praxis_filter::ChainBindingContext::bind_chain
#[expect(clippy::panic, reason = "matches register_filters! macro convention")]
Expand All @@ -413,9 +416,7 @@ fn register_file_resolve(registry: &mut FilterRegistry, subrequest_client: Optio
.register_chain_binding(
"openai_file_resolve",
std::sync::Arc::new(move |config, ctx| {
let chain_ref = praxis_ai_apis::openai::FileResolveFilter::outbound_chain_ref(config)?.ok_or_else(
|| -> praxis_filter::FilterError { "openai_file_resolve: 'outbound_chain' is required".into() },
)?;
let chain_ref = praxis_ai_apis::openai::FileResolveFilter::outbound_chain_ref(config)?;
let outbound = std::sync::Arc::new(ctx.bind_chain(&chain_ref)?);
let client = match &shared {
Some(client) => client.clone(),
Expand Down Expand Up @@ -610,6 +611,55 @@ outbound_chain:
);
}

/// Deserialize one `openai_file_resolve` filter entry from YAML.
fn file_resolve_entry(yaml: &str) -> FilterEntry {
serde_yaml::from_str(yaml).expect("file_resolve entry parses")
}

/// `openai_file_resolve` is a chain-binding filter, but `outbound_chain` is
/// optional (matching `openai_file_search_callout`). Omitting it must default
/// to an empty inline chain (pure passthrough) that binds cleanly, so the
/// pipeline build succeeds rather than rejecting the filter as misconfigured.
#[test]
fn file_resolve_binds_when_outbound_chain_omitted() {
let registry = build_ai_registry();
let mut entries = vec![file_resolve_entry(
"\
filter: openai_file_resolve
files_api_url: http://files-api:8321
allow_pre_security_callout: true
",
)];
let chains = HashMap::new();
FilterPipeline::build_with_chains(&mut entries, &registry, &chains, &InsecureOptions::default())
.expect("an omitted outbound_chain must default to an empty inline chain and bind");
}

/// A provided `outbound_chain` referencing an unknown filter type cannot be
/// built, so the whole pipeline build must fail closed rather than register a
/// filter whose outbound transport is broken.
#[test]
fn file_resolve_rejects_unbuildable_outbound_chain() {
let registry = build_ai_registry();
let mut entries = vec![file_resolve_entry(
"\
filter: openai_file_resolve
files_api_url: http://files-api:8321
allow_pre_security_callout: true
outbound_chain:
name: broken-outbound
filters:
- filter: this_filter_does_not_exist
",
)];
let chains = HashMap::new();
let result = FilterPipeline::build_with_chains(&mut entries, &registry, &chains, &InsecureOptions::default());
assert!(
result.is_err(),
"an outbound chain referencing an unknown filter must fail the pipeline build"
);
}

/// The same registration path builds when the inline outbound chain resolves,
/// proving the negative case fails on the chain, not on the filter's own
/// configuration.
Expand Down
Loading