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    #[tokio::test]
709    async fn stream_usage_is_not_double_counted_when_deltas_repeat_input_tokens() {
710        // Anthropic streams *cumulative* usage: `message_delta` repeats the
711        // input/cache counts already reported by `message_start`. Emitting the
712        // raw snapshots aggregated input_tokens to 2x the real figure.
713        let mut text = String::new();
714        text.push_str(&sse_frame(
715            "message_start",
716            &serde_json::json!({
717                "type": "message_start",
718                "message": {
719                    "id": "msg_1",
720                    "model": "claude-sonnet-4",
721                    "usage": {
722                        "input_tokens": 25,
723                        "cache_read_input_tokens": 8,
724                        "output_tokens": 1
725                    }
726                }
727            }),
728        ));
729        text.push_str(&sse_frame(
730            "message_delta",
731            &serde_json::json!({
732                "type": "message_delta",
733                "delta": { "stop_reason": "end_turn" },
734                "usage": {
735                    "input_tokens": 25,
736                    "cache_read_input_tokens": 8,
737                    "output_tokens": 15
738                }
739            }),
740        ));
741
742        let updates = collect_updates(text).await;
743        let resp = ChatResponse::from_updates(updates);
744        let usage = resp.usage_details.unwrap();
745        assert_eq!(usage.input_token_count, Some(25));
746        assert_eq!(usage.output_token_count, Some(15));
747        assert_eq!(usage.cache_read_input_token_count, Some(8));
748    }
749
750    #[tokio::test]
751    async fn stream_tool_call_accumulates_arguments() {
752        let mut text = String::new();
753        text.push_str(&sse_frame(
754            "content_block_start",
755            &serde_json::json!({ "type": "content_block_start", "index": 0, "content_block": { "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {} } }),
756        ));
757        text.push_str(&sse_frame(
758            "content_block_delta",
759            &serde_json::json!({ "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "{\"city\": \"San" } }),
760        ));
761        text.push_str(&sse_frame(
762            "content_block_delta",
763            &serde_json::json!({ "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": " Francisco\"}" } }),
764        ));
765        text.push_str(&sse_frame(
766            "content_block_stop",
767            &serde_json::json!({ "type": "content_block_stop", "index": 0 }),
768        ));
769        text.push_str(&sse_frame(
770            "message_delta",
771            &serde_json::json!({ "type": "message_delta", "delta": { "stop_reason": "tool_use" }, "usage": { "output_tokens": 20 } }),
772        ));
773
774        let updates = collect_updates(text).await;
775        let resp = ChatResponse::from_updates(updates);
776        let calls = resp.function_calls();
777        assert_eq!(calls.len(), 1);
778        assert_eq!(calls[0].call_id, "toolu_1");
779        assert_eq!(calls[0].name, "get_weather");
780        assert_eq!(
781            calls[0].parse_arguments().unwrap().get("city").unwrap(),
782            &serde_json::json!("San Francisco")
783        );
784        assert_eq!(
785            resp.finish_reason,
786            Some(agent_framework_core::types::FinishReason::tool_calls())
787        );
788    }
789
790    #[tokio::test]
791    async fn stream_hosted_tool_use_and_result_via_content_block_start() {
792        // `server_tool_use` (the hosted-tool invocation) and
793        // `web_search_tool_result` (its atomic result) both arrive as
794        // complete `content_block_start` blocks with no follow-up deltas --
795        // mirrors upstream funneling `content_block_start`'s block through
796        // the same content parser used for full responses.
797        let mut text = String::new();
798        text.push_str(&sse_frame(
799            "content_block_start",
800            &serde_json::json!({ "type": "content_block_start", "index": 0, "content_block": { "type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": { "query": "rust" } } }),
801        ));
802        text.push_str(&sse_frame(
803            "content_block_stop",
804            &serde_json::json!({ "type": "content_block_stop", "index": 0 }),
805        ));
806        text.push_str(&sse_frame(
807            "content_block_start",
808            &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" }] } }),
809        ));
810        text.push_str(&sse_frame(
811            "content_block_stop",
812            &serde_json::json!({ "type": "content_block_stop", "index": 1 }),
813        ));
814
815        let updates = collect_updates(text).await;
816        let resp = ChatResponse::from_updates(updates);
817        let calls = resp.function_calls();
818        assert_eq!(calls.len(), 1);
819        assert_eq!(calls[0].call_id, "srvtoolu_1");
820        assert_eq!(calls[0].name, "web_search");
821        let has_function_result = resp
822            .messages
823            .iter()
824            .flat_map(|m| &m.contents)
825            .any(|c| matches!(c, Content::FunctionResult(_)));
826        assert!(
827            has_function_result,
828            "expected a FunctionResult content from the web_search_tool_result block"
829        );
830    }
831
832    #[tokio::test]
833    async fn stream_mcp_tool_use_via_content_block_start() {
834        let text = sse_frame(
835            "content_block_start",
836            &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": {} } }),
837        );
838        let updates = collect_updates(text).await;
839        let resp = ChatResponse::from_updates(updates);
840        let calls = resp.function_calls();
841        assert_eq!(calls.len(), 1);
842        assert_eq!(calls[0].call_id, "mcptoolu_1");
843        assert_eq!(calls[0].name, "search_docs");
844    }
845
846    #[tokio::test]
847    async fn stream_citations_delta_is_ignored_like_upstream() {
848        // Upstream's `_process_stream_event`/`_parse_message_contents` has no
849        // case for the `citations_delta` delta type (it only appears inside
850        // `content_block_delta`), so it falls through to a debug-logged
851        // no-op; citations are only ever populated from a full `text` block's
852        // `citations` array (non-streaming, or a hypothetical fully-formed
853        // streamed block). This asserts the streaming path tolerates the
854        // event (no panic, no spurious update) rather than mirroring
855        // citation *population* during streaming, which upstream doesn't do
856        // either.
857        let text = sse_frame(
858            "content_block_delta",
859            &serde_json::json!({
860                "type": "content_block_delta",
861                "index": 0,
862                "delta": {
863                    "type": "citations_delta",
864                    "citation": {
865                        "type": "char_location",
866                        "cited_text": "example",
867                        "document_index": 0,
868                        "document_title": "Doc",
869                        "start_char_index": 0,
870                        "end_char_index": 7
871                    }
872                }
873            }),
874        );
875        let updates = collect_updates(text).await;
876        assert!(
877            updates.is_empty(),
878            "citations_delta should not produce an update, matching upstream"
879        );
880    }
881
882    #[tokio::test]
883    async fn stream_error_event_is_surfaced() {
884        let text = sse_frame(
885            "error",
886            &serde_json::json!({ "type": "error", "error": { "type": "overloaded_error", "message": "Overloaded" } }),
887        );
888        let stream =
889            futures::stream::once(async move { Ok::<_, reqwest::Error>(bytes::Bytes::from(text)) });
890        let byte_stream: ByteStream = Box::pin(stream);
891        let mut state = SseState {
892            byte_stream,
893            buffer: String::new(),
894            utf8: Utf8StreamDecoder::new(),
895            queued: VecDeque::new(),
896            tool_use_ids: HashMap::new(),
897            usage: convert::StreamUsageAccumulator::default(),
898            done: false,
899        };
900        let bytes = state.byte_stream.next().await.unwrap().unwrap();
901        let decoded = state.utf8.push(&bytes);
902        state.buffer.push_str(&decoded);
903        let mut saw_error = false;
904        while let Some(pos) = state.buffer.find('\n') {
905            let line = state.buffer[..pos].trim().to_string();
906            state.buffer.drain(..=pos);
907            let Some(data) = line.strip_prefix("data:") else {
908                continue;
909            };
910            let data = data.trim();
911            if data.is_empty() {
912                continue;
913            }
914            let value: Value = serde_json::from_str(data).unwrap();
915            if value.get("type").and_then(Value::as_str) == Some("error") {
916                let msg = value
917                    .get("error")
918                    .and_then(|e| e.get("message"))
919                    .and_then(Value::as_str)
920                    .unwrap_or_default();
921                assert_eq!(msg, "Overloaded");
922                saw_error = true;
923            }
924        }
925        assert!(saw_error, "expected the error event to be recognized");
926    }
927
928    // region: env-var constructor
929
930    /// Guards `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` mutation: tests
931    /// within a crate run on multiple threads, and env vars are
932    /// process-global, so this serializes access across the two tests below.
933    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
934
935    #[test]
936    fn from_env_reads_api_key_and_base_url() {
937        let _guard = ENV_MUTEX.lock().unwrap();
938        // SAFETY: serialized by ENV_MUTEX against the other env-var test in
939        // this module; no other test in this crate touches these variables.
940        unsafe {
941            std::env::set_var("ANTHROPIC_API_KEY", "sk-ant-test-123");
942            std::env::set_var("ANTHROPIC_BASE_URL", "https://example.test");
943        }
944        let client = AnthropicClient::from_env("claude-x").unwrap();
945        assert_eq!(client.inner.api_key, "sk-ant-test-123");
946        assert_eq!(client.inner.base_url, "https://example.test");
947        unsafe {
948            std::env::remove_var("ANTHROPIC_API_KEY");
949            std::env::remove_var("ANTHROPIC_BASE_URL");
950        }
951    }
952
953    #[test]
954    fn from_env_errors_when_api_key_missing() {
955        let _guard = ENV_MUTEX.lock().unwrap();
956        // SAFETY: serialized by ENV_MUTEX; see above.
957        unsafe {
958            std::env::remove_var("ANTHROPIC_API_KEY");
959            std::env::remove_var("ANTHROPIC_BASE_URL");
960        }
961        let result = AnthropicClient::from_env("claude-x");
962        assert!(result.is_err());
963    }
964
965    // endregion
966
967    #[test]
968    fn default_max_tokens_is_1024() {
969        // Matches upstream's `ANTHROPIC_DEFAULT_MAX_TOKENS` (`_chat_client.py`
970        // ~line 53), not the historical Rust default of 4096.
971        let client = AnthropicClient::new("key", "claude-x");
972        let (body, _betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
973        assert_eq!(body["max_tokens"], serde_json::json!(1024));
974    }
975
976    #[test]
977    fn with_max_tokens_overrides_default() {
978        let client = AnthropicClient::new("key", "claude-x").with_max_tokens(8192);
979        let (body, _betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
980        assert_eq!(body["max_tokens"], serde_json::json!(8192));
981    }
982
983    #[test]
984    fn per_request_max_tokens_overrides_client_default() {
985        let client = AnthropicClient::new("key", "claude-x").with_max_tokens(8192);
986        let options = ChatOptions::new().with_max_tokens(256);
987        let (body, _betas) = client.build_body(&[Message::user("hi")], &options, false);
988        assert_eq!(body["max_tokens"], serde_json::json!(256));
989    }
990
991    #[test]
992    fn with_default_options_merged_under_per_request_options() {
993        let client = AnthropicClient::new("key", "claude-x")
994            .with_default_options(ChatOptions::new().with_temperature(0.2));
995        let (body, _betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
996        // `temperature` is `f32`; compare against an `f32` literal so the
997        // widened-to-f64 JSON values match exactly.
998        assert_eq!(body["temperature"], serde_json::json!(0.2_f32));
999
1000        // Per-request temperature overrides the client default.
1001        let (body2, _betas2) = client.build_body(
1002            &[Message::user("hi")],
1003            &ChatOptions::new().with_temperature(0.9),
1004            false,
1005        );
1006        assert_eq!(body2["temperature"], serde_json::json!(0.9_f32));
1007    }
1008
1009    // region: beta flags
1010
1011    #[test]
1012    fn build_body_always_includes_default_beta_flags() {
1013        // Upstream sends `betas` on every `beta.messages.create` call, not
1014        // only when hosted tools/MCP servers are present -- verified against
1015        // `_create_run_options` (`_chat_client.py` ~254-264).
1016        let client = AnthropicClient::new("key", "claude-x");
1017        let (_body, betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
1018        assert!(betas.contains(&"mcp-client-2025-04-04".to_string()));
1019        assert!(betas.contains(&"code-execution-2025-08-25".to_string()));
1020        assert_eq!(betas.len(), 2);
1021    }
1022
1023    #[test]
1024    fn build_body_merges_client_level_additional_beta_flags() {
1025        let client =
1026            AnthropicClient::new("key", "claude-x").with_additional_beta_flags(["my-custom-beta"]);
1027        let (_body, betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
1028        assert!(betas.contains(&"my-custom-beta".to_string()));
1029        assert!(betas.contains(&"mcp-client-2025-04-04".to_string()));
1030        assert_eq!(betas.len(), 3);
1031    }
1032
1033    #[test]
1034    fn build_body_merges_per_request_additional_beta_flags_and_strips_them_from_body() {
1035        let client = AnthropicClient::new("key", "claude-x");
1036        let mut options = ChatOptions::new();
1037        options.additional_properties.insert(
1038            "additional_beta_flags".into(),
1039            serde_json::json!(["request-only-beta"]),
1040        );
1041        let (body, betas) = client.build_body(&[Message::user("hi")], &options, false);
1042        assert!(betas.contains(&"request-only-beta".to_string()));
1043        // Popped, like upstream's `.pop("additional_beta_flags")` -- must not
1044        // leak into the JSON body as a stray top-level field.
1045        assert!(body.get("additional_beta_flags").is_none());
1046    }
1047
1048    #[test]
1049    fn new_message_request_sets_anthropic_beta_header_when_betas_present() {
1050        // This is the same helper `post` calls, so it exercises the actual
1051        // header-attachment code path (unlike hitting the network).
1052        let http = reqwest::Client::new();
1053        let betas = vec!["a".to_string(), "b".to_string()];
1054        let request = new_message_request(
1055            &http,
1056            "https://api.anthropic.com/v1/messages",
1057            "test-key",
1058            &betas,
1059        )
1060        .build()
1061        .unwrap();
1062        assert_eq!(request.headers().get("anthropic-beta").unwrap(), "a,b");
1063    }
1064
1065    #[test]
1066    fn new_message_request_omits_anthropic_beta_header_when_betas_empty() {
1067        let http = reqwest::Client::new();
1068        let request = new_message_request(
1069            &http,
1070            "https://api.anthropic.com/v1/messages",
1071            "test-key",
1072            &[],
1073        )
1074        .build()
1075        .unwrap();
1076        assert!(request.headers().get("anthropic-beta").is_none());
1077    }
1078
1079    // endregion
1080
1081    // region: classify_anthropic_error
1082
1083    #[test]
1084    fn classifies_401_and_403_as_invalid_auth() {
1085        for status in [401, 403] {
1086            let body = format!(
1087                r#"{{"type":"error","error":{{"type":"authentication_error","message":"nope {status}"}}}}"#
1088            );
1089            let err = classify_anthropic_error(status, &body, format!("err {status}"), None);
1090            assert!(
1091                matches!(err, Error::ServiceInvalidAuth { .. }),
1092                "status {status}: {err:?}"
1093            );
1094        }
1095    }
1096
1097    #[test]
1098    fn classifies_400_invalid_request_error_as_invalid_request() {
1099        let body = r#"{"type":"error","error":{"type":"invalid_request_error","message":"messages: at least one message is required"}}"#;
1100        let err = classify_anthropic_error(400, body, "err", None);
1101        assert!(
1102            matches!(err, Error::ServiceInvalidRequest { .. }),
1103            "{err:?}"
1104        );
1105    }
1106
1107    #[test]
1108    fn a_400_without_confirming_body_stays_service_status() {
1109        // Conservative: only reclassify once the body actually confirms
1110        // Anthropic's documented `invalid_request_error` type; an
1111        // unparseable or differently-typed body falls back to the generic
1112        // status-carrying variant rather than guessing.
1113        let err = classify_anthropic_error(400, "not json", "err", None);
1114        assert_eq!(err.status(), Some(400), "{err:?}");
1115
1116        let err = classify_anthropic_error(
1117            400,
1118            r#"{"type":"error","error":{"type":"something_else"}}"#,
1119            "err",
1120            None,
1121        );
1122        assert_eq!(err.status(), Some(400), "{err:?}");
1123    }
1124
1125    #[test]
1126    fn leaves_retryable_statuses_as_service_status() {
1127        // 408/429/5xx (and Anthropic's overloaded 529) must stay
1128        // `ServiceStatus` exactly as before — the retry layer depends on it.
1129        for status in [408, 429, 500, 529] {
1130            let err = classify_anthropic_error(status, "", format!("err {status}"), Some(1.5));
1131            assert_eq!(err.status(), Some(status), "{err:?}");
1132            assert_eq!(err.retry_after(), Some(1.5), "{err:?}");
1133        }
1134    }
1135
1136    #[test]
1137    fn never_produces_content_filter() {
1138        // Anthropic has no content-filter-specific HTTP error: content-policy
1139        // refusals surface as `stop_reason: "refusal"` on a 200 (see
1140        // `map_stop_reason_covers_documented_mapping`), never as a non-success
1141        // status, so this path must never invent a `ServiceContentFilter`.
1142        let bodies = [
1143            "",
1144            "not json",
1145            r#"{"type":"error","error":{"type":"invalid_request_error"}}"#,
1146            r#"{"type":"error","error":{"type":"authentication_error"}}"#,
1147        ];
1148        for status in [400, 401, 403, 404, 422, 429, 500] {
1149            for body in bodies {
1150                let err = classify_anthropic_error(status, body, "err", None);
1151                assert!(
1152                    !matches!(err, Error::ServiceContentFilter { .. }),
1153                    "status {status}, body {body:?}: {err:?}"
1154                );
1155            }
1156        }
1157    }
1158
1159    // endregion
1160}