Skip to main content

atomcode_core/provider/
claude.rs

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                            // Thinking blocks must come BEFORE text/tool_use
105                            // per Anthropic's spec; the API rejects requests
106                            // that interleave or trail thinking. Each block
107                            // carries the server-issued `signature` we
108                            // captured at receive time — required for echo
109                            // verification on the next turn.
110                            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                            // Should not appear on assistant role; skip.
138                        }
139                    }
140                }
141                Role::Tool => {
142                    // Both inline ToolResult and externalized ToolResultRef are
143                    // serialized the same way — ToolResultRef uses its summary.
144                    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// ── SSE deserialization structs ──────────────────────────────────────────────
168
169#[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    /// Set by `text_delta`. Some Anthropic-compatible proxies (notably
207    /// the deepseek-v4-pro Anthropic-style endpoint) also stash
208    /// thinking text here instead of in the spec-correct `thinking`
209    /// field — `thinking_delta` falls back to this to stay compatible.
210    text: Option<String>,
211    /// Set by `thinking_delta` (Anthropic spec field name; not `text`).
212    thinking: Option<String>,
213    /// Set by `signature_delta`. Anthropic emits the cryptographic
214    /// signature for a thinking block as one or more signature_delta
215    /// chunks during streaming; we concatenate them per content block.
216    signature: Option<String>,
217    partial_json: Option<String>,
218}
219
220// ── Request body construction ────────────────────────────────────────────────
221
222impl ClaudeProvider {
223    /// Build the JSON request body for the Claude API.
224    /// Extracted for testability — the same logic is used by chat_stream.
225    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            // Anthropic requires max_tokens >= budget when thinking enabled
247            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            // System prompt as array with cache_control breakpoint.
255            // Claude caches prefix: system → tools → messages.
256            // Marking system as cacheable ensures it's reused across turns
257            // when the content is stable (same session).
258            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                // Cache breakpoint on last tool: system + all tools form
278                // the stable prefix (tools don't change within a session).
279                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// ── LlmProvider impl ─────────────────────────────────────────────────────────
291
292#[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        // Local Claude-compatible servers (e.g. oMLX) sometimes authenticate via
312        // the OpenAI-style `Authorization: Bearer` header instead of `x-api-key`.
313        // Anthropic's official endpoint ignores unknown headers, so sending both
314        // is safe and unblocks local deployments without a separate provider type.
315        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            // Use byte buffer to properly handle UTF-8 characters that span chunk boundaries
351            let mut byte_buffer: Vec<u8> = Vec::with_capacity(4096);
352
353            // Per-message state for the current tool_use content block.
354            let mut tc_id = String::new();
355            let mut tc_name = String::new();
356            let mut tc_json = String::new();
357            // Per-message state for the current thinking content block.
358            // `in_thinking_block` gates which content_block_stop emits a
359            // ThinkingBlock event. text/signature buffers accumulate
360            // across `thinking_delta` / `signature_delta` chunks within
361            // one block and reset at content_block_stop.
362            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                // Convert bytes to string, keeping incomplete UTF-8 sequences for next chunk
394                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                                        // Spec field is `thinking`; some
454                                        // Anthropic-compat proxies put it
455                                        // in `text` instead — accept either.
456                                        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                                // Emit even when text is empty: Anthropic
497                                // sometimes sends a thinking block with
498                                // only signature (redacted thinking). The
499                                // signature still has to be echoed back
500                                // or the next request 400s.
501                                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                            // message_start nests usage under message.usage
510                            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                            // message_delta has top-level usage with output_tokens
522                            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
553/// Normalize a user-provided base_url to always end with `/v1/messages`.
554///
555/// Handles the same three cases as the OpenAI equivalent:
556///   - Already complete: `http://host/v1/messages` → kept as-is
557///   - Has `/v1`: `http://host/v1` or `http://host/v1/` → `http://host/v1/messages`
558///   - Bare host: `http://host:8000` → `http://host:8000/v1/messages`
559///
560/// This lets local Claude-compatible servers (e.g. oMLX) work without forcing
561/// the user to type the full path in the config.
562fn 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        // First tool should NOT have cache_control
665        assert!(
666            arr[0].get("cache_control").is_none(),
667            "First tool should not have cache_control"
668        );
669
670        // Last tool SHOULD have cache_control
671        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); // 16384 > 10000+4096, no adjustment
721    }
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); // bumped: 10000+4096 > 8000
730    }
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    /// Regression: AssistantWithToolCalls turns must emit recorded
742    /// thinking blocks (with their server-issued `signature`) as the
743    /// FIRST elements of the assistant `content` array. Anthropic
744    /// rejects requests with `400 The content[].thinking in the
745    /// thinking mode must be passed back to the API` whenever the
746    /// thinking blocks from a prior turn are missing or trail the
747    /// text/tool_use blocks. Previously claude.rs dropped them via
748    /// `..` destructuring of MessageContent.
749    #[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        // Order: thinking, thinking, text, tool_use.
783        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    /// AssistantWithToolCalls with no thinking blocks (older session,
797    /// non-Anthropic provider) must still serialise cleanly without
798    /// an empty leading element.
799    #[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); // 1 image + 1 text
849
850        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); // 2 images + 1 text
907        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}