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 super::history_prune::prune_output_text(s, kind) {
205 Some(pruned) => {
206 *s = pruned;
207 true
208 }
209 None => false,
210 },
211 Value::Array(parts) => {
212 let mut changed = false;
213 for part in parts.iter_mut() {
214 if let Some(Value::String(text)) = part.get_mut("text")
215 && let Some(pruned) = super::history_prune::prune_output_text(text, kind)
216 {
217 *text = pruned;
218 changed = true;
219 }
220 }
221 changed
222 }
223 _ => false,
224 }
225}
226
227pub(super) fn compress_responses_input(doc: &mut Value) -> bool {
239 let cfg = crate::core::config::Config::load();
242 if !cfg.proxy.live_compresses() {
243 return false;
244 }
245 let mut modified = false;
246 if let Some(input) = doc.get_mut("input").and_then(|i| i.as_array_mut()) {
247 let tool_names = tool_kind::responses_tool_names(input);
248 for item in input.iter_mut() {
249 if item.get("type").and_then(|t| t.as_str()) != Some("function_call_output") {
250 continue;
251 }
252 let name = item
253 .get("call_id")
254 .and_then(|v| v.as_str())
255 .and_then(|id| tool_names.get(id))
256 .map(String::as_str);
257 if name.is_some_and(|n| cfg.proxy.is_tool_live_compress_excluded(n)) {
260 continue;
261 }
262 let kind = name.map_or(ToolResultKind::Other, tool_kind::classify_tool_name);
263 if let Some(output) = item.get_mut("output") {
264 modified |= compress_output_field(output, name, kind);
265 }
266 }
267 }
268 modified
269}
270
271fn compress_output_field(
278 output: &mut Value,
279 tool_name: Option<&str>,
280 kind: ToolResultKind,
281) -> bool {
282 match output {
283 Value::String(s) => {
284 if should_protect(kind, s) {
285 return false;
286 }
287 let compressed = compress_tool_result(s, tool_name);
288 if compressed.len() < s.len() {
289 *s = compressed;
290 return true;
291 }
292 false
293 }
294 Value::Array(parts) => {
295 let mut changed = false;
296 for part in parts.iter_mut() {
297 if let Some(Value::String(text)) = part.get_mut("text") {
298 if should_protect(kind, text) {
299 continue;
300 }
301 let compressed = compress_tool_result(text, tool_name);
302 if compressed.len() < text.len() {
303 *text = compressed;
304 changed = true;
305 }
306 }
307 }
308 changed
309 }
310 _ => false,
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 fn long_git_status() -> String {
321 let mut s = String::from(
322 "$ 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",
323 );
324 for i in 0..80 {
325 s.push_str(&format!("\tmodified: src/module_{i}/file_{i}.rs\n"));
326 }
327 s.push_str("\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\n");
328 s
329 }
330
331 fn big_prose() -> String {
332 let p = "You are a careful, senior software engineer. You always explain your \
333 reasoning before making changes, you prefer small reviewable diffs, and \
334 you never introduce mock data or placeholders into production code. ";
335 [p; 6].join("\n")
336 }
337
338 #[test]
339 fn string_output_mirrors_engine_and_shrinks() {
340 let _lock = crate::core::data_dir::test_env_lock();
343 let raw = long_git_status();
344 let expected = compress_tool_result(&raw, None);
345 assert!(
346 expected.len() < raw.len(),
347 "fixture must be compressible by the shared engine"
348 );
349
350 let body = serde_json::json!({
351 "model": "gpt-5",
352 "input": [
353 {"type": "function_call_output", "call_id": "call_1", "output": raw}
354 ]
355 });
356 let bytes = serde_json::to_vec(&body).unwrap();
357 let (out, orig, comp) = compress_request_body(body, bytes.len());
358
359 assert!(comp < orig, "compressed body must be smaller");
360 let parsed: Value = serde_json::from_slice(&out).unwrap();
361 assert_eq!(
362 parsed["input"][0]["output"].as_str().unwrap(),
363 expected,
364 "output must be exactly what the shared compressor produces"
365 );
366 }
367
368 #[test]
369 fn array_output_text_is_compressed() {
370 let _lock = crate::core::data_dir::test_env_lock();
373 let raw = long_git_status();
374 let expected = compress_tool_result(&raw, None);
375
376 let body = serde_json::json!({
377 "input": [
378 {
379 "type": "function_call_output",
380 "call_id": "call_1",
381 "output": [{"type": "input_text", "text": raw}]
382 }
383 ]
384 });
385 let bytes = serde_json::to_vec(&body).unwrap();
386 let (out, orig, comp) = compress_request_body(body, bytes.len());
387
388 assert!(comp < orig);
389 let parsed: Value = serde_json::from_slice(&out).unwrap();
390 assert_eq!(
391 parsed["input"][0]["output"][0]["text"].as_str().unwrap(),
392 expected
393 );
394 }
395
396 #[test]
397 fn non_tool_output_items_are_untouched() {
398 let body = serde_json::json!({
399 "input": [
400 {"type": "message", "role": "user", "content": long_git_status()},
401 {"type": "function_call", "call_id": "c", "name": "x", "arguments": "{}"}
402 ]
403 });
404 let bytes = serde_json::to_vec(&body).unwrap();
405 let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
406
407 assert_eq!(comp, orig, "no function_call_output → passthrough");
408 let reparsed: Value = serde_json::from_slice(&out).unwrap();
409 assert_eq!(reparsed, body);
410 }
411
412 #[test]
413 fn plain_string_input_passthrough() {
414 let body = serde_json::json!({"model": "gpt-5", "input": "hello world"});
415 let bytes = serde_json::to_vec(&body).unwrap();
416 let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
417 assert_eq!(comp, orig);
418 let reparsed: Value = serde_json::from_slice(&out).unwrap();
419 assert_eq!(reparsed, body);
420 }
421
422 #[test]
423 fn no_input_field_passthrough() {
424 let body = serde_json::json!({"model": "gpt-5", "previous_response_id": "resp_abc"});
425 let bytes = serde_json::to_vec(&body).unwrap();
426 let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
427 assert_eq!(comp, orig);
428 let reparsed: Value = serde_json::from_slice(&out).unwrap();
429 assert_eq!(reparsed, body);
430 }
431
432 #[test]
433 fn responses_instructions_prose_compressed_and_assistant_untouched() {
434 let _iso = crate::core::data_dir::isolated_data_dir();
435 crate::core::config::Config::update_global(|c| {
436 c.proxy.role_aggressiveness.system = Some(0.6);
437 })
438 .unwrap();
439
440 let prose = big_prose();
441 let body = serde_json::json!({
442 "model": "gpt-5",
443 "instructions": prose,
444 "input": [
445 {"type": "message", "role": "user", "content": "hi"},
446 {"type": "message", "role": "assistant", "content": prose},
447 ]
448 });
449 let bytes = serde_json::to_vec(&body).unwrap();
450 let (out, orig, comp) = compress_request_body(body, bytes.len());
451 assert!(comp < orig, "enabled instructions prose must save bytes");
452 let parsed: Value = serde_json::from_slice(&out).unwrap();
453
454 assert!(
455 parsed["instructions"].as_str().unwrap().len() < prose.len(),
456 "Responses instructions must be compressed when enabled"
457 );
458 assert_eq!(
459 parsed["input"][1]["content"].as_str().unwrap(),
460 prose,
461 "assistant turns must pass through verbatim (#710)"
462 );
463 }
464
465 #[test]
466 fn responses_user_prose_compressed_only_in_frozen_region() {
467 let _iso = crate::core::data_dir::isolated_data_dir();
468 crate::core::config::Config::update_global(|c| {
469 c.proxy.role_aggressiveness.user = Some(0.7);
470 })
471 .unwrap();
472
473 let prose = big_prose();
474 let mut input = Vec::new();
476 for i in 0..30 {
477 let role = if i % 2 == 0 { "user" } else { "assistant" };
478 input.push(serde_json::json!({
479 "type": "message",
480 "role": role,
481 "content": prose,
482 }));
483 }
484 let body = serde_json::json!({"model": "gpt-5", "input": input});
485 let bytes = serde_json::to_vec(&body).unwrap();
486 let (out, orig, comp) = compress_request_body(body, bytes.len());
487 assert!(comp < orig, "old user prose must save bytes");
488 let parsed: Value = serde_json::from_slice(&out).unwrap();
489
490 let frozen_user = parsed["input"][0]["content"].as_str().unwrap();
491 assert!(
492 frozen_user.len() < prose.len(),
493 "old user prose should compress"
494 );
495 assert_eq!(
496 parsed["input"][1]["content"].as_str().unwrap(),
497 prose,
498 "assistant prose must stay verbatim"
499 );
500 assert_eq!(
501 parsed["input"][16]["content"].as_str().unwrap(),
502 prose,
503 "live-tail user prose must stay verbatim"
504 );
505 }
506
507 #[test]
508 fn responses_prose_compression_is_deterministic() {
509 let _iso = crate::core::data_dir::isolated_data_dir();
510 crate::core::config::Config::update_global(|c| {
511 c.proxy.role_aggressiveness.system = Some(0.6);
512 })
513 .unwrap();
514
515 let prose = big_prose();
516 let mk = || {
517 serde_json::json!({
518 "model": "gpt-5",
519 "instructions": prose,
520 "input": [{"type": "message", "role": "user", "content": "hi"}],
521 })
522 };
523 let (a, b) = (mk(), mk());
524 let (la, lb) = (
525 serde_json::to_vec(&a).unwrap().len(),
526 serde_json::to_vec(&b).unwrap().len(),
527 );
528 assert_eq!(
529 compress_request_body(a, la).0,
530 compress_request_body(b, lb).0,
531 "identical input must yield byte-identical output (#498)"
532 );
533 }
534
535 #[test]
536 fn short_output_unchanged() {
537 let body = serde_json::json!({
538 "input": [
539 {"type": "function_call_output", "call_id": "c", "output": "ok"}
540 ]
541 });
542 let bytes = serde_json::to_vec(&body).unwrap();
543 let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
544 assert_eq!(comp, orig);
545 let reparsed: Value = serde_json::from_slice(&out).unwrap();
546 assert_eq!(reparsed, body);
547 }
548
549 fn responses_read_turns(pairs: usize) -> Vec<Value> {
552 let code = (0..40)
553 .map(|i| format!(" let v{i} = compute_{i}(ctx, opts);"))
554 .collect::<Vec<_>>()
555 .join("\n");
556 let mut input = Vec::new();
557 for t in 0..pairs {
558 input.push(serde_json::json!({
559 "type": "function_call", "call_id": format!("c{t}"),
560 "name": "read_file", "arguments": "{}"
561 }));
562 input.push(serde_json::json!({
563 "type": "function_call_output", "call_id": format!("c{t}"),
564 "output": format!("{code}\n// turn {t}")
565 }));
566 }
567 input
568 }
569
570 #[test]
571 fn cache_aware_prune_stubs_old_reads_keeps_recent_and_pairing() {
572 let _iso = crate::core::data_dir::isolated_data_dir();
574 let body = serde_json::json!({"model": "gpt-5", "input": responses_read_turns(14)});
576 let item_count = body["input"].as_array().unwrap().len();
577 let bytes = serde_json::to_vec(&body).unwrap();
578 let (out, orig, comp) = compress_request_body(body, bytes.len());
579 assert!(comp < orig, "old reads must be pruned for savings");
580
581 let parsed: Value = serde_json::from_slice(&out).unwrap();
582 let input = parsed["input"].as_array().unwrap();
583 assert_eq!(input.len(), item_count, "no items may be removed (pairing)");
585 for (i, item) in input.iter().enumerate() {
586 let expect = if i.is_multiple_of(2) {
587 "function_call"
588 } else {
589 "function_call_output"
590 };
591 assert_eq!(item["type"], expect, "item {i} type/order changed");
592 }
593 let old = input[1]["output"].as_str().unwrap();
595 assert!(
596 old.contains("Re-read the file"),
597 "old read should be stubbed, got: {old}"
598 );
599 let recent = input[27]["output"].as_str().unwrap();
601 assert!(
602 recent.contains("v39"),
603 "recent read must be protected, got: {recent}"
604 );
605 }
606
607 #[test]
608 fn responses_compression_is_deterministic() {
609 let _iso = crate::core::data_dir::isolated_data_dir();
612 let mk = || serde_json::json!({"model": "gpt-5", "input": responses_read_turns(14)});
613 let (a, b) = (mk(), mk());
614 let (la, lb) = (
615 serde_json::to_vec(&a).unwrap().len(),
616 serde_json::to_vec(&b).unwrap().len(),
617 );
618 let (out_a, _, _) = compress_request_body(a, la);
619 let (out_b, _, _) = compress_request_body(b, lb);
620 assert_eq!(out_a, out_b, "identical input must yield identical bytes");
621 }
622
623 #[test]
624 fn cache_aware_responses_prefix_is_byte_stable_across_turns() {
625 let _iso = crate::core::data_dir::isolated_data_dir();
629 let mut prev: Vec<String> = Vec::new();
630 let mut prev_boundary = 0;
631 for pairs in 1..=20 {
632 let input = responses_read_turns(pairs);
633 let len = input.len();
634 let body = serde_json::json!({"model": "gpt-5", "input": input});
635 let bytes = serde_json::to_vec(&body).unwrap();
636 let (out, _, _) = compress_request_body(body, bytes.len());
637 let parsed: Value = serde_json::from_slice(&out).unwrap();
638 let items: Vec<String> = parsed["input"]
639 .as_array()
640 .unwrap()
641 .iter()
642 .map(Value::to_string)
643 .collect();
644 for i in 0..prev_boundary {
645 assert_eq!(
646 prev[i], items[i],
647 "Responses item {i} changed at turn {pairs} — prompt cache prefix broken"
648 );
649 }
650 prev = items;
651 prev_boundary = crate::proxy::history_prune::prune_boundary(
652 crate::core::config::HistoryMode::CacheAware,
653 len,
654 );
655 }
656 }
657
658 #[test]
659 fn effort_control_sets_nested_reasoning_effort() {
660 let _iso = crate::core::data_dir::isolated_data_dir();
662 crate::test_env::remove_var("LEAN_CTX_PROXY_EFFORT");
663 crate::core::config::Config::update_global(|c| {
664 c.proxy.effort = Some("low".into());
665 })
666 .unwrap();
667 let body = serde_json::json!({"model": "gpt-5.5", "input": []});
668 let bytes = serde_json::to_vec(&body).unwrap();
669 let (out, _o, _c) = compress_request_body(body, bytes.len());
670 assert_eq!(
671 serde_json::from_slice::<Value>(&out).unwrap()["reasoning"]["effort"],
672 "low"
673 );
674 }
675}