Skip to main content

st/proxy/claude/
mod.rs

1//! Claude API Client - Comprehensive Anthropic Claude integration via raw reqwest
2//!
3//! This module provides two interfaces:
4//!
5//! 1. **`ClaudeClient`** - Rich API with full access to all Claude features:
6//!    adaptive thinking, tool use, streaming, vision, prompt caching, etc.
7//!
8//! 2. **`AnthropicProvider`** - Backward-compatible `LlmProvider` wrapper that
9//!    plugs into the existing `LlmProxy` system. Existing code keeps working.
10//!
11//! # Quick Example
12//! ```rust,no_run
13//! let client = ClaudeClient::new("sk-ant-...".to_string());
14//!
15//! // Simple text request
16//! let response = client.messages()
17//!     .opus()
18//!     .system("You are a Rust expert")
19//!     .user("Explain lifetimes")
20//!     .thinking_adaptive()
21//!     .max_tokens(4096)
22//!     .send()
23//!     .await?;
24//!
25//! println!("{}", response.text().unwrap_or("no text"));
26//! ```
27//!
28//! # Streaming Example
29//! ```rust,no_run
30//! let mut parser = client.messages()
31//!     .sonnet()
32//!     .user("Write a haiku about Rust")
33//!     .stream()
34//!     .await?;
35//!
36//! while let Some(event) = parser.next_event().await? {
37//!     if let StreamEvent::ContentBlockDelta { delta, .. } = event {
38//!         if let ContentDelta::TextDelta { text } = delta {
39//!             print!("{}", text);
40//!         }
41//!     }
42//! }
43//! ```
44
45pub mod builder;
46pub mod error;
47pub mod stream;
48pub mod types;
49
50// Re-export key types so callers can do `use proxy::claude::*`
51pub use builder::MessageRequestBuilder;
52pub use error::ClaudeApiError;
53pub use stream::{ContentDelta, MessageAccumulator, SseParser, StreamEvent};
54pub use types::*;
55
56use crate::proxy::{LlmProvider, LlmRequest, LlmResponse, LlmRole, LlmUsage};
57use anyhow::{Context, Result};
58use async_trait::async_trait;
59use reqwest::Client;
60
61/// Current Anthropic API version header value
62const API_VERSION: &str = "2023-06-01";
63
64// ---------------------------------------------------------------------------
65// ClaudeClient - the rich API
66// ---------------------------------------------------------------------------
67
68/// Full-featured Claude API client built on raw reqwest.
69///
70/// Supports every Messages API feature: adaptive thinking, tool use,
71/// streaming (SSE), vision, prompt caching, structured outputs, beta headers.
72///
73/// Create one and reuse it - the inner reqwest::Client uses connection pooling.
74pub struct ClaudeClient {
75    pub(crate) client: Client,
76    pub(crate) api_key: String,
77    pub(crate) base_url: String,
78    pub(crate) default_model: String,
79}
80
81impl ClaudeClient {
82    /// Create a new client with the given API key.
83    /// Default model: Sonnet 4.6. Default base URL: Anthropic's API.
84    pub fn new(api_key: String) -> Self {
85        Self {
86            client: Client::new(),
87            api_key,
88            base_url: "https://api.anthropic.com/v1".to_string(),
89            default_model: models::SONNET_4_6.to_string(),
90        }
91    }
92
93    /// Override the base URL (e.g. for a proxy or testing)
94    pub fn with_base_url(mut self, url: String) -> Self {
95        self.base_url = url;
96        self
97    }
98
99    /// Set the default model used when `.model()` isn't called on the builder
100    pub fn with_default_model(mut self, model: String) -> Self {
101        self.default_model = model;
102        self
103    }
104
105    /// Start building a messages request. Returns a fluent builder.
106    pub fn messages(&self) -> MessageRequestBuilder<'_> {
107        MessageRequestBuilder::new(self)
108    }
109
110    /// Low-level: send a raw `MessagesRequest` and return the raw HTTP response.
111    /// Used internally by the builder's `.send()` and `.stream()` methods.
112    ///
113    /// Adds the required auth and version headers automatically.
114    pub(crate) async fn send_request(
115        &self,
116        request: &MessagesRequest,
117        extra_betas: &[String],
118    ) -> Result<reqwest::Response, ClaudeApiError> {
119        let url = format!("{}/messages", self.base_url);
120
121        let mut req = self
122            .client
123            .post(&url)
124            .header("x-api-key", &self.api_key)
125            .header("anthropic-version", API_VERSION)
126            .header("content-type", "application/json");
127
128        // Add any beta feature headers
129        if !extra_betas.is_empty() {
130            req = req.header("anthropic-beta", extra_betas.join(","));
131        }
132
133        let response = req
134            .json(request)
135            .send()
136            .await
137            .map_err(ClaudeApiError::Network)?;
138
139        // Check for API errors (4xx/5xx)
140        if !response.status().is_success() {
141            let status = response.status().as_u16();
142            let body = response.text().await.unwrap_or_default();
143            return Err(ClaudeApiError::from_response(status, &body));
144        }
145
146        Ok(response)
147    }
148}
149
150// ---------------------------------------------------------------------------
151// AnthropicProvider - backward-compatible LlmProvider wrapper
152// ---------------------------------------------------------------------------
153
154/// Backward-compatible provider implementing `LlmProvider`.
155///
156/// Wraps `ClaudeClient` for use with the existing `LlmProxy` system.
157/// All existing code that creates `AnthropicProvider::default()` or calls
158/// `.complete()` continues to work unchanged.
159///
160/// For rich API access (thinking, tools, streaming), use `.claude_client()`.
161pub struct AnthropicProvider {
162    client: ClaudeClient,
163}
164
165impl AnthropicProvider {
166    pub fn new(api_key: String) -> Self {
167        Self {
168            client: ClaudeClient::new(api_key),
169        }
170    }
171
172    /// Get a reference to the underlying `ClaudeClient` for full API access.
173    ///
174    /// ```rust,no_run
175    /// let provider = AnthropicProvider::default();
176    /// let response = provider.claude_client().messages()
177    ///     .opus()
178    ///     .user("Hello!")
179    ///     .thinking_adaptive()
180    ///     .send()
181    ///     .await?;
182    /// ```
183    pub fn claude_client(&self) -> &ClaudeClient {
184        &self.client
185    }
186}
187
188impl Default for AnthropicProvider {
189    fn default() -> Self {
190        let api_key = std::env::var("ANTHROPIC_API_KEY").unwrap_or_default();
191        Self::new(api_key)
192    }
193}
194
195#[async_trait]
196impl LlmProvider for AnthropicProvider {
197    /// Complete a request using the simple LlmRequest interface.
198    /// Extracts the first text block from the response for backward compat.
199    async fn complete(&self, request: LlmRequest) -> Result<LlmResponse> {
200        // Build a rich request from the simple LlmRequest
201        let mut builder = self
202            .client
203            .messages()
204            .model(&request.model)
205            .max_tokens(request.max_tokens.unwrap_or(1024));
206
207        if let Some(temp) = request.temperature {
208            builder = builder.temperature(temp);
209        }
210
211        // Separate system messages from conversation messages
212        for msg in request.messages {
213            match msg.role {
214                LlmRole::System => {
215                    builder = builder.system(&msg.content);
216                }
217                LlmRole::User => {
218                    builder = builder.user(&msg.content);
219                }
220                LlmRole::Assistant => {
221                    builder = builder.assistant(&msg.content);
222                }
223            }
224        }
225
226        let response = builder
227            .send()
228            .await
229            .context("Failed to complete Claude request")?;
230
231        // Extract the first text block (backward compatible behavior)
232        let content = response.text().unwrap_or("").to_string();
233
234        Ok(LlmResponse {
235            content,
236            model: response.model,
237            usage: Some(LlmUsage::from(response.usage)),
238        })
239    }
240
241    fn name(&self) -> &'static str {
242        "Anthropic"
243    }
244}