diff --git a/apis/src/openai/responses/rehydrate/mod.rs b/apis/src/openai/responses/rehydrate/mod.rs index 36a8b527..b52dade9 100644 --- a/apis/src/openai/responses/rehydrate/mod.rs +++ b/apis/src/openai/responses/rehydrate/mod.rs @@ -58,32 +58,146 @@ const PREV_USAGE_TOTAL_KEY: &str = "responses.previous_usage_total_tokens"; /// ```yaml /// filter: openai_responses_rehydrate /// ``` -pub struct RehydrateFilter; +pub struct RehydrateFilter { + /// Maximum serialized byte size of stored conversation history. + max_history_bytes: usize, + /// Optional cap on the number of stored history items. + max_history_items: Option, +} impl RehydrateFilter { /// Create a filter from YAML config. /// - /// This filter has no configuration fields. - /// /// # Errors /// - /// Returns [`FilterError`] if the YAML config contains unknown fields. + /// Returns [`FilterError`] if the YAML config contains unknown + /// fields, or `max_history_bytes` / `max_history_items` is zero. pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { let empty = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); let cfg = if config.is_null() { &empty } else { config }; - let _validated: RehydrateConfig = parse_filter_config("openai_responses_rehydrate", cfg)?; - Ok(Box::new(Self)) + let validated: RehydrateConfig = parse_filter_config("openai_responses_rehydrate", cfg)?; + if validated.max_history_bytes == 0 { + return Err(FilterError::from( + "openai_responses_rehydrate: max_history_bytes must be greater than 0", + )); + } + if validated.max_history_items == Some(0) { + return Err(FilterError::from( + "openai_responses_rehydrate: max_history_items must be greater than 0", + )); + } + Ok(Box::new(Self { + max_history_bytes: validated.max_history_bytes, + max_history_items: validated.max_history_items, + })) + } + + /// Parse body, resolve rehydration source (`previous_response_id` or + /// `conversation`), and populate [`ResponsesState`] with the full + /// conversation history. + /// + /// `previous_response_id` takes precedence when both fields are + /// present. + async fn rehydrate( + &self, + ctx: &mut HttpFilterContext<'_>, + body: &Option, + streaming: bool, + ) -> Result { + let Some(bytes) = body.as_ref() else { + return Ok(FilterAction::Release); + }; + match parse_body_and_extract_id(bytes, streaming) { + Ok((body, Some(id))) => self.rehydrate_from_response(ctx, body, id, streaming).await, + Ok((body, None)) => self.rehydrate_from_conversation(ctx, body, streaming).await, + Err(action) => Ok(action), + } + } + + /// Rehydrate from a stored response. + async fn rehydrate_from_response( + &self, + ctx: &mut HttpFilterContext<'_>, + parsed_body: Value, + prev_id: String, + streaming: bool, + ) -> Result { + let tenant_id = ctx + .get_metadata(TENANT_METADATA_KEY) + .unwrap_or(DEFAULT_TENANT_ID) + .to_owned(); + let record = match fetch_and_validate_previous(ctx, &tenant_id, &prev_id, streaming).await { + Ok(r) => r, + Err(action) => return Ok(action), + }; + let stored = + match stored_messages_for_response(&record, self.max_history_bytes, self.max_history_items, streaming) { + Ok(s) => s, + Err(action) => return Ok(action), + }; + 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); + 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"); + ctx.set_metadata("responses.previous_response_id", prev_id); + Ok(FilterAction::Release) + } + + /// Rehydrate from a stored conversation when no `previous_response_id` + /// is present. + async fn rehydrate_from_conversation( + &self, + ctx: &mut HttpFilterContext<'_>, + parsed_body: Value, + streaming: bool, + ) -> Result { + let conv_id = match resolve_conversation_id(&parsed_body, streaming) { + Ok(id) => id, + Err(action) => return Ok(action), + }; + let tenant_id = ctx + .get_metadata(TENANT_METADATA_KEY) + .unwrap_or(DEFAULT_TENANT_ID) + .to_owned(); + let record = match fetch_conversation(ctx, &tenant_id, &conv_id, streaming).await { + Ok(r) => r, + Err(action) => return Ok(action), + }; + let stored = match stored_messages_for_conversation( + &record, + self.max_history_bytes, + self.max_history_items, + streaming, + ) { + Ok(s) => s, + Err(action) => return Ok(action), + }; + let state = build_state(parsed_body, stored, vec![], None); + write_previous_usage_metadata(ctx, state.previous_usage.as_ref()); + ctx.extensions.insert(state); + debug!(conversation_id = %conv_id, "conversation rehydrated, state populated"); + Ok(FilterAction::Release) } } -/// Empty YAML configuration for [`RehydrateFilter`]. +/// YAML configuration for [`RehydrateFilter`]. #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] -#[expect( - clippy::empty_structs_with_brackets, - reason = "serde cannot deserialize a map into a unit struct" -)] -struct RehydrateConfig {} +struct RehydrateConfig { + /// Maximum serialized byte size of stored conversation history. Default: 2,097,152 (2 MiB). + #[serde(default = "default_max_history_bytes")] + max_history_bytes: usize, + /// Optional cap on the number of stored history items. + #[serde(default)] + max_history_items: Option, +} + +/// Default maximum byte size for stored conversation history (2 MiB). +fn default_max_history_bytes() -> usize { + 2_097_152 // 2 MiB +} #[async_trait] impl HttpFilter for RehydrateFilter { @@ -133,7 +247,7 @@ impl HttpFilter for RehydrateFilter { .get_metadata("openai_responses_format.stream") .is_some_and(|v| v == "true"); - rehydrate(ctx, body, streaming).await + self.rehydrate(ctx, body, streaming).await } } @@ -151,89 +265,102 @@ fn is_responses_cancel_path(path: &str) -> bool { !response_id.is_empty() && !response_id.contains('/') } -/// Parse body, resolve rehydration source (`previous_response_id` or -/// `conversation`), and populate [`ResponsesState`] with the full -/// conversation history. -/// -/// `previous_response_id` takes precedence when both fields are -/// present. -async fn rehydrate( - ctx: &mut HttpFilterContext<'_>, - body: &Option, +/// Reject when `items` exceeds the configured byte-size or item-count cap. +fn check_history_limits( + items: &[Value], + max_bytes: usize, + max_items: Option, streaming: bool, -) -> Result { - let Some(bytes) = body.as_ref() else { - return Ok(FilterAction::Release); - }; +) -> Result<(), FilterAction> { + if let Some(max) = max_items { + let count = items.len(); + if count > max { + return Err(reject_too_large( + &format!( + "stored conversation history contains {count} items, \ + exceeding the {max} item limit; \ + compact or shorten the conversation before continuing" + ), + streaming, + )); + } + } - let (parsed_body, prev_id) = match parse_body_and_extract_id(bytes, streaming) { - Ok((body, Some(id))) => (body, id), - Ok((body, None)) => return rehydrate_from_conversation(ctx, body, streaming).await, - Err(action) => return Ok(action), - }; + let byte_size = serde_json::to_string(items).map_or(usize::MAX, |s| s.len()); + if byte_size > max_bytes { + return Err(reject_too_large( + &format!( + "stored conversation history is {byte_size} bytes, \ + exceeding the {max_bytes} byte limit; \ + compact or shorten the conversation before continuing" + ), + streaming, + )); + } - let tenant_id = ctx - .get_metadata(TENANT_METADATA_KEY) - .unwrap_or(DEFAULT_TENANT_ID) - .to_owned(); + Ok(()) +} - let record = match fetch_previous_response(ctx, &tenant_id, &prev_id, streaming).await { - Ok(r) => r, - Err(action) => return Ok(action), - }; +// ----------------------------------------------------------------------------- +// Stored message extraction +// ----------------------------------------------------------------------------- - if let Err(action) = validate_response_status(&record, streaming) { - return Ok(action); +/// Stored messages from a response record, checking limits before cloning. +fn stored_messages_for_response( + record: &ResponseRecord, + max_bytes: usize, + max_items: Option, + streaming: bool, +) -> Result, FilterAction> { + if let Some(messages) = record.messages.as_array().filter(|a| !a.is_empty()) { + check_history_limits(messages, max_bytes, max_items, streaming)?; + return Ok(messages.clone()); } + reconstruct_messages_from_public_response(record, max_bytes, max_items, streaming) +} - populate_state_and_usage_metadata(ctx, parsed_body, &record); - - debug!(previous_response_id = %prev_id, "previous response validated, state populated"); - ctx.set_metadata("responses.previous_response_id", prev_id); - - Ok(FilterAction::Release) +/// Stored messages from a conversation record, checking limits before cloning. +fn stored_messages_for_conversation( + record: &ConversationRecord, + max_bytes: usize, + max_items: Option, + streaming: bool, +) -> Result, FilterAction> { + let empty: &[Value] = &[]; + let messages = record.messages.as_array().map_or(empty, Vec::as_slice); + check_history_limits(messages, max_bytes, max_items, streaming)?; + Ok(messages.to_vec()) } -/// Rehydrate from a stored conversation when no `previous_response_id` -/// is present. -async fn rehydrate_from_conversation( - ctx: &mut HttpFilterContext<'_>, - parsed_body: Value, +/// Fetch the previous response and validate its status in one step. +async fn fetch_and_validate_previous( + ctx: &HttpFilterContext<'_>, + tenant_id: &str, + prev_id: &str, streaming: bool, -) -> Result { - let has_conversation_field = parsed_body.get("conversation").is_some(); - let Some(conv_id) = extract_conversation_id(&parsed_body) else { - if has_conversation_field { - return Ok(FilterAction::Reject(responses_error_rejection( +) -> Result { + let record = fetch_previous_response(ctx, tenant_id, prev_id, streaming).await?; + validate_response_status(&record, streaming)?; + Ok(record) +} + +/// Resolve the conversation ID from the request body, returning a +/// `Release` when no conversation field is present or a `Reject` +/// when the field is malformed. +fn resolve_conversation_id(body: &Value, streaming: bool) -> Result { + let has_field = body.get("conversation").is_some(); + extract_conversation_id(body).ok_or_else(|| { + if has_field { + FilterAction::Reject(responses_error_rejection( 400, "invalid_request_error", "invalid conversation value: expected a string ID or {\"id\": \"...\"}", streaming, - ))); + )) + } else { + FilterAction::Release } - return Ok(FilterAction::Release); - }; - - let tenant_id = ctx - .get_metadata(TENANT_METADATA_KEY) - .unwrap_or(DEFAULT_TENANT_ID) - .to_owned(); - - let record = match fetch_conversation(ctx, &tenant_id, &conv_id, streaming).await { - Ok(r) => r, - Err(action) => return Ok(action), - }; - - let stored = conversation_messages_for_rehydrate(&record); - let replay = replay_messages_from_stored(&stored); - let mut state = ResponsesState::from_request_body(parsed_body); - state.messages.splice(0..0, replay); - state.persisted_messages.splice(0..0, stored); - ctx.extensions.insert(state); - - debug!(conversation_id = %conv_id, "conversation rehydrated, state populated"); - - Ok(FilterAction::Release) + }) } /// Extract a conversation ID from the request body. @@ -277,37 +404,15 @@ async fn fetch_conversation( }) } -/// Extract messages from a conversation record for rehydration. -fn conversation_messages_for_rehydrate(record: &ConversationRecord) -> Vec { - record.messages.as_array().cloned().unwrap_or_default() -} - -/// Insert rehydrated request state and promote previous usage metadata. -fn populate_state_and_usage_metadata(ctx: &mut HttpFilterContext<'_>, parsed_body: Value, record: &ResponseRecord) { - let previous_tools = collect_mcp_tool_listings(record); - - let previous_usage = record.response_object.get("usage").filter(|usage| !usage.is_null()); - write_previous_usage_metadata(ctx, previous_usage); - - ctx.extensions.insert(build_state( - parsed_body, - record, - previous_tools, - previous_usage.cloned(), - )); -} - /// Build [`ResponsesState`] by prepending stored messages before the current input. -// TODO(#697): enforce a max rehydrated history size. fn build_state( parsed_body: Value, - record: &ResponseRecord, + stored: Vec, previous_tools: Vec, previous_usage: Option, ) -> ResponsesState { - let mut state = ResponsesState::from_request_body(parsed_body); - let stored = stored_messages_for_rehydrate(record); let replay = replay_messages_from_stored(&stored); + let mut state = ResponsesState::from_request_body(parsed_body); state.messages.splice(0..0, replay); state.persisted_messages.splice(0..0, stored); state.previous_tools = previous_tools; @@ -317,16 +422,12 @@ fn build_state( /// Return stored history, reconstructing from public fields for /// records created before hidden messages were persisted. -fn stored_messages_for_rehydrate(record: &ResponseRecord) -> Vec { - if let Some(messages) = record.messages.as_array().filter(|messages| !messages.is_empty()) { - return messages.clone(); - } - - reconstruct_messages_from_public_response(record) -} - -/// Reconstruct previous input/output items from public stored fields. -fn reconstruct_messages_from_public_response(record: &ResponseRecord) -> Vec { +fn reconstruct_messages_from_public_response( + record: &ResponseRecord, + max_bytes: usize, + max_items: Option, + streaming: bool, +) -> Result, FilterAction> { let mut messages = Vec::new(); append_stored_input_items(&mut messages, record.input.clone()); @@ -335,7 +436,8 @@ fn reconstruct_messages_from_public_response(record: &ResponseRecord) -> Vec FilterAction { FilterAction::Reject(responses_error_rejection(500, "server_error", message, streaming)) } +/// Build a 413 rejection with a Responses API error body. +fn reject_too_large(message: &str, streaming: bool) -> FilterAction { + FilterAction::Reject(responses_error_rejection( + 413, + "invalid_request_error", + message, + streaming, + )) +} + // ----------------------------------------------------------------------------- // Tests // ----------------------------------------------------------------------------- diff --git a/apis/src/openai/responses/rehydrate/tests.rs b/apis/src/openai/responses/rehydrate/tests.rs index 8f32b2c9..0f550c80 100644 --- a/apis/src/openai/responses/rehydrate/tests.rs +++ b/apis/src/openai/responses/rehydrate/tests.rs @@ -12,6 +12,13 @@ use crate::store::{ ConversationRecord, ResponseRecord, ResponseStore, ResponseStoreRegistry, SqliteResponseStore, StoreError, }; +fn default_filter() -> RehydrateFilter { + RehydrateFilter { + max_history_bytes: default_max_history_bytes(), + max_history_items: None, + } +} + // ----------------------------------------------------------------------------- // from_config // ----------------------------------------------------------------------------- @@ -38,7 +45,7 @@ fn unknown_field_rejected() { #[test] fn body_access_is_read_only() { - let filter = RehydrateFilter; + let filter = default_filter(); assert_eq!( filter.request_body_access(), BodyAccess::ReadOnly, @@ -52,7 +59,7 @@ fn body_access_is_read_only() { #[tokio::test] async fn skips_non_post_request() { - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::GET, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); let mut body = Some(Bytes::from(r#"{"input":"test"}"#)); @@ -63,7 +70,7 @@ async fn skips_non_post_request() { #[tokio::test] async fn skips_non_responses_format() { - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/chat/completions"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.set_metadata("openai_responses_format.format", "openai_chat_completions"); @@ -78,7 +85,7 @@ async fn skips_non_responses_format() { #[tokio::test] async fn continues_on_non_end_of_stream() { - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); let mut body = Some(Bytes::from(r#"{"input":"partial"}"#)); @@ -92,7 +99,7 @@ async fn continues_on_non_end_of_stream() { #[tokio::test] async fn skips_cancel_request_without_parsing_empty_body() { - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses/resp_123/cancel"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.set_metadata("openai_responses_format.format", "openai_responses"); @@ -116,7 +123,7 @@ async fn skips_cancel_request_without_parsing_empty_body() { #[tokio::test] async fn passthrough_when_no_previous_response_id() { - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.set_metadata("openai_responses_format.format", "openai_responses"); @@ -141,7 +148,7 @@ async fn passthrough_when_no_previous_response_id() { #[tokio::test] async fn passthrough_when_previous_response_id_is_null() { - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.set_metadata("openai_responses_format.format", "openai_responses"); @@ -169,7 +176,7 @@ async fn validates_previous_response_and_sets_metadata() { let store = MockStore::with_completed_response("resp_prev", json!("Hello"), messages); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -312,7 +319,7 @@ async fn rejects_when_previous_response_not_found() { let store = MockStore::empty(); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -331,7 +338,7 @@ async fn rejects_when_status_not_completed() { let store = MockStore::with_status("resp_123", "in_progress"); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -350,7 +357,7 @@ async fn rejects_when_status_incomplete() { let store = MockStore::with_status("resp_123", "incomplete"); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -369,7 +376,7 @@ async fn rejects_when_status_failed() { let store = MockStore::with_status("resp_123", "failed"); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -385,7 +392,7 @@ async fn rejects_when_status_failed() { #[tokio::test] async fn rejects_when_store_unavailable() { - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.set_metadata("openai_responses_format.format", "openai_responses"); @@ -402,7 +409,7 @@ async fn rejects_when_store_unavailable() { async fn rejects_when_store_not_registered() { let registry = ResponseStoreRegistry::new(); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -418,7 +425,7 @@ async fn rejects_when_store_not_registered() { #[tokio::test] async fn rejects_invalid_json_body() { - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.set_metadata("openai_responses_format.format", "openai_responses"); @@ -433,7 +440,7 @@ async fn rejects_invalid_json_body() { #[tokio::test] async fn rejects_non_string_previous_response_id() { - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.set_metadata("openai_responses_format.format", "openai_responses"); @@ -451,7 +458,7 @@ async fn rejects_when_store_fetch_fails() { let store = MockStore::failing(); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -470,7 +477,7 @@ async fn rejects_when_conversation_store_fails() { let store = MockStore::failing(); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -509,7 +516,7 @@ async fn extracts_mcp_tools_from_previous_response() { let store = MockStore::with_output_and_usage("resp_mcp", output, usage); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -557,7 +564,7 @@ async fn no_previous_tools_when_output_has_no_mcp_items() { let store = MockStore::with_output_and_usage("resp_no_mcp", output, usage); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -602,7 +609,7 @@ async fn extracts_mcp_tools_from_multiple_servers() { let store = MockStore::with_output_and_usage("resp_multi", output, Value::Null); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -679,7 +686,7 @@ async fn deduplicates_mcp_tools_independent_of_tool_order() { }; let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -740,7 +747,7 @@ async fn extracts_mcp_tools_from_stored_history_when_latest_output_has_none() { }; let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -799,7 +806,7 @@ async fn large_mcp_tool_listing_is_preserved_in_state() { let store = MockStore::with_output_and_usage("resp_big", output, Value::Null); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -838,7 +845,7 @@ async fn extracts_usage_from_previous_response() { let store = MockStore::with_output_and_usage("resp_usage", output, usage.clone()); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -883,7 +890,7 @@ async fn no_usage_metadata_when_usage_missing() { let store = MockStore::with_output_and_usage("resp_no_usage", output, Value::Null); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -925,7 +932,7 @@ async fn extracts_partial_usage_fields() { let store = MockStore::with_output_and_usage("resp_partial", output, usage); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -991,7 +998,7 @@ async fn fallback_reconstruction_excludes_mcp_list_tools_but_preserves_outputs() }; let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -1042,6 +1049,272 @@ async fn fallback_reconstruction_excludes_mcp_list_tools_but_preserves_outputs() assert_eq!(state.previous_tools.len(), 1, "fallback should populate previous tools"); } +// ----------------------------------------------------------------------------- +// History Limits +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn rejects_stored_history_exceeding_byte_limit() { + let user_content = "A".repeat(500); + let assistant_content = "B".repeat(500); + let messages = json!([ + {"role": "user", "content": user_content}, + {"role": "assistant", "content": assistant_content} + ]); + let store = MockStore::with_completed_response("resp_big", json!("Hello"), messages); + let registry = setup_registry(store); + + let filter = RehydrateFilter { + max_history_bytes: 64, + max_history_items: None, + }; + let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.extensions.insert(registry.clone()); + ctx.set_metadata("openai_responses_format.format", "openai_responses"); + let mut body = Some(Bytes::from(r#"{"input":"Hi","previous_response_id":"resp_big"}"#)); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + match action { + FilterAction::Reject(r) => { + assert_eq!(r.status, 413, "should reject with 413 for oversized history"); + let body_bytes = r.body.unwrap(); + let body_str = std::str::from_utf8(&body_bytes).unwrap(); + assert!( + body_str.contains("byte limit"), + "rejection body should mention byte limit: {body_str}" + ); + }, + other => panic!("expected Reject, got {other:?}"), + } +} + +#[tokio::test] +async fn rejects_stored_history_exceeding_item_limit() { + let messages = json!([ + {"role": "user", "content": "Turn 1"}, + {"role": "assistant", "content": "Reply 1"}, + {"role": "user", "content": "Turn 2"}, + {"role": "assistant", "content": "Reply 2"}, + {"role": "user", "content": "Turn 3"} + ]); + let store = MockStore::with_completed_response("resp_many", json!("Hello"), messages); + let registry = setup_registry(store); + + let filter = RehydrateFilter { + max_history_bytes: default_max_history_bytes(), + max_history_items: Some(3), + }; + let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.extensions.insert(registry.clone()); + ctx.set_metadata("openai_responses_format.format", "openai_responses"); + let mut body = Some(Bytes::from(r#"{"input":"Hi","previous_response_id":"resp_many"}"#)); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + match action { + FilterAction::Reject(r) => { + assert_eq!(r.status, 413, "should reject with 413 for too many items"); + let body_bytes = r.body.unwrap(); + let body_str = std::str::from_utf8(&body_bytes).unwrap(); + assert!( + body_str.contains("item limit"), + "rejection body should mention item limit: {body_str}" + ); + }, + other => panic!("expected Reject, got {other:?}"), + } +} + +#[tokio::test] +async fn allows_history_within_limits() { + let messages = json!([ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi"} + ]); + let store = MockStore::with_completed_response("resp_ok", json!("Hello"), messages); + let registry = setup_registry(store); + + let filter = RehydrateFilter { + max_history_bytes: default_max_history_bytes(), + max_history_items: Some(10), + }; + let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.extensions.insert(registry.clone()); + ctx.set_metadata("openai_responses_format.format", "openai_responses"); + let mut body = Some(Bytes::from(r#"{"input":"Next","previous_response_id":"resp_ok"}"#)); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + assert!( + matches!(action, FilterAction::Release), + "should release when history is within limits" + ); + + let state = ctx + .extensions + .get::() + .expect("ResponsesState should be populated"); + assert_eq!( + state.messages.len(), + 3, + "messages should contain 2 stored + 1 current input" + ); +} + +#[tokio::test] +async fn from_config_with_custom_limits() { + let yaml: serde_yaml::Value = serde_yaml::from_str("max_history_bytes: 2097152\nmax_history_items: 2").unwrap(); + let filter = RehydrateFilter::from_config(&yaml).unwrap(); + assert_eq!(filter.name(), "openai_responses_rehydrate"); + + let messages = json!([ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + {"role": "user", "content": "third"} + ]); + let store = MockStore::with_completed_response("resp_cfg", json!("hello"), messages); + let registry = setup_registry(store); + + let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.extensions.insert(registry); + ctx.set_metadata("openai_responses_format.format", "openai_responses"); + let mut body = Some(Bytes::from(r#"{"input":"next","previous_response_id":"resp_cfg"}"#)); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + match action { + FilterAction::Reject(r) => { + assert_eq!(r.status, 413, "configured item limit of 2 should reject 3-item history"); + }, + other => panic!("expected Reject for custom max_history_items=2, got {other:?}"), + } +} + +#[test] +fn from_config_rejects_zero_limits() { + let yaml = serde_yaml::from_str::("max_history_bytes: 0").unwrap(); + assert!(RehydrateFilter::from_config(&yaml).is_err()); + + let yaml = serde_yaml::from_str::("max_history_items: 0").unwrap(); + assert!(RehydrateFilter::from_config(&yaml).is_err()); +} + +#[tokio::test] +async fn rejects_fallback_reconstruction_exceeding_byte_limit() { + let large_input = "X".repeat(500); + let large_output = json!([ + {"type": "message", "content": [{"type": "output_text", "text": "Y".repeat(500)}]} + ]); + let mut records = std::collections::HashMap::new(); + records.insert( + "resp_fallback".to_owned(), + ResponseRecord { + id: "resp_fallback".to_owned(), + tenant_id: "default".to_owned(), + created_at: 1000, + model: "gpt-4.1".to_owned(), + response_object: json!({ + "id": "resp_fallback", + "status": "completed", + "output": large_output, + }), + input: json!(large_input), + messages: json!([]), + }, + ); + let store = MockStore { + records, + conversations: std::collections::HashMap::new(), + should_fail: false, + }; + let registry = setup_registry(store); + + let filter = RehydrateFilter { + max_history_bytes: 64, + max_history_items: None, + }; + let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.extensions.insert(registry.clone()); + ctx.set_metadata("openai_responses_format.format", "openai_responses"); + let mut body = Some(Bytes::from(r#"{"input":"Hi","previous_response_id":"resp_fallback"}"#)); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + match action { + FilterAction::Reject(r) => { + assert_eq!( + r.status, 413, + "fallback reconstruction exceeding byte limit should reject with 413" + ); + }, + other => panic!("expected Reject for oversized fallback reconstruction, got {other:?}"), + } +} + +#[tokio::test] +async fn rejects_fallback_reconstruction_exceeding_item_limit() { + let input = json!([ + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "a"}]}, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "b"}]}, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "c"}]} + ]); + let output = json!([ + {"type": "message", "content": [{"type": "output_text", "text": "r1"}]}, + {"type": "message", "content": [{"type": "output_text", "text": "r2"}]} + ]); + let mut records = std::collections::HashMap::new(); + records.insert( + "resp_items".to_owned(), + ResponseRecord { + id: "resp_items".to_owned(), + tenant_id: "default".to_owned(), + created_at: 1000, + model: "gpt-4.1".to_owned(), + response_object: json!({ + "id": "resp_items", + "status": "completed", + "output": output, + }), + input, + messages: json!([]), + }, + ); + let store = MockStore { + records, + conversations: std::collections::HashMap::new(), + should_fail: false, + }; + let registry = setup_registry(store); + + let filter = RehydrateFilter { + max_history_bytes: default_max_history_bytes(), + max_history_items: Some(3), + }; + let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.extensions.insert(registry.clone()); + ctx.set_metadata("openai_responses_format.format", "openai_responses"); + let mut body = Some(Bytes::from(r#"{"input":"Hi","previous_response_id":"resp_items"}"#)); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + match action { + FilterAction::Reject(r) => { + assert_eq!( + r.status, 413, + "fallback reconstruction exceeding item limit should reject with 413" + ); + let body_bytes = r.body.unwrap(); + let body_str = std::str::from_utf8(&body_bytes).unwrap(); + assert!( + body_str.contains("item limit"), + "rejection body should mention item limit: {body_str}" + ); + }, + other => panic!("expected Reject for oversized fallback reconstruction, got {other:?}"), + } +} + // ----------------------------------------------------------------------------- // Conversation Rehydration // ----------------------------------------------------------------------------- @@ -1055,7 +1328,7 @@ async fn rehydrates_from_conversation_string_id() { let store = MockStore::with_conversation("conv_abc", messages); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -1099,7 +1372,7 @@ async fn rehydrates_from_conversation_object_form() { let store = MockStore::with_conversation("conv_obj", messages); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -1152,7 +1425,7 @@ async fn previous_response_id_takes_precedence_over_conversation() { ); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -1187,7 +1460,7 @@ async fn rejects_when_conversation_not_found() { let store = MockStore::empty(); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -1208,7 +1481,7 @@ async fn tenant_mismatch_rejects_conversation() { let store = MockStore::with_conversation("conv_abc", json!([{"role": "user", "content": "hello"}])); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -1238,7 +1511,7 @@ async fn rejects_malformed_conversation_empty_object() { let store = MockStore::empty(); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -1257,7 +1530,7 @@ async fn rejects_malformed_conversation_numeric() { let store = MockStore::empty(); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -1276,7 +1549,7 @@ async fn empty_conversation_produces_valid_state() { let store = MockStore::with_conversation("conv_empty", json!([])); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -1305,7 +1578,7 @@ async fn empty_conversation_produces_valid_state() { #[tokio::test] async fn conversation_rehydration_requires_store_registry() { - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.set_metadata("openai_responses_format.format", "openai_responses"); @@ -1325,7 +1598,7 @@ async fn conversation_null_messages_treated_as_empty() { let store = MockStore::with_conversation("conv_null_msgs", Value::Null); let registry = setup_registry(store); - let filter = RehydrateFilter; + let filter = default_filter(); let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); let mut ctx = crate::test_utils::make_filter_context(&req); ctx.extensions.insert(registry.clone()); @@ -1351,6 +1624,128 @@ async fn conversation_null_messages_treated_as_empty() { ); } +// ----------------------------------------------------------------------------- +// Conversation History Limits +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn rejects_conversation_history_exceeding_byte_limit() { + let user_content = "A".repeat(500); + let assistant_content = "B".repeat(500); + let messages = json!([ + {"role": "user", "content": user_content}, + {"role": "assistant", "content": assistant_content} + ]); + let store = MockStore::with_conversation("conv_big", messages); + let registry = setup_registry(store); + + let filter = RehydrateFilter { + max_history_bytes: 64, + max_history_items: None, + }; + let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.extensions.insert(registry.clone()); + ctx.set_metadata("openai_responses_format.format", "openai_responses"); + let mut body = Some(Bytes::from( + r#"{"model":"gpt-4.1","input":"Hi","conversation":"conv_big"}"#, + )); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + match action { + FilterAction::Reject(r) => { + assert_eq!( + r.status, 413, + "should reject with 413 for oversized conversation history" + ); + let body_bytes = r.body.unwrap(); + let body_str = std::str::from_utf8(&body_bytes).unwrap(); + assert!( + body_str.contains("byte limit"), + "rejection body should mention byte limit: {body_str}" + ); + }, + other => panic!("expected Reject, got {other:?}"), + } +} + +#[tokio::test] +async fn rejects_conversation_history_exceeding_item_limit() { + let messages = json!([ + {"role": "user", "content": "Turn 1"}, + {"role": "assistant", "content": "Reply 1"}, + {"role": "user", "content": "Turn 2"}, + {"role": "assistant", "content": "Reply 2"}, + {"role": "user", "content": "Turn 3"} + ]); + let store = MockStore::with_conversation("conv_many", messages); + let registry = setup_registry(store); + + let filter = RehydrateFilter { + max_history_bytes: default_max_history_bytes(), + max_history_items: Some(3), + }; + let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.extensions.insert(registry.clone()); + ctx.set_metadata("openai_responses_format.format", "openai_responses"); + let mut body = Some(Bytes::from( + r#"{"model":"gpt-4.1","input":"Hi","conversation":"conv_many"}"#, + )); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + match action { + FilterAction::Reject(r) => { + assert_eq!(r.status, 413, "should reject with 413 for too many conversation items"); + let body_bytes = r.body.unwrap(); + let body_str = std::str::from_utf8(&body_bytes).unwrap(); + assert!( + body_str.contains("item limit"), + "rejection body should mention item limit: {body_str}" + ); + }, + other => panic!("expected Reject, got {other:?}"), + } +} + +#[tokio::test] +async fn allows_conversation_history_within_limits() { + let messages = json!([ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi"} + ]); + let store = MockStore::with_conversation("conv_ok", messages); + let registry = setup_registry(store); + + let filter = RehydrateFilter { + max_history_bytes: default_max_history_bytes(), + max_history_items: Some(10), + }; + let req = crate::test_utils::make_request(http::Method::POST, "/v1/responses"); + let mut ctx = crate::test_utils::make_filter_context(&req); + ctx.extensions.insert(registry.clone()); + ctx.set_metadata("openai_responses_format.format", "openai_responses"); + let mut body = Some(Bytes::from( + r#"{"model":"gpt-4.1","input":"Next","conversation":"conv_ok"}"#, + )); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + assert!( + matches!(action, FilterAction::Release), + "should release when conversation history is within limits" + ); + + let state = ctx + .extensions + .get::() + .expect("ResponsesState should be populated"); + assert_eq!( + state.messages.len(), + 3, + "messages should contain 2 stored + 1 current input" + ); +} + // ----------------------------------------------------------------------------- // Test Utilities // ----------------------------------------------------------------------------- diff --git a/docs/filters/openai_responses_rehydrate.md b/docs/filters/openai_responses_rehydrate.md index 557827c7..7020c1c6 100644 --- a/docs/filters/openai_responses_rehydrate.md +++ b/docs/filters/openai_responses_rehydrate.md @@ -9,6 +9,13 @@ Validates `previous_response_id` by fetching the stored response, confirming its The request body is **not** modified; downstream filters read from `ResponsesState.messages` instead. +## Configuration + +| Field | Type | Required | Description | +|-------|------|---------|-------------| +| `max_history_bytes` | integer | no | Maximum serialized byte size of stored conversation history. Default: 2,097,152 (2 MiB). | +| `max_history_items` | integer | no | Optional cap on the number of stored history items. | + ## Example ```yaml diff --git a/examples/configs/openai/responses/rehydrate.yaml b/examples/configs/openai/responses/rehydrate.yaml index 69fb7ac0..150ff829 100644 --- a/examples/configs/openai/responses/rehydrate.yaml +++ b/examples/configs/openai/responses/rehydrate.yaml @@ -50,6 +50,8 @@ filter_chains: conversations_table: openai_conversations - filter: openai_responses_rehydrate + # max_history_bytes: 2097152 # 2 MiB (default) + # max_history_items: ~ # unlimited (default) - filter: router routes: