diff --git a/apis/src/openai/responses/compact/mod.rs b/apis/src/openai/responses/compact/mod.rs index c857ec7d1..08e3464c9 100644 --- a/apis/src/openai/responses/compact/mod.rs +++ b/apis/src/openai/responses/compact/mod.rs @@ -8,7 +8,9 @@ //! this filter summarizes the conversation history via a sub-request //! to an inference backend, replacing it with a single compaction //! item. Runs after `rehydrate` (which populates messages and -//! previous usage) and after `openai_tool_parse`. +//! previous usage) and after `openai_tool_parse`. Place +//! `openai_file_resolve` and `openai_doc_extract` before compact so +//! rewritten current-turn content is what compaction preserves. //! //! # Scope //! @@ -33,6 +35,7 @@ pub(super) mod config; clippy::expect_used, clippy::indexing_slicing, clippy::panic, + clippy::too_many_lines, reason = "tests" )] mod tests; @@ -513,18 +516,32 @@ fn build_compaction_item(id: &str, summary: &str) -> Value { /// Replace conversation history with the compaction item. /// /// After replacement: -/// - `state.messages` = `[compaction_item, ...state.input]` -/// - `state.persisted_messages` = `[compaction_item, ...state.input]` +/// - `state.messages` = `[compaction_item, ...current_turn]` +/// - `state.persisted_messages` = `[compaction_item, ...current_turn]` /// /// The compaction item is `{"type": "compaction", "encrypted_content": ""}`. -/// `state.input` holds the current request's input items (unchanged -/// by rehydrate), so the current turn's messages are preserved. +/// The current turn is the tail of each message list whose length +/// matches `state.input`. File resolution and document extraction +/// rewrite that tail in place and leave `state.input` as the original +/// client payload, so compaction must not rebuild from `state.input`. fn replace_messages(state: &mut ResponsesState, compaction_item: Value) { - let mut new_messages = Vec::with_capacity(state.input.len() + 1); - new_messages.push(compaction_item); - new_messages.extend(state.input.iter().cloned()); - state.persisted_messages = new_messages.clone(); - state.messages = new_messages; + let input_len = state.input.len(); + let message_tail = split_current_turn(&mut state.messages, input_len); + let persisted_tail = split_current_turn(&mut state.persisted_messages, input_len); + + state.messages.clear(); + state.messages.push(compaction_item.clone()); + state.messages.extend(message_tail); + + state.persisted_messages.clear(); + state.persisted_messages.push(compaction_item); + state.persisted_messages.extend(persisted_tail); +} + +/// Move the current-turn tail off `items`, leaving history behind to drop. +fn split_current_turn(items: &mut Vec, input_len: usize) -> Vec { + let start = items.len().saturating_sub(input_len); + items.split_off(start) } /// Format a message array as readable text for the summarization prompt. diff --git a/apis/src/openai/responses/compact/tests.rs b/apis/src/openai/responses/compact/tests.rs index 65ac05995..b53435884 100644 --- a/apis/src/openai/responses/compact/tests.rs +++ b/apis/src/openai/responses/compact/tests.rs @@ -375,8 +375,140 @@ fn replace_messages_preserves_current_input() { assert_eq!(state.messages[0]["type"], "compaction"); assert_eq!(state.messages[0]["id"], "compact_test"); assert!(state.messages[0].get("encrypted_content").is_some()); + assert_eq!( + state.messages[1]["content"], "What's next?", + "current-turn tail from messages must be kept" + ); assert_eq!(state.persisted_messages.len(), 2); assert_eq!(state.persisted_messages[0]["type"], "compaction"); + assert_eq!( + state.persisted_messages[1]["content"], "What's next?", + "current-turn tail from persisted_messages must be kept" + ); +} + +#[test] +fn replace_messages_keeps_each_list_current_turn_independently() { + let mut state = ResponsesState::from_request_body(json!({ + "model": "gpt-4o", + "input": [{"type": "message", "role": "user", "content": "from-input"}] + })); + state.messages = vec![ + json!({"role": "user", "content": "hist-a"}), + json!({"role": "user", "content": "from-messages"}), + ]; + state.persisted_messages = vec![ + json!({"role": "user", "content": "hist-b1"}), + json!({"role": "user", "content": "hist-b2"}), + json!({"role": "user", "content": "from-persisted"}), + ]; + + replace_messages(&mut state, build_compaction_item("c1", "sum")); + + assert_eq!(state.messages.len(), 2); + assert_eq!(state.messages[1]["content"], "from-messages"); + assert_eq!(state.persisted_messages.len(), 2); + assert_eq!(state.persisted_messages[1]["content"], "from-persisted"); + assert_eq!( + state.input[0]["content"], "from-input", + "state.input must not be used to rebuild the current turn" + ); +} + +#[test] +fn compaction_preserves_resolved_file_data_instead_of_file_url() { + const FILE_URL: &str = "https://files.internal/secret.bin"; + let mut state = ResponsesState::from_request_body(json!({ + "model": "gpt-4o", + "previous_response_id": "resp_prev", + "context_management": [{"type": "compaction", "compact_threshold": 0}], + "input": [{ + "type": "message", + "role": "user", + "content": [{"type": "input_file", "file_url": FILE_URL}] + }] + })); + state.history_rehydrated = true; + let history = json!({"role": "user", "content": "earlier turn long enough to compact"}); + state.messages.insert(0, history.clone()); + state.persisted_messages.insert(0, history); + + let resolved_item = json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_file", "file_data": "SGVsbG8="}] + }); + state.request_body["input"] = json!([resolved_item.clone()]); + let tail = state.messages.len() - state.input.len(); + state.messages[tail] = resolved_item.clone(); + state.persisted_messages[tail] = resolved_item; + + assert_eq!( + state.input[0]["content"][0]["file_url"], FILE_URL, + "state.input stays the original client payload" + ); + + replace_messages(&mut state, build_compaction_item("compact_1", "summary")); + + assert_eq!(state.messages[0]["type"], "compaction"); + let current = &state.messages[1]; + assert_eq!( + current["content"][0]["file_data"], "SGVsbG8=", + "resolved file_data must survive compaction" + ); + assert!( + current["content"][0].get("file_url").is_none(), + "original file_url must not be restored from state.input" + ); + assert_eq!( + state.persisted_messages[1]["content"][0]["file_data"], "SGVsbG8=", + "persisted current-turn tail must keep resolved file_data" + ); + assert_eq!( + state.input[0]["content"][0]["file_url"], FILE_URL, + "state.input remains the unmodified client payload" + ); +} + +#[test] +fn compaction_preserves_extracted_input_text_instead_of_input_file() { + let mut state = ResponsesState::from_request_body(json!({ + "model": "gpt-4o", + "previous_response_id": "resp_prev", + "context_management": [{"type": "compaction", "compact_threshold": 0}], + "input": [{ + "type": "message", + "role": "user", + "content": [{"type": "input_file", "filename": "notes.txt", "file_data": "c2VjcmV0"}] + }] + })); + state.history_rehydrated = true; + let history = json!({"role": "user", "content": "earlier turn"}); + state.messages.insert(0, history.clone()); + state.persisted_messages.insert(0, history); + + let extracted_item = json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "secret"}] + }); + state.request_body["input"] = json!([extracted_item.clone()]); + let tail = state.messages.len() - state.input.len(); + state.messages[tail] = extracted_item.clone(); + state.persisted_messages[tail] = extracted_item; + + replace_messages(&mut state, build_compaction_item("compact_1", "summary")); + + let current = &state.messages[1]; + assert_eq!( + current["content"][0]["type"], "input_text", + "doc_extract rewrite must survive compaction" + ); + assert_eq!(current["content"][0]["text"], "secret"); + assert_eq!( + state.input[0]["content"][0]["type"], "input_file", + "state.input stays the original input_file part" + ); } // ============================================================================= diff --git a/apis/src/openai/responses/openai_responses_proxy/tests.rs b/apis/src/openai/responses/openai_responses_proxy/tests.rs index 93bb12c40..b01b2d4e1 100644 --- a/apis/src/openai/responses/openai_responses_proxy/tests.rs +++ b/apis/src/openai/responses/openai_responses_proxy/tests.rs @@ -562,6 +562,55 @@ fn messages_for_backend_mixed_items() { assert_eq!(result[1]["role"], "user"); } +#[tokio::test] +async fn compacted_outbound_serializes_resolved_file_data_not_file_url() { + const FILE_URL: &str = "https://files.internal/secret.bin"; + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + let request_body = json!({ + "model": "gpt-4o", + "previous_response_id": "resp_prev", + "input": [{ + "type": "message", + "role": "user", + "content": [{"type": "input_file", "file_url": FILE_URL}] + }] + }); + let mut state = ResponsesState::from_request_body(request_body); + state.history_rehydrated = true; + let encoded = base64::engine::general_purpose::STANDARD.encode("summary"); + state.messages = vec![ + json!({"type": "compaction", "id": "c_1", "encrypted_content": encoded}), + json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_file", "file_data": "SGVsbG8="}] + }), + ]; + ctx.extensions.insert(state); + let mut body = Some(Bytes::from_static( + br#"{"model":"gpt-4o","input":[{"type":"message","role":"user","content":[{"type":"input_file","file_url":"https://files.internal/secret.bin"}]}],"previous_response_id":"resp_prev"}"#, + )); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + assert!( + matches!(action, FilterAction::Continue), + "compaction rewrite should continue with the resolved current-turn body" + ); + + let outbound: serde_json::Value = serde_json::from_slice(body.as_ref().unwrap()).unwrap(); + let outbound_text = outbound.to_string(); + assert!( + !outbound_text.contains(FILE_URL), + "proxy must not serialize the unresolved file_url after compaction" + ); + assert_eq!( + outbound["input"][1]["content"][0]["file_data"], "SGVsbG8=", + "proxy must serialize the resolved current-turn file_data" + ); +} + #[test] fn compaction_to_assistant_message_decodes_encrypted_content() { let encoded = base64::engine::general_purpose::STANDARD.encode("decoded summary"); diff --git a/examples/README.md b/examples/README.md index d414c7605..21130f368 100644 --- a/examples/README.md +++ b/examples/README.md @@ -71,7 +71,7 @@ before sending requests. | [prompts-routing.yaml](configs/openai/prompts/prompts-routing.yaml) | Routes OpenAI Prompts API requests to a dedicated Prompts API backend | | [agentic-loop-fixture.yaml](configs/openai/responses/agentic-loop-fixture.yaml) | Minimal agentic loop pipeline for inference fixture replay | | [agentic-loop.yaml](configs/openai/responses/agentic-loop.yaml) | Demonstrates the openai_agentic_loop filter with iterative_request_router for step-based model-tool-model looping in the Responses API | -| [compact.yaml](configs/openai/responses/compact.yaml) | Demonstrates the compaction flow: store a response, rehydrate it on the next turn, and count tokens to check if compaction is needed | +| [compact.yaml](configs/openai/responses/compact.yaml) | Demonstrates compaction after rehydrate, file resolve, and document extract so rewritten current-turn content survives history replacement | | [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 | diff --git a/examples/configs/openai/responses/compact.yaml b/examples/configs/openai/responses/compact.yaml index 7c930ae40..d1fccf376 100644 --- a/examples/configs/openai/responses/compact.yaml +++ b/examples/configs/openai/responses/compact.yaml @@ -1,7 +1,19 @@ # Compact Filter Example # -# Demonstrates the compaction flow: store a response, rehydrate it -# on the next turn, and count tokens to check if compaction is needed. +# Demonstrates compaction after rehydrate, file resolve, and document +# extract so rewritten current-turn content survives history +# replacement. Store a response, rehydrate it on the next turn, and +# summarize when the token count exceeds the compact threshold. +# +# Pipeline order is required: file_resolve and doc_extract rewrite the +# current-turn tail in place and leave `state.input` as the original +# client payload. Compact preserves that rewritten tail rather than +# restoring `file_url` or `input_file`. +# +# Security: StreamBuffer body callouts run before this listener's +# header-phase filters. Deploy this example behind an outer +# authentication and authorization boundary before enabling the +# required allow_pre_security_callout acknowledgement below. # # Security: StreamBuffer body callouts run before this listener's # header-phase filters. Deploy this example behind an outer @@ -24,6 +36,11 @@ # curl -s -X POST http://localhost:8080/v1/responses \ # -H "Content-Type: application/json" \ # -d "{\"model\":\"llama3.2:1b\",\"input\":\"Compare with QUIC\",\"previous_response_id\":\"$RESP_ID\",\"context_management\":[{\"type\":\"compaction\",\"compact_threshold\":200}],\"store\":false}" +# +# # Step 3 — compact a follow-up that includes a file_url (needs a +# # Files API on 127.0.0.1:9999 only for file_id; file_url is fetched +# # directly). The upstream body must contain resolved file_data or +# # extracted input_text, never the original file_url. listeners: - name: ai-gateway @@ -55,6 +72,20 @@ filter_chains: - filter: openai_responses_rehydrate + - filter: openai_file_resolve + files_api_url: "http://127.0.0.1:9999" + allow_private_files_api_url: true + allow_pre_security_callout: true + file_url: resolve + forward_headers: + - authorization + on_missing: reject + timeout_ms: 10000 + + - filter: openai_doc_extract + allow_pre_security_callout: true + on_unsupported: continue + - filter: openai_responses_compact allow_pre_security_callout: true inference_url: "http://localhost:11434/v1/chat/completions" diff --git a/tests/integration/tests/suite/examples/compact.rs b/tests/integration/tests/suite/examples/compact.rs index f3885a68c..297988731 100644 --- a/tests/integration/tests/suite/examples/compact.rs +++ b/tests/integration/tests/suite/examples/compact.rs @@ -19,6 +19,8 @@ use praxis_test_utils::{ patch_yaml, start_proxy, }; +use super::openai_file_resolve::{start_file_url_stub, start_files_api_stub}; + // ----------------------------------------------------------------------------- // Constants // ----------------------------------------------------------------------------- @@ -40,9 +42,23 @@ const INFERENCE_RESPONSE: &str = r#"{"id":"resp_inf","created_at":2000,"model":" /// Load the compact example config, replacing the SQLite URL and /// patching listener/backend addresses. fn load_compact_config(yaml: &str, db_url: &str, proxy_port: u16, backend_port: u16) -> praxis_core::config::Config { - let replaced = yaml + load_compact_config_with_files(yaml, db_url, proxy_port, backend_port, None) +} + +/// Load the compact example, optionally pointing `files_api_url` at a stub. +fn load_compact_config_with_files( + yaml: &str, + db_url: &str, + proxy_port: u16, + backend_port: u16, + files_api_port: Option, +) -> praxis_core::config::Config { + let mut replaced = yaml .replace("sqlite://responses.db?mode=rwc", db_url) .replace("localhost:11434", &format!("127.0.0.1:{backend_port}")); + if let Some(files_port) = files_api_port { + replaced = replaced.replace("127.0.0.1:9999", &format!("127.0.0.1:{files_port}")); + } let patched = patch_yaml( &replaced, proxy_port, @@ -51,6 +67,31 @@ fn load_compact_config(yaml: &str, db_url: &str, proxy_port: u16, backend_port: praxis_core::config::Config::from_yaml(&patched).expect("patched config should parse") } +/// Drop `openai_doc_extract` so a test can assert the resolve-only shape. +fn without_doc_extract(yaml: &str) -> String { + yaml.replace( + " - filter: openai_doc_extract\n allow_pre_security_callout: true\n on_unsupported: continue\n\n", + "", + ) +} + +/// Allow the test file-url stub origin so loopback fetches are not SSRF-blocked. +fn with_allowed_file_url_origin(yaml: &str, origin: &str) -> String { + yaml.replace( + " file_url: resolve\n", + &format!(" file_url: resolve\n allowed_file_url_origins:\n - \"{origin}\"\n"), + ) +} + +/// True when any object in `value` still carries `key`. +fn json_contains_key(value: &serde_json::Value, key: &str) -> bool { + match value { + serde_json::Value::Object(map) => map.contains_key(key) || map.values().any(|v| json_contains_key(v, key)), + serde_json::Value::Array(items) => items.iter().any(|v| json_contains_key(v, key)), + _ => false, + } +} + /// Start a sequenced backend that: /// - Returns `first_response` for the first request (summarization callout) /// - Returns `second_response` for the second request (inference callout) @@ -275,3 +316,158 @@ async fn compact_verifies_summarization_call_and_compacted_state() { "second item should be the current user input" ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn compact_preserves_resolved_file_url_as_file_data() { + let file_url_port = start_file_url_stub(); + let origin = format!("http://127.0.0.1:{file_url_port}"); + let yaml = without_doc_extract(&with_allowed_file_url_origin( + &std::fs::read_to_string(example_config_path("openai/responses/compact.yaml")) + .expect("example config should exist"), + &origin, + )); + let inference = + compact_follow_up_and_capture(&yaml, "compact_file_url", None, &file_url_follow_up_body(file_url_port)); + + assert_no_unresolved_file_fields(&inference); + let current = &inference["input"][1]; + assert_eq!( + current["content"][0]["type"], "input_file", + "resolve-only pipeline should keep input_file after compaction" + ); + assert!( + current["content"][0] + .get("file_data") + .and_then(serde_json::Value::as_str) + .is_some(), + "resolved file_data must reach the inference backend: {current}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn compact_preserves_extracted_file_id_as_input_text() { + let files_api_port = start_files_api_stub(); + let yaml = std::fs::read_to_string(example_config_path("openai/responses/compact.yaml")) + .expect("example config should exist"); + let inference = compact_follow_up_and_capture(&yaml, "compact_file_id", Some(files_api_port), FILE_ID_FOLLOW_UP); + + assert_no_unresolved_file_fields(&inference); + let current = &inference["input"][1]; + assert_eq!( + current["content"][0]["type"], "input_text", + "doc_extract rewrite must survive compaction: {current}" + ); + let text = current["content"][0]["text"] + .as_str() + .expect("extracted input_text should have a text field"); + assert!( + text.contains("Hello, world!"), + "extracted text should include file content: {text}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn compact_example_extracts_file_url_and_does_not_restore_it() { + let file_url_port = start_file_url_stub(); + let origin = format!("http://127.0.0.1:{file_url_port}"); + let yaml = with_allowed_file_url_origin( + &std::fs::read_to_string(example_config_path("openai/responses/compact.yaml")) + .expect("example config should exist"), + &origin, + ); + let inference = compact_follow_up_and_capture( + &yaml, + "compact_example_file_url", + None, + &file_url_follow_up_body(file_url_port), + ); + + assert_no_unresolved_file_fields(&inference); + let current = &inference["input"][1]; + assert_eq!( + current["content"][0]["type"], "input_text", + "example pipeline should extract the fetched file_url: {current}" + ); + let text = current["content"][0]["text"] + .as_str() + .expect("extracted input_text should have a text field"); + assert!( + text.contains("Hello, world!"), + "extracted text should include file content: {text}" + ); +} + +const FILE_ID_FOLLOW_UP: &str = r#"{"model":"gpt-4.1","previous_response_id":"resp_compact","context_management":[{"type":"compaction","compact_threshold":0}],"input":[{"type":"message","role":"user","content":[{"type":"input_file","file_id":"test-file-123"}]}]}"#; + +fn file_url_follow_up_body(file_url_port: u16) -> String { + format!( + r#"{{"model":"gpt-4.1","previous_response_id":"resp_compact","context_management":[{{"type":"compaction","compact_threshold":0}}],"input":[{{"type":"message","role":"user","content":[{{"type":"input_file","file_url":"http://127.0.0.1:{file_url_port}/document.txt"}}]}}]}}"# + ) +} + +fn assert_no_unresolved_file_fields(inference: &serde_json::Value) { + let input = inference["input"] + .as_array() + .expect("inference input should be an array"); + assert_eq!( + input.len(), + 2, + "compacted input should have exactly 2 items: summary + current input" + ); + assert_eq!( + input[0]["role"], "assistant", + "first item should be the compaction summary as an assistant message" + ); + assert!( + !json_contains_key(inference, "file_url"), + "compacted outbound body must not restore file_url: {inference}" + ); + assert!( + !json_contains_key(inference, "file_id"), + "compacted outbound body must not restore file_id: {inference}" + ); +} + +/// Store the first compact turn, then send `follow_up` against a sequenced +/// backend and return the captured inference request JSON. +fn compact_follow_up_and_capture( + yaml: &str, + db_name: &str, + files_api_port: Option, + follow_up: &str, +) -> serde_json::Value { + let backend1 = Backend::fixed(FIRST_RESPONSE_JSON) + .header("content-type", "application/json") + .start_with_shutdown(); + let proxy_port = free_port(); + let db = TempSqlite::new(db_name); + + let config1 = load_compact_config_with_files(yaml, db.url(), proxy_port, backend1.port(), files_api_port); + let proxy1 = start_proxy(&config1); + let raw1 = http_send( + proxy1.addr(), + &json_post("/v1/responses", r#"{"model":"gpt-4.1","input":"Explain TCP vs UDP"}"#), + ); + assert_eq!(parse_status(&raw1), 200, "first request should succeed"); + drop(backend1); + drop(proxy1); + + let (backend_port, captured_inference_body) = + start_sequenced_backend(CHAT_COMPLETIONS_RESPONSE, INFERENCE_RESPONSE); + let config2 = load_compact_config_with_files(yaml, db.url(), proxy_port, backend_port, files_api_port); + let proxy2 = start_proxy(&config2); + let raw2 = http_send(proxy2.addr(), &json_post("/v1/responses", follow_up)); + assert_eq!( + parse_status(&raw2), + 200, + "compaction follow-up should succeed, body: {raw2}" + ); + drop(proxy2); + + let inference_body = captured_inference_body + .lock() + .unwrap() + .clone() + .expect("inference request body should have been captured"); + serde_json::from_str(&inference_body).expect("inference body should be valid JSON") +}