Skip to main content

lean_ctx/proxy/
openai_responses.rs

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::forward;
11use super::tool_kind::{self, ToolResultKind};
12use super::{cache_safety, prose};
13use crate::core::config::{HistoryMode, ProseRole};
14
15/// Proxy handler for OpenAI's Responses API (`POST /v1/responses`).
16///
17/// The Responses API superseded Chat Completions for clients such as opencode
18/// and the OpenAI Agents SDK. Its conversation turns live in `input` rather than
19/// `messages`, so the Chat Completions handler never saw — and never compressed —
20/// them. This handler reuses the same upstream, auth and streaming path but
21/// understands the Responses-API request shape.
22///
23/// Retrieve / cancel / delete / input_items sub-paths
24/// (`/v1/responses/{id}/...`) are routed here as well and pass through untouched:
25/// they carry no `input` array, so `compress_request_body` is a no-op for them.
26///
27/// Handles `POST /v1/responses` (and the bare `/responses`) over HTTP/SSE.
28pub async fn handler(
29    State(state): State<ProxyState>,
30    req: Request<Body>,
31) -> Result<Response, StatusCode> {
32    let upstream = state.openai_upstream();
33    forward::forward_request(
34        State(state),
35        req,
36        &upstream,
37        "/v1/responses",
38        compress_request_body,
39        "OpenAI",
40        &[],
41    )
42    .await
43}
44
45/// Handles the WebSocket Responses transport on `GET /v1/responses`.
46///
47/// Codex (and the OpenAI SDK) default to `ws://…/responses` with one
48/// `response.create` event per turn. Bridging the upgrade here lets the proxy be
49/// a drop-in for Codex without forcing `supports_websockets = false` (#440); the
50/// actual WS↔HTTP/SSE bridging lives in `openai_responses_ws`.
51pub async fn ws_handler(
52    State(state): State<ProxyState>,
53    headers: axum::http::HeaderMap,
54    ws: axum::extract::ws::WebSocketUpgrade,
55) -> Response {
56    super::openai_responses_ws::upgrade(state, ws, &headers)
57}
58
59pub(super) fn compress_request_body(
60    parsed: Value,
61    original_size: usize,
62) -> (Vec<u8>, usize, usize) {
63    let mut doc = parsed;
64    let cfg = crate::core::config::Config::load();
65    let system_aggr = cfg.proxy.resolved_role_aggressiveness(ProseRole::System);
66    let user_aggr = cfg.proxy.resolved_role_aggressiveness(ProseRole::User);
67    let live_compress = cfg.proxy.live_compresses();
68    let mode = cfg.proxy.resolved_history_mode();
69    // #493: in-band CCR expansion (opt-in). Splice any <lc_expand:HASH> the model
70    // echoed back into the verbatim original from the local tee store. A strict
71    // no-op when no marker is present (byte-identical body → cache-safe). Runs
72    // before the meter-only short-circuit so an explicit expand request is
73    // honored even when the proxy is otherwise byte-passthrough.
74    let mut modified = false;
75    // #895 Track B: output-savings holdout arm, from the pristine body (before any
76    // mutation below) so it matches the arm the response meter records. Control
77    // conversations skip output-shaping but are still metered. Default 0 → Treatment.
78    let arm = super::holdout::assign(
79        &super::holdout::openai_responses_key(&doc),
80        cfg.proxy.output_holdout_fraction(),
81    );
82    if cfg.proxy.ccr_inband_enabled() {
83        modified |= super::ccr::splice_inband_in_place(&mut doc);
84    }
85    // #834: cache-safe cross-provider effort control. Default off → no-op. The
86    // value is a constant, so it never perturbs the prompt-cache prefix; it sets
87    // `reasoning.effort` only on reasoning models and never overrides a
88    // client-set value.
89    if arm == super::holdout::Arm::Treatment {
90        if let Some(effort) = cfg.proxy.resolved_effort() {
91            modified |= super::effort::apply_openai_responses(&mut doc, effort);
92        }
93        // #895: cache-safe wire verbosity steer; control arm skips it (measured).
94        if cfg.proxy.verbosity_steer_enabled() {
95            modified |= super::verbosity::apply_openai_responses(&mut doc);
96        }
97    }
98    // Meter-only (#481): live compression off and history pruning off → forward
99    // the body unchanged while upstream usage metering still runs. A pending
100    // in-band splice (`modified`) opts out: the body did change this turn.
101    if !live_compress
102        && mode == HistoryMode::Off
103        && system_aggr.is_none()
104        && user_aggr.is_none()
105        && !modified
106    {
107        let out = serde_json::to_vec(&doc).unwrap_or_default();
108        return (out, original_size, original_size);
109    }
110    let mut prose_segments: u64 = 0;
111    if let Some(a) = system_aggr {
112        prose_segments += u64::from(prose::compress_string_field(&mut doc, "instructions", a));
113    }
114    // Two-stage, like the Chat Completions path: (1) cache-aware prune of the
115    // frozen OLD region — old file reads collapse to re-read stubs, old logs
116    // head/tail summarize — then (2) compress whatever recent outputs remain.
117    // Stage 1 runs first so a stubbed old output isn't needlessly re-compressed.
118    modified |= prune_responses_input(&mut doc);
119    modified |= compress_responses_input(&mut doc);
120    if let Some(a) = user_aggr {
121        prose_segments += u64::from(compress_responses_user_prose(&mut doc, mode, a));
122    }
123    if prose_segments > 0 {
124        modified = true;
125    }
126    cache_safety::record(prose_segments, true);
127    let out = serde_json::to_vec(&doc).unwrap_or_default();
128    let compressed_size = if modified { out.len() } else { original_size };
129    (out, original_size, compressed_size)
130}
131
132fn compress_responses_user_prose(doc: &mut Value, mode: HistoryMode, aggressiveness: f64) -> u32 {
133    let Some(input) = doc.get_mut("input").and_then(|i| i.as_array_mut()) else {
134        return 0;
135    };
136    let boundary = super::history_prune::prune_boundary(mode, input.len());
137    if boundary == 0 {
138        return 0;
139    }
140
141    let mut segments = 0;
142    for item in input.iter_mut().take(boundary) {
143        let item_type = item
144            .get("type")
145            .and_then(|t| t.as_str())
146            .unwrap_or("message");
147        let role = item.get("role").and_then(|r| r.as_str());
148        if item_type == "message" && role == Some("user") {
149            segments += prose::compress_message_content(item, aggressiveness);
150        }
151    }
152    segments
153}
154
155/// Cache-aware history pruning for the Responses API.
156///
157/// Unlike the Chat Completions path we never *remove* an item: the Responses API
158/// rejects a `function_call` whose matching `function_call_output` is absent (and
159/// reasoning items must keep their originating call). Instead we rewrite the
160/// `output` text of every `function_call_output` in the frozen OLD region
161/// (`input[..boundary]`) — pairing and ordering are untouched, so there is no
162/// risk of a 400.
163///
164/// The boundary is the same monotone staircase as every other rail
165/// ([`history_prune::prune_boundary`]), so the request prefix stays byte-stable
166/// for up to a full stride and OpenAI's automatic prompt cache keeps hitting.
167///
168/// Shared with the WebSocket bridge (#440) so Codex/WS turns prune identically.
169pub(super) fn prune_responses_input(doc: &mut Value) -> bool {
170    let mode = crate::core::config::Config::load()
171        .proxy
172        .resolved_history_mode();
173    let Some(input) = doc.get_mut("input").and_then(|i| i.as_array_mut()) else {
174        return false;
175    };
176    let boundary = super::history_prune::prune_boundary(mode, input.len());
177    if boundary == 0 {
178        return false;
179    }
180    let tool_names = tool_kind::responses_tool_names(input);
181    let mut modified = false;
182    for item in input.iter_mut().take(boundary) {
183        if item.get("type").and_then(|t| t.as_str()) != Some("function_call_output") {
184            continue;
185        }
186        let kind = item
187            .get("call_id")
188            .and_then(|v| v.as_str())
189            .and_then(|id| tool_names.get(id))
190            .map_or(ToolResultKind::Other, |n| tool_kind::classify_tool_name(n));
191        if let Some(output) = item.get_mut("output") {
192            modified |= prune_output_field(output, kind);
193        }
194    }
195    modified
196}
197
198/// Apply [`history_prune::prune_output_text`] to a `function_call_output.output`,
199/// handling both the JSON-string and array-of-content-parts shapes — the
200/// pruning analogue of [`compress_output_field`].
201fn prune_output_field(output: &mut Value, kind: ToolResultKind) -> bool {
202    super::tool_output::prune_value(output, kind)
203}
204
205/// Compresses the `function_call_output.output` entries of a Responses-API body
206/// in place, returning whether anything changed. Shared by the HTTP handler and
207/// the WebSocket bridge (#440) so both paths get identical, safe savings.
208///
209/// The only token sink we shrink is each `function_call_output.output` — the
210/// Responses-API analogue of a Chat Completions `role:"tool"` message. We never
211/// remove or reorder `input` items: the Responses API rejects a `function_call`
212/// whose matching `function_call_output` is absent (and reasoning items must keep
213/// their originating call), so all token reclamation happens *in place* on the
214/// output text. Cache-aware pruning of the frozen OLD region lives in
215/// [`prune_responses_input`]; this pass compresses whatever recent outputs remain.
216pub(super) fn compress_responses_input(doc: &mut Value) -> bool {
217    // #481: recent-region live compression respects the global toggle. Old-region
218    // pruning stays governed by `history_mode` in `prune_responses_input`.
219    let cfg = crate::core::config::Config::load();
220    if !cfg.proxy.live_compresses() {
221        return false;
222    }
223    let mut modified = false;
224    if let Some(input) = doc.get_mut("input").and_then(|i| i.as_array_mut()) {
225        let tool_names = tool_kind::responses_tool_names(input);
226        for item in input.iter_mut() {
227            if item.get("type").and_then(|t| t.as_str()) != Some("function_call_output") {
228                continue;
229            }
230            let name = item
231                .get("call_id")
232                .and_then(|v| v.as_str())
233                .and_then(|id| tool_names.get(id))
234                .map(String::as_str);
235            // #481: per-tool exclusion (Serena default) — skip live compression
236            // for excluded tools; history pruning above still applies.
237            if name.is_some_and(|n| cfg.proxy.is_tool_live_compress_excluded(n)) {
238                continue;
239            }
240            let kind = name.map_or(ToolResultKind::Other, tool_kind::classify_tool_name);
241            if let Some(output) = item.get_mut("output") {
242                modified |= compress_output_field(output, name, kind);
243            }
244        }
245    }
246    modified
247}
248
249/// Compress a `function_call_output.output`. OpenAI sends this as a JSON string,
250/// but the API also accepts an array of content parts (`input_text` blocks) for
251/// tools returning richer data, so both shapes are handled.
252///
253/// A protected file/source read (resolved from the matching `function_call`
254/// name) is left intact so a mid-refactor model never loses the body it edits.
255fn compress_output_field(
256    output: &mut Value,
257    tool_name: Option<&str>,
258    kind: ToolResultKind,
259) -> bool {
260    super::tool_output::compress_value(output, tool_name, kind)
261}
262
263#[cfg(test)]
264mod tests {
265    use super::super::compress::compress_tool_result;
266    use super::*;
267
268    /// A long `git status` is a known-compressible fixture: `has_structural_output`
269    /// is false for it, so it flows through the git-status pattern compressor.
270    fn long_git_status() -> String {
271        let mut s = String::from(
272            "$ 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",
273        );
274        for i in 0..80 {
275            s.push_str(&format!("\tmodified:   src/module_{i}/file_{i}.rs\n"));
276        }
277        s.push_str("\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\n");
278        s
279    }
280
281    fn big_prose() -> String {
282        let p = "You are a careful, senior software engineer. You always explain your \
283                 reasoning before making changes, you prefer small reviewable diffs, and \
284                 you never introduce mock data or placeholders into production code. ";
285        [p; 6].join("\n")
286    }
287
288    fn long_tool_json() -> String {
289        let rows = (0..32)
290            .map(|i| {
291                serde_json::json!({
292                    "path": format!("/Users/alex/work/app/src/module_{i}.rs"),
293                    "regex": r"src/[a-z_]+\.rs:\d+",
294                    "error": format!("error[E0{i:03}]: expected exact diagnostic text"),
295                })
296            })
297            .collect::<Vec<_>>();
298        serde_json::to_string(&serde_json::json!({ "results": rows })).unwrap()
299    }
300
301    #[test]
302    fn shell_json_envelope_text_is_compressed() {
303        let _lock = crate::core::data_dir::test_env_lock();
304        let raw = long_git_status();
305        let expected = compress_tool_result(&raw, Some("Bash"));
306        let envelope = serde_json::to_string(&serde_json::json!({
307            "content": [{"type": "text", "text": raw}],
308            "isError": false,
309        }))
310        .unwrap();
311
312        let body = serde_json::json!({
313            "model": "gpt-5",
314            "input": [
315                {"type": "function_call", "call_id": "call_1", "name": "Bash", "arguments": "{}"},
316                {"type": "function_call_output", "call_id": "call_1", "output": envelope}
317            ]
318        });
319        let bytes = serde_json::to_vec(&body).unwrap();
320        let (out, orig, comp) = compress_request_body(body, bytes.len());
321
322        assert!(comp < orig);
323        let parsed: Value = serde_json::from_slice(&out).unwrap();
324        let output = parsed["input"][1]["output"].as_str().unwrap();
325        let envelope: Value = serde_json::from_str(output).unwrap();
326        assert_eq!(envelope["content"][0]["text"].as_str().unwrap(), expected);
327    }
328
329    #[test]
330    fn shell_json_envelope_non_text_field_is_compressed() {
331        let _lock = crate::core::data_dir::test_env_lock();
332        let raw = long_git_status();
333        let expected = compress_tool_result(&raw, Some("Bash"));
334        // Big shell output in a non-"text" field: the Shell/Search all-strings
335        // rewrite must still reach it, while small scalar fields stay intact.
336        let envelope = serde_json::to_string(&serde_json::json!({
337            "stdout": raw,
338            "exit_code": 0,
339        }))
340        .unwrap();
341
342        let body = serde_json::json!({
343            "model": "gpt-5",
344            "input": [
345                {"type": "function_call", "call_id": "call_1", "name": "Bash", "arguments": "{}"},
346                {"type": "function_call_output", "call_id": "call_1", "output": envelope}
347            ]
348        });
349        let bytes = serde_json::to_vec(&body).unwrap();
350        let (out, orig, comp) = compress_request_body(body, bytes.len());
351
352        assert!(comp < orig, "non-text shell field should be compressed");
353        let parsed: Value = serde_json::from_slice(&out).unwrap();
354        let output = parsed["input"][1]["output"].as_str().unwrap();
355        let envelope: Value = serde_json::from_str(output).unwrap();
356        assert_eq!(envelope["stdout"].as_str().unwrap(), expected);
357        assert_eq!(envelope["exit_code"].as_i64().unwrap(), 0);
358    }
359
360    #[test]
361    fn old_shell_json_envelope_text_is_pruned() {
362        let raw = long_git_status();
363        let envelope = serde_json::to_string(&serde_json::json!({
364            "content": [{"type": "text", "text": raw}],
365            "isError": false,
366        }))
367        .unwrap();
368        let mut output = Value::String(envelope);
369
370        assert!(prune_output_field(&mut output, ToolResultKind::Shell));
371        let envelope: Value = serde_json::from_str(output.as_str().unwrap()).unwrap();
372        assert!(
373            envelope["content"][0]["text"].as_str().unwrap().len() < raw.len(),
374            "nested text payload should be pruned"
375        );
376    }
377
378    #[test]
379    fn string_output_mirrors_engine_and_shrinks() {
380        // tee path depends on the data dir; serialize env access so a parallel
381        // test never swaps LEAN_CTX_DATA_DIR between the two compressions (#498).
382        let _lock = crate::core::data_dir::test_env_lock();
383        let raw = long_git_status();
384        let expected = compress_tool_result(&raw, None);
385        assert!(
386            expected.len() < raw.len(),
387            "fixture must be compressible by the shared engine"
388        );
389
390        let body = serde_json::json!({
391            "model": "gpt-5",
392            "input": [
393                {"type": "function_call_output", "call_id": "call_1", "output": raw}
394            ]
395        });
396        let bytes = serde_json::to_vec(&body).unwrap();
397        let (out, orig, comp) = compress_request_body(body, bytes.len());
398
399        assert!(comp < orig, "compressed body must be smaller");
400        let parsed: Value = serde_json::from_slice(&out).unwrap();
401        assert_eq!(
402            parsed["input"][0]["output"].as_str().unwrap(),
403            expected,
404            "output must be exactly what the shared compressor produces"
405        );
406    }
407
408    #[test]
409    fn array_output_text_is_compressed() {
410        // tee path depends on the data dir; serialize env access so a parallel
411        // test never swaps LEAN_CTX_DATA_DIR between the two compressions (#498).
412        let _lock = crate::core::data_dir::test_env_lock();
413        let raw = long_git_status();
414        let expected = compress_tool_result(&raw, None);
415
416        let body = serde_json::json!({
417            "input": [
418                {
419                    "type": "function_call_output",
420                    "call_id": "call_1",
421                    "output": [{"type": "input_text", "text": raw}]
422                }
423            ]
424        });
425        let bytes = serde_json::to_vec(&body).unwrap();
426        let (out, orig, comp) = compress_request_body(body, bytes.len());
427
428        assert!(comp < orig);
429        let parsed: Value = serde_json::from_slice(&out).unwrap();
430        assert_eq!(
431            parsed["input"][0]["output"][0]["text"].as_str().unwrap(),
432            expected
433        );
434    }
435
436    #[test]
437    fn non_tool_output_items_are_untouched() {
438        let body = serde_json::json!({
439            "input": [
440                {"type": "message", "role": "user", "content": long_git_status()},
441                {"type": "function_call", "call_id": "c", "name": "x", "arguments": "{}"}
442            ]
443        });
444        let bytes = serde_json::to_vec(&body).unwrap();
445        let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
446
447        assert_eq!(comp, orig, "no function_call_output → passthrough");
448        let reparsed: Value = serde_json::from_slice(&out).unwrap();
449        assert_eq!(reparsed, body);
450    }
451
452    #[test]
453    fn plain_string_input_passthrough() {
454        let body = serde_json::json!({"model": "gpt-5", "input": "hello world"});
455        let bytes = serde_json::to_vec(&body).unwrap();
456        let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
457        assert_eq!(comp, orig);
458        let reparsed: Value = serde_json::from_slice(&out).unwrap();
459        assert_eq!(reparsed, body);
460    }
461
462    #[test]
463    fn no_input_field_passthrough() {
464        let body = serde_json::json!({"model": "gpt-5", "previous_response_id": "resp_abc"});
465        let bytes = serde_json::to_vec(&body).unwrap();
466        let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
467        assert_eq!(comp, orig);
468        let reparsed: Value = serde_json::from_slice(&out).unwrap();
469        assert_eq!(reparsed, body);
470    }
471
472    #[test]
473    fn chatgpt_responses_eval_fixture_keeps_exact_payloads_and_pairing() {
474        let _iso = crate::core::data_dir::isolated_data_dir();
475        crate::core::config::Config::update_global(|c| {
476            c.proxy.role_aggressiveness.user = Some(0.8);
477        })
478        .unwrap();
479
480        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";
481        let body = serde_json::json!({"model": "gpt-5", "input": command_input});
482        let bytes = serde_json::to_vec(&body).unwrap();
483        let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
484        assert_eq!(comp, orig, "top-level exact command string must stay raw");
485        assert_eq!(serde_json::from_slice::<Value>(&out).unwrap(), body);
486
487        let old_json = long_tool_json();
488        let recent_json = long_tool_json();
489        let old_shell = long_git_status();
490        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";
491        let mut input = vec![
492            serde_json::json!({"type": "reasoning", "id": "rs_1", "summary": []}),
493            serde_json::json!({"type": "function_call", "call_id": "json_old", "name": "submit_tool_json", "arguments": "{\"strict\":true}"}),
494            serde_json::json!({"type": "function_call_output", "call_id": "json_old", "output": old_json}),
495            serde_json::json!({"type": "function_call", "call_id": "shell_old", "name": "Bash", "arguments": "{\"cmd\":\"git status\"}"}),
496            serde_json::json!({"type": "function_call_output", "call_id": "shell_old", "output": old_shell}),
497            serde_json::json!({"type": "message", "role": "user", "content": [{"type": "input_text", "text": input_text_block}]}),
498        ];
499        while input.len() < 22 {
500            input.push(serde_json::json!({
501                "type": "message",
502                "role": "user",
503                "content": format!("filler {}", input.len()),
504            }));
505        }
506        input.push(serde_json::json!({"type": "function_call", "call_id": "json_recent", "name": "submit_tool_json", "arguments": "{\"strict\":true}"}));
507        input.push(serde_json::json!({"type": "function_call_output", "call_id": "json_recent", "output": recent_json}));
508
509        let body = serde_json::json!({"model": "gpt-5", "input": input});
510        let item_count = body["input"].as_array().unwrap().len();
511        let bytes = serde_json::to_vec(&body).unwrap();
512        let (out, orig, comp) = compress_request_body(body, bytes.len());
513        assert!(comp < orig, "old shell output should still provide savings");
514
515        let parsed: Value = serde_json::from_slice(&out).unwrap();
516        let input = parsed["input"].as_array().unwrap();
517        assert_eq!(input.len(), item_count, "no Responses item may be dropped");
518        assert_eq!(input[0]["type"], "reasoning");
519        assert_eq!(input[1]["type"], "function_call");
520        assert_eq!(input[2]["type"], "function_call_output");
521        assert_eq!(input[2]["output"].as_str().unwrap(), old_json);
522        assert_ne!(input[4]["output"].as_str().unwrap(), old_shell);
523        assert_eq!(
524            input[5]["content"][0]["text"].as_str().unwrap(),
525            input_text_block
526        );
527        assert_eq!(input[22]["type"], "function_call");
528        assert_eq!(input[23]["type"], "function_call_output");
529        assert_eq!(input[23]["output"].as_str().unwrap(), recent_json);
530        assert_eq!(input[1]["call_id"], input[2]["call_id"]);
531        assert_eq!(input[22]["call_id"], input[23]["call_id"]);
532    }
533
534    #[test]
535    fn responses_instructions_prose_compressed_and_assistant_untouched() {
536        let _iso = crate::core::data_dir::isolated_data_dir();
537        crate::core::config::Config::update_global(|c| {
538            c.proxy.role_aggressiveness.system = Some(0.6);
539        })
540        .unwrap();
541
542        let prose = big_prose();
543        let body = serde_json::json!({
544            "model": "gpt-5",
545            "instructions": prose,
546            "input": [
547                {"type": "message", "role": "user", "content": "hi"},
548                {"type": "message", "role": "assistant", "content": prose},
549            ]
550        });
551        let bytes = serde_json::to_vec(&body).unwrap();
552        let (out, orig, comp) = compress_request_body(body, bytes.len());
553        assert!(comp < orig, "enabled instructions prose must save bytes");
554        let parsed: Value = serde_json::from_slice(&out).unwrap();
555
556        assert!(
557            parsed["instructions"].as_str().unwrap().len() < prose.len(),
558            "Responses instructions must be compressed when enabled"
559        );
560        assert_eq!(
561            parsed["input"][1]["content"].as_str().unwrap(),
562            prose,
563            "assistant turns must pass through verbatim (#710)"
564        );
565    }
566
567    #[test]
568    fn responses_user_prose_compressed_only_in_frozen_region() {
569        let _iso = crate::core::data_dir::isolated_data_dir();
570        crate::core::config::Config::update_global(|c| {
571            c.proxy.role_aggressiveness.user = Some(0.7);
572        })
573        .unwrap();
574
575        let prose = big_prose();
576        // 30 messages -> cache-aware boundary = ((30 - 8) / 16) * 16 = 16.
577        let mut input = Vec::new();
578        for i in 0..30 {
579            let role = if i % 2 == 0 { "user" } else { "assistant" };
580            input.push(serde_json::json!({
581                "type": "message",
582                "role": role,
583                "content": prose,
584            }));
585        }
586        let body = serde_json::json!({"model": "gpt-5", "input": input});
587        let bytes = serde_json::to_vec(&body).unwrap();
588        let (out, orig, comp) = compress_request_body(body, bytes.len());
589        assert!(comp < orig, "old user prose must save bytes");
590        let parsed: Value = serde_json::from_slice(&out).unwrap();
591
592        let frozen_user = parsed["input"][0]["content"].as_str().unwrap();
593        assert!(
594            frozen_user.len() < prose.len(),
595            "old user prose should compress"
596        );
597        assert_eq!(
598            parsed["input"][1]["content"].as_str().unwrap(),
599            prose,
600            "assistant prose must stay verbatim"
601        );
602        assert_eq!(
603            parsed["input"][16]["content"].as_str().unwrap(),
604            prose,
605            "live-tail user prose must stay verbatim"
606        );
607    }
608
609    #[test]
610    fn responses_prose_compression_is_deterministic() {
611        let _iso = crate::core::data_dir::isolated_data_dir();
612        crate::core::config::Config::update_global(|c| {
613            c.proxy.role_aggressiveness.system = Some(0.6);
614        })
615        .unwrap();
616
617        let prose = big_prose();
618        let mk = || {
619            serde_json::json!({
620                "model": "gpt-5",
621                "instructions": prose,
622                "input": [{"type": "message", "role": "user", "content": "hi"}],
623            })
624        };
625        let (a, b) = (mk(), mk());
626        let (la, lb) = (
627            serde_json::to_vec(&a).unwrap().len(),
628            serde_json::to_vec(&b).unwrap().len(),
629        );
630        assert_eq!(
631            compress_request_body(a, la).0,
632            compress_request_body(b, lb).0,
633            "identical input must yield byte-identical output (#498)"
634        );
635    }
636
637    #[test]
638    fn short_output_unchanged() {
639        let body = serde_json::json!({
640            "input": [
641                {"type": "function_call_output", "call_id": "c", "output": "ok"}
642            ]
643        });
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    /// `pairs` Responses turns: each is a `function_call` + its matching
652    /// `function_call_output` carrying a long file read.
653    fn responses_read_turns(pairs: usize) -> Vec<Value> {
654        let code = (0..40)
655            .map(|i| format!("    let v{i} = compute_{i}(ctx, opts);"))
656            .collect::<Vec<_>>()
657            .join("\n");
658        let mut input = Vec::new();
659        for t in 0..pairs {
660            input.push(serde_json::json!({
661                "type": "function_call", "call_id": format!("c{t}"),
662                "name": "read_file", "arguments": "{}"
663            }));
664            input.push(serde_json::json!({
665                "type": "function_call_output", "call_id": format!("c{t}"),
666                "output": format!("{code}\n// turn {t}")
667            }));
668        }
669        input
670    }
671
672    #[test]
673    fn cache_aware_prune_stubs_old_reads_keeps_recent_and_pairing() {
674        // Default (isolated) config = cache-aware history mode.
675        let _iso = crate::core::data_dir::isolated_data_dir();
676        // 14 pairs = 28 items → staircase boundary 16.
677        let body = serde_json::json!({"model": "gpt-5", "input": responses_read_turns(14)});
678        let item_count = body["input"].as_array().unwrap().len();
679        let bytes = serde_json::to_vec(&body).unwrap();
680        let (out, orig, comp) = compress_request_body(body, bytes.len());
681        assert!(comp < orig, "old reads must be pruned for savings");
682
683        let parsed: Value = serde_json::from_slice(&out).unwrap();
684        let input = parsed["input"].as_array().unwrap();
685        // Pairing + ordering preserved: not a single item dropped or moved.
686        assert_eq!(input.len(), item_count, "no items may be removed (pairing)");
687        for (i, item) in input.iter().enumerate() {
688            let expect = if i.is_multiple_of(2) {
689                "function_call"
690            } else {
691                "function_call_output"
692            };
693            assert_eq!(item["type"], expect, "item {i} type/order changed");
694        }
695        // An OLD file read (output index 1, before boundary 16) is stubbed.
696        let old = input[1]["output"].as_str().unwrap();
697        assert!(
698            old.contains("Re-read the file"),
699            "old read should be stubbed, got: {old}"
700        );
701        // A RECENT file read (output index 27, after the boundary) keeps its body.
702        let recent = input[27]["output"].as_str().unwrap();
703        assert!(
704            recent.contains("v39"),
705            "recent read must be protected, got: {recent}"
706        );
707    }
708
709    #[test]
710    fn responses_compression_is_deterministic() {
711        // #498: the same request must compress to byte-identical output so the
712        // provider's prompt cache (and our regression diffs) stay stable.
713        let _iso = crate::core::data_dir::isolated_data_dir();
714        let mk = || serde_json::json!({"model": "gpt-5", "input": responses_read_turns(14)});
715        let (a, b) = (mk(), mk());
716        let (la, lb) = (
717            serde_json::to_vec(&a).unwrap().len(),
718            serde_json::to_vec(&b).unwrap().len(),
719        );
720        let (out_a, _, _) = compress_request_body(a, la);
721        let (out_b, _, _) = compress_request_body(b, lb);
722        assert_eq!(out_a, out_b, "identical input must yield identical bytes");
723    }
724
725    #[test]
726    fn cache_aware_responses_prefix_is_byte_stable_across_turns() {
727        // THE cache invariant for the Responses rail: as `input` grows turn by
728        // turn, every item before an already-passed boundary must stay
729        // byte-identical, or OpenAI's automatic prompt cache stops hitting.
730        let _iso = crate::core::data_dir::isolated_data_dir();
731        let mut prev: Vec<String> = Vec::new();
732        let mut prev_boundary = 0;
733        for pairs in 1..=20 {
734            let input = responses_read_turns(pairs);
735            let len = input.len();
736            let body = serde_json::json!({"model": "gpt-5", "input": input});
737            let bytes = serde_json::to_vec(&body).unwrap();
738            let (out, _, _) = compress_request_body(body, bytes.len());
739            let parsed: Value = serde_json::from_slice(&out).unwrap();
740            let items: Vec<String> = parsed["input"]
741                .as_array()
742                .unwrap()
743                .iter()
744                .map(Value::to_string)
745                .collect();
746            for i in 0..prev_boundary {
747                assert_eq!(
748                    prev[i], items[i],
749                    "Responses item {i} changed at turn {pairs} — prompt cache prefix broken"
750                );
751            }
752            prev = items;
753            prev_boundary = crate::proxy::history_prune::prune_boundary(
754                crate::core::config::HistoryMode::CacheAware,
755                len,
756            );
757        }
758    }
759
760    #[test]
761    fn effort_control_sets_nested_reasoning_effort() {
762        // #834 end-to-end through the Responses request path.
763        let _iso = crate::core::data_dir::isolated_data_dir();
764        crate::test_env::remove_var("LEAN_CTX_PROXY_EFFORT");
765        crate::core::config::Config::update_global(|c| {
766            c.proxy.effort = Some("low".into());
767        })
768        .unwrap();
769        let body = serde_json::json!({"model": "gpt-5.5", "input": []});
770        let bytes = serde_json::to_vec(&body).unwrap();
771        let (out, _o, _c) = compress_request_body(body, bytes.len());
772        assert_eq!(
773            serde_json::from_slice::<Value>(&out).unwrap()["reasoning"]["effort"],
774            "low"
775        );
776    }
777}