use std::collections::VecDeque;
use std::sync::Mutex;
use async_trait::async_trait;
use klieo_core::error::LlmError;
use klieo_core::llm::{
Capabilities, ChatRequest, ChatResponse, ChunkStream, Embedding, FinishReason, LlmClient,
Message, Role, ToolCall, Usage,
};
use crate::types::LlmIo;
#[derive(Clone)]
struct Recorded {
completion: String,
finish_reason: FinishReason,
tool_calls: Vec<ToolCall>,
model: String,
}
pub struct ReplayLlmClient {
name: String,
capabilities: Capabilities,
queue: Mutex<VecDeque<Recorded>>,
}
impl ReplayLlmClient {
pub fn from_llm_io(name: impl Into<String>, llm_io: &[LlmIo]) -> Self {
let queue = llm_io
.iter()
.map(|io| Recorded {
completion: io.completion.clone(),
finish_reason: io.finish_reason.unwrap_or(FinishReason::Stop),
tool_calls: io.tool_calls.clone(),
model: io.model.clone().unwrap_or_else(|| "replay:unknown".into()),
})
.collect();
Self {
name: name.into(),
capabilities: Capabilities::default(),
queue: Mutex::new(queue),
}
}
pub fn remaining(&self) -> usize {
self.queue
.lock()
.expect("ReplayLlmClient mutex poisoned")
.len()
}
}
#[async_trait]
impl LlmClient for ReplayLlmClient {
fn name(&self) -> &str {
&self.name
}
fn capabilities(&self) -> &Capabilities {
&self.capabilities
}
async fn complete(&self, _req: ChatRequest) -> Result<ChatResponse, LlmError> {
let recorded = {
let mut queue = self.queue.lock().expect("ReplayLlmClient mutex poisoned");
queue.pop_front().ok_or_else(|| {
LlmError::Server(
"re-drive divergence: current code requested more LLM calls than the recording"
.into(),
)
})?
};
Ok(ChatResponse::new(
Message {
role: Role::Assistant,
content: recorded.completion,
tool_calls: recorded.tool_calls,
tool_call_id: None,
},
Usage::default(),
recorded.finish_reason,
recorded.model,
))
}
async fn stream(&self, _req: ChatRequest) -> Result<ChunkStream, LlmError> {
Err(LlmError::Unsupported(
"ReplayLlmClient does not support streaming".into(),
))
}
async fn embed(&self, _texts: &[String]) -> Result<Vec<Embedding>, LlmError> {
Err(LlmError::Unsupported(
"ReplayLlmClient does not support embedding".into(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use klieo_core::llm::ResponseFormat;
fn request(content: &str) -> ChatRequest {
ChatRequest {
messages: vec![Message {
role: Role::User,
content: content.into(),
tool_calls: vec![],
tool_call_id: None,
}],
tools: vec![],
temperature: None,
max_tokens: None,
response_format: ResponseFormat::Text,
stop: vec![],
timeout: None,
}
}
#[tokio::test]
async fn replays_structured_responses_in_order() {
let tool_step = LlmIo::new("", "").with_response_shape(
FinishReason::ToolCalls,
vec![ToolCall::new("1", "search", serde_json::json!({}))],
);
let final_step = LlmIo::new("", "done");
let client = ReplayLlmClient::from_llm_io("replay", &[tool_step, final_step]);
let first = client.complete(request("a")).await.unwrap();
assert_eq!(first.finish_reason, FinishReason::ToolCalls);
assert_eq!(first.message.tool_calls.len(), 1);
let second = client.complete(request("b")).await.unwrap();
assert_eq!(second.finish_reason, FinishReason::Stop);
assert_eq!(second.message.content, "done");
}
#[tokio::test]
async fn remaining_tracks_undrained_responses() {
let client =
ReplayLlmClient::from_llm_io("replay", &[LlmIo::new("", "one"), LlmIo::new("", "two")]);
assert_eq!(client.remaining(), 2);
client.complete(request("a")).await.unwrap();
assert_eq!(client.remaining(), 1, "one response left after one call");
client.complete(request("b")).await.unwrap();
assert_eq!(client.remaining(), 0, "fully drained");
}
#[tokio::test]
async fn exhaustion_is_a_divergence_error() {
let client = ReplayLlmClient::from_llm_io("replay", &[LlmIo::new("", "only")]);
client.complete(request("a")).await.unwrap();
let err = client.complete(request("b")).await.unwrap_err();
assert!(
matches!(err, LlmError::Server(m) if m.contains("re-drive divergence")),
"a call past the recording is the hard divergence signal"
);
}
#[tokio::test]
async fn stream_and_embed_are_unsupported() {
let client = ReplayLlmClient::from_llm_io("replay", &[LlmIo::new("a", "b")]);
assert!(matches!(
client.stream(request("a")).await,
Err(LlmError::Unsupported(_))
));
assert!(matches!(
client.embed(&["x".to_string()]).await,
Err(LlmError::Unsupported(_))
));
}
#[tokio::test]
async fn ignores_request_content_answering_by_order() {
let client = ReplayLlmClient::from_llm_io("replay", &[LlmIo::new("recorded", "answer")]);
let resp = client.complete(request("totally different")).await.unwrap();
assert_eq!(resp.message.content, "answer");
}
}