use axum::{
extract::State,
http::{header, StatusCode},
response::IntoResponse,
routing::post,
Router,
};
use serde_json::{json, Value};
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc, Mutex,
};
use tokio_util::sync::CancellationToken;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Provider {
Anthropic,
OpenAi,
}
#[derive(Clone, Debug)]
pub struct RequestFacts {
pub call_index: usize,
pub has_tool_results: bool,
pub paths: Vec<String>,
pub body: Value,
}
#[derive(Clone, Default)]
pub struct MockState {
call_count: Arc<AtomicUsize>,
tools_declared_count: Arc<AtomicUsize>,
declared_tool_names: Arc<Mutex<Vec<String>>>,
tool_result_ids: Arc<Mutex<Vec<String>>>,
}
impl MockState {
pub fn call_count(&self) -> usize {
self.call_count.load(Ordering::SeqCst)
}
pub fn tools_declared_count(&self) -> usize {
self.tools_declared_count.load(Ordering::SeqCst)
}
pub fn declared_tool_names(&self) -> Vec<String> {
self.declared_tool_names.lock().expect("lock").clone()
}
pub fn declared_count_of(&self, name: &str) -> usize {
self.declared_tool_names
.lock()
.expect("lock")
.iter()
.filter(|n| n.as_str() == name)
.count()
}
pub fn tool_result_ids(&self) -> Vec<String> {
self.tool_result_ids.lock().expect("lock").clone()
}
}
pub struct MockHandle {
pub base_url: String,
pub state: MockState,
pub ct: CancellationToken,
}
type Responder = Arc<dyn Fn(&RequestFacts) -> Value + Send + Sync>;
pub struct MockLlm {
provider: Provider,
responder: Responder,
}
impl MockLlm {
pub fn anthropic(responder: impl Fn(&RequestFacts) -> Value + Send + Sync + 'static) -> Self {
Self {
provider: Provider::Anthropic,
responder: Arc::new(responder),
}
}
pub fn openai(responder: impl Fn(&RequestFacts) -> Value + Send + Sync + 'static) -> Self {
Self {
provider: Provider::OpenAi,
responder: Arc::new(responder),
}
}
pub async fn spawn(self) -> MockHandle {
let state = MockState::default();
let ct = CancellationToken::new();
let route = match self.provider {
Provider::Anthropic => "/v1/messages",
Provider::OpenAi => "/chat/completions",
};
let shared = HandlerState {
provider: self.provider,
responder: self.responder,
state: state.clone(),
};
let router = Router::new()
.route(route, post(handle_request))
.with_state(shared);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral port for testkit mock LLM");
let addr = listener.local_addr().expect("local_addr");
let ct_shutdown = ct.clone();
tokio::spawn(async move {
let _ = axum::serve(listener, router)
.with_graceful_shutdown(async move { ct_shutdown.cancelled_owned().await })
.await;
});
MockHandle {
base_url: format!("http://{addr}"),
state,
ct,
}
}
}
#[derive(Clone)]
struct HandlerState {
provider: Provider,
responder: Responder,
state: MockState,
}
async fn handle_request(
State(hs): State<HandlerState>,
body: axum::body::Bytes,
) -> impl IntoResponse {
let parsed = match serde_json::from_slice::<Value>(&body) {
Ok(v) => v,
Err(e) => {
let err_body = json!({ "error": format!("bad request: {e}") }).to_string();
return (
StatusCode::BAD_REQUEST,
[(header::CONTENT_TYPE, "application/json")],
err_body,
);
}
};
let call_index = hs.state.call_count.fetch_add(1, Ordering::SeqCst);
record_tools(&hs.state, &parsed);
record_tool_result_ids(&hs.state, hs.provider, &parsed);
let facts = RequestFacts {
call_index,
has_tool_results: has_tool_results(hs.provider, &parsed),
paths: extract_paths(&parsed),
body: parsed,
};
let response = (hs.responder)(&facts);
(
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json")],
response.to_string(),
)
}
fn record_tools(state: &MockState, body: &Value) {
let Some(tools) = body.get("tools").and_then(|t| t.as_array()) else {
return;
};
state.tools_declared_count.fetch_add(1, Ordering::SeqCst);
let mut names = state.declared_tool_names.lock().expect("lock");
for t in tools {
let name = t.get("name").and_then(|n| n.as_str()).or_else(|| {
t.get("function")
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str())
});
if let Some(name) = name {
names.push(name.to_string());
}
}
}
fn record_tool_result_ids(state: &MockState, provider: Provider, body: &Value) {
let Some(messages) = body.get("messages").and_then(|m| m.as_array()) else {
return;
};
let mut ids = state.tool_result_ids.lock().expect("lock");
for msg in messages {
match provider {
Provider::OpenAi => {
if msg.get("role").and_then(|r| r.as_str()) == Some("tool") {
let id = msg
.get("tool_call_id")
.and_then(|v| v.as_str())
.unwrap_or("");
ids.push(id.to_string());
}
}
Provider::Anthropic => {
if msg.get("role").and_then(|r| r.as_str()) != Some("user") {
continue;
}
let Some(blocks) = msg.get("content").and_then(|c| c.as_array()) else {
continue;
};
for b in blocks {
if b.get("type").and_then(|t| t.as_str()) == Some("tool_result") {
let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
ids.push(id.to_string());
}
}
}
}
}
}
fn has_tool_results(provider: Provider, body: &Value) -> bool {
let Some(messages) = body.get("messages").and_then(|m| m.as_array()) else {
return false;
};
messages.iter().any(|msg| match provider {
Provider::OpenAi => msg.get("role").and_then(|r| r.as_str()) == Some("tool"),
Provider::Anthropic => {
msg.get("role").and_then(|r| r.as_str()) == Some("user")
&& msg
.get("content")
.and_then(|c| c.as_array())
.map(|blocks| {
blocks
.iter()
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_result"))
})
.unwrap_or(false)
}
})
}
fn extract_paths(body: &Value) -> Vec<String> {
let mut paths = Vec::new();
let Some(messages) = body.get("messages").and_then(|m| m.as_array()) else {
return paths;
};
for msg in messages {
let Some(content) = msg.get("content").and_then(|c| c.as_str()) else {
continue;
};
let mut in_files_section = false;
for line in content.lines() {
if line.trim() == "Files:" {
in_files_section = true;
continue;
}
if in_files_section {
let trimmed = line.trim();
if trimmed.starts_with('/') {
let p = trimmed.to_string();
if !paths.contains(&p) {
paths.push(p);
}
if paths.len() >= 2 {
return paths;
}
} else if !trimmed.is_empty() {
in_files_section = false;
}
}
}
}
paths
}