[Rust Frontend] Return model metadata fields in /v1/models (#45950)

Signed-off-by: Tahsin Tunan <[email protected]>
This commit is contained in:
Tahsin Tunan
2026-06-18 10:29:21 +00:00
committed by GitHub
parent 08985351f3
commit 7299e6509e
8 changed files with 153 additions and 26 deletions
+1
View File
@@ -5880,6 +5880,7 @@ dependencies = [
"expect-test",
"futures",
"http-body",
"indexmap 2.13.0",
"itertools 0.14.0",
"libc",
"llm-multimodal",
+1
View File
@@ -11,6 +11,7 @@ axum.workspace = true
educe.workspace = true
futures.workspace = true
http-body.workspace = true
indexmap.workspace = true
itertools.workspace = true
libc.workspace = true
llm-multimodal.workspace = true
+1
View File
@@ -98,6 +98,7 @@ async fn build_state(config: &Config) -> Result<Arc<AppState>> {
Ok(Arc::new(
AppState::new(served_model_names, chat)
.with_model_path(config.model.clone())
.with_api_server_options(config.api_server_options)
.with_server_info(ServerInfoSnapshot::from_config(config))
.with_api_keys(config.api_keys.clone())
+8 -11
View File
@@ -1,6 +1,6 @@
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use indexmap::IndexMap;
use tokio::sync::{Mutex, RwLock};
use vllm_engine_core_client::EngineCoreClient;
use vllm_engine_core_client::protocol::lora::LoraRequest;
@@ -15,8 +15,8 @@ pub(crate) struct LoraModelResolution {
/// Runtime registry for dynamically loaded LoRA adapters.
pub(crate) struct LoraManager {
/// Dynamically loaded LoRA adapters keyed by public model name.
requests: RwLock<BTreeMap<String, LoraRequest>>,
/// Dynamically loaded LoRA adapters keyed by public model name, in load order.
requests: RwLock<IndexMap<String, LoraRequest>>,
/// Monotonic adapter id allocator. LoRA ids are one-indexed.
id_counter: AtomicU64,
/// Serialize dynamic LoRA registry updates around engine utility calls.
@@ -51,18 +51,15 @@ pub(crate) enum UnloadLoraError {
impl LoraManager {
pub fn new() -> Self {
Self {
requests: RwLock::new(BTreeMap::new()),
requests: RwLock::new(IndexMap::new()),
id_counter: AtomicU64::new(0),
update_lock: Mutex::new(()),
}
}
/// Return base served model names plus dynamically loaded LoRA adapter
/// names.
pub async fn served_model_names(&self, base_model_names: &[String]) -> Vec<String> {
let mut names = base_model_names.to_vec();
names.extend(self.requests.read().await.keys().cloned());
names
/// Snapshot loaded LoRA adapters in load order.
pub async fn served_lora_requests(&self) -> Vec<LoraRequest> {
self.requests.read().await.values().cloned().collect()
}
/// Resolve the requested model against one consistent LoRA registry
@@ -163,6 +160,6 @@ impl LoraManager {
});
}
Ok(self.requests.write().await.remove(lora_name).unwrap_or(lora_request))
Ok(self.requests.write().await.shift_remove(lora_name).unwrap_or(lora_request))
}
}
+32 -11
View File
@@ -1,4 +1,5 @@
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use axum::Json;
use axum::extract::State;
@@ -6,19 +7,39 @@ use axum::extract::State;
use crate::routes::openai::utils::types::{ListModelsResponse, ModelObject};
use crate::state::AppState;
/// Return all configured served model names in OpenAI `list models` format.
// Frontend marker; Python uses "vllm".
const OWNED_BY: &str = "vllm-frontend-rs";
/// Base cards carry `max_model_len` and `root` = model path; LoRA cards carry
/// `root` = adapter path and `parent` = base model. LoRA cards follow load order.
pub async fn list_models(State(state): State<Arc<AppState>>) -> Json<ListModelsResponse> {
let model_names = state.served_model_names_with_loras().await;
let created = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
let max_model_len = state.chat.engine_core_client().max_model_len();
let model_path = state.model_path().map(str::to_string);
let base_cards = state.served_model_names().iter().map(|name| ModelObject {
id: name.clone(),
object: "model".to_string(),
created,
owned_by: OWNED_BY.to_string(),
root: Some(model_path.clone().unwrap_or_else(|| name.clone())),
parent: None,
max_model_len: Some(max_model_len),
});
let primary = state.primary_model_name().to_string();
let lora_cards = state.served_lora_requests().await.into_iter().map(|lora| ModelObject {
id: lora.lora_name,
object: "model".to_string(),
created,
owned_by: OWNED_BY.to_string(),
root: Some(lora.lora_path),
parent: Some(lora.base_model_name.unwrap_or_else(|| primary.clone())),
max_model_len: None,
});
Json(ListModelsResponse {
object: "list".to_string(),
data: model_names
.into_iter()
.map(|name| ModelObject {
id: name,
object: "model".to_string(),
created: 0,
owned_by: "vllm-frontend-rs".to_string(),
})
.collect(),
data: base_cards.chain(lora_cards).collect(),
})
}
@@ -457,6 +457,12 @@ pub struct ModelObject {
pub object: String,
pub created: i64,
pub owned_by: String,
/// Backend model path (base cards) or adapter path (LoRA cards).
pub root: Option<String>,
/// Base model a LoRA adapter derives from; `null` for base models.
pub parent: Option<String>,
/// Maximum context length; `null` for LoRA adapter cards.
pub max_model_len: Option<u32>,
}
/// Response body for `GET /v1/models`.
+87
View File
@@ -1107,6 +1107,93 @@ async fn list_models_returns_configured_model() {
let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");
assert_eq!(json["data"][0]["id"], "Qwen/Qwen1.5-0.5B-Chat");
// No model path configured: `root` falls back to the served name.
assert_eq!(json["data"][0]["root"], "Qwen/Qwen1.5-0.5B-Chat");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn list_models_base_card_includes_metadata() {
let (chat, _engine_task) = test_models_with_engine_outputs_and_backend(
b"engine-openai-models-meta",
default_stream_output_specs(),
Arc::new(FakeChatBackend::new()),
)
.await;
// `id` is the served alias; `root` is the underlying model path.
let mut app = build_router(Arc::new(
AppState::new(vec!["public-alias".to_string()], chat)
.with_model_path("org/backend-model".to_string()),
));
let response = app
.call(Request::builder().uri("/v1/models").body(Body::empty()).expect("build request"))
.await
.expect("call app");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");
let card = json["data"][0].as_object().expect("card object");
assert_eq!(card["id"], "public-alias");
assert_eq!(card["owned_by"], "vllm-frontend-rs");
assert_eq!(card["root"], "org/backend-model");
assert!(card["max_model_len"].as_u64().expect("max_model_len") > 0);
assert!(card["created"].as_i64().expect("created") > 0);
// `parent` must be emitted as null, not omitted.
assert!(card.contains_key("parent") && card["parent"].is_null());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn list_models_lists_loras_in_load_order() {
// Load out of lexicographic order; the list must preserve load order, not sort.
let (mut app, _engine_task) = test_admin_app_with_engine_script(|dealer, push| {
boxed_test_future(async move {
for _ in 0..2 {
let utility = recv_engine_message(dealer).await;
let payload = decode_value(&utility[1]).expect("decode utility payload");
let call_id =
payload.as_array().expect("utility array")[1].as_u64().expect("call id");
send_outputs(push, utility_outputs(call_id, utility_result_value(true))).await;
}
})
})
.await;
for name in ["zebra", "alpha"] {
let path = format!("org/{name}");
let response = app
.call(
Request::builder()
.method("POST")
.uri("/v1/load_lora_adapter")
.header("content-type", "application/json")
.body(Body::from(
json!({ "lora_name": name, "lora_path": path }).to_string(),
))
.expect("build request"),
)
.await
.expect("call app");
assert_eq!(response.status(), StatusCode::OK);
}
let models = app
.call(Request::builder().uri("/v1/models").body(Body::empty()).expect("build request"))
.await
.expect("call app");
let body = to_bytes(models.into_body(), usize::MAX).await.expect("read body");
let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");
assert_eq!(json["data"][0]["id"], "Qwen/Qwen1.5-0.5B-Chat");
assert_eq!(json["data"][1]["id"], "zebra");
assert_eq!(json["data"][2]["id"], "alpha");
// `max_model_len` must be emitted as null on LoRA cards, not omitted.
let lora_card = json["data"][1].as_object().expect("lora card object");
assert_eq!(lora_card["root"], "org/zebra");
assert_eq!(lora_card["parent"], "Qwen/Qwen1.5-0.5B-Chat");
assert!(lora_card.contains_key("max_model_len") && lora_card["max_model_len"].is_null());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+17 -4
View File
@@ -40,6 +40,8 @@ pub struct AppState {
server_load: AtomicU64,
/// Dynamic LoRA adapter registry.
lora_manager: LoraManager,
/// Backend model path reported as `root` for base-model cards.
model_path: Option<String>,
}
impl AppState {
@@ -65,6 +67,7 @@ impl AppState {
api_key_hashes: Vec::new(),
server_load: AtomicU64::new(0),
lora_manager: LoraManager::new(),
model_path: None,
}
}
@@ -80,6 +83,12 @@ impl AppState {
self
}
/// Set the backend model path reported as `root` for base-model cards.
pub fn with_model_path(mut self, model_path: String) -> Self {
self.model_path = Some(model_path);
self
}
/// Attach the runtime server information snapshot used by `/server_info`.
pub(crate) fn with_server_info(mut self, server_info: ServerInfoSnapshot) -> Self {
self.server_info = Some(server_info);
@@ -123,10 +132,14 @@ impl AppState {
&self.served_model_names
}
/// Return base served model names plus dynamically loaded LoRA adapter
/// names.
pub async fn served_model_names_with_loras(&self) -> Vec<String> {
self.lora_manager.served_model_names(&self.served_model_names).await
/// Backend model path reported as `root` for base-model cards, if known.
pub fn model_path(&self) -> Option<&str> {
self.model_path.as_deref()
}
/// Snapshot the loaded LoRA adapters in load order, for `/v1/models` cards.
pub async fn served_lora_requests(&self) -> Vec<LoraRequest> {
self.lora_manager.served_lora_requests().await
}
/// Resolve the requested model against one dynamic LoRA registry snapshot.