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::{InsecureHttp, 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_json_sse, process_sse};
24use crate::types::{
25    BatchRequest, BatchResultItem, FileObject, Message, MessageBatch, MessageCountTokensParams,
26    MessageCreateParams, MessageStreamEvent, MessageTokensCount, ModelInfo, ModelListParams,
27    ModelListResponse, PaginatedList, ServerFallbackMessage, ServerFallbackRequest,
28    ServerFallbackStreamEvent, SkillObject, ThinkingConfig,
29};
30
31use base64::Engine as _;
32
33/// Simple base64 encoding for skill content.
34fn base64_encode(data: &[u8]) -> String {
35    base64::engine::general_purpose::STANDARD.encode(data)
36}
37
38/// A stream wrapper that logs events and the final message through a [`ClientLogger`].
39///
40/// This stream passes through all events from the underlying [`AccumulatingStream`],
41/// logging each event as it occurs and logging the final reconstructed message
42/// when the stream completes.
43pub struct LoggingStream<'a> {
44    inner: AccumulatingStream,
45    logger: &'a dyn ClientLogger,
46    receiver: Option<tokio::sync::oneshot::Receiver<Result<Message>>>,
47}
48
49impl<'a> LoggingStream<'a> {
50    /// Create a new logging stream wrapper.
51    fn new(
52        inner: AccumulatingStream,
53        receiver: tokio::sync::oneshot::Receiver<Result<Message>>,
54        logger: &'a dyn ClientLogger,
55    ) -> Self {
56        Self { inner, logger, receiver: Some(receiver) }
57    }
58}
59
60impl Stream for LoggingStream<'_> {
61    type Item = Result<MessageStreamEvent>;
62
63    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
64        let inner = Pin::new(&mut self.inner);
65        match inner.poll_next(cx) {
66            Poll::Ready(Some(Ok(event))) => {
67                self.logger.log_stream_event(&event);
68                Poll::Ready(Some(Ok(event)))
69            }
70            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
71            Poll::Ready(None) => {
72                // Stream ended - try to get the accumulated message
73                if let Some(mut receiver) = self.receiver.take()
74                    && let Ok(Ok(ref message)) = receiver.try_recv()
75                {
76                    self.logger.log_stream_message(message);
77                }
78                Poll::Ready(None)
79            }
80            Poll::Pending => Poll::Pending,
81        }
82    }
83}
84
85const DEFAULT_API_URL: &str = "https://api.anthropic.com";
86const ANTHROPIC_API_VERSION: &str = "2023-06-01";
87const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
88const STRUCTURED_OUTPUTS_BETA: &str = "structured-outputs-2025-11-13";
89const SERVER_FALLBACK_BETA: &str = "server-side-fallback-2026-07-01";
90/// Opt-in named when a base URL given in code is rejected for plain HTTP.
91const CODE_INSECURE_HTTP_OPT_IN: &str =
92    "call `allow_insecure_http()` on the client before setting the base URL";
93/// Opt-in named when `ANTHROPIC_BASE_URL` is rejected for plain HTTP.
94const ENV_INSECURE_HTTP_OPT_IN: &str =
95    "set ANTHROPIC_ALLOW_INSECURE_HTTP=1 alongside ANTHROPIC_BASE_URL";
96
97/// Client for the Anthropic API with performance optimizations.
98///
99/// # Timeouts
100///
101/// | Request kind | Bound |
102/// |--------------|-------|
103/// | Non-streaming | [`with_timeout`](Self::with_timeout) covers the whole request (default 60 seconds) |
104/// | Streaming, until response headers | the same timeout |
105/// | Streaming body | a 30-second inactivity timeout between chunks, plus the optional total bound from [`with_stream_timeout`](Self::with_stream_timeout) |
106///
107/// The `Debug` output redacts the API key.
108#[derive(Clone)]
109pub struct Anthropic {
110    api_key: String,
111    client: ReqwestClient,
112    /// Client without a total timeout, so long streams are not cut off mid-body.
113    stream_client: ReqwestClient,
114    base_url: String,
115    timeout: Duration,
116    stream_timeout: Option<Duration>,
117    max_retries: usize,
118    throughput_ops_sec: f64,
119    reserve_capacity: f64,
120    /// Cached headers for performance - Arc for cheap cloning
121    cached_headers: Arc<HeaderMap>,
122    /// Whether `with_base_url` accepts `http://` to a non-loopback host.
123    allow_insecure_http: bool,
124}
125
126impl std::fmt::Debug for Anthropic {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.debug_struct("Anthropic")
129            .field("api_key", &"[REDACTED]")
130            .field("client", &self.client)
131            .field("stream_client", &self.stream_client)
132            .field("base_url", &self.base_url)
133            .field("timeout", &self.timeout)
134            .field("stream_timeout", &self.stream_timeout)
135            .field("max_retries", &self.max_retries)
136            .field("throughput_ops_sec", &self.throughput_ops_sec)
137            .field("reserve_capacity", &self.reserve_capacity)
138            .field("cached_headers", &self.cached_headers)
139            .field("allow_insecure_http", &self.allow_insecure_http)
140            .finish()
141    }
142}
143
144impl Anthropic {
145    /// Build the HTTP client. `total_timeout` of `None` leaves response bodies
146    /// unbounded, which the streaming client relies on.
147    fn build_http_client(
148        total_timeout: Option<Duration>,
149        connect_timeout: Duration,
150    ) -> Result<ReqwestClient> {
151        let mut builder = ReqwestClient::builder()
152            .connect_timeout(connect_timeout)
153            .redirect(reqwest::redirect::Policy::none())
154            .pool_max_idle_per_host(10) // Connection pooling optimization
155            .pool_idle_timeout(Duration::from_secs(90))
156            .tcp_keepalive(Duration::from_secs(60));
157        if let Some(timeout) = total_timeout {
158            builder = builder.timeout(timeout);
159        }
160        builder.build().map_err(|e| {
161            Error::http_client(format!("Failed to build HTTP client: {e}"), Some(Box::new(e)))
162        })
163    }
164
165    /// Resolve an API key value, handling file:// URLs
166    fn resolve_api_key(key_value: &str) -> Result<String> {
167        if let Some(stripped) = key_value.strip_prefix("file://") {
168            // Handle file:// URLs
169            let path = if stripped.starts_with('/') {
170                // Absolute path: file:///root/.env -> /root/.env
171                stripped.to_string()
172            } else {
173                // Relative path: file://../foo -> ../foo
174                stripped.to_string()
175            };
176
177            fs::read_to_string(&path).map(|content| content.trim().to_string()).map_err(|e| {
178                Error::validation(
179                    format!("Failed to read API key from file '{}': {}", path, e),
180                    Some("api_key".to_string()),
181                )
182            })
183        } else {
184            // Regular API key value
185            Ok(key_value.to_string())
186        }
187    }
188
189    /// Resolve the effective base URL from optional `ANTHROPIC_BASE_URL` and
190    /// `ANTHROPIC_ALLOW_INSECURE_HTTP` values.
191    ///
192    /// A value supplied through the environment is held to the same rule as one
193    /// supplied through [`Anthropic::with_base_url`]: every request attaches the
194    /// API key, so an unencrypted endpoint would leak the credential. The
195    /// acknowledgement for plain HTTP comes from the same source as the URL, so
196    /// an `insecure_http_env` of `1` or `true` (case-insensitive) admits a
197    /// non-loopback `http://` value. When no URL is supplied the default
198    /// Anthropic API URL is used.
199    ///
200    /// # Errors
201    ///
202    /// Returns a validation error when the supplied value is not `https://`, not
203    /// `http://` with a loopback host, and not acknowledged `http://`.
204    fn resolve_base_url(
205        env_value: Option<String>,
206        insecure_http_env: Option<String>,
207    ) -> Result<String> {
208        match env_value {
209            Some(value) => {
210                let acknowledged = insecure_http_env
211                    .as_deref()
212                    .map(str::trim)
213                    .is_some_and(|flag| flag == "1" || flag.eq_ignore_ascii_case("true"));
214                let insecure_http = if acknowledged {
215                    InsecureHttp::Acknowledged
216                } else {
217                    InsecureHttp::Rejected { opt_in: Some(ENV_INSECURE_HTTP_OPT_IN) }
218                };
219                validate_base_url(&value, insecure_http)?;
220                Ok(value)
221            }
222            None => Ok(DEFAULT_API_URL.to_string()),
223        }
224    }
225
226    /// Create a new Anthropic client.
227    ///
228    /// The API key can be provided directly or read from the `ANTHROPIC_API_KEY`
229    /// environment variable. If the value starts with `"file://"`, it will be
230    /// treated as a file path and the API key will be read from that file.
231    ///
232    /// The base URL is resolved from the `ANTHROPIC_BASE_URL` environment
233    /// variable. If not set, the default Anthropic API URL is used.
234    ///
235    /// `ANTHROPIC_BASE_URL` may name a plain `http://` endpoint on a non-loopback
236    /// host, such as an internal gateway, only when `ANTHROPIC_ALLOW_INSECURE_HTTP`
237    /// is set to `1` or `true` (case-insensitive). The client then logs a warning
238    /// and sends the API key unencrypted. `ANTHROPIC_ALLOW_INSECURE_HTTP` applies
239    /// to the environment URL only; a URL given in code needs
240    /// [`allow_insecure_http`](Self::allow_insecure_http).
241    ///
242    /// # Errors
243    ///
244    /// Returns a validation error when `ANTHROPIC_BASE_URL` is set to an
245    /// endpoint that would transmit the API key in cleartext — anything other
246    /// than `https://`, or `http://` with a loopback host (`localhost`,
247    /// `127.0.0.1`, `[::1]`), unless `ANTHROPIC_ALLOW_INSECURE_HTTP`
248    /// acknowledges an `http://` endpoint. A misconfigured environment fails
249    /// loudly rather than silently falling back to the default URL.
250    pub fn new(api_key: Option<String>) -> Result<Self> {
251        let api_key = match api_key {
252            Some(key) => Self::resolve_api_key(&key)?,
253            None => {
254                let env_key = env::var("ANTHROPIC_API_KEY").map_err(|_| {
255                    Error::authentication(
256                        "API key not provided and ANTHROPIC_API_KEY environment variable not set",
257                    )
258                })?;
259                Self::resolve_api_key(&env_key)?
260            }
261        };
262
263        let base_url = Self::resolve_base_url(
264            env::var("ANTHROPIC_BASE_URL").ok(),
265            env::var("ANTHROPIC_ALLOW_INSECURE_HTTP").ok(),
266        )?;
267        Self::from_values(api_key, base_url)
268    }
269
270    /// Create a client with an explicit API key and base URL.
271    ///
272    /// Unlike [`Anthropic::new`], this constructor reads neither `ANTHROPIC_API_KEY`
273    /// nor `ANTHROPIC_BASE_URL`. A `file://` API key is read from that file, as in
274    /// [`Anthropic::new`].
275    ///
276    /// The base URL is validated here, so this constructor cannot take a plain
277    /// `http://` URL to a non-loopback host. For one, construct the client with an
278    /// `https://` URL, then call [`allow_insecure_http`](Self::allow_insecure_http)
279    /// followed by [`with_base_url`](Self::with_base_url).
280    ///
281    /// # Errors
282    ///
283    /// Returns a validation error when `base_url` is not `https://` and not
284    /// `http://` with a loopback host, or when a `file://` API key cannot be read.
285    ///
286    /// # Example
287    ///
288    /// ```
289    /// use adk_anthropic::Anthropic;
290    ///
291    /// let client = Anthropic::new_with_base_url("sk-ant-key", "https://proxy.example.com")?;
292    /// # Ok::<(), adk_anthropic::Error>(())
293    /// ```
294    pub fn new_with_base_url(
295        api_key: impl Into<String>,
296        base_url: impl Into<String>,
297    ) -> Result<Self> {
298        let base_url = base_url.into();
299        validate_base_url(
300            &base_url,
301            InsecureHttp::Rejected { opt_in: Some(CODE_INSECURE_HTTP_OPT_IN) },
302        )?;
303        Self::from_values(Self::resolve_api_key(&api_key.into())?, base_url)
304    }
305
306    fn from_values(api_key: String, base_url: String) -> Result<Self> {
307        let timeout = DEFAULT_TIMEOUT;
308        let client = Self::build_http_client(Some(timeout), timeout)?;
309        let stream_client = Self::build_http_client(None, timeout)?;
310
311        // Pre-build headers for performance
312        let cached_headers = Arc::new(Self::build_default_headers(&api_key)?);
313
314        Ok(Self {
315            api_key,
316            client,
317            stream_client,
318            base_url,
319            timeout,
320            stream_timeout: None,
321            max_retries: 3,
322            throughput_ops_sec: 1.0 / 60.0,
323            reserve_capacity: 1.0 / 60.0,
324            cached_headers,
325            allow_insecure_http: false,
326        })
327    }
328
329    /// Create an Anthropic client authenticated with a bearer token.
330    ///
331    /// Unlike [`Anthropic::new`], this constructor does not require an API key or
332    /// the `ANTHROPIC_API_KEY` environment variable. Requests carry
333    /// `Authorization: Bearer <token>` and omit `x-api-key`.
334    ///
335    /// # Errors
336    ///
337    /// Returns a validation error when the token is empty or cannot be encoded
338    /// as an HTTP header value.
339    pub fn new_with_auth_token(auth_token: impl Into<String>) -> Result<Self> {
340        Self::new(Some(String::new()))?.with_auth_token(auth_token)
341    }
342
343    /// Replace API-key authentication with a bearer token.
344    ///
345    /// The resulting client omits `x-api-key` from every request.
346    ///
347    /// # Errors
348    ///
349    /// Returns a validation error when the token is empty or cannot be encoded
350    /// as an HTTP header value.
351    pub fn with_auth_token(mut self, auth_token: impl Into<String>) -> Result<Self> {
352        let auth_token = auth_token.into();
353        if auth_token.trim().is_empty() {
354            return Err(Error::validation(
355                "Auth token cannot be empty".to_string(),
356                Some("auth_token".to_string()),
357            ));
358        }
359
360        let mut headers = (*self.cached_headers).clone();
361        headers.remove("x-api-key");
362        let mut value =
363            HeaderValue::from_str(&format!("Bearer {auth_token}")).map_err(|error| {
364                Error::validation(
365                    format!("Invalid auth token format: {error}"),
366                    Some("auth_token".to_string()),
367                )
368            })?;
369        value.set_sensitive(true);
370        headers.insert(header::AUTHORIZATION, value);
371        self.api_key.clear();
372        self.cached_headers = Arc::new(headers);
373        Ok(self)
374    }
375
376    /// Override the `anthropic-version` header used by this client.
377    ///
378    /// # Errors
379    ///
380    /// Returns a validation error when the version is empty or cannot be
381    /// encoded as an HTTP header value.
382    pub fn with_api_version(mut self, api_version: impl Into<String>) -> Result<Self> {
383        let api_version = api_version.into();
384        if api_version.trim().is_empty() {
385            return Err(Error::validation(
386                "API version cannot be empty".to_string(),
387                Some("api_version".to_string()),
388            ));
389        }
390
391        let mut headers = (*self.cached_headers).clone();
392        let value = HeaderValue::from_str(&api_version).map_err(|error| {
393            Error::validation(
394                format!("Invalid API version format: {error}"),
395                Some("api_version".to_string()),
396            )
397        })?;
398        headers.insert("anthropic-version", value);
399        self.cached_headers = Arc::new(headers);
400        Ok(self)
401    }
402
403    /// Allow a plain `http://` base URL on a non-loopback host.
404    ///
405    /// Use this for a trusted internal gateway that is reachable only over plain
406    /// HTTP. Every request attaches the API key, which then crosses the network
407    /// unencrypted. Loopback `http://` URLs and `https://` URLs need no opt-in.
408    ///
409    /// [`with_base_url`](Self::with_base_url) and
410    /// [`with_base_url_and_timeout`](Self::with_base_url_and_timeout) validate the
411    /// URL when they are called, so this method must be called **before** them.
412    /// Accepting a non-loopback `http://` URL logs a warning with the host.
413    ///
414    /// The opt-in applies to URLs given in code. A URL from `ANTHROPIC_BASE_URL`
415    /// is acknowledged by `ANTHROPIC_ALLOW_INSECURE_HTTP` instead; see
416    /// [`new`](Self::new).
417    ///
418    /// # Example
419    ///
420    /// ```
421    /// use adk_anthropic::Anthropic;
422    ///
423    /// let client = Anthropic::new(Some("placeholder-api-key".to_string()))?
424    ///     .allow_insecure_http()
425    ///     .with_base_url("http://10.60.1.20:8080/api/v1/llm/anthropic".to_string())?;
426    /// assert_eq!(client.base_url(), "http://10.60.1.20:8080/api/v1/llm/anthropic");
427    /// # Ok::<(), adk_anthropic::Error>(())
428    /// ```
429    pub fn allow_insecure_http(mut self) -> Self {
430        self.allow_insecure_http = true;
431        self
432    }
433
434    /// Set a custom base URL for this client.
435    ///
436    /// This method allows you to specify a different API endpoint for the client.
437    /// The base URL should be the root URL without the `/v1/` suffix - this will
438    /// be added automatically when constructing request URLs.
439    ///
440    /// The URL is validated when this method is called. To use a plain `http://`
441    /// URL on a non-loopback host, call
442    /// [`allow_insecure_http`](Self::allow_insecure_http) first.
443    ///
444    /// # Errors
445    ///
446    /// Every request made by this client attaches the Anthropic API key, so the
447    /// base URL must be encrypted. Returns a validation error unless the URL uses
448    /// `https://`, or `http://` with a loopback host (`localhost`, `127.0.0.1`,
449    /// `[::1]`) for local development, or `http://` after
450    /// [`allow_insecure_http`](Self::allow_insecure_http).
451    ///
452    /// # Examples
453    ///
454    /// ```
455    /// # use adk_anthropic::Anthropic;
456    /// // For Anthropic's API (default)
457    /// let client = Anthropic::new(Some("placeholder-api-key".to_string()))?
458    ///     .with_base_url("https://api.anthropic.com".to_string())?;
459    ///
460    /// // For Minimax (international)
461    /// let client = Anthropic::new(Some("placeholder-api-key".to_string()))?
462    ///     .with_base_url("https://api.minimax.io/anthropic".to_string())?;
463    ///
464    /// // For Minimax (China)
465    /// let client = Anthropic::new(Some("placeholder-api-key".to_string()))?
466    ///     .with_base_url("https://api.minimaxi.com/anthropic".to_string())?;
467    /// # Ok::<(), adk_anthropic::Error>(())
468    /// ```
469    pub fn with_base_url(mut self, base_url: String) -> Result<Self> {
470        let insecure_http = if self.allow_insecure_http {
471            InsecureHttp::Acknowledged
472        } else {
473            InsecureHttp::Rejected { opt_in: Some(CODE_INSECURE_HTTP_OPT_IN) }
474        };
475        validate_base_url(&base_url, insecure_http)?;
476        self.base_url = base_url;
477        Ok(self)
478    }
479
480    /// Return the effective API base URL used for message requests.
481    pub fn base_url(&self) -> &str {
482        &self.base_url
483    }
484
485    /// Set a custom timeout for this client.
486    ///
487    /// The timeout bounds a whole non-streaming request, and the wait for the
488    /// response headers of a streaming request. A streamed body is not bounded
489    /// by it — see [`with_stream_timeout`](Self::with_stream_timeout).
490    ///
491    /// # Errors
492    ///
493    /// Returns an HTTP client error when the underlying client cannot be built.
494    pub fn with_timeout(mut self, timeout: Duration) -> Result<Self> {
495        self.timeout = timeout;
496        self.client = Self::build_http_client(Some(timeout), timeout)?;
497        self.stream_client = Self::build_http_client(None, timeout)?;
498        Ok(self)
499    }
500
501    /// Bound the total duration of each streaming request, body included.
502    ///
503    /// Streams have no total bound by default: a long generation keeps running
504    /// for as long as the server keeps sending data, and a stalled stream fails
505    /// after 30 seconds without data. `None` restores that default.
506    ///
507    /// # Example
508    ///
509    /// ```
510    /// use std::time::Duration;
511    /// use adk_anthropic::Anthropic;
512    ///
513    /// let client = Anthropic::new(Some("placeholder-api-key".to_string()))?
514    ///     .with_stream_timeout(Some(Duration::from_secs(600)));
515    /// # Ok::<(), adk_anthropic::Error>(())
516    /// ```
517    pub fn with_stream_timeout(mut self, stream_timeout: Option<Duration>) -> Self {
518        self.stream_timeout = stream_timeout;
519        self
520    }
521
522    /// Set the maximum number of retries for this client.
523    ///
524    /// This method allows you to specify how many times to retry failed requests.
525    pub fn with_max_retries(mut self, max_retries: usize) -> Self {
526        self.max_retries = max_retries;
527        self
528    }
529
530    /// Get the API key being used by this client.
531    pub fn api_key(&self) -> &str {
532        &self.api_key
533    }
534
535    /// Set the backoff parameters for this client.
536    ///
537    /// This method allows you to configure the exponential backoff algorithm.
538    pub fn with_backoff_params(mut self, throughput_ops_sec: f64, reserve_capacity: f64) -> Self {
539        self.throughput_ops_sec = throughput_ops_sec;
540        self.reserve_capacity = reserve_capacity;
541        self
542    }
543
544    /// Set both a custom base URL and timeout for this client.
545    ///
546    /// This is a convenience method that chains [`with_base_url`](Self::with_base_url)
547    /// and [`with_timeout`](Self::with_timeout). The URL is validated when this
548    /// method is called, so a plain `http://` URL on a non-loopback host needs
549    /// [`allow_insecure_http`](Self::allow_insecure_http) to be called first.
550    ///
551    /// # Errors
552    ///
553    /// Returns the validation error of [`with_base_url`](Self::with_base_url), or
554    /// an HTTP client error when the underlying client cannot be built.
555    ///
556    /// # Example
557    ///
558    /// ```
559    /// use std::time::Duration;
560    ///
561    /// use adk_anthropic::Anthropic;
562    ///
563    /// let client = Anthropic::new(Some("placeholder-api-key".to_string()))?
564    ///     .allow_insecure_http()
565    ///     .with_base_url_and_timeout(
566    ///         "http://gateway.corp.internal/anthropic".to_string(),
567    ///         Duration::from_secs(30),
568    ///     )?;
569    /// # let _ = client;
570    /// # Ok::<(), adk_anthropic::Error>(())
571    /// ```
572    pub fn with_base_url_and_timeout(self, base_url: String, timeout: Duration) -> Result<Self> {
573        self.with_base_url(base_url)?.with_timeout(timeout)
574    }
575
576    /// Build default headers for API requests (static method for initialization).
577    fn build_default_headers(api_key: &str) -> Result<HeaderMap> {
578        let mut headers = HeaderMap::new();
579        headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
580        headers.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
581        let mut api_key_value = HeaderValue::from_str(api_key).map_err(|e| {
582            Error::validation(format!("Invalid API key format: {e}"), Some("api_key".to_string()))
583        })?;
584        // Keeps the key out of `Debug` output of the header map and of request logs.
585        api_key_value.set_sensitive(true);
586        headers.insert("x-api-key", api_key_value);
587        headers.insert("anthropic-version", HeaderValue::from_static(ANTHROPIC_API_VERSION));
588        Ok(headers)
589    }
590
591    /// Get cached headers for performance (no allocation needed).
592    fn default_headers(&self) -> HeaderMap {
593        (*self.cached_headers).clone()
594    }
595
596    /// Adds default HTTP headers, replacing defaults with the same name.
597    ///
598    /// Per-request replacement headers still take precedence. Automatically
599    /// selected beta headers continue to be derived from message parameters. The
600    /// headers are stored on the existing client rather than rebuilding it, so this
601    /// method cannot fail and returns `Self`.
602    ///
603    /// # Example
604    ///
605    /// ```no_run
606    /// use adk_anthropic::Anthropic;
607    /// use reqwest::header::{HeaderMap, HeaderValue};
608    /// let mut headers = HeaderMap::new();
609    /// headers.insert("x-session-id", HeaderValue::from_static("conversation-1"));
610    /// let client = Anthropic::new(Some("api-key".into()))?.with_default_headers(headers);
611    /// # Ok::<(), adk_anthropic::Error>(())
612    /// ```
613    #[must_use]
614    pub fn with_default_headers(mut self, headers: HeaderMap) -> Self {
615        Arc::make_mut(&mut self.cached_headers).extend(headers);
616        self
617    }
618
619    /// Return a copy of the headers this client normally sends.
620    ///
621    /// Callers can modify this map and pass it to
622    /// [`Anthropic::send_with_headers`] or [`Anthropic::stream_with_headers`].
623    /// Those methods use replacement semantics, so the supplied map is sent
624    /// instead of these defaults.
625    pub fn default_headers_for_request(&self) -> HeaderMap {
626        self.default_headers()
627    }
628
629    /// Build a full endpoint URL from the base URL and endpoint path.
630    ///
631    /// This method handles trailing slashes gracefully and always inserts `/v1/`
632    /// between the base URL and endpoint path. This allows the base URL to be
633    /// specified without requiring a specific format (with or without trailing slash,
634    /// with or without `/v1/` suffix).
635    ///
636    /// # Examples
637    ///
638    /// - Base: `https://api.anthropic.com`, endpoint: `messages` → `https://api.anthropic.com/v1/messages`
639    /// - Base: `https://api.minimax.io/anthropic`, endpoint: `messages` → `https://api.minimax.io/anthropic/v1/messages`
640    /// - Base: `https://example.com/`, endpoint: `models` → `https://example.com/v1/models`
641    fn build_url(&self, endpoint: &str) -> String {
642        let base = self.base_url.trim_end_matches('/');
643        format!("{}/v1/{}", base, endpoint)
644    }
645
646    /// Retry wrapper that implements exponential backoff with header-based retry-after
647    async fn retry_with_backoff<F, Fut, T>(&self, operation: F) -> Result<T>
648    where
649        F: Fn() -> Fut,
650        Fut: std::future::Future<Output = Result<T>>,
651    {
652        let backoff = ExponentialBackoff::new(self.throughput_ops_sec, self.reserve_capacity);
653        let mut last_error = None;
654
655        for attempt in 0..=self.max_retries {
656            match operation().await {
657                Ok(result) => return Ok(result),
658                Err(error) => {
659                    // Check if error is retryable
660                    if !error.is_retryable() {
661                        return Err(error);
662                    }
663
664                    // Don't sleep on the last attempt
665                    if attempt == self.max_retries {
666                        last_error = Some(error);
667                        break;
668                    }
669
670                    // Calculate backoff duration
671                    let exp_backoff_duration = backoff.next();
672
673                    // Get retry-after from error if available
674                    let header_backoff_duration = match &error {
675                        Error::RateLimit { retry_after: Some(seconds), .. } => {
676                            Some(Duration::from_secs(*seconds))
677                        }
678                        Error::ServiceUnavailable { retry_after: Some(seconds), .. } => {
679                            Some(Duration::from_secs(*seconds))
680                        }
681                        _ => None,
682                    };
683
684                    // Take the maximum of exponential backoff and header-based backoff
685                    let sleep_duration = match header_backoff_duration {
686                        Some(header_duration) => exp_backoff_duration.max(header_duration),
687                        None => exp_backoff_duration,
688                    };
689
690                    CLIENT_REQUEST_RETRIES.click();
691                    CLIENT_RETRY_BACKOFF.add(sleep_duration.as_secs_f64());
692                    sleep(sleep_duration).await;
693                    last_error = Some(error);
694                }
695            }
696        }
697
698        Err(last_error
699            .unwrap_or_else(|| Error::unknown("Failed after retries without capturing error")))
700    }
701
702    /// Process API response errors and convert to our Error type
703    async fn process_error_response(response: Response) -> Error {
704        let status = response.status();
705        let status_code = status.as_u16();
706
707        // Get headers we might need for error processing
708        let request_id = response
709            .headers()
710            .get("x-request-id")
711            .and_then(|val| val.to_str().ok())
712            .map(String::from);
713
714        let retry_after = response
715            .headers()
716            .get("retry-after")
717            .and_then(|val| val.to_str().ok())
718            .and_then(|val| val.parse::<u64>().ok());
719
720        // Try to parse error response body
721        #[derive(Deserialize)]
722        struct ErrorResponse {
723            error: Option<ErrorDetail>,
724        }
725
726        #[derive(Deserialize)]
727        struct ErrorDetail {
728            #[serde(rename = "type")]
729            error_type: Option<String>,
730            message: Option<String>,
731            param: Option<String>,
732        }
733
734        let error_body = match response.text().await {
735            Ok(body) => body,
736            Err(e) => {
737                return Error::http_client(
738                    format!("Failed to read error response: {e}"),
739                    Some(Box::new(e)),
740                );
741            }
742        };
743
744        // Try to parse as JSON first
745        let parsed_error = serde_json::from_str::<ErrorResponse>(&error_body).ok();
746        let error_type =
747            parsed_error.as_ref().and_then(|e| e.error.as_ref()).and_then(|e| e.error_type.clone());
748        let error_message = parsed_error
749            .as_ref()
750            .and_then(|e| e.error.as_ref())
751            .and_then(|e| e.message.clone())
752            .unwrap_or_else(|| error_body.clone());
753        let error_param =
754            parsed_error.as_ref().and_then(|e| e.error.as_ref()).and_then(|e| e.param.clone());
755
756        // Map HTTP status code to appropriate error type
757        match status_code {
758            400 => Error::bad_request(error_message, error_param),
759            401 => Error::authentication(error_message),
760            403 => Error::permission(error_message),
761            404 => Error::not_found(error_message, None, None),
762            408 => Error::timeout(error_message, None),
763            429 => Error::rate_limit(error_message, retry_after),
764            500 => Error::internal_server(error_message, request_id),
765            502..=504 => Error::service_unavailable(error_message, retry_after),
766            529 => Error::rate_limit(error_message, retry_after),
767            _ => Error::api(status_code, error_type, error_message, request_id),
768        }
769    }
770
771    /// Convert reqwest errors to appropriate Error types
772    fn map_request_error(&self, e: reqwest::Error) -> Error {
773        if e.is_timeout() {
774            Error::timeout(format!("Request timed out: {e}"), Some(self.timeout.as_secs_f64()))
775        } else if e.is_connect() {
776            Error::connection(format!("Connection error: {e}"), Some(Box::new(e)))
777        } else {
778            Error::http_client(format!("Request failed: {e}"), Some(Box::new(e)))
779        }
780    }
781
782    /// Execute a POST request with error handling
783    async fn execute_post_request<T: serde::de::DeserializeOwned>(
784        &self,
785        url: &str,
786        body: &impl serde::Serialize,
787        headers: Option<HeaderMap>,
788    ) -> Result<T> {
789        let headers = headers.unwrap_or_else(|| self.default_headers());
790
791        let response = self
792            .client
793            .post(url)
794            .headers(headers)
795            .json(body)
796            .send()
797            .await
798            .map_err(|e| self.map_request_error(e))?;
799
800        if !response.status().is_success() {
801            return Err(Self::process_error_response(response).await);
802        }
803
804        response.json::<T>().await.map_err(|e| {
805            Error::serialization(format!("Failed to parse response: {e}"), Some(Box::new(e)))
806        })
807    }
808
809    /// Send a streaming POST request on the client that has no total timeout.
810    ///
811    /// The configured timeout bounds the wait for the response headers, the SSE
812    /// layer bounds inactivity in the body, and `stream_timeout`, when set,
813    /// bounds the whole request.
814    async fn send_stream_request(
815        &self,
816        url: &str,
817        headers: HeaderMap,
818        body: &impl serde::Serialize,
819    ) -> Result<Response> {
820        let mut request = self.stream_client.post(url).headers(headers).json(body);
821        if let Some(stream_timeout) = self.stream_timeout {
822            request = request.timeout(stream_timeout);
823        }
824
825        let response = tokio::time::timeout(self.timeout, request.send())
826            .await
827            .map_err(|_| {
828                Error::timeout(
829                    format!(
830                        "Timed out after {} seconds waiting for the streaming response headers",
831                        self.timeout.as_secs_f64()
832                    ),
833                    Some(self.timeout.as_secs_f64()),
834                )
835            })?
836            .map_err(|e| self.map_request_error(e))?;
837
838        if !response.status().is_success() {
839            return Err(Self::process_error_response(response).await);
840        }
841        Ok(response)
842    }
843
844    /// Execute a GET request with error handling
845    async fn execute_get_request<T: serde::de::DeserializeOwned>(
846        &self,
847        url: &str,
848        query_params: Option<&[(String, String)]>,
849    ) -> Result<T> {
850        let mut request = self.client.get(url).headers(self.default_headers());
851
852        if let Some(params) = query_params {
853            for (key, value) in params {
854                request = request.query(&[(key, value)]);
855            }
856        }
857
858        let response = request.send().await.map_err(|e| self.map_request_error(e))?;
859
860        if !response.status().is_success() {
861            return Err(Self::process_error_response(response).await);
862        }
863
864        response.json::<T>().await.map_err(|e| {
865            Error::serialization(format!("Failed to parse response: {e}"), Some(Box::new(e)))
866        })
867    }
868
869    fn append_beta_header(headers: &mut HeaderMap, beta: &str) -> Result<()> {
870        if beta.trim().is_empty() {
871            return Err(Error::validation(
872                "Beta identifier cannot be empty".to_string(),
873                Some("anthropic-beta".to_string()),
874            ));
875        }
876        let existing = headers
877            .get("anthropic-beta")
878            .map(HeaderValue::to_str)
879            .transpose()
880            .map_err(|error| {
881                Error::validation(
882                    format!("Invalid existing anthropic-beta header: {error}"),
883                    Some("anthropic-beta".to_string()),
884                )
885            })?
886            .unwrap_or_default();
887        if existing.split(',').any(|value| value.trim() == beta) {
888            return Ok(());
889        }
890
891        let combined =
892            if existing.is_empty() { beta.to_string() } else { format!("{existing},{beta}") };
893        let value = HeaderValue::from_str(&combined).map_err(|error| {
894            Error::validation(
895                format!("Invalid anthropic-beta header: {error}"),
896                Some("anthropic-beta".to_string()),
897            )
898        })?;
899        headers.insert("anthropic-beta", value);
900        Ok(())
901    }
902
903    fn message_headers(
904        &self,
905        params: &MessageCreateParams,
906        extra_betas: &[&str],
907        streaming: bool,
908    ) -> Result<HeaderMap> {
909        let mut headers = self.default_headers();
910        if streaming {
911            headers.insert(header::ACCEPT, HeaderValue::from_static("text/event-stream"));
912        }
913        if params.requires_structured_outputs_beta() {
914            Self::append_beta_header(&mut headers, STRUCTURED_OUTPUTS_BETA)?;
915        }
916        if params.context_management.is_some() {
917            Self::append_beta_header(&mut headers, "context-management-2025-06-27")?;
918        }
919        if params.speed.is_some() {
920            Self::append_beta_header(&mut headers, "fast-mode-2026-02-01")?;
921        }
922        for beta in extra_betas {
923            Self::append_beta_header(&mut headers, beta)?;
924        }
925        Ok(headers)
926    }
927
928    async fn send_with_resolved_headers(
929        &self,
930        mut params: MessageCreateParams,
931        replacement_headers: Option<HeaderMap>,
932    ) -> Result<Message> {
933        let start = Instant::now();
934        CLIENT_REQUESTS.click();
935
936        // Validate parameters first
937        if let Err(err) = params.validate() {
938            CLIENT_REQUEST_ERRORS.click();
939            CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
940            return Err(err);
941        }
942
943        // Ensure stream is disabled
944        params.stream = false;
945
946        // Task 8.1: When thinking is Enabled, force temperature to 1.0
947        if matches!(params.thinking, Some(ThinkingConfig::Enabled { .. })) {
948            params.temperature = Some(1.0);
949        }
950
951        let headers = match replacement_headers {
952            Some(headers) => headers,
953            None => self.message_headers(&params, &[], false)?,
954        };
955
956        let result = self
957            .retry_with_backoff(|| async {
958                let url = self.build_url("messages");
959                self.execute_post_request(&url, &params, Some(headers.clone())).await
960            })
961            .await;
962
963        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
964        if result.is_err() {
965            CLIENT_REQUEST_ERRORS.click();
966        }
967        result
968    }
969
970    /// Send a message to the API and get a non-streaming response.
971    pub async fn send(&self, params: MessageCreateParams) -> Result<Message> {
972        self.send_with_resolved_headers(params, None).await
973    }
974
975    /// Send a message using an exact replacement header map.
976    ///
977    /// The supplied headers replace all client defaults and automatically
978    /// generated beta headers. This permits per-request bearer authentication,
979    /// API versions, beta selection, and deliberate beta suppression. Start
980    /// from [`Anthropic::default_headers_for_request`] when only a small change
981    /// is needed.
982    ///
983    /// # Errors
984    ///
985    /// Returns an error when the parameters are invalid, a header is invalid,
986    /// or the request fails.
987    pub async fn send_with_headers(
988        &self,
989        params: MessageCreateParams,
990        headers: HeaderMap,
991    ) -> Result<Message> {
992        self.send_with_resolved_headers(params, Some(headers)).await
993    }
994
995    /// Send a message with caller-selected Anthropic beta versions.
996    ///
997    /// Caller-selected betas are de-duplicated with beta headers required by
998    /// the request's typed features. They are sent only as headers and never
999    /// serialized into the JSON body.
1000    ///
1001    /// # Errors
1002    ///
1003    /// Returns an error when a beta identifier or message parameter is invalid,
1004    /// or the request fails.
1005    pub async fn send_with_betas(
1006        &self,
1007        params: MessageCreateParams,
1008        betas: &[&str],
1009    ) -> Result<Message> {
1010        let headers = self.message_headers(&params, betas, false)?;
1011        self.send_with_resolved_headers(params, Some(headers)).await
1012    }
1013
1014    /// Send a message to the API with logging and get a non-streaming response.
1015    ///
1016    /// This method is identical to [`send`](Self::send) but additionally logs
1017    /// the response through the provided [`ClientLogger`].
1018    pub async fn send_with_logger(
1019        &self,
1020        params: MessageCreateParams,
1021        logger: &dyn ClientLogger,
1022    ) -> Result<Message> {
1023        let result = self.send(params).await;
1024        if let Ok(ref message) = result {
1025            logger.log_response(message);
1026        }
1027        result
1028    }
1029
1030    /// Send a message to the API and get a streaming response.
1031    ///
1032    /// Returns a stream of MessageStreamEvent objects that can be processed incrementally.
1033    async fn stream_with_resolved_headers(
1034        &self,
1035        params: &MessageCreateParams,
1036        replacement_headers: Option<HeaderMap>,
1037    ) -> Result<impl Stream<Item = Result<MessageStreamEvent>> + use<>> {
1038        let start = Instant::now();
1039        CLIENT_REQUESTS.click();
1040
1041        // Validate parameters first
1042        if let Err(err) = params.validate() {
1043            CLIENT_REQUEST_ERRORS.click();
1044            CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1045            return Err(err);
1046        }
1047
1048        // Task 8.3: Clone and force stream = true in the request body
1049        let mut params = params.clone();
1050        params.stream = true;
1051
1052        // Task 8.1: When thinking is Enabled, force temperature to 1.0
1053        if matches!(params.thinking, Some(ThinkingConfig::Enabled { .. })) {
1054            params.temperature = Some(1.0);
1055        }
1056
1057        let headers = match replacement_headers {
1058            Some(headers) => headers,
1059            None => self.message_headers(&params, &[], true)?,
1060        };
1061
1062        let response = self
1063            .retry_with_backoff(|| async {
1064                let url = self.build_url("messages");
1065                self.send_stream_request(&url, headers.clone(), &params).await
1066            })
1067            .await;
1068
1069        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1070        let response = match response {
1071            Ok(response) => response,
1072            Err(err) => {
1073                CLIENT_REQUEST_ERRORS.click();
1074                return Err(err);
1075            }
1076        };
1077
1078        // Get the byte stream from the response
1079        let stream = response.bytes_stream();
1080
1081        // Create an SSE processor
1082        Ok(process_sse(stream))
1083    }
1084
1085    /// Send a message to the API and stream response events.
1086    pub async fn stream(
1087        &self,
1088        params: &MessageCreateParams,
1089    ) -> Result<impl Stream<Item = Result<MessageStreamEvent>> + use<>> {
1090        self.stream_with_resolved_headers(params, None).await
1091    }
1092
1093    /// Stream a message using an exact replacement header map.
1094    ///
1095    /// The supplied headers replace all defaults, including `Accept`,
1096    /// authentication, API-version, and generated beta headers. Callers should
1097    /// normally include `Accept: text/event-stream`.
1098    ///
1099    /// # Errors
1100    ///
1101    /// Returns an error when the parameters are invalid, a header is invalid,
1102    /// or the request fails.
1103    pub async fn stream_with_headers(
1104        &self,
1105        params: &MessageCreateParams,
1106        headers: HeaderMap,
1107    ) -> Result<impl Stream<Item = Result<MessageStreamEvent>> + use<>> {
1108        self.stream_with_resolved_headers(params, Some(headers)).await
1109    }
1110
1111    /// Stream a message with caller-selected Anthropic beta versions.
1112    ///
1113    /// Caller-selected betas are de-duplicated with beta headers required by
1114    /// the request's typed features. They are sent only as headers and never
1115    /// serialized into the JSON body.
1116    ///
1117    /// # Errors
1118    ///
1119    /// Returns an error when a beta identifier or message parameter is invalid,
1120    /// or the request fails.
1121    pub async fn stream_with_betas(
1122        &self,
1123        params: &MessageCreateParams,
1124        betas: &[&str],
1125    ) -> Result<impl Stream<Item = Result<MessageStreamEvent>> + use<>> {
1126        let headers = self.message_headers(params, betas, true)?;
1127        self.stream_with_resolved_headers(params, Some(headers)).await
1128    }
1129
1130    /// Send a message with Claude server-side refusal fallback enabled.
1131    ///
1132    /// The beta API retries only safety-classifier refusals. Rate limits,
1133    /// overloads, and server failures are returned without fallback.
1134    ///
1135    /// # Errors
1136    ///
1137    /// Returns an error when the request or fallback configuration is invalid,
1138    /// or the request fails.
1139    pub async fn send_with_server_fallbacks(
1140        &self,
1141        mut request: ServerFallbackRequest,
1142    ) -> Result<ServerFallbackMessage> {
1143        let start = Instant::now();
1144        CLIENT_REQUESTS.click();
1145        if let Err(error) = request.validate() {
1146            CLIENT_REQUEST_ERRORS.click();
1147            CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1148            return Err(error);
1149        }
1150
1151        request.params.stream = false;
1152        if matches!(request.params.thinking, Some(ThinkingConfig::Enabled { .. })) {
1153            request.params.temperature = Some(1.0);
1154        }
1155        let headers = self.message_headers(&request.params, &[SERVER_FALLBACK_BETA], false)?;
1156        let result = self
1157            .retry_with_backoff(|| async {
1158                let url = self.build_url("messages");
1159                self.execute_post_request(&url, &request, Some(headers.clone())).await
1160            })
1161            .await;
1162
1163        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1164        if result.is_err() {
1165            CLIENT_REQUEST_ERRORS.click();
1166        }
1167        result
1168    }
1169
1170    /// Stream a message with Claude server-side refusal fallback enabled.
1171    ///
1172    /// # Errors
1173    ///
1174    /// Returns an error when the request or fallback configuration is invalid,
1175    /// or the request fails.
1176    pub async fn stream_with_server_fallbacks(
1177        &self,
1178        request: &ServerFallbackRequest,
1179    ) -> Result<impl Stream<Item = Result<ServerFallbackStreamEvent>> + use<>> {
1180        let start = Instant::now();
1181        CLIENT_REQUESTS.click();
1182        if let Err(error) = request.validate() {
1183            CLIENT_REQUEST_ERRORS.click();
1184            CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1185            return Err(error);
1186        }
1187
1188        let mut request = request.clone();
1189        request.params.stream = true;
1190        if matches!(request.params.thinking, Some(ThinkingConfig::Enabled { .. })) {
1191            request.params.temperature = Some(1.0);
1192        }
1193        let headers = self.message_headers(&request.params, &[SERVER_FALLBACK_BETA], true)?;
1194        let response = self
1195            .retry_with_backoff(|| async {
1196                let url = self.build_url("messages");
1197                self.send_stream_request(&url, headers.clone(), &request).await
1198            })
1199            .await;
1200
1201        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1202        let response = match response {
1203            Ok(response) => response,
1204            Err(error) => {
1205                CLIENT_REQUEST_ERRORS.click();
1206                return Err(error);
1207            }
1208        };
1209        Ok(process_json_sse(response.bytes_stream()))
1210    }
1211
1212    /// Send a message to the API with logging and get a streaming response.
1213    ///
1214    /// This method is identical to [`stream`](Self::stream) but additionally logs
1215    /// each streaming event and the final reconstructed message through the
1216    /// provided [`ClientLogger`].
1217    ///
1218    /// Returns a [`LoggingStream`] that wraps an [`AccumulatingStream`], logging
1219    /// each event as it passes through and logging the final message when the
1220    /// stream completes.
1221    pub async fn stream_with_logger<'a>(
1222        &self,
1223        params: &MessageCreateParams,
1224        logger: &'a dyn ClientLogger,
1225    ) -> Result<LoggingStream<'a>> {
1226        let raw_stream = self.stream(params).await?;
1227        let (accumulating_stream, receiver) = AccumulatingStream::new(raw_stream);
1228        Ok(LoggingStream::new(accumulating_stream, receiver, logger))
1229    }
1230
1231    /// Count tokens for a message.
1232    ///
1233    /// This method counts the number of tokens that would be used by a message with the given parameters.
1234    /// It's useful for estimating costs or making sure your messages fit within the model's context window.
1235    pub async fn count_tokens(
1236        &self,
1237        params: MessageCountTokensParams,
1238    ) -> Result<MessageTokensCount> {
1239        let start = Instant::now();
1240        CLIENT_REQUESTS.click();
1241        let result = self
1242            .retry_with_backoff(|| async {
1243                let url = self.build_url("messages/count_tokens");
1244                self.execute_post_request(&url, &params, None).await
1245            })
1246            .await;
1247
1248        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1249        if result.is_err() {
1250            CLIENT_REQUEST_ERRORS.click();
1251        }
1252        result
1253    }
1254
1255    /// List available models from the API.
1256    ///
1257    /// Returns a paginated list of all available models. Use the parameters to control
1258    /// pagination and filter results.
1259    pub async fn list_models(&self, params: Option<ModelListParams>) -> Result<ModelListResponse> {
1260        let start = Instant::now();
1261        CLIENT_REQUESTS.click();
1262        let result = self
1263            .retry_with_backoff(|| async {
1264                let url = self.build_url("models");
1265
1266                let query_params = params.as_ref().map(|p| {
1267                    let mut params = Vec::new();
1268                    if let Some(ref after_id) = p.after_id {
1269                        params.push(("after_id".to_string(), after_id.clone()));
1270                    }
1271                    if let Some(ref before_id) = p.before_id {
1272                        params.push(("before_id".to_string(), before_id.clone()));
1273                    }
1274                    if let Some(limit) = p.limit {
1275                        params.push(("limit".to_string(), limit.to_string()));
1276                    }
1277                    params
1278                });
1279
1280                self.execute_get_request(&url, query_params.as_deref()).await
1281            })
1282            .await;
1283
1284        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1285        if result.is_err() {
1286            CLIENT_REQUEST_ERRORS.click();
1287        }
1288        result
1289    }
1290
1291    /// Retrieve information about a specific model.
1292    ///
1293    /// Returns detailed information about the specified model, including its
1294    /// ID, creation date, display name, and type.
1295    pub async fn get_model(&self, model_id: &str) -> Result<ModelInfo> {
1296        let start = Instant::now();
1297        CLIENT_REQUESTS.click();
1298        let result = self
1299            .retry_with_backoff(|| async {
1300                let url = self.build_url(&format!("models/{}", model_id));
1301                self.execute_get_request(&url, None).await
1302            })
1303            .await;
1304
1305        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1306        if result.is_err() {
1307            CLIENT_REQUEST_ERRORS.click();
1308        }
1309        result
1310    }
1311
1312    // --- Helper methods for DELETE and multipart requests ---
1313
1314    /// Execute a DELETE request with error handling.
1315    async fn execute_delete_request(&self, url: &str) -> Result<()> {
1316        let response = self
1317            .client
1318            .delete(url)
1319            .headers(self.default_headers())
1320            .send()
1321            .await
1322            .map_err(|e| self.map_request_error(e))?;
1323
1324        if !response.status().is_success() {
1325            return Err(Self::process_error_response(response).await);
1326        }
1327
1328        Ok(())
1329    }
1330
1331    /// Build standard pagination query params.
1332    fn pagination_params(
1333        before_id: Option<&str>,
1334        after_id: Option<&str>,
1335        limit: Option<u32>,
1336    ) -> Option<Vec<(String, String)>> {
1337        let mut params = Vec::new();
1338        if let Some(before) = before_id {
1339            params.push(("before_id".to_string(), before.to_string()));
1340        }
1341        if let Some(after) = after_id {
1342            params.push(("after_id".to_string(), after.to_string()));
1343        }
1344        if let Some(lim) = limit {
1345            params.push(("limit".to_string(), lim.to_string()));
1346        }
1347        if params.is_empty() { None } else { Some(params) }
1348    }
1349
1350    // --- Batches API (Req 13) ---
1351
1352    /// Create a message batch for asynchronous processing.
1353    pub async fn create_batch(&self, requests: Vec<BatchRequest>) -> Result<MessageBatch> {
1354        let start = Instant::now();
1355        CLIENT_REQUESTS.click();
1356        let body = serde_json::json!({ "requests": requests });
1357        let result = self
1358            .retry_with_backoff(|| async {
1359                let url = self.build_url("messages/batches");
1360                self.execute_post_request(&url, &body, None).await
1361            })
1362            .await;
1363        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1364        if result.is_err() {
1365            CLIENT_REQUEST_ERRORS.click();
1366        }
1367        result
1368    }
1369
1370    /// Get the status of a message batch.
1371    pub async fn get_batch(&self, batch_id: &str) -> Result<MessageBatch> {
1372        let start = Instant::now();
1373        CLIENT_REQUESTS.click();
1374        let result = self
1375            .retry_with_backoff(|| async {
1376                let url = self.build_url(&format!("messages/batches/{batch_id}"));
1377                self.execute_get_request(&url, None).await
1378            })
1379            .await;
1380        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1381        if result.is_err() {
1382            CLIENT_REQUEST_ERRORS.click();
1383        }
1384        result
1385    }
1386
1387    /// Get results of a completed batch as newline-delimited JSON.
1388    pub async fn batch_results(&self, batch_id: &str) -> Result<Vec<BatchResultItem>> {
1389        let start = Instant::now();
1390        CLIENT_REQUESTS.click();
1391        let result = self
1392            .retry_with_backoff(|| async {
1393                let url = self.build_url(&format!("messages/batches/{batch_id}/results"));
1394                let response = self
1395                    .client
1396                    .get(&url)
1397                    .headers(self.default_headers())
1398                    .send()
1399                    .await
1400                    .map_err(|e| self.map_request_error(e))?;
1401
1402                if !response.status().is_success() {
1403                    return Err(Self::process_error_response(response).await);
1404                }
1405
1406                let text = response.text().await.map_err(|e| {
1407                    Error::serialization(
1408                        format!("Failed to read batch results: {e}"),
1409                        Some(Box::new(e)),
1410                    )
1411                })?;
1412
1413                let mut items = Vec::new();
1414                for line in text.lines() {
1415                    let trimmed = line.trim();
1416                    if trimmed.is_empty() {
1417                        continue;
1418                    }
1419                    let item: BatchResultItem = serde_json::from_str(trimmed)?;
1420                    items.push(item);
1421                }
1422                Ok(items)
1423            })
1424            .await;
1425        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1426        if result.is_err() {
1427            CLIENT_REQUEST_ERRORS.click();
1428        }
1429        result
1430    }
1431
1432    /// Cancel an in-progress batch.
1433    pub async fn cancel_batch(&self, batch_id: &str) -> Result<MessageBatch> {
1434        let start = Instant::now();
1435        CLIENT_REQUESTS.click();
1436        let result = self
1437            .retry_with_backoff(|| async {
1438                let url = self.build_url(&format!("messages/batches/{batch_id}/cancel"));
1439                self.execute_post_request(&url, &serde_json::json!({}), None).await
1440            })
1441            .await;
1442        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1443        if result.is_err() {
1444            CLIENT_REQUEST_ERRORS.click();
1445        }
1446        result
1447    }
1448
1449    /// Delete a batch.
1450    pub async fn delete_batch(&self, batch_id: &str) -> Result<()> {
1451        let start = Instant::now();
1452        CLIENT_REQUESTS.click();
1453        let result = self
1454            .retry_with_backoff(|| async {
1455                let url = self.build_url(&format!("messages/batches/{batch_id}"));
1456                self.execute_delete_request(&url).await
1457            })
1458            .await;
1459        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1460        if result.is_err() {
1461            CLIENT_REQUEST_ERRORS.click();
1462        }
1463        result
1464    }
1465
1466    /// List message batches with pagination.
1467    pub async fn list_batches(
1468        &self,
1469        before_id: Option<&str>,
1470        after_id: Option<&str>,
1471        limit: Option<u32>,
1472    ) -> Result<PaginatedList<MessageBatch>> {
1473        let start = Instant::now();
1474        CLIENT_REQUESTS.click();
1475        let result = self
1476            .retry_with_backoff(|| async {
1477                let url = self.build_url("messages/batches");
1478                let query = Self::pagination_params(before_id, after_id, limit);
1479                self.execute_get_request(&url, query.as_deref()).await
1480            })
1481            .await;
1482        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1483        if result.is_err() {
1484            CLIENT_REQUEST_ERRORS.click();
1485        }
1486        result
1487    }
1488
1489    // --- Files API (Req 21) ---
1490
1491    /// Upload a file via multipart form upload.
1492    pub async fn upload_file(
1493        &self,
1494        data: Vec<u8>,
1495        mime_type: &str,
1496        filename: &str,
1497        purpose: &str,
1498    ) -> Result<FileObject> {
1499        let start = Instant::now();
1500        CLIENT_REQUESTS.click();
1501
1502        let mime_type = mime_type.to_string();
1503        let filename = filename.to_string();
1504        let purpose = purpose.to_string();
1505
1506        let result = self
1507            .retry_with_backoff(|| {
1508                let data = data.clone();
1509                let mime_type = mime_type.clone();
1510                let filename = filename.clone();
1511                let purpose = purpose.clone();
1512                async move {
1513                    let url = self.build_url("files");
1514                    let part = reqwest::multipart::Part::bytes(data)
1515                        .file_name(filename)
1516                        .mime_str(&mime_type)
1517                        .map_err(|e| {
1518                            Error::validation(
1519                                format!("Invalid MIME type: {e}"),
1520                                Some("mime_type".to_string()),
1521                            )
1522                        })?;
1523                    let form =
1524                        reqwest::multipart::Form::new().text("purpose", purpose).part("file", part);
1525
1526                    let response = self
1527                        .client
1528                        .post(&url)
1529                        .headers(self.default_headers())
1530                        .multipart(form)
1531                        .send()
1532                        .await
1533                        .map_err(|e| self.map_request_error(e))?;
1534
1535                    if !response.status().is_success() {
1536                        return Err(Self::process_error_response(response).await);
1537                    }
1538
1539                    response.json::<FileObject>().await.map_err(|e| {
1540                        Error::serialization(
1541                            format!("Failed to parse file response: {e}"),
1542                            Some(Box::new(e)),
1543                        )
1544                    })
1545                }
1546            })
1547            .await;
1548        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1549        if result.is_err() {
1550            CLIENT_REQUEST_ERRORS.click();
1551        }
1552        result
1553    }
1554
1555    /// Get metadata for a file.
1556    pub async fn get_file(&self, file_id: &str) -> Result<FileObject> {
1557        let start = Instant::now();
1558        CLIENT_REQUESTS.click();
1559        let result = self
1560            .retry_with_backoff(|| async {
1561                let url = self.build_url(&format!("files/{file_id}"));
1562                self.execute_get_request(&url, None).await
1563            })
1564            .await;
1565        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1566        if result.is_err() {
1567            CLIENT_REQUEST_ERRORS.click();
1568        }
1569        result
1570    }
1571
1572    /// Delete a file.
1573    pub async fn delete_file(&self, file_id: &str) -> Result<()> {
1574        let start = Instant::now();
1575        CLIENT_REQUESTS.click();
1576        let result = self
1577            .retry_with_backoff(|| async {
1578                let url = self.build_url(&format!("files/{file_id}"));
1579                self.execute_delete_request(&url).await
1580            })
1581            .await;
1582        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1583        if result.is_err() {
1584            CLIENT_REQUEST_ERRORS.click();
1585        }
1586        result
1587    }
1588
1589    /// List files with pagination.
1590    pub async fn list_files(
1591        &self,
1592        before_id: Option<&str>,
1593        after_id: Option<&str>,
1594        limit: Option<u32>,
1595    ) -> Result<PaginatedList<FileObject>> {
1596        let start = Instant::now();
1597        CLIENT_REQUESTS.click();
1598        let result = self
1599            .retry_with_backoff(|| async {
1600                let url = self.build_url("files");
1601                let query = Self::pagination_params(before_id, after_id, limit);
1602                self.execute_get_request(&url, query.as_deref()).await
1603            })
1604            .await;
1605        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1606        if result.is_err() {
1607            CLIENT_REQUEST_ERRORS.click();
1608        }
1609        result
1610    }
1611
1612    // --- Skills API (Req 22) ---
1613
1614    /// Create a new skill.
1615    pub async fn create_skill(
1616        &self,
1617        name: &str,
1618        description: &str,
1619        content: Vec<u8>,
1620    ) -> Result<SkillObject> {
1621        let start = Instant::now();
1622        CLIENT_REQUESTS.click();
1623        let body = serde_json::json!({
1624            "name": name,
1625            "description": description,
1626            "content": base64_encode(&content),
1627        });
1628        let result = self
1629            .retry_with_backoff(|| async {
1630                let url = self.build_url("skills");
1631                self.execute_post_request(&url, &body, None).await
1632            })
1633            .await;
1634        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1635        if result.is_err() {
1636            CLIENT_REQUEST_ERRORS.click();
1637        }
1638        result
1639    }
1640
1641    /// Get a skill by ID.
1642    pub async fn get_skill(&self, skill_id: &str) -> Result<SkillObject> {
1643        let start = Instant::now();
1644        CLIENT_REQUESTS.click();
1645        let result = self
1646            .retry_with_backoff(|| async {
1647                let url = self.build_url(&format!("skills/{skill_id}"));
1648                self.execute_get_request(&url, None).await
1649            })
1650            .await;
1651        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1652        if result.is_err() {
1653            CLIENT_REQUEST_ERRORS.click();
1654        }
1655        result
1656    }
1657
1658    /// Update a skill's content.
1659    pub async fn update_skill(&self, skill_id: &str, content: Vec<u8>) -> Result<SkillObject> {
1660        let start = Instant::now();
1661        CLIENT_REQUESTS.click();
1662        let body = serde_json::json!({
1663            "content": base64_encode(&content),
1664        });
1665        let result = self
1666            .retry_with_backoff(|| async {
1667                let url = self.build_url(&format!("skills/{skill_id}"));
1668                self.execute_post_request(&url, &body, None).await
1669            })
1670            .await;
1671        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1672        if result.is_err() {
1673            CLIENT_REQUEST_ERRORS.click();
1674        }
1675        result
1676    }
1677
1678    /// Delete a skill.
1679    pub async fn delete_skill(&self, skill_id: &str) -> Result<()> {
1680        let start = Instant::now();
1681        CLIENT_REQUESTS.click();
1682        let result = self
1683            .retry_with_backoff(|| async {
1684                let url = self.build_url(&format!("skills/{skill_id}"));
1685                self.execute_delete_request(&url).await
1686            })
1687            .await;
1688        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1689        if result.is_err() {
1690            CLIENT_REQUEST_ERRORS.click();
1691        }
1692        result
1693    }
1694
1695    /// List skills with pagination.
1696    pub async fn list_skills(
1697        &self,
1698        before_id: Option<&str>,
1699        after_id: Option<&str>,
1700        limit: Option<u32>,
1701    ) -> Result<PaginatedList<SkillObject>> {
1702        let start = Instant::now();
1703        CLIENT_REQUESTS.click();
1704        let result = self
1705            .retry_with_backoff(|| async {
1706                let url = self.build_url("skills");
1707                let query = Self::pagination_params(before_id, after_id, limit);
1708                self.execute_get_request(&url, query.as_deref()).await
1709            })
1710            .await;
1711        CLIENT_REQUEST_DURATION.add(start.elapsed().as_secs_f64());
1712        if result.is_err() {
1713            CLIENT_REQUEST_ERRORS.click();
1714        }
1715        result
1716    }
1717}
1718
1719#[cfg(test)]
1720mod tests {
1721    use super::*;
1722    use std::sync::Arc;
1723    use std::sync::atomic::{AtomicUsize, Ordering};
1724
1725    #[test]
1726    fn debug_output_redacts_api_key_and_bearer_token() {
1727        let client = Anthropic::new(Some("sk-ant-secret-key-value".to_string())).unwrap();
1728        let debug = format!("{client:?}");
1729        assert!(!debug.contains("sk-ant-secret-key-value"), "api key leaked: {debug}");
1730        assert!(debug.contains("[REDACTED]"));
1731
1732        let client = Anthropic::new_with_auth_token("secret-bearer-token").unwrap();
1733        let debug = format!("{client:?}");
1734        assert!(!debug.contains("secret-bearer-token"), "bearer token leaked: {debug}");
1735    }
1736
1737    #[test]
1738    fn api_key_header_is_marked_sensitive() {
1739        let headers = Anthropic::build_default_headers("sk-ant-secret-key-value").unwrap();
1740
1741        assert!(headers.get("x-api-key").unwrap().is_sensitive());
1742    }
1743
1744    #[tokio::test]
1745    async fn retry_logic_with_backoff() {
1746        let client = Anthropic {
1747            api_key: "test".to_string(),
1748            client: ReqwestClient::new(),
1749            stream_client: ReqwestClient::new(),
1750            base_url: "http://localhost".to_string(),
1751            timeout: Duration::from_secs(1),
1752            stream_timeout: None,
1753            max_retries: 2,
1754            throughput_ops_sec: 1.0 / 60.0,
1755            reserve_capacity: 1.0 / 60.0,
1756            cached_headers: Arc::new(HeaderMap::new()),
1757            allow_insecure_http: false,
1758        };
1759
1760        let attempt_counter = Arc::new(AtomicUsize::new(0));
1761        let counter_clone = attempt_counter.clone();
1762
1763        let result = client
1764            .retry_with_backoff(|| {
1765                let counter = counter_clone.clone();
1766                async move {
1767                    let attempt = counter.fetch_add(1, Ordering::SeqCst);
1768                    match attempt {
1769                        0 | 1 => Err(Error::rate_limit("Rate limited", Some(1))),
1770                        _ => Ok("success".to_string()),
1771                    }
1772                }
1773            })
1774            .await;
1775
1776        assert!(result.is_ok());
1777        assert_eq!(result.unwrap(), "success");
1778        assert_eq!(attempt_counter.load(Ordering::SeqCst), 3);
1779    }
1780
1781    #[tokio::test]
1782    async fn retry_logic_with_non_retryable_error() {
1783        let client = Anthropic {
1784            api_key: "test".to_string(),
1785            client: ReqwestClient::new(),
1786            stream_client: ReqwestClient::new(),
1787            base_url: "http://localhost".to_string(),
1788            timeout: Duration::from_secs(1),
1789            stream_timeout: None,
1790            max_retries: 2,
1791            throughput_ops_sec: 1.0 / 60.0,
1792            reserve_capacity: 1.0 / 60.0,
1793            cached_headers: Arc::new(HeaderMap::new()),
1794            allow_insecure_http: false,
1795        };
1796
1797        let attempt_counter = Arc::new(AtomicUsize::new(0));
1798        let counter_clone = attempt_counter.clone();
1799
1800        let result: Result<String> = client
1801            .retry_with_backoff(|| {
1802                let counter = counter_clone.clone();
1803                async move {
1804                    counter.fetch_add(1, Ordering::SeqCst);
1805                    Err(Error::authentication("Invalid API key"))
1806                }
1807            })
1808            .await;
1809
1810        assert!(result.is_err());
1811        assert!(result.unwrap_err().is_authentication());
1812        // Should only attempt once since authentication errors are not retryable
1813        assert_eq!(attempt_counter.load(Ordering::SeqCst), 1);
1814    }
1815
1816    #[tokio::test]
1817    async fn retry_logic_max_retries_exceeded() {
1818        let client = Anthropic {
1819            api_key: "test".to_string(),
1820            client: ReqwestClient::new(),
1821            stream_client: ReqwestClient::new(),
1822            base_url: "http://localhost".to_string(),
1823            timeout: Duration::from_secs(1),
1824            stream_timeout: None,
1825            max_retries: 2,
1826            throughput_ops_sec: 1.0 / 60.0,
1827            reserve_capacity: 1.0 / 60.0,
1828            cached_headers: Arc::new(HeaderMap::new()),
1829            allow_insecure_http: false,
1830        };
1831
1832        let attempt_counter = Arc::new(AtomicUsize::new(0));
1833        let counter_clone = attempt_counter.clone();
1834
1835        let result: Result<String> = client
1836            .retry_with_backoff(|| {
1837                let counter = counter_clone.clone();
1838                async move {
1839                    counter.fetch_add(1, Ordering::SeqCst);
1840                    Err(Error::rate_limit("Always rate limited", Some(1)))
1841                }
1842            })
1843            .await;
1844
1845        assert!(result.is_err());
1846        assert!(result.unwrap_err().is_rate_limit());
1847        // Should attempt max_retries + 1 times (3 total: initial + 2 retries)
1848        assert_eq!(attempt_counter.load(Ordering::SeqCst), 3);
1849    }
1850
1851    #[tokio::test]
1852    async fn error_529_is_retryable() {
1853        // Test that 529 errors are properly mapped to rate_limit and are retryable
1854        let client = Anthropic {
1855            api_key: "test".to_string(),
1856            client: ReqwestClient::new(),
1857            stream_client: ReqwestClient::new(),
1858            base_url: "http://localhost".to_string(),
1859            timeout: Duration::from_secs(1),
1860            stream_timeout: None,
1861            max_retries: 2,
1862            throughput_ops_sec: 1.0 / 60.0,
1863            reserve_capacity: 1.0 / 60.0,
1864            cached_headers: Arc::new(HeaderMap::new()),
1865            allow_insecure_http: false,
1866        };
1867
1868        let attempt_counter = Arc::new(AtomicUsize::new(0));
1869        let counter_clone = attempt_counter.clone();
1870
1871        let result = client
1872            .retry_with_backoff(|| {
1873                let counter = counter_clone.clone();
1874                async move {
1875                    let attempt = counter.fetch_add(1, Ordering::SeqCst);
1876                    match attempt {
1877                        0 | 1 => {
1878                            // Simulate a 529 overloaded error
1879                            Err(Error::api(
1880                                529,
1881                                Some("overloaded_error".to_string()),
1882                                "Overloaded".to_string(),
1883                                None,
1884                            ))
1885                        }
1886                        _ => Ok("success".to_string()),
1887                    }
1888                }
1889            })
1890            .await;
1891
1892        assert!(result.is_ok());
1893        assert_eq!(result.unwrap(), "success");
1894        // Should retry: initial attempt + 2 retries = 3 total
1895        assert_eq!(attempt_counter.load(Ordering::SeqCst), 3);
1896    }
1897
1898    #[test]
1899    fn error_529_mapped_correctly() {
1900        // Test that a 529 API error is correctly identified as retryable
1901        let error =
1902            Error::api(529, Some("overloaded_error".to_string()), "Overloaded".to_string(), None);
1903        assert!(error.is_retryable());
1904
1905        // Test that rate_limit error (which 529 now maps to) is also retryable
1906        let rate_limit_error = Error::rate_limit("Overloaded", Some(5));
1907        assert!(rate_limit_error.is_retryable());
1908    }
1909
1910    #[test]
1911    fn explicit_configuration() {
1912        if std::env::var_os("ADK_EXPLICIT_CONFIG_CHILD").is_none() {
1913            let status = std::process::Command::new(std::env::current_exe().unwrap())
1914                .args(["--exact", "client::tests::explicit_configuration"])
1915                .env("ADK_EXPLICIT_CONFIG_CHILD", "1")
1916                .env("ANTHROPIC_BASE_URL", "invalid-environment-url")
1917                .status()
1918                .unwrap();
1919            assert!(status.success());
1920            return;
1921        }
1922        let key = "sk-explicit-key";
1923        let client = Anthropic::new_with_base_url(key, "http://127.0.0.1:12345").unwrap();
1924        assert_eq!(client.api_key, key);
1925        assert_eq!(client.cached_headers["x-api-key"], key);
1926        assert_eq!(client.base_url, "http://127.0.0.1:12345");
1927        assert!(Anthropic::new_with_base_url(key, "invalid-explicit-url").is_err());
1928
1929        let dir =
1930            std::env::temp_dir().join(format!("adk_anthropic_explicit_{}", std::process::id()));
1931        std::fs::create_dir_all(&dir).unwrap();
1932        let file = dir.join("key.txt");
1933        std::fs::write(&file, "sk-from-file\n").unwrap();
1934        let client =
1935            Anthropic::new_with_base_url(format!("file://{}", file.display()), "https://a.example")
1936                .unwrap();
1937        std::fs::remove_dir_all(&dir).unwrap();
1938        assert_eq!(client.api_key, "sk-from-file");
1939        assert!(
1940            Anthropic::new_with_base_url("file:///adk-missing-key-file", "https://a.example")
1941                .is_err()
1942        );
1943    }
1944
1945    #[test]
1946    fn resolve_api_key_regular_value() {
1947        let result = Anthropic::resolve_api_key("sk-test-key-123");
1948        assert!(result.is_ok());
1949        assert_eq!(result.unwrap(), "sk-test-key-123");
1950    }
1951
1952    #[test]
1953    fn resolve_api_key_file_url_absolute() {
1954        let test_dir =
1955            std::env::temp_dir().join(format!("adk_anthropic_test_{}", std::process::id()));
1956        std::fs::create_dir_all(&test_dir).unwrap();
1957        let test_file = test_dir.join("test_api_key.txt");
1958        std::fs::write(&test_file, "sk-test-from-file-123\n").unwrap();
1959
1960        let file_url = format!("file://{}", test_file.display());
1961        let result = Anthropic::resolve_api_key(&file_url);
1962
1963        std::fs::remove_dir_all(&test_dir).unwrap();
1964
1965        assert!(result.is_ok());
1966        assert_eq!(result.unwrap(), "sk-test-from-file-123");
1967    }
1968
1969    #[test]
1970    fn resolve_api_key_file_url_relative() {
1971        let test_file = "test_relative_key.txt";
1972        std::fs::write(test_file, "sk-relative-key-456\n").unwrap();
1973
1974        let file_url = format!("file://{}", test_file);
1975        let result = Anthropic::resolve_api_key(&file_url);
1976
1977        std::fs::remove_file(test_file).unwrap();
1978
1979        assert!(result.is_ok());
1980        assert_eq!(result.unwrap(), "sk-relative-key-456");
1981    }
1982
1983    #[test]
1984    fn resolve_api_key_file_url_nonexistent() {
1985        let result = Anthropic::resolve_api_key("file:///nonexistent/path/to/key.txt");
1986        assert!(result.is_err());
1987
1988        let error = result.unwrap_err();
1989        assert!(error.is_validation());
1990        assert!(format!("{}", error).contains("Failed to read API key from file"));
1991    }
1992
1993    #[test]
1994    fn resolve_api_key_file_url_with_whitespace() {
1995        let test_file = "test_whitespace_key.txt";
1996        std::fs::write(test_file, "  sk-whitespace-key-789  \n  ").unwrap();
1997
1998        let file_url = format!("file://{}", test_file);
1999        let result = Anthropic::resolve_api_key(&file_url);
2000
2001        std::fs::remove_file(test_file).unwrap();
2002
2003        assert!(result.is_ok());
2004        assert_eq!(result.unwrap(), "sk-whitespace-key-789");
2005    }
2006
2007    #[test]
2008    fn client_builder_methods() {
2009        let client = Anthropic::new(Some("test_key".to_string())).unwrap();
2010
2011        // Test builder pattern methods
2012        let configured_client = client
2013            .with_base_url("https://custom.api.com".to_string())
2014            .unwrap()
2015            .with_max_retries(5)
2016            .with_backoff_params(2.0, 1.0);
2017
2018        assert_eq!(configured_client.base_url, "https://custom.api.com");
2019        assert_eq!(configured_client.max_retries, 5);
2020        assert_eq!(configured_client.throughput_ops_sec, 2.0);
2021        assert_eq!(configured_client.reserve_capacity, 1.0);
2022    }
2023
2024    #[test]
2025    fn build_url_default_base() {
2026        let client = Anthropic::new(Some("test_key".to_string())).unwrap();
2027        // Default base URL: https://api.anthropic.com
2028        assert_eq!(client.build_url("messages"), "https://api.anthropic.com/v1/messages");
2029        assert_eq!(
2030            client.build_url("messages/count_tokens"),
2031            "https://api.anthropic.com/v1/messages/count_tokens"
2032        );
2033        assert_eq!(client.build_url("models"), "https://api.anthropic.com/v1/models");
2034    }
2035
2036    #[test]
2037    fn build_url_custom_base_without_trailing_slash() {
2038        let client = Anthropic::new(Some("test_key".to_string()))
2039            .unwrap()
2040            .with_base_url("https://api.minimax.io/anthropic".to_string())
2041            .unwrap();
2042        assert_eq!(client.build_url("messages"), "https://api.minimax.io/anthropic/v1/messages");
2043    }
2044
2045    #[test]
2046    fn build_url_custom_base_with_trailing_slash() {
2047        let client = Anthropic::new(Some("test_key".to_string()))
2048            .unwrap()
2049            .with_base_url("https://api.minimax.io/anthropic/".to_string())
2050            .unwrap();
2051        assert_eq!(client.build_url("messages"), "https://api.minimax.io/anthropic/v1/messages");
2052    }
2053
2054    #[test]
2055    fn build_url_minimax_china() {
2056        let client = Anthropic::new(Some("test_key".to_string()))
2057            .unwrap()
2058            .with_base_url("https://api.minimaxi.com/anthropic".to_string())
2059            .unwrap();
2060        assert_eq!(client.build_url("messages"), "https://api.minimaxi.com/anthropic/v1/messages");
2061        assert_eq!(
2062            client.build_url(&format!("models/{}", "claude-3-opus")),
2063            "https://api.minimaxi.com/anthropic/v1/models/claude-3-opus"
2064        );
2065    }
2066
2067    #[test]
2068    fn with_base_url_accepts_https() {
2069        let client = Anthropic::new(Some("placeholder-api-key".to_string()))
2070            .unwrap()
2071            .with_base_url("https://gateway.example.com/anthropic".to_string())
2072            .unwrap();
2073        assert_eq!(client.base_url, "https://gateway.example.com/anthropic");
2074    }
2075
2076    #[test]
2077    fn with_base_url_allows_loopback_http_for_local_dev() {
2078        for url in ["http://localhost:8080", "http://127.0.0.1:8080", "http://[::1]:8080"] {
2079            let client = Anthropic::new(Some("placeholder-api-key".to_string()))
2080                .unwrap()
2081                .with_base_url(url.to_string())
2082                .unwrap_or_else(|e| panic!("loopback url {url} should be accepted: {e}"));
2083            assert_eq!(client.base_url, url);
2084        }
2085    }
2086
2087    #[test]
2088    fn with_base_url_rejects_cleartext_http() {
2089        let err = Anthropic::new(Some("placeholder-api-key".to_string()))
2090            .unwrap()
2091            .with_base_url("http://gateway.internal.example.com".to_string())
2092            .expect_err("a non-loopback http base URL must be rejected");
2093
2094        assert!(err.is_validation(), "expected a validation error, got {err}");
2095        let message = err.to_string();
2096        assert!(
2097            message.contains("unencrypted"),
2098            "error should explain the cleartext risk, got: {message}"
2099        );
2100        assert!(message.contains("'http'"), "error should name the scheme, got: {message}");
2101        assert!(
2102            message.contains("allow_insecure_http"),
2103            "error should name the opt-in, got: {message}"
2104        );
2105    }
2106
2107    #[test]
2108    fn allow_insecure_http_before_with_base_url_accepts_cleartext_http() {
2109        let client = Anthropic::new(Some("placeholder-api-key".to_string()))
2110            .unwrap()
2111            .allow_insecure_http()
2112            .with_base_url("http://10.60.1.20:8080/api/v1/llm/anthropic".to_string())
2113            .unwrap();
2114        assert_eq!(client.base_url, "http://10.60.1.20:8080/api/v1/llm/anthropic");
2115        assert_eq!(
2116            client.build_url("messages"),
2117            "http://10.60.1.20:8080/api/v1/llm/anthropic/v1/messages"
2118        );
2119
2120        let client = Anthropic::new_with_base_url("placeholder-api-key", DEFAULT_API_URL)
2121            .unwrap()
2122            .allow_insecure_http()
2123            .with_base_url_and_timeout(
2124                "http://gateway.corp.internal".to_string(),
2125                Duration::from_secs(5),
2126            )
2127            .unwrap();
2128        assert_eq!(client.base_url, "http://gateway.corp.internal");
2129        assert_eq!(client.timeout, Duration::from_secs(5));
2130    }
2131
2132    #[test]
2133    fn allow_insecure_http_after_with_base_url_is_too_late() {
2134        let err = Anthropic::new(Some("placeholder-api-key".to_string()))
2135            .unwrap()
2136            .with_base_url("http://10.60.1.20:8080".to_string())
2137            .map(Anthropic::allow_insecure_http)
2138            .expect_err("with_base_url validates before the opt-in is set");
2139        assert!(err.is_validation(), "expected a validation error, got {err}");
2140        assert!(
2141            err.to_string().contains("allow_insecure_http"),
2142            "error should name the opt-in, got: {err}"
2143        );
2144    }
2145
2146    #[test]
2147    fn allow_insecure_http_admits_http_only() {
2148        for url in ["ftp://files.example.com", "ws://gateway.example.com", "not-a-url"] {
2149            let err = Anthropic::new(Some("placeholder-api-key".to_string()))
2150                .unwrap()
2151                .allow_insecure_http()
2152                .with_base_url(url.to_string())
2153                .expect_err("the opt-in must not admit other schemes");
2154            assert!(err.is_validation(), "expected a validation error for {url}, got {err}");
2155        }
2156    }
2157
2158    #[test]
2159    fn new_with_base_url_rejection_names_the_opt_in() {
2160        let err = Anthropic::new_with_base_url("placeholder-api-key", "http://10.60.1.20:8080")
2161            .expect_err("a non-loopback http base URL must be rejected");
2162        assert!(
2163            err.to_string().contains("allow_insecure_http"),
2164            "error should name the opt-in, got: {err}"
2165        );
2166    }
2167
2168    #[test]
2169    fn insecure_http_environment() {
2170        const GATEWAY: &str = "http://10.60.1.20:8080/api/v1/llm/anthropic";
2171        if std::env::var_os("ADK_INSECURE_HTTP_ENV_CHILD").is_none() {
2172            let status = std::process::Command::new(std::env::current_exe().unwrap())
2173                .args(["--exact", "client::tests::insecure_http_environment"])
2174                .env("ADK_INSECURE_HTTP_ENV_CHILD", "1")
2175                .env("ANTHROPIC_BASE_URL", GATEWAY)
2176                .env("ANTHROPIC_ALLOW_INSECURE_HTTP", "true")
2177                .status()
2178                .unwrap();
2179            assert!(status.success());
2180            return;
2181        }
2182        let client = Anthropic::new(Some("placeholder-api-key".to_string()))
2183            .expect("ANTHROPIC_ALLOW_INSECURE_HTTP admits the ANTHROPIC_BASE_URL gateway");
2184        assert_eq!(client.base_url(), GATEWAY);
2185
2186        // The environment acknowledges the environment URL only, not URLs given in code.
2187        assert!(!client.allow_insecure_http);
2188        let err = client
2189            .with_base_url("http://gateway.corp.internal".to_string())
2190            .expect_err("a URL given in code needs allow_insecure_http()");
2191        assert!(err.to_string().contains("allow_insecure_http"), "got: {err}");
2192        assert!(Anthropic::new_with_base_url("placeholder-api-key", GATEWAY).is_err());
2193    }
2194
2195    #[test]
2196    fn with_base_url_rejects_non_http_schemes_and_garbage() {
2197        for url in ["ftp://files.example.com", "ws://gateway.example.com", "not-a-url"] {
2198            let err = Anthropic::new(Some("placeholder-api-key".to_string()))
2199                .unwrap()
2200                .with_base_url(url.to_string())
2201                .expect_err("non-https, non-loopback base URL must be rejected");
2202            assert!(err.is_validation(), "expected a validation error for {url}, got {err}");
2203        }
2204    }
2205
2206    // The `ANTHROPIC_BASE_URL` tests exercise `resolve_base_url` directly instead
2207    // of mutating the process environment. The environment is process-global, so
2208    // setting an intentionally-rejected value would race against every other test
2209    // in this binary that calls `Anthropic::new`. `resolve_base_url` is the exact
2210    // code path `Anthropic::new` uses for the env value, so nothing is lost.
2211
2212    #[test]
2213    fn env_base_url_absent_falls_back_to_default() {
2214        let resolved = Anthropic::resolve_base_url(None, None).unwrap();
2215        assert_eq!(resolved, DEFAULT_API_URL);
2216        let resolved = Anthropic::resolve_base_url(None, Some("1".to_string())).unwrap();
2217        assert_eq!(resolved, DEFAULT_API_URL);
2218    }
2219
2220    #[test]
2221    fn env_base_url_accepts_https() {
2222        let resolved = Anthropic::resolve_base_url(
2223            Some("https://gateway.example.com/anthropic".to_string()),
2224            None,
2225        )
2226        .unwrap();
2227        assert_eq!(resolved, "https://gateway.example.com/anthropic");
2228    }
2229
2230    #[test]
2231    fn env_base_url_allows_loopback_http_for_local_dev() {
2232        for url in ["http://localhost:11434", "http://127.0.0.1:11434", "http://[::1]:11434"] {
2233            let resolved = Anthropic::resolve_base_url(Some(url.to_string()), None)
2234                .unwrap_or_else(|e| panic!("loopback url {url} should be accepted: {e}"));
2235            assert_eq!(resolved, url);
2236        }
2237    }
2238
2239    #[test]
2240    fn env_base_url_rejects_cleartext_http() {
2241        for flag in [None, Some("0"), Some("false"), Some("yes"), Some("")] {
2242            let err = Anthropic::resolve_base_url(
2243                Some("http://gateway.internal.example.com".to_string()),
2244                flag.map(str::to_string),
2245            )
2246            .expect_err("a non-loopback http ANTHROPIC_BASE_URL must be rejected");
2247
2248            assert!(err.is_validation(), "expected a validation error, got {err}");
2249            let message = err.to_string();
2250            assert!(
2251                message.contains("unencrypted"),
2252                "error should explain the cleartext risk, got: {message}"
2253            );
2254            assert!(message.contains("'http'"), "error should name the scheme, got: {message}");
2255            assert!(
2256                message.contains("ANTHROPIC_ALLOW_INSECURE_HTTP=1"),
2257                "error should name the environment opt-in for {flag:?}, got: {message}"
2258            );
2259        }
2260    }
2261
2262    #[test]
2263    fn env_base_url_accepts_cleartext_http_when_acknowledged() {
2264        for flag in ["1", "true", "TRUE", " True "] {
2265            let resolved = Anthropic::resolve_base_url(
2266                Some("http://10.60.1.20:8080/api/v1/llm/anthropic".to_string()),
2267                Some(flag.to_string()),
2268            )
2269            .unwrap_or_else(|e| {
2270                panic!("ANTHROPIC_ALLOW_INSECURE_HTTP={flag:?} should admit http: {e}")
2271            });
2272            assert_eq!(resolved, "http://10.60.1.20:8080/api/v1/llm/anthropic");
2273        }
2274    }
2275
2276    #[test]
2277    fn env_base_url_rejects_non_http_schemes_and_garbage() {
2278        for url in ["ftp://files.example.com", "ws://gateway.example.com", "not-a-url"] {
2279            let err = Anthropic::resolve_base_url(Some(url.to_string()), Some("1".to_string()))
2280                .expect_err("non-https, non-loopback ANTHROPIC_BASE_URL must be rejected");
2281            assert!(err.is_validation(), "expected a validation error for {url}, got {err}");
2282        }
2283    }
2284
2285    #[test]
2286    fn with_base_url_and_timeout_rejects_cleartext_http() {
2287        let err = Anthropic::new(Some("placeholder-api-key".to_string()))
2288            .unwrap()
2289            .with_base_url_and_timeout(
2290                "http://gateway.internal.example.com".to_string(),
2291                Duration::from_secs(5),
2292            )
2293            .expect_err("a non-loopback http base URL must be rejected");
2294        assert!(err.is_validation(), "expected a validation error, got {err}");
2295    }
2296
2297    #[test]
2298    fn client_timeout_configuration() {
2299        let client = Anthropic::new(Some("test_key".to_string())).unwrap();
2300        let timeout = Duration::from_secs(30);
2301
2302        let configured_client = client.with_timeout(timeout).unwrap();
2303        assert_eq!(configured_client.timeout, timeout);
2304    }
2305
2306    #[test]
2307    fn client_cached_headers_performance() {
2308        let client = Anthropic::new(Some("test_key".to_string())).unwrap();
2309
2310        // Test that headers are cached and cloning is cheap
2311        let headers1 = client.default_headers();
2312        let headers2 = client.default_headers();
2313
2314        assert_eq!(headers1.len(), headers2.len());
2315        assert!(headers1.contains_key("x-api-key"));
2316        assert!(headers1.contains_key("anthropic-version"));
2317        assert!(headers1.contains_key("content-type"));
2318    }
2319
2320    #[test]
2321    fn request_error_mapping() {
2322        let client = Anthropic::new(Some("test_key".to_string())).unwrap();
2323
2324        // Test different types of reqwest errors are mapped correctly
2325        // Note: These are unit tests for the mapping logic, not integration tests
2326        let _timeout = Duration::from_secs(30);
2327        assert_eq!(client.timeout, DEFAULT_TIMEOUT); // Should use default initially
2328    }
2329
2330    #[tokio::test]
2331    async fn concurrent_retry_safety() {
2332        use std::sync::atomic::{AtomicUsize, Ordering};
2333        use tokio::spawn;
2334
2335        let client = Anthropic {
2336            api_key: "test".to_string(),
2337            client: ReqwestClient::new(),
2338            stream_client: ReqwestClient::new(),
2339            base_url: "http://localhost".to_string(),
2340            timeout: Duration::from_secs(1),
2341            stream_timeout: None,
2342            max_retries: 1,
2343            throughput_ops_sec: 1.0,
2344            reserve_capacity: 1.0,
2345            cached_headers: Arc::new(HeaderMap::new()),
2346            allow_insecure_http: false,
2347        };
2348
2349        let attempt_counter = Arc::new(AtomicUsize::new(0));
2350        let mut handles = vec![];
2351
2352        // Spawn multiple concurrent retry operations
2353        for _ in 0..3 {
2354            let client_clone = client.clone();
2355            let counter_clone = attempt_counter.clone();
2356
2357            let handle = spawn(async move {
2358                client_clone
2359                    .retry_with_backoff(|| {
2360                        let counter = counter_clone.clone();
2361                        async move {
2362                            counter.fetch_add(1, Ordering::SeqCst);
2363                            Ok::<String, Error>("success".to_string())
2364                        }
2365                    })
2366                    .await
2367            });
2368            handles.push(handle);
2369        }
2370
2371        // Wait for all operations to complete
2372        for handle in handles {
2373            let result = handle.await.unwrap();
2374            assert!(result.is_ok());
2375        }
2376
2377        // Verify all operations executed
2378        assert_eq!(attempt_counter.load(Ordering::SeqCst), 3);
2379    }
2380}