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).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            profile.default_reasoning_effort = aither_models::default_reasoning_effort(&cfg.model);
353
354            for ability in &cfg.native_abilities {
355                if !profile.abilities.contains(ability) {
356                    profile.abilities.push(*ability);
357                }
358            }
359            Ok(profile)
360        }
361    }
362}
363
364/// Whether this client's model takes a per-message effort change: the
365/// registry lists the model for it, and the client talks to the Claude API
366/// itself, one of the platforms Anthropic documents the beta on (a proxy
367/// behind another base URL is not).
368fn takes_per_message_effort(cfg: &Config) -> bool {
369    cfg.base_url.trim_end_matches('/') == CLAUDE_BASE_URL
370        && aither_models::lookup(&cfg.model)
371            .is_some_and(|entry| entry.has_ability(Ability::PerMessageEffort))
372}
373
374fn native_tool_name_enabled(snapshot: &ParameterSnapshot, name: &str) -> bool {
375    let tools = &snapshot.native_tools;
376    matches!(
377        name,
378        "web_search" if tools.web_search
379    ) || matches!(
380        name,
381        "web_fetch" if tools.web_fetch
382    ) || matches!(
383        name,
384        "code_execution" if tools.code_execution
385    ) || matches!(
386        name,
387        "bash" if tools.bash
388    ) || matches!(
389        name,
390        "str_replace_based_edit_tool" if tools.text_editor.is_some()
391    )
392}
393
394/// Try to fetch context length from the models endpoint.
395async fn fetch_model_context_length(cfg: &Config) -> Result<u32, ClaudeError> {
396    #[derive(serde::Deserialize)]
397    struct ModelsResponse {
398        data: Vec<ModelInfo>,
399    }
400    #[derive(serde::Deserialize)]
401    struct ModelInfo {
402        id: String,
403        /// Anthropic uses `max_tokens`, `OpenRouter` uses `context_length`
404        #[serde(default, alias = "max_tokens")]
405        context_length: Option<u32>,
406    }
407
408    let url = format!("{}/models", cfg.base_url.trim_end_matches('/'));
409    let mut backend = client();
410    let mut req = backend.get(&url).map_err(ClaudeError::Http)?;
411
412    // Anthropic uses x-api-key header, proxies use Bearer token
413    if cfg.base_url.contains("anthropic.com") {
414        req = req
415            .header("x-api-key", cfg.api_key.clone())
416            .map_err(ClaudeError::Http)?
417            .header("anthropic-version", ANTHROPIC_VERSION)
418            .map_err(ClaudeError::Http)?;
419    } else {
420        req = req
421            .header(
422                header::AUTHORIZATION.as_str(),
423                format!("Bearer {}", cfg.api_key),
424            )
425            .map_err(ClaudeError::Http)?;
426    }
427
428    let response: ModelsResponse = req.json().await.map_err(ClaudeError::Http)?;
429
430    for model in response.data {
431        if model.id == cfg.model
432            && let Some(ctx) = model.context_length
433        {
434            return Ok(ctx);
435        }
436    }
437
438    Err(ClaudeError::Api(format!(
439        "Model '{}' not found or missing context_length",
440        cfg.model
441    )))
442}
443
444/// Builder for Claude clients.
445#[derive(Debug)]
446pub struct Builder {
447    api_key: String,
448    base_url: String,
449    model: String,
450    default_max_tokens: u32,
451    native_abilities: Vec<Ability>,
452    idle_timeout: Duration,
453}
454
455impl Builder {
456    fn new(api_key: impl Into<String>) -> Self {
457        Self {
458            api_key: api_key.into(),
459            base_url: CLAUDE_BASE_URL.to_string(),
460            model: DEFAULT_MODEL.to_string(),
461            default_max_tokens: DEFAULT_MAX_TOKENS,
462            native_abilities: Vec::new(),
463            idle_timeout: DEFAULT_IDLE_TIMEOUT,
464        }
465    }
466
467    /// Set a custom API base URL.
468    #[must_use]
469    pub fn base_url(mut self, url: impl Into<String>) -> Self {
470        self.base_url = url.into();
471        self
472    }
473
474    /// Select a model identifier.
475    #[must_use]
476    pub fn model(mut self, model: impl Into<String>) -> Self {
477        self.model = sanitize_model(model);
478        self
479    }
480
481    /// Set the default `max_tokens` for requests.
482    #[must_use]
483    pub const fn max_tokens(mut self, max_tokens: u32) -> Self {
484        self.default_max_tokens = max_tokens;
485        self
486    }
487
488    /// Set how long a response stream may stay silent before it ends with a
489    /// [`FailureKind::Network`](aither_core::llm::FailureKind::Network) error;
490    /// 90 seconds by default.
491    #[must_use]
492    pub const fn idle_timeout(mut self, idle_timeout: Duration) -> Self {
493        self.idle_timeout = idle_timeout;
494        self
495    }
496
497    /// Declare extra native capabilities supported by the model.
498    #[must_use]
499    pub fn native_capabilities(mut self, abilities: impl IntoIterator<Item = Ability>) -> Self {
500        for ability in abilities {
501            if !self.native_abilities.contains(&ability) {
502                self.native_abilities.push(ability);
503            }
504        }
505        self
506    }
507
508    /// Mark this model as having built-in PDF understanding.
509    #[must_use]
510    pub fn enable_native_pdf(self) -> Self {
511        self.native_capabilities([Ability::Pdf])
512    }
513
514    /// Consume the builder and create a Claude client.
515    #[must_use]
516    pub fn build(self) -> Claude {
517        Claude {
518            inner: Arc::new(Config {
519                api_key: self.api_key,
520                base_url: self.base_url,
521                model: self.model,
522                default_max_tokens: self.default_max_tokens,
523                native_abilities: self.native_abilities,
524                idle_timeout: self.idle_timeout,
525            }),
526        }
527    }
528}
529
530/// Internal configuration for the Claude client.
531#[derive(Debug, Clone)]
532pub struct Config {
533    pub(crate) api_key: String,
534    pub(crate) base_url: String,
535    pub(crate) model: String,
536    pub(crate) default_max_tokens: u32,
537    pub(crate) native_abilities: Vec<Ability>,
538    pub(crate) idle_timeout: Duration,
539}
540
541impl Config {
542    pub(crate) fn request_url(&self, path: &str) -> String {
543        format!(
544            "{}/{}",
545            self.base_url.trim_end_matches('/'),
546            path.trim_start_matches('/')
547        )
548    }
549}
550
551fn sanitize_model(model: impl Into<String>) -> String {
552    model.into().trim().to_string()
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use aither_core::llm::{Message, model::Parameters, model::ReasoningEffort};
559
560    /// A loopback server that records the one request it receives, headers
561    /// and body, and answers it with an error.
562    fn capturing_server() -> (String, std::sync::mpsc::Receiver<String>) {
563        use std::io::{Read, Write};
564
565        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind stub server");
566        let addr = listener.local_addr().expect("stub server address");
567        let (sender, received) = std::sync::mpsc::channel();
568        std::thread::spawn(move || {
569            let (mut stream, _) = listener.accept().expect("accept the request");
570            let mut request = Vec::new();
571            let mut chunk = [0_u8; 8192];
572            let body_start = loop {
573                let read = stream.read(&mut chunk).expect("read the request");
574                request.extend_from_slice(chunk.get(..read).expect("read within the chunk"));
575                if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") {
576                    break end + 4;
577                }
578            };
579            let head =
580                String::from_utf8_lossy(request.get(..body_start).expect("head")).into_owned();
581            let length = head.lines().find_map(|line| {
582                line.to_ascii_lowercase()
583                    .strip_prefix("content-length:")
584                    .map(|length| length.trim().parse::<usize>().expect("a numeric length"))
585            });
586            let complete = |request: &[u8]| {
587                length.map_or_else(
588                    || request.ends_with(b"0\r\n\r\n"),
589                    |length| request.len() >= body_start + length,
590                )
591            };
592            while !complete(&request) {
593                let read = stream.read(&mut chunk).expect("read the body");
594                request.extend_from_slice(chunk.get(..read).expect("read within the chunk"));
595            }
596            let raw = String::from_utf8_lossy(request.get(body_start..).expect("body"));
597            let body = if length.is_some() {
598                raw.into_owned()
599            } else {
600                dechunk(&raw)
601            };
602            sender
603                .send(format!("{head}{body}"))
604                .expect("the test is listening");
605            let _ = stream.write_all(
606                b"HTTP/1.1 400 Bad Request\r\ncontent-type: application/json\r\ncontent-length: 2\r\nconnection: close\r\n\r\n{}",
607            );
608        });
609        (format!("http://{addr}"), received)
610    }
611
612    /// The payload of a chunked transfer-encoded body.
613    fn dechunk(mut raw: &str) -> String {
614        let mut body = String::new();
615        loop {
616            let (size, rest) = raw.split_once("\r\n").expect("a chunk size line");
617            let size = usize::from_str_radix(size.trim(), 16).expect("a hex chunk size");
618            if size == 0 {
619                return body;
620            }
621            body.push_str(rest.get(..size).expect("the chunk is complete"));
622            raw = rest
623                .get(size..)
624                .and_then(|rest| rest.strip_prefix("\r\n"))
625                .expect("the chunk ends with CRLF");
626        }
627    }
628
629    /// A request carrying an effort update sends the beta header and the
630    /// effort-only system message, with the request-level effort as given.
631    #[tokio::test]
632    async fn an_effort_update_sends_the_beta_and_the_per_message_form() {
633        let (base_url, received) = capturing_server();
634        let client = Claude::builder("key")
635            .base_url(base_url)
636            .model("claude-opus-5-5")
637            .build();
638        let request = LLMRequest::new(vec![
639            Message::user("Plan the migration."),
640            Message::assistant("Here is the plan."),
641            Message::configuration(ReasoningEffort::Low),
642            Message::user("Now rename the config file."),
643        ])
644        .with_parameters(
645            Parameters::default()
646                .reasoning_effort(ReasoningEffort::High)
647                .stream(false),
648        );
649        let stream = client.respond(request);
650        futures_lite::pin!(stream);
651        let _ = stream.next().await;
652
653        let sent = received.recv().expect("the request arrives");
654        let (head, body) = sent.split_once("\r\n\r\n").expect("head and body");
655        assert!(
656            head.to_ascii_lowercase()
657                .contains("anthropic-beta: mid-conversation-output-config-2026-07-01"),
658            "{head}"
659        );
660        let body: serde_json::Value = serde_json::from_str(body).expect("a JSON body");
661        assert_eq!(body["output_config"]["effort"], "high");
662        assert_eq!(
663            body["messages"][2],
664            serde_json::json!({"role": "system", "content": [], "output_config": {"effort": "low"}})
665        );
666    }
667
668    #[test]
669    fn per_message_effort_is_the_claude_apis_on_the_listed_models() {
670        let config = |model: &str, base_url: &str| {
671            Claude::builder("key")
672                .model(model)
673                .base_url(base_url)
674                .build()
675                .config()
676        };
677        assert!(takes_per_message_effort(&config(
678            "claude-opus-5-5",
679            CLAUDE_BASE_URL
680        )));
681        assert!(!takes_per_message_effort(&config(
682            "claude-sonnet-5",
683            CLAUDE_BASE_URL
684        )));
685        assert!(!takes_per_message_effort(&config(
686            "claude-opus-5-5",
687            "https://openrouter.ai/api/v1"
688        )));
689    }
690}