pub use dynamo_protocols::types::anthropic::*;
use dynamo_protocols::types::{
ChatCompletionMessageToolCall, ChatCompletionNamedToolChoice,
ChatCompletionRequestAssistantMessage, ChatCompletionRequestAssistantMessageContent,
ChatCompletionRequestMessage, ChatCompletionRequestMessageContentPartImageArgs,
ChatCompletionRequestMessageContentPartText, ChatCompletionRequestSystemMessage,
ChatCompletionRequestSystemMessageContent, ChatCompletionRequestToolMessage,
ChatCompletionRequestToolMessageContent, ChatCompletionRequestToolMessageContentPart,
ChatCompletionRequestUserMessage, ChatCompletionRequestUserMessageContent,
ChatCompletionRequestUserMessageContentPart, ChatCompletionTool,
ChatCompletionToolChoiceOption, ChatCompletionToolType, CompletionUsage, FunctionName,
FunctionObject, FunctionType, ImageUrl, ReasoningContent, ResponseFormat,
ResponseFormatJsonSchema,
};
use uuid::Uuid;
use crate::protocols::openai::chat_completions::{
NvCreateChatCompletionRequest, NvCreateChatCompletionResponse,
};
use crate::protocols::openai::common_ext::CommonExt;
fn push_system_message(content: String, messages: &mut Vec<ChatCompletionRequestMessage>) {
messages.push(ChatCompletionRequestMessage::System(
ChatCompletionRequestSystemMessage {
content: ChatCompletionRequestSystemMessageContent::Text(content),
name: None,
tools: None,
},
));
}
fn system_message_content(content: &AnthropicMessageContent) -> String {
match content {
AnthropicMessageContent::Text { content } => content.clone(),
AnthropicMessageContent::Blocks { content } => content
.iter()
.filter_map(|block| match block {
AnthropicContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n"),
}
}
impl TryFrom<AnthropicCreateMessageRequest> for NvCreateChatCompletionRequest {
type Error = anyhow::Error;
fn try_from(mut req: AnthropicCreateMessageRequest) -> Result<Self, Self::Error> {
let mut messages = Vec::new();
if let Some(system_content) = &req.system {
push_system_message(system_content.text.clone(), &mut messages);
}
for msg in &req.messages {
match (&msg.role, &msg.content) {
(AnthropicRole::System, content) => {
push_system_message(system_message_content(content), &mut messages);
}
(AnthropicRole::User, AnthropicMessageContent::Text { content }) => {
messages.push(ChatCompletionRequestMessage::User(
ChatCompletionRequestUserMessage {
content: ChatCompletionRequestUserMessageContent::Text(content.clone()),
name: None,
},
));
}
(AnthropicRole::User, AnthropicMessageContent::Blocks { content: blocks }) => {
convert_user_blocks(blocks, &mut messages)?;
}
(AnthropicRole::Assistant, AnthropicMessageContent::Text { content }) => {
messages.push(ChatCompletionRequestMessage::Assistant(
#[allow(deprecated)]
ChatCompletionRequestAssistantMessage {
content: Some(ChatCompletionRequestAssistantMessageContent::Text(
content.clone(),
)),
reasoning_content: None,
refusal: None,
name: None,
audio: None,
tool_calls: None,
function_call: None,
partial: None,
},
));
}
(AnthropicRole::Assistant, AnthropicMessageContent::Blocks { content: blocks }) => {
convert_assistant_blocks(blocks, &mut messages);
}
}
}
let tools = req
.tools
.as_deref()
.filter(|tools| !tools.is_empty())
.map(convert_anthropic_tools)
.transpose()?;
let tool_choice = req.tool_choice.as_ref().map(convert_anthropic_tool_choice);
let parallel_tool_calls = req
.tool_choice
.as_ref()
.and_then(|choice| match choice {
AnthropicToolChoice::Simple(simple) => simple.disable_parallel_tool_use,
AnthropicToolChoice::Named(named) => named.disable_parallel_tool_use,
})
.map(|disabled| !disabled);
let stop = req
.stop_sequences
.filter(|sequences| !sequences.is_empty())
.map(dynamo_protocols::types::Stop::StringArray);
let response_format = req
.output_config
.as_mut()
.and_then(|config| config.get_mut("format"))
.filter(|format| !format.is_null())
.map(|format| {
anyhow::ensure!(
format.get("type").and_then(|value| value.as_str()) == Some("json_schema"),
"output_config.format.type must be json_schema"
);
let schema = format
.get_mut("schema")
.filter(|schema| schema.is_object())
.ok_or_else(|| {
anyhow::anyhow!("output_config.format.schema must be an object")
})?;
Ok::<_, anyhow::Error>(ResponseFormat::JsonSchema {
json_schema: ResponseFormatJsonSchema {
name: "response".into(),
description: None,
schema: schema.take(),
strict: Some(true),
},
})
})
.transpose()?;
Ok(NvCreateChatCompletionRequest {
inner: dynamo_protocols::types::CreateChatCompletionRequest {
messages,
model: req.model,
temperature: req.temperature,
top_p: req.top_p,
max_completion_tokens: Some(req.max_tokens),
stop,
tools,
tool_choice,
parallel_tool_calls,
response_format,
stream: Some(true), stream_options: Some(dynamo_protocols::types::ChatCompletionStreamOptions {
include_usage: true,
continuous_usage_stats: true,
}),
..Default::default()
},
common: CommonExt {
top_k: req.top_k.map(|k| k as i32),
..Default::default()
},
nvext: crate::protocols::common::extensions::parse_nvext(req.nvext)?,
chat_template_args: if req
.thinking
.as_ref()
.is_some_and(|t| t.thinking_type == "enabled")
{
let mut args = std::collections::HashMap::new();
args.insert("enable_thinking".to_string(), serde_json::Value::Bool(true));
Some(args)
} else {
None
},
thinking: None,
thinking_token_budget: None,
media_io_kwargs: None,
return_tokens_as_token_ids: None,
unsupported_fields: Default::default(),
})
}
}
fn convert_user_blocks(
blocks: &[AnthropicContentBlock],
messages: &mut Vec<ChatCompletionRequestMessage>,
) -> Result<(), anyhow::Error> {
let mut content_parts: Vec<ChatCompletionRequestUserMessageContentPart> = Vec::new();
let mut has_image = false;
for block in blocks {
match block {
AnthropicContentBlock::Text { text, .. } => {
content_parts.push(ChatCompletionRequestUserMessageContentPart::Text(
ChatCompletionRequestMessageContentPartText {
text: text.clone(),
..Default::default()
},
));
}
AnthropicContentBlock::Image { source } => {
has_image = true;
content_parts.push(ChatCompletionRequestUserMessageContentPart::ImageUrl(
convert_image(source)?,
));
}
AnthropicContentBlock::ToolResult {
tool_use_id,
content,
..
} => {
flush_user_content_parts(&mut content_parts, has_image, messages);
has_image = false;
let content = content
.as_ref()
.map(convert_tool_result_content)
.transpose()?
.unwrap_or_default();
messages.push(ChatCompletionRequestMessage::Tool(
ChatCompletionRequestToolMessage {
content,
tool_call_id: tool_use_id.clone(),
},
));
}
AnthropicContentBlock::ToolUse { .. }
| AnthropicContentBlock::Thinking { .. }
| AnthropicContentBlock::RedactedThinking { .. }
| AnthropicContentBlock::ServerToolUse { .. }
| AnthropicContentBlock::WebSearchToolResult { .. }
| AnthropicContentBlock::Other(_) => {
}
}
}
flush_user_content_parts(&mut content_parts, has_image, messages);
Ok(())
}
fn convert_image(
source: &AnthropicImageSource,
) -> Result<dynamo_protocols::types::ChatCompletionRequestMessageContentPartImage, anyhow::Error> {
if source.source_type != "base64" {
anyhow::bail!(
"unsupported image source type {:?}; only base64 is supported",
source.source_type
);
}
let data_uri = format!("data:{};base64,{}", source.media_type, source.data);
let url =
url::Url::parse(&data_uri).map_err(|e| anyhow::anyhow!("invalid image data URI: {e}"))?;
let image_url = ImageUrl::from(url.to_string());
let image = ChatCompletionRequestMessageContentPartImageArgs::default()
.image_url(image_url)
.build()?;
Ok(image)
}
fn convert_tool_result_content(
content: &ToolResultContent,
) -> Result<ChatCompletionRequestToolMessageContent, anyhow::Error> {
let blocks = match content {
ToolResultContent::Text(text) => {
return Ok(ChatCompletionRequestToolMessageContent::Text(text.clone()));
}
ToolResultContent::Blocks(blocks) => blocks,
};
if blocks
.iter()
.any(|block| matches!(block, ToolResultContentBlock::Other(_)))
{
anyhow::bail!(
"unsupported Anthropic tool_result content block; only text and image are supported"
);
}
if !blocks
.iter()
.any(|block| matches!(block, ToolResultContentBlock::Image { .. }))
{
return Ok(ChatCompletionRequestToolMessageContent::Text(
content.clone().into_text(),
));
}
let mut parts = Vec::with_capacity(blocks.len());
for block in blocks {
match block {
ToolResultContentBlock::Text { text } => {
parts.push(ChatCompletionRequestToolMessageContentPart::Text(
ChatCompletionRequestMessageContentPartText {
text: text.clone(),
..Default::default()
},
));
}
ToolResultContentBlock::Image { source } => {
parts.push(ChatCompletionRequestToolMessageContentPart::ImageUrl(
convert_image(source)?,
));
}
ToolResultContentBlock::Other(_) => unreachable!("validated above"),
}
}
Ok(ChatCompletionRequestToolMessageContent::Array(parts))
}
fn flush_user_content_parts(
parts: &mut Vec<ChatCompletionRequestUserMessageContentPart>,
has_image: bool,
messages: &mut Vec<ChatCompletionRequestMessage>,
) {
if parts.is_empty() {
return;
}
let content = if has_image {
ChatCompletionRequestUserMessageContent::Array(std::mem::take(parts))
} else {
let combined = parts
.drain(..)
.filter_map(|p| match p {
ChatCompletionRequestUserMessageContentPart::Text(t) => Some(t.text),
_ => None,
})
.collect::<Vec<_>>()
.join("");
ChatCompletionRequestUserMessageContent::Text(combined)
};
messages.push(ChatCompletionRequestMessage::User(
ChatCompletionRequestUserMessage {
content,
name: None,
},
));
}
fn convert_assistant_blocks(
blocks: &[AnthropicContentBlock],
messages: &mut Vec<ChatCompletionRequestMessage>,
) {
let mut text_content = String::new();
let mut tool_calls = Vec::new();
let mut segments: Vec<String> = Vec::new();
let mut pending_reasoning = String::new();
for block in blocks {
match block {
AnthropicContentBlock::Text { text, .. } => {
text_content.push_str(text);
}
AnthropicContentBlock::Thinking { thinking, .. } => {
if !pending_reasoning.is_empty() {
pending_reasoning.push('\n');
}
pending_reasoning.push_str(thinking);
}
AnthropicContentBlock::RedactedThinking { .. } => {
}
AnthropicContentBlock::ToolUse {
id, name, input, ..
}
| AnthropicContentBlock::ServerToolUse {
id, name, input, ..
} => {
segments.push(std::mem::take(&mut pending_reasoning));
tool_calls.push(ChatCompletionMessageToolCall {
id: id.clone(),
r#type: FunctionType::Function,
function: dynamo_protocols::types::FunctionCall {
name: name.clone(),
arguments: serde_json::to_string(input).unwrap_or_default(),
},
});
}
_ => {}
}
}
segments.push(std::mem::take(&mut pending_reasoning));
let content = if text_content.is_empty() {
None
} else {
Some(ChatCompletionRequestAssistantMessageContent::Text(
text_content,
))
};
let reasoning_content = if !tool_calls.is_empty() && segments.iter().any(|s| !s.is_empty()) {
Some(ReasoningContent::Segments(segments))
} else {
let flat: String = segments
.iter()
.filter(|s| !s.is_empty())
.cloned()
.collect::<Vec<_>>()
.join("\n");
if flat.is_empty() {
None
} else {
Some(ReasoningContent::Text(flat))
}
};
let tc = if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
};
messages.push(ChatCompletionRequestMessage::Assistant(
ChatCompletionRequestAssistantMessage {
content,
reasoning_content,
refusal: None,
name: None,
audio: None,
tool_calls: tc,
#[allow(deprecated)]
function_call: None,
partial: None,
},
));
}
fn convert_anthropic_tools(
tools: &[AnthropicTool],
) -> Result<Vec<ChatCompletionTool>, anyhow::Error> {
tools
.iter()
.enumerate()
.map(|(tool_index, tool)| {
let schema = tool.input_schema.clone().ok_or_else(|| {
anyhow::anyhow!("tools[{tool_index}].input_schema: field required for client tools")
})?;
Ok(ChatCompletionTool {
r#type: ChatCompletionToolType::Function,
function: FunctionObject {
name: tool.name.clone(),
description: tool.description.clone(),
parameters: Some(schema),
strict: tool.strict,
},
})
})
.collect()
}
fn convert_anthropic_tool_choice(tc: &AnthropicToolChoice) -> ChatCompletionToolChoiceOption {
match tc {
AnthropicToolChoice::Simple(simple) => match simple.choice_type {
AnthropicToolChoiceMode::Auto => ChatCompletionToolChoiceOption::Auto,
AnthropicToolChoiceMode::Any => ChatCompletionToolChoiceOption::Required,
AnthropicToolChoiceMode::None => ChatCompletionToolChoiceOption::None,
AnthropicToolChoiceMode::Tool => {
tracing::warn!(
"tool_choice has type 'tool' without a 'name' field; treating as 'any' (required)"
);
ChatCompletionToolChoiceOption::Required
}
},
AnthropicToolChoice::Named(named) => {
ChatCompletionToolChoiceOption::Named(ChatCompletionNamedToolChoice {
r#type: ChatCompletionToolType::Function,
function: FunctionName {
name: named.name.clone(),
},
})
}
}
}
pub(super) fn completion_usage_to_anthropic(usage: &CompletionUsage) -> AnthropicUsage {
let cache_read_input_tokens = usage
.prompt_tokens_details
.as_ref()
.and_then(|details| details.cached_tokens)
.map(|tokens| tokens.min(usage.prompt_tokens))
.filter(|&tokens| tokens > 0);
AnthropicUsage {
input_tokens: usage
.prompt_tokens
.saturating_sub(cache_read_input_tokens.unwrap_or(0)),
output_tokens: usage.completion_tokens,
cache_creation_input_tokens: Some(0),
cache_read_input_tokens,
}
}
pub(super) fn new_tool_use_id() -> String {
format!("toolu_{}", Uuid::new_v4().simple())
}
pub(super) fn tool_use_input(
tool_name: &str,
arguments: &str,
truncated: bool,
) -> Option<serde_json::Value> {
if arguments.is_empty() {
return (!truncated).then(|| serde_json::json!({}));
}
let error = match serde_json::from_str::<serde_json::Value>(arguments) {
Ok(value) if value.is_object() => return Some(value),
Ok(_) => {
tracing::warn!(
tool_name = %tool_name,
argument_bytes = arguments.len(),
"suppressing tool_use block with non-object arguments"
);
return None;
}
Err(error) => error,
};
if !truncated {
tracing::warn!(
tool_name = %tool_name,
argument_bytes = arguments.len(),
error = %error,
"suppressing tool_use block with invalid arguments because generation was not cut off"
);
return None;
}
match recover_completed_members(arguments) {
Some(map) => {
tracing::warn!(
tool_name = %tool_name,
argument_bytes = arguments.len(),
recovered_members = map.len(),
error = %error,
"tool call arguments were cut off at the token limit; keeping the members \
that completed. `stop_reason` is `max_tokens`"
);
Some(serde_json::Value::Object(map))
}
None => {
tracing::warn!(
tool_name = %tool_name,
argument_bytes = arguments.len(),
error = %error,
"suppressing truncated tool_use block because no complete member survived"
);
None
}
}
}
fn recover_completed_members(raw: &str) -> Option<serde_json::Map<String, serde_json::Value>> {
let trimmed = raw.trim_start();
if !trimmed.starts_with('{') {
return None;
}
let mut depth: i32 = 0;
let mut in_string = false;
let mut escaped = false;
let mut last_boundary: Option<usize> = None;
for (index, ch) in trimmed.char_indices() {
if escaped {
escaped = false;
continue;
}
match ch {
'\\' if in_string => escaped = true,
'"' => in_string = !in_string,
_ if in_string => {}
'{' | '[' => depth += 1,
'}' | ']' => depth -= 1,
',' if depth == 1 => last_boundary = Some(index),
_ => {}
}
}
if ends_on_a_finished_value(trimmed)
&& let Ok(map) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(
&format!("{}}}", trimmed.trim_end()),
)
{
return Some(map);
}
let boundary = last_boundary?;
let candidate = format!("{}}}", &trimmed[..boundary]);
serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&candidate).ok()
}
fn ends_on_a_finished_value(trimmed: &str) -> bool {
let tail = trimmed.trim_end();
if tail.len() != trimmed.len() {
return true;
}
if let Some(before_quote) = tail.strip_suffix('"') {
let backslashes = before_quote
.chars()
.rev()
.take_while(|ch| *ch == '\\')
.count();
return backslashes % 2 == 0;
}
tail.ends_with('}')
|| tail.ends_with(']')
|| tail.ends_with("true")
|| tail.ends_with("false")
|| tail.ends_with("null")
}
pub(super) fn anthropic_stop_fields(
finish_reason: &dynamo_protocols::types::FinishReason,
stop_reason: Option<&serde_json::Value>,
) -> (AnthropicStopReason, Option<String>) {
use dynamo_protocols::types::FinishReason;
match finish_reason {
FinishReason::Stop => match stop_reason.and_then(serde_json::Value::as_str) {
Some(sequence) => (
AnthropicStopReason::StopSequence,
Some(sequence.to_string()),
),
None => (AnthropicStopReason::EndTurn, None),
},
FinishReason::Length => (AnthropicStopReason::MaxTokens, None),
FinishReason::ToolCalls | FinishReason::FunctionCall => {
(AnthropicStopReason::ToolUse, None)
}
FinishReason::ContentFilter => (AnthropicStopReason::Refusal, None),
}
}
pub fn chat_completion_to_anthropic_response(
chat_resp: NvCreateChatCompletionResponse,
model: &str,
api_context: Option<&crate::protocols::unified::AnthropicContext>,
) -> AnthropicMessageResponse {
let _ = api_context; let msg_id = format!("msg_{}", Uuid::new_v4().simple());
let choice = chat_resp.inner.choices.into_iter().next();
let mut content = Vec::new();
let mut stop_reason = None;
let mut stop_sequence = None;
if let Some(choice) = choice {
let truncated = matches!(
choice.finish_reason,
Some(dynamo_protocols::types::FinishReason::Length)
);
if let Some(finish_reason) = &choice.finish_reason {
let fields = anthropic_stop_fields(
finish_reason,
chat_resp
.nvext
.as_ref()
.and_then(|ext| ext.get("stop_reason")),
);
stop_reason = Some(fields.0);
stop_sequence = fields.1;
}
if let Some(tool_calls) = choice.message.tool_calls {
let last_call = tool_calls.len().saturating_sub(1);
for (call_index, tc) in tool_calls.into_iter().enumerate() {
let Some(input) = tool_use_input(
&tc.function.name,
&tc.function.arguments,
truncated && call_index == last_call,
) else {
continue;
};
let emitted_id = new_tool_use_id();
tracing::debug!(
backend_id = %tc.id,
emitted_id = %emitted_id,
"minting Anthropic tool_use id"
);
content.push(AnthropicResponseContentBlock::ToolUse {
id: emitted_id,
name: tc.function.name,
input,
});
}
}
if stop_reason == Some(AnthropicStopReason::ToolUse)
&& !content
.iter()
.any(|block| matches!(block, AnthropicResponseContentBlock::ToolUse { .. }))
{
tracing::warn!(
"every tool_use block was suppressed; reporting end_turn instead of tool_use"
);
stop_reason = Some(AnthropicStopReason::EndTurn);
}
if let Some(thinking) = choice.message.reasoning_content.filter(|t| !t.is_empty()) {
content.insert(
0,
AnthropicResponseContentBlock::Thinking {
thinking,
signature: String::new(),
},
);
}
let text = match choice.message.content {
Some(dynamo_protocols::types::ChatCompletionMessageContent::Text(t)) => Some(t),
Some(dynamo_protocols::types::ChatCompletionMessageContent::Parts(_)) => {
tracing::warn!(
"Multimodal (Parts) content in chat completion response replaced with placeholder text in Anthropic conversion."
);
Some("[multimodal content]".to_string())
}
None => None,
};
if let Some(text) = text {
content.push(AnthropicResponseContentBlock::Text {
text,
citations: None,
});
}
}
if content.is_empty() {
content.push(AnthropicResponseContentBlock::Text {
text: String::new(),
citations: None,
});
}
let usage = chat_resp
.inner
.usage
.as_ref()
.map(completion_usage_to_anthropic)
.unwrap_or(AnthropicUsage {
cache_creation_input_tokens: Some(0),
..Default::default()
});
AnthropicMessageResponse {
id: msg_id,
object_type: "message".to_string(),
role: "assistant".to_string(),
content,
model: model.to_string(),
stop_reason,
stop_sequence,
usage,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unary_stop_sequence_metadata() {
for (finish, matched, expected_reason, expected_sequence) in [
(
"stop",
serde_json::json!("</answer>"),
"stop_sequence",
Some("</answer>"),
),
(
"stop",
serde_json::json!("\n\nHuman:"),
"stop_sequence",
Some("\n\nHuman:"),
),
("stop", serde_json::json!(42), "end_turn", None),
("length", serde_json::json!("</answer>"), "max_tokens", None),
(
"content_filter",
serde_json::json!("</answer>"),
"refusal",
None,
),
(
"tool_calls",
serde_json::json!("</answer>"),
"tool_use",
None,
),
(
"function_call",
serde_json::json!("</answer>"),
"tool_use",
None,
),
] {
let mut chat = serde_json::json!({
"id": "chatcmpl-test", "object": "chat.completion", "created": 0,
"model": "test-model", "choices": [{
"index": 0, "finish_reason": finish,
"message": {"role": "assistant", "content": "<answer>2 plus 3 is 5"}
}], "nvext": {"stop_reason": matched}
});
if expected_reason == "tool_use" {
chat["choices"][0]["message"]["tool_calls"] = serde_json::json!([{
"id": "call-1", "type": "function",
"function": {"name": "calculate", "arguments": "{}"}
}]);
}
let response = chat_completion_to_anthropic_response(
serde_json::from_value(chat).unwrap(),
"test-model",
None,
);
let response = serde_json::to_value(response).unwrap();
assert_eq!(
response["stop_reason"], expected_reason,
"{finish}: {matched}"
);
assert_eq!(
response["stop_sequence"],
serde_json::json!(expected_sequence),
"{finish}: {matched}"
);
assert!(response.get("nvext").is_none());
}
}
#[test]
fn test_output_config_format_conversion() {
let schema = serde_json::json!({
"type": "object",
"$defs": {"value": {"type": "string", "enum": ["OK"]}},
"properties": {"value": {"$ref": "#/$defs/value"}},
"required": ["value"],
"additionalProperties": false
});
let req: AnthropicCreateMessageRequest = serde_json::from_value(serde_json::json!({
"model": "test-model", "max_tokens": 100,
"messages": [{"role": "user", "content": "Return a value."}],
"output_config": {"format": {"type": "json_schema", "schema": schema}}
}))
.unwrap();
let chat = NvCreateChatCompletionRequest::try_from(req).unwrap();
assert_eq!(
serde_json::to_value(chat.inner.response_format).unwrap(),
serde_json::json!({
"type": "json_schema",
"json_schema": {"name": "response", "schema": schema, "strict": true}
})
);
}
#[test]
fn test_output_config_format_absent_or_invalid() {
for (config, valid) in [
(serde_json::json!(null), true),
(serde_json::json!({}), true),
(serde_json::json!({"effort": "high"}), true),
(serde_json::json!({"format": null}), true),
(serde_json::json!({"format": "json_schema"}), false),
(serde_json::json!({"format": {"type": "text"}}), false),
(serde_json::json!({"format": {"schema": {}}}), false),
(
serde_json::json!({"format": {"type": "json_schema"}}),
false,
),
(
serde_json::json!({"format": {"type": "json_schema", "schema": null}}),
false,
),
(
serde_json::json!({"format": {"type": "json_schema", "schema": []}}),
false,
),
] {
let req: AnthropicCreateMessageRequest = serde_json::from_value(serde_json::json!({
"model": "test-model", "max_tokens": 100,
"messages": [{"role": "user", "content": "Hi"}],
"output_config": config
}))
.unwrap();
let result = NvCreateChatCompletionRequest::try_from(req);
if valid {
assert!(result.unwrap().inner.response_format.is_none());
} else {
assert!(
result
.unwrap_err()
.to_string()
.starts_with("output_config.format")
);
}
}
}
#[test]
fn test_simple_user_message_conversion() {
let req = AnthropicCreateMessageRequest {
model: "test-model".into(),
max_tokens: 100,
messages: vec![AnthropicMessage {
role: AnthropicRole::User,
content: AnthropicMessageContent::Text {
content: "Hello!".into(),
},
}],
nvext: None,
system: None,
temperature: Some(0.7),
top_p: None,
top_k: None,
stop_sequences: None,
stream: false,
metadata: None,
tools: None,
tool_choice: None,
cache_control: None,
thinking: None,
service_tier: None,
container: None,
output_config: None,
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(chat_req.inner.model, "test-model");
assert_eq!(chat_req.inner.max_completion_tokens, Some(100));
assert_eq!(chat_req.inner.temperature, Some(0.7));
assert_eq!(chat_req.inner.messages.len(), 1);
match &chat_req.inner.messages[0] {
ChatCompletionRequestMessage::User(u) => match &u.content {
ChatCompletionRequestUserMessageContent::Text(t) => {
assert_eq!(t, "Hello!");
}
_ => panic!("expected text content"),
},
_ => panic!("expected user message"),
}
}
#[test]
fn test_nvext_agent_context_is_rejected() {
let json = r#"{
"model": "test-model",
"max_tokens": 100,
"messages": [{"role": "user", "content": "Hi"}],
"nvext": {
"agent_context": {
"session_id": "run-123:researcher-0",
"parent_session_id": "run-123:root"
}
}
}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
let err = NvCreateChatCompletionRequest::try_from(req).unwrap_err();
assert!(err.to_string().contains("invalid nvext"));
assert!(err.to_string().contains("unknown field `agent_context`"));
}
#[test]
fn test_nvext_agent_context_empty_id_is_still_unknown_field() {
let json = r#"{
"model": "test-model",
"max_tokens": 100,
"messages": [{"role": "user", "content": "Hi"}],
"nvext": {
"agent_context": {
"session_id": ""
}
}
}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
let err = NvCreateChatCompletionRequest::try_from(req).unwrap_err();
assert!(err.to_string().contains("invalid nvext"));
assert!(err.to_string().contains("unknown field `agent_context`"));
}
#[test]
fn test_nvext_unknown_field_errors_in_llm_layer() {
let json = r#"{
"model": "test-model",
"max_tokens": 100,
"messages": [{"role": "user", "content": "Hi"}],
"nvext": {
"unsupported_future_field": true
}
}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
let err = NvCreateChatCompletionRequest::try_from(req).unwrap_err();
assert!(err.to_string().contains("invalid nvext"));
assert!(err.to_string().contains("unknown field"));
}
#[test]
fn test_system_message_prepended() {
let req = AnthropicCreateMessageRequest {
model: "test-model".into(),
max_tokens: 100,
messages: vec![AnthropicMessage {
role: AnthropicRole::User,
content: AnthropicMessageContent::Text {
content: "Hi".into(),
},
}],
nvext: None,
system: Some(SystemContent {
text: "You are helpful.".into(),
cache_control: None,
}),
temperature: None,
top_p: None,
top_k: None,
stop_sequences: None,
stream: false,
metadata: None,
tools: None,
tool_choice: None,
cache_control: None,
thinking: None,
service_tier: None,
container: None,
output_config: None,
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(chat_req.inner.messages.len(), 2);
assert!(matches!(
&chat_req.inner.messages[0],
ChatCompletionRequestMessage::System(_)
));
assert!(matches!(
&chat_req.inner.messages[1],
ChatCompletionRequestMessage::User(_)
));
}
#[test]
fn test_message_level_system_role_conversion() {
let json = r#"{
"model": "test-model",
"max_tokens": 100,
"messages": [
{"role": "user", "content": "Hi"},
{
"role": "system",
"content": [
{"type": "text", "text": "Keep answers short."},
{"type": "text", "text": "Use the available shell."}
]
},
{"role": "user", "content": "List files"}
]
}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
assert!(matches!(req.messages[1].role, AnthropicRole::System));
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(chat_req.inner.messages.len(), 3);
assert!(matches!(
&chat_req.inner.messages[0],
ChatCompletionRequestMessage::User(_)
));
match &chat_req.inner.messages[1] {
ChatCompletionRequestMessage::System(system) => match &system.content {
ChatCompletionRequestSystemMessageContent::Text(text) => {
assert_eq!(text, "Keep answers short.\nUse the available shell.");
}
other => panic!("expected text content, got {other:?}"),
},
other => panic!("expected system message, got {other:?}"),
}
assert!(matches!(
&chat_req.inner.messages[2],
ChatCompletionRequestMessage::User(_)
));
}
#[test]
fn test_tool_use_blocks_conversion() {
let req = AnthropicCreateMessageRequest {
model: "test-model".into(),
max_tokens: 100,
messages: vec![
AnthropicMessage {
role: AnthropicRole::User,
content: AnthropicMessageContent::Text {
content: "What's the weather?".into(),
},
},
AnthropicMessage {
role: AnthropicRole::Assistant,
content: AnthropicMessageContent::Blocks {
content: vec![AnthropicContentBlock::ToolUse {
id: "tool_123".into(),
name: "get_weather".into(),
input: serde_json::json!({"location": "SF"}),
cache_control: None,
}],
},
},
AnthropicMessage {
role: AnthropicRole::User,
content: AnthropicMessageContent::Blocks {
content: vec![AnthropicContentBlock::ToolResult {
tool_use_id: "tool_123".into(),
content: Some(ToolResultContent::Text("72F and sunny".into())),
is_error: None,
cache_control: None,
}],
},
},
],
nvext: None,
system: None,
temperature: None,
top_p: None,
top_k: None,
stop_sequences: None,
stream: false,
metadata: None,
tools: None,
tool_choice: None,
cache_control: None,
thinking: None,
service_tier: None,
container: None,
output_config: None,
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(chat_req.inner.messages.len(), 3);
assert!(matches!(
&chat_req.inner.messages[0],
ChatCompletionRequestMessage::User(_)
));
assert!(matches!(
&chat_req.inner.messages[1],
ChatCompletionRequestMessage::Assistant(_)
));
assert!(matches!(
&chat_req.inner.messages[2],
ChatCompletionRequestMessage::Tool(_)
));
}
#[test]
fn test_stop_sequences_conversion() {
let req = AnthropicCreateMessageRequest {
model: "test-model".into(),
max_tokens: 100,
messages: vec![AnthropicMessage {
role: AnthropicRole::User,
content: AnthropicMessageContent::Text {
content: "Hi".into(),
},
}],
nvext: None,
system: None,
temperature: None,
top_p: None,
top_k: None,
stop_sequences: Some(vec!["STOP".into(), "END".into()]),
stream: false,
metadata: None,
tools: None,
tool_choice: None,
cache_control: None,
thinking: None,
service_tier: None,
container: None,
output_config: None,
};
let mut empty_req = req.clone();
empty_req.stop_sequences = Some(vec![]);
let empty_chat_req: NvCreateChatCompletionRequest = empty_req.try_into().unwrap();
assert!(empty_chat_req.inner.stop.is_none());
crate::engines::ValidateRequest::validate(&empty_chat_req).unwrap();
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert!(chat_req.inner.stop.is_some());
}
#[test]
fn test_tool_strict_conversion() {
for strict in [Some(true), Some(false), None] {
let mut input = serde_json::json!({
"model": "test-model",
"max_tokens": 256,
"messages": [{"role": "user", "content": "Set the status to gamma."}],
"tools": [{
"name": "set_status",
"input_schema": {
"type": "object",
"properties": {"status": {"type": "string", "enum": ["ALPHA", "BETA"]}},
"required": ["status"],
"additionalProperties": false
}
}]
});
if let Some(strict) = strict {
input["tools"][0]["strict"] = strict.into();
}
let req: AnthropicCreateMessageRequest = serde_json::from_value(input.clone()).unwrap();
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let tools = chat_req.inner.tools.unwrap();
assert_eq!(tools[0].function.strict, strict);
assert_eq!(
tools[0].function.parameters.as_ref().unwrap(),
&input["tools"][0]["input_schema"]
);
}
}
#[test]
fn test_tools_conversion() {
let req = AnthropicCreateMessageRequest {
model: "test-model".into(),
max_tokens: 100,
messages: vec![AnthropicMessage {
role: AnthropicRole::User,
content: AnthropicMessageContent::Text {
content: "Hi".into(),
},
}],
nvext: None,
system: None,
temperature: None,
top_p: None,
top_k: None,
stop_sequences: None,
stream: false,
metadata: None,
tools: Some(vec![AnthropicTool {
name: "get_weather".into(),
tool_type: None,
description: Some("Get weather info".into()),
input_schema: Some(serde_json::json!({
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"]
})),
strict: None,
cache_control: None,
}]),
tool_choice: Some(AnthropicToolChoice::Simple(AnthropicToolChoiceSimple {
choice_type: AnthropicToolChoiceMode::Auto,
disable_parallel_tool_use: None,
})),
cache_control: None,
thinking: None,
service_tier: None,
container: None,
output_config: None,
};
let mut empty_req = req.clone();
empty_req.tools = Some(vec![]);
empty_req.tool_choice = None;
let empty_chat_req: NvCreateChatCompletionRequest = empty_req.try_into().unwrap();
assert!(empty_chat_req.inner.tools.is_none());
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert!(chat_req.inner.tools.is_some());
let tools = chat_req.inner.tools.unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].function.name, "get_weather");
assert!(matches!(
chat_req.inner.tool_choice,
Some(ChatCompletionToolChoiceOption::Auto)
));
}
#[test]
fn test_tool_choice_conversion_preserves_request_level_parser_policy() {
for (label, tool_choice, parsing_enabled) in [
("auto", serde_json::json!({"type": "auto"}), true),
("any", serde_json::json!({"type": "any"}), true),
(
"named",
serde_json::json!({"type": "tool", "name": "get_weather"}),
true,
),
("none", serde_json::json!({"type": "none"}), false),
] {
let req: AnthropicCreateMessageRequest = serde_json::from_value(serde_json::json!({
"model": "test-model",
"max_tokens": 100,
"messages": [{"role": "user", "content": "Hi"}],
"tools": [{
"name": "get_weather",
"description": "Get weather",
"input_schema": {"type": "object"}
}],
"tool_choice": tool_choice
}))
.unwrap();
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(
chat_req.inner.tools.as_ref().map(Vec::len),
Some(1),
"{label} must preserve tools"
);
assert_eq!(
crate::preprocessor::OpenAIPreprocessor::tool_call_parsing_enabled(&chat_req),
parsing_enabled,
"unexpected parser policy for Anthropic {label}"
);
match (label, chat_req.inner.tool_choice.as_ref()) {
("auto", Some(ChatCompletionToolChoiceOption::Auto))
| ("any", Some(ChatCompletionToolChoiceOption::Required))
| ("none", Some(ChatCompletionToolChoiceOption::None)) => {}
("named", Some(ChatCompletionToolChoiceOption::Named(named))) => {
assert_eq!(named.function.name, "get_weather");
}
(_, actual) => panic!("unexpected converted tool choice for {label}: {actual:?}"),
}
}
}
#[allow(deprecated)]
#[test]
fn test_chat_completion_to_anthropic_response() {
let chat_resp = NvCreateChatCompletionResponse {
inner: dynamo_protocols::types::CreateChatCompletionResponse {
id: "chatcmpl-xyz".into(),
choices: vec![dynamo_protocols::types::ChatChoice {
index: 0,
message: dynamo_protocols::types::ChatCompletionResponseMessage {
content: Some(dynamo_protocols::types::ChatCompletionMessageContent::Text(
"Hello!".to_string(),
)),
refusal: None,
tool_calls: None,
role: dynamo_protocols::types::Role::Assistant,
function_call: None,
audio: None,
reasoning_content: None,
},
finish_reason: Some(dynamo_protocols::types::FinishReason::Stop),
logprobs: None,
}],
created: 1726000000,
model: "test-model".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".to_string(),
usage: Some(dynamo_protocols::types::CompletionUsage {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
prompt_tokens_details: None,
completion_tokens_details: None,
}),
},
prompt_logprobs: None,
nvext: None,
};
let response = chat_completion_to_anthropic_response(chat_resp, "test-model", None);
assert!(response.id.starts_with("msg_"));
assert_eq!(response.object_type, "message");
assert_eq!(response.role, "assistant");
assert_eq!(response.model, "test-model");
assert_eq!(response.stop_reason, Some(AnthropicStopReason::EndTurn));
assert_eq!(response.usage.input_tokens, 10);
assert_eq!(response.usage.output_tokens, 5);
assert_eq!(response.content.len(), 1);
match &response.content[0] {
AnthropicResponseContentBlock::Text { text, .. } => {
assert_eq!(text, "Hello!");
}
_ => panic!("expected text block"),
}
}
#[allow(deprecated)]
#[test]
fn test_anthropic_response_input_tokens_excludes_cached() {
let chat_resp = NvCreateChatCompletionResponse {
inner: dynamo_protocols::types::CreateChatCompletionResponse {
id: "chatcmpl-cache".into(),
choices: vec![dynamo_protocols::types::ChatChoice {
index: 0,
message: dynamo_protocols::types::ChatCompletionResponseMessage {
content: Some(dynamo_protocols::types::ChatCompletionMessageContent::Text(
"Hi!".to_string(),
)),
refusal: None,
tool_calls: None,
role: dynamo_protocols::types::Role::Assistant,
function_call: None,
audio: None,
reasoning_content: None,
},
finish_reason: Some(dynamo_protocols::types::FinishReason::Stop),
logprobs: None,
}],
created: 1726000000,
model: "test-model".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".to_string(),
usage: Some(dynamo_protocols::types::CompletionUsage {
prompt_tokens: 12,
completion_tokens: 5,
total_tokens: 17,
prompt_tokens_details: Some(dynamo_protocols::types::PromptTokensDetails {
audio_tokens: None,
cached_tokens: Some(11),
..Default::default()
}),
completion_tokens_details: None,
}),
},
prompt_logprobs: None,
nvext: None,
};
let response = chat_completion_to_anthropic_response(chat_resp, "test-model", None);
assert_eq!(response.usage.input_tokens, 1);
assert_eq!(response.usage.cache_read_input_tokens, Some(11));
assert_eq!(response.usage.cache_creation_input_tokens, Some(0));
assert_eq!(response.usage.output_tokens, 5);
let serialized = serde_json::to_value(&response.usage).expect("usage serializes");
assert_eq!(serialized["input_tokens"], 1);
assert_eq!(serialized["cache_read_input_tokens"], 11);
assert_eq!(serialized["cache_creation_input_tokens"], 0);
assert_eq!(serialized["output_tokens"], 5);
}
#[test]
fn test_anthropic_usage_clamps_cached_tokens_to_prompt_tokens() {
let usage = CompletionUsage {
prompt_tokens: 12,
completion_tokens: 5,
total_tokens: 17,
prompt_tokens_details: Some(dynamo_protocols::types::PromptTokensDetails {
audio_tokens: None,
cached_tokens: Some(20),
..Default::default()
}),
completion_tokens_details: None,
};
let usage = completion_usage_to_anthropic(&usage);
assert_eq!(usage.input_tokens, 0);
assert_eq!(usage.cache_read_input_tokens, Some(12));
assert_eq!(usage.output_tokens, 5);
}
#[allow(deprecated)]
#[test]
fn test_anthropic_response_emits_zero_cache_creation_when_backend_reports_no_usage() {
let chat_resp = NvCreateChatCompletionResponse {
inner: dynamo_protocols::types::CreateChatCompletionResponse {
id: "chatcmpl-no-usage".into(),
choices: vec![dynamo_protocols::types::ChatChoice {
index: 0,
message: dynamo_protocols::types::ChatCompletionResponseMessage {
content: Some(dynamo_protocols::types::ChatCompletionMessageContent::Text(
"Hi!".to_string(),
)),
refusal: None,
tool_calls: None,
role: dynamo_protocols::types::Role::Assistant,
function_call: None,
audio: None,
reasoning_content: None,
},
finish_reason: Some(dynamo_protocols::types::FinishReason::Stop),
logprobs: None,
}],
created: 1726000000,
model: "test-model".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".to_string(),
usage: None,
},
prompt_logprobs: None,
nvext: None,
};
let response = chat_completion_to_anthropic_response(chat_resp, "test-model", None);
assert_eq!(response.usage.cache_creation_input_tokens, Some(0));
let serialized = serde_json::to_value(&response.usage).expect("usage serializes");
assert_eq!(serialized["cache_creation_input_tokens"], 0);
}
#[allow(deprecated)]
#[test]
fn test_anthropic_response_does_not_emit_nvext() {
let chat_resp = NvCreateChatCompletionResponse {
inner: dynamo_protocols::types::CreateChatCompletionResponse {
id: "chatcmpl-xyz".into(),
choices: vec![dynamo_protocols::types::ChatChoice {
index: 0,
message: dynamo_protocols::types::ChatCompletionResponseMessage {
content: Some(dynamo_protocols::types::ChatCompletionMessageContent::Text(
"Hello!".to_string(),
)),
refusal: None,
tool_calls: None,
role: dynamo_protocols::types::Role::Assistant,
function_call: None,
audio: None,
reasoning_content: None,
},
finish_reason: Some(dynamo_protocols::types::FinishReason::Stop),
logprobs: None,
}],
created: 1726000000,
model: "test-model".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".to_string(),
usage: None,
},
prompt_logprobs: None,
nvext: Some(serde_json::json!({
"worker_id": {"decode_worker_id": 1}
})),
};
let response = chat_completion_to_anthropic_response(chat_resp, "test-model", None);
let value = serde_json::to_value(response).unwrap();
assert!(value.get("nvext").is_none(), "got: {value}");
}
#[test]
fn test_deserialize_simple_message() {
let json =
r#"{"model":"test","max_tokens":100,"messages":[{"role":"user","content":"Hello"}]}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.model, "test");
assert_eq!(req.max_tokens, 100);
assert_eq!(req.messages.len(), 1);
}
#[test]
fn test_deserialize_content_blocks() {
let json = r#"{
"model": "test",
"max_tokens": 100,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "What is this?"},
{"type": "tool_result", "tool_use_id": "tool_1", "content": "result text"}
]
}]
}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.messages.len(), 1);
match &req.messages[0].content {
AnthropicMessageContent::Blocks { content } => {
assert_eq!(content.len(), 2);
}
_ => panic!("expected blocks content"),
}
}
#[test]
fn test_deserialize_thinking_block() {
let json = r#"{
"model": "test",
"max_tokens": 100,
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Let me reason about this...", "signature": "sig123"},
{"type": "text", "text": "Here is my answer."}
]
}]
}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
match &req.messages[0].content {
AnthropicMessageContent::Blocks { content } => {
assert_eq!(content.len(), 2);
match &content[0] {
AnthropicContentBlock::Thinking {
thinking,
signature,
..
} => {
assert_eq!(thinking, "Let me reason about this...");
assert_eq!(signature, "sig123");
}
other => panic!("expected Thinking, got {other:?}"),
}
}
_ => panic!("expected blocks content"),
}
}
#[test]
fn test_thinking_block_becomes_reasoning_content() {
let req = AnthropicCreateMessageRequest {
model: "test-model".into(),
max_tokens: 100,
messages: vec![AnthropicMessage {
role: AnthropicRole::Assistant,
content: AnthropicMessageContent::Blocks {
content: vec![
AnthropicContentBlock::Thinking {
thinking: "I should think...".into(),
signature: "sig".into(),
cache_control: None,
},
AnthropicContentBlock::Text {
text: "Answer".into(),
citations: None,
cache_control: None,
},
],
},
}],
nvext: None,
system: None,
temperature: None,
top_p: None,
top_k: None,
stop_sequences: None,
stream: false,
metadata: None,
tools: None,
tool_choice: None,
cache_control: None,
thinking: None,
service_tier: None,
container: None,
output_config: None,
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
match &chat_req.inner.messages[0] {
ChatCompletionRequestMessage::Assistant(a) => {
assert_eq!(
a.reasoning_content,
Some(ReasoningContent::Text("I should think...".into()))
);
match &a.content {
Some(ChatCompletionRequestAssistantMessageContent::Text(t)) => {
assert_eq!(t, "Answer");
}
other => panic!("expected text content, got {other:?}"),
}
}
other => panic!("expected assistant message, got {other:?}"),
}
}
#[test]
fn test_known_and_unknown_block_types() {
let json = r#"{
"model": "test",
"max_tokens": 100,
"messages": [{
"role": "assistant",
"content": [
{"type": "text", "text": "hello"},
{"type": "server_tool_use", "id": "stu_1", "name": "web_search", "input": {}},
{"type": "redacted_thinking", "data": "encrypted"},
{"type": "web_search_tool_result", "tool_use_id": "stu_1", "content": [{"type": "web_search_result", "url": "https://example.com"}]},
{"type": "future_block_type", "some_field": 42},
{"type": "text", "text": "world"}
]
}]
}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
match &req.messages[0].content {
AnthropicMessageContent::Blocks { content } => {
assert_eq!(content.len(), 6);
assert!(matches!(&content[0], AnthropicContentBlock::Text { .. }));
assert!(matches!(
&content[1],
AnthropicContentBlock::ServerToolUse { name, .. } if name == "web_search"
));
assert!(matches!(
&content[2],
AnthropicContentBlock::RedactedThinking { data } if data == "encrypted"
));
assert!(matches!(
&content[3],
AnthropicContentBlock::WebSearchToolResult { tool_use_id, .. } if tool_use_id == "stu_1"
));
assert!(matches!(
&content[4],
AnthropicContentBlock::Other(v) if v.get("type").and_then(|t| t.as_str()) == Some("future_block_type")
));
assert!(matches!(&content[5], AnthropicContentBlock::Text { .. }));
}
_ => panic!("expected blocks content"),
}
let chat_req: NvCreateChatCompletionRequest = AnthropicCreateMessageRequest {
model: "test".into(),
max_tokens: 100,
messages: req.messages,
nvext: None,
system: None,
temperature: None,
top_p: None,
top_k: None,
stop_sequences: None,
stream: false,
metadata: None,
tools: None,
tool_choice: None,
cache_control: None,
thinking: None,
service_tier: None,
container: None,
output_config: None,
}
.try_into()
.unwrap();
assert_eq!(chat_req.inner.messages.len(), 1);
match &chat_req.inner.messages[0] {
ChatCompletionRequestMessage::Assistant(a) => {
assert!(a.tool_calls.is_some());
let tc = a.tool_calls.as_ref().unwrap();
assert_eq!(tc.len(), 1);
assert_eq!(tc[0].function.name, "web_search");
}
other => panic!("expected assistant, got {other:?}"),
}
}
#[test]
fn test_tool_result_string_content() {
let json = r#"{
"model": "test",
"max_tokens": 100,
"messages": [{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "t1", "content": "simple text"}
]
}]
}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
match &req.messages[0].content {
AnthropicMessageContent::Blocks { content } => match &content[0] {
AnthropicContentBlock::ToolResult { content, .. } => {
let text = content.clone().unwrap().into_text();
assert_eq!(text, "simple text");
}
other => panic!("expected ToolResult, got {other:?}"),
},
_ => panic!("expected blocks"),
}
}
#[test]
fn test_tool_result_array_content() {
let json = r#"{
"model": "test",
"max_tokens": 100,
"messages": [{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "t1", "content": [
{"type": "text", "text": "line 1"},
{"type": "text", "text": "line 2"}
]}
]
}]
}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
match &req.messages[0].content {
AnthropicMessageContent::Blocks { content } => match &content[0] {
AnthropicContentBlock::ToolResult { content, .. } => {
let text = content.clone().unwrap().into_text();
assert_eq!(text, "line 1line 2");
}
other => panic!("expected ToolResult, got {other:?}"),
},
_ => panic!("expected blocks"),
}
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let ChatCompletionRequestMessage::Tool(tool) = &chat_req.inner.messages[0] else {
panic!("expected tool message");
};
assert_eq!(
tool.content,
ChatCompletionRequestToolMessageContent::Text("line 1line 2".into())
);
}
#[test]
fn test_tool_result_image_preserved() {
let json = r#"{
"model": "test",
"max_tokens": 100,
"messages": [{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "t1",
"content": [
{"type": "text", "text": "Screenshot captured"},
{"type": "image", "source": {
"type": "base64",
"media_type": "image/png",
"data": "aGVsbG8="
}}
]
}]
}]
}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
let ChatCompletionRequestMessage::Tool(tool) = &chat_req.inner.messages[0] else {
panic!("expected tool message");
};
let ChatCompletionRequestToolMessageContent::Array(parts) = &tool.content else {
panic!("expected array content");
};
assert_eq!(parts.len(), 2);
assert!(matches!(
&parts[0],
ChatCompletionRequestToolMessageContentPart::Text(text)
if text.text == "Screenshot captured"
));
let ChatCompletionRequestToolMessageContentPart::ImageUrl(image) = &parts[1] else {
panic!("expected image_url part");
};
assert_eq!(
image.image_url.as_ref().unwrap().url.as_str(),
"data:image/png;base64,aGVsbG8="
);
}
#[test]
fn test_tool_result_other_block_is_rejected() {
let content = ToolResultContent::Blocks(vec![ToolResultContentBlock::Other(
serde_json::json!({"type": "document"}),
)]);
let error = convert_tool_result_content(&content).unwrap_err();
assert!(
error
.to_string()
.contains("only text and image are supported")
);
}
#[test]
fn test_count_tokens_estimate() {
let req = AnthropicCountTokensRequest {
model: "test".into(),
messages: vec![AnthropicMessage {
role: AnthropicRole::User,
content: AnthropicMessageContent::Text {
content: "Hello, world! This is a test message.".into(),
},
}],
system: Some(SystemContent {
text: "You are helpful.".into(),
cache_control: None,
}),
tools: None,
};
let tokens = req.estimate_tokens();
assert!(tokens > 0, "should estimate non-zero tokens");
assert_eq!(tokens, 19);
}
fn make_req(blocks: Vec<AnthropicContentBlock>) -> ChatCompletionRequestAssistantMessage {
let req = AnthropicCreateMessageRequest {
model: "test-model".into(),
max_tokens: 100,
messages: vec![AnthropicMessage {
role: AnthropicRole::Assistant,
content: AnthropicMessageContent::Blocks { content: blocks },
}],
nvext: None,
system: None,
temperature: None,
top_p: None,
top_k: None,
stop_sequences: None,
stream: false,
metadata: None,
tools: None,
tool_choice: None,
cache_control: None,
thinking: None,
service_tier: None,
container: None,
output_config: None,
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
match chat_req.inner.messages.into_iter().next().unwrap() {
ChatCompletionRequestMessage::Assistant(a) => a,
other => panic!("expected assistant, got {other:?}"),
}
}
fn tool_use(id: &str) -> AnthropicContentBlock {
AnthropicContentBlock::ToolUse {
id: id.into(),
name: "fn".into(),
input: serde_json::json!({}),
cache_control: None,
}
}
fn thinking(text: &str) -> AnthropicContentBlock {
AnthropicContentBlock::Thinking {
thinking: text.into(),
signature: "sig".into(),
cache_control: None,
}
}
#[test]
fn test_interleaved_thinking_and_tool_calls() {
let msg = make_req(vec![
thinking("A"),
tool_use("t1"),
thinking("B"),
tool_use("t2"),
]);
let segs = msg
.reasoning_content
.as_ref()
.expect("reasoning_content should be set")
.segments()
.expect("should be Segments variant");
assert_eq!(segs.len(), 3); assert_eq!(segs[0], "A");
assert_eq!(segs[1], "B");
assert_eq!(segs[2], "");
assert_eq!(
msg.reasoning_content.as_ref().unwrap().to_flat_string(),
"A\nB"
);
let tcs = msg.tool_calls.as_ref().expect("tool_calls should be set");
assert_eq!(tcs.len(), 2);
assert_eq!(tcs[0].id, "t1");
assert_eq!(tcs[1].id, "t2");
}
#[test]
fn test_trailing_reasoning_preserved_in_segments() {
let msg = make_req(vec![thinking("A"), tool_use("t1"), thinking("B")]);
let segs = msg
.reasoning_content
.as_ref()
.expect("reasoning_content should be set")
.segments()
.expect("should be Segments variant");
assert_eq!(segs.len(), 2); assert_eq!(segs[0], "A");
assert_eq!(segs[1], "B");
assert_eq!(
msg.reasoning_content.as_ref().unwrap().to_flat_string(),
"A\nB"
);
}
#[test]
fn test_tool_use_before_thinking() {
let msg = make_req(vec![tool_use("t1"), thinking("A"), tool_use("t2")]);
let segs = msg
.reasoning_content
.as_ref()
.expect("reasoning_content should be set")
.segments()
.expect("should be Segments variant");
assert_eq!(segs.len(), 3);
assert_eq!(segs[0], ""); assert_eq!(segs[1], "A");
assert_eq!(segs[2], "");
assert_eq!(
msg.reasoning_content.as_ref().unwrap().to_flat_string(),
"A"
);
}
#[test]
fn test_all_thinking_then_all_tools() {
let msg = make_req(vec![
thinking("A"),
thinking("B"),
tool_use("t1"),
tool_use("t2"),
]);
let segs = msg
.reasoning_content
.as_ref()
.expect("reasoning_content should be set")
.segments()
.expect("should be Segments variant");
assert_eq!(segs.len(), 3);
assert_eq!(segs[0], "A\nB");
assert_eq!(segs[1], "");
assert_eq!(segs[2], "");
assert_eq!(
msg.reasoning_content.as_ref().unwrap().to_flat_string(),
"A\nB"
);
}
#[test]
fn test_tool_calls_no_thinking_produces_no_segments() {
let msg = make_req(vec![tool_use("t1"), tool_use("t2")]);
assert!(
msg.reasoning_content.is_none(),
"no reasoning means no reasoning_content"
);
}
#[test]
fn test_thinking_only_no_tools_produces_text_variant() {
let msg = make_req(vec![
thinking("A"),
AnthropicContentBlock::Text {
text: "answer".into(),
citations: None,
cache_control: None,
},
]);
assert_eq!(
msg.reasoning_content,
Some(ReasoningContent::Text("A".into()))
);
assert!(msg.reasoning_content.as_ref().unwrap().segments().is_none());
assert!(matches!(
msg.content,
Some(ChatCompletionRequestAssistantMessageContent::Text(ref t)) if t == "answer"
));
}
#[test]
fn test_single_thinking_then_single_tool() {
let msg = make_req(vec![thinking("reason"), tool_use("t1")]);
let segs = msg
.reasoning_content
.as_ref()
.expect("reasoning_content should be set")
.segments()
.expect("should be Segments variant");
assert_eq!(segs.len(), 2);
assert_eq!(segs[0], "reason");
assert_eq!(segs[1], "");
assert_eq!(
msg.reasoning_content.as_ref().unwrap().to_flat_string(),
"reason"
);
}
#[test]
fn test_interleaved_reasoning_not_flattened_regression() {
let msg = make_req(vec![
thinking("A"),
tool_use("t1"),
thinking("B"),
tool_use("t2"),
]);
assert!(
!matches!(msg.reasoning_content, Some(ReasoningContent::Text(_))),
"reasoning_content must NOT be flat Text when tool calls are interleaved; \
Text loses positional info and forces a KV cache miss on every multi-tool turn"
);
let segs = msg
.reasoning_content
.as_ref()
.expect("reasoning_content should be set")
.segments()
.expect(
"must be Segments so a chat template can reconstruct \
<think>A</think><call>t1</call><think>B</think><call>t2</call> \
rather than front-loading all reasoning before all calls",
);
assert_eq!(segs[0], "A", "reasoning before t1");
assert_eq!(segs[1], "B", "reasoning before t2");
assert_eq!(segs[2], "", "no trailing reasoning");
let tools = msg.tool_calls.as_ref().unwrap();
assert_eq!(tools[0].id, "t1");
assert_eq!(tools[1].id, "t2");
}
#[test]
fn test_per_block_cache_control_deserialization() {
let json = r#"{
"model": "test",
"max_tokens": 100,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "World"}
]
}]
}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
match &req.messages[0].content {
AnthropicMessageContent::Blocks { content } => {
match &content[0] {
AnthropicContentBlock::Text { cache_control, .. } => {
assert!(cache_control.is_some());
}
other => panic!("expected Text, got {other:?}"),
}
match &content[1] {
AnthropicContentBlock::Text { cache_control, .. } => {
assert!(cache_control.is_none());
}
other => panic!("expected Text, got {other:?}"),
}
}
_ => panic!("expected blocks"),
}
}
#[test]
fn test_system_string_no_cache_control() {
let json = r#"{
"model": "test",
"max_tokens": 100,
"messages": [{"role": "user", "content": "Hello"}],
"system": "You are helpful."
}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
let system = req.system.as_ref().unwrap();
assert_eq!(system.text, "You are helpful.");
assert!(system.cache_control.is_none());
}
#[test]
fn test_text_block_with_citations() {
let json = r#"{
"model": "test",
"max_tokens": 100,
"messages": [{
"role": "assistant",
"content": [
{
"type": "text",
"text": "According to the document...",
"citations": [
{"type": "char_location", "cited_text": "relevant text", "document_index": 0, "start_char_index": 0, "end_char_index": 13}
]
}
]
}]
}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
match &req.messages[0].content {
AnthropicMessageContent::Blocks { content } => match &content[0] {
AnthropicContentBlock::Text { citations, .. } => {
assert!(citations.is_some());
let cites = citations.as_ref().unwrap();
assert_eq!(cites.len(), 1);
assert_eq!(cites[0]["type"], "char_location");
}
other => panic!("expected Text, got {other:?}"),
},
_ => panic!("expected blocks"),
}
}
#[test]
fn test_redacted_thinking_block() {
let json = r#"{
"model": "test",
"max_tokens": 100,
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "visible reasoning", "signature": "sig1"},
{"type": "redacted_thinking", "data": "base64-encrypted-data"},
{"type": "text", "text": "Final answer"}
]
}]
}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
match &req.messages[0].content {
AnthropicMessageContent::Blocks { content } => {
assert_eq!(content.len(), 3);
assert!(matches!(
&content[0],
AnthropicContentBlock::Thinking { .. }
));
match &content[1] {
AnthropicContentBlock::RedactedThinking { data } => {
assert_eq!(data, "base64-encrypted-data");
}
other => panic!("expected RedactedThinking, got {other:?}"),
}
assert!(matches!(&content[2], AnthropicContentBlock::Text { .. }));
}
_ => panic!("expected blocks"),
}
}
#[test]
fn test_server_tool_use_and_web_search_result() {
let json = r#"{
"model": "test",
"max_tokens": 100,
"messages": [{
"role": "assistant",
"content": [
{"type": "server_tool_use", "id": "stu_1", "name": "web_search", "input": {"query": "rust programming"}},
{"type": "web_search_tool_result", "tool_use_id": "stu_1", "content": [{"type": "web_search_result", "url": "https://www.rust-lang.org", "title": "Rust"}]},
{"type": "text", "text": "Based on my search..."}
]
}]
}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
match &req.messages[0].content {
AnthropicMessageContent::Blocks { content } => {
assert_eq!(content.len(), 3);
match &content[0] {
AnthropicContentBlock::ServerToolUse { id, name, input } => {
assert_eq!(id, "stu_1");
assert_eq!(name, "web_search");
assert_eq!(input["query"], "rust programming");
}
other => panic!("expected ServerToolUse, got {other:?}"),
}
match &content[1] {
AnthropicContentBlock::WebSearchToolResult {
tool_use_id,
content,
} => {
assert_eq!(tool_use_id, "stu_1");
assert!(content.is_array());
}
other => panic!("expected WebSearchToolResult, got {other:?}"),
}
}
_ => panic!("expected blocks"),
}
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
match &chat_req.inner.messages[0] {
ChatCompletionRequestMessage::Assistant(a) => {
let tc = a.tool_calls.as_ref().expect("should have tool calls");
assert_eq!(tc.len(), 1);
assert_eq!(tc[0].id, "stu_1");
assert_eq!(tc[0].function.name, "web_search");
}
other => panic!("expected assistant, got {other:?}"),
}
}
#[test]
fn test_thinking_config_deserialization() {
let json = r#"{
"model": "test",
"max_tokens": 16000,
"messages": [{"role": "user", "content": "Solve this step by step"}],
"thinking": {"type": "enabled", "budget_tokens": 10000}
}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
let thinking = req.thinking.as_ref().expect("thinking should be set");
assert_eq!(thinking.thinking_type, "enabled");
assert_eq!(thinking.budget_tokens, Some(10000));
}
#[test]
fn test_thinking_config_disabled() {
let json = r#"{
"model": "test",
"max_tokens": 100,
"messages": [{"role": "user", "content": "Hello"}],
"thinking": {"type": "disabled"}
}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
let thinking = req.thinking.as_ref().expect("thinking should be set");
assert_eq!(thinking.thinking_type, "disabled");
assert!(thinking.budget_tokens.is_none());
}
#[test]
fn test_disable_parallel_tool_use() {
let json = r#"{
"model": "test",
"max_tokens": 100,
"messages": [{"role": "user", "content": "Hello"}],
"tools": [{"name": "get_weather", "description": "Get weather", "input_schema": {"type": "object"}}],
"tool_choice": {"type": "auto", "disable_parallel_tool_use": true}
}"#;
let req: AnthropicCreateMessageRequest = serde_json::from_str(json).unwrap();
match &req.tool_choice {
Some(AnthropicToolChoice::Simple(s)) => {
assert_eq!(s.choice_type, AnthropicToolChoiceMode::Auto);
assert_eq!(s.disable_parallel_tool_use, Some(true));
}
other => panic!("expected Simple tool choice, got {other:?}"),
}
}
#[test]
fn test_image_block_becomes_multimodal_content() {
let req = AnthropicCreateMessageRequest {
model: "test-model".into(),
max_tokens: 100,
messages: vec![AnthropicMessage {
role: AnthropicRole::User,
content: AnthropicMessageContent::Blocks {
content: vec![
AnthropicContentBlock::Text {
text: "What is in this image?".into(),
citations: None,
cache_control: None,
},
AnthropicContentBlock::Image {
source: AnthropicImageSource {
source_type: "base64".into(),
media_type: "image/png".into(),
data: "iVBORw0KGgo=".into(), },
},
],
},
}],
nvext: None,
system: None,
temperature: None,
top_p: None,
top_k: None,
stop_sequences: None,
stream: false,
metadata: None,
tools: None,
tool_choice: None,
cache_control: None,
thinking: None,
service_tier: None,
container: None,
output_config: None,
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(chat_req.inner.messages.len(), 1);
match &chat_req.inner.messages[0] {
ChatCompletionRequestMessage::User(u) => match &u.content {
ChatCompletionRequestUserMessageContent::Array(parts) => {
assert_eq!(parts.len(), 2);
match &parts[0] {
ChatCompletionRequestUserMessageContentPart::Text(t) => {
assert_eq!(t.text, "What is in this image?");
}
other => panic!("expected text part, got {other:?}"),
}
match &parts[1] {
ChatCompletionRequestUserMessageContentPart::ImageUrl(img) => {
let url_str = img
.image_url
.as_ref()
.expect("converted image must contain a URL")
.url
.to_string();
assert!(
url_str.starts_with("data:image/png;base64,"),
"expected data URI, got: {url_str}"
);
assert!(url_str.contains("iVBORw0KGgo="));
}
other => panic!("expected image_url part, got {other:?}"),
}
}
other => panic!("expected Array content, got {other:?}"),
},
other => panic!("expected user message, got {other:?}"),
}
}
#[test]
fn test_pure_text_stays_text_format() {
let req = AnthropicCreateMessageRequest {
model: "test-model".into(),
max_tokens: 100,
messages: vec![AnthropicMessage {
role: AnthropicRole::User,
content: AnthropicMessageContent::Blocks {
content: vec![
AnthropicContentBlock::Text {
text: "Hello ".into(),
citations: None,
cache_control: None,
},
AnthropicContentBlock::Text {
text: "world".into(),
citations: None,
cache_control: None,
},
],
},
}],
nvext: None,
system: None,
temperature: None,
top_p: None,
top_k: None,
stop_sequences: None,
stream: false,
metadata: None,
tools: None,
tool_choice: None,
cache_control: None,
thinking: None,
service_tier: None,
container: None,
output_config: None,
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
match &chat_req.inner.messages[0] {
ChatCompletionRequestMessage::User(u) => match &u.content {
ChatCompletionRequestUserMessageContent::Text(t) => {
assert_eq!(t, "Hello world");
}
other => panic!("expected Text content (not Array), got {other:?}"),
},
other => panic!("expected user message, got {other:?}"),
}
}
#[test]
fn test_image_with_tool_result_flush() {
let req = AnthropicCreateMessageRequest {
model: "test-model".into(),
max_tokens: 100,
messages: vec![
AnthropicMessage {
role: AnthropicRole::User,
content: AnthropicMessageContent::Text {
content: "What's the weather?".into(),
},
},
AnthropicMessage {
role: AnthropicRole::Assistant,
content: AnthropicMessageContent::Blocks {
content: vec![AnthropicContentBlock::ToolUse {
id: "tool_1".into(),
name: "screenshot".into(),
input: serde_json::json!({}),
cache_control: None,
}],
},
},
AnthropicMessage {
role: AnthropicRole::User,
content: AnthropicMessageContent::Blocks {
content: vec![
AnthropicContentBlock::Image {
source: AnthropicImageSource {
source_type: "base64".into(),
media_type: "image/jpeg".into(),
data: "/9j/4AAQ".into(),
},
},
AnthropicContentBlock::ToolResult {
tool_use_id: "tool_1".into(),
content: Some(ToolResultContent::Text("screenshot taken".into())),
is_error: None,
cache_control: None,
},
],
},
},
],
nvext: None,
system: None,
temperature: None,
top_p: None,
top_k: None,
stop_sequences: None,
stream: false,
metadata: None,
tools: None,
tool_choice: None,
cache_control: None,
thinking: None,
service_tier: None,
container: None,
output_config: None,
};
let chat_req: NvCreateChatCompletionRequest = req.try_into().unwrap();
assert_eq!(chat_req.inner.messages.len(), 4);
match &chat_req.inner.messages[2] {
ChatCompletionRequestMessage::User(u) => match &u.content {
ChatCompletionRequestUserMessageContent::Array(parts) => {
assert_eq!(parts.len(), 1);
assert!(matches!(
&parts[0],
ChatCompletionRequestUserMessageContentPart::ImageUrl(_)
));
}
other => panic!("expected Array content for image, got {other:?}"),
},
other => panic!("expected user message, got {other:?}"),
}
assert!(matches!(
&chat_req.inner.messages[3],
ChatCompletionRequestMessage::Tool(_)
));
}
}
#[cfg(test)]
mod anthropic_types_tests {
use super::*;
#[allow(deprecated)]
fn converted_tool_use_response_for(
finish_reason: dynamo_protocols::types::FinishReason,
arguments: &[&str],
) -> AnthropicMessageResponse {
let tool_calls = arguments
.iter()
.enumerate()
.map(
|(index, arguments)| dynamo_protocols::types::ChatCompletionMessageToolCall {
id: format!("call_{}", index + 1),
r#type: dynamo_protocols::types::FunctionType::Function,
function: dynamo_protocols::types::FunctionCall {
name: "record_literal".into(),
arguments: (*arguments).into(),
},
},
)
.collect();
let chat_resp = NvCreateChatCompletionResponse {
inner: dynamo_protocols::types::CreateChatCompletionResponse {
id: "chatcmpl-wiring".into(),
choices: vec![dynamo_protocols::types::ChatChoice {
index: 0,
message: dynamo_protocols::types::ChatCompletionResponseMessage {
content: None,
refusal: None,
tool_calls: Some(tool_calls),
role: dynamo_protocols::types::Role::Assistant,
function_call: None,
audio: None,
reasoning_content: None,
},
finish_reason: Some(finish_reason),
logprobs: None,
}],
created: 1726000000,
model: "test-model".into(),
service_tier: None,
system_fingerprint: None,
object: "chat.completion".into(),
usage: None,
},
prompt_logprobs: None,
nvext: None,
};
chat_completion_to_anthropic_response(chat_resp, "test-model", None)
}
fn converted_tool_use_inputs(
finish_reason: dynamo_protocols::types::FinishReason,
arguments: &[&str],
) -> Vec<serde_json::Value> {
converted_tool_use_response_for(finish_reason, arguments)
.content
.into_iter()
.filter_map(|block| match block {
AnthropicResponseContentBlock::ToolUse { input, .. } => Some(input),
_ => None,
})
.collect()
}
const TRUNCATED: &str =
r#"{"label": "customer-eof", "literal_text": "customer-eof-xxxxxxxxxxxxxxxxxxxx"#;
const MALFORMED_BUT_COMPLETE: &str = r#"{"label": "a", "extra": }"#;
#[test]
fn conversion_recovers_members_only_for_the_final_length_call() {
let inputs = converted_tool_use_inputs(
dynamo_protocols::types::FinishReason::Length,
&[MALFORMED_BUT_COMPLETE, TRUNCATED],
);
assert_eq!(inputs.len(), 1);
assert_eq!(inputs[0], serde_json::json!({"label": "customer-eof"}));
}
#[test]
fn conversion_drops_a_length_call_with_no_argument_bytes() {
let inputs =
converted_tool_use_inputs(dynamo_protocols::types::FinishReason::Length, &[""]);
assert!(inputs.is_empty(), "unexpected tool block: {inputs:?}");
assert_eq!(tool_use_input("get_weather", "", true), None);
assert_eq!(
tool_use_input("get_weather", "", false),
Some(serde_json::json!({}))
);
}
#[test]
fn conversion_reports_end_turn_when_every_tool_block_is_suppressed() {
let response = converted_tool_use_response_for(
dynamo_protocols::types::FinishReason::ToolCalls,
&[r#"{"label": "cut""#],
);
assert!(
!response
.content
.iter()
.any(|block| matches!(block, AnthropicResponseContentBlock::ToolUse { .. })),
"the malformed call must not be emitted"
);
assert_eq!(response.stop_reason, Some(AnthropicStopReason::EndTurn));
}
#[test]
fn conversion_suppresses_non_object_inputs() {
for arguments in ["[]", "null", "42", "true", r#""hello""#] {
for reason in [
dynamo_protocols::types::FinishReason::ToolCalls,
dynamo_protocols::types::FinishReason::Length,
] {
let response = converted_tool_use_response_for(reason, &[arguments]);
assert!(
response.content.iter().all(|block| !matches!(
block,
AnthropicResponseContentBlock::ToolUse { .. }
)),
"{arguments} with {reason:?}"
);
assert_eq!(
response.stop_reason,
Some(if reason == dynamo_protocols::types::FinishReason::Length {
AnthropicStopReason::MaxTokens
} else {
AnthropicStopReason::EndTurn
})
);
}
}
}
#[test]
fn conversion_does_not_recover_malformed_non_length_arguments() {
for reason in [
dynamo_protocols::types::FinishReason::ToolCalls,
dynamo_protocols::types::FinishReason::Stop,
dynamo_protocols::types::FinishReason::ContentFilter,
dynamo_protocols::types::FinishReason::FunctionCall,
] {
let inputs = converted_tool_use_inputs(reason, &[TRUNCATED]);
assert!(inputs.is_empty(), "unexpected tool block for {reason:?}");
}
}
#[test]
fn truncated_arguments_keep_the_members_that_completed() {
let input = tool_use_input("record_literal", TRUNCATED, true)
.expect("completed members should be recovered");
let obj = input.as_object().expect("input must stay an object");
assert_eq!(
obj.get("label").and_then(|v| v.as_str()),
Some("customer-eof")
);
assert!(
!obj.contains_key("literal_text"),
"a truncated value must not be reported, got: {input}"
);
assert_eq!(obj.len(), 1);
}
#[test]
fn truncated_arguments_are_not_silently_emptied() {
assert_eq!(
tool_use_input("record_literal", TRUNCATED, true),
Some(serde_json::json!({"label": "customer-eof"}))
);
}
#[test]
fn valid_arguments_are_passed_through_unchanged() {
let input = tool_use_input("get_weather", r#"{"location": "SF", "unit": "c"}"#, true);
assert_eq!(
input,
Some(serde_json::json!({"location": "SF", "unit": "c"}))
);
}
#[test]
fn conversion_maps_content_filter_to_refusal() {
let response = converted_tool_use_response_for(
dynamo_protocols::types::FinishReason::ContentFilter,
&[r#"{"label":"blocked"}"#],
);
assert_eq!(response.stop_reason, Some(AnthropicStopReason::Refusal));
}
#[test]
fn final_member_without_closing_brace_is_recovered_when_provably_complete() {
assert_eq!(
tool_use_input("record_literal", r#"{"label":"done""#, true),
Some(serde_json::json!({"label": "done"}))
);
assert_eq!(
recover_completed_members(r#"{"count": 2 "#).unwrap()["count"],
2
);
assert_eq!(
recover_completed_members(r#"{"ok": true "#).unwrap()["ok"],
true
);
assert_eq!(
recover_completed_members(r#"{"ok": false "#).unwrap()["ok"],
false
);
assert_eq!(
recover_completed_members(r#"{"value": null "#).unwrap()["value"],
serde_json::Value::Null
);
}
#[test]
fn undelimited_scalar_prefix_is_not_recovered() {
assert_eq!(recover_completed_members(r#"{"count": 2"#), None);
assert_eq!(recover_completed_members(r#"{"ok": tru"#), None);
assert_eq!(recover_completed_members(r#"{"value": nul"#), None);
}
#[test]
fn nothing_is_recovered_before_the_first_member_closes() {
assert_eq!(recover_completed_members(r#"{"label": "customer-eo"#), None);
assert_eq!(recover_completed_members("{"), None);
assert_eq!(recover_completed_members(""), None);
assert_eq!(recover_completed_members(r#"["a", "b""#), None);
}
#[test]
fn commas_inside_strings_and_nested_values_are_not_member_boundaries() {
let raw = r#"{"a": "x, y", "b": "unterminated"#;
let recovered = recover_completed_members(raw).expect("`a` completed");
assert_eq!(recovered.get("a").and_then(|v| v.as_str()), Some("x, y"));
assert_eq!(recovered.len(), 1);
let raw = r#"{"a": {"p": 1, "q": 2}, "b": [1, 2], "c": "cut"#;
let recovered = recover_completed_members(raw).expect("`a` and `b` completed");
assert_eq!(
recovered.get("a"),
Some(&serde_json::json!({"p": 1, "q": 2}))
);
assert_eq!(recovered.get("b"), Some(&serde_json::json!([1, 2])));
assert!(!recovered.contains_key("c"));
}
#[test]
fn escaped_quotes_do_not_end_the_string() {
let raw = r#"{"a": "he said \"hi\", ok", "b": "cut"#;
let recovered = recover_completed_members(raw).expect("`a` completed");
assert_eq!(
recovered.get("a").and_then(|v| v.as_str()),
Some(r#"he said "hi", ok"#)
);
assert_eq!(recovered.len(), 1);
}
}