fix(bench): identify Ferrum and vLLM by endpoint owner (#994)

* fix(bench): identify Ferrum and vLLM by endpoint owner

* fix(bench): normalize equivalent loopback endpoints

* fix(bench): reject malformed endpoint ports

* fix(bench): consume complete fixture HTTP requests

Read headers and the Content-Length body before sending the fixture response, avoiding Windows connection aborts when a POST arrives in multiple TCP reads. Cover short reads, bodies larger than the old buffer, and truncated requests while retaining the Ferrum provider attribution assertions.
This commit is contained in:
Carl 2026-09-10 04:30:24 +08:00 • committed by GitHub
parent 856db58855
commit b3e09fd2d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 662 additions and 126 deletions

View file

@ -363,6 +363,9 @@ llmfit bench --provider ollama --url http://my-server:11434 llama3.2
# vLLM エンドポイントを上書き
llmfit bench --provider vllm --url http://localhost:8000
# Ferrum をベンチマーク(FERRUM_HOST または http://localhost:8000 を使用)
llmfit bench --provider ferrum
# JSON として出力(スクリプト用)
llmfit bench --json
@ -379,6 +382,7 @@ llmfit bench --quality --routing
|---|---|---|
| `OLLAMA_HOST` | `http://localhost:11434` | Ollama API のベース URL |
| `VLLM_PORT` | `8000` | vLLM サーバーのポート(`http://localhost:$VLLM_PORT` として使用) |
| `FERRUM_HOST` | `http://localhost:8000` | Ferrum API のベース URL |
### テーマ

View file

@ -98,12 +98,14 @@ python3 scripts/test_api.py --base-url http://127.0.0.1:8787
### Contributing benchmarks (`bench --share`)
`llmfit bench` measures inference performance against a running provider
(Ollama, vLLM, MLX, or llama-server). llama-server is auto-detected on port
8080 via its `/props` endpoint (override with `LLAMA_SERVER_HOST` for a full
URL, or `LLAMA_SERVER_PORT`), or select it explicitly with
`--provider llamacpp`. Add `--share` to contribute your results back to the
project as a pull request — **no `gh` CLI and no account on a third-party
service required**:
(Ollama, vLLM, Ferrum, MLX, or llama-server). vLLM and Ferrum are distinguished
by the `owned_by` identity in `/v1/models`; set `FERRUM_HOST` to override
Ferrum's default `http://localhost:8000` endpoint. llama-server is
auto-detected on port 8080 via its `/props` endpoint (override with
`LLAMA_SERVER_HOST` for a full URL, or `LLAMA_SERVER_PORT`), or select it
explicitly with `--provider llamacpp`. Add `--share` to contribute your results
back to the project as a pull request — **no `gh` CLI and no account on a
third-party service required**:
```sh
# Benchmark every discovered model and open a PR with the results

View file

@ -266,6 +266,9 @@ llmfit bench --provider ollama --url http://my-server:11434 llama3.2
# Override vLLM endpoint
llmfit bench --provider vllm --url http://localhost:8000
# Benchmark Ferrum (uses FERRUM_HOST or http://localhost:8000)
llmfit bench --provider ferrum
# Output as JSON (for scripting)
llmfit bench --json
@ -282,6 +285,7 @@ llmfit bench --quality --routing
|---|---|---|
| `OLLAMA_HOST` | `http://localhost:11434` | Ollama API base URL |
| `VLLM_PORT` | `8000` | vLLM server port (used as `http://localhost:$VLLM_PORT`) |
| `FERRUM_HOST` | `http://localhost:8000` | Ferrum API base URL |
### Themes

View file

@ -43,7 +43,7 @@
"required": ["model", "provider", "numRuns", "avgTps", "minTps", "maxTps", "avgTotalMs", "avgOutputTokens"],
"properties": {
"model": { "type": "string", "minLength": 1 },
"provider": { "type": "string", "enum": ["ollama", "vllm", "mlx", "llamacpp"] },
"provider": { "type": "string", "enum": ["ollama", "vllm", "ferrum", "mlx", "llamacpp"] },
"numRuns": { "type": "integer", "minimum": 1 },
"avgTps": { "type": "number", "minimum": 0, "maximum": 100000 },
"minTps": { "type": "number", "minimum": 0, "maximum": 100000 },

View file

@ -1,10 +1,12 @@
//! LLM inference benchmarking against Ollama, vLLM, and MLX endpoints.
//! LLM inference benchmarking against Ollama and OpenAI-compatible endpoints.
//!
//! Measures time-to-first-token (TTFT), tokens per second (TPS),
//! and total latency using real inference requests.
use std::time::{Duration, Instant};
use crate::providers::{OpenAiEndpointIdentity, fetch_openai_model_list, openai_model_ids};
/// Results from a single benchmark run.
#[derive(Debug, Clone, serde::Serialize)]
pub struct BenchRun {
@ -256,7 +258,7 @@ pub(crate) struct ChatUsage {
pub(crate) completion_tokens: u32,
}
/// Benchmark a model via OpenAI-compatible /v1/chat/completions (vLLM, MLX).
/// Benchmark a model via OpenAI-compatible /v1/chat/completions.
pub fn bench_openai_compat(
base_url: &str,
model: &str,
@ -347,14 +349,48 @@ fn openai_chat(url: &str, model: &str, prompt: &str, max_tokens: u32) -> Result<
// ── Auto-detect and benchmark ──────────────────────────────────────
/// Which provider to benchmark against.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BenchTarget {
Ollama { url: String, model: String },
VLlm { url: String, model: String },
Ferrum { url: String, model: String },
Mlx { url: String, model: String },
LlamaCpp { url: String, model: String },
}
/// Benchmark a discovered target while preserving its provider attribution.
pub fn benchmark_target(
target: &BenchTarget,
num_runs: usize,
on_progress: &dyn Fn(usize, usize),
) -> Result<BenchResult, String> {
match target {
BenchTarget::Ollama { url, model } => bench_ollama(url, model, num_runs, on_progress),
BenchTarget::VLlm { url, model } => {
bench_openai_compat(url, model, "vllm", num_runs, on_progress)
}
BenchTarget::Ferrum { url, model } => {
bench_openai_compat(url, model, "ferrum", num_runs, on_progress)
}
BenchTarget::Mlx { url, model } => {
bench_openai_compat(url, model, "mlx", num_runs, on_progress)
}
BenchTarget::LlamaCpp { url, model } => {
bench_openai_compat(url, model, "llamacpp", num_runs, on_progress)
}
}
}
/// Base URL for a running Ferrum server.
/// `FERRUM_HOST` is a full URL and defaults to Ferrum's default listen port.
pub fn ferrum_url() -> String {
std::env::var("FERRUM_HOST")
.ok()
.filter(|host| !host.trim().is_empty())
.map(|host| host.trim().trim_end_matches('/').to_string())
.unwrap_or_else(|| "http://localhost:8000".to_string())
}
/// Base URL for a running llama-server instance.
/// `LLAMA_SERVER_HOST` (full URL) wins; otherwise localhost with
/// `LLAMA_SERVER_PORT` (default 8080, llama-server's own default).
@ -380,16 +416,165 @@ pub fn probe_llamacpp(base_url: &str) -> bool {
.is_ok()
}
fn vllm_url() -> String {
let port = std::env::var("VLLM_PORT").unwrap_or_else(|_| "8000".to_string());
format!("http://localhost:{}", port)
}
#[derive(Debug, PartialEq, Eq)]
struct EndpointKey {
scheme: String,
host: String,
port: Option<u16>,
path: String,
query: Option<String>,
}
/// Build a comparison-only endpoint identity without resolving DNS. Loopback
/// spellings are equivalent; schemes, non-default ports, paths, and queries
/// remain significant.
fn endpoint_key(raw_url: &str) -> Option<EndpointKey> {
let uri = raw_url.trim().parse::<http::Uri>().ok()?;
let scheme = uri.scheme_str()?.to_ascii_lowercase();
let authority = uri.authority()?;
// `http::Uri` accepts userinfo and syntactically present ports that do not
// fit in a u16. Keep those URLs distinct rather than canonicalizing away
// authentication or treating an invalid port as the scheme default.
let authority_text = authority.as_str();
if authority_text.contains('@') {
return None;
}
let raw_host = authority.host();
let port = match authority_text.strip_prefix(raw_host)? {
"" => match scheme.as_str() {
"http" => Some(80),
"https" => Some(443),
_ => None,
},
suffix => Some(suffix.strip_prefix(':')?.parse::<u16>().ok()?),
};
let host = raw_host.trim_matches(['[', ']']);
let is_loopback = host.eq_ignore_ascii_case("localhost")
|| host
.parse::<std::net::IpAddr>()
.ok()
.is_some_and(|address| address.is_loopback());
let host = if is_loopback {
"loopback".to_string()
} else {
host.to_ascii_lowercase()
};
let path = uri.path().trim_end_matches('/');
let path = if path.is_empty() { "/" } else { path }.to_string();
Some(EndpointKey {
scheme,
host,
port,
path,
query: uri.query().map(str::to_owned),
})
}
fn endpoint_urls_match(left: &str, right: &str) -> bool {
match (endpoint_key(left), endpoint_key(right)) {
(Some(left), Some(right)) => left == right,
_ => left.trim().trim_end_matches('/') == right.trim().trim_end_matches('/'),
}
}
fn push_unique_endpoint_url(urls: &mut Vec<String>, candidate: String) {
if !urls
.iter()
.any(|existing| endpoint_urls_match(existing, &candidate))
{
urls.push(candidate);
}
}
fn openai_bench_urls() -> Vec<String> {
let mut urls = vec![vllm_url()];
push_unique_endpoint_url(&mut urls, ferrum_url());
urls
}
fn identified_openai_models(
base_url: &str,
timeout: Duration,
) -> Result<(OpenAiEndpointIdentity, Vec<String>), String> {
let (list, identity) = fetch_openai_model_list(base_url, timeout)
.ok_or_else(|| format!("Cannot read {}/v1/models", base_url.trim_end_matches('/')))?;
let models = openai_model_ids(&list).map(str::to_owned).collect();
Ok((identity, models))
}
fn choose_model(models: &[String], hint: Option<&str>) -> Result<String, String> {
if models.is_empty() {
return Err("No models loaded".to_string());
}
if let Some(hint) = hint {
let hint_lower = hint.to_lowercase();
if let Some(model) = models
.iter()
.find(|model| model.to_lowercase().contains(&hint_lower))
{
return Ok(model.clone());
}
}
Ok(models[0].clone())
}
fn target_for_identity(
identity: OpenAiEndpointIdentity,
url: String,
model: String,
) -> Option<BenchTarget> {
match identity {
OpenAiEndpointIdentity::Vllm => Some(BenchTarget::VLlm { url, model }),
OpenAiEndpointIdentity::Ferrum => Some(BenchTarget::Ferrum { url, model }),
_ => None,
}
}
fn auto_detect_openai_target(urls: &[String], hint: Option<&str>) -> Option<BenchTarget> {
urls.iter().find_map(|url| {
let (identity, models) = identified_openai_models(url, Duration::from_secs(5)).ok()?;
let model = choose_model(&models, hint).ok()?;
target_for_identity(identity, url.clone(), model)
})
}
fn discover_openai_targets(urls: &[String]) -> Vec<BenchTarget> {
let mut targets = Vec::new();
let mut probed_urls: Vec<&str> = Vec::new();
for url in urls {
if probed_urls
.iter()
.any(|probed| endpoint_urls_match(probed, url))
{
continue;
}
probed_urls.push(url);
let Ok((identity, models)) = identified_openai_models(url, Duration::from_secs(3)) else {
continue;
};
for model in models {
if let Some(target) = target_for_identity(identity, url.clone(), model) {
targets.push(target);
}
}
}
targets
}
/// Auto-detect available providers and pick the best one to benchmark.
pub fn auto_detect_target(model_hint: Option<&str>) -> Result<BenchTarget, String> {
// Check vLLM via VLLM_PORT env var (defaults to 8000)
let vllm_port = std::env::var("VLLM_PORT").unwrap_or_else(|_| "8000".to_string());
let vllm_url = format!("http://localhost:{}", vllm_port);
if let Ok(model_name) = detect_vllm_model(&vllm_url, model_hint) {
return Ok(BenchTarget::VLlm {
url: vllm_url,
model: model_name,
});
// vLLM and Ferrum share port 8000 by default, so classify the endpoint
// from positive `owned_by` evidence instead of assigning it by port.
if let Some(target) = auto_detect_openai_target(&openai_bench_urls(), model_hint) {
return Ok(target);
}
// Check Ollama
@ -438,45 +623,58 @@ pub fn auto_detect_target(model_hint: Option<&str>) -> Result<BenchTarget, Strin
});
}
Err("No inference provider found. Start Ollama, vLLM, MLX, or llama-server first.".to_string())
Err(
"No inference provider found. Start Ollama, vLLM, Ferrum, MLX, or llama-server first."
.to_string(),
)
}
/// Discover all available models across all providers.
pub fn discover_all_targets() -> Vec<BenchTarget> {
let mut targets = Vec::new();
// Check vLLM via VLLM_PORT env var (defaults to 8000)
let vllm_port = std::env::var("VLLM_PORT").unwrap_or_else(|_| "8000".to_string());
let vllm_url = format!("http://localhost:{}", vllm_port);
if let Ok(models) = list_openai_models(&vllm_url) {
for model in models {
targets.push(BenchTarget::VLlm {
url: vllm_url.clone(),
model,
});
}
}
// Check Ollama
let ollama_url =
std::env::var("OLLAMA_HOST").unwrap_or_else(|_| "http://localhost:11434".to_string());
if let Ok(models) = list_ollama_models(&ollama_url) {
let llama_url = llamacpp_url();
let mlx_url =
std::env::var("MLX_LM_HOST").unwrap_or_else(|_| "http://localhost:8080".to_string());
discover_all_targets_at(&openai_bench_urls(), &ollama_url, &llama_url, &mlx_url)
}
fn identified_openai_claims_url(targets: &[BenchTarget], candidate_url: &str) -> bool {
targets.iter().any(|target| match target {
BenchTarget::VLlm { url, .. } | BenchTarget::Ferrum { url, .. } => {
endpoint_urls_match(url, candidate_url)
}
_ => false,
})
}
fn discover_all_targets_at(
openai_urls: &[String],
ollama_url: &str,
llama_url: &str,
mlx_url: &str,
) -> Vec<BenchTarget> {
let mut targets = discover_openai_targets(openai_urls);
// Check Ollama
if let Ok(models) = list_ollama_models(ollama_url) {
for model in models {
targets.push(BenchTarget::Ollama {
url: ollama_url.clone(),
url: ollama_url.to_string(),
model,
});
}
}
// Check llama-server before MLX: both default to port 8080, but only
// llama.cpp answers /props, so it can be identified positively.
let llama_url = llamacpp_url();
let llamacpp_found = probe_llamacpp(&llama_url);
if llamacpp_found && let Ok(models) = list_llamacpp_models(&llama_url) {
// llama.cpp answers /props, so it can be identified positively. Do not
// probe a URL already claimed by vLLM or Ferrum.
let llamacpp_found =
!identified_openai_claims_url(&targets, llama_url) && probe_llamacpp(llama_url);
if llamacpp_found && let Ok(models) = list_llamacpp_models(llama_url) {
for model in models {
targets.push(BenchTarget::LlamaCpp {
url: llama_url.clone(),
url: llama_url.to_string(),
model,
});
}
@ -484,14 +682,13 @@ pub fn discover_all_targets() -> Vec<BenchTarget> {
// Check MLX (skip if the llama-server probe already claimed this URL,
// e.g. both on the default port 8080)
let mlx_url =
std::env::var("MLX_LM_HOST").unwrap_or_else(|_| "http://localhost:8080".to_string());
if !(llamacpp_found && mlx_url.trim_end_matches('/') == llama_url)
&& let Ok(models) = list_openai_models(&mlx_url)
if !identified_openai_claims_url(&targets, mlx_url)
&& !(llamacpp_found && endpoint_urls_match(mlx_url, llama_url))
&& let Ok(models) = list_openai_models(mlx_url)
{
for model in models {
targets.push(BenchTarget::Mlx {
url: mlx_url.clone(),
url: mlx_url.to_string(),
model,
});
}
@ -562,8 +759,31 @@ pub fn detect_model_from_url(base_url: &str, hint: Option<&str>) -> Result<Strin
detect_openai_model(base_url, hint)
}
fn detect_vllm_model(base_url: &str, hint: Option<&str>) -> Result<String, String> {
detect_openai_model(base_url, hint)
/// Detect a model only after the endpoint positively identifies as vLLM.
pub fn detect_vllm_model(base_url: &str, hint: Option<&str>) -> Result<String, String> {
detect_identified_openai_model(base_url, hint, OpenAiEndpointIdentity::Vllm, "vLLM")
}
/// Detect a model only after the endpoint positively identifies as Ferrum.
pub fn detect_ferrum_model(base_url: &str, hint: Option<&str>) -> Result<String, String> {
detect_identified_openai_model(base_url, hint, OpenAiEndpointIdentity::Ferrum, "Ferrum")
}
fn detect_identified_openai_model(
base_url: &str,
hint: Option<&str>,
expected: OpenAiEndpointIdentity,
provider: &str,
) -> Result<String, String> {
let (identity, models) = identified_openai_models(base_url, Duration::from_secs(5))?;
if identity != expected {
return Err(format!(
"Endpoint at {} did not identify as {}",
base_url.trim_end_matches('/'),
provider
));
}
choose_model(&models, hint)
}
fn detect_llamacpp_model(base_url: &str, hint: Option<&str>) -> Result<String, String> {
@ -727,6 +947,299 @@ impl BenchResult {
mod tests {
use super::*;
const FERRUM_MODELS_FIXTURE: &str = r#"{"data":[{"id":"ferrum","owned_by":"ferrum"}]}"#;
const VLLM_MODELS_FIXTURE: &str = r#"{"object":"list","data":[{"id":"facebook/opt-125m","object":"model","created":1788290125,"owned_by":"vllm","root":"facebook/opt-125m","parent":null,"max_model_len":512}]}"#;
const UNKNOWN_MODELS_FIXTURE: &str = r#"{"data":[{"id":"foreign-model"}]}"#;
const CHAT_COMPLETION_FIXTURE: &str = r#"{"choices":[{"message":{"content":"hello"}}],"usage":{"prompt_tokens":3,"completion_tokens":2}}"#;
fn read_fixture_request(reader: impl std::io::Read) -> std::io::Result<Vec<u8>> {
use std::io::{BufRead, BufReader, Error, ErrorKind, Read};
let mut reader = BufReader::new(reader);
let mut request = Vec::new();
let mut content_length = 0;
loop {
let start = request.len();
if reader.read_until(b'\n', &mut request)? == 0 {
return Err(Error::from(ErrorKind::UnexpectedEof));
}
let line = std::str::from_utf8(&request[start..])
.map_err(|error| Error::new(ErrorKind::InvalidData, error))?;
if line == "\r\n" {
break;
}
if let Some((name, value)) = line.split_once(':') {
if name.eq_ignore_ascii_case("content-length") {
content_length = value
.trim()
.parse::<usize>()
.map_err(|error| Error::new(ErrorKind::InvalidData, error))?;
} else if name.eq_ignore_ascii_case("transfer-encoding") {
return Err(Error::new(
ErrorKind::InvalidData,
"fixture expects Content-Length, as sent by ureq::send_json",
));
}
}
}
// A TCP read can end within either the headers or the body. Closing
// with unread POST bytes can abort the client's connection on Windows.
let body_start = request.len();
let request_length = body_start
.checked_add(content_length)
.ok_or_else(|| Error::from(ErrorKind::InvalidData))?;
request.resize(request_length, 0);
reader.read_exact(&mut request[body_start..])?;
Ok(request)
}
#[test]
fn fixture_reads_complete_request_across_short_reads() {
use std::io::Read;
struct ShortReads<'a>(&'a [u8]);
impl Read for ShortReads<'_> {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
let count = buffer.len().min(3);
self.0.read(&mut buffer[..count])
}
}
let body = serde_json::json!({ "content": "hello".repeat(512) }).to_string();
let requests = [
"GET /v1/models HTTP/1.1\r\nHost: localhost\r\n\r\n".to_string(),
format!(
"POST /v1/chat/completions HTTP/1.1\r\nHost: localhost\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
),
];
for request in requests {
let actual = read_fixture_request(ShortReads(request.as_bytes()))
.expect("read the complete fixture request");
assert_eq!(actual, request.as_bytes());
}
}
#[test]
fn fixture_rejects_incomplete_request_headers_and_body() {
for request in [
"GET /v1/models HTTP/1.1\r\nHost: localhost\r\n",
"POST /v1/chat/completions HTTP/1.1\r\nContent-Length: 5\r\n\r\n{}",
] {
let error = read_fixture_request(request.as_bytes())
.expect_err("do not respond before consuming the complete request");
assert_eq!(error.kind(), std::io::ErrorKind::UnexpectedEof);
}
}
/// Serve JSON responses on an ephemeral loopback port.
fn serve_fixture(body: &'static str) -> String {
use std::io::Write;
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind test listener");
let addr = listener.local_addr().expect("test listener addr");
std::thread::spawn(move || {
while let Ok((mut stream, _)) = listener.accept() {
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("set fixture read timeout");
stream
.set_write_timeout(Some(Duration::from_secs(5)))
.expect("set fixture write timeout");
read_fixture_request(&mut stream).expect("read fixture request");
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("write fixture response");
}
});
format!("http://{}", addr)
}
#[test]
fn auto_detects_ferrum_from_positive_identity() {
let url = serve_fixture(FERRUM_MODELS_FIXTURE);
let target = auto_detect_openai_target(std::slice::from_ref(&url), None)
.expect("Ferrum endpoint should be detected");
assert_eq!(
target,
BenchTarget::Ferrum {
url,
model: "ferrum".to_string(),
}
);
}
#[test]
fn endpoint_identity_normalizes_loopback_defaults_and_trailing_slashes() {
let port_8000 = endpoint_key("http://localhost:8000/").expect("valid endpoint");
assert_eq!(
port_8000,
endpoint_key("http://127.0.0.1:8000").expect("valid IPv4 endpoint")
);
assert_eq!(
port_8000,
endpoint_key("http://[::1]:8000///").expect("valid IPv6 endpoint")
);
assert_eq!(
endpoint_key("http://localhost").expect("valid HTTP endpoint"),
endpoint_key("http://127.0.0.1:80/").expect("valid explicit HTTP endpoint")
);
assert_eq!(
endpoint_key("https://localhost/").expect("valid HTTPS endpoint"),
endpoint_key("https://[::1]:443").expect("valid explicit HTTPS endpoint")
);
assert_ne!(
port_8000,
endpoint_key("http://127.0.0.1:8001").expect("valid distinct port")
);
assert_ne!(
endpoint_key("http://localhost:8000/api").expect("valid path endpoint"),
endpoint_key("http://127.0.0.1:8000/other").expect("valid distinct path")
);
}
#[test]
fn endpoint_identity_rejects_malformed_explicit_ports_and_userinfo() {
let default_http = "http://127.0.0.1:80";
for malformed in [
"http://localhost:abc",
"http://localhost:",
"http://localhost:65536",
"http://[::1]:abc",
"http://[::1]:",
"http://[::1]:65536",
"http://user@localhost",
"http://user:password@localhost:80",
] {
assert!(
endpoint_key(malformed).is_none(),
"{malformed} must not have a canonical endpoint identity"
);
assert!(
!endpoint_urls_match(malformed, default_http),
"{malformed} must not match the default HTTP endpoint"
);
let mut urls = vec![malformed.to_string()];
push_unique_endpoint_url(&mut urls, default_http.to_string());
assert_eq!(
urls.len(),
2,
"{malformed} must not swallow a valid default-port endpoint"
);
}
}
#[test]
fn openai_candidate_urls_deduplicate_loopback_aliases() {
let mut urls = Vec::new();
for url in [
"http://localhost:8000/",
"http://127.0.0.1:8000",
"http://[::1]:8000///",
] {
push_unique_endpoint_url(&mut urls, url.to_string());
}
assert_eq!(urls, vec!["http://localhost:8000/".to_string()]);
push_unique_endpoint_url(&mut urls, "http://localhost".to_string());
push_unique_endpoint_url(&mut urls, "http://127.0.0.1:80/".to_string());
assert_eq!(urls.len(), 2, "default HTTP port should deduplicate");
}
#[test]
fn discovery_includes_only_identified_vllm_and_ferrum_targets() {
let ferrum_url = serve_fixture(FERRUM_MODELS_FIXTURE);
let vllm_url = serve_fixture(VLLM_MODELS_FIXTURE);
let unknown_url = serve_fixture(UNKNOWN_MODELS_FIXTURE);
let targets =
discover_openai_targets(&[ferrum_url.clone(), vllm_url.clone(), unknown_url.clone()]);
assert_eq!(
targets,
vec![
BenchTarget::Ferrum {
url: ferrum_url,
model: "ferrum".to_string(),
},
BenchTarget::VLlm {
url: vllm_url,
model: "facebook/opt-125m".to_string(),
},
]
);
assert!(auto_detect_openai_target(&[unknown_url], None).is_none());
}
#[test]
fn discover_all_does_not_reclassify_claimed_loopback_aliases() {
let ferrum_url = serve_fixture(FERRUM_MODELS_FIXTURE);
let port = ferrum_url
.rsplit(':')
.next()
.expect("fixture URL has a port");
let localhost_url = format!("http://localhost:{port}/");
let ipv6_url = format!("http://[::1]:{port}");
let openai_urls = vec![ferrum_url.clone(), localhost_url.clone(), ipv6_url.clone()];
let expected = vec![BenchTarget::Ferrum {
url: ferrum_url.clone(),
model: "ferrum".to_string(),
}];
assert_eq!(
discover_all_targets_at(&openai_urls, &ferrum_url, &localhost_url, &ipv6_url),
expected
);
assert_eq!(
discover_all_targets_at(&openai_urls, &ferrum_url, &ipv6_url, &localhost_url),
expected
);
}
#[test]
fn explicit_vllm_and_ferrum_detection_rejects_foreign_endpoints() {
let vllm_url = serve_fixture(VLLM_MODELS_FIXTURE);
let ferrum_url = serve_fixture(FERRUM_MODELS_FIXTURE);
let unknown_url = serve_fixture(UNKNOWN_MODELS_FIXTURE);
assert_eq!(
detect_vllm_model(&vllm_url, None).expect("vLLM identity should match"),
"facebook/opt-125m"
);
assert_eq!(
detect_ferrum_model(&ferrum_url, None).expect("Ferrum identity should match"),
"ferrum"
);
assert!(detect_vllm_model(&ferrum_url, Some("ferrum")).is_err());
assert!(detect_ferrum_model(&vllm_url, Some("opt-125m")).is_err());
assert!(detect_vllm_model(&unknown_url, Some("foreign-model")).is_err());
}
#[test]
fn ferrum_target_keeps_json_provider_attribution() {
let url = serve_fixture(CHAT_COMPLETION_FIXTURE);
let result = benchmark_target(
&BenchTarget::Ferrum {
url,
model: "ferrum".to_string(),
},
1,
&|_, _| {},
)
.expect("Ferrum OpenAI-compatible benchmark should succeed");
assert_eq!(result.provider, "ferrum");
let json = serde_json::json!({ "result": result });
assert_eq!(json["result"]["provider"], "ferrum");
}
#[test]
fn normalizes_llamacpp_gguf_paths_to_filenames() {
assert_eq!(

View file

@ -456,7 +456,7 @@ impl ModelProvider for OllamaProvider {
// ---------------------------------------------------------------------------
#[derive(serde::Deserialize)]
struct OpenAiModelList {
pub(crate) struct OpenAiModelList {
data: Vec<OpenAiModel>,
}
@ -482,16 +482,19 @@ fn openai_models_url(base_url: &str) -> String {
/// `owned_by: "llamacpp"`; llama-swap lists models with
/// `owned_by: "llama-swap"`; mlx_lm.server (0.31.3) sends a Python
/// `BaseHTTP` Server header and no `owned_by` field at all. vLLM and Docker
/// Model Runner were measured 2026-09-01 (see the variants below); LM Studio is
/// identified out-of-band via its native /api/v0 API (`endpoint_is_lmstudio`).
/// Model Runner were measured 2026-09-01 (see the variants below); Ferrum was
/// captured 2026-09-02 in #992. LM Studio is identified out-of-band via its
/// native /api/v0 API (`endpoint_is_lmstudio`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum OpenAiEndpointIdentity {
pub(crate) enum OpenAiEndpointIdentity {
/// llama.cpp serving directly.
LlamaCpp,
/// A llama-swap proxy fronting llama.cpp instances.
LlamaSwap,
/// vLLM's OpenAI server (measured 2026-09-01: owned_by "vllm").
Vllm,
/// Ferrum's OpenAI-compatible server (owned_by "ferrum").
Ferrum,
/// Docker Model Runner (measured 2026-09-01: owned_by "docker").
DockerModelRunner,
/// No foreign marker recognized.
@ -515,6 +518,9 @@ fn classify_openai_endpoint(
if owned_by("vllm") {
return OpenAiEndpointIdentity::Vllm;
}
if owned_by("ferrum") {
return OpenAiEndpointIdentity::Ferrum;
}
// Docker Model Runner stamps owned_by "docker" (plus a per-model `dmr`
// object) and sends no Server header. Measured 2026-09-01.
if owned_by("docker") {
@ -529,7 +535,7 @@ fn classify_openai_endpoint(
OpenAiEndpointIdentity::Unrecognized
}
fn fetch_openai_model_list(
pub(crate) fn fetch_openai_model_list(
base_url: &str,
timeout: std::time::Duration,
) -> Option<(OpenAiModelList, OpenAiEndpointIdentity)> {
@ -558,7 +564,7 @@ fn openai_model_list_is_omlx(list: &OpenAiModelList) -> bool {
})
}
fn openai_model_ids(list: &OpenAiModelList) -> impl Iterator<Item = &str> {
pub(crate) fn openai_model_ids(list: &OpenAiModelList) -> impl Iterator<Item = &str> {
list.data.iter().map(|model| model.id.as_str())
}
@ -6839,6 +6845,7 @@ mod tests {
/// Verbatim `/v1/models` body captured from vLLM 0.28.0 on 2026-09-01.
const VLLM_MODELS_FIXTURE: &str = r#"{"object":"list","data":[{"id":"facebook/opt-125m","object":"model","created":1788290125,"owned_by":"vllm","root":"facebook/opt-125m","parent":null,"max_model_len":512}]}"#;
const FERRUM_MODELS_FIXTURE: &str = r#"{"data":[{"id":"ferrum","owned_by":"ferrum"}]}"#;
/// Verbatim `/v1/models` body captured from Docker Model Runner on
/// 2026-09-01: owned_by "docker" plus a per-model `dmr` object.
@ -6897,6 +6904,13 @@ mod tests {
OpenAiEndpointIdentity::Vllm
);
let ferrum: OpenAiModelList =
serde_json::from_str(FERRUM_MODELS_FIXTURE).expect("fixture should parse");
assert_eq!(
classify_openai_endpoint(None, &ferrum),
OpenAiEndpointIdentity::Ferrum
);
let docker: OpenAiModelList =
serde_json::from_str(DOCKER_MR_MODELS_FIXTURE).expect("fixture should parse");
assert_eq!(

View file

@ -1318,6 +1318,24 @@ mod tests {
}
}
fn sample_ferrum_result() -> BenchResult {
use crate::bench::BenchSummary;
BenchResult {
model: "ferrum".to_string(),
provider: "ferrum".to_string(),
runs: vec![],
summary: BenchSummary {
num_runs: 3,
avg_ttft_ms: None,
avg_tps: 96.7,
min_tps: 94.0,
max_tps: 99.0,
avg_total_ms: 3100.0,
avg_output_tokens: 300.0,
},
}
}
#[test]
fn slug_is_filename_safe() {
let submission = build_submission(&[sample_result()], &specs_with_gpu("NVIDIA RTX 4090!!"));
@ -1397,7 +1415,7 @@ mod tests {
unsafe { std::env::set_var("LLMFIT_BENCH_STORE", &dir) };
let specs = specs_with_gpu("NVIDIA GeForce RTX 4090");
let path = store_local(&[sample_result()], &specs).unwrap();
let path = store_local(&[sample_result(), sample_ferrum_result()], &specs).unwrap();
assert!(path.starts_with(dir.join("pending")));
let pending = pending_benchmarks();
@ -1405,8 +1423,12 @@ mod tests {
assert_eq!(pending[0].payload["schemaVersion"], 1);
assert_eq!(
pending[0].result_lines(),
vec!["llama3.1:8b via ollama — 128.4 tok/s".to_string()]
vec![
"llama3.1:8b via ollama — 128.4 tok/s".to_string(),
"ferrum via ferrum — 96.7 tok/s".to_string(),
]
);
assert_eq!(pending[0].payload["results"][1]["provider"], "ferrum");
assert!(shared_benchmarks().is_empty());
// #819: a payload written by an older binary can still carry an
@ -1499,9 +1521,10 @@ mod tests {
avg_output_tokens: 100.0,
},
};
let ferrum_result = sample_ferrum_result();
let submission = build_submission(
&[result, llamacpp_result],
&[result, llamacpp_result, ferrum_result],
&specs_with_gpu("NVIDIA GeForce RTX 4090"),
);
let value = serde_json::to_value(&submission).unwrap();
@ -1528,6 +1551,7 @@ mod tests {
assert_eq!(value["hardware"]["hwClass"], "DISCRETE_GPU");
assert_eq!(value["hardware"]["memTierGb"], 24);
assert_eq!(value["results"][0]["avgTps"], 128.44);
assert_eq!(value["results"][2]["provider"], "ferrum");
}
#[test]

View file

@ -851,7 +851,7 @@ AGENT USAGE:
/// Model name to benchmark (auto-detects provider if omitted)
model: Option<String>,
/// Provider to benchmark (auto, ollama, vllm, mlx, llamacpp)
/// Provider to benchmark (auto, ollama, vllm, ferrum, mlx, llamacpp)
#[arg(long, default_value = "auto")]
provider: String,
@ -2627,6 +2627,7 @@ fn target_info(target: &bench::BenchTarget) -> (&str, &str, &str) {
match target {
bench::BenchTarget::Ollama { url, model } => ("Ollama", url.as_str(), model.as_str()),
bench::BenchTarget::VLlm { url, model } => ("vLLM", url.as_str(), model.as_str()),
bench::BenchTarget::Ferrum { url, model } => ("Ferrum", url.as_str(), model.as_str()),
bench::BenchTarget::Mlx { url, model } => ("MLX", url.as_str(), model.as_str()),
bench::BenchTarget::LlamaCpp { url, model } => ("llama.cpp", url.as_str(), model.as_str()),
}
@ -2675,7 +2676,7 @@ fn run_bench(
let targets = bench::discover_all_targets();
if targets.is_empty() {
eprintln!(
"No providers or models found. Start Ollama, vLLM, MLX, or llama-server first."
"No providers or models found. Start Ollama, vLLM, Ferrum, MLX, or llama-server first."
);
std::process::exit(1);
}
@ -2706,20 +2707,7 @@ fn run_bench(
}
};
let result = match target {
bench::BenchTarget::Ollama { url, model } => {
bench::bench_ollama(url, model, runs, &progress)
}
bench::BenchTarget::VLlm { url, model } => {
bench::bench_openai_compat(url, model, "vllm", runs, &progress)
}
bench::BenchTarget::Mlx { url, model } => {
bench::bench_openai_compat(url, model, "mlx", runs, &progress)
}
bench::BenchTarget::LlamaCpp { url, model } => {
bench::bench_openai_compat(url, model, "llamacpp", runs, &progress)
}
};
let result = bench::benchmark_target(target, runs, &progress);
if !json {
eprintln!();
@ -2774,23 +2762,27 @@ fn run_bench(
let port = std::env::var("VLLM_PORT").unwrap_or_else(|_| "8000".to_string());
format!("http://localhost:{}", port)
});
match bench::detect_model_from_url(&url, model.as_deref()) {
match bench::detect_vllm_model(&url, model.as_deref()) {
Ok(model_name) => bench::BenchTarget::VLlm {
url,
model: model_name,
},
Err(_) => {
let model_name = model.unwrap_or_else(|| {
eprintln!(
"Error: could not detect model from vLLM at {}. Use --model",
url
);
std::process::exit(1);
});
bench::BenchTarget::VLlm {
url,
model: model_name,
}
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
}
"ferrum" => {
let url = url_override.clone().unwrap_or_else(bench::ferrum_url);
match bench::detect_ferrum_model(&url, model.as_deref()) {
Ok(model_name) => bench::BenchTarget::Ferrum {
url,
model: model_name,
},
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
}
@ -2852,20 +2844,7 @@ fn run_bench(
}
};
let result = match &target {
bench::BenchTarget::Ollama { url, model } => {
bench::bench_ollama(url, model, runs, &progress)
}
bench::BenchTarget::VLlm { url, model } => {
bench::bench_openai_compat(url, model, "vllm", runs, &progress)
}
bench::BenchTarget::Mlx { url, model } => {
bench::bench_openai_compat(url, model, "mlx", runs, &progress)
}
bench::BenchTarget::LlamaCpp { url, model } => {
bench::bench_openai_compat(url, model, "llamacpp", runs, &progress)
}
};
let result = bench::benchmark_target(&target, runs, &progress);
if !json {
eprintln!();
@ -3018,7 +2997,7 @@ fn run_quality_bench(
let all_targets = bench::discover_all_targets();
if all_targets.is_empty() {
eprintln!(
"No providers or models found. Start Ollama, vLLM, MLX, or llama-server first."
"No providers or models found. Start Ollama, vLLM, Ferrum, MLX, or llama-server first."
);
std::process::exit(1);
}
@ -3055,23 +3034,27 @@ fn run_quality_bench(
let port = std::env::var("VLLM_PORT").unwrap_or_else(|_| "8000".to_string());
format!("http://localhost:{}", port)
});
match bench::detect_model_from_url(&url, model.as_deref()) {
match bench::detect_vllm_model(&url, model.as_deref()) {
Ok(model_name) => bench::BenchTarget::VLlm {
url,
model: model_name,
},
Err(_) => {
let model_name = model.unwrap_or_else(|| {
eprintln!(
"Error: could not detect model from vLLM at {}. Use --model",
url
);
std::process::exit(1);
});
bench::BenchTarget::VLlm {
url,
model: model_name,
}
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
}
"ferrum" => {
let url = url_override.clone().unwrap_or_else(bench::ferrum_url);
match bench::detect_ferrum_model(&url, model.as_deref()) {
Ok(model_name) => bench::BenchTarget::Ferrum {
url,
model: model_name,
},
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
}
@ -3150,6 +3133,7 @@ fn run_quality_bench(
quality::bench_quality_ollama(url, model, &config, rf)
}
bench::BenchTarget::VLlm { url, model }
| bench::BenchTarget::Ferrum { url, model }
| bench::BenchTarget::Mlx { url, model }
| bench::BenchTarget::LlamaCpp { url, model } => {
quality::bench_quality_openai_compat(url, model, provider_name, &config, rf)

View file

@ -391,6 +391,7 @@ fn bench_offer_worker(
let model = match t {
BenchTarget::Ollama { model, .. }
| BenchTarget::VLlm { model, .. }
| BenchTarget::Ferrum { model, .. }
| BenchTarget::Mlx { model, .. }
| BenchTarget::LlamaCpp { model, .. } => model,
};
@ -408,6 +409,7 @@ fn bench_offer_worker(
let (provider, url, tag) = match &target {
BenchTarget::Ollama { url, model } => ("ollama", url, model),
BenchTarget::VLlm { url, model } => ("vllm", url, model),
BenchTarget::Ferrum { url, model } => ("ferrum", url, model),
BenchTarget::Mlx { url, model } => ("mlx", url, model),
BenchTarget::LlamaCpp { url, model } => ("llamacpp", url, model),
};
@ -451,18 +453,7 @@ fn bench_offer_worker(
let _ = progress_tx.send(BenchOfferMsg::Progress(msg));
};
let result = match &target {
BenchTarget::Ollama { url: u, model } => bench::bench_ollama(u, model, RUNS, &on_progress),
BenchTarget::VLlm { url: u, model } => {
bench::bench_openai_compat(u, model, "vllm", RUNS, &on_progress)
}
BenchTarget::Mlx { url: u, model } => {
bench::bench_openai_compat(u, model, "mlx", RUNS, &on_progress)
}
BenchTarget::LlamaCpp { url: u, model } => {
bench::bench_openai_compat(u, model, "llamacpp", RUNS, &on_progress)
}
};
let result = bench::benchmark_target(&target, RUNS, &on_progress);
let result = match result {
Ok(r) => r,