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::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
16/// Proxy handler for OpenAI's Responses API (`POST /v1/responses`).
17///
18/// The Responses API superseded Chat Completions for clients such as opencode
19/// and the OpenAI Agents SDK. Its conversation turns live in `input` rather than
20/// `messages`, so the Chat Completions handler never saw — and never compressed —
21/// them. This handler reuses the same upstream, auth and streaming path but
22/// understands the Responses-API request shape.
23///
24/// Retrieve / cancel / delete / input_items sub-paths
25/// (`/v1/responses/{id}/...`) are routed here as well and pass through untouched:
26/// they carry no `input` array, so `compress_request_body` is a no-op for them.
27///
28/// Handles `POST /v1/responses` (and the bare `/responses`) over HTTP/SSE.
29pub 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
46/// Handles the WebSocket Responses transport on `GET /v1/responses`.
47///
48/// Codex (and the OpenAI SDK) default to `ws://…/responses` with one
49/// `response.create` event per turn. Bridging the upgrade here lets the proxy be
50/// a drop-in for Codex without forcing `supports_websockets = false` (#440); the
51/// actual WS↔HTTP/SSE bridging lives in `openai_responses_ws`.
52pub 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    // #493: in-band CCR expansion (opt-in). Splice any <lc_expand:HASH> the model
71    // echoed back into the verbatim original from the local tee store. A strict
72    // no-op when no marker is present (byte-identical body → cache-safe). Runs
73    // before the meter-only short-circuit so an explicit expand request is
74    // honored even when the proxy is otherwise byte-passthrough.
75    let mut modified = false;
76    // #895 Track B: output-savings holdout arm, from the pristine body (before any
77    // mutation below) so it matches the arm the response meter records. Control
78    // conversations skip output-shaping but are still metered. Default 0 → Treatment.
79    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    // #834: cache-safe cross-provider effort control. Default off → no-op. The
87    // value is a constant, so it never perturbs the prompt-cache prefix; it sets
88    // `reasoning.effort` only on reasoning models and never overrides a
89    // client-set value.
90    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        // #895: cache-safe wire verbosity steer; control arm skips it (measured).
95        if cfg.proxy.verbosity_steer_enabled() {
96            modified |= super::verbosity::apply_openai_responses(&mut doc);
97        }
98    }
99    // Meter-only (#481): live compression off and history pruning off → forward
100    // the body unchanged while upstream usage metering still runs. A pending
101    // in-band splice (`modified`) opts out: the body did change this turn.
102    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    // Two-stage, like the Chat Completions path: (1) cache-aware prune of the
116    // frozen OLD region — old file reads collapse to re-read stubs, old logs
117    // head/tail summarize — then (2) compress whatever recent outputs remain.
118    // Stage 1 runs first so a stubbed old output isn't needlessly re-compressed.
119    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
156/// Cache-aware history pruning for the Responses API.
157///
158/// Unlike the Chat Completions path we never *remove* an item: the Responses API
159/// rejects a `function_call` whose matching `function_call_output` is absent (and
160/// reasoning items must keep their originating call). Instead we rewrite the
161/// `output` text of every `function_call_output` in the frozen OLD region
162/// (`input[..boundary]`) — pairing and ordering are untouched, so there is no
163/// risk of a 400.
164///
165/// The boundary is the same monotone staircase as every other rail
166/// ([`history_prune::prune_boundary`]), so the request prefix stays byte-stable
167/// for up to a full stride and OpenAI's automatic prompt cache keeps hitting.
168///
169/// Shared with the WebSocket bridge (#440) so Codex/WS turns prune identically.
170pub(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
199/// Apply [`history_prune::prune_output_text`] to a `function_call_output.output`,
200/// handling both the JSON-string and array-of-content-parts shapes — the
201/// pruning analogue of [`compress_output_field`].
202fn 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
227/// Compresses the `function_call_output.output` entries of a Responses-API body
228/// in place, returning whether anything changed. Shared by the HTTP handler and
229/// the WebSocket bridge (#440) so both paths get identical, safe savings.
230///
231/// The only token sink we shrink is each `function_call_output.output` — the
232/// Responses-API analogue of a Chat Completions `role:"tool"` message. We never
233/// remove or reorder `input` items: the Responses API rejects a `function_call`
234/// whose matching `function_call_output` is absent (and reasoning items must keep
235/// their originating call), so all token reclamation happens *in place* on the
236/// output text. Cache-aware pruning of the frozen OLD region lives in
237/// [`prune_responses_input`]; this pass compresses whatever recent outputs remain.
238pub(super) fn compress_responses_input(doc: &mut Value) -> bool {
239    // #481: recent-region live compression respects the global toggle. Old-region
240    // pruning stays governed by `history_mode` in `prune_responses_input`.
241    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            // #481: per-tool exclusion (Serena default) — skip live compression
258            // for excluded tools; history pruning above still applies.
259            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
271/// Compress a `function_call_output.output`. OpenAI sends this as a JSON string,
272/// but the API also accepts an array of content parts (`input_text` blocks) for
273/// tools returning richer data, so both shapes are handled.
274///
275/// A protected file/source read (resolved from the matching `function_call`
276/// name) is left intact so a mid-refactor model never loses the body it edits.
277fn 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    /// A long `git status` is a known-compressible fixture: `has_structural_output`
319    /// is false for it, so it flows through the git-status pattern compressor.
320    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        // tee path depends on the data dir; serialize env access so a parallel
341        // test never swaps LEAN_CTX_DATA_DIR between the two compressions (#498).
342        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        // tee path depends on the data dir; serialize env access so a parallel
371        // test never swaps LEAN_CTX_DATA_DIR between the two compressions (#498).
372        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        // 30 messages -> cache-aware boundary = ((30 - 8) / 16) * 16 = 16.
475        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    /// `pairs` Responses turns: each is a `function_call` + its matching
550    /// `function_call_output` carrying a long file read.
551    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        // Default (isolated) config = cache-aware history mode.
573        let _iso = crate::core::data_dir::isolated_data_dir();
574        // 14 pairs = 28 items → staircase boundary 16.
575        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        // Pairing + ordering preserved: not a single item dropped or moved.
584        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        // An OLD file read (output index 1, before boundary 16) is stubbed.
594        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        // A RECENT file read (output index 27, after the boundary) keeps its body.
600        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        // #498: the same request must compress to byte-identical output so the
610        // provider's prompt cache (and our regression diffs) stay stable.
611        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        // THE cache invariant for the Responses rail: as `input` grows turn by
626        // turn, every item before an already-passed boundary must stay
627        // byte-identical, or OpenAI's automatic prompt cache stops hitting.
628        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        // #834 end-to-end through the Responses request path.
661        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}