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 let Some(tool_name) = &missing_exact_tool {
145 yield Err(ClaudeError::Api(format!(
146 "Exact tool choice '{tool_name}' is not present in tool definitions"
147 )));
148 return;
149 }
150
151 let top_level_cache_control = if let Some(cache) = snapshot.cache {
152 match apply_cache_strategy(
153 &mut system_prompt,
154 &mut claude_messages,
155 &mut claude_tools,
156 cache,
157 ) {
158 Ok(control) => control,
159 Err(error) => {
160 yield Err(ClaudeError::Api(error));
161 return;
162 }
163 }
164 } else {
165 None
166 };
167
168 let thinking = build_thinking(&snapshot);
169 let output_config = match build_output_config(&snapshot) {
170 Ok(config) => config,
171 Err(error) => {
172 yield Err(error);
173 return;
174 }
175 };
176
177 let stream_requested = snapshot.stream.unwrap_or(true);
179 let request_body = MessagesRequest {
180 model: cfg.model.clone(),
181 max_tokens,
182 messages: claude_messages,
183 system: system_prompt,
184 stream: stream_requested,
185 temperature: snapshot.temperature,
186 top_p: snapshot.top_p,
187 top_k: snapshot.top_k,
188 stop_sequences: snapshot.stop_sequences.clone(),
189 tools: claude_tools,
190 tool_choice: claude_tool_choice,
191 thinking,
192 output_config,
193 cache_control: top_level_cache_control,
194 };
195
196 debug!("Claude request: {:?}", request_body);
197
198 let endpoint = cfg.request_url("/v1/messages");
199 let mut backend = client();
200
201 let builder = match backend
203 .post(endpoint)
204 .and_then(|b| b.header("x-api-key", cfg.api_key.clone()))
205 .and_then(|b| b.header("anthropic-version", ANTHROPIC_VERSION))
206 .and_then(|b| b.header(header::CONTENT_TYPE.as_str(), "application/json"))
207 .and_then(|b| {
208 if stream_requested {
209 b.header(header::ACCEPT.as_str(), "text/event-stream")
210 } else {
211 Ok(b)
212 }
213 })
214 .and_then(|b| b.header(header::USER_AGENT.as_str(), "aither-claude/0.1"))
215 .and_then(|b| b.json_body(&request_body))
216 {
217 Ok(b) => b,
218 Err(e) => {
219 yield Err(ClaudeError::Http(e));
220 return;
221 }
222 };
223
224 if !stream_requested {
225 match builder.json::<MessagesResponse>().await {
226 Ok(response) => {
227 for event in response_events(response) {
228 yield Ok(event);
229 }
230 }
231 Err(e) => yield Err(ClaudeError::Http(e)),
232 }
233 return;
234 }
235
236 let sse_stream = match builder.sse().await {
237 Ok(stream) => stream,
238 Err(e) => {
239 yield Err(ClaudeError::Http(e));
240 return;
241 }
242 };
243 futures_lite::pin!(sse_stream);
244
245 let mut state = StreamState::new();
246
247 loop {
248 let next = futures_lite::future::or(
251 async { Some(sse_stream.next().await) },
252 async {
253 aither_core::time::sleep(cfg.idle_timeout).await;
254 None
255 },
256 )
257 .await;
258 let Some(event) = next else {
259 yield Err(ClaudeError::IdleTimeout { idle: cfg.idle_timeout });
260 return;
261 };
262 let Some(event) = event else { break };
263 let event = match event {
264 Ok(event) => event,
265 Err(e) => {
266 yield Err(ClaudeError::from(e));
267 return;
268 }
269 };
270 if should_skip_event(&event) {
271 continue;
272 }
273 match parse_event(&event, &mut state) {
274 Ok(llm_events) => {
275 for llm_event in llm_events {
276 yield Ok(llm_event);
277 }
278 }
279 Err(e) => {
280 yield Err(e);
281 return;
282 }
283 }
284 }
285
286 for call in state.tool_calls {
288 yield Ok(Event::tool_call(call.id, call.name, call.input));
291 }
292
293 debug!("Claude response complete, stop_reason: {:?}", state.stop_reason);
294 }
295 }
296
297 fn complete(&self, prefix: &str) -> impl Stream<Item = Result<Event, Self::Error>> + Send {
298 self.respond(oneshot(
299 "Continue the user provided text without additional commentary.",
300 prefix,
301 ))
302 }
303
304 fn profile(
305 &self,
306 ) -> impl core::future::Future<Output = Result<ModelProfile, Self::Error>> + Send {
307 let cfg = self.inner.clone();
308 async move {
309 let context_length = match fetch_model_context_length(&cfg).await {
312 Ok(len) => len,
313 Err(e) => {
314 tracing::debug!("API did not return context_length: {e}");
315 aither_models::lookup(&cfg.model)
317 .and_then(aither_models::ModelEntry::max_input_tokens)
318 .ok_or_else(|| {
319 ClaudeError::Api(format!(
320 "cannot determine context length for model '{model}': \
321 {models_url} request failed ({e}) and aither-models has no entry",
322 model = cfg.model,
323 models_url = cfg.request_url("/models"),
324 ))
325 })?
326 }
327 };
328
329 let mut profile = ModelProfile::new(
330 cfg.model.clone(),
331 "Anthropic",
332 cfg.model.clone(),
333 "Claude model by Anthropic",
334 context_length,
335 )
336 .with_abilities([Ability::ToolUse, Ability::Vision]);
337
338 for ability in &cfg.native_abilities {
339 if !profile.abilities.contains(ability) {
340 profile.abilities.push(*ability);
341 }
342 }
343 Ok(profile)
344 }
345 }
346}
347
348fn native_tool_name_enabled(snapshot: &ParameterSnapshot, name: &str) -> bool {
349 let tools = &snapshot.native_tools;
350 matches!(
351 name,
352 "web_search" if tools.web_search
353 ) || matches!(
354 name,
355 "web_fetch" if tools.web_fetch
356 ) || matches!(
357 name,
358 "code_execution" if tools.code_execution
359 ) || matches!(
360 name,
361 "bash" if tools.bash
362 ) || matches!(
363 name,
364 "str_replace_based_edit_tool" if tools.text_editor.is_some()
365 )
366}
367
368async fn fetch_model_context_length(cfg: &Config) -> Result<u32, ClaudeError> {
370 #[derive(serde::Deserialize)]
371 struct ModelsResponse {
372 data: Vec<ModelInfo>,
373 }
374 #[derive(serde::Deserialize)]
375 struct ModelInfo {
376 id: String,
377 #[serde(default, alias = "max_tokens")]
379 context_length: Option<u32>,
380 }
381
382 let url = format!("{}/models", cfg.base_url.trim_end_matches('/'));
383 let mut backend = client();
384 let mut req = backend.get(&url).map_err(ClaudeError::Http)?;
385
386 if cfg.base_url.contains("anthropic.com") {
388 req = req
389 .header("x-api-key", cfg.api_key.clone())
390 .map_err(ClaudeError::Http)?
391 .header("anthropic-version", ANTHROPIC_VERSION)
392 .map_err(ClaudeError::Http)?;
393 } else {
394 req = req
395 .header(
396 header::AUTHORIZATION.as_str(),
397 format!("Bearer {}", cfg.api_key),
398 )
399 .map_err(ClaudeError::Http)?;
400 }
401
402 let response: ModelsResponse = req.json().await.map_err(ClaudeError::Http)?;
403
404 for model in response.data {
405 if model.id == cfg.model
406 && let Some(ctx) = model.context_length
407 {
408 return Ok(ctx);
409 }
410 }
411
412 Err(ClaudeError::Api(format!(
413 "Model '{}' not found or missing context_length",
414 cfg.model
415 )))
416}
417
418#[derive(Debug)]
420pub struct Builder {
421 api_key: String,
422 base_url: String,
423 model: String,
424 default_max_tokens: u32,
425 native_abilities: Vec<Ability>,
426 idle_timeout: Duration,
427}
428
429impl Builder {
430 fn new(api_key: impl Into<String>) -> Self {
431 Self {
432 api_key: api_key.into(),
433 base_url: CLAUDE_BASE_URL.to_string(),
434 model: DEFAULT_MODEL.to_string(),
435 default_max_tokens: DEFAULT_MAX_TOKENS,
436 native_abilities: Vec::new(),
437 idle_timeout: DEFAULT_IDLE_TIMEOUT,
438 }
439 }
440
441 #[must_use]
443 pub fn base_url(mut self, url: impl Into<String>) -> Self {
444 self.base_url = url.into();
445 self
446 }
447
448 #[must_use]
450 pub fn model(mut self, model: impl Into<String>) -> Self {
451 self.model = sanitize_model(model);
452 self
453 }
454
455 #[must_use]
457 pub const fn max_tokens(mut self, max_tokens: u32) -> Self {
458 self.default_max_tokens = max_tokens;
459 self
460 }
461
462 #[must_use]
466 pub const fn idle_timeout(mut self, idle_timeout: Duration) -> Self {
467 self.idle_timeout = idle_timeout;
468 self
469 }
470
471 #[must_use]
473 pub fn native_capabilities(mut self, abilities: impl IntoIterator<Item = Ability>) -> Self {
474 for ability in abilities {
475 if !self.native_abilities.contains(&ability) {
476 self.native_abilities.push(ability);
477 }
478 }
479 self
480 }
481
482 #[must_use]
484 pub fn enable_native_pdf(self) -> Self {
485 self.native_capabilities([Ability::Pdf])
486 }
487
488 #[must_use]
490 pub fn build(self) -> Claude {
491 Claude {
492 inner: Arc::new(Config {
493 api_key: self.api_key,
494 base_url: self.base_url,
495 model: self.model,
496 default_max_tokens: self.default_max_tokens,
497 native_abilities: self.native_abilities,
498 idle_timeout: self.idle_timeout,
499 }),
500 }
501 }
502}
503
504#[derive(Debug, Clone)]
506pub struct Config {
507 pub(crate) api_key: String,
508 pub(crate) base_url: String,
509 pub(crate) model: String,
510 pub(crate) default_max_tokens: u32,
511 pub(crate) native_abilities: Vec<Ability>,
512 pub(crate) idle_timeout: Duration,
513}
514
515impl Config {
516 pub(crate) fn request_url(&self, path: &str) -> String {
517 format!(
518 "{}/{}",
519 self.base_url.trim_end_matches('/'),
520 path.trim_start_matches('/')
521 )
522 }
523}
524
525fn sanitize_model(model: impl Into<String>) -> String {
526 model.into().trim().to_string()
527}