Skip to content

Commit 8ea2b9d

Browse files
mudlerlocalai-org-maint-bot
authored andcommitted
fix(vllm-cpp): resolve the DFlash draft path instead of missing the HF cache
The engine resolves speculative_config.model against a directory containing config.json, or against ~/.cache/huggingface/hub/models--<org>--<repo>/ snapshots/*, and it never downloads. LocalAI keeps models in its own directory, so the repo-id spelling the vLLM docs teach - "z-lab/Qwen3.6-27B-DFlash" - misses the HF cache and dies deep inside the load with "draft checkpoint not found", which reads like a broken checkpoint rather than a model nobody fetched. Resolve it before the load call: the reference as given, then its last path segment under LocalAI's models dir (what LocalAI's own downloader produces), then the whole reference under the models dir. When none resolve, fail there naming both what was asked for and every location tried, so the message says what to do about it. mtp and ngram pass through untouched - neither has a separate draft checkpoint. A speculative_config that does not parse also passes through, because the engine owns config validation and produces the better error. Docs also gain the two limits that were missing and are easy to lose an afternoon to: speculation is Qwen3.5/3.6-only at this engine pin regardless of format, and mtp/dflash need a safetensors target. The latter is a gap in the engine's GGUF loader rather than a property of GGUF - the format carries MTP weights fine, llama.cpp reads them as nextn.* tensors plus a <arch>.nextn_predict_layers key - so the docs say that rather than implying GGUF cannot express it. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-5 [ClaudeCode]
1 parent ed4478b commit 8ea2b9d

4 files changed

Lines changed: 197 additions & 1 deletion

File tree

backend/go/vllm-cpp/backend.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,16 @@ func (v *VllmCpp) Load(opts *pb.ModelOptions) error {
109109

110110
v.opts = parseOptions(opts)
111111

112+
// A DFlash draft is a second checkpoint the engine opens by path, and the
113+
// engine never downloads one. Resolve it against LocalAI's models directory
114+
// now so a repo-id spelling works, and so a missing draft fails here with an
115+
// actionable message rather than as an HF-cache miss inside the load.
116+
resolvedSpec, err := resolveDraftModelPath(v.opts.speculativeConfig, opts.ModelPath)
117+
if err != nil {
118+
return err
119+
}
120+
v.opts.speculativeConfig = resolvedSpec
121+
112122
mp := defaultModelParams()
113123
if v.opts.blockSize > 0 {
114124
mp.BlockSize = v.opts.blockSize

backend/go/vllm-cpp/options.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ package main
1818

1919
import (
2020
"encoding/json"
21+
"fmt"
22+
"os"
23+
"path"
24+
"path/filepath"
2125
"strconv"
2226
"strings"
2327

@@ -206,6 +210,76 @@ func jsonInt32(v any, fallback int32) int32 {
206210
}
207211
}
208212

213+
// resolveDraftModelPath rewrites a DFlash draft reference into an absolute path
214+
// the engine can actually open.
215+
//
216+
// The engine resolves `speculative_config.model` against a directory containing
217+
// config.json, or against ~/.cache/huggingface/hub/models--<org>--<repo>/
218+
// snapshots/* - and it NEVER downloads. LocalAI keeps models in its own
219+
// directory, so a bare HF repo id (the spelling the vLLM docs teach) misses the
220+
// HF cache and dies deep in the load with "draft checkpoint not found", which
221+
// reads like a broken checkpoint rather than a missing download.
222+
//
223+
// So: try the reference as given, then the last path segment under the models
224+
// dir (`z-lab/Qwen3.6-27B-DFlash` -> `<models>/Qwen3.6-27B-DFlash`, which is
225+
// what LocalAI's own downloader produces), then the whole reference under the
226+
// models dir. If none exist, fail HERE with a message naming both what was
227+
// asked for and where we looked.
228+
//
229+
// mtp and ngram carry no separate draft checkpoint, so they pass through. A
230+
// document that does not parse also passes through: the engine owns config
231+
// validation and produces the better error.
232+
func resolveDraftModelPath(speculativeConfig, modelsDir string) (string, error) {
233+
if strings.TrimSpace(speculativeConfig) == "" {
234+
return speculativeConfig, nil
235+
}
236+
var spec map[string]any
237+
if err := json.Unmarshal([]byte(speculativeConfig), &spec); err != nil {
238+
return speculativeConfig, nil
239+
}
240+
if method, _ := spec["method"].(string); !strings.EqualFold(method, "dflash") {
241+
return speculativeConfig, nil
242+
}
243+
244+
ref, _ := spec["model"].(string)
245+
ref = strings.TrimSpace(ref)
246+
if ref == "" {
247+
return "", fmt.Errorf(
248+
"vllm-cpp: speculative_config method %q requires a \"model\" key naming the draft checkpoint", "dflash")
249+
}
250+
251+
candidates := []string{ref}
252+
if modelsDir != "" {
253+
if base := path.Base(filepath.ToSlash(ref)); base != "" && base != "." && base != "/" {
254+
candidates = append(candidates, filepath.Join(modelsDir, base))
255+
}
256+
candidates = append(candidates, filepath.Join(modelsDir, filepath.FromSlash(ref)))
257+
}
258+
259+
for _, c := range candidates {
260+
if _, err := os.Stat(filepath.Join(c, "config.json")); err != nil {
261+
continue
262+
}
263+
abs, err := filepath.Abs(c)
264+
if err != nil {
265+
abs = c
266+
}
267+
spec["model"] = abs
268+
out, err := json.Marshal(spec)
269+
if err != nil {
270+
return "", fmt.Errorf("vllm-cpp: re-encoding speculative_config: %w", err)
271+
}
272+
xlog.Info("[vllm-cpp] resolved DFlash draft checkpoint", "reference", ref, "path", abs)
273+
return string(out), nil
274+
}
275+
276+
return "", fmt.Errorf(
277+
"vllm-cpp: DFlash draft checkpoint %q not found (looked in: %s). "+
278+
"The engine does not download drafts - install the draft model into LocalAI first, "+
279+
"or set speculative_config.model to an absolute path to a directory containing config.json",
280+
ref, strings.Join(candidates, ", "))
281+
}
282+
209283
func parseInt32(s string, fallback int32) int32 {
210284
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 32)
211285
if err != nil || n <= 0 {

backend/go/vllm-cpp/vllmcpp_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,91 @@ var _ = Describe("samplingFromPredict", func() {
265265
})
266266
})
267267

268+
// The engine resolves speculative_config.model against a local directory or
269+
// ~/.cache/huggingface/hub ONLY - it never downloads. LocalAI keeps models in
270+
// its own directory, so a bare repo id would miss the HF cache and fail deep in
271+
// the load with a confusing "draft checkpoint not found". Resolve it here.
272+
var _ = Describe("resolveDraftModelPath", func() {
273+
var modelsDir string
274+
275+
BeforeEach(func() {
276+
modelsDir = GinkgoT().TempDir()
277+
})
278+
279+
// draftDir creates a plausible draft checkpoint under models/.
280+
draftDir := func(name string) string {
281+
d := filepath.Join(modelsDir, name)
282+
Expect(os.MkdirAll(d, 0o750)).To(Succeed())
283+
Expect(os.WriteFile(filepath.Join(d, "config.json"), []byte("{}"), 0o600)).To(Succeed())
284+
return d
285+
}
286+
287+
It("rewrites a repo id to the matching directory in the models dir", func() {
288+
want := draftDir("Qwen3.6-27B-DFlash")
289+
spec := `{"method":"dflash","model":"z-lab/Qwen3.6-27B-DFlash"}`
290+
out, err := resolveDraftModelPath(spec, modelsDir)
291+
Expect(err).ToNot(HaveOccurred())
292+
Expect(out).To(MatchJSON(`{"method":"dflash","model":"` + want + `"}`))
293+
})
294+
295+
It("rewrites a models-dir-relative path", func() {
296+
want := draftDir("drafts__dflash")
297+
spec := `{"method":"dflash","model":"drafts__dflash"}`
298+
out, err := resolveDraftModelPath(spec, modelsDir)
299+
Expect(err).ToNot(HaveOccurred())
300+
Expect(out).To(ContainSubstring(want))
301+
})
302+
303+
It("leaves an absolute path that already resolves alone", func() {
304+
abs := draftDir("elsewhere")
305+
spec := `{"method":"dflash","model":"` + abs + `"}`
306+
out, err := resolveDraftModelPath(spec, modelsDir)
307+
Expect(err).ToNot(HaveOccurred())
308+
Expect(out).To(MatchJSON(spec))
309+
})
310+
311+
It("fails with an actionable error when the draft is nowhere on disk", func() {
312+
// Silently passing the repo id through would surface as an HF-cache
313+
// miss inside the engine, which reads as "your model is broken".
314+
spec := `{"method":"dflash","model":"z-lab/Not-Downloaded"}`
315+
_, err := resolveDraftModelPath(spec, modelsDir)
316+
Expect(err).To(HaveOccurred())
317+
Expect(err.Error()).To(ContainSubstring("z-lab/Not-Downloaded"))
318+
Expect(err.Error()).To(ContainSubstring(modelsDir))
319+
})
320+
321+
It("requires a model key for dflash", func() {
322+
_, err := resolveDraftModelPath(`{"method":"dflash"}`, modelsDir)
323+
Expect(err).To(HaveOccurred())
324+
Expect(err.Error()).To(ContainSubstring("model"))
325+
})
326+
327+
It("leaves mtp and ngram configs untouched", func() {
328+
// Neither has a separate draft checkpoint to resolve.
329+
for _, spec := range []string{
330+
`{"method":"mtp"}`,
331+
`{"method":"ngram","num_speculative_tokens":4}`,
332+
} {
333+
out, err := resolveDraftModelPath(spec, modelsDir)
334+
Expect(err).ToNot(HaveOccurred())
335+
Expect(out).To(MatchJSON(spec))
336+
}
337+
})
338+
339+
It("passes a malformed document through for the engine to reject", func() {
340+
// The engine owns config validation and produces the better message.
341+
out, err := resolveDraftModelPath(`{not json`, modelsDir)
342+
Expect(err).ToNot(HaveOccurred())
343+
Expect(out).To(Equal(`{not json`))
344+
})
345+
346+
It("is a no-op on an empty config", func() {
347+
out, err := resolveDraftModelPath("", modelsDir)
348+
Expect(err).ToNot(HaveOccurred())
349+
Expect(out).To(BeEmpty())
350+
})
351+
})
352+
268353
var _ = Describe("validModelPath", func() {
269354
It("accepts a .gguf file", func() {
270355
dir := GinkgoT().TempDir()

docs/content/features/text-generation.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -962,7 +962,22 @@ prefill from blowing up the per-step activation on the hybrid architectures.
962962
#### Speculative decoding
963963

964964
`speculative_config:` takes the same JSON object as vLLM's
965-
`--speculative-config`. Three methods are supported:
965+
`--speculative-config`. Three methods are supported.
966+
967+
> **Architecture limit.** At the current engine pin, `mtp` and `dflash` are
968+
> **Qwen3.5 / Qwen3.6 only**. The engine builds a widened speculative KV cache
969+
> directly for those families rather than through the model registry, so a
970+
> speculative config on any other architecture (Llama, GLM, Gemma, Mistral, ...)
971+
> will not work regardless of checkpoint format. `ngram` needs no draft weights
972+
> and is not subject to this limit.
973+
974+
> **Format limit.** `mtp` and `dflash` require a **safetensors** target and are
975+
> rejected at load on a `.gguf` target, with:
976+
> `speculative decoding requires a safetensors target checkpoint`.
977+
> This is a current gap in the engine's GGUF loader, not a property of the GGUF
978+
> format - GGUF can carry MTP weights (llama.cpp reads them as `nextn.*`
979+
> tensors plus a `<arch>.nextn_predict_layers` key), but vllm.cpp's GGUF path
980+
> does not map them yet. `ngram` works fine on GGUF.
966981

967982
**MTP** (Multi-Token Prediction) uses a draft head shipped inside the target
968983
checkpoint's own `mtp.*` tensors, so there is no second model to download. It
@@ -990,6 +1005,18 @@ engine_args:
9901005
num_speculative_tokens: 4
9911006
```
9921007

1008+
The draft shares the *target's* `embed_tokens` and `lm_head`, so both must come
1009+
from the same model family and the target must be safetensors.
1010+
1011+
**The engine does not download the draft.** `model:` is resolved, in order,
1012+
as a path as given, then as the last path segment under LocalAI's models
1013+
directory (`z-lab/Qwen3.6-27B-DFlash` → `<models>/Qwen3.6-27B-DFlash`, which is
1014+
what LocalAI's own downloader produces), then as the whole reference under the
1015+
models directory. Install the draft into LocalAI first, or give an absolute path
1016+
to a directory containing `config.json`. If none of those resolve, the load
1017+
fails immediately naming every location that was tried, rather than reporting a
1018+
missing checkpoint from inside the engine.
1019+
9931020
**N-gram** needs no draft model at all - it proposes from the prompt's own
9941021
suffix history. `num_speculative_tokens` is required:
9951022

0 commit comments

Comments
 (0)