feat: implement mistral.rs harness and neuron model API
All checks were successful
CI / Format, lint, build, test (push) Successful in 2m30s
CI / Build SRPM (push) Has been skipped
CI / Publish to COPR (push) Has been skipped

- MistralRsHarness: Harness trait impl wrapping mistral.rs HTTP API
  (list/load/unload models, health check, start/stop via systemd)
- HarnessRegistry: maps harness name -> Box<dyn Harness>, built from
  neuron.toml config
- Neuron API endpoints: GET /models, POST /models/load,
  POST /models/unload, GET /models/:id/endpoint
- NeuronConfig: figment-based config loading from neuron.toml
- Integration test: full model lifecycle through mock mistral.rs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 14:29:42 +03:00
parent 6dc717ebcd
commit 26e5e7ead8
10 changed files with 562 additions and 99 deletions

View File

@@ -1,20 +1,19 @@
use cortex_core::discovery::{DeviceHealth, DeviceInfo, DiscoveryResponse, HealthResponse};
use cortex_core::discovery::{DeviceInfo, DiscoveryResponse};
use cortex_neuron::api::{self, NeuronState};
use cortex_neuron::harness::HarnessRegistry;
use cortex_neuron::health::HealthCache;
use serde_json::json;
use std::sync::Arc;
use tokio::sync::RwLock;
async fn spawn_neuron(discovery: DiscoveryResponse, health: HealthResponse) -> String {
async fn spawn_neuron(discovery: DiscoveryResponse) -> String {
let health_cache = Arc::new(HealthCache::new());
// Pre-populate the health cache by writing through the snapshot mechanism.
// HealthCache doesn't expose a direct setter, so we'll build one with
// the data already in place via the NeuronState.
// For testing, we use the cache as-is (uptime 0, empty devices) unless
// we need specific values — see test_health_endpoint.
let _ = health; // used below via a different approach
let registry = HarnessRegistry::new();
let state = Arc::new(NeuronState {
discovery,
health_cache,
registry: RwLock::new(registry),
});
let app = api::neuron_routes().with_state(state);
@@ -51,32 +50,9 @@ fn fake_discovery() -> DiscoveryResponse {
}
}
fn fake_health() -> HealthResponse {
HealthResponse {
uptime_secs: 0,
devices: vec![
DeviceHealth {
index: 0,
vram_used_mb: 8192,
vram_free_mb: 24422,
utilization_pct: 45,
temp_c: 62,
},
DeviceHealth {
index: 1,
vram_used_mb: 4096,
vram_free_mb: 28518,
utilization_pct: 30,
temp_c: 58,
},
],
}
}
#[tokio::test]
async fn test_discovery_endpoint() {
let disc = fake_discovery();
let url = spawn_neuron(disc, fake_health()).await;
let url = spawn_neuron(fake_discovery()).await;
let client = reqwest::Client::new();
let resp = client
@@ -89,20 +65,17 @@ async fn test_discovery_endpoint() {
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["hostname"], "test-node");
assert_eq!(body["os"], "Linux");
assert_eq!(body["cuda_version"], "12.8");
assert_eq!(body["driver_version"], "570.86.16");
let devices = body["devices"].as_array().unwrap();
assert_eq!(devices.len(), 2);
assert_eq!(devices[0]["name"], "NVIDIA GeForce RTX 5090");
assert_eq!(devices[0]["vram_total_mb"], 32614);
assert_eq!(devices[0]["compute_capability"], "12.0");
}
#[tokio::test]
async fn test_health_endpoint() {
let url = spawn_neuron(fake_discovery(), fake_health()).await;
let url = spawn_neuron(fake_discovery()).await;
let client = reqwest::Client::new();
let resp = client
@@ -114,9 +87,7 @@ async fn test_health_endpoint() {
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
// HealthCache starts with uptime 0 and empty devices (no poller running in test).
assert_eq!(body["uptime_secs"], 0);
assert!(body["devices"].as_array().unwrap().is_empty());
}
#[tokio::test]
@@ -130,14 +101,7 @@ async fn test_discovery_no_gpus() {
devices: vec![],
harnesses: vec![],
};
let url = spawn_neuron(
disc,
HealthResponse {
uptime_secs: 0,
devices: vec![],
},
)
.await;
let url = spawn_neuron(disc).await;
let client = reqwest::Client::new();
let resp = client
@@ -153,3 +117,133 @@ async fn test_discovery_no_gpus() {
assert!(body["cuda_version"].is_null());
assert!(body["devices"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn test_models_empty_registry() {
let url = spawn_neuron(fake_discovery()).await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{url}/models"))
.send()
.await
.expect("request should succeed");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert!(body.as_array().unwrap().is_empty());
}
/// Spawn a mock mistral.rs backend and a neuron with the mistralrs harness
/// pointing at it, then test the full model lifecycle through neuron's API.
#[tokio::test]
async fn test_models_via_mistralrs_harness() {
use axum::routing::{get, post};
use axum::{Json, Router};
use cortex_core::harness::HarnessConfig;
use serde_json::Value;
// Mock mistral.rs backend.
let mock_app = Router::new()
.route(
"/v1/models",
get(|| async {
Json(json!({
"data": [
{"id": "test-model", "status": "loaded"},
{"id": "other-model", "status": "unloaded"}
]
}))
}),
)
.route(
"/v1/models/unload",
post(|Json(_body): Json<Value>| async { Json(json!({"status": "ok"})) }),
)
.route(
"/v1/models/reload",
post(|Json(_body): Json<Value>| async { Json(json!({"status": "ok"})) }),
);
let mock_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let mock_addr = mock_listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(mock_listener, mock_app).await.unwrap();
});
let mock_url = format!("http://{mock_addr}");
// Build neuron with mistralrs harness pointing at mock.
let registry = HarnessRegistry::from_configs(&[HarnessConfig {
name: "mistralrs".into(),
endpoint: Some(mock_url.clone()),
systemd_unit: None,
}]);
let health_cache = Arc::new(HealthCache::new());
let state = Arc::new(NeuronState {
discovery: fake_discovery(),
health_cache,
registry: RwLock::new(registry),
});
let app = api::neuron_routes().with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let neuron_addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let neuron_url = format!("http://{neuron_addr}");
let client = reqwest::Client::new();
// GET /models — should return models from mock mistralrs.
let resp = client
.get(format!("{neuron_url}/models"))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let models: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(models.len(), 2);
assert_eq!(models[0]["id"], "test-model");
assert_eq!(models[0]["harness"], "mistralrs");
assert_eq!(models[0]["status"], "loaded");
assert_eq!(models[1]["id"], "other-model");
assert_eq!(models[1]["status"], "unloaded");
// GET /models/test-model/endpoint — should return mock URL.
let resp = client
.get(format!("{neuron_url}/models/test-model/endpoint"))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["url"], mock_url);
// POST /models/unload — should succeed.
let resp = client
.post(format!("{neuron_url}/models/unload"))
.json(&json!({"model_id": "test-model"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "unloaded");
// POST /models/load — should succeed.
let resp = client
.post(format!("{neuron_url}/models/load"))
.json(&json!({
"model_id": "test-model",
"harness": "mistralrs"
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "loaded");
}