Skip to main content

lean_ctx/proxy/
anthropic.rs

1use 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        // Resolve tool-call id → tool name so file/source reads can be protected
37        // from lossy compression that would force the model to re-read mid-task.
38        let tool_names = tool_kind::anthropic_tool_names(messages);
39
40        // Prune at a frozen, cache-aware boundary by default: Anthropic's
41        // prompt cache matches exact prefixes, so the boundary must not move
42        // every turn (see `history_prune::prune_boundary`).
43        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
81/// Compresses a tool_result `content` field unless it is a protected file/source
82/// read, which must reach the model intact (it is what gets edited).
83fn 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    fn forge_log_body(tool_name: &str) -> Value {
167        // Generic, highly-repetitive log with no `$ cmd` hint, so routing falls
168        // back to the tool name (exercising the foreign-tool classification)
169        // and the generic compressor (not a command-specific pattern).
170        let mut log = String::new();
171        for i in 0..90 {
172            log.push_str(&format!(
173                "INFO  processing item {i}: ok, latency={i}ms, queue depth normal, retries 0\n"
174            ));
175        }
176        serde_json::json!({
177            "messages": [
178                {"role": "assistant", "content": [{"type": "tool_use", "id": "f1", "name": tool_name, "input": {}}]},
179                {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "f1", "content": log}]}
180            ]
181        })
182    }
183
184    #[test]
185    fn forge_shell_tool_result_compresses() {
186        // A vendor-prefixed foreign shell tool reaches the proxy; its log output
187        // must still be compressed (rtk/ctx_* never see another server's tools).
188        let body = forge_log_body("forge_shell");
189        let bytes = serde_json::to_vec(&body).unwrap();
190        let (_out, orig, comp) = compress_request_body(body, bytes.len());
191        assert!(comp < orig, "foreign shell output must be compressed");
192    }
193
194    #[test]
195    fn foreign_read_tool_protects_source() {
196        // `forge_read` is classified FileRead via the segment fallback, so the
197        // source body must reach the model intact (it is what gets edited).
198        let code = (0..60)
199            .map(|i| format!("    let binding_{i} = compute_value_{i}(context, options);"))
200            .collect::<Vec<_>>()
201            .join("\n");
202        let body = serde_json::json!({
203            "messages": [
204                {"role": "assistant", "content": [{"type": "tool_use", "id": "r1", "name": "forge_read", "input": {"path": "src/app.rs"}}]},
205                {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "r1", "content": code}]}
206            ]
207        });
208        let bytes = serde_json::to_vec(&body).unwrap();
209        let (out, _orig, _comp) = compress_request_body(body, bytes.len());
210        let parsed: Value = serde_json::from_slice(&out).unwrap();
211        let content = parsed["messages"][1]["content"][0]["content"]
212            .as_str()
213            .unwrap();
214        assert!(
215            content.contains("binding_59"),
216            "source body must survive intact"
217        );
218    }
219
220    #[test]
221    fn compress_request_body_is_deterministic() {
222        // #498: the proxy rewrite must be a pure function of the body so the
223        // provider prompt-cache prefix stays byte-identical across turns.
224        let bytes = serde_json::to_vec(&forge_log_body("Bash")).unwrap();
225        let a = compress_request_body(serde_json::from_slice(&bytes).unwrap(), bytes.len()).0;
226        let b = compress_request_body(serde_json::from_slice(&bytes).unwrap(), bytes.len()).0;
227        assert_eq!(a, b, "identical input must yield byte-identical output");
228    }
229
230    #[test]
231    fn bash_tool_result_still_compresses() {
232        let log = {
233            let mut s = String::from(
234                "$ 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",
235            );
236            for i in 0..90 {
237                s.push_str(&format!("\tmodified:   src/module_{i}/file_{i}.rs\n"));
238            }
239            s.push_str("\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\n");
240            s
241        };
242        let body = serde_json::json!({
243            "messages": [
244                {"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}]},
245                {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": log}]}
246            ]
247        });
248        let bytes = serde_json::to_vec(&body).unwrap();
249        let (_out, orig, comp) = compress_request_body(body, bytes.len());
250        assert!(comp < orig, "shell output must still be compressed");
251    }
252}