1use crate::anthropic::v1::request::{ContentBlockParam, MessageParam};
2use crate::prompt::Role;
3use crate::prompt::{MessageNum, ResponseContent};
4use crate::traits::{MessageResponseExt, ResponseAdapter};
5use crate::TypeError;
6use potato_util::utils::{construct_structured_response, TokenLogProbs};
7use potato_util::PyHelperFuncs;
8use pyo3::prelude::*;
9use pyo3::IntoPyObjectExt;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
13#[pyclass(from_py_object)]
14pub struct CitationCharLocation {
15 #[pyo3(get, set)]
16 pub cited_text: String,
17 #[pyo3(get, set)]
18 pub document_index: i32,
19 #[pyo3(get, set)]
20 pub document_title: String,
21 #[pyo3(get, set)]
22 pub end_char_index: i32,
23 #[pyo3(get, set)]
24 pub file_id: String,
25 #[pyo3(get, set)]
26 pub start_char_index: i32,
27 #[pyo3(get)]
28 #[serde(rename = "type")]
29 pub r#type: String,
30}
31
32#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
33#[pyclass(from_py_object)]
34pub struct CitationPageLocation {
35 #[pyo3(get, set)]
36 pub cited_text: String,
37 #[pyo3(get, set)]
38 pub document_index: i32,
39 #[pyo3(get, set)]
40 pub document_title: String,
41 #[pyo3(get, set)]
42 pub end_page_number: i32,
43 #[pyo3(get, set)]
44 pub file_id: String,
45 #[pyo3(get, set)]
46 pub start_page_number: i32,
47 #[pyo3(get)]
48 #[serde(rename = "type")]
49 pub r#type: String,
50}
51
52#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
53#[pyclass(from_py_object)]
54pub struct CitationContentBlockLocation {
55 #[pyo3(get, set)]
56 pub cited_text: String,
57 #[pyo3(get, set)]
58 pub document_index: i32,
59 #[pyo3(get, set)]
60 pub document_title: String,
61 #[pyo3(get, set)]
62 pub end_block_index: i32,
63 #[pyo3(get, set)]
64 pub file_id: String,
65 #[pyo3(get, set)]
66 pub start_block_index: i32,
67 #[pyo3(get)]
68 #[serde(rename = "type")]
69 pub r#type: String,
70}
71
72#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
73#[pyclass(from_py_object)]
74pub struct CitationsWebSearchResultLocation {
75 #[pyo3(get, set)]
76 pub cited_text: String,
77 #[pyo3(get, set)]
78 pub encrypted_index: String,
79 #[pyo3(get, set)]
80 pub title: String,
81 #[pyo3(get)]
82 #[serde(rename = "type")]
83 pub r#type: String,
84 #[pyo3(get, set)]
85 pub url: String,
86}
87
88#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
89#[pyclass(from_py_object)]
90pub struct CitationsSearchResultLocation {
91 #[pyo3(get, set)]
92 pub cited_text: String,
93 #[pyo3(get, set)]
94 pub end_block_index: i32,
95 #[pyo3(get, set)]
96 pub search_result_index: i32,
97 #[pyo3(get, set)]
98 pub source: String,
99 #[pyo3(get, set)]
100 pub start_block_index: i32,
101 #[pyo3(get, set)]
102 pub title: String,
103 #[pyo3(get)]
104 #[serde(rename = "type")]
105 pub r#type: String,
106}
107
108#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
110#[serde(untagged)]
111pub enum TextCitation {
112 CharLocation(CitationCharLocation),
113 PageLocation(CitationPageLocation),
114 ContentBlockLocation(CitationContentBlockLocation),
115 WebSearchResultLocation(CitationsWebSearchResultLocation),
116 SearchResultLocation(CitationsSearchResultLocation),
117}
118
119#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
121#[pyclass(from_py_object)]
122pub struct TextBlock {
123 #[pyo3(get, set)]
124 pub text: String,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub citations: Option<Vec<TextCitation>>,
127 #[pyo3(get)]
128 #[serde(rename = "type")]
129 pub r#type: String,
130}
131
132#[pymethods]
133impl TextBlock {
134 #[getter]
135 pub fn citations<'py>(
136 &self,
137 py: Python<'py>,
138 ) -> Result<Option<Vec<Bound<'py, PyAny>>>, TypeError> {
139 match &self.citations {
140 None => Ok(None),
141 Some(cits) => {
142 let py_citations: Result<Vec<_>, _> = cits
143 .iter()
144 .map(|cit| match cit {
145 TextCitation::CharLocation(c) => c.clone().into_bound_py_any(py),
146 TextCitation::PageLocation(c) => c.clone().into_bound_py_any(py),
147 TextCitation::ContentBlockLocation(c) => c.clone().into_bound_py_any(py),
148 TextCitation::WebSearchResultLocation(c) => c.clone().into_bound_py_any(py),
149 TextCitation::SearchResultLocation(c) => c.clone().into_bound_py_any(py),
150 })
151 .collect();
152 Ok(Some(py_citations?))
153 }
154 }
155 }
156}
157
158#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
159#[pyclass(from_py_object)]
160pub struct ThinkingBlock {
161 #[pyo3(get, set)]
162 pub thinking: String,
163 #[pyo3(get, set)]
164 pub signature: Option<String>,
165 #[pyo3(get)]
166 #[serde(rename = "type")]
167 pub r#type: String,
168}
169
170#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
171#[pyclass(from_py_object)]
172pub struct RedactedThinkingBlock {
173 #[pyo3(get, set)]
174 pub data: String,
175 #[pyo3(get)]
176 #[serde(rename = "type")]
177 pub r#type: String,
178}
179
180#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
181#[pyclass(from_py_object)]
182pub struct ToolUseBlock {
183 #[pyo3(get, set)]
184 pub id: String,
185 #[pyo3(get, set)]
186 pub name: String,
187 pub input: Value,
188 #[pyo3(get)]
189 #[serde(rename = "type")]
190 pub r#type: String,
191}
192
193#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
194#[pyclass(from_py_object)]
195pub struct ServerToolUseBlock {
196 #[pyo3(get, set)]
197 pub id: String,
198 #[pyo3(get, set)]
199 pub name: String,
200 pub input: Value,
201 #[pyo3(get)]
202 #[serde(rename = "type")]
203 pub r#type: String,
204}
205
206#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
207#[pyclass(from_py_object)]
208pub struct WebSearchResultBlock {
209 #[pyo3(get, set)]
210 pub encrypted_content: String,
211 #[pyo3(get, set)]
212 pub page_age: Option<String>,
213 #[pyo3(get, set)]
214 pub title: String,
215 #[pyo3(get)]
216 #[serde(rename = "type")]
217 pub r#type: String,
218 #[pyo3(get, set)]
219 pub url: String,
220}
221
222#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
223#[pyclass(from_py_object)]
224pub struct WebSearchToolResultError {
225 #[pyo3(get, set)]
226 pub error_code: String,
227 #[pyo3(get)]
228 #[serde(rename = "type")]
229 pub r#type: String,
230}
231
232#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
233#[serde(untagged)]
234pub enum WebSearchToolResultBlockContent {
235 Error(WebSearchToolResultError),
236 Results(Vec<WebSearchResultBlock>),
237}
238
239#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
241#[pyclass(from_py_object)]
242pub struct WebSearchToolResultBlock {
243 pub content: WebSearchToolResultBlockContent,
244 #[pyo3(get, set)]
245 pub tool_use_id: String,
246 #[pyo3(get)]
247 #[serde(rename = "type")]
248 pub r#type: String,
249}
250
251#[pymethods]
252impl WebSearchToolResultBlock {
253 #[getter]
254 pub fn content<'py>(&self, py: Python<'py>) -> Result<Bound<'py, PyAny>, TypeError> {
255 match &self.content {
256 WebSearchToolResultBlockContent::Error(err) => Ok(err.clone().into_bound_py_any(py)?),
257 WebSearchToolResultBlockContent::Results(results) => {
258 let py_list: Result<Vec<_>, _> = results
259 .iter()
260 .map(|r| r.clone().into_bound_py_any(py))
261 .collect();
262 Ok(py_list?.into_bound_py_any(py)?)
263 }
264 }
265 }
266}
267
268#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
269#[serde(untagged)]
270pub(crate) enum ResponseContentBlockInner {
271 Text(TextBlock),
272 Thinking(ThinkingBlock),
273 RedactedThinking(RedactedThinkingBlock),
274 ToolUse(ToolUseBlock),
275 ServerToolUse(ServerToolUseBlock),
276 WebSearchToolResult(WebSearchToolResultBlock),
277}
278
279#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
280pub struct ResponseContentBlock {
281 #[serde(flatten)]
282 inner: ResponseContentBlockInner,
283}
284
285impl ResponseContentBlock {
286 pub fn to_pyobject<'py>(&self, py: Python<'py>) -> Result<Bound<'py, PyAny>, TypeError> {
288 match &self.inner {
289 ResponseContentBlockInner::Text(block) => Ok(block.clone().into_bound_py_any(py)?),
290 ResponseContentBlockInner::Thinking(block) => {
291 Ok(block.clone().into_bound_py_any(py)?)
292 }
293 ResponseContentBlockInner::RedactedThinking(block) => {
294 Ok(block.clone().into_bound_py_any(py)?)
295 }
296 ResponseContentBlockInner::ToolUse(block) => Ok(block.clone().into_bound_py_any(py)?),
297 ResponseContentBlockInner::ServerToolUse(block) => {
298 Ok(block.clone().into_bound_py_any(py)?)
299 }
300 ResponseContentBlockInner::WebSearchToolResult(block) => {
301 Ok(block.clone().into_bound_py_any(py)?)
302 }
303 }
304 }
305}
306
307impl MessageResponseExt for ResponseContentBlock {
308 fn to_message_num(&self) -> Result<MessageNum, TypeError> {
309 let content_block_param = ContentBlockParam::from_response_content_block(&self.inner)?;
311
312 let message_param = MessageParam {
314 content: vec![content_block_param],
315 role: Role::Assistant.to_string(),
316 };
317
318 Ok(MessageNum::AnthropicMessageV1(message_param))
320 }
321}
322
323#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
324#[serde(rename_all = "snake_case")]
325#[pyclass(from_py_object)]
326pub enum StopReason {
327 EndTurn,
328 MaxTokens,
329 StopSequence,
330 ToolUse,
331}
332
333#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
334#[pyclass(skip_from_py_object, name = "AnthropicUsage")]
335pub struct Usage {
336 #[pyo3(get)]
337 pub input_tokens: i32,
338 #[pyo3(get)]
339 pub output_tokens: i32,
340 #[serde(skip_serializing_if = "Option::is_none")]
341 #[pyo3(get)]
342 pub cache_creation_input_tokens: Option<i32>,
343 #[serde(skip_serializing_if = "Option::is_none")]
344 #[pyo3(get)]
345 pub cache_read_input_tokens: Option<i32>,
346 #[pyo3(get)]
347 pub service_tier: Option<String>,
348}
349
350#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
351#[pyclass(from_py_object)]
352pub struct AnthropicMessageResponse {
353 #[pyo3(get)]
354 pub id: String,
355 #[pyo3(get)]
356 pub model: String,
357 #[pyo3(get)]
358 pub role: String,
359 #[pyo3(get)]
360 pub stop_reason: Option<StopReason>,
361 #[pyo3(get)]
362 pub stop_sequence: Option<String>,
363 #[pyo3(get)]
364 pub r#type: String,
365 #[pyo3(get)]
366 pub usage: Usage,
367 pub content: Vec<ResponseContentBlock>,
368}
369
370#[pymethods]
371impl AnthropicMessageResponse {
372 #[getter]
373 pub fn content<'py>(&self, py: Python<'py>) -> Result<Vec<Bound<'py, PyAny>>, TypeError> {
374 self.content
375 .iter()
376 .map(|block| block.to_pyobject(py))
377 .collect::<Result<Vec<_>, _>>()
378 .map_err(|e| TypeError::Error(e.to_string()))
379 }
380}
381
382impl ResponseAdapter for AnthropicMessageResponse {
383 fn __str__(&self) -> String {
384 PyHelperFuncs::__str__(self)
385 }
386
387 fn is_empty(&self) -> bool {
388 self.content.is_empty()
389 }
390
391 fn to_bound_py_object<'py>(&self, py: Python<'py>) -> Result<Bound<'py, PyAny>, TypeError> {
392 Ok(PyHelperFuncs::to_bound_py_object(py, self)?)
393 }
394
395 fn id(&self) -> &str {
396 &self.id
397 }
398
399 fn to_message_num(&self) -> Result<Vec<MessageNum>, TypeError> {
400 let mut results = Vec::new();
401 for content in &self.content {
402 match content.to_message_num() {
403 Ok(msg) => results.push(msg),
404 Err(e) => return Err(e),
405 }
406 }
407 Ok(results)
408 }
409
410 fn get_content(&self) -> ResponseContent {
411 ResponseContent::Anthropic(self.content.first().cloned().unwrap_or_else(|| {
412 ResponseContentBlock {
413 inner: ResponseContentBlockInner::Text(TextBlock {
414 text: String::new(),
415 citations: None,
416 r#type: "text".to_string(),
417 }),
418 }
419 }))
420 }
421
422 fn tool_call_output(&self) -> Option<Value> {
423 for block in &self.content {
424 if let ResponseContentBlockInner::ToolUse(tool_use_block) = &block.inner {
425 return serde_json::to_value(tool_use_block).ok();
426 }
427 }
428 None
429 }
430
431 fn structured_output<'py>(
432 &self,
433 py: Python<'py>,
434 output_model: Option<&Bound<'py, PyAny>>,
435 ) -> Result<Bound<'py, PyAny>, TypeError> {
436 if self.content.is_empty() {
437 return Ok(py.None().into_bound_py_any(py)?);
438 }
439
440 let text = self
441 .content
442 .iter()
443 .rev()
444 .find_map(|block| {
445 if let ResponseContentBlockInner::Text(text_block) = &block.inner {
446 Some(text_block.text.clone())
447 } else {
448 None
449 }
450 })
451 .unwrap_or_default();
452
453 if text.is_empty() {
454 return Ok(py.None().into_bound_py_any(py)?);
455 }
456 Ok(construct_structured_response(py, text, output_model)?)
457 }
458
459 fn structured_output_value(&self) -> Option<Value> {
460 let text = self.content.iter().rev().find_map(|block| {
461 if let ResponseContentBlockInner::Text(text_block) = &block.inner {
462 Some(text_block.text.clone())
463 } else {
464 None
465 }
466 })?;
467 serde_json::from_str(&text).ok()
468 }
469
470 fn usage<'py>(&self, py: Python<'py>) -> Result<Bound<'py, PyAny>, TypeError> {
471 Ok(PyHelperFuncs::to_bound_py_object(py, &self.usage)?)
472 }
473
474 fn get_log_probs(&self) -> Vec<TokenLogProbs> {
475 Vec::new()
477 }
478
479 fn response_text(&self) -> String {
480 self.content
481 .iter()
482 .rev()
483 .find_map(|block| {
484 if let ResponseContentBlockInner::Text(text_block) = &block.inner {
485 Some(text_block.text.clone())
486 } else {
487 None
488 }
489 })
490 .unwrap_or_default()
491 }
492
493 fn model_name(&self) -> Option<&str> {
494 Some(&self.model)
495 }
496
497 fn finish_reason(&self) -> Option<&str> {
498 self.stop_reason.as_ref().map(|reason| match reason {
499 StopReason::EndTurn => "end_turn",
500 StopReason::MaxTokens => "max_tokens",
501 StopReason::StopSequence => "stop_sequence",
502 StopReason::ToolUse => "tool_use",
503 })
504 }
505
506 fn input_tokens(&self) -> Option<i64> {
507 Some(self.usage.input_tokens as i64)
508 }
509
510 fn output_tokens(&self) -> Option<i64> {
511 Some(self.usage.output_tokens as i64)
512 }
513
514 fn total_tokens(&self) -> Option<i64> {
515 Some(self.usage.input_tokens as i64 + self.usage.output_tokens as i64)
516 }
517
518 fn get_tool_calls(&self) -> Vec<crate::tools::ToolCallInfo> {
519 let mut tool_calls = Vec::new();
520 for block in &self.content {
521 if let ResponseContentBlockInner::ToolUse(tool_use_block) = &block.inner {
522 tool_calls.push(crate::tools::ToolCallInfo {
523 name: tool_use_block.name.clone(),
524 arguments: tool_use_block.input.clone(),
525 call_id: Some(tool_use_block.id.clone()),
526 result: None,
527 });
528 }
529 }
530 tool_calls
531 }
532
533 fn extract_tool_calls(&self) -> Option<Vec<crate::tools::ToolCall>> {
534 let calls: Vec<crate::tools::ToolCall> = self
535 .content
536 .iter()
537 .filter_map(|block| {
538 if let ResponseContentBlockInner::ToolUse(tu) = &block.inner {
539 Some(crate::tools::ToolCall {
540 tool_name: tu.name.clone(),
541 call_id: Some(tu.id.clone()),
542 arguments: tu.input.clone(),
543 })
544 } else {
545 None
546 }
547 })
548 .collect();
549 if calls.is_empty() {
550 None
551 } else {
552 Some(calls)
553 }
554 }
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560 use crate::traits::ResponseAdapter;
561
562 fn make_text_block(text: &str) -> ResponseContentBlock {
563 ResponseContentBlock {
564 inner: ResponseContentBlockInner::Text(TextBlock {
565 text: text.to_string(),
566 citations: None,
567 r#type: "text".to_string(),
568 }),
569 }
570 }
571
572 fn make_thinking_block(thinking: &str) -> ResponseContentBlock {
573 ResponseContentBlock {
574 inner: ResponseContentBlockInner::Thinking(ThinkingBlock {
575 thinking: thinking.to_string(),
576 signature: None,
577 r#type: "thinking".to_string(),
578 }),
579 }
580 }
581
582 fn make_tool_use_block(id: &str, name: &str, input: Value) -> ResponseContentBlock {
583 ResponseContentBlock {
584 inner: ResponseContentBlockInner::ToolUse(ToolUseBlock {
585 id: id.to_string(),
586 name: name.to_string(),
587 input,
588 r#type: "tool_use".to_string(),
589 }),
590 }
591 }
592
593 fn make_usage() -> Usage {
594 Usage {
595 input_tokens: 100,
596 output_tokens: 50,
597 cache_creation_input_tokens: Some(10),
598 cache_read_input_tokens: Some(5),
599 service_tier: None,
600 }
601 }
602
603 fn make_text_response(text: &str) -> AnthropicMessageResponse {
604 AnthropicMessageResponse {
605 id: "msg_abc123".to_string(),
606 model: "claude-sonnet-4-20250514".to_string(),
607 role: "assistant".to_string(),
608 stop_reason: Some(StopReason::EndTurn),
609 stop_sequence: None,
610 r#type: "message".to_string(),
611 usage: make_usage(),
612 content: vec![make_text_block(text)],
613 }
614 }
615
616 fn make_tool_use_response() -> AnthropicMessageResponse {
617 AnthropicMessageResponse {
618 id: "msg_tool456".to_string(),
619 model: "claude-sonnet-4-20250514".to_string(),
620 role: "assistant".to_string(),
621 stop_reason: Some(StopReason::ToolUse),
622 stop_sequence: None,
623 r#type: "message".to_string(),
624 usage: make_usage(),
625 content: vec![
626 make_text_block("I'll look up the weather for you."),
627 make_tool_use_block(
628 "toolu_01",
629 "get_weather",
630 serde_json::json!({"location": "NYC"}),
631 ),
632 ],
633 }
634 }
635
636 fn make_empty_response() -> AnthropicMessageResponse {
637 AnthropicMessageResponse {
638 id: "msg_empty".to_string(),
639 model: "claude-sonnet-4-20250514".to_string(),
640 role: "assistant".to_string(),
641 stop_reason: None,
642 stop_sequence: None,
643 r#type: "message".to_string(),
644 usage: make_usage(),
645 content: vec![],
646 }
647 }
648
649 #[test]
650 fn test_id() {
651 assert_eq!(make_text_response("hi").id(), "msg_abc123");
652 }
653
654 #[test]
655 fn test_is_empty() {
656 assert!(!make_text_response("hi").is_empty());
657 assert!(make_empty_response().is_empty());
658 }
659
660 #[test]
661 fn test_response_text() {
662 assert_eq!(
663 make_text_response("hello world").response_text(),
664 "hello world"
665 );
666 assert_eq!(make_empty_response().response_text(), "");
667 }
668
669 #[test]
670 fn test_response_text_tool_use_first_block() {
671 let resp = make_tool_use_response();
672 assert_eq!(resp.response_text(), "I'll look up the weather for you.");
673 }
674
675 #[test]
676 fn test_response_text_thinking_block_first() {
677 let resp = AnthropicMessageResponse {
678 id: "msg_think".to_string(),
679 model: "claude-sonnet-4-20250514".to_string(),
680 role: "assistant".to_string(),
681 stop_reason: Some(StopReason::EndTurn),
682 stop_sequence: None,
683 r#type: "message".to_string(),
684 usage: make_usage(),
685 content: vec![
686 make_thinking_block("let me think..."),
687 make_text_block("final"),
688 ],
689 };
690 assert_eq!(resp.response_text(), "final");
691 }
692
693 #[test]
694 fn test_response_text_only_thinking_block() {
695 let resp = AnthropicMessageResponse {
696 id: "msg_think_only".to_string(),
697 model: "claude-sonnet-4-20250514".to_string(),
698 role: "assistant".to_string(),
699 stop_reason: Some(StopReason::EndTurn),
700 stop_sequence: None,
701 r#type: "message".to_string(),
702 usage: make_usage(),
703 content: vec![make_thinking_block("only thinking, no text")],
704 };
705 assert_eq!(resp.response_text(), "");
706 }
707
708 #[test]
709 fn test_response_text_tool_use_then_text() {
710 let resp = AnthropicMessageResponse {
711 id: "msg_reversed".to_string(),
712 model: "claude-sonnet-4-20250514".to_string(),
713 role: "assistant".to_string(),
714 stop_reason: Some(StopReason::EndTurn),
715 stop_sequence: None,
716 r#type: "message".to_string(),
717 usage: make_usage(),
718 content: vec![
719 make_tool_use_block("toolu_01", "get_weather", serde_json::json!({})),
720 make_text_block("final answer"),
721 ],
722 };
723 assert_eq!(resp.response_text(), "final answer");
724 }
725
726 #[test]
727 fn test_model_name() {
728 assert_eq!(
729 make_text_response("x").model_name(),
730 Some("claude-sonnet-4-20250514")
731 );
732 }
733
734 #[test]
735 fn test_finish_reason() {
736 assert_eq!(make_text_response("x").finish_reason(), Some("end_turn"));
737 assert_eq!(make_tool_use_response().finish_reason(), Some("tool_use"));
738 assert_eq!(make_empty_response().finish_reason(), None);
739 }
740
741 #[test]
742 fn test_finish_reason_all_variants() {
743 let mut resp = make_text_response("x");
744 resp.stop_reason = Some(StopReason::MaxTokens);
745 assert_eq!(resp.finish_reason(), Some("max_tokens"));
746 resp.stop_reason = Some(StopReason::StopSequence);
747 assert_eq!(resp.finish_reason(), Some("stop_sequence"));
748 }
749
750 #[test]
751 fn test_token_counts() {
752 let resp = make_text_response("x");
753 assert_eq!(resp.input_tokens(), Some(100));
754 assert_eq!(resp.output_tokens(), Some(50));
755 assert_eq!(resp.total_tokens(), Some(150));
756 }
757
758 #[test]
759 fn test_get_tool_calls() {
760 let calls = make_tool_use_response().get_tool_calls();
761 assert_eq!(calls.len(), 1);
762 assert_eq!(calls[0].name, "get_weather");
763 assert_eq!(calls[0].call_id, Some("toolu_01".to_string()));
764 assert_eq!(calls[0].arguments, serde_json::json!({"location": "NYC"}));
765 }
766
767 #[test]
768 fn test_get_tool_calls_empty() {
769 assert!(make_text_response("hello").get_tool_calls().is_empty());
770 assert!(make_empty_response().get_tool_calls().is_empty());
771 }
772
773 #[test]
774 fn test_tool_call_output() {
775 let resp = make_tool_use_response();
776 let output = resp.tool_call_output();
777 assert!(output.is_some());
778 }
779
780 #[test]
781 fn test_tool_call_output_none_for_text() {
782 assert!(make_text_response("hello").tool_call_output().is_none());
783 }
784
785 #[test]
786 fn test_structured_output_value_valid_json() {
787 let resp = make_text_response(r#"{"name":"Bob","score":95}"#);
788 let val = resp.structured_output_value();
789 assert!(val.is_some());
790 let obj = val.unwrap();
791 assert_eq!(obj["name"], "Bob");
792 assert_eq!(obj["score"], 95);
793 }
794
795 #[test]
796 fn test_structured_output_value_plain_text() {
797 assert!(make_text_response("not json")
798 .structured_output_value()
799 .is_none());
800 }
801
802 #[test]
803 fn test_structured_output_value_empty() {
804 assert!(make_empty_response().structured_output_value().is_none());
805 }
806
807 #[test]
808 fn test_get_content_empty_response_no_panic() {
809 let resp = make_empty_response();
810 let content = resp.get_content();
811 matches!(content, ResponseContent::Anthropic(_));
813 }
814
815 #[test]
816 fn test_structured_output_thinking_block_first() {
817 let resp = AnthropicMessageResponse {
818 id: "msg_think_struct".to_string(),
819 model: "claude-sonnet-4-20250514".to_string(),
820 role: "assistant".to_string(),
821 stop_reason: Some(StopReason::EndTurn),
822 stop_sequence: None,
823 r#type: "message".to_string(),
824 usage: make_usage(),
825 content: vec![
826 make_thinking_block("let me think..."),
827 make_text_block(r#"{"name":"Alice","score":99}"#),
828 ],
829 };
830 let val = resp.structured_output_value();
831 assert!(val.is_some());
832 let obj = val.unwrap();
833 assert_eq!(obj["name"], "Alice");
834 assert_eq!(obj["score"], 99);
835 }
836
837 #[test]
838 fn test_get_log_probs_always_empty() {
839 assert!(make_text_response("x").get_log_probs().is_empty());
840 }
841
842 #[test]
843 fn test_to_message_num() {
844 let resp = make_text_response("hello");
845 let msgs = resp.to_message_num().unwrap();
846 assert_eq!(msgs.len(), 1);
847 }
848
849 #[test]
850 fn test_to_message_num_empty() {
851 let resp = make_empty_response();
852 let msgs = resp.to_message_num().unwrap();
853 assert!(msgs.is_empty());
854 }
855
856 #[test]
857 fn test_deserialize_from_json() {
858 let json = serde_json::json!({
859 "id": "msg_test",
860 "model": "claude-sonnet-4-20250514",
861 "role": "assistant",
862 "stop_reason": "end_turn",
863 "stop_sequence": null,
864 "type": "message",
865 "usage": {
866 "input_tokens": 25,
867 "output_tokens": 10
868 },
869 "content": [{
870 "type": "text",
871 "text": "Hello from Anthropic!"
872 }]
873 });
874 let resp: AnthropicMessageResponse = serde_json::from_value(json).unwrap();
875 assert_eq!(resp.response_text(), "Hello from Anthropic!");
876 assert_eq!(resp.model_name(), Some("claude-sonnet-4-20250514"));
877 assert_eq!(resp.finish_reason(), Some("end_turn"));
878 assert_eq!(resp.input_tokens(), Some(25));
879 assert_eq!(resp.output_tokens(), Some(10));
880 assert_eq!(resp.total_tokens(), Some(35));
881 }
882
883 #[test]
884 fn test_multiple_tool_use_blocks() {
885 let resp = AnthropicMessageResponse {
886 id: "msg_multi".to_string(),
887 model: "claude-sonnet-4-20250514".to_string(),
888 role: "assistant".to_string(),
889 stop_reason: Some(StopReason::ToolUse),
890 stop_sequence: None,
891 r#type: "message".to_string(),
892 usage: make_usage(),
893 content: vec![
894 make_tool_use_block("toolu_01", "search", serde_json::json!({"q": "rust"})),
895 make_tool_use_block("toolu_02", "read_file", serde_json::json!({"path": "/tmp"})),
896 ],
897 };
898 let calls = resp.get_tool_calls();
899 assert_eq!(calls.len(), 2);
900 assert_eq!(calls[0].name, "search");
901 assert_eq!(calls[1].name, "read_file");
902 }
903
904 #[test]
905 fn test_deserialize_tool_calls_from_json() {
906 let raw = r#"{
907 "id": "msg_01XFDUDYJgAACzvnptvVoYEL",
908 "type": "message",
909 "role": "assistant",
910 "model": "claude-sonnet-4-20250514",
911 "stop_reason": "tool_use",
912 "stop_sequence": null,
913 "usage": {
914 "input_tokens": 384,
915 "output_tokens": 120
916 },
917 "content": [
918 {
919 "type": "text",
920 "text": "I'll check the weather and stock price for you."
921 },
922 {
923 "type": "tool_use",
924 "id": "toolu_01A09q90qw90lq917835lq9",
925 "name": "get_weather",
926 "input": {"location": "San Francisco", "unit": "celsius"}
927 },
928 {
929 "type": "tool_use",
930 "id": "toolu_02B19r91rw91mr928946mr0",
931 "name": "get_stock_price",
932 "input": {"ticker": "AAPL"}
933 }
934 ]
935 }"#;
936
937 let resp: AnthropicMessageResponse = serde_json::from_str(raw).unwrap();
938 let tool_calls = resp.get_tool_calls();
939
940 assert_eq!(tool_calls.len(), 2);
941
942 assert_eq!(tool_calls[0].name, "get_weather");
943 assert_eq!(
944 tool_calls[0].call_id,
945 Some("toolu_01A09q90qw90lq917835lq9".to_string())
946 );
947 assert_eq!(
948 tool_calls[0].arguments,
949 serde_json::json!({"location": "San Francisco", "unit": "celsius"})
950 );
951 assert!(tool_calls[0].result.is_none());
952
953 assert_eq!(tool_calls[1].name, "get_stock_price");
954 assert_eq!(
955 tool_calls[1].call_id,
956 Some("toolu_02B19r91rw91mr928946mr0".to_string())
957 );
958 assert_eq!(
959 tool_calls[1].arguments,
960 serde_json::json!({"ticker": "AAPL"})
961 );
962 assert!(tool_calls[1].result.is_none());
963 }
964}