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
14pub 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
44pub 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
66pub(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
99fn 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 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}