Skip to main content

agent_sdk_provider/
anthropic.rs

1use std::{fmt, sync::Arc};
2
3use agent_sdk_core::{
4    AgentError, ProviderAdapter, ProviderCapabilities, ProviderMessageRole,
5    ProviderProjectionPolicy, ProviderRequest, ProviderResponse, ProviderStopReason,
6    ProviderToolCall, ProviderUsage, RetryClassification, ToolCallId,
7    tool_records::CanonicalToolName,
8};
9use serde::{Deserialize, Serialize};
10use serde_json::{Value, json};
11
12use crate::{
13    ProviderApiKey, ProviderToolArgumentSink,
14    error::{provider_failure, unsupported_response},
15    http::{CurlJsonHttpTransport, JsonHttpRequest, JsonHttpTransport},
16};
17
18#[derive(Clone, Debug, Eq, PartialEq)]
19/// Configuration for the live Anthropic Messages adapter.
20pub struct AnthropicMessagesConfig {
21    /// Stable provider ref exposed through `ProviderCapabilities`.
22    pub provider_ref: String,
23    /// Anthropic model id.
24    pub model: String,
25    /// Absolute Messages API endpoint.
26    pub endpoint_url: String,
27    /// Anthropic API version header value.
28    pub api_version: String,
29    /// Maximum output tokens for one request.
30    pub max_tokens: u32,
31    /// Maximum input tokens advertised by this route.
32    pub max_input_tokens: Option<u32>,
33}
34
35impl AnthropicMessagesConfig {
36    /// Creates a config for Anthropic's hosted Messages API.
37    pub fn new(model: impl Into<String>) -> Self {
38        Self {
39            provider_ref: "provider.anthropic.messages".to_string(),
40            model: model.into(),
41            endpoint_url: "https://api.anthropic.com/v1/messages".to_string(),
42            api_version: "2023-06-01".to_string(),
43            max_tokens: 1024,
44            max_input_tokens: None,
45        }
46    }
47
48    /// Sets the stable provider ref used in SDK capability metadata.
49    pub fn provider_ref(mut self, provider_ref: impl Into<String>) -> Self {
50        self.provider_ref = provider_ref.into();
51        self
52    }
53
54    /// Sets a custom endpoint URL.
55    pub fn endpoint_url(mut self, endpoint_url: impl Into<String>) -> Self {
56        self.endpoint_url = endpoint_url.into();
57        self
58    }
59
60    /// Sets the Anthropic API version header.
61    pub fn api_version(mut self, api_version: impl Into<String>) -> Self {
62        self.api_version = api_version.into();
63        self
64    }
65
66    /// Sets the maximum output token budget.
67    pub fn max_tokens(mut self, max_tokens: u32) -> Self {
68        self.max_tokens = max_tokens;
69        self
70    }
71
72    /// Sets the maximum input token limit advertised for this route.
73    pub fn max_input_tokens(mut self, max_input_tokens: u32) -> Self {
74        self.max_input_tokens = Some(max_input_tokens);
75        self
76    }
77}
78
79#[derive(Clone)]
80/// Live Anthropic Messages API adapter.
81pub struct AnthropicMessagesAdapter {
82    config: AnthropicMessagesConfig,
83    api_key: ProviderApiKey,
84    http: Arc<dyn JsonHttpTransport>,
85    argument_sink: Option<Arc<dyn ProviderToolArgumentSink>>,
86}
87
88impl AnthropicMessagesAdapter {
89    /// Creates a live adapter using `ANTHROPIC_API_KEY`.
90    pub fn from_env(model: impl Into<String>) -> Result<Self, AgentError> {
91        Self::new(
92            AnthropicMessagesConfig::new(model),
93            ProviderApiKey::from_env("ANTHROPIC_API_KEY")?,
94        )
95    }
96
97    /// Creates a live adapter with a host-resolved API key.
98    pub fn new(
99        config: AnthropicMessagesConfig,
100        api_key: ProviderApiKey,
101    ) -> Result<Self, AgentError> {
102        Self::with_transport(config, api_key, Arc::new(CurlJsonHttpTransport::new()))
103    }
104
105    /// Creates an adapter with an injected JSON transport.
106    pub fn with_transport(
107        config: AnthropicMessagesConfig,
108        api_key: ProviderApiKey,
109        http: Arc<dyn JsonHttpTransport>,
110    ) -> Result<Self, AgentError> {
111        Ok(Self {
112            config,
113            api_key,
114            http,
115            argument_sink: None,
116        })
117    }
118
119    /// Adds an optional host-owned sink for raw tool-call arguments.
120    pub fn with_argument_sink(mut self, sink: Arc<dyn ProviderToolArgumentSink>) -> Self {
121        self.argument_sink = Some(sink);
122        self
123    }
124
125    fn wire_request(&self, request: &ProviderRequest) -> Value {
126        let mut system = Vec::new();
127        let mut messages = Vec::new();
128        for message in &request.messages {
129            match message.role {
130                ProviderMessageRole::System | ProviderMessageRole::Developer => {
131                    system.push(message.content.clone());
132                }
133                ProviderMessageRole::Assistant => {
134                    messages.push(json!({"role": "assistant", "content": message.content}));
135                }
136                ProviderMessageRole::Tool => {
137                    messages.push(json!({
138                        "role": "user",
139                        "content": format!("Tool result:\n{}", message.content),
140                    }));
141                }
142                ProviderMessageRole::Context | ProviderMessageRole::User => {
143                    messages.push(json!({"role": "user", "content": message.content}));
144                }
145            }
146        }
147
148        let mut body = json!({
149            "model": self.config.model.clone(),
150            "max_tokens": self.config.max_tokens,
151            "messages": messages,
152        });
153        if !system.is_empty() {
154            body["system"] = Value::String(system.join("\n\n"));
155        }
156        if let Some(output_config) = anthropic_output_config(request) {
157            body["output_config"] = output_config;
158        }
159        body
160    }
161
162    fn map_response(
163        &self,
164        response: AnthropicMessagesResponse,
165    ) -> Result<ProviderResponse, AgentError> {
166        let tool_calls = self.tool_calls_from_response(&response)?;
167        let usage = response.usage.clone().map(ProviderUsage::from);
168        if !tool_calls.is_empty() {
169            let mut mapped = ProviderResponse::tool_use(tool_calls);
170            mapped.usage = usage;
171            return Ok(mapped);
172        }
173
174        Ok(ProviderResponse {
175            schema_version: ProviderResponse::SCHEMA_VERSION,
176            output_text: response.output_text(),
177            stop_reason: response.stop_reason(),
178            tool_calls: Vec::new(),
179            usage,
180        })
181    }
182
183    fn tool_calls_from_response(
184        &self,
185        response: &AnthropicMessagesResponse,
186    ) -> Result<Vec<ProviderToolCall>, AgentError> {
187        let mut calls = Vec::new();
188        for item in &response.content {
189            if item.kind != "tool_use" {
190                continue;
191            }
192            let call_id = item.id.as_deref().ok_or_else(|| {
193                unsupported_response("Anthropic Messages", "tool_use block missing id")
194            })?;
195            let name = item.name.as_deref().ok_or_else(|| {
196                unsupported_response("Anthropic Messages", "tool_use block missing name")
197            })?;
198            let canonical_tool_name = CanonicalToolName::new(name);
199            let mut call = ProviderToolCall::new(
200                ToolCallId::new(call_id),
201                canonical_tool_name.clone(),
202                format!("provider requested tool {name} with arguments stored as content refs"),
203            );
204            if let (Some(sink), Some(input)) = (self.argument_sink.as_ref(), item.input.as_ref()) {
205                let raw_arguments = serde_json::to_string(input).map_err(|error| {
206                    provider_failure(
207                        RetryClassification::RepairNeeded,
208                        format!("Anthropic tool input could not be serialized: {error}"),
209                    )
210                })?;
211                if let Some(args_ref) = sink.store_tool_arguments(
212                    &self.config.provider_ref,
213                    call_id,
214                    &canonical_tool_name,
215                    &raw_arguments,
216                )? {
217                    call = call.with_args_ref(args_ref);
218                }
219            }
220            calls.push(call);
221        }
222        Ok(calls)
223    }
224}
225
226impl ProviderAdapter for AnthropicMessagesAdapter {
227    fn capabilities(&self) -> ProviderCapabilities {
228        let mut capabilities = ProviderCapabilities::text_only(self.config.provider_ref.clone());
229        capabilities.supports_usage = true;
230        capabilities.max_input_tokens = self.config.max_input_tokens;
231        capabilities
232    }
233
234    fn project_request(
235        &self,
236        projection: &agent_sdk_core::ContextProjection,
237        policy: &ProviderProjectionPolicy,
238    ) -> Result<ProviderRequest, AgentError> {
239        agent_sdk_core::projection::project_context_projection(projection, policy)
240    }
241
242    fn complete(&self, request: &ProviderRequest) -> Result<ProviderResponse, AgentError> {
243        let http_request =
244            JsonHttpRequest::new(self.config.endpoint_url.clone(), self.wire_request(request))
245                .header("x-api-key", self.api_key.expose_secret())
246                .header("anthropic-version", self.config.api_version.clone())
247                .header("Content-Type", "application/json");
248        let response = self.http.post_json(http_request)?;
249        let message = serde_json::from_value::<AnthropicMessagesResponse>(response.body)
250            .map_err(|error| unsupported_response("Anthropic Messages", error.to_string()))?;
251        self.map_response(message)
252    }
253}
254
255fn anthropic_output_config(request: &ProviderRequest) -> Option<Value> {
256    let hint = request.structured_output_hint.as_ref()?;
257    let schema = hint.redacted_schema.clone()?;
258    Some(json!({
259        "format": {
260            "type": "json_schema",
261            "schema": schema,
262        }
263    }))
264}
265
266#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
267/// Minimal Anthropic Messages response shape used by the adapter.
268pub struct AnthropicMessagesResponse {
269    /// Provider response id.
270    pub id: Option<String>,
271    /// Content blocks returned by Claude.
272    #[serde(default)]
273    pub content: Vec<AnthropicContentBlock>,
274    /// Provider stop reason.
275    pub stop_reason: Option<String>,
276    /// Provider usage accounting.
277    pub usage: Option<AnthropicUsage>,
278}
279
280impl AnthropicMessagesResponse {
281    /// Creates a text response fixture.
282    pub fn text(text: impl Into<String>) -> Self {
283        Self {
284            id: Some("msg_test".to_string()),
285            content: vec![AnthropicContentBlock::text(text)],
286            stop_reason: Some("end_turn".to_string()),
287            usage: None,
288        }
289    }
290
291    /// Creates a tool-use response fixture.
292    pub fn tool_use(id: impl Into<String>, name: impl Into<String>, input: Value) -> Self {
293        Self {
294            id: Some("msg_tool".to_string()),
295            content: vec![AnthropicContentBlock::tool_use(id, name, input)],
296            stop_reason: Some("tool_use".to_string()),
297            usage: None,
298        }
299    }
300
301    fn output_text(&self) -> String {
302        self.content
303            .iter()
304            .filter(|item| item.kind == "text")
305            .filter_map(|item| item.text.as_deref())
306            .collect::<Vec<_>>()
307            .join("")
308    }
309
310    fn stop_reason(&self) -> ProviderStopReason {
311        match self.stop_reason.as_deref().unwrap_or("end_turn") {
312            "end_turn" => ProviderStopReason::EndTurn,
313            "max_tokens" => ProviderStopReason::MaxTokens,
314            "tool_use" => ProviderStopReason::ToolUse,
315            "stop_sequence" => ProviderStopReason::EndTurn,
316            _ => ProviderStopReason::Unknown,
317        }
318    }
319}
320
321impl fmt::Debug for AnthropicMessagesResponse {
322    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
323        formatter
324            .debug_struct("AnthropicMessagesResponse")
325            .field("id", &self.id)
326            .field("content_count", &self.content.len())
327            .field("content", &self.content)
328            .field("stop_reason", &self.stop_reason)
329            .field("usage", &self.usage)
330            .finish()
331    }
332}
333
334#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
335/// Anthropic content block.
336pub struct AnthropicContentBlock {
337    #[serde(rename = "type")]
338    /// Content block type.
339    pub kind: String,
340    /// Text content for text blocks.
341    pub text: Option<String>,
342    /// Tool-use id.
343    pub id: Option<String>,
344    /// Tool name.
345    pub name: Option<String>,
346    /// Tool input arguments.
347    pub input: Option<Value>,
348}
349
350impl AnthropicContentBlock {
351    /// Creates a text block.
352    pub fn text(text: impl Into<String>) -> Self {
353        Self {
354            kind: "text".to_string(),
355            text: Some(text.into()),
356            id: None,
357            name: None,
358            input: None,
359        }
360    }
361
362    /// Creates a tool-use block.
363    pub fn tool_use(id: impl Into<String>, name: impl Into<String>, input: Value) -> Self {
364        Self {
365            kind: "tool_use".to_string(),
366            text: None,
367            id: Some(id.into()),
368            name: Some(name.into()),
369            input: Some(input),
370        }
371    }
372}
373
374impl fmt::Debug for AnthropicContentBlock {
375    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
376        formatter
377            .debug_struct("AnthropicContentBlock")
378            .field("kind", &self.kind)
379            .field(
380                "text_chars",
381                &self.text.as_ref().map(|value| value.chars().count()),
382            )
383            .field("id", &self.id)
384            .field("name", &self.name)
385            .field("input", &"<redacted>")
386            .field("input_present", &self.input.is_some())
387            .finish()
388    }
389}
390
391#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
392/// Anthropic usage accounting.
393pub struct AnthropicUsage {
394    /// Provider input tokens.
395    pub input_tokens: Option<u32>,
396    /// Provider output tokens.
397    pub output_tokens: Option<u32>,
398}
399
400impl From<AnthropicUsage> for ProviderUsage {
401    fn from(value: AnthropicUsage) -> Self {
402        let total_tokens = match (value.input_tokens, value.output_tokens) {
403            (Some(input), Some(output)) => Some(input + output),
404            _ => None,
405        };
406        Self {
407            input_tokens: value.input_tokens,
408            output_tokens: value.output_tokens,
409            total_tokens,
410        }
411    }
412}