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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 27 additions & 10 deletions apis/src/openai/responses/compact/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
//!
Expand All @@ -28,6 +30,7 @@ pub(super) mod config;
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic,
clippy::too_many_lines,
reason = "tests"
)]
mod tests;
Expand Down Expand Up @@ -501,18 +504,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": "<base64>"}`.
/// `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<Value>, input_len: usize) -> Vec<Value> {
let start = items.len().saturating_sub(input_len);
items.split_off(start)
}

/// Format a message array as readable text for the summarization prompt.
Expand Down
132 changes: 132 additions & 0 deletions apis/src/openai/responses/compact/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,8 +338,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"
);
}

// =============================================================================
Expand Down
46 changes: 46 additions & 0 deletions apis/src/openai/responses/openai_responses_proxy/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,52 @@ 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));

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");
Expand Down
2 changes: 1 addition & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
35 changes: 33 additions & 2 deletions examples/configs/openai/responses/compact.yaml
Original file line number Diff line number Diff line change
@@ -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.
#
# Usage:
# cargo run -p praxis-ai-proxy -- -c examples/configs/openai/responses/compact.yaml
Expand All @@ -19,6 +31,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
Expand Down Expand Up @@ -50,6 +67,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
inference_url: "http://localhost:11434/v1/chat/completions"
default_model: llama3.2:1b
Expand Down
Loading
Loading