blob: 04695c765dea691faededb31e475824d7dd154f6 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use super::*;
use serde_json::json;
#[test]
fn mcp_replay_preserves_opaque_results_without_unsupported_warnings() {
use super::prompt::{PreparedPrompt, PromptOptions};
use crate::input::{AssistantPart, Message, ToolCall, ToolOutput, ToolResult, ToolValue};
use std::collections::BTreeMap;
for is_error in [false, true] {
// A hosted-tool spelling must not reinterpret an MCP server's payload.
let payload = json!({"errorCode":"opaque", "content":[{"type":"text","text":"answer"}]});
let messages = [Message::Assistant {
parts: vec![
AssistantPart::ToolCall(ToolCall {
id: "remote-1".into(),
name: "web_search".into(),
input: json!({"q":"native"}),
provider_executed: true,
extensions: Some(BTreeMap::from([(
"anthropic".into(),
json!({"type":"mcp-tool-use","serverName":"research"}),
)])),
}),
AssistantPart::ToolResult(ToolResult {
id: "remote-1".into(),
name: "web_search".into(),
output: ToolOutput {
value: if is_error {
ToolValue::ErrorJson(payload.clone())
} else {
ToolValue::Json(payload.clone())
},
extensions: None,
},
extensions: None,
}),
],
extensions: None,
}];
let names = BTreeMap::new();
let mut cache = CacheBudget::default();
let prompt = PreparedPrompt::encode(
&messages,
PromptOptions {
send_reasoning: true,
cache_namespace: "anthropic",
tool_names: &names,
toolsets: &names,
},
&mut cache,
)
.unwrap();
assert_eq!(
serde_json::to_value(&prompt).unwrap(),
json!({"messages":[{
"role":"assistant","content":[
{"type":"mcp_tool_use","id":"remote-1","name":"web_search","input":{"q":"native"},"server_name":"research"},
{"type":"mcp_tool_result","tool_use_id":"remote-1","content":payload,"is_error":is_error}
]
}]})
);
assert!(prompt.warnings.is_empty());
assert!(cache.warnings().is_empty());
}
}
fn function(name: &str, cache: Option<CacheDirective>) -> Tool {
Tool::Function(FunctionTool {
name: name.into(),
description: None,
input_schema: json!({"type":"object"}),
cache_control: cache,
strict: Some(true),
defer_loading: None,
eager_input_streaming: None,
allowed_callers: None,
input_examples: None,
})
}
#[test]
fn cache_capacity_is_shared_by_messages_and_definitions_but_not_requests() {
let cache = Some(CacheDirective::Ephemeral { ttl: None });
let mut budget = CacheBudget::default();
assert_eq!(budget.reserve(cache, CacheSite::Reasoning), None);
for site in [CacheSite::System, CacheSite::Text, CacheSite::ToolResult] {
assert_eq!(budget.reserve(cache, site), cache);
}
let prepared = PreparedTools::build(
[function("first", cache), function("second", cache)],
None,
None,
ToolPolicy::default(),
&mut budget,
);
let wire = prepared.wire_tools();
assert_eq!(wire[0]["cache_control"], json!({"type":"ephemeral"}));
assert!(wire[1].get("cache_control").is_none());
assert_eq!(
budget.warnings(),
&[
Warning::CacheNotAllowed(CacheSite::Reasoning),
Warning::CacheLimit { attempted: 5 }
]
);
assert_eq!(
CacheBudget::default().reserve(cache, CacheSite::System),
cache
);
}
#[test]
fn fallback_usage_excludes_the_failed_primary_and_advisor() {
let usage: Usage = serde_json::from_value(json!({
"input_tokens":80,"output_tokens":11,"cache_read_input_tokens":4,
"cache_creation_input_tokens":2,"output_tokens_details":{"thinking_tokens":3},
"iterations":[
{"type":"message","input_tokens":900,"output_tokens":0},
{"type":"fallback_message","input_tokens":80,"output_tokens":11},
{"type":"advisor_message","input_tokens":50,"output_tokens":5}
]
}))
.unwrap();
assert_eq!(
usage.totals().unwrap(),
TokenCounts {
input: 86,
uncached_input: 80,
cache_read: 4,
cache_write: 2,
output: 11,
reasoning: Some(3)
}
);
}
#[test]
fn compaction_accounting_counts_only_executor_work_and_rejects_overflow() {
let mut usage: Usage = serde_json::from_value(json!({
"input_tokens":1,"output_tokens":1,
"iterations":[
{"type":"compaction","input_tokens":17,"output_tokens":3},
{"type":"message","input_tokens":6,"output_tokens":9},
{"type":"advisor_message","input_tokens":100,"output_tokens":100}
]
}))
.unwrap();
assert_eq!(
usage.totals().unwrap(),
TokenCounts {
input: 23,
uncached_input: 23,
cache_read: 0,
cache_write: 0,
output: 12,
reasoning: None
}
);
usage.cache_read_input_tokens = Some(u64::MAX);
assert!(matches!(usage.totals(), Err(Error::InvalidResponse { .. })));
}
#[test]
fn forced_choice_fallback_restricts_the_available_tools() {
let prepared = PreparedTools::build(
[function("lookup", None), function("write", None)],
Some(ToolChoice::Named("lookup".into())),
Some(false),
ToolPolicy {
forced_choice: false,
strict: false,
..ToolPolicy::default()
},
&mut CacheBudget::default(),
);
assert_eq!(
prepared.wire_tools(),
vec![json!({"name":"lookup","input_schema":{"type":"object"}})]
);
assert_eq!(
serde_json::to_value(&prepared.choice).unwrap(),
json!({"type":"auto","disable_parallel_tool_use":false})
);
assert_eq!(
prepared.warnings.last(),
Some(&Warning::ForcedToolChoiceIgnored {
name: Some("lookup".into())
})
);
}
#[test]
fn dated_web_tool_capabilities_are_encoded_at_the_wire_boundary() {
let options = json!({"maxUses":2,"allowedDomains":["example.org"],"useCache":false,"responseInclusion":"excluded"});
let legacy = HostedTool::from_definition("anthropic.web_fetch_20250910", &options)
.unwrap()
.unwrap();
let current = HostedTool::from_definition("anthropic.web_fetch_20260318", &options)
.unwrap()
.unwrap();
assert_eq!(
legacy.wire(),
json!({"type":"web_fetch_20250910","name":"web_fetch","max_uses":2,"allowed_domains":["example.org"]})
);
assert_eq!(
current.wire(),
json!({"type":"web_fetch_20260318","name":"web_fetch","max_uses":2,"allowed_domains":["example.org"],"use_cache":false,"response_inclusion":"excluded"})
);
for budget in [json!(1000), json!(1024.5)] {
assert!(
HostedTool::from_definition(
"anthropic.advisor_20260301",
&json!({"model":"advisor","maxTokens":budget})
)
.is_err()
);
}
}
#[test]
fn constrained_schema_keeps_guidance_and_nested_object_boundaries() {
let schema = json!({
"type":"object","additionalProperties":true,"required":["count"],
"properties":{
"count":{"type":"integer","description":"Items","minimum":2,"maximum":7},
"choice":{"oneOf":[{"type":"string"},{"type":"null"}],"default":null},
"reference":{"$ref":"#/$defs/entry","title":"ignored"}
}
});
assert_eq!(
constrained_schema(schema),
json!({
"type":"object","additionalProperties":false,"required":["count"],
"properties":{
"count":{"type":"integer","description":"Items\nminimum: 2; maximum: 7."},
"choice":{"anyOf":[{"type":"string"},{"type":"null"}],"default":null},
"reference":{"$ref":"#/$defs/entry"}
}
})
);
}
#[test]
fn response_sources_follow_documents_and_search_results_without_losing_citations() {
use crate::output::{Part, Source, Stop};
let raw = json!({"type":"message","id":"reply-1","model":"model-1","usage":{"input_tokens":7,"output_tokens":2},"stop_reason":"end_turn","content":[
{"type":"web_fetch_tool_result","tool_use_id":"fetch-1","content":{"type":"web_fetch_result","url":"https://example.org/document","retrieved_at":null,"content":{"type":"document","title":null,"source":{"type":"text","media_type":"text/plain","data":"evidence"}}}},
{"type":"text","text":"answer","citations":[
{"type":"char_location","cited_text":"evidence","document_index":0,"document_title":null,"start_char_index":0,"end_char_index":8},
{"type":"web_search_result_location","cited_text":"answer","url":"https://example.org/search","title":null,"encrypted_index":"citation-1"}
]},
{"type":"web_search_tool_result","tool_use_id":"search-1","content":[{"type":"web_search_result","url":"https://example.org/search","title":null,"encrypted_content":"opaque"}]}
]});
let mut ids = 0;
let decoded = response::decode(
raw.to_string().as_bytes(),
response::Options::default(),
&mut || {
ids += 1;
format!("source-{ids}")
},
)
.unwrap();
assert_eq!(decoded.finish.reason, Stop::Stop);
assert_eq!(
ids, 2,
"search results suppress duplicate standalone web citations"
);
let Part::Text { citations, .. } = &decoded.parts[1] else {
panic!("text after fetched result")
};
assert_eq!(
citations.len(),
2,
"attachments survive standalone source suppression"
);
assert!(
matches!(&citations[0].source, Source::Document { id, title, .. } if id == "0" && title == "https://example.org/document")
);
assert!(
matches!(&decoded.parts[2], Part::Source(Source::Document { id, .. }) if id == "source-1")
);
assert!(matches!(&decoded.parts[4], Part::Source(Source::Url { id, .. }) if id == "source-2"));
}
#[test]
fn hosted_boundary_rejects_wrong_discriminators_and_document_media_pairs() {
for content in [
json!({"type":"something_else","url":"https://example.org","retrieved_at":null,"content":{"type":"document","title":null,"source":{"type":"text","media_type":"text/plain","data":"x"}}}),
json!({"type":"web_fetch_result","url":"https://example.org","retrieved_at":null,"content":{"type":"not_document","title":null,"source":{"type":"text","media_type":"text/plain","data":"x"}}}),
json!({"type":"web_fetch_result","url":"https://example.org","retrieved_at":null,"content":{"type":"document","title":null,"source":{"type":"text","media_type":"application/pdf","data":"x"}}}),
] {
let raw = json!({"type":"message","usage":{"input_tokens":0,"output_tokens":0},"content":[{"type":"web_fetch_tool_result","tool_use_id":"x","content":content}]});
assert!(
response::decode(
raw.to_string().as_bytes(),
response::Options::default(),
&mut || "unused".into()
)
.is_err(),
"accepted {raw}"
);
}
}
#[test]
fn stream_eof_reuses_only_completed_accounting_and_spliced_messages_latch() {
use crate::output::{Event, Stop};
fn feed(stream: &mut stream::Stream, value: serde_json::Value) -> Vec<Event> {
stream.value(value, &mut || "unused".into())
}
let mut stream = stream::Stream::new(response::Options::default());
feed(
&mut stream,
json!({"type":"message_start","message":{"id":"one","usage":{"input_tokens":3}}}),
);
feed(
&mut stream,
json!({"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":4}}),
);
feed(&mut stream, json!({"type":"message_stop"}));
feed(
&mut stream,
json!({"type":"message_start","message":{"id":"two","usage":{"input_tokens":99}}}),
);
feed(
&mut stream,
json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}),
);
feed(
&mut stream,
json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}),
);
let events = stream.finish();
let [Event::Finish(finish)] = events.as_slice() else {
panic!("interrupted finish")
};
assert_eq!(finish.reason, Stop::Interrupted);
assert_eq!(finish.usage.as_ref().unwrap().input, Some(3));
assert_eq!(finish.usage.as_ref().unwrap().output, Some(4));
assert!(stream.finish().is_empty());
let mut stream = stream::Stream::new(response::Options::default());
let start = json!({"type":"message_start","message":{"id":"one","usage":{"input_tokens":3}}});
feed(&mut stream, start.clone());
assert!(
feed(&mut stream, start).is_empty(),
"duplicate start is idempotent"
);
let events = feed(
&mut stream,
json!({"type":"message_start","message":{"id":"other","usage":{"input_tokens":7}}}),
);
assert!(matches!(
events.as_slice(),
[Event::Error(Error::InvalidResponse { .. })]
));
assert!(feed(&mut stream, json!({"type":"message_stop"})).is_empty());
assert!(stream.finish().is_empty());
}
#[test]
fn json_tool_mode_suppresses_prose_and_preserves_explicit_null_citations() {
use crate::output::{Event, Part};
let mut stream = stream::Stream::new(response::Options {
json_tool: true,
..response::Options::default()
});
for value in [
json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}),
json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Let me answer"}}),
json!({"type":"content_block_stop","index":0}),
] {
assert!(stream.value(value, &mut || "unused".into()).is_empty());
}
assert!(
stream.finish().is_empty(),
"suppressed prose is not interrupted output"
);
let citation = json!({"type":"content_block_location","cited_text":"proof","document_index":0,"document_title":null,"file_id":null,"start_block_index":0,"end_block_index":1});
let raw = json!({"type":"message","usage":{"input_tokens":0,"output_tokens":1},"content":[{"type":"text","text":"answer","citations":[citation]}]});
let response = response::decode(
raw.to_string().as_bytes(),
response::Options::default(),
&mut || "unused".into(),
)
.unwrap();
let Part::Text {
extensions: Some(metadata),
..
} = &response.parts[0]
else {
panic!("citation metadata")
};
assert_eq!(metadata["anthropic"]["citations"][0], citation);
let mut stream = stream::Stream::new(response::Options::default());
stream.value(json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}), &mut || "unused".into());
let errors = stream.value(json!({"type":"error","error":{"type":"rate_limit_error","message":"retry later","statusCode":503,"isRetryable":false,"data":null}}), &mut || "unused".into());
let [Event::ProviderError(error)] = errors.as_slice() else {
panic!("provider error")
};
assert_eq!(error.status, Some(503));
assert_eq!(error.retryable, Some(false));
assert_eq!(error.data, serde_json::Value::Null);
assert!(
stream.finish().is_empty(),
"an error must not produce a second interrupted finish"
);
}