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