use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
use std::sync::{Arc, RwLock};
use async_trait::async_trait;
use foundation_core::valtron::Stream;
use crate::errors::GenerationResult;
use crate::types::{
BoxModel, CostStatus, MessageRole, Messages, ModelId, ModelInteraction, ModelOutput,
ModelParams, ModelProviderDescriptor, ModelProviders, ModelSpec, ModelState, ModelStreamBox,
StopReason, TextBasedFormatter, TextContent, ToolFormatter, UsageCosting, UsageReport,
UserModelContent,
};
use crate::types::{ProviderRouter, RoutableProvider};
use super::tool_impl::{ToolCallResult, ToolDefinition, ToolError, ToolImpl};
type Matcher = Box<dyn Fn(&ModelInteraction, usize) -> bool + Send + Sync>;
struct Script {
matcher: Matcher,
replies: Vec<Messages>,
}
struct Failure {
matcher: Matcher,
error: String,
}
pub struct MockModelProvider {
inner: Arc<MockInner>,
name: String,
}
#[derive(Default)]
struct MockInner {
scripts: RwLock<Vec<Script>>,
failures: RwLock<Vec<Failure>>,
call_count: AtomicUsize,
}
impl MockModelProvider {
#[must_use]
pub fn new() -> Self {
Self {
inner: Arc::new(MockInner::default()),
name: "mock".into(),
}
}
pub fn on(
&mut self,
matcher: impl Fn(&ModelInteraction) -> bool + Send + Sync + 'static,
reply: Vec<Messages>,
) -> &mut Self {
self.inner.scripts.write().expect("mock scripts poisoned").push(Script {
matcher: Box::new(move |mi, _| matcher(mi)),
replies: reply,
});
self
}
pub fn on_nth_call(&mut self, n: usize, reply: Vec<Messages>) -> &mut Self {
self.inner.scripts.write().expect("mock scripts poisoned").push(Script {
matcher: Box::new(move |_, call| call == n),
replies: reply,
});
self
}
pub fn on_any(&mut self, reply: Vec<Messages>) -> &mut Self {
self.inner.scripts.write().expect("mock scripts poisoned").push(Script {
matcher: Box::new(|_, _| true),
replies: reply,
});
self
}
pub fn fail_with(
&mut self,
matcher: impl Fn(&ModelInteraction) -> bool + Send + Sync + 'static,
error: impl Into<String>,
) -> &mut Self {
self.inner.failures.write().expect("mock failures poisoned").push(Failure {
matcher: Box::new(move |mi, _| matcher(mi)),
error: error.into(),
});
self
}
pub fn call_count(&self) -> usize {
self.inner.call_count.load(Ordering::Relaxed)
}
pub fn resolve(&self, mi: &ModelInteraction) -> GenerationResult<Vec<Messages>> {
self.inner.resolve(mi)
}
}
impl MockInner {
fn resolve(&self, mi: &ModelInteraction) -> GenerationResult<Vec<Messages>> {
let n = self.call_count.fetch_add(1, Ordering::Relaxed);
for f in self.failures.read().expect("mock failures poisoned").iter() {
if (f.matcher)(mi, n) {
return Err(crate::errors::GenerationError::Generic(f.error.clone()));
}
}
for s in self.scripts.read().expect("mock scripts poisoned").iter() {
if (s.matcher)(mi, n) {
return Ok(s.replies.clone());
}
}
Err(crate::errors::GenerationError::Generic(
"MockModelProvider: no matching script for interaction".into(),
))
}
}
impl MockModelProvider {
#[must_use]
pub fn into_router(self) -> ProviderRouter {
ProviderRouter::single(Box::new(self))
}
}
impl Default for MockModelProvider {
fn default() -> Self {
Self::new()
}
}
impl RoutableProvider for MockModelProvider {
fn name(&self) -> &str {
&self.name
}
fn provider_id(&self) -> ModelProviders {
ModelProviders::Custom("mock".into())
}
fn describe(&self) -> Option<ModelProviderDescriptor> {
None
}
fn serves(&self, _model_id: &ModelId) -> bool {
true
}
fn get_one(&self, model_id: &ModelId) -> Option<ModelSpec> {
Some(ModelSpec {
name: model_id.name().to_owned(),
id: model_id.clone(),
devices: None,
model_location: None,
lora_location: None,
})
}
fn get_all(&self, model_id: &ModelId) -> Vec<ModelSpec> {
self.get_one(model_id).into_iter().collect()
}
fn get_model(&self, model_id: &ModelId) -> Option<BoxModel> {
Some(Box::new(MockModel {
inner: Arc::clone(&self.inner),
model_id: model_id.clone(),
}))
}
}
pub struct MockModel {
inner: Arc<MockInner>,
model_id: ModelId,
}
impl crate::types::Model for MockModel {
fn spec(&self) -> ModelSpec {
ModelSpec {
name: self.model_id.name().to_owned(),
id: self.model_id.clone(),
devices: None,
model_location: None,
lora_location: None,
}
}
fn tool_formatter(&self) -> Box<dyn ToolFormatter> {
Box::new(TextBasedFormatter)
}
fn descriptor(&self) -> Option<ModelProviderDescriptor> {
None
}
fn costing(&self) -> GenerationResult<UsageReport> {
Ok(zero_usage())
}
fn generate(
&self,
interaction: ModelInteraction,
_specs: Option<ModelParams>,
) -> GenerationResult<Vec<Messages>> {
self.inner.resolve(&interaction)
}
fn stream(
&self,
interaction: ModelInteraction,
_specs: Option<ModelParams>,
) -> GenerationResult<ModelStreamBox> {
let messages = self.inner.resolve(&interaction)?;
Ok(Box::new(MockStreamIterator::new(messages)))
}
}
pub struct MockStreamIterator {
items: Vec<Messages>,
pos: usize,
sent_init: bool,
}
impl MockStreamIterator {
#[must_use]
pub fn new(items: Vec<Messages>) -> Self {
Self {
items,
pos: 0,
sent_init: false,
}
}
}
impl Iterator for MockStreamIterator {
type Item = Stream<Messages, ModelState>;
fn next(&mut self) -> Option<Self::Item> {
if !self.sent_init {
self.sent_init = true;
return Some(Stream::Init);
}
if self.pos < self.items.len() {
let msg = self.items[self.pos].clone();
self.pos += 1;
Some(Stream::Next(msg))
} else {
None
}
}
}
#[must_use]
pub fn mock_text(s: &str) -> Messages {
mock_text_usage(s, zero_usage())
}
#[must_use]
pub fn mock_text_usage(s: &str, usage: UsageReport) -> Messages {
Messages::Assistant {
id: foundation_compact::ids::new_scru128(),
model: ModelId::Name("mock".into(), None),
timestamp: foundation_compact::SystemTime::UNIX_EPOCH,
usage,
content: ModelOutput::Text(TextContent {
content: s.into(),
signature: None,
}),
stop_reason: StopReason::Stop,
provider: ModelProviders::Custom("mock".into()),
error_detail: None,
signature: None,
metadata: None,
}
}
#[must_use]
#[allow(clippy::implicit_hasher)]
pub fn mock_tool_call(name: &str, args: HashMap<String, crate::types::ArgType>) -> Messages {
Messages::Assistant {
id: foundation_compact::ids::new_scru128(),
model: ModelId::Name("mock".into(), None),
timestamp: foundation_compact::SystemTime::UNIX_EPOCH,
usage: zero_usage(),
content: ModelOutput::ToolCall {
id: format!("call_{name}"),
name: name.into(),
arguments: Some(args),
signature: None,
depends_on: Vec::new(),
execution_hint: crate::types::ExecutionHint::Unspecified,
},
stop_reason: StopReason::ToolUse,
provider: ModelProviders::Custom("mock".into()),
error_detail: None,
signature: None,
metadata: None,
}
}
#[must_use]
pub fn mock_user(s: &str) -> Messages {
Messages::User {
id: foundation_compact::ids::new_scru128(),
role: MessageRole::User,
content: UserModelContent::Text(TextContent {
content: s.into(),
signature: None,
}),
signature: None,
}
}
#[must_use]
pub fn zero_usage() -> UsageReport {
UsageReport {
input: 0.0,
output: 0.0,
cache_read: 0.0,
cache_write: 0.0,
total_tokens: 0.0,
cost: UsageCosting::zero(CostStatus::Estimated),
}
}
pub fn last_user_contains(needle: &str) -> impl Fn(&ModelInteraction) -> bool + Send + Sync + '_ {
move |mi: &ModelInteraction| {
mi.messages.iter().rev().any(|m| matches!(
m,
Messages::User { content: UserModelContent::Text(t), .. } if t.content.contains(needle)
))
}
}
pub fn tools_shed_has(name: impl Into<String>) -> impl Fn(&ModelInteraction) -> bool + Send + Sync {
let name = name.into();
move |mi: &ModelInteraction| mi.tools_shed.all_tools().iter().any(|t| t.name() == name)
}
pub fn system_prompt_contains(
needle: impl Into<String>,
) -> impl Fn(&ModelInteraction) -> bool + Send + Sync {
let needle = needle.into();
move |mi: &ModelInteraction| {
mi.system_prompt
.as_ref()
.is_some_and(|sp| sp.contains(&needle))
}
}
pub enum ToolBehavior {
Returns(ToolCallResult),
Fails(ToolError),
FailsThenSucceeds {
failures: u32,
error: ToolError,
result: ToolCallResult,
},
}
pub struct MockTool {
pub name: String,
pub description: String,
pub category: String,
pub behavior: ToolBehavior,
attempt: AtomicU32,
}
impl MockTool {
#[must_use]
pub fn new(name: impl Into<String>, behavior: ToolBehavior) -> Self {
Self {
name: name.into(),
description: "mock tool".into(),
category: "shell".into(),
behavior,
attempt: AtomicU32::new(0),
}
}
#[must_use]
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = desc.into();
self
}
#[must_use]
pub fn with_category(mut self, category: impl Into<String>) -> Self {
self.category = category.into();
self
}
#[must_use]
pub fn returning(name: impl Into<String>, text: impl Into<String>) -> Self {
Self::new(
name,
ToolBehavior::Returns(ToolCallResult {
content: UserModelContent::Text(TextContent {
content: text.into(),
signature: None,
}),
error_detail: None,
}),
)
}
#[must_use]
pub fn failing(name: impl Into<String>, reason: impl Into<String>) -> Self {
let n: String = name.into();
Self::new(
n.clone(),
ToolBehavior::Fails(ToolError::Execution {
tool: n,
reason: reason.into(),
}),
)
}
}
#[async_trait]
impl ToolImpl for MockTool {
fn definition(&self) -> crate::types::Tool {
crate::types::Tool::SingleCommand(ToolDefinition {
name: self.name.clone(),
category: self.category.clone(),
description: self.description.clone(),
arguments: crate::types::Args::from_value(serde_json::json!({})),
returns: None,
})
}
async fn execute(
&self,
_arguments: HashMap<String, crate::types::ArgType>,
) -> Result<ToolCallResult, ToolError> {
match &self.behavior {
ToolBehavior::Returns(r) => Ok(r.clone()),
ToolBehavior::Fails(e) => Err(e.clone()),
ToolBehavior::FailsThenSucceeds {
failures,
error,
result,
} => {
let n = self.attempt.fetch_add(1, Ordering::Relaxed);
if n < *failures {
Err(error.clone())
} else {
Ok(result.clone())
}
}
}
}
}