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 done: false,
384 },
385 |mut state| async move {
386 loop {
387 if let Some(update) = state.queued.pop_front() {
388 return Some((Ok(update), state));
389 }
390 if state.done {
391 return None;
392 }
393 match state.byte_stream.next().await {
394 Some(Ok(bytes)) => {
395 let decoded = state.utf8.push(&bytes);
396 state.buffer.push_str(&decoded);
397 while let Some(pos) = state.buffer.find('\n') {
398 let line = state.buffer[..pos].trim().to_string();
399 state.buffer.drain(..=pos);
400 let Some(data) = line.strip_prefix("data:") else {
406 continue;
407 };
408 let data = data.trim();
409 if data.is_empty() {
410 continue;
411 }
412 let Ok(value) = serde_json::from_str::<Value>(data) else {
413 continue;
414 };
415 if value.get("type").and_then(Value::as_str) == Some("error") {
416 let msg = value
417 .get("error")
418 .and_then(|e| e.get("message"))
419 .and_then(Value::as_str)
420 .unwrap_or("unknown Anthropic stream error")
421 .to_string();
422 state.done = true;
423 return Some((Err(Error::service(msg)), state));
424 }
425 if let Some(update) =
426 parse_stream_event(&value, &mut state.tool_use_ids)
427 {
428 state.queued.push_back(update);
429 }
430 }
431 }
432 Some(Err(e)) => {
433 state.done = true;
434 return Some((Err(Error::service(format!("stream error: {e}"))), state));
435 }
436 None => return None,
437 }
438 }
439 },
440 )
441}
442
443struct SseState {
445 byte_stream: ByteStream,
446 buffer: String,
447 utf8: Utf8StreamDecoder,
448 queued: VecDeque<ChatResponseUpdate>,
449 tool_use_ids: HashMap<i64, String>,
452 done: bool,
453}
454
455fn parse_stream_event(
459 value: &Value,
460 tool_use_ids: &mut HashMap<i64, String>,
461) -> Option<ChatResponseUpdate> {
462 match value.get("type").and_then(Value::as_str)? {
463 "message_start" => {
464 let message = value.get("message")?;
465 let response_id = message.get("id").and_then(Value::as_str).map(String::from);
466 let model = message
467 .get("model")
468 .and_then(Value::as_str)
469 .map(String::from);
470 let mut contents = Vec::new();
471 if let Some(usage) = message.get("usage") {
472 if let Some(usage_content) = convert::parse_message_start_usage(usage) {
473 contents.push(Content::Usage(usage_content));
474 }
475 }
476 Some(ChatResponseUpdate {
477 contents,
478 role: Some(Role::assistant()),
479 response_id,
480 model,
481 ..Default::default()
482 })
483 }
484 "content_block_start" => {
485 let index = value.get("index").and_then(Value::as_i64).unwrap_or(0);
486 let block = value.get("content_block")?;
487 match block.get("type").and_then(Value::as_str)? {
488 "tool_use" | "mcp_tool_use" | "server_tool_use" => {
489 let id = block
490 .get("id")
491 .and_then(Value::as_str)
492 .unwrap_or_default()
493 .to_string();
494 let name = block
495 .get("name")
496 .and_then(Value::as_str)
497 .unwrap_or_default()
498 .to_string();
499 tool_use_ids.insert(index, id.clone());
500 Some(ChatResponseUpdate {
501 contents: vec![Content::FunctionCall(FunctionCallContent::new(
502 id, name, None,
503 ))],
504 role: Some(Role::assistant()),
505 ..Default::default()
506 })
507 }
508 "text" | "thinking" => {
509 None
512 }
513 _ => {
514 let contents = convert::parse_content_blocks(std::slice::from_ref(block));
525 if contents.is_empty() {
526 None
527 } else {
528 Some(ChatResponseUpdate {
529 contents,
530 role: Some(Role::assistant()),
531 ..Default::default()
532 })
533 }
534 }
535 }
536 }
537 "content_block_delta" => {
538 let index = value.get("index").and_then(Value::as_i64).unwrap_or(0);
539 let delta = value.get("delta")?;
540 let content = match delta.get("type").and_then(Value::as_str)? {
541 "text_delta" => Content::Text(TextContent::new(
542 delta
543 .get("text")
544 .and_then(Value::as_str)
545 .unwrap_or_default(),
546 )),
547 "thinking_delta" => Content::TextReasoning(TextReasoningContent {
548 text: delta
549 .get("thinking")
550 .and_then(Value::as_str)
551 .unwrap_or_default()
552 .to_string(),
553 annotations: None,
554 ..Default::default()
555 }),
556 "input_json_delta" => {
557 let call_id = tool_use_ids.get(&index).cloned().unwrap_or_default();
558 let partial = delta
559 .get("partial_json")
560 .and_then(Value::as_str)
561 .unwrap_or_default();
562 Content::FunctionCall(FunctionCallContent::new(
563 call_id,
564 "",
565 Some(FunctionArguments::Raw(partial.to_string())),
566 ))
567 }
568 _ => return None,
569 };
570 Some(ChatResponseUpdate {
571 contents: vec![content],
572 role: Some(Role::assistant()),
573 ..Default::default()
574 })
575 }
576 "message_delta" => {
577 let mut contents = Vec::new();
578 if let Some(usage) = value.get("usage") {
579 contents.push(Content::Usage(UsageContent {
580 details: convert::parse_usage(usage),
581 }));
582 }
583 let finish_reason = value
584 .get("delta")
585 .and_then(|d| d.get("stop_reason"))
586 .and_then(Value::as_str)
587 .map(convert::map_stop_reason);
588 Some(ChatResponseUpdate {
589 contents,
590 finish_reason,
591 ..Default::default()
592 })
593 }
594 _ => None,
597 }
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603
604 fn sse_frame(event: &str, data: &Value) -> String {
605 format!("event: {event}\ndata: {data}\n\n")
606 }
607
608 async fn collect_updates(text: String) -> Vec<ChatResponseUpdate> {
609 let stream =
610 futures::stream::once(async move { Ok::<_, reqwest::Error>(bytes::Bytes::from(text)) });
611 let byte_stream: ByteStream = Box::pin(stream);
612 let mut state = SseState {
613 byte_stream,
614 buffer: String::new(),
615 utf8: Utf8StreamDecoder::new(),
616 queued: VecDeque::new(),
617 tool_use_ids: HashMap::new(),
618 done: false,
619 };
620 let mut updates = Vec::new();
621 if let Some(Ok(bytes)) = state.byte_stream.next().await {
622 let decoded = state.utf8.push(&bytes);
623 state.buffer.push_str(&decoded);
624 while let Some(pos) = state.buffer.find('\n') {
625 let line = state.buffer[..pos].trim().to_string();
626 state.buffer.drain(..=pos);
627 let Some(data) = line.strip_prefix("data:") else {
628 continue;
629 };
630 let data = data.trim();
631 if data.is_empty() {
632 continue;
633 }
634 let value: Value = serde_json::from_str(data).unwrap();
635 if let Some(update) = parse_stream_event(&value, &mut state.tool_use_ids) {
636 updates.push(update);
637 }
638 }
639 }
640 updates
641 }
642
643 #[tokio::test]
644 async fn stream_text_only_accumulates() {
645 let mut text = String::new();
646 text.push_str(&sse_frame(
647 "message_start",
648 &serde_json::json!({
649 "type": "message_start",
650 "message": { "id": "msg_1", "model": "claude-x", "usage": { "input_tokens": 25, "output_tokens": 1 } }
651 }),
652 ));
653 text.push_str(&sse_frame(
654 "content_block_start",
655 &serde_json::json!({ "type": "content_block_start", "index": 0, "content_block": { "type": "text", "text": "" } }),
656 ));
657 text.push_str(&sse_frame(
658 "content_block_delta",
659 &serde_json::json!({ "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "Hel" } }),
660 ));
661 text.push_str(&sse_frame(
662 "content_block_delta",
663 &serde_json::json!({ "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "lo!" } }),
664 ));
665 text.push_str(&sse_frame(
666 "content_block_stop",
667 &serde_json::json!({ "type": "content_block_stop", "index": 0 }),
668 ));
669 text.push_str(&sse_frame(
670 "message_delta",
671 &serde_json::json!({ "type": "message_delta", "delta": { "stop_reason": "end_turn" }, "usage": { "output_tokens": 15 } }),
672 ));
673 text.push_str(&sse_frame(
674 "message_stop",
675 &serde_json::json!({ "type": "message_stop" }),
676 ));
677
678 let updates = collect_updates(text).await;
679 let resp = ChatResponse::from_updates(updates);
680 assert_eq!(resp.text(), "Hello!");
681 assert_eq!(resp.response_id.as_deref(), Some("msg_1"));
682 assert_eq!(
683 resp.finish_reason,
684 Some(agent_framework_core::types::FinishReason::stop())
685 );
686 let usage = resp.usage_details.unwrap();
687 assert_eq!(usage.input_token_count, Some(25));
690 assert_eq!(usage.output_token_count, Some(15));
691 }
692
693 #[tokio::test]
694 async fn stream_tool_call_accumulates_arguments() {
695 let mut text = String::new();
696 text.push_str(&sse_frame(
697 "content_block_start",
698 &serde_json::json!({ "type": "content_block_start", "index": 0, "content_block": { "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {} } }),
699 ));
700 text.push_str(&sse_frame(
701 "content_block_delta",
702 &serde_json::json!({ "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "{\"city\": \"San" } }),
703 ));
704 text.push_str(&sse_frame(
705 "content_block_delta",
706 &serde_json::json!({ "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": " Francisco\"}" } }),
707 ));
708 text.push_str(&sse_frame(
709 "content_block_stop",
710 &serde_json::json!({ "type": "content_block_stop", "index": 0 }),
711 ));
712 text.push_str(&sse_frame(
713 "message_delta",
714 &serde_json::json!({ "type": "message_delta", "delta": { "stop_reason": "tool_use" }, "usage": { "output_tokens": 20 } }),
715 ));
716
717 let updates = collect_updates(text).await;
718 let resp = ChatResponse::from_updates(updates);
719 let calls = resp.function_calls();
720 assert_eq!(calls.len(), 1);
721 assert_eq!(calls[0].call_id, "toolu_1");
722 assert_eq!(calls[0].name, "get_weather");
723 assert_eq!(
724 calls[0].parse_arguments().unwrap().get("city").unwrap(),
725 &serde_json::json!("San Francisco")
726 );
727 assert_eq!(
728 resp.finish_reason,
729 Some(agent_framework_core::types::FinishReason::tool_calls())
730 );
731 }
732
733 #[tokio::test]
734 async fn stream_hosted_tool_use_and_result_via_content_block_start() {
735 let mut text = String::new();
741 text.push_str(&sse_frame(
742 "content_block_start",
743 &serde_json::json!({ "type": "content_block_start", "index": 0, "content_block": { "type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": { "query": "rust" } } }),
744 ));
745 text.push_str(&sse_frame(
746 "content_block_stop",
747 &serde_json::json!({ "type": "content_block_stop", "index": 0 }),
748 ));
749 text.push_str(&sse_frame(
750 "content_block_start",
751 &serde_json::json!({ "type": "content_block_start", "index": 1, "content_block": { "type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", "content": [{ "type": "web_search_result", "url": "https://example.com", "title": "Example" }] } }),
752 ));
753 text.push_str(&sse_frame(
754 "content_block_stop",
755 &serde_json::json!({ "type": "content_block_stop", "index": 1 }),
756 ));
757
758 let updates = collect_updates(text).await;
759 let resp = ChatResponse::from_updates(updates);
760 let calls = resp.function_calls();
761 assert_eq!(calls.len(), 1);
762 assert_eq!(calls[0].call_id, "srvtoolu_1");
763 assert_eq!(calls[0].name, "web_search");
764 let has_function_result = resp
765 .messages
766 .iter()
767 .flat_map(|m| &m.contents)
768 .any(|c| matches!(c, Content::FunctionResult(_)));
769 assert!(
770 has_function_result,
771 "expected a FunctionResult content from the web_search_tool_result block"
772 );
773 }
774
775 #[tokio::test]
776 async fn stream_mcp_tool_use_via_content_block_start() {
777 let text = sse_frame(
778 "content_block_start",
779 &serde_json::json!({ "type": "content_block_start", "index": 0, "content_block": { "type": "mcp_tool_use", "id": "mcptoolu_1", "name": "search_docs", "server_name": "docs", "input": {} } }),
780 );
781 let updates = collect_updates(text).await;
782 let resp = ChatResponse::from_updates(updates);
783 let calls = resp.function_calls();
784 assert_eq!(calls.len(), 1);
785 assert_eq!(calls[0].call_id, "mcptoolu_1");
786 assert_eq!(calls[0].name, "search_docs");
787 }
788
789 #[tokio::test]
790 async fn stream_citations_delta_is_ignored_like_upstream() {
791 let text = sse_frame(
801 "content_block_delta",
802 &serde_json::json!({
803 "type": "content_block_delta",
804 "index": 0,
805 "delta": {
806 "type": "citations_delta",
807 "citation": {
808 "type": "char_location",
809 "cited_text": "example",
810 "document_index": 0,
811 "document_title": "Doc",
812 "start_char_index": 0,
813 "end_char_index": 7
814 }
815 }
816 }),
817 );
818 let updates = collect_updates(text).await;
819 assert!(
820 updates.is_empty(),
821 "citations_delta should not produce an update, matching upstream"
822 );
823 }
824
825 #[tokio::test]
826 async fn stream_error_event_is_surfaced() {
827 let text = sse_frame(
828 "error",
829 &serde_json::json!({ "type": "error", "error": { "type": "overloaded_error", "message": "Overloaded" } }),
830 );
831 let stream =
832 futures::stream::once(async move { Ok::<_, reqwest::Error>(bytes::Bytes::from(text)) });
833 let byte_stream: ByteStream = Box::pin(stream);
834 let mut state = SseState {
835 byte_stream,
836 buffer: String::new(),
837 utf8: Utf8StreamDecoder::new(),
838 queued: VecDeque::new(),
839 tool_use_ids: HashMap::new(),
840 done: false,
841 };
842 let bytes = state.byte_stream.next().await.unwrap().unwrap();
843 let decoded = state.utf8.push(&bytes);
844 state.buffer.push_str(&decoded);
845 let mut saw_error = false;
846 while let Some(pos) = state.buffer.find('\n') {
847 let line = state.buffer[..pos].trim().to_string();
848 state.buffer.drain(..=pos);
849 let Some(data) = line.strip_prefix("data:") else {
850 continue;
851 };
852 let data = data.trim();
853 if data.is_empty() {
854 continue;
855 }
856 let value: Value = serde_json::from_str(data).unwrap();
857 if value.get("type").and_then(Value::as_str) == Some("error") {
858 let msg = value
859 .get("error")
860 .and_then(|e| e.get("message"))
861 .and_then(Value::as_str)
862 .unwrap_or_default();
863 assert_eq!(msg, "Overloaded");
864 saw_error = true;
865 }
866 }
867 assert!(saw_error, "expected the error event to be recognized");
868 }
869
870 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
876
877 #[test]
878 fn from_env_reads_api_key_and_base_url() {
879 let _guard = ENV_MUTEX.lock().unwrap();
880 unsafe {
883 std::env::set_var("ANTHROPIC_API_KEY", "sk-ant-test-123");
884 std::env::set_var("ANTHROPIC_BASE_URL", "https://example.test");
885 }
886 let client = AnthropicClient::from_env("claude-x").unwrap();
887 assert_eq!(client.inner.api_key, "sk-ant-test-123");
888 assert_eq!(client.inner.base_url, "https://example.test");
889 unsafe {
890 std::env::remove_var("ANTHROPIC_API_KEY");
891 std::env::remove_var("ANTHROPIC_BASE_URL");
892 }
893 }
894
895 #[test]
896 fn from_env_errors_when_api_key_missing() {
897 let _guard = ENV_MUTEX.lock().unwrap();
898 unsafe {
900 std::env::remove_var("ANTHROPIC_API_KEY");
901 std::env::remove_var("ANTHROPIC_BASE_URL");
902 }
903 let result = AnthropicClient::from_env("claude-x");
904 assert!(result.is_err());
905 }
906
907 #[test]
910 fn default_max_tokens_is_1024() {
911 let client = AnthropicClient::new("key", "claude-x");
914 let (body, _betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
915 assert_eq!(body["max_tokens"], serde_json::json!(1024));
916 }
917
918 #[test]
919 fn with_max_tokens_overrides_default() {
920 let client = AnthropicClient::new("key", "claude-x").with_max_tokens(8192);
921 let (body, _betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
922 assert_eq!(body["max_tokens"], serde_json::json!(8192));
923 }
924
925 #[test]
926 fn per_request_max_tokens_overrides_client_default() {
927 let client = AnthropicClient::new("key", "claude-x").with_max_tokens(8192);
928 let options = ChatOptions::new().with_max_tokens(256);
929 let (body, _betas) = client.build_body(&[Message::user("hi")], &options, false);
930 assert_eq!(body["max_tokens"], serde_json::json!(256));
931 }
932
933 #[test]
934 fn with_default_options_merged_under_per_request_options() {
935 let client = AnthropicClient::new("key", "claude-x")
936 .with_default_options(ChatOptions::new().with_temperature(0.2));
937 let (body, _betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
938 assert_eq!(body["temperature"], serde_json::json!(0.2_f32));
941
942 let (body2, _betas2) = client.build_body(
944 &[Message::user("hi")],
945 &ChatOptions::new().with_temperature(0.9),
946 false,
947 );
948 assert_eq!(body2["temperature"], serde_json::json!(0.9_f32));
949 }
950
951 #[test]
954 fn build_body_always_includes_default_beta_flags() {
955 let client = AnthropicClient::new("key", "claude-x");
959 let (_body, betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
960 assert!(betas.contains(&"mcp-client-2025-04-04".to_string()));
961 assert!(betas.contains(&"code-execution-2025-08-25".to_string()));
962 assert_eq!(betas.len(), 2);
963 }
964
965 #[test]
966 fn build_body_merges_client_level_additional_beta_flags() {
967 let client =
968 AnthropicClient::new("key", "claude-x").with_additional_beta_flags(["my-custom-beta"]);
969 let (_body, betas) = client.build_body(&[Message::user("hi")], &ChatOptions::new(), false);
970 assert!(betas.contains(&"my-custom-beta".to_string()));
971 assert!(betas.contains(&"mcp-client-2025-04-04".to_string()));
972 assert_eq!(betas.len(), 3);
973 }
974
975 #[test]
976 fn build_body_merges_per_request_additional_beta_flags_and_strips_them_from_body() {
977 let client = AnthropicClient::new("key", "claude-x");
978 let mut options = ChatOptions::new();
979 options.additional_properties.insert(
980 "additional_beta_flags".into(),
981 serde_json::json!(["request-only-beta"]),
982 );
983 let (body, betas) = client.build_body(&[Message::user("hi")], &options, false);
984 assert!(betas.contains(&"request-only-beta".to_string()));
985 assert!(body.get("additional_beta_flags").is_none());
988 }
989
990 #[test]
991 fn new_message_request_sets_anthropic_beta_header_when_betas_present() {
992 let http = reqwest::Client::new();
995 let betas = vec!["a".to_string(), "b".to_string()];
996 let request = new_message_request(
997 &http,
998 "https://api.anthropic.com/v1/messages",
999 "test-key",
1000 &betas,
1001 )
1002 .build()
1003 .unwrap();
1004 assert_eq!(request.headers().get("anthropic-beta").unwrap(), "a,b");
1005 }
1006
1007 #[test]
1008 fn new_message_request_omits_anthropic_beta_header_when_betas_empty() {
1009 let http = reqwest::Client::new();
1010 let request = new_message_request(
1011 &http,
1012 "https://api.anthropic.com/v1/messages",
1013 "test-key",
1014 &[],
1015 )
1016 .build()
1017 .unwrap();
1018 assert!(request.headers().get("anthropic-beta").is_none());
1019 }
1020
1021 #[test]
1026 fn classifies_401_and_403_as_invalid_auth() {
1027 for status in [401, 403] {
1028 let body = format!(
1029 r#"{{"type":"error","error":{{"type":"authentication_error","message":"nope {status}"}}}}"#
1030 );
1031 let err = classify_anthropic_error(status, &body, format!("err {status}"), None);
1032 assert!(
1033 matches!(err, Error::ServiceInvalidAuth { .. }),
1034 "status {status}: {err:?}"
1035 );
1036 }
1037 }
1038
1039 #[test]
1040 fn classifies_400_invalid_request_error_as_invalid_request() {
1041 let body = r#"{"type":"error","error":{"type":"invalid_request_error","message":"messages: at least one message is required"}}"#;
1042 let err = classify_anthropic_error(400, body, "err", None);
1043 assert!(
1044 matches!(err, Error::ServiceInvalidRequest { .. }),
1045 "{err:?}"
1046 );
1047 }
1048
1049 #[test]
1050 fn a_400_without_confirming_body_stays_service_status() {
1051 let err = classify_anthropic_error(400, "not json", "err", None);
1056 assert_eq!(err.status(), Some(400), "{err:?}");
1057
1058 let err = classify_anthropic_error(
1059 400,
1060 r#"{"type":"error","error":{"type":"something_else"}}"#,
1061 "err",
1062 None,
1063 );
1064 assert_eq!(err.status(), Some(400), "{err:?}");
1065 }
1066
1067 #[test]
1068 fn leaves_retryable_statuses_as_service_status() {
1069 for status in [408, 429, 500, 529] {
1072 let err = classify_anthropic_error(status, "", format!("err {status}"), Some(1.5));
1073 assert_eq!(err.status(), Some(status), "{err:?}");
1074 assert_eq!(err.retry_after(), Some(1.5), "{err:?}");
1075 }
1076 }
1077
1078 #[test]
1079 fn never_produces_content_filter() {
1080 let bodies = [
1085 "",
1086 "not json",
1087 r#"{"type":"error","error":{"type":"invalid_request_error"}}"#,
1088 r#"{"type":"error","error":{"type":"authentication_error"}}"#,
1089 ];
1090 for status in [400, 401, 403, 404, 422, 429, 500] {
1091 for body in bodies {
1092 let err = classify_anthropic_error(status, body, "err", None);
1093 assert!(
1094 !matches!(err, Error::ServiceContentFilter { .. }),
1095 "status {status}, body {body:?}: {err:?}"
1096 );
1097 }
1098 }
1099 }
1100
1101 }