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};
13
14/// Proxy handler for OpenAI's Responses API (`POST /v1/responses`).
15///
16/// The Responses API superseded Chat Completions for clients such as opencode
17/// and the OpenAI Agents SDK. Its conversation turns live in `input` rather than
18/// `messages`, so the Chat Completions handler never saw — and never compressed —
19/// them. This handler reuses the same upstream, auth and streaming path but
20/// understands the Responses-API request shape.
21///
22/// Retrieve / cancel / delete / input_items sub-paths
23/// (`/v1/responses/{id}/...`) are routed here as well and pass through untouched:
24/// they carry no `input` array, so `compress_request_body` is a no-op for them.
25///
26/// Handles `POST /v1/responses` (and the bare `/responses`) over HTTP/SSE.
27pub async fn handler(
28    State(state): State<ProxyState>,
29    req: Request<Body>,
30) -> Result<Response, StatusCode> {
31    let upstream = state.openai_upstream.clone();
32    forward::forward_request(
33        State(state),
34        req,
35        &upstream,
36        "/v1/responses",
37        compress_request_body,
38        "OpenAI",
39        &[],
40    )
41    .await
42}
43
44/// Handles the WebSocket Responses transport on `GET /v1/responses`.
45///
46/// Codex (and the OpenAI SDK) default to `ws://…/responses` with one
47/// `response.create` event per turn. Bridging the upgrade here lets the proxy be
48/// a drop-in for Codex without forcing `supports_websockets = false` (#440); the
49/// actual WS↔HTTP/SSE bridging lives in `openai_responses_ws`.
50pub async fn ws_handler(
51    State(state): State<ProxyState>,
52    headers: axum::http::HeaderMap,
53    ws: axum::extract::ws::WebSocketUpgrade,
54) -> Response {
55    super::openai_responses_ws::upgrade(state, ws, &headers)
56}
57
58fn compress_request_body(parsed: Value, original_size: usize) -> (Vec<u8>, usize, usize) {
59    let mut doc = parsed;
60    let modified = compress_responses_input(&mut doc);
61    let out = serde_json::to_vec(&doc).unwrap_or_default();
62    let compressed_size = if modified { out.len() } else { original_size };
63    (out, original_size, compressed_size)
64}
65
66/// Compresses the `function_call_output.output` entries of a Responses-API body
67/// in place, returning whether anything changed. Shared by the HTTP handler and
68/// the WebSocket bridge (#440) so both paths get identical, safe savings.
69///
70/// The only token sink we shrink is each `function_call_output.output` — the
71/// Responses-API analogue of a Chat Completions `role:"tool"` message. We
72/// deliberately do NOT prune or reorder the `input` array: the Responses API
73/// rejects a `function_call` whose matching `function_call_output` is absent
74/// (and reasoning items must keep their originating call), so structural
75/// history-pruning would risk 400s here. Compressing only the tool outputs
76/// captures the bulk of the savings without touching the conversation structure.
77pub(super) fn compress_responses_input(doc: &mut Value) -> bool {
78    let mut modified = false;
79    if let Some(input) = doc.get_mut("input").and_then(|i| i.as_array_mut()) {
80        let tool_names = tool_kind::responses_tool_names(input);
81        for item in input.iter_mut() {
82            if item.get("type").and_then(|t| t.as_str()) != Some("function_call_output") {
83                continue;
84            }
85            let name = item
86                .get("call_id")
87                .and_then(|v| v.as_str())
88                .and_then(|id| tool_names.get(id))
89                .map(String::as_str);
90            let kind = name.map_or(ToolResultKind::Other, tool_kind::classify_tool_name);
91            if let Some(output) = item.get_mut("output") {
92                modified |= compress_output_field(output, name, kind);
93            }
94        }
95    }
96    modified
97}
98
99/// Compress a `function_call_output.output`. OpenAI sends this as a JSON string,
100/// but the API also accepts an array of content parts (`input_text` blocks) for
101/// tools returning richer data, so both shapes are handled.
102///
103/// A protected file/source read (resolved from the matching `function_call`
104/// name) is left intact so a mid-refactor model never loses the body it edits.
105fn compress_output_field(
106    output: &mut Value,
107    tool_name: Option<&str>,
108    kind: ToolResultKind,
109) -> bool {
110    match output {
111        Value::String(s) => {
112            if should_protect(kind, s) {
113                return false;
114            }
115            let compressed = compress_tool_result(s, tool_name);
116            if compressed.len() < s.len() {
117                *s = compressed;
118                return true;
119            }
120            false
121        }
122        Value::Array(parts) => {
123            let mut changed = false;
124            for part in parts.iter_mut() {
125                if let Some(Value::String(text)) = part.get_mut("text") {
126                    if should_protect(kind, text) {
127                        continue;
128                    }
129                    let compressed = compress_tool_result(text, tool_name);
130                    if compressed.len() < text.len() {
131                        *text = compressed;
132                        changed = true;
133                    }
134                }
135            }
136            changed
137        }
138        _ => false,
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    /// A long `git status` is a known-compressible fixture: `has_structural_output`
147    /// is false for it, so it flows through the git-status pattern compressor.
148    fn long_git_status() -> String {
149        let mut s = String::from(
150            "$ 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",
151        );
152        for i in 0..80 {
153            s.push_str(&format!("\tmodified:   src/module_{i}/file_{i}.rs\n"));
154        }
155        s.push_str("\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\n");
156        s
157    }
158
159    #[test]
160    fn string_output_mirrors_engine_and_shrinks() {
161        let raw = long_git_status();
162        let expected = compress_tool_result(&raw, None);
163        assert!(
164            expected.len() < raw.len(),
165            "fixture must be compressible by the shared engine"
166        );
167
168        let body = serde_json::json!({
169            "model": "gpt-5",
170            "input": [
171                {"type": "function_call_output", "call_id": "call_1", "output": raw}
172            ]
173        });
174        let bytes = serde_json::to_vec(&body).unwrap();
175        let (out, orig, comp) = compress_request_body(body, bytes.len());
176
177        assert!(comp < orig, "compressed body must be smaller");
178        let parsed: Value = serde_json::from_slice(&out).unwrap();
179        assert_eq!(
180            parsed["input"][0]["output"].as_str().unwrap(),
181            expected,
182            "output must be exactly what the shared compressor produces"
183        );
184    }
185
186    #[test]
187    fn array_output_text_is_compressed() {
188        let raw = long_git_status();
189        let expected = compress_tool_result(&raw, None);
190
191        let body = serde_json::json!({
192            "input": [
193                {
194                    "type": "function_call_output",
195                    "call_id": "call_1",
196                    "output": [{"type": "input_text", "text": raw}]
197                }
198            ]
199        });
200        let bytes = serde_json::to_vec(&body).unwrap();
201        let (out, orig, comp) = compress_request_body(body, bytes.len());
202
203        assert!(comp < orig);
204        let parsed: Value = serde_json::from_slice(&out).unwrap();
205        assert_eq!(
206            parsed["input"][0]["output"][0]["text"].as_str().unwrap(),
207            expected
208        );
209    }
210
211    #[test]
212    fn non_tool_output_items_are_untouched() {
213        let body = serde_json::json!({
214            "input": [
215                {"type": "message", "role": "user", "content": long_git_status()},
216                {"type": "function_call", "call_id": "c", "name": "x", "arguments": "{}"}
217            ]
218        });
219        let bytes = serde_json::to_vec(&body).unwrap();
220        let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
221
222        assert_eq!(comp, orig, "no function_call_output → passthrough");
223        let reparsed: Value = serde_json::from_slice(&out).unwrap();
224        assert_eq!(reparsed, body);
225    }
226
227    #[test]
228    fn plain_string_input_passthrough() {
229        let body = serde_json::json!({"model": "gpt-5", "input": "hello world"});
230        let bytes = serde_json::to_vec(&body).unwrap();
231        let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
232        assert_eq!(comp, orig);
233        let reparsed: Value = serde_json::from_slice(&out).unwrap();
234        assert_eq!(reparsed, body);
235    }
236
237    #[test]
238    fn no_input_field_passthrough() {
239        let body = serde_json::json!({"model": "gpt-5", "previous_response_id": "resp_abc"});
240        let bytes = serde_json::to_vec(&body).unwrap();
241        let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
242        assert_eq!(comp, orig);
243        let reparsed: Value = serde_json::from_slice(&out).unwrap();
244        assert_eq!(reparsed, body);
245    }
246
247    #[test]
248    fn short_output_unchanged() {
249        let body = serde_json::json!({
250            "input": [
251                {"type": "function_call_output", "call_id": "c", "output": "ok"}
252            ]
253        });
254        let bytes = serde_json::to_vec(&body).unwrap();
255        let (out, orig, comp) = compress_request_body(body.clone(), bytes.len());
256        assert_eq!(comp, orig);
257        let reparsed: Value = serde_json::from_slice(&out).unwrap();
258        assert_eq!(reparsed, body);
259    }
260}