Skip to content

feat(openai): add OGX-backed file search callout#493

Draft
leseb wants to merge 28 commits into
praxis-proxy:mainfrom
leseb:leseb/issue-29-file-search-filter-callout
Draft

feat(openai): add OGX-backed file search callout#493
leseb wants to merge 28 commits into
praxis-proxy:mainfrom
leseb:leseb/issue-29-file-search-filter-callout

Conversation

@leseb

@leseb leseb commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the openai_file_search_callout filter — a hosted executor for OpenAI Responses API file_search_call output items. When the model emits a file_search_call, this filter fans out OpenAI-compatible /v1/vector_stores/{id}/search requests to an OGX backend, aggregates and ranks results, formats them as model context with file citations, and replays the completed items back to the response pipeline.

Closes #29.

What it does

  • Bounded fan-out: Cartesian product of vector_store_ids × queries, capped at 64 specs with 8-way concurrency
  • Ranked aggregation: Cross-store results merged, sorted by score, truncated to max_num_results
  • Citation tracking: file_id → filename mappings stored in ResponsesState.citation_files for downstream annotation
  • Failure policies: on_error: reject (502, default) or on_error: ignore (log + continue with partial results)
  • SSRF-safe config: Rejects localhost, private/link-local/CGNAT/zero-network IPs, DNS hostnames, userinfo, malformed bracketed IPv6, query strings, fragments, and invalid ports at startup
  • Status-aware execution: Only processes searching and in_progress items; terminal statuses are skipped
  • Partial failure tracking: Cap-interrupted and per-spec-failed calls marked incomplete (not completed)
  • Hybrid search rejection: ranking_options.hybrid_search rejected with 400 rather than silently degrading to vector-only

Architecture

ResponsesState.output_items (file_search_call, status: searching)
  → extract_file_search_tool_def (vector_store_ids, filters, ranking_options)
  → build SearchSpec[] (capped at 64, tracking skipped/capped call IDs)
  → FileSearchClient.search (8-way concurrent, 10 MiB per-response limit)
  → aggregate, sort by score, truncate to max_num_results
  → push completed/incomplete items to messages + persisted_messages + output_items

Not yet registered in production. This filter requires pipeline continuations (praxis#777) to receive pre-populated ResponsesState.output_items. It is compiled, tested, and documented but gated behind #[cfg(test)] in the server binary.

New files

File Purpose
apis/src/openai/responses/file_search_callout/mod.rs Filter implementation and orchestration
apis/src/openai/responses/file_search_callout/client.rs OGX callout client with bounded concurrency
apis/src/openai/responses/file_search_callout/config.rs YAML config parsing, URL/SSRF validation
apis/src/openai/responses/file_search_callout/citations.rs Citation formatting and annotation
apis/src/openai/responses/file_search_callout/tests.rs 58 unit + functional tests (mock OGX)
apis/src/openai/responses/file_search_callout/live_tests.rs Live OGX contract tests
examples/configs/openai/responses/file-search-callout.yaml Example pipeline configuration
docs/filters/openai_file_search_callout.md Generated filter reference

Test plan

  • 58 unit/functional tests against mock OGX (cargo test -p praxis-ai-apis -- file_search_callout)
  • Example integration test (cargo test -p praxis-ai-integration -- file_search_callout)
  • Live OGX contract test in vLLM CI workflow
  • make lint clean (clippy + nightly fmt + separator lint + filter doc lint)
  • make build and make doc pass

Known limitations

  • Body collection is unbounded: Core CalloutClient buffers the full response before the size check runs; requires a core crate change to enforce during streaming
  • Aggregate memory: All 64 parsed responses are retained in memory before ranking; incremental top-k merge deferred
  • First-turn state: Filter depends on upstream filters to initialize ResponsesState with output_items; not yet wired for non-continuation creates

leseb added 21 commits July 23, 2026 15:25
Prepares praxis-ai-apis for the file_search filter which needs
CalloutClient from praxis-core and join_all from futures.

Signed-off-by: Sébastien Han <seb@redhat.com>
Maps file_id to filename for citation markers injected by the
file_search filter. Empty by default, populated during search
result formatting.

Signed-off-by: Sébastien Han <seb@redhat.com>
Add deferred first-pass request ownership, continuation output accounting, and canonical tool-choice handling needed by local hosted-tool execution. Keep response output owned by the response object and expose move-based helpers for continuation assembly.

Signed-off-by: Sébastien Han <seb@redhat.com>
Keep the state-only stacked PR buildable before its continuation consumers land. The allowance is removed by the integration layer once those fields and helpers are live.

Signed-off-by: Sébastien Han <seb@redhat.com>
Add bounded request serialization, paginated response decoding, fan-out scheduling primitives, shared-deadline enforcement, and aggregate memory admission for OpenAI-compatible vector store searches. The client consumes the provider-scoped ApiClient without registering a filter.

Signed-off-by: Sébastien Han <seb@redhat.com>
Format vector-store chunks into bounded model context and convert explicit file markers into OpenAI file-citation annotations. Enforce response-wide marker, annotation, filename, and serialized-size budgets without retaining a second JSON body.

Signed-off-by: Sébastien Han <seb@redhat.com>
Keep the client-and-formatting stacked PR buildable before the filter consumes their crate-private entry points. The allowance is removed by the filter implementation commit.

Signed-off-by: Sébastien Han <seb@redhat.com>
Add the Responses filter that plans pending file-search calls, executes bounded OGX fan-out, ranks results, injects model context, tracks citation metadata, and emits compact replay provenance. Validate URL, authentication, templates, deadlines, and aggregate response limits at configuration time.

Signed-off-by: Sébastien Han <seb@redhat.com>
Keep the filter-only stacked PR buildable before response processing consumes citation annotation. The response integration commit removes this temporary allowance.

Signed-off-by: Sébastien Han <seb@redhat.com>
Initialize deferred state only for file-search requests, rebuild internal continuation requests from the latest body, enforce remaining hosted-tool policy, and rehydrate bounded citation provenance without replaying private local markers to the model.

Signed-off-by: Sébastien Han <seb@redhat.com>
Keep the request-continuation PR buildable before conversation append-back consumes deferred input. The response persistence commit removes this method-scoped allowance.

Signed-off-by: Sébastien Han <seb@redhat.com>
Accumulate non-streaming continuation responses, merge prior output and usage, rewrite file citations, and persist only the new output suffix. Preserve compact local-search provenance in response storage and conversation append-back with compare-and-swap retries under concurrent updates.

Signed-off-by: Sébastien Han <seb@redhat.com>
Register the filter, publish generated configuration reference material, add a runnable pipeline example, and cover first-pass passthrough behavior with functional integration tests.

Signed-off-by: Sébastien Han <seb@redhat.com>
Remove the branch-local continuation, conversation, replay, response-store, and stream accumulation implementation while Praxis core continuation support is still pending. Keep the OGX leaf executor request-scoped and hand only model context plus citation mappings to the future continuation owner.

Signed-off-by: Sébastien Han <seb@redhat.com>
Add a self-cleaning script that creates an Ollama-backed vector store, indexes a fixture, and verifies direct and metadata-filtered OGX searches.

Signed-off-by: Sébastien Han <seb@redhat.com>
Switch file-search imports to the praxis-core dependency landed by praxis-proxy#473 and move the request URL into the merged post_json_bytes API. Refresh the futures package edge in Cargo.lock.

Signed-off-by: Sébastien Han <seb@redhat.com>
Use OGX's source file_id attribute when the top-level search result identifies an internal indexed document. Preserve canonical top-level file IDs and cover both response shapes.

Signed-off-by: Sébastien Han <seb@redhat.com>
Extend the OGX-backed vLLM workflow with a live file-search filter contract test. Provision an indexed document, verify matching and non-matching metadata searches, assert model context and citation state, and clean up the resources.

Signed-off-by: Sébastien Han <seb@redhat.com>
Remove the manual shell demo and its README instructions now that the live OGX file-search contract is exercised by the integration workflow.

Signed-off-by: Sébastien Han <seb@redhat.com>
Avoid unchecked decoder-result indexing and explicitly scope the existing test-only line-count allowance to the live OGX orchestration helpers.

Signed-off-by: Sébastien Han <seb@redhat.com>
Use the sentence-transformers model registered by the OGX starter distribution and allow cold model download and indexing to complete within the live test.

Signed-off-by: Sébastien Han <seb@redhat.com>
@leseb

leseb commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

blocked on praxis-proxy/praxis#849

leseb added 5 commits July 23, 2026 16:07
Signed-off-by: Sébastien Han <seb@redhat.com>
Signed-off-by: Sébastien Han <seb@redhat.com>
Signed-off-by: Sébastien Han <seb@redhat.com>
Signed-off-by: Sébastien Han <seb@redhat.com>
Signed-off-by: Sébastien Han <seb@redhat.com>

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review: feat(openai): add OGX-backed file search callout

Excellent, comprehensive addition. The security posture, bounded deserialization, and resource admission design are all strong. 58 unit/functional tests with a full mock OGX server provide solid coverage. A few issues worth addressing before merge.

Findings

# Severity File Description
1 Medium client.rs Global static semaphores shared across all filter instances without documented sharing semantics
2 Medium client.rs merge_top_results uses O(n*k) linear-scan eviction instead of a min-heap
3 Medium mod.rs apply_batch returns Err(FilterAction) for non-error control flow
4 Medium citations.rs annotate_response is #[expect(dead_code)] -- dead public(crate) code shipped in production
5 Medium mod.rs bounded_string_copy can produce a string 1-4 bytes over max_bytes with misleading name
6 Medium tests.rs MockServer listener thread never terminates -- leaked OS threads per test
7 Medium live_tests.rs 300-second timeout in fixture provisioning is very long for CI

Details

[Medium] client.rs -- The RESPONSE_BODY_BUDGET and RESPONSE_DECODE_SLOTS are process-wide static semaphores. This means all FileSearchCalloutFilter instances in all pipelines share the same 512 MiB budget and 32 decode slots. Hot-reload creating a new pipeline does not release permits held by the old pipeline's in-flight requests. This is a reasonable design for a shared proxy process, but the tradeoff should be documented explicitly. A future multi-tenant deployment could hit surprising contention if two unrelated listeners each configure large max_total_response_bytes.

[Medium] client.rs:1952-1976 -- merge_top_results performs O(incoming * k) work per merge because it scans for the minimum-scored element on every candidate. For the current MAX_NUM_RESULTS=50 cap this is fine, but the comment says "without retaining more than the final top-k" which implies awareness of scalability. Consider a BinaryHeap<Reverse<...>> if the limit ever increases, or at least add a doc comment noting the quadratic bound is intentional given MAX_NUM_RESULTS.

[Medium] mod.rs:106-111 -- apply_batch returns Err(FilterAction::Reject(...)) to communicate a rejection through the call chain. Using Err for non-error control flow is unconventional and makes the return type confusing (Result<(), FilterAction> where both Ok and Err are valid outcomes). Consider returning Option<FilterAction> or a dedicated enum instead.

[Medium] citations.rs:571-573 -- annotate_response is marked #[expect(dead_code)] with reason "consumed when core continuation returns the final inference response". This is fine as a forward declaration, but the function is pub(crate) and shipped in the binary. If it truly cannot be tested until continuation lands, consider gating it behind #[cfg(test)] or removing the pub(crate) visibility until it has a caller.

[Medium] mod.rs:684-695 -- bounded_string_copy deliberately copies up to max_bytes + 4 bytes when the value exceeds the limit (to preserve a char boundary). The doc comment says "only enough of an oversized value for the client to reject it" but the actual behavior is that the copy exceeds max_bytes. Downstream code that trusts max_bytes as a hard ceiling (e.g., search_url checking store_id.len() > MAX_VECTOR_STORE_ID_BYTES) will still reject it, so this is safe in practice. But the naming and doc are misleading -- the function does not produce a bounded copy, it produces an intentionally-oversized copy. Rename or clarify the doc.

[Medium] tests.rs -- Each MockServer::start_with spawns an OS thread with std::thread::spawn that loops on listener.incoming(). Since the listener is never shut down, these threads leak for the duration of the test process. With 20+ async tests each creating a MockServer, this accumulates. Consider using a shutdown channel or Drop impl that closes the listener.

[Medium] live_tests.rs -- The OgxFixture::provision method has a 300-second timeout for client construction and a 300-second loop for wait_for_indexing. In CI, this means a broken OGX server will hang the job for 10 minutes. Consider a shorter deadline (e.g., 60s) or making it configurable via env var.

Positive observations

  • SSRF protection is thorough: rejects localhost, private/link-local/CGNAT/zero-network IPs, DNS hostnames, userinfo, malformed IPv6, query strings, fragments, and invalid ports.
  • API key handling uses SecretString + Zeroizing + sensitive header marking. Error paths never leak the key value.
  • Custom bounded DeserializeSeed deserialization with deadline checks is a strong defense against malicious OGX responses.
  • Citation marker extraction handles Unicode correctly (character offsets, not byte offsets).
  • The test suite covers failure modes, budget exhaustion, deadline enforcement, concurrent scheduling, and outbound input validation.
  • Integration test replaces ${VECTOR_STORE_API_KEY} without set_var (unsafe in Rust 2024).
  • Filter is properly registered in server/src/lib.rs and documented in docs/filters/.

Reviewed by praxis-bot

LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(GLOBAL_RESPONSE_BODY_BUDGET_UNITS)));

/// Fair admission for blocking decoders across filter instances.
static RESPONSE_DECODE_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(GLOBAL_RESPONSE_DECODE_SLOTS);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Medium] Process-wide static semaphores (RESPONSE_BODY_BUDGET at 512 MiB, RESPONSE_DECODE_SLOTS at 32) are shared across all filter instances and pipeline reloads. This is a reasonable design, but the sharing semantics should be called out explicitly in the module doc. A deployment with multiple listeners each configuring large max_total_response_bytes could hit surprising contention because the global budget is not partitioned.

*slot = candidate;
}
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Medium] merge_top_results uses O(incoming * k) linear-scan eviction to find the lowest-scored element on every candidate. Safe at the current MAX_NUM_RESULTS=50 cap, but worth a doc comment noting the quadratic bound is intentional. A BinaryHeap<Reverse<...>> would be needed if the limit ever grows.

state: &mut ResponsesState,
plan: &SearchPlan,
batch: &SearchBatch,
) -> Result<(), FilterAction> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Medium] apply_batch returns Err(FilterAction) for a non-error rejection path. Using Result<(), FilterAction> where the Err variant is a valid expected outcome (size-limit rejection) is unconventional. Consider Option<FilterAction> or a dedicated enum to make the control flow clearer.

#[expect(
dead_code,
reason = "consumed when core continuation returns the final inference response"
)]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Medium] annotate_response is #[expect(dead_code)] and pub(crate). If it cannot be called until continuation support lands, consider gating it behind #[cfg(test)] so the expectation is satisfied by test-only usage rather than a blanket dead-code suppression in production.

while !value.is_char_boundary(end) {
end = end.saturating_add(1).min(value.len());
}
value.get(..end).unwrap_or_default().to_owned()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Medium] bounded_string_copy intentionally returns a string exceeding max_bytes (up to +4 bytes for char boundary). The name "bounded" and the doc "only enough of an oversized value" are misleading. Downstream callers like search_url do reject the oversized result, so this is safe, but renaming to e.g. defensive_string_copy or adjusting the doc to say "slightly exceeds max_bytes" would prevent future confusion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Filter 10: file_search

2 participants