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#[cfg(test)]
888mod tests {
889 use super::*;
890 use agent_framework_core::tools::ApprovalMode;
891
892 fn user(text: &str) -> Message {
893 Message::user(text)
894 }
895
896 #[test]
899 fn build_request_simple_text() {
900 let body = build_request(
901 &[user("Hello there")],
902 &ChatOptions::new(),
903 "claude-x",
904 4096,
905 false,
906 );
907 assert_eq!(
908 body,
909 json!({
910 "model": "claude-x",
911 "max_tokens": 4096,
912 "messages": [
913 { "role": "user", "content": [{ "type": "text", "text": "Hello there" }] }
914 ],
915 })
916 );
917 }
918
919 #[test]
920 fn build_request_extracts_leading_system_message() {
921 let messages = vec![Message::system("Be terse."), user("Hi")];
922 let body = build_request(&messages, &ChatOptions::new(), "claude-x", 4096, false);
923 assert_eq!(body["system"], json!("Be terse."));
924 assert_eq!(
925 body["messages"],
926 json!([{ "role": "user", "content": [{ "type": "text", "text": "Hi" }] }])
927 );
928 }
929
930 #[test]
931 fn build_request_combines_options_instructions_and_system_message() {
932 let messages = vec![Message::system("Also be nice."), user("Hi")];
933 let options = ChatOptions::new().with_instructions("Be terse.");
934 let body = build_request(&messages, &options, "claude-x", 4096, false);
935 assert_eq!(body["system"], json!("Be terse.\n\nAlso be nice."));
936 }
937
938 #[test]
939 fn build_request_tool_role_message_becomes_user_tool_result() {
940 let tool_msg = Message::with_contents(
941 Role::tool(),
942 vec![Content::FunctionResult(FunctionResultContent::new(
943 "call_1",
944 Some(json!("18C and sunny")),
945 ))],
946 );
947 let body = build_request(&[tool_msg], &ChatOptions::new(), "claude-x", 4096, false);
948 assert_eq!(
949 body["messages"],
950 json!([{
951 "role": "user",
952 "content": [{ "type": "tool_result", "tool_use_id": "call_1", "content": "18C and sunny" }]
953 }])
954 );
955 }
956
957 #[test]
958 fn build_request_tool_result_error_sets_is_error() {
959 let mut result = FunctionResultContent::new("call_1", None);
960 result.exception = Some("boom".into());
961 let tool_msg = Message::with_contents(Role::tool(), vec![Content::FunctionResult(result)]);
962 let body = build_request(&[tool_msg], &ChatOptions::new(), "claude-x", 4096, false);
963 assert_eq!(
964 body["messages"][0]["content"][0],
965 json!({ "type": "tool_result", "tool_use_id": "call_1", "content": "boom", "is_error": true })
966 );
967 }
968
969 #[test]
970 fn build_request_assistant_function_call() {
971 let call = FunctionCallContent::new(
972 "call_1",
973 "get_weather",
974 Some(FunctionArguments::Object(HashMap::from([(
975 "city".to_string(),
976 json!("Paris"),
977 )]))),
978 );
979 let assistant_msg =
980 Message::with_contents(Role::assistant(), vec![Content::FunctionCall(call)]);
981 let body = build_request(
982 &[assistant_msg],
983 &ChatOptions::new(),
984 "claude-x",
985 4096,
986 false,
987 );
988 assert_eq!(
989 body["messages"],
990 json!([
991 {
992 "role": "user",
993 "content": [{ "type": "text", "text": "(continuing the conversation)" }]
994 },
995 {
996 "role": "assistant",
997 "content": [{ "type": "tool_use", "id": "call_1", "name": "get_weather", "input": { "city": "Paris" } }]
998 }
999 ])
1000 );
1001 }
1002
1003 #[test]
1004 fn build_request_data_content_image_uses_embedded_base64() {
1005 let dc = DataContent::from_bytes(b"hello", "image/png");
1006 let msg = Message::with_contents(Role::user(), vec![Content::Data(dc.clone())]);
1007 let body = build_request(&[msg], &ChatOptions::new(), "claude-x", 4096, false);
1008 let (_, expected_data) = split_data_uri(&dc.uri).unwrap();
1009 assert_eq!(
1010 body["messages"][0]["content"][0],
1011 json!({ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": expected_data } })
1012 );
1013 }
1014
1015 #[test]
1016 fn build_request_uri_content_image_uses_url_source() {
1017 let uc = UriContent {
1018 uri: "https://example.com/cat.png".into(),
1019 media_type: "image/png".into(),
1020 };
1021 let msg = Message::with_contents(Role::user(), vec![Content::Uri(uc)]);
1022 let body = build_request(&[msg], &ChatOptions::new(), "claude-x", 4096, false);
1023 assert_eq!(
1024 body["messages"][0]["content"][0],
1025 json!({ "type": "image", "source": { "type": "url", "url": "https://example.com/cat.png" } })
1026 );
1027 }
1028
1029 #[test]
1030 fn build_request_tools_and_tool_choice() {
1031 let tool = ToolDefinition {
1032 name: "get_weather".into(),
1033 description: "Get the weather".into(),
1034 parameters: json!({ "type": "object", "properties": {} }),
1035 kind: ToolKind::Function,
1036 approval_mode: ApprovalMode::NeverRequire,
1037 executor: None,
1038 };
1039 let options = ChatOptions::new()
1040 .with_tool(tool)
1041 .with_tool_choice(ToolMode::Required(Some("get_weather".into())));
1042 let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1043 assert_eq!(
1044 body["tools"],
1045 json!([{ "type": "custom", "name": "get_weather", "description": "Get the weather", "input_schema": { "type": "object", "properties": {} } }])
1046 );
1047 assert_eq!(
1048 body["tool_choice"],
1049 json!({ "type": "tool", "name": "get_weather" })
1050 );
1051 }
1052
1053 #[test]
1054 fn build_request_tool_choice_auto_with_disabled_parallel() {
1055 let mut options = ChatOptions::new().with_tool_choice(ToolMode::Auto);
1056 options.allow_multiple_tool_calls = Some(false);
1057 let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1058 assert_eq!(
1059 body["tool_choice"],
1060 json!({ "type": "auto", "disable_parallel_tool_use": true })
1061 );
1062 }
1063
1064 #[test]
1065 fn build_request_temperature_top_p_stop_sequences() {
1066 let mut options = ChatOptions::new().with_temperature(0.5);
1067 options.top_p = Some(0.9);
1068 options.stop = Some(vec!["STOP".into()]);
1069 let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1070 assert_eq!(body["temperature"], json!(0.5_f32));
1074 assert_eq!(body["top_p"], json!(0.9_f32));
1075 assert_eq!(body["stop_sequences"], json!(["STOP"]));
1076 }
1077
1078 #[test]
1079 fn build_request_stream_flag() {
1080 let body = build_request(&[user("hi")], &ChatOptions::new(), "claude-x", 4096, true);
1081 assert_eq!(body["stream"], json!(true));
1082 }
1083
1084 #[test]
1085 fn build_request_uses_given_max_tokens() {
1086 let body = build_request(&[user("hi")], &ChatOptions::new(), "claude-x", 2048, false);
1087 assert_eq!(body["max_tokens"], json!(2048));
1088 }
1089
1090 #[test]
1095 fn build_cloud_request_omits_model_and_sets_anthropic_version() {
1096 let body = build_cloud_request(
1097 &[user("hi")],
1098 &ChatOptions::new(),
1099 4096,
1100 false,
1101 "bedrock-2023-05-31",
1102 );
1103 assert!(body.get("model").is_none());
1104 assert_eq!(body["anthropic_version"], json!("bedrock-2023-05-31"));
1105 assert_eq!(body["max_tokens"], json!(4096));
1106 }
1107
1108 #[test]
1109 fn build_cloud_request_uses_given_anthropic_version() {
1110 let body = build_cloud_request(
1111 &[user("hi")],
1112 &ChatOptions::new(),
1113 4096,
1114 false,
1115 "vertex-2023-10-16",
1116 );
1117 assert_eq!(body["anthropic_version"], json!("vertex-2023-10-16"));
1118 }
1119
1120 #[test]
1121 fn build_cloud_request_messages_system_and_tools_match_build_request() {
1122 let tool = ToolDefinition {
1123 name: "get_weather".into(),
1124 description: "Get the weather".into(),
1125 parameters: json!({ "type": "object", "properties": {} }),
1126 kind: ToolKind::Function,
1127 approval_mode: ApprovalMode::NeverRequire,
1128 executor: None,
1129 };
1130 let messages = vec![Message::system("Be terse."), user("Hi")];
1131 let options = ChatOptions::new()
1132 .with_tool(tool)
1133 .with_tool_choice(ToolMode::Required(Some("get_weather".into())));
1134
1135 let direct = build_request(&messages, &options, "claude-x", 4096, false);
1136 let cloud = build_cloud_request(&messages, &options, 4096, false, "bedrock-2023-05-31");
1137
1138 assert_eq!(cloud["messages"], direct["messages"]);
1139 assert_eq!(cloud["system"], direct["system"]);
1140 assert_eq!(cloud["tools"], direct["tools"]);
1141 assert_eq!(cloud["tool_choice"], direct["tool_choice"]);
1142 }
1143
1144 #[test]
1145 fn build_cloud_request_stream_flag_and_additional_properties() {
1146 let mut options = ChatOptions::new();
1147 options
1148 .additional_properties
1149 .insert("top_k".into(), json!(5));
1150 let body = build_cloud_request(&[user("hi")], &options, 4096, true, "foundry-2025-01-01");
1151 assert_eq!(body["stream"], json!(true));
1152 assert_eq!(body["top_k"], json!(5));
1153 }
1154
1155 #[test]
1165 fn build_request_response_format_none_leaves_system_untouched() {
1166 let body = build_request(&[user("hi")], &ChatOptions::new(), "claude-x", 4096, false);
1167 assert!(body.get("system").is_none());
1168 }
1169
1170 #[test]
1171 fn build_request_response_format_text_is_a_noop() {
1172 let mut options = ChatOptions::new();
1173 options.response_format = Some(ResponseFormat::Text);
1174 let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1175 assert!(body.get("system").is_none());
1176 }
1177
1178 #[test]
1179 fn build_request_response_format_json_object_appends_system_instruction() {
1180 let mut options = ChatOptions::new();
1181 options.response_format = Some(ResponseFormat::JsonObject);
1182 let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1183 let system = body["system"].as_str().expect("system must be a string");
1184 assert!(
1185 system.to_lowercase().contains("json"),
1186 "expected a JSON instruction, got: {system}"
1187 );
1188 }
1189
1190 #[test]
1191 fn build_request_response_format_json_schema_embeds_schema_in_system() {
1192 let mut options = ChatOptions::new();
1193 options.response_format = Some(ResponseFormat::json_schema(
1194 "Person",
1195 json!({ "type": "object", "properties": { "name": { "type": "string" } } }),
1196 ));
1197 let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1198 let system = body["system"].as_str().expect("system must be a string");
1199 assert!(system.contains("Person"), "system: {system}");
1200 assert!(system.contains("\"name\""), "system: {system}");
1201 assert!(system.contains("\"type\": \"object\""), "system: {system}");
1202 }
1203
1204 #[test]
1205 fn build_request_response_format_json_schema_appends_after_existing_system() {
1206 let messages = vec![Message::system("Be terse."), user("Hi")];
1209 let mut options = ChatOptions::new();
1210 options.response_format = Some(ResponseFormat::JsonObject);
1211 let body = build_request(&messages, &options, "claude-x", 4096, false);
1212 let system = body["system"].as_str().expect("system must be a string");
1213 assert!(
1214 system.starts_with("Be terse."),
1215 "existing system text must be preserved first: {system}"
1216 );
1217 assert!(system.to_lowercase().contains("json"), "system: {system}");
1218 }
1219
1220 #[test]
1225 fn parse_response_text_and_usage() {
1226 let value = json!({
1227 "id": "msg_123",
1228 "model": "claude-x",
1229 "stop_reason": "end_turn",
1230 "content": [{ "type": "text", "text": "Hello!" }],
1231 "usage": { "input_tokens": 10, "output_tokens": 5 },
1232 });
1233 let resp = parse_response(&value);
1234 assert_eq!(resp.response_id.as_deref(), Some("msg_123"));
1235 assert_eq!(resp.text(), "Hello!");
1236 assert_eq!(resp.finish_reason, Some(FinishReason::stop()));
1237 let usage = resp.usage_details.unwrap();
1238 assert_eq!(usage.input_token_count, Some(10));
1239 assert_eq!(usage.output_token_count, Some(5));
1240 assert_eq!(usage.total_token_count, Some(15));
1241 }
1242
1243 #[test]
1244 fn parse_response_tool_use() {
1245 let value = json!({
1246 "id": "msg_123",
1247 "stop_reason": "tool_use",
1248 "content": [
1249 { "type": "text", "text": "Let me check." },
1250 { "type": "tool_use", "id": "call_1", "name": "get_weather", "input": { "city": "Paris" } },
1251 ],
1252 });
1253 let resp = parse_response(&value);
1254 assert_eq!(resp.finish_reason, Some(FinishReason::tool_calls()));
1255 let calls = resp.function_calls();
1256 assert_eq!(calls.len(), 1);
1257 assert_eq!(calls[0].call_id, "call_1");
1258 assert_eq!(calls[0].name, "get_weather");
1259 assert_eq!(
1260 calls[0].parse_arguments().unwrap().get("city").unwrap(),
1261 &json!("Paris")
1262 );
1263 }
1264
1265 #[test]
1266 fn parse_response_cache_usage_fields() {
1267 let value = json!({
1268 "id": "msg_123",
1269 "content": [],
1270 "usage": {
1271 "input_tokens": 100,
1272 "output_tokens": 10,
1273 "cache_creation_input_tokens": 50,
1274 "cache_read_input_tokens": 20,
1275 },
1276 });
1277 let resp = parse_response(&value);
1278 let usage = resp.usage_details.unwrap();
1279 assert_eq!(usage.cache_creation_input_token_count, Some(50));
1280 assert_eq!(usage.cache_read_input_token_count, Some(20));
1281 }
1282
1283 #[test]
1284 fn map_stop_reason_covers_documented_mapping() {
1285 assert_eq!(map_stop_reason("end_turn"), FinishReason::stop());
1286 assert_eq!(map_stop_reason("stop_sequence"), FinishReason::stop());
1287 assert_eq!(
1288 map_stop_reason("max_tokens"),
1289 FinishReason::new(FinishReason::LENGTH)
1290 );
1291 assert_eq!(map_stop_reason("tool_use"), FinishReason::tool_calls());
1292 }
1293
1294 #[test]
1295 fn message_start_usage_omits_output_tokens() {
1296 let usage = json!({ "input_tokens": 25, "output_tokens": 1 });
1297 let content = parse_message_start_usage(&usage).unwrap();
1298 assert_eq!(content.details.input_token_count, Some(25));
1299 assert_eq!(content.details.output_token_count, None);
1300 }
1301
1302 #[test]
1304 fn consecutive_same_role_messages_are_merged() {
1305 let msgs = vec![
1306 Message::user("first"),
1307 Message::user("second"),
1308 Message::assistant("reply"),
1309 Message::assistant("more"),
1310 Message::user("third"),
1311 ];
1312 let out = messages_to_anthropic(&msgs);
1313 assert_eq!(out.len(), 3);
1314 assert_eq!(out[0]["role"], "user");
1315 assert_eq!(out[0]["content"].as_array().unwrap().len(), 2);
1316 assert_eq!(out[1]["role"], "assistant");
1317 assert_eq!(out[1]["content"].as_array().unwrap().len(), 2);
1318 assert_eq!(out[2]["role"], "user");
1319 }
1320
1321 #[test]
1322 fn leading_assistant_message_gets_synthetic_user_turn() {
1323 let msgs = vec![Message::assistant("greeting"), Message::user("hello")];
1324 let out = messages_to_anthropic(&msgs);
1325 assert_eq!(out.len(), 3);
1326 assert_eq!(out[0]["role"], "user");
1327 assert_eq!(
1328 out[0]["content"][0]["text"],
1329 "(continuing the conversation)"
1330 );
1331 assert_eq!(out[1]["role"], "assistant");
1332 assert_eq!(out[2]["role"], "user");
1333 }
1334
1335 #[test]
1338 fn compute_beta_flags_default_includes_both_upstream_flags() {
1339 let mut options = ChatOptions::new();
1340 let flags = compute_beta_flags(&mut options, &[]);
1341 assert!(flags.contains(&"mcp-client-2025-04-04".to_string()));
1342 assert!(flags.contains(&"code-execution-2025-08-25".to_string()));
1343 assert_eq!(flags.len(), 2);
1344 }
1345
1346 #[test]
1347 fn compute_beta_flags_merges_client_level_additional_flags() {
1348 let mut options = ChatOptions::new();
1349 let flags = compute_beta_flags(&mut options, &["my-beta-flag".to_string()]);
1350 assert!(flags.contains(&"my-beta-flag".to_string()));
1351 assert_eq!(flags.len(), 3);
1352 }
1353
1354 #[test]
1355 fn compute_beta_flags_merges_and_removes_per_request_additional_flags() {
1356 let mut options = ChatOptions::new();
1357 options.additional_properties.insert(
1358 "additional_beta_flags".into(),
1359 json!(["request-level-flag"]),
1360 );
1361 let flags = compute_beta_flags(&mut options, &[]);
1362 assert!(flags.contains(&"request-level-flag".to_string()));
1363 assert!(!options
1366 .additional_properties
1367 .contains_key("additional_beta_flags"));
1368 }
1369
1370 #[test]
1371 fn compute_beta_flags_deduplicates_overlapping_flags() {
1372 let mut options = ChatOptions::new();
1373 options.additional_properties.insert(
1374 "additional_beta_flags".into(),
1375 json!(["mcp-client-2025-04-04"]),
1376 );
1377 let flags = compute_beta_flags(&mut options, &["mcp-client-2025-04-04".to_string()]);
1378 assert_eq!(flags.len(), 2);
1379 }
1380
1381 #[test]
1382 fn compute_beta_flags_does_not_leak_into_request_body() {
1383 let mut options = ChatOptions::new();
1384 options.additional_properties.insert(
1385 "additional_beta_flags".into(),
1386 json!(["request-level-flag"]),
1387 );
1388 let _ = compute_beta_flags(&mut options, &[]);
1389 let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1390 assert!(body.get("additional_beta_flags").is_none());
1391 }
1392
1393 fn make_tool(kind: ToolKind, name: &str, parameters: Value) -> ToolDefinition {
1398 ToolDefinition {
1399 name: name.into(),
1400 description: String::new(),
1401 parameters,
1402 kind,
1403 approval_mode: ApprovalMode::NeverRequire,
1404 executor: None,
1405 }
1406 }
1407
1408 #[test]
1409 fn tools_to_anthropic_web_search_basic() {
1410 let tool = make_tool(ToolKind::HostedWebSearch, "web_search", json!({}));
1411 let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1412 assert_eq!(
1413 tools,
1414 vec![json!({ "type": "web_search_20250305", "name": "web_search" })]
1415 );
1416 assert!(mcp_servers.is_empty());
1417 }
1418
1419 #[test]
1420 fn tools_to_anthropic_web_search_reads_max_uses_and_user_location_from_parameters() {
1421 let tool = make_tool(
1422 ToolKind::HostedWebSearch,
1423 "web_search",
1424 json!({ "max_uses": 3, "user_location": { "type": "approximate", "city": "Seattle" } }),
1425 );
1426 let (tools, _) = tools_to_anthropic(&[tool]);
1427 assert_eq!(
1428 tools[0],
1429 json!({
1430 "type": "web_search_20250305",
1431 "name": "web_search",
1432 "max_uses": 3,
1433 "user_location": { "type": "approximate", "city": "Seattle" },
1434 })
1435 );
1436 }
1437
1438 #[test]
1439 fn tools_to_anthropic_code_interpreter() {
1440 let tool = make_tool(
1441 ToolKind::HostedCodeInterpreter,
1442 "code_interpreter",
1443 json!({}),
1444 );
1445 let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1446 assert_eq!(
1447 tools,
1448 vec![json!({ "type": "code_execution_20250825", "name": "code_execution" })]
1449 );
1450 assert!(mcp_servers.is_empty());
1451 }
1452
1453 #[test]
1454 fn tools_to_anthropic_mcp_goes_to_mcp_servers_not_tools() {
1455 let tool = make_tool(
1456 ToolKind::HostedMcp {
1457 url: "https://example.com/mcp".into(),
1458 allowed_tools: None,
1459 },
1460 "my-mcp",
1461 json!({}),
1462 );
1463 let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1464 assert!(tools.is_empty());
1465 assert_eq!(
1466 mcp_servers,
1467 vec![json!({ "type": "url", "name": "my-mcp", "url": "https://example.com/mcp" })]
1468 );
1469 }
1470
1471 #[test]
1472 fn tools_to_anthropic_mcp_with_allowed_tools() {
1473 let tool = make_tool(
1474 ToolKind::HostedMcp {
1475 url: "https://example.com/mcp".into(),
1476 allowed_tools: Some(vec!["a".into(), "b".into()]),
1477 },
1478 "my-mcp",
1479 json!({}),
1480 );
1481 let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1482 assert_eq!(
1483 mcp_servers[0]["tool_configuration"],
1484 json!({ "allowed_tools": ["a", "b"] })
1485 );
1486 }
1487
1488 #[test]
1489 fn tools_to_anthropic_mcp_empty_allowed_tools_is_omitted() {
1490 let tool = make_tool(
1491 ToolKind::HostedMcp {
1492 url: "https://example.com/mcp".into(),
1493 allowed_tools: Some(vec![]),
1494 },
1495 "my-mcp",
1496 json!({}),
1497 );
1498 let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1499 assert!(mcp_servers[0].get("tool_configuration").is_none());
1500 }
1501
1502 #[test]
1503 fn tools_to_anthropic_mcp_authorization_header_becomes_authorization_token() {
1504 let tool = make_tool(
1505 ToolKind::HostedMcp {
1506 url: "https://example.com/mcp".into(),
1507 allowed_tools: None,
1508 },
1509 "my-mcp",
1510 json!({ "headers": { "authorization": "Bearer token123" } }),
1511 );
1512 let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1513 assert_eq!(
1514 mcp_servers[0]["authorization_token"],
1515 json!("Bearer token123")
1516 );
1517 }
1518
1519 #[test]
1520 fn tools_to_anthropic_mcp_authorization_header_lookup_is_case_insensitive() {
1521 let tool = make_tool(
1522 ToolKind::HostedMcp {
1523 url: "https://example.com/mcp".into(),
1524 allowed_tools: None,
1525 },
1526 "my-mcp",
1527 json!({ "headers": { "Authorization": "Bearer token456" } }),
1528 );
1529 let (_, mcp_servers) = tools_to_anthropic(&[tool]);
1530 assert_eq!(
1531 mcp_servers[0]["authorization_token"],
1532 json!("Bearer token456")
1533 );
1534 }
1535
1536 #[test]
1537 fn tools_to_anthropic_function_tool_has_custom_type() {
1538 let tool = make_tool(
1539 ToolKind::Function,
1540 "get_weather",
1541 json!({ "type": "object", "properties": {} }),
1542 );
1543 let (tools, _) = tools_to_anthropic(&[tool]);
1544 assert_eq!(tools[0]["type"], json!("custom"));
1545 }
1546
1547 #[test]
1548 fn tools_to_anthropic_unknown_hosted_kind_is_skipped() {
1549 let tool = make_tool(
1550 ToolKind::HostedFileSearch { max_results: None },
1551 "file_search",
1552 json!({}),
1553 );
1554 let (tools, mcp_servers) = tools_to_anthropic(&[tool]);
1555 assert!(tools.is_empty());
1556 assert!(mcp_servers.is_empty());
1557 }
1558
1559 #[test]
1560 fn tools_to_anthropic_mixed_tools_and_mcp_servers_both_populate_body() {
1561 let function_tool = make_tool(ToolKind::Function, "get_weather", json!({}));
1562 let mcp_tool = make_tool(
1563 ToolKind::HostedMcp {
1564 url: "https://example.com/mcp".into(),
1565 allowed_tools: None,
1566 },
1567 "my-mcp",
1568 json!({}),
1569 );
1570 let options = ChatOptions::new()
1571 .with_tool(function_tool)
1572 .with_tool(mcp_tool);
1573 let body = build_request(&[user("hi")], &options, "claude-x", 4096, false);
1574 assert_eq!(body["tools"].as_array().unwrap().len(), 1);
1575 assert_eq!(body["mcp_servers"].as_array().unwrap().len(), 1);
1576 }
1577
1578 #[test]
1583 fn parse_content_blocks_server_tool_use_is_function_call() {
1584 let blocks = vec![json!({
1585 "type": "server_tool_use",
1586 "id": "srvtoolu_1",
1587 "name": "web_search",
1588 "input": { "query": "rust" }
1589 })];
1590 let contents = parse_content_blocks(&blocks);
1591 assert_eq!(contents.len(), 1);
1592 match &contents[0] {
1593 Content::FunctionCall(fc) => {
1594 assert_eq!(fc.call_id, "srvtoolu_1");
1595 assert_eq!(fc.name, "web_search");
1596 assert_eq!(
1597 fc.parse_arguments().unwrap().get("query").unwrap(),
1598 &json!("rust")
1599 );
1600 }
1601 other => panic!("expected FunctionCall, got {other:?}"),
1602 }
1603 }
1604
1605 #[test]
1606 fn parse_content_blocks_mcp_tool_use_is_function_call() {
1607 let blocks = vec![json!({
1608 "type": "mcp_tool_use",
1609 "id": "mcptoolu_1",
1610 "name": "search_docs",
1611 "input": {}
1612 })];
1613 let contents = parse_content_blocks(&blocks);
1614 assert!(matches!(
1615 &contents[0],
1616 Content::FunctionCall(fc) if fc.call_id == "mcptoolu_1" && fc.name == "search_docs"
1617 ));
1618 }
1619
1620 #[test]
1621 fn parse_content_blocks_mcp_tool_result_with_list_content_is_recursively_parsed() {
1622 let blocks = vec![json!({
1623 "type": "mcp_tool_result",
1624 "tool_use_id": "mcptoolu_1",
1625 "is_error": false,
1626 "content": [{ "type": "text", "text": "result text" }]
1627 })];
1628 let contents = parse_content_blocks(&blocks);
1629 assert_eq!(contents.len(), 1);
1630 match &contents[0] {
1631 Content::FunctionResult(fr) => {
1632 assert_eq!(fr.call_id, "mcptoolu_1");
1633 assert_eq!(fr.exception, None);
1634 assert_eq!(
1637 fr.result,
1638 Some(json!([{ "type": "text", "text": "result text" }]))
1639 );
1640 }
1641 other => panic!("expected FunctionResult, got {other:?}"),
1642 }
1643 }
1644
1645 #[test]
1646 fn parse_content_blocks_mcp_tool_result_with_string_content_passes_through() {
1647 let blocks = vec![json!({
1648 "type": "mcp_tool_result",
1649 "tool_use_id": "mcptoolu_1",
1650 "content": "plain string result"
1651 })];
1652 let contents = parse_content_blocks(&blocks);
1653 match &contents[0] {
1654 Content::FunctionResult(fr) => {
1655 assert_eq!(fr.result, Some(json!("plain string result")));
1656 }
1657 other => panic!("expected FunctionResult, got {other:?}"),
1658 }
1659 }
1660
1661 #[test]
1662 fn parse_content_blocks_web_search_tool_result_is_not_recursively_parsed() {
1663 let blocks = vec![json!({
1664 "type": "web_search_tool_result",
1665 "tool_use_id": "srvtoolu_1",
1666 "content": [{ "type": "web_search_result", "url": "https://example.com", "title": "Example" }]
1667 })];
1668 let contents = parse_content_blocks(&blocks);
1669 match &contents[0] {
1670 Content::FunctionResult(fr) => {
1671 assert_eq!(fr.call_id, "srvtoolu_1");
1672 assert_eq!(
1674 fr.result,
1675 Some(
1676 json!([{ "type": "web_search_result", "url": "https://example.com", "title": "Example" }])
1677 )
1678 );
1679 }
1680 other => panic!("expected FunctionResult, got {other:?}"),
1681 }
1682 }
1683
1684 #[test]
1685 fn parse_content_blocks_web_fetch_tool_result_uses_same_mapping() {
1686 let blocks = vec![json!({
1687 "type": "web_fetch_tool_result",
1688 "tool_use_id": "srvtoolu_2",
1689 "content": { "type": "web_fetch_result", "url": "https://example.com" }
1690 })];
1691 let contents = parse_content_blocks(&blocks);
1692 assert_eq!(contents.len(), 1);
1693 assert!(matches!(&contents[0], Content::FunctionResult(fr) if fr.call_id == "srvtoolu_2"));
1694 }
1695
1696 #[test]
1697 fn parse_content_blocks_code_execution_tool_result_extracts_hosted_files_before_result() {
1698 let blocks = vec![json!({
1699 "type": "code_execution_tool_result",
1700 "tool_use_id": "srvtoolu_3",
1701 "content": {
1702 "type": "code_execution_result",
1703 "stdout": "",
1704 "stderr": "",
1705 "return_code": 0,
1706 "content": [
1707 { "type": "code_execution_output", "file_id": "file_abc" },
1708 { "type": "code_execution_output", "file_id": "file_def" }
1709 ]
1710 }
1711 })];
1712 let contents = parse_content_blocks(&blocks);
1713 assert_eq!(contents.len(), 3);
1714 assert_eq!(
1715 contents[0],
1716 Content::HostedFile(HostedFileContent {
1717 file_id: "file_abc".into()
1718 })
1719 );
1720 assert_eq!(
1721 contents[1],
1722 Content::HostedFile(HostedFileContent {
1723 file_id: "file_def".into()
1724 })
1725 );
1726 match &contents[2] {
1727 Content::FunctionResult(fr) => assert_eq!(fr.call_id, "srvtoolu_3"),
1728 other => panic!("expected FunctionResult, got {other:?}"),
1729 }
1730 }
1731
1732 #[test]
1733 fn parse_content_blocks_bash_code_execution_tool_result_extracts_hosted_files() {
1734 let blocks = vec![json!({
1735 "type": "bash_code_execution_tool_result",
1736 "tool_use_id": "srvtoolu_4",
1737 "content": {
1738 "type": "bash_code_execution_result",
1739 "stdout": "",
1740 "stderr": "",
1741 "return_code": 0,
1742 "content": [{ "type": "bash_code_execution_output", "file_id": "file_ghi" }]
1743 }
1744 })];
1745 let contents = parse_content_blocks(&blocks);
1746 assert_eq!(contents.len(), 2);
1747 assert_eq!(
1748 contents[0],
1749 Content::HostedFile(HostedFileContent {
1750 file_id: "file_ghi".into()
1751 })
1752 );
1753 }
1754
1755 #[test]
1756 fn parse_content_blocks_code_execution_tool_result_no_files_only_function_result() {
1757 let blocks = vec![json!({
1758 "type": "code_execution_tool_result",
1759 "tool_use_id": "srvtoolu_5",
1760 "content": { "type": "code_execution_result", "stdout": "hi", "stderr": "", "return_code": 0, "content": [] }
1761 })];
1762 let contents = parse_content_blocks(&blocks);
1763 assert_eq!(contents.len(), 1);
1764 assert!(matches!(&contents[0], Content::FunctionResult(_)));
1765 }
1766
1767 #[test]
1768 fn parse_content_blocks_text_editor_code_execution_tool_result_never_extracts_files() {
1769 let blocks = vec![json!({
1775 "type": "text_editor_code_execution_tool_result",
1776 "tool_use_id": "srvtoolu_6",
1777 "content": { "type": "text_editor_code_execution_view_result", "file_type": "text", "content": "print('hi')" }
1778 })];
1779 let contents = parse_content_blocks(&blocks);
1780 assert_eq!(contents.len(), 1);
1781 assert!(matches!(&contents[0], Content::FunctionResult(_)));
1782 }
1783
1784 #[test]
1785 fn parse_content_blocks_unknown_block_type_is_skipped() {
1786 let blocks = vec![json!({ "type": "totally_unknown_block" })];
1787 let contents = parse_content_blocks(&blocks);
1788 assert!(contents.is_empty());
1789 }
1790
1791 #[test]
1792 fn parse_response_includes_server_tool_use_and_web_search_result() {
1793 let value = json!({
1794 "id": "msg_1",
1795 "content": [
1796 { "type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": { "query": "rust" } },
1797 { "type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", "content": [{ "type": "web_search_result", "url": "https://example.com", "title": "Example" }] },
1798 ],
1799 });
1800 let resp = parse_response(&value);
1801 let contents = &resp.messages[0].contents;
1802 assert_eq!(contents.len(), 2);
1803 assert!(matches!(&contents[0], Content::FunctionCall(_)));
1804 assert!(matches!(&contents[1], Content::FunctionResult(_)));
1805 }
1806
1807 #[test]
1812 fn parse_citations_char_location() {
1813 let block = json!({
1814 "type": "text",
1815 "text": "cited",
1816 "citations": [{
1817 "type": "char_location",
1818 "cited_text": "The grass is green.",
1819 "document_index": 0,
1820 "document_title": "Example Document",
1821 "start_char_index": 0,
1822 "end_char_index": 20,
1823 }]
1824 });
1825 let annotations = parse_citations(&block).unwrap();
1826 assert_eq!(annotations.len(), 1);
1827 let cit = &annotations[0];
1828 assert_eq!(cit.title, None);
1833 assert_eq!(cit.snippet.as_deref(), Some("The grass is green."));
1834 assert_eq!(
1835 cit.annotated_regions,
1836 Some(vec![TextSpanRegion {
1837 start_index: Some(0),
1838 end_index: Some(20)
1839 }])
1840 );
1841 }
1842
1843 #[test]
1844 fn parse_citations_page_location_uses_document_title() {
1845 let block = json!({
1846 "type": "text",
1847 "text": "cited",
1848 "citations": [{
1849 "type": "page_location",
1850 "cited_text": "Water is essential for life.",
1851 "document_index": 1,
1852 "document_title": "PDF Document",
1853 "start_page_number": 5,
1854 "end_page_number": 6,
1855 }]
1856 });
1857 let annotations = parse_citations(&block).unwrap();
1858 let cit = &annotations[0];
1859 assert_eq!(cit.title.as_deref(), Some("PDF Document"));
1860 assert_eq!(cit.snippet.as_deref(), Some("Water is essential for life."));
1861 assert_eq!(
1862 cit.annotated_regions,
1863 Some(vec![TextSpanRegion {
1864 start_index: Some(5),
1865 end_index: Some(6)
1866 }])
1867 );
1868 }
1869
1870 #[test]
1871 fn parse_citations_content_block_location_uses_document_title() {
1872 let block = json!({
1873 "type": "text",
1874 "text": "cited",
1875 "citations": [{
1876 "type": "content_block_location",
1877 "cited_text": "These are important findings.",
1878 "document_index": 2,
1879 "document_title": "Custom Content Document",
1880 "start_block_index": 0,
1881 "end_block_index": 1,
1882 }]
1883 });
1884 let annotations = parse_citations(&block).unwrap();
1885 let cit = &annotations[0];
1886 assert_eq!(cit.title.as_deref(), Some("Custom Content Document"));
1887 assert_eq!(
1888 cit.annotated_regions,
1889 Some(vec![TextSpanRegion {
1890 start_index: Some(0),
1891 end_index: Some(1)
1892 }])
1893 );
1894 }
1895
1896 #[test]
1897 fn parse_citations_file_id_only_set_when_present() {
1898 let block = json!({
1899 "type": "text",
1900 "text": "cited",
1901 "citations": [{
1902 "type": "page_location",
1903 "cited_text": "text",
1904 "document_index": 0,
1905 "document_title": "Doc",
1906 "start_page_number": 1,
1907 "end_page_number": 2,
1908 "file_id": "file_123",
1909 }]
1910 });
1911 let annotations = parse_citations(&block).unwrap();
1912 assert_eq!(annotations[0].file_id.as_deref(), Some("file_123"));
1913 }
1914
1915 #[test]
1916 fn parse_citations_web_search_result_location() {
1917 let block = json!({
1918 "type": "text",
1919 "text": "cited",
1920 "citations": [{
1921 "type": "web_search_result_location",
1922 "cited_text": "some cited snippet",
1923 "url": "https://example.com/page",
1924 "title": "Example Page",
1925 "encrypted_index": "abc123",
1926 }]
1927 });
1928 let annotations = parse_citations(&block).unwrap();
1929 let cit = &annotations[0];
1930 assert_eq!(cit.title.as_deref(), Some("Example Page"));
1931 assert_eq!(cit.snippet.as_deref(), Some("some cited snippet"));
1932 assert_eq!(cit.url.as_deref(), Some("https://example.com/page"));
1933 assert_eq!(cit.annotated_regions, None);
1934 }
1935
1936 #[test]
1937 fn parse_citations_search_result_location_uses_source_as_url() {
1938 let block = json!({
1939 "type": "text",
1940 "text": "cited",
1941 "citations": [{
1942 "type": "search_result_location",
1943 "cited_text": "some cited snippet",
1944 "source": "https://example.com/doc",
1945 "title": "Search Result",
1946 "search_result_index": 0,
1947 "start_block_index": 0,
1948 "end_block_index": 1,
1949 }]
1950 });
1951 let annotations = parse_citations(&block).unwrap();
1952 let cit = &annotations[0];
1953 assert_eq!(cit.title.as_deref(), Some("Search Result"));
1954 assert_eq!(cit.url.as_deref(), Some("https://example.com/doc"));
1955 assert_eq!(
1956 cit.annotated_regions,
1957 Some(vec![TextSpanRegion {
1958 start_index: Some(0),
1959 end_index: Some(1)
1960 }])
1961 );
1962 }
1963
1964 #[test]
1965 fn parse_citations_unknown_type_still_produces_empty_annotation() {
1966 let block = json!({
1970 "type": "text",
1971 "text": "cited",
1972 "citations": [{ "type": "some_future_citation_type" }]
1973 });
1974 let annotations = parse_citations(&block).unwrap();
1975 assert_eq!(annotations.len(), 1);
1976 assert_eq!(annotations[0], Annotation::default());
1977 }
1978
1979 #[test]
1980 fn parse_citations_absent_returns_none() {
1981 let block = json!({ "type": "text", "text": "no citations here" });
1982 assert_eq!(parse_citations(&block), None);
1983 }
1984
1985 #[test]
1986 fn parse_citations_empty_array_returns_none() {
1987 let block = json!({ "type": "text", "text": "no citations here", "citations": [] });
1988 assert_eq!(parse_citations(&block), None);
1989 }
1990
1991 #[test]
1992 fn parse_response_text_block_carries_citations_as_annotations() {
1993 let value = json!({
1994 "id": "msg_1",
1995 "content": [{
1996 "type": "text",
1997 "text": "the grass is green",
1998 "citations": [{
1999 "type": "char_location",
2000 "cited_text": "The grass is green.",
2001 "document_index": 0,
2002 "document_title": "Example Document",
2003 "start_char_index": 0,
2004 "end_char_index": 20,
2005 }]
2006 }]
2007 });
2008 let resp = parse_response(&value);
2009 match &resp.messages[0].contents[0] {
2010 Content::Text(t) => {
2011 assert_eq!(t.text, "the grass is green");
2012 assert!(t.annotations.is_some());
2013 }
2014 other => panic!("expected Text, got {other:?}"),
2015 }
2016 }
2017
2018 }