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::{StreamState, parse_event, 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 request_body = MessagesRequest {
175 model: cfg.model.clone(),
176 max_tokens,
177 messages: claude_messages,
178 system: system_prompt,
179 stream: true,
180 temperature: snapshot.temperature,
181 top_p: snapshot.top_p,
182 top_k: snapshot.top_k,
183 stop_sequences: snapshot.stop_sequences.clone(),
184 tools: claude_tools,
185 tool_choice: claude_tool_choice,
186 thinking,
187 output_config,
188 cache_control: top_level_cache_control,
189 };
190
191 debug!("Claude request: {:?}", request_body);
192
193 let endpoint = cfg.request_url("/v1/messages");
194 let mut backend = client();
195
196 let builder = match backend
198 .post(endpoint)
199 .and_then(|b| b.header("x-api-key", cfg.api_key.clone()))
200 .and_then(|b| b.header("anthropic-version", ANTHROPIC_VERSION))
201 .and_then(|b| b.header(header::CONTENT_TYPE.as_str(), "application/json"))
202 .and_then(|b| b.header(header::ACCEPT.as_str(), "text/event-stream"))
203 .and_then(|b| b.header(header::USER_AGENT.as_str(), "aither-claude/0.1"))
204 .and_then(|b| b.json_body(&request_body))
205 {
206 Ok(b) => b,
207 Err(e) => {
208 yield Err(ClaudeError::Http(e));
209 return;
210 }
211 };
212 let sse_stream = match builder.sse().await {
213 Ok(stream) => stream,
214 Err(e) => {
215 yield Err(ClaudeError::Http(e));
216 return;
217 }
218 };
219 futures_lite::pin!(sse_stream);
220
221 let mut state = StreamState::new();
222
223 while let Some(event) = sse_stream.next().await {
224 let event = match event {
225 Ok(event) => event,
226 Err(e) => {
227 yield Err(ClaudeError::from(e));
228 return;
229 }
230 };
231 if should_skip_event(&event) {
232 continue;
233 }
234 match parse_event(&event, &mut state) {
235 Ok(llm_events) => {
236 for llm_event in llm_events {
237 yield Ok(llm_event);
238 }
239 }
240 Err(e) => {
241 yield Err(e);
242 return;
243 }
244 }
245 }
246
247 for call in state.tool_calls {
249 yield Ok(Event::ToolCall(aither_core::llm::ToolCall {
252 id: call.id,
253 name: call.name,
254 arguments: call.input,
255 reasoning_state: None,
256 }));
257 }
258
259 debug!("Claude response complete, stop_reason: {:?}", state.stop_reason);
260 }
261 }
262
263 fn complete(&self, prefix: &str) -> impl Stream<Item = Result<Event, Self::Error>> + Send {
264 self.respond(oneshot(
265 "Continue the user provided text without additional commentary.",
266 prefix,
267 ))
268 }
269
270 fn profile(&self) -> impl core::future::Future<Output = ModelProfile> + Send {
271 let cfg = self.inner.clone();
272 async move {
273 let context_length = match fetch_model_context_length(&cfg).await {
276 Ok(len) => len,
277 Err(e) => {
278 tracing::debug!("API did not return context_length: {e}");
279 aither_models::lookup(&cfg.model)
281 .and_then(aither_models::ModelEntry::max_input_tokens)
282 .unwrap_or_else(|| {
283 panic!(
284 "Claude model '{}' missing context metadata from provider and aither-models",
285 cfg.model
286 )
287 })
288 }
289 };
290
291 let mut profile = ModelProfile::new(
292 cfg.model.clone(),
293 "Anthropic",
294 cfg.model.clone(),
295 "Claude model by Anthropic",
296 context_length,
297 )
298 .with_abilities([Ability::ToolUse, Ability::Vision]);
299
300 for ability in &cfg.native_abilities {
301 if !profile.abilities.contains(ability) {
302 profile.abilities.push(*ability);
303 }
304 }
305 profile
306 }
307 }
308}
309
310fn native_tool_name_enabled(snapshot: &ParameterSnapshot, name: &str) -> bool {
311 let tools = &snapshot.native_tools;
312 matches!(
313 name,
314 "web_search" if tools.web_search
315 ) || matches!(
316 name,
317 "web_fetch" if tools.web_fetch
318 ) || matches!(
319 name,
320 "code_execution" if tools.code_execution
321 ) || matches!(
322 name,
323 "bash" if tools.bash
324 ) || matches!(
325 name,
326 "str_replace_based_edit_tool" if tools.text_editor.is_some()
327 )
328}
329
330async fn fetch_model_context_length(cfg: &Config) -> Result<u32, ClaudeError> {
332 #[derive(serde::Deserialize)]
333 struct ModelsResponse {
334 data: Vec<ModelInfo>,
335 }
336 #[derive(serde::Deserialize)]
337 struct ModelInfo {
338 id: String,
339 #[serde(default, alias = "max_tokens")]
341 context_length: Option<u32>,
342 }
343
344 let url = format!("{}/models", cfg.base_url.trim_end_matches('/'));
345 let mut backend = client();
346 let mut req = backend.get(&url).map_err(ClaudeError::Http)?;
347
348 if cfg.base_url.contains("anthropic.com") {
350 req = req
351 .header("x-api-key", cfg.api_key.clone())
352 .map_err(ClaudeError::Http)?
353 .header("anthropic-version", ANTHROPIC_VERSION)
354 .map_err(ClaudeError::Http)?;
355 } else {
356 req = req
357 .header(
358 header::AUTHORIZATION.as_str(),
359 format!("Bearer {}", cfg.api_key),
360 )
361 .map_err(ClaudeError::Http)?;
362 }
363
364 let response: ModelsResponse = req.json().await.map_err(ClaudeError::Http)?;
365
366 for model in response.data {
367 if model.id == cfg.model
368 && let Some(ctx) = model.context_length
369 {
370 return Ok(ctx);
371 }
372 }
373
374 Err(ClaudeError::Api(format!(
375 "Model '{}' not found or missing context_length",
376 cfg.model
377 )))
378}
379
380#[derive(Debug)]
382pub struct Builder {
383 api_key: String,
384 base_url: String,
385 model: String,
386 default_max_tokens: u32,
387 native_abilities: Vec<Ability>,
388}
389
390impl Builder {
391 fn new(api_key: impl Into<String>) -> Self {
392 Self {
393 api_key: api_key.into(),
394 base_url: CLAUDE_BASE_URL.to_string(),
395 model: DEFAULT_MODEL.to_string(),
396 default_max_tokens: DEFAULT_MAX_TOKENS,
397 native_abilities: Vec::new(),
398 }
399 }
400
401 #[must_use]
403 pub fn base_url(mut self, url: impl Into<String>) -> Self {
404 self.base_url = url.into();
405 self
406 }
407
408 #[must_use]
410 pub fn model(mut self, model: impl Into<String>) -> Self {
411 self.model = sanitize_model(model);
412 self
413 }
414
415 #[must_use]
417 pub const fn max_tokens(mut self, max_tokens: u32) -> Self {
418 self.default_max_tokens = max_tokens;
419 self
420 }
421
422 #[must_use]
424 pub fn native_capabilities(mut self, abilities: impl IntoIterator<Item = Ability>) -> Self {
425 for ability in abilities {
426 if !self.native_abilities.contains(&ability) {
427 self.native_abilities.push(ability);
428 }
429 }
430 self
431 }
432
433 #[must_use]
435 pub fn enable_native_pdf(self) -> Self {
436 self.native_capabilities([Ability::Pdf])
437 }
438
439 #[must_use]
441 pub fn build(self) -> Claude {
442 Claude {
443 inner: Arc::new(Config {
444 api_key: self.api_key,
445 base_url: self.base_url,
446 model: self.model,
447 default_max_tokens: self.default_max_tokens,
448 native_abilities: self.native_abilities,
449 }),
450 }
451 }
452}
453
454#[derive(Debug, Clone)]
456pub struct Config {
457 pub(crate) api_key: String,
458 pub(crate) base_url: String,
459 pub(crate) model: String,
460 pub(crate) default_max_tokens: u32,
461 pub(crate) native_abilities: Vec<Ability>,
462}
463
464impl Config {
465 pub(crate) fn request_url(&self, path: &str) -> String {
466 format!(
467 "{}/{}",
468 self.base_url.trim_end_matches('/'),
469 path.trim_start_matches('/')
470 )
471 }
472}
473
474fn sanitize_model(model: impl Into<String>) -> String {
475 model.into().trim().to_string()
476}