1use 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#[derive(Clone, Debug)]
50pub struct Claude {
51 inner: Arc<Config>,
52}
53
54impl Claude {
55 pub fn new(api_key: impl Into<String>) -> Self {
57 Self::builder(api_key).build()
58 }
59
60 #[must_use]
62 pub fn builder(api_key: impl Into<String>) -> Builder {
63 Builder::new(api_key)
64 }
65
66 #[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 #[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 #[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 #[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(¶meters);
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 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 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 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 for call in state.tool_calls {
300 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 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 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
364fn 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
394async 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 #[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 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#[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 #[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 #[must_use]
476 pub fn model(mut self, model: impl Into<String>) -> Self {
477 self.model = sanitize_model(model);
478 self
479 }
480
481 #[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 #[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 #[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 #[must_use]
510 pub fn enable_native_pdf(self) -> Self {
511 self.native_capabilities([Ability::Pdf])
512 }
513
514 #[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#[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 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 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 #[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}