Skip to main content

deepstrike_sdk/providers/
anthropic.rs

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