use async_trait::async_trait;
use langchainrust::{
AnthropicChat, AnthropicConfig, BaseChatModel, CallbackHandler, CallbackManager, Message,
RunnableConfig, ThinkingConfig, ThinkingType,
};
use std::sync::{Arc, Mutex};
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn mock_anthropic_thinking_server(thinking: &str, text: &str) -> (MockServer, String) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.and(header("x-api-key", "test-key"))
.and(header("anthropic-version", "2023-06-01"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "msg_test",
"model": "claude-3-5-sonnet-20241022",
"content": [
{"type": "thinking", "thinking": thinking},
{"type": "text", "text": text}
],
"usage": {"input_tokens": 10, "output_tokens": 50}
})))
.mount(&server)
.await;
let base_url = format!("{}/v1", server.uri());
(server, base_url)
}
async fn mock_anthropic_text_only_server(text: &str) -> (MockServer, String) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.and(header("x-api-key", "test-key"))
.and(header("anthropic-version", "2023-06-01"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "msg_test",
"model": "claude-3-5-sonnet-20241022",
"content": [
{"type": "text", "text": text}
],
"usage": {"input_tokens": 5, "output_tokens": 20}
})))
.mount(&server)
.await;
let base_url = format!("{}/v1", server.uri());
(server, base_url)
}
async fn mock_anthropic_streaming_thinking_server() -> (MockServer, String) {
let server = MockServer::start().await;
let body = [
"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_test\",\"model\":\"claude-3-5-sonnet-20241022\",\"content\":[],\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}\n\n",
"event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n",
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Let me \"}}\n\n",
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"analyze this...\"}}\n\n",
"event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"The answer \"}}\n\n",
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"is 42.\"}}\n\n",
"event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\n",
"event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":50}}\n\n",
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
].join("");
Mock::given(method("POST"))
.and(path("/v1/messages"))
.and(header("x-api-key", "test-key"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(body)
.insert_header("content-type", "text/event-stream"),
)
.mount(&server)
.await;
let base_url = format!("{}/v1", server.uri());
(server, base_url)
}
struct ThinkingCallbackHandler {
thinking_tokens: Arc<Mutex<Vec<String>>>,
text_tokens: Arc<Mutex<Vec<String>>>,
}
impl ThinkingCallbackHandler {
fn new() -> Self {
Self {
thinking_tokens: Arc::new(Mutex::new(Vec::new())),
text_tokens: Arc::new(Mutex::new(Vec::new())),
}
}
}
#[async_trait]
impl CallbackHandler for ThinkingCallbackHandler {
async fn on_run_start(&self, _run: &langchainrust::callbacks::RunTree) {}
async fn on_run_end(&self, _run: &langchainrust::callbacks::RunTree) {}
async fn on_run_error(&self, _run: &langchainrust::callbacks::RunTree, _error: &str) {}
async fn on_llm_new_token(&self, _run: &langchainrust::callbacks::RunTree, token: &str) {
let mut tokens = self.text_tokens.lock().unwrap();
tokens.push(token.to_string());
}
async fn on_llm_thinking(&self, _run: &langchainrust::callbacks::RunTree, thinking: &str) {
let mut tokens = self.thinking_tokens.lock().unwrap();
tokens.push(thinking.to_string());
}
}
#[tokio::test]
async fn test_anthropic_chat_with_thinking_response() {
let (_server, base_url) = mock_anthropic_thinking_server(
"I need to consider the question carefully.",
"The answer is 42.",
)
.await;
let config = AnthropicConfig {
api_key: "test-key".to_string(),
base_url,
model: "claude-3-5-sonnet-20241022".to_string(),
max_tokens: 4096,
temperature: None,
system_prompt: None,
thinking: ThinkingConfig::enabled(10000),
tools: None,
tool_choice: None,
};
let llm = AnthropicChat::new(config);
let result = llm
.chat(vec![Message::human("What is the answer?")], None)
.await
.unwrap();
assert_eq!(result.content, "The answer is 42.");
assert_eq!(
result.thinking_content,
Some("I need to consider the question carefully.".to_string())
);
assert_eq!(result.model, "claude-3-5-sonnet-20241022");
assert!(result.token_usage.is_some());
let usage = result.token_usage.unwrap();
assert_eq!(usage.prompt_tokens, 10);
assert_eq!(usage.completion_tokens, 50);
}
#[tokio::test]
async fn test_anthropic_chat_without_thinking_response() {
let (_server, base_url) = mock_anthropic_text_only_server("Hello!").await;
let config = AnthropicConfig {
api_key: "test-key".to_string(),
base_url,
model: "claude-3-5-sonnet-20241022".to_string(),
max_tokens: 4096,
temperature: None,
system_prompt: None,
thinking: ThinkingConfig::disabled(),
tools: None,
tool_choice: None,
};
let llm = AnthropicChat::new(config);
let result = llm.chat(vec![Message::human("Hi")], None).await.unwrap();
assert_eq!(result.content, "Hello!");
assert!(result.thinking_content.is_none());
}
#[tokio::test]
async fn test_anthropic_chat_with_thinking_builder() {
let config = AnthropicConfig::new("test-key");
let llm = AnthropicChat::new(config).with_thinking(8000);
assert!(llm.thinking_config().is_enabled());
assert_eq!(llm.thinking_config().budget_tokens, 8000);
}
#[tokio::test]
async fn test_anthropic_streaming_with_thinking() {
let (_server, base_url) = mock_anthropic_streaming_thinking_server().await;
let config = AnthropicConfig {
api_key: "test-key".to_string(),
base_url,
model: "claude-3-5-sonnet-20241022".to_string(),
max_tokens: 4096,
temperature: None,
system_prompt: None,
thinking: ThinkingConfig::enabled(10000),
tools: None,
tool_choice: None,
};
let llm = AnthropicChat::new(config);
let handler = Arc::new(ThinkingCallbackHandler::new());
let thinking_tokens = Arc::clone(&handler.thinking_tokens);
let text_tokens = Arc::clone(&handler.text_tokens);
let callbacks = CallbackManager::new().add_handler(handler);
let run_config = RunnableConfig::new().with_callbacks(Arc::new(callbacks));
let stream = llm
.stream_chat(
vec![Message::human("What is the answer?")],
Some(run_config),
)
.await
.unwrap();
use futures_util::StreamExt;
let text_content: String = stream
.filter_map(|result| async move { result.ok() })
.collect::<Vec<_>>()
.await
.join("");
assert!(
text_content.contains("42") || text_content.contains("answer"),
"expected text content to contain answer, got: {}",
text_content
);
let _thinking = thinking_tokens.lock().unwrap();
let _text = text_tokens.lock().unwrap();
}
#[test]
fn test_thinking_config_serialization() {
let enabled = ThinkingType::Enabled;
let json = serde_json::to_string(&enabled).unwrap();
assert!(json.contains("Enabled"));
let disabled = ThinkingType::Disabled;
let json = serde_json::to_string(&disabled).unwrap();
assert!(json.contains("Disabled"));
}
#[test]
fn test_llm_result_thinking_content_serialization() {
use langchainrust::core::language_models::TokenUsage;
use langchainrust::LLMResult;
let result = LLMResult {
content: "The answer is 42.".to_string(),
model: "claude-3-5-sonnet-20241022".to_string(),
token_usage: Some(TokenUsage {
prompt_tokens: 10,
completion_tokens: 50,
total_tokens: 60,
}),
tool_calls: None,
thinking_content: Some("I analyzed the question.".to_string()),
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("thinking_content"));
assert!(json.contains("I analyzed the question."));
let deserialized: LLMResult = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.content, "The answer is 42.");
assert_eq!(
deserialized.thinking_content,
Some("I analyzed the question.".to_string())
);
}
#[test]
fn test_llm_result_without_thinking_skips_field() {
use langchainrust::LLMResult;
let result = LLMResult {
content: "Hello".to_string(),
model: "test".to_string(),
token_usage: None,
tool_calls: None,
thinking_content: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(!json.contains("thinking_content"));
}