lean_ctx/proxy/
anthropic.rs1use axum::{
2 body::Body,
3 extract::State,
4 http::{Request, StatusCode},
5 response::Response,
6};
7use serde_json::Value;
8
9use super::compress::compress_tool_result;
10use super::forward;
11use super::tool_kind::{self, should_protect, ToolResultKind};
12use super::ProxyState;
13
14pub async fn handler(
15 State(state): State<ProxyState>,
16 req: Request<Body>,
17) -> Result<Response, StatusCode> {
18 let upstream = state.anthropic_upstream.clone();
19 forward::forward_request(
20 State(state),
21 req,
22 &upstream,
23 "/v1/messages",
24 compress_request_body,
25 "Anthropic",
26 &[],
27 )
28 .await
29}
30
31fn compress_request_body(parsed: Value, original_size: usize) -> (Vec<u8>, usize, usize) {
32 let mut doc = parsed;
33 let mut modified = false;
34
35 if let Some(messages) = doc.get_mut("messages").and_then(|m| m.as_array_mut()) {
36 let tool_names = tool_kind::anthropic_tool_names(messages);
39
40 let mode = crate::core::config::Config::load()
44 .proxy
45 .resolved_history_mode();
46 let boundary = super::history_prune::prune_boundary(mode, messages.len());
47 modified |= super::history_prune::prune_history(messages, boundary, &tool_names);
48
49 for msg in messages.iter_mut() {
50 let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
51 if role != "user" {
52 continue;
53 }
54
55 if let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut()) {
56 for block in content.iter_mut() {
57 if block.get("type").and_then(|t| t.as_str()) != Some("tool_result") {
58 continue;
59 }
60
61 let name = block
62 .get("tool_use_id")
63 .and_then(|v| v.as_str())
64 .and_then(|id| tool_names.get(id))
65 .map(String::as_str);
66 let kind = name.map_or(ToolResultKind::Other, tool_kind::classify_tool_name);
67
68 if let Some(inner_content) = block.get_mut("content") {
69 modified |= compress_content_field(inner_content, name, kind);
70 }
71 }
72 }
73 }
74 }
75
76 let out = serde_json::to_vec(&doc).unwrap_or_default();
77 let compressed_size = if modified { out.len() } else { original_size };
78 (out, original_size, compressed_size)
79}
80
81fn compress_content_field(
84 content: &mut Value,
85 tool_name: Option<&str>,
86 kind: ToolResultKind,
87) -> bool {
88 match content {
89 Value::String(s) => {
90 if should_protect(kind, s) {
91 return false;
92 }
93 let compressed = compress_tool_result(s, tool_name);
94 if compressed.len() < s.len() {
95 *s = compressed;
96 return true;
97 }
98 false
99 }
100 Value::Array(arr) => {
101 let mut modified = false;
102 for item in arr.iter_mut() {
103 if item.get("type").and_then(|t| t.as_str()) == Some("text") {
104 if let Some(text) = item
105 .get_mut("text")
106 .and_then(|t| t.as_str().map(String::from))
107 {
108 if should_protect(kind, &text) {
109 continue;
110 }
111 let compressed = compress_tool_result(&text, tool_name);
112 if compressed.len() < text.len() {
113 item["text"] = Value::String(compressed);
114 modified = true;
115 }
116 }
117 }
118 }
119 modified
120 }
121 _ => false,
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 fn source_file_body() -> Vec<u8> {
130 let code = (0..60)
131 .map(|i| format!(" let binding_{i} = compute_value_{i}(context, options);"))
132 .collect::<Vec<_>>()
133 .join("\n");
134 let body = serde_json::json!({
135 "model": "claude-opus-4-8",
136 "messages": [
137 {
138 "role": "assistant",
139 "content": [{"type": "tool_use", "id": "toolu_1", "name": "Read", "input": {"file_path": "src/app.rs"}}]
140 },
141 {
142 "role": "user",
143 "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": code}]
144 }
145 ]
146 });
147 serde_json::to_vec(&body).unwrap()
148 }
149
150 #[test]
151 fn read_tool_result_is_never_truncated() {
152 let bytes = source_file_body();
153 let body: Value = serde_json::from_slice(&bytes).unwrap();
154 let (out, _orig, _comp) = compress_request_body(body, bytes.len());
155 let parsed: Value = serde_json::from_slice(&out).unwrap();
156 let content = parsed["messages"][1]["content"][0]["content"]
157 .as_str()
158 .unwrap();
159 assert!(
160 content.contains("binding_59"),
161 "the full source body must survive — refactors need it intact"
162 );
163 assert!(!content.contains("lines omitted"));
164 }
165
166 #[test]
167 fn bash_tool_result_still_compresses() {
168 let log = {
169 let mut s = String::from(
170 "$ 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",
171 );
172 for i in 0..90 {
173 s.push_str(&format!("\tmodified: src/module_{i}/file_{i}.rs\n"));
174 }
175 s.push_str("\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\n");
176 s
177 };
178 let body = serde_json::json!({
179 "messages": [
180 {"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}]},
181 {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": log}]}
182 ]
183 });
184 let bytes = serde_json::to_vec(&body).unwrap();
185 let (_out, orig, comp) = compress_request_body(body, bytes.len());
186 assert!(comp < orig, "shell output must still be compressed");
187 }
188}