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 _iso = crate::core::data_dir::isolated_data_dir();
363        let raw = long_git_status();
364        let envelope = serde_json::to_string(&serde_json::json!({
365            "content": [{"type": "text", "text": raw}],
366            "isError": false,
367        }))
368        .unwrap();
369        let mut output = Value::String(envelope);
370
371        assert!(prune_output_field(&mut output, ToolResultKind::Shell));
372        let envelope: Value = serde_json::from_str(output.as_str().unwrap()).unwrap();
373        assert!(
374            envelope["content"][0]["text"].as_str().unwrap().len() < raw.len(),
375            "nested text payload should be pruned"
376        );
377    }
378
379    #[test]
380    fn string_output_mirrors_engine_and_shrinks() {
381        // tee path depends on the data dir; serialize env access so a parallel
382        // test never swaps LEAN_CTX_DATA_DIR between the two compressions (#498).
383        let _lock = crate::core::data_dir::test_env_lock();
384        let raw = long_git_status();
385        let expected = compress_tool_result(&raw, None);
386        assert!(
387            expected.len() < raw.len(),
388            "fixture must be compressible by the shared engine"
389        );
390
391        let body = serde_json::json!({
392            "model": "gpt-5",
393            "input": [
394                {"type": "function_call_output", "call_id": "call_1", "output": raw}
395            ]
396        });
397        let bytes = serde_json::to_vec(&body).unwrap();
398        let (out, orig, comp) = compress_request_body(body, bytes.len());
399
400        assert!(comp < orig, "compressed body must be smaller");
401        let parsed: Value = serde_json::from_slice(&out).unwrap();
402        assert_eq!(
403            parsed["input"][0]["output"].as_str().unwrap(),
404            expected,
405            "output must be exactly what the shared compressor produces"
406        );
407    }
408
409    #[test]
410    fn array_output_text_is_compressed() {
411        // tee path depends on the data dir; serialize env access so a parallel
412        // test never swaps LEAN_CTX_DATA_DIR between the two compressions (#498).
413        let _lock = crate::core::data_dir::test_env_lock();
414        let raw = long_git_status();
415        let expected = compress_tool_result(&raw, None);
416
417        let body = serde_json::json!({
418            "input": [
419                {
420                    "type": "function_call_output",
421                    "call_id": "call_1",
422                    "output": [{"type": "input_text", "text": raw}]
423                }
424            ]
425        });
426        let bytes = serde_json::to_vec(&body).unwrap();
427        let (out, orig, comp) = compress_request_body(body, bytes.len());
428
429        assert!(comp < orig);
430        let parsed: Value = serde_json::from_slice(&out).unwrap();
431        assert_eq!(
432            parsed["input"][0]["output"][0]["text"].as_str().unwrap(),
433            expected
434        );
435    }
436
437    #[test]
438    fn non_tool_output_items_are_untouched() {
439        let _iso = crate::core::data_dir::isolated_data_dir();
440        let body = serde_json::json!({
441            "input": [
442                {"type": "message", "role": "user", "content": long_git_status()},
443                {"type": "function_call", "call_id": "c", "name": "x", "arguments": "{}"}
444            ]
445        });
446        let bytes = serde_json::to_vec(&body).unwrap();
447        let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
448
449        assert_eq!(comp, orig, "no function_call_output → passthrough");
450        let reparsed: Value = serde_json::from_slice(&out).unwrap();
451        assert_eq!(reparsed, body);
452    }
453
454    #[test]
455    fn plain_string_input_passthrough() {
456        let _iso = crate::core::data_dir::isolated_data_dir();
457        let body = serde_json::json!({"model": "gpt-5", "input": "hello world"});
458        let bytes = serde_json::to_vec(&body).unwrap();
459        let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
460        assert_eq!(comp, orig);
461        let reparsed: Value = serde_json::from_slice(&out).unwrap();
462        assert_eq!(reparsed, body);
463    }
464
465    #[test]
466    fn no_input_field_passthrough() {
467        let _iso = crate::core::data_dir::isolated_data_dir();
468        let body = serde_json::json!({"model": "gpt-5", "previous_response_id": "resp_abc"});
469        let bytes = serde_json::to_vec(&body).unwrap();
470        let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
471        assert_eq!(comp, orig);
472        let reparsed: Value = serde_json::from_slice(&out).unwrap();
473        assert_eq!(reparsed, body);
474    }
475
476    #[test]
477    fn chatgpt_responses_eval_fixture_keeps_exact_payloads_and_pairing() {
478        let _iso = crate::core::data_dir::isolated_data_dir();
479        crate::core::config::Config::update_global(|c| {
480            c.proxy.role_aggressiveness.user = Some(0.8);
481        })
482        .unwrap();
483
484        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";
485        let body = serde_json::json!({"model": "gpt-5", "input": command_input});
486        let bytes = serde_json::to_vec(&body).unwrap();
487        let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
488        assert_eq!(comp, orig, "top-level exact command string must stay raw");
489        assert_eq!(serde_json::from_slice::<Value>(&out).unwrap(), body);
490
491        let old_json = long_tool_json();
492        let recent_json = long_tool_json();
493        let old_shell = long_git_status();
494        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";
495        let mut input = vec![
496            serde_json::json!({"type": "reasoning", "id": "rs_1", "summary": []}),
497            serde_json::json!({"type": "function_call", "call_id": "json_old", "name": "submit_tool_json", "arguments": "{\"strict\":true}"}),
498            serde_json::json!({"type": "function_call_output", "call_id": "json_old", "output": old_json}),
499            serde_json::json!({"type": "function_call", "call_id": "shell_old", "name": "Bash", "arguments": "{\"cmd\":\"git status\"}"}),
500            serde_json::json!({"type": "function_call_output", "call_id": "shell_old", "output": old_shell}),
501            serde_json::json!({"type": "message", "role": "user", "content": [{"type": "input_text", "text": input_text_block}]}),
502        ];
503        while input.len() < 22 {
504            input.push(serde_json::json!({
505                "type": "message",
506                "role": "user",
507                "content": format!("filler {}", input.len()),
508            }));
509        }
510        input.push(serde_json::json!({"type": "function_call", "call_id": "json_recent", "name": "submit_tool_json", "arguments": "{\"strict\":true}"}));
511        input.push(serde_json::json!({"type": "function_call_output", "call_id": "json_recent", "output": recent_json}));
512
513        let body = serde_json::json!({"model": "gpt-5", "input": input});
514        let item_count = body["input"].as_array().unwrap().len();
515        let bytes = serde_json::to_vec(&body).unwrap();
516        let (out, orig, comp) = compress_request_body(body, bytes.len());
517        assert!(comp < orig, "old shell output should still provide savings");
518
519        let parsed: Value = serde_json::from_slice(&out).unwrap();
520        let input = parsed["input"].as_array().unwrap();
521        assert_eq!(input.len(), item_count, "no Responses item may be dropped");
522        assert_eq!(input[0]["type"], "reasoning");
523        assert_eq!(input[1]["type"], "function_call");
524        assert_eq!(input[2]["type"], "function_call_output");
525        assert_eq!(input[2]["output"].as_str().unwrap(), old_json);
526        assert_ne!(input[4]["output"].as_str().unwrap(), old_shell);
527        assert_eq!(
528            input[5]["content"][0]["text"].as_str().unwrap(),
529            input_text_block
530        );
531        assert_eq!(input[22]["type"], "function_call");
532        assert_eq!(input[23]["type"], "function_call_output");
533        assert_eq!(input[23]["output"].as_str().unwrap(), recent_json);
534        assert_eq!(input[1]["call_id"], input[2]["call_id"]);
535        assert_eq!(input[22]["call_id"], input[23]["call_id"]);
536    }
537
538    #[test]
539    fn responses_instructions_prose_compressed_and_assistant_untouched() {
540        let _iso = crate::core::data_dir::isolated_data_dir();
541        crate::core::config::Config::update_global(|c| {
542            c.proxy.role_aggressiveness.system = Some(0.6);
543        })
544        .unwrap();
545
546        let prose = big_prose();
547        let body = serde_json::json!({
548            "model": "gpt-5",
549            "instructions": prose,
550            "input": [
551                {"type": "message", "role": "user", "content": "hi"},
552                {"type": "message", "role": "assistant", "content": prose},
553            ]
554        });
555        let bytes = serde_json::to_vec(&body).unwrap();
556        let (out, orig, comp) = compress_request_body(body, bytes.len());
557        assert!(comp < orig, "enabled instructions prose must save bytes");
558        let parsed: Value = serde_json::from_slice(&out).unwrap();
559
560        assert!(
561            parsed["instructions"].as_str().unwrap().len() < prose.len(),
562            "Responses instructions must be compressed when enabled"
563        );
564        assert_eq!(
565            parsed["input"][1]["content"].as_str().unwrap(),
566            prose,
567            "assistant turns must pass through verbatim (#710)"
568        );
569    }
570
571    #[test]
572    fn responses_user_prose_compressed_only_in_frozen_region() {
573        let _iso = crate::core::data_dir::isolated_data_dir();
574        crate::core::config::Config::update_global(|c| {
575            c.proxy.role_aggressiveness.user = Some(0.7);
576        })
577        .unwrap();
578
579        let prose = big_prose();
580        // 30 messages -> cache-aware boundary = ((30 - 8) / 16) * 16 = 16.
581        let mut input = Vec::new();
582        for i in 0..30 {
583            let role = if i % 2 == 0 { "user" } else { "assistant" };
584            input.push(serde_json::json!({
585                "type": "message",
586                "role": role,
587                "content": prose,
588            }));
589        }
590        let body = serde_json::json!({"model": "gpt-5", "input": input});
591        let bytes = serde_json::to_vec(&body).unwrap();
592        let (out, orig, comp) = compress_request_body(body, bytes.len());
593        assert!(comp < orig, "old user prose must save bytes");
594        let parsed: Value = serde_json::from_slice(&out).unwrap();
595
596        let frozen_user = parsed["input"][0]["content"].as_str().unwrap();
597        assert!(
598            frozen_user.len() < prose.len(),
599            "old user prose should compress"
600        );
601        assert_eq!(
602            parsed["input"][1]["content"].as_str().unwrap(),
603            prose,
604            "assistant prose must stay verbatim"
605        );
606        assert_eq!(
607            parsed["input"][16]["content"].as_str().unwrap(),
608            prose,
609            "live-tail user prose must stay verbatim"
610        );
611    }
612
613    #[test]
614    fn responses_prose_compression_is_deterministic() {
615        let _iso = crate::core::data_dir::isolated_data_dir();
616        crate::core::config::Config::update_global(|c| {
617            c.proxy.role_aggressiveness.system = Some(0.6);
618        })
619        .unwrap();
620
621        let prose = big_prose();
622        let mk = || {
623            serde_json::json!({
624                "model": "gpt-5",
625                "instructions": prose,
626                "input": [{"type": "message", "role": "user", "content": "hi"}],
627            })
628        };
629        let (a, b) = (mk(), mk());
630        let (la, lb) = (
631            serde_json::to_vec(&a).unwrap().len(),
632            serde_json::to_vec(&b).unwrap().len(),
633        );
634        assert_eq!(
635            compress_request_body(a, la).0,
636            compress_request_body(b, lb).0,
637            "identical input must yield byte-identical output (#498)"
638        );
639    }
640
641    #[test]
642    fn short_output_unchanged() {
643        let _iso = crate::core::data_dir::isolated_data_dir();
644        let body = serde_json::json!({
645            "input": [
646                {"type": "function_call_output", "call_id": "c", "output": "ok"}
647            ]
648        });
649        let bytes = serde_json::to_vec(&body).unwrap();
650        let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
651        assert_eq!(comp, orig);
652        let reparsed: Value = serde_json::from_slice(&out).unwrap();
653        assert_eq!(reparsed, body);
654    }
655
656    /// `pairs` Responses turns: each is a `function_call` + its matching
657    /// `function_call_output` carrying a long file read.
658    fn responses_read_turns(pairs: usize) -> Vec<Value> {
659        let code = (0..40)
660            .map(|i| format!("    let v{i} = compute_{i}(ctx, opts);"))
661            .collect::<Vec<_>>()
662            .join("\n");
663        let mut input = Vec::new();
664        for t in 0..pairs {
665            input.push(serde_json::json!({
666                "type": "function_call", "call_id": format!("c{t}"),
667                "name": "read_file", "arguments": "{}"
668            }));
669            input.push(serde_json::json!({
670                "type": "function_call_output", "call_id": format!("c{t}"),
671                "output": format!("{code}\n// turn {t}")
672            }));
673        }
674        input
675    }
676
677    #[test]
678    fn cache_aware_prune_stubs_old_reads_keeps_recent_and_pairing() {
679        // Default (isolated) config = cache-aware history mode.
680        let _iso = crate::core::data_dir::isolated_data_dir();
681        // 14 pairs = 28 items → staircase boundary 16.
682        let body = serde_json::json!({"model": "gpt-5", "input": responses_read_turns(14)});
683        let item_count = body["input"].as_array().unwrap().len();
684        let bytes = serde_json::to_vec(&body).unwrap();
685        let (out, orig, comp) = compress_request_body(body, bytes.len());
686        assert!(comp < orig, "old reads must be pruned for savings");
687
688        let parsed: Value = serde_json::from_slice(&out).unwrap();
689        let input = parsed["input"].as_array().unwrap();
690        // Pairing + ordering preserved: not a single item dropped or moved.
691        assert_eq!(input.len(), item_count, "no items may be removed (pairing)");
692        for (i, item) in input.iter().enumerate() {
693            let expect = if i.is_multiple_of(2) {
694                "function_call"
695            } else {
696                "function_call_output"
697            };
698            assert_eq!(item["type"], expect, "item {i} type/order changed");
699        }
700        // An OLD file read (output index 1, before boundary 16) is stubbed.
701        let old = input[1]["output"].as_str().unwrap();
702        assert!(
703            old.contains("Re-read the file"),
704            "old read should be stubbed, got: {old}"
705        );
706        // A RECENT file read (output index 27, after the boundary) keeps its body.
707        let recent = input[27]["output"].as_str().unwrap();
708        assert!(
709            recent.contains("v39"),
710            "recent read must be protected, got: {recent}"
711        );
712    }
713
714    #[test]
715    fn responses_compression_is_deterministic() {
716        // #498: the same request must compress to byte-identical output so the
717        // provider's prompt cache (and our regression diffs) stay stable.
718        let _iso = crate::core::data_dir::isolated_data_dir();
719        let mk = || serde_json::json!({"model": "gpt-5", "input": responses_read_turns(14)});
720        let (a, b) = (mk(), mk());
721        let (la, lb) = (
722            serde_json::to_vec(&a).unwrap().len(),
723            serde_json::to_vec(&b).unwrap().len(),
724        );
725        let (out_a, _, _) = compress_request_body(a, la);
726        let (out_b, _, _) = compress_request_body(b, lb);
727        assert_eq!(out_a, out_b, "identical input must yield identical bytes");
728    }
729
730    #[test]
731    fn cache_aware_responses_prefix_is_byte_stable_across_turns() {
732        // THE cache invariant for the Responses rail: as `input` grows turn by
733        // turn, every item before an already-passed boundary must stay
734        // byte-identical, or OpenAI's automatic prompt cache stops hitting.
735        let _iso = crate::core::data_dir::isolated_data_dir();
736        let mut prev: Vec<String> = Vec::new();
737        let mut prev_boundary = 0;
738        for pairs in 1..=20 {
739            let input = responses_read_turns(pairs);
740            let len = input.len();
741            let body = serde_json::json!({"model": "gpt-5", "input": input});
742            let bytes = serde_json::to_vec(&body).unwrap();
743            let (out, _, _) = compress_request_body(body, bytes.len());
744            let parsed: Value = serde_json::from_slice(&out).unwrap();
745            let items: Vec<String> = parsed["input"]
746                .as_array()
747                .unwrap()
748                .iter()
749                .map(Value::to_string)
750                .collect();
751            for i in 0..prev_boundary {
752                assert_eq!(
753                    prev[i], items[i],
754                    "Responses item {i} changed at turn {pairs} — prompt cache prefix broken"
755                );
756            }
757            prev = items;
758            prev_boundary = crate::proxy::history_prune::prune_boundary(
759                crate::core::config::HistoryMode::CacheAware,
760                len,
761            );
762        }
763    }
764
765    #[test]
766    fn effort_control_sets_nested_reasoning_effort() {
767        // #834 end-to-end through the Responses request path.
768        let _iso = crate::core::data_dir::isolated_data_dir();
769        crate::test_env::remove_var("LEAN_CTX_PROXY_EFFORT");
770        crate::core::config::Config::update_global(|c| {
771            c.proxy.effort = Some("low".into());
772        })
773        .unwrap();
774        let body = serde_json::json!({"model": "gpt-5.5", "input": []});
775        let bytes = serde_json::to_vec(&body).unwrap();
776        let (out, _o, _c) = compress_request_body(body, bytes.len());
777        assert_eq!(
778            serde_json::from_slice::<Value>(&out).unwrap()["reasoning"]["effort"],
779            "low"
780        );
781    }
782}