Skip to main content

aither_claude/
client.rs

1//! Claude API client implementation.
2
3use std::sync::Arc;
4
5use aither_core::{
6    LanguageModel,
7    llm::{
8        Event, LLMRequest,
9        model::{Ability, Profile as ModelProfile, ToolChoice},
10        oneshot,
11    },
12};
13use futures_core::Stream;
14use futures_lite::StreamExt;
15use tracing::debug;
16use zenwave::{Client, client, header};
17
18use crate::{
19    constant::{ANTHROPIC_VERSION, CLAUDE_BASE_URL, DEFAULT_MAX_TOKENS, DEFAULT_MODEL},
20    error::ClaudeError,
21    request::{
22        MessagesRequest, ParameterSnapshot, apply_cache_strategy, build_output_config,
23        build_thinking, convert_native_tools, convert_tools, filter_tool_definitions,
24        to_claude_messages, tool_choice_payload,
25    },
26    response::{StreamState, parse_event, should_skip_event},
27};
28
29/// Claude chat model client for the Anthropic Messages API.
30///
31/// # Example
32///
33/// ```ignore
34/// use aither_claude::Claude;
35/// use aither_core::{LanguageModel, llm::oneshot};
36///
37/// let client = Claude::new(std::env::var("ANTHROPIC_API_KEY")?);
38///
39/// let response = client.respond(oneshot(
40///     "You are a helpful assistant.",
41///     "What is the capital of France?"
42/// )).await?;
43///
44/// println!("{response}");
45/// ```
46#[derive(Clone, Debug)]
47pub struct Claude {
48    inner: Arc<Config>,
49}
50
51impl Claude {
52    /// Create a new client using the provided API key.
53    pub fn new(api_key: impl Into<String>) -> Self {
54        Self::builder(api_key).build()
55    }
56
57    /// Start building a Claude client with custom configuration.
58    #[must_use]
59    pub fn builder(api_key: impl Into<String>) -> Builder {
60        Builder::new(api_key)
61    }
62
63    /// Override the default model in-place.
64    #[must_use]
65    pub fn with_model(mut self, model: impl Into<String>) -> Self {
66        Arc::make_mut(&mut self.inner).model = sanitize_model(model);
67        self
68    }
69
70    /// Override the base URL (useful for proxies or local deployments).
71    #[must_use]
72    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
73        Arc::make_mut(&mut self.inner).base_url = base_url.into();
74        self
75    }
76
77    /// Override the default `max_tokens`.
78    #[must_use]
79    pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
80        Arc::make_mut(&mut self.inner).default_max_tokens = max_tokens;
81        self
82    }
83
84    pub(crate) fn config(&self) -> Arc<Config> {
85        self.inner.clone()
86    }
87}
88
89impl LanguageModel for Claude {
90    type Error = ClaudeError;
91
92    #[allow(clippy::too_many_lines)]
93    fn respond(
94        &self,
95        request: LLMRequest,
96    ) -> impl Stream<Item = Result<Event, Self::Error>> + Send {
97        let cfg = self.config();
98        let (core_messages, parameters, tool_definitions) = request.into_parts();
99        let snapshot = ParameterSnapshot::from(&parameters);
100        let filtered_tool_definitions =
101            filter_tool_definitions(tool_definitions, &snapshot.tool_choice);
102        let missing_exact_tool = match &snapshot.tool_choice {
103            ToolChoice::Exact(name)
104                if filtered_tool_definitions.is_empty()
105                    && !native_tool_name_enabled(&snapshot, name) =>
106            {
107                Some(name.clone())
108            }
109            _ => None,
110        };
111        let mut claude_tool_payloads = convert_tools(&filtered_tool_definitions);
112        claude_tool_payloads.extend(convert_native_tools(&snapshot.native_tools));
113        let has_tools = !claude_tool_payloads.is_empty();
114        let mut claude_tools = has_tools.then_some(claude_tool_payloads);
115        let claude_tool_choice = tool_choice_payload(
116            &snapshot.tool_choice,
117            has_tools,
118            snapshot.parallel_tool_calls,
119        );
120
121        let max_tokens = snapshot.max_tokens.unwrap_or(cfg.default_max_tokens);
122
123        async_stream::stream! {
124            let (mut system_prompt, mut claude_messages) =
125                match to_claude_messages(&core_messages).await {
126                    Ok(payload) => payload,
127                    Err(error) => {
128                        yield Err(ClaudeError::Api(error));
129                        return;
130                    }
131                };
132
133            if parameters.cache.openai.is_some() || parameters.cache.gemini.is_some() {
134                yield Err(ClaudeError::Api(
135                    "Claude provider only accepts cache.claude settings".to_string(),
136                ));
137                return;
138            }
139
140            if let Some(tool_name) = &missing_exact_tool {
141                yield Err(ClaudeError::Api(format!(
142                    "Exact tool choice '{tool_name}' is not present in tool definitions"
143                )));
144                return;
145            }
146
147            let top_level_cache_control = if let Some(cache) = snapshot.cache {
148                match apply_cache_strategy(
149                    &mut system_prompt,
150                    &mut claude_messages,
151                    &mut claude_tools,
152                    cache,
153                ) {
154                    Ok(control) => control,
155                    Err(error) => {
156                        yield Err(ClaudeError::Api(error));
157                        return;
158                    }
159                }
160            } else {
161                None
162            };
163
164            let thinking = build_thinking(&snapshot);
165            let output_config = match build_output_config(&snapshot) {
166                Ok(config) => config,
167                Err(error) => {
168                    yield Err(error);
169                    return;
170                }
171            };
172
173            // Build and send request
174            let request_body = MessagesRequest {
175                model: cfg.model.clone(),
176                max_tokens,
177                messages: claude_messages,
178                system: system_prompt,
179                stream: true,
180                temperature: snapshot.temperature,
181                top_p: snapshot.top_p,
182                top_k: snapshot.top_k,
183                stop_sequences: snapshot.stop_sequences.clone(),
184                tools: claude_tools,
185                tool_choice: claude_tool_choice,
186                thinking,
187                output_config,
188                cache_control: top_level_cache_control,
189            };
190
191            debug!("Claude request: {:?}", request_body);
192
193            let endpoint = cfg.request_url("/v1/messages");
194            let mut backend = client();
195
196            // Claude-specific headers
197            let builder = match backend
198                .post(endpoint)
199                .and_then(|b| b.header("x-api-key", cfg.api_key.clone()))
200                .and_then(|b| b.header("anthropic-version", ANTHROPIC_VERSION))
201                .and_then(|b| b.header(header::CONTENT_TYPE.as_str(), "application/json"))
202                .and_then(|b| b.header(header::ACCEPT.as_str(), "text/event-stream"))
203                .and_then(|b| b.header(header::USER_AGENT.as_str(), "aither-claude/0.1"))
204                .and_then(|b| b.json_body(&request_body))
205            {
206                Ok(b) => b,
207                Err(e) => {
208                    yield Err(ClaudeError::Http(e));
209                    return;
210                }
211            };
212            let sse_stream = match builder.sse().await {
213                Ok(stream) => stream,
214                Err(e) => {
215                    yield Err(ClaudeError::Http(e));
216                    return;
217                }
218            };
219            futures_lite::pin!(sse_stream);
220
221            let mut state = StreamState::new();
222
223            while let Some(event) = sse_stream.next().await {
224                let event = match event {
225                    Ok(event) => event,
226                    Err(e) => {
227                        yield Err(ClaudeError::from(e));
228                        return;
229                    }
230                };
231                if should_skip_event(&event) {
232                    continue;
233                }
234                match parse_event(&event, &mut state) {
235                    Ok(llm_events) => {
236                        for llm_event in llm_events {
237                            yield Ok(llm_event);
238                        }
239                    }
240                    Err(e) => {
241                        yield Err(e);
242                        return;
243                    }
244                }
245            }
246
247            // Tool calls are reported, never executed: the consumer decides.
248            for call in state.tool_calls {
249                // Claude scopes thinking to the turn, not to the individual
250                // call, so the state rides on the assistant message instead.
251                yield Ok(Event::ToolCall(aither_core::llm::ToolCall {
252                    id: call.id,
253                    name: call.name,
254                    arguments: call.input,
255                    reasoning_state: None,
256                }));
257            }
258
259            debug!("Claude response complete, stop_reason: {:?}", state.stop_reason);
260        }
261    }
262
263    fn complete(&self, prefix: &str) -> impl Stream<Item = Result<Event, Self::Error>> + Send {
264        self.respond(oneshot(
265            "Continue the user provided text without additional commentary.",
266            prefix,
267        ))
268    }
269
270    fn profile(&self) -> impl core::future::Future<Output = ModelProfile> + Send {
271        let cfg = self.inner.clone();
272        async move {
273            // Try to fetch context window from proxy API (e.g., OpenRouter)
274            // Native Anthropic API doesn't expose this
275            let context_length = match fetch_model_context_length(&cfg).await {
276                Ok(len) => len,
277                Err(e) => {
278                    tracing::debug!("API did not return context_length: {e}");
279                    // Fallback to models database
280                    aither_models::lookup(&cfg.model)
281                        .and_then(aither_models::ModelEntry::max_input_tokens)
282                        .unwrap_or_else(|| {
283                            panic!(
284                                "Claude model '{}' missing context metadata from provider and aither-models",
285                                cfg.model
286                            )
287                        })
288                }
289            };
290
291            let mut profile = ModelProfile::new(
292                cfg.model.clone(),
293                "Anthropic",
294                cfg.model.clone(),
295                "Claude model by Anthropic",
296                context_length,
297            )
298            .with_abilities([Ability::ToolUse, Ability::Vision]);
299
300            for ability in &cfg.native_abilities {
301                if !profile.abilities.contains(ability) {
302                    profile.abilities.push(*ability);
303                }
304            }
305            profile
306        }
307    }
308}
309
310fn native_tool_name_enabled(snapshot: &ParameterSnapshot, name: &str) -> bool {
311    let tools = &snapshot.native_tools;
312    matches!(
313        name,
314        "web_search" if tools.web_search
315    ) || matches!(
316        name,
317        "web_fetch" if tools.web_fetch
318    ) || matches!(
319        name,
320        "code_execution" if tools.code_execution
321    ) || matches!(
322        name,
323        "bash" if tools.bash
324    ) || matches!(
325        name,
326        "str_replace_based_edit_tool" if tools.text_editor.is_some()
327    )
328}
329
330/// Try to fetch context length from the models endpoint.
331async fn fetch_model_context_length(cfg: &Config) -> Result<u32, ClaudeError> {
332    #[derive(serde::Deserialize)]
333    struct ModelsResponse {
334        data: Vec<ModelInfo>,
335    }
336    #[derive(serde::Deserialize)]
337    struct ModelInfo {
338        id: String,
339        /// Anthropic uses `max_tokens`, `OpenRouter` uses `context_length`
340        #[serde(default, alias = "max_tokens")]
341        context_length: Option<u32>,
342    }
343
344    let url = format!("{}/models", cfg.base_url.trim_end_matches('/'));
345    let mut backend = client();
346    let mut req = backend.get(&url).map_err(ClaudeError::Http)?;
347
348    // Anthropic uses x-api-key header, proxies use Bearer token
349    if cfg.base_url.contains("anthropic.com") {
350        req = req
351            .header("x-api-key", cfg.api_key.clone())
352            .map_err(ClaudeError::Http)?
353            .header("anthropic-version", ANTHROPIC_VERSION)
354            .map_err(ClaudeError::Http)?;
355    } else {
356        req = req
357            .header(
358                header::AUTHORIZATION.as_str(),
359                format!("Bearer {}", cfg.api_key),
360            )
361            .map_err(ClaudeError::Http)?;
362    }
363
364    let response: ModelsResponse = req.json().await.map_err(ClaudeError::Http)?;
365
366    for model in response.data {
367        if model.id == cfg.model
368            && let Some(ctx) = model.context_length
369        {
370            return Ok(ctx);
371        }
372    }
373
374    Err(ClaudeError::Api(format!(
375        "Model '{}' not found or missing context_length",
376        cfg.model
377    )))
378}
379
380/// Builder for Claude clients.
381#[derive(Debug)]
382pub struct Builder {
383    api_key: String,
384    base_url: String,
385    model: String,
386    default_max_tokens: u32,
387    native_abilities: Vec<Ability>,
388}
389
390impl Builder {
391    fn new(api_key: impl Into<String>) -> Self {
392        Self {
393            api_key: api_key.into(),
394            base_url: CLAUDE_BASE_URL.to_string(),
395            model: DEFAULT_MODEL.to_string(),
396            default_max_tokens: DEFAULT_MAX_TOKENS,
397            native_abilities: Vec::new(),
398        }
399    }
400
401    /// Set a custom API base URL.
402    #[must_use]
403    pub fn base_url(mut self, url: impl Into<String>) -> Self {
404        self.base_url = url.into();
405        self
406    }
407
408    /// Select a model identifier.
409    #[must_use]
410    pub fn model(mut self, model: impl Into<String>) -> Self {
411        self.model = sanitize_model(model);
412        self
413    }
414
415    /// Set the default `max_tokens` for requests.
416    #[must_use]
417    pub const fn max_tokens(mut self, max_tokens: u32) -> Self {
418        self.default_max_tokens = max_tokens;
419        self
420    }
421
422    /// Declare extra native capabilities supported by the model.
423    #[must_use]
424    pub fn native_capabilities(mut self, abilities: impl IntoIterator<Item = Ability>) -> Self {
425        for ability in abilities {
426            if !self.native_abilities.contains(&ability) {
427                self.native_abilities.push(ability);
428            }
429        }
430        self
431    }
432
433    /// Mark this model as having built-in PDF understanding.
434    #[must_use]
435    pub fn enable_native_pdf(self) -> Self {
436        self.native_capabilities([Ability::Pdf])
437    }
438
439    /// Consume the builder and create a Claude client.
440    #[must_use]
441    pub fn build(self) -> Claude {
442        Claude {
443            inner: Arc::new(Config {
444                api_key: self.api_key,
445                base_url: self.base_url,
446                model: self.model,
447                default_max_tokens: self.default_max_tokens,
448                native_abilities: self.native_abilities,
449            }),
450        }
451    }
452}
453
454/// Internal configuration for the Claude client.
455#[derive(Debug, Clone)]
456pub struct Config {
457    pub(crate) api_key: String,
458    pub(crate) base_url: String,
459    pub(crate) model: String,
460    pub(crate) default_max_tokens: u32,
461    pub(crate) native_abilities: Vec<Ability>,
462}
463
464impl Config {
465    pub(crate) fn request_url(&self, path: &str) -> String {
466        format!(
467            "{}/{}",
468            self.base_url.trim_end_matches('/'),
469            path.trim_start_matches('/')
470        )
471    }
472}
473
474fn sanitize_model(model: impl Into<String>) -> String {
475    model.into().trim().to_string()
476}