1use axum::{
2 body::Body,
3 extract::State,
4 http::{Request, StatusCode},
5 response::Response,
6};
7use serde_json::Value;
8
9use super::ProxyState;
10use super::compress::compress_tool_result;
11use super::forward;
12use super::tool_kind::{self, ToolResultKind, should_protect};
13use super::{cache_safety, prose};
14use crate::core::config::{HistoryMode, ProseRole};
15
16pub async fn handler(
30 State(state): State<ProxyState>,
31 req: Request<Body>,
32) -> Result<Response, StatusCode> {
33 let upstream = state.openai_upstream();
34 forward::forward_request(
35 State(state),
36 req,
37 &upstream,
38 "/v1/responses",
39 compress_request_body,
40 "OpenAI",
41 &[],
42 )
43 .await
44}
45
46pub async fn ws_handler(
53 State(state): State<ProxyState>,
54 headers: axum::http::HeaderMap,
55 ws: axum::extract::ws::WebSocketUpgrade,
56) -> Response {
57 super::openai_responses_ws::upgrade(state, ws, &headers)
58}
59
60pub(super) fn compress_request_body(
61 parsed: Value,
62 original_size: usize,
63) -> (Vec<u8>, usize, usize) {
64 let mut doc = parsed;
65 let cfg = crate::core::config::Config::load();
66 let system_aggr = cfg.proxy.resolved_role_aggressiveness(ProseRole::System);
67 let user_aggr = cfg.proxy.resolved_role_aggressiveness(ProseRole::User);
68 let live_compress = cfg.proxy.live_compresses();
69 let mode = cfg.proxy.resolved_history_mode();
70 let mut modified = false;
76 let arm = super::holdout::assign(
80 &super::holdout::openai_responses_key(&doc),
81 cfg.proxy.output_holdout_fraction(),
82 );
83 if cfg.proxy.ccr_inband_enabled() {
84 modified |= super::ccr::splice_inband_in_place(&mut doc);
85 }
86 if arm == super::holdout::Arm::Treatment {
91 if let Some(effort) = cfg.proxy.resolved_effort() {
92 modified |= super::effort::apply_openai_responses(&mut doc, effort);
93 }
94 if cfg.proxy.verbosity_steer_enabled() {
96 modified |= super::verbosity::apply_openai_responses(&mut doc);
97 }
98 }
99 if !live_compress
103 && mode == HistoryMode::Off
104 && system_aggr.is_none()
105 && user_aggr.is_none()
106 && !modified
107 {
108 let out = serde_json::to_vec(&doc).unwrap_or_default();
109 return (out, original_size, original_size);
110 }
111 let mut prose_segments: u64 = 0;
112 if let Some(a) = system_aggr {
113 prose_segments += u64::from(prose::compress_string_field(&mut doc, "instructions", a));
114 }
115 modified |= prune_responses_input(&mut doc);
120 modified |= compress_responses_input(&mut doc);
121 if let Some(a) = user_aggr {
122 prose_segments += u64::from(compress_responses_user_prose(&mut doc, mode, a));
123 }
124 if prose_segments > 0 {
125 modified = true;
126 }
127 cache_safety::record(prose_segments, true);
128 let out = serde_json::to_vec(&doc).unwrap_or_default();
129 let compressed_size = if modified { out.len() } else { original_size };
130 (out, original_size, compressed_size)
131}
132
133fn compress_responses_user_prose(doc: &mut Value, mode: HistoryMode, aggressiveness: f64) -> u32 {
134 let Some(input) = doc.get_mut("input").and_then(|i| i.as_array_mut()) else {
135 return 0;
136 };
137 let boundary = super::history_prune::prune_boundary(mode, input.len());
138 if boundary == 0 {
139 return 0;
140 }
141
142 let mut segments = 0;
143 for item in input.iter_mut().take(boundary) {
144 let item_type = item
145 .get("type")
146 .and_then(|t| t.as_str())
147 .unwrap_or("message");
148 let role = item.get("role").and_then(|r| r.as_str());
149 if item_type == "message" && role == Some("user") {
150 segments += prose::compress_message_content(item, aggressiveness);
151 }
152 }
153 segments
154}
155
156pub(super) fn prune_responses_input(doc: &mut Value) -> bool {
171 let mode = crate::core::config::Config::load()
172 .proxy
173 .resolved_history_mode();
174 let Some(input) = doc.get_mut("input").and_then(|i| i.as_array_mut()) else {
175 return false;
176 };
177 let boundary = super::history_prune::prune_boundary(mode, input.len());
178 if boundary == 0 {
179 return false;
180 }
181 let tool_names = tool_kind::responses_tool_names(input);
182 let mut modified = false;
183 for item in input.iter_mut().take(boundary) {
184 if item.get("type").and_then(|t| t.as_str()) != Some("function_call_output") {
185 continue;
186 }
187 let kind = item
188 .get("call_id")
189 .and_then(|v| v.as_str())
190 .and_then(|id| tool_names.get(id))
191 .map_or(ToolResultKind::Other, |n| tool_kind::classify_tool_name(n));
192 if let Some(output) = item.get_mut("output") {
193 modified |= prune_output_field(output, kind);
194 }
195 }
196 modified
197}
198
199fn prune_output_field(output: &mut Value, kind: ToolResultKind) -> bool {
203 match output {
204 Value::String(s) => match rewrite_json_payload_text(s, kind, |text| {
205 super::history_prune::prune_output_text(text, kind)
206 }) {
207 JsonRewrite::Changed(pruned) => {
208 *s = pruned;
209 true
210 }
211 JsonRewrite::Unchanged => false,
212 JsonRewrite::NotJson => match super::history_prune::prune_output_text(s, kind) {
213 Some(pruned) => {
214 *s = pruned;
215 true
216 }
217 None => false,
218 },
219 },
220 Value::Array(parts) => {
221 let mut changed = false;
222 for part in parts.iter_mut() {
223 if let Some(Value::String(text)) = part.get_mut("text") {
224 match rewrite_json_payload_text(text, kind, |inner| {
225 super::history_prune::prune_output_text(inner, kind)
226 }) {
227 JsonRewrite::Changed(pruned) => {
228 *text = pruned;
229 changed = true;
230 }
231 JsonRewrite::Unchanged => {}
232 JsonRewrite::NotJson => {
233 if let Some(pruned) =
234 super::history_prune::prune_output_text(text, kind)
235 {
236 *text = pruned;
237 changed = true;
238 }
239 }
240 }
241 }
242 }
243 changed
244 }
245 _ => false,
246 }
247}
248
249pub(super) fn compress_responses_input(doc: &mut Value) -> bool {
261 let cfg = crate::core::config::Config::load();
264 if !cfg.proxy.live_compresses() {
265 return false;
266 }
267 let mut modified = false;
268 if let Some(input) = doc.get_mut("input").and_then(|i| i.as_array_mut()) {
269 let tool_names = tool_kind::responses_tool_names(input);
270 for item in input.iter_mut() {
271 if item.get("type").and_then(|t| t.as_str()) != Some("function_call_output") {
272 continue;
273 }
274 let name = item
275 .get("call_id")
276 .and_then(|v| v.as_str())
277 .and_then(|id| tool_names.get(id))
278 .map(String::as_str);
279 if name.is_some_and(|n| cfg.proxy.is_tool_live_compress_excluded(n)) {
282 continue;
283 }
284 let kind = name.map_or(ToolResultKind::Other, tool_kind::classify_tool_name);
285 if let Some(output) = item.get_mut("output") {
286 modified |= compress_output_field(output, name, kind);
287 }
288 }
289 }
290 modified
291}
292
293fn compress_output_field(
300 output: &mut Value,
301 tool_name: Option<&str>,
302 kind: ToolResultKind,
303) -> bool {
304 match output {
305 Value::String(s) => {
306 match rewrite_json_payload_text(s, kind, |text| {
307 if should_protect(kind, text) {
308 return None;
309 }
310 let compressed = compress_tool_result(text, tool_name);
311 (compressed.len() < text.len()).then_some(compressed)
312 }) {
313 JsonRewrite::Changed(compressed) => {
314 *s = compressed;
315 return true;
316 }
317 JsonRewrite::Unchanged => return false,
318 JsonRewrite::NotJson => {}
319 }
320 if should_protect(kind, s) {
321 return false;
322 }
323 let compressed = compress_tool_result(s, tool_name);
324 if compressed.len() < s.len() {
325 *s = compressed;
326 return true;
327 }
328 false
329 }
330 Value::Array(parts) => {
331 let mut changed = false;
332 for part in parts.iter_mut() {
333 if let Some(Value::String(text)) = part.get_mut("text") {
334 match rewrite_json_payload_text(text, kind, |inner| {
335 if should_protect(kind, inner) {
336 return None;
337 }
338 let compressed = compress_tool_result(inner, tool_name);
339 (compressed.len() < inner.len()).then_some(compressed)
340 }) {
341 JsonRewrite::Changed(compressed) => {
342 *text = compressed;
343 changed = true;
344 continue;
345 }
346 JsonRewrite::Unchanged => continue,
347 JsonRewrite::NotJson => {}
348 }
349 if should_protect(kind, text) {
350 continue;
351 }
352 let compressed = compress_tool_result(text, tool_name);
353 if compressed.len() < text.len() {
354 *text = compressed;
355 changed = true;
356 }
357 }
358 }
359 changed
360 }
361 _ => false,
362 }
363}
364
365enum JsonRewrite {
366 NotJson,
367 Unchanged,
368 Changed(String),
369}
370
371fn rewrite_json_payload_text(
382 text: &str,
383 kind: ToolResultKind,
384 mut rewrite: impl FnMut(&str) -> Option<String>,
385) -> JsonRewrite {
386 let trimmed = text.trim();
387 if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
388 return JsonRewrite::NotJson;
389 }
390 let Ok(mut value) = serde_json::from_str::<Value>(trimmed) else {
391 return JsonRewrite::NotJson;
392 };
393 let mut touched = false;
394 let mut changed = false;
395 rewrite_json_text_values(&mut value, kind, &mut rewrite, &mut touched, &mut changed);
396 if !touched || !changed {
397 return JsonRewrite::Unchanged;
398 }
399 match serde_json::to_string(&value) {
400 Ok(serialized) if serialized.len() < text.len() => JsonRewrite::Changed(serialized),
401 _ => JsonRewrite::Unchanged,
402 }
403}
404
405fn rewrite_json_text_values(
406 value: &mut Value,
407 kind: ToolResultKind,
408 rewrite: &mut impl FnMut(&str) -> Option<String>,
409 touched: &mut bool,
410 changed: &mut bool,
411) {
412 match value {
413 Value::Object(map) => {
414 let is_text_part = map
415 .get("type")
416 .and_then(Value::as_str)
417 .is_some_and(|t| matches!(t, "text" | "input_text" | "output_text"));
418 let rewrite_all_strings =
419 matches!(kind, ToolResultKind::Shell | ToolResultKind::Search);
420 for (key, child) in map.iter_mut() {
421 if let Value::String(s) = child
422 && (rewrite_all_strings || (is_text_part && key == "text"))
423 {
424 *touched = true;
425 if let Some(next) = rewrite(s) {
426 *s = next;
427 *changed = true;
428 }
429 continue;
430 }
431 rewrite_json_text_values(child, kind, rewrite, touched, changed);
432 }
433 }
434 Value::Array(items) => {
435 for item in items {
436 rewrite_json_text_values(item, kind, rewrite, touched, changed);
437 }
438 }
439 _ => {}
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446
447 fn long_git_status() -> String {
450 let mut s = String::from(
451 "$ git status\nOn branch main\nYour branch is up to date with 'origin/main'.\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n",
452 );
453 for i in 0..80 {
454 s.push_str(&format!("\tmodified: src/module_{i}/file_{i}.rs\n"));
455 }
456 s.push_str("\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\n");
457 s
458 }
459
460 fn big_prose() -> String {
461 let p = "You are a careful, senior software engineer. You always explain your \
462 reasoning before making changes, you prefer small reviewable diffs, and \
463 you never introduce mock data or placeholders into production code. ";
464 [p; 6].join("\n")
465 }
466
467 fn long_tool_json() -> String {
468 let rows = (0..32)
469 .map(|i| {
470 serde_json::json!({
471 "path": format!("/Users/alex/work/app/src/module_{i}.rs"),
472 "regex": r"src/[a-z_]+\.rs:\d+",
473 "error": format!("error[E0{i:03}]: expected exact diagnostic text"),
474 })
475 })
476 .collect::<Vec<_>>();
477 serde_json::to_string(&serde_json::json!({ "results": rows })).unwrap()
478 }
479
480 #[test]
481 fn shell_json_envelope_text_is_compressed() {
482 let _lock = crate::core::data_dir::test_env_lock();
483 let raw = long_git_status();
484 let expected = compress_tool_result(&raw, Some("Bash"));
485 let envelope = serde_json::to_string(&serde_json::json!({
486 "content": [{"type": "text", "text": raw}],
487 "isError": false,
488 }))
489 .unwrap();
490
491 let body = serde_json::json!({
492 "model": "gpt-5",
493 "input": [
494 {"type": "function_call", "call_id": "call_1", "name": "Bash", "arguments": "{}"},
495 {"type": "function_call_output", "call_id": "call_1", "output": envelope}
496 ]
497 });
498 let bytes = serde_json::to_vec(&body).unwrap();
499 let (out, orig, comp) = compress_request_body(body, bytes.len());
500
501 assert!(comp < orig);
502 let parsed: Value = serde_json::from_slice(&out).unwrap();
503 let output = parsed["input"][1]["output"].as_str().unwrap();
504 let envelope: Value = serde_json::from_str(output).unwrap();
505 assert_eq!(envelope["content"][0]["text"].as_str().unwrap(), expected);
506 }
507
508 #[test]
509 fn shell_json_envelope_non_text_field_is_compressed() {
510 let _lock = crate::core::data_dir::test_env_lock();
511 let raw = long_git_status();
512 let expected = compress_tool_result(&raw, Some("Bash"));
513 let envelope = serde_json::to_string(&serde_json::json!({
516 "stdout": raw,
517 "exit_code": 0,
518 }))
519 .unwrap();
520
521 let body = serde_json::json!({
522 "model": "gpt-5",
523 "input": [
524 {"type": "function_call", "call_id": "call_1", "name": "Bash", "arguments": "{}"},
525 {"type": "function_call_output", "call_id": "call_1", "output": envelope}
526 ]
527 });
528 let bytes = serde_json::to_vec(&body).unwrap();
529 let (out, orig, comp) = compress_request_body(body, bytes.len());
530
531 assert!(comp < orig, "non-text shell field should be compressed");
532 let parsed: Value = serde_json::from_slice(&out).unwrap();
533 let output = parsed["input"][1]["output"].as_str().unwrap();
534 let envelope: Value = serde_json::from_str(output).unwrap();
535 assert_eq!(envelope["stdout"].as_str().unwrap(), expected);
536 assert_eq!(envelope["exit_code"].as_i64().unwrap(), 0);
537 }
538
539 #[test]
540 fn old_shell_json_envelope_text_is_pruned() {
541 let raw = long_git_status();
542 let envelope = serde_json::to_string(&serde_json::json!({
543 "content": [{"type": "text", "text": raw}],
544 "isError": false,
545 }))
546 .unwrap();
547 let mut output = Value::String(envelope);
548
549 assert!(prune_output_field(&mut output, ToolResultKind::Shell));
550 let envelope: Value = serde_json::from_str(output.as_str().unwrap()).unwrap();
551 assert!(
552 envelope["content"][0]["text"].as_str().unwrap().len() < raw.len(),
553 "nested text payload should be pruned"
554 );
555 }
556
557 #[test]
558 fn string_output_mirrors_engine_and_shrinks() {
559 let _lock = crate::core::data_dir::test_env_lock();
562 let raw = long_git_status();
563 let expected = compress_tool_result(&raw, None);
564 assert!(
565 expected.len() < raw.len(),
566 "fixture must be compressible by the shared engine"
567 );
568
569 let body = serde_json::json!({
570 "model": "gpt-5",
571 "input": [
572 {"type": "function_call_output", "call_id": "call_1", "output": raw}
573 ]
574 });
575 let bytes = serde_json::to_vec(&body).unwrap();
576 let (out, orig, comp) = compress_request_body(body, bytes.len());
577
578 assert!(comp < orig, "compressed body must be smaller");
579 let parsed: Value = serde_json::from_slice(&out).unwrap();
580 assert_eq!(
581 parsed["input"][0]["output"].as_str().unwrap(),
582 expected,
583 "output must be exactly what the shared compressor produces"
584 );
585 }
586
587 #[test]
588 fn array_output_text_is_compressed() {
589 let _lock = crate::core::data_dir::test_env_lock();
592 let raw = long_git_status();
593 let expected = compress_tool_result(&raw, None);
594
595 let body = serde_json::json!({
596 "input": [
597 {
598 "type": "function_call_output",
599 "call_id": "call_1",
600 "output": [{"type": "input_text", "text": raw}]
601 }
602 ]
603 });
604 let bytes = serde_json::to_vec(&body).unwrap();
605 let (out, orig, comp) = compress_request_body(body, bytes.len());
606
607 assert!(comp < orig);
608 let parsed: Value = serde_json::from_slice(&out).unwrap();
609 assert_eq!(
610 parsed["input"][0]["output"][0]["text"].as_str().unwrap(),
611 expected
612 );
613 }
614
615 #[test]
616 fn non_tool_output_items_are_untouched() {
617 let body = serde_json::json!({
618 "input": [
619 {"type": "message", "role": "user", "content": long_git_status()},
620 {"type": "function_call", "call_id": "c", "name": "x", "arguments": "{}"}
621 ]
622 });
623 let bytes = serde_json::to_vec(&body).unwrap();
624 let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
625
626 assert_eq!(comp, orig, "no function_call_output → passthrough");
627 let reparsed: Value = serde_json::from_slice(&out).unwrap();
628 assert_eq!(reparsed, body);
629 }
630
631 #[test]
632 fn plain_string_input_passthrough() {
633 let body = serde_json::json!({"model": "gpt-5", "input": "hello world"});
634 let bytes = serde_json::to_vec(&body).unwrap();
635 let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
636 assert_eq!(comp, orig);
637 let reparsed: Value = serde_json::from_slice(&out).unwrap();
638 assert_eq!(reparsed, body);
639 }
640
641 #[test]
642 fn no_input_field_passthrough() {
643 let body = serde_json::json!({"model": "gpt-5", "previous_response_id": "resp_abc"});
644 let bytes = serde_json::to_vec(&body).unwrap();
645 let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
646 assert_eq!(comp, orig);
647 let reparsed: Value = serde_json::from_slice(&out).unwrap();
648 assert_eq!(reparsed, body);
649 }
650
651 #[test]
652 fn chatgpt_responses_eval_fixture_keeps_exact_payloads_and_pairing() {
653 let _iso = crate::core::data_dir::isolated_data_dir();
654 crate::core::config::Config::update_global(|c| {
655 c.proxy.role_aggressiveness.user = Some(0.8);
656 })
657 .unwrap();
658
659 let command_input = "$ cargo test --lib proxy::openai_responses\nerror[E0425]: cannot find value `x` in this scope\nsrc/proxy/openai_responses.rs:12:9";
660 let body = serde_json::json!({"model": "gpt-5", "input": command_input});
661 let bytes = serde_json::to_vec(&body).unwrap();
662 let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
663 assert_eq!(comp, orig, "top-level exact command string must stay raw");
664 assert_eq!(serde_json::from_slice::<Value>(&out).unwrap(), body);
665
666 let old_json = long_tool_json();
667 let recent_json = long_tool_json();
668 let old_shell = long_git_status();
669 let input_text_block = "```rust\nfn main() { panic!(\"exact\"); }\n```\nRegex: src/[a-z_]+\\.rs:\\d+\nPath: /Users/alex/work/app/src/main.rs\nError: error[E0425]: cannot find value `x` in this scope";
670 let mut input = vec![
671 serde_json::json!({"type": "reasoning", "id": "rs_1", "summary": []}),
672 serde_json::json!({"type": "function_call", "call_id": "json_old", "name": "submit_tool_json", "arguments": "{\"strict\":true}"}),
673 serde_json::json!({"type": "function_call_output", "call_id": "json_old", "output": old_json}),
674 serde_json::json!({"type": "function_call", "call_id": "shell_old", "name": "Bash", "arguments": "{\"cmd\":\"git status\"}"}),
675 serde_json::json!({"type": "function_call_output", "call_id": "shell_old", "output": old_shell}),
676 serde_json::json!({"type": "message", "role": "user", "content": [{"type": "input_text", "text": input_text_block}]}),
677 ];
678 while input.len() < 22 {
679 input.push(serde_json::json!({
680 "type": "message",
681 "role": "user",
682 "content": format!("filler {}", input.len()),
683 }));
684 }
685 input.push(serde_json::json!({"type": "function_call", "call_id": "json_recent", "name": "submit_tool_json", "arguments": "{\"strict\":true}"}));
686 input.push(serde_json::json!({"type": "function_call_output", "call_id": "json_recent", "output": recent_json}));
687
688 let body = serde_json::json!({"model": "gpt-5", "input": input});
689 let item_count = body["input"].as_array().unwrap().len();
690 let bytes = serde_json::to_vec(&body).unwrap();
691 let (out, orig, comp) = compress_request_body(body, bytes.len());
692 assert!(comp < orig, "old shell output should still provide savings");
693
694 let parsed: Value = serde_json::from_slice(&out).unwrap();
695 let input = parsed["input"].as_array().unwrap();
696 assert_eq!(input.len(), item_count, "no Responses item may be dropped");
697 assert_eq!(input[0]["type"], "reasoning");
698 assert_eq!(input[1]["type"], "function_call");
699 assert_eq!(input[2]["type"], "function_call_output");
700 assert_eq!(input[2]["output"].as_str().unwrap(), old_json);
701 assert_ne!(input[4]["output"].as_str().unwrap(), old_shell);
702 assert_eq!(
703 input[5]["content"][0]["text"].as_str().unwrap(),
704 input_text_block
705 );
706 assert_eq!(input[22]["type"], "function_call");
707 assert_eq!(input[23]["type"], "function_call_output");
708 assert_eq!(input[23]["output"].as_str().unwrap(), recent_json);
709 assert_eq!(input[1]["call_id"], input[2]["call_id"]);
710 assert_eq!(input[22]["call_id"], input[23]["call_id"]);
711 }
712
713 #[test]
714 fn responses_instructions_prose_compressed_and_assistant_untouched() {
715 let _iso = crate::core::data_dir::isolated_data_dir();
716 crate::core::config::Config::update_global(|c| {
717 c.proxy.role_aggressiveness.system = Some(0.6);
718 })
719 .unwrap();
720
721 let prose = big_prose();
722 let body = serde_json::json!({
723 "model": "gpt-5",
724 "instructions": prose,
725 "input": [
726 {"type": "message", "role": "user", "content": "hi"},
727 {"type": "message", "role": "assistant", "content": prose},
728 ]
729 });
730 let bytes = serde_json::to_vec(&body).unwrap();
731 let (out, orig, comp) = compress_request_body(body, bytes.len());
732 assert!(comp < orig, "enabled instructions prose must save bytes");
733 let parsed: Value = serde_json::from_slice(&out).unwrap();
734
735 assert!(
736 parsed["instructions"].as_str().unwrap().len() < prose.len(),
737 "Responses instructions must be compressed when enabled"
738 );
739 assert_eq!(
740 parsed["input"][1]["content"].as_str().unwrap(),
741 prose,
742 "assistant turns must pass through verbatim (#710)"
743 );
744 }
745
746 #[test]
747 fn responses_user_prose_compressed_only_in_frozen_region() {
748 let _iso = crate::core::data_dir::isolated_data_dir();
749 crate::core::config::Config::update_global(|c| {
750 c.proxy.role_aggressiveness.user = Some(0.7);
751 })
752 .unwrap();
753
754 let prose = big_prose();
755 let mut input = Vec::new();
757 for i in 0..30 {
758 let role = if i % 2 == 0 { "user" } else { "assistant" };
759 input.push(serde_json::json!({
760 "type": "message",
761 "role": role,
762 "content": prose,
763 }));
764 }
765 let body = serde_json::json!({"model": "gpt-5", "input": input});
766 let bytes = serde_json::to_vec(&body).unwrap();
767 let (out, orig, comp) = compress_request_body(body, bytes.len());
768 assert!(comp < orig, "old user prose must save bytes");
769 let parsed: Value = serde_json::from_slice(&out).unwrap();
770
771 let frozen_user = parsed["input"][0]["content"].as_str().unwrap();
772 assert!(
773 frozen_user.len() < prose.len(),
774 "old user prose should compress"
775 );
776 assert_eq!(
777 parsed["input"][1]["content"].as_str().unwrap(),
778 prose,
779 "assistant prose must stay verbatim"
780 );
781 assert_eq!(
782 parsed["input"][16]["content"].as_str().unwrap(),
783 prose,
784 "live-tail user prose must stay verbatim"
785 );
786 }
787
788 #[test]
789 fn responses_prose_compression_is_deterministic() {
790 let _iso = crate::core::data_dir::isolated_data_dir();
791 crate::core::config::Config::update_global(|c| {
792 c.proxy.role_aggressiveness.system = Some(0.6);
793 })
794 .unwrap();
795
796 let prose = big_prose();
797 let mk = || {
798 serde_json::json!({
799 "model": "gpt-5",
800 "instructions": prose,
801 "input": [{"type": "message", "role": "user", "content": "hi"}],
802 })
803 };
804 let (a, b) = (mk(), mk());
805 let (la, lb) = (
806 serde_json::to_vec(&a).unwrap().len(),
807 serde_json::to_vec(&b).unwrap().len(),
808 );
809 assert_eq!(
810 compress_request_body(a, la).0,
811 compress_request_body(b, lb).0,
812 "identical input must yield byte-identical output (#498)"
813 );
814 }
815
816 #[test]
817 fn short_output_unchanged() {
818 let body = serde_json::json!({
819 "input": [
820 {"type": "function_call_output", "call_id": "c", "output": "ok"}
821 ]
822 });
823 let bytes = serde_json::to_vec(&body).unwrap();
824 let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
825 assert_eq!(comp, orig);
826 let reparsed: Value = serde_json::from_slice(&out).unwrap();
827 assert_eq!(reparsed, body);
828 }
829
830 fn responses_read_turns(pairs: usize) -> Vec<Value> {
833 let code = (0..40)
834 .map(|i| format!(" let v{i} = compute_{i}(ctx, opts);"))
835 .collect::<Vec<_>>()
836 .join("\n");
837 let mut input = Vec::new();
838 for t in 0..pairs {
839 input.push(serde_json::json!({
840 "type": "function_call", "call_id": format!("c{t}"),
841 "name": "read_file", "arguments": "{}"
842 }));
843 input.push(serde_json::json!({
844 "type": "function_call_output", "call_id": format!("c{t}"),
845 "output": format!("{code}\n// turn {t}")
846 }));
847 }
848 input
849 }
850
851 #[test]
852 fn cache_aware_prune_stubs_old_reads_keeps_recent_and_pairing() {
853 let _iso = crate::core::data_dir::isolated_data_dir();
855 let body = serde_json::json!({"model": "gpt-5", "input": responses_read_turns(14)});
857 let item_count = body["input"].as_array().unwrap().len();
858 let bytes = serde_json::to_vec(&body).unwrap();
859 let (out, orig, comp) = compress_request_body(body, bytes.len());
860 assert!(comp < orig, "old reads must be pruned for savings");
861
862 let parsed: Value = serde_json::from_slice(&out).unwrap();
863 let input = parsed["input"].as_array().unwrap();
864 assert_eq!(input.len(), item_count, "no items may be removed (pairing)");
866 for (i, item) in input.iter().enumerate() {
867 let expect = if i.is_multiple_of(2) {
868 "function_call"
869 } else {
870 "function_call_output"
871 };
872 assert_eq!(item["type"], expect, "item {i} type/order changed");
873 }
874 let old = input[1]["output"].as_str().unwrap();
876 assert!(
877 old.contains("Re-read the file"),
878 "old read should be stubbed, got: {old}"
879 );
880 let recent = input[27]["output"].as_str().unwrap();
882 assert!(
883 recent.contains("v39"),
884 "recent read must be protected, got: {recent}"
885 );
886 }
887
888 #[test]
889 fn responses_compression_is_deterministic() {
890 let _iso = crate::core::data_dir::isolated_data_dir();
893 let mk = || serde_json::json!({"model": "gpt-5", "input": responses_read_turns(14)});
894 let (a, b) = (mk(), mk());
895 let (la, lb) = (
896 serde_json::to_vec(&a).unwrap().len(),
897 serde_json::to_vec(&b).unwrap().len(),
898 );
899 let (out_a, _, _) = compress_request_body(a, la);
900 let (out_b, _, _) = compress_request_body(b, lb);
901 assert_eq!(out_a, out_b, "identical input must yield identical bytes");
902 }
903
904 #[test]
905 fn cache_aware_responses_prefix_is_byte_stable_across_turns() {
906 let _iso = crate::core::data_dir::isolated_data_dir();
910 let mut prev: Vec<String> = Vec::new();
911 let mut prev_boundary = 0;
912 for pairs in 1..=20 {
913 let input = responses_read_turns(pairs);
914 let len = input.len();
915 let body = serde_json::json!({"model": "gpt-5", "input": input});
916 let bytes = serde_json::to_vec(&body).unwrap();
917 let (out, _, _) = compress_request_body(body, bytes.len());
918 let parsed: Value = serde_json::from_slice(&out).unwrap();
919 let items: Vec<String> = parsed["input"]
920 .as_array()
921 .unwrap()
922 .iter()
923 .map(Value::to_string)
924 .collect();
925 for i in 0..prev_boundary {
926 assert_eq!(
927 prev[i], items[i],
928 "Responses item {i} changed at turn {pairs} — prompt cache prefix broken"
929 );
930 }
931 prev = items;
932 prev_boundary = crate::proxy::history_prune::prune_boundary(
933 crate::core::config::HistoryMode::CacheAware,
934 len,
935 );
936 }
937 }
938
939 #[test]
940 fn effort_control_sets_nested_reasoning_effort() {
941 let _iso = crate::core::data_dir::isolated_data_dir();
943 crate::test_env::remove_var("LEAN_CTX_PROXY_EFFORT");
944 crate::core::config::Config::update_global(|c| {
945 c.proxy.effort = Some("low".into());
946 })
947 .unwrap();
948 let body = serde_json::json!({"model": "gpt-5.5", "input": []});
949 let bytes = serde_json::to_vec(&body).unwrap();
950 let (out, _o, _c) = compress_request_body(body, bytes.len());
951 assert_eq!(
952 serde_json::from_slice::<Value>(&out).unwrap()["reasoning"]["effort"],
953 "low"
954 );
955 }
956}