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 parameters.cache.openai.is_some() || parameters.cache.gemini.is_some() {
145                yield Err(ClaudeError::Api(
146                    "Claude provider only accepts cache.claude settings".to_string(),
147                ));
148                return;
149            }
150
151            if let Some(tool_name) = &missing_exact_tool {
152                yield Err(ClaudeError::Api(format!(
153                    "Exact tool choice '{tool_name}' is not present in tool definitions"
154                )));
155                return;
156            }
157
158            let top_level_cache_control = if let Some(cache) = snapshot.cache {
159                match apply_cache_strategy(
160                    &mut system_prompt,
161                    &mut claude_messages,
162                    &mut claude_tools,
163                    cache,
164                ) {
165                    Ok(control) => control,
166                    Err(error) => {
167                        yield Err(ClaudeError::Api(error));
168                        return;
169                    }
170                }
171            } else {
172                None
173            };
174
175            let thinking = build_thinking(&snapshot);
176            let output_config = match build_output_config(&snapshot) {
177                Ok(config) => config,
178                Err(error) => {
179                    yield Err(error);
180                    return;
181                }
182            };
183
184            // Build and send request
185            let stream_requested = snapshot.stream.unwrap_or(true);
186            let request_body = MessagesRequest {
187                model: cfg.model.clone(),
188                max_tokens,
189                messages: claude_messages,
190                system: system_prompt,
191                stream: stream_requested,
192                temperature: snapshot.temperature,
193                top_p: snapshot.top_p,
194                top_k: snapshot.top_k,
195                stop_sequences: snapshot.stop_sequences.clone(),
196                tools: claude_tools,
197                tool_choice: claude_tool_choice,
198                thinking,
199                output_config,
200                cache_control: top_level_cache_control,
201            };
202
203            debug!("Claude request: {:?}", request_body);
204
205            let endpoint = cfg.request_url("/v1/messages");
206            let mut backend = client();
207
208            // Claude-specific headers
209            let builder = match backend
210                .post(endpoint)
211                .and_then(|b| b.header("x-api-key", cfg.api_key.clone()))
212                .and_then(|b| b.header("anthropic-version", ANTHROPIC_VERSION))
213                .and_then(|b| b.header(header::CONTENT_TYPE.as_str(), "application/json"))
214                .and_then(|b| {
215                    if stream_requested {
216                        b.header(header::ACCEPT.as_str(), "text/event-stream")
217                    } else {
218                        Ok(b)
219                    }
220                })
221                .and_then(|b| b.header(header::USER_AGENT.as_str(), "aither-claude/0.1"))
222                .and_then(|b| b.json_body(&request_body))
223            {
224                Ok(b) => b,
225                Err(e) => {
226                    yield Err(ClaudeError::Http(e));
227                    return;
228                }
229            };
230
231            if !stream_requested {
232                match builder.json::<MessagesResponse>().await {
233                    Ok(response) => {
234                        for event in response_events(response) {
235                            yield Ok(event);
236                        }
237                    }
238                    Err(e) => yield Err(ClaudeError::Http(e)),
239                }
240                return;
241            }
242
243            let sse_stream = match builder.sse().await {
244                Ok(stream) => stream,
245                Err(e) => {
246                    yield Err(ClaudeError::Http(e));
247                    return;
248                }
249            };
250            futures_lite::pin!(sse_stream);
251
252            let mut state = StreamState::new();
253
254            loop {
255                // Bytes that never come are the signature of a socket that
256                // died while the machine slept.
257                let next = futures_lite::future::or(
258                    async { Some(sse_stream.next().await) },
259                    async {
260                        aither_core::time::sleep(cfg.idle_timeout).await;
261                        None
262                    },
263                )
264                .await;
265                let Some(event) = next else {
266                    yield Err(ClaudeError::IdleTimeout { idle: cfg.idle_timeout });
267                    return;
268                };
269                let Some(event) = event else { break };
270                let event = match event {
271                    Ok(event) => event,
272                    Err(e) => {
273                        yield Err(ClaudeError::from(e));
274                        return;
275                    }
276                };
277                if should_skip_event(&event) {
278                    continue;
279                }
280                match parse_event(&event, &mut state) {
281                    Ok(llm_events) => {
282                        for llm_event in llm_events {
283                            yield Ok(llm_event);
284                        }
285                    }
286                    Err(e) => {
287                        yield Err(e);
288                        return;
289                    }
290                }
291            }
292
293            // Tool calls are reported, never executed: the consumer decides.
294            for call in state.tool_calls {
295                // Claude scopes thinking to the turn, not to the individual
296                // call, so the state rides on the assistant message instead.
297                yield Ok(Event::tool_call(call.id, call.name, call.input));
298            }
299
300            debug!("Claude response complete, stop_reason: {:?}", state.stop_reason);
301        }
302    }
303
304    fn complete(&self, prefix: &str) -> impl Stream<Item = Result<Event, Self::Error>> + Send {
305        self.respond(oneshot(
306            "Continue the user provided text without additional commentary.",
307            prefix,
308        ))
309    }
310
311    fn profile(
312        &self,
313    ) -> impl core::future::Future<Output = Result<ModelProfile, Self::Error>> + Send {
314        let cfg = self.inner.clone();
315        async move {
316            // Try to fetch context window from proxy API (e.g., OpenRouter)
317            // Native Anthropic API doesn't expose this
318            let context_length = match fetch_model_context_length(&cfg).await {
319                Ok(len) => len,
320                Err(e) => {
321                    tracing::debug!("API did not return context_length: {e}");
322                    // Fallback to models database
323                    aither_models::lookup(&cfg.model)
324                        .and_then(aither_models::ModelEntry::max_input_tokens)
325                        .ok_or_else(|| {
326                            ClaudeError::Api(format!(
327                                "cannot determine context length for model '{model}': \
328                                 {models_url} request failed ({e}) and aither-models has no entry",
329                                model = cfg.model,
330                                models_url = cfg.request_url("/models"),
331                            ))
332                        })?
333                }
334            };
335
336            let mut profile = ModelProfile::new(
337                cfg.model.clone(),
338                "Anthropic",
339                cfg.model.clone(),
340                "Claude model by Anthropic",
341                context_length,
342            )
343            .with_abilities([Ability::ToolUse, Ability::Vision]);
344
345            for ability in &cfg.native_abilities {
346                if !profile.abilities.contains(ability) {
347                    profile.abilities.push(*ability);
348                }
349            }
350            Ok(profile)
351        }
352    }
353}
354
355fn native_tool_name_enabled(snapshot: &ParameterSnapshot, name: &str) -> bool {
356    let tools = &snapshot.native_tools;
357    matches!(
358        name,
359        "web_search" if tools.web_search
360    ) || matches!(
361        name,
362        "web_fetch" if tools.web_fetch
363    ) || matches!(
364        name,
365        "code_execution" if tools.code_execution
366    ) || matches!(
367        name,
368        "bash" if tools.bash
369    ) || matches!(
370        name,
371        "str_replace_based_edit_tool" if tools.text_editor.is_some()
372    )
373}
374
375/// Try to fetch context length from the models endpoint.
376async fn fetch_model_context_length(cfg: &Config) -> Result<u32, ClaudeError> {
377    #[derive(serde::Deserialize)]
378    struct ModelsResponse {
379        data: Vec<ModelInfo>,
380    }
381    #[derive(serde::Deserialize)]
382    struct ModelInfo {
383        id: String,
384        /// Anthropic uses `max_tokens`, `OpenRouter` uses `context_length`
385        #[serde(default, alias = "max_tokens")]
386        context_length: Option<u32>,
387    }
388
389    let url = format!("{}/models", cfg.base_url.trim_end_matches('/'));
390    let mut backend = client();
391    let mut req = backend.get(&url).map_err(ClaudeError::Http)?;
392
393    // Anthropic uses x-api-key header, proxies use Bearer token
394    if cfg.base_url.contains("anthropic.com") {
395        req = req
396            .header("x-api-key", cfg.api_key.clone())
397            .map_err(ClaudeError::Http)?
398            .header("anthropic-version", ANTHROPIC_VERSION)
399            .map_err(ClaudeError::Http)?;
400    } else {
401        req = req
402            .header(
403                header::AUTHORIZATION.as_str(),
404                format!("Bearer {}", cfg.api_key),
405            )
406            .map_err(ClaudeError::Http)?;
407    }
408
409    let response: ModelsResponse = req.json().await.map_err(ClaudeError::Http)?;
410
411    for model in response.data {
412        if model.id == cfg.model
413            && let Some(ctx) = model.context_length
414        {
415            return Ok(ctx);
416        }
417    }
418
419    Err(ClaudeError::Api(format!(
420        "Model '{}' not found or missing context_length",
421        cfg.model
422    )))
423}
424
425/// Builder for Claude clients.
426#[derive(Debug)]
427pub struct Builder {
428    api_key: String,
429    base_url: String,
430    model: String,
431    default_max_tokens: u32,
432    native_abilities: Vec<Ability>,
433    idle_timeout: Duration,
434}
435
436impl Builder {
437    fn new(api_key: impl Into<String>) -> Self {
438        Self {
439            api_key: api_key.into(),
440            base_url: CLAUDE_BASE_URL.to_string(),
441            model: DEFAULT_MODEL.to_string(),
442            default_max_tokens: DEFAULT_MAX_TOKENS,
443            native_abilities: Vec::new(),
444            idle_timeout: DEFAULT_IDLE_TIMEOUT,
445        }
446    }
447
448    /// Set a custom API base URL.
449    #[must_use]
450    pub fn base_url(mut self, url: impl Into<String>) -> Self {
451        self.base_url = url.into();
452        self
453    }
454
455    /// Select a model identifier.
456    #[must_use]
457    pub fn model(mut self, model: impl Into<String>) -> Self {
458        self.model = sanitize_model(model);
459        self
460    }
461
462    /// Set the default `max_tokens` for requests.
463    #[must_use]
464    pub const fn max_tokens(mut self, max_tokens: u32) -> Self {
465        self.default_max_tokens = max_tokens;
466        self
467    }
468
469    /// Set how long a response stream may stay silent before it ends with a
470    /// [`FailureKind::Network`](aither_core::llm::FailureKind::Network) error;
471    /// 90 seconds by default.
472    #[must_use]
473    pub const fn idle_timeout(mut self, idle_timeout: Duration) -> Self {
474        self.idle_timeout = idle_timeout;
475        self
476    }
477
478    /// Declare extra native capabilities supported by the model.
479    #[must_use]
480    pub fn native_capabilities(mut self, abilities: impl IntoIterator<Item = Ability>) -> Self {
481        for ability in abilities {
482            if !self.native_abilities.contains(&ability) {
483                self.native_abilities.push(ability);
484            }
485        }
486        self
487    }
488
489    /// Mark this model as having built-in PDF understanding.
490    #[must_use]
491    pub fn enable_native_pdf(self) -> Self {
492        self.native_capabilities([Ability::Pdf])
493    }
494
495    /// Consume the builder and create a Claude client.
496    #[must_use]
497    pub fn build(self) -> Claude {
498        Claude {
499            inner: Arc::new(Config {
500                api_key: self.api_key,
501                base_url: self.base_url,
502                model: self.model,
503                default_max_tokens: self.default_max_tokens,
504                native_abilities: self.native_abilities,
505                idle_timeout: self.idle_timeout,
506            }),
507        }
508    }
509}
510
511/// Internal configuration for the Claude client.
512#[derive(Debug, Clone)]
513pub struct Config {
514    pub(crate) api_key: String,
515    pub(crate) base_url: String,
516    pub(crate) model: String,
517    pub(crate) default_max_tokens: u32,
518    pub(crate) native_abilities: Vec<Ability>,
519    pub(crate) idle_timeout: Duration,
520}
521
522impl Config {
523    pub(crate) fn request_url(&self, path: &str) -> String {
524        format!(
525            "{}/{}",
526            self.base_url.trim_end_matches('/'),
527            path.trim_start_matches('/')
528        )
529    }
530}
531
532fn sanitize_model(model: impl Into<String>) -> String {
533    model.into().trim().to_string()
534}