Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
83c48dc
feat(checked_file): add Checksum::hash() accessor + cross-version com…
nnshah1 May 2, 2026
01e996c
feat(model_card): add MDC cache primitives — flock + atomic publish (…
nnshah1 May 2, 2026
610985b
feat(model_card): add resolve_uri pipeline for self-hosted metadata (…
nnshah1 May 2, 2026
6077ce2
feat(model_card): unify download_config through resolve_metadata_file…
nnshah1 May 2, 2026
514079f
test(model_card): add e2e for download_config + cache pipeline (gh-8749)
nnshah1 May 2, 2026
d56ed8c
chore(deps): refresh nested Cargo.lock files for libc bump (gh-8749)
nnshah1 May 2, 2026
ea22e63
fix(model_card): cfg(unix) BlobLock + bounded http timeouts (gh-8749)
nnshah1 May 2, 2026
fa5d981
debug(model_card): log MDC slot population + gen_config disk-vs-loade…
nnshah1 May 3, 2026
fc6f9cf
Revert "debug(model_card): log MDC slot population + gen_config disk-…
nnshah1 May 3, 2026
b07bb03
fix(model_card): keep cf state untouched in resolve Pass 1 (gh-8749)
nnshah1 May 3, 2026
a7b7e1c
refactor(model_card): extract checked_file_basename/uri + use absolut…
nnshah1 May 3, 2026
30f646e
refactor(local_model): mirror frontend — use std::path::absolute (gh-…
nnshah1 May 3, 2026
81f56fe
refactor(model_card): derive filename from URI instead of cf (gh-8749)
nnshah1 May 3, 2026
a3f380f
refactor(model_card): use url::path_segments for uri_basename (gh-8749)
nnshah1 May 3, 2026
5b1c50b
test(model_card): collapse duplicated test scaffolding (gh-8749)
nnshah1 May 3, 2026
2be7de5
docs(model_card): TODO for extra_files slot (vllm pass-through) (gh-8…
nnshah1 May 3, 2026
b9f8586
docs(frontend): TODO to consume MDC cache from sglang/vllm processors…
nnshah1 May 4, 2026
eeabebf
fix(checked_file): validate blake3 hash format at deserialize (gh-8749)
nnshah1 May 6, 2026
f2d8e1d
Merge branch 'main' into nnshah1/gh-8749-frontend-resolve-cache
nnshah1 May 7, 2026
6fbe4a1
feat(self-host): plumb frontend --model-path into resolve pipeline
nnshah1 May 7, 2026
890aa3a
chore(self-host): trim docs + tests on --model-path plumbing
nnshah1 May 7, 2026
76e4b46
fix(self-host): fail fast on unreachable custom chat template
nnshah1 May 8, 2026
b7d06f4
fix(self-host): keep custom templates in resolve pipeline, block hf f…
nnshah1 May 8, 2026
2030aa0
refactor(self-host): inline slot walk, call is_custom_chat_template d…
nnshah1 May 8, 2026
8e04cc3
refactor(self-host): rename allow_hf_fallback -> is_custom
nnshah1 May 8, 2026
0f4f667
test(self-host): merge two rung-4 tests for checked_file_uri
nnshah1 May 8, 2026
ff79745
fix(self-host): address Graham's review pass on resolve pipeline
nnshah1 May 8, 2026
c147dd3
fix(self-host): unlink tmp on cancel via Drop guard
nnshah1 May 11, 2026
81b4358
refactor(self-host): drop BlobLock
nnshah1 May 11, 2026
53ee6c7
chore(deps): drop libc from lib/llm
nnshah1 May 11, 2026
6b2a342
fix(self-host): re-verify on cache hit; refetch on mismatch
nnshah1 May 11, 2026
312c447
chore(deps): sync lib/bindings/kvbm/Cargo.lock after libc drop
nnshah1 May 11, 2026
011320f
chore(deps): sync lib/bindings/python/Cargo.lock after libc drop
nnshah1 May 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions components/src/dynamo/frontend/sglang_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,9 @@ async def chat_engine_factory(
)
loop = asyncio.get_running_loop()

# TODO(gh-8749): consume mdc.model_info.path()'s parent (slug_dir)
# instead of re-running fetch_model. The MDC cache already has
# blake3-verified copies; this path duplicates the download.
source_path = mdc.source_path()
if not os.path.exists(source_path):
await fetch_model(source_path, ignore_weights=True)
Expand Down
3 changes: 3 additions & 0 deletions components/src/dynamo/frontend/vllm_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,9 @@ async def chat_engine_factory(
)
loop = asyncio.get_running_loop()

# TODO(gh-8749): consume mdc.model_info.path()'s parent (slug_dir)
# instead of re-running fetch_model. The MDC cache already has
# blake3-verified copies; this path duplicates the download.
source_path = mdc.source_path()
if not os.path.exists(source_path):
await fetch_model(source_path, ignore_weights=True)
Expand Down
2 changes: 1 addition & 1 deletion lib/bindings/c/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1498,7 +1498,7 @@ async fn fetch_preprocessor_from_discovery(
);

// Download config (tokenizer files) if not local
card.download_config().await?;
card.download_config(None).await?;

// Create preprocessor
let preprocessor = OpenAIPreprocessor::new(card.clone())?;
Expand Down
1 change: 1 addition & 0 deletions lib/bindings/kvbm/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions lib/bindings/python/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions lib/llm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ futures = { workspace = true }
futures-util = { workspace = true }
hf-hub = { workspace = true }
ipnet = { workspace = true }
libc = { workspace = true }
lru = { workspace = true }
rand = { workspace = true }
oneshot = { workspace = true }
Expand Down
90 changes: 77 additions & 13 deletions lib/llm/src/common/checked_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,27 @@ impl Checksum {
algorithm,
}
}

pub fn hash(&self) -> &str {
&self.hash
}
}

/// Worker-controlled wire field used as a path component downstream;
/// reject anything that doesn't look like the algorithm's canonical
/// digest. Called from both `Deserialize` and `TryFrom<&str>` so both
/// wire entry points stay in lockstep.
fn validate_checksum_hash(algorithm: CryptographicHashMethods, hash: &str) -> Result<(), String> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't check the hash is correct, it only checks that it looks like a hash.

I gather that's intended, but it's confusing. Could you check the shape when you check the value also?

/// Worker-controlled wire field used as a path component downstream;

What does that mean? Is it relevant here.

match algorithm {
CryptographicHashMethods::BLAKE3 => {
if hash.len() != 64 || !hash.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
return Err(format!(
"expected 64-char lowercase hex blake3 digest, got {hash:?}"
));
}
}
}
Ok(())
}

impl Serialize for Checksum {
Expand Down Expand Up @@ -273,6 +294,9 @@ impl<'de> Deserialize<'de> for Checksum {
let algorithm = parts[0].parse().map_err(|_| {
de::Error::invalid_value(de::Unexpected::Str(parts[0]), &"invalid algorithm")
})?;
validate_checksum_hash(algorithm, parts[1]).map_err(|msg| {
de::Error::invalid_value(de::Unexpected::Str(parts[1]), &msg.as_str())
})?;

Ok(Checksum::new(parts[1], algorithm))
}
Expand All @@ -297,6 +321,7 @@ impl TryFrom<&str> for Checksum {
anyhow::bail!("Unsupported cryptographic hash method: {}", parts[0]);
}
};
validate_checksum_hash(algo, parts[1]).map_err(|msg| anyhow::anyhow!("{msg}"))?;

Ok(Checksum::new(parts[1], algo))
}
Expand Down Expand Up @@ -348,26 +373,32 @@ mod tests {
assert_eq!(serialized.trim(), "\"blake3:a12c3d4\"");
}

const HEX64: &str = "62bc124be974d3a25db05bedc99422660c26715e5bbda0b37d14bd84a0c65ab2";

#[test]
fn test_deserialization_blake3() {
let s = "\"blake3:abcd1234\"";
let deserialized: Checksum = serde_json::from_str(s).unwrap();

let s = format!("\"blake3:{HEX64}\"");
let deserialized: Checksum = serde_json::from_str(&s).unwrap();
assert_eq!(deserialized.algorithm, CryptographicHashMethods::BLAKE3);
assert_eq!(deserialized.hash, "abcd1234");
assert_eq!(deserialized.hash, HEX64);
}

#[test]
fn test_deserialization_invalid_format() {
let s = "\"invalidformat\"";
let result: Result<Checksum, _> = serde_json::from_str(s);

assert!(result.is_err());

let s = "\"blake3:invalid:format\"";
let result: Result<Checksum, _> = serde_json::from_str(s);

assert!(result.is_err());
for bad in [
"\"invalidformat\"",
"\"blake3:invalid:format\"",
// Worker-controlled wire field shouldn't survive as a path
// component; reject anything that's not a 64-char lowercase
// hex blake3 digest.
"\"blake3:abcd1234\"", // too short
"\"blake3:../../etc/passwd\"", // path traversal
"\"blake3:62BC124BE974D3A25DB05BEDC99422660C26715E5BBDA0B37D14BD84A0C65AB2\"", // uppercase
&format!("\"blake3:{HEX64}x\""), // 65 chars
] {
let result: Result<Checksum, _> = serde_json::from_str(bad);
assert!(result.is_err(), "expected reject: {bad}");
}
}

#[test]
Expand All @@ -378,5 +409,38 @@ mod tests {
let expected =
Checksum::blake3("62bc124be974d3a25db05bedc99422660c26715e5bbda0b37d14bd84a0c65ab2");
assert_eq!(expected, *cf.checksum());
assert_eq!(Some(560), cf.size(), "TinyLlama config.json is 560 bytes");
assert_eq!(
"62bc124be974d3a25db05bedc99422660c26715e5bbda0b37d14bd84a0c65ab2",
cf.checksum().hash()
);
}

/// Old MDC bytes (no `size` field) deserialize cleanly with
/// `size = None`, preserving cross-version compat between a
/// pre-size worker and a post-size frontend.
#[test]
fn test_legacy_wire_format_omits_size() {
let legacy = format!(r#"{{"path":"/x/config.json","checksum":"blake3:{HEX64}"}}"#);
let cf: CheckedFile = serde_json::from_str(&legacy).unwrap();
assert_eq!(None, cf.size());
assert_eq!("/x/config.json", cf.path().unwrap().to_str().unwrap());
}

/// New-format CheckedFiles round-trip with the size field.
#[test]
fn test_new_wire_format_carries_size() {
let cf = CheckedFile {
path: Either::Left(PathBuf::from("/x/tokenizer.json")),
checksum: Checksum::blake3(HEX64),
size: Some(12345),
};
let json = serde_json::to_string(&cf).unwrap();
assert!(
json.contains("\"size\":12345"),
"expected size field in serialization, got: {json}"
);
let back: CheckedFile = serde_json::from_str(&json).unwrap();
assert_eq!(Some(12345), back.size());
}
}
13 changes: 12 additions & 1 deletion lib/llm/src/discovery/watcher.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::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Notify;
Expand Down Expand Up @@ -88,6 +89,10 @@ pub struct ModelWatcher {
/// Tracks in-flight `handle_put` tasks by instance path so that `handle_delete`
/// can await a racing put before proceeding with cleanup.
pending_puts: DashMap<String, JoinHandle<()>>,
/// Frontend's `--model-path`. Threaded into `download_config` so
/// `file://` slots can fall back here when the worker's path is
/// unreachable on this host.
local_model_path: Option<PathBuf>,
}

const ALL_MODEL_TYPES: &[ModelType] = &[
Expand Down Expand Up @@ -167,13 +172,18 @@ impl ModelWatcher {
registering_worker_sets: DashSet::new(),
registration_notify: Notify::new(),
pending_puts: DashMap::new(),
local_model_path: None,
}
}

pub fn set_notify_on_model_update(&mut self, tx: Sender<ModelUpdate>) {
self.model_update_tx = Some(tx);
}

pub fn set_local_model_path(&mut self, path: Option<PathBuf>) {
self.local_model_path = path;
}

/// Wait until we have at least one chat completions model and return it's name.
pub async fn wait_for_chat_model(&self) -> String {
// Loop in case it gets added and immediately deleted
Expand Down Expand Up @@ -673,7 +683,8 @@ impl ModelWatcher {
mcid: &ModelCardInstanceId,
card: &mut ModelDeploymentCard,
) -> anyhow::Result<()> {
card.download_config().await?;
card.download_config(self.local_model_path.as_deref())
.await?;

// Use per-worker-set router config if the worker provided one in its MDC,
// otherwise fall back to the frontend-level global config.
Expand Down
8 changes: 6 additions & 2 deletions lib/llm/src/entrypoint/input/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ pub async fn prepare_engine(
let model_manager = Arc::new(ModelManager::new());
// Create metrics for migration tracking (not exposed via /metrics in Dynamic engine mode)
let metrics = Arc::new(Metrics::new());
let watch_obj = Arc::new(ModelWatcher::new(
let mut watcher = ModelWatcher::new(
distributed_runtime.clone(),
model_manager.clone(),
RouterConfig::default(),
Expand All @@ -110,7 +110,11 @@ pub async fn prepare_engine(
None,
prefill_load_estimator,
metrics,
));
);
if !local_model.path().as_os_str().is_empty() {
watcher.set_local_model_path(Some(local_model.path().to_path_buf()));
}
let watch_obj = Arc::new(watcher);
let discovery = distributed_runtime.discovery();
let discovery_stream = discovery
.list_and_watch(
Expand Down
9 changes: 8 additions & 1 deletion lib/llm/src/entrypoint/input/grpc.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::path::PathBuf;
use std::sync::Arc;

use crate::{
Expand Down Expand Up @@ -47,6 +48,8 @@ pub async fn run(
model.namespace(),
model.namespace_prefix(),
);
let local_model_path =
(!model.path().as_os_str().is_empty()).then(|| model.path().to_path_buf());
run_watcher(
distributed_runtime.clone(),
grpc_service.state().manager_clone(),
Expand All @@ -55,6 +58,7 @@ pub async fn run(
migration_max_seq_len,
namespace_filter,
prefill_load_estimator.clone(),
local_model_path,
)
.await?;
grpc_service
Expand Down Expand Up @@ -112,6 +116,7 @@ pub async fn run(

/// Spawns a task that watches for new models in store,
/// and registers them with the ModelManager so that the HTTP service can use them.
#[allow(clippy::too_many_arguments)]
async fn run_watcher(
runtime: DistributedRuntime,
model_manager: Arc<ModelManager>,
Expand All @@ -120,10 +125,11 @@ async fn run_watcher(
migration_max_seq_len: Option<u32>,
namespace_filter: NamespaceFilter,
prefill_load_estimator: Option<Arc<dyn dynamo_kv_router::PrefillLoadEstimator>>,
local_model_path: Option<PathBuf>,
) -> anyhow::Result<()> {
// Create metrics for migration tracking (not exposed via /metrics in gRPC mode)
let metrics = Arc::new(Metrics::new());
let watch_obj = ModelWatcher::new(
let mut watch_obj = ModelWatcher::new(
runtime.clone(),
model_manager,
router_config,
Expand All @@ -133,6 +139,7 @@ async fn run_watcher(
prefill_load_estimator,
metrics,
);
watch_obj.set_local_model_path(local_model_path);
tracing::debug!("Waiting for remote model");
let discovery = runtime.discovery();
let discovery_stream = discovery
Expand Down
6 changes: 6 additions & 0 deletions lib/llm/src/entrypoint/input/http.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::path::PathBuf;
use std::sync::Arc;

use crate::{
Expand Down Expand Up @@ -83,6 +84,8 @@ pub async fn run(
model.namespace(),
model.namespace_prefix(),
);
let local_model_path =
(!model.path().as_os_str().is_empty()).then(|| model.path().to_path_buf());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In here and the other places, why do you do as_os_str() before checking is_empty()? Likely can simplify.

run_watcher(
distributed_runtime.clone(),
http_service.state().manager_clone(),
Expand All @@ -94,6 +97,7 @@ pub async fn run(
http_service.state().metrics_clone(),
chat_engine_factory.clone(),
prefill_load_estimator.clone(),
local_model_path,
)
.await?;
http_service
Expand Down Expand Up @@ -173,6 +177,7 @@ async fn run_watcher(
metrics: Arc<crate::http::service::metrics::Metrics>,
chat_engine_factory: Option<ChatEngineFactoryCallback>,
prefill_load_estimator: Option<Arc<dyn dynamo_kv_router::PrefillLoadEstimator>>,
local_model_path: Option<PathBuf>,
) -> anyhow::Result<()> {
let mut watch_obj = ModelWatcher::new(
runtime.clone(),
Expand All @@ -184,6 +189,7 @@ async fn run_watcher(
prefill_load_estimator,
metrics.clone(),
);
watch_obj.set_local_model_path(local_model_path);
tracing::debug!("Waiting for remote model");
let discovery = runtime.discovery();
let discovery_stream = discovery
Expand Down
8 changes: 2 additions & 6 deletions lib/llm/src/local_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -564,24 +564,20 @@ impl LocalModel {
let Some(local_path) = cf.path().map(Path::to_path_buf) else {
continue;
};
// Filename from the original path — HF cache symlinks would
// otherwise canonicalize to the LFS SHA1 and break downstream
// lookups by literal name (e.g. `parent.join("generation_config.json")`).
let Some(filename) = local_path
.file_name()
.and_then(|f| f.to_str())
.map(|s| s.to_string())
else {
continue;
};
// Canonicalize so the handler serves the resolved HF blob, not the symlink.
let absolute = match fs::canonicalize(&local_path) {
let absolute = match std::path::absolute(&local_path) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the docs:

unlike canonicalize, this does not resolve symlinks and may succeed even if the path does not exist

You deleted the comment about symlinks, does that mean we don't have symlinks here any more? This one seems like it might break things.

Ok(p) => p,
Err(err) => {
tracing::warn!(
path = %local_path.display(),
%err,
"failed to canonicalize self-host metadata path; skipping",
"failed to absolutize self-host metadata path; skipping",
);
continue;
}
Expand Down
Loading
Loading