use std::collections::VecDeque;
use std::io::BufRead;
use std::path::Path;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use futures_util::Stream;
use lc_core::language_models::{BaseChatModel, BaseLanguageModel, LLMResult};
use lc_core::runnables::{Runnable, RunnableConfig};
use lc_core::tools::ToolDefinition;
use lc_schema::Message;
use crate::error::TestkitError;
use crate::recording::RecordedExchange;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ReplayStrategy {
#[default]
Fifo,
ByToolName,
}
#[derive(Clone)]
pub struct ReplayProvider {
queue: Arc<Mutex<VecDeque<RecordedExchange>>>,
model_name: String,
strategy: ReplayStrategy,
tools: Option<Vec<ToolDefinition>>,
}
impl ReplayProvider {
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, TestkitError> {
let file = std::fs::File::open(path)?;
let reader = std::io::BufReader::new(file);
let mut queue = VecDeque::new();
for line in reader.lines() {
let line = line?.trim().to_string();
if line.is_empty() {
continue;
}
let exchange: RecordedExchange = serde_json::from_str(&line).map_err(|e| {
TestkitError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("invalid recording line: {e}"),
))
})?;
queue.push_back(exchange);
}
Ok(Self {
queue: Arc::new(Mutex::new(queue)),
model_name: "replay".to_string(),
strategy: ReplayStrategy::Fifo,
tools: None,
})
}
pub fn from_exchanges(exchanges: Vec<RecordedExchange>) -> Self {
Self {
queue: Arc::new(Mutex::new(exchanges.into())),
model_name: "replay".to_string(),
strategy: ReplayStrategy::Fifo,
tools: None,
}
}
pub fn single(response: LLMResult) -> Self {
Self::from_exchanges(vec![RecordedExchange {
messages: Vec::new(),
response,
tools: None,
}])
}
pub fn with_strategy(mut self, strategy: ReplayStrategy) -> Self {
self.strategy = strategy;
self
}
pub fn bind_tools(&self, tools: Vec<ToolDefinition>) -> Self {
Self {
queue: self.queue.clone(),
model_name: self.model_name.clone(),
strategy: self.strategy,
tools: Some(tools),
}
}
pub fn len(&self) -> usize {
self.queue.lock().unwrap_or_else(|e| e.into_inner()).len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
fn exchange_matches(exchange: &RecordedExchange, tool_name: &str) -> bool {
if let Some(tools) = &exchange.tools {
if tools.iter().any(|t| t.function.name == tool_name) {
return true;
}
}
if let Some(calls) = &exchange.response.tool_calls {
if calls.iter().any(|c| c.name() == tool_name) {
return true;
}
}
false
}
#[async_trait]
impl Runnable<Vec<Message>, LLMResult> for ReplayProvider {
type Error = TestkitError;
async fn invoke(
&self,
input: Vec<Message>,
config: Option<RunnableConfig>,
) -> Result<LLMResult, Self::Error> {
self.chat(input, config).await
}
}
impl BaseLanguageModel<Vec<Message>, LLMResult> for ReplayProvider {
fn model_name(&self) -> &str {
&self.model_name
}
fn get_num_tokens(&self, text: &str) -> usize {
text.chars().count() / 4 + 1
}
fn temperature(&self) -> Option<f32> {
None
}
fn max_tokens(&self) -> Option<usize> {
None
}
fn with_temperature(self, _temp: f32) -> Self {
self
}
fn with_max_tokens(self, _max: usize) -> Self {
self
}
}
#[async_trait]
impl BaseChatModel for ReplayProvider {
async fn chat(
&self,
messages: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<LLMResult, Self::Error> {
let mut queue = self.queue.lock().unwrap_or_else(|e| e.into_inner());
let exchange = match self.strategy {
ReplayStrategy::Fifo => queue.pop_front(),
ReplayStrategy::ByToolName => {
let want = self
.tools
.as_ref()
.and_then(|tools| tools.first().map(|t| t.function.name.clone()));
match want {
Some(name) => queue
.iter()
.position(|ex| exchange_matches(ex, &name))
.map(|i| queue.remove(i).expect("position 必有元素")),
None => queue.pop_front(),
}
}
};
let Some(exchange) = exchange else {
return Err(TestkitError::ReplayExhausted {
requested: messages.len(),
});
};
Ok(exchange.response)
}
async fn stream_chat(
&self,
messages: Vec<Message>,
config: Option<RunnableConfig>,
) -> Result<Pin<Box<dyn Stream<Item = Result<String, Self::Error>> + Send>>, Self::Error> {
let response = self.chat(messages, config).await?;
let stream = futures_util::stream::iter(vec![Ok(response.content)]);
Ok(Box::pin(stream))
}
fn bind_tools(
&self,
tools: Vec<ToolDefinition>,
) -> Option<Box<dyn BaseChatModel<Error = Self::Error> + Send + Sync>> {
Some(Box::new(self.bind_tools(tools)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use lc_core::language_models::TokenUsage;
use lc_core::tools::{ToolCall, ToolDefinition};
fn exchange(content: &str) -> RecordedExchange {
RecordedExchange {
messages: vec![Message::system("ping")],
response: LLMResult {
content: content.to_string(),
model: "replay".to_string(),
token_usage: Some(TokenUsage {
prompt_tokens: 1,
completion_tokens: 2,
total_tokens: 3,
}),
..Default::default()
},
tools: None,
}
}
fn exchange_with_tool_call(tool_name: &str, content: &str) -> RecordedExchange {
let mut response = exchange(content).response;
response.tool_calls = Some(vec![ToolCall::builder("call_1")
.name(tool_name)
.arguments("{}".to_string())
.build()]);
RecordedExchange {
response,
..exchange(content)
}
}
fn exchange_with_bound_tool(tool_name: &str, content: &str) -> RecordedExchange {
RecordedExchange {
tools: Some(vec![ToolDefinition::new(tool_name, "a tool")]),
..exchange(content)
}
}
#[tokio::test]
async fn single_returns_fixed_response_for_any_request() {
let provider = ReplayProvider::single(exchange("hello").response);
let result = provider
.chat(vec![Message::system("any")], None)
.await
.unwrap();
assert_eq!(result.content, "hello");
}
#[tokio::test]
async fn replay_is_fifo_ordered() {
let provider = ReplayProvider::from_exchanges(vec![exchange("first"), exchange("second")]);
let first = provider
.chat(vec![Message::system("a")], None)
.await
.unwrap();
let second = provider
.chat(vec![Message::system("b")], None)
.await
.unwrap();
assert_eq!(first.content, "first");
assert_eq!(second.content, "second");
}
#[tokio::test]
async fn replay_exhausted_returns_error() {
let provider = ReplayProvider::from_exchanges(vec![exchange("only")]);
provider
.chat(vec![Message::system("a")], None)
.await
.unwrap();
let err = provider
.chat(vec![Message::system("b")], None)
.await
.unwrap_err();
assert!(matches!(
err,
TestkitError::ReplayExhausted { requested: 1 }
));
}
#[test]
fn bind_tools_returns_some_and_carries_tools() {
let provider = ReplayProvider::from_exchanges(vec![exchange("x")]);
let bound = provider.bind_tools(vec![ToolDefinition::new("calculator", "calc")]);
assert!(bound.tools.is_some());
assert_eq!(bound.tools.as_ref().unwrap()[0].function.name, "calculator");
assert_eq!(provider.len(), 1);
assert_eq!(bound.len(), 1);
let trait_bound = BaseChatModel::bind_tools(&provider, vec![ToolDefinition::new("x", "y")]);
assert!(trait_bound.is_some());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn parallel_replay_fifo_is_order_independent() {
let provider = ReplayProvider::from_exchanges(vec![
exchange("first"),
exchange("second"),
exchange("third"),
]);
let provider = std::sync::Arc::new(provider);
let mut handles = Vec::new();
for _ in 0..3 {
let p = provider.clone();
handles.push(tokio::spawn(async move {
p.chat(vec![Message::system("parallel")], None)
.await
.expect("并发回放不应失败")
}));
}
let mut contents: Vec<String> = Vec::new();
for handle in handles {
contents.push(handle.await.unwrap().content);
}
contents.sort();
assert_eq!(
contents,
vec![
"first".to_string(),
"second".to_string(),
"third".to_string()
]
);
assert!(provider.is_empty(), "并发回放应恰好耗尽全部录播");
}
#[tokio::test]
async fn by_tool_name_routes_to_matching_exchange() {
let provider = ReplayProvider::from_exchanges(vec![
exchange_with_tool_call("search", "search result"),
exchange_with_tool_call("calc", "calc result"),
])
.with_strategy(ReplayStrategy::ByToolName);
let search =
BaseChatModel::bind_tools(&provider, vec![ToolDefinition::new("search", "s")]).unwrap();
let calc =
BaseChatModel::bind_tools(&provider, vec![ToolDefinition::new("calc", "c")]).unwrap();
let calc_res = calc.chat(vec![Message::system("q")], None).await.unwrap();
let search_res = search.chat(vec![Message::system("q")], None).await.unwrap();
assert_eq!(search_res.content, "search result");
assert_eq!(calc_res.content, "calc result");
assert!(provider.is_empty());
}
#[tokio::test]
async fn by_tool_name_matches_request_side_tools() {
let provider = ReplayProvider::from_exchanges(vec![
exchange_with_bound_tool("weather", "sunny"),
exchange_with_bound_tool("news", "headlines"),
])
.with_strategy(ReplayStrategy::ByToolName);
let weather =
BaseChatModel::bind_tools(&provider, vec![ToolDefinition::new("weather", "w")])
.unwrap();
let res = weather
.chat(vec![Message::system("q")], None)
.await
.unwrap();
assert_eq!(res.content, "sunny");
}
}