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, 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 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 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
367fn 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
377fn 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
406async 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 #[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 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#[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 #[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 #[must_use]
488 pub fn model(mut self, model: impl Into<String>) -> Self {
489 self.model = sanitize_model(model);
490 self
491 }
492
493 #[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 #[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 #[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 #[must_use]
522 pub fn enable_native_pdf(self) -> Self {
523 self.native_capabilities([Ability::Pdf])
524 }
525
526 #[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#[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 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 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 #[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}