Skip to main content

claude_sdk/
client.rs

1//! Claude API client implementation
2
3use crate::error::{ApiErrorResponse, Error, Result};
4use crate::streaming::StreamEvent;
5use crate::types::{MessagesRequest, MessagesResponse, RateLimitInfo};
6use eventsource_stream::Eventsource;
7use futures::{Stream, StreamExt, TryStreamExt};
8use reqwest::{Client, StatusCode};
9use std::pin::Pin;
10use tracing::{debug, instrument};
11
12#[cfg(feature = "bedrock")]
13use aws_sdk_bedrockruntime::Client as BedrockClient;
14
15/// API endpoint for Anthropic
16const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
17
18/// Token counting endpoint
19const TOKEN_COUNT_URL: &str = "https://api.anthropic.com/v1/messages/count_tokens";
20
21/// Current API version
22const API_VERSION: &str = "2023-06-01";
23
24/// Backend for Claude API
25pub enum ClaudeBackend {
26    /// Anthropic API with API key
27    Anthropic { api_key: String },
28
29    /// AWS Bedrock with Bedrock runtime client
30    #[cfg(feature = "bedrock")]
31    Bedrock {
32        region: String,
33        bedrock_client: BedrockClient,
34    },
35}
36
37/// Claude API client
38///
39/// This client can connect to either the Anthropic API directly or AWS Bedrock.
40///
41/// # Example - Anthropic API
42///
43/// ```rust,no_run
44/// use claude_sdk::ClaudeClient;
45///
46/// #[tokio::main]
47/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
48///     let client = ClaudeClient::anthropic(
49///         std::env::var("ANTHROPIC_API_KEY")?
50///     );
51///     Ok(())
52/// }
53/// ```
54///
55/// # Example - AWS Bedrock
56///
57/// ```rust,ignore
58/// use claude_sdk::ClaudeClient;
59///
60/// #[tokio::main]
61/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
62///     // Requires --features bedrock
63///     let client = ClaudeClient::bedrock("us-east-1").await?;
64///     Ok(())
65/// }
66/// ```
67pub struct ClaudeClient {
68    http: Client,
69    backend: ClaudeBackend,
70    api_version: String,
71}
72
73fn parse_rate_limit_headers(headers: &reqwest::header::HeaderMap) -> RateLimitInfo {
74    RateLimitInfo {
75        requests_remaining: headers
76            .get("anthropic-ratelimit-requests-remaining")
77            .and_then(|v| v.to_str().ok())
78            .and_then(|s| s.parse().ok()),
79        tokens_remaining: headers
80            .get("anthropic-ratelimit-tokens-remaining")
81            .and_then(|v| v.to_str().ok())
82            .and_then(|s| s.parse().ok()),
83        requests_reset: headers
84            .get("anthropic-ratelimit-requests-reset")
85            .and_then(|v| v.to_str().ok())
86            .map(String::from),
87        tokens_reset: headers
88            .get("anthropic-ratelimit-tokens-reset")
89            .and_then(|v| v.to_str().ok())
90            .map(String::from),
91    }
92}
93
94impl ClaudeClient {
95    /// Create a new client for the Anthropic API
96    ///
97    /// # Example
98    ///
99    /// ```rust,no_run
100    /// use claude_sdk::ClaudeClient;
101    ///
102    /// let client = ClaudeClient::anthropic("your-api-key");
103    /// ```
104    pub fn anthropic(api_key: impl Into<String>) -> Self {
105        Self {
106            http: Client::new(),
107            backend: ClaudeBackend::Anthropic {
108                api_key: api_key.into(),
109            },
110            api_version: API_VERSION.to_string(),
111        }
112    }
113
114    /// Create a new client for AWS Bedrock
115    ///
116    /// This loads AWS credentials from the environment (AWS_PROFILE, AWS_ACCESS_KEY_ID, etc.)
117    /// using the standard AWS credential chain.
118    ///
119    /// # Example
120    ///
121    /// ```rust,no_run
122    /// # #[cfg(feature = "bedrock")]
123    /// use claude_sdk::ClaudeClient;
124    ///
125    /// # #[cfg(feature = "bedrock")]
126    /// #[tokio::main]
127    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
128    ///     // Uses AWS_PROFILE or default credential chain
129    ///     let client = ClaudeClient::bedrock("us-east-1").await?;
130    ///     Ok(())
131    /// }
132    /// ```
133    #[cfg(feature = "bedrock")]
134    pub async fn bedrock(region: impl Into<String>) -> Result<Self> {
135        let region = region.into();
136
137        // Load AWS config with specified region
138        let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
139        let bedrock_config = aws_sdk_bedrockruntime::config::Builder::from(&config)
140            .region(aws_sdk_bedrockruntime::config::Region::new(region.clone()))
141            .build();
142
143        // Create Bedrock runtime client
144        let bedrock_client = BedrockClient::from_conf(bedrock_config);
145
146        Ok(Self {
147            http: Client::new(),
148            backend: ClaudeBackend::Bedrock {
149                region,
150                bedrock_client,
151            },
152            api_version: API_VERSION.to_string(),
153        })
154    }
155
156    /// Send a message and get a complete response
157    ///
158    /// This is the non-streaming API. For streaming responses, use `send_streaming()`.
159    ///
160    /// # Example
161    ///
162    /// ```rust,no_run
163    /// use claude_sdk::{ClaudeClient, MessagesRequest, Message};
164    ///
165    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
166    /// let client = ClaudeClient::anthropic("your-api-key");
167    ///
168    /// let request = MessagesRequest::new(
169    ///     "claude-3-5-sonnet-20241022",
170    ///     1024,
171    ///     vec![Message::user("Hello, Claude!")],
172    /// );
173    ///
174    /// let response = client.send_message(request).await?;
175    /// println!("Response: {:?}", response.content);
176    /// # Ok(())
177    /// # }
178    /// ```
179    #[instrument(skip(self, request), fields(model = %request.model))]
180    pub async fn send_message(&self, request: MessagesRequest) -> Result<MessagesResponse> {
181        match &self.backend {
182            ClaudeBackend::Anthropic { .. } => self.send_anthropic(request).await,
183            #[cfg(feature = "bedrock")]
184            ClaudeBackend::Bedrock { .. } => self.send_bedrock(request).await,
185        }
186    }
187
188    /// Send message to Anthropic API
189    async fn send_anthropic(&self, request: MessagesRequest) -> Result<MessagesResponse> {
190        let api_key = match &self.backend {
191            ClaudeBackend::Anthropic { api_key } => api_key,
192            #[allow(unreachable_patterns)]
193            _ => unreachable!("send_anthropic called with non-Anthropic backend"),
194        };
195
196        debug!("Sending message to Anthropic API");
197
198        // Ensure stream is not set or is false
199        let mut request = request;
200        request.stream = Some(false);
201
202        let response = self
203            .http
204            .post(ANTHROPIC_API_URL)
205            .header("x-api-key", api_key)
206            .header("anthropic-version", &self.api_version)
207            .header("content-type", "application/json")
208            .json(&request)
209            .send()
210            .await?;
211
212        let status = response.status();
213        debug!("Received response with status: {}", status);
214
215        // Handle different status codes
216        match status {
217            StatusCode::OK => {
218                let headers = response.headers().clone();
219                let mut messages_response: MessagesResponse = response.json().await?;
220                messages_response.rate_limit_info = Some(parse_rate_limit_headers(&headers));
221                Ok(messages_response)
222            }
223            StatusCode::TOO_MANY_REQUESTS => {
224                // Parse retry-after header if present
225                let retry_after = response
226                    .headers()
227                    .get("retry-after")
228                    .and_then(|h| h.to_str().ok())
229                    .and_then(|s| s.parse().ok());
230
231                let error_body = response.text().await.unwrap_or_default();
232                Err(Error::RateLimit {
233                    retry_after,
234                    message: error_body,
235                })
236            }
237            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
238                let error_body = response.text().await.unwrap_or_default();
239                Err(Error::Authentication(error_body))
240            }
241            StatusCode::BAD_REQUEST => {
242                // Try to parse structured error
243                let error_text = response.text().await.unwrap_or_default();
244                if let Ok(api_error) = serde_json::from_str::<ApiErrorResponse>(&error_text) {
245                    Err(Error::Api {
246                        status: status.as_u16(),
247                        message: api_error.error.message,
248                        error_type: Some(api_error.error.error_type),
249                    })
250                } else {
251                    Err(Error::InvalidRequest(error_text))
252                }
253            }
254            _ if status.is_server_error() => {
255                let error_body = response.text().await.unwrap_or_default();
256                Err(Error::Server {
257                    status: status.as_u16(),
258                    message: error_body,
259                })
260            }
261            _ => {
262                let error_body = response.text().await.unwrap_or_default();
263                Err(Error::Api {
264                    status: status.as_u16(),
265                    message: error_body,
266                    error_type: None,
267                })
268            }
269        }
270    }
271
272    /// Send message to AWS Bedrock
273    #[cfg(feature = "bedrock")]
274    async fn send_bedrock(&self, request: MessagesRequest) -> Result<MessagesResponse> {
275        let (bedrock_client, model_id) = match &self.backend {
276            ClaudeBackend::Bedrock { bedrock_client, .. } => {
277                let model_id = self.get_bedrock_model_id(&request.model)?;
278                (bedrock_client, model_id)
279            }
280            _ => unreachable!("send_bedrock called with non-Bedrock backend"),
281        };
282
283        debug!("Sending message to AWS Bedrock");
284
285        // Serialize request to JSON
286        let body = serde_json::to_string(&request)?;
287
288        // Use Bedrock runtime client
289        let response = bedrock_client
290            .invoke_model()
291            .model_id(&model_id)
292            .content_type("application/json")
293            .body(aws_sdk_bedrockruntime::primitives::Blob::new(
294                body.as_bytes(),
295            ))
296            .send()
297            .await
298            .map_err(|e| Error::Network(format!("Bedrock API call failed: {}", e)))?;
299
300        // Parse response body
301        let response_bytes = response.body().as_ref();
302        let messages_response: MessagesResponse = serde_json::from_slice(response_bytes)?;
303
304        Ok(messages_response)
305    }
306
307    /// Get Bedrock model ID for a given model string
308    #[cfg(feature = "bedrock")]
309    fn get_bedrock_model_id(&self, model: &str) -> Result<String> {
310        // If already a Bedrock ID, use as-is
311        if model.starts_with("anthropic.")
312            || model.starts_with("global.")
313            || model.starts_with("us.")
314            || model.starts_with("eu.")
315            || model.starts_with("ap.")
316        {
317            return Ok(model.to_string());
318        }
319
320        // Try to find the model and get its Bedrock ID
321        if let Some(model_info) = crate::models::get_model_by_anthropic_id(model) {
322            if let Some(bedrock_id) = model_info.bedrock_id {
323                return Ok(bedrock_id.to_string());
324            }
325        }
326
327        // Fallback: assume it's a valid ID
328        Ok(model.to_string())
329    }
330
331    /// Send a message and stream the response
332    ///
333    /// Returns a stream of events as Claude generates its response.
334    ///
335    /// # Example
336    ///
337    /// ```rust,no_run
338    /// use claude_sdk::{ClaudeClient, MessagesRequest, Message, StreamEvent};
339    /// use futures::StreamExt;
340    ///
341    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
342    /// let client = ClaudeClient::anthropic("your-api-key");
343    ///
344    /// let request = MessagesRequest::new(
345    ///     claude_sdk::models::CLAUDE_SONNET_4_5.anthropic_id,
346    ///     1024,
347    ///     vec![Message::user("Tell me a story")],
348    /// );
349    ///
350    /// let mut stream = client.send_streaming(request).await?;
351    ///
352    /// while let Some(event) = stream.next().await {
353    ///     match event? {
354    ///         StreamEvent::ContentBlockDelta { delta, .. } => {
355    ///             if let Some(text) = delta.text() {
356    ///                 print!("{}", text);
357    ///             }
358    ///         }
359    ///         StreamEvent::MessageStop => break,
360    ///         _ => {}
361    ///     }
362    /// }
363    /// # Ok(())
364    /// # }
365    /// ```
366    #[instrument(skip(self, request), fields(model = %request.model))]
367    pub async fn send_streaming(
368        &self,
369        request: MessagesRequest,
370    ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send>>> {
371        match &self.backend {
372            ClaudeBackend::Anthropic { .. } => self.send_streaming_anthropic(request).await,
373            #[cfg(feature = "bedrock")]
374            ClaudeBackend::Bedrock { .. } => self.send_streaming_bedrock(request).await,
375        }
376    }
377
378    /// Send streaming message to Anthropic API
379    async fn send_streaming_anthropic(
380        &self,
381        request: MessagesRequest,
382    ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send>>> {
383        let api_key = match &self.backend {
384            ClaudeBackend::Anthropic { api_key } => api_key,
385            #[allow(unreachable_patterns)]
386            _ => unreachable!("send_streaming_anthropic called with non-Anthropic backend"),
387        };
388
389        debug!("Sending streaming message to Anthropic API");
390
391        // Enable streaming
392        let mut request = request;
393        request.stream = Some(true);
394
395        let response = self
396            .http
397            .post(ANTHROPIC_API_URL)
398            .header("x-api-key", api_key)
399            .header("anthropic-version", &self.api_version)
400            .header("content-type", "application/json")
401            .json(&request)
402            .send()
403            .await?;
404
405        let status = response.status();
406        debug!("Received streaming response with status: {}", status);
407
408        // Handle non-OK status codes
409        if !status.is_success() {
410            return Err(self.handle_error_response(status, response).await);
411        }
412
413        // Convert the response into an SSE stream
414        let byte_stream = response.bytes_stream();
415        let event_stream = byte_stream.eventsource();
416
417        // Map SSE events to our StreamEvent type
418        let stream = event_stream.map(|result| {
419            let event = result.map_err(|e| Error::StreamParse(e.to_string()))?;
420
421            // Skip empty data
422            if event.data.is_empty() {
423                return Ok(None);
424            }
425
426            // Parse based on event type
427            let stream_event = match event.event.as_str() {
428                "ping" => Some(StreamEvent::Ping),
429                "error" => {
430                    let error: crate::streaming::StreamError = serde_json::from_str(&event.data)
431                        .map_err(|e| Error::StreamParse(e.to_string()))?;
432                    Some(StreamEvent::Error { error })
433                }
434                _ => {
435                    // All other events (message_start, content_block_start, etc.)
436                    // follow the standard format with type field
437                    Some(
438                        serde_json::from_str::<StreamEvent>(&event.data).map_err(|e| {
439                            Error::StreamParse(format!(
440                                "Failed to parse event '{}': {}",
441                                event.event, e
442                            ))
443                        })?,
444                    )
445                }
446            };
447
448            Ok(stream_event)
449        });
450
451        // Filter out None values
452        let filtered_stream = stream.try_filter_map(|opt| async move { Ok(opt) });
453
454        Ok(Box::pin(filtered_stream))
455    }
456
457    /// Send streaming message to AWS Bedrock
458    #[cfg(feature = "bedrock")]
459    async fn send_streaming_bedrock(
460        &self,
461        request: MessagesRequest,
462    ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send>>> {
463        let (bedrock_client, model_id) = match &self.backend {
464            ClaudeBackend::Bedrock { bedrock_client, .. } => {
465                let model_id = self.get_bedrock_model_id(&request.model)?;
466                (bedrock_client, model_id)
467            }
468            _ => unreachable!("send_streaming_bedrock called with non-Bedrock backend"),
469        };
470
471        debug!("Sending streaming message to AWS Bedrock");
472
473        // Enable streaming
474        let mut request = request;
475        request.stream = Some(true);
476
477        // Serialize request to JSON
478        let body = serde_json::to_string(&request)?;
479
480        // Use Bedrock runtime client with streaming
481        let response = bedrock_client
482            .invoke_model_with_response_stream()
483            .model_id(&model_id)
484            .content_type("application/json")
485            .body(aws_sdk_bedrockruntime::primitives::Blob::new(
486                body.as_bytes(),
487            ))
488            .send()
489            .await
490            .map_err(|e| Error::Network(format!("Bedrock streaming API call failed: {}", e)))?;
491
492        // Convert Bedrock EventReceiver to a stream
493        let mut event_stream = response.body;
494
495        // Create a stream by polling the EventReceiver
496        let stream = async_stream::stream! {
497            loop {
498                match event_stream.recv().await {
499                    Ok(Some(event)) => {
500                        // Parse the event based on Bedrock's format
501                        if let aws_sdk_bedrockruntime::types::ResponseStream::Chunk(payload) = event {
502                            let bytes = payload.bytes().ok_or_else(|| {
503                                Error::StreamParse("Bedrock chunk missing bytes".into())
504                            })?;
505
506                            let json_str = std::str::from_utf8(bytes.as_ref())
507                                .map_err(|e| Error::StreamParse(format!("Invalid UTF-8: {}", e)))?;
508
509                            // Parse as StreamEvent
510                            let stream_event: StreamEvent = serde_json::from_str(json_str)
511                                .map_err(|e| Error::StreamParse(format!("Failed to parse Bedrock event: {}", e)))?;
512
513                            yield Ok(stream_event);
514                        }
515                        // Skip other event types
516                    }
517                    Ok(None) => break, // Stream ended
518                    Err(e) => {
519                        yield Err(Error::StreamParse(format!("Bedrock stream error: {}", e)));
520                        break;
521                    }
522                }
523            }
524        };
525
526        Ok(Box::pin(stream))
527    }
528
529    /// Helper to handle error responses
530    async fn handle_error_response(
531        &self,
532        status: StatusCode,
533        response: reqwest::Response,
534    ) -> Error {
535        match status {
536            StatusCode::TOO_MANY_REQUESTS => {
537                let retry_after = response
538                    .headers()
539                    .get("retry-after")
540                    .and_then(|h| h.to_str().ok())
541                    .and_then(|s| s.parse().ok());
542
543                let error_body = response.text().await.unwrap_or_default();
544                Error::RateLimit {
545                    retry_after,
546                    message: error_body,
547                }
548            }
549            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
550                let error_body = response.text().await.unwrap_or_default();
551                Error::Authentication(error_body)
552            }
553            StatusCode::BAD_REQUEST => {
554                let error_text = response.text().await.unwrap_or_default();
555                if let Ok(api_error) = serde_json::from_str::<ApiErrorResponse>(&error_text) {
556                    Error::Api {
557                        status: status.as_u16(),
558                        message: api_error.error.message,
559                        error_type: Some(api_error.error.error_type),
560                    }
561                } else {
562                    Error::InvalidRequest(error_text)
563                }
564            }
565            _ if status.is_server_error() => {
566                let error_body = response.text().await.unwrap_or_default();
567                Error::Server {
568                    status: status.as_u16(),
569                    message: error_body,
570                }
571            }
572            _ => {
573                let error_body = response.text().await.unwrap_or_default();
574                Error::Api {
575                    status: status.as_u16(),
576                    message: error_body,
577                    error_type: None,
578                }
579            }
580        }
581    }
582
583    /// Count tokens for a request without sending it
584    ///
585    /// Uses the server-side token counting endpoint for accurate counts.
586    /// This is more accurate than the local `TokenCounter` but requires an API call.
587    ///
588    /// # Example
589    ///
590    /// ```rust,no_run
591    /// use claude_sdk::{ClaudeClient, MessagesRequest, Message};
592    ///
593    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
594    /// let client = ClaudeClient::anthropic("your-api-key");
595    ///
596    /// let request = MessagesRequest::new(
597    ///     "claude-sonnet-4-5-20250929",
598    ///     1024,
599    ///     vec![Message::user("Hello!")],
600    /// );
601    ///
602    /// let count = client.count_tokens(request).await?;
603    /// println!("Would use {} input tokens", count.input_tokens);
604    /// # Ok(())
605    /// # }
606    /// ```
607    pub async fn count_tokens(&self, request: MessagesRequest) -> Result<crate::types::TokenCount> {
608        match &self.backend {
609            ClaudeBackend::Anthropic { api_key } => {
610                let response = self
611                    .http
612                    .post(TOKEN_COUNT_URL)
613                    .header("x-api-key", api_key)
614                    .header("anthropic-version", &self.api_version)
615                    .header("content-type", "application/json")
616                    .json(&request)
617                    .send()
618                    .await?;
619
620                let status = response.status();
621                if !status.is_success() {
622                    return Err(self.handle_error_response(status, response).await);
623                }
624
625                let token_count: crate::types::TokenCount = response.json().await?;
626                Ok(token_count)
627            }
628            #[cfg(feature = "bedrock")]
629            ClaudeBackend::Bedrock { .. } => Err(crate::error::Error::InvalidRequest(
630                "Token counting endpoint is not available for Bedrock".into(),
631            )),
632        }
633    }
634
635    /// Send a message with automatic retry on transient failures
636    ///
637    /// This method automatically retries on rate limits (429) and server errors (5xx)
638    /// using exponential backoff. Use the provided `RetryConfig` to customize behavior.
639    ///
640    /// # Example
641    ///
642    /// ```rust,no_run
643    /// use claude_sdk::{ClaudeClient, MessagesRequest, Message};
644    /// use claude_sdk::retry::RetryConfig;
645    ///
646    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
647    /// let client = ClaudeClient::anthropic("your-api-key");
648    ///
649    /// let request = MessagesRequest::new(
650    ///     claude_sdk::models::CLAUDE_SONNET_4_5.anthropic_id,
651    ///     1024,
652    ///     vec![Message::user("Hello!")],
653    /// );
654    ///
655    /// let config = RetryConfig::new().with_max_attempts(5);
656    /// let response = client.send_message_with_retry(request, config).await?;
657    /// # Ok(())
658    /// # }
659    /// ```
660    pub async fn send_message_with_retry(
661        &self,
662        request: MessagesRequest,
663        config: crate::retry::RetryConfig,
664    ) -> Result<MessagesResponse> {
665        crate::retry::retry_with_backoff(config, || async {
666            self.send_message(request.clone()).await
667        })
668        .await
669    }
670
671    /// Send a streaming message with automatic retry on transient failures
672    ///
673    /// Note: Retries create a new stream, so partial results from failed attempts are lost.
674    pub async fn send_streaming_with_retry(
675        &self,
676        request: MessagesRequest,
677        config: crate::retry::RetryConfig,
678    ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send>>> {
679        crate::retry::retry_with_backoff(config, || async {
680            self.send_streaming(request.clone()).await
681        })
682        .await
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689
690    #[test]
691    fn test_parse_rate_limit_headers() {
692        use reqwest::header::{HeaderMap, HeaderValue};
693
694        let mut headers = HeaderMap::new();
695        headers.insert(
696            "anthropic-ratelimit-requests-remaining",
697            HeaderValue::from_static("95"),
698        );
699        headers.insert(
700            "anthropic-ratelimit-tokens-remaining",
701            HeaderValue::from_static("49000"),
702        );
703        headers.insert(
704            "anthropic-ratelimit-requests-reset",
705            HeaderValue::from_static("2025-01-01T00:01:00Z"),
706        );
707        headers.insert(
708            "anthropic-ratelimit-tokens-reset",
709            HeaderValue::from_static("2025-01-01T00:01:00Z"),
710        );
711
712        let info = parse_rate_limit_headers(&headers);
713        assert_eq!(info.requests_remaining, Some(95));
714        assert_eq!(info.tokens_remaining, Some(49000));
715        assert_eq!(info.requests_reset.as_deref(), Some("2025-01-01T00:01:00Z"));
716        assert_eq!(info.tokens_reset.as_deref(), Some("2025-01-01T00:01:00Z"));
717    }
718
719    #[test]
720    fn test_parse_rate_limit_headers_empty() {
721        let headers = reqwest::header::HeaderMap::new();
722        let info = parse_rate_limit_headers(&headers);
723        assert!(info.requests_remaining.is_none());
724        assert!(info.tokens_remaining.is_none());
725    }
726
727    #[test]
728    fn test_token_count_url() {
729        assert_eq!(
730            TOKEN_COUNT_URL,
731            "https://api.anthropic.com/v1/messages/count_tokens"
732        );
733    }
734
735    #[test]
736    fn test_client_creation_anthropic() {
737        let client = ClaudeClient::anthropic("test-key");
738
739        match &client.backend {
740            ClaudeBackend::Anthropic { api_key } => {
741                assert_eq!(api_key, "test-key");
742            }
743            #[allow(unreachable_patterns)]
744            _ => panic!("Expected Anthropic backend"),
745        }
746
747        assert_eq!(client.api_version, API_VERSION);
748    }
749
750    #[tokio::test]
751    #[cfg(feature = "bedrock")]
752    #[ignore] // Requires AWS credentials
753    async fn test_client_creation_bedrock() {
754        // This test only runs with: cargo test -- --ignored
755        // and requires AWS credentials to be configured
756        let result = ClaudeClient::bedrock("us-east-1").await;
757
758        if let Ok(client) = result {
759            match &client.backend {
760                ClaudeBackend::Bedrock { region, .. } => {
761                    assert_eq!(region, "us-east-1");
762                }
763                _ => panic!("Expected Bedrock backend"),
764            }
765        }
766        // If credentials aren't available, test is skipped
767    }
768}