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