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            usage: convert::StreamUsageAccumulator::default(),
384            done: false,
385        },
386        |mut state| async move {
387            loop {
388                if let Some(update) = state.queued.pop_front() {
389                    return Some((Ok(update), state));
390                }
391                if state.done {
392                    return None;
393                }
394                match state.byte_stream.next().await {
395                    Some(Ok(bytes)) => {
396                        let decoded = state.utf8.push(&bytes);
397                        state.buffer.push_str(&decoded);
398                        while let Some(pos) = state.buffer.find('\n') {
399                            let line = state.buffer[..pos].trim().to_string();
400                            state.buffer.drain(..=pos);
401                            // Anthropic SSE frames an `event: <type>` line
402                            // before each `data: {...}` line, but the JSON
403                            // payload also carries its own `type` field, so
404                            // (like the openai crate) we only need the
405                            // `data:` lines.
406                            let Some(data) = line.strip_prefix("data:") else {
407                                continue;
408                            };
409                            let data = data.trim();
410                            if data.is_empty() {
411                                continue;
412                            }
413                            let Ok(value) = serde_json::from_str::<Value>(data) else {
414                                continue;
415                            };
416                            if value.get("type").and_then(Value::as_str) == Some("error") {
417                                let msg = value
418                                    .get("error")
419                                    .and_then(|e| e.get("message"))
420                                    .and_then(Value::as_str)
421                                    .unwrap_or("unknown Anthropic stream error")
422                                    .to_string();
423                                state.done = true;
424                                return Some((Err(Error::service(msg)), state));
425                            }
426                            if let Some(update) = parse_stream_event(
427                                &value,
428                                &mut state.tool_use_ids,
429                                &mut state.usage,
430                            ) {
431                                state.queued.push_back(update);
432                            }
433                        }
434                    }
435                    Some(Err(e)) => {
436                        state.done = true;
437                        return Some((Err(Error::service(format!("stream error: {e}"))), state));
438                    }
439                    None => return None,
440                }
441            }
442        },
443    )
444}
445
446/// State carried across `unfold` iterations while parsing the SSE stream.
447struct SseState {
448    byte_stream: ByteStream,
449    buffer: String,
450    utf8: Utf8StreamDecoder,
451    queued: VecDeque<ChatResponseUpdate>,
452    /// `content_block` index -> `tool_use` call id, so `input_json_delta`
453    /// fragments (which carry only the index) resolve to the right call.
454    tool_use_ids: HashMap<i64, String>,
455    /// Turns Anthropic's cumulative usage snapshots into per-update increments.
456    usage: convert::StreamUsageAccumulator,
457    done: bool,
458}
459
460/// Parse one decoded SSE event into an update, or `None` for event types that
461/// carry no content of their own (`content_block_stop`, `message_stop`,
462/// `ping`, ...).
463fn parse_stream_event(
464    value: &Value,
465    tool_use_ids: &mut HashMap<i64, String>,
466    usage_acc: &mut convert::StreamUsageAccumulator,
467) -> Option<ChatResponseUpdate> {
468    match value.get("type").and_then(Value::as_str)? {
469        "message_start" => {
470            let message = value.get("message")?;
471            let response_id = message.get("id").and_then(Value::as_str).map(String::from);
472            let model = message
473                .get("model")
474                .and_then(Value::as_str)
475                .map(String::from);
476            let mut contents = Vec::new();
477            if let Some(usage) = message.get("usage") {
478                if let Some(usage_content) = convert::parse_message_start_usage(usage) {
479                    // Seed the accumulator: `message_start`'s counts are the
480                    // first cumulative snapshot, so the increment equals it.
481                    contents.push(Content::Usage(UsageContent {
482                        details: usage_acc.increment(&usage_content.details),
483                    }));
484                }
485            }
486            Some(ChatResponseUpdate {
487                contents,
488                role: Some(Role::assistant()),
489                response_id,
490                model,
491                ..Default::default()
492            })
493        }
494        "content_block_start" => {
495            let index = value.get("index").and_then(Value::as_i64).unwrap_or(0);
496            let block = value.get("content_block")?;
497            match block.get("type").and_then(Value::as_str)? {
498                "tool_use" | "mcp_tool_use" | "server_tool_use" => {
499                    let id = block
500                        .get("id")
501                        .and_then(Value::as_str)
502                        .unwrap_or_default()
503                        .to_string();
504                    let name = block
505                        .get("name")
506                        .and_then(Value::as_str)
507                        .unwrap_or_default()
508                        .to_string();
509                    tool_use_ids.insert(index, id.clone());
510                    Some(ChatResponseUpdate {
511                        contents: vec![Content::FunctionCall(FunctionCallContent::new(
512                            id, name, None,
513                        ))],
514                        role: Some(Role::assistant()),
515                        ..Default::default()
516                    })
517                }
518                "text" | "thinking" => {
519                    // Text/thinking blocks always start empty; real content
520                    // only arrives via `content_block_delta`.
521                    None
522                }
523                _ => {
524                    // Atomic hosted-tool result blocks (`mcp_tool_result`,
525                    // `web_search_tool_result`, `web_fetch_tool_result`,
526                    // `code_execution_tool_result`, and siblings) are
527                    // delivered whole in a single `content_block_start`, with
528                    // no follow-up deltas: mirrors upstream, which funnels
529                    // `content_block_start`'s block through the very same
530                    // `_parse_message_contents` used for full (non-streaming)
531                    // responses (`_process_stream_event`'s
532                    // `case "content_block_start":`, `_chat_client.py`
533                    // ~490-495).
534                    let contents = convert::parse_content_blocks(std::slice::from_ref(block));
535                    if contents.is_empty() {
536                        None
537                    } else {
538                        Some(ChatResponseUpdate {
539                            contents,
540                            role: Some(Role::assistant()),
541                            ..Default::default()
542                        })
543                    }
544                }
545            }
546        }
547        "content_block_delta" => {
548            let index = value.get("index").and_then(Value::as_i64).unwrap_or(0);
549            let delta = value.get("delta")?;
550            let content = match delta.get("type").and_then(Value::as_str)? {
551                "text_delta" => Content::Text(TextContent::new(
552                    delta
553                        .get("text")
554                        .and_then(Value::as_str)
555                        .unwrap_or_default(),
556                )),
557                "thinking_delta" => Content::TextReasoning(TextReasoningContent {
558                    text: delta
559                        .get("thinking")
560                        .and_then(Value::as_str)
561                        .unwrap_or_default()
562                        .to_string(),
563                    annotations: None,
564                    ..Default::default()
565                }),
566                "input_json_delta" => {
567                    let call_id = tool_use_ids.get(&index).cloned().unwrap_or_default();
568                    let partial = delta
569                        .get("partial_json")
570                        .and_then(Value::as_str)
571                        .unwrap_or_default();
572                    Content::FunctionCall(FunctionCallContent::new(
573                        call_id,
574                        "",
575                        Some(FunctionArguments::Raw(partial.to_string())),
576                    ))
577                }
578                _ => return None,
579            };
580            Some(ChatResponseUpdate {
581                contents: vec![content],
582                role: Some(Role::assistant()),
583                ..Default::default()
584            })
585        }
586        "message_delta" => {
587            let mut contents = Vec::new();
588            if let Some(usage) = value.get("usage") {
589                // `message_delta.usage` is the running total for the message,
590                // not a per-delta increment: emit only what it adds.
591                contents.push(Content::Usage(UsageContent {
592                    details: usage_acc.increment(&convert::parse_usage(usage)),
593                }));
594            }
595            let finish_reason = value
596                .get("delta")
597                .and_then(|d| d.get("stop_reason"))
598                .and_then(Value::as_str)
599                .map(convert::map_stop_reason);
600            Some(ChatResponseUpdate {
601                contents,
602                finish_reason,
603                ..Default::default()
604            })
605        }
606        // `content_block_stop`, `message_stop`, `ping`, and anything else
607        // carry no content of their own.
608        _ => None,
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615
616    fn sse_frame(event: &str, data: &Value) -> String {
617        format!("event: {event}\ndata: {data}\n\n")
618    }
619
620    async fn collect_updates(text: String) -> Vec<ChatResponseUpdate> {
621        let stream =
622            futures::stream::once(async move { Ok::<_, reqwest::Error>(bytes::Bytes::from(text)) });
623        let byte_stream: ByteStream = Box::pin(stream);
624        let mut state = SseState {
625            byte_stream,
626            buffer: String::new(),
627            utf8: Utf8StreamDecoder::new(),
628            queued: VecDeque::new(),
629            tool_use_ids: HashMap::new(),
630            usage: convert::StreamUsageAccumulator::default(),
631            done: false,
632        };
633        let mut updates = Vec::new();
634        if let Some(Ok(bytes)) = state.byte_stream.next().await {
635            let decoded = state.utf8.push(&bytes);
636            state.buffer.push_str(&decoded);
637            while let Some(pos) = state.buffer.find('\n') {
638                let line = state.buffer[..pos].trim().to_string();
639                state.buffer.drain(..=pos);
640                let Some(data) = line.strip_prefix("data:") else {
641                    continue;
642                };
643                let data = data.trim();
644                if data.is_empty() {
645                    continue;
646                }
647                let value: Value = serde_json::from_str(data).unwrap();
648                if let Some(update) =
649                    parse_stream_event(&value, &mut state.tool_use_ids, &mut state.usage)
650                {
651                    updates.push(update);
652                }
653            }
654        }
655        updates
656    }
657
658    #[tokio::test]
659    async fn stream_text_only_accumulates() {
660        let mut text = String::new();
661        text.push_str(&sse_frame(
662            "message_start",
663            &serde_json::json!({
664                "type": "message_start",
665                "message": { "id": "msg_1", "model": "claude-x", "usage": { "input_tokens": 25, "output_tokens": 1 } }
666            }),
667        ));
668        text.push_str(&sse_frame(
669            "content_block_start",
670            &serde_json::json!({ "type": "content_block_start", "index": 0, "content_block": { "type": "text", "text": "" } }),
671        ));
672        text.push_str(&sse_frame(
673            "content_block_delta",
674            &serde_json::json!({ "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "Hel" } }),
675        ));
676        text.push_str(&sse_frame(
677            "content_block_delta",
678            &serde_json::json!({ "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "lo!" } }),
679        ));
680        text.push_str(&sse_frame(
681            "content_block_stop",
682            &serde_json::json!({ "type": "content_block_stop", "index": 0 }),
683        ));
684        text.push_str(&sse_frame(
685            "message_delta",
686            &serde_json::json!({ "type": "message_delta", "delta": { "stop_reason": "end_turn" }, "usage": { "output_tokens": 15 } }),
687        ));
688        text.push_str(&sse_frame(
689            "message_stop",
690            &serde_json::json!({ "type": "message_stop" }),
691        ));
692
693        let updates = collect_updates(text).await;
694        let resp = ChatResponse::from_updates(updates);
695        assert_eq!(resp.text(), "Hello!");
696        assert_eq!(resp.response_id.as_deref(), Some("msg_1"));
697        assert_eq!(
698            resp.finish_reason,
699            Some(agent_framework_core::types::FinishReason::stop())
700        );
701        let usage = resp.usage_details.unwrap();
702        // input_tokens comes from message_start; output_tokens from
703        // message_delta only (not doubled with message_start's placeholder).
704        assert_eq!(usage.input_token_count, Some(25));
705        assert_eq!(usage.output_token_count, Some(15));
706    }
707
708    /// The streaming counterpart of
709    /// `convert::tests::map_stop_reason_passes_unmapped_values_through`
710    /// (upstream #7850): `message_delta` is the other of the two sites that
711    /// resolve a stop reason, and Python dropped unmapped values on both. It
712    /// routes through the same [`convert::map_stop_reason`], so an unknown
713    /// value survives aggregation onto the final response.
714    #[tokio::test]
715    async fn stream_passes_an_unmapped_stop_reason_through() {
716        let mut text = String::new();
717        text.push_str(&sse_frame(
718            "message_start",
719            &serde_json::json!({
720                "type": "message_start",
721                "message": { "id": "msg_1", "model": "claude-x", "usage": { "input_tokens": 25, "output_tokens": 1 } }
722            }),
723        ));
724        text.push_str(&sse_frame(
725            "message_delta",
726            &serde_json::json!({
727                "type": "message_delta",
728                "delta": { "stop_reason": "model_context_window_exceeded" },
729                "usage": { "output_tokens": 15 }
730            }),
731        ));
732        text.push_str(&sse_frame(
733            "message_stop",
734            &serde_json::json!({ "type": "message_stop" }),
735        ));
736
737        let resp = ChatResponse::from_updates(collect_updates(text).await);
738        assert_eq!(
739            resp.finish_reason,
740            Some(agent_framework_core::types::FinishReason::new(
741                "model_context_window_exceeded"
742            ))
743        );
744    }
745
746    #[tokio::test]
747    async fn stream_usage_is_not_double_counted_when_deltas_repeat_input_tokens() {
748        // Anthropic streams *cumulative* usage: `message_delta` repeats the
749        // input/cache counts already reported by `message_start`. Emitting the
750        // raw snapshots aggregated input_tokens to 2x the real figure.
751        let mut text = String::new();
752        text.push_str(&sse_frame(
753            "message_start",
754            &serde_json::json!({
755                "type": "message_start",
756                "message": {
757                    "id": "msg_1",
758                    "model": "claude-sonnet-4",
759                    "usage": {
760                        "input_tokens": 25,
761                        "cache_read_input_tokens": 8,
762                        "output_tokens": 1
763                    }
764                }
765            }),
766        ));
767        text.push_str(&sse_frame(
768            "message_delta",
769            &serde_json::json!({
770                "type": "message_delta",
771                "delta": { "stop_reason": "end_turn" },
772                "usage": {
773                    "input_tokens": 25,
774                    "cache_read_input_tokens": 8,
775                    "output_tokens": 15
776                }
777            }),
778        ));
779
780        let updates = collect_updates(text).await;
781        let resp = ChatResponse::from_updates(updates);
782        let usage = resp.usage_details.unwrap();
783        assert_eq!(usage.input_token_count, Some(25));
784        assert_eq!(usage.output_token_count, Some(15));
785        assert_eq!(usage.cache_read_input_token_count, Some(8));
786    }
787
788    #[tokio::test]
789    async fn stream_tool_call_accumulates_arguments() {
790        let mut text = String::new();
791        text.push_str(&sse_frame(
792            "content_block_start",
793            &serde_json::json!({ "type": "content_block_start", "index": 0, "content_block": { "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {} } }),
794        ));
795        text.push_str(&sse_frame(
796            "content_block_delta",
797            &serde_json::json!({ "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "{\"city\": \"San" } }),
798        ));
799        text.push_str(&sse_frame(
800            "content_block_delta",
801            &serde_json::json!({ "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": " Francisco\"}" } }),
802        ));
803        text.push_str(&sse_frame(
804            "content_block_stop",
805            &serde_json::json!({ "type": "content_block_stop", "index": 0 }),
806        ));
807        text.push_str(&sse_frame(
808            "message_delta",
809            &serde_json::json!({ "type": "message_delta", "delta": { "stop_reason": "tool_use" }, "usage": { "output_tokens": 20 } }),
810        ));
811
812        let updates = collect_updates(text).await;
813        let resp = ChatResponse::from_updates(updates);
814        let calls = resp.function_calls();
815        assert_eq!(calls.len(), 1);
816        assert_eq!(calls[0].call_id, "toolu_1");
817        assert_eq!(calls[0].name, "get_weather");
818        assert_eq!(
819            calls[0].parse_arguments().unwrap().get("city").unwrap(),
820            &serde_json::json!("San Francisco")
821        );
822        assert_eq!(
823            resp.finish_reason,
824            Some(agent_framework_core::types::FinishReason::tool_calls())
825        );
826    }
827
828    #[tokio::test]
829    async fn stream_hosted_tool_use_and_result_via_content_block_start() {
830        // `server_tool_use` (the hosted-tool invocation) and
831        // `web_search_tool_result` (its atomic result) both arrive as
832        // complete `content_block_start` blocks with no follow-up deltas --
833        // mirrors upstream funneling `content_block_start`'s block through
834        // the same content parser used for full responses.
835        let mut text = String::new();
836        text.push_str(&sse_frame(
837            "content_block_start",
838            &serde_json::json!({ "type": "content_block_start", "index": 0, "content_block": { "type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": { "query": "rust" } } }),
839        ));
840        text.push_str(&sse_frame(
841            "content_block_stop",
842            &serde_json::json!({ "type": "content_block_stop", "index": 0 }),
843        ));
844        text.push_str(&sse_frame(
845            "content_block_start",
846            &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" }] } }),
847        ));
848        text.push_str(&sse_frame(
849            "content_block_stop",
850            &serde_json::json!({ "type": "content_block_stop", "index": 1 }),
851        ));
852
853        let updates = collect_updates(text).await;
854        let resp = ChatResponse::from_updates(updates);
855        let calls = resp.function_calls();
856        assert_eq!(calls.len(), 1);
857        assert_eq!(calls[0].call_id, "srvtoolu_1");
858        assert_eq!(calls[0].name, "web_search");
859        let has_function_result = resp
860            .messages
861            .iter()
862            .flat_map(|m| &m.contents)
863            .any(|c| matches!(c, Content::FunctionResult(_)));
864        assert!(
865            has_function_result,
866            "expected a FunctionResult content from the web_search_tool_result block"
867        );
868    }
869
870    #[tokio::test]
871    async fn stream_mcp_tool_use_via_content_block_start() {
872        let text = sse_frame(
873            "content_block_start",
874            &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": {} } }),
875        );
876        let updates = collect_updates(text).await;
877        let resp = ChatResponse::from_updates(updates);
878        let calls = resp.function_calls();
879        assert_eq!(calls.len(), 1);
880        assert_eq!(calls[0].call_id, "mcptoolu_1");
881        assert_eq!(calls[0].name, "search_docs");
882    }
883
884    #[tokio::test]
885    async fn stream_citations_delta_is_ignored_like_upstream() {
886        // Upstream's `_process_stream_event`/`_parse_message_contents` has no
887        // case for the `citations_delta` delta type (it only appears inside
888        // `content_block_delta`), so it falls through to a debug-logged
889        // no-op; citations are only ever populated from a full `text` block's
890        // `citations` array (non-streaming, or a hypothetical fully-formed
891        // streamed block). This asserts the streaming path tolerates the
892        // event (no panic, no spurious update) rather than mirroring
893        // citation *population* during streaming, which upstream doesn't do
894        // either.
895        let text = sse_frame(
896            "content_block_delta",
897            &serde_json::json!({
898                "type": "content_block_delta",
899                "index": 0,
900                "delta": {
901                    "type": "citations_delta",
902                    "citation": {
903                        "type": "char_location",
904                        "cited_text": "example",
905                        "document_index": 0,
906                        "document_title": "Doc",
907                        "start_char_index": 0,
908                        "end_char_index": 7
909                    }
910                }
911            }),
912        );
913        let updates = collect_updates(text).await;
914        assert!(
915            updates.is_empty(),
916            "citations_delta should not produce an update, matching upstream"
917        );
918    }
919
920    #[tokio::test]
921    async fn stream_error_event_is_surfaced() {
922        let text = sse_frame(
923            "error",
924            &serde_json::json!({ "type": "error", "error": { "type": "overloaded_error", "message": "Overloaded" } }),
925        );
926        let stream =
927            futures::stream::once(async move { Ok::<_, reqwest::Error>(bytes::Bytes::from(text)) });
928        let byte_stream: ByteStream = Box::pin(stream);
929        let mut state = SseState {
930            byte_stream,
931            buffer: String::new(),
932            utf8: Utf8StreamDecoder::new(),
933            queued: VecDeque::new(),
934            tool_use_ids: HashMap::new(),
935            usage: convert::StreamUsageAccumulator::default(),
936            done: false,
937        };
938        let bytes = state.byte_stream.next().await.unwrap().unwrap();
939        let decoded = state.utf8.push(&bytes);
940        state.buffer.push_str(&decoded);
941        let mut saw_error = false;
942        while let Some(pos) = state.buffer.find('\n') {
943            let line = state.buffer[..pos].trim().to_string();
944            state.buffer.drain(..=pos);
945            let Some(data) = line.strip_prefix("data:") else {
946                continue;
947            };
948            let data = data.trim();
949            if data.is_empty() {
950                continue;
951            }
952            let value: Value = serde_json::from_str(data).unwrap();
953            if value.get("type").and_then(Value::as_str) == Some("error") {
954                let msg = value
955                    .get("error")
956                    .and_then(|e| e.get("message"))
957                    .and_then(Value::as_str)
958                    .unwrap_or_default();
959                assert_eq!(msg, "Overloaded");
960                saw_error = true;
961            }
962        }
963        assert!(saw_error, "expected the error event to be recognized");
964    }
965
966    // region: env-var constructor
967
968    /// Guards `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` mutation: tests
969    /// within a crate run on multiple threads, and env vars are
970    /// process-global, so this serializes access across the two tests below.
971    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
972
973    #[test]
974    fn from_env_reads_api_key_and_base_url() {
975        let _guard = ENV_MUTEX.lock().unwrap();
976        // SAFETY: serialized by ENV_MUTEX against the other env-var test in
977        // this module; no other test in this crate touches these variables.
978        unsafe {
979            std::env::set_var("ANTHROPIC_API_KEY", "sk-ant-test-123");
980            std::env::set_var("ANTHROPIC_BASE_URL", "https://example.test");
981        }
982        let client = AnthropicClient::from_env("claude-x").unwrap();
983        assert_eq!(client.inner.api_key, "sk-ant-test-123");
984        assert_eq!(client.inner.base_url, "https://example.test");
985        unsafe {
986            std::env::remove_var("ANTHROPIC_API_KEY");
987            std::env::remove_var("ANTHROPIC_BASE_URL");
988        }
989    }
990
991    #[test]
992    fn from_env_errors_when_api_key_missing() {
993        let _guard = ENV_MUTEX.lock().unwrap();
994        // SAFETY: serialized by ENV_MUTEX; see above.
995        unsafe {
996            std::env::remove_var("ANTHROPIC_API_KEY");
997            std::env::remove_var("ANTHROPIC_BASE_URL");
998        }
999        let result = AnthropicClient::from_env("claude-x");
1000        assert!(result.is_err());
1001    }
1002
1003    // endregion
1004
1005    #[test]
1006    fn default_max_tokens_is_1024() {
1007        // Matches upstream's `ANTHROPIC_DEFAULT_MAX_TOKENS` (`_chat_client.py`
1008        // ~line 53), not the historical Rust default of 4096.
1009        let client = AnthropicClient::new("key", "claude-x");
1010        let (body, _betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
1011        assert_eq!(body["max_tokens"], serde_json::json!(1024));
1012    }
1013
1014    #[test]
1015    fn with_max_tokens_overrides_default() {
1016        let client = AnthropicClient::new("key", "claude-x").with_max_tokens(8192);
1017        let (body, _betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
1018        assert_eq!(body["max_tokens"], serde_json::json!(8192));
1019    }
1020
1021    #[test]
1022    fn per_request_max_tokens_overrides_client_default() {
1023        let client = AnthropicClient::new("key", "claude-x").with_max_tokens(8192);
1024        let options = ChatOptions::new().with_max_tokens(256);
1025        let (body, _betas) = client.build_body(&[Message::user("hi")], &options, false);
1026        assert_eq!(body["max_tokens"], serde_json::json!(256));
1027    }
1028
1029    #[test]
1030    fn with_default_options_merged_under_per_request_options() {
1031        let client = AnthropicClient::new("key", "claude-x")
1032            .with_default_options(ChatOptions::new().with_temperature(0.2));
1033        let (body, _betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
1034        // `temperature` is `f32`; compare against an `f32` literal so the
1035        // widened-to-f64 JSON values match exactly.
1036        assert_eq!(body["temperature"], serde_json::json!(0.2_f32));
1037
1038        // Per-request temperature overrides the client default.
1039        let (body2, _betas2) = client.build_body(
1040            &[Message::user("hi")],
1041            &ChatOptions::new().with_temperature(0.9),
1042            false,
1043        );
1044        assert_eq!(body2["temperature"], serde_json::json!(0.9_f32));
1045    }
1046
1047    // region: beta flags
1048
1049    #[test]
1050    fn build_body_always_includes_default_beta_flags() {
1051        // Upstream sends `betas` on every `beta.messages.create` call, not
1052        // only when hosted tools/MCP servers are present -- verified against
1053        // `_create_run_options` (`_chat_client.py` ~254-264).
1054        let client = AnthropicClient::new("key", "claude-x");
1055        let (_body, betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
1056        assert!(betas.contains(&"mcp-client-2025-04-04".to_string()));
1057        assert!(betas.contains(&"code-execution-2025-08-25".to_string()));
1058        assert_eq!(betas.len(), 2);
1059    }
1060
1061    #[test]
1062    fn build_body_merges_client_level_additional_beta_flags() {
1063        let client =
1064            AnthropicClient::new("key", "claude-x").with_additional_beta_flags(["my-custom-beta"]);
1065        let (_body, betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
1066        assert!(betas.contains(&"my-custom-beta".to_string()));
1067        assert!(betas.contains(&"mcp-client-2025-04-04".to_string()));
1068        assert_eq!(betas.len(), 3);
1069    }
1070
1071    #[test]
1072    fn build_body_merges_per_request_additional_beta_flags_and_strips_them_from_body() {
1073        let client = AnthropicClient::new("key", "claude-x");
1074        let mut options = ChatOptions::new();
1075        options.additional_properties.insert(
1076            "additional_beta_flags".into(),
1077            serde_json::json!(["request-only-beta"]),
1078        );
1079        let (body, betas) = client.build_body(&[Message::user("hi")], &options, false);
1080        assert!(betas.contains(&"request-only-beta".to_string()));
1081        // Popped, like upstream's `.pop("additional_beta_flags")` -- must not
1082        // leak into the JSON body as a stray top-level field.
1083        assert!(body.get("additional_beta_flags").is_none());
1084    }
1085
1086    #[test]
1087    fn new_message_request_sets_anthropic_beta_header_when_betas_present() {
1088        // This is the same helper `post` calls, so it exercises the actual
1089        // header-attachment code path (unlike hitting the network).
1090        let http = reqwest::Client::new();
1091        let betas = vec!["a".to_string(), "b".to_string()];
1092        let request = new_message_request(
1093            &http,
1094            "https://api.anthropic.com/v1/messages",
1095            "test-key",
1096            &betas,
1097        )
1098        .build()
1099        .unwrap();
1100        assert_eq!(request.headers().get("anthropic-beta").unwrap(), "a,b");
1101    }
1102
1103    #[test]
1104    fn new_message_request_omits_anthropic_beta_header_when_betas_empty() {
1105        let http = reqwest::Client::new();
1106        let request = new_message_request(
1107            &http,
1108            "https://api.anthropic.com/v1/messages",
1109            "test-key",
1110            &[],
1111        )
1112        .build()
1113        .unwrap();
1114        assert!(request.headers().get("anthropic-beta").is_none());
1115    }
1116
1117    // endregion
1118
1119    // region: classify_anthropic_error
1120
1121    #[test]
1122    fn classifies_401_and_403_as_invalid_auth() {
1123        for status in [401, 403] {
1124            let body = format!(
1125                r#"{{"type":"error","error":{{"type":"authentication_error","message":"nope {status}"}}}}"#
1126            );
1127            let err = classify_anthropic_error(status, &body, format!("err {status}"), None);
1128            assert!(
1129                matches!(err, Error::ServiceInvalidAuth { .. }),
1130                "status {status}: {err:?}"
1131            );
1132        }
1133    }
1134
1135    #[test]
1136    fn classifies_400_invalid_request_error_as_invalid_request() {
1137        let body = r#"{"type":"error","error":{"type":"invalid_request_error","message":"messages: at least one message is required"}}"#;
1138        let err = classify_anthropic_error(400, body, "err", None);
1139        assert!(
1140            matches!(err, Error::ServiceInvalidRequest { .. }),
1141            "{err:?}"
1142        );
1143    }
1144
1145    #[test]
1146    fn a_400_without_confirming_body_stays_service_status() {
1147        // Conservative: only reclassify once the body actually confirms
1148        // Anthropic's documented `invalid_request_error` type; an
1149        // unparseable or differently-typed body falls back to the generic
1150        // status-carrying variant rather than guessing.
1151        let err = classify_anthropic_error(400, "not json", "err", None);
1152        assert_eq!(err.status(), Some(400), "{err:?}");
1153
1154        let err = classify_anthropic_error(
1155            400,
1156            r#"{"type":"error","error":{"type":"something_else"}}"#,
1157            "err",
1158            None,
1159        );
1160        assert_eq!(err.status(), Some(400), "{err:?}");
1161    }
1162
1163    #[test]
1164    fn leaves_retryable_statuses_as_service_status() {
1165        // 408/429/5xx (and Anthropic's overloaded 529) must stay
1166        // `ServiceStatus` exactly as before — the retry layer depends on it.
1167        for status in [408, 429, 500, 529] {
1168            let err = classify_anthropic_error(status, "", format!("err {status}"), Some(1.5));
1169            assert_eq!(err.status(), Some(status), "{err:?}");
1170            assert_eq!(err.retry_after(), Some(1.5), "{err:?}");
1171        }
1172    }
1173
1174    #[test]
1175    fn never_produces_content_filter() {
1176        // Anthropic has no content-filter-specific HTTP error: content-policy
1177        // refusals surface as `stop_reason: "refusal"` on a 200 (see
1178        // `map_stop_reason_covers_documented_mapping`), never as a non-success
1179        // status, so this path must never invent a `ServiceContentFilter`.
1180        let bodies = [
1181            "",
1182            "not json",
1183            r#"{"type":"error","error":{"type":"invalid_request_error"}}"#,
1184            r#"{"type":"error","error":{"type":"authentication_error"}}"#,
1185        ];
1186        for status in [400, 401, 403, 404, 422, 429, 500] {
1187            for body in bodies {
1188                let err = classify_anthropic_error(status, body, "err", None);
1189                assert!(
1190                    !matches!(err, Error::ServiceContentFilter { .. }),
1191                    "status {status}, body {body:?}: {err:?}"
1192                );
1193            }
1194        }
1195    }
1196
1197    // endregion
1198}