//! Anthropic Request Builder //! //! Converts Open Responses native Item model to Anthropic Messages API format. use std::collections::BTreeSet; use serde::Serialize; use crate::llm_client::{ Request, capability::{CacheStrategy, ModelCapability, ReasoningControl, ReasoningSupport}, types::{ContentPart, Item, Role, ToolDefinition, parse_tool_arguments}, }; use super::AnthropicScheme; fn is_false(value: &bool) -> bool { !*value } /// Anthropic API request body #[derive(Debug, Serialize)] pub(crate) struct AnthropicRequest { pub model: String, pub max_tokens: u32, #[serde(skip_serializing_if = "Option::is_none")] pub system: Option, pub messages: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] pub tools: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub temperature: Option, #[serde(skip_serializing_if = "Option::is_none")] pub top_p: Option, #[serde(skip_serializing_if = "Option::is_none")] pub top_k: Option, #[serde(skip_serializing_if = "Vec::is_empty")] pub stop_sequences: Vec, pub stream: bool, #[serde(skip_serializing_if = "Option::is_none")] pub thinking: Option, } /// Anthropic extended thinking 指示。 #[derive(Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub(crate) enum AnthropicThinking { Enabled { budget_tokens: i32 }, } /// Anthropic message #[derive(Debug, Serialize)] pub(crate) struct AnthropicMessage { pub role: String, pub content: AnthropicContent, } /// Anthropic content #[derive(Debug, Serialize)] #[serde(untagged)] pub(crate) enum AnthropicContent { Text(String), Parts(Vec), } /// `cache_control` marker attached to a content part, signalling that the /// prefix up to and including this part should be cached. #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[serde(tag = "type", rename_all = "snake_case")] pub(crate) enum CacheControl { Ephemeral, } /// Anthropic content part #[derive(Debug, Serialize)] #[serde(tag = "type")] pub(crate) enum AnthropicContentPart { #[serde(rename = "text")] Text { text: String, #[serde(skip_serializing_if = "Option::is_none")] cache_control: Option, }, #[serde(rename = "thinking")] Thinking { thinking: String, signature: String, #[serde(skip_serializing_if = "Option::is_none")] cache_control: Option, }, #[serde(rename = "redacted_thinking")] RedactedThinking { /// 暗号化済み reasoning blob。`Item::Reasoning::encrypted_content` /// から渡る。 data: String, #[serde(skip_serializing_if = "Option::is_none")] cache_control: Option, }, #[serde(rename = "tool_use")] ToolUse { id: String, name: String, input: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] cache_control: Option, }, #[serde(rename = "tool_result")] ToolResult { tool_use_id: String, content: String, #[serde(default, skip_serializing_if = "is_false")] is_error: bool, #[serde(skip_serializing_if = "Option::is_none")] cache_control: Option, }, } impl AnthropicContentPart { fn text(text: String) -> Self { Self::Text { text, cache_control: None, } } fn thinking(thinking: String, signature: String) -> Self { Self::Thinking { thinking, signature, cache_control: None, } } fn redacted_thinking(data: String) -> Self { Self::RedactedThinking { data, cache_control: None, } } fn tool_use(id: String, name: String, input: serde_json::Value) -> Self { Self::ToolUse { id, name, input, cache_control: None, } } fn tool_result(tool_use_id: String, content: String, is_error: bool) -> Self { Self::ToolResult { tool_use_id, content, is_error, cache_control: None, } } fn set_cache_control(&mut self, cc: CacheControl) { match self { Self::Text { cache_control, .. } | Self::Thinking { cache_control, .. } | Self::RedactedThinking { cache_control, .. } | Self::ToolUse { cache_control, .. } | Self::ToolResult { cache_control, .. } => { *cache_control = Some(cc); } } } } /// Anthropic tool definition #[derive(Debug, Serialize)] pub(crate) struct AnthropicTool { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub input_schema: serde_json::Value, } impl AnthropicScheme { /// Build Anthropic request from Request. /// /// `capability.prompt_caching` が [`CacheStrategy::Auto`] のときは /// `cache_control` マーカーを一切挿入しない(Ollama の `/v1/messages` /// 流用時など、サーバ側が `cache_control` を受け付けないケース)。 pub(crate) fn build_request( &self, model: &str, request: &Request, capability: &ModelCapability, ) -> AnthropicRequest { let breakpoints = if matches!(capability.prompt_caching, CacheStrategy::Explicit { .. }) { compute_breakpoints(&request.items, request.cache_anchor) } else { BTreeSet::new() }; let messages = self.convert_items_to_messages(&request.items, &breakpoints); let tools = request.tools.iter().map(|t| self.convert_tool(t)).collect(); // Reasoning の投影: capability が BudgetTokens / Both をサポート // していて、request 側で budget_tokens が指定されているときだけ // thinking フィールドを付ける。 let supports_budget_tokens = matches!( capability.reasoning, Some(ReasoningSupport::BudgetTokens | ReasoningSupport::Both), ); let thinking = request .config .reasoning .as_ref() .filter(|_| supports_budget_tokens) .and_then(|rc| match rc { ReasoningControl::BudgetTokens(budget_tokens) => Some(AnthropicThinking::Enabled { budget_tokens: *budget_tokens, }), ReasoningControl::Effort(_) => None, }); AnthropicRequest { model: model.to_string(), max_tokens: request.config.max_tokens.unwrap_or(4096), system: request.system_prompt.clone(), messages, tools, temperature: request.config.temperature, top_p: request.config.top_p, top_k: request.config.top_k, stop_sequences: request.config.stop_sequences.clone(), stream: true, thinking, } } /// Convert Open Responses Items to Anthropic Messages and attach /// `cache_control` markers at each breakpoint item's last content /// part. /// /// Anthropic uses a message-based model where: /// - User messages have role "user" /// - Assistant messages have role "assistant" /// - Tool calls are content parts within assistant messages /// - Tool results are content parts within user messages /// /// Each non-`Message` item produces exactly one content part, so /// "last part for the item" is always well-defined. For breakpoint /// `Message` items the output is forced into the array form so a /// marker has a part to attach to. fn convert_items_to_messages( &self, items: &[Item], breakpoints: &BTreeSet, ) -> Vec { let mut messages = Vec::new(); // Pending parts carry their origin item index so we can record // (msg_idx, part_idx) when we flush them into a message. let mut pending_assistant: Vec<(usize, AnthropicContentPart)> = Vec::new(); let mut pending_user: Vec<(usize, AnthropicContentPart)> = Vec::new(); let mut locations: Vec> = vec![None; items.len()]; for (i, item) in items.iter().enumerate() { match item { Item::Message { role, content, .. } => { flush_pending( &mut messages, &mut pending_assistant, "assistant", &mut locations, ); flush_pending(&mut messages, &mut pending_user, "user", &mut locations); let anthropic_role = match role { Role::User | Role::System => "user", Role::Assistant => "assistant", }; let parts: Vec = content .iter() .map(|p| match p { ContentPart::Text { text } => AnthropicContentPart::text(text.clone()), ContentPart::Refusal { refusal } => { AnthropicContentPart::text(refusal.clone()) } }) .collect(); let force_parts = breakpoints.contains(&i); let msg_idx = messages.len(); // Preserve the single-text shorthand unless a // breakpoint needs a concrete part to live on. if parts.len() == 1 && !force_parts { if let AnthropicContentPart::Text { text, .. } = &parts[0] { messages.push(AnthropicMessage { role: anthropic_role.to_string(), content: AnthropicContent::Text(text.clone()), }); continue; } } let last_part_idx = parts.len().saturating_sub(1); messages.push(AnthropicMessage { role: anthropic_role.to_string(), content: AnthropicContent::Parts(parts), }); locations[i] = Some((msg_idx, last_part_idx)); } Item::ToolCall { call_id, name, arguments, .. } => { flush_pending(&mut messages, &mut pending_user, "user", &mut locations); // Parse arguments JSON string to Value (defensive: // normalize non-object / legacy "null" payloads to // `{}` so the Anthropic API accepts it). let input = parse_tool_arguments(arguments); pending_assistant.push(( i, AnthropicContentPart::tool_use(call_id.clone(), name.clone(), input), )); } Item::ToolResult { call_id, summary, content, is_error, .. } => { flush_pending( &mut messages, &mut pending_assistant, "assistant", &mut locations, ); let text = match content { Some(c) => format!("{summary}\n{c}"), None => summary.clone(), }; pending_user.push(( i, AnthropicContentPart::tool_result(call_id.clone(), text, *is_error), )); } Item::Reasoning { text, encrypted_content, signature, .. } => { flush_pending(&mut messages, &mut pending_user, "user", &mut locations); // Anthropic はアシスタントターン中の `thinking` / // `redacted_thinking` ブロックを必ず assistant role の // content_part として送り返す必要がある。 // // - signature あり: `thinking` content_part を投影 // - signature 無し + encrypted_content あり: // `redacted_thinking` content_part を投影 // - どちらも無い: 他 scheme(OpenAI 等)から流入した // 素の reasoning text。Anthropic に投げる意味も // round-trip の根拠も無いので drop。 if let Some(sig) = signature.clone() { pending_assistant .push((i, AnthropicContentPart::thinking(text.clone(), sig))); } else if let Some(data) = encrypted_content.clone() { pending_assistant.push((i, AnthropicContentPart::redacted_thinking(data))); } // どちらも None なら何も pend せず、本 item は無視。 } } } flush_pending( &mut messages, &mut pending_assistant, "assistant", &mut locations, ); flush_pending(&mut messages, &mut pending_user, "user", &mut locations); // Apply cache_control markers at each breakpoint item's last part. for &bp in breakpoints { let Some((msg_idx, part_idx)) = locations.get(bp).copied().flatten() else { continue; }; if let AnthropicContent::Parts(parts) = &mut messages[msg_idx].content { if let Some(part) = parts.get_mut(part_idx) { part.set_cache_control(CacheControl::Ephemeral); } } } messages } fn convert_tool(&self, tool: &ToolDefinition) -> AnthropicTool { AnthropicTool { name: tool.name.clone(), description: tool.description.clone(), input_schema: tool.input_schema.clone(), } } } /// Flush a pending parts buffer into a new message, recording the /// emitted `(msg_idx, part_idx)` for each originating item. fn flush_pending( messages: &mut Vec, pending: &mut Vec<(usize, AnthropicContentPart)>, role: &str, locations: &mut [Option<(usize, usize)>], ) { if pending.is_empty() { return; } let msg_idx = messages.len(); let taken: Vec<(usize, AnthropicContentPart)> = std::mem::take(pending); let mut parts = Vec::with_capacity(taken.len()); for (part_idx, (origin, part)) in taken.into_iter().enumerate() { locations[origin] = Some((msg_idx, part_idx)); parts.push(part); } messages.push(AnthropicMessage { role: role.to_string(), content: AnthropicContent::Parts(parts), }); } /// Compute the set of item indices that should receive a cache_control /// breakpoint: /// /// 1. `cache_anchor` (stable prefix boundary — typically a post-compact /// summary at index 0). /// 2. The item immediately preceding the most recent `Role::User` /// message (i.e. the end of the previous turn). This is the rewind /// boundary: if a user re-issues the turn from scratch, the cache up /// to here remains valid. /// 3. The final item (head of the outgoing request, for the next /// request in the same turn to read from). /// /// Overlapping positions are collapsed to a single breakpoint. fn compute_breakpoints(items: &[Item], cache_anchor: Option) -> BTreeSet { let mut bps = BTreeSet::new(); if items.is_empty() { return bps; } if let Some(anchor) = cache_anchor { if anchor < items.len() { bps.insert(anchor); } } let last_user = items.iter().rposition(|it| { matches!( it, Item::Message { role: Role::User, .. } ) }); if let Some(i) = last_user { if i > 0 { bps.insert(i - 1); } } bps.insert(items.len() - 1); bps } #[cfg(test)] mod tests { use super::*; use crate::llm_client::capability::{ CacheStrategy, ReasoningEffort, StructuredOutput, ToolCallingSupport, }; /// cache_control が有効になる既定の capability。 fn cap_explicit() -> ModelCapability { ModelCapability { tool_calling: ToolCallingSupport::Parallel, structured_output: StructuredOutput::JsonSchema, reasoning: None, vision: false, prompt_caching: CacheStrategy::Explicit { max_breakpoints: 4 }, } } /// cache_control を送らない capability(Ollama 等)。 fn cap_auto() -> ModelCapability { ModelCapability { prompt_caching: CacheStrategy::Auto, ..cap_explicit() } } fn cap_budget_reasoning() -> ModelCapability { ModelCapability { reasoning: Some(ReasoningSupport::BudgetTokens), ..cap_explicit() } } #[test] fn test_build_simple_request() { let scheme = AnthropicScheme::new(); let request = Request::new() .system("You are a helpful assistant.") .user("Hello!"); let anthropic_req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); assert_eq!(anthropic_req.model, "claude-sonnet-4-20250514"); assert_eq!( anthropic_req.system, Some("You are a helpful assistant.".to_string()) ); assert_eq!(anthropic_req.messages.len(), 1); assert!(anthropic_req.stream); } #[test] fn test_build_request_with_tool() { let scheme = AnthropicScheme::new(); let request = Request::new().user("What's the weather?").tool( ToolDefinition::new("get_weather") .description("Get current weather") .input_schema(serde_json::json!({ "type": "object", "properties": { "location": { "type": "string" } }, "required": ["location"] })), ); let anthropic_req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); assert_eq!(anthropic_req.tools.len(), 1); assert_eq!(anthropic_req.tools[0].name, "get_weather"); } #[test] fn thinking_budget_projected_when_supported() { let scheme = AnthropicScheme::new(); let mut request = Request::new().user("think"); request.config.reasoning = Some(ReasoningControl::BudgetTokens(4096)); let req = scheme.build_request( "claude-sonnet-4-20250514", &request, &cap_budget_reasoning(), ); let json = serde_json::to_value(&req).unwrap(); assert_eq!(json["thinking"]["type"], "enabled"); assert_eq!(json["thinking"]["budget_tokens"], 4096); } #[test] fn effort_reasoning_not_projected_to_anthropic() { let scheme = AnthropicScheme::new(); let mut request = Request::new().user("think"); request.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::High)); let req = scheme.build_request( "claude-sonnet-4-20250514", &request, &cap_budget_reasoning(), ); assert!(req.thinking.is_none()); } #[test] fn test_tool_call_and_result() { let scheme = AnthropicScheme::new(); let request = Request::new() .user("What's the weather?") .item(Item::tool_call( "call_123", "get_weather", r#"{"city":"Tokyo"}"#, )) .item(Item::tool_result("call_123", "Sunny, 25°C")); let anthropic_req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); assert_eq!(anthropic_req.messages.len(), 3); assert_eq!(anthropic_req.messages[0].role, "user"); assert_eq!(anthropic_req.messages[1].role, "assistant"); assert_eq!(anthropic_req.messages[2].role, "user"); } /// Pull out the `cache_control` field from a part regardless of variant. fn part_cache_control(part: &AnthropicContentPart) -> Option { match part { AnthropicContentPart::Text { cache_control, .. } | AnthropicContentPart::Thinking { cache_control, .. } | AnthropicContentPart::RedactedThinking { cache_control, .. } | AnthropicContentPart::ToolUse { cache_control, .. } | AnthropicContentPart::ToolResult { cache_control, .. } => *cache_control, } } /// All `(msg_idx, part_idx, cache_control)` triples whose `cache_control` /// is set, in iteration order over the output. fn breakpoint_positions(req: &AnthropicRequest) -> Vec<(usize, usize, CacheControl)> { let mut out = Vec::new(); for (mi, msg) in req.messages.iter().enumerate() { if let AnthropicContent::Parts(parts) = &msg.content { for (pi, part) in parts.iter().enumerate() { if let Some(cc) = part_cache_control(part) { out.push((mi, pi, cc)); } } } } out } /// Convenience: a turn that ends with one assistant text, one tool /// call/result pair, and a final assistant text. Produced at /// `history[head..]` indices shown alongside, so tests can reason /// about breakpoint positions. fn completed_turn() -> Vec { vec![ Item::user_message("hello"), // 0 Item::assistant_message("hi"), // 1 Item::tool_call("c1", "tool_a", "{}"), // 2 Item::tool_result("c1", "ok"), // 3 Item::assistant_message("done"), // 4 ] } #[test] fn three_breakpoints_when_compact_plus_prior_turn() { let scheme = AnthropicScheme::new(); let mut items = vec![Item::system_message("[Compacted context summary]\n\nprior")]; items.extend(completed_turn()); // now indices 1..=5 items.push(Item::user_message("next turn")); // anchor=0, last user idx=6 → turn_end=5, head=6. let mut request = Request::new().items(items); request.cache_anchor = Some(0); let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); let bps = breakpoint_positions(&req); assert_eq!(bps.len(), 3, "expected 3 breakpoints, got {:?}", bps); for (_, _, cc) in bps { assert_eq!(cc, CacheControl::Ephemeral); } } #[test] fn two_breakpoints_without_compaction() { let scheme = AnthropicScheme::new(); let mut items = completed_turn(); items.push(Item::user_message("next turn")); // index 5 = latest user // cache_anchor=None, turn_end=4, head=5. let request = Request::new().items(items); let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); let bps = breakpoint_positions(&req); assert_eq!(bps.len(), 2, "expected 2 breakpoints, got {:?}", bps); } #[test] fn single_breakpoint_when_only_first_user_message() { let scheme = AnthropicScheme::new(); let request = Request::new().user("first ever turn"); // latest user at 0 → no turn_end; head=0; no anchor. Collapse → 1. let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); let bps = breakpoint_positions(&req); assert_eq!(bps.len(), 1, "expected 1 breakpoint, got {:?}", bps); } #[test] fn overlap_collapses_anchor_and_turn_end() { // items = [compact_summary(0, Role::System), user(1)] // anchor=0, latest user=1 → turn_end=0, head=1. Anchor∩turn_end at 0. let scheme = AnthropicScheme::new(); let mut request = Request::new().items(vec![ Item::system_message("[Compacted context summary]\n\nprior"), Item::user_message("fresh user"), ]); request.cache_anchor = Some(0); let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); let bps = breakpoint_positions(&req); assert_eq!(bps.len(), 2, "expected collapse to 2, got {:?}", bps); } #[test] fn breakpoint_on_tool_result_head() { // Mid-turn second call: items end with a tool_result. Head must // land on the ToolResult part. let scheme = AnthropicScheme::new(); let request = Request::new() .user("run it") .item(Item::tool_call("c1", "t", "{}")) .item(Item::tool_result("c1", "result")); let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); let bps = breakpoint_positions(&req); assert_eq!(bps.len(), 1); let (mi, pi, _) = bps[0]; let part = match &req.messages[mi].content { AnthropicContent::Parts(parts) => &parts[pi], _ => panic!("expected Parts for breakpoint-bearing message"), }; assert!( matches!(part, AnthropicContentPart::ToolResult { .. }), "expected ToolResult, got {:?}", part, ); } #[test] fn single_text_message_uses_text_shorthand_without_breakpoint() { // Non-breakpoint single-text messages stay on the text shorthand // so we don't bloat requests with wrapper arrays. Here the Head // lands on items[1], leaving items[0] without a marker. let scheme = AnthropicScheme::new(); let request = Request::new().user("hello").assistant("hi there"); let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); assert!( matches!(req.messages[0].content, AnthropicContent::Text(_)), "non-breakpoint single-text message should use text shorthand", ); } #[test] fn single_text_message_is_forced_to_parts_when_breakpoint() { // A breakpoint on a single-text message must be emitted in the // array form so cache_control has a part to attach to. let scheme = AnthropicScheme::new(); let mut request = Request::new().user("hello"); request.cache_anchor = Some(0); let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); match &req.messages[0].content { AnthropicContent::Parts(parts) => { assert_eq!(parts.len(), 1); assert_eq!(part_cache_control(&parts[0]), Some(CacheControl::Ephemeral)); } AnthropicContent::Text(_) => panic!("breakpoint item should use Parts form"), } } #[test] fn serialized_json_shape_matches_anthropic_spec() { // Single-sentence smoke test against the exact JSON key shape // Anthropic expects for cache_control. let scheme = AnthropicScheme::new(); let mut request = Request::new().user("hello"); request.cache_anchor = Some(0); let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); let json = serde_json::to_value(&req).unwrap(); let part = &json["messages"][0]["content"][0]; assert_eq!(part["type"], "text"); assert_eq!(part["text"], "hello"); assert_eq!(part["cache_control"]["type"], "ephemeral"); } #[test] fn cache_anchor_out_of_range_is_ignored() { // Defensive: if a caller passes a stale anchor beyond items.len(), // we drop it silently rather than panicking. let scheme = AnthropicScheme::new(); let mut request = Request::new().user("one"); request.cache_anchor = Some(99); let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); // Only the Head breakpoint survives. let bps = breakpoint_positions(&req); assert_eq!(bps.len(), 1); } #[test] fn empty_items_produce_no_breakpoints() { let scheme = AnthropicScheme::new(); let req = scheme.build_request("claude-sonnet-4-20250514", &Request::new(), &cap_explicit()); assert!(req.messages.is_empty()); assert!(breakpoint_positions(&req).is_empty()); } #[test] fn cache_auto_does_not_add_cache_control() { // Ollama のように `CacheStrategy::Auto` のときは cache_control // マーカーを一切付けない。breakpoint 計算も走らないこと。 let scheme = AnthropicScheme::new(); let mut request = Request::new().user("hello"); request.cache_anchor = Some(0); let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_auto()); assert!(breakpoint_positions(&req).is_empty()); } fn collect_assistant_thinking_parts(req: &AnthropicRequest) -> Vec<&AnthropicContentPart> { let mut out = Vec::new(); for msg in &req.messages { if msg.role != "assistant" { continue; } if let AnthropicContent::Parts(parts) = &msg.content { for part in parts { if matches!( part, AnthropicContentPart::Thinking { .. } | AnthropicContentPart::RedactedThinking { .. } ) { out.push(part); } } } } out } #[test] fn reasoning_with_signature_projects_thinking_part() { // Item::Reasoning に signature があれば assistant role の // `thinking` content_part として送る。 let scheme = AnthropicScheme::new(); let request = Request::new() .user("hi") .item(Item::reasoning("step-by-step").with_signature("SIG-A")) .item(Item::assistant_message("done")); let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); let thinking_parts = collect_assistant_thinking_parts(&req); assert_eq!(thinking_parts.len(), 1); match thinking_parts[0] { AnthropicContentPart::Thinking { thinking, signature, .. } => { assert_eq!(thinking, "step-by-step"); assert_eq!(signature, "SIG-A"); } other => panic!("expected Thinking part, got {other:?}"), } } #[test] fn reasoning_with_only_encrypted_content_projects_redacted_thinking() { let scheme = AnthropicScheme::new(); let request = Request::new() .user("hi") .item(Item::reasoning("").with_encrypted_content("opaque")) .item(Item::assistant_message("done")); let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); let parts = collect_assistant_thinking_parts(&req); assert_eq!(parts.len(), 1); match parts[0] { AnthropicContentPart::RedactedThinking { data, .. } => { assert_eq!(data, "opaque"); } other => panic!("expected RedactedThinking, got {other:?}"), } } #[test] fn reasoning_without_signature_or_encrypted_is_dropped() { // 他 scheme から流入した素の reasoning は Anthropic に投げない。 let scheme = AnthropicScheme::new(); let request = Request::new() .user("hi") .item(Item::reasoning("plain text")) .item(Item::assistant_message("done")); let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); // thinking part は 1 つも乗らない assert!(collect_assistant_thinking_parts(&req).is_empty()); } #[test] fn thinking_part_lands_in_assistant_role_message() { // wire 構造の position 検証: thinking part は assistant role の // message 配列に並ぶ(user role には絶対に入らない)。 let scheme = AnthropicScheme::new(); let request = Request::new() .user("question?") .item(Item::reasoning("thinking inside").with_signature("SIG-A")) .item(Item::tool_call("c1", "tool_a", "{}")) .item(Item::tool_result("c1", "result")) .user("follow up"); let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); // 全 thinking part が assistant role の message に存在すること let mut thinking_msg_indices = Vec::new(); for (i, msg) in req.messages.iter().enumerate() { if let AnthropicContent::Parts(parts) = &msg.content { if parts .iter() .any(|p| matches!(p, AnthropicContentPart::Thinking { .. })) { assert_eq!( msg.role, "assistant", "thinking part must be in assistant role, got {} at msg {}", msg.role, i, ); thinking_msg_indices.push(i); } } } assert!( !thinking_msg_indices.is_empty(), "expected at least one thinking part in messages: {:?}", req.messages, ); // thinking part を含む assistant message は、それに続く tool_use を含む // assistant message より前 (= 先頭側) に位置すること // (Anthropic 仕様: 同一論理ターン内で thinking → tool_use の順) let mut tool_use_msg_indices = Vec::new(); for (i, msg) in req.messages.iter().enumerate() { if let AnthropicContent::Parts(parts) = &msg.content { if parts .iter() .any(|p| matches!(p, AnthropicContentPart::ToolUse { .. })) { tool_use_msg_indices.push(i); } } } assert!(!tool_use_msg_indices.is_empty(), "expected tool_use part"); let first_thinking = thinking_msg_indices[0]; let first_tool_use = tool_use_msg_indices[0]; assert!( first_thinking <= first_tool_use, "thinking msg ({}) must precede tool_use msg ({})", first_thinking, first_tool_use, ); } #[test] fn redacted_thinking_part_lands_in_assistant_role_message() { // RedactedThinking も同様に assistant role に置かれること。 let scheme = AnthropicScheme::new(); let request = Request::new() .user("ask") .item(Item::reasoning("").with_encrypted_content("opaque")) .item(Item::tool_call("c1", "tool_a", "{}")) .item(Item::tool_result("c1", "ok")); let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); for msg in &req.messages { if let AnthropicContent::Parts(parts) = &msg.content { for part in parts { if matches!(part, AnthropicContentPart::RedactedThinking { .. }) { assert_eq!(msg.role, "assistant"); } } } } } #[test] fn tool_definitions_carry_no_cache_control() { // Tool JSON schema must serialise unchanged — no sneak-in of // cache_control at the tools-array level. let scheme = AnthropicScheme::new(); let request = Request::new() .user("hello") .tool(ToolDefinition::new("noop").input_schema(serde_json::json!({ "type": "object", "properties": {} }))); let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); let json = serde_json::to_value(&req).unwrap(); let tool = &json["tools"][0]; assert!(tool.get("cache_control").is_none()); } }