1use crate::completion::CompletionRequest;
4use crate::completion::NormalizeCompletionResponse;
5use crate::json_utils::string_or_vec;
6use crate::providers::internal::completion_send::send_completion;
7use crate::{
8 client::Provider,
9 completion::{self, CompletionError},
10 http_client::HttpClientExt,
11 message::{self, DocumentMediaType, DocumentSourceKind, MessageError, MimeType, Reasoning},
12 telemetry::{CompletionOperation, CompletionSpanBuilder, ProviderResponseExt, SpanCombinator},
13 wasm_compat::*,
14};
15use serde::{Deserialize, Serialize};
16use std::{convert::Infallible, str::FromStr};
17use tracing::Instrument;
18
19pub const CLAUDE_OPUS_4_6: &str = "claude-opus-4-6";
25pub const CLAUDE_OPUS_4_7: &str = "claude-opus-4-7";
27pub const CLAUDE_OPUS_4_8: &str = "claude-opus-4-8";
29pub const CLAUDE_SONNET_4_6: &str = "claude-sonnet-4-6";
31pub const CLAUDE_HAIKU_4_5: &str = "claude-haiku-4-5";
33
34pub const ANTHROPIC_VERSION_2023_01_01: &str = "2023-01-01";
35pub const ANTHROPIC_VERSION_2023_06_01: &str = "2023-06-01";
36pub const ANTHROPIC_VERSION_LATEST: &str = ANTHROPIC_VERSION_2023_06_01;
37pub(crate) const ANTHROPIC_RAW_CONTENT_KEY: &str = "anthropic_content";
38
39pub trait AnthropicCompatibleProvider: Provider {
40 const PROVIDER_NAME: &'static str;
41
42 const REQUEST_ID_HEADER: Option<&'static str> = Some("request-id");
47
48 fn default_max_tokens(model: &str) -> Option<u64> {
49 let _ = model;
50 None
51 }
52
53 fn enable_strict_tool_use(_tool: &mut ToolDefinition) {}
59}
60
61impl AnthropicCompatibleProvider for super::client::AnthropicExt {
62 const PROVIDER_NAME: &'static str = "anthropic";
63
64 fn default_max_tokens(model: &str) -> Option<u64> {
65 default_max_tokens_for_model(model)
66 }
67
68 fn enable_strict_tool_use(tool: &mut ToolDefinition) {
69 sanitize_strict_tool_schema(&mut tool.input_schema);
70 tool.strict = true;
71 }
72}
73
74#[derive(Debug, Deserialize, Serialize)]
75pub struct CompletionResponse {
76 pub content: Vec<Content>,
77 pub id: String,
78 pub model: String,
79 pub role: String,
80 pub stop_reason: Option<String>,
81 pub stop_sequence: Option<String>,
82 pub usage: Usage,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub provider_request_id: Option<String>,
89}
90
91pub(crate) fn map_finish_reason(stop_reason: &str) -> completion::FinishReason {
98 match stop_reason {
99 "end_turn" | "stop_sequence" => completion::FinishReason::Stop,
102 "max_tokens" => completion::FinishReason::Length,
103 "tool_use" => completion::FinishReason::ToolCalls,
104 "refusal" => completion::FinishReason::ContentFilter,
107 other => completion::FinishReason::Other(other.to_owned()),
108 }
109}
110
111impl ProviderResponseExt for CompletionResponse {
112 type Usage = Usage;
113
114 fn get_response_id(&self) -> Option<String> {
115 Some(self.id.to_owned())
116 }
117
118 fn get_response_model_name(&self) -> Option<String> {
119 Some(self.model.to_owned())
120 }
121
122 fn get_text_response(&self) -> Option<String> {
123 let res = self
124 .content
125 .iter()
126 .filter_map(|x| {
127 if let Content::Text { text, .. } = x {
128 Some(text.as_str())
129 } else {
130 None
131 }
132 })
133 .collect::<String>();
134
135 if res.is_empty() { None } else { Some(res) }
136 }
137
138 fn get_usage(&self) -> Option<Self::Usage> {
139 Some(self.usage.clone())
140 }
141}
142
143#[derive(Clone, Debug, Deserialize, Serialize)]
144pub struct Usage {
145 pub input_tokens: u64,
146 pub cache_read_input_tokens: Option<u64>,
147 pub cache_creation_input_tokens: Option<u64>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub cache_creation: Option<CacheCreation>,
152 pub output_tokens: u64,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub output_tokens_details: Option<OutputTokensDetails>,
157}
158
159#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
167pub struct OutputTokensDetails {
168 #[serde(default)]
170 pub thinking_tokens: u64,
171}
172
173#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
180pub struct CacheCreation {
181 #[serde(default)]
183 pub ephemeral_5m_input_tokens: u64,
184 #[serde(default)]
186 pub ephemeral_1h_input_tokens: u64,
187}
188
189impl std::fmt::Display for Usage {
190 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191 write!(
192 f,
193 "Input tokens: {}\nCache read input tokens: {}\nCache creation input tokens: {}\nOutput tokens: {}",
194 self.input_tokens,
195 match self.cache_read_input_tokens {
196 Some(token) => token.to_string(),
197 None => "n/a".to_string(),
198 },
199 match self.cache_creation_input_tokens {
200 Some(token) => token.to_string(),
201 None => "n/a".to_string(),
202 },
203 self.output_tokens
204 )
205 }
206}
207
208pub(super) fn anthropic_usage_totals(
218 input_tokens: u64,
219 output_tokens: u64,
220 cache_read: Option<u64>,
221 cache_creation: Option<u64>,
222 output_tokens_details: Option<OutputTokensDetails>,
223) -> crate::completion::Usage {
224 let mut usage = crate::completion::Usage::new();
225
226 usage.input_tokens = input_tokens;
227 usage.output_tokens = output_tokens;
228 usage.cached_input_tokens = cache_read.unwrap_or_default();
229 usage.cache_creation_input_tokens = cache_creation.unwrap_or_default();
230 usage.reasoning_tokens = output_tokens_details
231 .map(|details| details.thinking_tokens)
232 .unwrap_or_default();
233 usage.total_tokens = usage.input_tokens
234 + usage.cached_input_tokens
235 + usage.cache_creation_input_tokens
236 + usage.output_tokens;
237
238 usage
239}
240
241impl From<&Usage> for crate::completion::Usage {
242 fn from(value: &Usage) -> crate::completion::Usage {
243 anthropic_usage_totals(
244 value.input_tokens,
245 value.output_tokens,
246 value.cache_read_input_tokens,
247 value.cache_creation_input_tokens,
248 value.output_tokens_details,
249 )
250 }
251}
252
253impl From<Usage> for crate::completion::Usage {
254 fn from(value: Usage) -> crate::completion::Usage {
255 (&value).into()
256 }
257}
258
259#[derive(Debug, Deserialize, Serialize)]
260pub struct ToolDefinition {
261 pub name: String,
262 pub description: Option<String>,
263 pub input_schema: serde_json::Value,
264 #[serde(default, skip_serializing_if = "is_false")]
266 pub strict: bool,
267 #[serde(skip_serializing_if = "Option::is_none")]
271 pub cache_control: Option<CacheControl>,
272}
273
274fn is_false(value: &bool) -> bool {
275 !value
276}
277
278#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Default)]
284pub enum CacheTtl {
285 #[default]
287 #[serde(rename = "5m")]
288 FiveMinutes,
289 #[serde(rename = "1h")]
291 OneHour,
292}
293
294#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
299#[serde(tag = "type", rename_all = "snake_case")]
300pub enum CacheControl {
301 Ephemeral {
302 #[serde(skip_serializing_if = "Option::is_none")]
304 ttl: Option<CacheTtl>,
305 },
306}
307
308impl CacheControl {
309 pub fn ephemeral() -> Self {
311 Self::Ephemeral { ttl: None }
312 }
313
314 pub fn ephemeral_1h() -> Self {
316 Self::Ephemeral {
317 ttl: Some(CacheTtl::OneHour),
318 }
319 }
320}
321
322#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
324#[serde(tag = "type", rename_all = "snake_case")]
325pub enum SystemContent {
326 Text {
327 text: String,
328 #[serde(skip_serializing_if = "Option::is_none")]
329 cache_control: Option<CacheControl>,
330 },
331}
332
333impl crate::completion::NormalizeCompletionResponse for CompletionResponse {
341 fn normalize(self, provider: &str) -> Result<completion::CompletionResponse, CompletionError> {
342 let response = self;
343 let content = response
344 .content
345 .iter()
346 .map(|content| content.clone().try_into())
347 .collect::<Result<Vec<_>, _>>()?;
348
349 let legal_empty_turn = match response.stop_reason.as_deref() {
378 Some("end_turn") => true,
379 Some("stop_sequence") => response.stop_sequence.is_some(),
380 _ => false,
381 };
382 let choice = if content.is_empty() && legal_empty_turn {
383 Vec::new()
384 } else {
385 crate::message::require_non_empty_response(content)?
386 };
387
388 let finish_reason = response.stop_reason.as_deref().map(map_finish_reason);
389
390 Ok(completion::CompletionResponse::new(
391 choice,
392 crate::completion::Usage::from(&response.usage),
393 provider,
394 )
395 .with_optional_message_id(Some(response.id.as_str()).filter(|id| !id.is_empty()))
396 .with_optional_provider_request_id(response.provider_request_id.clone())
397 .with_model(response.model.as_str())
398 .with_optional_finish_reason(finish_reason))
399 }
400}
401
402#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
403pub struct Message {
404 pub role: Role,
405 #[serde(deserialize_with = "string_or_vec")]
406 pub content: Vec<Content>,
407}
408
409#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
410#[serde(rename_all = "lowercase")]
411pub enum Role {
412 User,
413 Assistant,
414 System,
415}
416
417#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
418#[serde(tag = "type", rename_all = "snake_case")]
419pub enum Content {
420 Text {
421 text: String,
422 #[serde(
425 default,
426 deserialize_with = "null_as_empty_vec",
427 skip_serializing_if = "Vec::is_empty"
428 )]
429 citations: Vec<Citation>,
430 #[serde(skip_serializing_if = "Option::is_none")]
431 cache_control: Option<CacheControl>,
432 },
433 Image {
434 source: ImageSource,
435 #[serde(skip_serializing_if = "Option::is_none")]
436 cache_control: Option<CacheControl>,
437 },
438 ToolUse {
439 id: String,
440 name: String,
441 input: serde_json::Value,
442 },
443 ServerToolUse {
444 id: String,
445 name: String,
446 #[serde(default)]
447 input: serde_json::Value,
448 },
449 WebSearchToolResult {
450 tool_use_id: String,
451 content: serde_json::Value,
452 },
453 CodeExecutionToolResult {
455 tool_use_id: String,
456 content: serde_json::Value,
457 },
458 ToolResult {
459 tool_use_id: String,
460 #[serde(deserialize_with = "string_or_vec")]
461 content: Vec<ToolResultContent>,
462 #[serde(skip_serializing_if = "Option::is_none")]
463 is_error: Option<bool>,
464 #[serde(skip_serializing_if = "Option::is_none")]
465 cache_control: Option<CacheControl>,
466 },
467 Document {
468 source: DocumentSource,
469 #[serde(default, skip_serializing_if = "Option::is_none")]
471 title: Option<String>,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
476 context: Option<String>,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
481 citations: Option<CitationsConfig>,
482 #[serde(skip_serializing_if = "Option::is_none")]
483 cache_control: Option<CacheControl>,
484 },
485 Thinking {
486 thinking: String,
487 #[serde(skip_serializing_if = "Option::is_none")]
488 signature: Option<String>,
489 },
490 RedactedThinking {
491 data: String,
492 },
493}
494
495impl FromStr for Content {
496 type Err = Infallible;
497
498 fn from_str(s: &str) -> Result<Self, Self::Err> {
499 Ok(Content::from(s.to_owned()))
500 }
501}
502
503#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
515pub struct CitationsConfig {
516 pub enabled: bool,
518}
519
520#[derive(Debug, Clone, PartialEq, Eq)]
541pub enum Citation {
542 CharLocation(CharLocationCitation),
544 PageLocation(PageLocationCitation),
546 ContentBlockLocation(ContentBlockLocationCitation),
548 SearchResultLocation(SearchResultLocationCitation),
550 WebSearchResultLocation(WebSearchResultLocationCitation),
552 Unknown(serde_json::Value),
555}
556
557#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
559pub struct CharLocationCitation {
560 pub cited_text: String,
562 pub document_index: usize,
564 #[serde(default, skip_serializing_if = "Option::is_none")]
566 pub document_title: Option<String>,
567 pub start_char_index: usize,
569 pub end_char_index: usize,
571}
572
573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
575pub struct PageLocationCitation {
576 pub cited_text: String,
578 pub document_index: usize,
580 #[serde(default, skip_serializing_if = "Option::is_none")]
582 pub document_title: Option<String>,
583 pub start_page_number: u32,
585 pub end_page_number: u32,
587}
588
589#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
591pub struct ContentBlockLocationCitation {
592 pub cited_text: String,
594 pub document_index: usize,
596 #[serde(default, skip_serializing_if = "Option::is_none")]
598 pub document_title: Option<String>,
599 pub start_block_index: usize,
601 pub end_block_index: usize,
603}
604
605#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
607pub struct SearchResultLocationCitation {
608 pub cited_text: String,
610 pub source: String,
612 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub title: Option<String>,
615 pub search_result_index: usize,
618 pub start_block_index: usize,
620 pub end_block_index: usize,
622}
623
624#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
626pub struct WebSearchResultLocationCitation {
627 pub cited_text: String,
629 pub url: String,
631 pub title: Option<String>,
635 pub encrypted_index: String,
638}
639
640impl Serialize for Citation {
641 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
642 where
643 S: serde::Serializer,
644 {
645 fn tagged<S, T>(serializer: S, tag: &str, fields: &T) -> Result<S::Ok, S::Error>
647 where
648 S: serde::Serializer,
649 T: Serialize,
650 {
651 let mut value = serde_json::to_value(fields).map_err(serde::ser::Error::custom)?;
652 if let serde_json::Value::Object(obj) = &mut value {
653 obj.insert("type".into(), serde_json::json!(tag));
654 }
655 value.serialize(serializer)
656 }
657
658 match self {
659 Citation::CharLocation(fields) => tagged(serializer, "char_location", fields),
660 Citation::PageLocation(fields) => tagged(serializer, "page_location", fields),
661 Citation::ContentBlockLocation(fields) => {
662 tagged(serializer, "content_block_location", fields)
663 }
664 Citation::SearchResultLocation(fields) => {
665 tagged(serializer, "search_result_location", fields)
666 }
667 Citation::WebSearchResultLocation(fields) => {
668 tagged(serializer, "web_search_result_location", fields)
669 }
670 Citation::Unknown(raw) => raw.serialize(serializer),
671 }
672 }
673}
674
675impl<'de> Deserialize<'de> for Citation {
676 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
677 where
678 D: serde::Deserializer<'de>,
679 {
680 fn payload<T, E>(value: serde_json::Value) -> Result<T, E>
684 where
685 T: serde::de::DeserializeOwned,
686 E: serde::de::Error,
687 {
688 serde_json::from_value(value).map_err(E::custom)
689 }
690
691 let value = serde_json::Value::deserialize(deserializer)?;
696 let Some(citation_type) = value.get("type").and_then(serde_json::Value::as_str) else {
697 return Ok(Citation::Unknown(value));
698 };
699
700 match citation_type {
701 "char_location" => Ok(Citation::CharLocation(payload(value)?)),
702 "page_location" => Ok(Citation::PageLocation(payload(value)?)),
703 "content_block_location" => Ok(Citation::ContentBlockLocation(payload(value)?)),
704 "search_result_location" => Ok(Citation::SearchResultLocation(payload(value)?)),
705 "web_search_result_location" => Ok(Citation::WebSearchResultLocation(payload(value)?)),
706 _ => Ok(Citation::Unknown(value)),
707 }
708 }
709}
710
711fn null_as_empty_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
718where
719 D: serde::Deserializer<'de>,
720 T: serde::Deserialize<'de>,
721{
722 Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
723}
724
725type AnthropicDocParams = (Option<String>, Option<String>, Option<CitationsConfig>);
728
729fn extract_anthropic_doc_params(
737 additional_params: Option<message::AdditionalParams>,
738) -> Result<AnthropicDocParams, MessageError> {
739 let Some(value) = additional_params else {
740 return Ok((None, None, None));
741 };
742 let title = value
743 .get("title")
744 .and_then(|v| v.as_str())
745 .map(String::from);
746 let context = value
747 .get("context")
748 .and_then(|v| v.as_str())
749 .map(String::from);
750 let citations = value
751 .get("citations")
752 .cloned()
753 .map(serde_json::from_value::<CitationsConfig>)
754 .transpose()
755 .map_err(|e| {
756 MessageError::ConversionError(format!(
757 "Document `additional_params.citations` is not a valid CitationsConfig: {e}",
758 ))
759 })?;
760 Ok((title, context, citations))
761}
762
763pub fn anthropic_citations(text: &message::Text) -> Result<Vec<Citation>, serde_json::Error> {
791 match text
792 .additional_params
793 .as_ref()
794 .and_then(|v| v.get("citations"))
795 {
796 Some(c) => serde_json::from_value::<Vec<Citation>>(c.clone()),
797 None => Ok(Vec::new()),
798 }
799}
800
801fn extract_anthropic_text_citations(text: &message::Text) -> Result<Vec<Citation>, MessageError> {
802 anthropic_citations(text).map_err(|err| {
803 MessageError::ConversionError(format!(
804 "Text `additional_params.citations` is not valid Anthropic citations: {err}"
805 ))
806 })
807}
808
809fn anthropic_text_content_from_message_text(text: message::Text) -> Result<Content, MessageError> {
810 if let Some(raw_content) = extract_anthropic_raw_content(&text)? {
811 if !text.text.is_empty() {
812 return Err(MessageError::ConversionError(format!(
813 "Text `{ANTHROPIC_RAW_CONTENT_KEY}` metadata cannot be combined with non-empty text"
814 )));
815 }
816
817 return Ok(raw_content);
818 }
819
820 let citations = extract_anthropic_text_citations(&text)?;
821 Ok(Content::Text {
822 text: text.text,
823 citations,
824 cache_control: None,
825 })
826}
827
828fn extract_anthropic_raw_content(text: &message::Text) -> Result<Option<Content>, MessageError> {
829 let Some(raw_content) = text
830 .additional_params
831 .as_ref()
832 .and_then(|value| value.get(ANTHROPIC_RAW_CONTENT_KEY))
833 else {
834 return Ok(None);
835 };
836
837 let content = serde_json::from_value::<Content>(raw_content.clone()).map_err(|err| {
838 MessageError::ConversionError(format!(
839 "Text `{ANTHROPIC_RAW_CONTENT_KEY}` metadata is not valid Anthropic content: {err}"
840 ))
841 })?;
842
843 match content {
844 Content::ServerToolUse { .. }
845 | Content::WebSearchToolResult { .. }
846 | Content::CodeExecutionToolResult { .. } => Ok(Some(content)),
847 _ => Err(MessageError::ConversionError(format!(
848 "Text `{ANTHROPIC_RAW_CONTENT_KEY}` metadata only supports Anthropic server_tool_use, web_search_tool_result, and code_execution_tool_result blocks"
849 ))),
850 }
851}
852
853fn anthropic_raw_content_to_message_text(content: Content) -> Result<message::Text, MessageError> {
854 let raw_content = serde_json::to_value(content).map_err(|err| {
855 MessageError::ConversionError(format!("Failed to preserve Anthropic content block: {err}"))
856 })?;
857
858 Ok(message::Text {
859 text: String::new(),
860 additional_params: message::AdditionalParams::from_entries([(
861 ANTHROPIC_RAW_CONTENT_KEY,
862 raw_content,
863 )]),
864 })
865}
866
867fn anthropic_document_additional_params(
868 title: Option<String>,
869 context: Option<String>,
870 citations: Option<CitationsConfig>,
871) -> Result<Option<message::AdditionalParams>, MessageError> {
872 let mut params = serde_json::Map::new();
873
874 if let Some(title) = title {
875 params.insert("title".to_string(), serde_json::Value::String(title));
876 }
877 if let Some(context) = context {
878 params.insert("context".to_string(), serde_json::Value::String(context));
879 }
880 if let Some(citations) = citations {
881 params.insert(
882 "citations".to_string(),
883 serde_json::to_value(citations).map_err(|err| {
884 MessageError::ConversionError(format!(
885 "Failed to preserve Anthropic document citations metadata: {err}"
886 ))
887 })?,
888 );
889 }
890
891 Ok(message::AdditionalParams::new(params))
894}
895
896#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
897#[serde(tag = "type", rename_all = "snake_case")]
898pub enum ToolResultContent {
899 Text { text: String },
900 Image { source: ImageSource },
901}
902
903impl FromStr for ToolResultContent {
904 type Err = Infallible;
905
906 fn from_str(s: &str) -> Result<Self, Self::Err> {
907 Ok(ToolResultContent::Text { text: s.to_owned() })
908 }
909}
910
911#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
919#[serde(tag = "type", rename_all = "snake_case")]
920pub enum ImageSource {
921 #[serde(rename = "base64")]
922 Base64 {
923 data: String,
924 media_type: ImageFormat,
925 },
926 #[serde(rename = "url")]
927 Url { url: String },
928}
929
930#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
938#[serde(tag = "type", rename_all = "snake_case")]
939pub enum DocumentSource {
940 Base64 {
941 data: String,
942 media_type: DocumentFormat,
943 },
944 Text {
945 data: String,
946 media_type: PlainTextMediaType,
947 },
948 Url {
949 url: String,
950 },
951 File {
952 file_id: String,
953 },
954}
955
956#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
957#[serde(rename_all = "lowercase")]
958pub enum ImageFormat {
959 #[serde(rename = "image/jpeg")]
960 JPEG,
961 #[serde(rename = "image/png")]
962 PNG,
963 #[serde(rename = "image/gif")]
964 GIF,
965 #[serde(rename = "image/webp")]
966 WEBP,
967}
968
969#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
976#[serde(rename_all = "lowercase")]
977pub enum DocumentFormat {
978 #[serde(rename = "application/pdf")]
979 PDF,
980}
981
982#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
988pub enum PlainTextMediaType {
989 #[serde(rename = "text/plain")]
990 Plain,
991}
992
993#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
994#[serde(rename_all = "lowercase")]
995pub enum SourceType {
996 BASE64,
997 URL,
998 TEXT,
999}
1000
1001impl From<String> for Content {
1002 fn from(text: String) -> Self {
1003 Content::Text {
1004 text,
1005 citations: Vec::new(),
1006 cache_control: None,
1007 }
1008 }
1009}
1010
1011impl From<String> for ToolResultContent {
1012 fn from(text: String) -> Self {
1013 ToolResultContent::Text { text }
1014 }
1015}
1016
1017impl TryFrom<message::ContentFormat> for SourceType {
1018 type Error = MessageError;
1019
1020 fn try_from(format: message::ContentFormat) -> Result<Self, Self::Error> {
1021 match format {
1022 message::ContentFormat::Base64 => Ok(SourceType::BASE64),
1023 message::ContentFormat::Url => Ok(SourceType::URL),
1024 message::ContentFormat::String => Ok(SourceType::TEXT),
1025 }
1026 }
1027}
1028
1029impl From<SourceType> for message::ContentFormat {
1030 fn from(source_type: SourceType) -> Self {
1031 match source_type {
1032 SourceType::BASE64 => message::ContentFormat::Base64,
1033 SourceType::URL => message::ContentFormat::Url,
1034 SourceType::TEXT => message::ContentFormat::String,
1035 }
1036 }
1037}
1038
1039impl TryFrom<message::ImageMediaType> for ImageFormat {
1040 type Error = MessageError;
1041
1042 fn try_from(media_type: message::ImageMediaType) -> Result<Self, Self::Error> {
1043 Ok(match media_type {
1044 message::ImageMediaType::JPEG => ImageFormat::JPEG,
1045 message::ImageMediaType::PNG => ImageFormat::PNG,
1046 message::ImageMediaType::GIF => ImageFormat::GIF,
1047 message::ImageMediaType::WEBP => ImageFormat::WEBP,
1048 _ => {
1049 return Err(MessageError::ConversionError(
1050 format!("Unsupported image media type: {media_type:?}").to_owned(),
1051 ));
1052 }
1053 })
1054 }
1055}
1056
1057impl From<ImageFormat> for message::ImageMediaType {
1058 fn from(format: ImageFormat) -> Self {
1059 match format {
1060 ImageFormat::JPEG => message::ImageMediaType::JPEG,
1061 ImageFormat::PNG => message::ImageMediaType::PNG,
1062 ImageFormat::GIF => message::ImageMediaType::GIF,
1063 ImageFormat::WEBP => message::ImageMediaType::WEBP,
1064 }
1065 }
1066}
1067
1068impl TryFrom<DocumentMediaType> for DocumentFormat {
1069 type Error = MessageError;
1070 fn try_from(value: DocumentMediaType) -> Result<Self, Self::Error> {
1071 match value {
1072 DocumentMediaType::PDF => Ok(DocumentFormat::PDF),
1073 other => Err(MessageError::ConversionError(format!(
1074 "DocumentFormat only supports PDF for base64 sources, got: {}",
1075 other.to_mime_type()
1076 ))),
1077 }
1078 }
1079}
1080
1081fn coerce_tool_input(input: serde_json::Value) -> serde_json::Value {
1092 match input {
1093 v @ serde_json::Value::Object(_) => v,
1094 serde_json::Value::String(s) => match serde_json::from_str::<serde_json::Value>(&s) {
1095 Ok(serde_json::Value::Object(m)) => serde_json::Value::Object(m),
1096 _ => serde_json::json!({}),
1097 },
1098 _ => serde_json::json!({}),
1100 }
1101}
1102
1103fn anthropic_content_from_assistant_content(
1104 content: message::AssistantContent,
1105) -> Result<Vec<Content>, MessageError> {
1106 match content {
1107 message::AssistantContent::Text(text) => {
1108 if text.text.is_empty() && extract_anthropic_raw_content(&text)?.is_none() {
1117 return Ok(Vec::new());
1118 }
1119 Ok(vec![anthropic_text_content_from_message_text(text)?])
1120 }
1121 message::AssistantContent::Image(_) => Err(MessageError::ConversionError(
1122 "Anthropic currently doesn't support images.".to_string(),
1123 )),
1124 message::AssistantContent::ToolCall(tool_call) => Ok(vec![Content::ToolUse {
1125 id: tool_call.wire_call_id().to_owned(),
1128 name: tool_call.function.name,
1129 input: coerce_tool_input(tool_call.function.arguments),
1130 }]),
1131 message::AssistantContent::Reasoning(reasoning) => {
1132 let mut converted = Vec::new();
1133 for block in reasoning.content {
1134 match block {
1135 message::ReasoningContent::Text { text, signature } => {
1136 converted.push(Content::Thinking {
1137 thinking: text,
1138 signature,
1139 });
1140 }
1141 message::ReasoningContent::Summary(summary) => {
1142 converted.push(Content::Thinking {
1143 thinking: summary,
1144 signature: None,
1145 });
1146 }
1147 message::ReasoningContent::Redacted { data }
1148 | message::ReasoningContent::Encrypted(data) => {
1149 converted.push(Content::RedactedThinking { data });
1150 }
1151 }
1152 }
1153
1154 if converted.is_empty() {
1155 return Err(MessageError::ConversionError(
1156 "Cannot convert empty reasoning content to Anthropic format".to_string(),
1157 ));
1158 }
1159
1160 Ok(converted)
1161 }
1162 }
1163}
1164
1165impl TryFrom<message::Message> for Message {
1166 type Error = MessageError;
1167
1168 fn try_from(message: message::Message) -> Result<Self, Self::Error> {
1169 Ok(match message {
1170 message::Message::User { content } => Message {
1171 role: Role::User,
1172 content: content.into_iter().map(|content| match content {
1173 message::UserContent::Text(message::Text { text, .. }) => {
1174 Ok(Content::from(text))
1175 }
1176 message::UserContent::ToolResult(tool_result) => Ok(Content::ToolResult {
1177 tool_use_id: tool_result.wire_call_id().to_owned(),
1178 content: tool_result.content.into_iter().map(|content| match content {
1179 message::ToolResultContent::Text(message::Text { text, .. }) => {
1180 Ok(ToolResultContent::Text { text })
1181 }
1182 message::ToolResultContent::Json { value } => {
1183 Ok(ToolResultContent::Text {
1184 text: value.to_string(),
1185 })
1186 }
1187 message::ToolResultContent::Image(image) => {
1188 let DocumentSourceKind::Base64(data) = image.data else {
1189 return Err(MessageError::ConversionError(
1190 "Only base64 strings can be used with the Anthropic API"
1191 .to_string(),
1192 ));
1193 };
1194 let media_type =
1195 image.media_type.ok_or(MessageError::ConversionError(
1196 "Image media type is required".to_owned(),
1197 ))?;
1198 Ok(ToolResultContent::Image {
1199 source: ImageSource::Base64 {
1200 data,
1201 media_type: media_type.try_into()?,
1202 },
1203 })
1204 }
1205 }).collect::<Result<Vec<_>, _>>()?,
1206 is_error: None,
1207 cache_control: None,
1208 }),
1209 message::UserContent::Image(message::Image {
1210 data, media_type, ..
1211 }) => {
1212 let source = match data {
1213 DocumentSourceKind::Base64(data) => {
1214 let media_type =
1215 media_type.ok_or(MessageError::ConversionError(
1216 "Image media type is required for Claude API".to_string(),
1217 ))?;
1218 ImageSource::Base64 {
1219 data,
1220 media_type: ImageFormat::try_from(media_type)?,
1221 }
1222 }
1223 DocumentSourceKind::Url(url) => ImageSource::Url { url },
1224 DocumentSourceKind::Unknown => {
1225 return Err(MessageError::ConversionError(
1226 "Image content has no body".into(),
1227 ));
1228 }
1229 doc => {
1230 return Err(MessageError::ConversionError(format!(
1231 "Unsupported document type: {doc:?}"
1232 )));
1233 }
1234 };
1235
1236 Ok(Content::Image {
1237 source,
1238 cache_control: None,
1239 })
1240 }
1241 message::UserContent::Document(message::Document {
1242 data,
1243 media_type,
1244 additional_params,
1245 }) => {
1246 let (title, context, citations) =
1247 extract_anthropic_doc_params(additional_params)?;
1248
1249 if let DocumentSourceKind::FileId(file_id) = data {
1250 return Ok(Content::Document {
1251 source: DocumentSource::File { file_id },
1252 title,
1253 context,
1254 citations,
1255 cache_control: None,
1256 });
1257 }
1258
1259 let media_type = match media_type {
1260 Some(media_type) => media_type,
1261 None if matches!(&data, DocumentSourceKind::Url(_)) => {
1264 DocumentMediaType::PDF
1265 }
1266 None => {
1267 return Err(MessageError::ConversionError(
1268 "Document media type is required".to_string(),
1269 ));
1270 }
1271 };
1272
1273 let source = match media_type {
1274 DocumentMediaType::PDF => match data {
1275 DocumentSourceKind::Base64(data)
1276 | DocumentSourceKind::String(data) => DocumentSource::Base64 {
1277 data,
1278 media_type: DocumentFormat::PDF,
1279 },
1280 DocumentSourceKind::Url(url) => DocumentSource::Url { url },
1281 _ => {
1282 return Err(MessageError::ConversionError(
1283 "Only base64 encoded data or URLs are supported for PDF documents".into(),
1284 ));
1285 }
1286 },
1287 DocumentMediaType::TXT => {
1288 let data = match data {
1289 DocumentSourceKind::String(data)
1290 | DocumentSourceKind::Base64(data) => data,
1291 _ => {
1292 return Err(MessageError::ConversionError(
1293 "Only string or base64 data is supported for plain text documents".into(),
1294 ));
1295 }
1296 };
1297 DocumentSource::Text {
1298 data,
1299 media_type: PlainTextMediaType::Plain,
1300 }
1301 }
1302 other => {
1303 return Err(MessageError::ConversionError(format!(
1304 "Anthropic only supports PDF and plain text documents, got: {}",
1305 other.to_mime_type()
1306 )));
1307 }
1308 };
1309
1310 Ok(Content::Document {
1311 source,
1312 title,
1313 context,
1314 citations,
1315 cache_control: None,
1316 })
1317 }
1318 message::UserContent::Audio { .. } => Err(MessageError::ConversionError(
1319 "Audio is not supported in Anthropic".to_owned(),
1320 )),
1321 message::UserContent::Video { .. } => Err(MessageError::ConversionError(
1322 "Video is not supported in Anthropic".to_owned(),
1323 )),
1324 }).collect::<Result<Vec<_>, _>>()?,
1325 },
1326
1327 message::Message::System { content } => Message {
1328 role: Role::System,
1329 content: vec![Content::from(content)],
1330 },
1331
1332 message::Message::Assistant { content, .. } => {
1333 let converted_content = content.into_iter().try_fold(
1334 Vec::new(),
1335 |mut accumulated, assistant_content| {
1336 accumulated
1337 .extend(anthropic_content_from_assistant_content(assistant_content)?);
1338 Ok::<Vec<Content>, MessageError>(accumulated)
1339 },
1340 )?;
1341
1342 Message {
1343 content: crate::message::require_non_empty(converted_content, || {
1344 MessageError::ConversionError(
1345 "Assistant message did not contain Anthropic-compatible content"
1346 .to_owned(),
1347 )
1348 })?,
1349 role: Role::Assistant,
1350 }
1351 }
1352 })
1353 }
1354}
1355
1356impl TryFrom<Content> for message::AssistantContent {
1357 type Error = MessageError;
1358
1359 fn try_from(content: Content) -> Result<Self, Self::Error> {
1360 Ok(match content {
1361 Content::Text {
1366 text,
1367 citations,
1368 cache_control: _,
1369 } => {
1370 let additional_params = message::AdditionalParams::from_entries(
1375 (!citations.is_empty()).then(|| ("citations", serde_json::json!(citations))),
1376 );
1377 message::AssistantContent::Text(message::Text {
1378 text,
1379 additional_params,
1380 })
1381 }
1382 Content::ToolUse { id, name, input } => {
1383 message::AssistantContent::tool_call(id, name, input)
1384 }
1385 raw @ (Content::ServerToolUse { .. }
1386 | Content::WebSearchToolResult { .. }
1387 | Content::CodeExecutionToolResult { .. }) => {
1388 message::AssistantContent::Text(anthropic_raw_content_to_message_text(raw)?)
1389 }
1390 Content::Thinking {
1391 thinking,
1392 signature,
1393 } => message::AssistantContent::Reasoning(Reasoning::new_with_signature(
1394 &thinking, signature,
1395 )),
1396 Content::RedactedThinking { data } => {
1397 message::AssistantContent::Reasoning(Reasoning::redacted(data))
1398 }
1399 _ => {
1400 return Err(MessageError::ConversionError(
1401 "Content did not contain a message, tool call, or reasoning".to_owned(),
1402 ));
1403 }
1404 })
1405 }
1406}
1407
1408impl From<ToolResultContent> for message::ToolResultContent {
1409 fn from(content: ToolResultContent) -> Self {
1410 match content {
1411 ToolResultContent::Text { text, .. } => message::ToolResultContent::text(text),
1412 ToolResultContent::Image { source } => match source {
1413 ImageSource::Base64 { data, media_type } => {
1414 message::ToolResultContent::image_base64(data, Some(media_type.into()), None)
1415 }
1416 ImageSource::Url { url } => message::ToolResultContent::image_url(url, None, None),
1417 },
1418 }
1419 }
1420}
1421
1422impl TryFrom<Message> for message::Message {
1423 type Error = MessageError;
1424
1425 fn try_from(message: Message) -> Result<Self, Self::Error> {
1426 Ok(match message.role {
1427 Role::User => message::Message::User {
1428 content: message
1429 .content
1430 .into_iter()
1431 .map(|content| {
1432 Ok(match content {
1433 Content::Text { text, .. } => message::UserContent::text(text),
1434 Content::ToolResult {
1435 tool_use_id,
1436 content,
1437 ..
1438 } => message::UserContent::tool_result_from_wire(
1439 tool_use_id,
1440 "",
1444 content.into_iter().map(|content| content.into()).collect(),
1445 ),
1446 Content::Image { source, .. } => match source {
1447 ImageSource::Base64 { data, media_type } => {
1448 message::UserContent::Image(message::Image {
1449 data: DocumentSourceKind::Base64(data),
1450 media_type: Some(media_type.into()),
1451 detail: None,
1452 additional_params: None,
1453 })
1454 }
1455 ImageSource::Url { url } => {
1456 message::UserContent::Image(message::Image {
1457 data: DocumentSourceKind::Url(url),
1458 media_type: None,
1459 detail: None,
1460 additional_params: None,
1461 })
1462 }
1463 },
1464 Content::Document {
1465 source,
1466 title,
1467 context,
1468 citations,
1469 ..
1470 } => {
1471 let additional_params = anthropic_document_additional_params(
1472 title, context, citations,
1473 )?;
1474
1475 match source {
1476 DocumentSource::Base64 { data, media_type } => {
1477 let rig_media_type = match media_type {
1478 DocumentFormat::PDF => message::DocumentMediaType::PDF,
1479 };
1480 message::UserContent::Document(message::Document {
1481 data: DocumentSourceKind::String(data),
1482 media_type: Some(rig_media_type),
1483 additional_params,
1484 })
1485 }
1486 DocumentSource::Text { data, .. } => {
1487 message::UserContent::Document(message::Document {
1488 data: DocumentSourceKind::String(data),
1489 media_type: Some(message::DocumentMediaType::TXT),
1490 additional_params,
1491 })
1492 }
1493 DocumentSource::Url { url } => {
1494 message::UserContent::Document(message::Document {
1495 data: DocumentSourceKind::Url(url),
1496 media_type: None,
1497 additional_params,
1498 })
1499 }
1500 DocumentSource::File { file_id } => {
1501 message::UserContent::Document(message::Document {
1502 data: DocumentSourceKind::FileId(file_id),
1503 media_type: None,
1504 additional_params,
1505 })
1506 }
1507 }
1508 }
1509 _ => {
1510 return Err(MessageError::ConversionError(
1511 "Unsupported content type for User role".to_owned(),
1512 ));
1513 }
1514 })
1515 })
1516 .collect::<Result<Vec<_>, _>>()?,
1517 },
1518 Role::Assistant => message::Message::Assistant {
1519 id: None,
1520 content: message
1521 .content
1522 .into_iter()
1523 .map(|content| content.try_into())
1524 .collect::<Result<Vec<_>, _>>()?,
1525 },
1526 Role::System => {
1527 let content =
1528 message
1529 .content
1530 .into_iter()
1531 .try_fold(String::new(), |mut content, block| {
1532 let Content::Text { text, .. } = block else {
1533 return Err(MessageError::ConversionError(
1534 "Unsupported content type for System role".to_owned(),
1535 ));
1536 };
1537
1538 content.push_str(&text);
1539 Ok(content)
1540 })?;
1541
1542 message::Message::System { content }
1543 }
1544 })
1545 }
1546}
1547
1548#[doc(hidden)]
1549#[derive(Clone)]
1550pub struct GenericCompletionModel<Ext = super::client::AnthropicExt, T = reqwest::Client> {
1551 pub(crate) client: crate::client::Client<Ext, T>,
1552 pub model: String,
1553 pub default_max_tokens: Option<u64>,
1554 pub prompt_caching: bool,
1557 pub automatic_caching: bool,
1561 pub automatic_caching_ttl: Option<CacheTtl>,
1564 pub static_prefix_cache_ttl: Option<CacheTtl>,
1568 pub strict_tools: bool,
1570}
1571
1572pub type CompletionModel<T = reqwest::Client> =
1577 GenericCompletionModel<super::client::AnthropicExt, T>;
1578
1579impl<Ext, T> GenericCompletionModel<Ext, T>
1580where
1581 T: HttpClientExt,
1582 Ext: AnthropicCompatibleProvider + Clone + 'static,
1583{
1584 pub(super) fn prepare_request(
1598 &self,
1599 mut completion_request: completion::CompletionRequest,
1600 operation: CompletionOperation,
1601 ) -> Result<(tracing::Span, AnthropicCompletionRequest), CompletionError> {
1602 let request_model = completion_request
1603 .model
1604 .clone()
1605 .unwrap_or_else(|| self.model.clone());
1606 let span = CompletionSpanBuilder::new(Ext::PROVIDER_NAME, &request_model, operation)
1607 .system_instructions(
1608 completion_request.preamble.as_deref(),
1609 completion_request.record_telemetry_content,
1610 )
1611 .build();
1612
1613 if completion_request.max_tokens.is_none() {
1614 let Some(tokens) = self.default_max_tokens else {
1615 return Err(CompletionError::RequestError(
1616 "`max_tokens` must be set for Anthropic".into(),
1617 ));
1618 };
1619 completion_request.max_tokens = Some(tokens);
1620 }
1621
1622 let request = AnthropicCompletionRequest::try_from_params::<Ext>(
1623 AnthropicRequestParams {
1624 model: &request_model,
1625 request: completion_request,
1626 prompt_caching: self.prompt_caching,
1627 automatic_caching: self.automatic_caching,
1628 automatic_caching_ttl: self.automatic_caching_ttl.clone(),
1629 static_prefix_cache_ttl: self.static_prefix_cache_ttl.clone(),
1630 },
1631 self.strict_tools,
1632 )?;
1633
1634 Ok((span, request))
1635 }
1636
1637 fn with_defaults(
1640 client: crate::client::Client<Ext, T>,
1641 model: String,
1642 default_max_tokens: Option<u64>,
1643 ) -> Self {
1644 Self {
1645 client,
1646 model,
1647 default_max_tokens,
1648 prompt_caching: false,
1649 automatic_caching: false,
1650 automatic_caching_ttl: None,
1651 static_prefix_cache_ttl: None,
1652 strict_tools: false,
1653 }
1654 }
1655
1656 pub fn new(client: crate::client::Client<Ext, T>, model: impl Into<String>) -> Self {
1657 let model = model.into();
1658 let default_max_tokens = Ext::default_max_tokens(&model);
1659
1660 Self::with_defaults(client, model, default_max_tokens)
1661 }
1662
1663 pub fn with_model(client: crate::client::Client<Ext, T>, model: &str) -> Self {
1664 let default_max_tokens = Ext::default_max_tokens(model)
1665 .or_else(|| Some(default_max_tokens_with_fallback(model)));
1666
1667 Self::with_defaults(client, model.to_string(), default_max_tokens)
1668 }
1669
1670 pub fn with_prompt_caching(mut self) -> Self {
1688 self.prompt_caching = true;
1689 self
1690 }
1691
1692 pub fn with_automatic_caching(mut self) -> Self {
1727 self.automatic_caching = true;
1728 self
1729 }
1730
1731 pub fn with_automatic_caching_1h(mut self) -> Self {
1743 self.automatic_caching = true;
1744 self.automatic_caching_ttl = Some(CacheTtl::OneHour);
1745 self
1746 }
1747
1748 pub fn with_static_prefix_cache_ttl(mut self, ttl: CacheTtl) -> Self {
1783 self.static_prefix_cache_ttl = Some(ttl);
1784 self
1785 }
1786}
1787
1788impl<T> GenericCompletionModel<super::client::AnthropicExt, T>
1789where
1790 T: HttpClientExt,
1791{
1792 pub fn with_strict_tools(mut self) -> Self {
1808 self.strict_tools = true;
1809 self
1810 }
1811}
1812
1813fn default_max_tokens_for_model(model: &str) -> Option<u64> {
1817 if model.starts_with("claude-opus-4-8")
1818 || model.starts_with("claude-opus-4-7")
1819 || model.starts_with("claude-opus-4-6")
1820 {
1821 Some(128_000)
1822 } else if model.starts_with("claude-opus-4")
1823 || model.starts_with("claude-sonnet-4")
1824 || model.starts_with("claude-haiku-4-5")
1825 {
1826 Some(64_000)
1827 } else {
1828 None
1829 }
1830}
1831
1832fn default_max_tokens_with_fallback(model: &str) -> u64 {
1833 default_max_tokens_for_model(model).unwrap_or(2_048)
1834}
1835
1836pub(super) fn supports_mid_conversation_system_messages(model: &str) -> bool {
1837 model.starts_with(CLAUDE_OPUS_4_8)
1838}
1839
1840#[derive(Debug, Deserialize, Serialize)]
1841pub struct Metadata {
1842 user_id: Option<String>,
1843}
1844
1845#[derive(Default, Debug, Serialize, Deserialize)]
1846#[serde(tag = "type", rename_all = "snake_case")]
1847pub enum ToolChoice {
1848 #[default]
1849 Auto,
1850 Any,
1851 None,
1852 Tool {
1853 name: String,
1854 },
1855}
1856impl TryFrom<message::ToolChoice> for ToolChoice {
1857 type Error = CompletionError;
1858
1859 fn try_from(value: message::ToolChoice) -> Result<Self, Self::Error> {
1860 let res = match value {
1861 message::ToolChoice::Auto => Self::Auto,
1862 message::ToolChoice::None => Self::None,
1863 message::ToolChoice::Required => Self::Any,
1864 message::ToolChoice::Specific { function_names } => {
1865 if function_names.len() != 1 {
1866 return Err(CompletionError::ProviderError(
1867 "Only one tool may be specified to be used by Claude".into(),
1868 ));
1869 }
1870
1871 let Some(name) = function_names.into_iter().next() else {
1872 return Err(CompletionError::ProviderError(
1873 "Only one tool may be specified to be used by Claude".into(),
1874 ));
1875 };
1876
1877 Self::Tool { name }
1878 }
1879 };
1880
1881 Ok(res)
1882 }
1883}
1884
1885fn sanitize_schema(schema: &mut serde_json::Value) {
1891 crate::providers::internal::schema::sanitize_schema(
1892 schema,
1893 crate::providers::internal::schema::SanitizeOptions {
1894 strip_ref_siblings: false,
1895 inject_empty_properties: false,
1896 strip_numeric_constraints: true,
1897 },
1898 );
1899}
1900
1901fn sanitize_strict_tool_schema(schema: &mut serde_json::Value) {
1907 let mut original = std::mem::take(schema);
1908 inline_local_root_reference(&mut original);
1909 flatten_root_all_of(&mut original);
1910 if let serde_json::Value::Object(source) = &mut original
1915 && !source.contains_key("type")
1916 && (source.contains_key("properties") || source.contains_key("$ref"))
1917 {
1918 source.insert(
1919 "type".to_string(),
1920 serde_json::Value::String("object".to_string()),
1921 );
1922 }
1923 *schema = transform_strict_tool_schema(original);
1924}
1925
1926fn flatten_root_all_of(schema: &mut serde_json::Value) {
1930 use serde_json::{Map, Value};
1931
1932 let Value::Object(root) = schema else {
1933 return;
1934 };
1935 let Some(all_of) = root.remove("allOf") else {
1936 return;
1937 };
1938 let mut conflicting_constraints = Map::new();
1939 merge_root_all_of(root, all_of, &mut conflicting_constraints);
1940 if !conflicting_constraints.is_empty() {
1941 root.insert(
1942 "rootAllOfConstraints".to_string(),
1943 Value::Object(conflicting_constraints),
1944 );
1945 }
1946}
1947
1948fn inline_local_root_reference(schema: &mut serde_json::Value) {
1953 use serde_json::Value;
1954
1955 let mut seen = std::collections::BTreeSet::new();
1956 loop {
1957 let Some(reference) = schema
1958 .get("$ref")
1959 .and_then(serde_json::Value::as_str)
1960 .map(str::to_string)
1961 else {
1962 return;
1963 };
1964 let Some(pointer) = reference.strip_prefix('#') else {
1965 return;
1966 };
1967 if !seen.insert(reference.clone()) {
1968 return;
1969 }
1970 let Some(Value::Object(mut referenced)) = schema.pointer(pointer).cloned() else {
1971 return;
1972 };
1973 let Some(mut root) = schema.as_object().cloned() else {
1974 return;
1975 };
1976 root.remove("$ref");
1977
1978 for keyword in ["$defs", "definitions"] {
1979 let Some(root_definitions) = root.remove(keyword) else {
1980 continue;
1981 };
1982 let definitions =
1983 merge_document_definitions(root_definitions, referenced.remove(keyword));
1984 referenced.insert(keyword.to_string(), definitions);
1985 }
1986
1987 merge_root_reference_siblings(&mut referenced, root);
1988
1989 *schema = Value::Object(referenced);
1990 }
1991}
1992
1993fn merge_document_definitions(
1994 root_definitions: serde_json::Value,
1995 local_definitions: Option<serde_json::Value>,
1996) -> serde_json::Value {
1997 use serde_json::Value;
1998
1999 match (root_definitions, local_definitions) {
2000 (Value::Object(root_definitions), Some(Value::Object(mut local_definitions))) => {
2001 local_definitions.extend(root_definitions);
2005 Value::Object(local_definitions)
2006 }
2007 (root_definitions, _) => root_definitions,
2008 }
2009}
2010
2011fn merge_root_reference_siblings(
2015 referenced: &mut serde_json::Map<String, serde_json::Value>,
2016 siblings: serde_json::Map<String, serde_json::Value>,
2017) {
2018 use serde_json::{Map, Value};
2019
2020 let mut conflicting_constraints = Map::new();
2021 for (keyword, sibling) in siblings {
2022 match keyword.as_str() {
2023 "properties" => merge_schema_properties(referenced, sibling),
2024 "required" => merge_required_properties(referenced, sibling),
2025 "allOf" => merge_root_all_of(referenced, sibling, &mut conflicting_constraints),
2026 "anyOf" | "oneOf" => {
2031 conflicting_constraints.insert(keyword, sibling);
2032 }
2033 "description" | "title" | "$schema" | "$id" | "$comment" | "default" | "examples"
2036 | "deprecated" | "readOnly" | "writeOnly" => {
2037 referenced.insert(keyword, sibling);
2038 }
2039 _ => match referenced.get(&keyword) {
2040 None => {
2041 referenced.insert(keyword, sibling);
2042 }
2043 Some(existing) if existing == &sibling => {}
2044 Some(_) => {
2045 conflicting_constraints.insert(keyword, sibling);
2046 }
2047 },
2048 }
2049 }
2050
2051 if !conflicting_constraints.is_empty() {
2052 referenced.insert(
2056 "rootRefSiblingConstraints".to_string(),
2057 Value::Object(conflicting_constraints),
2058 );
2059 }
2060}
2061
2062fn merge_root_all_of(
2063 schema: &mut serde_json::Map<String, serde_json::Value>,
2064 sibling: serde_json::Value,
2065 conflicting_constraints: &mut serde_json::Map<String, serde_json::Value>,
2066) {
2067 use serde_json::Value;
2068
2069 let Value::Array(branches) = sibling else {
2070 conflicting_constraints.insert("allOf".to_string(), sibling);
2071 return;
2072 };
2073 let mut unsupported_branches = Vec::new();
2074 for branch in branches {
2075 match branch {
2076 Value::Object(mut branch) => {
2077 if branch.contains_key("$ref") {
2078 for keyword in ["$defs", "definitions"] {
2079 let Some(root_definitions) = schema.get(keyword).cloned() else {
2080 continue;
2081 };
2082 let definitions =
2083 merge_document_definitions(root_definitions, branch.remove(keyword));
2084 branch.insert(keyword.to_string(), definitions);
2085 }
2086 let mut branch = Value::Object(branch);
2087 inline_local_root_reference(&mut branch);
2088 match branch {
2089 Value::Object(branch) => merge_root_reference_siblings(schema, branch),
2090 branch => unsupported_branches.push(branch),
2091 }
2092 } else {
2093 merge_root_reference_siblings(schema, branch);
2094 }
2095 }
2096 branch => unsupported_branches.push(branch),
2097 }
2098 }
2099 if !unsupported_branches.is_empty() {
2100 conflicting_constraints.insert("allOf".to_string(), Value::Array(unsupported_branches));
2101 }
2102}
2103
2104fn merge_schema_properties(
2105 schema: &mut serde_json::Map<String, serde_json::Value>,
2106 sibling: serde_json::Value,
2107) {
2108 use serde_json::{Map, Value};
2109
2110 let Value::Object(sibling_properties) = sibling else {
2111 schema.entry("properties".to_string()).or_insert(sibling);
2112 return;
2113 };
2114 let properties = schema
2115 .entry("properties".to_string())
2116 .or_insert_with(|| Value::Object(Map::new()));
2117 let Value::Object(properties) = properties else {
2118 return;
2119 };
2120
2121 for (name, sibling_schema) in sibling_properties {
2122 match properties.remove(&name) {
2123 None => {
2124 properties.insert(name, sibling_schema);
2125 }
2126 Some(existing) if existing == sibling_schema => {
2127 properties.insert(name, existing);
2128 }
2129 Some(existing) => {
2130 properties.insert(
2131 name,
2132 Value::Object(Map::from_iter([(
2133 "allOf".to_string(),
2134 Value::Array(vec![existing, sibling_schema]),
2135 )])),
2136 );
2137 }
2138 }
2139 }
2140}
2141
2142fn merge_required_properties(
2143 schema: &mut serde_json::Map<String, serde_json::Value>,
2144 sibling: serde_json::Value,
2145) {
2146 use serde_json::Value;
2147
2148 let Value::Array(sibling_required) = sibling else {
2149 schema.entry("required".to_string()).or_insert(sibling);
2150 return;
2151 };
2152 let required = schema
2153 .entry("required".to_string())
2154 .or_insert_with(|| Value::Array(Vec::new()));
2155 let Value::Array(required) = required else {
2156 return;
2157 };
2158 for name in sibling_required {
2159 if !required.contains(&name) {
2160 required.push(name);
2161 }
2162 }
2163}
2164
2165fn transform_strict_tool_schema(schema: serde_json::Value) -> serde_json::Value {
2166 use serde_json::{Map, Value};
2167
2168 let Value::Object(mut source) = schema else {
2169 return schema;
2170 };
2171 let mut strict = Map::new();
2172
2173 for keyword in ["$defs", "definitions"] {
2174 if let Some(definitions) = source.remove(keyword) {
2175 match definitions {
2176 Value::Object(definitions) => {
2177 strict.insert(
2178 keyword.to_string(),
2179 Value::Object(
2180 definitions
2181 .into_iter()
2182 .map(|(name, schema)| (name, transform_strict_tool_schema(schema)))
2183 .collect(),
2184 ),
2185 );
2186 }
2187 definitions => {
2188 source.insert(keyword.to_string(), definitions);
2189 }
2190 }
2191 }
2192 }
2193
2194 if let Some(reference) = source.remove("$ref") {
2195 strict.insert("$ref".to_string(), reference);
2196 return Value::Object(strict);
2197 }
2198
2199 let schema_type = source.remove("type");
2200 let any_of = source.remove("anyOf");
2201 let one_of = source.remove("oneOf");
2202 let all_of = source.remove("allOf");
2203 let alternatives = match (any_of, one_of, all_of) {
2204 (Some(Value::Array(variants)), _, _) => Some(("anyOf", variants)),
2205 (_, Some(Value::Array(variants)), _) => Some(("anyOf", variants)),
2206 (_, _, Some(Value::Array(variants))) => Some(("allOf", variants)),
2207 _ => None,
2208 };
2209 if let Some((keyword, variants)) = alternatives {
2210 strict.insert(
2211 keyword.to_string(),
2212 Value::Array(
2213 variants
2214 .into_iter()
2215 .map(transform_strict_tool_schema)
2216 .collect(),
2217 ),
2218 );
2219 } else if let Some(schema_type) = schema_type.clone() {
2220 strict.insert("type".to_string(), schema_type);
2221 }
2222
2223 if let Some(Value::Array(values)) = source.remove("enum") {
2224 strict.insert("enum".to_string(), Value::Array(values));
2225 }
2226 if let Some(constant) = source.remove("const") {
2227 strict.insert("const".to_string(), constant);
2228 }
2229 for keyword in ["description", "title"] {
2230 if let Some(Value::String(value)) = source.remove(keyword) {
2231 strict.insert(keyword.to_string(), Value::String(value));
2232 }
2233 }
2234
2235 let has_properties = source.contains_key("properties");
2236 let properties_imply_object = schema_type.is_none() && has_properties;
2237 if properties_imply_object {
2238 strict.insert("type".to_string(), Value::String("object".to_string()));
2239 }
2240 if schema_has_type(schema_type.as_ref(), "object") || has_properties {
2241 let properties = match source.remove("properties") {
2242 Some(Value::Object(properties)) => properties
2243 .into_iter()
2244 .map(|(name, schema)| (name, transform_strict_tool_schema(schema)))
2245 .collect(),
2246 _ => Map::new(),
2247 };
2248 strict.insert("properties".to_string(), Value::Object(properties));
2249 source.remove("additionalProperties");
2250 strict.insert("additionalProperties".to_string(), Value::Bool(false));
2251 if let Some(Value::Array(required)) = source.remove("required") {
2252 strict.insert("required".to_string(), Value::Array(required));
2253 }
2254 }
2255
2256 if schema_has_type(schema_type.as_ref(), "string")
2257 && let Some(format) = source.remove("format")
2258 {
2259 const SUPPORTED_FORMATS: &[&str] = &[
2260 "date-time",
2261 "time",
2262 "date",
2263 "duration",
2264 "email",
2265 "hostname",
2266 "uri",
2267 "ipv4",
2268 "ipv6",
2269 "uuid",
2270 ];
2271 if format
2272 .as_str()
2273 .is_some_and(|format| SUPPORTED_FORMATS.contains(&format))
2274 {
2275 strict.insert("format".to_string(), format);
2276 } else {
2277 source.insert("format".to_string(), format);
2278 }
2279 }
2280
2281 if schema_has_type(schema_type.as_ref(), "array") {
2282 if let Some(items) = source.remove("items") {
2283 strict.insert("items".to_string(), transform_strict_tool_schema(items));
2284 }
2285 if let Some(min_items) = source.remove("minItems") {
2286 if matches!(min_items.as_u64(), Some(0 | 1)) {
2287 strict.insert("minItems".to_string(), min_items);
2288 } else {
2289 source.insert("minItems".to_string(), min_items);
2290 }
2291 }
2292 }
2293
2294 if !source.is_empty() {
2295 let hints = source
2296 .into_iter()
2297 .map(|(keyword, value)| {
2298 let value = match value {
2299 Value::String(value) => value,
2300 value => value.to_string(),
2301 };
2302 format!("{keyword}: {value}")
2303 })
2304 .collect::<Vec<_>>()
2305 .join(", ");
2306 let suffix = format!("{{{hints}}}");
2307 match strict.get_mut("description") {
2308 Some(Value::String(description)) => {
2309 description.push_str("\n\n");
2310 description.push_str(&suffix);
2311 }
2312 _ => {
2313 strict.insert("description".to_string(), Value::String(suffix));
2314 }
2315 }
2316 }
2317
2318 Value::Object(strict)
2319}
2320
2321fn schema_has_type(schema_type: Option<&serde_json::Value>, expected: &str) -> bool {
2322 match schema_type {
2323 Some(serde_json::Value::String(schema_type)) => schema_type == expected,
2324 Some(serde_json::Value::Array(schema_types)) => schema_types
2325 .iter()
2326 .any(|schema_type| schema_type.as_str() == Some(expected)),
2327 _ => false,
2328 }
2329}
2330
2331#[derive(Debug, Deserialize, Serialize)]
2334#[serde(tag = "type", rename_all = "snake_case")]
2335enum OutputFormat {
2336 JsonSchema { schema: serde_json::Value },
2338}
2339
2340#[derive(Debug, Deserialize, Serialize)]
2342struct OutputConfig {
2343 format: OutputFormat,
2344}
2345
2346#[derive(Debug, Deserialize, Serialize)]
2347pub(super) struct AnthropicCompletionRequest {
2348 model: String,
2349 messages: Vec<Message>,
2350 max_tokens: u64,
2351 #[serde(skip_serializing_if = "Vec::is_empty")]
2353 system: Vec<SystemContent>,
2354 #[serde(skip_serializing_if = "Option::is_none")]
2355 temperature: Option<f64>,
2356 #[serde(skip_serializing_if = "Option::is_none")]
2357 tool_choice: Option<ToolChoice>,
2358 #[serde(skip_serializing_if = "Vec::is_empty")]
2359 tools: Vec<serde_json::Value>,
2360 #[serde(skip_serializing_if = "Option::is_none")]
2361 output_config: Option<OutputConfig>,
2362 #[serde(flatten, skip_serializing_if = "Option::is_none")]
2363 additional_params: Option<serde_json::Value>,
2364 #[serde(skip_serializing_if = "Option::is_none")]
2368 cache_control: Option<CacheControl>,
2369}
2370
2371fn set_content_cache_control(content: &mut Content, value: Option<CacheControl>) {
2373 match content {
2374 Content::Text { cache_control, .. } => *cache_control = value,
2375 Content::Image { cache_control, .. } => *cache_control = value,
2376 Content::ToolResult { cache_control, .. } => *cache_control = value,
2377 Content::Document { cache_control, .. } => *cache_control = value,
2378 _ => {}
2379 }
2380}
2381
2382const MAX_CACHE_CONTROL_MARKERS: usize = 4;
2383
2384fn final_cacheable_tool_idx(tools: &[serde_json::Value]) -> Option<usize> {
2385 tools.iter().rposition(|tool| {
2386 tool.as_object().is_some_and(|tool| {
2387 !matches!(
2388 tool.get("defer_loading"),
2389 Some(serde_json::Value::Bool(true))
2390 )
2391 })
2392 })
2393}
2394
2395fn tool_cache_control_count(tools: &[serde_json::Value]) -> usize {
2396 tools
2397 .iter()
2398 .filter(|tool| tool_cache_control_value(tool).is_some())
2399 .count()
2400}
2401
2402fn tool_cache_control_value(tool: &serde_json::Value) -> Option<&serde_json::Value> {
2403 tool.get("cache_control")
2404 .filter(|cache_control| !cache_control.is_null())
2405}
2406
2407fn normalize_tool_cache_control(tools: &mut [serde_json::Value]) {
2408 for tool in tools.iter_mut() {
2409 if let Some(tool) = tool.as_object_mut()
2410 && tool
2411 .get("cache_control")
2412 .is_some_and(serde_json::Value::is_null)
2413 {
2414 tool.remove("cache_control");
2415 }
2416 }
2417}
2418
2419fn build_cache_control(ttl: Option<CacheTtl>) -> CacheControl {
2420 CacheControl::Ephemeral { ttl }
2421}
2422
2423#[derive(Clone, Copy, PartialEq, Eq)]
2424enum CacheControlTtl {
2425 FiveMinutes,
2426 OneHour,
2427}
2428
2429fn cache_control_ttl(cache_control: &CacheControl) -> CacheControlTtl {
2430 match cache_control {
2431 CacheControl::Ephemeral {
2432 ttl: Some(CacheTtl::OneHour),
2433 } => CacheControlTtl::OneHour,
2434 CacheControl::Ephemeral { .. } => CacheControlTtl::FiveMinutes,
2435 }
2436}
2437
2438fn cache_control_ttl_from_json(cache_control: &serde_json::Value) -> CacheControlTtl {
2439 match cache_control.get("ttl") {
2440 Some(serde_json::Value::String(ttl)) if ttl == "1h" => CacheControlTtl::OneHour,
2441 _ => CacheControlTtl::FiveMinutes,
2442 }
2443}
2444
2445fn content_cache_control(content: &Content) -> Option<&CacheControl> {
2446 match content {
2447 Content::Text { cache_control, .. }
2448 | Content::Image { cache_control, .. }
2449 | Content::ToolResult { cache_control, .. }
2450 | Content::Document { cache_control, .. } => cache_control.as_ref(),
2451 _ => None,
2452 }
2453}
2454
2455fn validate_cache_control_ttl(
2456 ttl: CacheControlTtl,
2457 shorter_ttl_seen: &mut bool,
2458) -> Result<(), CompletionError> {
2459 match ttl {
2460 CacheControlTtl::OneHour if *shorter_ttl_seen => Err(CompletionError::RequestError(
2461 "Anthropic cache_control markers with ttl `1h` must appear before markers with \
2462 the default 5-minute TTL"
2463 .into(),
2464 )),
2465 CacheControlTtl::OneHour => Ok(()),
2466 CacheControlTtl::FiveMinutes => {
2467 *shorter_ttl_seen = true;
2468 Ok(())
2469 }
2470 }
2471}
2472
2473fn validate_cache_control_ttl_order(
2474 system: &[SystemContent],
2475 messages: &[Message],
2476 tools: &[serde_json::Value],
2477 top_level_cache_control: Option<&CacheControl>,
2478) -> Result<(), CompletionError> {
2479 let mut shorter_ttl_seen = false;
2480
2481 for tool in tools {
2482 if let Some(cache_control) = tool_cache_control_value(tool) {
2483 validate_cache_control_ttl(
2484 cache_control_ttl_from_json(cache_control),
2485 &mut shorter_ttl_seen,
2486 )?;
2487 }
2488 }
2489
2490 for SystemContent::Text { cache_control, .. } in system {
2491 if let Some(cache_control) = cache_control {
2492 validate_cache_control_ttl(cache_control_ttl(cache_control), &mut shorter_ttl_seen)?;
2493 }
2494 }
2495
2496 for message in messages {
2497 for content in message.content.iter() {
2498 if let Some(cache_control) = content_cache_control(content) {
2499 validate_cache_control_ttl(
2500 cache_control_ttl(cache_control),
2501 &mut shorter_ttl_seen,
2502 )?;
2503 }
2504 }
2505 }
2506
2507 if let Some(cache_control) = top_level_cache_control {
2508 validate_cache_control_ttl(cache_control_ttl(cache_control), &mut shorter_ttl_seen)?;
2509 }
2510
2511 Ok(())
2512}
2513
2514fn top_level_cache_control_ttl(cache_control: Option<&CacheControl>) -> Option<CacheTtl> {
2515 cache_control
2516 .map(|cache_control| match cache_control {
2517 CacheControl::Ephemeral { ttl } => ttl.clone(),
2518 })
2519 .unwrap_or_default()
2520}
2521
2522fn apply_tool_cache_control(
2524 tools: &mut [serde_json::Value],
2525 remaining_cache_markers: &mut usize,
2526 cache_control: &CacheControl,
2527) -> Result<(), CompletionError> {
2528 let Some(idx) = final_cacheable_tool_idx(tools) else {
2529 return Ok(());
2530 };
2531
2532 let Some(tool) = tools
2533 .get_mut(idx)
2534 .and_then(serde_json::Value::as_object_mut)
2535 else {
2536 return Ok(());
2537 };
2538
2539 if tool
2540 .get("cache_control")
2541 .is_some_and(|cache_control| !cache_control.is_null())
2542 {
2543 return Ok(());
2544 }
2545
2546 if *remaining_cache_markers == 0 {
2547 return Err(CompletionError::RequestError(
2548 "Anthropic manual prompt caching requires a cache_control marker on the final \
2549 non-deferred tool, but explicit tool markers exhaust the available cache point budget"
2550 .into(),
2551 ));
2552 }
2553
2554 tool.insert(
2555 "cache_control".to_string(),
2556 serde_json::to_value(cache_control)?,
2557 );
2558 *remaining_cache_markers -= 1;
2559
2560 Ok(())
2561}
2562
2563fn apply_system_cache_control(
2564 system: &mut [SystemContent],
2565 remaining_cache_markers: &mut usize,
2566 cache_control_value: &CacheControl,
2567) {
2568 if *remaining_cache_markers == 0 {
2569 return;
2570 }
2571
2572 if let Some(SystemContent::Text { cache_control, .. }) = system.last_mut()
2573 && cache_control.is_none()
2574 {
2575 *cache_control = Some(cache_control_value.clone());
2576 *remaining_cache_markers -= 1;
2577 }
2578}
2579
2580fn clear_message_cache_control(messages: &mut [Message]) {
2581 for msg in messages.iter_mut() {
2582 for content in msg.content.iter_mut() {
2583 set_content_cache_control(content, None);
2584 }
2585 }
2586}
2587
2588fn apply_message_cache_control(
2589 messages: &mut [Message],
2590 remaining_cache_markers: &mut usize,
2591 cache_control: &CacheControl,
2592) {
2593 clear_message_cache_control(messages);
2594
2595 if *remaining_cache_markers == 0 {
2596 return;
2597 }
2598
2599 if let Some(last_msg) = messages.last_mut()
2600 && let Some(last_content) = last_msg.content.last_mut()
2601 {
2602 set_content_cache_control(last_content, Some(cache_control.clone()));
2603 *remaining_cache_markers -= 1;
2604 }
2605}
2606
2607pub(super) fn apply_prompt_cache_control(
2608 system: &mut [SystemContent],
2609 messages: &mut [Message],
2610 tools: &mut [serde_json::Value],
2611 prompt_caching: bool,
2612 static_prefix_cache_ttl: Option<&CacheTtl>,
2613 top_level_cache_control: Option<&CacheControl>,
2614) -> Result<(), CompletionError> {
2615 normalize_tool_cache_control(tools);
2616
2617 let max_cache_markers = if top_level_cache_control.is_some() {
2618 MAX_CACHE_CONTROL_MARKERS - 1
2619 } else {
2620 MAX_CACHE_CONTROL_MARKERS
2621 };
2622 let tool_cache_markers = tool_cache_control_count(tools);
2623
2624 if tool_cache_markers > max_cache_markers {
2625 return Err(CompletionError::RequestError(
2626 format!(
2627 "Too many Anthropic tool `cache_control` markers: {tool_cache_markers} exceeds \
2628 the available prompt caching budget of {max_cache_markers}"
2629 )
2630 .into(),
2631 ));
2632 }
2633
2634 let mut remaining_cache_markers = max_cache_markers - tool_cache_markers;
2635
2636 let top_level_ttl = top_level_cache_control_ttl(top_level_cache_control);
2642 if static_prefix_cache_ttl == Some(&CacheTtl::FiveMinutes)
2643 && top_level_ttl == Some(CacheTtl::OneHour)
2644 {
2645 return Err(CompletionError::RequestError(
2646 "`with_static_prefix_cache_ttl(CacheTtl::FiveMinutes)` conflicts with the 1-hour \
2647 top-level cache TTL (`with_automatic_caching_1h` or a raw top-level \
2648 `cache_control`): Anthropic requires 1h markers to precede 5-minute ones, and the \
2649 static prefix precedes the conversation tail"
2650 .into(),
2651 ));
2652 }
2653
2654 if prompt_caching || static_prefix_cache_ttl.is_some() {
2658 let static_cache_control =
2659 build_cache_control(static_prefix_cache_ttl.cloned().or(top_level_ttl.clone()));
2660
2661 apply_tool_cache_control(tools, &mut remaining_cache_markers, &static_cache_control)?;
2662 apply_system_cache_control(system, &mut remaining_cache_markers, &static_cache_control);
2663 }
2664
2665 if prompt_caching {
2666 if top_level_cache_control.is_some() {
2667 clear_message_cache_control(messages);
2668 } else {
2669 let tail_cache_control = build_cache_control(top_level_ttl);
2670 apply_message_cache_control(
2671 messages,
2672 &mut remaining_cache_markers,
2673 &tail_cache_control,
2674 );
2675 }
2676 }
2677
2678 validate_cache_control_ttl_order(system, messages, tools, top_level_cache_control)?;
2679
2680 Ok(())
2681}
2682
2683pub(super) fn extract_top_level_cache_control(
2684 additional_params: &mut serde_json::Value,
2685) -> Result<Option<CacheControl>, CompletionError> {
2686 if let Some(map) = additional_params.as_object_mut()
2687 && let Some(raw_cache_control) = map.remove("cache_control")
2688 {
2689 if raw_cache_control.is_null() {
2690 return Ok(None);
2691 }
2692
2693 return serde_json::from_value::<CacheControl>(raw_cache_control)
2694 .map(Some)
2695 .map_err(|err| {
2696 CompletionError::RequestError(
2697 format!("Invalid Anthropic `additional_params.cache_control` payload: {err}")
2698 .into(),
2699 )
2700 });
2701 }
2702
2703 Ok(None)
2704}
2705
2706pub(super) fn resolve_top_level_cache_control(
2707 automatic_caching: bool,
2708 automatic_caching_ttl: Option<CacheTtl>,
2709 additional_params: &mut serde_json::Value,
2710) -> Result<Option<CacheControl>, CompletionError> {
2711 let raw_cache_control = extract_top_level_cache_control(additional_params)?;
2712 let typed_cache_control = automatic_caching.then_some(CacheControl::Ephemeral {
2713 ttl: automatic_caching_ttl.clone(),
2714 });
2715
2716 match (typed_cache_control, raw_cache_control) {
2717 (Some(typed_cache_control), Some(raw_cache_control)) => {
2718 if automatic_caching_ttl.is_some()
2719 && cache_control_ttl(&typed_cache_control) != cache_control_ttl(&raw_cache_control)
2720 {
2721 return Err(CompletionError::RequestError(
2722 "Anthropic `additional_params.cache_control` conflicts with the typed \
2723 automatic caching TTL"
2724 .into(),
2725 ));
2726 }
2727
2728 Ok(Some(raw_cache_control))
2729 }
2730 (Some(typed_cache_control), None) => Ok(Some(typed_cache_control)),
2731 (None, raw_cache_control) => Ok(raw_cache_control),
2732 }
2733}
2734
2735pub(super) fn split_system_messages_from_history(
2736 history: Vec<message::Message>,
2737 preserve_mid_conversation_system_messages: bool,
2738) -> (Vec<SystemContent>, Vec<message::Message>) {
2739 let mut system = Vec::new();
2740 let mut remaining = Vec::new();
2741
2742 for (index, message) in history.iter().enumerate() {
2743 match message {
2744 message::Message::System { content } => {
2745 if !content.is_empty() {
2746 if preserve_mid_conversation_system_messages
2747 && is_valid_mid_conversation_system_message(&history, index)
2748 {
2749 remaining.push(message.clone());
2750 } else {
2751 system.push(SystemContent::Text {
2752 text: content.clone(),
2753 cache_control: None,
2754 });
2755 }
2756 }
2757 }
2758 other => remaining.push(other.clone()),
2759 }
2760 }
2761
2762 (system, remaining)
2763}
2764
2765fn is_valid_mid_conversation_system_message(history: &[message::Message], index: usize) -> bool {
2766 let follows_valid_turn = index > 0
2767 && history.get(index - 1).is_some_and(|message| {
2768 matches!(message, message::Message::User { .. })
2769 || assistant_ends_in_server_tool_block(message)
2770 });
2771 let is_last_or_precedes_assistant = history
2772 .get(index + 1)
2773 .is_none_or(|message| matches!(message, message::Message::Assistant { .. }));
2774
2775 follows_valid_turn && is_last_or_precedes_assistant
2776}
2777
2778fn assistant_ends_in_server_tool_block(message: &message::Message) -> bool {
2779 let message::Message::Assistant { content, .. } = message else {
2780 return false;
2781 };
2782
2783 let Some(message::AssistantContent::Text(text)) = content.iter().last() else {
2784 return false;
2785 };
2786
2787 let Some(raw_type) = text
2788 .additional_params
2789 .as_ref()
2790 .and_then(|params| params.get(ANTHROPIC_RAW_CONTENT_KEY))
2791 .and_then(|raw_content| raw_content.get("type"))
2792 .and_then(serde_json::Value::as_str)
2793 else {
2794 return false;
2795 };
2796
2797 matches!(
2798 raw_type,
2799 "server_tool_use" | "web_search_tool_result" | "code_execution_tool_result"
2800 )
2801}
2802
2803pub struct AnthropicRequestParams<'a> {
2805 pub model: &'a str,
2806 pub request: CompletionRequest,
2807 pub prompt_caching: bool,
2808 pub automatic_caching: bool,
2810 pub automatic_caching_ttl: Option<CacheTtl>,
2812 pub static_prefix_cache_ttl: Option<CacheTtl>,
2814}
2815
2816impl AnthropicCompletionRequest {
2817 pub(super) fn try_from_params<Ext>(
2818 params: AnthropicRequestParams<'_>,
2819 strict_tools: bool,
2820 ) -> Result<Self, CompletionError>
2821 where
2822 Ext: AnthropicCompatibleProvider,
2823 {
2824 let AnthropicRequestParams {
2825 model,
2826 request: mut req,
2827 prompt_caching,
2828 automatic_caching,
2829 automatic_caching_ttl,
2830 static_prefix_cache_ttl,
2831 } = params;
2832 let chat_history = req.chat_history_with_documents();
2833
2834 let Some(max_tokens) = req.max_tokens else {
2836 return Err(CompletionError::RequestError(
2837 "`max_tokens` must be set for Anthropic".into(),
2838 ));
2839 };
2840
2841 let (history_system, chat_history) = split_system_messages_from_history(
2842 chat_history,
2843 supports_mid_conversation_system_messages(model),
2844 );
2845 let mut full_history = vec![];
2846 full_history.extend(chat_history);
2847
2848 let mut messages = full_history
2849 .into_iter()
2850 .map(Message::try_from)
2851 .collect::<Result<Vec<Message>, _>>()?;
2852
2853 let mut additional_params_payload = req
2854 .additional_params
2855 .take()
2856 .unwrap_or(serde_json::Value::Null);
2857 let top_level_cache_control = resolve_top_level_cache_control(
2858 automatic_caching,
2859 automatic_caching_ttl,
2860 &mut additional_params_payload,
2861 )?;
2862 let mut tools =
2863 build_tool_definitions::<Ext>(req.tools, &mut additional_params_payload, strict_tools)?;
2864
2865 let mut system = if let Some(preamble) = req.preamble {
2867 if preamble.is_empty() {
2868 vec![]
2869 } else {
2870 vec![SystemContent::Text {
2871 text: preamble,
2872 cache_control: None,
2873 }]
2874 }
2875 } else {
2876 vec![]
2877 };
2878 system.extend(history_system);
2879
2880 apply_prompt_cache_control(
2881 &mut system,
2882 &mut messages,
2883 &mut tools,
2884 prompt_caching,
2885 static_prefix_cache_ttl.as_ref(),
2886 top_level_cache_control.as_ref(),
2887 )?;
2888
2889 let output_config = if let Some(schema) = req.output_schema {
2890 let mut schema_value = schema.to_value();
2891 sanitize_schema(&mut schema_value);
2892 Some(OutputConfig {
2893 format: OutputFormat::JsonSchema {
2894 schema: schema_value,
2895 },
2896 })
2897 } else {
2898 None
2899 };
2900
2901 Ok(Self {
2902 model: model.to_string(),
2903 messages,
2904 max_tokens,
2905 system,
2906 temperature: req.temperature,
2907 tool_choice: req.tool_choice.map(ToolChoice::try_from).transpose()?,
2908 tools,
2909 output_config,
2910 cache_control: top_level_cache_control,
2912 additional_params: if additional_params_payload.is_null() {
2913 None
2914 } else {
2915 Some(additional_params_payload)
2916 },
2917 })
2918 }
2919}
2920
2921impl TryFrom<AnthropicRequestParams<'_>> for AnthropicCompletionRequest {
2922 type Error = CompletionError;
2923
2924 fn try_from(params: AnthropicRequestParams<'_>) -> Result<Self, Self::Error> {
2925 Self::try_from_params::<super::client::AnthropicExt>(params, false)
2926 }
2927}
2928
2929pub(super) fn extract_tools_from_additional_params(
2930 additional_params: &mut serde_json::Value,
2931) -> Result<Vec<serde_json::Value>, CompletionError> {
2932 if let Some(map) = additional_params.as_object_mut()
2933 && let Some(raw_tools) = map.remove("tools")
2934 {
2935 return serde_json::from_value::<Vec<serde_json::Value>>(raw_tools).map_err(|err| {
2936 CompletionError::RequestError(
2937 format!("Invalid Anthropic `additional_params.tools` payload: {err}").into(),
2938 )
2939 });
2940 }
2941
2942 Ok(Vec::new())
2943}
2944
2945pub(super) fn build_tool_definitions<Ext>(
2946 tools: Vec<completion::ToolDefinition>,
2947 additional_params_payload: &mut serde_json::Value,
2948 strict_tools: bool,
2949) -> Result<Vec<serde_json::Value>, CompletionError>
2950where
2951 Ext: AnthropicCompatibleProvider,
2952{
2953 let mut additional_tools = extract_tools_from_additional_params(additional_params_payload)?;
2954
2955 let mut tools = tools
2956 .into_iter()
2957 .map(|tool| {
2958 let input_schema = tool.parameters;
2959 let mut tool = ToolDefinition {
2960 name: tool.name,
2961 description: Some(tool.description),
2962 input_schema,
2963 strict: false,
2964 cache_control: None,
2965 };
2966 if strict_tools {
2967 Ext::enable_strict_tool_use(&mut tool);
2968 }
2969
2970 tool
2971 })
2972 .map(serde_json::to_value)
2973 .collect::<Result<Vec<_>, _>>()?;
2974 tools.append(&mut additional_tools);
2975
2976 Ok(tools)
2977}
2978
2979impl<Ext, T> GenericCompletionModel<Ext, T>
2980where
2981 T: HttpClientExt + Clone + Default + WasmCompatSend + WasmCompatSync + 'static,
2982 Ext: AnthropicCompatibleProvider + Clone + WasmCompatSend + WasmCompatSync + 'static,
2983{
2984 pub async fn raw_completion(
2993 &self,
2994 completion_request: completion::CompletionRequest,
2995 ) -> Result<CompletionResponse, CompletionError> {
2996 let (span, request) =
2997 self.prepare_request(completion_request, CompletionOperation::Chat)?;
2998
2999 crate::providers::internal::trace_json(
3000 crate::providers::internal::LogTarget::Completions,
3001 "Anthropic completion request",
3002 &request,
3003 );
3004
3005 let request: Vec<u8> = serde_json::to_vec(&request)?;
3006
3007 let req = self
3008 .client
3009 .post("/v1/messages")?
3010 .body(request)
3011 .map_err(|e| CompletionError::HttpError(e.into()))?;
3012
3013 let (mut completion, provider_request_id) =
3014 send_completion::<_, ApiResponse<CompletionResponse>, _>(
3015 &self.client,
3016 req,
3017 "Anthropic completion",
3018 Ext::REQUEST_ID_HEADER,
3019 |completion| {
3020 let span = tracing::Span::current();
3021 span.record_response_metadata(completion);
3022 span.record_token_usage(&crate::completion::Usage::from(&completion.usage));
3023 },
3024 )
3025 .instrument(span)
3026 .await?;
3027 completion.provider_request_id = provider_request_id;
3028 Ok(completion)
3029 }
3030}
3031
3032impl<Ext, T> completion::CompletionModel for GenericCompletionModel<Ext, T>
3033where
3034 T: HttpClientExt + Clone + Default + WasmCompatSend + WasmCompatSync + 'static,
3035 Ext: AnthropicCompatibleProvider + Clone + WasmCompatSend + WasmCompatSync + 'static,
3036{
3037 fn capabilities(&self) -> completion::ProviderCapabilities {
3041 completion::ProviderCapabilities::default().with_native_output_tool_composition(true)
3042 }
3043
3044 async fn completion(
3045 &self,
3046 completion_request: completion::CompletionRequest,
3047 ) -> Result<completion::CompletionResponse, CompletionError> {
3048 let response = self.raw_completion(completion_request).await?;
3050 let captured = serde_json::to_value(&response)?;
3051 Ok(response.normalize(Ext::PROVIDER_NAME)?.with_raw(captured))
3052 }
3053
3054 async fn stream(
3055 &self,
3056 request: CompletionRequest,
3057 ) -> Result<crate::streaming::StreamingCompletionResponse, CompletionError> {
3058 GenericCompletionModel::stream(self, request).await
3059 }
3060}
3061
3062impl<Ext, T> crate::client::ConstructCompletionModel<crate::client::Client<Ext, T>>
3063 for GenericCompletionModel<Ext, T>
3064where
3065 crate::client::Client<Ext, T>: Clone,
3066 T: HttpClientExt,
3067 Ext: AnthropicCompatibleProvider + Clone + 'static,
3068{
3069 fn construct(client: &crate::client::Client<Ext, T>, model: String) -> Self {
3070 Self::new(client.clone(), model)
3071 }
3072}
3073
3074use crate::providers::internal::envelope::ApiErrorResponse;
3075
3076#[derive(Debug, Deserialize)]
3077#[serde(tag = "type", rename_all = "snake_case")]
3078enum ApiResponse<T> {
3079 Message(T),
3080 Error(ApiErrorResponse),
3081}
3082
3083impl<T> crate::providers::internal::envelope::ProviderEnvelope for ApiResponse<T> {
3084 type Payload = T;
3085
3086 fn into_payload(self) -> Result<T, String> {
3087 match self {
3088 Self::Message(payload) => Ok(payload),
3089 Self::Error(ApiErrorResponse { message }) => Err(message),
3090 }
3091 }
3092}
3093
3094#[cfg(test)]
3095mod tests {
3096 use super::*;
3097 use crate::message::EMPTY_RESPONSE_ERROR;
3098 use serde_json::json;
3099 use serde_path_to_error::deserialize;
3100
3101 #[test]
3102 fn current_model_default_max_tokens_match_anthropic_limits() {
3103 assert_eq!(default_max_tokens_for_model(CLAUDE_OPUS_4_8), Some(128_000));
3104 assert_eq!(default_max_tokens_for_model(CLAUDE_OPUS_4_7), Some(128_000));
3105 assert_eq!(default_max_tokens_for_model(CLAUDE_OPUS_4_6), Some(128_000));
3106 assert_eq!(
3107 default_max_tokens_for_model(CLAUDE_SONNET_4_6),
3108 Some(64_000)
3109 );
3110 assert_eq!(default_max_tokens_for_model(CLAUDE_HAIKU_4_5), Some(64_000));
3111 }
3112
3113 #[test]
3114 fn unknown_model_uses_conservative_default_max_tokens_fallback() {
3115 assert_eq!(default_max_tokens_for_model("claude-unknown"), None);
3116 assert_eq!(default_max_tokens_with_fallback("claude-unknown"), 2_048);
3117 }
3118
3119 #[test]
3120 fn system_role_message_deserializes_and_round_trips() {
3121 let message: Message = serde_json::from_str(
3122 r#"
3123 {
3124 "role": "system",
3125 "content": "From now on, require explicit type annotations."
3126 }
3127 "#,
3128 )
3129 .unwrap();
3130
3131 assert_eq!(message.role, Role::System);
3132
3133 let generic: message::Message = message.try_into().unwrap();
3134 assert_eq!(
3135 generic,
3136 message::Message::System {
3137 content: "From now on, require explicit type annotations.".to_string()
3138 }
3139 );
3140
3141 let provider: Message = generic.try_into().unwrap();
3142 assert_eq!(provider.role, Role::System);
3143 }
3144
3145 #[test]
3146 fn test_deserialize_message() {
3147 let assistant_message_json = r#"
3148 {
3149 "role": "assistant",
3150 "content": "\n\nHello there, how may I assist you today?"
3151 }
3152 "#;
3153
3154 let assistant_message_json2 = r#"
3155 {
3156 "role": "assistant",
3157 "content": [
3158 {
3159 "type": "text",
3160 "text": "\n\nHello there, how may I assist you today?"
3161 },
3162 {
3163 "type": "tool_use",
3164 "id": "toolu_01A09q90qw90lq917835lq9",
3165 "name": "get_weather",
3166 "input": {"location": "San Francisco, CA"}
3167 }
3168 ]
3169 }
3170 "#;
3171
3172 let user_message_json = r#"
3173 {
3174 "role": "user",
3175 "content": [
3176 {
3177 "type": "image",
3178 "source": {
3179 "type": "base64",
3180 "media_type": "image/jpeg",
3181 "data": "/9j/4AAQSkZJRg..."
3182 }
3183 },
3184 {
3185 "type": "text",
3186 "text": "What is in this image?"
3187 },
3188 {
3189 "type": "tool_result",
3190 "tool_use_id": "toolu_01A09q90qw90lq917835lq9",
3191 "content": "15 degrees"
3192 }
3193 ]
3194 }
3195 "#;
3196
3197 let assistant_message: Message = {
3198 let jd = &mut serde_json::Deserializer::from_str(assistant_message_json);
3199 deserialize(jd).unwrap_or_else(|err| {
3200 panic!("Deserialization error at {}: {}", err.path(), err);
3201 })
3202 };
3203
3204 let assistant_message2: Message = {
3205 let jd = &mut serde_json::Deserializer::from_str(assistant_message_json2);
3206 deserialize(jd).unwrap_or_else(|err| {
3207 panic!("Deserialization error at {}: {}", err.path(), err);
3208 })
3209 };
3210
3211 let user_message: Message = {
3212 let jd = &mut serde_json::Deserializer::from_str(user_message_json);
3213 deserialize(jd).unwrap_or_else(|err| {
3214 panic!("Deserialization error at {}: {}", err.path(), err);
3215 })
3216 };
3217
3218 let Message { role, content } = assistant_message;
3219 assert_eq!(role, Role::Assistant);
3220 assert_eq!(
3221 content.first(),
3222 Some(&Content::Text {
3223 text: "\n\nHello there, how may I assist you today?".to_owned(),
3224 citations: Vec::new(),
3225 cache_control: None,
3226 })
3227 );
3228
3229 let Message { role, content } = assistant_message2;
3230 {
3231 assert_eq!(role, Role::Assistant);
3232 assert_eq!(content.len(), 2);
3233
3234 let mut iter = content.into_iter();
3235
3236 match iter.next().unwrap() {
3237 Content::Text { text, .. } => {
3238 assert_eq!(text, "\n\nHello there, how may I assist you today?");
3239 }
3240 _ => panic!("Expected text content"),
3241 }
3242
3243 match iter.next().unwrap() {
3244 Content::ToolUse { id, name, input } => {
3245 assert_eq!(id, "toolu_01A09q90qw90lq917835lq9");
3246 assert_eq!(name, "get_weather");
3247 assert_eq!(input, json!({"location": "San Francisco, CA"}));
3248 }
3249 _ => panic!("Expected tool use content"),
3250 }
3251
3252 assert_eq!(iter.next(), None);
3253 }
3254
3255 let Message { role, content } = user_message;
3256 {
3257 assert_eq!(role, Role::User);
3258 assert_eq!(content.len(), 3);
3259
3260 let mut iter = content.into_iter();
3261
3262 match iter.next().unwrap() {
3263 Content::Image { source, .. } => {
3264 assert_eq!(
3265 source,
3266 ImageSource::Base64 {
3267 data: "/9j/4AAQSkZJRg...".to_owned(),
3268 media_type: ImageFormat::JPEG,
3269 }
3270 );
3271 }
3272 _ => panic!("Expected image content"),
3273 }
3274
3275 match iter.next().unwrap() {
3276 Content::Text { text, .. } => {
3277 assert_eq!(text, "What is in this image?");
3278 }
3279 _ => panic!("Expected text content"),
3280 }
3281
3282 match iter.next().unwrap() {
3283 Content::ToolResult {
3284 tool_use_id,
3285 content,
3286 is_error,
3287 ..
3288 } => {
3289 assert_eq!(tool_use_id, "toolu_01A09q90qw90lq917835lq9");
3290 assert_eq!(
3291 content.first(),
3292 Some(&ToolResultContent::Text {
3293 text: "15 degrees".to_owned()
3294 })
3295 );
3296 assert_eq!(is_error, None);
3297 }
3298 _ => panic!("Expected tool result content"),
3299 }
3300
3301 assert_eq!(iter.next(), None);
3302 }
3303 }
3304
3305 #[test]
3306 fn test_message_to_message_conversion() {
3307 let user_message: Message = serde_json::from_str(
3308 r#"
3309 {
3310 "role": "user",
3311 "content": [
3312 {
3313 "type": "image",
3314 "source": {
3315 "type": "base64",
3316 "media_type": "image/jpeg",
3317 "data": "/9j/4AAQSkZJRg..."
3318 }
3319 },
3320 {
3321 "type": "text",
3322 "text": "What is in this image?"
3323 },
3324 {
3325 "type": "document",
3326 "source": {
3327 "type": "base64",
3328 "data": "base64_encoded_pdf_data",
3329 "media_type": "application/pdf"
3330 }
3331 }
3332 ]
3333 }
3334 "#,
3335 )
3336 .unwrap();
3337
3338 let assistant_message = Message {
3339 role: Role::Assistant,
3340 content: vec![Content::ToolUse {
3341 id: "toolu_01A09q90qw90lq917835lq9".to_string(),
3342 name: "get_weather".to_string(),
3343 input: json!({"location": "San Francisco, CA"}),
3344 }],
3345 };
3346
3347 let tool_message = Message {
3348 role: Role::User,
3349 content: vec![Content::ToolResult {
3350 tool_use_id: "toolu_01A09q90qw90lq917835lq9".to_string(),
3351 content: vec![ToolResultContent::Text {
3352 text: "15 degrees".to_string(),
3353 }],
3354 is_error: None,
3355 cache_control: None,
3356 }],
3357 };
3358
3359 let converted_user_message: message::Message = user_message.clone().try_into().unwrap();
3360 let converted_assistant_message: message::Message =
3361 assistant_message.clone().try_into().unwrap();
3362 let converted_tool_message: message::Message = tool_message.clone().try_into().unwrap();
3363
3364 match converted_user_message.clone() {
3365 message::Message::User { content } => {
3366 assert_eq!(content.len(), 3);
3367
3368 let mut iter = content.into_iter();
3369
3370 match iter.next().unwrap() {
3371 message::UserContent::Image(message::Image {
3372 data, media_type, ..
3373 }) => {
3374 assert_eq!(data, DocumentSourceKind::base64("/9j/4AAQSkZJRg..."));
3375 assert_eq!(media_type, Some(message::ImageMediaType::JPEG));
3376 }
3377 _ => panic!("Expected image content"),
3378 }
3379
3380 match iter.next().unwrap() {
3381 message::UserContent::Text(message::Text { text, .. }) => {
3382 assert_eq!(text, "What is in this image?");
3383 }
3384 _ => panic!("Expected text content"),
3385 }
3386
3387 match iter.next().unwrap() {
3388 message::UserContent::Document(message::Document {
3389 data, media_type, ..
3390 }) => {
3391 assert_eq!(
3392 data,
3393 DocumentSourceKind::String("base64_encoded_pdf_data".into())
3394 );
3395 assert_eq!(media_type, Some(message::DocumentMediaType::PDF));
3396 }
3397 _ => panic!("Expected document content"),
3398 }
3399
3400 assert_eq!(iter.next(), None);
3401 }
3402 _ => panic!("Expected user message"),
3403 }
3404
3405 match converted_tool_message.clone() {
3406 message::Message::User { content } => {
3407 let message::ToolResult {
3408 call,
3409 name,
3410 content,
3411 ..
3412 } = match content.first() {
3413 Some(message::UserContent::ToolResult(tool_result)) => tool_result,
3414 _ => panic!("Expected tool result content"),
3415 };
3416 assert_eq!(call, "toolu_01A09q90qw90lq917835lq9");
3417 assert_eq!(name, "");
3420 match content.first() {
3421 Some(message::ToolResultContent::Text(message::Text { text, .. })) => {
3422 assert_eq!(text, "15 degrees");
3423 }
3424 _ => panic!("Expected text content"),
3425 }
3426 }
3427 _ => panic!("Expected tool result content"),
3428 }
3429
3430 match converted_assistant_message.clone() {
3431 message::Message::Assistant { content, .. } => {
3432 assert_eq!(content.len(), 1);
3433
3434 match content.first() {
3435 Some(message::AssistantContent::ToolCall(message::ToolCall {
3436 id,
3437 function,
3438 ..
3439 })) => {
3440 assert_eq!(id, "toolu_01A09q90qw90lq917835lq9");
3441 assert_eq!(function.name, "get_weather");
3442 assert_eq!(function.arguments, json!({"location": "San Francisco, CA"}));
3443 }
3444 _ => panic!("Expected tool call content"),
3445 }
3446 }
3447 _ => panic!("Expected assistant message"),
3448 }
3449
3450 let original_user_message: Message = converted_user_message.try_into().unwrap();
3451 let original_assistant_message: Message = converted_assistant_message.try_into().unwrap();
3452 let original_tool_message: Message = converted_tool_message.try_into().unwrap();
3453
3454 assert_eq!(user_message, original_user_message);
3455 assert_eq!(assistant_message, original_assistant_message);
3456 assert_eq!(tool_message, original_tool_message);
3457 }
3458
3459 #[test]
3460 fn test_content_format_conversion() {
3461 use crate::completion::message::ContentFormat;
3462
3463 let source_type: SourceType = ContentFormat::Url.try_into().unwrap();
3464 assert_eq!(source_type, SourceType::URL);
3465
3466 let content_format: ContentFormat = SourceType::URL.into();
3467 assert_eq!(content_format, ContentFormat::Url);
3468
3469 let source_type: SourceType = ContentFormat::Base64.try_into().unwrap();
3470 assert_eq!(source_type, SourceType::BASE64);
3471
3472 let content_format: ContentFormat = SourceType::BASE64.into();
3473 assert_eq!(content_format, ContentFormat::Base64);
3474
3475 let source_type: SourceType = ContentFormat::String.try_into().unwrap();
3476 assert_eq!(source_type, SourceType::TEXT);
3477
3478 let content_format: ContentFormat = SourceType::TEXT.into();
3479 assert_eq!(content_format, ContentFormat::String);
3480 }
3481
3482 #[test]
3483 fn test_cache_control_serialization() {
3484 let system = SystemContent::Text {
3486 text: "You are a helpful assistant.".to_string(),
3487 cache_control: Some(CacheControl::ephemeral()),
3488 };
3489 let json = serde_json::to_string(&system).unwrap();
3490 assert!(json.contains(r#""cache_control":{"type":"ephemeral"}"#));
3491 assert!(json.contains(r#""type":"text""#));
3492
3493 let system_no_cache = SystemContent::Text {
3495 text: "Hello".to_string(),
3496 cache_control: None,
3497 };
3498 let json_no_cache = serde_json::to_string(&system_no_cache).unwrap();
3499 assert!(!json_no_cache.contains("cache_control"));
3500
3501 let content = Content::Text {
3503 text: "Test message".to_string(),
3504 citations: Vec::new(),
3505 cache_control: Some(CacheControl::ephemeral()),
3506 };
3507 let json_content = serde_json::to_string(&content).unwrap();
3508 assert!(json_content.contains(r#""cache_control":{"type":"ephemeral"}"#));
3509
3510 let mut system_vec = vec![SystemContent::Text {
3513 text: "System prompt".to_string(),
3514 cache_control: None,
3515 }];
3516 let mut messages = vec![
3517 Message {
3518 role: Role::User,
3519 content: vec![Content::Text {
3520 text: "First message".to_string(),
3521 citations: Vec::new(),
3522 cache_control: None,
3523 }],
3524 },
3525 Message {
3526 role: Role::Assistant,
3527 content: vec![Content::Text {
3528 text: "Response".to_string(),
3529 citations: Vec::new(),
3530 cache_control: None,
3531 }],
3532 },
3533 ];
3534
3535 apply_prompt_cache_control(&mut system_vec, &mut messages, &mut [], true, None, None)
3536 .unwrap();
3537
3538 match &system_vec[0] {
3540 SystemContent::Text { cache_control, .. } => {
3541 assert!(cache_control.is_some());
3542 }
3543 }
3544
3545 for content in messages[0].content.iter() {
3548 if let Content::Text { cache_control, .. } = content {
3549 assert!(cache_control.is_none());
3550 }
3551 }
3552
3553 for content in messages[1].content.iter() {
3555 if let Content::Text { cache_control, .. } = content {
3556 assert!(cache_control.is_some());
3557 }
3558 }
3559 }
3560
3561 fn generic_tool(name: &str) -> completion::ToolDefinition {
3562 completion::ToolDefinition {
3563 name: name.to_string(),
3564 description: format!("{name} description"),
3565 parameters: json!({
3566 "type": "object",
3567 "properties": {}
3568 }),
3569 }
3570 }
3571
3572 fn completion_request_with_tools(
3573 tools: Vec<completion::ToolDefinition>,
3574 additional_params: Option<serde_json::Value>,
3575 ) -> CompletionRequest {
3576 CompletionRequest {
3577 model: None,
3578 preamble: Some("System prompt".to_string()),
3579 chat_history: vec![message::Message::from("Hello")],
3580 documents: Vec::new(),
3581 tools,
3582 temperature: None,
3583 max_tokens: Some(64),
3584 tool_choice: None,
3585 additional_params,
3586 output_schema: None,
3587 record_telemetry_content: false,
3588 }
3589 }
3590
3591 fn completion_request_with_history(
3592 chat_history: Vec<message::Message>,
3593 preamble: Option<String>,
3594 ) -> CompletionRequest {
3595 CompletionRequest {
3596 model: None,
3597 preamble,
3598 chat_history,
3599 documents: Vec::new(),
3600 tools: Vec::new(),
3601 temperature: None,
3602 max_tokens: Some(64),
3603 tool_choice: None,
3604 additional_params: None,
3605 output_schema: None,
3606 record_telemetry_content: false,
3607 }
3608 }
3609
3610 #[test]
3611 fn rig_tools_are_non_strict_by_default() {
3612 let request = completion_request_with_tools(vec![generic_tool("lookup")], None);
3613 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
3614 model: CLAUDE_SONNET_4_6,
3615 request,
3616 prompt_caching: false,
3617 automatic_caching: false,
3618 automatic_caching_ttl: None,
3619 static_prefix_cache_ttl: None,
3620 })
3621 .unwrap();
3622
3623 let value = serde_json::to_value(request).unwrap();
3624 assert!(value["tools"][0].get("strict").is_none());
3625 assert!(
3626 value["tools"][0]["input_schema"]
3627 .get("additionalProperties")
3628 .is_none()
3629 );
3630 }
3631
3632 #[test]
3633 fn strict_tool_hook_is_a_noop_for_anthropic_compatible_gateways() {
3634 let mut additional_params = serde_json::Value::Null;
3635 let tools = build_tool_definitions::<crate::providers::minimax::MiniMaxAnthropicExt>(
3636 vec![generic_tool("lookup")],
3637 &mut additional_params,
3638 true,
3639 )
3640 .unwrap();
3641
3642 assert!(tools[0].get("strict").is_none());
3643 assert!(
3644 tools[0]["input_schema"]
3645 .get("additionalProperties")
3646 .is_none()
3647 );
3648 }
3649
3650 #[test]
3651 fn strict_tools_opt_in_marks_and_sanitizes_rig_tools_only() {
3652 let mut tool = generic_tool("lookup");
3653 tool.parameters = json!({
3654 "type": "object",
3655 "additionalProperties": true,
3656 "properties": {
3657 "query": {
3658 "type": "string",
3659 "minLength": 2,
3660 "maxLength": 20,
3661 "pattern": "^[a-z]+$",
3662 "format": "uuid"
3663 },
3664 "kind": {
3665 "type": "string",
3666 "const": "lookup"
3667 },
3668 "legacy_filter": {
3669 "$ref": "#/definitions/LegacyFilter"
3670 },
3671 "options": {
3672 "type": "object",
3673 "additionalProperties": true,
3674 "properties": {
3675 "limit": {
3676 "type": ["integer", "null"],
3677 "minimum": 1,
3678 "maximum": 100,
3679 "format": "uint32"
3680 }
3681 }
3682 }
3683 },
3684 "definitions": {
3685 "LegacyFilter": {
3686 "type": "object",
3687 "properties": {
3688 "term": { "type": "string" }
3689 }
3690 }
3691 },
3692 "required": ["query"]
3693 });
3694 let request = completion_request_with_tools(
3695 vec![tool],
3696 Some(json!({
3697 "tools": [{
3698 "type": "mcp_toolset",
3699 "name": "remote_tools"
3700 }]
3701 })),
3702 );
3703 let request = AnthropicCompletionRequest::try_from_params::<
3704 crate::providers::anthropic::client::AnthropicExt,
3705 >(
3706 AnthropicRequestParams {
3707 model: CLAUDE_SONNET_4_6,
3708 request,
3709 prompt_caching: false,
3710 automatic_caching: false,
3711 automatic_caching_ttl: None,
3712 static_prefix_cache_ttl: None,
3713 },
3714 true,
3715 )
3716 .unwrap();
3717
3718 let value = serde_json::to_value(request).unwrap();
3719 let rig_tool = &value["tools"][0];
3720 assert_eq!(rig_tool["strict"], true);
3721 assert_eq!(rig_tool["input_schema"]["additionalProperties"], false);
3722 let required = rig_tool["input_schema"]["required"]
3723 .as_array()
3724 .expect("strict object schema should list required properties");
3725 assert_eq!(required.len(), 1);
3726 assert!(required.contains(&json!("query")));
3727 assert_eq!(
3728 rig_tool["input_schema"]["properties"]["options"]["additionalProperties"],
3729 false
3730 );
3731 assert!(
3732 rig_tool["input_schema"]["properties"]["options"]
3733 .get("required")
3734 .is_none()
3735 );
3736 let query = &rig_tool["input_schema"]["properties"]["query"];
3737 assert_eq!(query["format"], "uuid");
3738 for keyword in ["minLength", "maxLength", "pattern"] {
3739 assert!(query.get(keyword).is_none());
3740 }
3741 let query_description = query["description"]
3742 .as_str()
3743 .expect("unsupported string constraints should become guidance");
3744 for guidance in ["minLength: 2", "maxLength: 20", "pattern: ^[a-z]+$"] {
3745 assert!(query_description.contains(guidance));
3746 }
3747 assert_eq!(
3748 rig_tool["input_schema"]["properties"]["kind"]["const"],
3749 "lookup"
3750 );
3751 assert_eq!(
3752 rig_tool["input_schema"]["properties"]["legacy_filter"]["$ref"],
3753 "#/definitions/LegacyFilter"
3754 );
3755 assert_eq!(
3756 rig_tool["input_schema"]["definitions"]["LegacyFilter"]["additionalProperties"],
3757 false
3758 );
3759 let limit = &rig_tool["input_schema"]["properties"]["options"]["properties"]["limit"];
3760 assert!(limit.get("format").is_none());
3761 assert!(
3762 ["minimum", "maximum"]
3763 .into_iter()
3764 .all(|keyword| limit.get(keyword).is_none())
3765 );
3766 let limit_description = limit["description"]
3767 .as_str()
3768 .expect("unsupported numeric constraints should become guidance");
3769 for guidance in ["minimum: 1", "maximum: 100", "format: uint32"] {
3770 assert!(limit_description.contains(guidance));
3771 }
3772
3773 let provider_tool = &value["tools"][1];
3774 assert_eq!(provider_tool["type"], "mcp_toolset");
3775 assert!(provider_tool.get("strict").is_none());
3776 }
3777
3778 fn system_has_cache_control(value: &serde_json::Value) -> bool {
3779 value["system"]
3780 .as_array()
3781 .and_then(|blocks| blocks.last())
3782 .and_then(|block| block.get("cache_control"))
3783 .is_some()
3784 }
3785
3786 fn last_message_has_cache_control(value: &serde_json::Value) -> bool {
3787 value["messages"]
3788 .as_array()
3789 .and_then(|messages| messages.last())
3790 .and_then(|message| message["content"].as_array())
3791 .and_then(|content| content.last())
3792 .and_then(|content| content.get("cache_control"))
3793 .is_some()
3794 }
3795
3796 #[test]
3797 fn opus_4_8_preserves_mid_conversation_system_message() {
3798 let request = completion_request_with_history(
3799 vec![
3800 message::Message::System {
3801 content: "Global history instruction.".to_string(),
3802 },
3803 message::Message::from("Review this code."),
3804 message::Message::System {
3805 content: "From now on, require explicit type annotations.".to_string(),
3806 },
3807 ],
3808 Some("Top-level instruction.".to_string()),
3809 );
3810
3811 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
3812 model: CLAUDE_OPUS_4_8,
3813 request,
3814 prompt_caching: false,
3815 automatic_caching: false,
3816 automatic_caching_ttl: None,
3817 static_prefix_cache_ttl: None,
3818 })
3819 .unwrap();
3820
3821 let value = serde_json::to_value(request).unwrap();
3822 assert_eq!(value["system"][0]["text"], "Top-level instruction.");
3823 assert_eq!(value["system"][1]["text"], "Global history instruction.");
3824
3825 let messages = value["messages"].as_array().unwrap();
3826 assert_eq!(messages.len(), 2);
3827 assert_eq!(messages[0]["role"], "user");
3828 assert_eq!(messages[1]["role"], "system");
3829 assert_eq!(
3830 messages[1]["content"][0]["text"],
3831 "From now on, require explicit type annotations."
3832 );
3833 }
3834
3835 #[test]
3836 fn opus_4_8_preserves_mid_conversation_system_message_before_assistant_turn() {
3837 let request = completion_request_with_history(
3838 vec![
3839 message::Message::user("Review this code."),
3840 message::Message::System {
3841 content: "From now on, require explicit type annotations.".to_string(),
3842 },
3843 message::Message::assistant("I will enforce explicit type annotations."),
3844 ],
3845 None,
3846 );
3847
3848 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
3849 model: CLAUDE_OPUS_4_8,
3850 request,
3851 prompt_caching: false,
3852 automatic_caching: false,
3853 automatic_caching_ttl: None,
3854 static_prefix_cache_ttl: None,
3855 })
3856 .unwrap();
3857
3858 let value = serde_json::to_value(request).unwrap();
3859 let messages = value["messages"].as_array().unwrap();
3860 assert_eq!(messages.len(), 3);
3861 assert_eq!(messages[0]["role"], "user");
3862 assert_eq!(messages[1]["role"], "system");
3863 assert_eq!(messages[2]["role"], "assistant");
3864 assert!(value.get("system").is_none());
3865 }
3866
3867 #[test]
3868 fn opus_4_8_hoists_leading_system_message_when_documents_are_present() {
3869 let mut request = completion_request_with_history(
3870 vec![
3871 message::Message::System {
3872 content: "Global history instruction.".to_string(),
3873 },
3874 message::Message::assistant("Acknowledged."),
3875 message::Message::System {
3876 content: "Mid-conversation instruction.".to_string(),
3877 },
3878 message::Message::user("Answer from the document."),
3879 ],
3880 None,
3881 );
3882 request.documents = vec![completion::Document {
3883 id: "doc".to_string(),
3884 text: "Document context.".to_string(),
3885 additional_props: Default::default(),
3886 }];
3887
3888 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
3889 model: CLAUDE_OPUS_4_8,
3890 request,
3891 prompt_caching: false,
3892 automatic_caching: false,
3893 automatic_caching_ttl: None,
3894 static_prefix_cache_ttl: None,
3895 })
3896 .unwrap();
3897
3898 let value = serde_json::to_value(request).unwrap();
3899 assert_eq!(value["system"][0]["text"], "Global history instruction.");
3900 assert_eq!(value["system"][1]["text"], "Mid-conversation instruction.");
3901
3902 let messages = value["messages"].as_array().unwrap();
3903 assert_eq!(messages.len(), 3);
3904 assert_eq!(messages[0]["role"], "user");
3905 assert_eq!(messages[1]["role"], "assistant");
3906 assert_eq!(messages[2]["role"], "user");
3907 assert!(
3908 messages[0].to_string().contains("<file id: doc>"),
3909 "document message should follow top-level system: {messages:?}"
3910 );
3911 assert_eq!(
3912 messages
3913 .iter()
3914 .filter(|message| message.to_string().contains("<file id: doc>"))
3915 .count(),
3916 1,
3917 "document message should appear exactly once: {messages:?}"
3918 );
3919 assert!(
3920 messages
3921 .iter()
3922 .all(|message| message["role"].as_str() != Some("system"))
3923 );
3924 }
3925
3926 #[test]
3927 fn opus_4_8_preserves_system_message_after_assistant_server_tool_result() {
3928 let request = completion_request_with_history(
3929 vec![
3930 message::Message::Assistant {
3931 id: None,
3932 content: vec![
3933 message::AssistantContent::Text(message::Text {
3934 text: String::new(),
3935 additional_params: crate::message::AdditionalParams::try_from_value(
3936 json!({
3937 ANTHROPIC_RAW_CONTENT_KEY: {
3938 "type": "server_tool_use",
3939 "id": "srvtoolu_01",
3940 "name": "web_search",
3941 "input": {
3942 "query": "clear daytime sky color"
3943 }
3944 }
3945 }),
3946 )
3947 .expect("object params"),
3948 }),
3949 message::AssistantContent::Text(message::Text {
3950 text: String::new(),
3951 additional_params: crate::message::AdditionalParams::try_from_value(
3952 json!({
3953 ANTHROPIC_RAW_CONTENT_KEY: {
3954 "type": "web_search_tool_result",
3955 "tool_use_id": "srvtoolu_01",
3956 "content": {
3957 "type": "web_search_tool_result_error",
3958 "error_code": "unavailable"
3959 }
3960 }
3961 }),
3962 )
3963 .expect("object params"),
3964 }),
3965 ],
3966 },
3967 message::Message::System {
3968 content: "For the rest of this conversation, answer in Spanish.".to_string(),
3969 },
3970 message::Message::assistant("Entendido."),
3971 ],
3972 None,
3973 );
3974
3975 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
3976 model: CLAUDE_OPUS_4_8,
3977 request,
3978 prompt_caching: false,
3979 automatic_caching: false,
3980 automatic_caching_ttl: None,
3981 static_prefix_cache_ttl: None,
3982 })
3983 .unwrap();
3984
3985 let value = serde_json::to_value(request).unwrap();
3986 assert!(value.get("system").is_none());
3987
3988 let messages = value["messages"].as_array().unwrap();
3989 assert_eq!(messages.len(), 3);
3990 assert_eq!(messages[0]["role"], "assistant");
3991 assert_eq!(messages[0]["content"][0]["type"], "server_tool_use");
3992 assert_eq!(messages[0]["content"][1]["type"], "web_search_tool_result");
3993 assert_eq!(messages[1]["role"], "system");
3994 assert_eq!(
3995 messages[1]["content"][0]["text"],
3996 "For the rest of this conversation, answer in Spanish."
3997 );
3998 assert_eq!(messages[2]["role"], "assistant");
3999 }
4000
4001 #[test]
4002 fn foreign_annotated_empty_text_produces_no_anthropic_block() {
4003 let foreign_annotated_empty = message::AssistantContent::Text(message::Text {
4009 text: String::new(),
4010 additional_params: message::AdditionalParams::try_from_value(json!({
4011 "openai_responses": {"annotations": [{"type": "url_citation"}]}
4012 }))
4013 .expect("object params"),
4014 });
4015 assert_eq!(
4016 anthropic_content_from_assistant_content(foreign_annotated_empty.clone())
4017 .expect("conversion succeeds"),
4018 Vec::new(),
4019 "a foreign-annotated empty block must produce no Anthropic content"
4020 );
4021
4022 let message = message::Message::Assistant {
4023 id: None,
4024 content: vec![
4025 foreign_annotated_empty,
4026 message::AssistantContent::text("real answer"),
4027 ],
4028 };
4029 let converted = Message::try_from(message).expect("message converts");
4030 assert_eq!(converted.content.len(), 1, "only the real block survives");
4031 assert!(matches!(
4032 converted.content.first(),
4033 Some(Content::Text { text, .. }) if text == "real answer"
4034 ));
4035 }
4036
4037 #[test]
4038 fn opus_4_8_preserves_system_message_after_assistant_server_tool_use() {
4039 let request = completion_request_with_history(
4040 vec![
4041 message::Message::Assistant {
4042 id: None,
4043 content: vec![message::AssistantContent::Text(message::Text {
4044 text: String::new(),
4045 additional_params: crate::message::AdditionalParams::try_from_value(
4046 json!({
4047 ANTHROPIC_RAW_CONTENT_KEY: {
4048 "type": "server_tool_use",
4049 "id": "srvtoolu_01",
4050 "name": "web_search",
4051 "input": {
4052 "query": "clear daytime sky color"
4053 }
4054 }
4055 }),
4056 )
4057 .expect("object params"),
4058 })],
4059 },
4060 message::Message::System {
4061 content: "For the rest of this conversation, answer in Spanish.".to_string(),
4062 },
4063 message::Message::assistant("Entendido."),
4064 ],
4065 None,
4066 );
4067
4068 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4069 model: CLAUDE_OPUS_4_8,
4070 request,
4071 prompt_caching: false,
4072 automatic_caching: false,
4073 automatic_caching_ttl: None,
4074 static_prefix_cache_ttl: None,
4075 })
4076 .unwrap();
4077
4078 let value = serde_json::to_value(request).unwrap();
4079 assert!(value.get("system").is_none());
4080
4081 let messages = value["messages"].as_array().unwrap();
4082 assert_eq!(messages.len(), 3);
4083 assert_eq!(messages[0]["role"], "assistant");
4084 assert_eq!(messages[0]["content"][0]["type"], "server_tool_use");
4085 assert_eq!(messages[1]["role"], "system");
4086 assert_eq!(
4087 messages[1]["content"][0]["text"],
4088 "For the rest of this conversation, answer in Spanish."
4089 );
4090 assert_eq!(messages[2]["role"], "assistant");
4091 }
4092
4093 #[test]
4094 fn opus_4_8_hoists_system_message_in_invalid_mid_conversation_position() {
4095 let request = completion_request_with_history(
4096 vec![
4097 message::Message::user("Review this code."),
4098 message::Message::System {
4099 content: "From now on, require explicit type annotations.".to_string(),
4100 },
4101 message::Message::user("Now review this other file."),
4102 ],
4103 None,
4104 );
4105
4106 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4107 model: CLAUDE_OPUS_4_8,
4108 request,
4109 prompt_caching: false,
4110 automatic_caching: false,
4111 automatic_caching_ttl: None,
4112 static_prefix_cache_ttl: None,
4113 })
4114 .unwrap();
4115
4116 let value = serde_json::to_value(request).unwrap();
4117 assert_eq!(
4118 value["system"][0]["text"],
4119 "From now on, require explicit type annotations."
4120 );
4121
4122 let messages = value["messages"].as_array().unwrap();
4123 assert_eq!(messages.len(), 2);
4124 assert_eq!(messages[0]["role"], "user");
4125 assert_eq!(messages[1]["role"], "user");
4126 }
4127
4128 #[test]
4129 fn older_anthropic_models_hoist_mid_conversation_system_message() {
4130 let request = completion_request_with_history(
4131 vec![
4132 message::Message::from("Review this code."),
4133 message::Message::System {
4134 content: "From now on, require explicit type annotations.".to_string(),
4135 },
4136 ],
4137 None,
4138 );
4139
4140 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4141 model: CLAUDE_OPUS_4_7,
4142 request,
4143 prompt_caching: false,
4144 automatic_caching: false,
4145 automatic_caching_ttl: None,
4146 static_prefix_cache_ttl: None,
4147 })
4148 .unwrap();
4149
4150 let value = serde_json::to_value(request).unwrap();
4151 assert_eq!(
4152 value["system"][0]["text"],
4153 "From now on, require explicit type annotations."
4154 );
4155
4156 let messages = value["messages"].as_array().unwrap();
4157 assert_eq!(messages.len(), 1);
4158 assert_eq!(messages[0]["role"], "user");
4159 }
4160
4161 #[test]
4162 fn test_tool_definition_cache_control_serialization() {
4163 let tool = ToolDefinition {
4164 name: "cached_tool".to_string(),
4165 description: Some("Cached tool".to_string()),
4166 input_schema: json!({"type": "object"}),
4167 strict: false,
4168 cache_control: Some(CacheControl::ephemeral()),
4169 };
4170
4171 let value = serde_json::to_value(tool).unwrap();
4172 assert_eq!(value["cache_control"]["type"], "ephemeral");
4173
4174 let tool_without_cache = ToolDefinition {
4175 name: "uncached_tool".to_string(),
4176 description: Some("Uncached tool".to_string()),
4177 input_schema: json!({"type": "object"}),
4178 strict: false,
4179 cache_control: None,
4180 };
4181
4182 let value = serde_json::to_value(tool_without_cache).unwrap();
4183 assert!(value.get("cache_control").is_none());
4184 }
4185
4186 #[test]
4187 fn test_apply_tool_cache_control_marks_only_final_tool() {
4188 let mut tools = vec![
4189 json!({
4190 "name": "first_tool",
4191 "description": "First tool",
4192 "input_schema": {"type": "object"}
4193 }),
4194 json!({
4195 "name": "second_tool",
4196 "description": "Second tool",
4197 "input_schema": {"type": "object"}
4198 }),
4199 ];
4200
4201 let mut remaining_cache_markers = 4;
4202 apply_tool_cache_control(
4203 &mut tools,
4204 &mut remaining_cache_markers,
4205 &CacheControl::ephemeral(),
4206 )
4207 .unwrap();
4208
4209 assert!(tools[0].get("cache_control").is_none());
4210 assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
4211 assert_eq!(remaining_cache_markers, 3);
4212 }
4213
4214 #[test]
4215 fn test_prompt_caching_skips_final_deferred_tool_in_request() {
4216 let request = completion_request_with_tools(
4217 Vec::new(),
4218 Some(json!({
4219 "tools": [
4220 {
4221 "name": "regular_tool",
4222 "description": "Regular tool",
4223 "input_schema": {"type": "object"}
4224 },
4225 {
4226 "name": "deferred_tool",
4227 "description": "Deferred tool",
4228 "input_schema": {"type": "object"},
4229 "defer_loading": true
4230 }
4231 ]
4232 })),
4233 );
4234
4235 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4236 model: "claude-sonnet-4-6",
4237 request,
4238 prompt_caching: true,
4239 automatic_caching: false,
4240 automatic_caching_ttl: None,
4241 static_prefix_cache_ttl: None,
4242 })
4243 .unwrap();
4244
4245 let value = serde_json::to_value(request).unwrap();
4246 let tools = value["tools"].as_array().unwrap();
4247 assert_eq!(tools[0]["name"], "regular_tool");
4248 assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4249 assert_eq!(tools[1]["name"], "deferred_tool");
4250 assert!(tools[1].get("cache_control").is_none());
4251 }
4252
4253 #[test]
4254 fn test_prompt_caching_preserves_existing_final_tool_cache_control() {
4255 let request = completion_request_with_tools(
4256 Vec::new(),
4257 Some(json!({
4258 "tools": [{
4259 "name": "cached_tool",
4260 "description": "Cached tool",
4261 "input_schema": {"type": "object"},
4262 "cache_control": {"type": "ephemeral", "ttl": "1h"}
4263 }]
4264 })),
4265 );
4266
4267 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4268 model: "claude-sonnet-4-6",
4269 request,
4270 prompt_caching: true,
4271 automatic_caching: false,
4272 automatic_caching_ttl: None,
4273 static_prefix_cache_ttl: None,
4274 })
4275 .unwrap();
4276
4277 let value = serde_json::to_value(request).unwrap();
4278 let tools = value["tools"].as_array().unwrap();
4279 assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4280 assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
4281 }
4282
4283 #[test]
4284 fn test_prompt_caching_all_deferred_tools_do_not_receive_cache_control() {
4285 let request = completion_request_with_tools(
4286 Vec::new(),
4287 Some(json!({
4288 "tools": [
4289 {
4290 "name": "first_deferred_tool",
4291 "description": "First deferred tool",
4292 "input_schema": {"type": "object"},
4293 "defer_loading": true
4294 },
4295 {
4296 "name": "second_deferred_tool",
4297 "description": "Second deferred tool",
4298 "input_schema": {"type": "object"},
4299 "defer_loading": true
4300 }
4301 ]
4302 })),
4303 );
4304
4305 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4306 model: "claude-sonnet-4-6",
4307 request,
4308 prompt_caching: true,
4309 automatic_caching: false,
4310 automatic_caching_ttl: None,
4311 static_prefix_cache_ttl: None,
4312 })
4313 .unwrap();
4314
4315 let value = serde_json::to_value(request).unwrap();
4316 let tools = value["tools"].as_array().unwrap();
4317 assert!(tools[0].get("cache_control").is_none());
4318 assert!(tools[1].get("cache_control").is_none());
4319 }
4320
4321 #[test]
4322 fn test_prompt_caching_preserves_earlier_tool_cache_control() {
4323 let request = completion_request_with_tools(
4324 Vec::new(),
4325 Some(json!({
4326 "tools": [
4327 {
4328 "name": "earlier_tool",
4329 "description": "Earlier tool",
4330 "input_schema": {"type": "object"},
4331 "cache_control": {"type": "ephemeral", "ttl": "1h"}
4332 },
4333 {
4334 "name": "later_tool",
4335 "description": "Later tool",
4336 "input_schema": {"type": "object"}
4337 }
4338 ]
4339 })),
4340 );
4341
4342 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4343 model: "claude-sonnet-4-6",
4344 request,
4345 prompt_caching: true,
4346 automatic_caching: false,
4347 automatic_caching_ttl: None,
4348 static_prefix_cache_ttl: None,
4349 })
4350 .unwrap();
4351
4352 let value = serde_json::to_value(request).unwrap();
4353 let tools = value["tools"].as_array().unwrap();
4354 assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4355 assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
4356 assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
4357 }
4358
4359 #[test]
4360 fn test_prompt_caching_deferred_marker_does_not_suppress_loaded_tool_marker() {
4361 let request = completion_request_with_tools(
4362 Vec::new(),
4363 Some(json!({
4364 "tools": [
4365 {
4366 "name": "regular_tool",
4367 "description": "Regular tool",
4368 "input_schema": {"type": "object"}
4369 },
4370 {
4371 "name": "deferred_cached_tool",
4372 "description": "Deferred cached tool",
4373 "input_schema": {"type": "object"},
4374 "defer_loading": true,
4375 "cache_control": {"type": "ephemeral"}
4376 }
4377 ]
4378 })),
4379 );
4380
4381 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4382 model: "claude-sonnet-4-6",
4383 request,
4384 prompt_caching: true,
4385 automatic_caching: false,
4386 automatic_caching_ttl: None,
4387 static_prefix_cache_ttl: None,
4388 })
4389 .unwrap();
4390
4391 let value = serde_json::to_value(request).unwrap();
4392 let tools = value["tools"].as_array().unwrap();
4393 assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4394 assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
4395 }
4396
4397 #[test]
4398 fn test_prompt_caching_errors_when_tool_cache_control_ttl_order_is_invalid() {
4399 let request = completion_request_with_tools(
4400 Vec::new(),
4401 Some(json!({
4402 "tools": [
4403 {
4404 "name": "first_cached_tool",
4405 "description": "First cached tool",
4406 "input_schema": {"type": "object"},
4407 "cache_control": {"type": "ephemeral"}
4408 },
4409 {
4410 "name": "second_cached_tool",
4411 "description": "Second cached tool",
4412 "input_schema": {"type": "object"},
4413 "cache_control": {"type": "ephemeral", "ttl": "1h"}
4414 }
4415 ]
4416 })),
4417 );
4418
4419 let err = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4420 model: "claude-sonnet-4-6",
4421 request,
4422 prompt_caching: true,
4423 automatic_caching: false,
4424 automatic_caching_ttl: None,
4425 static_prefix_cache_ttl: None,
4426 })
4427 .unwrap_err();
4428
4429 assert!(err.to_string().contains("ttl `1h`"));
4430 }
4431
4432 #[test]
4433 fn test_prompt_caching_preserves_valid_mixed_ttl_tool_cache_controls() {
4434 let request = completion_request_with_tools(
4435 Vec::new(),
4436 Some(json!({
4437 "tools": [
4438 {
4439 "name": "first_cached_tool",
4440 "description": "First cached tool",
4441 "input_schema": {"type": "object"},
4442 "cache_control": {"type": "ephemeral", "ttl": "1h"}
4443 },
4444 {
4445 "name": "second_cached_tool",
4446 "description": "Second cached tool",
4447 "input_schema": {"type": "object"},
4448 "cache_control": {"type": "ephemeral"}
4449 }
4450 ]
4451 })),
4452 );
4453
4454 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4455 model: "claude-sonnet-4-6",
4456 request,
4457 prompt_caching: true,
4458 automatic_caching: false,
4459 automatic_caching_ttl: None,
4460 static_prefix_cache_ttl: None,
4461 })
4462 .unwrap();
4463
4464 let value = serde_json::to_value(request).unwrap();
4465 let tools = value["tools"].as_array().unwrap();
4466 assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4467 assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
4468 assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
4469 assert!(tools[1]["cache_control"].get("ttl").is_none());
4470 }
4471
4472 #[test]
4473 fn test_prompt_caching_preserves_deferred_tool_cache_control() {
4474 let request = completion_request_with_tools(
4475 Vec::new(),
4476 Some(json!({
4477 "tools": [{
4478 "name": "deferred_cached_tool",
4479 "description": "Deferred cached tool",
4480 "input_schema": {"type": "object"},
4481 "defer_loading": true,
4482 "cache_control": {"type": "ephemeral"}
4483 }]
4484 })),
4485 );
4486
4487 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4488 model: "claude-sonnet-4-6",
4489 request,
4490 prompt_caching: true,
4491 automatic_caching: false,
4492 automatic_caching_ttl: None,
4493 static_prefix_cache_ttl: None,
4494 })
4495 .unwrap();
4496
4497 let value = serde_json::to_value(request).unwrap();
4498 let tools = value["tools"].as_array().unwrap();
4499 assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4500 }
4501
4502 #[test]
4503 fn test_prompt_caching_budget_preserves_three_tool_markers_and_skips_message() {
4504 let request = completion_request_with_tools(
4505 Vec::new(),
4506 Some(json!({
4507 "tools": [
4508 {
4509 "name": "first_cached_tool",
4510 "description": "First cached tool",
4511 "input_schema": {"type": "object"},
4512 "cache_control": {"type": "ephemeral"}
4513 },
4514 {
4515 "name": "second_cached_tool",
4516 "description": "Second cached tool",
4517 "input_schema": {"type": "object"},
4518 "cache_control": {"type": "ephemeral"}
4519 },
4520 {
4521 "name": "third_cached_tool",
4522 "description": "Third cached tool",
4523 "input_schema": {"type": "object"},
4524 "cache_control": {"type": "ephemeral"}
4525 }
4526 ]
4527 })),
4528 );
4529
4530 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4531 model: "claude-sonnet-4-6",
4532 request,
4533 prompt_caching: true,
4534 automatic_caching: false,
4535 automatic_caching_ttl: None,
4536 static_prefix_cache_ttl: None,
4537 })
4538 .unwrap();
4539
4540 let value = serde_json::to_value(request).unwrap();
4541 let tools = value["tools"].as_array().unwrap();
4542 assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4543 assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
4544 assert_eq!(tools[2]["cache_control"]["type"], "ephemeral");
4545 assert!(system_has_cache_control(&value));
4546 assert!(!last_message_has_cache_control(&value));
4547 }
4548
4549 #[test]
4550 fn test_prompt_caching_errors_when_explicit_tool_markers_exceed_budget() {
4551 let request = completion_request_with_tools(
4552 Vec::new(),
4553 Some(json!({
4554 "tools": [
4555 {
4556 "name": "first_cached_tool",
4557 "description": "First cached tool",
4558 "input_schema": {"type": "object"},
4559 "cache_control": {"type": "ephemeral"}
4560 },
4561 {
4562 "name": "second_cached_tool",
4563 "description": "Second cached tool",
4564 "input_schema": {"type": "object"},
4565 "cache_control": {"type": "ephemeral"}
4566 },
4567 {
4568 "name": "third_cached_tool",
4569 "description": "Third cached tool",
4570 "input_schema": {"type": "object"},
4571 "cache_control": {"type": "ephemeral"}
4572 },
4573 {
4574 "name": "fourth_cached_tool",
4575 "description": "Fourth cached tool",
4576 "input_schema": {"type": "object"},
4577 "cache_control": {"type": "ephemeral"}
4578 },
4579 {
4580 "name": "fifth_cached_tool",
4581 "description": "Fifth cached tool",
4582 "input_schema": {"type": "object"},
4583 "cache_control": {"type": "ephemeral"}
4584 }
4585 ]
4586 })),
4587 );
4588
4589 let err = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4590 model: "claude-sonnet-4-6",
4591 request,
4592 prompt_caching: true,
4593 automatic_caching: false,
4594 automatic_caching_ttl: None,
4595 static_prefix_cache_ttl: None,
4596 })
4597 .unwrap_err();
4598
4599 assert!(err.to_string().contains("Too many Anthropic tool"));
4600 }
4601
4602 #[test]
4603 fn test_prompt_caching_errors_when_final_tool_marker_has_no_budget() {
4604 let request = completion_request_with_tools(
4605 Vec::new(),
4606 Some(json!({
4607 "tools": [
4608 {
4609 "name": "first_cached_tool",
4610 "description": "First cached tool",
4611 "input_schema": {"type": "object"},
4612 "cache_control": {"type": "ephemeral"}
4613 },
4614 {
4615 "name": "second_cached_tool",
4616 "description": "Second cached tool",
4617 "input_schema": {"type": "object"},
4618 "cache_control": {"type": "ephemeral"}
4619 },
4620 {
4621 "name": "third_cached_tool",
4622 "description": "Third cached tool",
4623 "input_schema": {"type": "object"},
4624 "cache_control": {"type": "ephemeral"}
4625 },
4626 {
4627 "name": "fourth_cached_tool",
4628 "description": "Fourth cached tool",
4629 "input_schema": {"type": "object"},
4630 "cache_control": {"type": "ephemeral"}
4631 },
4632 {
4633 "name": "final_uncached_tool",
4634 "description": "Final uncached tool",
4635 "input_schema": {"type": "object"}
4636 }
4637 ]
4638 })),
4639 );
4640
4641 let err = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4642 model: "claude-sonnet-4-6",
4643 request,
4644 prompt_caching: true,
4645 automatic_caching: false,
4646 automatic_caching_ttl: None,
4647 static_prefix_cache_ttl: None,
4648 })
4649 .unwrap_err();
4650
4651 assert!(err.to_string().contains("final non-deferred tool"));
4652 }
4653
4654 #[test]
4655 fn test_prompt_caching_replaces_null_final_tool_cache_control() {
4656 let request = completion_request_with_tools(
4657 Vec::new(),
4658 Some(json!({
4659 "tools": [{
4660 "name": "final_tool",
4661 "description": "Final tool",
4662 "input_schema": {"type": "object"},
4663 "cache_control": null
4664 }]
4665 })),
4666 );
4667
4668 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4669 model: "claude-sonnet-4-6",
4670 request,
4671 prompt_caching: true,
4672 automatic_caching: false,
4673 automatic_caching_ttl: None,
4674 static_prefix_cache_ttl: None,
4675 })
4676 .unwrap();
4677
4678 let value = serde_json::to_value(request).unwrap();
4679 let tools = value["tools"].as_array().unwrap();
4680 assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4681 }
4682
4683 #[test]
4684 fn test_prompt_caching_ignores_null_tool_cache_control_when_budgeting() {
4685 let request = completion_request_with_tools(
4686 Vec::new(),
4687 Some(json!({
4688 "tools": [
4689 {
4690 "name": "first_null_tool",
4691 "description": "First null tool",
4692 "input_schema": {"type": "object"},
4693 "cache_control": null
4694 },
4695 {
4696 "name": "second_null_tool",
4697 "description": "Second null tool",
4698 "input_schema": {"type": "object"},
4699 "cache_control": null
4700 },
4701 {
4702 "name": "third_null_tool",
4703 "description": "Third null tool",
4704 "input_schema": {"type": "object"},
4705 "cache_control": null
4706 },
4707 {
4708 "name": "fourth_null_tool",
4709 "description": "Fourth null tool",
4710 "input_schema": {"type": "object"},
4711 "cache_control": null
4712 },
4713 {
4714 "name": "final_uncached_tool",
4715 "description": "Final uncached tool",
4716 "input_schema": {"type": "object"}
4717 }
4718 ]
4719 })),
4720 );
4721
4722 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4723 model: "claude-sonnet-4-6",
4724 request,
4725 prompt_caching: true,
4726 automatic_caching: false,
4727 automatic_caching_ttl: None,
4728 static_prefix_cache_ttl: None,
4729 })
4730 .unwrap();
4731
4732 let value = serde_json::to_value(request).unwrap();
4733 let tools = value["tools"].as_array().unwrap();
4734 assert!(tools[0].get("cache_control").is_none());
4735 assert!(tools[1].get("cache_control").is_none());
4736 assert!(tools[2].get("cache_control").is_none());
4737 assert!(tools[3].get("cache_control").is_none());
4738 assert_eq!(tools[4]["cache_control"]["type"], "ephemeral");
4739 }
4740
4741 #[test]
4742 fn test_prompt_caching_preserves_non_null_provider_tool_cache_control_escape_hatch() {
4743 let request = completion_request_with_tools(
4744 Vec::new(),
4745 Some(json!({
4746 "tools": [{
4747 "name": "provider_tool",
4748 "description": "Provider tool",
4749 "input_schema": {"type": "object"},
4750 "cache_control": {"type": "provider_specific"}
4751 }]
4752 })),
4753 );
4754
4755 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4756 model: "claude-sonnet-4-6",
4757 request,
4758 prompt_caching: true,
4759 automatic_caching: false,
4760 automatic_caching_ttl: None,
4761 static_prefix_cache_ttl: None,
4762 })
4763 .unwrap();
4764
4765 let value = serde_json::to_value(request).unwrap();
4766 let tools = value["tools"].as_array().unwrap();
4767 assert_eq!(tools[0]["cache_control"]["type"], "provider_specific");
4768 }
4769
4770 #[test]
4771 fn test_prompt_caching_automatic_mode_uses_reduced_marker_budget() {
4772 let request = completion_request_with_tools(
4773 Vec::new(),
4774 Some(json!({
4775 "tools": [
4776 {
4777 "name": "first_cached_tool",
4778 "description": "First cached tool",
4779 "input_schema": {"type": "object"},
4780 "cache_control": {"type": "ephemeral"}
4781 },
4782 {
4783 "name": "second_cached_tool",
4784 "description": "Second cached tool",
4785 "input_schema": {"type": "object"},
4786 "cache_control": {"type": "ephemeral"}
4787 },
4788 {
4789 "name": "third_cached_tool",
4790 "description": "Third cached tool",
4791 "input_schema": {"type": "object"},
4792 "cache_control": {"type": "ephemeral"}
4793 }
4794 ]
4795 })),
4796 );
4797
4798 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4799 model: "claude-sonnet-4-6",
4800 request,
4801 prompt_caching: true,
4802 automatic_caching: true,
4803 automatic_caching_ttl: None,
4804 static_prefix_cache_ttl: None,
4805 })
4806 .unwrap();
4807
4808 let value = serde_json::to_value(request).unwrap();
4809 let tools = value["tools"].as_array().unwrap();
4810 assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4811 assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
4812 assert_eq!(tools[2]["cache_control"]["type"], "ephemeral");
4813 assert_eq!(value["cache_control"]["type"], "ephemeral");
4814 assert!(!system_has_cache_control(&value));
4815 assert!(!last_message_has_cache_control(&value));
4816 }
4817
4818 #[test]
4819 fn test_prompt_caching_automatic_mode_errors_when_final_tool_marker_has_no_budget() {
4820 let request = completion_request_with_tools(
4821 Vec::new(),
4822 Some(json!({
4823 "tools": [
4824 {
4825 "name": "first_cached_tool",
4826 "description": "First cached tool",
4827 "input_schema": {"type": "object"},
4828 "cache_control": {"type": "ephemeral"}
4829 },
4830 {
4831 "name": "second_cached_tool",
4832 "description": "Second cached tool",
4833 "input_schema": {"type": "object"},
4834 "cache_control": {"type": "ephemeral"}
4835 },
4836 {
4837 "name": "third_cached_tool",
4838 "description": "Third cached tool",
4839 "input_schema": {"type": "object"},
4840 "cache_control": {"type": "ephemeral"}
4841 },
4842 {
4843 "name": "final_uncached_tool",
4844 "description": "Final uncached tool",
4845 "input_schema": {"type": "object"}
4846 }
4847 ]
4848 })),
4849 );
4850
4851 let err = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4852 model: "claude-sonnet-4-6",
4853 request,
4854 prompt_caching: true,
4855 automatic_caching: true,
4856 automatic_caching_ttl: None,
4857 static_prefix_cache_ttl: None,
4858 })
4859 .unwrap_err();
4860
4861 assert!(err.to_string().contains("final non-deferred tool"));
4862 }
4863
4864 #[test]
4865 fn test_automatic_caching_errors_when_explicit_tool_markers_exhaust_budget() {
4866 let request = completion_request_with_tools(
4867 Vec::new(),
4868 Some(json!({
4869 "tools": [
4870 {
4871 "name": "first_cached_tool",
4872 "description": "First cached tool",
4873 "input_schema": {"type": "object"},
4874 "cache_control": {"type": "ephemeral"}
4875 },
4876 {
4877 "name": "second_cached_tool",
4878 "description": "Second cached tool",
4879 "input_schema": {"type": "object"},
4880 "cache_control": {"type": "ephemeral"}
4881 },
4882 {
4883 "name": "third_cached_tool",
4884 "description": "Third cached tool",
4885 "input_schema": {"type": "object"},
4886 "cache_control": {"type": "ephemeral"}
4887 },
4888 {
4889 "name": "fourth_cached_tool",
4890 "description": "Fourth cached tool",
4891 "input_schema": {"type": "object"},
4892 "cache_control": {"type": "ephemeral"}
4893 }
4894 ]
4895 })),
4896 );
4897
4898 let err = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4899 model: "claude-sonnet-4-6",
4900 request,
4901 prompt_caching: false,
4902 automatic_caching: true,
4903 automatic_caching_ttl: None,
4904 static_prefix_cache_ttl: None,
4905 })
4906 .unwrap_err();
4907
4908 assert!(err.to_string().contains("Too many Anthropic tool"));
4909 }
4910
4911 #[test]
4912 fn test_automatic_caching_1h_errors_with_explicit_five_minute_tool_marker() {
4913 let request = completion_request_with_tools(
4914 Vec::new(),
4915 Some(json!({
4916 "tools": [{
4917 "name": "cached_tool",
4918 "description": "Cached tool",
4919 "input_schema": {"type": "object"},
4920 "cache_control": {"type": "ephemeral"}
4921 }]
4922 })),
4923 );
4924
4925 let err = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4926 model: "claude-sonnet-4-6",
4927 request,
4928 prompt_caching: false,
4929 automatic_caching: true,
4930 automatic_caching_ttl: Some(CacheTtl::OneHour),
4931 static_prefix_cache_ttl: None,
4932 })
4933 .unwrap_err();
4934
4935 assert!(err.to_string().contains("ttl `1h`"));
4936 }
4937
4938 #[test]
4939 fn test_prompt_and_automatic_caching_1h_uses_1h_generated_markers() {
4940 let request = completion_request_with_tools(vec![generic_tool("cached_tool")], None);
4941
4942 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4943 model: "claude-sonnet-4-6",
4944 request,
4945 prompt_caching: true,
4946 automatic_caching: true,
4947 automatic_caching_ttl: Some(CacheTtl::OneHour),
4948 static_prefix_cache_ttl: None,
4949 })
4950 .unwrap();
4951
4952 let value = serde_json::to_value(request).unwrap();
4953 let tools = value["tools"].as_array().unwrap();
4954 assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4955 assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
4956 assert_eq!(
4957 value["system"]
4958 .as_array()
4959 .and_then(|blocks| blocks.last())
4960 .and_then(|block| block["cache_control"].get("ttl")),
4961 Some(&json!("1h"))
4962 );
4963 assert_eq!(value["cache_control"]["ttl"], "1h");
4964 assert!(!last_message_has_cache_control(&value));
4965 }
4966
4967 #[test]
4968 fn test_prompt_and_raw_top_level_automatic_caching_1h_uses_1h_generated_markers() {
4969 let request = completion_request_with_tools(
4970 vec![generic_tool("cached_tool")],
4971 Some(json!({
4972 "cache_control": {"type": "ephemeral", "ttl": "1h"},
4973 "metadata": {"source": "test"}
4974 })),
4975 );
4976
4977 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
4978 model: "claude-sonnet-4-6",
4979 request,
4980 prompt_caching: true,
4981 automatic_caching: true,
4982 automatic_caching_ttl: None,
4983 static_prefix_cache_ttl: None,
4984 })
4985 .unwrap();
4986
4987 let value = serde_json::to_value(request).unwrap();
4988 let tools = value["tools"].as_array().unwrap();
4989 assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
4990 assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
4991 assert_eq!(
4992 value["system"]
4993 .as_array()
4994 .and_then(|blocks| blocks.last())
4995 .and_then(|block| block["cache_control"].get("ttl")),
4996 Some(&json!("1h"))
4997 );
4998 assert_eq!(value["cache_control"]["ttl"], "1h");
4999 assert_eq!(value["metadata"]["source"], "test");
5000 assert!(!last_message_has_cache_control(&value));
5001 }
5002
5003 #[test]
5004 fn test_prompt_caching_uses_raw_top_level_cache_control_ttl() {
5005 let request = completion_request_with_tools(
5006 vec![generic_tool("cached_tool")],
5007 Some(json!({
5008 "cache_control": {"type": "ephemeral", "ttl": "1h"},
5009 "metadata": {"source": "raw-cache-control"}
5010 })),
5011 );
5012
5013 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5014 model: "claude-sonnet-4-6",
5015 request,
5016 prompt_caching: true,
5017 automatic_caching: false,
5018 automatic_caching_ttl: None,
5019 static_prefix_cache_ttl: None,
5020 })
5021 .unwrap();
5022
5023 let value = serde_json::to_value(request).unwrap();
5024 let tools = value["tools"].as_array().unwrap();
5025 assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
5026 assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
5027 assert_eq!(
5028 value["system"]
5029 .as_array()
5030 .and_then(|blocks| blocks.last())
5031 .and_then(|block| block["cache_control"].get("ttl")),
5032 Some(&json!("1h"))
5033 );
5034 assert_eq!(value["cache_control"]["ttl"], "1h");
5035 assert_eq!(value["metadata"]["source"], "raw-cache-control");
5036 assert!(!last_message_has_cache_control(&value));
5037 }
5038
5039 #[test]
5040 fn test_static_prefix_ttl_with_manual_caching_splits_prefix_and_tail() {
5041 let request = completion_request_with_tools(vec![generic_tool("cached_tool")], None);
5042
5043 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5044 model: "claude-sonnet-4-6",
5045 request,
5046 prompt_caching: true,
5047 automatic_caching: false,
5048 automatic_caching_ttl: None,
5049 static_prefix_cache_ttl: Some(CacheTtl::OneHour),
5050 })
5051 .unwrap();
5052
5053 let value = serde_json::to_value(request).unwrap();
5054 let tools = value["tools"].as_array().unwrap();
5055 assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
5056 assert_eq!(
5057 value["system"]
5058 .as_array()
5059 .and_then(|blocks| blocks.last())
5060 .and_then(|block| block["cache_control"].get("ttl")),
5061 Some(&json!("1h"))
5062 );
5063 let tail_cache_control = value["messages"]
5065 .as_array()
5066 .and_then(|messages| messages.last())
5067 .and_then(|message| message["content"].as_array())
5068 .and_then(|content| content.last())
5069 .map(|block| &block["cache_control"])
5070 .unwrap();
5071 assert_eq!(tail_cache_control["type"], "ephemeral");
5072 assert!(tail_cache_control.get("ttl").is_none());
5073 assert!(value.get("cache_control").is_none());
5074 }
5075
5076 #[test]
5077 fn test_static_prefix_ttl_with_automatic_caching_marks_prefix_only() {
5078 let request = completion_request_with_tools(vec![generic_tool("cached_tool")], None);
5079
5080 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5081 model: "claude-sonnet-4-6",
5082 request,
5083 prompt_caching: false,
5084 automatic_caching: true,
5085 automatic_caching_ttl: None,
5086 static_prefix_cache_ttl: Some(CacheTtl::OneHour),
5087 })
5088 .unwrap();
5089
5090 let value = serde_json::to_value(request).unwrap();
5091 let tools = value["tools"].as_array().unwrap();
5092 assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
5093 assert_eq!(
5094 value["system"]
5095 .as_array()
5096 .and_then(|blocks| blocks.last())
5097 .and_then(|block| block["cache_control"].get("ttl")),
5098 Some(&json!("1h"))
5099 );
5100 assert_eq!(value["cache_control"]["type"], "ephemeral");
5103 assert!(value["cache_control"].get("ttl").is_none());
5104 assert!(!last_message_has_cache_control(&value));
5105 }
5106
5107 #[test]
5108 fn test_static_prefix_ttl_alone_marks_prefix_without_tail_or_top_level() {
5109 let request = completion_request_with_tools(vec![generic_tool("cached_tool")], None);
5110
5111 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5112 model: "claude-sonnet-4-6",
5113 request,
5114 prompt_caching: false,
5115 automatic_caching: false,
5116 automatic_caching_ttl: None,
5117 static_prefix_cache_ttl: Some(CacheTtl::OneHour),
5118 })
5119 .unwrap();
5120
5121 let value = serde_json::to_value(request).unwrap();
5122 let tools = value["tools"].as_array().unwrap();
5123 assert_eq!(tools[0]["cache_control"]["ttl"], "1h");
5124 assert_eq!(
5125 value["system"]
5126 .as_array()
5127 .and_then(|blocks| blocks.last())
5128 .and_then(|block| block["cache_control"].get("ttl")),
5129 Some(&json!("1h"))
5130 );
5131 assert!(value.get("cache_control").is_none());
5132 assert!(!last_message_has_cache_control(&value));
5133 }
5134
5135 #[test]
5136 fn test_static_prefix_ttl_five_minutes_with_automatic_1h_errors_client_side() {
5137 let request = completion_request_with_tools(vec![generic_tool("cached_tool")], None);
5138
5139 let error = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5140 model: "claude-sonnet-4-6",
5141 request,
5142 prompt_caching: false,
5143 automatic_caching: true,
5144 automatic_caching_ttl: Some(CacheTtl::OneHour),
5145 static_prefix_cache_ttl: Some(CacheTtl::FiveMinutes),
5146 })
5147 .unwrap_err();
5148
5149 let message = error.to_string();
5150 assert!(
5151 message.contains("with_static_prefix_cache_ttl"),
5152 "error should name the knob: {message}"
5153 );
5154 assert!(
5155 message.contains("with_automatic_caching_1h"),
5156 "error should name the conflicting knob: {message}"
5157 );
5158 }
5159
5160 #[test]
5161 fn test_static_prefix_ttl_five_minutes_matches_automatic_default_ttl() {
5162 let request = completion_request_with_tools(vec![generic_tool("cached_tool")], None);
5163
5164 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5166 model: "claude-sonnet-4-6",
5167 request,
5168 prompt_caching: false,
5169 automatic_caching: true,
5170 automatic_caching_ttl: None,
5171 static_prefix_cache_ttl: Some(CacheTtl::FiveMinutes),
5172 })
5173 .unwrap();
5174
5175 let value = serde_json::to_value(request).unwrap();
5176 let tools = value["tools"].as_array().unwrap();
5177 assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
5178 assert_eq!(tools[0]["cache_control"]["ttl"], "5m");
5181 }
5182
5183 #[test]
5184 fn test_static_prefix_ttl_preserves_marker_budget_arithmetic() {
5185 let request = completion_request_with_tools(vec![generic_tool("cached_tool")], None);
5189
5190 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5191 model: "claude-sonnet-4-6",
5192 request,
5193 prompt_caching: false,
5194 automatic_caching: true,
5195 automatic_caching_ttl: None,
5196 static_prefix_cache_ttl: Some(CacheTtl::OneHour),
5197 })
5198 .unwrap();
5199
5200 let value = serde_json::to_value(request).unwrap();
5201 let marker_count = value["tools"]
5202 .as_array()
5203 .into_iter()
5204 .flatten()
5205 .filter(|tool| !tool["cache_control"].is_null())
5206 .count()
5207 + value["system"]
5208 .as_array()
5209 .into_iter()
5210 .flatten()
5211 .filter(|block| !block["cache_control"].is_null())
5212 .count()
5213 + usize::from(!value["cache_control"].is_null());
5214 assert_eq!(marker_count, 3);
5215 assert!(marker_count <= MAX_CACHE_CONTROL_MARKERS);
5216 }
5217
5218 #[test]
5219 fn test_usage_parses_per_ttl_cache_creation_breakdown() {
5220 let usage: Usage = serde_json::from_str(
5221 r#"{
5222 "input_tokens": 3,
5223 "cache_read_input_tokens": 0,
5224 "cache_creation_input_tokens": 9677,
5225 "cache_creation": {
5226 "ephemeral_5m_input_tokens": 9677,
5227 "ephemeral_1h_input_tokens": 0,
5228 "ephemeral_24h_input_tokens": 0
5229 },
5230 "output_tokens": 7
5231 }"#,
5232 )
5233 .unwrap();
5234
5235 assert_eq!(usage.cache_creation_input_tokens, Some(9677));
5236 let cache_creation = usage.cache_creation.unwrap();
5237 assert_eq!(cache_creation.ephemeral_5m_input_tokens, 9677);
5238 assert_eq!(cache_creation.ephemeral_1h_input_tokens, 0);
5239 }
5240
5241 #[test]
5242 fn test_usage_without_cache_creation_breakdown_parses_as_none() {
5243 let usage: Usage =
5244 serde_json::from_str(r#"{"input_tokens": 3, "output_tokens": 7}"#).unwrap();
5245 assert!(usage.cache_creation.is_none());
5246 }
5247
5248 #[test]
5249 fn test_raw_top_level_automatic_caching_reduces_marker_budget() {
5250 let request = completion_request_with_tools(
5251 Vec::new(),
5252 Some(json!({
5253 "cache_control": {"type": "ephemeral"},
5254 "tools": [
5255 {
5256 "name": "first_cached_tool",
5257 "description": "First cached tool",
5258 "input_schema": {"type": "object"},
5259 "cache_control": {"type": "ephemeral"}
5260 },
5261 {
5262 "name": "second_cached_tool",
5263 "description": "Second cached tool",
5264 "input_schema": {"type": "object"},
5265 "cache_control": {"type": "ephemeral"}
5266 },
5267 {
5268 "name": "third_cached_tool",
5269 "description": "Third cached tool",
5270 "input_schema": {"type": "object"},
5271 "cache_control": {"type": "ephemeral"}
5272 },
5273 {
5274 "name": "fourth_cached_tool",
5275 "description": "Fourth cached tool",
5276 "input_schema": {"type": "object"},
5277 "cache_control": {"type": "ephemeral"}
5278 }
5279 ]
5280 })),
5281 );
5282
5283 let err = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5284 model: "claude-sonnet-4-6",
5285 request,
5286 prompt_caching: false,
5287 automatic_caching: false,
5288 automatic_caching_ttl: None,
5289 static_prefix_cache_ttl: None,
5290 })
5291 .unwrap_err();
5292
5293 assert!(err.to_string().contains("Too many Anthropic tool"));
5294 }
5295
5296 #[test]
5297 fn test_raw_top_level_automatic_caching_1h_errors_after_explicit_five_minute_tool_marker() {
5298 let request = completion_request_with_tools(
5299 Vec::new(),
5300 Some(json!({
5301 "cache_control": {"type": "ephemeral", "ttl": "1h"},
5302 "tools": [{
5303 "name": "cached_tool",
5304 "description": "Cached tool",
5305 "input_schema": {"type": "object"},
5306 "cache_control": {"type": "ephemeral"}
5307 }]
5308 })),
5309 );
5310
5311 let err = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5312 model: "claude-sonnet-4-6",
5313 request,
5314 prompt_caching: false,
5315 automatic_caching: false,
5316 automatic_caching_ttl: None,
5317 static_prefix_cache_ttl: None,
5318 })
5319 .unwrap_err();
5320
5321 assert!(err.to_string().contains("ttl `1h`"));
5322 }
5323
5324 #[test]
5325 fn test_typed_automatic_caching_ttl_errors_on_conflicting_raw_top_level_ttl() {
5326 let request = completion_request_with_tools(
5327 Vec::new(),
5328 Some(json!({
5329 "cache_control": {"type": "ephemeral"}
5330 })),
5331 );
5332
5333 let err = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5334 model: "claude-sonnet-4-6",
5335 request,
5336 prompt_caching: false,
5337 automatic_caching: true,
5338 automatic_caching_ttl: Some(CacheTtl::OneHour),
5339 static_prefix_cache_ttl: None,
5340 })
5341 .unwrap_err();
5342
5343 assert!(
5344 err.to_string()
5345 .contains("conflicts with the typed automatic caching TTL")
5346 );
5347 }
5348
5349 #[test]
5350 fn test_prompt_caching_marks_final_tool_in_request() {
5351 let request = completion_request_with_tools(
5352 vec![generic_tool("first_tool"), generic_tool("second_tool")],
5353 None,
5354 );
5355
5356 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5357 model: "claude-sonnet-4-6",
5358 request,
5359 prompt_caching: true,
5360 automatic_caching: false,
5361 automatic_caching_ttl: None,
5362 static_prefix_cache_ttl: None,
5363 })
5364 .unwrap();
5365
5366 let value = serde_json::to_value(request).unwrap();
5367 let tools = value["tools"].as_array().unwrap();
5368 assert_eq!(tools.len(), 2);
5369 assert!(tools[0].get("cache_control").is_none());
5370 assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
5371 }
5372
5373 #[test]
5374 fn test_prompt_caching_marks_final_additional_tool_in_request() {
5375 let request = completion_request_with_tools(
5376 vec![generic_tool("rig_tool")],
5377 Some(json!({
5378 "tools": [{
5379 "name": "provider_tool",
5380 "description": "Provider tool",
5381 "input_schema": {"type": "object"}
5382 }],
5383 "metadata": {"source": "test"}
5384 })),
5385 );
5386
5387 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5388 model: "claude-sonnet-4-6",
5389 request,
5390 prompt_caching: true,
5391 automatic_caching: false,
5392 automatic_caching_ttl: None,
5393 static_prefix_cache_ttl: None,
5394 })
5395 .unwrap();
5396
5397 let value = serde_json::to_value(request).unwrap();
5398 let tools = value["tools"].as_array().unwrap();
5399 assert_eq!(tools.len(), 2);
5400 assert!(tools[0].get("cache_control").is_none());
5401 assert_eq!(tools[1]["name"], "provider_tool");
5402 assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
5403 assert_eq!(value["metadata"]["source"], "test");
5404 }
5405
5406 #[test]
5407 fn test_prompt_caching_without_tools_omits_tools() {
5408 let request = completion_request_with_tools(Vec::new(), None);
5409
5410 let request = AnthropicCompletionRequest::try_from(AnthropicRequestParams {
5411 model: "claude-sonnet-4-6",
5412 request,
5413 prompt_caching: true,
5414 automatic_caching: false,
5415 automatic_caching_ttl: None,
5416 static_prefix_cache_ttl: None,
5417 })
5418 .unwrap();
5419
5420 let value = serde_json::to_value(request).unwrap();
5421 assert!(value.get("tools").is_none());
5422 }
5423
5424 #[test]
5425 fn test_plaintext_document_serialization() {
5426 let content = Content::Document {
5427 source: DocumentSource::Text {
5428 data: "Hello, world!".to_string(),
5429 media_type: PlainTextMediaType::Plain,
5430 },
5431 title: None,
5432 context: None,
5433 citations: None,
5434 cache_control: None,
5435 };
5436
5437 let json = serde_json::to_value(&content).unwrap();
5438 assert_eq!(json["type"], "document");
5439 assert_eq!(json["source"]["type"], "text");
5440 assert_eq!(json["source"]["media_type"], "text/plain");
5441 assert_eq!(json["source"]["data"], "Hello, world!");
5442 }
5443
5444 #[test]
5445 fn test_plaintext_document_deserialization() {
5446 let json = r#"
5447 {
5448 "type": "document",
5449 "source": {
5450 "type": "text",
5451 "media_type": "text/plain",
5452 "data": "Hello, world!"
5453 }
5454 }
5455 "#;
5456
5457 let content: Content = serde_json::from_str(json).unwrap();
5458 match content {
5459 Content::Document {
5460 source,
5461 cache_control,
5462 ..
5463 } => {
5464 assert_eq!(
5465 source,
5466 DocumentSource::Text {
5467 data: "Hello, world!".to_string(),
5468 media_type: PlainTextMediaType::Plain,
5469 }
5470 );
5471 assert_eq!(cache_control, None);
5472 }
5473 _ => panic!("Expected Document content"),
5474 }
5475 }
5476
5477 #[test]
5478 fn test_base64_pdf_document_serialization() {
5479 let content = Content::Document {
5480 source: DocumentSource::Base64 {
5481 data: "base64data".to_string(),
5482 media_type: DocumentFormat::PDF,
5483 },
5484 title: None,
5485 context: None,
5486 citations: None,
5487 cache_control: None,
5488 };
5489
5490 let json = serde_json::to_value(&content).unwrap();
5491 assert_eq!(json["type"], "document");
5492 assert_eq!(json["source"]["type"], "base64");
5493 assert_eq!(json["source"]["media_type"], "application/pdf");
5494 assert_eq!(json["source"]["data"], "base64data");
5495 }
5496
5497 #[test]
5498 fn test_base64_pdf_document_deserialization() {
5499 let json = r#"
5500 {
5501 "type": "document",
5502 "source": {
5503 "type": "base64",
5504 "media_type": "application/pdf",
5505 "data": "base64data"
5506 }
5507 }
5508 "#;
5509
5510 let content: Content = serde_json::from_str(json).unwrap();
5511 match content {
5512 Content::Document { source, .. } => {
5513 assert_eq!(
5514 source,
5515 DocumentSource::Base64 {
5516 data: "base64data".to_string(),
5517 media_type: DocumentFormat::PDF,
5518 }
5519 );
5520 }
5521 _ => panic!("Expected Document content"),
5522 }
5523 }
5524
5525 #[test]
5526 fn test_file_id_document_serialization() {
5527 let content = Content::Document {
5528 source: DocumentSource::File {
5529 file_id: "file_abc".to_string(),
5530 },
5531 title: None,
5532 context: None,
5533 citations: None,
5534 cache_control: None,
5535 };
5536
5537 let json = serde_json::to_value(&content).unwrap();
5538 assert_eq!(json["type"], "document");
5539 assert_eq!(json["source"]["type"], "file");
5540 assert_eq!(json["source"]["file_id"], "file_abc");
5541 }
5542
5543 #[test]
5544 fn test_file_id_document_deserialization() {
5545 let json = r#"
5546 {
5547 "type": "document",
5548 "source": {
5549 "type": "file",
5550 "file_id": "file_abc"
5551 }
5552 }
5553 "#;
5554
5555 let content: Content = serde_json::from_str(json).unwrap();
5556 match content {
5557 Content::Document { source, .. } => {
5558 assert_eq!(
5559 source,
5560 DocumentSource::File {
5561 file_id: "file_abc".to_string(),
5562 }
5563 );
5564 }
5565 _ => panic!("Expected Document content"),
5566 }
5567 }
5568
5569 #[test]
5570 fn test_file_id_rig_to_anthropic_conversion() {
5571 use crate::completion::message as msg;
5572
5573 let rig_message = msg::Message::User {
5574 content: vec![msg::UserContent::Document(msg::Document {
5575 data: DocumentSourceKind::FileId("file_abc".to_string()),
5576 media_type: None,
5577 additional_params: None,
5578 })],
5579 };
5580
5581 let anthropic_message: Message = rig_message.try_into().unwrap();
5582 assert_eq!(anthropic_message.role, Role::User);
5583
5584 let mut iter = anthropic_message.content.into_iter();
5585 match iter.next().unwrap() {
5586 Content::Document { source, .. } => {
5587 assert_eq!(
5588 source,
5589 DocumentSource::File {
5590 file_id: "file_abc".to_string(),
5591 }
5592 );
5593 }
5594 other => panic!("Expected Document content, got: {other:?}"),
5595 }
5596 }
5597
5598 #[test]
5599 fn test_file_id_anthropic_to_rig_conversion() {
5600 use crate::completion::message as msg;
5601
5602 let anthropic_message = Message {
5603 role: Role::User,
5604 content: vec![Content::Document {
5605 source: DocumentSource::File {
5606 file_id: "file_abc".to_string(),
5607 },
5608 title: None,
5609 context: None,
5610 citations: None,
5611 cache_control: None,
5612 }],
5613 };
5614
5615 let rig_message: msg::Message = anthropic_message.try_into().unwrap();
5616 match rig_message {
5617 msg::Message::User { content } => {
5618 let mut iter = content.into_iter();
5619 match iter.next().unwrap() {
5620 msg::UserContent::Document(msg::Document {
5621 data, media_type, ..
5622 }) => {
5623 assert_eq!(data, DocumentSourceKind::FileId("file_abc".to_string()));
5624 assert_eq!(media_type, None);
5625 }
5626 other => panic!("Expected Document content, got: {other:?}"),
5627 }
5628 }
5629 _ => panic!("Expected User message"),
5630 }
5631 }
5632
5633 #[test]
5634 fn test_plaintext_rig_to_anthropic_conversion() {
5635 use crate::completion::message as msg;
5636
5637 let rig_message = msg::Message::User {
5638 content: vec![msg::UserContent::document(
5639 "Some plain text content".to_string(),
5640 Some(msg::DocumentMediaType::TXT),
5641 )],
5642 };
5643
5644 let anthropic_message: Message = rig_message.try_into().unwrap();
5645 assert_eq!(anthropic_message.role, Role::User);
5646
5647 let mut iter = anthropic_message.content.into_iter();
5648 match iter.next().unwrap() {
5649 Content::Document { source, .. } => {
5650 assert_eq!(
5651 source,
5652 DocumentSource::Text {
5653 data: "Some plain text content".to_string(),
5654 media_type: PlainTextMediaType::Plain,
5655 }
5656 );
5657 }
5658 other => panic!("Expected Document content, got: {other:?}"),
5659 }
5660 }
5661
5662 #[test]
5663 fn test_plaintext_anthropic_to_rig_conversion() {
5664 use crate::completion::message as msg;
5665
5666 let anthropic_message = Message {
5667 role: Role::User,
5668 content: vec![Content::Document {
5669 source: DocumentSource::Text {
5670 data: "Some plain text content".to_string(),
5671 media_type: PlainTextMediaType::Plain,
5672 },
5673 title: None,
5674 context: None,
5675 citations: None,
5676 cache_control: None,
5677 }],
5678 };
5679
5680 let rig_message: msg::Message = anthropic_message.try_into().unwrap();
5681 match rig_message {
5682 msg::Message::User { content } => {
5683 let mut iter = content.into_iter();
5684 match iter.next().unwrap() {
5685 msg::UserContent::Document(msg::Document {
5686 data, media_type, ..
5687 }) => {
5688 assert_eq!(
5689 data,
5690 DocumentSourceKind::String("Some plain text content".into())
5691 );
5692 assert_eq!(media_type, Some(msg::DocumentMediaType::TXT));
5693 }
5694 other => panic!("Expected Document content, got: {other:?}"),
5695 }
5696 }
5697 _ => panic!("Expected User message"),
5698 }
5699 }
5700
5701 #[test]
5702 fn test_plaintext_roundtrip_rig_to_anthropic_and_back() {
5703 use crate::completion::message as msg;
5704
5705 let original = msg::Message::User {
5706 content: vec![msg::UserContent::document(
5707 "Round trip text".to_string(),
5708 Some(msg::DocumentMediaType::TXT),
5709 )],
5710 };
5711
5712 let anthropic: Message = original.clone().try_into().unwrap();
5713 let back: msg::Message = anthropic.try_into().unwrap();
5714
5715 match (&original, &back) {
5716 (
5717 msg::Message::User {
5718 content: orig_content,
5719 },
5720 msg::Message::User {
5721 content: back_content,
5722 },
5723 ) => match (orig_content.first(), back_content.first()) {
5724 (
5725 Some(msg::UserContent::Document(msg::Document {
5726 media_type: orig_mt,
5727 ..
5728 })),
5729 Some(msg::UserContent::Document(msg::Document {
5730 media_type: back_mt,
5731 ..
5732 })),
5733 ) => {
5734 assert_eq!(orig_mt, back_mt);
5735 }
5736 _ => panic!("Expected Document content in both"),
5737 },
5738 _ => panic!("Expected User messages"),
5739 }
5740 }
5741
5742 #[test]
5743 fn test_unsupported_document_type_returns_error() {
5744 use crate::completion::message as msg;
5745
5746 let rig_message = msg::Message::User {
5747 content: vec![msg::UserContent::Document(msg::Document {
5748 data: DocumentSourceKind::String("data".into()),
5749 media_type: Some(msg::DocumentMediaType::HTML),
5750 additional_params: None,
5751 })],
5752 };
5753
5754 let result: Result<Message, _> = rig_message.try_into();
5755 assert!(result.is_err());
5756 let err = result.unwrap_err().to_string();
5757 assert!(
5758 err.contains("Anthropic only supports PDF and plain text documents"),
5759 "Unexpected error: {err}"
5760 );
5761 }
5762
5763 #[test]
5764 fn test_plaintext_document_url_source_returns_error() {
5765 use crate::completion::message as msg;
5766
5767 let rig_message = msg::Message::User {
5768 content: vec![msg::UserContent::Document(msg::Document {
5769 data: DocumentSourceKind::Url("https://example.com/doc.txt".into()),
5770 media_type: Some(msg::DocumentMediaType::TXT),
5771 additional_params: None,
5772 })],
5773 };
5774
5775 let result: Result<Message, _> = rig_message.try_into();
5776 assert!(result.is_err());
5777 let err = result.unwrap_err().to_string();
5778 assert!(
5779 err.contains("Only string or base64 data is supported for plain text documents"),
5780 "Unexpected error: {err}"
5781 );
5782 }
5783
5784 #[test]
5785 fn test_plaintext_document_with_cache_control() {
5786 let content = Content::Document {
5787 source: DocumentSource::Text {
5788 data: "cached text".to_string(),
5789 media_type: PlainTextMediaType::Plain,
5790 },
5791 title: None,
5792 context: None,
5793 citations: None,
5794 cache_control: Some(CacheControl::ephemeral()),
5795 };
5796
5797 let json = serde_json::to_value(&content).unwrap();
5798 assert_eq!(json["source"]["type"], "text");
5799 assert_eq!(json["source"]["media_type"], "text/plain");
5800 assert_eq!(json["cache_control"]["type"], "ephemeral");
5801 }
5802
5803 #[test]
5804 fn test_message_with_plaintext_document_deserialization() {
5805 let json = r#"
5806 {
5807 "role": "user",
5808 "content": [
5809 {
5810 "type": "document",
5811 "source": {
5812 "type": "text",
5813 "media_type": "text/plain",
5814 "data": "Hello from a text file"
5815 }
5816 },
5817 {
5818 "type": "text",
5819 "text": "Summarize this document."
5820 }
5821 ]
5822 }
5823 "#;
5824
5825 let message: Message = serde_json::from_str(json).unwrap();
5826 assert_eq!(message.role, Role::User);
5827 assert_eq!(message.content.len(), 2);
5828
5829 let mut iter = message.content.into_iter();
5830
5831 match iter.next().unwrap() {
5832 Content::Document { source, .. } => {
5833 assert_eq!(
5834 source,
5835 DocumentSource::Text {
5836 data: "Hello from a text file".to_string(),
5837 media_type: PlainTextMediaType::Plain,
5838 }
5839 );
5840 }
5841 _ => panic!("Expected Document content"),
5842 }
5843
5844 match iter.next().unwrap() {
5845 Content::Text { text, .. } => {
5846 assert_eq!(text, "Summarize this document.");
5847 }
5848 _ => panic!("Expected Text content"),
5849 }
5850 }
5851
5852 #[test]
5853 fn test_assistant_reasoning_multiblock_to_anthropic_content() {
5854 let reasoning = message::Reasoning {
5855 id: None,
5856 content: vec![
5857 message::ReasoningContent::Text {
5858 text: "step one".to_string(),
5859 signature: Some("sig-1".to_string()),
5860 },
5861 message::ReasoningContent::Summary("summary".to_string()),
5862 message::ReasoningContent::Text {
5863 text: "step two".to_string(),
5864 signature: Some("sig-2".to_string()),
5865 },
5866 message::ReasoningContent::Redacted {
5867 data: "redacted block".to_string(),
5868 },
5869 ],
5870 };
5871
5872 let msg = message::Message::Assistant {
5873 id: None,
5874 content: vec![message::AssistantContent::Reasoning(reasoning)],
5875 };
5876 let converted: Message = msg.try_into().expect("convert assistant message");
5877 let converted_content = converted.content.clone();
5878
5879 assert_eq!(converted.role, Role::Assistant);
5880 assert_eq!(converted_content.len(), 4);
5881 assert!(matches!(
5882 converted_content.first(),
5883 Some(Content::Thinking { thinking, signature: Some(signature) })
5884 if thinking == "step one" && signature == "sig-1"
5885 ));
5886 assert!(matches!(
5887 converted_content.get(1),
5888 Some(Content::Thinking { thinking, signature: None }) if thinking == "summary"
5889 ));
5890 assert!(matches!(
5891 converted_content.get(2),
5892 Some(Content::Thinking { thinking, signature: Some(signature) })
5893 if thinking == "step two" && signature == "sig-2"
5894 ));
5895 assert!(matches!(
5896 converted_content.get(3),
5897 Some(Content::RedactedThinking { data }) if data == "redacted block"
5898 ));
5899 }
5900
5901 #[test]
5902 fn test_redacted_thinking_content_to_assistant_reasoning() {
5903 let content = Content::RedactedThinking {
5904 data: "opaque-redacted".to_string(),
5905 };
5906 let converted: message::AssistantContent =
5907 content.try_into().expect("convert redacted thinking");
5908
5909 assert!(matches!(
5910 converted,
5911 message::AssistantContent::Reasoning(message::Reasoning { content, .. })
5912 if matches!(
5913 content.first(),
5914 Some(message::ReasoningContent::Redacted { data }) if data == "opaque-redacted"
5915 )
5916 ));
5917 }
5918
5919 #[test]
5920 fn test_assistant_encrypted_reasoning_maps_to_redacted_thinking() {
5921 let reasoning = message::Reasoning {
5922 id: None,
5923 content: vec![message::ReasoningContent::Encrypted(
5924 "ciphertext".to_string(),
5925 )],
5926 };
5927 let msg = message::Message::Assistant {
5928 id: None,
5929 content: vec![message::AssistantContent::Reasoning(reasoning)],
5930 };
5931
5932 let converted: Message = msg.try_into().expect("convert assistant message");
5933 let converted_content = converted.content.clone();
5934
5935 assert_eq!(converted_content.len(), 1);
5936 assert!(matches!(
5937 converted_content.first(),
5938 Some(Content::RedactedThinking { data }) if data == "ciphertext"
5939 ));
5940 }
5941
5942 #[test]
5943 fn empty_end_turn_response_normalizes_to_an_empty_choice() {
5944 let response = CompletionResponse {
5945 content: vec![],
5946 id: "msg_123".to_string(),
5947 model: CLAUDE_SONNET_4_6.to_string(),
5948 role: "assistant".to_string(),
5949 stop_reason: Some("end_turn".to_string()),
5950 stop_sequence: None,
5951 provider_request_id: None,
5952 usage: Usage {
5953 input_tokens: 7,
5954 cache_read_input_tokens: None,
5955 cache_creation_input_tokens: None,
5956 cache_creation: None,
5957 output_tokens: 2,
5958 output_tokens_details: None,
5959 },
5960 };
5961
5962 let parsed: completion::CompletionResponse = response
5963 .normalize("anthropic")
5964 .expect("empty end_turn should not error");
5965
5966 assert!(parsed.choice.is_empty());
5972 assert_eq!(parsed.provider, "anthropic");
5973 assert_eq!(parsed.message_id.as_deref(), Some("msg_123"));
5974 assert_eq!(parsed.model.as_deref(), Some(CLAUDE_SONNET_4_6));
5975 assert_eq!(parsed.finish_reason(), Some(completion::FinishReason::Stop));
5976 }
5977
5978 fn empty_response_with(
5981 stop_reason: Option<&str>,
5982 stop_sequence: Option<&str>,
5983 ) -> CompletionResponse {
5984 CompletionResponse {
5985 content: vec![],
5986 id: "msg_123".to_string(),
5987 model: CLAUDE_SONNET_4_6.to_string(),
5988 role: "assistant".to_string(),
5989 stop_reason: stop_reason.map(str::to_string),
5990 stop_sequence: stop_sequence.map(str::to_string),
5991 provider_request_id: None,
5992 usage: Usage {
5993 input_tokens: 7,
5994 cache_read_input_tokens: None,
5995 cache_creation_input_tokens: None,
5996 cache_creation: None,
5997 output_tokens: 2,
5998 output_tokens_details: None,
5999 },
6000 }
6001 }
6002
6003 #[test]
6004 fn empty_response_outside_the_legal_terminals_still_errors() {
6005 for (stop_reason, stop_sequence) in [
6006 (Some("tool_use"), None),
6007 (Some("max_tokens"), None),
6008 (Some("refusal"), None),
6009 (Some("pause_turn"), None),
6010 (None, None),
6011 (Some("stop_sequence"), None),
6014 (Some("max_tokens"), Some("alpha")),
6017 ] {
6018 let err = empty_response_with(stop_reason, stop_sequence)
6019 .normalize("anthropic")
6020 .expect_err(&format!(
6021 "empty {stop_reason:?} response should remain an error"
6022 ));
6023
6024 assert!(matches!(
6025 err,
6026 CompletionError::ResponseError(message) if message == EMPTY_RESPONSE_ERROR
6027 ));
6028 }
6029 }
6030
6031 #[test]
6032 fn empty_stop_sequence_response_naming_its_sequence_is_a_completed_turn() {
6033 let parsed = empty_response_with(Some("stop_sequence"), Some("alpha"))
6034 .normalize("anthropic")
6035 .expect("a completed stop-sequence turn must not normalize into an error");
6036
6037 assert!(parsed.choice.is_empty());
6038 assert_eq!(parsed.finish_reason(), Some(completion::FinishReason::Stop));
6039 }
6040
6041 #[test]
6042 fn stop_reason_maps_onto_the_normalized_vocabulary() {
6043 assert_eq!(
6044 map_finish_reason("end_turn"),
6045 completion::FinishReason::Stop
6046 );
6047 assert_eq!(
6048 map_finish_reason("stop_sequence"),
6049 completion::FinishReason::Stop
6050 );
6051 assert_eq!(
6052 map_finish_reason("max_tokens"),
6053 completion::FinishReason::Length
6054 );
6055 assert_eq!(
6056 map_finish_reason("tool_use"),
6057 completion::FinishReason::ToolCalls
6058 );
6059 assert_eq!(
6060 map_finish_reason("refusal"),
6061 completion::FinishReason::ContentFilter
6062 );
6063 }
6064
6065 #[test]
6066 fn unknown_stop_reason_is_preserved_verbatim() {
6067 assert_eq!(
6070 map_finish_reason("pause_turn"),
6071 completion::FinishReason::Other("pause_turn".to_owned())
6072 );
6073 assert_eq!(
6074 map_finish_reason("model_context_window_exceeded"),
6075 completion::FinishReason::Other("model_context_window_exceeded".to_owned())
6076 );
6077 }
6078
6079 #[test]
6080 fn end_turn_with_a_tool_call_is_reconciled_to_tool_calls() {
6081 let response = CompletionResponse {
6085 content: vec![Content::ToolUse {
6086 id: "toolu_1".to_string(),
6087 name: "add".to_string(),
6088 input: json!({"x": 1}),
6089 }],
6090 id: "msg_123".to_string(),
6091 model: CLAUDE_SONNET_4_6.to_string(),
6092 role: "assistant".to_string(),
6093 stop_reason: Some("end_turn".to_string()),
6094 stop_sequence: None,
6095 provider_request_id: None,
6096 usage: Usage {
6097 input_tokens: 7,
6098 cache_read_input_tokens: None,
6099 cache_creation_input_tokens: None,
6100 cache_creation: None,
6101 output_tokens: 2,
6102 output_tokens_details: None,
6103 },
6104 };
6105
6106 let parsed = response
6107 .normalize("anthropic")
6108 .expect("tool-use response should normalize");
6109
6110 assert_eq!(
6111 parsed.finish_reason(),
6112 Some(completion::FinishReason::ToolCalls)
6113 );
6114 }
6115
6116 #[test]
6117 fn test_tool_result_content_in_message_roundtrip() {
6118 let message_json = r#"{
6119 "role": "user",
6120 "content": [
6121 {
6122 "type": "tool_result",
6123 "tool_use_id": "toolu_01A09q90qw90lq917835lq9",
6124 "content": [
6125 {
6126 "type": "text",
6127 "text": "Here is the screenshot:"
6128 },
6129 {
6130 "type": "image",
6131 "source": {
6132 "type": "base64",
6133 "media_type": "image/png",
6134 "data": "iVBORw0KGgo..."
6135 }
6136 }
6137 ]
6138 }
6139 ]
6140 }"#;
6141
6142 let message: Message = serde_json::from_str(message_json).unwrap();
6143 let serialized = serde_json::to_value(&message).unwrap();
6144
6145 let tool_result = &serialized["content"][0];
6146 assert_eq!(tool_result["type"], "tool_result");
6147
6148 let image_content = &tool_result["content"][1];
6149 assert_eq!(image_content["type"], "image");
6150 assert_eq!(image_content["source"]["type"], "base64");
6151 assert_eq!(image_content["source"]["media_type"], "image/png");
6152 assert_eq!(image_content["source"]["data"], "iVBORw0KGgo...");
6153 }
6154
6155 #[test]
6160 fn document_serializes_citations_and_metadata() {
6161 let doc = Content::Document {
6162 source: DocumentSource::Text {
6163 data: "hello".into(),
6164 media_type: PlainTextMediaType::Plain,
6165 },
6166 title: Some("My Doc".into()),
6167 context: None,
6168 citations: Some(CitationsConfig { enabled: true }),
6169 cache_control: None,
6170 };
6171 let value = serde_json::to_value(&doc).unwrap();
6172 assert_eq!(value["citations"]["enabled"], true);
6173 assert_eq!(value["title"], "My Doc");
6174 assert!(
6175 value.get("context").is_none(),
6176 "context should be skipped when None"
6177 );
6178 }
6179
6180 #[test]
6181 fn text_serializes_without_citations_when_empty() {
6182 let content = Content::Text {
6183 text: "hello".into(),
6184 citations: Vec::new(),
6185 cache_control: None,
6186 };
6187 let value = serde_json::to_value(&content).unwrap();
6188 assert!(
6189 value.get("citations").is_none(),
6190 "empty citations vec must be skipped"
6191 );
6192 }
6193
6194 #[test]
6195 fn text_deserializes_char_location_citation() {
6196 let value = json!({
6197 "type": "text",
6198 "text": "the grass is green",
6199 "citations": [{
6200 "type": "char_location",
6201 "cited_text": "The grass is green.",
6202 "document_index": 0,
6203 "document_title": "Example",
6204 "start_char_index": 0,
6205 "end_char_index": 20
6206 }]
6207 });
6208 let parsed: Content = serde_json::from_value(value).unwrap();
6209 let Content::Text { citations, .. } = parsed else {
6210 panic!("expected Content::Text");
6211 };
6212 assert_eq!(citations.len(), 1);
6213 let Citation::CharLocation(citation) = &citations[0] else {
6214 panic!("expected CharLocation");
6215 };
6216 assert_eq!(citation.start_char_index, 0);
6217 assert_eq!(citation.end_char_index, 20);
6218 }
6219
6220 #[test]
6221 fn text_deserializes_search_result_location_citation() {
6222 let value = json!({
6223 "type": "text",
6224 "text": "API keys are required.",
6225 "citations": [{
6226 "type": "search_result_location",
6227 "cited_text": "All API requests must include an API key.",
6228 "source": "https://docs.example.com/api-reference",
6229 "title": "API Reference",
6230 "search_result_index": 0,
6231 "start_block_index": 0,
6232 "end_block_index": 1
6233 }]
6234 });
6235
6236 let parsed: Content = serde_json::from_value(value).unwrap();
6237 let Content::Text { citations, .. } = parsed else {
6238 panic!("expected Content::Text");
6239 };
6240
6241 assert!(matches!(
6242 &citations[0],
6243 Citation::SearchResultLocation(SearchResultLocationCitation {
6244 source,
6245 title: Some(title),
6246 search_result_index: 0,
6247 start_block_index: 0,
6248 end_block_index: 1,
6249 ..
6250 }) if source == "https://docs.example.com/api-reference" && title == "API Reference"
6251 ));
6252 }
6253
6254 #[test]
6255 fn text_deserializes_web_search_result_location_citation() {
6256 let value = json!({
6257 "type": "text",
6258 "text": "Claude Shannon worked at Bell Labs.",
6259 "citations": [{
6260 "type": "web_search_result_location",
6261 "cited_text": "Claude Shannon was a mathematician.",
6262 "url": "https://example.com/shannon",
6263 "title": "Claude Shannon",
6264 "encrypted_index": "encrypted-reference"
6265 }]
6266 });
6267
6268 let parsed: Content = serde_json::from_value(value).unwrap();
6269 let Content::Text { citations, .. } = parsed else {
6270 panic!("expected Content::Text");
6271 };
6272
6273 assert!(matches!(
6274 &citations[0],
6275 Citation::WebSearchResultLocation(WebSearchResultLocationCitation {
6276 url,
6277 title,
6278 encrypted_index,
6279 ..
6280 }) if url == "https://example.com/shannon"
6281 && title.as_deref() == Some("Claude Shannon")
6282 && encrypted_index == "encrypted-reference"
6283 ));
6284 }
6285
6286 #[test]
6287 fn text_deserializes_web_search_result_location_citation_with_null_title() {
6288 let value = json!({
6289 "type": "text",
6290 "text": "Claude Shannon worked at Bell Labs.",
6291 "citations": [{
6292 "type": "web_search_result_location",
6293 "cited_text": "Claude Shannon was a mathematician.",
6294 "url": "https://example.com/shannon",
6295 "title": null,
6296 "encrypted_index": "encrypted-reference"
6297 }]
6298 });
6299
6300 let parsed: Content = serde_json::from_value(value).unwrap();
6301 let Content::Text { citations, .. } = parsed else {
6302 panic!("expected Content::Text");
6303 };
6304
6305 let Citation::WebSearchResultLocation(citation) = &citations[0] else {
6306 panic!("expected WebSearchResultLocation");
6307 };
6308 assert_eq!(citation.title, None);
6309
6310 let serialized = serde_json::to_value(&citations[0]).unwrap();
6311 assert!(serialized.get("title").is_some());
6312 assert!(serialized["title"].is_null());
6313 }
6314
6315 #[test]
6316 fn web_search_response_preserves_raw_blocks_and_citations() {
6317 let value = json!({
6318 "id": "msg_web_search",
6319 "model": CLAUDE_SONNET_4_6,
6320 "role": "assistant",
6321 "stop_reason": "end_turn",
6322 "stop_sequence": null,
6323 "usage": {
6324 "input_tokens": 10,
6325 "output_tokens": 20
6326 },
6327 "content": [
6328 {
6329 "type": "server_tool_use",
6330 "id": "srvtoolu_01",
6331 "name": "web_search",
6332 "input": {
6333 "query": "claude shannon birth date"
6334 }
6335 },
6336 {
6337 "type": "web_search_tool_result",
6338 "tool_use_id": "srvtoolu_01",
6339 "content": [
6340 {
6341 "type": "web_search_result",
6342 "url": "https://example.com/shannon",
6343 "title": "Claude Shannon",
6344 "encrypted_content": "encrypted-content",
6345 "page_age": "April 30, 2025"
6346 }
6347 ]
6348 },
6349 {
6350 "type": "text",
6351 "text": "Claude Shannon was born on April 30, 1916.",
6352 "citations": [{
6353 "type": "web_search_result_location",
6354 "cited_text": "Claude Shannon was born on April 30, 1916.",
6355 "url": "https://example.com/shannon",
6356 "title": "Claude Shannon",
6357 "encrypted_index": "encrypted-index"
6358 }]
6359 }
6360 ]
6361 });
6362
6363 let response: CompletionResponse = serde_json::from_value(value).unwrap();
6364 let raw_text_response = response.get_text_response();
6367 let converted = response.normalize("anthropic").unwrap();
6368 assert_eq!(converted.choice.len(), 3);
6369 assert_eq!(
6370 raw_text_response.as_deref(),
6371 Some("Claude Shannon was born on April 30, 1916.")
6372 );
6373
6374 let items = converted.choice.iter().collect::<Vec<_>>();
6375 let message::AssistantContent::Text(server_tool_use) = items[0] else {
6376 panic!("expected raw server_tool_use metadata");
6377 };
6378 assert_eq!(server_tool_use.text, "");
6379 assert_eq!(
6380 server_tool_use.additional_params.as_ref().unwrap()[ANTHROPIC_RAW_CONTENT_KEY]["type"],
6381 "server_tool_use"
6382 );
6383
6384 let message::AssistantContent::Text(web_search_result) = items[1] else {
6385 panic!("expected raw web_search_tool_result metadata");
6386 };
6387 assert_eq!(
6388 web_search_result.additional_params.as_ref().unwrap()[ANTHROPIC_RAW_CONTENT_KEY]["content"]
6389 [0]["encrypted_content"],
6390 "encrypted-content"
6391 );
6392
6393 let message::AssistantContent::Text(answer) = items[2] else {
6394 panic!("expected text answer");
6395 };
6396 let citations = anthropic_citations(answer).unwrap();
6397 assert!(matches!(
6398 citations.first(),
6399 Some(Citation::WebSearchResultLocation(citation))
6400 if citation.encrypted_index == "encrypted-index"
6401 ));
6402
6403 let round_trip: Message = message::Message::Assistant {
6404 id: converted.message_id.clone(),
6405 content: converted.choice,
6406 }
6407 .try_into()
6408 .unwrap();
6409
6410 let round_trip_items = round_trip.content.iter().collect::<Vec<_>>();
6411 assert!(matches!(
6412 round_trip_items.first(),
6413 Some(Content::ServerToolUse { id, name, input })
6414 if id == "srvtoolu_01"
6415 && name == "web_search"
6416 && input["query"] == "claude shannon birth date"
6417 ));
6418 assert!(matches!(
6419 round_trip_items.get(1),
6420 Some(Content::WebSearchToolResult {
6421 tool_use_id,
6422 content
6423 }) if tool_use_id == "srvtoolu_01"
6424 && content[0]["encrypted_content"] == "encrypted-content"
6425 ));
6426 }
6427
6428 #[test]
6429 fn web_search_tool_result_error_object_is_preserved_raw() {
6430 let value = json!({
6431 "id": "msg_web_search_error",
6432 "model": CLAUDE_SONNET_4_6,
6433 "role": "assistant",
6434 "stop_reason": "end_turn",
6435 "stop_sequence": null,
6436 "usage": {
6437 "input_tokens": 10,
6438 "output_tokens": 2
6439 },
6440 "content": [{
6441 "type": "web_search_tool_result",
6442 "tool_use_id": "srvtoolu_01",
6443 "content": {
6444 "type": "web_search_tool_result_error",
6445 "error_code": "max_uses_exceeded"
6446 }
6447 }]
6448 });
6449
6450 let response: CompletionResponse = serde_json::from_value(value).unwrap();
6451 let converted = response.normalize("anthropic").unwrap();
6452 let Some(message::AssistantContent::Text(web_search_result)) = converted.choice.first()
6453 else {
6454 panic!("expected raw web_search_tool_result metadata");
6455 };
6456
6457 let raw_content =
6458 &web_search_result.additional_params.as_ref().unwrap()[ANTHROPIC_RAW_CONTENT_KEY];
6459 assert_eq!(raw_content["type"], "web_search_tool_result");
6460 assert_eq!(raw_content["content"]["error_code"], "max_uses_exceeded");
6461 assert_eq!(
6462 raw_content["content"]["type"],
6463 "web_search_tool_result_error"
6464 );
6465
6466 let round_trip: Message = message::Message::Assistant {
6467 id: converted.message_id,
6468 content: converted.choice,
6469 }
6470 .try_into()
6471 .unwrap();
6472
6473 assert!(matches!(
6474 round_trip.content.first(),
6475 Some(Content::WebSearchToolResult {
6476 tool_use_id,
6477 content
6478 }) if tool_use_id == "srvtoolu_01"
6479 && content["error_code"] == "max_uses_exceeded"
6480 ));
6481 }
6482
6483 #[test]
6484 fn code_execution_tool_result_variants_deserialize() {
6485 let normal: Content = serde_json::from_value(json!({
6486 "type": "code_execution_tool_result",
6487 "tool_use_id": "srvtoolu_normal",
6488 "content": {
6489 "type": "code_execution_result",
6490 "return_code": 0,
6491 "stdout": "42\n",
6492 "stderr": "",
6493 "content": []
6494 }
6495 }))
6496 .unwrap();
6497 assert!(matches!(
6498 normal,
6499 Content::CodeExecutionToolResult {
6500 ref tool_use_id,
6501 ref content
6502 } if tool_use_id == "srvtoolu_normal"
6503 && content["type"] == "code_execution_result"
6504 && content["stdout"] == "42\n"
6505 ));
6506
6507 let encrypted: Content = serde_json::from_value(json!({
6508 "type": "code_execution_tool_result",
6509 "tool_use_id": "srvtoolu_encrypted",
6510 "content": {
6511 "type": "encrypted_code_execution_result",
6512 "return_code": 1,
6513 "stderr": "failure",
6514 "encrypted_stdout": "encrypted-output",
6515 "content": []
6516 }
6517 }))
6518 .unwrap();
6519 assert!(matches!(
6520 encrypted,
6521 Content::CodeExecutionToolResult {
6522 ref tool_use_id,
6523 ref content
6524 } if tool_use_id == "srvtoolu_encrypted"
6525 && content["type"] == "encrypted_code_execution_result"
6526 && content["encrypted_stdout"] == "encrypted-output"
6527 ));
6528 }
6529
6530 #[test]
6531 fn code_execution_tool_result_is_preserved_and_round_trips() {
6532 let raw_block = json!({
6533 "type": "code_execution_tool_result",
6534 "tool_use_id": "srvtoolu_01",
6535 "content": {
6536 "type": "code_execution_result",
6537 "return_code": 0,
6538 "stdout": "42\n",
6539 "stderr": "",
6540 "content": []
6541 }
6542 });
6543 let value = json!({
6544 "id": "msg_code_execution",
6545 "model": CLAUDE_OPUS_4_8,
6546 "role": "assistant",
6547 "stop_reason": "end_turn",
6548 "stop_sequence": null,
6549 "usage": {
6550 "input_tokens": 10,
6551 "output_tokens": 20
6552 },
6553 "content": [raw_block.clone()]
6554 });
6555
6556 let response: CompletionResponse = serde_json::from_value(value).unwrap();
6557 let converted = response.normalize("anthropic").unwrap();
6558 let Some(message::AssistantContent::Text(code_execution_result)) = converted.choice.first()
6559 else {
6560 panic!("expected raw code_execution_tool_result metadata");
6561 };
6562 assert_eq!(
6563 code_execution_result.additional_params.as_ref().unwrap()[ANTHROPIC_RAW_CONTENT_KEY],
6564 raw_block
6565 );
6566
6567 let round_trip: Message = message::Message::Assistant {
6568 id: converted.message_id,
6569 content: converted.choice,
6570 }
6571 .try_into()
6572 .unwrap();
6573 assert!(matches!(
6574 round_trip.content.first(),
6575 Some(Content::CodeExecutionToolResult {
6576 tool_use_id,
6577 content
6578 }) if tool_use_id == "srvtoolu_01"
6579 && content["type"] == "code_execution_result"
6580 && content["stdout"] == "42\n"
6581 ));
6582 }
6583
6584 #[test]
6585 fn text_deserializes_unknown_citation_without_failing() {
6586 let value = json!({
6587 "type": "text",
6588 "text": "future citation",
6589 "citations": [{
6590 "type": "future_location",
6591 "cited_text": "future text",
6592 "new_field": "kept"
6593 }]
6594 });
6595
6596 let parsed: Content = serde_json::from_value(value).unwrap();
6597 let Content::Text { citations, .. } = parsed else {
6598 panic!("expected Content::Text");
6599 };
6600
6601 assert!(matches!(
6602 &citations[0],
6603 Citation::Unknown(raw)
6604 if raw["type"] == "future_location" && raw["new_field"] == "kept"
6605 ));
6606 }
6607
6608 #[test]
6609 fn page_location_citation_roundtrips() {
6610 let citation = Citation::PageLocation(PageLocationCitation {
6611 cited_text: "Water is essential for life.".into(),
6612 document_index: 1,
6613 document_title: Some("PDF Doc".into()),
6614 start_page_number: 5,
6615 end_page_number: 6,
6616 });
6617 let value = serde_json::to_value(&citation).unwrap();
6618 assert_eq!(value["type"], "page_location");
6619 assert_eq!(value["start_page_number"], 5);
6620 let back: Citation = serde_json::from_value(value).unwrap();
6621 assert_eq!(back, citation);
6622 }
6623
6624 #[test]
6625 fn content_block_location_citation_roundtrips() {
6626 let citation = Citation::ContentBlockLocation(ContentBlockLocationCitation {
6627 cited_text: "These are important findings.".into(),
6628 document_index: 2,
6629 document_title: None,
6630 start_block_index: 0,
6631 end_block_index: 1,
6632 });
6633 let value = serde_json::to_value(&citation).unwrap();
6634 assert_eq!(value["type"], "content_block_location");
6635 assert!(value.get("document_title").is_none());
6636 let back: Citation = serde_json::from_value(value).unwrap();
6637 assert_eq!(back, citation);
6638 }
6639
6640 #[test]
6641 fn anthropic_citations_extracts_from_additional_params() {
6642 let text = message::Text {
6643 text: "the grass is green".into(),
6644 additional_params: crate::message::AdditionalParams::try_from_value(json!({
6645 "citations": [{
6646 "type": "char_location",
6647 "cited_text": "The grass is green.",
6648 "document_index": 0,
6649 "start_char_index": 0,
6650 "end_char_index": 20
6651 }]
6652 }))
6653 .expect("object params"),
6654 };
6655 let citations = anthropic_citations(&text).unwrap();
6656 assert_eq!(citations.len(), 1);
6657 }
6658
6659 #[test]
6660 fn anthropic_citations_returns_empty_when_absent() {
6661 let text = message::Text::new("hello".to_string());
6662 assert!(anthropic_citations(&text).unwrap().is_empty());
6663 }
6664
6665 #[test]
6666 fn content_text_with_citations_survives_assistant_conversion() {
6667 let content = Content::Text {
6668 text: "the grass is green".into(),
6669 citations: vec![Citation::CharLocation(CharLocationCitation {
6670 cited_text: "The grass is green.".into(),
6671 document_index: 0,
6672 document_title: None,
6673 start_char_index: 0,
6674 end_char_index: 20,
6675 })],
6676 cache_control: None,
6677 };
6678 let assistant: message::AssistantContent = content.try_into().unwrap();
6679 let message::AssistantContent::Text(text) = assistant else {
6680 panic!("expected text variant");
6681 };
6682 let recovered = anthropic_citations(&text).unwrap();
6683 assert_eq!(recovered.len(), 1);
6684 }
6685
6686 #[test]
6687 fn provider_text_response_concatenates_text_blocks_without_inserted_newlines() {
6688 let response = CompletionResponse {
6689 content: vec![
6690 Content::Text {
6691 text: "According to the document, ".into(),
6692 citations: Vec::new(),
6693 cache_control: None,
6694 },
6695 Content::Text {
6696 text: "the grass is green".into(),
6697 citations: Vec::new(),
6698 cache_control: None,
6699 },
6700 Content::Text {
6701 text: " and the sky is blue.".into(),
6702 citations: Vec::new(),
6703 cache_control: None,
6704 },
6705 ],
6706 id: "msg_1".into(),
6707 model: "claude-test".into(),
6708 role: "assistant".into(),
6709 stop_reason: Some("end_turn".into()),
6710 stop_sequence: None,
6711 provider_request_id: None,
6712 usage: Usage {
6713 input_tokens: 1,
6714 cache_read_input_tokens: None,
6715 cache_creation_input_tokens: None,
6716 cache_creation: None,
6717 output_tokens: 1,
6718 output_tokens_details: None,
6719 },
6720 };
6721
6722 assert_eq!(
6723 response.get_text_response().as_deref(),
6724 Some("According to the document, the grass is green and the sky is blue.")
6725 );
6726 }
6727
6728 #[test]
6729 fn assistant_text_citations_survive_anthropic_request_conversion() {
6730 let assistant = message::Message::Assistant {
6731 id: None,
6732 content: vec![message::AssistantContent::Text(message::Text {
6733 text: "the grass is green".into(),
6734 additional_params: crate::message::AdditionalParams::try_from_value(json!({
6735 "citations": [{
6736 "type": "char_location",
6737 "cited_text": "The grass is green.",
6738 "document_index": 0,
6739 "start_char_index": 0,
6740 "end_char_index": 20
6741 }]
6742 }))
6743 .expect("object params"),
6744 })],
6745 };
6746
6747 let converted: Message = assistant.try_into().unwrap();
6748 let Some(Content::Text {
6749 citations, text, ..
6750 }) = converted.content.first()
6751 else {
6752 panic!("expected assistant text content");
6753 };
6754
6755 assert_eq!(text, "the grass is green");
6756 assert_eq!(
6757 citations,
6758 &vec![Citation::CharLocation(CharLocationCitation {
6759 cited_text: "The grass is green.".into(),
6760 document_index: 0,
6761 document_title: None,
6762 start_char_index: 0,
6763 end_char_index: 20,
6764 })]
6765 );
6766 }
6767
6768 #[test]
6769 fn assistant_text_invalid_known_citations_are_rejected_for_anthropic_request_conversion() {
6770 let text = message::AssistantContent::Text(message::Text {
6771 text: "bad citation".into(),
6772 additional_params: crate::message::AdditionalParams::try_from_value(json!({
6773 "citations": [{
6774 "type": "char_location",
6775 "cited_text": "bad"
6776 }]
6777 }))
6778 .expect("object params"),
6779 });
6780
6781 let result = anthropic_content_from_assistant_content(text);
6782
6783 assert!(
6784 result.is_err(),
6785 "invalid Anthropic citation metadata should not be silently dropped"
6786 );
6787 }
6788
6789 #[test]
6790 fn document_additional_params_forward_to_anthropic_document() {
6791 let doc = message::UserContent::Document(message::Document {
6792 data: message::DocumentSourceKind::String("Hello world.".into()),
6793 media_type: Some(message::DocumentMediaType::TXT),
6794 additional_params: crate::message::AdditionalParams::try_from_value(json!({
6795 "title": "Doc1",
6796 "context": "ctx",
6797 "citations": { "enabled": true }
6798 }))
6799 .expect("object params"),
6800 });
6801 let msg = message::Message::User { content: vec![doc] };
6802 let converted: Message = msg.try_into().unwrap();
6803 let block = converted.content.first();
6804 let Some(Content::Document {
6805 title,
6806 context,
6807 citations,
6808 ..
6809 }) = block
6810 else {
6811 panic!("expected Content::Document");
6812 };
6813 assert_eq!(title.as_deref(), Some("Doc1"));
6814 assert_eq!(context.as_deref(), Some("ctx"));
6815 assert_eq!(citations, &Some(CitationsConfig { enabled: true }));
6816 }
6817
6818 fn assert_reverse_document_metadata(
6819 source: DocumentSource,
6820 expected_data: DocumentSourceKind,
6821 expected_media_type: Option<message::DocumentMediaType>,
6822 ) -> message::Message {
6823 let provider_message = Message {
6824 role: Role::User,
6825 content: vec![Content::Document {
6826 source,
6827 title: Some("Doc1".into()),
6828 context: Some("ctx".into()),
6829 citations: Some(CitationsConfig { enabled: true }),
6830 cache_control: None,
6831 }],
6832 };
6833
6834 let generic: message::Message = provider_message.try_into().unwrap();
6835 let message::Message::User { content } = &generic else {
6836 panic!("expected generic user message");
6837 };
6838 let Some(message::UserContent::Document(document)) = content.first() else {
6839 panic!("expected generic document");
6840 };
6841
6842 assert_eq!(document.data, expected_data);
6843 assert_eq!(document.media_type, expected_media_type);
6844 let additional_params = document
6845 .additional_params
6846 .as_ref()
6847 .expect("expected Anthropic document metadata");
6848 assert_eq!(additional_params["title"], "Doc1");
6849 assert_eq!(additional_params["context"], "ctx");
6850 assert_eq!(additional_params["citations"]["enabled"], true);
6851
6852 generic
6853 }
6854
6855 #[test]
6856 fn anthropic_document_metadata_survives_reverse_conversion_for_all_sources() {
6857 assert_reverse_document_metadata(
6858 DocumentSource::Text {
6859 data: "Hello world.".into(),
6860 media_type: PlainTextMediaType::Plain,
6861 },
6862 DocumentSourceKind::String("Hello world.".into()),
6863 Some(message::DocumentMediaType::TXT),
6864 );
6865 assert_reverse_document_metadata(
6866 DocumentSource::Base64 {
6867 data: "base64-pdf".into(),
6868 media_type: DocumentFormat::PDF,
6869 },
6870 DocumentSourceKind::String("base64-pdf".into()),
6871 Some(message::DocumentMediaType::PDF),
6872 );
6873 assert_reverse_document_metadata(
6874 DocumentSource::Url {
6875 url: "https://example.com/doc.pdf".into(),
6876 },
6877 DocumentSourceKind::Url("https://example.com/doc.pdf".into()),
6878 None,
6879 );
6880 assert_reverse_document_metadata(
6881 DocumentSource::File {
6882 file_id: "file_abc".into(),
6883 },
6884 DocumentSourceKind::FileId("file_abc".into()),
6885 None,
6886 );
6887 }
6888
6889 #[test]
6890 fn anthropic_document_metadata_survives_reverse_round_trip() {
6891 let provider_message = Message {
6892 role: Role::User,
6893 content: vec![Content::Document {
6894 source: DocumentSource::Text {
6895 data: "Hello world.".into(),
6896 media_type: PlainTextMediaType::Plain,
6897 },
6898 title: Some("Doc1".into()),
6899 context: Some("ctx".into()),
6900 citations: Some(CitationsConfig { enabled: true }),
6901 cache_control: None,
6902 }],
6903 };
6904
6905 let generic: message::Message = provider_message.try_into().unwrap();
6906 let message::Message::User { content } = &generic else {
6907 panic!("expected generic user message");
6908 };
6909 let Some(message::UserContent::Document(document)) = content.first() else {
6910 panic!("expected generic document");
6911 };
6912 let additional_params = document
6913 .additional_params
6914 .as_ref()
6915 .expect("expected Anthropic document metadata");
6916 assert_eq!(additional_params["title"], "Doc1");
6917 assert_eq!(additional_params["context"], "ctx");
6918 assert_eq!(additional_params["citations"]["enabled"], true);
6919
6920 let round_trip: Message = generic.try_into().unwrap();
6921 let Some(Content::Document {
6922 title,
6923 context,
6924 citations,
6925 ..
6926 }) = round_trip.content.first()
6927 else {
6928 panic!("expected Anthropic document");
6929 };
6930 assert_eq!(title.as_deref(), Some("Doc1"));
6931 assert_eq!(context.as_deref(), Some("ctx"));
6932 assert_eq!(citations, &Some(CitationsConfig { enabled: true }));
6933 }
6934
6935 #[test]
6936 fn anthropic_document_empty_metadata_stays_none_on_reverse_conversion() {
6937 let provider_message = Message {
6938 role: Role::User,
6939 content: vec![Content::Document {
6940 source: DocumentSource::Text {
6941 data: "Hello world.".into(),
6942 media_type: PlainTextMediaType::Plain,
6943 },
6944 title: None,
6945 context: None,
6946 citations: None,
6947 cache_control: None,
6948 }],
6949 };
6950
6951 let generic: message::Message = provider_message.try_into().unwrap();
6952 let message::Message::User { content } = &generic else {
6953 panic!("expected generic user message");
6954 };
6955 let Some(message::UserContent::Document(document)) = content.first() else {
6956 panic!("expected generic document");
6957 };
6958
6959 assert_eq!(document.additional_params, None);
6960 }
6961
6962 #[tokio::test]
6963 async fn completion_http_non_success_preserves_status_and_body() {
6964 use crate::client::CompletionClient;
6965 use crate::completion::CompletionModel as _;
6966 use crate::providers::anthropic::Client;
6967 use crate::test_utils::RecordingHttpClient;
6968
6969 let body = r#"{"type":"error","error":{"type":"overloaded_error","message":"slow down"}}"#;
6970 let http_client =
6971 RecordingHttpClient::with_error_response(http::StatusCode::TOO_MANY_REQUESTS, body);
6972 let client = Client::builder()
6973 .api_key("test-key")
6974 .http_client(http_client)
6975 .build()
6976 .expect("build client");
6977 let model = client.completion_model(CLAUDE_SONNET_4_6);
6978 let request = model.completion_request("hello").build();
6979
6980 let error = model
6981 .completion(request)
6982 .await
6983 .expect_err("completion should fail with non-success status");
6984
6985 assert!(matches!(error, CompletionError::ProviderResponse(_)));
6989 assert_eq!(error.provider_request_id(), None);
6990 assert_eq!(
6991 error.provider_response_status(),
6992 Some(http::StatusCode::TOO_MANY_REQUESTS)
6993 );
6994 assert_eq!(error.provider_response_body(), Some(body));
6995 }
6996
6997 #[tokio::test]
6998 async fn completion_2xx_error_envelope_preserves_status_and_body() {
6999 use crate::client::CompletionClient;
7000 use crate::completion::CompletionModel as _;
7001 use crate::providers::anthropic::Client;
7002 use crate::test_utils::RecordingHttpClient;
7003
7004 let body = r#"{"type":"error","message":"model overloaded"}"#;
7009 let http_client = RecordingHttpClient::new(body); let client = Client::builder()
7011 .api_key("test-key")
7012 .http_client(http_client)
7013 .build()
7014 .expect("build client");
7015 let model = client.completion_model(CLAUDE_SONNET_4_6);
7016 let request = model.completion_request("hello").build();
7017
7018 let error = model
7019 .completion(request)
7020 .await
7021 .expect_err("completion should fail with provider error envelope");
7022
7023 match &error {
7024 CompletionError::ProviderResponse(stored) => {
7025 assert_eq!(stored.body, body);
7026 assert_eq!(stored.status, Some(http::StatusCode::OK));
7027 assert_eq!(error.provider_response_body(), Some(body));
7028 assert_eq!(error.provider_response_status(), Some(http::StatusCode::OK));
7029 }
7030 other => panic!("expected ProviderResponse, got {other:?}"),
7031 }
7032 }
7033
7034 #[tokio::test]
7035 async fn completion_streaming_http_non_success_preserves_status_and_body() {
7036 use crate::client::CompletionClient;
7037 use crate::completion::CompletionModel as _;
7038 use crate::providers::anthropic::Client;
7039 use crate::test_utils::HttpErrorStreamingClient;
7040 use futures::StreamExt;
7041
7042 let body = r#"{"type":"error","error":{"type":"overloaded_error","message":"slow down"}}"#;
7043 let http_client =
7044 HttpErrorStreamingClient::new(http::StatusCode::SERVICE_UNAVAILABLE, body);
7045 let client = Client::builder()
7046 .api_key("test-key")
7047 .http_client(http_client)
7048 .build()
7049 .expect("build client");
7050 let model = client.completion_model(CLAUDE_SONNET_4_6);
7051 let request = model.completion_request("hello").build();
7052
7053 let mut stream = model.stream(request).await.expect("stream should start");
7054
7055 let error = loop {
7057 match stream.next().await {
7058 Some(Ok(_)) => continue,
7059 Some(Err(error)) => break error,
7060 None => panic!("stream ended without yielding the transport error"),
7061 }
7062 };
7063
7064 assert!(matches!(error, CompletionError::HttpError(_)));
7068 assert_eq!(
7069 error.provider_response_status(),
7070 Some(http::StatusCode::SERVICE_UNAVAILABLE)
7071 );
7072 assert_eq!(error.provider_response_body(), Some(body));
7073
7074 assert!(stream.next().await.is_none());
7077 assert!(
7078 stream.response.is_none(),
7079 "a stream cut short by a transport error must not synthesize a terminal record"
7080 );
7081 }
7082
7083 #[test]
7084 fn coerce_tool_input_normalizes_non_object_arguments() {
7085 use serde_json::json;
7086
7087 assert_eq!(
7089 coerce_tool_input(json!({"q": "rust", "n": 3})),
7090 json!({"q": "rust", "n": 3})
7091 );
7092
7093 assert_eq!(
7095 coerce_tool_input(json!("{\"q\":\"rust\"}")),
7096 json!({"q": "rust"})
7097 );
7098
7099 assert_eq!(coerce_tool_input(json!("not json")), json!({}));
7103 assert_eq!(coerce_tool_input(json!("[1,2,3]")), json!({}));
7104 assert_eq!(coerce_tool_input(json!(null)), json!({}));
7105 assert_eq!(coerce_tool_input(json!([1, 2, 3])), json!({}));
7106 assert_eq!(coerce_tool_input(json!(42)), json!({}));
7107 assert_eq!(coerce_tool_input(json!(true)), json!({}));
7108 }
7109
7110 #[test]
7121 fn url_pdf_with_or_without_media_type_converts_to_url_document_source() {
7122 let pdf_url = "https://example.com/resume.pdf";
7123
7124 for media_type in [Some(message::DocumentMediaType::PDF), None] {
7125 let msg = message::Message::User {
7126 content: vec![message::UserContent::document_url(pdf_url, media_type)],
7127 };
7128
7129 let converted = Message::try_from(msg).expect("URL PDF should convert");
7130 let json = serde_json::to_value(&converted).expect("message should serialize");
7131
7132 assert_eq!(
7133 json.pointer("/content/0/source"),
7134 Some(&json!({ "type": "url", "url": pdf_url })),
7135 "URL PDF should map to a url document source: {json:#}"
7136 );
7137 }
7138 }
7139
7140 mod raw_capture {
7149 use super::*;
7150 use crate::client::CompletionClient;
7151 use crate::completion::CompletionModel as _;
7152 use crate::providers::anthropic::Client;
7153 use crate::test_utils::RecordingHttpClient;
7154
7155 const REQUEST_ID: &str = "req_unit_anthropic_0001";
7156
7157 const BODY: &str = r#"{
7161 "id": "msg_raw_1",
7162 "type": "message",
7163 "role": "assistant",
7164 "model": "claude-sonnet-4-6",
7165 "content": [{"type": "text", "text": "hello"}],
7166 "stop_reason": "stop_sequence",
7167 "stop_sequence": "alpha",
7168 "usage": {"input_tokens": 7, "output_tokens": 2}
7169 }"#;
7170
7171 fn model() -> CompletionModel<RecordingHttpClient> {
7172 let mut headers = http::HeaderMap::new();
7173 headers.insert("request-id", http::HeaderValue::from_static(REQUEST_ID));
7174 let http_client = RecordingHttpClient::with_error_response_headers(
7175 http::StatusCode::OK,
7176 BODY,
7177 headers,
7178 );
7179 let client = Client::builder()
7180 .api_key("test-key")
7181 .http_client(http_client)
7182 .build()
7183 .expect("build client");
7184 client.completion_model(CLAUDE_SONNET_4_6)
7185 }
7186
7187 #[tokio::test]
7196 async fn completion_captures_raw_that_round_trips_into_the_wire_type() {
7197 let model = model();
7198
7199 let response = model
7200 .completion(model.completion_request("hello").build())
7201 .await
7202 .expect("completion");
7203
7204 let raw = &response.raw;
7205 let typed: CompletionResponse =
7206 serde_json::from_value(raw.clone()).expect("raw must deserialize");
7207 assert_eq!(
7208 serde_json::to_value(&typed).expect("re-serialize"),
7209 *raw,
7210 "the capture must be exactly what the wire type serializes to"
7211 );
7212 assert_eq!(typed.stop_sequence.as_deref(), Some("alpha"));
7213 assert_eq!(typed.provider_request_id.as_deref(), Some(REQUEST_ID));
7214 assert_eq!(raw["stop_sequence"], "alpha");
7215
7216 let renormalized = typed
7217 .normalize(<crate::providers::anthropic::client::AnthropicExt as AnthropicCompatibleProvider>::PROVIDER_NAME)
7218 .expect("re-normalize the capture");
7219 assert_eq!(response.identity(), renormalized.identity());
7220 assert_eq!(response.finish_reason(), renormalized.finish_reason());
7221 assert_eq!(response.model, renormalized.model);
7222 assert_eq!(response.usage, renormalized.usage);
7223 assert_eq!(response.choice, renormalized.choice);
7224 assert_eq!(
7225 response.finish_reason(),
7226 Some(completion::FinishReason::Stop)
7227 );
7228 assert_eq!(response.provider_request_id.as_deref(), Some(REQUEST_ID));
7229 }
7230
7231 #[tokio::test]
7236 async fn raw_completion_then_normalize_reproduces_completion() {
7237 let model = model();
7238
7239 let raw = model
7240 .raw_completion(model.completion_request("hello").build())
7241 .await
7242 .expect("typed route");
7243 assert_eq!(raw.provider_request_id.as_deref(), Some(REQUEST_ID));
7244 let reassembled = raw
7245 .normalize(<crate::providers::anthropic::client::AnthropicExt as AnthropicCompatibleProvider>::PROVIDER_NAME)
7246 .expect("normalize");
7247
7248 let normalized = model
7249 .completion(model.completion_request("hello").build())
7250 .await
7251 .expect("normalized route");
7252
7253 assert_eq!(reassembled.identity(), normalized.identity());
7254 assert_eq!(reassembled.finish_reason(), normalized.finish_reason());
7255 assert_eq!(reassembled.model, normalized.model);
7256 assert_eq!(reassembled.usage, normalized.usage);
7257 assert_eq!(reassembled.provider_request_id.as_deref(), Some(REQUEST_ID));
7258 assert_eq!(normalized.provider_request_id.as_deref(), Some(REQUEST_ID));
7259 }
7260 }
7261}