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