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_shared::{self, ToolKind};
11use super::forward;
12use super::tool_kind::{self, ToolResultKind};
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 compress_shared::classify_tool_kind(item) != ToolKind::ToolResult {
185            continue;
186        }
187        let tool_name = item
188            .get("call_id")
189            .and_then(|v| v.as_str())
190            .and_then(|id| tool_names.get(id))
191            .map(String::as_str);
192        let kind = compress_shared::tool_result_kind(tool_name);
193        if let Some(output) = item.get_mut("output") {
194            modified |= prune_output_field(output, kind);
195        }
196    }
197    modified
198}
199
200/// Apply [`history_prune::prune_output_text`] to a `function_call_output.output`,
201/// handling both the JSON-string and array-of-content-parts shapes — the
202/// pruning analogue of [`compress_output_field`].
203fn prune_output_field(output: &mut Value, kind: ToolResultKind) -> bool {
204    super::tool_output::prune_value(output, kind)
205}
206
207/// Compresses the `function_call_output.output` entries of a Responses-API body
208/// in place, returning whether anything changed. Shared by the HTTP handler and
209/// the WebSocket bridge (#440) so both paths get identical, safe savings.
210///
211/// The only token sink we shrink is each `function_call_output.output` — the
212/// Responses-API analogue of a Chat Completions `role:"tool"` message. We never
213/// remove or reorder `input` items: the Responses API rejects a `function_call`
214/// whose matching `function_call_output` is absent (and reasoning items must keep
215/// their originating call), so all token reclamation happens *in place* on the
216/// output text. Cache-aware pruning of the frozen OLD region lives in
217/// [`prune_responses_input`]; this pass compresses whatever recent outputs remain.
218pub(super) fn compress_responses_input(doc: &mut Value) -> bool {
219    // #481: recent-region live compression respects the global toggle. Old-region
220    // pruning stays governed by `history_mode` in `prune_responses_input`.
221    let cfg = crate::core::config::Config::load();
222    if !cfg.proxy.live_compresses() {
223        return false;
224    }
225    let mut modified = false;
226    if let Some(input) = doc.get_mut("input").and_then(|i| i.as_array_mut()) {
227        let tool_names = tool_kind::responses_tool_names(input);
228        for item in input.iter_mut() {
229            if compress_shared::classify_tool_kind(item) != ToolKind::ToolResult {
230                continue;
231            }
232            let name = item
233                .get("call_id")
234                .and_then(|v| v.as_str())
235                .and_then(|id| tool_names.get(id))
236                .map(String::as_str);
237            // #481: per-tool exclusion (Serena default) — skip live compression
238            // for excluded tools; history pruning above still applies.
239            if name.is_some_and(|n| cfg.proxy.is_tool_live_compress_excluded(n)) {
240                continue;
241            }
242            let kind = compress_shared::tool_result_kind(name);
243            if let Some(output) = item.get_mut("output") {
244                modified |= compress_output_field(output, name, kind);
245            }
246        }
247    }
248    modified
249}
250
251/// Compress a `function_call_output.output`. OpenAI sends this as a JSON string,
252/// but the API also accepts an array of content parts (`input_text` blocks) for
253/// tools returning richer data, so both shapes are handled.
254///
255/// A protected file/source read (resolved from the matching `function_call`
256/// name) is left intact so a mid-refactor model never loses the body it edits.
257fn compress_output_field(
258    output: &mut Value,
259    tool_name: Option<&str>,
260    kind: ToolResultKind,
261) -> bool {
262    super::tool_output::compress_value(output, tool_name, kind)
263}
264
265#[cfg(test)]
266mod tests {
267    use super::super::compress::compress_tool_result;
268    use super::*;
269
270    /// A long `git status` is a known-compressible fixture: `has_structural_output`
271    /// is false for it, so it flows through the git-status pattern compressor.
272    fn long_git_status() -> String {
273        let mut s = String::from(
274            "$ 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",
275        );
276        for i in 0..80 {
277            s.push_str(&format!("\tmodified:   src/module_{i}/file_{i}.rs\n"));
278        }
279        s.push_str("\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\n");
280        s
281    }
282
283    fn big_prose() -> String {
284        let p = "You are a careful, senior software engineer. You always explain your \
285                 reasoning before making changes, you prefer small reviewable diffs, and \
286                 you never introduce mock data or placeholders into production code. ";
287        [p; 6].join("\n")
288    }
289
290    fn long_tool_json() -> String {
291        let rows = (0..32)
292            .map(|i| {
293                serde_json::json!({
294                    "path": format!("/Users/alex/work/app/src/module_{i}.rs"),
295                    "regex": r"src/[a-z_]+\.rs:\d+",
296                    "error": format!("error[E0{i:03}]: expected exact diagnostic text"),
297                })
298            })
299            .collect::<Vec<_>>();
300        serde_json::to_string(&serde_json::json!({ "results": rows })).unwrap()
301    }
302
303    #[test]
304    fn shell_json_envelope_text_is_compressed() {
305        let _lock = crate::core::data_dir::test_env_lock();
306        let raw = long_git_status();
307        let expected = compress_tool_result(&raw, Some("Bash"));
308        let envelope = serde_json::to_string(&serde_json::json!({
309            "content": [{"type": "text", "text": raw}],
310            "isError": false,
311        }))
312        .unwrap();
313
314        let body = serde_json::json!({
315            "model": "gpt-5",
316            "input": [
317                {"type": "function_call", "call_id": "call_1", "name": "Bash", "arguments": "{}"},
318                {"type": "function_call_output", "call_id": "call_1", "output": envelope}
319            ]
320        });
321        let bytes = serde_json::to_vec(&body).unwrap();
322        let (out, orig, comp) = compress_request_body(body, bytes.len());
323
324        assert!(comp < orig);
325        let parsed: Value = serde_json::from_slice(&out).unwrap();
326        let output = parsed["input"][1]["output"].as_str().unwrap();
327        let envelope: Value = serde_json::from_str(output).unwrap();
328        assert_eq!(envelope["content"][0]["text"].as_str().unwrap(), expected);
329    }
330
331    #[test]
332    fn shell_json_envelope_non_text_field_is_compressed() {
333        let _lock = crate::core::data_dir::test_env_lock();
334        let raw = long_git_status();
335        let expected = compress_tool_result(&raw, Some("Bash"));
336        // Big shell output in a non-"text" field: the Shell/Search all-strings
337        // rewrite must still reach it, while small scalar fields stay intact.
338        let envelope = serde_json::to_string(&serde_json::json!({
339            "stdout": raw,
340            "exit_code": 0,
341        }))
342        .unwrap();
343
344        let body = serde_json::json!({
345            "model": "gpt-5",
346            "input": [
347                {"type": "function_call", "call_id": "call_1", "name": "Bash", "arguments": "{}"},
348                {"type": "function_call_output", "call_id": "call_1", "output": envelope}
349            ]
350        });
351        let bytes = serde_json::to_vec(&body).unwrap();
352        let (out, orig, comp) = compress_request_body(body, bytes.len());
353
354        assert!(comp < orig, "non-text shell field should be compressed");
355        let parsed: Value = serde_json::from_slice(&out).unwrap();
356        let output = parsed["input"][1]["output"].as_str().unwrap();
357        let envelope: Value = serde_json::from_str(output).unwrap();
358        assert_eq!(envelope["stdout"].as_str().unwrap(), expected);
359        assert_eq!(envelope["exit_code"].as_i64().unwrap(), 0);
360    }
361
362    #[test]
363    fn old_shell_json_envelope_text_is_pruned() {
364        let _iso = crate::core::data_dir::isolated_data_dir();
365        let raw = long_git_status();
366        let envelope = serde_json::to_string(&serde_json::json!({
367            "content": [{"type": "text", "text": raw}],
368            "isError": false,
369        }))
370        .unwrap();
371        let mut output = Value::String(envelope);
372
373        assert!(prune_output_field(&mut output, ToolResultKind::Shell));
374        let envelope: Value = serde_json::from_str(output.as_str().unwrap()).unwrap();
375        assert!(
376            envelope["content"][0]["text"].as_str().unwrap().len() < raw.len(),
377            "nested text payload should be pruned"
378        );
379    }
380
381    #[test]
382    fn string_output_mirrors_engine_and_shrinks() {
383        // tee path depends on the data dir; serialize env access so a parallel
384        // test never swaps LEAN_CTX_DATA_DIR between the two compressions (#498).
385        let _lock = crate::core::data_dir::test_env_lock();
386        let raw = long_git_status();
387        let expected = compress_tool_result(&raw, None);
388        assert!(
389            expected.len() < raw.len(),
390            "fixture must be compressible by the shared engine"
391        );
392
393        let body = serde_json::json!({
394            "model": "gpt-5",
395            "input": [
396                {"type": "function_call_output", "call_id": "call_1", "output": raw}
397            ]
398        });
399        let bytes = serde_json::to_vec(&body).unwrap();
400        let (out, orig, comp) = compress_request_body(body, bytes.len());
401
402        assert!(comp < orig, "compressed body must be smaller");
403        let parsed: Value = serde_json::from_slice(&out).unwrap();
404        assert_eq!(
405            parsed["input"][0]["output"].as_str().unwrap(),
406            expected,
407            "output must be exactly what the shared compressor produces"
408        );
409    }
410
411    #[test]
412    fn array_output_text_is_compressed() {
413        // tee path depends on the data dir; serialize env access so a parallel
414        // test never swaps LEAN_CTX_DATA_DIR between the two compressions (#498).
415        let _lock = crate::core::data_dir::test_env_lock();
416        let raw = long_git_status();
417        let expected = compress_tool_result(&raw, None);
418
419        let body = serde_json::json!({
420            "input": [
421                {
422                    "type": "function_call_output",
423                    "call_id": "call_1",
424                    "output": [{"type": "input_text", "text": raw}]
425                }
426            ]
427        });
428        let bytes = serde_json::to_vec(&body).unwrap();
429        let (out, orig, comp) = compress_request_body(body, bytes.len());
430
431        assert!(comp < orig);
432        let parsed: Value = serde_json::from_slice(&out).unwrap();
433        assert_eq!(
434            parsed["input"][0]["output"][0]["text"].as_str().unwrap(),
435            expected
436        );
437    }
438
439    #[test]
440    fn non_tool_output_items_are_untouched() {
441        let _iso = crate::core::data_dir::isolated_data_dir();
442        let body = serde_json::json!({
443            "input": [
444                {"type": "message", "role": "user", "content": long_git_status()},
445                {"type": "function_call", "call_id": "c", "name": "x", "arguments": "{}"}
446            ]
447        });
448        let bytes = serde_json::to_vec(&body).unwrap();
449        let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
450
451        assert_eq!(comp, orig, "no function_call_output → passthrough");
452        let reparsed: Value = serde_json::from_slice(&out).unwrap();
453        assert_eq!(reparsed, body);
454    }
455
456    #[test]
457    fn plain_string_input_passthrough() {
458        let _iso = crate::core::data_dir::isolated_data_dir();
459        let body = serde_json::json!({"model": "gpt-5", "input": "hello world"});
460        let bytes = serde_json::to_vec(&body).unwrap();
461        let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
462        assert_eq!(comp, orig);
463        let reparsed: Value = serde_json::from_slice(&out).unwrap();
464        assert_eq!(reparsed, body);
465    }
466
467    #[test]
468    fn no_input_field_passthrough() {
469        let _iso = crate::core::data_dir::isolated_data_dir();
470        let body = serde_json::json!({"model": "gpt-5", "previous_response_id": "resp_abc"});
471        let bytes = serde_json::to_vec(&body).unwrap();
472        let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
473        assert_eq!(comp, orig);
474        let reparsed: Value = serde_json::from_slice(&out).unwrap();
475        assert_eq!(reparsed, body);
476    }
477
478    #[test]
479    fn chatgpt_responses_eval_fixture_keeps_exact_payloads_and_pairing() {
480        let _iso = crate::core::data_dir::isolated_data_dir();
481        crate::core::config::Config::update_global(|c| {
482            c.proxy.role_aggressiveness.user = Some(0.8);
483        })
484        .unwrap();
485
486        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";
487        let body = serde_json::json!({"model": "gpt-5", "input": command_input});
488        let bytes = serde_json::to_vec(&body).unwrap();
489        let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
490        assert_eq!(comp, orig, "top-level exact command string must stay raw");
491        assert_eq!(serde_json::from_slice::<Value>(&out).unwrap(), body);
492
493        let old_json = long_tool_json();
494        let recent_json = long_tool_json();
495        let old_shell = long_git_status();
496        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";
497        let mut input = vec![
498            serde_json::json!({"type": "reasoning", "id": "rs_1", "summary": []}),
499            serde_json::json!({"type": "function_call", "call_id": "json_old", "name": "submit_tool_json", "arguments": "{\"strict\":true}"}),
500            serde_json::json!({"type": "function_call_output", "call_id": "json_old", "output": old_json}),
501            serde_json::json!({"type": "function_call", "call_id": "shell_old", "name": "Bash", "arguments": "{\"cmd\":\"git status\"}"}),
502            serde_json::json!({"type": "function_call_output", "call_id": "shell_old", "output": old_shell}),
503            serde_json::json!({"type": "message", "role": "user", "content": [{"type": "input_text", "text": input_text_block}]}),
504        ];
505        while input.len() < 22 {
506            input.push(serde_json::json!({
507                "type": "message",
508                "role": "user",
509                "content": format!("filler {}", input.len()),
510            }));
511        }
512        input.push(serde_json::json!({"type": "function_call", "call_id": "json_recent", "name": "submit_tool_json", "arguments": "{\"strict\":true}"}));
513        input.push(serde_json::json!({"type": "function_call_output", "call_id": "json_recent", "output": recent_json}));
514
515        let body = serde_json::json!({"model": "gpt-5", "input": input});
516        let item_count = body["input"].as_array().unwrap().len();
517        let bytes = serde_json::to_vec(&body).unwrap();
518        let (out, orig, comp) = compress_request_body(body, bytes.len());
519        assert!(comp < orig, "old shell output should still provide savings");
520
521        let parsed: Value = serde_json::from_slice(&out).unwrap();
522        let input = parsed["input"].as_array().unwrap();
523        assert_eq!(input.len(), item_count, "no Responses item may be dropped");
524        assert_eq!(input[0]["type"], "reasoning");
525        assert_eq!(input[1]["type"], "function_call");
526        assert_eq!(input[2]["type"], "function_call_output");
527        assert_eq!(input[2]["output"].as_str().unwrap(), old_json);
528        assert_ne!(input[4]["output"].as_str().unwrap(), old_shell);
529        assert_eq!(
530            input[5]["content"][0]["text"].as_str().unwrap(),
531            input_text_block
532        );
533        assert_eq!(input[22]["type"], "function_call");
534        assert_eq!(input[23]["type"], "function_call_output");
535        assert_eq!(input[23]["output"].as_str().unwrap(), recent_json);
536        assert_eq!(input[1]["call_id"], input[2]["call_id"]);
537        assert_eq!(input[22]["call_id"], input[23]["call_id"]);
538    }
539
540    #[test]
541    fn responses_instructions_prose_compressed_and_assistant_untouched() {
542        let _iso = crate::core::data_dir::isolated_data_dir();
543        crate::core::config::Config::update_global(|c| {
544            c.proxy.role_aggressiveness.system = Some(0.6);
545        })
546        .unwrap();
547
548        let prose = big_prose();
549        let body = serde_json::json!({
550            "model": "gpt-5",
551            "instructions": prose,
552            "input": [
553                {"type": "message", "role": "user", "content": "hi"},
554                {"type": "message", "role": "assistant", "content": prose},
555            ]
556        });
557        let bytes = serde_json::to_vec(&body).unwrap();
558        let (out, orig, comp) = compress_request_body(body, bytes.len());
559        assert!(comp < orig, "enabled instructions prose must save bytes");
560        let parsed: Value = serde_json::from_slice(&out).unwrap();
561
562        assert!(
563            parsed["instructions"].as_str().unwrap().len() < prose.len(),
564            "Responses instructions must be compressed when enabled"
565        );
566        assert_eq!(
567            parsed["input"][1]["content"].as_str().unwrap(),
568            prose,
569            "assistant turns must pass through verbatim (#710)"
570        );
571    }
572
573    #[test]
574    fn responses_user_prose_compressed_only_in_frozen_region() {
575        let _iso = crate::core::data_dir::isolated_data_dir();
576        crate::core::config::Config::update_global(|c| {
577            c.proxy.role_aggressiveness.user = Some(0.7);
578        })
579        .unwrap();
580
581        let prose = big_prose();
582        // 30 messages -> cache-aware boundary = ((30 - 8) / 16) * 16 = 16.
583        let mut input = Vec::new();
584        for i in 0..30 {
585            let role = if i % 2 == 0 { "user" } else { "assistant" };
586            input.push(serde_json::json!({
587                "type": "message",
588                "role": role,
589                "content": prose,
590            }));
591        }
592        let body = serde_json::json!({"model": "gpt-5", "input": input});
593        let bytes = serde_json::to_vec(&body).unwrap();
594        let (out, orig, comp) = compress_request_body(body, bytes.len());
595        assert!(comp < orig, "old user prose must save bytes");
596        let parsed: Value = serde_json::from_slice(&out).unwrap();
597
598        let frozen_user = parsed["input"][0]["content"].as_str().unwrap();
599        assert!(
600            frozen_user.len() < prose.len(),
601            "old user prose should compress"
602        );
603        assert_eq!(
604            parsed["input"][1]["content"].as_str().unwrap(),
605            prose,
606            "assistant prose must stay verbatim"
607        );
608        assert_eq!(
609            parsed["input"][16]["content"].as_str().unwrap(),
610            prose,
611            "live-tail user prose must stay verbatim"
612        );
613    }
614
615    #[test]
616    fn responses_prose_compression_is_deterministic() {
617        let _iso = crate::core::data_dir::isolated_data_dir();
618        crate::core::config::Config::update_global(|c| {
619            c.proxy.role_aggressiveness.system = Some(0.6);
620        })
621        .unwrap();
622
623        let prose = big_prose();
624        let mk = || {
625            serde_json::json!({
626                "model": "gpt-5",
627                "instructions": prose,
628                "input": [{"type": "message", "role": "user", "content": "hi"}],
629            })
630        };
631        let (a, b) = (mk(), mk());
632        let (la, lb) = (
633            serde_json::to_vec(&a).unwrap().len(),
634            serde_json::to_vec(&b).unwrap().len(),
635        );
636        assert_eq!(
637            compress_request_body(a, la).0,
638            compress_request_body(b, lb).0,
639            "identical input must yield byte-identical output (#498)"
640        );
641    }
642
643    #[test]
644    fn short_output_unchanged() {
645        let _iso = crate::core::data_dir::isolated_data_dir();
646        let body = serde_json::json!({
647            "input": [
648                {"type": "function_call_output", "call_id": "c", "output": "ok"}
649            ]
650        });
651        let bytes = serde_json::to_vec(&body).unwrap();
652        let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
653        assert_eq!(comp, orig);
654        let reparsed: Value = serde_json::from_slice(&out).unwrap();
655        assert_eq!(reparsed, body);
656    }
657
658    /// `pairs` Responses turns: each is a `function_call` + its matching
659    /// `function_call_output` carrying a long file read.
660    fn responses_read_turns(pairs: usize) -> Vec<Value> {
661        let code = (0..40)
662            .map(|i| format!("    let v{i} = compute_{i}(ctx, opts);"))
663            .collect::<Vec<_>>()
664            .join("\n");
665        let mut input = Vec::new();
666        for t in 0..pairs {
667            input.push(serde_json::json!({
668                "type": "function_call", "call_id": format!("c{t}"),
669                "name": "read_file", "arguments": "{}"
670            }));
671            input.push(serde_json::json!({
672                "type": "function_call_output", "call_id": format!("c{t}"),
673                "output": format!("{code}\n// turn {t}")
674            }));
675        }
676        input
677    }
678
679    #[test]
680    fn cache_aware_prune_stubs_old_reads_keeps_recent_and_pairing() {
681        // Default (isolated) config = cache-aware history mode.
682        let _iso = crate::core::data_dir::isolated_data_dir();
683        // 14 pairs = 28 items → staircase boundary 16.
684        let body = serde_json::json!({"model": "gpt-5", "input": responses_read_turns(14)});
685        let item_count = body["input"].as_array().unwrap().len();
686        let bytes = serde_json::to_vec(&body).unwrap();
687        let (out, orig, comp) = compress_request_body(body, bytes.len());
688        assert!(comp < orig, "old reads must be pruned for savings");
689
690        let parsed: Value = serde_json::from_slice(&out).unwrap();
691        let input = parsed["input"].as_array().unwrap();
692        // Pairing + ordering preserved: not a single item dropped or moved.
693        assert_eq!(input.len(), item_count, "no items may be removed (pairing)");
694        for (i, item) in input.iter().enumerate() {
695            let expect = if i.is_multiple_of(2) {
696                "function_call"
697            } else {
698                "function_call_output"
699            };
700            assert_eq!(item["type"], expect, "item {i} type/order changed");
701        }
702        // An OLD file read (output index 1, before boundary 16) is stubbed.
703        let old = input[1]["output"].as_str().unwrap();
704        assert!(
705            old.contains("Re-read the file"),
706            "old read should be stubbed, got: {old}"
707        );
708        // A RECENT file read (output index 27, after the boundary) keeps its body.
709        let recent = input[27]["output"].as_str().unwrap();
710        assert!(
711            recent.contains("v39"),
712            "recent read must be protected, got: {recent}"
713        );
714    }
715
716    #[test]
717    fn responses_compression_is_deterministic() {
718        // #498: the same request must compress to byte-identical output so the
719        // provider's prompt cache (and our regression diffs) stay stable.
720        let _iso = crate::core::data_dir::isolated_data_dir();
721        let mk = || serde_json::json!({"model": "gpt-5", "input": responses_read_turns(14)});
722        let (a, b) = (mk(), mk());
723        let (la, lb) = (
724            serde_json::to_vec(&a).unwrap().len(),
725            serde_json::to_vec(&b).unwrap().len(),
726        );
727        let (out_a, _, _) = compress_request_body(a, la);
728        let (out_b, _, _) = compress_request_body(b, lb);
729        assert_eq!(out_a, out_b, "identical input must yield identical bytes");
730    }
731
732    #[test]
733    fn cache_aware_responses_prefix_is_byte_stable_across_turns() {
734        // THE cache invariant for the Responses rail: as `input` grows turn by
735        // turn, every item before an already-passed boundary must stay
736        // byte-identical, or OpenAI's automatic prompt cache stops hitting.
737        let _iso = crate::core::data_dir::isolated_data_dir();
738        let mut prev: Vec<String> = Vec::new();
739        let mut prev_boundary = 0;
740        for pairs in 1..=20 {
741            let input = responses_read_turns(pairs);
742            let len = input.len();
743            let body = serde_json::json!({"model": "gpt-5", "input": input});
744            let bytes = serde_json::to_vec(&body).unwrap();
745            let (out, _, _) = compress_request_body(body, bytes.len());
746            let parsed: Value = serde_json::from_slice(&out).unwrap();
747            let items: Vec<String> = parsed["input"]
748                .as_array()
749                .unwrap()
750                .iter()
751                .map(Value::to_string)
752                .collect();
753            for i in 0..prev_boundary {
754                assert_eq!(
755                    prev[i], items[i],
756                    "Responses item {i} changed at turn {pairs} — prompt cache prefix broken"
757                );
758            }
759            prev = items;
760            prev_boundary = crate::proxy::history_prune::prune_boundary(
761                crate::core::config::HistoryMode::CacheAware,
762                len,
763            );
764        }
765    }
766
767    #[test]
768    fn effort_control_sets_nested_reasoning_effort() {
769        // #834 end-to-end through the Responses request path.
770        let _iso = crate::core::data_dir::isolated_data_dir();
771        crate::test_env::remove_var("LEAN_CTX_PROXY_EFFORT");
772        crate::core::config::Config::update_global(|c| {
773            c.proxy.effort = Some("low".into());
774        })
775        .unwrap();
776        let body = serde_json::json!({"model": "gpt-5.5", "input": []});
777        let bytes = serde_json::to_vec(&body).unwrap();
778        let (out, _o, _c) = compress_request_body(body, bytes.len());
779        assert_eq!(
780            serde_json::from_slice::<Value>(&out).unwrap()["reasoning"]["effort"],
781            "low"
782        );
783    }
784}