Skip to main content

claude_codex/providers/anthropic/
mod.rs

1//! Anthropic passthrough backend.
2//!
3//! Unlike the other providers, this one performs no translation. Claude Code already
4//! speaks the Anthropic Messages API and, when pointed at a custom base URL, forwards
5//! its own subscription credentials (`Authorization: Bearer sk-ant-oat...`) plus the
6//! `anthropic-beta` flags that drive prompt caching. So the correct behavior is a
7//! transparent reverse proxy: relay the original body bytes and headers to
8//! api.anthropic.com and stream the response straight back. The proxy holds zero
9//! Anthropic credentials and never touches the cache-keyed request prefix.
10
11use async_trait::async_trait;
12use axum::body::Body;
13use axum::http::StatusCode;
14use axum::response::Response;
15use serde_json::Value;
16
17use crate::anthropic::error::json_error;
18use crate::anthropic::schema::MessagesRequest;
19use crate::logging::create_logger;
20use crate::provider::{CliHandlers, Provider, RequestContext};
21use crate::providers::translate_shared::wrap_reasoning;
22use crate::registry::ANTHROPIC_STYLE_ALIASES;
23
24/// Rewrite an outgoing Anthropic request body so it survives a mid-conversation switch
25/// away from the codex backend.
26///
27/// Claude Code stores the codex backend's reconstructed reasoning as `thinking` blocks
28/// carrying an empty signature. Anthropic rejects those on replay (400
29/// `Invalid signature in thinking block`), so every post-switch turn would otherwise pay
30/// a failed round-trip plus Claude Code's strip-and-retry. A native Anthropic turn does
31/// carry prior-turn reasoning forward, so instead of dropping it we convert each
32/// signature-less `thinking` block into a tagged `text` block: Anthropic accepts text
33/// without a signature and the reasoning stays in context. Genuine Anthropic reasoning
34/// (a non-empty signature) is left untouched.
35///
36/// Returns rewritten bytes only when something changed; `None` forwards the body
37/// verbatim, keeping the byte-identical cache prefix for pure-Anthropic conversations.
38fn sanitize_anthropic_request(raw: &[u8], req_id: &str) -> Option<Vec<u8>> {
39    let mut doc: Value = serde_json::from_slice(raw).ok()?;
40    let obj = doc.as_object_mut()?;
41
42    detect_hosted_web_search_regression(obj, req_id);
43
44    let messages = obj.get_mut("messages")?.as_array_mut()?;
45    let mut changed = false;
46    for message in messages.iter_mut() {
47        if message.get("role").and_then(Value::as_str) != Some("assistant") {
48            continue;
49        }
50        let Some(content) = message.get_mut("content").and_then(Value::as_array_mut) else {
51            continue;
52        };
53        for block in content.iter_mut() {
54            changed |= rehydrate_unsigned_thinking(block);
55        }
56    }
57
58    changed.then(|| serde_json::to_vec(&doc).unwrap_or_else(|_| raw.to_vec()))
59}
60
61/// Convert one signature-less `thinking` block into a tagged `text` block in place.
62/// Returns whether the block was rewritten.
63fn rehydrate_unsigned_thinking(block: &mut Value) -> bool {
64    let Some(map) = block.as_object() else {
65        return false;
66    };
67    if map.get("type").and_then(Value::as_str) != Some("thinking") {
68        return false;
69    }
70    let signed = map
71        .get("signature")
72        .and_then(Value::as_str)
73        .is_some_and(|sig| !sig.is_empty());
74    if signed {
75        return false;
76    }
77    let reasoning = map.get("thinking").and_then(Value::as_str).unwrap_or("");
78    *block = serde_json::json!({
79        "type": "text",
80        "text": wrap_reasoning(reasoning),
81    });
82    true
83}
84
85/// Regression tripwire. Claude Code drives its `WebSearch` tool through an isolated,
86/// history-free inner call, so the hosted `web_search_20250305` tool and its
87/// reconstructed `server_tool_use` / `web_search_tool_result` blocks never appear in the
88/// outer transcript. If that ever changes (hosted web search reaching a request that
89/// already carries assistant history), those blocks would ride the transcript across a
90/// model switch and this warning flags it so the assumption can be re-checked.
91fn detect_hosted_web_search_regression(obj: &serde_json::Map<String, Value>, req_id: &str) {
92    let messages = obj.get("messages").and_then(Value::as_array);
93    let has_assistant_history = messages.is_some_and(|ms| {
94        ms.iter()
95            .any(|m| m.get("role").and_then(Value::as_str) == Some("assistant"))
96    });
97    let hosted_tool = obj
98        .get("tools")
99        .and_then(Value::as_array)
100        .is_some_and(|ts| {
101            ts.iter()
102                .any(|t| t.get("type").and_then(Value::as_str) == Some("web_search_20250305"))
103        });
104    let reconstructed_block = messages.is_some_and(|ms| {
105        ms.iter().any(|m| {
106            m.get("content")
107                .and_then(Value::as_array)
108                .is_some_and(|blocks| {
109                    blocks.iter().any(|b| {
110                        matches!(
111                            b.get("type").and_then(Value::as_str),
112                            Some("server_tool_use") | Some("web_search_tool_result")
113                        )
114                    })
115                })
116        })
117    });
118
119    if (hosted_tool && has_assistant_history) || reconstructed_block {
120        let mut fields = serde_json::Map::new();
121        fields.insert("reqId".into(), Value::String(req_id.to_string()));
122        fields.insert("hostedWebSearchTool".into(), Value::Bool(hosted_tool));
123        fields.insert(
124            "reconstructedSearchBlock".into(),
125            Value::Bool(reconstructed_block),
126        );
127        create_logger("anthropic").warn("hosted_web_search_in_history", Some(fields));
128    }
129}
130
131/// Request headers that must not be forwarded to the upstream. Hop-by-hop headers are
132/// connection-scoped; `content-length` is recomputed by the client from the body; and
133/// `accept-encoding` is dropped so the upstream answers with an identity encoding
134/// (this build of reqwest does not decompress, so forwarding a compressed body under a
135/// stale `content-encoding` would corrupt it).
136fn is_stripped_request_header(name: &str) -> bool {
137    matches!(
138        name,
139        "host"
140            | "connection"
141            | "keep-alive"
142            | "proxy-authenticate"
143            | "proxy-authorization"
144            | "te"
145            | "trailer"
146            | "transfer-encoding"
147            | "upgrade"
148            | "content-length"
149            | "accept-encoding"
150    )
151}
152
153/// Response headers that must not be relayed back to Claude Code. Hop-by-hop and
154/// framing headers are re-derived by axum for the streamed body; `content-encoding`
155/// is dropped for symmetry with the identity request above.
156fn is_stripped_response_header(name: &str) -> bool {
157    matches!(
158        name,
159        "connection"
160            | "keep-alive"
161            | "proxy-authenticate"
162            | "proxy-authorization"
163            | "te"
164            | "trailer"
165            | "transfer-encoding"
166            | "upgrade"
167            | "content-length"
168            | "content-encoding"
169    )
170}
171
172pub struct AnthropicProvider {
173    client: reqwest::Client,
174    base_url: String,
175}
176
177impl AnthropicProvider {
178    pub fn new() -> Self {
179        let client = reqwest::Client::builder()
180            .redirect(reqwest::redirect::Policy::none())
181            .build()
182            .expect("failed to build anthropic passthrough client");
183        Self {
184            client,
185            base_url: crate::config::anthropic_base_url(),
186        }
187    }
188
189    async fn relay(&self, ctx: RequestContext) -> Response {
190        let RequestContext {
191            req_id,
192            monitor,
193            passthrough,
194            ..
195        } = ctx;
196        let Some(passthrough) = passthrough else {
197            return json_error(
198                StatusCode::INTERNAL_SERVER_ERROR,
199                "api_error",
200                "anthropic passthrough is missing the original request",
201            );
202        };
203
204        let url = format!("{}{}", self.base_url, passthrough.path_and_query);
205        let mut headers = axum::http::HeaderMap::with_capacity(passthrough.headers.len());
206        for (name, value) in passthrough.headers.iter() {
207            if is_stripped_request_header(name.as_str()) {
208                continue;
209            }
210            headers.append(name.clone(), value.clone());
211        }
212
213        if let Some(monitor) = monitor.as_ref() {
214            monitor.upstream_started(&req_id);
215        }
216
217        // Rehydrate signature-less codex `thinking` blocks so a mid-conversation switch
218        // to Anthropic does not 400. Unchanged bodies are forwarded verbatim.
219        let outgoing = match sanitize_anthropic_request(&passthrough.raw_body, &req_id) {
220            Some(bytes) => reqwest::Body::from(bytes),
221            None => reqwest::Body::from(passthrough.raw_body),
222        };
223
224        let upstream = self
225            .client
226            .post(&url)
227            .headers(headers)
228            .body(outgoing)
229            .send()
230            .await;
231
232        match upstream {
233            Ok(upstream) => {
234                let status = upstream.status();
235                let mut out_headers =
236                    axum::http::HeaderMap::with_capacity(upstream.headers().len());
237                for (name, value) in upstream.headers() {
238                    if is_stripped_response_header(name.as_str()) {
239                        continue;
240                    }
241                    out_headers.append(name.clone(), value.clone());
242                }
243                let mut response = Response::new(Body::from_stream(upstream.bytes_stream()));
244                *response.status_mut() = status;
245                *response.headers_mut() = out_headers;
246                response
247            }
248            Err(err) => json_error(
249                StatusCode::BAD_GATEWAY,
250                "api_error",
251                format!("anthropic upstream request failed: {err}"),
252            ),
253        }
254    }
255}
256
257impl Default for AnthropicProvider {
258    fn default() -> Self {
259        Self::new()
260    }
261}
262
263#[async_trait]
264impl Provider for AnthropicProvider {
265    fn name(&self) -> &'static str {
266        "anthropic"
267    }
268
269    fn supported_models(&self) -> Vec<String> {
270        ANTHROPIC_STYLE_ALIASES
271            .iter()
272            .map(|alias| (*alias).to_string())
273            .collect()
274    }
275
276    fn cli(&self) -> &'static dyn CliHandlers {
277        &ANTHROPIC_CLI
278    }
279
280    async fn handle_messages(&self, _body: MessagesRequest, ctx: RequestContext) -> Response {
281        self.relay(ctx).await
282    }
283
284    async fn handle_count_tokens(&self, _body: MessagesRequest, ctx: RequestContext) -> Response {
285        self.relay(ctx).await
286    }
287}
288
289pub struct AnthropicCli;
290pub static ANTHROPIC_CLI: AnthropicCli = AnthropicCli;
291
292impl CliHandlers for AnthropicCli {
293    fn login(&self) -> anyhow::Result<()> {
294        anyhow::bail!(
295            "The Claude backend reuses Claude Code's own login; no separate authentication is required"
296        )
297    }
298    fn device(&self) -> anyhow::Result<()> {
299        anyhow::bail!(
300            "The Claude backend reuses Claude Code's own login; no separate authentication is required"
301        )
302    }
303    fn status(&self) -> anyhow::Result<()> {
304        println!("Claude backend: transparent passthrough to api.anthropic.com");
305        println!("Auth: forwarded from Claude Code (no proxy credentials stored)");
306        Ok(())
307    }
308    fn logout(&self) -> anyhow::Result<()> {
309        println!("Claude backend stores no credentials; nothing to remove");
310        Ok(())
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::providers::translate_shared::{REASONING_CLOSE, REASONING_OPEN};
318
319    #[test]
320    fn strips_hop_by_hop_and_encoding_from_request() {
321        assert!(is_stripped_request_header("host"));
322        assert!(is_stripped_request_header("content-length"));
323        assert!(is_stripped_request_header("accept-encoding"));
324        assert!(is_stripped_request_header("connection"));
325        // credentials and cache-relevant headers must survive
326        assert!(!is_stripped_request_header("authorization"));
327        assert!(!is_stripped_request_header("anthropic-beta"));
328        assert!(!is_stripped_request_header("anthropic-version"));
329        assert!(!is_stripped_request_header("content-type"));
330    }
331
332    #[test]
333    fn strips_framing_from_response() {
334        assert!(is_stripped_response_header("content-length"));
335        assert!(is_stripped_response_header("content-encoding"));
336        assert!(is_stripped_response_header("transfer-encoding"));
337        // rate-limit and request-id headers must reach Claude Code
338        assert!(!is_stripped_response_header("content-type"));
339        assert!(!is_stripped_response_header("request-id"));
340        assert!(!is_stripped_response_header(
341            "anthropic-ratelimit-requests-remaining"
342        ));
343    }
344
345    #[test]
346    fn provider_reports_name_and_models() {
347        let provider = AnthropicProvider::new();
348        assert_eq!(provider.name(), "anthropic");
349        assert!(provider.supported_models().iter().any(|m| m == "opus"));
350    }
351
352    fn parse(bytes: &[u8]) -> Value {
353        serde_json::from_slice(bytes).unwrap()
354    }
355
356    #[test]
357    fn unsigned_thinking_becomes_tagged_text() {
358        let body = serde_json::json!({
359            "messages": [
360                {"role": "user", "content": "hi"},
361                {"role": "assistant", "content": [
362                    {"type": "thinking", "thinking": "codex reasoning", "signature": ""},
363                    {"type": "text", "text": "391"}
364                ]}
365            ]
366        });
367        let raw = serde_json::to_vec(&body).unwrap();
368        let out = sanitize_anthropic_request(&raw, "req1").expect("should rewrite");
369        let doc = parse(&out);
370        let blocks = doc["messages"][1]["content"].as_array().unwrap();
371        // the thinking block is gone, replaced by tagged text; the real answer survives
372        assert!(blocks.iter().all(|b| b["type"] != "thinking"));
373        let tagged = blocks[0]["text"].as_str().unwrap();
374        assert!(tagged.starts_with(REASONING_OPEN), "{tagged}");
375        assert!(tagged.contains("codex reasoning"), "{tagged}");
376        assert!(tagged.ends_with(REASONING_CLOSE), "{tagged}");
377        assert_eq!(blocks[1]["text"], "391");
378    }
379
380    #[test]
381    fn signed_thinking_is_forwarded_verbatim() {
382        // A genuine Anthropic reasoning block (non-empty signature) must not be touched,
383        // so a pure-Anthropic conversation keeps its byte-identical cache prefix.
384        let body = serde_json::json!({
385            "messages": [
386                {"role": "assistant", "content": [
387                    {"type": "thinking", "thinking": "opus reasoning", "signature": "abc123"}
388                ]}
389            ]
390        });
391        let raw = serde_json::to_vec(&body).unwrap();
392        assert!(sanitize_anthropic_request(&raw, "req2").is_none());
393    }
394
395    #[test]
396    fn missing_signature_is_treated_as_unsigned() {
397        let body = serde_json::json!({
398            "messages": [
399                {"role": "assistant", "content": [
400                    {"type": "thinking", "thinking": "r"}
401                ]}
402            ]
403        });
404        let raw = serde_json::to_vec(&body).unwrap();
405        let out = sanitize_anthropic_request(&raw, "req3").expect("should rewrite");
406        assert_eq!(parse(&out)["messages"][0]["content"][0]["type"], "text");
407    }
408
409    #[test]
410    fn plain_request_is_forwarded_verbatim() {
411        let body = serde_json::json!({
412            "messages": [
413                {"role": "user", "content": "hi"},
414                {"role": "assistant", "content": [{"type": "text", "text": "hello"}]}
415            ]
416        });
417        let raw = serde_json::to_vec(&body).unwrap();
418        assert!(sanitize_anthropic_request(&raw, "req4").is_none());
419    }
420
421    #[test]
422    fn rewrite_is_deterministic() {
423        let body = serde_json::json!({
424            "messages": [
425                {"role": "assistant", "content": [
426                    {"type": "thinking", "thinking": "same", "signature": ""}
427                ]}
428            ]
429        });
430        let raw = serde_json::to_vec(&body).unwrap();
431        let a = sanitize_anthropic_request(&raw, "r").unwrap();
432        let b = sanitize_anthropic_request(&raw, "r").unwrap();
433        assert_eq!(
434            a, b,
435            "rewrite must be byte-stable to preserve the cache prefix"
436        );
437    }
438
439    #[test]
440    fn non_json_body_is_forwarded_verbatim() {
441        assert!(sanitize_anthropic_request(b"not json", "req5").is_none());
442    }
443}