1use std::collections::{BTreeSet, HashMap};
5
6use agent_framework_core::tools::{ToolDefinition, ToolKind};
7use agent_framework_core::types::{
8 Annotation, ChatOptions, ChatResponse, Content, DataContent, FinishReason, FunctionArguments,
9 FunctionCallContent, FunctionResultContent, HostedFileContent, Message, ResponseFormat, Role,
10 TextContent, TextReasoningContent, TextSpanRegion, ToolMode, UriContent, UsageContent,
11 UsageDetails,
12};
13use serde_json::{json, Map, Value};
14
15pub const DEFAULT_BETA_FLAGS: &[&str] = &["mcp-client-2025-04-04", "code-execution-2025-08-25"];
24
25const ADDITIONAL_BETA_FLAGS_KEY: &str = "additional_beta_flags";
28
29pub(crate) fn compute_beta_flags(
40 options: &mut ChatOptions,
41 client_additional: &[String],
42) -> Vec<String> {
43 let mut flags: BTreeSet<String> = DEFAULT_BETA_FLAGS.iter().map(|s| s.to_string()).collect();
44 flags.extend(client_additional.iter().cloned());
45 if let Some(value) = options
46 .additional_properties
47 .remove(ADDITIONAL_BETA_FLAGS_KEY)
48 {
49 if let Some(arr) = value.as_array() {
50 flags.extend(arr.iter().filter_map(Value::as_str).map(str::to_string));
51 }
52 }
53 flags.into_iter().collect()
54}
55
56pub fn build_request(
58 messages: &[Message],
59 options: &ChatOptions,
60 model: &str,
61 max_tokens: u32,
62 stream: bool,
63) -> Value {
64 let mut body = Map::new();
65 body.insert("model".into(), json!(model));
66 body.insert("max_tokens".into(), json!(max_tokens));
67 fill_request_body(body, messages, options, stream)
68}
69
70pub fn build_cloud_request(
82 messages: &[Message],
83 options: &ChatOptions,
84 max_tokens: u32,
85 stream: bool,
86 anthropic_version: &str,
87) -> Value {
88 let mut body = Map::new();
89 body.insert("anthropic_version".into(), json!(anthropic_version));
90 body.insert("max_tokens".into(), json!(max_tokens));
91 fill_request_body(body, messages, options, stream)
92}
93
94fn fill_request_body(
100 mut body: Map<String, Value>,
101 messages: &[Message],
102 options: &ChatOptions,
103 stream: bool,
104) -> Value {
105 let (system, rest) = extract_system(messages, options.instructions.as_deref());
106 let system = append_response_format_instructions(system, options.response_format.as_ref());
107 if let Some(system) = system {
108 body.insert("system".into(), json!(system));
109 }
110 body.insert("messages".into(), json!(messages_to_anthropic(rest)));
111
112 if let Some(t) = options.temperature {
113 body.insert("temperature".into(), json!(t));
114 }
115 if let Some(t) = options.top_p {
116 body.insert("top_p".into(), json!(t));
117 }
118 if let Some(stop) = &options.stop {
119 body.insert("stop_sequences".into(), json!(stop));
120 }
121
122 if !options.tools.is_empty() {
123 let (tools, mcp_servers) = tools_to_anthropic(&options.tools);
124 if !tools.is_empty() {
125 body.insert("tools".into(), json!(tools));
126 }
127 if !mcp_servers.is_empty() {
128 body.insert("mcp_servers".into(), json!(mcp_servers));
129 }
130 }
131 if let Some(tool_choice) = &options.tool_choice {
132 body.insert(
133 "tool_choice".into(),
134 tool_choice_to_anthropic(tool_choice, options.allow_multiple_tool_calls),
135 );
136 }
137
138 for (k, v) in &options.additional_properties {
139 body.entry(k.clone()).or_insert_with(|| v.clone());
140 }
141
142 if stream {
143 body.insert("stream".into(), json!(true));
144 }
145 Value::Object(body)
146}
147
148pub fn extract_system<'a>(
156 messages: &'a [Message],
157 options_instructions: Option<&str>,
158) -> (Option<String>, &'a [Message]) {
159 let mut parts = Vec::new();
160 if let Some(instr) = options_instructions {
161 if !instr.is_empty() {
162 parts.push(instr.to_string());
163 }
164 }
165 let mut rest = messages;
166 if let Some(first) = messages.first() {
167 if first.role == Role::system() {
168 let text = first.text();
169 if !text.is_empty() {
170 parts.push(text);
171 }
172 rest = &messages[1..];
173 }
174 }
175 if parts.is_empty() {
176 (None, rest)
177 } else {
178 (Some(parts.join("\n\n")), rest)
179 }
180}
181
182fn append_response_format_instructions(
204 system: Option<String>,
205 format: Option<&ResponseFormat>,
206) -> Option<String> {
207 let instruction = match format {
208 None | Some(ResponseFormat::Text) => return system,
209 Some(ResponseFormat::JsonObject) => {
210 "Respond only with a single valid JSON object. Do not include any \
211 explanation, preamble, or markdown code fences before or after the JSON."
212 .to_string()
213 }
214 Some(ResponseFormat::JsonSchema { name, schema, .. }) => {
215 let pretty =
216 serde_json::to_string_pretty(schema).unwrap_or_else(|_| schema.to_string());
217 format!(
218 "Respond only with a single valid JSON object that conforms exactly to \
219 the following JSON Schema (named \"{name}\"). Do not include any \
220 explanation, preamble, or markdown code fences before or after the JSON.\n\n\
221 JSON Schema:\n{pretty}"
222 )
223 }
224 };
225 Some(match system {
226 Some(existing) if !existing.is_empty() => format!("{existing}\n\n{instruction}"),
227 _ => instruction,
228 })
229}
230
231pub fn messages_to_anthropic(messages: &[Message]) -> Vec<Value> {
237 let mut out = Vec::with_capacity(messages.len());
238 for msg in messages {
239 let role = if msg.role == Role::assistant() {
240 "assistant"
241 } else {
242 "user"
243 };
244 let mut blocks: Vec<Value> = Vec::new();
245 for content in &msg.contents {
246 match content {
247 Content::Text(t) => blocks.push(json!({ "type": "text", "text": t.text })),
248 Content::TextReasoning(t) => {
249 blocks.push(json!({ "type": "thinking", "thinking": t.text }))
250 }
251 Content::FunctionCall(fc) => blocks.push(function_call_block(fc)),
252 Content::FunctionResult(fr) => blocks.push(function_result_block(fr)),
253 Content::Data(dc) => {
254 if let Some(block) = image_block_from_data(dc) {
255 blocks.push(block);
256 }
257 }
258 Content::Uri(uc) => {
259 if let Some(block) = image_block_from_uri(uc) {
260 blocks.push(block);
261 }
262 }
263 _ => {}
264 }
265 }
266 if blocks.is_empty() {
267 continue;
269 }
270 out.push(json!({ "role": role, "content": blocks }));
271 }
272 normalize_role_alternation(out)
273}
274
275fn normalize_role_alternation(messages: Vec<Value>) -> Vec<Value> {
282 let mut out: Vec<Value> = Vec::with_capacity(messages.len());
283 for msg in messages {
284 match out.last_mut() {
285 Some(prev) if prev["role"] == msg["role"] => {
286 if let (Some(prev_blocks), Some(new_blocks)) =
287 (prev["content"].as_array_mut(), msg["content"].as_array())
288 {
289 prev_blocks.extend(new_blocks.iter().cloned());
290 }
291 }
292 _ => out.push(msg),
293 }
294 }
295 if out.first().map(|m| m["role"] == "assistant") == Some(true) {
296 out.insert(
297 0,
298 json!({
299 "role": "user",
300 "content": [{ "type": "text", "text": "(continuing the conversation)" }]
301 }),
302 );
303 }
304 out
305}
306
307fn function_call_block(fc: &FunctionCallContent) -> Value {
308 let input = fc.parse_arguments().unwrap_or_default();
309 json!({
310 "type": "tool_use",
311 "id": fc.call_id,
312 "name": fc.name,
313 "input": Value::Object(input.into_iter().collect()),
314 })
315}
316
317fn function_result_block(fr: &FunctionResultContent) -> Value {
318 let mut block = Map::new();
319 block.insert("type".into(), json!("tool_result"));
320 block.insert("tool_use_id".into(), json!(fr.call_id));
321 block.insert("content".into(), json!(result_text(fr)));
322 if fr.exception.is_some() {
323 block.insert("is_error".into(), json!(true));
324 }
325 Value::Object(block)
326}
327
328fn result_text(fr: &FunctionResultContent) -> String {
329 if let Some(exc) = &fr.exception {
330 return exc.clone();
331 }
332 match &fr.result {
333 Some(Value::String(s)) => s.clone(),
334 Some(v) => v.to_string(),
335 None => String::new(),
336 }
337}
338
339fn image_block_from_data(dc: &DataContent) -> Option<Value> {
345 let is_image = dc
346 .media_type
347 .as_deref()
348 .map(is_image_media_type)
349 .unwrap_or_else(|| dc.uri.starts_with("data:image/"));
350 if !is_image {
351 return None;
352 }
353 let (parsed_media_type, data) = split_data_uri(&dc.uri)?;
354 let media_type = dc.media_type.clone().unwrap_or(parsed_media_type);
355 Some(json!({
356 "type": "image",
357 "source": { "type": "base64", "media_type": media_type, "data": data }
358 }))
359}
360
361fn image_block_from_uri(uc: &UriContent) -> Option<Value> {
362 if !is_image_media_type(&uc.media_type) {
363 return None;
364 }
365 Some(json!({ "type": "image", "source": { "type": "url", "url": uc.uri } }))
366}
367
368fn split_data_uri(uri: &str) -> Option<(String, String)> {
369 let rest = uri.strip_prefix("data:")?;
370 let (meta, data) = rest.split_once(',')?;
371 let media_type = meta
372 .split(';')
373 .next()
374 .filter(|s| !s.is_empty())
375 .unwrap_or("application/octet-stream")
376 .to_string();
377 Some((media_type, data.to_string()))
378}
379
380fn is_image_media_type(media_type: &str) -> bool {
381 media_type.starts_with("image/")
382}
383
384pub fn tools_to_anthropic(tools: &[ToolDefinition]) -> (Vec<Value>, Vec<Value>) {
419 let mut tool_list = Vec::new();
420 let mut mcp_servers = Vec::new();
421 for t in tools {
422 match &t.kind {
423 ToolKind::Function => {
424 tool_list.push(json!({
425 "type": "custom",
426 "name": t.name,
427 "description": t.description,
428 "input_schema": t.parameters,
429 }));
430 }
431 ToolKind::HostedWebSearch => {
432 let mut search_tool = Map::new();
433 search_tool.insert("type".into(), json!("web_search_20250305"));
434 search_tool.insert("name".into(), json!("web_search"));
435 if let Some(max_uses) = t.parameters.get("max_uses") {
436 search_tool.insert("max_uses".into(), max_uses.clone());
437 }
438 if let Some(user_location) = t.parameters.get("user_location") {
439 search_tool.insert("user_location".into(), user_location.clone());
440 }
441 tool_list.push(Value::Object(search_tool));
442 }
443 ToolKind::HostedCodeInterpreter => {
444 tool_list.push(json!({
445 "type": "code_execution_20250825",
446 "name": "code_execution",
447 }));
448 }
449 ToolKind::HostedMcp { url, allowed_tools } => {
450 let mut server_def = Map::new();
451 server_def.insert("type".into(), json!("url"));
452 server_def.insert("name".into(), json!(t.name));
453 server_def.insert("url".into(), json!(url));
454 if let Some(allowed) = allowed_tools {
455 if !allowed.is_empty() {
456 server_def.insert(
457 "tool_configuration".into(),
458 json!({ "allowed_tools": allowed }),
459 );
460 }
461 }
462 if let Some(auth) = t
465 .parameters
466 .get("headers")
467 .and_then(|h| h.as_object())
468 .and_then(|obj| {
469 obj.iter()
470 .find(|(k, _)| k.eq_ignore_ascii_case("authorization"))
471 .and_then(|(_, v)| v.as_str())
472 })
473 {
474 server_def.insert("authorization_token".into(), json!(auth));
475 }
476 mcp_servers.push(Value::Object(server_def));
477 }
478 ToolKind::HostedFileSearch { .. } => {
479 tracing::warn!(
480 tool = %t.name,
481 "Anthropic: hosted file-search tools are not supported by the Anthropic Messages API; skipping"
482 );
483 }
484 ToolKind::HostedImageGeneration => {
485 tracing::warn!(
486 tool = %t.name,
487 "Anthropic: hosted image-generation tools are not supported by the Anthropic Messages API; skipping"
488 );
489 }
490 }
491 }
492 (tool_list, mcp_servers)
493}
494
495fn tool_choice_to_anthropic(mode: &ToolMode, allow_multiple: Option<bool>) -> Value {
496 let mut obj = Map::new();
497 match mode {
498 ToolMode::Auto => {
499 obj.insert("type".into(), json!("auto"));
500 }
501 ToolMode::Required(Some(name)) => {
502 obj.insert("type".into(), json!("tool"));
503 obj.insert("name".into(), json!(name));
504 }
505 ToolMode::Required(None) => {
506 obj.insert("type".into(), json!("any"));
507 }
508 ToolMode::None => {
509 obj.insert("type".into(), json!("none"));
510 }
511 }
512 if !matches!(mode, ToolMode::None) {
513 if let Some(allow) = allow_multiple {
514 obj.insert("disable_parallel_tool_use".into(), json!(!allow));
515 }
516 }
517 Value::Object(obj)
518}
519
520pub fn parse_response(value: &Value) -> ChatResponse {
522 let mut response = ChatResponse {
523 response_id: value.get("id").and_then(Value::as_str).map(String::from),
524 model: value.get("model").and_then(Value::as_str).map(String::from),
525 ..Default::default()
526 };
527
528 let contents = value
529 .get("content")
530 .and_then(Value::as_array)
531 .map(|blocks| parse_content_blocks(blocks))
532 .unwrap_or_default();
533
534 let mut message = Message::with_contents(Role::assistant(), contents);
535 message.message_id = response.response_id.clone();
536 response.messages.push(message);
537
538 if let Some(reason) = value.get("stop_reason").and_then(Value::as_str) {
539 response.finish_reason = Some(map_stop_reason(reason));
540 }
541 if let Some(usage) = value.get("usage") {
542 response.usage_details = Some(parse_usage(usage));
543 }
544 response
545}
546
547pub(crate) fn parse_content_blocks(blocks: &[Value]) -> Vec<Content> {
599 let mut out = Vec::with_capacity(blocks.len());
600 for block in blocks {
601 let Some(block_type) = block.get("type").and_then(Value::as_str) else {
602 continue;
603 };
604 match block_type {
605 "text" => {
606 let text = block
607 .get("text")
608 .and_then(Value::as_str)
609 .unwrap_or_default();
610 out.push(Content::Text(TextContent {
611 text: text.to_string(),
612 annotations: parse_citations(block),
613 }));
614 }
615 "tool_use" | "mcp_tool_use" | "server_tool_use" => {
616 let id = block
617 .get("id")
618 .and_then(Value::as_str)
619 .unwrap_or_default()
620 .to_string();
621 let name = block
622 .get("name")
623 .and_then(Value::as_str)
624 .unwrap_or_default()
625 .to_string();
626 let input = match block.get("input") {
627 Some(Value::Object(m)) => m.clone().into_iter().collect(),
628 _ => HashMap::new(),
629 };
630 out.push(Content::FunctionCall(FunctionCallContent::new(
631 id,
632 name,
633 Some(FunctionArguments::Object(input)),
634 )));
635 }
636 "mcp_tool_result" => {
637 let call_id = tool_use_id(block);
638 let result = match block.get("content") {
639 Some(Value::Array(items)) => {
640 serde_json::to_value(parse_content_blocks(items)).unwrap_or(Value::Null)
641 }
642 Some(other) => other.clone(),
643 None => Value::Null,
644 };
645 out.push(Content::FunctionResult(FunctionResultContent::new(
646 call_id,
647 Some(result),
648 )));
649 }
650 "web_search_tool_result" | "web_fetch_tool_result" => {
651 let call_id = tool_use_id(block);
652 let result = block.get("content").cloned().unwrap_or(Value::Null);
653 out.push(Content::FunctionResult(FunctionResultContent::new(
654 call_id,
655 Some(result),
656 )));
657 }
658 "code_execution_tool_result"
659 | "bash_code_execution_tool_result"
660 | "text_editor_code_execution_tool_result" => {
661 let call_id = tool_use_id(block);
662 let nested = block.get("content");
663 if let Some(nc) = nested {
664 let nc_type = nc.get("type").and_then(Value::as_str);
665 if matches!(
666 nc_type,
667 Some("bash_code_execution_result") | Some("code_execution_result")
668 ) {
669 if let Some(items) = nc.get("content").and_then(Value::as_array) {
670 for item in items {
671 if let Some(file_id) = item.get("file_id").and_then(Value::as_str) {
672 out.push(Content::HostedFile(HostedFileContent {
673 file_id: file_id.to_string(),
674 }));
675 }
676 }
677 }
678 }
679 }
680 out.push(Content::FunctionResult(FunctionResultContent::new(
681 call_id,
682 Some(nested.cloned().unwrap_or(Value::Null)),
683 )));
684 }
685 "thinking" => {
686 out.push(Content::TextReasoning(TextReasoningContent {
687 text: block
688 .get("thinking")
689 .and_then(Value::as_str)
690 .unwrap_or_default()
691 .to_string(),
692 annotations: None,
693 ..Default::default()
694 }));
695 }
696 other => {
697 tracing::debug!(block_type = %other, "Anthropic: ignoring unsupported content block type");
698 }
699 }
700 }
701 out
702}
703
704fn tool_use_id(block: &Value) -> String {
706 block
707 .get("tool_use_id")
708 .and_then(Value::as_str)
709 .unwrap_or_default()
710 .to_string()
711}
712
713pub(crate) fn parse_citations(block: &Value) -> Option<Vec<Annotation>> {
745 let citations = block.get("citations").and_then(Value::as_array)?;
746 if citations.is_empty() {
747 return None;
748 }
749 let mut annotations = Vec::with_capacity(citations.len());
750 for citation in citations {
751 let mut cit = Annotation::default();
752 let str_field = |key: &str| {
757 citation
758 .get(key)
759 .and_then(Value::as_str)
760 .map(str::to_string)
761 };
762 let truthy_str = |key: &str| {
766 citation
767 .get(key)
768 .and_then(Value::as_str)
769 .filter(|s| !s.is_empty())
770 .map(str::to_string)
771 };
772 match citation.get("type").and_then(Value::as_str) {
773 Some("char_location") => {
774 cit.title = str_field("title");
777 cit.snippet = str_field("cited_text");
778 cit.file_id = truthy_str("file_id");
779 cit.annotated_regions = Some(vec![TextSpanRegion {
780 start_index: citation.get("start_char_index").and_then(Value::as_i64),
781 end_index: citation.get("end_char_index").and_then(Value::as_i64),
782 }]);
783 }
784 Some("page_location") => {
785 cit.title = str_field("document_title");
786 cit.snippet = str_field("cited_text");
787 cit.file_id = truthy_str("file_id");
788 cit.annotated_regions = Some(vec![TextSpanRegion {
789 start_index: citation.get("start_page_number").and_then(Value::as_i64),
790 end_index: citation.get("end_page_number").and_then(Value::as_i64),
791 }]);
792 }
793 Some("content_block_location") => {
794 cit.title = str_field("document_title");
795 cit.snippet = str_field("cited_text");
796 cit.file_id = truthy_str("file_id");
797 cit.annotated_regions = Some(vec![TextSpanRegion {
798 start_index: citation.get("start_block_index").and_then(Value::as_i64),
799 end_index: citation.get("end_block_index").and_then(Value::as_i64),
800 }]);
801 }
802 Some("web_search_result_location") => {
803 cit.title = str_field("title");
804 cit.snippet = str_field("cited_text");
805 cit.url = str_field("url");
806 }
807 Some("search_result_location") => {
808 cit.title = str_field("title");
809 cit.snippet = str_field("cited_text");
810 cit.url = str_field("source");
811 cit.annotated_regions = Some(vec![TextSpanRegion {
812 start_index: citation.get("start_block_index").and_then(Value::as_i64),
813 end_index: citation.get("end_block_index").and_then(Value::as_i64),
814 }]);
815 }
816 other => {
817 tracing::debug!(
818 citation_type = ?other,
819 "Anthropic: unknown citation type encountered"
820 );
821 }
822 }
823 annotations.push(cit);
824 }
825 if annotations.is_empty() {
826 None
827 } else {
828 Some(annotations)
829 }
830}
831
832pub(crate) fn map_stop_reason(reason: &str) -> FinishReason {
834 match reason {
835 "end_turn" | "stop_sequence" => FinishReason::stop(),
836 "max_tokens" => FinishReason::new(FinishReason::LENGTH),
837 "tool_use" => FinishReason::tool_calls(),
838 "refusal" => FinishReason::new(FinishReason::CONTENT_FILTER),
839 "pause_turn" => FinishReason::stop(),
840 other => FinishReason::new(other),
841 }
842}
843
844pub(crate) fn parse_usage(usage: &Value) -> UsageDetails {
847 let mut details = UsageDetails {
848 input_token_count: usage.get("input_tokens").and_then(Value::as_u64),
849 output_token_count: usage.get("output_tokens").and_then(Value::as_u64),
850 cache_creation_input_token_count: usage
851 .get("cache_creation_input_tokens")
852 .and_then(Value::as_u64),
853 cache_read_input_token_count: usage.get("cache_read_input_tokens").and_then(Value::as_u64),
854 ..Default::default()
855 };
856 if let (Some(i), Some(o)) = (details.input_token_count, details.output_token_count) {
857 details.total_token_count = Some(i + o);
858 }
859 details
860}
861
862pub(crate) fn parse_message_start_usage(usage: &Value) -> Option<UsageContent> {
870 let details = UsageDetails {
871 input_token_count: usage.get("input_tokens").and_then(Value::as_u64),
872 cache_creation_input_token_count: usage
873 .get("cache_creation_input_tokens")
874 .and_then(Value::as_u64),
875 cache_read_input_token_count: usage.get("cache_read_input_tokens").and_then(Value::as_u64),
876 ..Default::default()
877 };
878 if details.input_token_count.is_none()
879 && details.cache_creation_input_token_count.is_none()
880 && details.cache_read_input_token_count.is_none()
881 {
882 return None;
883 }
884 Some(UsageContent { details })
885}
886
887#[derive(Debug, Default)]
908pub(crate) struct StreamUsageAccumulator {
909 emitted: UsageDetails,
910}
911
912impl StreamUsageAccumulator {
913 pub(crate) fn increment(&mut self, cumulative: &UsageDetails) -> UsageDetails {
916 fn delta(emitted: &mut Option<u64>, cumulative: Option<u64>) -> Option<u64> {
917 let total = cumulative?;
918 let previous = emitted.unwrap_or(0);
919 let increment = total.saturating_sub(previous);
925 *emitted = Some(total.max(previous));
926 Some(increment)
927 }
928
929 let mut out = UsageDetails {
930 input_token_count: delta(
931 &mut self.emitted.input_token_count,
932 cumulative.input_token_count,
933 ),
934 output_token_count: delta(
935 &mut self.emitted.output_token_count,
936 cumulative.output_token_count,
937 ),
938 cache_creation_input_token_count: delta(
939 &mut self.emitted.cache_creation_input_token_count,
940 cumulative.cache_creation_input_token_count,
941 ),
942 cache_read_input_token_count: delta(
943 &mut self.emitted.cache_read_input_token_count,
944 cumulative.cache_read_input_token_count,
945 ),
946 reasoning_output_token_count: delta(
947 &mut self.emitted.reasoning_output_token_count,
948 cumulative.reasoning_output_token_count,
949 ),
950 ..Default::default()
951 };
952 for (key, total) in &cumulative.additional_counts {
953 let emitted = self
954 .emitted
955 .additional_counts
956 .entry(key.clone())
957 .or_insert(0);
958 out.additional_counts
959 .insert(key.clone(), total.saturating_sub(*emitted));
960 *emitted = (*total).max(*emitted);
962 }
963 out.total_token_count = match (out.input_token_count, out.output_token_count) {
967 (None, None) => None,
968 (i, o) => Some(i.unwrap_or(0) + o.unwrap_or(0)),
969 };
970 out
971 }
972}
973
974#[cfg(test)]
975mod tests {
976 use super::*;
977
978 fn universal_content_samples() -> Vec<Content> {
982 use agent_framework_core::types::{
983 DataContent, FunctionArguments, FunctionCallContent, FunctionResultContent,
984 };
985 vec![
986 Content::text("hello"),
987 Content::FunctionCall(FunctionCallContent::new(
988 "contract_call_1",
989 "get_weather",
990 Some(FunctionArguments::Raw("{\"city\":\"SF\"}".into())),
991 )),
992 Content::FunctionResult(FunctionResultContent::new(
993 "contract_call_1",
994 Some(serde_json::json!("sunny")),
995 )),
996 Content::Data(DataContent::from_bytes(b"png-bytes", "image/png")),
997 Content::Data(DataContent::from_bytes(b"jpeg-bytes", "image/jpeg")),
998 Content::Data(DataContent::from_bytes(b"webp-bytes", "image/webp")),
999 Content::Data(DataContent::from_bytes(b"gif-bytes", "image/gif")),
1000 ]
1001 }
1002
1003 #[test]
1004 fn every_universal_content_produces_an_anthropic_block() {
1005 for content in universal_content_samples() {
1006 assert!(content.renders_on_every_provider(), "sample not universal");
1007 let msg = Message::with_contents(Role::user(), vec![content.clone()]);
1008 let body = build_request(&[msg], &ChatOptions::new(), "claude-test", 128, false);
1009 let blocks = body["messages"][0]["content"]
1010 .as_array()
1011 .map(|a| a.len())
1012 .unwrap_or(0);
1013 assert!(
1014 blocks > 0,
1015 "core claims this renders everywhere but Anthropic emits nothing: {content:?}"
1016 );
1017 }
1018 }
1019
1020 #[test]
1023 fn stream_usage_accumulator_emits_increments_not_cumulative_totals() {
1024 let mut acc = StreamUsageAccumulator::default();
1025
1026 let start = parse_message_start_usage(&json!({
1028 "input_tokens": 10,
1029 "cache_read_input_tokens": 4,
1030 }))
1031 .unwrap();
1032 let first = acc.increment(&start.details);
1033 assert_eq!(first.input_token_count, Some(10));
1034 assert_eq!(first.cache_read_input_token_count, Some(4));
1035
1036 let delta = acc.increment(&parse_usage(&json!({
1039 "input_tokens": 10,
1040 "output_tokens": 25,
1041 "cache_read_input_tokens": 4,
1042 })));
1043 assert_eq!(delta.input_token_count, Some(0));
1044 assert_eq!(delta.output_token_count, Some(25));
1045 assert_eq!(delta.cache_read_input_token_count, Some(0));
1046
1047 let mut aggregated = first;
1049 aggregated.add_assign(&delta);
1050 assert_eq!(aggregated.input_token_count, Some(10));
1051 assert_eq!(aggregated.output_token_count, Some(25));
1052 assert_eq!(aggregated.cache_read_input_token_count, Some(4));
1053 }
1054
1055 #[test]
1056 fn stream_usage_accumulator_handles_several_deltas() {
1057 let mut acc = StreamUsageAccumulator::default();
1058 let mut aggregated = UsageDetails::default();
1059 for output in [5u64, 17, 25] {
1061 let inc = acc.increment(&parse_usage(&json!({
1062 "input_tokens": 10,
1063 "output_tokens": output,
1064 })));
1065 aggregated.add_assign(&inc);
1066 }
1067 assert_eq!(aggregated.input_token_count, Some(10));
1068 assert_eq!(aggregated.output_token_count, Some(25));
1069 }
1070
1071 #[test]
1072 fn stream_usage_accumulator_leaves_absent_counts_untouched() {
1073 let mut acc = StreamUsageAccumulator::default();
1074 acc.increment(&parse_usage(&json!({ "input_tokens": 10 })));
1075 let inc = acc.increment(&parse_usage(&json!({ "output_tokens": 7 })));
1077 assert_eq!(inc.input_token_count, None);
1078 assert_eq!(inc.output_token_count, Some(7));
1079 let inc = acc.increment(&parse_usage(&json!({
1081 "input_tokens": 10,
1082 "output_tokens": 9,
1083 })));
1084 assert_eq!(inc.input_token_count, Some(0));
1085 assert_eq!(inc.output_token_count, Some(2));
1086 }
1087
1088 #[test]
1089 fn stream_usage_accumulator_holds_its_high_water_mark() {
1090 let mut acc = StreamUsageAccumulator::default();
1094 let mut aggregated = UsageDetails::default();
1095 for output in [30u64, 20, 25] {
1096 let inc = acc.increment(&parse_usage(&json!({ "output_tokens": output })));
1097 aggregated.add_assign(&inc);
1098 }
1099 assert_eq!(aggregated.output_token_count, Some(30));
1100 }
1101
1102 #[test]
1103 fn stream_usage_accumulator_holds_its_high_water_mark_for_extra_counts() {
1104 let mut acc = StreamUsageAccumulator::default();
1105 let mut aggregated = UsageDetails::default();
1106 for value in [30u64, 20, 25] {
1107 let mut cumulative = UsageDetails::default();
1108 cumulative.additional_counts.insert("extra".into(), value);
1109 aggregated.add_assign(&acc.increment(&cumulative));
1110 }
1111 assert_eq!(aggregated.additional_counts.get("extra"), Some(&30));
1112 }
1113
1114 #[test]
1115 fn stream_usage_accumulator_clamps_a_decreasing_snapshot() {
1116 let mut acc = StreamUsageAccumulator::default();
1117 acc.increment(&parse_usage(&json!({ "output_tokens": 30 })));
1118 let inc = acc.increment(&parse_usage(&json!({ "output_tokens": 20 })));
1120 assert_eq!(inc.output_token_count, Some(0));
1121 }
1122
1123 use agent_framework_core::tools::ApprovalMode;
1124
1125 fn user(text: &str) -> Message {
1126 Message::user(text)
1127 }
1128
1129 #[test]
1132 fn build_request_simple_text() {
1133 let body = build_request(
1134 &[user("Hello there")],
1135 &ChatOptions::new(),
1136 "claude-x",
1137 4096,
1138 false,
1139 );
1140 assert_eq!(
1141 body,
1142 json!({
1143 "model": "claude-x",
1144 "max_tokens": 4096,
1145 "messages": [
1146 { "role": "user", "content": [{ "type": "text", "text": "Hello there" }] }
1147 ],
1148 })
1149 );
1150 }
1151
1152 #[test]
1153 fn build_request_extracts_leading_system_message() {
1154 let messages = vec![Message::system("Be terse."), user("Hi")];
1155 let body = build_request(&messages, &ChatOptions::new(), "claude-x", 4096, false);
1156 assert_eq!(body["system"], json!("Be terse."));
1157 assert_eq!(
1158 body["messages"],
1159 json!([{ "role": "user", "content": [{ "type": "text", "text": "Hi" }] }])
1160 );
1161 }
1162
1163 #[test]
1164 fn build_request_combines_options_instructions_and_system_message() {
1165 let messages = vec![Message::system("Also be nice."), user("Hi")];
1166 let options = ChatOptions::new().with_instructions("Be terse.");
1167 let body = build_request(&messages, &options, "claude-x", 4096, false);
1168 assert_eq!(body["system"], json!("Be terse.\n\nAlso be nice."));
1169 }
1170
1171 #[test]
1172 fn build_request_tool_role_message_becomes_user_tool_result() {
1173 let tool_msg = Message::with_contents(
1174 Role::tool(),
1175 vec![Content::FunctionResult(FunctionResultContent::new(
1176 "call_1",
1177 Some(json!("18C and sunny")),
1178 ))],
1179 );
1180 let body = build_request(&[tool_msg], &ChatOptions::new(), "claude-x", 4096, false);
1181 assert_eq!(
1182 body["messages"],
1183 json!([{
1184 "role": "user",
1185 "content": [{ "type": "tool_result", "tool_use_id": "call_1", "content": "18C and sunny" }]
1186 }])
1187 );
1188 }
1189
1190 #[test]
1191 fn build_request_tool_result_error_sets_is_error() {
1192 let mut result = FunctionResultContent::new("call_1", None);
1193 result.exception = Some("boom".into());
1194 let tool_msg = Message::with_contents(Role::tool(), vec![Content::FunctionResult(result)]);
1195 let body = build_request(&[tool_msg], &ChatOptions::new(), "claude-x", 4096, false);
1196 assert_eq!(
1197 body["messages"][0]["content"][0],
1198 json!({ "type": "tool_result", "tool_use_id": "call_1", "content": "boom", "is_error": true })
1199 );
1200 }
1201
1202 #[test]
1203 fn build_request_assistant_function_call() {
1204 let call = FunctionCallContent::new(
1205 "call_1",
1206 "get_weather",
1207 Some(FunctionArguments::Object(HashMap::from([(
1208 "city".to_string(),
1209 json!("Paris"),
1210 )]))),
1211 );
1212 let assistant_msg =
1213 Message::with_contents(Role::assistant(), vec![Content::FunctionCall(call)]);
1214 let body = build_request(
1215 &[assistant_msg],
1216 &ChatOptions::new(),
1217 "claude-x",
1218 4096,
1219 false,
1220 );
1221 assert_eq!(
1222 body["messages"],
1223 json!([
1224 {
1225 "role": "user",
1226 "content": [{ "type": "text", "text": "(continuing the conversation)" }]
1227 },
1228 {
1229 "role": "assistant",
1230 "content": [{ "type": "tool_use", "id": "call_1", "name": "get_weather", "input": { "city": "Paris" } }]
1231 }
1232 ])
1233 );
1234 }
1235
1236 #[test]
1237 fn build_request_data_content_image_uses_embedded_base64() {
1238 let dc = DataContent::from_bytes(b"hello", "image/png");
1239 let msg = Message::with_contents(Role::user(), vec![Content::Data(dc.clone())]);
1240 let body = build_request(&[msg], &ChatOptions::new(), "claude-x", 4096, false);
1241 let (_, expected_data) = split_data_uri(&dc.uri).unwrap();
1242 assert_eq!(
1243 body["messages"][0]["content"][0],
1244 json!({ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": expected_data } })
1245 );
1246 }
1247
1248 #[test]
1249 fn build_request_uri_content_image_uses_url_source() {
1250 let uc = UriContent {
1251 uri: "https://example.com/cat.png".into(),
1252 media_type: "image/png".into(),
1253 };
1254 let msg = Message::with_contents(Role::user(), vec![Content::Uri(uc)]);
1255 let body = build_request(&[msg], &ChatOptions::new(), "claude-x", 4096, false);
1256 assert_eq!(
1257 body["messages"][0]["content"][0],
1258 json!({ "type": "image", "source": { "type": "url", "url": "https://example.com/cat.png" } })
1259 );
1260 }
1261
1262 #[test]
1263 fn build_request_tools_and_tool_choice() {
1264 let tool = ToolDefinition {
1265 name: "get_weather".into(),
1266 description: "Get the weather".into(),
1267 parameters: json!({ "type": "object", "properties": {} }),
1268 kind: ToolKind::Function,
1269 approval_mode: ApprovalMode::NeverRequire,
1270 executor: None,
1271 };
1272 let options = ChatOptions::new()
1273 .with_tool(tool)
1274 .with_tool_choice(ToolMode::Required(Some("get_weather".into())));
1275 let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1276 assert_eq!(
1277 body["tools"],
1278 json!([{ "type": "custom", "name": "get_weather", "description": "Get the weather", "input_schema": { "type": "object", "properties": {} } }])
1279 );
1280 assert_eq!(
1281 body["tool_choice"],
1282 json!({ "type": "tool", "name": "get_weather" })
1283 );
1284 }
1285
1286 #[test]
1287 fn build_request_tool_choice_auto_with_disabled_parallel() {
1288 let mut options = ChatOptions::new().with_tool_choice(ToolMode::Auto);
1289 options.allow_multiple_tool_calls = Some(false);
1290 let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1291 assert_eq!(
1292 body["tool_choice"],
1293 json!({ "type": "auto", "disable_parallel_tool_use": true })
1294 );
1295 }
1296
1297 #[test]
1298 fn build_request_temperature_top_p_stop_sequences() {
1299 let mut options = ChatOptions::new().with_temperature(0.5);
1300 options.top_p = Some(0.9);
1301 options.stop = Some(vec!["STOP".into()]);
1302 let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1303 assert_eq!(body["temperature"], json!(0.5_f32));
1307 assert_eq!(body["top_p"], json!(0.9_f32));
1308 assert_eq!(body["stop_sequences"], json!(["STOP"]));
1309 }
1310
1311 #[test]
1312 fn build_request_stream_flag() {
1313 let body = build_request(&[user("hi")], &ChatOptions::new(), "claude-x", 4096, true);
1314 assert_eq!(body["stream"], json!(true));
1315 }
1316
1317 #[test]
1318 fn build_request_uses_given_max_tokens() {
1319 let body = build_request(&[user("hi")], &ChatOptions::new(), "claude-x", 2048, false);
1320 assert_eq!(body["max_tokens"], json!(2048));
1321 }
1322
1323 #[test]
1328 fn build_cloud_request_omits_model_and_sets_anthropic_version() {
1329 let body = build_cloud_request(
1330 &[user("hi")],
1331 &ChatOptions::new(),
1332 4096,
1333 false,
1334 "bedrock-2023-05-31",
1335 );
1336 assert!(body.get("model").is_none());
1337 assert_eq!(body["anthropic_version"], json!("bedrock-2023-05-31"));
1338 assert_eq!(body["max_tokens"], json!(4096));
1339 }
1340
1341 #[test]
1342 fn build_cloud_request_uses_given_anthropic_version() {
1343 let body = build_cloud_request(
1344 &[user("hi")],
1345 &ChatOptions::new(),
1346 4096,
1347 false,
1348 "vertex-2023-10-16",
1349 );
1350 assert_eq!(body["anthropic_version"], json!("vertex-2023-10-16"));
1351 }
1352
1353 #[test]
1354 fn build_cloud_request_messages_system_and_tools_match_build_request() {
1355 let tool = ToolDefinition {
1356 name: "get_weather".into(),
1357 description: "Get the weather".into(),
1358 parameters: json!({ "type": "object", "properties": {} }),
1359 kind: ToolKind::Function,
1360 approval_mode: ApprovalMode::NeverRequire,
1361 executor: None,
1362 };
1363 let messages = vec![Message::system("Be terse."), user("Hi")];
1364 let options = ChatOptions::new()
1365 .with_tool(tool)
1366 .with_tool_choice(ToolMode::Required(Some("get_weather".into())));
1367
1368 let direct = build_request(&messages, &options, "claude-x", 4096, false);
1369 let cloud = build_cloud_request(&messages, &options, 4096, false, "bedrock-2023-05-31");
1370
1371 assert_eq!(cloud["messages"], direct["messages"]);
1372 assert_eq!(cloud["system"], direct["system"]);
1373 assert_eq!(cloud["tools"], direct["tools"]);
1374 assert_eq!(cloud["tool_choice"], direct["tool_choice"]);
1375 }
1376
1377 #[test]
1378 fn build_cloud_request_stream_flag_and_additional_properties() {
1379 let mut options = ChatOptions::new();
1380 options
1381 .additional_properties
1382 .insert("top_k".into(), json!(5));
1383 let body = build_cloud_request(&[user("hi")], &options, 4096, true, "foundry-2025-01-01");
1384 assert_eq!(body["stream"], json!(true));
1385 assert_eq!(body["top_k"], json!(5));
1386 }
1387
1388 #[test]
1398 fn build_request_response_format_none_leaves_system_untouched() {
1399 let body = build_request(&[user("hi")], &ChatOptions::new(), "claude-x", 4096, false);
1400 assert!(body.get("system").is_none());
1401 }
1402
1403 #[test]
1404 fn build_request_response_format_text_is_a_noop() {
1405 let mut options = ChatOptions::new();
1406 options.response_format = Some(ResponseFormat::Text);
1407 let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1408 assert!(body.get("system").is_none());
1409 }
1410
1411 #[test]
1412 fn build_request_response_format_json_object_appends_system_instruction() {
1413 let mut options = ChatOptions::new();
1414 options.response_format = Some(ResponseFormat::JsonObject);
1415 let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1416 let system = body["system"].as_str().expect("system must be a string");
1417 assert!(
1418 system.to_lowercase().contains("json"),
1419 "expected a JSON instruction, got: {system}"
1420 );
1421 }
1422
1423 #[test]
1424 fn build_request_response_format_json_schema_embeds_schema_in_system() {
1425 let mut options = ChatOptions::new();
1426 options.response_format = Some(ResponseFormat::json_schema(
1427 "Person",
1428 json!({ "type": "object", "properties": { "name": { "type": "string" } } }),
1429 ));
1430 let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1431 let system = body["system"].as_str().expect("system must be a string");
1432 assert!(system.contains("Person"), "system: {system}");
1433 assert!(system.contains("\"name\""), "system: {system}");
1434 assert!(system.contains("\"type\": \"object\""), "system: {system}");
1435 }
1436
1437 #[test]
1438 fn build_request_response_format_json_schema_appends_after_existing_system() {
1439 let messages = vec![Message::system("Be terse."), user("Hi")];
1442 let mut options = ChatOptions::new();
1443 options.response_format = Some(ResponseFormat::JsonObject);
1444 let body = build_request(&messages, &options, "claude-x", 4096, false);
1445 let system = body["system"].as_str().expect("system must be a string");
1446 assert!(
1447 system.starts_with("Be terse."),
1448 "existing system text must be preserved first: {system}"
1449 );
1450 assert!(system.to_lowercase().contains("json"), "system: {system}");
1451 }
1452
1453 #[test]
1458 fn parse_response_text_and_usage() {
1459 let value = json!({
1460 "id": "msg_123",
1461 "model": "claude-x",
1462 "stop_reason": "end_turn",
1463 "content": [{ "type": "text", "text": "Hello!" }],
1464 "usage": { "input_tokens": 10, "output_tokens": 5 },
1465 });
1466 let resp = parse_response(&value);
1467 assert_eq!(resp.response_id.as_deref(), Some("msg_123"));
1468 assert_eq!(resp.text(), "Hello!");
1469 assert_eq!(resp.finish_reason, Some(FinishReason::stop()));
1470 let usage = resp.usage_details.unwrap();
1471 assert_eq!(usage.input_token_count, Some(10));
1472 assert_eq!(usage.output_token_count, Some(5));
1473 assert_eq!(usage.total_token_count, Some(15));
1474 }
1475
1476 #[test]
1477 fn parse_response_tool_use() {
1478 let value = json!({
1479 "id": "msg_123",
1480 "stop_reason": "tool_use",
1481 "content": [
1482 { "type": "text", "text": "Let me check." },
1483 { "type": "tool_use", "id": "call_1", "name": "get_weather", "input": { "city": "Paris" } },
1484 ],
1485 });
1486 let resp = parse_response(&value);
1487 assert_eq!(resp.finish_reason, Some(FinishReason::tool_calls()));
1488 let calls = resp.function_calls();
1489 assert_eq!(calls.len(), 1);
1490 assert_eq!(calls[0].call_id, "call_1");
1491 assert_eq!(calls[0].name, "get_weather");
1492 assert_eq!(
1493 calls[0].parse_arguments().unwrap().get("city").unwrap(),
1494 &json!("Paris")
1495 );
1496 }
1497
1498 #[test]
1499 fn parse_response_cache_usage_fields() {
1500 let value = json!({
1501 "id": "msg_123",
1502 "content": [],
1503 "usage": {
1504 "input_tokens": 100,
1505 "output_tokens": 10,
1506 "cache_creation_input_tokens": 50,
1507 "cache_read_input_tokens": 20,
1508 },
1509 });
1510 let resp = parse_response(&value);
1511 let usage = resp.usage_details.unwrap();
1512 assert_eq!(usage.cache_creation_input_token_count, Some(50));
1513 assert_eq!(usage.cache_read_input_token_count, Some(20));
1514 }
1515
1516 #[test]
1517 fn map_stop_reason_covers_documented_mapping() {
1518 assert_eq!(map_stop_reason("end_turn"), FinishReason::stop());
1519 assert_eq!(map_stop_reason("stop_sequence"), FinishReason::stop());
1520 assert_eq!(
1521 map_stop_reason("max_tokens"),
1522 FinishReason::new(FinishReason::LENGTH)
1523 );
1524 assert_eq!(map_stop_reason("tool_use"), FinishReason::tool_calls());
1525 }
1526
1527 #[test]
1528 fn message_start_usage_omits_output_tokens() {
1529 let usage = json!({ "input_tokens": 25, "output_tokens": 1 });
1530 let content = parse_message_start_usage(&usage).unwrap();
1531 assert_eq!(content.details.input_token_count, Some(25));
1532 assert_eq!(content.details.output_token_count, None);
1533 }
1534
1535 #[test]
1537 fn consecutive_same_role_messages_are_merged() {
1538 let msgs = vec![
1539 Message::user("first"),
1540 Message::user("second"),
1541 Message::assistant("reply"),
1542 Message::assistant("more"),
1543 Message::user("third"),
1544 ];
1545 let out = messages_to_anthropic(&msgs);
1546 assert_eq!(out.len(), 3);
1547 assert_eq!(out[0]["role"], "user");
1548 assert_eq!(out[0]["content"].as_array().unwrap().len(), 2);
1549 assert_eq!(out[1]["role"], "assistant");
1550 assert_eq!(out[1]["content"].as_array().unwrap().len(), 2);
1551 assert_eq!(out[2]["role"], "user");
1552 }
1553
1554 #[test]
1555 fn leading_assistant_message_gets_synthetic_user_turn() {
1556 let msgs = vec![Message::assistant("greeting"), Message::user("hello")];
1557 let out = messages_to_anthropic(&msgs);
1558 assert_eq!(out.len(), 3);
1559 assert_eq!(out[0]["role"], "user");
1560 assert_eq!(
1561 out[0]["content"][0]["text"],
1562 "(continuing the conversation)"
1563 );
1564 assert_eq!(out[1]["role"], "assistant");
1565 assert_eq!(out[2]["role"], "user");
1566 }
1567
1568 #[test]
1571 fn compute_beta_flags_default_includes_both_upstream_flags() {
1572 let mut options = ChatOptions::new();
1573 let flags = compute_beta_flags(&mut options, &[]);
1574 assert!(flags.contains(&"mcp-client-2025-04-04".to_string()));
1575 assert!(flags.contains(&"code-execution-2025-08-25".to_string()));
1576 assert_eq!(flags.len(), 2);
1577 }
1578
1579 #[test]
1580 fn compute_beta_flags_merges_client_level_additional_flags() {
1581 let mut options = ChatOptions::new();
1582 let flags = compute_beta_flags(&mut options, &["my-beta-flag".to_string()]);
1583 assert!(flags.contains(&"my-beta-flag".to_string()));
1584 assert_eq!(flags.len(), 3);
1585 }
1586
1587 #[test]
1588 fn compute_beta_flags_merges_and_removes_per_request_additional_flags() {
1589 let mut options = ChatOptions::new();
1590 options.additional_properties.insert(
1591 "additional_beta_flags".into(),
1592 json!(["request-level-flag"]),
1593 );
1594 let flags = compute_beta_flags(&mut options, &[]);
1595 assert!(flags.contains(&"request-level-flag".to_string()));
1596 assert!(!options
1599 .additional_properties
1600 .contains_key("additional_beta_flags"));
1601 }
1602
1603 #[test]
1604 fn compute_beta_flags_deduplicates_overlapping_flags() {
1605 let mut options = ChatOptions::new();
1606 options.additional_properties.insert(
1607 "additional_beta_flags".into(),
1608 json!(["mcp-client-2025-04-04"]),
1609 );
1610 let flags = compute_beta_flags(&mut options, &["mcp-client-2025-04-04".to_string()]);
1611 assert_eq!(flags.len(), 2);
1612 }
1613
1614 #[test]
1615 fn compute_beta_flags_does_not_leak_into_request_body() {
1616 let mut options = ChatOptions::new();
1617 options.additional_properties.insert(
1618 "additional_beta_flags".into(),
1619 json!(["request-level-flag"]),
1620 );
1621 let _ = compute_beta_flags(&mut options, &[]);
1622 let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1623 assert!(body.get("additional_beta_flags").is_none());
1624 }
1625
1626 fn make_tool(kind: ToolKind, name: &str, parameters: Value) -> ToolDefinition {
1631 ToolDefinition {
1632 name: name.into(),
1633 description: String::new(),
1634 parameters,
1635 kind,
1636 approval_mode: ApprovalMode::NeverRequire,
1637 executor: None,
1638 }
1639 }
1640
1641 #[test]
1642 fn tools_to_anthropic_web_search_basic() {
1643 let tool = make_tool(ToolKind::HostedWebSearch, "web_search", json!({}));
1644 let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1645 assert_eq!(
1646 tools,
1647 vec![json!({ "type": "web_search_20250305", "name": "web_search" })]
1648 );
1649 assert!(mcp_servers.is_empty());
1650 }
1651
1652 #[test]
1653 fn tools_to_anthropic_web_search_reads_max_uses_and_user_location_from_parameters() {
1654 let tool = make_tool(
1655 ToolKind::HostedWebSearch,
1656 "web_search",
1657 json!({ "max_uses": 3, "user_location": { "type": "approximate", "city": "Seattle" } }),
1658 );
1659 let (tools, _) = tools_to_anthropic(&[tool]);
1660 assert_eq!(
1661 tools[0],
1662 json!({
1663 "type": "web_search_20250305",
1664 "name": "web_search",
1665 "max_uses": 3,
1666 "user_location": { "type": "approximate", "city": "Seattle" },
1667 })
1668 );
1669 }
1670
1671 #[test]
1672 fn tools_to_anthropic_code_interpreter() {
1673 let tool = make_tool(
1674 ToolKind::HostedCodeInterpreter,
1675 "code_interpreter",
1676 json!({}),
1677 );
1678 let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1679 assert_eq!(
1680 tools,
1681 vec![json!({ "type": "code_execution_20250825", "name": "code_execution" })]
1682 );
1683 assert!(mcp_servers.is_empty());
1684 }
1685
1686 #[test]
1687 fn tools_to_anthropic_mcp_goes_to_mcp_servers_not_tools() {
1688 let tool = make_tool(
1689 ToolKind::HostedMcp {
1690 url: "https://example.com/mcp".into(),
1691 allowed_tools: None,
1692 },
1693 "my-mcp",
1694 json!({}),
1695 );
1696 let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1697 assert!(tools.is_empty());
1698 assert_eq!(
1699 mcp_servers,
1700 vec![json!({ "type": "url", "name": "my-mcp", "url": "https://example.com/mcp" })]
1701 );
1702 }
1703
1704 #[test]
1705 fn tools_to_anthropic_mcp_with_allowed_tools() {
1706 let tool = make_tool(
1707 ToolKind::HostedMcp {
1708 url: "https://example.com/mcp".into(),
1709 allowed_tools: Some(vec!["a".into(), "b".into()]),
1710 },
1711 "my-mcp",
1712 json!({}),
1713 );
1714 let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1715 assert_eq!(
1716 mcp_servers[0]["tool_configuration"],
1717 json!({ "allowed_tools": ["a", "b"] })
1718 );
1719 }
1720
1721 #[test]
1722 fn tools_to_anthropic_mcp_empty_allowed_tools_is_omitted() {
1723 let tool = make_tool(
1724 ToolKind::HostedMcp {
1725 url: "https://example.com/mcp".into(),
1726 allowed_tools: Some(vec![]),
1727 },
1728 "my-mcp",
1729 json!({}),
1730 );
1731 let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1732 assert!(mcp_servers[0].get("tool_configuration").is_none());
1733 }
1734
1735 #[test]
1736 fn tools_to_anthropic_mcp_authorization_header_becomes_authorization_token() {
1737 let tool = make_tool(
1738 ToolKind::HostedMcp {
1739 url: "https://example.com/mcp".into(),
1740 allowed_tools: None,
1741 },
1742 "my-mcp",
1743 json!({ "headers": { "authorization": "Bearer token123" } }),
1744 );
1745 let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1746 assert_eq!(
1747 mcp_servers[0]["authorization_token"],
1748 json!("Bearer token123")
1749 );
1750 }
1751
1752 #[test]
1753 fn tools_to_anthropic_mcp_authorization_header_lookup_is_case_insensitive() {
1754 let tool = make_tool(
1755 ToolKind::HostedMcp {
1756 url: "https://example.com/mcp".into(),
1757 allowed_tools: None,
1758 },
1759 "my-mcp",
1760 json!({ "headers": { "Authorization": "Bearer token456" } }),
1761 );
1762 let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1763 assert_eq!(
1764 mcp_servers[0]["authorization_token"],
1765 json!("Bearer token456")
1766 );
1767 }
1768
1769 #[test]
1770 fn tools_to_anthropic_function_tool_has_custom_type() {
1771 let tool = make_tool(
1772 ToolKind::Function,
1773 "get_weather",
1774 json!({ "type": "object", "properties": {} }),
1775 );
1776 let (tools, _) = tools_to_anthropic(&[tool]);
1777 assert_eq!(tools[0]["type"], json!("custom"));
1778 }
1779
1780 #[test]
1781 fn tools_to_anthropic_unknown_hosted_kind_is_skipped() {
1782 let tool = make_tool(
1783 ToolKind::HostedFileSearch { max_results: None },
1784 "file_search",
1785 json!({}),
1786 );
1787 let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1788 assert!(tools.is_empty());
1789 assert!(mcp_servers.is_empty());
1790 }
1791
1792 #[test]
1793 fn tools_to_anthropic_mixed_tools_and_mcp_servers_both_populate_body() {
1794 let function_tool = make_tool(ToolKind::Function, "get_weather", json!({}));
1795 let mcp_tool = make_tool(
1796 ToolKind::HostedMcp {
1797 url: "https://example.com/mcp".into(),
1798 allowed_tools: None,
1799 },
1800 "my-mcp",
1801 json!({}),
1802 );
1803 let options = ChatOptions::new()
1804 .with_tool(function_tool)
1805 .with_tool(mcp_tool);
1806 let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1807 assert_eq!(body["tools"].as_array().unwrap().len(), 1);
1808 assert_eq!(body["mcp_servers"].as_array().unwrap().len(), 1);
1809 }
1810
1811 #[test]
1816 fn parse_content_blocks_server_tool_use_is_function_call() {
1817 let blocks = vec![json!({
1818 "type": "server_tool_use",
1819 "id": "srvtoolu_1",
1820 "name": "web_search",
1821 "input": { "query": "rust" }
1822 })];
1823 let contents = parse_content_blocks(&blocks);
1824 assert_eq!(contents.len(), 1);
1825 match &contents[0] {
1826 Content::FunctionCall(fc) => {
1827 assert_eq!(fc.call_id, "srvtoolu_1");
1828 assert_eq!(fc.name, "web_search");
1829 assert_eq!(
1830 fc.parse_arguments().unwrap().get("query").unwrap(),
1831 &json!("rust")
1832 );
1833 }
1834 other => panic!("expected FunctionCall, got {other:?}"),
1835 }
1836 }
1837
1838 #[test]
1839 fn parse_content_blocks_mcp_tool_use_is_function_call() {
1840 let blocks = vec![json!({
1841 "type": "mcp_tool_use",
1842 "id": "mcptoolu_1",
1843 "name": "search_docs",
1844 "input": {}
1845 })];
1846 let contents = parse_content_blocks(&blocks);
1847 assert!(matches!(
1848 &contents[0],
1849 Content::FunctionCall(fc) if fc.call_id == "mcptoolu_1" && fc.name == "search_docs"
1850 ));
1851 }
1852
1853 #[test]
1854 fn parse_content_blocks_mcp_tool_result_with_list_content_is_recursively_parsed() {
1855 let blocks = vec![json!({
1856 "type": "mcp_tool_result",
1857 "tool_use_id": "mcptoolu_1",
1858 "is_error": false,
1859 "content": [{ "type": "text", "text": "result text" }]
1860 })];
1861 let contents = parse_content_blocks(&blocks);
1862 assert_eq!(contents.len(), 1);
1863 match &contents[0] {
1864 Content::FunctionResult(fr) => {
1865 assert_eq!(fr.call_id, "mcptoolu_1");
1866 assert_eq!(fr.exception, None);
1867 assert_eq!(
1870 fr.result,
1871 Some(json!([{ "type": "text", "text": "result text" }]))
1872 );
1873 }
1874 other => panic!("expected FunctionResult, got {other:?}"),
1875 }
1876 }
1877
1878 #[test]
1879 fn parse_content_blocks_mcp_tool_result_with_string_content_passes_through() {
1880 let blocks = vec![json!({
1881 "type": "mcp_tool_result",
1882 "tool_use_id": "mcptoolu_1",
1883 "content": "plain string result"
1884 })];
1885 let contents = parse_content_blocks(&blocks);
1886 match &contents[0] {
1887 Content::FunctionResult(fr) => {
1888 assert_eq!(fr.result, Some(json!("plain string result")));
1889 }
1890 other => panic!("expected FunctionResult, got {other:?}"),
1891 }
1892 }
1893
1894 #[test]
1895 fn parse_content_blocks_web_search_tool_result_is_not_recursively_parsed() {
1896 let blocks = vec![json!({
1897 "type": "web_search_tool_result",
1898 "tool_use_id": "srvtoolu_1",
1899 "content": [{ "type": "web_search_result", "url": "https://example.com", "title": "Example" }]
1900 })];
1901 let contents = parse_content_blocks(&blocks);
1902 match &contents[0] {
1903 Content::FunctionResult(fr) => {
1904 assert_eq!(fr.call_id, "srvtoolu_1");
1905 assert_eq!(
1907 fr.result,
1908 Some(
1909 json!([{ "type": "web_search_result", "url": "https://example.com", "title": "Example" }])
1910 )
1911 );
1912 }
1913 other => panic!("expected FunctionResult, got {other:?}"),
1914 }
1915 }
1916
1917 #[test]
1918 fn parse_content_blocks_web_fetch_tool_result_uses_same_mapping() {
1919 let blocks = vec![json!({
1920 "type": "web_fetch_tool_result",
1921 "tool_use_id": "srvtoolu_2",
1922 "content": { "type": "web_fetch_result", "url": "https://example.com" }
1923 })];
1924 let contents = parse_content_blocks(&blocks);
1925 assert_eq!(contents.len(), 1);
1926 assert!(matches!(&contents[0], Content::FunctionResult(fr) if fr.call_id == "srvtoolu_2"));
1927 }
1928
1929 #[test]
1930 fn parse_content_blocks_code_execution_tool_result_extracts_hosted_files_before_result() {
1931 let blocks = vec![json!({
1932 "type": "code_execution_tool_result",
1933 "tool_use_id": "srvtoolu_3",
1934 "content": {
1935 "type": "code_execution_result",
1936 "stdout": "",
1937 "stderr": "",
1938 "return_code": 0,
1939 "content": [
1940 { "type": "code_execution_output", "file_id": "file_abc" },
1941 { "type": "code_execution_output", "file_id": "file_def" }
1942 ]
1943 }
1944 })];
1945 let contents = parse_content_blocks(&blocks);
1946 assert_eq!(contents.len(), 3);
1947 assert_eq!(
1948 contents[0],
1949 Content::HostedFile(HostedFileContent {
1950 file_id: "file_abc".into()
1951 })
1952 );
1953 assert_eq!(
1954 contents[1],
1955 Content::HostedFile(HostedFileContent {
1956 file_id: "file_def".into()
1957 })
1958 );
1959 match &contents[2] {
1960 Content::FunctionResult(fr) => assert_eq!(fr.call_id, "srvtoolu_3"),
1961 other => panic!("expected FunctionResult, got {other:?}"),
1962 }
1963 }
1964
1965 #[test]
1966 fn parse_content_blocks_bash_code_execution_tool_result_extracts_hosted_files() {
1967 let blocks = vec![json!({
1968 "type": "bash_code_execution_tool_result",
1969 "tool_use_id": "srvtoolu_4",
1970 "content": {
1971 "type": "bash_code_execution_result",
1972 "stdout": "",
1973 "stderr": "",
1974 "return_code": 0,
1975 "content": [{ "type": "bash_code_execution_output", "file_id": "file_ghi" }]
1976 }
1977 })];
1978 let contents = parse_content_blocks(&blocks);
1979 assert_eq!(contents.len(), 2);
1980 assert_eq!(
1981 contents[0],
1982 Content::HostedFile(HostedFileContent {
1983 file_id: "file_ghi".into()
1984 })
1985 );
1986 }
1987
1988 #[test]
1989 fn parse_content_blocks_code_execution_tool_result_no_files_only_function_result() {
1990 let blocks = vec![json!({
1991 "type": "code_execution_tool_result",
1992 "tool_use_id": "srvtoolu_5",
1993 "content": { "type": "code_execution_result", "stdout": "hi", "stderr": "", "return_code": 0, "content": [] }
1994 })];
1995 let contents = parse_content_blocks(&blocks);
1996 assert_eq!(contents.len(), 1);
1997 assert!(matches!(&contents[0], Content::FunctionResult(_)));
1998 }
1999
2000 #[test]
2001 fn parse_content_blocks_text_editor_code_execution_tool_result_never_extracts_files() {
2002 let blocks = vec![json!({
2008 "type": "text_editor_code_execution_tool_result",
2009 "tool_use_id": "srvtoolu_6",
2010 "content": { "type": "text_editor_code_execution_view_result", "file_type": "text", "content": "print('hi')" }
2011 })];
2012 let contents = parse_content_blocks(&blocks);
2013 assert_eq!(contents.len(), 1);
2014 assert!(matches!(&contents[0], Content::FunctionResult(_)));
2015 }
2016
2017 #[test]
2018 fn parse_content_blocks_unknown_block_type_is_skipped() {
2019 let blocks = vec![json!({ "type": "totally_unknown_block" })];
2020 let contents = parse_content_blocks(&blocks);
2021 assert!(contents.is_empty());
2022 }
2023
2024 #[test]
2025 fn parse_response_includes_server_tool_use_and_web_search_result() {
2026 let value = json!({
2027 "id": "msg_1",
2028 "content": [
2029 { "type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": { "query": "rust" } },
2030 { "type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", "content": [{ "type": "web_search_result", "url": "https://example.com", "title": "Example" }] },
2031 ],
2032 });
2033 let resp = parse_response(&value);
2034 let contents = &resp.messages[0].contents;
2035 assert_eq!(contents.len(), 2);
2036 assert!(matches!(&contents[0], Content::FunctionCall(_)));
2037 assert!(matches!(&contents[1], Content::FunctionResult(_)));
2038 }
2039
2040 #[test]
2045 fn parse_citations_char_location() {
2046 let block = json!({
2047 "type": "text",
2048 "text": "cited",
2049 "citations": [{
2050 "type": "char_location",
2051 "cited_text": "The grass is green.",
2052 "document_index": 0,
2053 "document_title": "Example Document",
2054 "start_char_index": 0,
2055 "end_char_index": 20,
2056 }]
2057 });
2058 let annotations = parse_citations(&block).unwrap();
2059 assert_eq!(annotations.len(), 1);
2060 let cit = &annotations[0];
2061 assert_eq!(cit.title, None);
2066 assert_eq!(cit.snippet.as_deref(), Some("The grass is green."));
2067 assert_eq!(
2068 cit.annotated_regions,
2069 Some(vec![TextSpanRegion {
2070 start_index: Some(0),
2071 end_index: Some(20)
2072 }])
2073 );
2074 }
2075
2076 #[test]
2077 fn parse_citations_page_location_uses_document_title() {
2078 let block = json!({
2079 "type": "text",
2080 "text": "cited",
2081 "citations": [{
2082 "type": "page_location",
2083 "cited_text": "Water is essential for life.",
2084 "document_index": 1,
2085 "document_title": "PDF Document",
2086 "start_page_number": 5,
2087 "end_page_number": 6,
2088 }]
2089 });
2090 let annotations = parse_citations(&block).unwrap();
2091 let cit = &annotations[0];
2092 assert_eq!(cit.title.as_deref(), Some("PDF Document"));
2093 assert_eq!(cit.snippet.as_deref(), Some("Water is essential for life."));
2094 assert_eq!(
2095 cit.annotated_regions,
2096 Some(vec![TextSpanRegion {
2097 start_index: Some(5),
2098 end_index: Some(6)
2099 }])
2100 );
2101 }
2102
2103 #[test]
2104 fn parse_citations_content_block_location_uses_document_title() {
2105 let block = json!({
2106 "type": "text",
2107 "text": "cited",
2108 "citations": [{
2109 "type": "content_block_location",
2110 "cited_text": "These are important findings.",
2111 "document_index": 2,
2112 "document_title": "Custom Content Document",
2113 "start_block_index": 0,
2114 "end_block_index": 1,
2115 }]
2116 });
2117 let annotations = parse_citations(&block).unwrap();
2118 let cit = &annotations[0];
2119 assert_eq!(cit.title.as_deref(), Some("Custom Content Document"));
2120 assert_eq!(
2121 cit.annotated_regions,
2122 Some(vec![TextSpanRegion {
2123 start_index: Some(0),
2124 end_index: Some(1)
2125 }])
2126 );
2127 }
2128
2129 #[test]
2130 fn parse_citations_file_id_only_set_when_present() {
2131 let block = json!({
2132 "type": "text",
2133 "text": "cited",
2134 "citations": [{
2135 "type": "page_location",
2136 "cited_text": "text",
2137 "document_index": 0,
2138 "document_title": "Doc",
2139 "start_page_number": 1,
2140 "end_page_number": 2,
2141 "file_id": "file_123",
2142 }]
2143 });
2144 let annotations = parse_citations(&block).unwrap();
2145 assert_eq!(annotations[0].file_id.as_deref(), Some("file_123"));
2146 }
2147
2148 #[test]
2149 fn parse_citations_web_search_result_location() {
2150 let block = json!({
2151 "type": "text",
2152 "text": "cited",
2153 "citations": [{
2154 "type": "web_search_result_location",
2155 "cited_text": "some cited snippet",
2156 "url": "https://example.com/page",
2157 "title": "Example Page",
2158 "encrypted_index": "abc123",
2159 }]
2160 });
2161 let annotations = parse_citations(&block).unwrap();
2162 let cit = &annotations[0];
2163 assert_eq!(cit.title.as_deref(), Some("Example Page"));
2164 assert_eq!(cit.snippet.as_deref(), Some("some cited snippet"));
2165 assert_eq!(cit.url.as_deref(), Some("https://example.com/page"));
2166 assert_eq!(cit.annotated_regions, None);
2167 }
2168
2169 #[test]
2170 fn parse_citations_search_result_location_uses_source_as_url() {
2171 let block = json!({
2172 "type": "text",
2173 "text": "cited",
2174 "citations": [{
2175 "type": "search_result_location",
2176 "cited_text": "some cited snippet",
2177 "source": "https://example.com/doc",
2178 "title": "Search Result",
2179 "search_result_index": 0,
2180 "start_block_index": 0,
2181 "end_block_index": 1,
2182 }]
2183 });
2184 let annotations = parse_citations(&block).unwrap();
2185 let cit = &annotations[0];
2186 assert_eq!(cit.title.as_deref(), Some("Search Result"));
2187 assert_eq!(cit.url.as_deref(), Some("https://example.com/doc"));
2188 assert_eq!(
2189 cit.annotated_regions,
2190 Some(vec![TextSpanRegion {
2191 start_index: Some(0),
2192 end_index: Some(1)
2193 }])
2194 );
2195 }
2196
2197 #[test]
2198 fn parse_citations_unknown_type_still_produces_empty_annotation() {
2199 let block = json!({
2203 "type": "text",
2204 "text": "cited",
2205 "citations": [{ "type": "some_future_citation_type" }]
2206 });
2207 let annotations = parse_citations(&block).unwrap();
2208 assert_eq!(annotations.len(), 1);
2209 assert_eq!(annotations[0], Annotation::default());
2210 }
2211
2212 #[test]
2213 fn parse_citations_absent_returns_none() {
2214 let block = json!({ "type": "text", "text": "no citations here" });
2215 assert_eq!(parse_citations(&block), None);
2216 }
2217
2218 #[test]
2219 fn parse_citations_empty_array_returns_none() {
2220 let block = json!({ "type": "text", "text": "no citations here", "citations": [] });
2221 assert_eq!(parse_citations(&block), None);
2222 }
2223
2224 #[test]
2225 fn parse_response_text_block_carries_citations_as_annotations() {
2226 let value = json!({
2227 "id": "msg_1",
2228 "content": [{
2229 "type": "text",
2230 "text": "the grass is green",
2231 "citations": [{
2232 "type": "char_location",
2233 "cited_text": "The grass is green.",
2234 "document_index": 0,
2235 "document_title": "Example Document",
2236 "start_char_index": 0,
2237 "end_char_index": 20,
2238 }]
2239 }]
2240 });
2241 let resp = parse_response(&value);
2242 match &resp.messages[0].contents[0] {
2243 Content::Text(t) => {
2244 assert_eq!(t.text, "the grass is green");
2245 assert!(t.annotations.is_some());
2246 }
2247 other => panic!("expected Text, got {other:?}"),
2248 }
2249 }
2250
2251 }