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"
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
153fn 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 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 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 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 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 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}