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