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