use reqwest::Client;
use serde_json::{json, Value};
use std::net::TcpListener;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
const SMOKE_MODEL: &str = "qwen3:0.6b";
const STARTUP_TIMEOUT: Duration = Duration::from_secs(120);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(300);
fn http_client() -> Client {
Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.expect("build HTTP client")
}
fn ferrum_bin() -> PathBuf {
if let Ok(bin) = std::env::var("CARGO_BIN_EXE_ferrum") {
return PathBuf::from(bin);
}
let current = std::env::current_exe().expect("test exe path");
let dir = current
.parent()
.and_then(|p| p.parent())
.expect("target dir");
let mut bin = dir.join("ferrum");
if cfg!(windows) {
bin.set_extension("exe");
}
assert!(bin.exists(), "ferrum binary not found at {}", bin.display());
bin
}
fn free_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
listener.local_addr().expect("local_addr").port()
}
struct ServerFixture {
url: String,
child: Child,
}
impl ServerFixture {
async fn spawn(model: &str) -> Self {
let port = free_port();
let url = format!("http://127.0.0.1:{port}");
let child = Command::new(ferrum_bin())
.args([
"serve",
model,
"--disable-thinking",
"--port",
&port.to_string(),
])
.env("NO_COLOR", "1")
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.expect("spawn ferrum serve");
let client = http_client();
let healthz = format!("{url}/health");
let start = Instant::now();
loop {
if start.elapsed() > STARTUP_TIMEOUT {
panic!("server did not become healthy within {STARTUP_TIMEOUT:?}");
}
let ok = client
.get(&healthz)
.timeout(Duration::from_secs(2))
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false);
if ok {
break;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
Self { url, child }
}
fn chat_url(&self) -> String {
format!("{}/v1/chat/completions", self.url)
}
}
impl Drop for ServerFixture {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn parse_sse(body: &str) -> (Vec<Value>, bool) {
let mut chunks = Vec::new();
let mut saw_done = false;
for block in body.split("\n\n") {
for line in block.lines() {
if let Some(data) = line.strip_prefix("data: ") {
let data = data.trim();
if data == "[DONE]" {
saw_done = true;
} else if !data.is_empty() {
let v: Value = serde_json::from_str(data)
.unwrap_or_else(|e| panic!("bad SSE JSON: {data:?} ({e})"));
chunks.push(v);
}
}
}
}
(chunks, saw_done)
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "loads real model — run with `cargo test -- --ignored`"]
async fn test_chat_completion_basic() {
let fx = ServerFixture::spawn(SMOKE_MODEL).await;
let resp = http_client()
.post(fx.chat_url())
.json(&json!({
"model": SMOKE_MODEL,
"messages": [{"role": "user", "content": "Say hi in one short sentence."}],
"max_tokens": 8,
"temperature": 0.0
}))
.send()
.await
.expect("post");
assert_eq!(resp.status(), 200, "non-200: {:?}", resp.status());
let body: Value = resp.json().await.expect("json");
let content = body["choices"][0]["message"]["content"]
.as_str()
.expect("missing choices[0].message.content");
assert!(!content.trim().is_empty(), "content empty: {body:?}");
let fr = body["choices"][0]["finish_reason"].as_str();
assert!(
matches!(fr, Some("stop" | "length")),
"unexpected finish_reason: {fr:?}"
);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "loads real model"]
async fn test_chat_streaming_sse() {
let fx = ServerFixture::spawn(SMOKE_MODEL).await;
let resp = http_client()
.post(fx.chat_url())
.json(&json!({
"model": SMOKE_MODEL,
"messages": [{"role": "user", "content": "Say hi in one short sentence."}],
"max_tokens": 8,
"temperature": 0.0,
"stream": true
}))
.send()
.await
.expect("post");
assert_eq!(resp.status(), 200);
let body = resp.text().await.expect("body");
let (chunks, saw_done) = parse_sse(&body);
assert!(!chunks.is_empty(), "expected SSE chunks, got 0");
assert!(saw_done, "missing `data: [DONE]` terminator");
let mut content = String::new();
for c in &chunks {
if let Some(delta) = c["choices"][0]["delta"]["content"].as_str() {
content.push_str(delta);
}
}
assert!(!content.trim().is_empty(), "concatenated content empty");
let terminal = chunks
.iter()
.rev()
.find(|c| !c["choices"][0]["finish_reason"].is_null());
let fr = terminal.and_then(|c| c["choices"][0]["finish_reason"].as_str());
assert!(
matches!(fr, Some("stop" | "length")),
"terminal finish_reason: {fr:?}"
);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "loads real model"]
async fn test_chat_multi_turn_messages() {
let fx = ServerFixture::spawn(SMOKE_MODEL).await;
let resp = http_client()
.post(fx.chat_url())
.json(&json!({
"model": SMOKE_MODEL,
"messages": [
{"role": "user", "content": "Remember the code name."},
{"role": "assistant", "content": "The code name is XiaoMing."},
{"role": "user", "content": "Copy only the code name from the previous assistant message."}
],
"max_tokens": 16,
"temperature": 0.0
}))
.send()
.await
.expect("post");
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await.expect("json");
let content = body["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("");
assert!(
!content.trim().is_empty(),
"multi-turn request returned empty content: {body}"
);
for marker in &["<|im_start|>", "<|im_end|>", "<|endoftext|>"] {
assert!(
!content.contains(marker),
"multi-turn response leaked template token {marker:?}: {content:?}"
);
}
let fr = body["choices"][0]["finish_reason"].as_str();
assert!(
matches!(fr, Some("stop" | "length")),
"unexpected multi-turn finish_reason {fr:?}: {body}"
);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "loads real model"]
async fn test_chat_no_template_leak() {
let fx = ServerFixture::spawn(SMOKE_MODEL).await;
let resp = http_client()
.post(fx.chat_url())
.json(&json!({
"model": SMOKE_MODEL,
"messages": [{"role": "user", "content": "Tell me a small number."}],
"max_tokens": 8,
"temperature": 0.0
}))
.send()
.await
.expect("post");
let body: Value = resp.json().await.expect("json");
let content = body["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("");
for marker in &["<|im_start|>", "<|im_end|>", "<|endoftext|>"] {
assert!(
!content.contains(marker),
"assistant leaked template token {marker:?} in: {content:?}"
);
}
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "loads real model"]
async fn test_chat_max_tokens_truncation() {
let fx = ServerFixture::spawn(SMOKE_MODEL).await;
let body: Value = http_client()
.post(fx.chat_url())
.json(&json!({
"model": SMOKE_MODEL,
"messages": [{"role": "user", "content": "Tell me a long story about dragons."}],
"max_tokens": 5,
"temperature": 0.0
}))
.send()
.await
.expect("post")
.json()
.await
.expect("json");
let fr = body["choices"][0]["finish_reason"].as_str();
assert_eq!(
fr,
Some("length"),
"max_tokens=5 should hit length truncation; got {fr:?}; content={:?}",
body["choices"][0]["message"]["content"]
);
let completion_tokens = body["usage"]["completion_tokens"].as_u64().unwrap_or(0);
assert!(
completion_tokens <= 6,
"completion_tokens={completion_tokens} should be ≤ 6 with max_tokens=5"
);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "loads real model"]
async fn test_chat_custom_stop_never_leaks_sentinel() {
let fx = ServerFixture::spawn(SMOKE_MODEL).await;
let body: Value = http_client()
.post(fx.chat_url())
.json(&json!({
"model": SMOKE_MODEL,
"messages": [{"role": "user", "content": "Reply with the single word END."}],
"max_tokens": 8,
"temperature": 0.0,
"stop": ["END"]
}))
.send()
.await
.expect("post")
.json()
.await
.expect("json");
let content = body["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("");
assert!(
!content.contains("END"),
"stop sentinel 'END' should have been stripped; got: {content:?}"
);
let fr = body["choices"][0]["finish_reason"].as_str();
assert!(
matches!(fr, Some("stop" | "length")),
"unexpected finish_reason for bounded stop request: {fr:?}"
);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "loads real model"]
async fn test_chat_empty_messages_400() {
let fx = ServerFixture::spawn(SMOKE_MODEL).await;
let resp = http_client()
.post(fx.chat_url())
.json(&json!({
"model": SMOKE_MODEL,
"messages": []
}))
.send()
.await
.expect("post");
assert_eq!(
resp.status().as_u16(),
400,
"empty messages should be 400 BadRequest"
);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "loads real model"]
async fn test_models_endpoint_lists_loaded() {
let fx = ServerFixture::spawn(SMOKE_MODEL).await;
let resp = http_client()
.get(format!("{}/v1/models", fx.url))
.send()
.await
.expect("get");
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await.expect("json");
assert_eq!(body["object"].as_str(), Some("list"));
let data = body["data"].as_array().expect("data must be an array");
assert!(!data.is_empty(), "/v1/models data array must not be empty");
let ids: Vec<_> = data.iter().filter_map(|m| m["id"].as_str()).collect();
assert!(
ids.iter()
.any(|id| id.to_lowercase().contains("qwen3-0.6b")),
"expected loaded model in /v1/models data; got ids: {ids:?}"
);
for entry in data {
assert_eq!(entry["object"].as_str(), Some("model"));
}
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "loads real model"]
async fn test_chat_concurrent_2_requests() {
let fx = ServerFixture::spawn(SMOKE_MODEL).await;
let client = http_client();
let url = fx.chat_url();
let req_a = client
.post(&url)
.json(&json!({
"model": SMOKE_MODEL,
"messages": [{"role": "user", "content": "Say hi in one short sentence."}],
"max_tokens": 8,
"temperature": 0.0
}))
.send();
let req_b = client
.post(&url)
.json(&json!({
"model": SMOKE_MODEL,
"messages": [{"role": "user", "content": "Reply with the word OK."}],
"max_tokens": 8,
"temperature": 0.0
}))
.send();
let (a, b) = tokio::join!(req_a, req_b);
let resp_a = a.expect("a post");
let resp_b = b.expect("b post");
assert_eq!(resp_a.status(), 200, "request A non-200");
assert_eq!(resp_b.status(), 200, "request B non-200");
let body_a: Value = resp_a.json().await.expect("a json");
let body_b: Value = resp_b.json().await.expect("b json");
let content_a = body_a["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("");
let content_b = body_b["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("");
assert!(!content_a.trim().is_empty(), "request A content empty");
assert!(!content_b.trim().is_empty(), "request B content empty");
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "loads real model"]
async fn test_chat_greedy_is_deterministic() {
let fx = ServerFixture::spawn(SMOKE_MODEL).await;
let req = json!({
"model": SMOKE_MODEL,
"messages": [{"role": "user", "content": "Reply with the digits 1 2 3 in order."}],
"max_tokens": 8,
"temperature": 0.0
});
let mut contents = Vec::new();
for _ in 0..2 {
let body: Value = http_client()
.post(fx.chat_url())
.json(&req)
.send()
.await
.expect("post")
.json()
.await
.expect("json");
contents.push(
body["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("")
.to_string(),
);
}
assert_eq!(
contents[0], contents[1],
"greedy decoding must be deterministic across requests"
);
assert!(!contents[0].trim().is_empty(), "greedy content empty");
}