#![allow(clippy::missing_const_for_fn)]
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use thiserror::Error;
use crate::entropy::{Entropy, SeededEntropy, derive_uuid_from};
const LLM_FAULT_SALT: u64 = 0x11A0_FA17_11A0_FA17;
const LLM_LATENCY_SALT: u64 = 0x1A7E_0C77_1A7E_0C77;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LlmRequest {
pub model: String,
pub prompt: String,
}
impl LlmRequest {
#[must_use]
pub fn new(model: impl Into<String>, prompt: impl Into<String>) -> Self {
Self {
model: model.into(),
prompt: prompt.into(),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LlmResponse {
pub text: String,
pub model: String,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum LlmError {
#[error("llm request timed out")]
Timeout,
#[error("llm request was rate limited")]
RateLimited,
#[error("llm service unavailable")]
ServiceUnavailable,
#[error("llm server error: {0}")]
Server(String),
}
pub trait LlmClient: Send + Sync {
fn complete<'a>(
&'a self,
req: LlmRequest,
) -> Pin<Box<dyn Future<Output = Result<LlmResponse, LlmError>> + Send + 'a>>;
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LlmCall {
pub seq: u64,
pub latency: Duration,
pub error: Option<LlmError>,
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct SeededLlmBuilder {
seed: u64,
default_model: String,
canned: Vec<(String, String)>,
explicit_faults: Vec<(u64, LlmError)>,
fault_prob: f64,
fault_kind: LlmError,
max_latency: Option<Duration>,
}
impl SeededLlmBuilder {
fn new(seed: u64) -> Self {
Self {
seed,
default_model: "sim-llm".to_string(),
canned: Vec::new(),
explicit_faults: Vec::new(),
fault_prob: 0.0,
fault_kind: LlmError::ServiceUnavailable,
max_latency: None,
}
}
#[must_use]
pub fn default_model(mut self, model: impl Into<String>) -> Self {
self.default_model = model.into();
self
}
#[must_use]
pub fn canned_response(
mut self,
prompt_match: impl Into<String>,
response: impl Into<String>,
) -> Self {
self.canned.push((prompt_match.into(), response.into()));
self
}
#[must_use]
pub fn fault_at(mut self, call_index: u64, error: LlmError) -> Self {
self.explicit_faults.push((call_index, error));
self
}
#[must_use]
pub fn fault_probability(mut self, p: f64, error: LlmError) -> Self {
self.fault_prob = clamp_prob(p);
self.fault_kind = error;
self
}
#[must_use]
pub fn latency_up_to(mut self, max: Duration) -> Self {
self.max_latency = (max > Duration::ZERO).then_some(max);
self
}
#[must_use]
pub fn build(self) -> SeededLlm {
SeededLlm {
seed: self.seed,
default_model: self.default_model,
canned: self.canned,
explicit_faults: self.explicit_faults,
fault_prob: self.fault_prob,
fault_kind: self.fault_kind,
max_latency: self.max_latency,
fault_stream: SeededEntropy::shared(self.seed ^ LLM_FAULT_SALT),
latency_stream: SeededEntropy::shared(self.seed ^ LLM_LATENCY_SALT),
call_seq: AtomicU64::new(0),
calls: Mutex::new(Vec::new()),
}
}
}
pub struct SeededLlm {
seed: u64,
default_model: String,
canned: Vec<(String, String)>,
explicit_faults: Vec<(u64, LlmError)>,
fault_prob: f64,
fault_kind: LlmError,
max_latency: Option<Duration>,
fault_stream: Arc<dyn Entropy>,
latency_stream: Arc<dyn Entropy>,
call_seq: AtomicU64,
calls: Mutex<Vec<LlmCall>>,
}
impl std::fmt::Debug for SeededLlm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SeededLlm")
.field("seed", &self.seed)
.finish_non_exhaustive()
}
}
impl SeededLlm {
#[must_use]
pub fn builder(seed: u64) -> SeededLlmBuilder {
SeededLlmBuilder::new(seed)
}
#[must_use]
pub fn from_entropy(source: &dyn Entropy) -> SeededLlmBuilder {
SeededLlmBuilder::new(source.next_u64())
}
#[must_use]
pub fn calls(&self) -> Vec<LlmCall> {
self.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
fn draw_latency(&self) -> Duration {
let Some(max) = self.max_latency else {
return Duration::ZERO;
};
let max_nanos = max.as_nanos();
if max_nanos == 0 {
return Duration::ZERO;
}
let draw = u128::from(self.latency_stream.next_u64());
let picked = 1 + (draw % max_nanos);
Duration::from_nanos(u64::try_from(picked).unwrap_or(u64::MAX))
}
#[allow(clippy::cast_precision_loss)] fn fault_fires(&self) -> bool {
if self.fault_prob <= 0.0 {
return false;
}
let draw = self.fault_stream.next_u64();
let unit = (draw >> 11) as f64 / (1u64 << 53) as f64;
unit < self.fault_prob
}
fn outcome(&self, call: u64, req: &LlmRequest) -> Result<LlmResponse, LlmError> {
if let Some((_, err)) = self.explicit_faults.iter().find(|(idx, _)| *idx == call) {
return Err(err.clone());
}
if self.fault_fires() {
return Err(self.fault_kind.clone());
}
let model = if req.model.is_empty() {
self.default_model.clone()
} else {
req.model.clone()
};
let text = self
.canned
.iter()
.find(|(pat, _)| req.prompt.contains(pat.as_str()))
.map_or_else(|| fallback_text(self.seed, &req.prompt), |(_, r)| r.clone());
Ok(LlmResponse { text, model })
}
fn record(&self, seq: u64, latency: Duration, error: Option<LlmError>) {
self.calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(LlmCall {
seq,
latency,
error,
});
}
}
impl LlmClient for SeededLlm {
fn complete<'a>(
&'a self,
req: LlmRequest,
) -> Pin<Box<dyn Future<Output = Result<LlmResponse, LlmError>> + Send + 'a>> {
Box::pin(async move {
let seq = self.call_seq.fetch_add(1, Ordering::SeqCst);
let latency = self.draw_latency();
if latency > Duration::ZERO {
tokio::time::sleep(latency).await;
}
let outcome = self.outcome(seq, &req);
self.record(seq, latency, outcome.as_ref().err().cloned());
outcome
})
}
}
fn clamp_prob(p: f64) -> f64 {
if p.is_nan() { 0.0 } else { p.clamp(0.0, 1.0) }
}
fn fallback_text(seed: u64, prompt: &str) -> String {
let mut tag = b"sim-llm-fallback:".to_vec();
tag.extend_from_slice(prompt.as_bytes());
let id = derive_uuid_from(seed, &tag);
format!("sim-llm[{id}]: acknowledged \"{prompt}\"")
}
#[cfg(test)]
mod tests {
use super::{LlmError, SeededLlm, clamp_prob, fallback_text};
#[test]
fn probabilities_are_clamped() {
assert!((clamp_prob(-1.0) - 0.0).abs() < f64::EPSILON);
assert!((clamp_prob(2.0) - 1.0).abs() < f64::EPSILON);
assert!((clamp_prob(f64::NAN) - 0.0).abs() < f64::EPSILON);
}
#[test]
fn fallback_is_seed_and_prompt_stable_and_diverges() {
assert_eq!(fallback_text(7, "hello"), fallback_text(7, "hello"));
assert_ne!(fallback_text(7, "hello"), fallback_text(7, "world"));
assert_ne!(fallback_text(7, "hello"), fallback_text(8, "hello"));
}
#[test]
fn explicit_fault_is_a_pure_lookup() {
let llm = SeededLlm::builder(1).fault_at(2, LlmError::Timeout).build();
assert!(matches!(
llm.outcome(2, &super::LlmRequest::new("m", "p")),
Err(LlmError::Timeout)
));
assert!(llm.outcome(0, &super::LlmRequest::new("m", "p")).is_ok());
}
#[test]
fn from_entropy_seed_is_reproducible() {
use crate::entropy::SeededEntropy;
let a = SeededLlm::from_entropy(&SeededEntropy::new(3)).build();
let b = SeededLlm::from_entropy(&SeededEntropy::new(3)).build();
assert_eq!(a.seed, b.seed);
assert_eq!(fallback_text(a.seed, "x"), fallback_text(b.seed, "x"));
}
}