Skip to main content

adk_anthropic/
client.rs

1use std::env;
2use std::fs;
3use std::pin::Pin;
4use std::sync::Arc;
5use std::task::{Context, Poll};
6use std::time::{Duration, Instant};
7
8use futures::Stream;
9use reqwest::header::{HeaderMap, HeaderValue};
10use reqwest::{Client as ReqwestClient, Response, header};
11use serde::Deserialize;
12use tokio::time::sleep;
13
14use crate::AccumulatingStream;
15use crate::backoff::ExponentialBackoff;
16use crate::base_url::validate_base_url;
17use crate::client_logger::ClientLogger;
18use crate::error::{Error, Result};
19use crate::observability::{
20    CLIENT_REQUEST_DURATION, CLIENT_REQUEST_ERRORS, CLIENT_REQUEST_RETRIES, CLIENT_REQUESTS,
21    CLIENT_RETRY_BACKOFF,
22};
23use crate::sse::process_sse;
24use crate::types::{
25    BatchRequest, BatchResultItem, FileObject, Message, MessageBatch, MessageCountTokensParams,
26    MessageCreateParams, MessageStreamEvent, MessageTokensCount, ModelInfo, ModelListParams,
27    ModelListResponse, PaginatedList, SkillObject, ThinkingConfig,
28};
29
30use base64::Engine as _;
31
32/// Simple base64 encoding for skill content.
33fn base64_encode(data: &[u8]) -> String {
34    base64::engine::general_purpose::STANDARD.encode(data)
35}
36
37/// A stream wrapper that logs events and the final message through a [`ClientLogger`].
38///
39/// This stream passes through all events from the underlying [`AccumulatingStream`],
40/// logging each event as it occurs and logging the final reconstructed message
41/// when the stream completes.
42pub struct LoggingStream<'a> {
43    inner: AccumulatingStream,
44    logger: &'a dyn ClientLogger,
45    receiver: Option<tokio::sync::oneshot::Receiver<Result<Message>>>,
46}
47
48impl<'a> LoggingStream<'a> {
49    /// Create a new logging stream wrapper.
50    fn new(
51        inner: AccumulatingStream,
52        receiver: tokio::sync::oneshot::Receiver<Result<Message>>,
53        logger: &'a dyn ClientLogger,
54    ) -> Self {
55        Self { inner, logger, receiver: Some(receiver) }
56    }
57}
58
59impl Stream for LoggingStream<'_> {
60    type Item = Result<MessageStreamEvent>;
61
62    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
63        let inner = Pin::new(&mut self.inner);
64        match inner.poll_next(cx) {
65            Poll::Ready(Some(Ok(event))) => {
66                self.logger.log_stream_event(&event);
67                Poll::Ready(Some(Ok(event)))
68            }
69            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
70            Poll::Ready(None) => {
71                // Stream ended - try to get the accumulated message
72                if let Some(mut receiver) = self.receiver.take()
73                    && let Ok(Ok(ref message)) = receiver.try_recv()
74                {
75                    self.logger.log_stream_message(message);
76                }
77                Poll::Ready(None)
78            }
79            Poll::Pending => Poll::Pending,
80        }
81    }
82}
83
84const DEFAULT_API_URL: &str = "https://api.anthropic.com";
85const ANTHROPIC_API_VERSION: &str = "2023-06-01";
86const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
87const STRUCTURED_OUTPUTS_BETA: &str = "structured-outputs-2025-11-13";
88
89/// Client for the Anthropic API with performance optimizations.
90#[derive(Debug, Clone)]
91pub struct Anthropic {
92    api_key: String,
93    client: ReqwestClient,
94    base_url: String,
95    timeout: Duration,
96    max_retries: usize,
97    throughput_ops_sec: f64,
98    reserve_capacity: f64,
99    /// Cached headers for performance - Arc for cheap cloning
100    cached_headers: Arc<HeaderMap>,
101}
102
103impl Anthropic {
104    /// Resolve an API key value, handling file:// URLs
105    fn resolve_api_key(key_value: &str) -> Result<String> {
106        if let Some(stripped) = key_value.strip_prefix("file://") {
107            // Handle file:// URLs
108            let path = if stripped.starts_with('/') {
109                // Absolute path: file:///root/.env -> /root/.env
110                stripped.to_string()
111            } else {
112                // Relative path: file://../foo -> ../foo
113                stripped.to_string()
114            };
115
116            fs::read_to_string(&path).map(|content| content.trim().to_string()).map_err(|e| {
117                Error::validation(
118                    format!("Failed to read API key from file '{}': {}", path, e),
119                    Some("api_key".to_string()),
120                )
121            })
122        } else {
123            // Regular API key value
124            Ok(key_value.to_string())
125        }
126    }
127
128    /// Resolve the effective base URL from an optional `ANTHROPIC_BASE_URL` value.
129    ///
130    /// A value supplied through the environment is held to exactly the same rule
131    /// as one supplied through [`Anthropic::with_base_url`]: every request
132    /// attaches the API key, so an unencrypted endpoint would leak the
133    /// credential. When no value is supplied the default Anthropic API URL is
134    /// used.
135    ///
136    /// # Errors
137    ///
138    /// Returns a validation error when the supplied value is not `https://` and
139    /// not `http://` with a loopback host.
140    fn resolve_base_url(env_value: Option<String>) -> Result<String> {
141        match env_value {
142            Some(value) => {
143                validate_base_url(&value)?;
144                Ok(value)
145            }
146            None => Ok(DEFAULT_API_URL.to_string()),
147        }
148    }
149
150    /// Create a new Anthropic client.
151    ///
152    /// The API key can be provided directly or read from the `ANTHROPIC_API_KEY`
153    /// environment variable. If the value starts with `"file://"`, it will be
154    /// treated as a file path and the API key will be read from that file.
155    ///
156    /// The base URL is resolved from the `ANTHROPIC_BASE_URL` environment
157    /// variable. If not set, the default Anthropic API URL is used.
158    ///
159    /// # Errors
160    ///
161    /// Returns a validation error when `ANTHROPIC_BASE_URL` is set to an
162    /// endpoint that would transmit the API key in cleartext — anything other
163    /// than `https://`, or `http://` with a loopback host (`localhost`,
164    /// `127.0.0.1`, `[::1]`). A misconfigured environment fails loudly rather
165    /// than silently falling back to the default URL.
166    pub fn new(api_key: Option<String>) -> Result<Self> {
167        let api_key = match api_key {
168            Some(key) => Self::resolve_api_key(&key)?,
169            None => {
170                let env_key = env::var("ANTHROPIC_API_KEY").map_err(|_| {
171                    Error::authentication(
172                        "API key not provided and ANTHROPIC_API_KEY environment variable not set",
173                    )
174                })?;
175                Self::resolve_api_key(&env_key)?
176            }
177        };
178
179        let timeout = DEFAULT_TIMEOUT;
180        let client = ReqwestClient::builder()
181            .timeout(timeout)
182            .pool_max_idle_per_host(10) // Connection pooling optimization
183            .pool_idle_timeout(Duration::from_secs(90))
184            .tcp_keepalive(Duration::from_secs(60))
185            .build()
186            .map_err(|e| {
187                Error::http_client(format!("Failed to build HTTP client: {e}"), Some(Box::new(e)))
188            })?;
189
190        // Pre-build headers for performance
191        let cached_headers = Arc::new(Self::build_default_headers(&api_key)?);
192
193        // Resolve base URL from environment variable, defaulting to the API URL.
194        // An env-provided value is validated here so the cleartext path closed on
195        // `with_base_url` cannot be reopened through the environment.
196        let base_url = Self::resolve_base_url(env::var("ANTHROPIC_BASE_URL").ok())?;
197
198        Ok(Self {
199            api_key,
200            client,
201            base_url,
202            timeout,
203            max_retries: 3,
204            throughput_ops_sec: 1.0 / 60.0,
205            reserve_capacity: 1.0 / 60.0,
206            cached_headers,
207        })
208    }
209
210    /// Set a custom base URL for this client.
211    ///
212    /// This method allows you to specify a different API endpoint for the client.
213    /// The base URL should be the root URL without the `/v1/` suffix - this will
214    /// be added automatically when constructing request URLs.
215    ///
216    /// # Errors
217    ///
218    /// Every request made by this client attaches the Anthropic API key, so the
219    /// base URL must be encrypted. Returns a validation error unless the URL uses
220    /// `https://`, or `http://` with a loopback host (`localhost`, `127.0.0.1`,
221    /// `[::1]`) for local development.
222    ///
223    /// # Examples
224    ///
225    /// ```
226    /// # use adk_anthropic::Anthropic;
227    /// // For Anthropic's API (default)
228    /// let client = Anthropic::new(Some("placeholder-api-key".to_string()))?
229    ///     .with_base_url("https://api.anthropic.com".to_string())?;
230    ///
231    /// // For Minimax (international)
232    /// let client = Anthropic::new(Some("placeholder-api-key".to_string()))?
233    ///     .with_base_url("https://api.minimax.io/anthropic".to_string())?;
234    ///
235    /// // For Minimax (China)
236    /// let client = Anthropic::new(Some("placeholder-api-key".to_string()))?
237    ///     .with_base_url("https://api.minimaxi.com/anthropic".to_string())?;
238    /// # Ok::<(), adk_anthropic::Error>(())
239    /// ```
240    pub fn with_base_url(mut self, base_url: String) -> Result<Self> {
241        validate_base_url(&base_url)?;
242        self.base_url = base_url;
243        Ok(self)
244    }
245
246    /// Return the effective API base URL used for message requests.
247    pub fn base_url(&self) -> &str {
248        &self.base_url
249    }
250
251    /// Set a custom timeout for this client.
252    ///
253    /// This method allows you to specify a different timeout for API requests.
254    pub fn with_timeout(mut self, timeout: Duration) -> Result<Self> {
255        self.timeout = timeout;
256
257        // Recreate the client with the new timeout and performance optimizations
258        let client = ReqwestClient::builder()
259            .timeout(timeout)
260            .pool_max_idle_per_host(10)
261            .pool_idle_timeout(Duration::from_secs(90))
262            .tcp_keepalive(Duration::from_secs(60))
263            .build()
264            .map_err(|e| {
265                Error::http_client(
266                    "Failed to build HTTP client with new timeout",
267                    Some(Box::new(e)),
268                )
269            })?;
270
271        self.client = client;
272        Ok(self)
273    }
274
275    /// Set the maximum number of retries for this client.
276    ///
277    /// This method allows you to specify how many times to retry failed requests.
278    pub fn with_max_retries(mut self, max_retries: usize) -> Self {
279        self.max_retries = max_retries;
280        self
281    }
282
283    /// Get the API key being used by this client.
284    pub fn api_key(&self) -> &str {
285        &self.api_key
286    }
287
288    /// Set the backoff parameters for this client.
289    ///
290    /// This method allows you to configure the exponential backoff algorithm.
291    pub fn with_backoff_params(mut self, throughput_ops_sec: f64, reserve_capacity: f64) -> Self {
292        self.throughput_ops_sec = throughput_ops_sec;
293        self.reserve_capacity = reserve_capacity;
294        self
295    }
296
297    /// Set both a custom base URL and timeout for this client.
298    ///
299    /// This is a convenience method that chains with_base_url and with_timeout.
300    pub fn with_base_url_and_timeout(self, base_url: String, timeout: Duration) -> Result<Self> {
301        self.with_base_url(base_url)?.with_timeout(timeout)
302    }
303
304    /// Build default headers for API requests (static method for initialization).
305    fn build_default_headers(api_key: &str) -> Result<HeaderMap> {
306        let mut headers = HeaderMap::new();
307        headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
308        headers.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
309        headers.insert(
310            "x-api-key",
311            HeaderValue::from_str(api_key).map_err(|e| {
312                Error::validation(
313                    format!("Invalid API key format: {e}"),
314                    Some("api_key".to_string()),
315                )
316            })?,
317        );
318        headers.insert("anthropic-version", HeaderValue::from_static(ANTHROPIC_API_VERSION));
319        Ok(headers)
320    }
321
322    /// Get cached headers for performance (no allocation needed).
323    fn default_headers(&self) -> HeaderMap {
324        (*self.cached_headers).clone()
325    }
326
327    /// Build a full endpoint URL from the base URL and endpoint path.
328    ///
329    /// This method handles trailing slashes gracefully and always inserts `/v1/`
330    /// between the base URL and endpoint path. This allows the base URL to be
331    /// specified without requiring a specific format (with or without trailing slash,
332    /// with or without `/v1/` suffix).
333    ///
334    /// # Examples
335    ///
336    /// - Base: `https://api.anthropic.com`, endpoint: `messages` → `https://api.anthropic.com/v1/messages`
337    /// - Base: `https://api.minimax.io/anthropic`, endpoint: `messages` → `https://api.minimax.io/anthropic/v1/messages`
338    /// - Base: `https://example.com/`, endpoint: `models` → `https://example.com/v1/models`
339    fn build_url(&self, endpoint: &str) -> String {
340        let base = self.base_url.trim_end_matches('/');
341        format!("{}/v1/{}", base, endpoint)
342    }
343
344    /// Retry wrapper that implements exponential backoff with header-based retry-after
345    async fn retry_with_backoff<F, Fut, T>(&self, operation: F) -> Result<T>
346    where
347        F: Fn() -> Fut,
348        Fut: std::future::Future<Output = Result<T>>,
349    {
350        let backoff = ExponentialBackoff::new(self.throughput_ops_sec, self.reserve_capacity);
351        let mut last_error = None;
352
353        for attempt in 0..=self.max_retries {
354            match operation().await {
355                Ok(result) => return Ok(result),
356                Err(error) => {
357                    // Check if error is retryable
358                    if !error.is_retryable() {
359                        return Err(error);
360                    }
361
362                    // Don't sleep on the last attempt
363                    if attempt == self.max_retries {
364                        last_error = Some(error);
365                        break;
366                    }
367
368                    // Calculate backoff duration
369                    let exp_backoff_duration = backoff.next();
370
371                    // Get retry-after from error if available
372                    let header_backoff_duration = match &error {
373                        Error::RateLimit { retry_after: Some(seconds), .. } => {
374                            Some(Duration::from_secs(*seconds))
375                        }
376                        Error::ServiceUnavailable { retry_after: Some(seconds), .. } => {
377                            Some(Duration::from_secs(*seconds))
378                        }
379                        _ => None,
380                    };
381
382                    // Take the maximum of exponential backoff and header-based backoff
383                    let sleep_duration = match header_backoff_duration {
384                        Some(header_duration) => exp_backoff_duration.max(header_duration),
385                        None => exp_backoff_duration,
386                    };
387
388                    CLIENT_REQUEST_RETRIES.click();
389                    CLIENT_RETRY_BACKOFF.add(sleep_duration.as_secs_f64());
390                    sleep(sleep_duration).await;
391                    last_error = Some(error);
392                }
393            }
394        }
395
396        Err(last_error
397            .unwrap_or_else(|| Error::unknown("Failed after retries without capturing error")))
398    }
399
400    /// Process API response errors and convert to our Error type
401    async fn process_error_response(response: Response) -> Error {
402        let status = response.status();
403        let status_code = status.as_u16();
404
405        // Get headers we might need for error processing
406        let request_id = response
407            .headers()
408            .get("x-request-id")
409            .and_then(|val| val.to_str().ok())
410            .map(String::from);
411
412        let retry_after = response
413            .headers()
414            .get("retry-after")
415            .and_then(|val| val.to_str().ok())
416            .and_then(|val| val.parse::<u64>().ok());
417
418        // Try to parse error response body
419        #[derive(Deserialize)]
420        struct ErrorResponse {
421            error: Option<ErrorDetail>,
422        }
423
424        #[derive(Deserialize)]
425        struct ErrorDetail {
426            #[serde(rename = "type")]
427            error_type: Option<String>,
428            message: Option<String>,
429            param: Option<String>,
430        }
431
432        let error_body = match response.text().await {
433            Ok(body) => body,
434            Err(e) => {
435                return Error::http_client(
436                    format!("Failed to read error response: {e}"),
437                    Some(Box::new(e)),
438                );
439            }
440        };
441
442        // Try to parse as JSON first
443        let parsed_error = serde_json::from_str::<ErrorResponse>(&error_body).ok();
444        let error_type =
445            parsed_error.as_ref().and_then(|e| e.error.as_ref()).and_then(|e| e.error_type.clone());
446        let error_message = parsed_error
447            .as_ref()
448            .and_then(|e| e.error.as_ref())
449            .and_then(|e| e.message.clone())
450            .unwrap_or_else(|| error_body.clone());
451        let error_param =
452            parsed_error.as_ref().and_then(|e| e.error.as_ref()).and_then(|e| e.param.clone());
453
454        // Map HTTP status code to appropriate error type
455        match status_code {
456            400 => Error::bad_request(error_message, error_param),
457            401 => Error::authentication(error_message),
458            403 => Error::permission(error_message),
459            404 => Error::not_found(error_message, None, None),
460            408 => Error::timeout(error_message, None),
461            429 => Error::rate_limit(error_message, retry_after),
462            500 => Error::internal_server(error_message, request_id),
463            502..=504 => Error::service_unavailable(error_message, retry_after),
464            529 => Error::rate_limit(error_message, retry_after),
465            _ => Error::api(status_code, error_type, error_message, request_id),
466        }
467    }
468
469    /// Convert reqwest errors to appropriate Error types
470    fn map_request_error(&self, e: reqwest::Error) -> Error {
471        if e.is_timeout() {
472            Error::timeout(format!("Request timed out: {e}"), Some(self.timeout.as_secs_f64()))
473        } else if e.is_connect() {
474            Error::connection(format!("Connection error: {e}"), Some(Box::new(e)))
475        } else {
476            Error::http_client(format!("Request failed: {e}"), Some(Box::new(e)))
477        }
478    }
479
480    /// Execute a POST request with error handling
481    async fn execute_post_request<T: serde::de::DeserializeOwned>(
482        &self,
483        url: &str,
484        body: &impl serde::Serialize,
485        headers: Option<HeaderMap>,
486    ) -> Result<T> {
487        let headers = headers.unwrap_or_else(|| self.default_headers());
488
489        let response = self
490            .client
491            .post(url)
492            .headers(headers)
493            .json(body)
494            .send()
495            .await
496            .map_err(|e| self.map_request_error(e))?;
497
498        if !response.status().is_success() {
499            return Err(Self::process_error_response(response).await);
500        }
501
502        response.json::<T>().await.map_err(|e| {
503            Error::serialization(format!("Failed to parse response: {e}"), Some(Box::new(e)))
504        })
505    }
506
507    /// Execute a GET request with error handling
508    async fn execute_get_request<T: serde::de::DeserializeOwned>(
509        &self,
510        url: &str,
511        query_params: Option<&[(String, String)]>,
512    ) -> Result<T> {
513        let mut request = self.client.get(url).headers(self.default_headers());
514
515        if let Some(params) = query_params {
516            for (key, value) in params {
517                request = request.query(&[(key, value)]);
518            }
519        }
520
521        let response = request.send().await.map_err(|e| self.map_request_error(e))?;
522
523        if !response.status().is_success() {
524            return Err(Self::process_error_response(response).await);
525        }
526
527        response.json::<T>().await.map_err(|e| {
528            Error::serialization(format!("Failed to parse response: {e}"), Some(Box::new(e)))
529        })
530    }
531
532    /// Send a message to the API and get a non-streaming response.
533    pub async fn send(&self, mut params: MessageCreateParams) -> Result<Message> {
534        let start = Instant::now();
535        CLIENT_REQUESTS.click();
536
537        // Validate parameters first
538        if let Err(err) = params.validate() {
539            CLIENT_REQUEST_ERRORS.click();
540            CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
541            return Err(err);
542        }
543
544        // Ensure stream is disabled
545        params.stream = false;
546
547        // Task 8.1: When thinking is Enabled, force temperature to 1.0
548        if matches!(params.thinking, Some(ThinkingConfig::Enabled { .. })) {
549            params.temperature = Some(1.0);
550        }
551
552        // Build headers
553        let mut headers = self.default_headers();
554
555        // Check if structured outputs beta header is needed
556        if params.requires_structured_outputs_beta() {
557            headers.insert("anthropic-beta", HeaderValue::from_static(STRUCTURED_OUTPUTS_BETA));
558        }
559
560        // When context_management is set, add the beta header
561        if params.context_management.is_some() {
562            let existing =
563                headers.get("anthropic-beta").and_then(|v| v.to_str().ok()).unwrap_or("");
564            let new_val = if existing.is_empty() {
565                "context-management-2025-06-27".to_string()
566            } else {
567                format!("{existing},context-management-2025-06-27")
568            };
569            headers.insert(
570                "anthropic-beta",
571                HeaderValue::from_str(&new_val)
572                    .unwrap_or_else(|_| HeaderValue::from_static("context-management-2025-06-27")),
573            );
574        }
575
576        // When speed is set, add the fast-mode beta header
577        if params.speed.is_some() {
578            let existing =
579                headers.get("anthropic-beta").and_then(|v| v.to_str().ok()).unwrap_or("");
580            let new_val = if existing.is_empty() {
581                "fast-mode-2026-02-01".to_string()
582            } else {
583                format!("{existing},fast-mode-2026-02-01")
584            };
585            headers.insert(
586                "anthropic-beta",
587                HeaderValue::from_str(&new_val)
588                    .unwrap_or_else(|_| HeaderValue::from_static("fast-mode-2026-02-01")),
589            );
590        }
591
592        let result = self
593            .retry_with_backoff(|| async {
594                let url = self.build_url("messages");
595                self.execute_post_request(&url, &params, Some(headers.clone())).await
596            })
597            .await;
598
599        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
600        if result.is_err() {
601            CLIENT_REQUEST_ERRORS.click();
602        }
603        result
604    }
605
606    /// Send a message to the API with logging and get a non-streaming response.
607    ///
608    /// This method is identical to [`send`](Self::send) but additionally logs
609    /// the response through the provided [`ClientLogger`].
610    pub async fn send_with_logger(
611        &self,
612        params: MessageCreateParams,
613        logger: &dyn ClientLogger,
614    ) -> Result<Message> {
615        let result = self.send(params).await;
616        if let Ok(ref message) = result {
617            logger.log_response(message);
618        }
619        result
620    }
621
622    /// Send a message to the API and get a streaming response.
623    ///
624    /// Returns a stream of MessageStreamEvent objects that can be processed incrementally.
625    pub async fn stream(
626        &self,
627        params: &MessageCreateParams,
628    ) -> Result<impl Stream<Item = Result<MessageStreamEvent>> + use<>> {
629        let start = Instant::now();
630        CLIENT_REQUESTS.click();
631
632        // Validate parameters first
633        if let Err(err) = params.validate() {
634            CLIENT_REQUEST_ERRORS.click();
635            CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
636            return Err(err);
637        }
638
639        // Task 8.3: Clone and force stream = true in the request body
640        let mut params = params.clone();
641        params.stream = true;
642
643        // Task 8.1: When thinking is Enabled, force temperature to 1.0
644        if matches!(params.thinking, Some(ThinkingConfig::Enabled { .. })) {
645            params.temperature = Some(1.0);
646        }
647
648        // Check if structured outputs beta header is needed
649        let needs_beta = params.requires_structured_outputs_beta();
650
651        // Check if context_management beta header is needed
652        let needs_context_mgmt = params.context_management.is_some();
653
654        // Check if fast-mode beta header is needed
655        let needs_fast_mode = params.speed.is_some();
656
657        let response = self
658            .retry_with_backoff(|| async {
659                let url = self.build_url("messages");
660
661                let mut headers = self.default_headers();
662                headers.insert(header::ACCEPT, HeaderValue::from_static("text/event-stream"));
663
664                // Build anthropic-beta header combining all needed betas
665                let mut betas = Vec::new();
666                if needs_beta {
667                    betas.push(STRUCTURED_OUTPUTS_BETA);
668                }
669                if needs_context_mgmt {
670                    betas.push("context-management-2025-06-27");
671                }
672                if needs_fast_mode {
673                    betas.push("fast-mode-2026-02-01");
674                }
675                if !betas.is_empty() {
676                    let beta_val = betas.join(",");
677                    headers.insert(
678                        "anthropic-beta",
679                        HeaderValue::from_str(&beta_val)
680                            .unwrap_or_else(|_| HeaderValue::from_static(STRUCTURED_OUTPUTS_BETA)),
681                    );
682                }
683
684                let response = self
685                    .client
686                    .post(&url)
687                    .headers(headers)
688                    .json(&params)
689                    .send()
690                    .await
691                    .map_err(|e| self.map_request_error(e))?;
692
693                if !response.status().is_success() {
694                    return Err(Self::process_error_response(response).await);
695                }
696
697                Ok(response)
698            })
699            .await;
700
701        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
702        let response = match response {
703            Ok(response) => response,
704            Err(err) => {
705                CLIENT_REQUEST_ERRORS.click();
706                return Err(err);
707            }
708        };
709
710        // Get the byte stream from the response
711        let stream = response.bytes_stream();
712
713        // Create an SSE processor
714        Ok(process_sse(stream))
715    }
716
717    /// Send a message to the API with logging and get a streaming response.
718    ///
719    /// This method is identical to [`stream`](Self::stream) but additionally logs
720    /// each streaming event and the final reconstructed message through the
721    /// provided [`ClientLogger`].
722    ///
723    /// Returns a [`LoggingStream`] that wraps an [`AccumulatingStream`], logging
724    /// each event as it passes through and logging the final message when the
725    /// stream completes.
726    pub async fn stream_with_logger<'a>(
727        &self,
728        params: &MessageCreateParams,
729        logger: &'a dyn ClientLogger,
730    ) -> Result<LoggingStream<'a>> {
731        let raw_stream = self.stream(params).await?;
732        let (accumulating_stream, receiver) = AccumulatingStream::new(raw_stream);
733        Ok(LoggingStream::new(accumulating_stream, receiver, logger))
734    }
735
736    /// Count tokens for a message.
737    ///
738    /// This method counts the number of tokens that would be used by a message with the given parameters.
739    /// It's useful for estimating costs or making sure your messages fit within the model's context window.
740    pub async fn count_tokens(
741        &self,
742        params: MessageCountTokensParams,
743    ) -> Result<MessageTokensCount> {
744        let start = Instant::now();
745        CLIENT_REQUESTS.click();
746        let result = self
747            .retry_with_backoff(|| async {
748                let url = self.build_url("messages/count_tokens");
749                self.execute_post_request(&url, &params, None).await
750            })
751            .await;
752
753        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
754        if result.is_err() {
755            CLIENT_REQUEST_ERRORS.click();
756        }
757        result
758    }
759
760    /// List available models from the API.
761    ///
762    /// Returns a paginated list of all available models. Use the parameters to control
763    /// pagination and filter results.
764    pub async fn list_models(&self, params: Option<ModelListParams>) -> Result<ModelListResponse> {
765        let start = Instant::now();
766        CLIENT_REQUESTS.click();
767        let result = self
768            .retry_with_backoff(|| async {
769                let url = self.build_url("models");
770
771                let query_params = params.as_ref().map(|p| {
772                    let mut params = Vec::new();
773                    if let Some(ref after_id) = p.after_id {
774                        params.push(("after_id".to_string(), after_id.clone()));
775                    }
776                    if let Some(ref before_id) = p.before_id {
777                        params.push(("before_id".to_string(), before_id.clone()));
778                    }
779                    if let Some(limit) = p.limit {
780                        params.push(("limit".to_string(), limit.to_string()));
781                    }
782                    params
783                });
784
785                self.execute_get_request(&url, query_params.as_deref()).await
786            })
787            .await;
788
789        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
790        if result.is_err() {
791            CLIENT_REQUEST_ERRORS.click();
792        }
793        result
794    }
795
796    /// Retrieve information about a specific model.
797    ///
798    /// Returns detailed information about the specified model, including its
799    /// ID, creation date, display name, and type.
800    pub async fn get_model(&self, model_id: &str) -> Result<ModelInfo> {
801        let start = Instant::now();
802        CLIENT_REQUESTS.click();
803        let result = self
804            .retry_with_backoff(|| async {
805                let url = self.build_url(&format!("models/{}", model_id));
806                self.execute_get_request(&url, None).await
807            })
808            .await;
809
810        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
811        if result.is_err() {
812            CLIENT_REQUEST_ERRORS.click();
813        }
814        result
815    }
816
817    // --- Helper methods for DELETE and multipart requests ---
818
819    /// Execute a DELETE request with error handling.
820    async fn execute_delete_request(&self, url: &str) -> Result<()> {
821        let response = self
822            .client
823            .delete(url)
824            .headers(self.default_headers())
825            .send()
826            .await
827            .map_err(|e| self.map_request_error(e))?;
828
829        if !response.status().is_success() {
830            return Err(Self::process_error_response(response).await);
831        }
832
833        Ok(())
834    }
835
836    /// Build standard pagination query params.
837    fn pagination_params(
838        before_id: Option<&str>,
839        after_id: Option<&str>,
840        limit: Option<u32>,
841    ) -> Option<Vec<(String, String)>> {
842        let mut params = Vec::new();
843        if let Some(before) = before_id {
844            params.push(("before_id".to_string(), before.to_string()));
845        }
846        if let Some(after) = after_id {
847            params.push(("after_id".to_string(), after.to_string()));
848        }
849        if let Some(lim) = limit {
850            params.push(("limit".to_string(), lim.to_string()));
851        }
852        if params.is_empty() { None } else { Some(params) }
853    }
854
855    // --- Batches API (Req 13) ---
856
857    /// Create a message batch for asynchronous processing.
858    pub async fn create_batch(&self, requests: Vec<BatchRequest>) -> Result<MessageBatch> {
859        let start = Instant::now();
860        CLIENT_REQUESTS.click();
861        let body = serde_json::json!({ "requests": requests });
862        let result = self
863            .retry_with_backoff(|| async {
864                let url = self.build_url("messages/batches");
865                self.execute_post_request(&url, &body, None).await
866            })
867            .await;
868        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
869        if result.is_err() {
870            CLIENT_REQUEST_ERRORS.click();
871        }
872        result
873    }
874
875    /// Get the status of a message batch.
876    pub async fn get_batch(&self, batch_id: &str) -> Result<MessageBatch> {
877        let start = Instant::now();
878        CLIENT_REQUESTS.click();
879        let result = self
880            .retry_with_backoff(|| async {
881                let url = self.build_url(&format!("messages/batches/{batch_id}"));
882                self.execute_get_request(&url, None).await
883            })
884            .await;
885        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
886        if result.is_err() {
887            CLIENT_REQUEST_ERRORS.click();
888        }
889        result
890    }
891
892    /// Get results of a completed batch as newline-delimited JSON.
893    pub async fn batch_results(&self, batch_id: &str) -> Result<Vec<BatchResultItem>> {
894        let start = Instant::now();
895        CLIENT_REQUESTS.click();
896        let result = self
897            .retry_with_backoff(|| async {
898                let url = self.build_url(&format!("messages/batches/{batch_id}/results"));
899                let response = self
900                    .client
901                    .get(&url)
902                    .headers(self.default_headers())
903                    .send()
904                    .await
905                    .map_err(|e| self.map_request_error(e))?;
906
907                if !response.status().is_success() {
908                    return Err(Self::process_error_response(response).await);
909                }
910
911                let text = response.text().await.map_err(|e| {
912                    Error::serialization(
913                        format!("Failed to read batch results: {e}"),
914                        Some(Box::new(e)),
915                    )
916                })?;
917
918                let mut items = Vec::new();
919                for line in text.lines() {
920                    let trimmed = line.trim();
921                    if trimmed.is_empty() {
922                        continue;
923                    }
924                    let item: BatchResultItem = serde_json::from_str(trimmed)?;
925                    items.push(item);
926                }
927                Ok(items)
928            })
929            .await;
930        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
931        if result.is_err() {
932            CLIENT_REQUEST_ERRORS.click();
933        }
934        result
935    }
936
937    /// Cancel an in-progress batch.
938    pub async fn cancel_batch(&self, batch_id: &str) -> Result<MessageBatch> {
939        let start = Instant::now();
940        CLIENT_REQUESTS.click();
941        let result = self
942            .retry_with_backoff(|| async {
943                let url = self.build_url(&format!("messages/batches/{batch_id}/cancel"));
944                self.execute_post_request(&url, &serde_json::json!({}), None).await
945            })
946            .await;
947        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
948        if result.is_err() {
949            CLIENT_REQUEST_ERRORS.click();
950        }
951        result
952    }
953
954    /// Delete a batch.
955    pub async fn delete_batch(&self, batch_id: &str) -> Result<()> {
956        let start = Instant::now();
957        CLIENT_REQUESTS.click();
958        let result = self
959            .retry_with_backoff(|| async {
960                let url = self.build_url(&format!("messages/batches/{batch_id}"));
961                self.execute_delete_request(&url).await
962            })
963            .await;
964        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
965        if result.is_err() {
966            CLIENT_REQUEST_ERRORS.click();
967        }
968        result
969    }
970
971    /// List message batches with pagination.
972    pub async fn list_batches(
973        &self,
974        before_id: Option<&str>,
975        after_id: Option<&str>,
976        limit: Option<u32>,
977    ) -> Result<PaginatedList<MessageBatch>> {
978        let start = Instant::now();
979        CLIENT_REQUESTS.click();
980        let result = self
981            .retry_with_backoff(|| async {
982                let url = self.build_url("messages/batches");
983                let query = Self::pagination_params(before_id, after_id, limit);
984                self.execute_get_request(&url, query.as_deref()).await
985            })
986            .await;
987        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
988        if result.is_err() {
989            CLIENT_REQUEST_ERRORS.click();
990        }
991        result
992    }
993
994    // --- Files API (Req 21) ---
995
996    /// Upload a file via multipart form upload.
997    pub async fn upload_file(
998        &self,
999        data: Vec<u8>,
1000        mime_type: &str,
1001        filename: &str,
1002        purpose: &str,
1003    ) -> Result<FileObject> {
1004        let start = Instant::now();
1005        CLIENT_REQUESTS.click();
1006
1007        let mime_type = mime_type.to_string();
1008        let filename = filename.to_string();
1009        let purpose = purpose.to_string();
1010
1011        let result = self
1012            .retry_with_backoff(|| {
1013                let data = data.clone();
1014                let mime_type = mime_type.clone();
1015                let filename = filename.clone();
1016                let purpose = purpose.clone();
1017                async move {
1018                    let url = self.build_url("files");
1019                    let part = reqwest::multipart::Part::bytes(data)
1020                        .file_name(filename)
1021                        .mime_str(&mime_type)
1022                        .map_err(|e| {
1023                            Error::validation(
1024                                format!("Invalid MIME type: {e}"),
1025                                Some("mime_type".to_string()),
1026                            )
1027                        })?;
1028                    let form =
1029                        reqwest::multipart::Form::new().text("purpose", purpose).part("file", part);
1030
1031                    let response = self
1032                        .client
1033                        .post(&url)
1034                        .headers(self.default_headers())
1035                        .multipart(form)
1036                        .send()
1037                        .await
1038                        .map_err(|e| self.map_request_error(e))?;
1039
1040                    if !response.status().is_success() {
1041                        return Err(Self::process_error_response(response).await);
1042                    }
1043
1044                    response.json::<FileObject>().await.map_err(|e| {
1045                        Error::serialization(
1046                            format!("Failed to parse file response: {e}"),
1047                            Some(Box::new(e)),
1048                        )
1049                    })
1050                }
1051            })
1052            .await;
1053        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1054        if result.is_err() {
1055            CLIENT_REQUEST_ERRORS.click();
1056        }
1057        result
1058    }
1059
1060    /// Get metadata for a file.
1061    pub async fn get_file(&self, file_id: &str) -> Result<FileObject> {
1062        let start = Instant::now();
1063        CLIENT_REQUESTS.click();
1064        let result = self
1065            .retry_with_backoff(|| async {
1066                let url = self.build_url(&format!("files/{file_id}"));
1067                self.execute_get_request(&url, None).await
1068            })
1069            .await;
1070        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1071        if result.is_err() {
1072            CLIENT_REQUEST_ERRORS.click();
1073        }
1074        result
1075    }
1076
1077    /// Delete a file.
1078    pub async fn delete_file(&self, file_id: &str) -> Result<()> {
1079        let start = Instant::now();
1080        CLIENT_REQUESTS.click();
1081        let result = self
1082            .retry_with_backoff(|| async {
1083                let url = self.build_url(&format!("files/{file_id}"));
1084                self.execute_delete_request(&url).await
1085            })
1086            .await;
1087        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1088        if result.is_err() {
1089            CLIENT_REQUEST_ERRORS.click();
1090        }
1091        result
1092    }
1093
1094    /// List files with pagination.
1095    pub async fn list_files(
1096        &self,
1097        before_id: Option<&str>,
1098        after_id: Option<&str>,
1099        limit: Option<u32>,
1100    ) -> Result<PaginatedList<FileObject>> {
1101        let start = Instant::now();
1102        CLIENT_REQUESTS.click();
1103        let result = self
1104            .retry_with_backoff(|| async {
1105                let url = self.build_url("files");
1106                let query = Self::pagination_params(before_id, after_id, limit);
1107                self.execute_get_request(&url, query.as_deref()).await
1108            })
1109            .await;
1110        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1111        if result.is_err() {
1112            CLIENT_REQUEST_ERRORS.click();
1113        }
1114        result
1115    }
1116
1117    // --- Skills API (Req 22) ---
1118
1119    /// Create a new skill.
1120    pub async fn create_skill(
1121        &self,
1122        name: &str,
1123        description: &str,
1124        content: Vec<u8>,
1125    ) -> Result<SkillObject> {
1126        let start = Instant::now();
1127        CLIENT_REQUESTS.click();
1128        let body = serde_json::json!({
1129            "name": name,
1130            "description": description,
1131            "content": base64_encode(&content),
1132        });
1133        let result = self
1134            .retry_with_backoff(|| async {
1135                let url = self.build_url("skills");
1136                self.execute_post_request(&url, &body, None).await
1137            })
1138            .await;
1139        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1140        if result.is_err() {
1141            CLIENT_REQUEST_ERRORS.click();
1142        }
1143        result
1144    }
1145
1146    /// Get a skill by ID.
1147    pub async fn get_skill(&self, skill_id: &str) -> Result<SkillObject> {
1148        let start = Instant::now();
1149        CLIENT_REQUESTS.click();
1150        let result = self
1151            .retry_with_backoff(|| async {
1152                let url = self.build_url(&format!("skills/{skill_id}"));
1153                self.execute_get_request(&url, None).await
1154            })
1155            .await;
1156        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1157        if result.is_err() {
1158            CLIENT_REQUEST_ERRORS.click();
1159        }
1160        result
1161    }
1162
1163    /// Update a skill's content.
1164    pub async fn update_skill(&self, skill_id: &str, content: Vec<u8>) -> Result<SkillObject> {
1165        let start = Instant::now();
1166        CLIENT_REQUESTS.click();
1167        let body = serde_json::json!({
1168            "content": base64_encode(&content),
1169        });
1170        let result = self
1171            .retry_with_backoff(|| async {
1172                let url = self.build_url(&format!("skills/{skill_id}"));
1173                self.execute_post_request(&url, &body, None).await
1174            })
1175            .await;
1176        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1177        if result.is_err() {
1178            CLIENT_REQUEST_ERRORS.click();
1179        }
1180        result
1181    }
1182
1183    /// Delete a skill.
1184    pub async fn delete_skill(&self, skill_id: &str) -> Result<()> {
1185        let start = Instant::now();
1186        CLIENT_REQUESTS.click();
1187        let result = self
1188            .retry_with_backoff(|| async {
1189                let url = self.build_url(&format!("skills/{skill_id}"));
1190                self.execute_delete_request(&url).await
1191            })
1192            .await;
1193        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1194        if result.is_err() {
1195            CLIENT_REQUEST_ERRORS.click();
1196        }
1197        result
1198    }
1199
1200    /// List skills with pagination.
1201    pub async fn list_skills(
1202        &self,
1203        before_id: Option<&str>,
1204        after_id: Option<&str>,
1205        limit: Option<u32>,
1206    ) -> Result<PaginatedList<SkillObject>> {
1207        let start = Instant::now();
1208        CLIENT_REQUESTS.click();
1209        let result = self
1210            .retry_with_backoff(|| async {
1211                let url = self.build_url("skills");
1212                let query = Self::pagination_params(before_id, after_id, limit);
1213                self.execute_get_request(&url, query.as_deref()).await
1214            })
1215            .await;
1216        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1217        if result.is_err() {
1218            CLIENT_REQUEST_ERRORS.click();
1219        }
1220        result
1221    }
1222}
1223
1224#[cfg(test)]
1225mod tests {
1226    use super::*;
1227    use std::sync::Arc;
1228    use std::sync::atomic::{AtomicUsize, Ordering};
1229
1230    #[tokio::test]
1231    async fn retry_logic_with_backoff() {
1232        let client = Anthropic {
1233            api_key: "test".to_string(),
1234            client: ReqwestClient::new(),
1235            base_url: "http://localhost".to_string(),
1236            timeout: Duration::from_secs(1),
1237            max_retries: 2,
1238            throughput_ops_sec: 1.0 / 60.0,
1239            reserve_capacity: 1.0 / 60.0,
1240            cached_headers: Arc::new(HeaderMap::new()),
1241        };
1242
1243        let attempt_counter = Arc::new(AtomicUsize::new(0));
1244        let counter_clone = attempt_counter.clone();
1245
1246        let result = client
1247            .retry_with_backoff(|| {
1248                let counter = counter_clone.clone();
1249                async move {
1250                    let attempt = counter.fetch_add(1, Ordering::SeqCst);
1251                    match attempt {
1252                        0 | 1 => Err(Error::rate_limit("Rate limited", Some(1))),
1253                        _ => Ok("success".to_string()),
1254                    }
1255                }
1256            })
1257            .await;
1258
1259        assert!(result.is_ok());
1260        assert_eq!(result.unwrap(), "success");
1261        assert_eq!(attempt_counter.load(Ordering::SeqCst), 3);
1262    }
1263
1264    #[tokio::test]
1265    async fn retry_logic_with_non_retryable_error() {
1266        let client = Anthropic {
1267            api_key: "test".to_string(),
1268            client: ReqwestClient::new(),
1269            base_url: "http://localhost".to_string(),
1270            timeout: Duration::from_secs(1),
1271            max_retries: 2,
1272            throughput_ops_sec: 1.0 / 60.0,
1273            reserve_capacity: 1.0 / 60.0,
1274            cached_headers: Arc::new(HeaderMap::new()),
1275        };
1276
1277        let attempt_counter = Arc::new(AtomicUsize::new(0));
1278        let counter_clone = attempt_counter.clone();
1279
1280        let result: Result<String> = client
1281            .retry_with_backoff(|| {
1282                let counter = counter_clone.clone();
1283                async move {
1284                    counter.fetch_add(1, Ordering::SeqCst);
1285                    Err(Error::authentication("Invalid API key"))
1286                }
1287            })
1288            .await;
1289
1290        assert!(result.is_err());
1291        assert!(result.unwrap_err().is_authentication());
1292        // Should only attempt once since authentication errors are not retryable
1293        assert_eq!(attempt_counter.load(Ordering::SeqCst), 1);
1294    }
1295
1296    #[tokio::test]
1297    async fn retry_logic_max_retries_exceeded() {
1298        let client = Anthropic {
1299            api_key: "test".to_string(),
1300            client: ReqwestClient::new(),
1301            base_url: "http://localhost".to_string(),
1302            timeout: Duration::from_secs(1),
1303            max_retries: 2,
1304            throughput_ops_sec: 1.0 / 60.0,
1305            reserve_capacity: 1.0 / 60.0,
1306            cached_headers: Arc::new(HeaderMap::new()),
1307        };
1308
1309        let attempt_counter = Arc::new(AtomicUsize::new(0));
1310        let counter_clone = attempt_counter.clone();
1311
1312        let result: Result<String> = client
1313            .retry_with_backoff(|| {
1314                let counter = counter_clone.clone();
1315                async move {
1316                    counter.fetch_add(1, Ordering::SeqCst);
1317                    Err(Error::rate_limit("Always rate limited", Some(1)))
1318                }
1319            })
1320            .await;
1321
1322        assert!(result.is_err());
1323        assert!(result.unwrap_err().is_rate_limit());
1324        // Should attempt max_retries + 1 times (3 total: initial + 2 retries)
1325        assert_eq!(attempt_counter.load(Ordering::SeqCst), 3);
1326    }
1327
1328    #[tokio::test]
1329    async fn error_529_is_retryable() {
1330        // Test that 529 errors are properly mapped to rate_limit and are retryable
1331        let client = Anthropic {
1332            api_key: "test".to_string(),
1333            client: ReqwestClient::new(),
1334            base_url: "http://localhost".to_string(),
1335            timeout: Duration::from_secs(1),
1336            max_retries: 2,
1337            throughput_ops_sec: 1.0 / 60.0,
1338            reserve_capacity: 1.0 / 60.0,
1339            cached_headers: Arc::new(HeaderMap::new()),
1340        };
1341
1342        let attempt_counter = Arc::new(AtomicUsize::new(0));
1343        let counter_clone = attempt_counter.clone();
1344
1345        let result = client
1346            .retry_with_backoff(|| {
1347                let counter = counter_clone.clone();
1348                async move {
1349                    let attempt = counter.fetch_add(1, Ordering::SeqCst);
1350                    match attempt {
1351                        0 | 1 => {
1352                            // Simulate a 529 overloaded error
1353                            Err(Error::api(
1354                                529,
1355                                Some("overloaded_error".to_string()),
1356                                "Overloaded".to_string(),
1357                                None,
1358                            ))
1359                        }
1360                        _ => Ok("success".to_string()),
1361                    }
1362                }
1363            })
1364            .await;
1365
1366        assert!(result.is_ok());
1367        assert_eq!(result.unwrap(), "success");
1368        // Should retry: initial attempt + 2 retries = 3 total
1369        assert_eq!(attempt_counter.load(Ordering::SeqCst), 3);
1370    }
1371
1372    #[test]
1373    fn error_529_mapped_correctly() {
1374        // Test that a 529 API error is correctly identified as retryable
1375        let error =
1376            Error::api(529, Some("overloaded_error".to_string()), "Overloaded".to_string(), None);
1377        assert!(error.is_retryable());
1378
1379        // Test that rate_limit error (which 529 now maps to) is also retryable
1380        let rate_limit_error = Error::rate_limit("Overloaded", Some(5));
1381        assert!(rate_limit_error.is_retryable());
1382    }
1383
1384    #[test]
1385    fn resolve_api_key_regular_value() {
1386        let result = Anthropic::resolve_api_key("sk-test-key-123");
1387        assert!(result.is_ok());
1388        assert_eq!(result.unwrap(), "sk-test-key-123");
1389    }
1390
1391    #[test]
1392    fn resolve_api_key_file_url_absolute() {
1393        let test_dir =
1394            std::env::temp_dir().join(format!("adk_anthropic_test_{}", std::process::id()));
1395        std::fs::create_dir_all(&test_dir).unwrap();
1396        let test_file = test_dir.join("test_api_key.txt");
1397        std::fs::write(&test_file, "sk-test-from-file-123\n").unwrap();
1398
1399        let file_url = format!("file://{}", test_file.display());
1400        let result = Anthropic::resolve_api_key(&file_url);
1401
1402        std::fs::remove_dir_all(&test_dir).unwrap();
1403
1404        assert!(result.is_ok());
1405        assert_eq!(result.unwrap(), "sk-test-from-file-123");
1406    }
1407
1408    #[test]
1409    fn resolve_api_key_file_url_relative() {
1410        let test_file = "test_relative_key.txt";
1411        std::fs::write(test_file, "sk-relative-key-456\n").unwrap();
1412
1413        let file_url = format!("file://{}", test_file);
1414        let result = Anthropic::resolve_api_key(&file_url);
1415
1416        std::fs::remove_file(test_file).unwrap();
1417
1418        assert!(result.is_ok());
1419        assert_eq!(result.unwrap(), "sk-relative-key-456");
1420    }
1421
1422    #[test]
1423    fn resolve_api_key_file_url_nonexistent() {
1424        let result = Anthropic::resolve_api_key("file:///nonexistent/path/to/key.txt");
1425        assert!(result.is_err());
1426
1427        let error = result.unwrap_err();
1428        assert!(error.is_validation());
1429        assert!(format!("{}", error).contains("Failed to read API key from file"));
1430    }
1431
1432    #[test]
1433    fn resolve_api_key_file_url_with_whitespace() {
1434        let test_file = "test_whitespace_key.txt";
1435        std::fs::write(test_file, "  sk-whitespace-key-789  \n  ").unwrap();
1436
1437        let file_url = format!("file://{}", test_file);
1438        let result = Anthropic::resolve_api_key(&file_url);
1439
1440        std::fs::remove_file(test_file).unwrap();
1441
1442        assert!(result.is_ok());
1443        assert_eq!(result.unwrap(), "sk-whitespace-key-789");
1444    }
1445
1446    #[test]
1447    fn client_builder_methods() {
1448        let client = Anthropic::new(Some("test_key".to_string())).unwrap();
1449
1450        // Test builder pattern methods
1451        let configured_client = client
1452            .with_base_url("https://custom.api.com".to_string())
1453            .unwrap()
1454            .with_max_retries(5)
1455            .with_backoff_params(2.0, 1.0);
1456
1457        assert_eq!(configured_client.base_url, "https://custom.api.com");
1458        assert_eq!(configured_client.max_retries, 5);
1459        assert_eq!(configured_client.throughput_ops_sec, 2.0);
1460        assert_eq!(configured_client.reserve_capacity, 1.0);
1461    }
1462
1463    #[test]
1464    fn build_url_default_base() {
1465        let client = Anthropic::new(Some("test_key".to_string())).unwrap();
1466        // Default base URL: https://api.anthropic.com
1467        assert_eq!(client.build_url("messages"), "https://api.anthropic.com/v1/messages");
1468        assert_eq!(
1469            client.build_url("messages/count_tokens"),
1470            "https://api.anthropic.com/v1/messages/count_tokens"
1471        );
1472        assert_eq!(client.build_url("models"), "https://api.anthropic.com/v1/models");
1473    }
1474
1475    #[test]
1476    fn build_url_custom_base_without_trailing_slash() {
1477        let client = Anthropic::new(Some("test_key".to_string()))
1478            .unwrap()
1479            .with_base_url("https://api.minimax.io/anthropic".to_string())
1480            .unwrap();
1481        assert_eq!(client.build_url("messages"), "https://api.minimax.io/anthropic/v1/messages");
1482    }
1483
1484    #[test]
1485    fn build_url_custom_base_with_trailing_slash() {
1486        let client = Anthropic::new(Some("test_key".to_string()))
1487            .unwrap()
1488            .with_base_url("https://api.minimax.io/anthropic/".to_string())
1489            .unwrap();
1490        assert_eq!(client.build_url("messages"), "https://api.minimax.io/anthropic/v1/messages");
1491    }
1492
1493    #[test]
1494    fn build_url_minimax_china() {
1495        let client = Anthropic::new(Some("test_key".to_string()))
1496            .unwrap()
1497            .with_base_url("https://api.minimaxi.com/anthropic".to_string())
1498            .unwrap();
1499        assert_eq!(client.build_url("messages"), "https://api.minimaxi.com/anthropic/v1/messages");
1500        assert_eq!(
1501            client.build_url(&format!("models/{}", "claude-3-opus")),
1502            "https://api.minimaxi.com/anthropic/v1/models/claude-3-opus"
1503        );
1504    }
1505
1506    #[test]
1507    fn with_base_url_accepts_https() {
1508        let client = Anthropic::new(Some("placeholder-api-key".to_string()))
1509            .unwrap()
1510            .with_base_url("https://gateway.example.com/anthropic".to_string())
1511            .unwrap();
1512        assert_eq!(client.base_url, "https://gateway.example.com/anthropic");
1513    }
1514
1515    #[test]
1516    fn with_base_url_allows_loopback_http_for_local_dev() {
1517        for url in ["http://localhost:8080", "http://127.0.0.1:8080", "http://[::1]:8080"] {
1518            let client = Anthropic::new(Some("placeholder-api-key".to_string()))
1519                .unwrap()
1520                .with_base_url(url.to_string())
1521                .unwrap_or_else(|e| panic!("loopback url {url} should be accepted: {e}"));
1522            assert_eq!(client.base_url, url);
1523        }
1524    }
1525
1526    #[test]
1527    fn with_base_url_rejects_cleartext_http() {
1528        let err = Anthropic::new(Some("placeholder-api-key".to_string()))
1529            .unwrap()
1530            .with_base_url("http://gateway.internal.example.com".to_string())
1531            .expect_err("a non-loopback http base URL must be rejected");
1532
1533        assert!(err.is_validation(), "expected a validation error, got {err}");
1534        let message = err.to_string();
1535        assert!(
1536            message.contains("unencrypted"),
1537            "error should explain the cleartext risk, got: {message}"
1538        );
1539        assert!(message.contains("'http'"), "error should name the scheme, got: {message}");
1540    }
1541
1542    #[test]
1543    fn with_base_url_rejects_non_http_schemes_and_garbage() {
1544        for url in ["ftp://files.example.com", "ws://gateway.example.com", "not-a-url"] {
1545            let err = Anthropic::new(Some("placeholder-api-key".to_string()))
1546                .unwrap()
1547                .with_base_url(url.to_string())
1548                .expect_err("non-https, non-loopback base URL must be rejected");
1549            assert!(err.is_validation(), "expected a validation error for {url}, got {err}");
1550        }
1551    }
1552
1553    // The `ANTHROPIC_BASE_URL` tests exercise `resolve_base_url` directly instead
1554    // of mutating the process environment. The environment is process-global, so
1555    // setting an intentionally-rejected value would race against every other test
1556    // in this binary that calls `Anthropic::new`. `resolve_base_url` is the exact
1557    // code path `Anthropic::new` uses for the env value, so nothing is lost.
1558
1559    #[test]
1560    fn env_base_url_absent_falls_back_to_default() {
1561        let resolved = Anthropic::resolve_base_url(None).unwrap();
1562        assert_eq!(resolved, DEFAULT_API_URL);
1563    }
1564
1565    #[test]
1566    fn env_base_url_accepts_https() {
1567        let resolved =
1568            Anthropic::resolve_base_url(Some("https://gateway.example.com/anthropic".to_string()))
1569                .unwrap();
1570        assert_eq!(resolved, "https://gateway.example.com/anthropic");
1571    }
1572
1573    #[test]
1574    fn env_base_url_allows_loopback_http_for_local_dev() {
1575        for url in ["http://localhost:11434", "http://127.0.0.1:11434", "http://[::1]:11434"] {
1576            let resolved = Anthropic::resolve_base_url(Some(url.to_string()))
1577                .unwrap_or_else(|e| panic!("loopback url {url} should be accepted: {e}"));
1578            assert_eq!(resolved, url);
1579        }
1580    }
1581
1582    #[test]
1583    fn env_base_url_rejects_cleartext_http() {
1584        let err =
1585            Anthropic::resolve_base_url(Some("http://gateway.internal.example.com".to_string()))
1586                .expect_err("a non-loopback http ANTHROPIC_BASE_URL must be rejected");
1587
1588        assert!(err.is_validation(), "expected a validation error, got {err}");
1589        let message = err.to_string();
1590        assert!(
1591            message.contains("unencrypted"),
1592            "error should explain the cleartext risk, got: {message}"
1593        );
1594        assert!(message.contains("'http'"), "error should name the scheme, got: {message}");
1595    }
1596
1597    #[test]
1598    fn env_base_url_rejects_non_http_schemes_and_garbage() {
1599        for url in ["ftp://files.example.com", "ws://gateway.example.com", "not-a-url"] {
1600            let err = Anthropic::resolve_base_url(Some(url.to_string()))
1601                .expect_err("non-https, non-loopback ANTHROPIC_BASE_URL must be rejected");
1602            assert!(err.is_validation(), "expected a validation error for {url}, got {err}");
1603        }
1604    }
1605
1606    #[test]
1607    fn with_base_url_and_timeout_rejects_cleartext_http() {
1608        let err = Anthropic::new(Some("placeholder-api-key".to_string()))
1609            .unwrap()
1610            .with_base_url_and_timeout(
1611                "http://gateway.internal.example.com".to_string(),
1612                Duration::from_secs(5),
1613            )
1614            .expect_err("a non-loopback http base URL must be rejected");
1615        assert!(err.is_validation(), "expected a validation error, got {err}");
1616    }
1617
1618    #[test]
1619    fn client_timeout_configuration() {
1620        let client = Anthropic::new(Some("test_key".to_string())).unwrap();
1621        let timeout = Duration::from_secs(30);
1622
1623        let configured_client = client.with_timeout(timeout).unwrap();
1624        assert_eq!(configured_client.timeout, timeout);
1625    }
1626
1627    #[test]
1628    fn client_cached_headers_performance() {
1629        let client = Anthropic::new(Some("test_key".to_string())).unwrap();
1630
1631        // Test that headers are cached and cloning is cheap
1632        let headers1 = client.default_headers();
1633        let headers2 = client.default_headers();
1634
1635        assert_eq!(headers1.len(), headers2.len());
1636        assert!(headers1.contains_key("x-api-key"));
1637        assert!(headers1.contains_key("anthropic-version"));
1638        assert!(headers1.contains_key("content-type"));
1639    }
1640
1641    #[test]
1642    fn request_error_mapping() {
1643        let client = Anthropic::new(Some("test_key".to_string())).unwrap();
1644
1645        // Test different types of reqwest errors are mapped correctly
1646        // Note: These are unit tests for the mapping logic, not integration tests
1647        let _timeout = Duration::from_secs(30);
1648        assert_eq!(client.timeout, DEFAULT_TIMEOUT); // Should use default initially
1649    }
1650
1651    #[tokio::test]
1652    async fn concurrent_retry_safety() {
1653        use std::sync::atomic::{AtomicUsize, Ordering};
1654        use tokio::spawn;
1655
1656        let client = Anthropic {
1657            api_key: "test".to_string(),
1658            client: ReqwestClient::new(),
1659            base_url: "http://localhost".to_string(),
1660            timeout: Duration::from_secs(1),
1661            max_retries: 1,
1662            throughput_ops_sec: 1.0,
1663            reserve_capacity: 1.0,
1664            cached_headers: Arc::new(HeaderMap::new()),
1665        };
1666
1667        let attempt_counter = Arc::new(AtomicUsize::new(0));
1668        let mut handles = vec![];
1669
1670        // Spawn multiple concurrent retry operations
1671        for _ in 0..3 {
1672            let client_clone = client.clone();
1673            let counter_clone = attempt_counter.clone();
1674
1675            let handle = spawn(async move {
1676                client_clone
1677                    .retry_with_backoff(|| {
1678                        let counter = counter_clone.clone();
1679                        async move {
1680                            counter.fetch_add(1, Ordering::SeqCst);
1681                            Ok::<String, Error>("success".to_string())
1682                        }
1683                    })
1684                    .await
1685            });
1686            handles.push(handle);
1687        }
1688
1689        // Wait for all operations to complete
1690        for handle in handles {
1691            let result = handle.await.unwrap();
1692            assert!(result.is_ok());
1693        }
1694
1695        // Verify all operations executed
1696        assert_eq!(attempt_counter.load(Ordering::SeqCst), 3);
1697    }
1698}