use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::{watch, Mutex};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MockToolCall {
pub id: String,
pub name: String,
pub arguments: String,
}
#[derive(Debug, Clone)]
pub enum MockResponse {
Text(String),
ToolCalls(Vec<MockToolCall>),
Error { status: u16, body: String },
ErrorWithHeaders {
status: u16,
body: String,
headers: Vec<(String, String)>,
},
}
pub struct MockLlmServer {
url: String,
shutdown_tx: watch::Sender<bool>,
handle: tokio::task::JoinHandle<()>,
captured_requests: Arc<Mutex<Vec<String>>>,
}
impl MockLlmServer {
pub fn builder() -> MockLlmServerBuilder {
MockLlmServerBuilder::default()
}
pub async fn start(config: MockServerConfig) -> Self {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind mock server");
let addr = listener.local_addr().expect("failed to get local addr");
let url = format!("http://{}", addr);
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let config = Arc::new(config);
let captured_requests = Arc::new(Mutex::new(Vec::new()));
let handle = tokio::spawn(accept_loop(
listener,
config,
shutdown_rx,
Arc::clone(&captured_requests),
));
Self {
url,
shutdown_tx,
handle,
captured_requests,
}
}
pub fn url(&self) -> &str {
&self.url
}
pub async fn captured_request_bodies(&self) -> Vec<String> {
self.captured_requests.lock().await.clone()
}
pub async fn stop(self) {
let _ = self.shutdown_tx.send(true);
let _ = self.handle.await;
}
}
#[derive(Debug, Clone)]
pub struct MockServerConfig {
pub responses: Vec<MockResponse>,
pub default_response: MockResponse,
pub latency_ms: u64,
pub model: String,
}
impl Default for MockServerConfig {
fn default() -> Self {
Self {
responses: Vec::new(),
default_response: MockResponse::Text("Hello from MockLlmServer".to_string()),
latency_ms: 0,
model: "mock-model".to_string(),
}
}
}
#[derive(Default)]
pub struct MockLlmServerBuilder {
config: MockServerConfig,
}
impl MockLlmServerBuilder {
pub fn with_response(mut self, text: impl Into<String>) -> Self {
self.config.responses.push(MockResponse::Text(text.into()));
self
}
pub fn with_tool_calls(mut self, calls: Vec<MockToolCall>) -> Self {
self.config.responses.push(MockResponse::ToolCalls(calls));
self
}
pub fn with_error(mut self, status: u16, body: impl Into<String>) -> Self {
self.config.responses.push(MockResponse::Error {
status,
body: body.into(),
});
self
}
pub fn with_error_and_headers(
mut self,
status: u16,
body: impl Into<String>,
headers: Vec<(String, String)>,
) -> Self {
self.config.responses.push(MockResponse::ErrorWithHeaders {
status,
body: body.into(),
headers,
});
self
}
pub fn with_latency(mut self, ms: u64) -> Self {
self.config.latency_ms = ms;
self
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.config.model = model.into();
self
}
pub fn with_default_response(mut self, resp: MockResponse) -> Self {
self.config.default_response = resp;
self
}
pub async fn build(self) -> MockLlmServer {
MockLlmServer::start(self.config).await
}
}
async fn accept_loop(
listener: TcpListener,
config: Arc<MockServerConfig>,
mut shutdown_rx: watch::Receiver<bool>,
captured_requests: Arc<Mutex<Vec<String>>>,
) {
let response_idx = Arc::new(Mutex::new(0usize));
loop {
tokio::select! {
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
break;
}
}
accept_result = listener.accept() => {
match accept_result {
Ok((stream, _addr)) => {
let cfg = Arc::clone(&config);
let idx = Arc::clone(&response_idx);
let cap = Arc::clone(&captured_requests);
tokio::spawn(async move {
if let Err(e) = handle_connection(stream, cfg, idx, cap).await {
tracing::debug!("mock server connection error: {}", e);
}
});
}
Err(e) => {
tracing::debug!("mock server accept error: {}", e);
}
}
}
}
}
}
async fn handle_connection(
mut stream: tokio::net::TcpStream,
config: Arc<MockServerConfig>,
response_idx: Arc<Mutex<usize>>,
captured_requests: Arc<Mutex<Vec<String>>>,
) -> std::io::Result<()> {
let mut buf = vec![0u8; 65536];
let n = stream.read(&mut buf).await?;
if n == 0 {
return Ok(());
}
let request = String::from_utf8_lossy(&buf[..n]);
if let Some(body_start) = request.find("\r\n\r\n") {
let body = request[body_start + 4..].trim().to_string();
if !body.is_empty() {
captured_requests.lock().await.push(body);
}
}
let is_post = request.starts_with("POST");
let is_chat = is_post && request.contains("/v1/chat/completions");
let is_completion = is_post && request.contains("/v1/completions") && !is_chat;
let is_streaming = request.contains("\"stream\":true") || request.contains("\"stream\": true");
if !is_chat && !is_completion {
let response = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
stream.write_all(response.as_bytes()).await?;
return Ok(());
}
if config.latency_ms > 0 {
tokio::time::sleep(std::time::Duration::from_millis(config.latency_ms)).await;
}
let mock_response = {
let mut idx = response_idx.lock().await;
if *idx < config.responses.len() {
let resp = config.responses[*idx].clone();
*idx += 1;
resp
} else {
config.default_response.clone()
}
};
match mock_response {
MockResponse::Text(text) => {
if is_streaming {
write_sse_text_response(&mut stream, &text).await?;
} else {
let body = format_chat_response(&config.model, &text, None);
write_http_response(&mut stream, 200, &body, &[]).await?;
}
}
MockResponse::ToolCalls(calls) => {
let tool_calls_json = format_tool_calls(&calls);
let body = format_chat_response(&config.model, "", Some(&tool_calls_json));
write_http_response(&mut stream, 200, &body, &[]).await?;
}
MockResponse::Error { status, body } => {
write_http_response(&mut stream, status, &body, &[]).await?;
}
MockResponse::ErrorWithHeaders {
status,
body,
headers,
} => {
let header_refs: Vec<(&str, &str)> = headers
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
write_http_response(&mut stream, status, &body, &header_refs).await?;
}
}
Ok(())
}
fn format_chat_response(model: &str, content: &str, tool_calls: Option<&str>) -> String {
let tool_calls_field = match tool_calls {
Some(tc) => format!(r#","tool_calls":{}"#, tc),
None => String::new(),
};
let finish_reason = if tool_calls.is_some() {
"tool_calls"
} else {
"stop"
};
let escaped_content = serde_json::to_string(content).unwrap_or_else(|_| "\"\"".to_string());
let escaped_content = &escaped_content[1..escaped_content.len() - 1];
format!(
r#"{{"id":"mock-resp-1","object":"chat.completion","created":1700000000,"model":"{}","choices":[{{"index":0,"message":{{"role":"assistant","content":"{}"{}}},"finish_reason":"{}"}}],"usage":{{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}}}"#,
model, escaped_content, tool_calls_field, finish_reason,
)
}
async fn write_sse_text_response(
stream: &mut tokio::net::TcpStream,
content: &str,
) -> std::io::Result<()> {
use tokio::io::AsyncWriteExt;
let escaped = serde_json::to_string(content).unwrap_or_else(|_| "\"\"".to_string());
let escaped = &escaped[1..escaped.len() - 1];
let events = format!(
"data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"content\":\"{}\"}},\"finish_reason\":null}}]}}\n\n\
data: {{\"choices\":[{{\"index\":0,\"delta\":{{}},\"finish_reason\":\"stop\"}}],\"usage\":{{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}}}\n\n\
data: [DONE]\n\n",
escaped
);
let chunk = format!("{:X}\r\n{}\r\n", events.len(), events);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n{}0\r\n\r\n",
chunk
);
stream.write_all(response.as_bytes()).await?;
stream.shutdown().await
}
fn format_tool_calls(calls: &[MockToolCall]) -> String {
let items: Vec<String> = calls
.iter()
.map(|c| {
let escaped_args =
serde_json::to_string(&c.arguments).unwrap_or_else(|_| "\"{}\"".to_string());
format!(
r#"{{"id":"{}","type":"function","function":{{"name":"{}","arguments":{}}}}}"#,
c.id, c.name, escaped_args,
)
})
.collect();
format!("[{}]", items.join(","))
}
async fn write_http_response(
stream: &mut tokio::net::TcpStream,
status: u16,
body: &str,
extra_headers: &[(&str, &str)],
) -> std::io::Result<()> {
let status_text = match status {
200 => "OK",
400 => "Bad Request",
401 => "Unauthorized",
404 => "Not Found",
429 => "Too Many Requests",
500 => "Internal Server Error",
503 => "Service Unavailable",
_ => "Error",
};
let mut extra = String::new();
for (key, value) in extra_headers {
extra.push_str(&format!("{}: {}\r\n", key, value));
}
let response = format!(
"HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{}Connection: close\r\n\r\n{}",
status,
status_text,
body.len(),
extra,
body,
);
stream.write_all(response.as_bytes()).await
}
#[cfg(test)]
#[path = "../../tests/unit/testing/mock_api/mock_api_test.rs"]
mod tests;