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, DEFAULT_MODEL,
21 },
22 error::ClaudeError,
23 request::{
24 MessagesRequest, ParameterSnapshot, apply_cache_strategy, build_output_config,
25 build_thinking, convert_native_tools, convert_tools, filter_tool_definitions,
26 to_claude_messages, tool_choice_payload,
27 },
28 response::{MessagesResponse, StreamState, parse_event, response_events, should_skip_event},
29};
30
31#[derive(Clone, Debug)]
49pub struct Claude {
50 inner: Arc<Config>,
51}
52
53impl Claude {
54 pub fn new(api_key: impl Into<String>) -> Self {
56 Self::builder(api_key).build()
57 }
58
59 #[must_use]
61 pub fn builder(api_key: impl Into<String>) -> Builder {
62 Builder::new(api_key)
63 }
64
65 #[must_use]
67 pub fn with_model(mut self, model: impl Into<String>) -> Self {
68 Arc::make_mut(&mut self.inner).model = sanitize_model(model);
69 self
70 }
71
72 #[must_use]
74 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
75 Arc::make_mut(&mut self.inner).base_url = base_url.into();
76 self
77 }
78
79 #[must_use]
81 pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
82 Arc::make_mut(&mut self.inner).default_max_tokens = max_tokens;
83 self
84 }
85
86 #[must_use]
90 pub fn with_idle_timeout(mut self, idle_timeout: Duration) -> Self {
91 Arc::make_mut(&mut self.inner).idle_timeout = idle_timeout;
92 self
93 }
94
95 pub(crate) fn config(&self) -> Arc<Config> {
96 self.inner.clone()
97 }
98}
99
100impl LanguageModel for Claude {
101 type Error = ClaudeError;
102
103 #[allow(clippy::too_many_lines)]
104 fn respond(
105 &self,
106 request: LLMRequest,
107 ) -> impl Stream<Item = Result<Event, Self::Error>> + Send {
108 let cfg = self.config();
109 let (core_messages, parameters, tool_definitions) = request.into_parts();
110 let snapshot = ParameterSnapshot::from(¶meters);
111 let filtered_tool_definitions =
112 filter_tool_definitions(tool_definitions, &snapshot.tool_choice);
113 let missing_exact_tool = match &snapshot.tool_choice {
114 ToolChoice::Exact(name)
115 if filtered_tool_definitions.is_empty()
116 && !native_tool_name_enabled(&snapshot, name) =>
117 {
118 Some(name.clone())
119 }
120 _ => None,
121 };
122 let mut claude_tool_payloads = convert_tools(&filtered_tool_definitions);
123 claude_tool_payloads.extend(convert_native_tools(&snapshot.native_tools));
124 let has_tools = !claude_tool_payloads.is_empty();
125 let mut claude_tools = has_tools.then_some(claude_tool_payloads);
126 let claude_tool_choice = tool_choice_payload(
127 &snapshot.tool_choice,
128 has_tools,
129 snapshot.parallel_tool_calls,
130 );
131
132 let max_tokens = snapshot.max_tokens.unwrap_or(cfg.default_max_tokens);
133
134 async_stream::stream! {
135 let (mut system_prompt, mut claude_messages) =
136 match to_claude_messages(&core_messages).await {
137 Ok(payload) => payload,
138 Err(error) => {
139 yield Err(ClaudeError::Api(error));
140 return;
141 }
142 };
143
144 if parameters.cache.openai.is_some() || parameters.cache.gemini.is_some() {
145 yield Err(ClaudeError::Api(
146 "Claude provider only accepts cache.claude settings".to_string(),
147 ));
148 return;
149 }
150
151 if let Some(tool_name) = &missing_exact_tool {
152 yield Err(ClaudeError::Api(format!(
153 "Exact tool choice '{tool_name}' is not present in tool definitions"
154 )));
155 return;
156 }
157
158 let top_level_cache_control = if let Some(cache) = snapshot.cache {
159 match apply_cache_strategy(
160 &mut system_prompt,
161 &mut claude_messages,
162 &mut claude_tools,
163 cache,
164 ) {
165 Ok(control) => control,
166 Err(error) => {
167 yield Err(ClaudeError::Api(error));
168 return;
169 }
170 }
171 } else {
172 None
173 };
174
175 let thinking = build_thinking(&snapshot);
176 let output_config = match build_output_config(&snapshot) {
177 Ok(config) => config,
178 Err(error) => {
179 yield Err(error);
180 return;
181 }
182 };
183
184 let stream_requested = snapshot.stream.unwrap_or(true);
186 let request_body = MessagesRequest {
187 model: cfg.model.clone(),
188 max_tokens,
189 messages: claude_messages,
190 system: system_prompt,
191 stream: stream_requested,
192 temperature: snapshot.temperature,
193 top_p: snapshot.top_p,
194 top_k: snapshot.top_k,
195 stop_sequences: snapshot.stop_sequences.clone(),
196 tools: claude_tools,
197 tool_choice: claude_tool_choice,
198 thinking,
199 output_config,
200 cache_control: top_level_cache_control,
201 };
202
203 debug!("Claude request: {:?}", request_body);
204
205 let endpoint = cfg.request_url("/v1/messages");
206 let mut backend = client();
207
208 let builder = match backend
210 .post(endpoint)
211 .and_then(|b| b.header("x-api-key", cfg.api_key.clone()))
212 .and_then(|b| b.header("anthropic-version", ANTHROPIC_VERSION))
213 .and_then(|b| b.header(header::CONTENT_TYPE.as_str(), "application/json"))
214 .and_then(|b| {
215 if stream_requested {
216 b.header(header::ACCEPT.as_str(), "text/event-stream")
217 } else {
218 Ok(b)
219 }
220 })
221 .and_then(|b| b.header(header::USER_AGENT.as_str(), "aither-claude/0.1"))
222 .and_then(|b| b.json_body(&request_body))
223 {
224 Ok(b) => b,
225 Err(e) => {
226 yield Err(ClaudeError::Http(e));
227 return;
228 }
229 };
230
231 if !stream_requested {
232 match builder.json::<MessagesResponse>().await {
233 Ok(response) => {
234 for event in response_events(response) {
235 yield Ok(event);
236 }
237 }
238 Err(e) => yield Err(ClaudeError::Http(e)),
239 }
240 return;
241 }
242
243 let sse_stream = match builder.sse().await {
244 Ok(stream) => stream,
245 Err(e) => {
246 yield Err(ClaudeError::Http(e));
247 return;
248 }
249 };
250 futures_lite::pin!(sse_stream);
251
252 let mut state = StreamState::new();
253
254 loop {
255 let next = futures_lite::future::or(
258 async { Some(sse_stream.next().await) },
259 async {
260 aither_core::time::sleep(cfg.idle_timeout).await;
261 None
262 },
263 )
264 .await;
265 let Some(event) = next else {
266 yield Err(ClaudeError::IdleTimeout { idle: cfg.idle_timeout });
267 return;
268 };
269 let Some(event) = event else { break };
270 let event = match event {
271 Ok(event) => event,
272 Err(e) => {
273 yield Err(ClaudeError::from(e));
274 return;
275 }
276 };
277 if should_skip_event(&event) {
278 continue;
279 }
280 match parse_event(&event, &mut state) {
281 Ok(llm_events) => {
282 for llm_event in llm_events {
283 yield Ok(llm_event);
284 }
285 }
286 Err(e) => {
287 yield Err(e);
288 return;
289 }
290 }
291 }
292
293 for call in state.tool_calls {
295 yield Ok(Event::tool_call(call.id, call.name, call.input));
298 }
299
300 debug!("Claude response complete, stop_reason: {:?}", state.stop_reason);
301 }
302 }
303
304 fn complete(&self, prefix: &str) -> impl Stream<Item = Result<Event, Self::Error>> + Send {
305 self.respond(oneshot(
306 "Continue the user provided text without additional commentary.",
307 prefix,
308 ))
309 }
310
311 fn profile(
312 &self,
313 ) -> impl core::future::Future<Output = Result<ModelProfile, Self::Error>> + Send {
314 let cfg = self.inner.clone();
315 async move {
316 let context_length = match fetch_model_context_length(&cfg).await {
319 Ok(len) => len,
320 Err(e) => {
321 tracing::debug!("API did not return context_length: {e}");
322 aither_models::lookup(&cfg.model)
324 .and_then(aither_models::ModelEntry::max_input_tokens)
325 .ok_or_else(|| {
326 ClaudeError::Api(format!(
327 "cannot determine context length for model '{model}': \
328 {models_url} request failed ({e}) and aither-models has no entry",
329 model = cfg.model,
330 models_url = cfg.request_url("/models"),
331 ))
332 })?
333 }
334 };
335
336 let mut profile = ModelProfile::new(
337 cfg.model.clone(),
338 "Anthropic",
339 cfg.model.clone(),
340 "Claude model by Anthropic",
341 context_length,
342 )
343 .with_abilities([Ability::ToolUse, Ability::Vision]);
344
345 for ability in &cfg.native_abilities {
346 if !profile.abilities.contains(ability) {
347 profile.abilities.push(*ability);
348 }
349 }
350 Ok(profile)
351 }
352 }
353}
354
355fn native_tool_name_enabled(snapshot: &ParameterSnapshot, name: &str) -> bool {
356 let tools = &snapshot.native_tools;
357 matches!(
358 name,
359 "web_search" if tools.web_search
360 ) || matches!(
361 name,
362 "web_fetch" if tools.web_fetch
363 ) || matches!(
364 name,
365 "code_execution" if tools.code_execution
366 ) || matches!(
367 name,
368 "bash" if tools.bash
369 ) || matches!(
370 name,
371 "str_replace_based_edit_tool" if tools.text_editor.is_some()
372 )
373}
374
375async fn fetch_model_context_length(cfg: &Config) -> Result<u32, ClaudeError> {
377 #[derive(serde::Deserialize)]
378 struct ModelsResponse {
379 data: Vec<ModelInfo>,
380 }
381 #[derive(serde::Deserialize)]
382 struct ModelInfo {
383 id: String,
384 #[serde(default, alias = "max_tokens")]
386 context_length: Option<u32>,
387 }
388
389 let url = format!("{}/models", cfg.base_url.trim_end_matches('/'));
390 let mut backend = client();
391 let mut req = backend.get(&url).map_err(ClaudeError::Http)?;
392
393 if cfg.base_url.contains("anthropic.com") {
395 req = req
396 .header("x-api-key", cfg.api_key.clone())
397 .map_err(ClaudeError::Http)?
398 .header("anthropic-version", ANTHROPIC_VERSION)
399 .map_err(ClaudeError::Http)?;
400 } else {
401 req = req
402 .header(
403 header::AUTHORIZATION.as_str(),
404 format!("Bearer {}", cfg.api_key),
405 )
406 .map_err(ClaudeError::Http)?;
407 }
408
409 let response: ModelsResponse = req.json().await.map_err(ClaudeError::Http)?;
410
411 for model in response.data {
412 if model.id == cfg.model
413 && let Some(ctx) = model.context_length
414 {
415 return Ok(ctx);
416 }
417 }
418
419 Err(ClaudeError::Api(format!(
420 "Model '{}' not found or missing context_length",
421 cfg.model
422 )))
423}
424
425#[derive(Debug)]
427pub struct Builder {
428 api_key: String,
429 base_url: String,
430 model: String,
431 default_max_tokens: u32,
432 native_abilities: Vec<Ability>,
433 idle_timeout: Duration,
434}
435
436impl Builder {
437 fn new(api_key: impl Into<String>) -> Self {
438 Self {
439 api_key: api_key.into(),
440 base_url: CLAUDE_BASE_URL.to_string(),
441 model: DEFAULT_MODEL.to_string(),
442 default_max_tokens: DEFAULT_MAX_TOKENS,
443 native_abilities: Vec::new(),
444 idle_timeout: DEFAULT_IDLE_TIMEOUT,
445 }
446 }
447
448 #[must_use]
450 pub fn base_url(mut self, url: impl Into<String>) -> Self {
451 self.base_url = url.into();
452 self
453 }
454
455 #[must_use]
457 pub fn model(mut self, model: impl Into<String>) -> Self {
458 self.model = sanitize_model(model);
459 self
460 }
461
462 #[must_use]
464 pub const fn max_tokens(mut self, max_tokens: u32) -> Self {
465 self.default_max_tokens = max_tokens;
466 self
467 }
468
469 #[must_use]
473 pub const fn idle_timeout(mut self, idle_timeout: Duration) -> Self {
474 self.idle_timeout = idle_timeout;
475 self
476 }
477
478 #[must_use]
480 pub fn native_capabilities(mut self, abilities: impl IntoIterator<Item = Ability>) -> Self {
481 for ability in abilities {
482 if !self.native_abilities.contains(&ability) {
483 self.native_abilities.push(ability);
484 }
485 }
486 self
487 }
488
489 #[must_use]
491 pub fn enable_native_pdf(self) -> Self {
492 self.native_capabilities([Ability::Pdf])
493 }
494
495 #[must_use]
497 pub fn build(self) -> Claude {
498 Claude {
499 inner: Arc::new(Config {
500 api_key: self.api_key,
501 base_url: self.base_url,
502 model: self.model,
503 default_max_tokens: self.default_max_tokens,
504 native_abilities: self.native_abilities,
505 idle_timeout: self.idle_timeout,
506 }),
507 }
508 }
509}
510
511#[derive(Debug, Clone)]
513pub struct Config {
514 pub(crate) api_key: String,
515 pub(crate) base_url: String,
516 pub(crate) model: String,
517 pub(crate) default_max_tokens: u32,
518 pub(crate) native_abilities: Vec<Ability>,
519 pub(crate) idle_timeout: Duration,
520}
521
522impl Config {
523 pub(crate) fn request_url(&self, path: &str) -> String {
524 format!(
525 "{}/{}",
526 self.base_url.trim_end_matches('/'),
527 path.trim_start_matches('/')
528 )
529 }
530}
531
532fn sanitize_model(model: impl Into<String>) -> String {
533 model.into().trim().to_string()
534}