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