doum_cli/llm/anthropic/
client.rs

1use crate::llm::anthropic::payloads::{
2    AnthropicConfig, AnthropicError, AnthropicRequest, AnthropicResponse,
3};
4use crate::llm::client::{LLMClient, LLMRequest};
5use anyhow::{Context, Result};
6use reqwest::Client;
7use std::time::Duration;
8
9/// Anthropic LLM Client
10pub struct AnthropicClient {
11    http_client: Client,
12    config: AnthropicConfig,
13}
14
15impl AnthropicClient {
16    /// Anthropic API URL and Version
17    const API_URL: &'static str = "https://api.anthropic.com/v1/messages";
18    const API_VERSION: &'static str = "2023-06-01";
19
20    /// Create a new AnthropicClient
21    pub fn new(config: AnthropicConfig, timeout: u64) -> Result<Self> {
22        if config.api_key.is_empty() {
23            anyhow::bail!(
24                "Anthropic API key is not set. Please configure it in the interactive config menu (doum config)."
25            );
26        }
27
28        let http_client = Client::builder()
29            .timeout(Duration::from_secs(timeout))
30            .build()
31            .context("Failed to build HTTP client")?;
32
33        Ok(Self {
34            http_client,
35            config,
36        })
37    }
38}
39
40#[async_trait::async_trait]
41impl LLMClient for AnthropicClient {
42    async fn generate(&self, request: LLMRequest) -> Result<String> {
43        let request_body = AnthropicRequest {
44            model: self.config.model.clone(),
45            system: Some(request.system),
46            messages: request.messages,
47            max_tokens: 4096,
48        };
49
50        let response = self
51            .http_client
52            .post(Self::API_URL)
53            .header("x-api-key", &self.config.api_key)
54            .header("anthropic-version", Self::API_VERSION)
55            .header("Content-Type", "application/json")
56            .json(&request_body)
57            .send()
58            .await
59            .map_err(|e| {
60                if e.is_timeout() {
61                    anyhow::anyhow!("Request timeout")
62                } else if e.is_connect() {
63                    anyhow::anyhow!("Failed to connect to Anthropic API")
64                } else {
65                    anyhow::anyhow!("Failed to send request to Anthropic API: {}", e)
66                }
67            })?;
68
69        // Check response status
70        let status = response.status();
71
72        if !status.is_success() {
73            let error_text = response
74                .text()
75                .await
76                .unwrap_or_else(|_| "Unknown error".to_string());
77
78            // Try to parse Anthropic error format
79            if let Ok(anthropic_error) = serde_json::from_str::<AnthropicError>(&error_text) {
80                anyhow::bail!(
81                    "Anthropic API Error ({}): {}",
82                    status,
83                    anthropic_error.error.message
84                );
85            }
86
87            anyhow::bail!("Anthropic API Error: {} - {}", status, error_text);
88        }
89
90        // Parse response body
91        let anthropic_response: AnthropicResponse = response
92            .json()
93            .await
94            .context("Failed to parse Anthropic response")?;
95
96        // Extract and return the generated content
97        anthropic_response
98            .content
99            .first()
100            .map(|block| block.text.clone())
101            .ok_or_else(|| anyhow::anyhow!("No content in Anthropic response"))
102    }
103}