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}