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