internal/autogen/hf.go
Rama main · réplica de lectura del repositorio original.
package autogen
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// Hugging Face model folders: the layout `huggingface-cli download` (and the
// in-app hub, `<models root>/<owner>/<repo>`) produces for an unconverted model:
// config.json beside one or more *.safetensors weight files. llama.cpp cannot
// load these; vllm can, from the folder itself. So an HF folder is a row only
// for the vllm path, and its "metadata" comes from config.json instead of a gguf
// header.
//
// The detection is deliberately narrow, because a models tree is full of folders
// that LOOK like this and are not chat models: diffusion components (text
// encoders, VAEs, CLIP), classifier heads (ModernBERT), vision backbones (the
// DINOv3 encoder TRELLIS.2 pairs with), and the source folder a gguf was
// converted from. A folder qualifies only when all of these hold:
//
// 1. config.json names a generative architecture (hfGenerativeArch);
// 2. at least one *.safetensors sits directly in the folder;
// 3. no *.gguf sits directly in the folder. A conversion kept beside its
// source is the same model twice, and the gguf is the one every backend
// here can run, so it wins and the folder is left to the gguf scan.
const hfConfigFile = "config.json"
// hfConfig is the subset of a transformers config.json the sizer reads. Every
// numeric field is optional (0 = absent); several are null in real configs,
// which json decodes to 0 as well.
type hfConfig struct {
Architectures []string `json:"architectures"`
ModelType string `json:"model_type"`
NumHiddenLayers int64 `json:"num_hidden_layers"`
NumAttentionHeads int64 `json:"num_attention_heads"`
NumKeyValueHeads int64 `json:"num_key_value_heads"`
HeadDim int64 `json:"head_dim"`
HiddenSize int64 `json:"hidden_size"`
MaxPositionEmbeddings int64 `json:"max_position_embeddings"`
// LayerTypes is the per-layer attention kind newer configs spell out
// ("full_attention", "sliding_attention", "linear_attention"), and the only
// place a hybrid (Qwen3.5/3.6 GatedDeltaNet, Qwen3-Next) says which layers
// hold no KV at all.
LayerTypes []string `json:"layer_types"`
FullAttentionInterval int64 `json:"full_attention_interval"`
// TorchDtype / Dtype: the weights' storage type. transformers renamed the
// key, so both spellings are in circulation.
TorchDtype string `json:"torch_dtype"`
Dtype string `json:"dtype"`
QuantizationConfig *hfQuantization `json:"quantization_config"`
// Multimodal wrappers (Gemma 3, Llama 4, Mistral 3, Qwen3.5-VL...) nest the
// language model's dimensions here and leave the top level nearly empty.
TextConfig *hfConfig `json:"text_config"`
VisionConfig json.RawMessage `json:"vision_config"`
}
type hfQuantization struct {
QuantMethod string `json:"quant_method"`
}
// text returns the config holding the language model's dimensions: the nested
// text_config for a multimodal wrapper, else the top level. The wrapper's own
// architectures list and any top-level trained window are kept, since those
// describe the served model rather than its text tower.
func (c hfConfig) text() hfConfig {
if c.TextConfig == nil || c.NumHiddenLayers > 0 {
return c
}
t := *c.TextConfig
t.Architectures = c.Architectures
if t.MaxPositionEmbeddings == 0 {
t.MaxPositionEmbeddings = c.MaxPositionEmbeddings
}
return t
}
// hfGenerativeArch reports whether config.json describes a model vllm serves as
// a chat/completions LLM. *ForCausalLM is a decoder by definition.
// *ForConditionalGeneration is ALSO what encoder-decoder models call themselves
// (T5, BART, Whisper), which are not chat models; the multimodal chat wrappers
// that share the suffix (Llava, Gemma 3, Qwen2-VL, Mistral 3) are told apart by
// carrying a text_config or vision_config, which the seq2seq ones never do.
func hfGenerativeArch(c hfConfig) bool {
for _, a := range c.Architectures {
switch {
case strings.HasSuffix(a, "ForCausalLM"):
return true
case strings.HasSuffix(a, "ForConditionalGeneration"):
if c.TextConfig != nil || len(c.VisionConfig) > 0 {
return true
}
}
}
return false
}
// readHFConfig parses dir/config.json. ok is false when the folder has none.
func readHFConfig(dir string) (hfConfig, bool, error) {
data, err := os.ReadFile(filepath.Join(dir, hfConfigFile))
if err != nil {
if os.IsNotExist(err) {
return hfConfig{}, false, nil
}
return hfConfig{}, false, err
}
var c hfConfig
if err := json.Unmarshal(data, &c); err != nil {
return hfConfig{}, true, fmt.Errorf("%s: %w", filepath.Join(dir, hfConfigFile), err)
}
return c, true, nil
}
// hfWeights scans dir's own entries (not subfolders) and returns the summed
// size of its *.safetensors files and whether any *.gguf sits beside them.
func hfWeights(dir string) (bytes int64, hasGguf bool) {
entries, err := os.ReadDir(dir)
if err != nil {
return 0, false
}
for _, e := range entries {
if e.IsDir() {
continue
}
switch strings.ToLower(filepath.Ext(e.Name())) {
case ".safetensors":
// os.Stat, not e.Info(): an HF cache snapshot holds symlinks into its
// blobs/ folder, and the entry's own info is the link's.
if fi, err := os.Stat(filepath.Join(dir, e.Name())); err == nil {
bytes += fi.Size()
}
case ".gguf":
hasGguf = true
}
}
return bytes, hasGguf
}
// IsHFModelDir reports whether dir is a Hugging Face LLM folder autogen serves
// through vllm (see the file comment for the three rules).
func IsHFModelDir(dir string) bool {
_, ok := hfModelDir(dir)
return ok
}
// hfModelDir applies the three rules and returns the parsed config on success.
// A folder whose config.json fails to parse is simply not a model: the same
// "skip what cannot be read" rule the gguf walk follows.
func hfModelDir(dir string) (hfConfig, bool) {
c, found, err := readHFConfig(dir)
if !found || err != nil || !hfGenerativeArch(c) {
return hfConfig{}, false
}
bytes, hasGguf := hfWeights(dir)
if bytes == 0 || hasGguf {
return hfConfig{}, false
}
return c, true
}
// hfQuant names how the folder's weights are stored, for the row's Quant and
// id: the quantization method when the config declares one (AWQ, GPTQ, FP8),
// else the storage dtype (BF16, F16, F32). "" when the config says neither.
func hfQuant(c hfConfig) string {
if q := c.QuantizationConfig; q != nil && strings.TrimSpace(q.QuantMethod) != "" {
return strings.ToUpper(strings.TrimSpace(q.QuantMethod))
}
dt := c.TorchDtype
if dt == "" {
dt = c.Dtype
}
switch strings.ToLower(dt) {
case "bfloat16":
return "BF16"
case "float16":
return "F16"
case "float32":
return "F32"
}
return ""
}
// hfRow builds the discovery row for an HF folder. FullPath is the folder, which
// is what `vllm serve` takes, and the id follows the gguf convention (lowercased
// name, quant appended unless the name already spells it) so a folder and a
// gguf of the same model sort and read alike in the catalog.
func hfRow(dir string, c hfConfig) GgufRow {
base := filepath.Base(dir)
publisher := filepath.Base(filepath.Dir(dir))
if owner, repo, ok := hfCacheRepo(dir); ok {
publisher, base = owner, repo
}
quant := hfQuant(c)
id := strings.ToLower(base)
baseID := id
if quant != "" && !hasQuantPart(id, quant) {
id += "-" + strings.ToLower(quant)
}
bytes, _ := hfWeights(dir)
return GgufRow{
ID: id,
BaseID: baseID,
FullPath: dir,
FileName: base,
Quant: quant,
SizeGB: round(float64(bytes)/gib, 2),
Publisher: publisher,
Repo: base,
IsHF: true,
}
}
// hfCacheRepo names the repo of a folder inside the Hugging Face CACHE layout,
// `models--<owner>--<repo>/snapshots/<commit>/`, where the folder's own name is
// a commit hash and would make a meaningless id. ok is false for any other
// layout. A repo name may itself contain "--", so only the first one splits.
func hfCacheRepo(dir string) (owner, repo string, ok bool) {
snapshots := filepath.Dir(dir)
if !strings.EqualFold(filepath.Base(snapshots), "snapshots") {
return "", "", false
}
name, found := strings.CutPrefix(filepath.Base(filepath.Dir(snapshots)), "models--")
if !found {
return "", "", false
}
owner, repo, ok = strings.Cut(name, "--")
if !ok || owner == "" || repo == "" {
return "", "", false
}
return owner, repo, true
}
// ReadHFMetadata maps an HF folder's config.json onto the Metadata fields the
// vllm sizer reads, so an HF model runs through the same --max-model-len math a
// gguf does. It is deliberately CONSERVATIVE where config.json is ambiguous:
// sliding-window layers are charged as full-attention layers (a larger KV slope,
// so a smaller window, never a window vllm then refuses to allocate), and the
// constant recurrent state of a hybrid's linear layers is not modelled (it is
// small and ctx-independent; vllmOverheadGB covers it).
func ReadHFMetadata(dir string) (Metadata, error) {
c, found, err := readHFConfig(dir)
if err != nil {
return Metadata{}, err
}
if !found {
return Metadata{}, fmt.Errorf("%s: no %s", dir, hfConfigFile)
}
bytes, _ := hfWeights(dir)
t := c.text()
arch := c.ModelType
if arch == "" {
arch = t.ModelType
}
m := Metadata{
Path: dir,
FileSizeGB: round(float64(bytes)/gib, 2),
Architecture: arch,
BlockCount: t.NumHiddenLayers,
ContextLength: t.MaxPositionEmbeddings,
EmbeddingLength: t.HiddenSize,
HeadCount: t.NumAttentionHeads,
HeadCountKv: t.NumKeyValueHeads,
KeyLength: t.HeadDim,
QuantLabel: hfQuant(c),
}
if m.HeadCountKv == 0 {
m.HeadCountKv = m.HeadCount // MHA configs omit the KV head count
}
if m.KeyLength == 0 && m.HeadCount > 0 {
m.KeyLength = t.HiddenSize / m.HeadCount
}
m.ValueLength = m.KeyLength
// Which layers carry KV. layer_types is exact per layer, so it wins over the
// interval form; a list whose length disagrees with the layer count is not
// trusted (every layer is then charged, the conservative reading).
if n := len(t.LayerTypes); n > 0 && int64(n) == m.BlockCount {
arr := make([]int64, n)
attn := int64(0)
for i, lt := range t.LayerTypes {
if hfLayerHasKV(lt) {
arr[i] = m.HeadCountKv
attn++
}
}
m.HeadCountKvArr = arr
m.AttnLayerCount = attn
} else if t.FullAttentionInterval > 0 {
m.FullAttnInterval = t.FullAttentionInterval
}
return m, nil
}
// ReadModelMetadata is ReadGgufMetadataCached for any path a served model can
// name: an HF folder reads its config.json, everything else its gguf header.
// For the server's per-model lookups, which hold a path and not a GgufRow.
func ReadModelMetadata(path string) (Metadata, error) {
if IsHFModelDir(path) {
return ReadHFMetadata(path)
}
return ReadGgufMetadataCached(path)
}
// HFRowFor is the discovery row for dir when it is an HF model folder. A
// caller rendering a command from a bare path needs the row's weight size, not
// just IsHF: vllmMaxModelLen charges it against the budget.
func HFRowFor(dir string) (GgufRow, bool) {
c, ok := hfModelDir(dir)
if !ok {
return GgufRow{}, false
}
return hfRow(dir, c), true
}
// HFWeightBytes is the summed size of an HF folder's safetensors, 0 for
// anything else. A folder's own stat size says nothing about its weights.
func HFWeightBytes(dir string) int64 {
bytes, _ := hfWeights(dir)
return bytes
}
// emitHFModel serves an HF folder through vllm. The backend pick PREFERS vllm
// over the class default, since llama.cpp (the usual ★ default for "llm") cannot
// read safetensors at all; a per-model backend pin still wins, and one naming a
// non-vllm engine is an error rather than a command that cannot load.
func emitHFModel(b *strings.Builder, s Settings, row GgufRow, ov *Override, name string, emitted *[]string) error {
be := resolveBackendPreferring(s, ov, "llm", "vllm")
if !strings.EqualFold(be.Kind, "vllm") {
return fmt.Errorf("safetensors model folder needs a vllm backend; llama.cpp loads gguf only. %s", hfSkipHint(runtime.GOOS))
}
meta, err := ReadHFMetadata(row.FullPath)
if err != nil {
return err
}
emitVllmModel(b, s, row, ov, name, be, meta, emitted)
return nil
}
// hfLayerHasKV reports whether a layer_types entry keeps a per-token KV cache.
// Linear-attention and state-space layers hold a fixed recurrent state instead.
func hfLayerHasKV(layerType string) bool {
lt := strings.ToLower(layerType)
return !strings.Contains(lt, "linear") && !strings.Contains(lt, "mamba") && !strings.Contains(lt, "ssm")
}
// hfSkipHint is the next step a skipped HF folder's comment points at. vllm is
// Linux-only, so on Windows "add a vllm backend" is advice nobody can follow:
// the one real way to run the model there is a gguf conversion of it.
func hfSkipHint(goos string) string {
if goos == "windows" {
return "vllm does not run on Windows: download a GGUF build of this model instead"
}
return "Add one under Settings > Backends"
}