yoi/crates/llm-worker/src/llm_client/scheme/anthropic/request.rs

979 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<String>,
pub messages: Vec<AnthropicMessage>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<AnthropicTool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_k: Option<u32>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub stop_sequences: Vec<String>,
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<AnthropicThinking>,
}
/// 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<AnthropicContentPart>),
}
/// `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<CacheControl>,
},
#[serde(rename = "thinking")]
Thinking {
thinking: String,
signature: String,
#[serde(skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
},
#[serde(rename = "redacted_thinking")]
RedactedThinking {
/// 暗号化済み reasoning blob。`Item::Reasoning::encrypted_content`
/// から渡る。
data: String,
#[serde(skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
},
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
},
#[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<CacheControl>,
},
}
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<String>,
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<usize>,
) -> Vec<AnthropicMessage> {
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<Option<(usize, usize)>> = 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<AnthropicContentPart> = 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 を投影
// - どちらも無い: 他 schemeOpenAI 等)から流入した
// 素の 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<AnthropicMessage>,
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<usize>) -> BTreeSet<usize> {
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 を送らない capabilityOllama 等)。
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<CacheControl> {
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<Item> {
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());
}
}