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 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
198const MAX_CACHE_BREAKPOINTS: usize = 4;
200const MESSAGE_CACHE_BREAKPOINTS: usize = 2;
202
203#[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 _ => 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
239fn 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 if anchor_cache && strategy.emit_on_tools() && i == last {
267 def["cache_control"] = json!({ "type": "ephemeral" });
268 }
269 def
270 })
271 .collect()
272}
273
274fn 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
311fn 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 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; }
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
359fn 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 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
548fn 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.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 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 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 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 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 let usage = json!({ "output_tokens": 50 });
810 assert_eq!(anthropic_usage_breakdown(&usage), Some((0, 0, 0, 50)));
811 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 assert_eq!(
848 system,
849 Some(json!([
850 { "type": "text", "text": "system rules", "cache_control": { "type": "ephemeral" } }
851 ]))
852 );
853 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 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 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 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 assert!(messages[2].get("cache_control").is_none());
932 assert!(messages[1]["content"].is_array() || messages[0]["content"].is_array());
934 }
935}