claude_codex/providers/anthropic/
mod.rs1use 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
24fn 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
61fn 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
85fn 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
131fn 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
152fn 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 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 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 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 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 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}