Skip to content
134 changes: 133 additions & 1 deletion lib/llm/src/local_model.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};

Expand All @@ -11,9 +12,11 @@ use dynamo_runtime::discovery::DiscoverySpec;
use dynamo_runtime::protocols::EndpointId;
use dynamo_runtime::slug::Slug;
use dynamo_runtime::traits::DistributedRuntimeProvider;
use modelexpress_common::providers::{HuggingFaceProvider, ModelProviderTrait as _};

use crate::common::checked_file::CheckedFile;
use crate::entrypoint::RouterConfig;
use crate::model_card::ModelDeploymentCard;
use crate::model_card::{ModelDeploymentCard, is_weight_file};
use crate::model_type::{ModelInput, ModelType};
use crate::preprocessor::media::{MediaDecoder, MediaFetcher};
use crate::request_template::RequestTemplate;
Expand Down Expand Up @@ -564,6 +567,29 @@ impl LocalModel {
let suffix = model_suffix.unwrap_or(dynamo_runtime::metadata_registry::BASE_SUFFIX);
let registry = drt.metadata_artifacts();

// Advertise non-typed siblings (preprocessor_config.json,
// special_tokens_map.json, …) so external preprocessors that load
// via `from_pretrained(slug_dir)` see a complete model dir.
let typed_filenames: HashSet<String> = self
.card
.iter_metadata_files()
.iter()
.filter_map(|(cf, _)| {
cf.path()
.and_then(Path::file_name)
.and_then(|n| n.to_str())
.map(str::to_string)
})
.collect();
let harvested =
harvest_extra_files(&self.full_path, &typed_filenames).with_context(|| {
Comment thread
nnshah1 marked this conversation as resolved.
format!(
"harvesting extra metadata files from {}",
self.full_path.display()
)
})?;
self.card.extra_files.extend(harvested);

let mut rewritten = 0usize;
for (cf, _) in self.card.iter_metadata_files_mut() {
let Some(local_path) = cf.path().map(Path::to_path_buf) else {
Expand Down Expand Up @@ -686,6 +712,47 @@ pub(crate) fn self_host_base_url(
Ok(Some(format!("http://{host}:{}", info.port())))
}

/// Scan `model_dir` for files to advertise alongside the typed MDC slots.
/// Skips weights, dotfiles / README (`is_ignored`), already-typed
/// filenames, and anything that isn't a regular file. Non-recursive.
/// Results are sorted by filename so the resulting `mdcsum` is stable
Comment thread
nnshah1 marked this conversation as resolved.
Outdated
/// across filesystems that return `read_dir` entries in different
/// orders. Returns an empty vec when `model_dir` doesn't exist (e.g.
/// name-only `LocalModel` placeholders).
fn harvest_extra_files(
model_dir: &Path,
typed_filenames: &HashSet<String>,
) -> anyhow::Result<Vec<CheckedFile>> {
if !model_dir.is_dir() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in
fs::read_dir(model_dir).with_context(|| format!("read_dir {}", model_dir.display()))?
{
let entry = entry?;
let path = entry.path();
// `Path::is_file` uses `fs::metadata` which follows symlinks.
// `entry.file_type()` / `entry.metadata()` use lstat on Unix —
// they would skip the HF blob symlinks we need to harvest.
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if typed_filenames.contains(name)
|| is_weight_file(&path)
|| HuggingFaceProvider::is_ignored(name)
{
continue;
}
out.push(CheckedFile::from_disk(&path)?);
}
out.sort_by(|a, b| a.path().cmp(&b.path()));
Comment thread
nnshah1 marked this conversation as resolved.
Outdated
Ok(out)
}

#[cfg(test)]
mod env_self_host_metadata_tests {
use super::*;
Expand All @@ -710,3 +777,68 @@ mod env_self_host_metadata_tests {
unsafe { std::env::remove_var(ENV_SELF_HOST_METADATA) };
}
}

#[cfg(test)]
mod harvest_extra_files_tests {
use super::*;

#[test]
fn filters_weights_typed_and_ignored() {
let dir = tempfile::tempdir().unwrap();
let touch = |name: &str| {
std::fs::write(dir.path().join(name), b"x").unwrap();
};
// typed slot (excluded by name)
touch("config.json");
// weights — covers the mx-narrow case (safetensors) and the
// ecosystem-wide case (.pt) which mx alone wouldn't catch.
touch("model.safetensors");
touch("pytorch_lora_weights.pt");
// dotfile / README (excluded by is_ignored)
touch(".gitattributes");
touch("README.md");
// genuine extras (kept)
touch("preprocessor_config.json");
touch("special_tokens_map.json");
// subdir (skipped — not a file)
std::fs::create_dir(dir.path().join("subdir")).unwrap();
// symlink to a real file (must be followed — HF snapshot dirs
// are full of these pointing into blobs/)
let target = dir.path().join("target_for_symlink");
std::fs::write(&target, b"x").unwrap();
std::os::unix::fs::symlink(&target, dir.path().join("added_tokens.json")).unwrap();

let typed: HashSet<String> = ["config.json".to_string()].into_iter().collect();
let mut names: Vec<String> = harvest_extra_files(dir.path(), &typed)
.unwrap()
.iter()
.map(|cf| {
cf.path()
.unwrap()
.file_name()
.unwrap()
.to_str()
.unwrap()
.to_string()
})
.collect();
names.sort();
assert_eq!(
names,
vec![
"added_tokens.json",
"preprocessor_config.json",
"special_tokens_map.json",
"target_for_symlink",
]
);
}

#[test]
fn returns_empty_for_missing_dir() {
let typed: HashSet<String> = HashSet::new();
// bogus path: doesn't exist on disk; must not error
let result = harvest_extra_files(Path::new("/nonexistent/dynamo/test/path"), &typed);
assert!(result.unwrap().is_empty());
}
}
65 changes: 48 additions & 17 deletions lib/llm/src/model_card.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,13 +317,14 @@ fn symlink_force(target: &Path, link: &Path) -> anyhow::Result<()> {
/// the fallback when `size` is absent on a `CheckedFile`.
const ABSOLUTE_MAX_METADATA_BYTES: u64 = 1024 * 1024 * 1024;

/// File extensions that identify model weights. Used by the hf:// sibling
/// harvest to skip weight blobs (which `hub::from_hf(_, ignore_weights=true)`
/// also already filters at download, but the snapshot dir may contain
/// pre-existing weights from a prior unrestricted pull).
/// File extensions that identify model weights. Used by both gh-8749
/// harvest paths — the frontend hf:// sibling harvest below and the
/// worker self-host harvest in `local_model::move_to_self_host`. Wider
/// than mx's `HuggingFaceProvider::is_weight_file` so a stray `.pt` /
/// `.gguf` / `.onnx` weight doesn't ride the metadata HTTP path.
Comment thread
nnshah1 marked this conversation as resolved.
Outdated
///
/// `.safetensors.index.json` is correctly kept: extension is `.json`.
fn is_weight_file(path: &Path) -> bool {
pub(crate) fn is_weight_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("safetensors" | "bin" | "gguf" | "onnx" | "tflite" | "h5" | "pt" | "pth" | "msgpack")
Expand Down Expand Up @@ -731,6 +732,14 @@ pub struct ModelDeploymentCard {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub router_config: Option<RouterConfig>,

/// Non-typed sibling files the worker found next to the typed slots
/// (`preprocessor_config.json`, `special_tokens_map.json`, etc.).
/// Populated by the worker for self-host; ride the same
/// resolve-and-cache pipeline as typed slots so external preprocessors
/// see a complete `slug_dir`.
Comment thread
nnshah1 marked this conversation as resolved.
Outdated
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_files: Vec<CheckedFile>,
Comment thread
nnshah1 marked this conversation as resolved.

#[serde(skip, default)]
checksum: OnceLock<String>,
}
Expand All @@ -747,6 +756,11 @@ pub struct LoraInfo {
}

impl ModelDeploymentCard {
/// Number of typed metadata slots (`model_info`, `tokenizer`,
/// `prompt_formatter`, `chat_template_file`, `gen_config`). Used as
/// a capacity hint for [`Self::iter_metadata_files`].
const TYPED_SLOT_COUNT: usize = 5;

pub fn builder() -> ModelDeploymentCardBuilder {
ModelDeploymentCardBuilder::default()
}
Expand Down Expand Up @@ -832,6 +846,20 @@ impl ModelDeploymentCard {
bytes_to_hash.extend(gen_config.checksum().as_bytes());
}

// extra_files: hash sorted checksums so two workers with
// identical siblings produce the same mdcsum regardless
// of `read_dir` order. Without this, the frontend cache
// could serve stale metadata.
let mut extra_hashes: Vec<&str> = self
.extra_files
.iter()
.map(|cf| cf.checksum().hash())
Comment thread
nnshah1 marked this conversation as resolved.
Outdated
.collect();
extra_hashes.sort_unstable();
for h in &extra_hashes {
bytes_to_hash.extend(h.as_bytes());
}

if let Some(prompt_context_vec) = self.prompt_context.as_ref() {
// Paste it as the bytes of the debug format. It's a Vec of enum, so this should be
// fine. If the debug representation changes that only happens in a new release.
Expand Down Expand Up @@ -980,18 +1008,13 @@ impl ModelDeploymentCard {

/// Iterate populated metadata slots in deterministic order:
/// model_info, tokenizer, prompt_formatter, chat_template_file,
/// gen_config. Each entry is `(file, is_custom)` — `is_custom` is
/// only ever true for operator-supplied chat templates, which
/// can't fall back to HF.
///
/// TODO(gh-8749): external preprocessors (vllm/sglang) read
/// `from_pretrained(slug_dir)` and may expect siblings outside the
/// typed slots — `preprocessor_config.json`, `special_tokens_map.json`,
/// `added_tokens.json`, etc. Add an `extra_files: Vec<CheckedFile>`
/// MDC field so the worker can advertise everything in its model dir
/// minus weights. Frontend pipeline is already generic over slot count.
/// gen_config, then any `extra_files` siblings the worker harvested
/// (preprocessor_config.json, special_tokens_map.json, etc.). Each entry
/// is `(file, is_custom)` — `is_custom` is only ever true for
/// operator-supplied chat templates, which can't fall back to HF.
pub fn iter_metadata_files(&self) -> Vec<(&CheckedFile, bool)> {
let mut out: Vec<(&CheckedFile, bool)> = Vec::with_capacity(5);
let mut out: Vec<(&CheckedFile, bool)> =
Vec::with_capacity(Self::TYPED_SLOT_COUNT + self.extra_files.len());
if let Some(ModelInfoType::HfConfigJson(cf)) = self.model_info.as_ref() {
out.push((cf, false));
}
Expand All @@ -1009,12 +1032,16 @@ impl ModelDeploymentCard {
if let Some(GenerationConfig::HfGenerationConfigJson(cf)) = self.gen_config.as_ref() {
out.push((cf, false));
}
for cf in &self.extra_files {
out.push((cf, false));
}
out
}

/// Mutable mirror of [`Self::iter_metadata_files`].
pub fn iter_metadata_files_mut(&mut self) -> Vec<(&mut CheckedFile, bool)> {
let mut out: Vec<(&mut CheckedFile, bool)> = Vec::with_capacity(5);
let mut out: Vec<(&mut CheckedFile, bool)> =
Vec::with_capacity(Self::TYPED_SLOT_COUNT + self.extra_files.len());
if let Some(ModelInfoType::HfConfigJson(cf)) = self.model_info.as_mut() {
out.push((cf, false));
}
Expand All @@ -1034,6 +1061,9 @@ impl ModelDeploymentCard {
if let Some(GenerationConfig::HfGenerationConfigJson(cf)) = self.gen_config.as_mut() {
out.push((cf, false));
}
for cf in &mut self.extra_files {
out.push((cf, false));
}
out
}

Expand Down Expand Up @@ -1317,6 +1347,7 @@ impl ModelDeploymentCard {
media_decoder: None,
media_fetcher: None,
router_config: None,
extra_files: Vec::new(),
checksum: OnceLock::new(),
})
}
Expand Down
15 changes: 14 additions & 1 deletion tests/frontend/test_self_host_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,21 @@ def test_worker_serves_metadata_via_http(
)
assert miss.status_code == 404, miss.text

# extra_files harvest: a non-typed sibling that exists in the
# Qwen3 HF snapshot (`vocab.json`) must also be served. Doubles
# as a symlink-follow check — HF snapshot entries are symlinks
# into `blobs/`, so a non-following stat would miss this file.
extra_url = (
f"http://localhost:{system_port}/v1/metadata/{slug}/{suffix}/vocab.json"
)
extra_response = requests.get(extra_url, timeout=10)
assert (
extra_response.status_code == 200
), f"GET {extra_url} returned {extra_response.status_code}: harvest did not advertise vocab.json"
assert extra_response.content, "vocab.json served but body was empty"

logger.info(
"self-host metadata verified: %s/%s/config.json served from worker on port %d",
"self-host metadata verified: %s/%s/{config,vocab}.json served from worker on port %d",
slug,
suffix,
system_port,
Expand Down
Loading