Skip to main content

aither_claude/
client.rs

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