Skip to main content

deepstrike_sdk/providers/
anthropic.rs

1use async_trait::async_trait;
2use deepstrike_core::context::renderer::RenderedContext;
3use deepstrike_core::runtime::session::ProviderReplay;
4use deepstrike_core::types::message::{Content, ContentPart, Role, ToolCall, ToolSchema};
5use futures::{Stream, StreamExt};
6use reqwest::Client;
7use serde_json::{Value, json};
8use std::collections::HashMap;
9use std::sync::{Arc, Mutex};
10
11use super::{LLMProvider, RuntimePolicy, StreamEvent};
12use crate::runtime::provider_replay::assistant_replay_key;
13use crate::{Error, Result};
14
15pub struct AnthropicProvider {
16    client: Client,
17    api_key: String,
18    model: String,
19    max_tokens: u32,
20    native_assistant_blocks: Mutex<HashMap<String, Vec<Value>>>,
21    stream_native_blocks: Arc<Mutex<HashMap<usize, Value>>>,
22}
23
24impl AnthropicProvider {
25    pub fn new(api_key: impl Into<String>) -> Self {
26        Self::with_model(api_key, "claude-sonnet-4-6")
27    }
28
29    pub fn with_model(api_key: impl Into<String>, model: impl Into<String>) -> Self {
30        Self {
31            client: Client::new(),
32            api_key: api_key.into(),
33            model: model.into(),
34            max_tokens: 8096,
35            native_assistant_blocks: Mutex::new(HashMap::new()),
36            stream_native_blocks: Arc::new(Mutex::new(HashMap::new())),
37        }
38    }
39
40    fn remember_native_blocks(&self, content: &str, tool_calls: &[ToolCall], blocks: Vec<Value>) {
41        if blocks.is_empty() {
42            return;
43        }
44        if tool_calls.is_empty()
45            && !blocks
46                .iter()
47                .any(|b| b.get("type").and_then(|v| v.as_str()) == Some("thinking"))
48        {
49            return;
50        }
51        self.native_assistant_blocks
52            .lock()
53            .unwrap()
54            .insert(assistant_replay_key(content, tool_calls), blocks);
55    }
56
57    fn context_to_anthropic(
58        &self,
59        context: &RenderedContext,
60        strategy: CacheBreakpointStrategy,
61    ) -> Result<(Option<Value>, Vec<Value>)> {
62        let native = self.native_assistant_blocks.lock().unwrap();
63        context_to_anthropic(context, strategy, |content, tool_calls| {
64            native
65                .get(&assistant_replay_key(content, tool_calls))
66                .cloned()
67        })
68    }
69}
70
71fn content_part_to_anthropic(part: &ContentPart) -> Result<Value> {
72    match part {
73        ContentPart::Text { text } => Ok(json!({ "type": "text", "text": text })),
74        ContentPart::Image {
75            url: Some(url),
76            data: None,
77            ..
78        } => Ok(json!({ "type": "image", "source": { "type": "url", "url": url } })),
79        ContentPart::Image {
80            data: Some(data),
81            media_type,
82            ..
83        } => {
84            let mt = media_type.as_deref().unwrap_or("image/png");
85            Ok(
86                json!({ "type": "image", "source": { "type": "base64", "media_type": mt, "data": data } }),
87            )
88        }
89        ContentPart::Image { .. } => Ok(json!({ "type": "text", "text": "" })),
90        ContentPart::Audio { .. } => Err(Error::Provider(
91            "UnsupportedModality: audio is not supported by anthropic".into(),
92        )),
93        ContentPart::ToolResult {
94            call_id,
95            output,
96            is_error,
97            ..
98        } => Ok(
99            json!({ "type": "tool_result", "tool_use_id": call_id.as_str(), "content": output, "is_error": is_error }),
100        ),
101    }
102}
103
104fn content_to_anthropic(content: &Content) -> Result<Value> {
105    match content {
106        Content::Text(s) => Ok(json!(s)),
107        Content::Parts(parts) => {
108            let blocks: Vec<Value> = parts
109                .iter()
110                .map(content_part_to_anthropic)
111                .collect::<Result<Vec<_>>>()?;
112            Ok(json!(blocks))
113        }
114    }
115}
116
117fn context_to_anthropic(
118    context: &RenderedContext,
119    strategy: CacheBreakpointStrategy,
120    native_replay: impl Fn(&str, &[ToolCall]) -> Option<Vec<Value>>,
121) -> Result<(Option<Value>, Vec<Value>)> {
122    let mut msgs = Vec::new();
123    for message in &context.turns {
124        if message.role == Role::Tool {
125            if let Content::Parts(parts) = &message.content {
126                let tool_results = parts
127                    .iter()
128                    .filter_map(|part| {
129                        if let ContentPart::ToolResult {
130                            call_id,
131                            output,
132                            is_error,
133                            ..
134                        } = part
135                        {
136                            Some(json!({
137                                "type": "tool_result",
138                                "tool_use_id": call_id.as_str(),
139                                "content": output,
140                                "is_error": is_error,
141                            }))
142                        } else {
143                            None
144                        }
145                    })
146                    .collect::<Vec<_>>();
147                if !tool_results.is_empty() {
148                    msgs.push(json!({ "role": "user", "content": tool_results }));
149                }
150            }
151            continue;
152        }
153
154        if message.role == Role::Assistant && !message.tool_calls.is_empty() {
155            let content = message.content.as_text().unwrap_or("");
156            if let Some(replay) = native_replay(content, &message.tool_calls) {
157                msgs.push(json!({ "role": "assistant", "content": replay }));
158                continue;
159            }
160            let mut blocks = Vec::new();
161            if !content.is_empty() {
162                blocks.push(json!({ "type": "text", "text": content }));
163            }
164            blocks.extend(message.tool_calls.iter().map(|tc| {
165                json!({
166                    "type": "tool_use",
167                    "id": tc.id.as_str(),
168                    "name": tc.name.as_str(),
169                    "input": tc.arguments.clone(),
170                })
171            }));
172            msgs.push(json!({ "role": "assistant", "content": blocks }));
173            continue;
174        }
175
176        let role = match message.role {
177            Role::User => "user",
178            Role::Assistant => "assistant",
179            Role::System => "assistant",
180            Role::Tool => unreachable!(),
181        };
182        msgs.push(json!({ "role": role, "content": content_to_anthropic(&message.content)? }));
183    }
184    apply_message_cache_control(&mut msgs, strategy);
185    // The volatile State turn is rendered AFTER the cache breakpoints, so the
186    // history prefix stays cacheable and the state is the cheap uncached tail.
187    // (When produced by an un-rebuilt binding, state_turn is None and the state
188    // is already inside `turns` — rendered as-is above.)
189    if let Some(state) = &context.state_turn {
190        let role = if state.role == Role::Assistant {
191            "assistant"
192        } else {
193            "user"
194        };
195        msgs.push(json!({ "role": role, "content": content_to_anthropic(&state.content)? }));
196    }
197    Ok((build_system(context, strategy), msgs))
198}
199
200/// Anthropic accepts at most this many cache_control breakpoints per request.
201const MAX_CACHE_BREAKPOINTS: usize = 4;
202/// Rolling cache breakpoints reserved for the message history (system uses ≤2).
203const MESSAGE_CACHE_BREAKPOINTS: usize = 2;
204
205/// Cache-control placement strategy. Mirrors the Node SDK's `CacheBreakpointStrategy`.
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum CacheBreakpointStrategy {
208    Default,
209    ToolsOnly,
210    SystemOnly,
211    FrozenPrefix,
212    None,
213}
214
215impl CacheBreakpointStrategy {
216    fn from_str(raw: &str) -> Self {
217        match raw {
218            "tools-only" => Self::ToolsOnly,
219            "system-only" => Self::SystemOnly,
220            "frozen-prefix" => Self::FrozenPrefix,
221            "none" => Self::None,
222            // "default" and every unrecognised value
223            _ => Self::Default,
224        }
225    }
226
227    fn emit_on_tools(self) -> bool {
228        matches!(self, Self::Default | Self::ToolsOnly)
229    }
230    fn emit_on_system(self) -> bool {
231        matches!(self, Self::Default | Self::SystemOnly)
232    }
233    fn emit_on_messages(self) -> bool {
234        matches!(self, Self::Default | Self::FrozenPrefix)
235    }
236    fn use_rolling_fallback(self) -> bool {
237        matches!(self, Self::Default)
238    }
239}
240
241/// Pull `cacheBreakpointStrategy` from per-call extensions; unrecognised → Default.
242fn resolve_cache_breakpoint_strategy(extensions: Option<&Value>) -> CacheBreakpointStrategy {
243    extensions
244        .and_then(|e| e.get("cacheBreakpointStrategy"))
245        .and_then(|v| v.as_str())
246        .map(CacheBreakpointStrategy::from_str)
247        .unwrap_or(CacheBreakpointStrategy::Default)
248}
249
250fn tools_to_anthropic(
251    tools: &[ToolSchema],
252    anchor_cache: bool,
253    strategy: CacheBreakpointStrategy,
254) -> Vec<Value> {
255    let last = tools.len().saturating_sub(1);
256    tools
257        .iter()
258        .enumerate()
259        .map(|(i, t)| {
260            let mut def = json!({
261                "name": t.name.as_str(),
262                "description": t.description,
263                "input_schema": t.parameters,
264            });
265            // Anchor a tool breakpoint only when the system blocks won't carry one;
266            // otherwise system_stable already caches the tools prefix (tools render
267            // first) and a redundant breakpoint would overrun the 4-slot budget.
268            if anchor_cache && strategy.emit_on_tools() && i == last {
269                def["cache_control"] = json!({ "type": "ephemeral" });
270            }
271            def
272        })
273        .collect()
274}
275
276/// Structured system blocks with cache_control when the kernel partitioned the
277/// prompt (system_stable / system_knowledge); else the flat system_text string
278/// (no breakpoint), or None.
279fn build_system(context: &RenderedContext, strategy: CacheBreakpointStrategy) -> Option<Value> {
280    if context.system_stable.is_empty() && context.system_knowledge.is_empty() {
281        return if context.system_text.is_empty() {
282            None
283        } else {
284            Some(json!(context.system_text))
285        };
286    }
287    let emit = strategy.emit_on_system();
288    let mut blocks = Vec::new();
289    if !context.system_stable.is_empty() {
290        let mut b = json!({ "type": "text", "text": context.system_stable });
291        if emit {
292            b["cache_control"] = json!({ "type": "ephemeral" });
293        }
294        blocks.push(b);
295    }
296    if !context.system_knowledge.is_empty() {
297        let mut b = json!({ "type": "text", "text": context.system_knowledge });
298        if emit {
299            b["cache_control"] = json!({ "type": "ephemeral" });
300        }
301        blocks.push(b);
302    }
303    if blocks.is_empty() {
304        None
305    } else {
306        Some(json!(blocks))
307    }
308}
309
310/// Roll cache breakpoints across the conversation tail so the message-history
311/// prefix is written once and re-read on later turns. Without this the cached
312/// prefix stops at the end of `system` and the whole tool-result history is
313/// re-billed at full input price every turn. Marks the final message plus the
314/// nearest preceding user turn (read anchor); a bare string body is promoted to
315/// a cache-bearing text block.
316fn apply_message_cache_control(msgs: &mut [Value], strategy: CacheBreakpointStrategy) {
317    if msgs.is_empty() || !strategy.emit_on_messages() {
318        return;
319    }
320    let last = msgs.len() - 1;
321    let mut targets = vec![last];
322    // Rust SDK currently has no `frozen_prefix_len` field on RenderedContext; only the rolling
323    // fallback applies, and only under Default strategy (FrozenPrefix degrades to last-message only).
324    if strategy.use_rolling_fallback() {
325        let mut i = last;
326        while i > 0 && targets.len() < MESSAGE_CACHE_BREAKPOINTS {
327            i -= 1;
328            if msgs[i].get("role").and_then(|v| v.as_str()) == Some("user") {
329                targets.push(i);
330            }
331        }
332    }
333    for idx in targets {
334        mark_last_block_cacheable(&mut msgs[idx]);
335    }
336}
337
338fn mark_last_block_cacheable(msg: &mut Value) {
339    let cache_control = json!({ "type": "ephemeral" });
340    match msg.get_mut("content") {
341        Some(Value::String(s)) => {
342            if s.is_empty() {
343                return; // don't synthesize an empty (API-rejected) text block
344            }
345            let text = s.clone();
346            msg["content"] =
347                json!([{ "type": "text", "text": text, "cache_control": cache_control }]);
348        }
349        Some(Value::Array(arr)) => {
350            if let Some(obj) = arr.last_mut().and_then(|b| b.as_object_mut()) {
351                obj.insert("cache_control".to_string(), cache_control);
352            }
353        }
354        _ => {}
355    }
356}
357
358/// Regression guard: fail before the API would reject the request for exceeding
359/// the cache_control breakpoint limit. Uses the worst-case message count, so it
360/// can only fire if a future change adds a system partition or raises the budget.
361fn assert_cache_budget(system: Option<&Value>, tool_count: usize) -> Result<()> {
362    let system_breakpoints = match system {
363        Some(Value::Array(a)) => a.len(),
364        _ => 0,
365    };
366    let is_array = matches!(system, Some(Value::Array(_)));
367    let tool_breakpoints = if tool_count > 0 && !is_array { 1 } else { 0 };
368    if system_breakpoints + tool_breakpoints + MESSAGE_CACHE_BREAKPOINTS > MAX_CACHE_BREAKPOINTS {
369        return Err(Error::Provider(format!(
370            "Anthropic cache_control budget exceeded: {system_breakpoints} system + {tool_breakpoints} tool + {MESSAGE_CACHE_BREAKPOINTS} message > {MAX_CACHE_BREAKPOINTS}"
371        )));
372    }
373    Ok(())
374}
375
376#[async_trait]
377impl LLMProvider for AnthropicProvider {
378    fn runtime_policy(&self) -> RuntimePolicy {
379        match self.model.as_str() {
380            "claude-opus-4-7" | "claude-opus-4-6" => RuntimePolicy {
381                max_turns: Some(50),
382                timeout_ms: None,
383            },
384            "claude-sonnet-4-6" => RuntimePolicy {
385                max_turns: Some(25),
386                timeout_ms: None,
387            },
388            "claude-haiku-4-5" | "claude-haiku-4-5-20251001" => RuntimePolicy {
389                max_turns: Some(15),
390                timeout_ms: None,
391            },
392            _ => RuntimePolicy::default(),
393        }
394    }
395
396    fn peek_provider_replay(
397        &self,
398        content: &str,
399        tool_calls: &[ToolCall],
400    ) -> Option<ProviderReplay> {
401        let blocks = self
402            .native_assistant_blocks
403            .lock()
404            .unwrap()
405            .get(&assistant_replay_key(content, tool_calls))?
406            .clone();
407        if blocks.is_empty() {
408            None
409        } else {
410            Some(ProviderReplay {
411                protocol: "anthropic-messages".into(),
412                provider: Some("anthropic".into()),
413                model: Some(self.model.clone()),
414                native_blocks: Some(blocks),
415                reasoning_content: None,
416                reasoning_details: None,
417                native_message: None,
418                tool_calls: None,
419            })
420        }
421    }
422
423    fn seed_provider_replay(
424        &self,
425        content: &str,
426        tool_calls: &[ToolCall],
427        replay: &ProviderReplay,
428    ) {
429        if let Some(blocks) = &replay.native_blocks {
430            if !blocks.is_empty() {
431                self.native_assistant_blocks
432                    .lock()
433                    .unwrap()
434                    .insert(assistant_replay_key(content, tool_calls), blocks.clone());
435            }
436        }
437    }
438
439    fn commit_stream_replay(&self, content: &str, tool_calls: &[ToolCall]) {
440        let blocks: Vec<Value> = {
441            let map = self.stream_native_blocks.lock().unwrap();
442            let mut indices: Vec<_> = map.keys().copied().collect();
443            indices.sort_unstable();
444            indices
445                .into_iter()
446                .filter_map(|idx| map.get(&idx).cloned())
447                .collect()
448        };
449        self.remember_native_blocks(content, tool_calls, blocks);
450    }
451
452    async fn stream(
453        &self,
454        context: &RenderedContext,
455        tools: &[ToolSchema],
456        extensions: Option<&Value>,
457        _state: Option<&super::ProviderRunState>,
458    ) -> Result<Box<dyn Stream<Item = Result<StreamEvent>> + Send + Unpin>> {
459        self.stream_native_blocks.lock().unwrap().clear();
460        let strategy = resolve_cache_breakpoint_strategy(extensions);
461        let (system, msgs) = self.context_to_anthropic(context, strategy)?;
462        // Anchor the tool breakpoint only when system is not structured blocks.
463        let tool_anchor = !matches!(&system, Some(Value::Array(_)));
464        assert_cache_budget(system.as_ref(), tools.len())?;
465        let mut body = json!({
466            "model": self.model,
467            "max_tokens": self.max_tokens,
468            "messages": msgs,
469            "stream": true,
470        });
471        if let Some(s) = system {
472            body["system"] = s;
473        }
474        if !tools.is_empty() {
475            body["tools"] = json!(tools_to_anthropic(tools, tool_anchor, strategy));
476        }
477        if let Some(ext) = extensions {
478            if ext
479                .get("enable_thinking")
480                .and_then(|v| v.as_bool())
481                .unwrap_or(false)
482            {
483                body["thinking"] = json!({ "type": "enabled", "budget_tokens": 8000 });
484            }
485        }
486
487        let resp = self
488            .client
489            .post("https://api.anthropic.com/v1/messages")
490            .header("x-api-key", &self.api_key)
491            .header("anthropic-version", "2023-06-01")
492            .header("content-type", "application/json")
493            .body(body.to_string())
494            .send()
495            .await
496            .map_err(|e| {
497                Error::from(super::ProviderError::transport("anthropic", e.to_string()))
498            })?;
499
500        if !resp.status().is_success() {
501            let status = resp.status().as_u16();
502            let text = resp.text().await.unwrap_or_default();
503            return Err(super::ProviderError::from_http("anthropic", status, text).into());
504        }
505
506        let byte_stream = resp.bytes_stream();
507        let stream = parse_anthropic_sse(byte_stream, self.stream_native_blocks.clone());
508        Ok(Box::new(Box::pin(stream)))
509    }
510}
511
512/// Extract raw usage components `(uncached_input, cache_read, cache_creation,
513/// output)` from an Anthropic usage object; absent fields default to 0. Returns
514/// None only when `usage` is not an object. Anthropic pins input + cache counts
515/// at message_start and reports cumulative output on message_delta, so callers
516/// max-accumulate these across events rather than trusting any single one.
517fn anthropic_usage_breakdown(usage: &Value) -> Option<(u32, u32, u32, u32)> {
518    if !usage.is_object() {
519        return None;
520    }
521    let field = |key: &str| usage.get(key).and_then(|v| v.as_u64()).unwrap_or(0) as u32;
522    Some((
523        field("input_tokens"),
524        field("cache_read_input_tokens"),
525        field("cache_creation_input_tokens"),
526        field("output_tokens"),
527    ))
528}
529
530fn parse_anthropic_sse(
531    byte_stream: impl Stream<Item = reqwest::Result<bytes::Bytes>> + Send + 'static,
532    native_blocks: Arc<Mutex<HashMap<usize, Value>>>,
533) -> impl Stream<Item = Result<StreamEvent>> + Send {
534    let mut buf = String::new();
535    let mut tool_blocks: std::collections::HashMap<usize, (String, String, String)> =
536        std::collections::HashMap::new();
537
538    futures::stream::unfold(
539        (
540            Box::pin(byte_stream),
541            buf,
542            tool_blocks,
543            native_blocks,
544            (0u32, 0u32, 0u32, 0u32),
545        ),
546        |(mut stream, mut buf, mut tool_blocks, native_blocks, mut acc)| async move {
547            loop {
548                if let Some(pos) = buf.find('\n') {
549                    let line = buf[..pos].trim().to_string();
550                    buf = buf[pos + 1..].to_string();
551
552                    if !line.starts_with("data: ") {
553                        continue;
554                    }
555                    let data = &line[6..];
556                    if data == "[DONE]" {
557                        return None;
558                    }
559
560                    let Ok(evt) = serde_json::from_str::<Value>(data) else {
561                        continue;
562                    };
563                    let kind = evt["type"].as_str().unwrap_or("");
564
565                    if kind == "content_block_start" {
566                        let idx = evt["index"].as_u64().unwrap_or(0) as usize;
567                        let cb = &evt["content_block"];
568                        native_blocks.lock().unwrap().insert(idx, cb.clone());
569                        if cb["type"] == "tool_use" {
570                            tool_blocks.insert(
571                                idx,
572                                (
573                                    cb["id"].as_str().unwrap_or("").to_string(),
574                                    cb["name"].as_str().unwrap_or("").to_string(),
575                                    String::new(),
576                                ),
577                            );
578                        }
579                    } else if kind == "content_block_delta" {
580                        let d = &evt["delta"];
581                        let idx = evt["index"].as_u64().unwrap_or(0) as usize;
582                        if d["type"] == "text_delta" {
583                            let delta = d["text"].as_str().unwrap_or("").to_string();
584                            if let Some(block) = native_blocks.lock().unwrap().get_mut(&idx) {
585                                let text = block.get("text").and_then(|v| v.as_str()).unwrap_or("");
586                                block["text"] = json!(format!("{text}{delta}"));
587                            }
588                            if !delta.is_empty() {
589                                return Some((
590                                    Ok(StreamEvent::TextDelta { delta }),
591                                    (stream, buf, tool_blocks, native_blocks, acc),
592                                ));
593                            }
594                        } else if d["type"] == "thinking_delta" {
595                            let delta = d["thinking"].as_str().unwrap_or("").to_string();
596                            if let Some(block) = native_blocks.lock().unwrap().get_mut(&idx) {
597                                let text =
598                                    block.get("thinking").and_then(|v| v.as_str()).unwrap_or("");
599                                block["thinking"] = json!(format!("{text}{delta}"));
600                            }
601                            if !delta.is_empty() {
602                                return Some((
603                                    Ok(StreamEvent::ThinkingDelta { delta }),
604                                    (stream, buf, tool_blocks, native_blocks, acc),
605                                ));
606                            }
607                        } else if d["type"] == "signature_delta" {
608                            if let Some(block) = native_blocks.lock().unwrap().get_mut(&idx) {
609                                let sig = block
610                                    .get("signature")
611                                    .and_then(|v| v.as_str())
612                                    .unwrap_or("");
613                                let delta = d["signature"].as_str().unwrap_or("");
614                                block["signature"] = json!(format!("{sig}{delta}"));
615                            }
616                        } else if d["type"] == "input_json_delta" {
617                            if let Some(tb) = tool_blocks.get_mut(&idx) {
618                                tb.2.push_str(d["partial_json"].as_str().unwrap_or(""));
619                            }
620                        }
621                    } else if kind == "content_block_stop" {
622                        let idx = evt["index"].as_u64().unwrap_or(0) as usize;
623                        if let Some((id, name, args_buf)) = tool_blocks.remove(&idx) {
624                            let arguments: Value = serde_json::from_str(&args_buf)
625                                .unwrap_or(Value::Object(Default::default()));
626                            if let Some(block) = native_blocks.lock().unwrap().get_mut(&idx) {
627                                block["input"] = arguments.clone();
628                            }
629                            return Some((
630                                Ok(StreamEvent::ToolCall {
631                                    id,
632                                    name,
633                                    arguments,
634                                }),
635                                (stream, buf, tool_blocks, native_blocks, acc),
636                            ));
637                        }
638                    } else if kind == "message_start" || kind == "message_delta" {
639                        let usage = evt
640                            .get("usage")
641                            .or_else(|| evt.get("message").and_then(|m| m.get("usage")));
642                        if let Some((uncached, cache_read, cache_creation, output)) =
643                            usage.and_then(anthropic_usage_breakdown)
644                        {
645                            // acc = (uncached, cache_read, cache_creation, output).
646                            // A message_delta omits input/cache (read as 0); max()
647                            // keeps the totals pinned at message_start while letting
648                            // the final cumulative output through.
649                            acc.0 = acc.0.max(uncached);
650                            acc.1 = acc.1.max(cache_read);
651                            acc.2 = acc.2.max(cache_creation);
652                            acc.3 = acc.3.max(output);
653                            let full_input = acc.0 + acc.1 + acc.2;
654                            // stop_reason rides on message_delta (the closing frame); `max_tokens`
655                            // drives the kernel's output-cap recovery.
656                            let stop_reason = evt
657                                .get("delta")
658                                .and_then(|d| d.get("stop_reason"))
659                                .and_then(|s| s.as_str())
660                                .map(|s| s.to_string());
661                            return Some((
662                                Ok(StreamEvent::Usage {
663                                    total_tokens: full_input + acc.3,
664                                    input_tokens: full_input,
665                                    output_tokens: acc.3,
666                                    cache_read_input_tokens: acc.1,
667                                    cache_creation_input_tokens: acc.2,
668                                    // I1: per-slot attribution not yet wired in Rust Anthropic
669                                    // provider; field reserved. Mirrors the field-presence
670                                    // contract across SDKs; non-Anthropic Rust providers also
671                                    // emit None. Wiring is left for a focused Rust iteration.
672                                    cache_read_input_tokens_by_slot: None,
673                                    stop_reason,
674                                }),
675                                (stream, buf, tool_blocks, native_blocks, acc),
676                            ));
677                        }
678                    }
679                    continue;
680                }
681
682                match stream.next().await {
683                    Some(Ok(chunk)) => {
684                        buf.push_str(&String::from_utf8_lossy(&chunk));
685                    }
686                    Some(Err(e)) => {
687                        return Some((
688                            Err(super::ProviderError::transport("anthropic", e.to_string()).into()),
689                            (stream, buf, tool_blocks, native_blocks, acc),
690                        ));
691                    }
692                    None => return None,
693                }
694            }
695        },
696    )
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702    use compact_str::CompactString;
703    use deepstrike_core::types::message::{ContentPart, Message, ToolCall};
704
705    #[test]
706    fn anthropic_usage_breakdown_extracts_raw_components() {
707        let usage = json!({
708            "input_tokens": 100,
709            "output_tokens": 50,
710            "cache_read_input_tokens": 900,
711            "cache_creation_input_tokens": 10,
712        });
713        // (uncached, cache_read, cache_creation, output)
714        assert_eq!(anthropic_usage_breakdown(&usage), Some((100, 900, 10, 50)));
715    }
716
717    #[test]
718    fn anthropic_usage_breakdown_defaults_absent_fields_to_zero() {
719        // A message_delta carries only the cumulative output; the rest read as 0
720        // so the caller's max-accumulator keeps the message_start input/cache.
721        let usage = json!({ "output_tokens": 50 });
722        assert_eq!(anthropic_usage_breakdown(&usage), Some((0, 0, 0, 50)));
723        // A non-object usage yields None.
724        assert_eq!(anthropic_usage_breakdown(&json!("nope")), None);
725    }
726
727    #[test]
728    fn context_replays_tool_calls_and_results_as_blocks() {
729        let context = RenderedContext {
730            system_text: "system rules".into(),
731            system_stable: "system rules".into(),
732            system_knowledge: String::new(),
733            turns: vec![
734                Message::user("What is the weather?"),
735                Message {
736                    role: Role::Assistant,
737                    content: Content::Text("I'll check.".into()),
738                    tool_calls: vec![ToolCall {
739                        id: CompactString::new("call_1"),
740                        name: CompactString::new("get_weather"),
741                        arguments: json!({ "city": "Shanghai" }),
742                    }],
743                    token_count: None,
744                },
745                Message::tool(vec![ContentPart::ToolResult {
746                    call_id: CompactString::new("call_1"),
747                    output: "sunny".into(),
748                    is_error: false,
749                    durable_content: None,
750                }]),
751            ],
752            state_turn: None,
753            frozen_prefix_len: None,
754            budget_overflow: None,
755        };
756
757        let (system, messages) =
758            context_to_anthropic(&context, CacheBreakpointStrategy::Default, |_, _| None).unwrap();
759        // system_stable present -> structured cache block, not a bare string.
760        assert_eq!(
761            system,
762            Some(json!([
763                { "type": "text", "text": "system rules", "cache_control": { "type": "ephemeral" } }
764            ]))
765        );
766        // The first user turn and the trailing tool-result turn carry rolling
767        // cache breakpoints; the assistant tool-use turn is untouched.
768        assert_eq!(
769            messages,
770            vec![
771                json!({ "role": "user", "content": [
772                    { "type": "text", "text": "What is the weather?", "cache_control": { "type": "ephemeral" } }
773                ] }),
774                json!({
775                    "role": "assistant",
776                    "content": [
777                        { "type": "text", "text": "I'll check." },
778                        {
779                            "type": "tool_use",
780                            "id": "call_1",
781                            "name": "get_weather",
782                            "input": { "city": "Shanghai" },
783                        },
784                    ],
785                }),
786                json!({
787                    "role": "user",
788                    "content": [{
789                        "type": "tool_result",
790                        "tool_use_id": "call_1",
791                        "content": "sunny",
792                        "is_error": false,
793                        "cache_control": { "type": "ephemeral" },
794                    }],
795                }),
796            ]
797        );
798    }
799
800    #[test]
801    fn budget_guard_passes_for_partitioned_system_with_tools() {
802        let context = RenderedContext {
803            system_text: "rules\nknowledge".into(),
804            system_stable: "rules".into(),
805            system_knowledge: "knowledge".into(),
806            turns: vec![Message::user("hi")],
807            state_turn: None,
808            frozen_prefix_len: None,
809            budget_overflow: None,
810        };
811        let (system, _msgs) =
812            context_to_anthropic(&context, CacheBreakpointStrategy::Default, |_, _| None).unwrap();
813        // 2 system + 2 message = 4 (tool breakpoint dropped) — at the limit, ok.
814        assert!(assert_cache_budget(system.as_ref(), 3).is_ok());
815    }
816
817    #[test]
818    fn state_turn_rendered_after_history_without_cache_control() {
819        // History is the cacheable prefix; the volatile state turn is the tail.
820        let context = RenderedContext {
821            system_text: String::new(),
822            system_stable: String::new(),
823            system_knowledge: String::new(),
824            turns: vec![
825                Message::user("earlier question"),
826                Message::assistant("earlier answer"),
827            ],
828            state_turn: Some(Message::user("[TASK STATE] goal: g\n\nProceed.")),
829            frozen_prefix_len: None,
830            budget_overflow: None,
831        };
832        let (_system, messages) =
833            context_to_anthropic(&context, CacheBreakpointStrategy::Default, |_, _| None).unwrap();
834        // history (2) + state (1) appended last
835        assert_eq!(messages.len(), 3);
836        assert_eq!(messages[2]["role"], "user");
837        assert!(
838            messages[2]["content"]
839                .as_str()
840                .unwrap()
841                .contains("[TASK STATE]")
842        );
843        // the state turn carries NO cache breakpoint (it is the uncached tail)
844        assert!(messages[2].get("cache_control").is_none());
845        // the last history turn DID get a breakpoint (read anchor) — it became a block array
846        assert!(messages[1]["content"].is_array() || messages[0]["content"].is_array());
847    }
848}