feat(openai): add OGX-backed file search callout#493
Conversation
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>
|
blocked on praxis-proxy/praxis#849 |
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
left a comment
There was a problem hiding this comment.
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
DeserializeSeeddeserialization 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}withoutset_var(unsafe in Rust 2024). - Filter is properly registered in
server/src/lib.rsand documented indocs/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); |
There was a problem hiding this comment.
[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; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
[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> { |
There was a problem hiding this comment.
[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" | ||
| )] |
There was a problem hiding this comment.
[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() |
There was a problem hiding this comment.
[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.
Summary
Adds the
openai_file_search_calloutfilter — a hosted executor for OpenAI Responses APIfile_search_calloutput items. When the model emits afile_search_call, this filter fans out OpenAI-compatible/v1/vector_stores/{id}/searchrequests 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
vector_store_ids × queries, capped at 64 specs with 8-way concurrencymax_num_resultsfile_id → filenamemappings stored inResponsesState.citation_filesfor downstream annotationon_error: reject(502, default) oron_error: ignore(log + continue with partial results)searchingandin_progressitems; terminal statuses are skippedincomplete(notcompleted)ranking_options.hybrid_searchrejected with 400 rather than silently degrading to vector-onlyArchitecture
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
apis/src/openai/responses/file_search_callout/mod.rsapis/src/openai/responses/file_search_callout/client.rsapis/src/openai/responses/file_search_callout/config.rsapis/src/openai/responses/file_search_callout/citations.rsapis/src/openai/responses/file_search_callout/tests.rsapis/src/openai/responses/file_search_callout/live_tests.rsexamples/configs/openai/responses/file-search-callout.yamldocs/filters/openai_file_search_callout.mdTest plan
cargo test -p praxis-ai-apis -- file_search_callout)cargo test -p praxis-ai-integration -- file_search_callout)make lintclean (clippy + nightly fmt + separator lint + filter doc lint)make buildandmake docpassKnown limitations
CalloutClientbuffers the full response before the size check runs; requires a core crate change to enforce during streamingResponsesStatewithoutput_items; not yet wired for non-continuation creates