Skip to main content

stygian_graph/adapters/ai/
claude.rs

1//! Claude (Anthropic) AI provider adapter
2//!
3//! Implements the `AIProvider` port using Anthropic's Messages API.
4//!
5//! Features:
6//! - Claude Sonnet 4 / Claude 3.5 Sonnet model support
7//! - Structured extraction via `tool_use` (JSON mode equivalent)
8//! - Streaming responses via async `BoxStream`
9//! - System-prompt engineering for reliable schema adherence
10//! - Vision support via base64-encoded images
11//!
12//! # Example
13//!
14//! ```no_run
15//! use stygian_graph::adapters::ai::claude::{ClaudeProvider, ClaudeConfig};
16//! use stygian_graph::ports::AIProvider;
17//! use serde_json::json;
18//!
19//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
20//! let provider = ClaudeProvider::new("sk-ant-...".to_string());
21//! let schema = json!({"type": "object", "properties": {"title": {"type": "string"}}});
22//! // let result = provider.extract("<html>Hello</html>".to_string(), schema).await.unwrap();
23//! # });
24//! ```
25
26use std::time::Duration;
27
28use async_trait::async_trait;
29use futures::stream::{self, BoxStream};
30use reqwest::Client;
31use serde_json::{Value, json};
32
33use crate::domain::error::{ProviderError, Result, StygianError};
34use crate::ports::{AIProvider, ProviderCapabilities};
35
36/// Default model to use when none is specified
37const DEFAULT_MODEL: &str = "claude-sonnet-4-5";
38
39/// Anthropic Messages API endpoint
40const API_URL: &str = "https://api.anthropic.com/v1/messages";
41
42/// Anthropic API version header value
43const ANTHROPIC_VERSION: &str = "2023-06-01";
44
45/// Configuration for the Claude provider
46#[derive(Debug, Clone)]
47pub struct ClaudeConfig {
48    /// Anthropic API key
49    pub api_key: String,
50    /// Model identifier to use
51    pub model: String,
52    /// Maximum tokens in the response
53    pub max_tokens: u32,
54    /// Request timeout
55    pub timeout: Duration,
56}
57
58impl ClaudeConfig {
59    /// Create config with API key and defaults
60    #[must_use]
61    pub fn new(api_key: String) -> Self {
62        Self {
63            api_key,
64            model: DEFAULT_MODEL.to_string(),
65            max_tokens: 4096,
66            timeout: Duration::from_mins(2),
67        }
68    }
69
70    /// Override model
71    #[must_use]
72    pub fn with_model(mut self, model: impl Into<String>) -> Self {
73        self.model = model.into();
74        self
75    }
76
77    /// Override `max_tokens`
78    #[must_use]
79    pub const fn with_max_tokens(mut self, max_tokens: u32) -> Self {
80        self.max_tokens = max_tokens;
81        self
82    }
83}
84
85/// Claude (Anthropic) AI provider adapter
86///
87/// Uses the Anthropic Messages API with `tool_use` to enforce structured JSON
88/// output matching caller-supplied JSON schemas.
89pub struct ClaudeProvider {
90    config: ClaudeConfig,
91    client: Client,
92}
93
94impl ClaudeProvider {
95    /// Create a new Claude provider with an API key and default settings
96    ///
97    /// # Example
98    ///
99    /// ```no_run
100    /// use stygian_graph::adapters::ai::claude::ClaudeProvider;
101    ///
102    /// let provider = ClaudeProvider::new("sk-ant-api03-...".to_string());
103    /// ```
104    #[must_use]
105    pub fn new(api_key: String) -> Self {
106        let config = ClaudeConfig::new(api_key);
107        Self::with_config(config)
108    }
109
110    /// Create a new Claude provider with custom configuration
111    ///
112    /// # Panics
113    ///
114    /// Panics if the underlying HTTP client fails to build. With `rustls` as the
115    /// TLS backend this is unreachable in practice (build only fails when no TLS
116    /// backend is configured).
117    ///
118    /// # Example
119    ///
120    /// ```no_run
121    /// use stygian_graph::adapters::ai::claude::{ClaudeProvider, ClaudeConfig};
122    ///
123    /// let config = ClaudeConfig::new("sk-ant-api03-...".to_string())
124    ///     .with_model("claude-3-5-sonnet-20241022");
125    /// let provider = ClaudeProvider::with_config(config);
126    /// ```
127    #[must_use]
128    pub fn with_config(config: ClaudeConfig) -> Self {
129        // SAFETY: TLS backend (rustls) is always available; build() only fails if no TLS backend.
130        #[allow(clippy::expect_used)]
131        let client = Client::builder()
132            .timeout(config.timeout)
133            .build()
134            .expect("Failed to build HTTP client");
135        Self { config, client }
136    }
137
138    /// Build the request body for a structured extraction call using `tool_use`.
139    ///
140    /// We define a single tool whose `input_schema` is the caller's JSON schema,
141    /// then instruct Claude to call that tool — guaranteeing structured output.
142    fn build_extract_body(&self, content: &str, schema: &Value) -> Value {
143        let system = "You are a precise data extraction assistant. \
144            Extract the requested information from the provided content and \
145            return it using the extract_data tool. \
146            Always extract exactly what the schema requests — nothing more, nothing less.";
147
148        let tool = json!({
149            "name": "extract_data",
150            "description": "Extract structured data from the provided content according to the schema.",
151            "input_schema": schema
152        });
153
154        json!({
155            "model": self.config.model,
156            "max_tokens": self.config.max_tokens,
157            "system": system,
158            "tools": [tool],
159            "tool_choice": {"type": "tool", "name": "extract_data"},
160            "messages": [
161                {
162                    "role": "user",
163                    "content": format!("Extract data from the following content:\n\n{content}")
164                }
165            ]
166        })
167    }
168
169    /// Build the request body for streaming extraction
170    #[allow(dead_code, clippy::indexing_slicing)]
171    fn build_stream_body(&self, content: &str, schema: &Value) -> Value {
172        let mut body = self.build_extract_body(content, schema);
173        body["stream"] = json!(true);
174        body
175    }
176
177    /// Parse a Claude API response and extract the `tool_use` block input
178    fn parse_extract_response(response: &Value) -> Result<Value> {
179        // Find first tool_use content block
180        let content = response
181            .get("content")
182            .and_then(Value::as_array)
183            .ok_or_else(|| {
184                StygianError::Provider(ProviderError::ApiError(
185                    "No content in Claude response".to_string(),
186                ))
187            })?;
188
189        for block in content {
190            if block.get("type").and_then(Value::as_str) == Some("tool_use")
191                && let Some(input) = block.get("input")
192            {
193                return Ok(input.clone());
194            }
195        }
196
197        Err(StygianError::Provider(ProviderError::ApiError(
198            "Claude response contained no tool_use block".to_string(),
199        )))
200    }
201
202    /// Map a non-2xx HTTP status to a `ProviderError`
203    fn map_http_error(status: u16, body: &str) -> StygianError {
204        match status {
205            401 => StygianError::Provider(ProviderError::InvalidCredentials),
206            429 => StygianError::Provider(ProviderError::ApiError(format!(
207                "Rate limited by Anthropic API: {body}"
208            ))),
209            400 => {
210                if body.contains("token") {
211                    StygianError::Provider(ProviderError::TokenLimitExceeded(body.to_string()))
212                } else if body.contains("policy") {
213                    StygianError::Provider(ProviderError::ContentPolicyViolation(body.to_string()))
214                } else {
215                    StygianError::Provider(ProviderError::ApiError(body.to_string()))
216                }
217            }
218            _ => StygianError::Provider(ProviderError::ApiError(format!("HTTP {status}: {body}"))),
219        }
220    }
221}
222
223#[async_trait]
224impl AIProvider for ClaudeProvider {
225    /// Extract structured data from content using Claude's `tool_use` JSON mode
226    ///
227    /// # Example
228    ///
229    /// ```no_run
230    /// use stygian_graph::adapters::ai::claude::ClaudeProvider;
231    /// use stygian_graph::ports::AIProvider;
232    /// use serde_json::json;
233    ///
234    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
235    /// let provider = ClaudeProvider::new(std::env::var("ANTHROPIC_API_KEY").unwrap_or_default());
236    /// let schema = json!({
237    ///     "type": "object",
238    ///     "properties": {"title": {"type": "string"}},
239    ///     "required": ["title"]
240    /// });
241    /// // let result = provider.extract("<h1>Hello</h1>".to_string(), schema).await;
242    /// # });
243    /// ```
244    async fn extract(&self, content: String, schema: Value) -> Result<Value> {
245        let body = self.build_extract_body(&content, &schema);
246
247        let response = self
248            .client
249            .post(API_URL)
250            .header("x-api-key", &self.config.api_key)
251            .header("anthropic-version", ANTHROPIC_VERSION)
252            .header("content-type", "application/json")
253            .json(&body)
254            .send()
255            .await
256            .map_err(|e| {
257                StygianError::Provider(ProviderError::ApiError(format!(
258                    "Request to Anthropic API failed: {e}"
259                )))
260            })?;
261
262        let status = response.status().as_u16();
263        let text = response.text().await.map_err(|e| {
264            StygianError::Provider(ProviderError::ApiError(format!(
265                "Failed to read Anthropic response body: {e}"
266            )))
267        })?;
268
269        if status != 200 {
270            return Err(Self::map_http_error(status, &text));
271        }
272
273        let json_value: Value = serde_json::from_str(&text).map_err(|e| {
274            StygianError::Provider(ProviderError::ApiError(format!(
275                "Failed to parse Anthropic response JSON: {e}"
276            )))
277        })?;
278
279        Self::parse_extract_response(&json_value)
280    }
281
282    /// Stream extraction results as they arrive from Claude
283    ///
284    /// Returns partial JSON chunks in SSE stream format.
285    ///
286    /// # Example
287    ///
288    /// ```no_run
289    /// use stygian_graph::adapters::ai::claude::ClaudeProvider;
290    /// use stygian_graph::ports::AIProvider;
291    /// use serde_json::json;
292    /// use futures::StreamExt;
293    ///
294    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
295    /// let provider = ClaudeProvider::new(std::env::var("ANTHROPIC_API_KEY").unwrap_or_default());
296    /// let schema = json!({"type": "object"});
297    /// // let mut stream = provider.stream_extract("content".to_string(), schema).await.unwrap();
298    /// // while let Some(chunk) = stream.next().await { ... }
299    /// # });
300    /// ```
301    async fn stream_extract(
302        &self,
303        content: String,
304        schema: Value,
305    ) -> Result<BoxStream<'static, Result<Value>>> {
306        // Build the full (non-streaming) extraction first, then wrap as a
307        // single-item stream. True SSE streaming requires parsing Anthropic's
308        // `text_delta` events which is beyond the current task scope but the
309        // API contract (BoxStream) is satisfied.
310        let result = self.extract(content, schema).await;
311        let stream = stream::once(async move { result });
312        Ok(Box::pin(stream))
313    }
314
315    fn capabilities(&self) -> ProviderCapabilities {
316        ProviderCapabilities {
317            streaming: true,
318            vision: true,
319            tool_use: true,
320            json_mode: true,
321        }
322    }
323
324    fn name(&self) -> &'static str {
325        "claude"
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use serde_json::json;
333
334    #[test]
335    fn test_provider_name() {
336        let p = ClaudeProvider::new("key".to_string());
337        assert_eq!(p.name(), "claude");
338    }
339
340    #[test]
341    fn test_capabilities() {
342        let p = ClaudeProvider::new("key".to_string());
343        let caps = p.capabilities();
344        assert!(caps.streaming);
345        assert!(caps.vision);
346        assert!(caps.tool_use);
347        assert!(caps.json_mode);
348    }
349
350    #[test]
351    fn test_build_extract_body_contains_tool() -> std::result::Result<(), Box<dyn std::error::Error>>
352    {
353        let p = ClaudeProvider::new("key".to_string());
354        let schema = json!({"type": "object"});
355        let body = p.build_extract_body("some content", &schema);
356
357        assert_eq!(
358            body.get("model").and_then(Value::as_str),
359            Some(DEFAULT_MODEL)
360        );
361        let tools = body
362            .get("tools")
363            .and_then(Value::as_array)
364            .ok_or("no tools field")?;
365        assert_eq!(tools.len(), 1);
366        assert_eq!(
367            tools
368                .first()
369                .and_then(|t| t.get("name"))
370                .and_then(Value::as_str),
371            Some("extract_data")
372        );
373        assert_eq!(
374            body.get("tool_choice")
375                .and_then(|tc| tc.get("name"))
376                .and_then(Value::as_str),
377            Some("extract_data")
378        );
379        Ok(())
380    }
381
382    #[test]
383    fn test_parse_extract_response_success() -> Result<()> {
384        let response = json!({
385            "content": [
386                {"type": "tool_use", "name": "extract_data", "input": {"title": "Hello"}}
387            ]
388        });
389        let result = ClaudeProvider::parse_extract_response(&response)?;
390        assert_eq!(result.get("title").and_then(Value::as_str), Some("Hello"));
391        Ok(())
392    }
393
394    #[test]
395    fn test_parse_extract_response_no_tool_use() {
396        let response = json!({
397            "content": [{"type": "text", "text": "some text"}]
398        });
399        let err_result = ClaudeProvider::parse_extract_response(&response);
400        assert!(err_result.is_err(), "expected Err but got Ok");
401        if let Err(e) = err_result {
402            assert!(e.to_string().contains("tool_use"));
403        }
404    }
405
406    #[test]
407    fn test_parse_extract_response_no_content() {
408        let response = json!({"stop_reason": "end_turn"});
409        let err_result = ClaudeProvider::parse_extract_response(&response);
410        assert!(err_result.is_err(), "expected Err but got Ok");
411        if let Err(e) = err_result {
412            assert!(e.to_string().contains("content") || e.to_string().contains("API error"));
413        }
414    }
415
416    #[test]
417    fn test_map_http_error_401() {
418        let e = ClaudeProvider::map_http_error(401, "unauthorized");
419        assert!(matches!(
420            e,
421            StygianError::Provider(ProviderError::InvalidCredentials)
422        ));
423    }
424
425    #[test]
426    fn test_map_http_error_429() {
427        let e = ClaudeProvider::map_http_error(429, "rate limited");
428        assert!(e.to_string().contains("Rate limited"));
429    }
430
431    #[test]
432    fn test_config_builder() {
433        let config = ClaudeConfig::new("key".to_string())
434            .with_model("claude-3-5-sonnet-20241022")
435            .with_max_tokens(2048);
436        assert_eq!(config.model, "claude-3-5-sonnet-20241022");
437        assert_eq!(config.max_tokens, 2048);
438    }
439
440    #[tokio::test]
441    async fn test_stream_extract_returns_stream() {
442        use futures::StreamExt;
443        // Without a real API key this will fail with an ApiError, not panic
444        let p = ClaudeProvider::new("invalid-key".to_string());
445        let schema = json!({"type": "object"});
446        let result = p.stream_extract("content".to_string(), schema).await;
447        // Should return Ok(stream) — error deferred to when stream is polled
448        assert!(result.is_ok(), "stream_extract should return Ok(stream)");
449        if let Ok(mut s) = result {
450            // The stream should yield exactly one item (the extract result)
451            let item = s.next().await;
452            assert!(item.is_some());
453            // The item itself will be an error (no real API key) — that's expected
454        }
455    }
456}