diff --git a/apis/src/openai/responses/file_search_callout/mod.rs b/apis/src/openai/responses/file_search_callout/mod.rs index 131fa8049a..664463f9f8 100644 --- a/apis/src/openai/responses/file_search_callout/mod.rs +++ b/apis/src/openai/responses/file_search_callout/mod.rs @@ -719,6 +719,7 @@ fn continuation_state_fits( for value in [ state.context_management.as_ref(), state.conversation.as_ref(), + state.original_tool_choice.as_ref(), state.previous_usage.as_ref(), ] .into_iter() @@ -735,6 +736,7 @@ fn continuation_state_fits( .map(|(key, value)| key.len().saturating_add(value.len())) .chain(state.include.iter().map(String::len)) .chain(state.previous_response_id.iter().map(String::len)) + .chain(state.response_id.iter().map(String::len)) .chain( state .mcp_tool_map @@ -825,7 +827,8 @@ fn mixed_tool_response_rejection() -> FilterAction { /// Allow the model to answer after satisfying the first forced search call. fn reset_tool_choice(state: &mut ResponsesState) { - state.tool_choice = Value::String("auto".to_owned()); + let original = std::mem::replace(&mut state.tool_choice, Value::String("auto".to_owned())); + state.original_tool_choice.get_or_insert(original); if let Some(request) = state.request_body.as_object_mut() { request.remove("tool_choice"); } diff --git a/apis/src/openai/responses/file_search_callout/tests.rs b/apis/src/openai/responses/file_search_callout/tests.rs index 310d9ecd7c..6bb6cb8c62 100644 --- a/apis/src/openai/responses/file_search_callout/tests.rs +++ b/apis/src/openai/responses/file_search_callout/tests.rs @@ -769,6 +769,7 @@ async fn forced_tool_choice_resets_after_search_execution() { )); let state = ctx.extensions.get::().unwrap(); assert_eq!(state.tool_choice, "auto"); + assert_eq!(state.original_tool_choice, Some(json!({"type":"file_search"}))); assert!(state.request_body.get("tool_choice").is_none()); } diff --git a/apis/src/openai/responses/rehydrate/mod.rs b/apis/src/openai/responses/rehydrate/mod.rs index 44c6184f78..fa90e2472f 100644 --- a/apis/src/openai/responses/rehydrate/mod.rs +++ b/apis/src/openai/responses/rehydrate/mod.rs @@ -139,7 +139,8 @@ impl RehydrateFilter { }; let previous_tools = collect_mcp_tool_listings(&record); let previous_usage = record.response_object.get("usage").filter(|u| !u.is_null()).cloned(); - let state = build_state(parsed_body, stored, previous_tools, previous_usage); + let mut state = build_state(parsed_body, stored, previous_tools, previous_usage); + state.response_id = ctx.get_metadata("responses.response_id").map(ToOwned::to_owned); write_previous_usage_metadata(ctx, state.previous_usage.as_ref()); ctx.extensions.insert(state); debug!(previous_response_id = %prev_id, "previous response validated, state populated"); @@ -176,7 +177,8 @@ impl RehydrateFilter { Ok(s) => s, Err(action) => return Ok(action), }; - let state = build_state(parsed_body, stored, vec![], None); + let mut state = build_state(parsed_body, stored, vec![], None); + state.response_id = ctx.get_metadata("responses.response_id").map(ToOwned::to_owned); write_previous_usage_metadata(ctx, state.previous_usage.as_ref()); ctx.extensions.insert(state); debug!(conversation_id = %conv_id, "conversation rehydrated, state populated"); diff --git a/apis/src/openai/responses/rehydrate/tests.rs b/apis/src/openai/responses/rehydrate/tests.rs index 0f1ced32c6..33d32d6c42 100644 --- a/apis/src/openai/responses/rehydrate/tests.rs +++ b/apis/src/openai/responses/rehydrate/tests.rs @@ -181,6 +181,7 @@ async fn validates_previous_response_and_sets_metadata() { let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); ctx.set_metadata("openai_responses_format.format", "openai_responses"); + ctx.set_metadata("responses.response_id", "resp_current"); let original = r#"{"model":"gpt-4.1","input":"What next?","previous_response_id":"resp_prev"}"#; let mut body = Some(Bytes::from(original)); @@ -216,6 +217,7 @@ async fn validates_previous_response_and_sets_metadata() { state.messages[2]["content"], "What next?", "current input should be last" ); + assert_eq!(state.response_id.as_deref(), Some("resp_current")); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1351,6 +1353,7 @@ async fn rehydrates_from_conversation_string_id() { let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); ctx.set_metadata("openai_responses_format.format", "openai_responses"); + ctx.set_metadata("responses.response_id", "resp_conversation"); let mut body = Some(Bytes::from( r#"{"model":"gpt-4.1","input":"turn two","conversation":"conv_abc"}"#, )); @@ -1379,6 +1382,7 @@ async fn rehydrates_from_conversation_string_id() { 3, "persisted_messages should mirror messages for conversation rehydration" ); + assert_eq!(state.response_id.as_deref(), Some("resp_conversation")); } #[tokio::test] diff --git a/apis/src/openai/responses/responses_to_chat_completions/mod.rs b/apis/src/openai/responses/responses_to_chat_completions/mod.rs index ace067c6af..e3871698bb 100644 --- a/apis/src/openai/responses/responses_to_chat_completions/mod.rs +++ b/apis/src/openai/responses/responses_to_chat_completions/mod.rs @@ -282,7 +282,12 @@ impl HttpFilter for ResponsesToChatCompletionsFilter { ctx.request_headers_to_remove.push(http::header::ACCEPT_ENCODING); *body = Some(Bytes::from(serialized)); ctx.set_metadata(ARMED_KEY, "true"); - ctx.set_metadata(CREATED_AT_KEY, ctx.time_source.now().as_secs().to_string()); + let now = ctx.time_source.now().as_secs(); + let created_at = ctx + .extensions + .get_mut::() + .map_or(now, |state| *state.response_created_at.get_or_insert(now)); + ctx.set_metadata(CREATED_AT_KEY, created_at.to_string()); Ok(FilterAction::Continue) } @@ -299,6 +304,14 @@ fn request_disposition(ctx: &HttpFilterContext<'_>) -> Option { trace!(format, "releasing request classified as a different API format"); Some(FilterAction::Release) }, + None if ctx + .extensions + .get::() + .is_some_and(|state| state.response_id.is_some()) => + { + trace!("using canonical Responses state across an iterative router metadata boundary"); + None + }, None => { warn!( prerequisite = "openai_responses_format", @@ -346,8 +359,16 @@ fn ensure_previous_response_rehydrated(state: &ResponsesState, streaming: bool) /// Return the client stream preference captured by the classifier. fn request_is_streaming(ctx: &HttpFilterContext<'_>) -> bool { - ctx.get_metadata("openai_responses_format.stream") - .is_some_and(|value| value == "true") + ctx.get_metadata("openai_responses_format.stream").map_or_else( + || { + ctx.extensions + .get::() + .and_then(|state| state.request_body.get("stream")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + }, + |value| value == "true", + ) } /// Detect an SSE media type while response headers are still available. @@ -444,20 +465,25 @@ fn prepare_transformed_response_headers(ctx: &mut HttpFilterContext<'_>) { /// Convert a finite successful Chat response into a Responses resource. fn translate_success_response(ctx: &HttpFilterContext<'_>, body: &[u8]) -> Result { + let state = ctx + .extensions + .get::() + .ok_or_else(|| -> FilterError { "responses_to_chat_completions: missing Responses state".into() })?; let response_id = ctx .get_metadata("responses.response_id") + .or(state.response_id.as_deref()) .ok_or_else(|| -> FilterError { "responses_to_chat_completions: missing response id".into() })?; let created_at = ctx .get_metadata(CREATED_AT_KEY) .and_then(|value| value.parse::().ok()) + .or(state.response_created_at) .ok_or_else(|| -> FilterError { "responses_to_chat_completions: missing creation timestamp".into() })?; - let state = ctx - .extensions - .get::() - .ok_or_else(|| -> FilterError { "responses_to_chat_completions: missing Responses state".into() })?; - let response_context = + let mut response_context = ResponseContext::from_responses_request(&state.request_body, response_id.to_owned(), created_at) .with_completed_at(ctx.time_source.now().as_secs()); + if let Some(tool_choice) = state.original_tool_choice.as_ref() { + response_context.tool_choice = Some(tool_choice); + } let provider_response: serde_json::Value = serde_json::from_slice(body) .map_err(|error| -> FilterError { format!("responses_to_chat_completions: {error}").into() })?; let translated = chat_response_to_response_resource(&provider_response, &response_context) diff --git a/apis/src/openai/responses/responses_to_chat_completions/tests.rs b/apis/src/openai/responses/responses_to_chat_completions/tests.rs index 7b549ceff1..ec259b1f0e 100644 --- a/apis/src/openai/responses/responses_to_chat_completions/tests.rs +++ b/apis/src/openai/responses/responses_to_chat_completions/tests.rs @@ -157,6 +157,48 @@ async fn classified_responses_create_without_state_fails_closed() { assert_server_error(action); } +#[tokio::test] +async fn canonical_state_translates_across_iterative_metadata_boundary() { + let filter = ResponsesToChatCompletionsFilter::from_config(&serde_yaml::Value::Null).unwrap(); + let request = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); + let mut context = crate::test_utils::make_filter_context(&request); + let mut state = ResponsesState::from_request_body(json!({ + "model": "gpt-4.1-mini", + "input": "hello", + "stream": false + })); + state.response_id = Some("resp_iterative".to_owned()); + context.extensions.insert(state); + let mut body = Some(Bytes::from_static( + br#"{"model":"gpt-4.1-mini","input":"hello","stream":false}"#, + )); + + let action = filter.on_request_body(&mut context, &mut body, true).await.unwrap(); + + assert!(matches!(action, FilterAction::Continue)); + let translated: serde_json::Value = serde_json::from_slice(body.as_deref().unwrap()).unwrap(); + assert_eq!(translated["messages"][0]["content"], "hello"); + assert_eq!(translated["stream"], false); + assert_eq!(context.get_metadata(ARMED_KEY), Some("true")); +} + +#[tokio::test] +async fn unvalidated_state_does_not_bypass_missing_classifier_metadata() { + let filter = ResponsesToChatCompletionsFilter::from_config(&serde_yaml::Value::Null).unwrap(); + let request = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); + let mut context = crate::test_utils::make_filter_context(&request); + context.extensions.insert(ResponsesState::from_request_body(json!({ + "model": "gpt-4.1-mini", + "input": "hello" + }))); + let mut body = Some(Bytes::from_static(br#"{"model":"gpt-4.1-mini","input":"hello"}"#)); + + let action = filter.on_request_body(&mut context, &mut body, true).await.unwrap(); + + assert_server_error(action); + assert!(context.get_metadata(ARMED_KEY).is_none()); +} + #[tokio::test] async fn unresolved_previous_response_id_fails_closed() { let filter = ResponsesToChatCompletionsFilter::from_config(&serde_yaml::Value::Null).unwrap(); @@ -324,6 +366,13 @@ async fn canonical_state_is_translated_and_arms_response() { ); assert_eq!(context.get_metadata(ARMED_KEY), Some("true")); assert_eq!(context.get_metadata(CREATED_AT_KEY), Some("1700000000")); + assert_eq!( + context + .extensions + .get::() + .and_then(|state| state.response_created_at), + Some(1_700_000_000) + ); } #[tokio::test] @@ -773,6 +822,57 @@ async fn non_streaming_chat_response_becomes_response_resource() { assert_eq!(translated["usage"]["output_tokens"], 2); } +#[tokio::test] +async fn chat_file_search_function_call_becomes_responses_function_call() { + let filter = ResponsesToChatCompletionsFilter::from_config(&serde_yaml::Value::Null).unwrap(); + let request = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); + let fixed_time = FixedTimeSource::new(Duration::from_secs(1_700_000_000)); + let mut context = crate::test_utils::make_filter_context(&request); + context.time_source = &fixed_time; + let request_value = json!({ + "model": "chat-only-model", + "input": "find revenue", + "stream": false, + "store": false, + "tools": [{"type": "file_search", "vector_store_ids": ["vs_q4"]}], + "tool_choice": {"type": "file_search"} + }); + let mut state = ResponsesState::from_request_body(request_value); + state.response_id = Some("resp_file_search".to_owned()); + context.extensions.insert(state); + let mut request_body = Some(Bytes::from_static( + br#"{"model":"chat-only-model","input":"find revenue"}"#, + )); + let request_action = filter + .on_request_body(&mut context, &mut request_body, true) + .await + .unwrap(); + assert!(matches!(request_action, FilterAction::Continue)); + + let response = Box::leak(Box::new(crate::test_utils::make_response())); + response.headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + context.response_header = Some(response); + let response_action = filter.on_response(&mut context).await.unwrap(); + assert!(matches!(response_action, FilterAction::Continue)); + context.response_header = None; + let mut response_body = Some(Bytes::from_static( + br#"{"id":"chatcmpl_search","object":"chat.completion","model":"chat-only-model","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_search","type":"function","function":{"name":"file_search","arguments":"{\"query\":\"Q4 revenue\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}"#, + )); + + let body_action = filter.on_response_body(&mut context, &mut response_body, true).unwrap(); + + assert!(matches!(body_action, FilterAction::Continue)); + let translated: serde_json::Value = serde_json::from_slice(response_body.as_deref().unwrap()).unwrap(); + assert_eq!(translated["id"], "resp_file_search"); + assert_eq!(translated["tools"][0]["type"], "file_search"); + assert_eq!(translated["output"][0]["type"], "function_call"); + assert_eq!(translated["output"][0]["name"], "file_search"); + assert_eq!(translated["output"][0]["arguments"], "{\"query\":\"Q4 revenue\"}"); +} + #[tokio::test] async fn malformed_success_aborts_after_headers_are_sent() { let yaml = serde_yaml::from_str("{}").unwrap(); diff --git a/apis/src/openai/responses/state.rs b/apis/src/openai/responses/state.rs index e541c0fb47..08b4b058cc 100644 --- a/apis/src/openai/responses/state.rs +++ b/apis/src/openai/responses/state.rs @@ -124,6 +124,19 @@ pub(crate) struct ResponsesState { /// Parsed request body as received from the client. pub request_body: serde_json::Value, + /// Original client tool choice retained when continuation widens the + /// provider-visible choice to `auto`. + pub original_tool_choice: Option, + + /// Stable creation timestamp for the public response across iterations. + pub response_created_at: Option, + + /// Stable public response ID assigned by request validation. + /// + /// Stored with canonical state because iterative router steps preserve + /// extensions while resetting per-step metadata. + pub response_id: Option, + /// Whether provider-visible request fields require outbound serialization. pub request_body_rebuild: RequestBodyRebuild, @@ -200,6 +213,9 @@ impl Default for ResponsesState { previous_tools: Vec::new(), previous_usage: None, request_body: serde_json::Value::Null, + original_tool_choice: None, + response_created_at: None, + response_id: None, request_body_rebuild: RequestBodyRebuild::PreserveOriginal, response_object: serde_json::Value::Null, tool_calls: Vec::new(), diff --git a/apis/src/openai/responses/validate/mod.rs b/apis/src/openai/responses/validate/mod.rs index 29218f23a3..8ca44129cd 100644 --- a/apis/src/openai/responses/validate/mod.rs +++ b/apis/src/openai/responses/validate/mod.rs @@ -121,7 +121,7 @@ impl HttpFilter for OpenaiResponsesValidateFilter { let conversation_id = resolve_conversation_id(ctx, &parsed); enrich_context(ctx, &response_id, &conversation_id); - ctx.extensions.insert(ResponsesState::from_request_body(parsed)); + insert_responses_state(ctx, parsed, &response_id); debug!( response_id = %response_id, @@ -137,6 +137,13 @@ impl HttpFilter for OpenaiResponsesValidateFilter { // Helpers // ----------------------------------------------------------------------------- +/// Initialize canonical request state, including metadata that survives IRR steps. +fn insert_responses_state(ctx: &mut HttpFilterContext<'_>, parsed: serde_json::Value, response_id: &str) { + let mut state = ResponsesState::from_request_body(parsed); + state.response_id = Some(response_id.to_owned()); + ctx.extensions.insert(state); +} + /// Parse the request body as JSON. fn parse_request_body(ctx: &HttpFilterContext<'_>, body: &Option) -> Result { let streaming = ctx @@ -317,6 +324,11 @@ mod tests { assert_eq!(state.tools.len(), 1, "tools should be populated"); assert_eq!(state.iteration, 0, "iteration should start at 0"); assert!(state.tool_calls.is_empty(), "tool_calls should start empty"); + assert_eq!( + state.response_id.as_deref(), + ctx.filter_metadata.get("responses.response_id").map(String::as_str), + "canonical state should retain the public ID across iterative metadata boundaries" + ); } #[tokio::test] diff --git a/apis/src/openai/translation/chat_completions.rs b/apis/src/openai/translation/chat_completions.rs index 0351cc5eef..1e75e1d479 100644 --- a/apis/src/openai/translation/chat_completions.rs +++ b/apis/src/openai/translation/chat_completions.rs @@ -23,6 +23,22 @@ const DEFAULT_TOOL_CHOICE: &str = "auto"; /// Default text format for translated responses. const DEFAULT_TEXT_FORMAT: &str = "text"; +/// Maximum query length advertised by the synthesized file-search function. +/// +/// The executor also applies a byte limit before issuing a vector-store +/// request, so multi-byte input remains bounded at the callout boundary. +const FILE_SEARCH_QUERY_MAX_LENGTH: usize = 65_536; + +/// Maximum number of vector stores a single hosted file-search tool may target. +/// +/// `openai_file_search_callout` issues an upstream vector-store query per id on +/// every inference round, so an unbounded array would amplify one inbound +/// request into many outbound searches. The OpenAI API currently caps this at +/// 1; a small generous bound keeps proxy fan-out finite without enforcing the +/// exact backend range. Keep the rejection message below in sync with this +/// value. +const MAX_VECTOR_STORE_IDS: usize = 10; + /// Build the default `Responses` text configuration. fn default_text_config() -> Value { json!({"format": {"type": DEFAULT_TEXT_FORMAT}}) @@ -188,6 +204,12 @@ pub(crate) enum TranslationError { /// A Responses tool choice has no Chat Completions-compatible representation. #[error("unsupported Responses tool_choice type for Chat Completions translation: {0}")] UnsupportedToolChoiceType(String), + /// A client function would be indistinguishable from synthesized file search. + #[error("Responses function tool name `file_search` conflicts with the synthesized file_search function")] + FileSearchFunctionNameCollision, + /// A file-search definition cannot be executed by the local callout. + #[error("invalid Responses file_search tool for Chat Completions translation: {0}")] + InvalidFileSearchTool(&'static str), } /// Borrowed canonical request fields that supersede their original request values. @@ -242,9 +264,11 @@ fn translate_responses_request(request: &Value, overrides: RequestOverrides<'_>) let tools = overrides .tools .or_else(|| obj.get("tools").and_then(Value::as_array).map(Vec::as_slice)); - if let Some(tools) = tools - && let Some(tools) = build_chat_tools(tools)? - { + let BuiltChatTools { + value: built_tools, + has_file_search, + } = tools.map(build_chat_tools).transpose()?.unwrap_or_default(); + if let Some(tools) = built_tools { chat.insert("tools".to_owned(), tools); chat.remove("response_format"); } @@ -254,7 +278,7 @@ fn translate_responses_request(request: &Value, overrides: RequestOverrides<'_>) && overrides .tool_choice .is_some_and(|choice| choice.as_str() == Some("auto")); - if !omit_synthesized_default && let Some(tool_choice) = build_chat_tool_choice(tool_choice)? { + if !omit_synthesized_default && let Some(tool_choice) = build_chat_tool_choice(tool_choice, has_file_search)? { chat.insert("tool_choice".to_owned(), tool_choice); } @@ -664,24 +688,152 @@ fn convert_input_file_part(part: &Value) -> Result { })) } +/// Chat tool translation plus facts needed to validate `tool_choice`. +#[derive(Default)] +struct BuiltChatTools { + /// Translated Chat Completions tools, omitted when empty. + value: Option, + /// Whether the request declared a valid hosted file-search tool. + has_file_search: bool, +} + /// Build Chat Completions tool definitions from `Responses` tools. -fn build_chat_tools(tools: &[Value]) -> Result, TranslationError> { +fn build_chat_tools(tools: &[Value]) -> Result { + validate_file_search_tools(tools)?; + let mut chat_tools = Vec::new(); + let mut has_file_search = false; for tool in tools { let Some(tool_obj) = tool.as_object() else { continue; }; - if tool_obj.get("type").and_then(Value::as_str) == Some("function") { - chat_tools.push(convert_function_tool(tool_obj)); - } else { - let tool_type = tool_obj.get("type").and_then(Value::as_str).unwrap_or("unknown"); - return Err(TranslationError::UnsupportedToolType(tool_type.to_owned())); + match tool_obj.get("type").and_then(Value::as_str) { + Some("function") => chat_tools.push(convert_function_tool(tool_obj)), + Some("file_search") => { + chat_tools.push(synthesized_file_search_tool()); + has_file_search = true; + }, + Some(tool_type) => return Err(TranslationError::UnsupportedToolType(tool_type.to_owned())), + None => return Err(TranslationError::UnsupportedToolType("unknown".to_owned())), + } + } + + Ok(BuiltChatTools { + value: (!chat_tools.is_empty()).then_some(Value::Array(chat_tools)), + has_file_search, + }) +} + +/// Reject ambiguous or structurally unusable file-search declarations. +fn validate_file_search_tools(tools: &[Value]) -> Result<(), TranslationError> { + let mut file_search_count = 0_usize; + let mut has_file_search_function = false; + + for tool in tools.iter().filter_map(Value::as_object) { + match tool.get("type").and_then(Value::as_str) { + Some("function") if function_tool_name(tool) == Some("file_search") => { + has_file_search_function = true; + }, + Some("file_search") => { + file_search_count = file_search_count.saturating_add(1); + validate_file_search_tool(tool)?; + }, + _ => {}, } } - Ok((!chat_tools.is_empty()).then_some(Value::Array(chat_tools))) + if file_search_count > 1 { + return Err(TranslationError::InvalidFileSearchTool( + "only one file_search tool may be declared", + )); + } + if file_search_count == 1 && has_file_search_function { + return Err(TranslationError::FileSearchFunctionNameCollision); + } + + Ok(()) +} + +/// Return a function name from either Responses or pre-wrapped Chat shape. +fn function_tool_name(tool: &Map) -> Option<&str> { + tool.get("name") + .and_then(Value::as_str) + .or_else(|| tool.get("function")?.get("name")?.as_str()) +} + +/// Validate fields required later by `openai_file_search_callout`. +fn validate_file_search_tool(tool: &Map) -> Result<(), TranslationError> { + validate_vector_store_ids(tool)?; + + if tool + .get("max_num_results") + .is_some_and(|value| !matches!(value.as_u64(), Some(1..=50))) + { + return Err(TranslationError::InvalidFileSearchTool( + "max_num_results must be an integer between 1 and 50", + )); + } + if tool + .get("filters") + .is_some_and(|value| !value.is_null() && !value.is_object()) + { + return Err(TranslationError::InvalidFileSearchTool( + "filters must be an object or null", + )); + } + if tool.get("ranking_options").is_some_and(|value| !value.is_object()) { + return Err(TranslationError::InvalidFileSearchTool( + "ranking_options must be an object", + )); + } + + Ok(()) +} + +/// Validate the vector stores that the callout will search. +fn validate_vector_store_ids(tool: &Map) -> Result<(), TranslationError> { + const ERROR: TranslationError = + TranslationError::InvalidFileSearchTool("vector_store_ids must be a non-empty array of non-empty strings"); + let vector_store_ids = tool.get("vector_store_ids").and_then(Value::as_array).ok_or(ERROR)?; + if vector_store_ids.is_empty() + || vector_store_ids + .iter() + .any(|value| value.as_str().is_none_or(str::is_empty)) + { + return Err(ERROR); + } + if vector_store_ids.len() > MAX_VECTOR_STORE_IDS { + return Err(TranslationError::InvalidFileSearchTool( + "vector_store_ids must contain at most 10 entries", + )); + } + Ok(()) +} + +/// Build the private Chat Completions representation of hosted file search. +fn synthesized_file_search_tool() -> Value { + json!({ + "type": "function", + "function": { + "name": "file_search", + "description": "Search the configured vector stores for relevant files.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "minLength": 1, + "maxLength": FILE_SEARCH_QUERY_MAX_LENGTH + } + }, + "required": ["query"], + "additionalProperties": false + }, + "strict": true + } + }) } /// Convert a `Responses` function tool to the Chat Completions nested shape. @@ -703,30 +855,40 @@ fn convert_function_tool(tool: &Map) -> Value { } /// Convert Responses `tool_choice` into Chat Completions-compatible shape. -fn build_chat_tool_choice(choice: Option<&Value>) -> Result, TranslationError> { +fn build_chat_tool_choice(choice: Option<&Value>, has_file_search: bool) -> Result, TranslationError> { let Some(choice) = choice else { return Ok(None); }; - let tool_choice = match choice { - Value::String(_) => Some(choice.clone()), - Value::Object(choice_obj) => match choice_obj.get("type").and_then(Value::as_str) { - Some("function") => { - let mut function = Map::new(); - copy_field(choice_obj, &mut function, "name"); - Some(json!({"type": "function", "function": Value::Object(function)})) - }, - Some(other) => return Err(TranslationError::UnsupportedToolChoiceType(other.to_owned())), - None => return Err(TranslationError::UnsupportedToolChoiceType("unknown".to_owned())), + match choice { + Value::String(_) => Ok(Some(choice.clone())), + Value::Object(choice_obj) => build_object_tool_choice(choice_obj, has_file_search).map(Some), + _ => Err(TranslationError::UnsupportedToolChoiceType( + json_type_name(choice).to_owned(), + )), + } +} + +/// Convert an object-form Responses tool choice. +fn build_object_tool_choice(choice: &Map, has_file_search: bool) -> Result { + match choice.get("type").and_then(Value::as_str) { + Some("function") if has_file_search && choice.get("name").and_then(Value::as_str) == Some("file_search") => { + Err(TranslationError::InvalidFileSearchTool( + "tool_choice for hosted file_search must use type file_search", + )) }, - _ => { - return Err(TranslationError::UnsupportedToolChoiceType( - json_type_name(choice).to_owned(), - )); + Some("function") => { + let mut function = Map::new(); + copy_field(choice, &mut function, "name"); + Ok(json!({"type": "function", "function": Value::Object(function)})) }, - }; - - Ok(tool_choice) + Some("file_search") if has_file_search => Ok(json!({"type": "function", "function": {"name": "file_search"}})), + Some("file_search") => Err(TranslationError::InvalidFileSearchTool( + "tool_choice requires a declared file_search tool", + )), + Some(other) => Err(TranslationError::UnsupportedToolChoiceType(other.to_owned())), + None => Err(TranslationError::UnsupportedToolChoiceType("unknown".to_owned())), + } } /// Return a stable JSON type name for diagnostics. diff --git a/apis/src/openai/translation/mod.rs b/apis/src/openai/translation/mod.rs index 08fcb26980..a2ea2f2eb8 100644 --- a/apis/src/openai/translation/mod.rs +++ b/apis/src/openai/translation/mod.rs @@ -181,23 +181,201 @@ mod tests { } #[test] - fn non_function_responses_tools_are_rejected() { + fn unsupported_hosted_responses_tools_are_rejected() { let only_unsupported = map_error(&json!({ "model": "gpt-4o-mini", "input": "hello", - "tools": [{"type": "code_interpreter"}, {"type": "file_search"}] + "tools": [{"type": "code_interpreter"}] })); let mixed = map_error(&json!({ "model": "gpt-4o-mini", "input": "hello", "tools": [ - {"type": "file_search"}, + {"type": "web_search"}, {"type": "function", "name": "lookup_weather", "parameters": {"type": "object"}} ] })); assert!(only_unsupported.contains("code_interpreter")); - assert!(mixed.contains("file_search")); + assert!(mixed.contains("web_search")); + } + + #[test] + fn file_search_tool_maps_to_bounded_private_function() { + let mapped = map(&json!({ + "model": "gpt-4o-mini", + "input": "find the quarterly results", + "tools": [{ + "type": "file_search", + "vector_store_ids": ["vs_q4"], + "max_num_results": 8, + "filters": {"type": "eq", "key": "year", "value": 2026}, + "ranking_options": {"ranker": "auto", "score_threshold": 0.2} + }] + })); + + assert_eq!(mapped["tools"].as_array().map(Vec::len), Some(1)); + let function = &mapped["tools"][0]["function"]; + assert_eq!(function["name"], "file_search"); + assert_eq!(function["strict"], true); + assert_eq!(function["parameters"]["type"], "object"); + assert_eq!(function["parameters"]["required"], json!(["query"])); + assert_eq!(function["parameters"]["properties"]["query"]["type"], "string"); + assert_eq!(function["parameters"]["properties"]["query"]["minLength"], 1); + assert_eq!(function["parameters"]["properties"]["query"]["maxLength"], 65_536); + assert_eq!(function["parameters"]["additionalProperties"], false); + assert!( + mapped["tools"][0].get("vector_store_ids").is_none(), + "hosted-tool configuration must not leak into the Chat function" + ); + } + + #[test] + fn file_search_tool_choice_maps_to_forced_function() { + let mapped = map(&json!({ + "model": "gpt-4o-mini", + "input": "find the report", + "tools": [{"type": "file_search", "vector_store_ids": ["vs_reports"]}], + "tool_choice": {"type": "file_search"} + })); + + assert_eq!( + mapped["tool_choice"], + json!({"type": "function", "function": {"name": "file_search"}}) + ); + } + + #[test] + fn client_function_choice_cannot_target_synthesized_file_search() { + let error = map_error(&json!({ + "model": "gpt-4o-mini", + "input": "find the report", + "tools": [{"type": "file_search", "vector_store_ids": ["vs_reports"]}], + "tool_choice": {"type": "function", "name": "file_search"} + })); + + assert_eq!( + error, + "invalid Responses file_search tool for Chat Completions translation: tool_choice for hosted file_search must use type file_search" + ); + } + + #[test] + fn file_search_function_name_collisions_are_rejected() { + for function in [ + json!({"type": "function", "name": "file_search", "parameters": {"type": "object"}}), + json!({ + "type": "function", + "function": {"name": "file_search", "parameters": {"type": "object"}} + }), + ] { + let error = map_error(&json!({ + "model": "gpt-4o-mini", + "input": "find the report", + "tools": [ + {"type": "file_search", "vector_store_ids": ["vs_reports"]}, + function + ] + })); + + assert_eq!( + error, + "Responses function tool name `file_search` conflicts with the synthesized file_search function" + ); + } + } + + #[test] + fn client_file_search_function_without_hosted_tool_is_preserved() { + let mapped = map(&json!({ + "model": "gpt-4o-mini", + "input": "call my function", + "tools": [{ + "type": "function", + "name": "file_search", + "parameters": {"type": "object"} + }] + })); + + assert_eq!(mapped["tools"][0]["function"]["name"], "file_search"); + } + + #[test] + fn malformed_file_search_definitions_are_rejected() { + let malformed = [ + json!({"type": "file_search"}), + json!({"type": "file_search", "vector_store_ids": []}), + json!({"type": "file_search", "vector_store_ids": [""]}), + json!({"type": "file_search", "vector_store_ids": [42]}), + json!({"type": "file_search", "vector_store_ids": ["vs"], "max_num_results": 0}), + json!({"type": "file_search", "vector_store_ids": ["vs"], "max_num_results": 51}), + json!({"type": "file_search", "vector_store_ids": ["vs"], "filters": []}), + json!({"type": "file_search", "vector_store_ids": ["vs"], "ranking_options": "auto"}), + ]; + + for tool in malformed { + let error = map_error(&json!({"model": "m", "input": "hello", "tools": [tool]})); + assert!( + error.starts_with("invalid Responses file_search tool for Chat Completions translation:"), + "unexpected error: {error}" + ); + } + } + + #[test] + fn excessive_vector_store_ids_are_rejected() { + // One past the maximum (10) amplifies fan-out and is rejected... + let too_many: Vec = (0..11).map(|i| format!("vs_{i}")).collect(); + let error = map_error(&json!({ + "model": "m", + "input": "hello", + "tools": [{"type": "file_search", "vector_store_ids": too_many}] + })); + assert_eq!( + error, + "invalid Responses file_search tool for Chat Completions translation: \ + vector_store_ids must contain at most 10 entries" + ); + + // ...but the maximum count itself is accepted. + let at_limit: Vec = (0..10).map(|i| format!("vs_{i}")).collect(); + let mapped = map(&json!({ + "model": "m", + "input": "hello", + "tools": [{"type": "file_search", "vector_store_ids": at_limit}] + })); + assert_eq!(mapped["tools"].as_array().map(Vec::len), Some(1)); + } + + #[test] + fn duplicate_file_search_definitions_are_rejected() { + let error = map_error(&json!({ + "model": "m", + "input": "hello", + "tools": [ + {"type": "file_search", "vector_store_ids": ["vs_a"]}, + {"type": "file_search", "vector_store_ids": ["vs_b"]} + ] + })); + + assert_eq!( + error, + "invalid Responses file_search tool for Chat Completions translation: only one file_search tool may be declared" + ); + } + + #[test] + fn file_search_choice_without_definition_is_rejected() { + let error = map_error(&json!({ + "model": "m", + "input": "hello", + "tool_choice": {"type": "file_search"} + })); + + assert_eq!( + error, + "invalid Responses file_search tool for Chat Completions translation: tool_choice requires a declared file_search tool" + ); } #[test] diff --git a/examples/README.md b/examples/README.md index af9e5ed707..53ff139f9e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -76,6 +76,8 @@ before sending requests. | [doc-extract.yaml](configs/openai/responses/doc-extract.yaml) | Converts `input_file` content parts to `input_text` for inference backends that do not natively support `input_file` (e.g. vLLM, llm-d) | | [file-resolve.yaml](configs/openai/responses/file-resolve.yaml) | Resolves `file_id` and `file_url` references in Responses API input by fetching file metadata and content, then inlining base64 content as `file_data` or `image_url` before forwarding | | [file-search-callout.yaml](configs/openai/responses/file-search-callout.yaml) | Demonstrates the `openai_file_search_callout` filter configuration | +| [file-search-chat-completions-fixture.yaml](configs/openai/responses/file-search-chat-completions-fixture.yaml) | Single-upstream fixture configuration for recording the private Chat Completions function representation of a Responses file_search tool | +| [file-search-chat-completions.yaml](configs/openai/responses/file-search-chat-completions.yaml) | Accepts finite OpenAI Responses requests with hosted file search while targeting a backend that only implements /v1/chat/completions | | [format-routing.yaml](configs/openai/responses/format-routing.yaml) | Routes AI API traffic by detected body format | | [full-flow-agentic.yaml](configs/openai/responses/full-flow-agentic.yaml) | Extends the full-flow pipeline with an iterative_request_router (IRR) around the inference step, enabling server-side file search execution | | [full-flow.yaml](configs/openai/responses/full-flow.yaml) | Combines conversations, format classification, request validation, file resolution, and backend routing into a single pipeline | diff --git a/examples/configs/openai/responses/file-search-chat-completions-fixture.yaml b/examples/configs/openai/responses/file-search-chat-completions-fixture.yaml new file mode 100644 index 0000000000..252509ddc7 --- /dev/null +++ b/examples/configs/openai/responses/file-search-chat-completions-fixture.yaml @@ -0,0 +1,50 @@ +# Responses File Search Translation Fixture +# +# Single-upstream fixture configuration for recording the private Chat +# Completions function representation of a Responses file_search tool. The +# complete model-search-model lifecycle is demonstrated by +# file-search-chat-completions.yaml; this variant intentionally omits the +# vector-store callout because inference replay owns exactly one upstream. + +listeners: + - name: responses-file-search-chat-fixture + address: "127.0.0.1:8080" + filter_chains: [responses-file-search-chat-fixture] + +filter_chains: + - name: responses-file-search-chat-fixture + filters: + - filter: openai_responses_format + on_invalid: reject + headers: + format: x-praxis-ai-format + model: x-praxis-ai-model + stream: x-praxis-ai-stream + + - filter: openai_responses_validate + + - filter: responses_to_chat_completions + max_rewritten_body_bytes: 67108864 + + - filter: path_rewrite + replace: + pattern: "^/v1/responses/?$" + replacement: "/v1/chat/completions" + conditions: + - when: + path_prefix: "/v1/responses" + methods: [POST] + + - filter: router + routes: + - path: "/v1/chat/completions" + cluster: "chat-completions-backend" + + - filter: load_balancer + clusters: + - name: "chat-completions-backend" + endpoints: + - "127.0.0.1:3001" + +insecure_options: + allow_private_endpoints: true # fixture proxies to a local controlled backend diff --git a/examples/configs/openai/responses/file-search-chat-completions.yaml b/examples/configs/openai/responses/file-search-chat-completions.yaml new file mode 100644 index 0000000000..aac1856475 --- /dev/null +++ b/examples/configs/openai/responses/file-search-chat-completions.yaml @@ -0,0 +1,101 @@ +# Responses File Search with a Chat Completions Backend +# +# Accepts finite OpenAI Responses requests with hosted file search while +# targeting a backend that only implements /v1/chat/completions. The +# compatibility filter privately exposes file_search to that backend as a +# function tool, then the existing file-search callout executes the returned +# function call and drives one more finite inference round. +# +# Request order: +# 1. openai_responses_format classifies the client request. +# 2. openai_responses_validate creates canonical ResponsesState. +# 3. iterative_request_router owns the finite model-search-model loop. +# 4. openai_file_search_callout executes pending hosted search calls. +# 5. responses_to_chat_completions synthesizes the private function tool. +# 6. path_rewrite selects /v1/chat/completions explicitly. +# +# Response filters run in reverse order. Each Chat Completions response is +# therefore converted into a Responses resource before +# openai_file_search_callout inspects it. A returned +# function_call(name="file_search") is normalized to file_search_call, +# executed through the vector store, and retained in the client-visible final +# Responses output. +# +# This pipeline is finite only. Streaming file-search orchestration is not +# supported. + +listeners: + - name: responses-file-search-chat-gateway + address: "127.0.0.1:8080" + filter_chains: [responses-file-search-chat] + +filter_chains: + - name: responses-file-search-chat + filters: + - filter: openai_responses_format + on_invalid: reject + headers: + format: x-praxis-ai-format + model: x-praxis-ai-model + stream: x-praxis-ai-stream + + - filter: openai_responses_validate + + - filter: iterative_request_router + initial_step: inference + max_iterations: 8 + timeout_ms: 120000 + step_timeout_ms: 60000 + max_response_bytes: 67108864 + max_state_bytes: 136314880 + steps: + - name: inference + filters: + - filter: openai_file_search_callout + vector_store_url: http://127.0.0.1:8001 + allow_private_url: true + timeout_ms: 5000 + max_response_bytes: 10485760 + max_total_response_bytes: 67108864 + max_state_bytes: 136314880 + on_failure: closed + forward_headers: + - authorization + + - filter: responses_to_chat_completions + max_rewritten_body_bytes: 67108864 + + - filter: path_rewrite + replace: + pattern: "^/v1/responses/?$" + replacement: "/v1/chat/completions" + conditions: + - when: + path_prefix: "/v1/responses" + methods: [POST] + + - filter: headers + request_set: + - name: Content-Type + value: application/json + + - filter: router + routes: + - path: "/v1/chat/completions" + cluster: "chat-completions-backend" + + - filter: load_balancer + clusters: + - name: "chat-completions-backend" + endpoints: + - "127.0.0.1:3001" + on_result: + - filter: openai_file_search_callout + key: pending + value: "true" + next: inference + - default: true + done: true + +insecure_options: + allow_private_endpoints: true # example proxies to local backends diff --git a/tests/integration/fixtures/inference/README.md b/tests/integration/fixtures/inference/README.md index fab33d5433..4104b0aaae 100644 --- a/tests/integration/fixtures/inference/README.md +++ b/tests/integration/fixtures/inference/README.md @@ -19,7 +19,7 @@ than editing the table. -The manifest declares **15 features** across **5 scopes**, linked to **13 scenarios**. +The manifest declares **16 features** across **5 scopes**, linked to **14 scenarios**. | Scope | Feature | Status | Scenarios | Provider coverage | | --- | --- | --- | --- | --- | @@ -36,6 +36,7 @@ The manifest declares **15 features** across **5 scopes**, linked to **13 scenar | `responses_native_passthrough` | `responses.native.tool_call` | `live_covered` | `responses/native-tool-call` | `openai`: `live_covered`
`vllm`: `live_covered` | | `responses_to_chat_completions` | `responses.chat.request` | `synthetic_only` | `responses/chat-basic-nonstream` | `synthetic`: `synthetic_only` | | `responses_to_chat_completions` | `responses.chat.response.text` | `synthetic_only` | `responses/chat-basic-nonstream` | `synthetic`: `synthetic_only` | +| `responses_to_chat_completions` | `responses.chat.file_search` | `synthetic_only` | `responses/chat-file-search` | `synthetic`: `synthetic_only` | | `responses_agentic_loop` | `responses.agentic.parallel_tool_calls` | `synthetic_only` | `responses/agentic-parallel-tool-calls` | `synthetic`: `synthetic_only` | | `responses_to_chat_completions` | `responses.chat.continuation` | `synthetic_only` | `responses/chat-basic-nonstream` | `synthetic`: `synthetic_only` | diff --git a/tests/integration/fixtures/inference/coverage.yaml b/tests/integration/fixtures/inference/coverage.yaml index 616b137480..400beeee1a 100644 --- a/tests/integration/fixtures/inference/coverage.yaml +++ b/tests/integration/fixtures/inference/coverage.yaml @@ -141,6 +141,15 @@ features: providers: synthetic: status: synthetic_only + - id: responses.chat.file_search + scopes: + - responses_to_chat_completions + status: synthetic_only + scenarios: + - responses/chat-file-search + providers: + synthetic: + status: synthetic_only - id: responses.agentic.parallel_tool_calls scopes: - responses_agentic_loop diff --git a/tests/integration/fixtures/inference/recordings/synthetic/responses/chat-file-search.json b/tests/integration/fixtures/inference/recordings/synthetic/responses/chat-file-search.json new file mode 100644 index 0000000000..2cdd9b2d95 --- /dev/null +++ b/tests/integration/fixtures/inference/recordings/synthetic/responses/chat-file-search.json @@ -0,0 +1,225 @@ +{ + "version": 1, + "scenario_id": "responses/chat-file-search", + "protocol": "openai_responses", + "provenance": { + "kind": "synthetic", + "provider": "synthetic", + "model": "synthetic-chat-file-search", + "source_id": "controlled-chat-file-search-translation" + }, + "normalization": { + "version": 1, + "linked_ids": { + "msg_resp_5a7ec9b6f1282b58e466000000000000": "msg_recorded_0002", + "resp_5a7ec9b6f1282b58e466000000000000": "resp_recorded_0001" + } + }, + "turns": [ + { + "name": "initial", + "client": { + "request": { + "method": "POST", + "path": "/v1/responses", + "headers": { + "content-type": [ + "application/json" + ] + }, + "body": { + "kind": "json", + "value": { + "input": "Find the Q4 revenue.", + "model": "synthetic-chat-file-search", + "store": false, + "stream": false, + "tool_choice": { + "type": "file_search" + }, + "tools": [ + { + "max_num_results": 5, + "type": "file_search", + "vector_store_ids": [ + "vs_q4" + ] + } + ] + } + } + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json" + ] + }, + "body": { + "kind": "json", + "value": { + "background": false, + "completed_at": 0, + "created_at": "1970-01-01T00:00:00Z", + "error": null, + "frequency_penalty": 0.0, + "id": "resp_recorded_0001", + "incomplete_details": null, + "input": "Find the Q4 revenue.", + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "metadata": {}, + "model": "synthetic-chat-file-search", + "object": "response", + "output": [ + { + "content": [ + { + "annotations": [], + "logprobs": [], + "text": "Q4 revenue was 42 million.", + "type": "output_text" + } + ], + "id": "msg_recorded_0002", + "role": "assistant", + "status": "completed", + "type": "message" + } + ], + "parallel_tool_calls": true, + "presence_penalty": 0.0, + "previous_response_id": null, + "prompt_cache_key": null, + "reasoning": null, + "safety_identifier": null, + "service_tier": "default", + "status": "completed", + "store": false, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": { + "type": "file_search" + }, + "tools": [ + { + "max_num_results": 5, + "type": "file_search", + "vector_store_ids": [ + "vs_q4" + ] + } + ], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 12, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 6, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 18 + } + } + } + } + }, + "upstream": { + "request": { + "method": "POST", + "path": "/v1/chat/completions", + "headers": { + "content-type": [ + "application/json" + ] + }, + "body": { + "kind": "json", + "value": { + "messages": [ + { + "content": "Find the Q4 revenue.", + "role": "user" + } + ], + "model": "synthetic-chat-file-search", + "stream": false, + "tool_choice": { + "function": { + "name": "file_search" + }, + "type": "function" + }, + "tools": [ + { + "function": { + "description": "Search the configured vector stores for relevant files.", + "name": "file_search", + "parameters": { + "additionalProperties": false, + "properties": { + "query": { + "maxLength": 65536, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "strict": true + }, + "type": "function" + } + ] + } + } + }, + "response": { + "status": 200, + "headers": { + "content-type": [ + "application/json" + ] + }, + "body": { + "kind": "json", + "value": { + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Q4 revenue was 42 million.", + "role": "assistant" + } + } + ], + "created": 0, + "id": "chatcmpl_final", + "model": "synthetic-chat-file-search", + "object": "chat.completion", + "usage": { + "completion_tokens": 6, + "prompt_tokens": 12, + "total_tokens": 18 + } + } + } + } + } + } + ] +} diff --git a/tests/integration/fixtures/inference/scenarios/responses/chat-file-search.yaml b/tests/integration/fixtures/inference/scenarios/responses/chat-file-search.yaml new file mode 100644 index 0000000000..08f78ad96c --- /dev/null +++ b/tests/integration/fixtures/inference/scenarios/responses/chat-file-search.yaml @@ -0,0 +1,37 @@ +version: 1 +id: responses/chat-file-search +description: Hosted Responses file search is synthesized as a bounded Chat Completions function tool. +protocol: openai_responses +example_config: openai/responses/file-search-chat-completions-fixture.yaml +upstream_authority: 127.0.0.1:3001 +features: + - responses.chat.file_search +turns: + - name: initial + request: + method: POST + path: /v1/responses + headers: + content-type: + - application/json + body: + kind: json + value: + model: ${MODEL} + input: Find the Q4 revenue. + store: false + stream: false + tools: + - type: file_search + vector_store_ids: + - vs_q4 + max_num_results: 5 + tool_choice: + type: file_search + expect: + client_status: 200 + client_body_kind: json + upstream_path: /v1/chat/completions + upstream_body_kind: json + client_sse_events: [] + upstream_sse_events: [] diff --git a/tests/integration/sdk/openai/test_openai_responses_vllm.py b/tests/integration/sdk/openai/test_openai_responses_vllm.py index 1b09ed8520..38d6124833 100644 --- a/tests/integration/sdk/openai/test_openai_responses_vllm.py +++ b/tests/integration/sdk/openai/test_openai_responses_vllm.py @@ -1041,5 +1041,151 @@ def test_file_search_with( ) +# --------------------------------------------------------------------------- +# File search via Chat Completions translation (issue #296) +# --------------------------------------------------------------------------- + +FILE_SEARCH_CHAT_CONFIG_PATH = ( + "examples/configs/openai/responses/file-search-chat-completions.yaml" +) + + +def _write_file_search_chat_config(praxis_port: int) -> str: + """Patch the shipped file-search-chat-completions example for testing. + + Exercises the real example config (per repo test requirements) while + retargeting the vector-store callout at OGX and the model backend at + vLLM's /v1/chat/completions endpoint. + """ + with open(FILE_SEARCH_CHAT_CONFIG_PATH) as f: + config = f.read() + + config = config.replace("127.0.0.1:8080", f"127.0.0.1:{praxis_port}") + config = config.replace("127.0.0.1:3001", _vllm_endpoint()) + config = config.replace("127.0.0.1:8001", _ogx_endpoint()) + + fd, path = tempfile.mkstemp(suffix=".yaml") + with os.fdopen(fd, "w") as f: + f.write(config) + return path + + +@pytest.fixture(scope="session") +def file_search_chat_proxy(tmp_path_factory, request): + """Start a Praxis proxy with the file-search Chat Completions pipeline.""" + port = _free_port() + config_path = _write_file_search_chat_config(port) + binary = _find_binary() + + log_dir = tmp_path_factory.mktemp("file-search-chat") + log_path = str(log_dir / "praxis.log") + log_file = open(log_path, "w") + started = False + + proc = subprocess.Popen( + [binary, "-c", config_path], + stdout=log_file, + stderr=subprocess.STDOUT, + ) + try: + _wait_for_proxy(port, proc, log_path) + started = True + yield port + finally: + proc.send_signal(signal.SIGINT) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + log_file.close() + if not started or request.session.testsfailed > 0: + with open(log_path) as f: + print( + f"\n=== File search (chat) proxy logs ===\n{f.read()}", + file=sys.stderr, + ) + os.unlink(config_path) + + +@pytest.fixture(scope="session") +def file_search_chat_client(file_search_chat_proxy): + """Return an OpenAI client pointed at the file-search chat proxy.""" + return OpenAI( + base_url=f"http://127.0.0.1:{file_search_chat_proxy}/v1", + api_key="test", + max_retries=0, + timeout=300, + ) + + +class TestFileSearchChatCompletionsVLLM: + """Issue #296: hosted file_search against a Chat Completions backend. + + Unlike TestFileSearchVLLM (which proxies vLLM's native /v1/responses), + this drives responses_to_chat_completions: the native file_search tool + is synthesized into a private chat `function`, vLLM's + /v1/chat/completions emits the call, the proxy runs the OGX vector-store + search, and drives one more finite inference round -- without ever + exposing the private function to the client. + """ + + def test_file_search_translated_to_chat_function_round_trip( + self, file_search_chat_client, vector_store + ): + store_id, marker = vector_store + response = file_search_chat_client.responses.create( + model=VLLM_MODEL, + input=( + "You MUST use the file_search tool to find the Praxis marker " + "in the indexed report. Do not answer from memory. /no_think" + ), + tools=[ + { + "type": "file_search", + "vector_store_ids": [store_id], + } + ], + include=["file_search_call.results"], + store=False, + max_output_tokens=512, + ) + + assert response.status in ("completed", "incomplete"), ( + f"response should reach a terminal status; got {response.status}" + ) + + output_types = [item.type for item in response.output] + + # The synthesized private function must never leak to the client; it + # is normalized back to a hosted file_search_call. + assert all(t != "function_call" for t in output_types), ( + "the private file_search function must not surface as a client " + f"function_call; got output types: {output_types}" + ) + + file_search_items = [ + item for item in response.output if item.type == "file_search_call" + ] + assert file_search_items, ( + "the synthesized file_search function call should be normalized " + f"back to a file_search_call; got output types: {output_types}" + ) + for item in file_search_items: + assert item.status in ("completed", "incomplete"), ( + f"file_search_call status should be terminal; got: {item.status}" + ) + + # Results come from OGX deterministically (not the model), so the + # indexed marker must round-trip through the model->search->model flow. + # If a future OGX result shape omits content text, relax this to + # asserting file_search results are simply non-empty. + payload = json.dumps(response.model_dump(), default=str) + assert marker in payload, ( + "OGX search results (via include=file_search_call.results) should " + f"contain the indexed marker {marker!r}; got: {payload}" + ) + + if __name__ == "__main__": sys.exit(pytest.main([__file__, "-v"] + sys.argv[1:])) diff --git a/tests/integration/tests/suite/examples/file_search_chat_completions.rs b/tests/integration/tests/suite/examples/file_search_chat_completions.rs new file mode 100644 index 0000000000..1b3ebd26b3 --- /dev/null +++ b/tests/integration/tests/suite/examples/file_search_chat_completions.rs @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +//! Functional tests for hosted file search through a Chat Completions backend. + +use std::collections::HashMap; + +use praxis_core::config::Config; +use praxis_test_utils::{ + StatefulCapturingBackend, example_config_path, free_port, http_send, json_post, parse_body, parse_status, + patch_yaml, start_capturing_backend, start_proxy, +}; +use serde_json::{Value, json}; + +const EXAMPLE: &str = "openai/responses/file-search-chat-completions.yaml"; + +fn load_test_config(listener_port: u16, port_map: &HashMap<&str, u16>) -> Config { + let yaml = std::fs::read_to_string(example_config_path(EXAMPLE)).expect("example config should exist"); + let patched = patch_yaml(&yaml, listener_port, port_map); + Config::from_yaml(&patched).expect("patched config should parse") +} + +#[test] +fn file_search_chat_example_runs_model_search_model_round_trip() { + let first_model_response = json!({ + "id": "chatcmpl_search", + "object": "chat.completion", + "model": "chat-only-model", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_search", + "type": "function", + "function": { + "name": "file_search", + "arguments": "{\"query\":\"What were the Q4 results?\"}" + } + }] + }, + "finish_reason": "tool_calls" + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + }); + let final_model_response = json!({ + "id": "chatcmpl_final", + "object": "chat.completion", + "model": "chat-only-model", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "Q4 revenue was $42 million <|file-q4|>" + }, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 20, "completion_tokens": 7, "total_tokens": 27} + }); + let model = StatefulCapturingBackend::new(vec![ + (200, first_model_response.to_string()), + (200, final_model_response.to_string()), + ]) + .start_with_shutdown(); + let search = start_capturing_backend( + &json!({ + "data": [{ + "file_id": "file-q4", + "filename": "q4-results.txt", + "score": 0.99, + "content": [{"type": "text", "text": "Q4 revenue was $42 million."}], + "attributes": null + }] + }) + .to_string(), + ); + let proxy_port = free_port(); + let config = load_test_config( + proxy_port, + &HashMap::from([("127.0.0.1:3001", model.port()), ("127.0.0.1:8001", search.port())]), + ); + let proxy = start_proxy(&config); + let file_search_tool = json!({ + "type": "file_search", + "vector_store_ids": ["vs_q4"], + "max_num_results": 5, + "ranking_options": {"ranker": "auto", "score_threshold": 0.2} + }); + let request = json!({ + "model": "chat-only-model", + "input": "What do the uploaded documents say about Q4 results?", + "include": ["file_search_call.results"], + "tools": [file_search_tool.clone()], + "tool_choice": {"type": "file_search"}, + "stream": false, + "store": false + }); + + let raw = http_send(proxy.addr(), &json_post("/v1/responses", &request.to_string())); + + assert_eq!(parse_status(&raw), 200, "round trip failed: {raw}"); + let response: Value = serde_json::from_str(&parse_body(&raw)).expect("final response should be JSON"); + assert_eq!(response["object"], "response"); + assert_eq!(response["tools"], json!([file_search_tool])); + assert_eq!(response["tool_choice"], json!({"type": "file_search"})); + assert_eq!(response["output"][0]["type"], "file_search_call"); + assert_eq!(response["output"][0]["status"], "completed"); + assert_eq!(response["output"][0]["results"][0]["file_id"], "file-q4"); + assert_eq!(response["output"][1]["type"], "message"); + assert_eq!( + response["output"][1]["content"][0]["text"], + "Q4 revenue was $42 million" + ); + assert_eq!( + response["output"][1]["content"][0]["annotations"][0]["file_id"], + "file-q4" + ); + assert_eq!( + response["usage"], + json!({ + "input_tokens": 30, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": 12, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 42 + }) + ); + + let model_requests = model.requests(); + assert_eq!(model_requests.len(), 2, "file search should drive two model calls"); + for captured in &model_requests { + assert_eq!(captured.uri, "/v1/chat/completions"); + } + let first_forwarded: Value = + serde_json::from_str(&model_requests[0].body).expect("first model request should be JSON"); + assert_eq!(first_forwarded["tools"][0]["type"], "function"); + assert_eq!(first_forwarded["tools"][0]["function"]["name"], "file_search"); + assert_eq!( + first_forwarded["tools"][0]["function"]["parameters"]["required"], + json!(["query"]) + ); + assert_eq!( + first_forwarded["tools"][0]["function"]["parameters"]["properties"]["query"]["minLength"], + 1 + ); + assert_eq!( + first_forwarded["tool_choice"], + json!({"type": "function", "function": {"name": "file_search"}}) + ); + assert!( + first_forwarded["tools"][0].get("vector_store_ids").is_none(), + "hosted configuration must remain private to ResponsesState" + ); + + let second_forwarded: Value = + serde_json::from_str(&model_requests[1].body).expect("second model request should be JSON"); + assert_eq!(second_forwarded["tool_choice"], "auto"); + assert!(second_forwarded["messages"].as_array().is_some_and(|messages| { + messages.iter().any(|message| { + message["tool_calls"][0]["function"]["name"] == "file_search" + && message["tool_calls"][0]["function"]["arguments"] == "{\"query\":\"What were the Q4 results?\"}" + }) + })); + assert!(second_forwarded["messages"].as_array().is_some_and(|messages| { + messages + .iter() + .any(|message| message["role"] == "tool" && message["tool_call_id"].as_str().is_some()) + })); + + let search_request: Value = serde_json::from_str(&search.body()).expect("search request should be JSON"); + assert_eq!(search_request["query"], "What were the Q4 results?"); + assert_eq!(search_request["max_num_results"], 5); + assert_eq!(search_request["rewrite_query"], false); +} + +#[test] +fn file_search_chat_example_rejects_function_name_collision_before_forwarding() { + let backend = StatefulCapturingBackend::new(vec![(200, r#"{"id":"unexpected"}"#.to_owned())]).start_with_shutdown(); + let proxy_port = free_port(); + let config = load_test_config(proxy_port, &HashMap::from([("127.0.0.1:3001", backend.port())])); + let proxy = start_proxy(&config); + let request = json!({ + "model": "chat-only-model", + "input": "search", + "tools": [ + {"type": "file_search", "vector_store_ids": ["vs_q4"]}, + {"type": "function", "name": "file_search", "parameters": {"type": "object"}} + ] + }); + + let raw = http_send(proxy.addr(), &json_post("/v1/responses", &request.to_string())); + + assert_eq!( + parse_status(&raw), + 400, + "collision should fail before forwarding: {raw}" + ); + assert!(parse_body(&raw).contains("conflicts with the synthesized file_search function")); + assert!( + backend.requests().is_empty(), + "collision must not reach the model backend" + ); +} + +#[test] +fn file_search_chat_example_rejects_malformed_configuration_before_forwarding() { + let backend = StatefulCapturingBackend::new(vec![(200, r#"{"id":"unexpected"}"#.to_owned())]).start_with_shutdown(); + let proxy_port = free_port(); + let config = load_test_config(proxy_port, &HashMap::from([("127.0.0.1:3001", backend.port())])); + let proxy = start_proxy(&config); + let request = json!({ + "model": "chat-only-model", + "input": "search", + "tools": [{"type": "file_search", "vector_store_ids": []}] + }); + + let raw = http_send(proxy.addr(), &json_post("/v1/responses", &request.to_string())); + + assert_eq!( + parse_status(&raw), + 400, + "malformed tool should fail before forwarding: {raw}" + ); + assert!(parse_body(&raw).contains("vector_store_ids must be a non-empty array")); + assert!( + backend.requests().is_empty(), + "malformed configuration must not reach the model backend" + ); +} diff --git a/tests/integration/tests/suite/examples/mod.rs b/tests/integration/tests/suite/examples/mod.rs index 9ca9c759da..63e66ac248 100644 --- a/tests/integration/tests/suite/examples/mod.rs +++ b/tests/integration/tests/suite/examples/mod.rs @@ -16,6 +16,7 @@ mod azure_ad; mod compact; mod credential_injection; mod file_search_callout; +mod file_search_chat_completions; mod full_flow; mod full_flow_agentic; #[cfg(feature = "gcp-adc-filter")] diff --git a/tests/utils/src/inference_fixture/coverage.rs b/tests/utils/src/inference_fixture/coverage.rs index ff364e9ce2..13ce7c6daa 100644 --- a/tests/utils/src/inference_fixture/coverage.rs +++ b/tests/utils/src/inference_fixture/coverage.rs @@ -1237,6 +1237,7 @@ mod tests { vec!["responses_native_passthrough"], vec!["responses_to_chat_completions"], vec!["responses_to_chat_completions"], + vec!["responses_to_chat_completions"], vec!["responses_agentic_loop"], vec!["responses_to_chat_completions"], ] @@ -1263,11 +1264,12 @@ mod tests { CoverageStatus::SyntheticOnly, CoverageStatus::SyntheticOnly, CoverageStatus::SyntheticOnly, + CoverageStatus::SyntheticOnly, ] ); - assert_eq!(report.features_total, 15); - assert_eq!(report.scenarios_total, 13); - assert_eq!(report.recordings_total, 18); + assert_eq!(report.features_total, 16); + assert_eq!(report.scenarios_total, 14); + assert_eq!(report.recordings_total, 19); assert_eq!( scenarios.keys().collect::>(), vec![ @@ -1281,12 +1283,13 @@ mod tests { "messages/upstream-error", "responses/agentic-parallel-tool-calls", "responses/chat-basic-nonstream", + "responses/chat-file-search", "responses/native-basic-nonstream", "responses/native-basic-stream", "responses/native-tool-call", ] ); - assert_eq!(manifest.features.len(), 15); + assert_eq!(manifest.features.len(), 16); assert_eq!(manifest.version, 1); assert_eq!( manifest @@ -1367,6 +1370,10 @@ mod tests { &"responses.chat.response.text".to_owned(), &vec!["responses/chat-basic-nonstream".to_owned()] ), + ( + &"responses.chat.file_search".to_owned(), + &vec!["responses/chat-file-search".to_owned()] + ), ( &"responses.agentic.parallel_tool_calls".to_owned(), &vec!["responses/agentic-parallel-tool-calls".to_owned()] diff --git a/xtask/src/lint_example_tests.rs b/xtask/src/lint_example_tests.rs index a31b6cd294..04de7b1409 100644 --- a/xtask/src/lint_example_tests.rs +++ b/xtask/src/lint_example_tests.rs @@ -29,6 +29,7 @@ const SKIP: &[&str] = &[ "model-to-header-routing.yaml", "openai/conversations/conversations.yaml", "openai/responses/agentic-loop-fixture.yaml", + "openai/responses/file-search-chat-completions-fixture.yaml", "openai/responses/format-routing.yaml", "openai/responses/full-flow.yaml", "openai/responses/model-rewrite.yaml",