1pub 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
74fn 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
88fn 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
133fn 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
144fn 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}
170const DEFAULT_MAX_TOKENS: u32 = 1024;
175
176#[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_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 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 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 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 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 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 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 pub fn model(&self) -> &str {
276 &self.inner.model
277 }
278
279 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
370fn 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 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
446struct SseState {
448 byte_stream: ByteStream,
449 buffer: String,
450 utf8: Utf8StreamDecoder,
451 queued: VecDeque<ChatResponseUpdate>,
452 tool_use_ids: HashMap<i64, String>,
455 usage: convert::StreamUsageAccumulator,
457 done: bool,
458}
459
460fn 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 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 None
522 }
523 _ => {
524 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 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 _ => 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 assert_eq!(usage.input_token_count, Some(25));
705 assert_eq!(usage.output_token_count, Some(15));
706 }
707
708 #[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 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 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 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 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 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 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 #[test]
1006 fn default_max_tokens_is_1024() {
1007 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 assert_eq!(body["temperature"], serde_json::json!(0.2_f32));
1037
1038 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 #[test]
1050 fn build_body_always_includes_default_beta_flags() {
1051 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 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 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 #[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 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 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 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 }