use std::collections::VecDeque;
use std::sync::Mutex;
use af_llm::{ChatMessage, CompletionRequest, CompletionResponse, LlmError};
use async_trait::async_trait;
use tokio::sync::mpsc::UnboundedSender;
use crate::ChatModel;
#[derive(Default)]
pub struct ScriptedModel {
responses: Mutex<VecDeque<Result<CompletionResponse, LlmError>>>,
requests: Mutex<Vec<CompletionRequest>>,
}
impl ScriptedModel {
pub fn new(responses: impl IntoIterator<Item = Result<CompletionResponse, LlmError>>) -> Self {
Self {
responses: Mutex::new(responses.into_iter().collect()),
requests: Mutex::new(Vec::new()),
}
}
pub fn replies(messages: impl IntoIterator<Item = ChatMessage>) -> Self {
Self::new(
messages
.into_iter()
.map(|message| Ok(Self::response(message))),
)
}
pub fn response(message: ChatMessage) -> CompletionResponse {
let finish_reason = if message
.tool_calls
.as_ref()
.is_some_and(|calls| !calls.is_empty())
{
af_llm::FinishReason::ToolCalls
} else {
af_llm::FinishReason::Stop
};
CompletionResponse {
id: "scripted".into(),
choices: vec![af_llm::Choice {
index: 0,
message,
finish_reason: Some(finish_reason),
output_blocks: Vec::new(),
}],
usage: Some(af_llm::Usage {
prompt_tokens: 1,
completion_tokens: 1,
total_tokens: 2,
}),
}
}
pub fn remaining(&self) -> usize {
self.responses
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len()
}
pub fn requests(&self) -> Vec<CompletionRequest> {
self.requests
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
}
#[async_trait]
impl ChatModel for ScriptedModel {
async fn complete_streaming(
&self,
request: &CompletionRequest,
delta_tx: UnboundedSender<(String, bool)>,
) -> Result<CompletionResponse, LlmError> {
self.requests
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(request.clone());
let next = self
.responses
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.pop_front();
let response = match next {
Some(response) => response?,
None => return Err(LlmError::StreamProtocol("scripted model exhausted".into())),
};
if let Some(content) = response.first_content() {
let _ = delta_tx.send((content.into(), response.first_tool_calls().is_some()));
}
Ok(response)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn scripted_model_replays_records_and_exhausts_loudly() {
let model = ScriptedModel::replies([ChatMessage::assistant("hi")]);
assert_eq!(model.remaining(), 1);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let request = CompletionRequest::new("m", vec![ChatMessage::user("hello")]);
let response = model.complete_streaming(&request, tx).await.unwrap();
assert_eq!(response.first_content(), Some("hi"));
assert_eq!(rx.recv().await.unwrap(), ("hi".to_string(), false));
assert_eq!(model.requests().len(), 1);
assert_eq!(model.remaining(), 0);
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
assert!(matches!(
model.complete_streaming(&request, tx).await,
Err(LlmError::StreamProtocol(_))
));
let failing = ScriptedModel::new([Err(LlmError::CircuitOpen)]);
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
assert!(matches!(
failing.complete_streaming(&request, tx).await,
Err(LlmError::CircuitOpen)
));
}
}