Skip to main content

potato_type/anthropic/v1/
response.rs

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/// Untagged enum for citation types in response content
109#[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/// Text block in response content with citations
120#[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/// Web search tool result block
240#[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    /// Convert back to Python object
287    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        // Convert the response content block to a request content block parameter
310        let content_block_param = ContentBlockParam::from_response_content_block(&self.inner)?;
311
312        // Create a MessageParam with the converted content block
313        let message_param = MessageParam {
314            content: vec![content_block_param],
315            role: Role::Assistant.to_string(),
316        };
317
318        // Convert MessageParam to MessageNum
319        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        // Anthropic responses do not include log probabilities
476        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        // Should return an empty text block rather than panic
812        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}