Skip to main content

agent_framework_anthropic/
lib.rs

1//! # agent-framework-anthropic
2//!
3//! Anthropic (Claude) [`ChatClient`]s for `agent-framework-rs`.
4//!
5//! [`AnthropicClient`] talks directly to the Anthropic Messages API
6//! (`POST /v1/messages`), the same way `agent-framework-openai` talks to Chat
7//! Completions: hand-rolled request/response JSON conversion plus a
8//! hand-rolled SSE parser, with no dependency on Anthropic's own SDK.
9//!
10//! ```no_run
11//! use agent_framework_anthropic::AnthropicClient;
12//! use agent_framework_core::prelude::*;
13//!
14//! # async fn demo() -> Result<()> {
15//! let client = AnthropicClient::new("sk-ant-...", "claude-sonnet-4-5-20250929");
16//! let agent = Agent::builder(client)
17//!     .instructions("You are concise.")
18//!     .build();
19//! let reply = agent.run_once("Say hi").await?;
20//! println!("{}", reply.text());
21//! # Ok(())
22//! # }
23//! ```
24//!
25//! ## Multi-cloud transports
26//!
27//! Claude models are also available through three managed-cloud offerings,
28//! each of which speaks the *same* Anthropic Messages API wire format
29//! [`AnthropicClient`] does — only the URL shape, model-selection mechanism,
30//! and authentication scheme differ. Each is a thin transport built on
31//! [`convert::build_cloud_request`] (the direct API's `convert::build_request`
32//! minus the top-level `model` field, plus a cloud-specific
33//! `anthropic_version` tag) rather than a reimplementation of the wire
34//! format:
35//!
36//! * [`bedrock::AnthropicBedrockClient`] — AWS Bedrock's `InvokeModel` API,
37//!   [SigV4](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html)-signed
38//!   via [`agent_framework_bedrock::sigv4`] (reused, not reimplemented).
39//! * [`vertex::AnthropicVertexClient`] — Google Vertex AI's
40//!   `rawPredict`/`streamRawPredict` publisher-model routes, authenticated
41//!   via a caller-supplied [`vertex::VertexTokenProvider`] (no Google Cloud
42//!   SDK dependency in this workspace; see that module's docs).
43//! * [`foundry::AnthropicFoundryClient`] — Azure AI Foundry Anthropic
44//!   deployments, authenticated via
45//!   [`agent_framework_azure::TokenCredential`] (Microsoft Entra ID); see
46//!   that module's docs for the caveats around its non-stably-documented
47//!   route/version defaults.
48
49pub mod bedrock;
50pub mod convert;
51pub mod foundry;
52pub mod vertex;
53
54pub use bedrock::AnthropicBedrockClient;
55pub use foundry::AnthropicFoundryClient;
56pub use vertex::{AnthropicVertexClient, StaticVertexToken, VertexTokenProvider};
57
58use std::collections::{HashMap, VecDeque};
59use std::sync::Arc;
60
61use agent_framework_core::client::{ChatClient, ChatStream};
62use agent_framework_core::error::{Error, Result};
63use agent_framework_core::streaming::Utf8StreamDecoder;
64use agent_framework_core::types::{
65    ChatOptions, ChatResponse, ChatResponseUpdate, Content, FunctionArguments, FunctionCallContent,
66    Message, Role, TextContent, TextReasoningContent, UsageContent,
67};
68use futures::StreamExt;
69use serde_json::Value;
70
71const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
72const ANTHROPIC_VERSION: &str = "2023-06-01";
73
74/// Parse Anthropic's `retry-after` header into a delay in seconds.
75///
76/// Anthropic returns `retry-after` (in integer seconds) alongside `429` and
77/// overloaded `529` responses; we honor that hint on [`Error::ServiceStatus`]
78/// so a retry layer can wait exactly as long as the server asks. A date-form
79/// or unparseable value is treated as absent.
80fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<f64> {
81    headers
82        .get(reqwest::header::RETRY_AFTER)
83        .and_then(|v| v.to_str().ok())
84        .and_then(|s| s.trim().parse::<f64>().ok())
85        .filter(|s| s.is_finite() && *s >= 0.0)
86}
87
88/// Classify a non-success Anthropic Messages API HTTP response into a
89/// granular [`Error`].
90///
91/// Upstream's Anthropic connector (`agent_framework_anthropic/_chat_client.py`)
92/// does not wrap `messages.create`/`beta.messages.create` in any
93/// status-specific exception handling at all — SDK errors propagate
94/// unchanged, so there is no Python call-site behavior to mirror status by
95/// status here. This instead applies upstream's exception *hierarchy*
96/// (`agent_framework.exceptions.ServiceInvalidAuthError` /
97/// `ServiceInvalidRequestError`) using Anthropic's own documented
98/// status <-> `error.type` convention
99/// (<https://docs.anthropic.com/en/api/errors>):
100///
101/// * `401` / `403` -> [`Error::ServiceInvalidAuth`] (Anthropic's
102///   `authentication_error` / `permission_error`)
103/// * `400` -> [`Error::ServiceInvalidRequest`], but only once the body
104///   confirms `error.type == "invalid_request_error"` (Anthropic's
105///   documented sole `400` type); an unparseable or unexpected body
106///   conservatively falls back to the generic [`Error::ServiceStatus`]
107///   rather than guessing
108/// * anything else — notably `408` / `429` / `5xx`, which the retry layer
109///   depends on — -> [`Error::ServiceStatus`], unchanged
110///
111/// Anthropic has no content-filter-specific HTTP error to classify: a
112/// content-policy refusal is a `200 OK` response with `stop_reason:
113/// "refusal"`, mapped to `FinishReason::CONTENT_FILTER` by
114/// [`convert::map_stop_reason`] (mirroring upstream's `FINISH_REASON_MAP`)
115/// rather than raised as an error, so [`Error::ServiceContentFilter`] is
116/// never constructed on this path — don't invent one.
117fn classify_anthropic_error(
118    status: u16,
119    body: &str,
120    message: impl Into<String>,
121    retry_after: Option<f64>,
122) -> Error {
123    let message = message.into();
124    match status {
125        401 | 403 => Error::service_invalid_auth(message),
126        400 if anthropic_error_type(body).as_deref() == Some("invalid_request_error") => {
127            Error::service_invalid_request(message)
128        }
129        _ => Error::service_status(status, message, retry_after),
130    }
131}
132
133/// The Anthropic error body's `error.type`, if the body parses as JSON and
134/// carries one (e.g. `"invalid_request_error"`, `"authentication_error"`).
135fn anthropic_error_type(body: &str) -> Option<String> {
136    let value: Value = serde_json::from_str(body).ok()?;
137    value
138        .get("error")?
139        .get("type")?
140        .as_str()
141        .map(str::to_string)
142}
143
144/// Build the base `POST /v1/messages` request, including the `anthropic-beta`
145/// header when `betas` is non-empty. Split out from [`AnthropicClient::post`]
146/// so the header-attachment logic is unit-testable without an HTTP round
147/// trip.
148///
149/// Upstream always passes a non-empty `betas` set to `beta.messages.create`
150/// (at minimum [`convert::DEFAULT_BETA_FLAGS`]), so in practice the header is
151/// unconditionally present; the emptiness check here just avoids sending a
152/// spurious empty header if some future caller manages to clear the default
153/// set entirely.
154fn new_message_request(
155    http: &reqwest::Client,
156    url: &str,
157    api_key: &str,
158    betas: &[String],
159) -> reqwest::RequestBuilder {
160    let mut request = http
161        .post(url)
162        .header("x-api-key", api_key)
163        .header("anthropic-version", ANTHROPIC_VERSION)
164        .header("content-type", "application/json");
165    if !betas.is_empty() {
166        request = request.header("anthropic-beta", betas.join(","));
167    }
168    request
169}
170/// `max_tokens` is required by the Anthropic Messages API; this is used
171/// whenever neither `ChatOptions::max_tokens` nor a client-level override is
172/// set. Matches upstream's `ANTHROPIC_DEFAULT_MAX_TOKENS`
173/// (`agent_framework_anthropic/_chat_client.py` ~line 53).
174const DEFAULT_MAX_TOKENS: u32 = 1024;
175
176/// An Anthropic (Claude) Messages API chat client.
177#[derive(Clone)]
178pub struct AnthropicClient {
179    inner: Arc<Inner>,
180}
181
182#[derive(Clone)]
183struct Inner {
184    http: reqwest::Client,
185    api_key: String,
186    base_url: String,
187    model: String,
188    max_tokens: u32,
189    default_options: ChatOptions,
190    /// Additional `anthropic-beta` flags unioned with
191    /// [`convert::DEFAULT_BETA_FLAGS`] on every request. Mirrors upstream's
192    /// `additional_beta_flags` constructor keyword argument
193    /// (`AnthropicClient.__init__`, `_chat_client.py` ~126, stored as
194    /// `self.additional_beta_flags` ~203).
195    additional_beta_flags: Vec<String>,
196}
197
198impl std::fmt::Debug for AnthropicClient {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        f.debug_struct("AnthropicClient")
201            .field("base_url", &self.inner.base_url)
202            .field("model", &self.inner.model)
203            .field("max_tokens", &self.inner.max_tokens)
204            .finish_non_exhaustive()
205    }
206}
207
208impl AnthropicClient {
209    /// Create a client for the given API key and default model.
210    pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
211        Self {
212            inner: Arc::new(Inner {
213                http: reqwest::Client::new(),
214                api_key: api_key.into(),
215                base_url: DEFAULT_BASE_URL.to_string(),
216                model: model.into(),
217                max_tokens: DEFAULT_MAX_TOKENS,
218                default_options: ChatOptions::default(),
219                additional_beta_flags: Vec::new(),
220            }),
221        }
222    }
223
224    /// Build a client from the `ANTHROPIC_API_KEY` (and optional
225    /// `ANTHROPIC_BASE_URL`) environment variables.
226    pub fn from_env(model: impl Into<String>) -> Result<Self> {
227        let key = std::env::var("ANTHROPIC_API_KEY")
228            .map_err(|_| Error::Configuration("ANTHROPIC_API_KEY is not set".into()))?;
229        let mut client = Self::new(key, model);
230        if let Ok(base) = std::env::var("ANTHROPIC_BASE_URL") {
231            client = client.with_base_url(base);
232        }
233        Ok(client)
234    }
235
236    /// Override the base URL (for proxies or private deployments).
237    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
238        Arc::make_mut(&mut self.inner).base_url = base_url.into();
239        self
240    }
241
242    /// Override the default `max_tokens` sent when `ChatOptions::max_tokens`
243    /// is unset (the Anthropic API requires this field on every request).
244    pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
245        Arc::make_mut(&mut self.inner).max_tokens = max_tokens;
246        self
247    }
248
249    /// Set default [`ChatOptions`] applied as a base under any options passed
250    /// per-request (per-request options take precedence; see
251    /// [`ChatOptions::merge`]).
252    pub fn with_default_options(mut self, options: ChatOptions) -> Self {
253        Arc::make_mut(&mut self.inner).default_options = options;
254        self
255    }
256
257    /// Additional `anthropic-beta` flags to send (via the `anthropic-beta`
258    /// header) on every request, unioned with the always-on
259    /// [`convert::DEFAULT_BETA_FLAGS`] and any per-request flags supplied
260    /// through `ChatOptions::additional_properties["additional_beta_flags"]`.
261    ///
262    /// Mirrors upstream's `additional_beta_flags` constructor keyword
263    /// argument (`_chat_client.py` ~126, ~139-140): "Default flags are:
264    /// `mcp-client-2025-04-04`, `code-execution-2025-08-25`."
265    pub fn with_additional_beta_flags(
266        mut self,
267        flags: impl IntoIterator<Item = impl Into<String>>,
268    ) -> Self {
269        Arc::make_mut(&mut self.inner).additional_beta_flags =
270            flags.into_iter().map(Into::into).collect();
271        self
272    }
273
274    /// The default model id.
275    pub fn model(&self) -> &str {
276        &self.inner.model
277    }
278
279    /// Build the request body and the `anthropic-beta` flags for a request.
280    ///
281    /// The beta-flags computation (mirroring upstream's
282    /// `chat_options.additional_properties.pop("additional_beta_flags")`,
283    /// `_chat_client.py` ~254-264) must run against the same *merged* +
284    /// owned [`ChatOptions`] that [`convert::build_request`] then converts,
285    /// and before it does: [`convert::compute_beta_flags`] removes the
286    /// `additional_beta_flags` key from `additional_properties` so it is not
287    /// also copied into the request body as a stray top-level field.
288    fn build_body(
289        &self,
290        messages: &[Message],
291        options: &ChatOptions,
292        stream: bool,
293    ) -> (Value, Vec<String>) {
294        let mut effective = self.inner.default_options.clone().merge(options.clone());
295        let betas = convert::compute_beta_flags(&mut effective, &self.inner.additional_beta_flags);
296        let model = effective
297            .model
298            .clone()
299            .unwrap_or_else(|| self.inner.model.clone());
300        let max_tokens = effective.max_tokens.unwrap_or(self.inner.max_tokens);
301        let body = convert::build_request(messages, &effective, &model, max_tokens, stream);
302        (body, betas)
303    }
304
305    async fn post(&self, body: &Value, betas: &[String]) -> Result<reqwest::Response> {
306        let url = format!("{}/v1/messages", self.inner.base_url.trim_end_matches('/'));
307        let request = new_message_request(&self.inner.http, &url, &self.inner.api_key, betas);
308        let resp = request
309            .json(body)
310            .send()
311            .await
312            .map_err(|e| Error::service(format!("request failed: {e}")))?;
313        if !resp.status().is_success() {
314            let status = resp.status();
315            let retry_after = parse_retry_after(resp.headers());
316            let text = resp.text().await.unwrap_or_default();
317            return Err(classify_anthropic_error(
318                status.as_u16(),
319                &text,
320                format!("Anthropic API error {status}: {text}"),
321                retry_after,
322            ));
323        }
324        Ok(resp)
325    }
326}
327
328#[async_trait::async_trait]
329impl ChatClient for AnthropicClient {
330    async fn get_response(
331        &self,
332        messages: Vec<Message>,
333        options: ChatOptions,
334    ) -> Result<ChatResponse> {
335        let (body, betas) = self.build_body(&messages, &options, false);
336        let resp = self.post(&body, &betas).await?;
337        let value: Value = resp
338            .json()
339            .await
340            .map_err(|e| Error::service(format!("invalid response json: {e}")))?;
341        if let Some(err) = value.get("error") {
342            let msg = err
343                .get("message")
344                .and_then(Value::as_str)
345                .unwrap_or("unknown Anthropic error")
346                .to_string();
347            return Err(Error::service(msg));
348        }
349        Ok(convert::parse_response(&value))
350    }
351
352    async fn get_streaming_response(
353        &self,
354        messages: Vec<Message>,
355        options: ChatOptions,
356    ) -> Result<ChatStream> {
357        let (body, betas) = self.build_body(&messages, &options, true);
358        let resp = self.post(&body, &betas).await?;
359        Ok(parse_sse_stream(resp).boxed())
360    }
361
362    fn model(&self) -> Option<&str> {
363        Some(&self.inner.model)
364    }
365}
366
367type ByteStream =
368    std::pin::Pin<Box<dyn futures::Stream<Item = reqwest::Result<bytes::Bytes>> + Send>>;
369
370/// Turn an Anthropic Messages API SSE HTTP response into a stream of
371/// [`ChatResponseUpdate`]s.
372fn parse_sse_stream(
373    resp: reqwest::Response,
374) -> impl futures::Stream<Item = Result<ChatResponseUpdate>> + Send {
375    let byte_stream: ByteStream = Box::pin(resp.bytes_stream());
376    futures::stream::unfold(
377        SseState {
378            byte_stream,
379            buffer: String::new(),
380            utf8: Utf8StreamDecoder::new(),
381            queued: VecDeque::new(),
382            tool_use_ids: HashMap::new(),
383            done: false,
384        },
385        |mut state| async move {
386            loop {
387                if let Some(update) = state.queued.pop_front() {
388                    return Some((Ok(update), state));
389                }
390                if state.done {
391                    return None;
392                }
393                match state.byte_stream.next().await {
394                    Some(Ok(bytes)) => {
395                        let decoded = state.utf8.push(&bytes);
396                        state.buffer.push_str(&decoded);
397                        while let Some(pos) = state.buffer.find('\n') {
398                            let line = state.buffer[..pos].trim().to_string();
399                            state.buffer.drain(..=pos);
400                            // Anthropic SSE frames an `event: <type>` line
401                            // before each `data: {...}` line, but the JSON
402                            // payload also carries its own `type` field, so
403                            // (like the openai crate) we only need the
404                            // `data:` lines.
405                            let Some(data) = line.strip_prefix("data:") else {
406                                continue;
407                            };
408                            let data = data.trim();
409                            if data.is_empty() {
410                                continue;
411                            }
412                            let Ok(value) = serde_json::from_str::<Value>(data) else {
413                                continue;
414                            };
415                            if value.get("type").and_then(Value::as_str) == Some("error") {
416                                let msg = value
417                                    .get("error")
418                                    .and_then(|e| e.get("message"))
419                                    .and_then(Value::as_str)
420                                    .unwrap_or("unknown Anthropic stream error")
421                                    .to_string();
422                                state.done = true;
423                                return Some((Err(Error::service(msg)), state));
424                            }
425                            if let Some(update) =
426                                parse_stream_event(&value, &mut state.tool_use_ids)
427                            {
428                                state.queued.push_back(update);
429                            }
430                        }
431                    }
432                    Some(Err(e)) => {
433                        state.done = true;
434                        return Some((Err(Error::service(format!("stream error: {e}"))), state));
435                    }
436                    None => return None,
437                }
438            }
439        },
440    )
441}
442
443/// State carried across `unfold` iterations while parsing the SSE stream.
444struct SseState {
445    byte_stream: ByteStream,
446    buffer: String,
447    utf8: Utf8StreamDecoder,
448    queued: VecDeque<ChatResponseUpdate>,
449    /// `content_block` index -> `tool_use` call id, so `input_json_delta`
450    /// fragments (which carry only the index) resolve to the right call.
451    tool_use_ids: HashMap<i64, String>,
452    done: bool,
453}
454
455/// Parse one decoded SSE event into an update, or `None` for event types that
456/// carry no content of their own (`content_block_stop`, `message_stop`,
457/// `ping`, ...).
458fn parse_stream_event(
459    value: &Value,
460    tool_use_ids: &mut HashMap<i64, String>,
461) -> Option<ChatResponseUpdate> {
462    match value.get("type").and_then(Value::as_str)? {
463        "message_start" => {
464            let message = value.get("message")?;
465            let response_id = message.get("id").and_then(Value::as_str).map(String::from);
466            let model = message
467                .get("model")
468                .and_then(Value::as_str)
469                .map(String::from);
470            let mut contents = Vec::new();
471            if let Some(usage) = message.get("usage") {
472                if let Some(usage_content) = convert::parse_message_start_usage(usage) {
473                    contents.push(Content::Usage(usage_content));
474                }
475            }
476            Some(ChatResponseUpdate {
477                contents,
478                role: Some(Role::assistant()),
479                response_id,
480                model,
481                ..Default::default()
482            })
483        }
484        "content_block_start" => {
485            let index = value.get("index").and_then(Value::as_i64).unwrap_or(0);
486            let block = value.get("content_block")?;
487            match block.get("type").and_then(Value::as_str)? {
488                "tool_use" | "mcp_tool_use" | "server_tool_use" => {
489                    let id = block
490                        .get("id")
491                        .and_then(Value::as_str)
492                        .unwrap_or_default()
493                        .to_string();
494                    let name = block
495                        .get("name")
496                        .and_then(Value::as_str)
497                        .unwrap_or_default()
498                        .to_string();
499                    tool_use_ids.insert(index, id.clone());
500                    Some(ChatResponseUpdate {
501                        contents: vec![Content::FunctionCall(FunctionCallContent::new(
502                            id, name, None,
503                        ))],
504                        role: Some(Role::assistant()),
505                        ..Default::default()
506                    })
507                }
508                "text" | "thinking" => {
509                    // Text/thinking blocks always start empty; real content
510                    // only arrives via `content_block_delta`.
511                    None
512                }
513                _ => {
514                    // Atomic hosted-tool result blocks (`mcp_tool_result`,
515                    // `web_search_tool_result`, `web_fetch_tool_result`,
516                    // `code_execution_tool_result`, and siblings) are
517                    // delivered whole in a single `content_block_start`, with
518                    // no follow-up deltas: mirrors upstream, which funnels
519                    // `content_block_start`'s block through the very same
520                    // `_parse_message_contents` used for full (non-streaming)
521                    // responses (`_process_stream_event`'s
522                    // `case "content_block_start":`, `_chat_client.py`
523                    // ~490-495).
524                    let contents = convert::parse_content_blocks(std::slice::from_ref(block));
525                    if contents.is_empty() {
526                        None
527                    } else {
528                        Some(ChatResponseUpdate {
529                            contents,
530                            role: Some(Role::assistant()),
531                            ..Default::default()
532                        })
533                    }
534                }
535            }
536        }
537        "content_block_delta" => {
538            let index = value.get("index").and_then(Value::as_i64).unwrap_or(0);
539            let delta = value.get("delta")?;
540            let content = match delta.get("type").and_then(Value::as_str)? {
541                "text_delta" => Content::Text(TextContent::new(
542                    delta
543                        .get("text")
544                        .and_then(Value::as_str)
545                        .unwrap_or_default(),
546                )),
547                "thinking_delta" => Content::TextReasoning(TextReasoningContent {
548                    text: delta
549                        .get("thinking")
550                        .and_then(Value::as_str)
551                        .unwrap_or_default()
552                        .to_string(),
553                    annotations: None,
554                    ..Default::default()
555                }),
556                "input_json_delta" => {
557                    let call_id = tool_use_ids.get(&index).cloned().unwrap_or_default();
558                    let partial = delta
559                        .get("partial_json")
560                        .and_then(Value::as_str)
561                        .unwrap_or_default();
562                    Content::FunctionCall(FunctionCallContent::new(
563                        call_id,
564                        "",
565                        Some(FunctionArguments::Raw(partial.to_string())),
566                    ))
567                }
568                _ => return None,
569            };
570            Some(ChatResponseUpdate {
571                contents: vec![content],
572                role: Some(Role::assistant()),
573                ..Default::default()
574            })
575        }
576        "message_delta" => {
577            let mut contents = Vec::new();
578            if let Some(usage) = value.get("usage") {
579                contents.push(Content::Usage(UsageContent {
580                    details: convert::parse_usage(usage),
581                }));
582            }
583            let finish_reason = value
584                .get("delta")
585                .and_then(|d| d.get("stop_reason"))
586                .and_then(Value::as_str)
587                .map(convert::map_stop_reason);
588            Some(ChatResponseUpdate {
589                contents,
590                finish_reason,
591                ..Default::default()
592            })
593        }
594        // `content_block_stop`, `message_stop`, `ping`, and anything else
595        // carry no content of their own.
596        _ => None,
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    fn sse_frame(event: &str, data: &Value) -> String {
605        format!("event: {event}\ndata: {data}\n\n")
606    }
607
608    async fn collect_updates(text: String) -> Vec<ChatResponseUpdate> {
609        let stream =
610            futures::stream::once(async move { Ok::<_, reqwest::Error>(bytes::Bytes::from(text)) });
611        let byte_stream: ByteStream = Box::pin(stream);
612        let mut state = SseState {
613            byte_stream,
614            buffer: String::new(),
615            utf8: Utf8StreamDecoder::new(),
616            queued: VecDeque::new(),
617            tool_use_ids: HashMap::new(),
618            done: false,
619        };
620        let mut updates = Vec::new();
621        if let Some(Ok(bytes)) = state.byte_stream.next().await {
622            let decoded = state.utf8.push(&bytes);
623            state.buffer.push_str(&decoded);
624            while let Some(pos) = state.buffer.find('\n') {
625                let line = state.buffer[..pos].trim().to_string();
626                state.buffer.drain(..=pos);
627                let Some(data) = line.strip_prefix("data:") else {
628                    continue;
629                };
630                let data = data.trim();
631                if data.is_empty() {
632                    continue;
633                }
634                let value: Value = serde_json::from_str(data).unwrap();
635                if let Some(update) = parse_stream_event(&value, &mut state.tool_use_ids) {
636                    updates.push(update);
637                }
638            }
639        }
640        updates
641    }
642
643    #[tokio::test]
644    async fn stream_text_only_accumulates() {
645        let mut text = String::new();
646        text.push_str(&sse_frame(
647            "message_start",
648            &serde_json::json!({
649                "type": "message_start",
650                "message": { "id": "msg_1", "model": "claude-x", "usage": { "input_tokens": 25, "output_tokens": 1 } }
651            }),
652        ));
653        text.push_str(&sse_frame(
654            "content_block_start",
655            &serde_json::json!({ "type": "content_block_start", "index": 0, "content_block": { "type": "text", "text": "" } }),
656        ));
657        text.push_str(&sse_frame(
658            "content_block_delta",
659            &serde_json::json!({ "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "Hel" } }),
660        ));
661        text.push_str(&sse_frame(
662            "content_block_delta",
663            &serde_json::json!({ "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "lo!" } }),
664        ));
665        text.push_str(&sse_frame(
666            "content_block_stop",
667            &serde_json::json!({ "type": "content_block_stop", "index": 0 }),
668        ));
669        text.push_str(&sse_frame(
670            "message_delta",
671            &serde_json::json!({ "type": "message_delta", "delta": { "stop_reason": "end_turn" }, "usage": { "output_tokens": 15 } }),
672        ));
673        text.push_str(&sse_frame(
674            "message_stop",
675            &serde_json::json!({ "type": "message_stop" }),
676        ));
677
678        let updates = collect_updates(text).await;
679        let resp = ChatResponse::from_updates(updates);
680        assert_eq!(resp.text(), "Hello!");
681        assert_eq!(resp.response_id.as_deref(), Some("msg_1"));
682        assert_eq!(
683            resp.finish_reason,
684            Some(agent_framework_core::types::FinishReason::stop())
685        );
686        let usage = resp.usage_details.unwrap();
687        // input_tokens comes from message_start; output_tokens from
688        // message_delta only (not doubled with message_start's placeholder).
689        assert_eq!(usage.input_token_count, Some(25));
690        assert_eq!(usage.output_token_count, Some(15));
691    }
692
693    #[tokio::test]
694    async fn stream_tool_call_accumulates_arguments() {
695        let mut text = String::new();
696        text.push_str(&sse_frame(
697            "content_block_start",
698            &serde_json::json!({ "type": "content_block_start", "index": 0, "content_block": { "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {} } }),
699        ));
700        text.push_str(&sse_frame(
701            "content_block_delta",
702            &serde_json::json!({ "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "{\"city\": \"San" } }),
703        ));
704        text.push_str(&sse_frame(
705            "content_block_delta",
706            &serde_json::json!({ "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": " Francisco\"}" } }),
707        ));
708        text.push_str(&sse_frame(
709            "content_block_stop",
710            &serde_json::json!({ "type": "content_block_stop", "index": 0 }),
711        ));
712        text.push_str(&sse_frame(
713            "message_delta",
714            &serde_json::json!({ "type": "message_delta", "delta": { "stop_reason": "tool_use" }, "usage": { "output_tokens": 20 } }),
715        ));
716
717        let updates = collect_updates(text).await;
718        let resp = ChatResponse::from_updates(updates);
719        let calls = resp.function_calls();
720        assert_eq!(calls.len(), 1);
721        assert_eq!(calls[0].call_id, "toolu_1");
722        assert_eq!(calls[0].name, "get_weather");
723        assert_eq!(
724            calls[0].parse_arguments().unwrap().get("city").unwrap(),
725            &serde_json::json!("San Francisco")
726        );
727        assert_eq!(
728            resp.finish_reason,
729            Some(agent_framework_core::types::FinishReason::tool_calls())
730        );
731    }
732
733    #[tokio::test]
734    async fn stream_hosted_tool_use_and_result_via_content_block_start() {
735        // `server_tool_use` (the hosted-tool invocation) and
736        // `web_search_tool_result` (its atomic result) both arrive as
737        // complete `content_block_start` blocks with no follow-up deltas --
738        // mirrors upstream funneling `content_block_start`'s block through
739        // the same content parser used for full responses.
740        let mut text = String::new();
741        text.push_str(&sse_frame(
742            "content_block_start",
743            &serde_json::json!({ "type": "content_block_start", "index": 0, "content_block": { "type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": { "query": "rust" } } }),
744        ));
745        text.push_str(&sse_frame(
746            "content_block_stop",
747            &serde_json::json!({ "type": "content_block_stop", "index": 0 }),
748        ));
749        text.push_str(&sse_frame(
750            "content_block_start",
751            &serde_json::json!({ "type": "content_block_start", "index": 1, "content_block": { "type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", "content": [{ "type": "web_search_result", "url": "https://example.com", "title": "Example" }] } }),
752        ));
753        text.push_str(&sse_frame(
754            "content_block_stop",
755            &serde_json::json!({ "type": "content_block_stop", "index": 1 }),
756        ));
757
758        let updates = collect_updates(text).await;
759        let resp = ChatResponse::from_updates(updates);
760        let calls = resp.function_calls();
761        assert_eq!(calls.len(), 1);
762        assert_eq!(calls[0].call_id, "srvtoolu_1");
763        assert_eq!(calls[0].name, "web_search");
764        let has_function_result = resp
765            .messages
766            .iter()
767            .flat_map(|m| &m.contents)
768            .any(|c| matches!(c, Content::FunctionResult(_)));
769        assert!(
770            has_function_result,
771            "expected a FunctionResult content from the web_search_tool_result block"
772        );
773    }
774
775    #[tokio::test]
776    async fn stream_mcp_tool_use_via_content_block_start() {
777        let text = sse_frame(
778            "content_block_start",
779            &serde_json::json!({ "type": "content_block_start", "index": 0, "content_block": { "type": "mcp_tool_use", "id": "mcptoolu_1", "name": "search_docs", "server_name": "docs", "input": {} } }),
780        );
781        let updates = collect_updates(text).await;
782        let resp = ChatResponse::from_updates(updates);
783        let calls = resp.function_calls();
784        assert_eq!(calls.len(), 1);
785        assert_eq!(calls[0].call_id, "mcptoolu_1");
786        assert_eq!(calls[0].name, "search_docs");
787    }
788
789    #[tokio::test]
790    async fn stream_citations_delta_is_ignored_like_upstream() {
791        // Upstream's `_process_stream_event`/`_parse_message_contents` has no
792        // case for the `citations_delta` delta type (it only appears inside
793        // `content_block_delta`), so it falls through to a debug-logged
794        // no-op; citations are only ever populated from a full `text` block's
795        // `citations` array (non-streaming, or a hypothetical fully-formed
796        // streamed block). This asserts the streaming path tolerates the
797        // event (no panic, no spurious update) rather than mirroring
798        // citation *population* during streaming, which upstream doesn't do
799        // either.
800        let text = sse_frame(
801            "content_block_delta",
802            &serde_json::json!({
803                "type": "content_block_delta",
804                "index": 0,
805                "delta": {
806                    "type": "citations_delta",
807                    "citation": {
808                        "type": "char_location",
809                        "cited_text": "example",
810                        "document_index": 0,
811                        "document_title": "Doc",
812                        "start_char_index": 0,
813                        "end_char_index": 7
814                    }
815                }
816            }),
817        );
818        let updates = collect_updates(text).await;
819        assert!(
820            updates.is_empty(),
821            "citations_delta should not produce an update, matching upstream"
822        );
823    }
824
825    #[tokio::test]
826    async fn stream_error_event_is_surfaced() {
827        let text = sse_frame(
828            "error",
829            &serde_json::json!({ "type": "error", "error": { "type": "overloaded_error", "message": "Overloaded" } }),
830        );
831        let stream =
832            futures::stream::once(async move { Ok::<_, reqwest::Error>(bytes::Bytes::from(text)) });
833        let byte_stream: ByteStream = Box::pin(stream);
834        let mut state = SseState {
835            byte_stream,
836            buffer: String::new(),
837            utf8: Utf8StreamDecoder::new(),
838            queued: VecDeque::new(),
839            tool_use_ids: HashMap::new(),
840            done: false,
841        };
842        let bytes = state.byte_stream.next().await.unwrap().unwrap();
843        let decoded = state.utf8.push(&bytes);
844        state.buffer.push_str(&decoded);
845        let mut saw_error = false;
846        while let Some(pos) = state.buffer.find('\n') {
847            let line = state.buffer[..pos].trim().to_string();
848            state.buffer.drain(..=pos);
849            let Some(data) = line.strip_prefix("data:") else {
850                continue;
851            };
852            let data = data.trim();
853            if data.is_empty() {
854                continue;
855            }
856            let value: Value = serde_json::from_str(data).unwrap();
857            if value.get("type").and_then(Value::as_str) == Some("error") {
858                let msg = value
859                    .get("error")
860                    .and_then(|e| e.get("message"))
861                    .and_then(Value::as_str)
862                    .unwrap_or_default();
863                assert_eq!(msg, "Overloaded");
864                saw_error = true;
865            }
866        }
867        assert!(saw_error, "expected the error event to be recognized");
868    }
869
870    // region: env-var constructor
871
872    /// Guards `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` mutation: tests
873    /// within a crate run on multiple threads, and env vars are
874    /// process-global, so this serializes access across the two tests below.
875    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
876
877    #[test]
878    fn from_env_reads_api_key_and_base_url() {
879        let _guard = ENV_MUTEX.lock().unwrap();
880        // SAFETY: serialized by ENV_MUTEX against the other env-var test in
881        // this module; no other test in this crate touches these variables.
882        unsafe {
883            std::env::set_var("ANTHROPIC_API_KEY", "sk-ant-test-123");
884            std::env::set_var("ANTHROPIC_BASE_URL", "https://example.test");
885        }
886        let client = AnthropicClient::from_env("claude-x").unwrap();
887        assert_eq!(client.inner.api_key, "sk-ant-test-123");
888        assert_eq!(client.inner.base_url, "https://example.test");
889        unsafe {
890            std::env::remove_var("ANTHROPIC_API_KEY");
891            std::env::remove_var("ANTHROPIC_BASE_URL");
892        }
893    }
894
895    #[test]
896    fn from_env_errors_when_api_key_missing() {
897        let _guard = ENV_MUTEX.lock().unwrap();
898        // SAFETY: serialized by ENV_MUTEX; see above.
899        unsafe {
900            std::env::remove_var("ANTHROPIC_API_KEY");
901            std::env::remove_var("ANTHROPIC_BASE_URL");
902        }
903        let result = AnthropicClient::from_env("claude-x");
904        assert!(result.is_err());
905    }
906
907    // endregion
908
909    #[test]
910    fn default_max_tokens_is_1024() {
911        // Matches upstream's `ANTHROPIC_DEFAULT_MAX_TOKENS` (`_chat_client.py`
912        // ~line 53), not the historical Rust default of 4096.
913        let client = AnthropicClient::new("key", "claude-x");
914        let (body, _betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
915        assert_eq!(body["max_tokens"], serde_json::json!(1024));
916    }
917
918    #[test]
919    fn with_max_tokens_overrides_default() {
920        let client = AnthropicClient::new("key", "claude-x").with_max_tokens(8192);
921        let (body, _betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
922        assert_eq!(body["max_tokens"], serde_json::json!(8192));
923    }
924
925    #[test]
926    fn per_request_max_tokens_overrides_client_default() {
927        let client = AnthropicClient::new("key", "claude-x").with_max_tokens(8192);
928        let options = ChatOptions::new().with_max_tokens(256);
929        let (body, _betas) = client.build_body(&[Message::user("hi")], &options, false);
930        assert_eq!(body["max_tokens"], serde_json::json!(256));
931    }
932
933    #[test]
934    fn with_default_options_merged_under_per_request_options() {
935        let client = AnthropicClient::new("key", "claude-x")
936            .with_default_options(ChatOptions::new().with_temperature(0.2));
937        let (body, _betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
938        // `temperature` is `f32`; compare against an `f32` literal so the
939        // widened-to-f64 JSON values match exactly.
940        assert_eq!(body["temperature"], serde_json::json!(0.2_f32));
941
942        // Per-request temperature overrides the client default.
943        let (body2, _betas2) = client.build_body(
944            &[Message::user("hi")],
945            &ChatOptions::new().with_temperature(0.9),
946            false,
947        );
948        assert_eq!(body2["temperature"], serde_json::json!(0.9_f32));
949    }
950
951    // region: beta flags
952
953    #[test]
954    fn build_body_always_includes_default_beta_flags() {
955        // Upstream sends `betas` on every `beta.messages.create` call, not
956        // only when hosted tools/MCP servers are present -- verified against
957        // `_create_run_options` (`_chat_client.py` ~254-264).
958        let client = AnthropicClient::new("key", "claude-x");
959        let (_body, betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
960        assert!(betas.contains(&"mcp-client-2025-04-04".to_string()));
961        assert!(betas.contains(&"code-execution-2025-08-25".to_string()));
962        assert_eq!(betas.len(), 2);
963    }
964
965    #[test]
966    fn build_body_merges_client_level_additional_beta_flags() {
967        let client =
968            AnthropicClient::new("key", "claude-x").with_additional_beta_flags(["my-custom-beta"]);
969        let (_body, betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
970        assert!(betas.contains(&"my-custom-beta".to_string()));
971        assert!(betas.contains(&"mcp-client-2025-04-04".to_string()));
972        assert_eq!(betas.len(), 3);
973    }
974
975    #[test]
976    fn build_body_merges_per_request_additional_beta_flags_and_strips_them_from_body() {
977        let client = AnthropicClient::new("key", "claude-x");
978        let mut options = ChatOptions::new();
979        options.additional_properties.insert(
980            "additional_beta_flags".into(),
981            serde_json::json!(["request-only-beta"]),
982        );
983        let (body, betas) = client.build_body(&[Message::user("hi")], &options, false);
984        assert!(betas.contains(&"request-only-beta".to_string()));
985        // Popped, like upstream's `.pop("additional_beta_flags")` -- must not
986        // leak into the JSON body as a stray top-level field.
987        assert!(body.get("additional_beta_flags").is_none());
988    }
989
990    #[test]
991    fn new_message_request_sets_anthropic_beta_header_when_betas_present() {
992        // This is the same helper `post` calls, so it exercises the actual
993        // header-attachment code path (unlike hitting the network).
994        let http = reqwest::Client::new();
995        let betas = vec!["a".to_string(), "b".to_string()];
996        let request = new_message_request(
997            &http,
998            "https://api.anthropic.com/v1/messages",
999            "test-key",
1000            &betas,
1001        )
1002        .build()
1003        .unwrap();
1004        assert_eq!(request.headers().get("anthropic-beta").unwrap(), "a,b");
1005    }
1006
1007    #[test]
1008    fn new_message_request_omits_anthropic_beta_header_when_betas_empty() {
1009        let http = reqwest::Client::new();
1010        let request = new_message_request(
1011            &http,
1012            "https://api.anthropic.com/v1/messages",
1013            "test-key",
1014            &[],
1015        )
1016        .build()
1017        .unwrap();
1018        assert!(request.headers().get("anthropic-beta").is_none());
1019    }
1020
1021    // endregion
1022
1023    // region: classify_anthropic_error
1024
1025    #[test]
1026    fn classifies_401_and_403_as_invalid_auth() {
1027        for status in [401, 403] {
1028            let body = format!(
1029                r#"{{"type":"error","error":{{"type":"authentication_error","message":"nope {status}"}}}}"#
1030            );
1031            let err = classify_anthropic_error(status, &body, format!("err {status}"), None);
1032            assert!(
1033                matches!(err, Error::ServiceInvalidAuth { .. }),
1034                "status {status}: {err:?}"
1035            );
1036        }
1037    }
1038
1039    #[test]
1040    fn classifies_400_invalid_request_error_as_invalid_request() {
1041        let body = r#"{"type":"error","error":{"type":"invalid_request_error","message":"messages: at least one message is required"}}"#;
1042        let err = classify_anthropic_error(400, body, "err", None);
1043        assert!(
1044            matches!(err, Error::ServiceInvalidRequest { .. }),
1045            "{err:?}"
1046        );
1047    }
1048
1049    #[test]
1050    fn a_400_without_confirming_body_stays_service_status() {
1051        // Conservative: only reclassify once the body actually confirms
1052        // Anthropic's documented `invalid_request_error` type; an
1053        // unparseable or differently-typed body falls back to the generic
1054        // status-carrying variant rather than guessing.
1055        let err = classify_anthropic_error(400, "not json", "err", None);
1056        assert_eq!(err.status(), Some(400), "{err:?}");
1057
1058        let err = classify_anthropic_error(
1059            400,
1060            r#"{"type":"error","error":{"type":"something_else"}}"#,
1061            "err",
1062            None,
1063        );
1064        assert_eq!(err.status(), Some(400), "{err:?}");
1065    }
1066
1067    #[test]
1068    fn leaves_retryable_statuses_as_service_status() {
1069        // 408/429/5xx (and Anthropic's overloaded 529) must stay
1070        // `ServiceStatus` exactly as before — the retry layer depends on it.
1071        for status in [408, 429, 500, 529] {
1072            let err = classify_anthropic_error(status, "", format!("err {status}"), Some(1.5));
1073            assert_eq!(err.status(), Some(status), "{err:?}");
1074            assert_eq!(err.retry_after(), Some(1.5), "{err:?}");
1075        }
1076    }
1077
1078    #[test]
1079    fn never_produces_content_filter() {
1080        // Anthropic has no content-filter-specific HTTP error: content-policy
1081        // refusals surface as `stop_reason: "refusal"` on a 200 (see
1082        // `map_stop_reason_covers_documented_mapping`), never as a non-success
1083        // status, so this path must never invent a `ServiceContentFilter`.
1084        let bodies = [
1085            "",
1086            "not json",
1087            r#"{"type":"error","error":{"type":"invalid_request_error"}}"#,
1088            r#"{"type":"error","error":{"type":"authentication_error"}}"#,
1089        ];
1090        for status in [400, 401, 403, 404, 422, 429, 500] {
1091            for body in bodies {
1092                let err = classify_anthropic_error(status, body, "err", None);
1093                assert!(
1094                    !matches!(err, Error::ServiceContentFilter { .. }),
1095                    "status {status}, body {body:?}: {err:?}"
1096                );
1097            }
1098        }
1099    }
1100
1101    // endregion
1102}