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,
21        DEFAULT_MODEL, MID_CONVERSATION_OUTPUT_CONFIG_BETA,
22    },
23    error::ClaudeError,
24    request::{
25        MessagePayload, MessagesRequest, ParameterSnapshot, apply_cache_strategy,
26        build_output_config, build_thinking, convert_native_tools, convert_tools,
27        filter_tool_definitions, to_claude_messages, tool_choice_payload,
28    },
29    response::{MessagesResponse, StreamState, parse_event, response_events, should_skip_event},
30};
31
32/// Claude chat model client for the Anthropic Messages API.
33///
34/// # Example
35///
36/// ```ignore
37/// use aither_claude::Claude;
38/// use aither_core::{LanguageModel, llm::oneshot};
39///
40/// let client = Claude::new(std::env::var("ANTHROPIC_API_KEY")?);
41///
42/// let response = client.respond(oneshot(
43///     "You are a helpful assistant.",
44///     "What is the capital of France?"
45/// )).await?;
46///
47/// println!("{response}");
48/// ```
49#[derive(Clone, Debug)]
50pub struct Claude {
51    inner: Arc<Config>,
52}
53
54impl Claude {
55    /// Create a new client using the provided API key.
56    pub fn new(api_key: impl Into<String>) -> Self {
57        Self::builder(api_key).build()
58    }
59
60    /// Start building a Claude client with custom configuration.
61    #[must_use]
62    pub fn builder(api_key: impl Into<String>) -> Builder {
63        Builder::new(api_key)
64    }
65
66    /// Override the default model in-place.
67    #[must_use]
68    pub fn with_model(mut self, model: impl Into<String>) -> Self {
69        Arc::make_mut(&mut self.inner).model = sanitize_model(model);
70        self
71    }
72
73    /// Override the base URL (useful for proxies or local deployments).
74    #[must_use]
75    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
76        Arc::make_mut(&mut self.inner).base_url = base_url.into();
77        self
78    }
79
80    /// Override the default `max_tokens`.
81    #[must_use]
82    pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
83        Arc::make_mut(&mut self.inner).default_max_tokens = max_tokens;
84        self
85    }
86
87    /// Override how long a response stream may stay silent before it ends with
88    /// a [`FailureKind::Network`](aither_core::llm::FailureKind::Network)
89    /// error. Defaults to 90 seconds.
90    #[must_use]
91    pub fn with_idle_timeout(mut self, idle_timeout: Duration) -> Self {
92        Arc::make_mut(&mut self.inner).idle_timeout = idle_timeout;
93        self
94    }
95
96    pub(crate) fn config(&self) -> Arc<Config> {
97        self.inner.clone()
98    }
99}
100
101impl LanguageModel for Claude {
102    type Error = ClaudeError;
103
104    #[allow(clippy::too_many_lines)]
105    fn respond(
106        &self,
107        request: LLMRequest,
108    ) -> impl Stream<Item = Result<Event, Self::Error>> + Send {
109        let cfg = self.config();
110        let (core_messages, parameters, tool_definitions) = request.into_parts();
111        let snapshot = ParameterSnapshot::from(&parameters);
112        let filtered_tool_definitions =
113            filter_tool_definitions(tool_definitions, &snapshot.tool_choice);
114        let missing_exact_tool = match &snapshot.tool_choice {
115            ToolChoice::Exact(name)
116                if filtered_tool_definitions.is_empty()
117                    && !native_tool_name_enabled(&snapshot, name) =>
118            {
119                Some(name.clone())
120            }
121            _ => None,
122        };
123        let mut claude_tool_payloads = convert_tools(&filtered_tool_definitions);
124        claude_tool_payloads.extend(convert_native_tools(&snapshot.native_tools));
125        let has_tools = !claude_tool_payloads.is_empty();
126        let mut claude_tools = has_tools.then_some(claude_tool_payloads);
127        let claude_tool_choice = tool_choice_payload(
128            &snapshot.tool_choice,
129            has_tools,
130            snapshot.parallel_tool_calls,
131        );
132
133        let max_tokens = snapshot.max_tokens.unwrap_or(cfg.default_max_tokens);
134
135        async_stream::stream! {
136            let (mut system_prompt, mut claude_messages) =
137                match to_claude_messages(&core_messages, takes_mid_conversation_system(&cfg)).await {
138                    Ok(payload) => payload,
139                    Err(error) => {
140                        yield Err(ClaudeError::Api(error));
141                        return;
142                    }
143                };
144
145            if let Some(tool_name) = &missing_exact_tool {
146                yield Err(ClaudeError::Api(format!(
147                    "Exact tool choice '{tool_name}' is not present in tool definitions"
148                )));
149                return;
150            }
151
152            let top_level_cache_control = if let Some(cache) = snapshot.cache {
153                match apply_cache_strategy(
154                    &mut system_prompt,
155                    &mut claude_messages,
156                    &mut claude_tools,
157                    cache,
158                ) {
159                    Ok(control) => control,
160                    Err(error) => {
161                        yield Err(ClaudeError::Api(error));
162                        return;
163                    }
164                }
165            } else {
166                None
167            };
168
169            let thinking = build_thinking(&snapshot);
170            let output_config = match build_output_config(&snapshot) {
171                Ok(config) => config,
172                Err(error) => {
173                    yield Err(error);
174                    return;
175                }
176            };
177
178            // Build and send request
179            let stream_requested = snapshot.stream.unwrap_or(true);
180            let request_body = MessagesRequest {
181                model: cfg.model.clone(),
182                max_tokens,
183                messages: claude_messages,
184                system: system_prompt,
185                stream: stream_requested,
186                temperature: snapshot.temperature,
187                top_p: snapshot.top_p,
188                top_k: snapshot.top_k,
189                stop_sequences: snapshot.stop_sequences.clone(),
190                tools: claude_tools,
191                tool_choice: claude_tool_choice,
192                thinking,
193                output_config,
194                cache_control: top_level_cache_control,
195            };
196
197            debug!("Claude request: {:?}", request_body);
198            let effort_updates = request_body
199                .messages
200                .iter()
201                .any(MessagePayload::is_effort_update);
202
203            let endpoint = cfg.request_url("/v1/messages");
204            let mut backend = client();
205
206            // Claude-specific headers
207            let builder = match backend
208                .post(endpoint)
209                .and_then(|b| b.header("x-api-key", cfg.api_key.clone()))
210                .and_then(|b| b.header("anthropic-version", ANTHROPIC_VERSION))
211                .and_then(|b| b.header(header::CONTENT_TYPE.as_str(), "application/json"))
212                .and_then(|b| {
213                    if stream_requested {
214                        b.header(header::ACCEPT.as_str(), "text/event-stream")
215                    } else {
216                        Ok(b)
217                    }
218                })
219                .and_then(|b| b.header(header::USER_AGENT.as_str(), "aither-claude/0.1"))
220                .and_then(|b| {
221                    if effort_updates {
222                        b.header("anthropic-beta", MID_CONVERSATION_OUTPUT_CONFIG_BETA)
223                    } else {
224                        Ok(b)
225                    }
226                })
227                .and_then(|b| b.json_body(&request_body))
228            {
229                Ok(b) => b,
230                Err(e) => {
231                    yield Err(ClaudeError::Http(e));
232                    return;
233                }
234            };
235
236            if !stream_requested {
237                match builder.json::<MessagesResponse>().await {
238                    Ok(response) => {
239                        for event in response_events(response) {
240                            yield Ok(event);
241                        }
242                    }
243                    Err(e) => yield Err(ClaudeError::Http(e)),
244                }
245                return;
246            }
247
248            let sse_stream = match builder.sse().await {
249                Ok(stream) => stream,
250                Err(e) => {
251                    yield Err(ClaudeError::Http(e));
252                    return;
253                }
254            };
255            futures_lite::pin!(sse_stream);
256
257            let mut state = StreamState::new();
258
259            loop {
260                // Bytes that never come are the signature of a socket that
261                // died while the machine slept.
262                let next = futures_lite::future::or(
263                    async { Some(sse_stream.next().await) },
264                    async {
265                        aither_core::time::sleep(cfg.idle_timeout).await;
266                        None
267                    },
268                )
269                .await;
270                let Some(event) = next else {
271                    yield Err(ClaudeError::IdleTimeout { idle: cfg.idle_timeout });
272                    return;
273                };
274                let Some(event) = event else { break };
275                let event = match event {
276                    Ok(event) => event,
277                    Err(e) => {
278                        yield Err(ClaudeError::from(e));
279                        return;
280                    }
281                };
282                if should_skip_event(&event) {
283                    continue;
284                }
285                match parse_event(&event, &mut state) {
286                    Ok(llm_events) => {
287                        for llm_event in llm_events {
288                            yield Ok(llm_event);
289                        }
290                    }
291                    Err(e) => {
292                        yield Err(e);
293                        return;
294                    }
295                }
296            }
297
298            // Tool calls are reported, never executed: the consumer decides.
299            for call in state.tool_calls {
300                // Claude scopes thinking to the turn, not to the individual
301                // call, so the state rides on the assistant message instead.
302                yield Ok(Event::tool_call(call.id, call.name, call.input));
303            }
304
305            debug!("Claude response complete, stop_reason: {:?}", state.stop_reason);
306        }
307    }
308
309    fn complete(&self, prefix: &str) -> impl Stream<Item = Result<Event, Self::Error>> + Send {
310        self.respond(oneshot(
311            "Continue the user provided text without additional commentary.",
312            prefix,
313        ))
314    }
315
316    fn profile(
317        &self,
318    ) -> impl core::future::Future<Output = Result<ModelProfile, Self::Error>> + Send {
319        let cfg = self.inner.clone();
320        async move {
321            // Try to fetch context window from proxy API (e.g., OpenRouter)
322            // Native Anthropic API doesn't expose this
323            let context_length = match fetch_model_context_length(&cfg).await {
324                Ok(len) => len,
325                Err(e) => {
326                    tracing::debug!("API did not return context_length: {e}");
327                    // Fallback to models database
328                    aither_models::lookup(&cfg.model)
329                        .and_then(aither_models::ModelEntry::max_input_tokens)
330                        .ok_or_else(|| {
331                            ClaudeError::Api(format!(
332                                "cannot determine context length for model '{model}': \
333                                 {models_url} request failed ({e}) and aither-models has no entry",
334                                model = cfg.model,
335                                models_url = cfg.request_url("/models"),
336                            ))
337                        })?
338                }
339            };
340
341            let mut profile = ModelProfile::new(
342                cfg.model.clone(),
343                "Anthropic",
344                cfg.model.clone(),
345                "Claude model by Anthropic",
346                context_length,
347            )
348            .with_abilities([Ability::ToolUse, Ability::Vision]);
349            if takes_per_message_effort(&cfg) {
350                profile.abilities.push(Ability::PerMessageEffort);
351            }
352            if takes_mid_conversation_system(&cfg) {
353                profile.abilities.push(Ability::MidConversationSystem);
354            }
355            profile.default_reasoning_effort = aither_models::default_reasoning_effort(&cfg.model);
356
357            for ability in &cfg.native_abilities {
358                if !profile.abilities.contains(ability) {
359                    profile.abilities.push(*ability);
360                }
361            }
362            Ok(profile)
363        }
364    }
365}
366
367/// Whether this client's model takes a per-message effort change: the
368/// registry lists the model for it, and the client talks to the Claude API
369/// itself, one of the platforms Anthropic documents the beta on (a proxy
370/// behind another base URL is not).
371fn takes_per_message_effort(cfg: &Config) -> bool {
372    cfg.base_url.trim_end_matches('/') == CLAUDE_BASE_URL
373        && aither_models::lookup(&cfg.model)
374            .is_some_and(|entry| entry.has_ability(Ability::PerMessageEffort))
375}
376
377/// Whether this client's model takes a system message inside `messages`: the
378/// registry lists the model for it and the client talks to the Claude API
379/// itself.
380fn takes_mid_conversation_system(cfg: &Config) -> bool {
381    cfg.base_url.trim_end_matches('/') == CLAUDE_BASE_URL
382        && aither_models::lookup(&cfg.model)
383            .is_some_and(|entry| entry.has_ability(Ability::MidConversationSystem))
384}
385
386fn native_tool_name_enabled(snapshot: &ParameterSnapshot, name: &str) -> bool {
387    let tools = &snapshot.native_tools;
388    matches!(
389        name,
390        "web_search" if tools.web_search
391    ) || matches!(
392        name,
393        "web_fetch" if tools.web_fetch
394    ) || matches!(
395        name,
396        "code_execution" if tools.code_execution
397    ) || matches!(
398        name,
399        "bash" if tools.bash
400    ) || matches!(
401        name,
402        "str_replace_based_edit_tool" if tools.text_editor.is_some()
403    )
404}
405
406/// Try to fetch context length from the models endpoint.
407async fn fetch_model_context_length(cfg: &Config) -> Result<u32, ClaudeError> {
408    #[derive(serde::Deserialize)]
409    struct ModelsResponse {
410        data: Vec<ModelInfo>,
411    }
412    #[derive(serde::Deserialize)]
413    struct ModelInfo {
414        id: String,
415        /// Anthropic uses `max_tokens`, `OpenRouter` uses `context_length`
416        #[serde(default, alias = "max_tokens")]
417        context_length: Option<u32>,
418    }
419
420    let url = format!("{}/models", cfg.base_url.trim_end_matches('/'));
421    let mut backend = client();
422    let mut req = backend.get(&url).map_err(ClaudeError::Http)?;
423
424    // Anthropic uses x-api-key header, proxies use Bearer token
425    if cfg.base_url.contains("anthropic.com") {
426        req = req
427            .header("x-api-key", cfg.api_key.clone())
428            .map_err(ClaudeError::Http)?
429            .header("anthropic-version", ANTHROPIC_VERSION)
430            .map_err(ClaudeError::Http)?;
431    } else {
432        req = req
433            .header(
434                header::AUTHORIZATION.as_str(),
435                format!("Bearer {}", cfg.api_key),
436            )
437            .map_err(ClaudeError::Http)?;
438    }
439
440    let response: ModelsResponse = req.json().await.map_err(ClaudeError::Http)?;
441
442    for model in response.data {
443        if model.id == cfg.model
444            && let Some(ctx) = model.context_length
445        {
446            return Ok(ctx);
447        }
448    }
449
450    Err(ClaudeError::Api(format!(
451        "Model '{}' not found or missing context_length",
452        cfg.model
453    )))
454}
455
456/// Builder for Claude clients.
457#[derive(Debug)]
458pub struct Builder {
459    api_key: String,
460    base_url: String,
461    model: String,
462    default_max_tokens: u32,
463    native_abilities: Vec<Ability>,
464    idle_timeout: Duration,
465}
466
467impl Builder {
468    fn new(api_key: impl Into<String>) -> Self {
469        Self {
470            api_key: api_key.into(),
471            base_url: CLAUDE_BASE_URL.to_string(),
472            model: DEFAULT_MODEL.to_string(),
473            default_max_tokens: DEFAULT_MAX_TOKENS,
474            native_abilities: Vec::new(),
475            idle_timeout: DEFAULT_IDLE_TIMEOUT,
476        }
477    }
478
479    /// Set a custom API base URL.
480    #[must_use]
481    pub fn base_url(mut self, url: impl Into<String>) -> Self {
482        self.base_url = url.into();
483        self
484    }
485
486    /// Select a model identifier.
487    #[must_use]
488    pub fn model(mut self, model: impl Into<String>) -> Self {
489        self.model = sanitize_model(model);
490        self
491    }
492
493    /// Set the default `max_tokens` for requests.
494    #[must_use]
495    pub const fn max_tokens(mut self, max_tokens: u32) -> Self {
496        self.default_max_tokens = max_tokens;
497        self
498    }
499
500    /// Set how long a response stream may stay silent before it ends with a
501    /// [`FailureKind::Network`](aither_core::llm::FailureKind::Network) error;
502    /// 90 seconds by default.
503    #[must_use]
504    pub const fn idle_timeout(mut self, idle_timeout: Duration) -> Self {
505        self.idle_timeout = idle_timeout;
506        self
507    }
508
509    /// Declare extra native capabilities supported by the model.
510    #[must_use]
511    pub fn native_capabilities(mut self, abilities: impl IntoIterator<Item = Ability>) -> Self {
512        for ability in abilities {
513            if !self.native_abilities.contains(&ability) {
514                self.native_abilities.push(ability);
515            }
516        }
517        self
518    }
519
520    /// Mark this model as having built-in PDF understanding.
521    #[must_use]
522    pub fn enable_native_pdf(self) -> Self {
523        self.native_capabilities([Ability::Pdf])
524    }
525
526    /// Consume the builder and create a Claude client.
527    #[must_use]
528    pub fn build(self) -> Claude {
529        Claude {
530            inner: Arc::new(Config {
531                api_key: self.api_key,
532                base_url: self.base_url,
533                model: self.model,
534                default_max_tokens: self.default_max_tokens,
535                native_abilities: self.native_abilities,
536                idle_timeout: self.idle_timeout,
537            }),
538        }
539    }
540}
541
542/// Internal configuration for the Claude client.
543#[derive(Debug, Clone)]
544pub struct Config {
545    pub(crate) api_key: String,
546    pub(crate) base_url: String,
547    pub(crate) model: String,
548    pub(crate) default_max_tokens: u32,
549    pub(crate) native_abilities: Vec<Ability>,
550    pub(crate) idle_timeout: Duration,
551}
552
553impl Config {
554    pub(crate) fn request_url(&self, path: &str) -> String {
555        format!(
556            "{}/{}",
557            self.base_url.trim_end_matches('/'),
558            path.trim_start_matches('/')
559        )
560    }
561}
562
563fn sanitize_model(model: impl Into<String>) -> String {
564    model.into().trim().to_string()
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use aither_core::llm::{Message, model::Parameters, model::ReasoningEffort};
571
572    /// A loopback server that records the one request it receives, headers
573    /// and body, and answers it with an error.
574    fn capturing_server() -> (String, std::sync::mpsc::Receiver<String>) {
575        use std::io::{Read, Write};
576
577        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind stub server");
578        let addr = listener.local_addr().expect("stub server address");
579        let (sender, received) = std::sync::mpsc::channel();
580        std::thread::spawn(move || {
581            let (mut stream, _) = listener.accept().expect("accept the request");
582            let mut request = Vec::new();
583            let mut chunk = [0_u8; 8192];
584            let body_start = loop {
585                let read = stream.read(&mut chunk).expect("read the request");
586                request.extend_from_slice(chunk.get(..read).expect("read within the chunk"));
587                if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") {
588                    break end + 4;
589                }
590            };
591            let head =
592                String::from_utf8_lossy(request.get(..body_start).expect("head")).into_owned();
593            let length = head.lines().find_map(|line| {
594                line.to_ascii_lowercase()
595                    .strip_prefix("content-length:")
596                    .map(|length| length.trim().parse::<usize>().expect("a numeric length"))
597            });
598            let complete = |request: &[u8]| {
599                length.map_or_else(
600                    || request.ends_with(b"0\r\n\r\n"),
601                    |length| request.len() >= body_start + length,
602                )
603            };
604            while !complete(&request) {
605                let read = stream.read(&mut chunk).expect("read the body");
606                request.extend_from_slice(chunk.get(..read).expect("read within the chunk"));
607            }
608            let raw = String::from_utf8_lossy(request.get(body_start..).expect("body"));
609            let body = if length.is_some() {
610                raw.into_owned()
611            } else {
612                dechunk(&raw)
613            };
614            sender
615                .send(format!("{head}{body}"))
616                .expect("the test is listening");
617            let _ = stream.write_all(
618                b"HTTP/1.1 400 Bad Request\r\ncontent-type: application/json\r\ncontent-length: 2\r\nconnection: close\r\n\r\n{}",
619            );
620        });
621        (format!("http://{addr}"), received)
622    }
623
624    /// The payload of a chunked transfer-encoded body.
625    fn dechunk(mut raw: &str) -> String {
626        let mut body = String::new();
627        loop {
628            let (size, rest) = raw.split_once("\r\n").expect("a chunk size line");
629            let size = usize::from_str_radix(size.trim(), 16).expect("a hex chunk size");
630            if size == 0 {
631                return body;
632            }
633            body.push_str(rest.get(..size).expect("the chunk is complete"));
634            raw = rest
635                .get(size..)
636                .and_then(|rest| rest.strip_prefix("\r\n"))
637                .expect("the chunk ends with CRLF");
638        }
639    }
640
641    /// A request carrying an effort update sends the beta header and the
642    /// effort-only system message, with the request-level effort as given.
643    #[tokio::test]
644    async fn an_effort_update_sends_the_beta_and_the_per_message_form() {
645        let (base_url, received) = capturing_server();
646        let client = Claude::builder("key")
647            .base_url(base_url)
648            .model("claude-opus-5-5")
649            .build();
650        let request = LLMRequest::new(vec![
651            Message::user("Plan the migration."),
652            Message::assistant("Here is the plan."),
653            Message::configuration(ReasoningEffort::Low),
654            Message::user("Now rename the config file."),
655        ])
656        .with_parameters(
657            Parameters::default()
658                .reasoning_effort(ReasoningEffort::High)
659                .stream(false),
660        );
661        let stream = client.respond(request);
662        futures_lite::pin!(stream);
663        let _ = stream.next().await;
664
665        let sent = received.recv().expect("the request arrives");
666        let (head, body) = sent.split_once("\r\n\r\n").expect("head and body");
667        assert!(
668            head.to_ascii_lowercase()
669                .contains("anthropic-beta: mid-conversation-output-config-2026-07-01"),
670            "{head}"
671        );
672        let body: serde_json::Value = serde_json::from_str(body).expect("a JSON body");
673        assert_eq!(body["output_config"]["effort"], "high");
674        assert_eq!(
675            body["messages"][2],
676            serde_json::json!({"role": "system", "content": [], "output_config": {"effort": "low"}})
677        );
678    }
679
680    #[test]
681    fn per_message_effort_is_the_claude_apis_on_the_listed_models() {
682        let config = |model: &str, base_url: &str| {
683            Claude::builder("key")
684                .model(model)
685                .base_url(base_url)
686                .build()
687                .config()
688        };
689        assert!(takes_per_message_effort(&config(
690            "claude-opus-5-5",
691            CLAUDE_BASE_URL
692        )));
693        assert!(!takes_per_message_effort(&config(
694            "claude-sonnet-5",
695            CLAUDE_BASE_URL
696        )));
697        assert!(!takes_per_message_effort(&config(
698            "claude-opus-5-5",
699            "https://openrouter.ai/api/v1"
700        )));
701    }
702}