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" | "connection"
140            | "keep-alive"
141            | "proxy-authenticate"
142            | "proxy-authorization"
143            | "te"
144            | "trailer"
145            | "transfer-encoding"
146            | "upgrade"
147            | "content-length"
148            | "accept-encoding"
149    )
150}
151
152/// Response headers that must not be relayed back to Claude Code. Hop-by-hop and
153/// framing headers are re-derived by axum for the streamed body; `content-encoding`
154/// is dropped for symmetry with the identity request above.
155fn is_stripped_response_header(name: &str) -> bool {
156    matches!(
157        name,
158        "connection"
159            | "keep-alive"
160            | "proxy-authenticate"
161            | "proxy-authorization"
162            | "te"
163            | "trailer"
164            | "transfer-encoding"
165            | "upgrade"
166            | "content-length"
167            | "content-encoding"
168    )
169}
170
171pub struct AnthropicProvider {
172    client: reqwest::Client,
173    base_url: String,
174}
175
176impl AnthropicProvider {
177    pub fn new() -> Self {
178        let client = reqwest::Client::builder()
179            .redirect(reqwest::redirect::Policy::none())
180            .build()
181            .expect("failed to build anthropic passthrough client");
182        Self {
183            client,
184            base_url: crate::config::anthropic_base_url(),
185        }
186    }
187
188    async fn relay(&self, ctx: RequestContext) -> Response {
189        let RequestContext {
190            req_id,
191            monitor,
192            passthrough,
193            ..
194        } = ctx;
195        let Some(passthrough) = passthrough else {
196            return json_error(
197                StatusCode::INTERNAL_SERVER_ERROR,
198                "api_error",
199                "anthropic passthrough is missing the original request",
200            );
201        };
202
203        let url = format!("{}{}", self.base_url, passthrough.path_and_query);
204        let mut headers = axum::http::HeaderMap::with_capacity(passthrough.headers.len());
205        for (name, value) in passthrough.headers.iter() {
206            if is_stripped_request_header(name.as_str()) {
207                continue;
208            }
209            headers.append(name.clone(), value.clone());
210        }
211
212        if let Some(monitor) = monitor.as_ref() {
213            monitor.upstream_started(&req_id);
214        }
215
216        // Rehydrate signature-less codex `thinking` blocks so a mid-conversation switch
217        // to Anthropic does not 400. Unchanged bodies are forwarded verbatim.
218        let outgoing = match sanitize_anthropic_request(&passthrough.raw_body, &req_id) {
219            Some(bytes) => reqwest::Body::from(bytes),
220            None => reqwest::Body::from(passthrough.raw_body),
221        };
222
223        let upstream = self
224            .client
225            .post(&url)
226            .headers(headers)
227            .body(outgoing)
228            .send()
229            .await;
230
231        match upstream {
232            Ok(upstream) => {
233                let status = upstream.status();
234                let mut out_headers =
235                    axum::http::HeaderMap::with_capacity(upstream.headers().len());
236                for (name, value) in upstream.headers() {
237                    if is_stripped_response_header(name.as_str()) {
238                        continue;
239                    }
240                    out_headers.append(name.clone(), value.clone());
241                }
242                let mut response = Response::new(Body::from_stream(upstream.bytes_stream()));
243                *response.status_mut() = status;
244                *response.headers_mut() = out_headers;
245                response
246            }
247            Err(err) => json_error(
248                StatusCode::BAD_GATEWAY,
249                "api_error",
250                format!("anthropic upstream request failed: {err}"),
251            ),
252        }
253    }
254}
255
256impl Default for AnthropicProvider {
257    fn default() -> Self {
258        Self::new()
259    }
260}
261
262#[async_trait]
263impl Provider for AnthropicProvider {
264    fn name(&self) -> &'static str {
265        "anthropic"
266    }
267
268    fn supported_models(&self) -> Vec<String> {
269        ANTHROPIC_STYLE_ALIASES
270            .iter()
271            .map(|alias| (*alias).to_string())
272            .collect()
273    }
274
275    fn cli(&self) -> &'static dyn CliHandlers {
276        &ANTHROPIC_CLI
277    }
278
279    async fn handle_messages(&self, _body: MessagesRequest, ctx: RequestContext) -> Response {
280        self.relay(ctx).await
281    }
282
283    async fn handle_count_tokens(&self, _body: MessagesRequest, ctx: RequestContext) -> Response {
284        self.relay(ctx).await
285    }
286}
287
288pub struct AnthropicCli;
289pub static ANTHROPIC_CLI: AnthropicCli = AnthropicCli;
290
291impl CliHandlers for AnthropicCli {
292    fn login(&self) -> anyhow::Result<()> {
293        anyhow::bail!("The Claude backend reuses Claude Code's own login; no separate authentication is required")
294    }
295    fn device(&self) -> anyhow::Result<()> {
296        anyhow::bail!("The Claude backend reuses Claude Code's own login; no separate authentication is required")
297    }
298    fn status(&self) -> anyhow::Result<()> {
299        println!("Claude backend: transparent passthrough to api.anthropic.com");
300        println!("Auth: forwarded from Claude Code (no proxy credentials stored)");
301        Ok(())
302    }
303    fn logout(&self) -> anyhow::Result<()> {
304        println!("Claude backend stores no credentials; nothing to remove");
305        Ok(())
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::providers::translate_shared::{REASONING_CLOSE, REASONING_OPEN};
313
314    #[test]
315    fn strips_hop_by_hop_and_encoding_from_request() {
316        assert!(is_stripped_request_header("host"));
317        assert!(is_stripped_request_header("content-length"));
318        assert!(is_stripped_request_header("accept-encoding"));
319        assert!(is_stripped_request_header("connection"));
320        // credentials and cache-relevant headers must survive
321        assert!(!is_stripped_request_header("authorization"));
322        assert!(!is_stripped_request_header("anthropic-beta"));
323        assert!(!is_stripped_request_header("anthropic-version"));
324        assert!(!is_stripped_request_header("content-type"));
325    }
326
327    #[test]
328    fn strips_framing_from_response() {
329        assert!(is_stripped_response_header("content-length"));
330        assert!(is_stripped_response_header("content-encoding"));
331        assert!(is_stripped_response_header("transfer-encoding"));
332        // rate-limit and request-id headers must reach Claude Code
333        assert!(!is_stripped_response_header("content-type"));
334        assert!(!is_stripped_response_header("request-id"));
335        assert!(!is_stripped_response_header("anthropic-ratelimit-requests-remaining"));
336    }
337
338    #[test]
339    fn provider_reports_name_and_models() {
340        let provider = AnthropicProvider::new();
341        assert_eq!(provider.name(), "anthropic");
342        assert!(provider.supported_models().iter().any(|m| m == "opus"));
343    }
344
345    fn parse(bytes: &[u8]) -> Value {
346        serde_json::from_slice(bytes).unwrap()
347    }
348
349    #[test]
350    fn unsigned_thinking_becomes_tagged_text() {
351        let body = serde_json::json!({
352            "messages": [
353                {"role": "user", "content": "hi"},
354                {"role": "assistant", "content": [
355                    {"type": "thinking", "thinking": "codex reasoning", "signature": ""},
356                    {"type": "text", "text": "391"}
357                ]}
358            ]
359        });
360        let raw = serde_json::to_vec(&body).unwrap();
361        let out = sanitize_anthropic_request(&raw, "req1").expect("should rewrite");
362        let doc = parse(&out);
363        let blocks = doc["messages"][1]["content"].as_array().unwrap();
364        // the thinking block is gone, replaced by tagged text; the real answer survives
365        assert!(blocks.iter().all(|b| b["type"] != "thinking"));
366        let tagged = blocks[0]["text"].as_str().unwrap();
367        assert!(tagged.starts_with(REASONING_OPEN), "{tagged}");
368        assert!(tagged.contains("codex reasoning"), "{tagged}");
369        assert!(tagged.ends_with(REASONING_CLOSE), "{tagged}");
370        assert_eq!(blocks[1]["text"], "391");
371    }
372
373    #[test]
374    fn signed_thinking_is_forwarded_verbatim() {
375        // A genuine Anthropic reasoning block (non-empty signature) must not be touched,
376        // so a pure-Anthropic conversation keeps its byte-identical cache prefix.
377        let body = serde_json::json!({
378            "messages": [
379                {"role": "assistant", "content": [
380                    {"type": "thinking", "thinking": "opus reasoning", "signature": "abc123"}
381                ]}
382            ]
383        });
384        let raw = serde_json::to_vec(&body).unwrap();
385        assert!(sanitize_anthropic_request(&raw, "req2").is_none());
386    }
387
388    #[test]
389    fn missing_signature_is_treated_as_unsigned() {
390        let body = serde_json::json!({
391            "messages": [
392                {"role": "assistant", "content": [
393                    {"type": "thinking", "thinking": "r"}
394                ]}
395            ]
396        });
397        let raw = serde_json::to_vec(&body).unwrap();
398        let out = sanitize_anthropic_request(&raw, "req3").expect("should rewrite");
399        assert_eq!(parse(&out)["messages"][0]["content"][0]["type"], "text");
400    }
401
402    #[test]
403    fn plain_request_is_forwarded_verbatim() {
404        let body = serde_json::json!({
405            "messages": [
406                {"role": "user", "content": "hi"},
407                {"role": "assistant", "content": [{"type": "text", "text": "hello"}]}
408            ]
409        });
410        let raw = serde_json::to_vec(&body).unwrap();
411        assert!(sanitize_anthropic_request(&raw, "req4").is_none());
412    }
413
414    #[test]
415    fn rewrite_is_deterministic() {
416        let body = serde_json::json!({
417            "messages": [
418                {"role": "assistant", "content": [
419                    {"type": "thinking", "thinking": "same", "signature": ""}
420                ]}
421            ]
422        });
423        let raw = serde_json::to_vec(&body).unwrap();
424        let a = sanitize_anthropic_request(&raw, "r").unwrap();
425        let b = sanitize_anthropic_request(&raw, "r").unwrap();
426        assert_eq!(a, b, "rewrite must be byte-stable to preserve the cache prefix");
427    }
428
429    #[test]
430    fn non_json_body_is_forwarded_verbatim() {
431        assert!(sanitize_anthropic_request(b"not json", "req5").is_none());
432    }
433}