fix(moa): /v1/models advertises quant-suffix IDs that route back

PR #566 review feedback (Apr 2026):

> Some IDs in /v1/models dropped quant suffixes. Other endpoints
> used the full model refs.
> [...]
> Direct calls from Carrack to worker-hosted models didn't work.
> Carrack to Lemony 35B returned HTTP 404.

Reproduced on a 2-node mesh (Mac M4 Max + Mac Studio M3 Ultra):

* M4 served `Qwen/Qwen2.5-3B-Instruct-GGUF:qwen2.5-3b-instruct-q4_k_m`.
* Studio served `unsloth/Qwen3-0.6B-GGUF:BF16`.
* M4 `/v1/models` listed:
    Qwen/Qwen2.5-3B-Instruct-GGUF        (quant suffix lost)
    unsloth/Qwen3-0.6B-GGUF:BF16         (full id)
* Calling either listed id directly:
    local short id  \u2192 200 (rewritten via internal alias table)
    remote short id \u2192 404 (no alias for remote models)

The natural client flow \u2014 read `/v1/models`, take an id, call
`/v1/chat/completions` with it \u2014 was broken for remote-hosted
models, and inconsistent for local ones.

## Two root causes

1. **`quant_selector_from_gguf_file` was uppercase-only** when
   matching markers like `-Q`, `-BF16`. Real GGUF filenames mix
   cases (`...-Q4_K_M.gguf` vs `...-q4_k_m.gguf`). The matcher
   on the read side (`gguf_matches_quant_selector`) was already
   case-insensitive, so emitting a lowercase selector is safe and
   keeps the public id round-trippable.

2. **`public_huggingface_model_ref` only handled artifact-as-filename.**
   Locally-built `ServedModelDescriptor`s set
   `artifact = model_ref.selector` \u2014 e.g.
   `"qwen2.5-3b-instruct-q4_k_m"`, a quant selector, not a GGUF
   filename. `quant_selector_from_gguf_file` returned None for
   anything not ending `.gguf`, so the public id collapsed to just
   the repo name.

The `public_model_id` selection logic also needed tightening so we
fall back to local disk only when the descriptor cannot produce a
lossless id (e.g. catalog or local-gguf identities without enough
metadata).

## Test (added first, observed failing)

`models_list_id_preserves_quant_suffix_when_descriptor_has_no_artifact`
in `transport.rs` builds a HuggingFace descriptor with no `artifact`
field and asserts the resulting public id either matches the internal
model_name verbatim or carries a non-empty quant tag. Pre-fix the
public id collapsed to bare repo and the test failed.

## Fix

* `model-ref/src/lib.rs::quant_selector_from_gguf_file` \u2014 lowercases
  the stem before matching markers so lowercase-quant filenames like
  `qwen2.5-3b-instruct-q4_k_m.gguf` extract `q4_k_m` instead of None.
  Returns the slice from the original stem so display casing is
  preserved.

* `transport.rs::public_huggingface_model_ref` \u2014 accepts artifact
  values that are already a quant selector (no `.gguf` suffix) in
  addition to GGUF filenames. The selector now round-trips through
  the resolver.

* `transport.rs::public_model_id` \u2014 prefers the descriptor only
  when its identity carries enough information to produce a lossless
  id (HuggingFace needs an artifact; Catalog needs a canonical_ref).
  Otherwise falls back to the on-disk file, then the model_name
  itself \u2014 never silently drops information.

## Validation

`cargo test -p mesh-llm-host-runtime --lib` \u2014 1435/1435 pass.
`cargo test -p model-ref` \u2014 10/10 pass.

Live 2-node mesh (M4 gateway + Studio peer):

`/v1/models` now reports both models with their full ids:
    Qwen/Qwen2.5-3B-Instruct-GGUF:q4_k_m
    unsloth/Qwen3-0.6B-GGUF:BF16

Direct `/v1/chat/completions` calls with either listed id return
200 and real inference for both local and remote models.

`model: "mesh"` (MoA) still works end-to-end on the same setup,
returning a real fanout response (`Tokyo` from the capital-of-japan
prompt).

`cargo clippy -p mesh-llm-host-runtime --all-targets -- -D warnings`
\u2014 clean. `cargo fmt --all -- --check` \u2014 clean.
This commit is contained in:
Michael Neale 2026-05-20 18:19:45 +10:00
parent 64c4e0ec83
commit f3355bfd34
2 changed files with 113 additions and 6 deletions

View file

@ -3928,12 +3928,45 @@ fn descriptor_for_model<'a>(
}
fn public_model_id(model_name: &str, descriptor: Option<&mesh::ServedModelDescriptor>) -> String {
// A descriptor with an `artifact` field has enough information to
// produce a public ID that round-trips to the same model. Without
// it, the HuggingFace path collapses to just the repo name and
// silently drops the quant-tag suffix the resolver needs (PR #566
// review feedback — "some IDs in /v1/models dropped quant
// suffixes"). Only use the descriptor-derived id when it can be
// lossless; otherwise prefer the on-disk file (authoritative for
// local models), and finally the internal model_name (which
// always carries the quant suffix our resolver knows how to
// route).
if let Some(descriptor) = descriptor {
return public_model_id_from_identity(&descriptor.identity)
.unwrap_or_else(|| model_name.to_string());
if descriptor_can_produce_lossless_id(&descriptor.identity) {
if let Some(id) = public_model_id_from_identity(&descriptor.identity) {
return id;
}
}
}
public_model_id_from_local_path(model_name).unwrap_or_else(|| model_name.to_string())
if let Some(id) = public_model_id_from_local_path(model_name) {
return id;
}
model_name.to_string()
}
/// A descriptor identity carries enough information for
/// `public_model_id_from_identity` to produce an ID that round-trips
/// to the same model. For HuggingFace that means the `artifact` field
/// (the GGUF file name) is present so the quant selector can be
/// derived. Catalog identities always carry a `canonical_ref` with the
/// selector baked in.
fn descriptor_can_produce_lossless_id(identity: &mesh::ServedModelIdentity) -> bool {
match identity.source_kind {
mesh::ModelSourceKind::HuggingFace => identity.artifact.is_some(),
mesh::ModelSourceKind::Catalog => identity.canonical_ref.is_some(),
mesh::ModelSourceKind::LocalGguf
| mesh::ModelSourceKind::DirectUrl
| mesh::ModelSourceKind::Unknown => false,
}
}
fn public_model_id_from_identity(identity: &mesh::ServedModelIdentity) -> Option<String> {
@ -3972,7 +4005,16 @@ fn public_model_id_from_local_path(model_name: &str) -> Option<String> {
}
fn public_huggingface_model_ref(repo: &str, artifact: Option<&str>) -> Option<String> {
let selector = artifact.and_then(model_ref::quant_selector_from_gguf_file);
// `artifact` can be either a GGUF filename (e.g. `Falcon-Q4_K_M.gguf`)
// or an already-extracted quant selector (e.g. `Q4_K_M` or
// `qwen2.5-3b-instruct-q4_k_m`, when the descriptor was built from
// a parsed `ModelRef::selector`). Handle both — if the artifact
// looks like a quant selector use it directly; otherwise try to
// pull a selector out of the filename.
let selector = artifact.and_then(|a| {
model_ref::quant_selector_from_gguf_file(a)
.or_else(|| (!a.is_empty() && !a.ends_with(".gguf")).then(|| a.to_string()))
});
Some(model_ref::format_model_ref(repo, None, selector.as_deref()))
}
@ -4385,6 +4427,62 @@ mod tests {
assert_eq!(body["data"][0]["owned_by"], "mesh-llm");
}
#[test]
fn models_list_id_preserves_quant_suffix_when_descriptor_has_no_artifact() {
// Regression for PR #566 review feedback: the gateway's view of a
// model's public ID must include enough information to route a
// request back to that exact model. When a `ServedModelDescriptor`
// for a HuggingFace model has no `artifact` field (because the
// descriptor was built without inspecting the GGUF file on disk),
// `public_huggingface_model_ref` collapses the public ID to just
// the repo name — dropping the quant-tag suffix the internal
// `model_name` carries. The model is then advertised in `/v1/models`
// under a shorter ID than the resolver knows how to route.
//
// Symptom on a real 2-node mesh: the studio's Qwen3-0.6B-GGUF
// shows as `unsloth/Qwen3-0.6B-GGUF:BF16` (descriptor has
// artifact), but the gateway-local Qwen2.5-3B-Instruct-GGUF
// shows as `Qwen/Qwen2.5-3B-Instruct-GGUF` (descriptor has no
// artifact). A client doing the natural thing — read /v1/models,
// call /v1/chat/completions with the listed id — then 404s on
// remote models because the resolver doesn't know the short id.
//
// Acceptable behaviour: the public ID either round-trips to the
// same model, OR includes the quant suffix the internal name
// carries.
let models = vec!["Qwen/Qwen2.5-3B-Instruct-GGUF:qwen2.5-3b-instruct-q4_k_m".to_string()];
let descriptor = mesh::ServedModelDescriptor {
identity: mesh::ServedModelIdentity {
model_name: models[0].clone(),
source_kind: mesh::ModelSourceKind::HuggingFace,
repository: Some("Qwen/Qwen2.5-3B-Instruct-GGUF".to_string()),
// No artifact — this is the field whose absence loses the
// quant suffix.
artifact: None,
..Default::default()
},
..Default::default()
};
let descriptors = vec![descriptor];
let body = models_list_json(&models, &descriptors);
let public_id = body["data"][0]["id"].as_str().unwrap_or_default();
// The public ID must NOT silently drop the quant suffix that the
// internal model_name carries. Acceptable IDs:
// * the full internal name, OR
// * the repo with a quant tag we can route back to.
assert!(
public_id == models[0]
|| public_id
.strip_prefix("Qwen/Qwen2.5-3B-Instruct-GGUF:")
.is_some_and(|tag| !tag.is_empty()),
"public id must keep enough information to route back; got {public_id:?}, \
internal model_name was {:?}",
models[0]
);
}
#[test]
fn models_list_uses_catalog_model_ref_ids() {
let models = vec!["Falcon-H1-1.5B-Instruct-Q4_K_M".to_string()];

View file

@ -113,10 +113,19 @@ pub fn quant_selector_from_gguf_file(file: &str) -> Option<String> {
stem = prefix;
}
// Markers are matched case-insensitively. Real-world GGUF filenames
// are inconsistent: `Qwen3-32B-Q4_K_M.gguf` (uppercase markers) is
// common, but so is `qwen2.5-3b-instruct-q4_k_m.gguf` (all
// lowercase). The matcher in `gguf_matches_quant_selector` already
// lowercases both sides, so emitting a lowercase selector here is
// safe and keeps the public ID round-trippable.
let stem_lower = stem.to_ascii_lowercase();
for marker in [
"-UD-", ".UD-", "-IQ", ".IQ", "-Q", ".Q", "-BF16", ".BF16", "-F16", ".F16", "-F32", ".F32",
"-ud-", ".ud-", "-iq", ".iq", "-q", ".q", "-bf16", ".bf16", "-f16", ".f16", "-f32", ".f32",
] {
if let Some(pos) = stem.rfind(marker) {
if let Some(pos) = stem_lower.rfind(marker) {
// Use the original casing for the returned slice so callers
// that build a public ID keep the model's preferred display.
return Some(stem[pos + 1..].to_string());
}
}