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]
1536 fn map_stop_reason_passes_unmapped_values_through() {
1537 assert_eq!(
1538 map_stop_reason("model_context_window_exceeded"),
1539 FinishReason::new("model_context_window_exceeded")
1540 );
1541
1542 let value = json!({
1544 "id": "msg_1",
1545 "model": "claude-x",
1546 "content": [{ "type": "text", "text": "hi" }],
1547 "stop_reason": "model_context_window_exceeded",
1548 });
1549 assert_eq!(
1550 parse_response(&value).finish_reason,
1551 Some(FinishReason::new("model_context_window_exceeded"))
1552 );
1553 }
1554
1555 #[test]
1556 fn message_start_usage_omits_output_tokens() {
1557 let usage = json!({ "input_tokens": 25, "output_tokens": 1 });
1558 let content = parse_message_start_usage(&usage).unwrap();
1559 assert_eq!(content.details.input_token_count, Some(25));
1560 assert_eq!(content.details.output_token_count, None);
1561 }
1562
1563 #[test]
1565 fn consecutive_same_role_messages_are_merged() {
1566 let msgs = vec![
1567 Message::user("first"),
1568 Message::user("second"),
1569 Message::assistant("reply"),
1570 Message::assistant("more"),
1571 Message::user("third"),
1572 ];
1573 let out = messages_to_anthropic(&msgs);
1574 assert_eq!(out.len(), 3);
1575 assert_eq!(out[0]["role"], "user");
1576 assert_eq!(out[0]["content"].as_array().unwrap().len(), 2);
1577 assert_eq!(out[1]["role"], "assistant");
1578 assert_eq!(out[1]["content"].as_array().unwrap().len(), 2);
1579 assert_eq!(out[2]["role"], "user");
1580 }
1581
1582 #[test]
1583 fn leading_assistant_message_gets_synthetic_user_turn() {
1584 let msgs = vec![Message::assistant("greeting"), Message::user("hello")];
1585 let out = messages_to_anthropic(&msgs);
1586 assert_eq!(out.len(), 3);
1587 assert_eq!(out[0]["role"], "user");
1588 assert_eq!(
1589 out[0]["content"][0]["text"],
1590 "(continuing the conversation)"
1591 );
1592 assert_eq!(out[1]["role"], "assistant");
1593 assert_eq!(out[2]["role"], "user");
1594 }
1595
1596 #[test]
1599 fn compute_beta_flags_default_includes_both_upstream_flags() {
1600 let mut options = ChatOptions::new();
1601 let flags = compute_beta_flags(&mut options, &[]);
1602 assert!(flags.contains(&"mcp-client-2025-04-04".to_string()));
1603 assert!(flags.contains(&"code-execution-2025-08-25".to_string()));
1604 assert_eq!(flags.len(), 2);
1605 }
1606
1607 #[test]
1608 fn compute_beta_flags_merges_client_level_additional_flags() {
1609 let mut options = ChatOptions::new();
1610 let flags = compute_beta_flags(&mut options, &["my-beta-flag".to_string()]);
1611 assert!(flags.contains(&"my-beta-flag".to_string()));
1612 assert_eq!(flags.len(), 3);
1613 }
1614
1615 #[test]
1616 fn compute_beta_flags_merges_and_removes_per_request_additional_flags() {
1617 let mut options = ChatOptions::new();
1618 options.additional_properties.insert(
1619 "additional_beta_flags".into(),
1620 json!(["request-level-flag"]),
1621 );
1622 let flags = compute_beta_flags(&mut options, &[]);
1623 assert!(flags.contains(&"request-level-flag".to_string()));
1624 assert!(!options
1627 .additional_properties
1628 .contains_key("additional_beta_flags"));
1629 }
1630
1631 #[test]
1632 fn compute_beta_flags_deduplicates_overlapping_flags() {
1633 let mut options = ChatOptions::new();
1634 options.additional_properties.insert(
1635 "additional_beta_flags".into(),
1636 json!(["mcp-client-2025-04-04"]),
1637 );
1638 let flags = compute_beta_flags(&mut options, &["mcp-client-2025-04-04".to_string()]);
1639 assert_eq!(flags.len(), 2);
1640 }
1641
1642 #[test]
1643 fn compute_beta_flags_does_not_leak_into_request_body() {
1644 let mut options = ChatOptions::new();
1645 options.additional_properties.insert(
1646 "additional_beta_flags".into(),
1647 json!(["request-level-flag"]),
1648 );
1649 let _ = compute_beta_flags(&mut options, &[]);
1650 let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1651 assert!(body.get("additional_beta_flags").is_none());
1652 }
1653
1654 fn make_tool(kind: ToolKind, name: &str, parameters: Value) -> ToolDefinition {
1659 ToolDefinition {
1660 name: name.into(),
1661 description: String::new(),
1662 parameters,
1663 kind,
1664 approval_mode: ApprovalMode::NeverRequire,
1665 executor: None,
1666 }
1667 }
1668
1669 #[test]
1670 fn tools_to_anthropic_web_search_basic() {
1671 let tool = make_tool(ToolKind::HostedWebSearch, "web_search", json!({}));
1672 let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1673 assert_eq!(
1674 tools,
1675 vec![json!({ "type": "web_search_20250305", "name": "web_search" })]
1676 );
1677 assert!(mcp_servers.is_empty());
1678 }
1679
1680 #[test]
1681 fn tools_to_anthropic_web_search_reads_max_uses_and_user_location_from_parameters() {
1682 let tool = make_tool(
1683 ToolKind::HostedWebSearch,
1684 "web_search",
1685 json!({ "max_uses": 3, "user_location": { "type": "approximate", "city": "Seattle" } }),
1686 );
1687 let (tools, _) = tools_to_anthropic(&[tool]);
1688 assert_eq!(
1689 tools[0],
1690 json!({
1691 "type": "web_search_20250305",
1692 "name": "web_search",
1693 "max_uses": 3,
1694 "user_location": { "type": "approximate", "city": "Seattle" },
1695 })
1696 );
1697 }
1698
1699 #[test]
1700 fn tools_to_anthropic_code_interpreter() {
1701 let tool = make_tool(
1702 ToolKind::HostedCodeInterpreter,
1703 "code_interpreter",
1704 json!({}),
1705 );
1706 let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1707 assert_eq!(
1708 tools,
1709 vec![json!({ "type": "code_execution_20250825", "name": "code_execution" })]
1710 );
1711 assert!(mcp_servers.is_empty());
1712 }
1713
1714 #[test]
1715 fn tools_to_anthropic_mcp_goes_to_mcp_servers_not_tools() {
1716 let tool = make_tool(
1717 ToolKind::HostedMcp {
1718 url: "https://example.com/mcp".into(),
1719 allowed_tools: None,
1720 },
1721 "my-mcp",
1722 json!({}),
1723 );
1724 let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1725 assert!(tools.is_empty());
1726 assert_eq!(
1727 mcp_servers,
1728 vec![json!({ "type": "url", "name": "my-mcp", "url": "https://example.com/mcp" })]
1729 );
1730 }
1731
1732 #[test]
1733 fn tools_to_anthropic_mcp_with_allowed_tools() {
1734 let tool = make_tool(
1735 ToolKind::HostedMcp {
1736 url: "https://example.com/mcp".into(),
1737 allowed_tools: Some(vec!["a".into(), "b".into()]),
1738 },
1739 "my-mcp",
1740 json!({}),
1741 );
1742 let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1743 assert_eq!(
1744 mcp_servers[0]["tool_configuration"],
1745 json!({ "allowed_tools": ["a", "b"] })
1746 );
1747 }
1748
1749 #[test]
1750 fn tools_to_anthropic_mcp_empty_allowed_tools_is_omitted() {
1751 let tool = make_tool(
1752 ToolKind::HostedMcp {
1753 url: "https://example.com/mcp".into(),
1754 allowed_tools: Some(vec![]),
1755 },
1756 "my-mcp",
1757 json!({}),
1758 );
1759 let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1760 assert!(mcp_servers[0].get("tool_configuration").is_none());
1761 }
1762
1763 #[test]
1764 fn tools_to_anthropic_mcp_authorization_header_becomes_authorization_token() {
1765 let tool = make_tool(
1766 ToolKind::HostedMcp {
1767 url: "https://example.com/mcp".into(),
1768 allowed_tools: None,
1769 },
1770 "my-mcp",
1771 json!({ "headers": { "authorization": "Bearer token123" } }),
1772 );
1773 let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1774 assert_eq!(
1775 mcp_servers[0]["authorization_token"],
1776 json!("Bearer token123")
1777 );
1778 }
1779
1780 #[test]
1781 fn tools_to_anthropic_mcp_authorization_header_lookup_is_case_insensitive() {
1782 let tool = make_tool(
1783 ToolKind::HostedMcp {
1784 url: "https://example.com/mcp".into(),
1785 allowed_tools: None,
1786 },
1787 "my-mcp",
1788 json!({ "headers": { "Authorization": "Bearer token456" } }),
1789 );
1790 let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1791 assert_eq!(
1792 mcp_servers[0]["authorization_token"],
1793 json!("Bearer token456")
1794 );
1795 }
1796
1797 #[test]
1798 fn tools_to_anthropic_function_tool_has_custom_type() {
1799 let tool = make_tool(
1800 ToolKind::Function,
1801 "get_weather",
1802 json!({ "type": "object", "properties": {} }),
1803 );
1804 let (tools, _) = tools_to_anthropic(&[tool]);
1805 assert_eq!(tools[0]["type"], json!("custom"));
1806 }
1807
1808 #[test]
1809 fn tools_to_anthropic_unknown_hosted_kind_is_skipped() {
1810 let tool = make_tool(
1811 ToolKind::HostedFileSearch { max_results: None },
1812 "file_search",
1813 json!({}),
1814 );
1815 let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1816 assert!(tools.is_empty());
1817 assert!(mcp_servers.is_empty());
1818 }
1819
1820 #[test]
1821 fn tools_to_anthropic_mixed_tools_and_mcp_servers_both_populate_body() {
1822 let function_tool = make_tool(ToolKind::Function, "get_weather", json!({}));
1823 let mcp_tool = make_tool(
1824 ToolKind::HostedMcp {
1825 url: "https://example.com/mcp".into(),
1826 allowed_tools: None,
1827 },
1828 "my-mcp",
1829 json!({}),
1830 );
1831 let options = ChatOptions::new()
1832 .with_tool(function_tool)
1833 .with_tool(mcp_tool);
1834 let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1835 assert_eq!(body["tools"].as_array().unwrap().len(), 1);
1836 assert_eq!(body["mcp_servers"].as_array().unwrap().len(), 1);
1837 }
1838
1839 #[test]
1844 fn parse_content_blocks_server_tool_use_is_function_call() {
1845 let blocks = vec![json!({
1846 "type": "server_tool_use",
1847 "id": "srvtoolu_1",
1848 "name": "web_search",
1849 "input": { "query": "rust" }
1850 })];
1851 let contents = parse_content_blocks(&blocks);
1852 assert_eq!(contents.len(), 1);
1853 match &contents[0] {
1854 Content::FunctionCall(fc) => {
1855 assert_eq!(fc.call_id, "srvtoolu_1");
1856 assert_eq!(fc.name, "web_search");
1857 assert_eq!(
1858 fc.parse_arguments().unwrap().get("query").unwrap(),
1859 &json!("rust")
1860 );
1861 }
1862 other => panic!("expected FunctionCall, got {other:?}"),
1863 }
1864 }
1865
1866 #[test]
1867 fn parse_content_blocks_mcp_tool_use_is_function_call() {
1868 let blocks = vec![json!({
1869 "type": "mcp_tool_use",
1870 "id": "mcptoolu_1",
1871 "name": "search_docs",
1872 "input": {}
1873 })];
1874 let contents = parse_content_blocks(&blocks);
1875 assert!(matches!(
1876 &contents[0],
1877 Content::FunctionCall(fc) if fc.call_id == "mcptoolu_1" && fc.name == "search_docs"
1878 ));
1879 }
1880
1881 #[test]
1882 fn parse_content_blocks_mcp_tool_result_with_list_content_is_recursively_parsed() {
1883 let blocks = vec![json!({
1884 "type": "mcp_tool_result",
1885 "tool_use_id": "mcptoolu_1",
1886 "is_error": false,
1887 "content": [{ "type": "text", "text": "result text" }]
1888 })];
1889 let contents = parse_content_blocks(&blocks);
1890 assert_eq!(contents.len(), 1);
1891 match &contents[0] {
1892 Content::FunctionResult(fr) => {
1893 assert_eq!(fr.call_id, "mcptoolu_1");
1894 assert_eq!(fr.exception, None);
1895 assert_eq!(
1898 fr.result,
1899 Some(json!([{ "type": "text", "text": "result text" }]))
1900 );
1901 }
1902 other => panic!("expected FunctionResult, got {other:?}"),
1903 }
1904 }
1905
1906 #[test]
1907 fn parse_content_blocks_mcp_tool_result_with_string_content_passes_through() {
1908 let blocks = vec![json!({
1909 "type": "mcp_tool_result",
1910 "tool_use_id": "mcptoolu_1",
1911 "content": "plain string result"
1912 })];
1913 let contents = parse_content_blocks(&blocks);
1914 match &contents[0] {
1915 Content::FunctionResult(fr) => {
1916 assert_eq!(fr.result, Some(json!("plain string result")));
1917 }
1918 other => panic!("expected FunctionResult, got {other:?}"),
1919 }
1920 }
1921
1922 #[test]
1923 fn parse_content_blocks_web_search_tool_result_is_not_recursively_parsed() {
1924 let blocks = vec![json!({
1925 "type": "web_search_tool_result",
1926 "tool_use_id": "srvtoolu_1",
1927 "content": [{ "type": "web_search_result", "url": "https://example.com", "title": "Example" }]
1928 })];
1929 let contents = parse_content_blocks(&blocks);
1930 match &contents[0] {
1931 Content::FunctionResult(fr) => {
1932 assert_eq!(fr.call_id, "srvtoolu_1");
1933 assert_eq!(
1935 fr.result,
1936 Some(
1937 json!([{ "type": "web_search_result", "url": "https://example.com", "title": "Example" }])
1938 )
1939 );
1940 }
1941 other => panic!("expected FunctionResult, got {other:?}"),
1942 }
1943 }
1944
1945 #[test]
1946 fn parse_content_blocks_web_fetch_tool_result_uses_same_mapping() {
1947 let blocks = vec![json!({
1948 "type": "web_fetch_tool_result",
1949 "tool_use_id": "srvtoolu_2",
1950 "content": { "type": "web_fetch_result", "url": "https://example.com" }
1951 })];
1952 let contents = parse_content_blocks(&blocks);
1953 assert_eq!(contents.len(), 1);
1954 assert!(matches!(&contents[0], Content::FunctionResult(fr) if fr.call_id == "srvtoolu_2"));
1955 }
1956
1957 #[test]
1958 fn parse_content_blocks_code_execution_tool_result_extracts_hosted_files_before_result() {
1959 let blocks = vec![json!({
1960 "type": "code_execution_tool_result",
1961 "tool_use_id": "srvtoolu_3",
1962 "content": {
1963 "type": "code_execution_result",
1964 "stdout": "",
1965 "stderr": "",
1966 "return_code": 0,
1967 "content": [
1968 { "type": "code_execution_output", "file_id": "file_abc" },
1969 { "type": "code_execution_output", "file_id": "file_def" }
1970 ]
1971 }
1972 })];
1973 let contents = parse_content_blocks(&blocks);
1974 assert_eq!(contents.len(), 3);
1975 assert_eq!(
1976 contents[0],
1977 Content::HostedFile(HostedFileContent {
1978 file_id: "file_abc".into()
1979 })
1980 );
1981 assert_eq!(
1982 contents[1],
1983 Content::HostedFile(HostedFileContent {
1984 file_id: "file_def".into()
1985 })
1986 );
1987 match &contents[2] {
1988 Content::FunctionResult(fr) => assert_eq!(fr.call_id, "srvtoolu_3"),
1989 other => panic!("expected FunctionResult, got {other:?}"),
1990 }
1991 }
1992
1993 #[test]
1994 fn parse_content_blocks_bash_code_execution_tool_result_extracts_hosted_files() {
1995 let blocks = vec![json!({
1996 "type": "bash_code_execution_tool_result",
1997 "tool_use_id": "srvtoolu_4",
1998 "content": {
1999 "type": "bash_code_execution_result",
2000 "stdout": "",
2001 "stderr": "",
2002 "return_code": 0,
2003 "content": [{ "type": "bash_code_execution_output", "file_id": "file_ghi" }]
2004 }
2005 })];
2006 let contents = parse_content_blocks(&blocks);
2007 assert_eq!(contents.len(), 2);
2008 assert_eq!(
2009 contents[0],
2010 Content::HostedFile(HostedFileContent {
2011 file_id: "file_ghi".into()
2012 })
2013 );
2014 }
2015
2016 #[test]
2017 fn parse_content_blocks_code_execution_tool_result_no_files_only_function_result() {
2018 let blocks = vec![json!({
2019 "type": "code_execution_tool_result",
2020 "tool_use_id": "srvtoolu_5",
2021 "content": { "type": "code_execution_result", "stdout": "hi", "stderr": "", "return_code": 0, "content": [] }
2022 })];
2023 let contents = parse_content_blocks(&blocks);
2024 assert_eq!(contents.len(), 1);
2025 assert!(matches!(&contents[0], Content::FunctionResult(_)));
2026 }
2027
2028 #[test]
2029 fn parse_content_blocks_text_editor_code_execution_tool_result_never_extracts_files() {
2030 let blocks = vec![json!({
2036 "type": "text_editor_code_execution_tool_result",
2037 "tool_use_id": "srvtoolu_6",
2038 "content": { "type": "text_editor_code_execution_view_result", "file_type": "text", "content": "print('hi')" }
2039 })];
2040 let contents = parse_content_blocks(&blocks);
2041 assert_eq!(contents.len(), 1);
2042 assert!(matches!(&contents[0], Content::FunctionResult(_)));
2043 }
2044
2045 #[test]
2046 fn parse_content_blocks_unknown_block_type_is_skipped() {
2047 let blocks = vec![json!({ "type": "totally_unknown_block" })];
2048 let contents = parse_content_blocks(&blocks);
2049 assert!(contents.is_empty());
2050 }
2051
2052 #[test]
2053 fn parse_response_includes_server_tool_use_and_web_search_result() {
2054 let value = json!({
2055 "id": "msg_1",
2056 "content": [
2057 { "type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": { "query": "rust" } },
2058 { "type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", "content": [{ "type": "web_search_result", "url": "https://example.com", "title": "Example" }] },
2059 ],
2060 });
2061 let resp = parse_response(&value);
2062 let contents = &resp.messages[0].contents;
2063 assert_eq!(contents.len(), 2);
2064 assert!(matches!(&contents[0], Content::FunctionCall(_)));
2065 assert!(matches!(&contents[1], Content::FunctionResult(_)));
2066 }
2067
2068 #[test]
2073 fn parse_citations_char_location() {
2074 let block = json!({
2075 "type": "text",
2076 "text": "cited",
2077 "citations": [{
2078 "type": "char_location",
2079 "cited_text": "The grass is green.",
2080 "document_index": 0,
2081 "document_title": "Example Document",
2082 "start_char_index": 0,
2083 "end_char_index": 20,
2084 }]
2085 });
2086 let annotations = parse_citations(&block).unwrap();
2087 assert_eq!(annotations.len(), 1);
2088 let cit = &annotations[0];
2089 assert_eq!(cit.title, None);
2094 assert_eq!(cit.snippet.as_deref(), Some("The grass is green."));
2095 assert_eq!(
2096 cit.annotated_regions,
2097 Some(vec![TextSpanRegion {
2098 start_index: Some(0),
2099 end_index: Some(20)
2100 }])
2101 );
2102 }
2103
2104 #[test]
2105 fn parse_citations_page_location_uses_document_title() {
2106 let block = json!({
2107 "type": "text",
2108 "text": "cited",
2109 "citations": [{
2110 "type": "page_location",
2111 "cited_text": "Water is essential for life.",
2112 "document_index": 1,
2113 "document_title": "PDF Document",
2114 "start_page_number": 5,
2115 "end_page_number": 6,
2116 }]
2117 });
2118 let annotations = parse_citations(&block).unwrap();
2119 let cit = &annotations[0];
2120 assert_eq!(cit.title.as_deref(), Some("PDF Document"));
2121 assert_eq!(cit.snippet.as_deref(), Some("Water is essential for life."));
2122 assert_eq!(
2123 cit.annotated_regions,
2124 Some(vec![TextSpanRegion {
2125 start_index: Some(5),
2126 end_index: Some(6)
2127 }])
2128 );
2129 }
2130
2131 #[test]
2132 fn parse_citations_content_block_location_uses_document_title() {
2133 let block = json!({
2134 "type": "text",
2135 "text": "cited",
2136 "citations": [{
2137 "type": "content_block_location",
2138 "cited_text": "These are important findings.",
2139 "document_index": 2,
2140 "document_title": "Custom Content Document",
2141 "start_block_index": 0,
2142 "end_block_index": 1,
2143 }]
2144 });
2145 let annotations = parse_citations(&block).unwrap();
2146 let cit = &annotations[0];
2147 assert_eq!(cit.title.as_deref(), Some("Custom Content Document"));
2148 assert_eq!(
2149 cit.annotated_regions,
2150 Some(vec![TextSpanRegion {
2151 start_index: Some(0),
2152 end_index: Some(1)
2153 }])
2154 );
2155 }
2156
2157 #[test]
2158 fn parse_citations_file_id_only_set_when_present() {
2159 let block = json!({
2160 "type": "text",
2161 "text": "cited",
2162 "citations": [{
2163 "type": "page_location",
2164 "cited_text": "text",
2165 "document_index": 0,
2166 "document_title": "Doc",
2167 "start_page_number": 1,
2168 "end_page_number": 2,
2169 "file_id": "file_123",
2170 }]
2171 });
2172 let annotations = parse_citations(&block).unwrap();
2173 assert_eq!(annotations[0].file_id.as_deref(), Some("file_123"));
2174 }
2175
2176 #[test]
2177 fn parse_citations_web_search_result_location() {
2178 let block = json!({
2179 "type": "text",
2180 "text": "cited",
2181 "citations": [{
2182 "type": "web_search_result_location",
2183 "cited_text": "some cited snippet",
2184 "url": "https://example.com/page",
2185 "title": "Example Page",
2186 "encrypted_index": "abc123",
2187 }]
2188 });
2189 let annotations = parse_citations(&block).unwrap();
2190 let cit = &annotations[0];
2191 assert_eq!(cit.title.as_deref(), Some("Example Page"));
2192 assert_eq!(cit.snippet.as_deref(), Some("some cited snippet"));
2193 assert_eq!(cit.url.as_deref(), Some("https://example.com/page"));
2194 assert_eq!(cit.annotated_regions, None);
2195 }
2196
2197 #[test]
2198 fn parse_citations_search_result_location_uses_source_as_url() {
2199 let block = json!({
2200 "type": "text",
2201 "text": "cited",
2202 "citations": [{
2203 "type": "search_result_location",
2204 "cited_text": "some cited snippet",
2205 "source": "https://example.com/doc",
2206 "title": "Search Result",
2207 "search_result_index": 0,
2208 "start_block_index": 0,
2209 "end_block_index": 1,
2210 }]
2211 });
2212 let annotations = parse_citations(&block).unwrap();
2213 let cit = &annotations[0];
2214 assert_eq!(cit.title.as_deref(), Some("Search Result"));
2215 assert_eq!(cit.url.as_deref(), Some("https://example.com/doc"));
2216 assert_eq!(
2217 cit.annotated_regions,
2218 Some(vec![TextSpanRegion {
2219 start_index: Some(0),
2220 end_index: Some(1)
2221 }])
2222 );
2223 }
2224
2225 #[test]
2226 fn parse_citations_unknown_type_still_produces_empty_annotation() {
2227 let block = json!({
2231 "type": "text",
2232 "text": "cited",
2233 "citations": [{ "type": "some_future_citation_type" }]
2234 });
2235 let annotations = parse_citations(&block).unwrap();
2236 assert_eq!(annotations.len(), 1);
2237 assert_eq!(annotations[0], Annotation::default());
2238 }
2239
2240 #[test]
2241 fn parse_citations_absent_returns_none() {
2242 let block = json!({ "type": "text", "text": "no citations here" });
2243 assert_eq!(parse_citations(&block), None);
2244 }
2245
2246 #[test]
2247 fn parse_citations_empty_array_returns_none() {
2248 let block = json!({ "type": "text", "text": "no citations here", "citations": [] });
2249 assert_eq!(parse_citations(&block), None);
2250 }
2251
2252 #[test]
2253 fn parse_response_text_block_carries_citations_as_annotations() {
2254 let value = json!({
2255 "id": "msg_1",
2256 "content": [{
2257 "type": "text",
2258 "text": "the grass is green",
2259 "citations": [{
2260 "type": "char_location",
2261 "cited_text": "The grass is green.",
2262 "document_index": 0,
2263 "document_title": "Example Document",
2264 "start_char_index": 0,
2265 "end_char_index": 20,
2266 }]
2267 }]
2268 });
2269 let resp = parse_response(&value);
2270 match &resp.messages[0].contents[0] {
2271 Content::Text(t) => {
2272 assert_eq!(t.text, "the grass is green");
2273 assert!(t.annotations.is_some());
2274 }
2275 other => panic!("expected Text, got {other:?}"),
2276 }
2277 }
2278
2279 }