Skip to main content

opendev_http/adapters/anthropic/
mod.rs

1//! Anthropic-specific adapter.
2//!
3//! Handles Anthropic API differences:
4//! - Messages API format (system as top-level field, not in messages)
5//! - `anthropic-version` header
6//! - Prompt caching via `cache_control` blocks
7//! - Image blocks using Anthropic's native `source` format
8
9mod request;
10mod response;
11
12use serde_json::{Value, json};
13
14const DEFAULT_API_URL: &str = "https://api.anthropic.com/v1/messages";
15const ANTHROPIC_VERSION: &str = "2023-06-01";
16
17/// Adapter for the Anthropic Messages API.
18#[derive(Debug, Clone)]
19pub struct AnthropicAdapter {
20    api_url: String,
21    enable_caching: bool,
22}
23
24impl AnthropicAdapter {
25    /// Create a new Anthropic adapter.
26    pub fn new() -> Self {
27        Self {
28            api_url: DEFAULT_API_URL.to_string(),
29            enable_caching: true,
30        }
31    }
32
33    /// Create with a custom API URL.
34    pub fn with_url(url: impl Into<String>) -> Self {
35        Self {
36            api_url: url.into(),
37            enable_caching: true,
38        }
39    }
40
41    /// Enable or disable prompt caching.
42    pub fn with_caching(mut self, enable: bool) -> Self {
43        self.enable_caching = enable;
44        self
45    }
46}
47
48/// Check if a model supports extended thinking (Claude 3.7+).
49fn supports_thinking(model: &str) -> bool {
50    let m = model.to_lowercase();
51    m.starts_with("claude-3-7")
52        || m.starts_with("claude-3.7")
53        || m.starts_with("claude-4")
54        || m.starts_with("claude-opus")
55        || m.starts_with("claude-sonnet-4")
56        || m.starts_with("claude-sonnet-5")
57}
58
59/// Check if a model supports adaptive thinking (Claude 4.6+ only).
60/// Adaptive thinking uses `type: "adaptive"` instead of `type: "enabled"`,
61/// letting the model decide how much to think rather than requiring a fixed budget.
62fn supports_adaptive_thinking(model: &str) -> bool {
63    let m = model.to_lowercase();
64    m.contains("opus-4-6")
65        || m.contains("opus-4.6")
66        || m.contains("sonnet-4-6")
67        || m.contains("sonnet-4.6")
68}
69
70impl Default for AnthropicAdapter {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76#[async_trait::async_trait]
77impl super::base::ProviderAdapter for AnthropicAdapter {
78    fn provider_name(&self) -> &str {
79        "anthropic"
80    }
81
82    fn convert_request(&self, mut payload: Value) -> Value {
83        // Extract and handle reasoning effort before other conversions
84        let reasoning_effort = payload
85            .as_object_mut()
86            .and_then(|obj| obj.remove("_reasoning_effort"))
87            .and_then(|v| v.as_str().map(String::from));
88
89        Self::extract_system(&mut payload);
90        Self::convert_image_blocks(&mut payload);
91        Self::convert_tools(&mut payload);
92        Self::convert_tool_messages(&mut payload);
93        Self::ensure_max_tokens(&mut payload);
94
95        // Configure extended thinking if requested and supported
96        let model = payload
97            .get("model")
98            .and_then(|m| m.as_str())
99            .unwrap_or("")
100            .to_string();
101        if let Some(ref effort) = reasoning_effort
102            && effort != "none"
103            && supports_thinking(&model)
104        {
105            if supports_adaptive_thinking(&model) {
106                // Claude 4.6+ uses adaptive thinking — the model decides how much to think.
107                // For "low"/"medium" we set an optional budget cap; for "high" we leave it uncapped.
108                match effort.as_str() {
109                    "low" => {
110                        payload["thinking"] = json!({
111                            "type": "adaptive",
112                            "budget_tokens": 8000
113                        });
114                    }
115                    "medium" => {
116                        payload["thinking"] = json!({
117                            "type": "adaptive",
118                            "budget_tokens": 16000
119                        });
120                    }
121                    _ => {
122                        // "high" or any other value — uncapped adaptive
123                        payload["thinking"] = json!({
124                            "type": "adaptive"
125                        });
126                    }
127                }
128            } else {
129                // Legacy models (3.7, 4.0) use fixed budget thinking
130                let budget_tokens: u64 = match effort.as_str() {
131                    "low" => 4000,
132                    "medium" => 16000,
133                    "high" => 31999,
134                    _ => 16000,
135                };
136                payload["thinking"] = json!({
137                    "type": "enabled",
138                    "budget_tokens": budget_tokens
139                });
140                // Ensure max_tokens >= budget_tokens + 1024
141                let current_max = payload
142                    .get("max_tokens")
143                    .and_then(|v| v.as_u64())
144                    .unwrap_or(16384);
145                let min_max = budget_tokens + 1024;
146                if current_max < min_max {
147                    payload["max_tokens"] = json!(min_max);
148                }
149            }
150            // Anthropic requires temperature=1 for extended thinking
151            payload["temperature"] = json!(1);
152        }
153
154        if self.enable_caching {
155            Self::add_cache_control(&mut payload);
156        }
157
158        // Remove unsupported fields
159        if let Some(obj) = payload.as_object_mut() {
160            obj.remove("n");
161            obj.remove("frequency_penalty");
162            obj.remove("presence_penalty");
163            obj.remove("logprobs");
164        }
165
166        payload
167    }
168
169    fn convert_response(&self, response: Value) -> Value {
170        Self::response_to_chat_completions(response)
171    }
172
173    fn api_url(&self) -> &str {
174        &self.api_url
175    }
176
177    fn supports_streaming(&self) -> bool {
178        true
179    }
180
181    fn enable_streaming(&self, payload: &mut Value) {
182        payload["stream"] = json!(true);
183    }
184
185    fn parse_stream_event(
186        &self,
187        event_type: &str,
188        data: &Value,
189    ) -> Option<crate::streaming::StreamEvent> {
190        self.parse_stream_event_impl(event_type, data)
191    }
192
193    fn extra_headers(&self) -> Vec<(String, String)> {
194        let mut headers = vec![("anthropic-version".into(), ANTHROPIC_VERSION.into())];
195        // Build beta features list
196        let mut beta_features = Vec::new();
197        if self.enable_caching {
198            beta_features.push("prompt-caching-2024-07-31");
199        }
200        // Always include thinking beta — harmless when thinking isn't enabled
201        beta_features.push("interleaved-thinking-2025-05-14");
202        if !beta_features.is_empty() {
203            headers.push(("anthropic-beta".into(), beta_features.join(",")));
204        }
205        headers
206    }
207}
208
209#[cfg(test)]
210mod tests;