1use std::pin::Pin;
2
3use anyhow::{Context, Result};
4use async_trait::async_trait;
5use futures::stream::StreamExt;
6use futures::Stream;
7use reqwest::Client;
8use serde::Deserialize;
9use serde_json::json;
10
11use crate::config::provider::ProviderConfig;
12use crate::conversation::message::{Message, MessageContent, Role};
13use crate::stream::StreamEvent;
14use crate::tool::{ToolCall, ToolDef};
15
16use super::LlmProvider;
17
18pub struct ClaudeProvider {
19 client: Client,
20 api_key: String,
21 model: String,
22 base_url: String,
23 max_tokens: usize,
24 thinking_enabled: bool,
25 thinking_budget: u32,
26}
27
28impl ClaudeProvider {
29 pub fn new(config: &ProviderConfig) -> Result<Self> {
30 let api_key = config
31 .api_key
32 .clone()
33 .context("Claude provider requires an api_key")?;
34 let thinking_enabled = config.thinking_enabled.unwrap_or(false);
35 let thinking_budget = config.thinking_budget.unwrap_or(10_000);
36 Ok(Self {
37 client: super::build_http_client(config.user_agent.as_deref(), config.skip_tls_verify),
38 api_key,
39 model: config.model.clone(),
40 base_url: config
41 .base_url
42 .clone()
43 .unwrap_or_else(|| "https://api.anthropic.com".to_string()),
44 max_tokens: config
45 .max_tokens
46 .unwrap_or((config.context_window / 4).clamp(8_000, 16_384)),
47 thinking_enabled,
48 thinking_budget,
49 })
50 }
51
52 fn format_messages(messages: &[Message]) -> (Option<String>, Vec<serde_json::Value>) {
53 let mut system = None;
54 let mut msgs = Vec::new();
55
56 for m in messages {
57 match m.role {
58 Role::System => {
59 let text = match &m.content {
60 MessageContent::Text(s) => s.clone(),
61 _ => String::new(),
62 };
63 system = Some(text);
64 }
65 Role::User => {
66 let content = match &m.content {
67 MessageContent::Text(s) => json!(s),
68 MessageContent::MultiPart { text, images } => {
69 let mut parts: Vec<serde_json::Value> = Vec::new();
70 for img in images {
71 parts.push(json!({
72 "type": "image",
73 "source": {
74 "type": "base64",
75 "media_type": &img.media_type,
76 "data": &img.data,
77 }
78 }));
79 }
80 if let Some(t) = text {
81 parts.push(json!({"type": "text", "text": t}));
82 }
83 json!(parts)
84 }
85 _ => json!(""),
86 };
87 msgs.push(json!({"role": "user", "content": content}));
88 }
89 Role::Assistant => {
90 match &m.content {
91 MessageContent::Text(s) => {
92 msgs.push(json!({
93 "role": "assistant",
94 "content": [{"type": "text", "text": s}]
95 }));
96 }
97 MessageContent::AssistantWithToolCalls {
98 text,
99 tool_calls,
100 thinking_blocks,
101 ..
102 } => {
103 let mut parts: Vec<serde_json::Value> = Vec::new();
104 for tb in thinking_blocks {
111 parts.push(json!({
112 "type": "thinking",
113 "thinking": tb.text,
114 "signature": tb.signature,
115 }));
116 }
117 if let Some(t) = text {
118 if !t.is_empty() {
119 parts.push(json!({"type": "text", "text": t}));
120 }
121 }
122 for tc in tool_calls {
123 let input: serde_json::Value =
124 serde_json::from_str(&tc.arguments).unwrap_or(json!({}));
125 parts.push(json!({
126 "type": "tool_use",
127 "id": tc.id,
128 "name": tc.name,
129 "input": input,
130 }));
131 }
132 msgs.push(json!({"role": "assistant", "content": parts}));
133 }
134 MessageContent::ToolResult(_)
135 | MessageContent::ToolResultRef(_)
136 | MessageContent::MultiPart { .. } => {
137 }
139 }
140 }
141 Role::Tool => {
142 let (call_id, output) = match &m.content {
145 MessageContent::ToolResult(r) => (r.call_id.as_str(), r.output.as_str()),
146 MessageContent::ToolResultRef(r) => {
147 (r.call_id.as_str(), r.summary.as_str())
148 }
149 _ => continue,
150 };
151 msgs.push(json!({
152 "role": "user",
153 "content": [{
154 "type": "tool_result",
155 "tool_use_id": call_id,
156 "content": output,
157 }]
158 }));
159 }
160 }
161 }
162
163 (system, msgs)
164 }
165}
166
167#[derive(Deserialize)]
170struct ClaudeSSE {
171 #[serde(rename = "type")]
172 event_type: String,
173 content_block: Option<ContentBlock>,
174 delta: Option<ClaudeDelta>,
175 usage: Option<ClaudeUsage>,
176 message: Option<ClaudeMessage>,
177}
178
179#[derive(Deserialize)]
180struct ClaudeMessage {
181 usage: Option<ClaudeUsage>,
182}
183
184#[derive(Deserialize)]
185struct ClaudeUsage {
186 #[serde(default)]
187 input_tokens: usize,
188 #[serde(default)]
189 output_tokens: usize,
190 #[serde(default)]
191 cache_read_input_tokens: usize,
192}
193
194#[derive(Deserialize)]
195struct ContentBlock {
196 #[serde(rename = "type")]
197 block_type: String,
198 id: Option<String>,
199 name: Option<String>,
200}
201
202#[derive(Deserialize)]
203struct ClaudeDelta {
204 #[serde(rename = "type")]
205 delta_type: String,
206 text: Option<String>,
211 thinking: Option<String>,
213 signature: Option<String>,
217 partial_json: Option<String>,
218}
219
220impl ClaudeProvider {
223 fn build_request_body(
226 model: &str,
227 max_tokens: usize,
228 system: Option<String>,
229 msgs: Vec<serde_json::Value>,
230 tools: Option<&[ToolDef]>,
231 thinking_enabled: bool,
232 thinking_budget: u32,
233 ) -> serde_json::Value {
234 let mut body = json!({
235 "model": model,
236 "messages": msgs,
237 "max_tokens": max_tokens,
238 "stream": true,
239 });
240
241 if thinking_enabled {
242 body["thinking"] = json!({
243 "type": "enabled",
244 "budget_tokens": thinking_budget
245 });
246 let min_max = thinking_budget as usize + 4096;
248 if max_tokens < min_max {
249 body["max_tokens"] = json!(min_max);
250 }
251 }
252
253 if let Some(sys) = system {
254 body["system"] = json!([{
259 "type": "text",
260 "text": sys,
261 "cache_control": {"type": "ephemeral"}
262 }]);
263 }
264
265 if let Some(tool_defs) = tools {
266 if !tool_defs.is_empty() {
267 let mut tools_json: Vec<serde_json::Value> = tool_defs
268 .iter()
269 .map(|td| {
270 json!({
271 "name": td.name,
272 "description": td.description,
273 "input_schema": td.parameters,
274 })
275 })
276 .collect();
277 if let Some(last) = tools_json.last_mut() {
280 last["cache_control"] = json!({"type": "ephemeral"});
281 }
282 body["tools"] = json!(tools_json);
283 }
284 }
285
286 body
287 }
288}
289
290#[async_trait]
293impl LlmProvider for ClaudeProvider {
294 fn chat_stream(
295 &self,
296 messages: &[Message],
297 tools: Option<&[ToolDef]>,
298 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send>>> {
299 let (system, msgs) = Self::format_messages(messages);
300 let body = Self::build_request_body(
301 &self.model,
302 self.max_tokens,
303 system,
304 msgs,
305 tools,
306 self.thinking_enabled,
307 self.thinking_budget,
308 );
309
310 let url = normalize_claude_base_url(&self.base_url);
311 let request = self
316 .client
317 .post(&url)
318 .header("x-api-key", &self.api_key)
319 .header("authorization", format!("Bearer {}", self.api_key))
320 .header("anthropic-version", "2023-06-01")
321 .header("content-type", "application/json")
322 .json(&body);
323
324 let policy = crate::provider::retry::RetryPolicy::default_policy();
325
326 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
327
328 tokio::spawn(async move {
329 let response = match crate::provider::retry::send_with_retry(request, &policy).await {
330 Ok(resp) => resp,
331 Err(e) => {
332 let _ = tx.send(Ok(StreamEvent::Error(format!("Connection failed: {}", e))));
333 return;
334 }
335 };
336
337 if !response.status().is_success() {
338 let status = response.status();
339 let body = response.text().await.unwrap_or_default();
340 let msg = super::extract_error_message(&body);
341 let _ = tx.send(Ok(StreamEvent::Error(format!(
342 "Claude API error ({}): {}",
343 status, msg
344 ))));
345 return;
346 }
347
348 let mut buffer = String::new();
349 let mut byte_stream = response.bytes_stream();
350 let mut byte_buffer: Vec<u8> = Vec::with_capacity(4096);
352
353 let mut tc_id = String::new();
355 let mut tc_name = String::new();
356 let mut tc_json = String::new();
357 let mut in_thinking_block = false;
363 let mut thinking_text = String::new();
364 let mut thinking_signature = String::new();
365
366 loop {
367 let chunk = match tokio::time::timeout(
368 std::time::Duration::from_secs(120),
369 byte_stream.next(),
370 )
371 .await
372 {
373 Ok(Some(chunk)) => chunk,
374 Ok(None) => break,
375 Err(_) => {
376 let _ = tx.send(Ok(StreamEvent::Error(
377 "Stream timeout: no data received for 120 seconds".to_string(),
378 )));
379 return;
380 }
381 };
382
383 match chunk {
384 Ok(bytes) => {
385 byte_buffer.extend_from_slice(&bytes);
386 }
387 Err(e) => {
388 let _ = tx.send(Ok(StreamEvent::Error(e.to_string())));
389 return;
390 }
391 }
392
393 let text = match String::from_utf8(byte_buffer.clone()) {
395 Ok(s) => {
396 byte_buffer.clear();
397 s
398 }
399 Err(e) => {
400 let valid_len = e.utf8_error().valid_up_to();
401 if valid_len == 0 {
402 continue;
403 }
404 let valid = String::from_utf8_lossy(&byte_buffer[..valid_len]).to_string();
405 byte_buffer = byte_buffer[valid_len..].to_vec();
406 valid
407 }
408 };
409
410 buffer.push_str(&text);
411
412 while let Some(pos) = buffer.find('\n') {
413 let line = buffer[..pos].trim().to_string();
414 buffer = buffer[pos + 1..].to_string();
415
416 if !line.starts_with("data: ") {
417 continue;
418 }
419
420 let data = &line[6..];
421 let evt = match serde_json::from_str::<ClaudeSSE>(data) {
422 Ok(e) => e,
423 Err(_) => continue,
424 };
425
426 match evt.event_type.as_str() {
427 "content_block_start" => {
428 if let Some(block) = &evt.content_block {
429 if block.block_type == "tool_use" {
430 tc_id = block.id.clone().unwrap_or_default();
431 tc_name = block.name.clone().unwrap_or_default();
432 tc_json.clear();
433 let _ = tx.send(Ok(StreamEvent::ToolCallStart {
434 id: tc_id.clone(),
435 name: tc_name.clone(),
436 }));
437 } else if block.block_type == "thinking" {
438 in_thinking_block = true;
439 thinking_text.clear();
440 thinking_signature.clear();
441 }
442 }
443 }
444 "content_block_delta" => {
445 if let Some(delta) = &evt.delta {
446 match delta.delta_type.as_str() {
447 "text_delta" => {
448 if let Some(text) = &delta.text {
449 let _ = tx.send(Ok(StreamEvent::Delta(text.clone())));
450 }
451 }
452 "thinking_delta" => {
453 let chunk = delta
457 .thinking
458 .as_deref()
459 .or(delta.text.as_deref());
460 if let Some(text) = chunk {
461 thinking_text.push_str(text);
462 let _ = tx.send(Ok(StreamEvent::Reasoning(
463 text.to_string(),
464 )));
465 }
466 }
467 "signature_delta" => {
468 if let Some(sig) = &delta.signature {
469 thinking_signature.push_str(sig);
470 }
471 }
472 "input_json_delta" => {
473 if let Some(json_chunk) = &delta.partial_json {
474 tc_json.push_str(json_chunk);
475 let _ = tx.send(Ok(StreamEvent::ToolCallDelta(
476 json_chunk.clone(),
477 )));
478 }
479 }
480 _ => {}
481 }
482 }
483 }
484 "content_block_stop" => {
485 if !tc_id.is_empty() {
486 let _ = tx.send(Ok(StreamEvent::ToolCallDone(ToolCall {
487 id: tc_id.clone(),
488 name: tc_name.clone(),
489 arguments: tc_json.clone(),
490 })));
491 tc_id.clear();
492 tc_name.clear();
493 tc_json.clear();
494 }
495 if in_thinking_block {
496 let _ = tx.send(Ok(StreamEvent::ThinkingBlock {
502 text: std::mem::take(&mut thinking_text),
503 signature: std::mem::take(&mut thinking_signature),
504 }));
505 in_thinking_block = false;
506 }
507 }
508 "message_start" => {
509 if let Some(usage) = evt.message.as_ref().and_then(|m| m.usage.as_ref())
511 {
512 let _ =
513 tx.send(Ok(StreamEvent::Usage(crate::stream::TokenUsage {
514 prompt_tokens: usage.input_tokens,
515 completion_tokens: usage.output_tokens,
516 cached_tokens: usage.cache_read_input_tokens,
517 })));
518 }
519 }
520 "message_delta" => {
521 if let Some(usage) = &evt.usage {
523 let _ =
524 tx.send(Ok(StreamEvent::Usage(crate::stream::TokenUsage {
525 prompt_tokens: usage.input_tokens,
526 completion_tokens: usage.output_tokens,
527 cached_tokens: usage.cache_read_input_tokens,
528 })));
529 }
530 }
531 "message_stop" => {
532 let _ = tx.send(Ok(StreamEvent::Done { truncated: false }));
533 return;
534 }
535 _ => {}
536 }
537 }
538 }
539
540 let _ = tx.send(Ok(StreamEvent::Done { truncated: false }));
541 });
542
543 Ok(Box::pin(
544 tokio_stream::wrappers::UnboundedReceiverStream::new(rx),
545 ))
546 }
547
548 fn model_name(&self) -> &str {
549 &self.model
550 }
551}
552
553fn normalize_claude_base_url(base: &str) -> String {
563 let base = base.trim_end_matches('/');
564 if base.ends_with("/v1/messages") {
565 base.to_string()
566 } else if base.ends_with("/v1") {
567 format!("{}/messages", base)
568 } else {
569 format!("{}/v1/messages", base)
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use serde_json::json;
577
578 #[test]
579 fn normalize_claude_base_url_bare_host() {
580 assert_eq!(
581 normalize_claude_base_url("http://127.0.0.1:8000"),
582 "http://127.0.0.1:8000/v1/messages"
583 );
584 }
585
586 #[test]
587 fn normalize_claude_base_url_v1_suffix() {
588 assert_eq!(
589 normalize_claude_base_url("http://127.0.0.1:8000/v1"),
590 "http://127.0.0.1:8000/v1/messages"
591 );
592 assert_eq!(
593 normalize_claude_base_url("http://127.0.0.1:8000/v1/"),
594 "http://127.0.0.1:8000/v1/messages"
595 );
596 }
597
598 #[test]
599 fn normalize_claude_base_url_full_path_preserved() {
600 assert_eq!(
601 normalize_claude_base_url("https://api.anthropic.com/v1/messages"),
602 "https://api.anthropic.com/v1/messages"
603 );
604 }
605
606 #[test]
607 fn normalize_claude_base_url_official_default() {
608 assert_eq!(
609 normalize_claude_base_url("https://api.anthropic.com"),
610 "https://api.anthropic.com/v1/messages"
611 );
612 }
613
614 #[test]
615 fn test_system_prompt_has_cache_control() {
616 let body = ClaudeProvider::build_request_body(
617 "claude-sonnet-4-20250514",
618 8192,
619 Some("You are a helpful assistant.".to_string()),
620 vec![json!({"role": "user", "content": "hello"})],
621 None,
622 false,
623 10000,
624 );
625
626 let system = &body["system"];
627 assert!(system.is_array(), "system should be array, got: {}", system);
628 let block = &system[0];
629 assert_eq!(block["type"], "text");
630 assert_eq!(block["text"], "You are a helpful assistant.");
631 assert_eq!(block["cache_control"]["type"], "ephemeral");
632 }
633
634 #[test]
635 fn test_tools_last_has_cache_control() {
636 let tools = vec![
637 ToolDef {
638 name: "grep",
639 description: "Search".into(),
640 parameters: json!({"type": "object"}),
641 },
642 ToolDef {
643 name: "read_file",
644 description: "Read".into(),
645 parameters: json!({"type": "object"}),
646 },
647 ];
648
649 let body = ClaudeProvider::build_request_body(
650 "claude-sonnet-4-20250514",
651 8192,
652 Some("sys".to_string()),
653 vec![],
654 Some(&tools),
655 false,
656 10000,
657 );
658
659 let tools_json = &body["tools"];
660 assert!(tools_json.is_array());
661 let arr = tools_json.as_array().unwrap();
662 assert_eq!(arr.len(), 2);
663
664 assert!(
666 arr[0].get("cache_control").is_none(),
667 "First tool should not have cache_control"
668 );
669
670 assert_eq!(
672 arr[1]["cache_control"]["type"], "ephemeral",
673 "Last tool must have cache_control"
674 );
675 }
676
677 #[test]
678 fn test_single_tool_has_cache_control() {
679 let tools = vec![ToolDef {
680 name: "bash",
681 description: "Run".into(),
682 parameters: json!({"type": "object"}),
683 }];
684
685 let body = ClaudeProvider::build_request_body("model", 8192, None, vec![], Some(&tools), false, 10000);
686
687 let arr = body["tools"].as_array().unwrap();
688 assert_eq!(arr.len(), 1);
689 assert_eq!(arr[0]["cache_control"]["type"], "ephemeral");
690 }
691
692 #[test]
693 fn test_empty_tools_no_tools_field() {
694 let tools: Vec<ToolDef> = vec![];
695 let body = ClaudeProvider::build_request_body("model", 8192, None, vec![], Some(&tools), false, 10000);
696 assert!(
697 body.get("tools").is_none(),
698 "Empty tools should not add tools field"
699 );
700 }
701
702 #[test]
703 fn test_no_system_no_system_field() {
704 let body = ClaudeProvider::build_request_body("model", 8192, None, vec![], None, false, 10000);
705 assert!(
706 body.get("system").is_none(),
707 "No system prompt should not add system field"
708 );
709 }
710
711 #[test]
712 fn build_request_body_with_thinking() {
713 let body = ClaudeProvider::build_request_body(
714 "claude-sonnet-4", 16384,
715 Some("system".into()), vec![json!({"role":"user","content":"hi"})],
716 None, true, 10000,
717 );
718 assert_eq!(body["thinking"]["type"], "enabled");
719 assert_eq!(body["thinking"]["budget_tokens"], 10000);
720 assert_eq!(body["max_tokens"], 16384); }
722
723 #[test]
724 fn build_request_body_adjusts_max_tokens_for_thinking() {
725 let body = ClaudeProvider::build_request_body(
726 "claude-sonnet-4", 8000,
727 None, vec![], None, true, 10000,
728 );
729 assert_eq!(body["max_tokens"], 14096); }
731
732 #[test]
733 fn build_request_body_without_thinking() {
734 let body = ClaudeProvider::build_request_body(
735 "claude-sonnet-4", 16384,
736 None, vec![], None, false, 10000,
737 );
738 assert!(body.get("thinking").is_none());
739 }
740
741 #[test]
750 fn format_messages_assistant_with_tool_calls_emits_thinking_first() {
751 use crate::conversation::message::ThinkingBlock;
752 use crate::tool::ToolCall;
753
754 let messages = vec![Message {
755 role: Role::Assistant,
756 content: MessageContent::AssistantWithToolCalls {
757 text: Some("running ls".to_string()),
758 tool_calls: vec![ToolCall {
759 id: "tu_1".to_string(),
760 name: "Bash".to_string(),
761 arguments: r#"{"command":"ls"}"#.to_string(),
762 }],
763 reasoning_content: None,
764 thinking_blocks: vec![
765 ThinkingBlock {
766 text: "Let me think...".to_string(),
767 signature: "sig_abc123".to_string(),
768 },
769 ThinkingBlock {
770 text: "Running the command".to_string(),
771 signature: "sig_def456".to_string(),
772 },
773 ],
774 },
775 }];
776
777 let (_system, msgs) = ClaudeProvider::format_messages(&messages);
778 assert_eq!(msgs.len(), 1);
779 let content = msgs[0]["content"]
780 .as_array()
781 .expect("content should be array");
782 assert_eq!(content.len(), 4);
784 assert_eq!(content[0]["type"], "thinking");
785 assert_eq!(content[0]["thinking"], "Let me think...");
786 assert_eq!(content[0]["signature"], "sig_abc123");
787 assert_eq!(content[1]["type"], "thinking");
788 assert_eq!(content[1]["thinking"], "Running the command");
789 assert_eq!(content[1]["signature"], "sig_def456");
790 assert_eq!(content[2]["type"], "text");
791 assert_eq!(content[2]["text"], "running ls");
792 assert_eq!(content[3]["type"], "tool_use");
793 assert_eq!(content[3]["id"], "tu_1");
794 }
795
796 #[test]
800 fn format_messages_assistant_without_thinking_unchanged() {
801 use crate::tool::ToolCall;
802
803 let messages = vec![Message {
804 role: Role::Assistant,
805 content: MessageContent::AssistantWithToolCalls {
806 text: Some("ok".to_string()),
807 tool_calls: vec![ToolCall {
808 id: "tu_1".to_string(),
809 name: "Bash".to_string(),
810 arguments: "{}".to_string(),
811 }],
812 reasoning_content: None,
813 thinking_blocks: Vec::new(),
814 },
815 }];
816
817 let (_system, msgs) = ClaudeProvider::format_messages(&messages);
818 let content = msgs[0]["content"]
819 .as_array()
820 .expect("content should be array");
821 assert_eq!(content.len(), 2);
822 assert_eq!(content[0]["type"], "text");
823 assert_eq!(content[1]["type"], "tool_use");
824 }
825
826 #[test]
827 fn format_messages_multipart_produces_image_blocks() {
828 use crate::conversation::message::ImagePart;
829
830 let messages = vec![Message {
831 role: Role::User,
832 content: MessageContent::MultiPart {
833 text: Some("What is in this image?".to_string()),
834 images: vec![ImagePart {
835 media_type: "image/png".to_string(),
836 data: "aWdub3JlLXRoaXM=".to_string(),
837 }],
838 },
839 }];
840
841 let (_system, msgs) = ClaudeProvider::format_messages(&messages);
842 assert_eq!(msgs.len(), 1);
843
844 let user_msg = &msgs[0];
845 assert_eq!(user_msg["role"], "user");
846
847 let content = user_msg["content"].as_array().expect("content should be array");
848 assert_eq!(content.len(), 2); assert_eq!(content[0]["type"], "image");
851 assert_eq!(content[0]["source"]["type"], "base64");
852 assert_eq!(content[0]["source"]["media_type"], "image/png");
853 assert_eq!(content[0]["source"]["data"], "aWdub3JlLXRoaXM=");
854
855 assert_eq!(content[1]["type"], "text");
856 assert_eq!(content[1]["text"], "What is in this image?");
857 }
858
859 #[test]
860 fn format_messages_multipart_images_only_no_text_block() {
861 use crate::conversation::message::ImagePart;
862
863 let messages = vec![Message {
864 role: Role::User,
865 content: MessageContent::MultiPart {
866 text: None,
867 images: vec![ImagePart {
868 media_type: "image/jpeg".to_string(),
869 data: "c29tZS1kYXRh".to_string(),
870 }],
871 },
872 }];
873
874 let (_system, msgs) = ClaudeProvider::format_messages(&messages);
875 let content = msgs[0]["content"].as_array().expect("content should be array");
876
877 assert_eq!(content.len(), 1);
878 assert_eq!(content[0]["type"], "image");
879 assert_eq!(content[0]["source"]["media_type"], "image/jpeg");
880 }
881
882 #[test]
883 fn format_messages_multipart_multiple_images() {
884 use crate::conversation::message::ImagePart;
885
886 let messages = vec![Message {
887 role: Role::User,
888 content: MessageContent::MultiPart {
889 text: Some("compare".to_string()),
890 images: vec![
891 ImagePart {
892 media_type: "image/png".to_string(),
893 data: "aW1nMQ==".to_string(),
894 },
895 ImagePart {
896 media_type: "image/jpeg".to_string(),
897 data: "aW1nMg==".to_string(),
898 },
899 ],
900 },
901 }];
902
903 let (_system, msgs) = ClaudeProvider::format_messages(&messages);
904 let content = msgs[0]["content"].as_array().expect("content should be array");
905
906 assert_eq!(content.len(), 3); assert_eq!(content[0]["type"], "image");
908 assert_eq!(content[0]["source"]["data"], "aW1nMQ==");
909 assert_eq!(content[1]["type"], "image");
910 assert_eq!(content[1]["source"]["data"], "aW1nMg==");
911 assert_eq!(content[2]["type"], "text");
912 assert_eq!(content[2]["text"], "compare");
913 }
914}