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