Files
llmgw/internal/agent/weights/fetch.go
T
Ilker ManapandClaude Opus 5 dfbb7557dd
CI / test (push) Successful in 1m42s
Serve a model made of several files
The mirror would happily hold a four-shard model that nothing could serve. A
catalog entry names one source, the agent fetched one URL, and so a version of
several files had no way to be described — which is every safetensors model,
and every GGUF too large to ship whole.

A source ending in a slash names a version rather than a file. The agent fetches
every file in it into one directory and hands the engine the directory: vLLM
reads the shards and the config out of the folder, and llama.cpp is given the
first part and finds the rest beside it.

The trailing slash is the whole signal. A flag beside the source would be a
second place for the two to disagree, and a URL already distinguishes a
directory from a file everywhere else.

The mirror answers a version with a manifest instead of a file listing. A plain
file server gives HTML there, and something that has to know what a version
consists of would be left parsing it. It is on the byte listener, not the
management API: fetching weights is the one thing every node does, and making
it depend on the administrative port would undo the split that keeps that port
closeable.

Built from the directory rather than a file written beside it. A manifest on
disk is a second record of what is there, and the two disagree the first time
somebody copies a file in by hand — which the mirror explicitly allows.

The whole set or nothing. A directory with three of four shards loads as a
broken model rather than failing to load, so a completion marker is written
last and an interrupted fetch starts the missing files again rather than being
found and served. Files already there at the right size are not fetched twice.

Progress is reported under the version's address, not each file's: the shards
waiting on it asked for the version, and progress under a file's own URL
reaches nobody.

A filename from the manifest is refused if it contains a separator. The mirror
is a place an operator copies files into by hand, so what it reports is not
automatically safe to join onto a path.

An HTML listing where a manifest was expected says so. "invalid character '<'"
sends somebody looking at the wrong thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 21:07:26 +02:00

324 lines
10 KiB
Go

// Package weights makes a model's files present on a node.
//
// The simple arrangement, and the default: every node has its own copy. A
// source that is already a local path is used as it stands; one that names a
// remote file is fetched into a cache directory once and reused. Nothing here
// manages storage, decides a retention policy, or knows what a filesystem is —
// it answers one question, which is where the engine should be pointed.
//
// The exotic arrangements are all handled by not acting. Weights on a shared
// filer, an image exported over the fabric, or anything else an operator has
// mounted are simply present, and present means there is nothing to do. That is
// why the interesting case needs no code: the escape hatch from a distribution
// mechanism is a distribution mechanism that checks first.
package weights
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// Progress reports how a fetch is going, for anything that wants to say so.
type Progress struct {
Source string
BytesDone int64
BytesTotal int64
}
// Percent is how far along, or -1 when the total is unknown.
func (p Progress) Percent() int {
if p.BytesTotal <= 0 {
return -1
}
return int(p.BytesDone * 100 / p.BytesTotal)
}
// Fetcher makes sources local.
type Fetcher struct {
CacheDir string
Client *http.Client
Log *slog.Logger
// OnProgress is called occasionally during a fetch. A missing model is the
// difference between a replica that is slow to start and one that is stuck,
// and without this the two are indistinguishable from outside.
OnProgress func(Progress)
// inflight makes concurrent requests for one source share a single fetch.
//
// Eight shards of a tensor-parallel replica start at the same instant and
// want the same file. Without this they would each open a connection and
// each write the same bytes, turning one download into eight and, on a
// shared origin, into a self-inflicted denial of service.
mu sync.Mutex
inflight map[string]*fetchCall
}
type fetchCall struct {
done chan struct{}
path string
err error
}
// ErrNotLocal means a source names a file that is not here and cannot be
// fetched — a bare path with nothing to fetch it from.
var ErrNotLocal = errors.New("the weights are not on this node and the source says nothing about where to get them")
// Ensure returns a local path for a source, fetching it if it is not here.
//
// A plain path is never fetched, only checked. That is deliberate: a path is
// what an operator writes when the file is already there, whether it arrived on
// local disk, a Lustre mount or an image exported over the fabric, and a
// downloader that second-guessed that would copy a hundred gigabytes that were
// already present.
func (f *Fetcher) Ensure(ctx context.Context, source string) (string, error) {
if source == "" {
return "", fmt.Errorf("no source given")
}
// A version rather than a file. Everything below assumes one URL is one
// file, and a set has to be handled before any of it.
if IsSet(source) {
return f.ensureSet(ctx, source)
}
if !isRemote(source) {
if !isPath(source) {
// A bare model id — "Qwen/Qwen2.5-7B-Instruct" — is not a file and
// was never meant to be one. vLLM and SGLang resolve those
// themselves, and doing it for them would be both a duplicate
// downloader and a change in documented behaviour.
return source, nil
}
if _, err := os.Stat(source); err == nil {
return source, nil
}
return "", fmt.Errorf("%w: %s", ErrNotLocal, source)
}
target, err := f.cachePath(source)
if err != nil {
return "", err
}
if _, err := os.Stat(target); err == nil {
return target, nil
}
f.mu.Lock()
if f.inflight == nil {
f.inflight = map[string]*fetchCall{}
}
if call, running := f.inflight[source]; running {
f.mu.Unlock()
select {
case <-call.done:
return call.path, call.err
case <-ctx.Done():
return "", ctx.Err()
}
}
call := &fetchCall{done: make(chan struct{})}
f.inflight[source] = call
f.mu.Unlock()
call.path, call.err = f.fetch(ctx, source, target)
close(call.done)
f.mu.Lock()
delete(f.inflight, source)
f.mu.Unlock()
return call.path, call.err
}
// fetch downloads a source to a target, resuming and verifying.
func (f *Fetcher) fetch(ctx context.Context, source, target string) (string, error) {
return f.fetchAs(ctx, source, source, target)
}
// fetchAs downloads one URL but reports progress under another name.
//
// The two differ for a set: the shards waiting on it asked for the version, not
// for the fourth file in it, and progress reported under the file's own URL
// reaches nobody. What they see is one number climbing across the whole set.
func (f *Fetcher) fetchAs(ctx context.Context, source, reportAs, target string) (string, error) {
url, err := resolveURL(source)
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return "", err
}
// Written beside the target and renamed at the end. A partial file under
// the real name would be found by the next scan, reported as present, and
// handed to an engine that fails on it minutes later — the exact failure
// this whole area exists to prevent.
part := target + ".part"
existing := int64(0)
if st, err := os.Stat(part); err == nil {
existing = st.Size()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
if existing > 0 {
// Resume. A forty gigabyte fetch that fails at ninety percent must not
// start again from nothing.
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", existing))
}
client := f.Client
if client == nil {
client = &http.Client{}
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("fetching %s: %w", source, err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
existing = 0 // the server ignored the range; start over
case http.StatusPartialContent:
default:
return "", fmt.Errorf("fetching %s: %s returned %s", source, url, resp.Status)
}
flags := os.O_CREATE | os.O_WRONLY
if existing > 0 {
flags |= os.O_APPEND
} else {
flags |= os.O_TRUNC
}
out, err := os.OpenFile(part, flags, 0o644)
if err != nil {
return "", err
}
total := existing + resp.ContentLength
f.report(Progress{Source: reportAs, BytesDone: existing, BytesTotal: total})
written, err := f.copyWithProgress(ctx, out, resp.Body, reportAs, existing, total)
closeErr := out.Close()
if err != nil {
// The part file is kept on purpose, so the next attempt resumes.
return "", fmt.Errorf("fetching %s after %d bytes: %w", source, written, err)
}
if closeErr != nil {
return "", closeErr
}
if err := os.Rename(part, target); err != nil {
return "", err
}
if f.Log != nil {
f.Log.Info("weights fetched", "source", source, "path", target, "bytes", written)
}
return target, nil
}
func (f *Fetcher) copyWithProgress(ctx context.Context, dst io.Writer, src io.Reader,
source string, done, total int64,
) (int64, error) {
buf := make([]byte, 4<<20)
last := time.Now()
for {
if err := ctx.Err(); err != nil {
return done, err
}
n, err := src.Read(buf)
if n > 0 {
if _, werr := dst.Write(buf[:n]); werr != nil {
return done, werr
}
done += int64(n)
// Occasionally, not per read: a four-megabyte buffer over a
// hundred gigabytes is twenty-five thousand callbacks, and nobody
// needs to be told that often.
if time.Since(last) > 2*time.Second {
f.report(Progress{Source: source, BytesDone: done, BytesTotal: total})
last = time.Now()
}
}
if errors.Is(err, io.EOF) {
f.report(Progress{Source: source, BytesDone: done, BytesTotal: total})
return done, nil
}
if err != nil {
return done, err
}
}
}
func (f *Fetcher) report(p Progress) {
if f.OnProgress != nil {
f.OnProgress(p)
}
}
// cachePath is where a remote source lands.
//
// Named by a hash of the source with the original filename kept on the end, so
// two models with the same filename from different places cannot collide while
// a human looking at the directory can still tell what is in it.
func (f *Fetcher) cachePath(source string) (string, error) {
if f.CacheDir == "" {
return "", fmt.Errorf("no cache directory configured, so %s cannot be fetched", source)
}
return CachePathFor(f.CacheDir, source), nil
}
// isPath reports whether a source names a file on this machine.
//
// By its leading character, because that is the only honest signal available:
// "Qwen/Qwen2.5-7B-Instruct" and "models/qwen.gguf" are indistinguishable as
// strings, and an operator writing a local file writes an absolute path. The
// cost of being wrong in this direction is that a relative path is passed to
// the engine untouched, which is what happened before this code existed.
func isPath(source string) bool {
return strings.HasPrefix(source, "/") ||
strings.HasPrefix(source, "./") ||
strings.HasPrefix(source, "../")
}
// isRemote reports whether a source has to be fetched at all.
func isRemote(source string) bool {
return strings.HasPrefix(source, "hf:") ||
strings.HasPrefix(source, "http://") ||
strings.HasPrefix(source, "https://")
}
// resolveURL turns a source into something to GET.
//
// hf: names one file in a repository, which is what a GGUF is. A whole
// repository of shards is deliberately not handled here: vLLM and SGLang
// download those themselves and do it well, and reimplementing a snapshot
// downloader to compete with them would be work spent to arrive back where we
// started.
func resolveURL(source string) (string, error) {
if rest, ok := strings.CutPrefix(source, "hf:"); ok {
parts := strings.Split(rest, "/")
if len(parts) < 3 {
return "", fmt.Errorf(
"%s names a repository, not a file; give the file as hf:org/repo/model.gguf, "+
"or use the repository id directly and let the engine download it", source)
}
repo := strings.Join(parts[:2], "/")
file := strings.Join(parts[2:], "/")
return "https://huggingface.co/" + repo + "/resolve/main/" + file, nil
}
if _, err := url.Parse(source); err != nil {
return "", fmt.Errorf("%s is not a usable source: %w", source, err)
}
return source, nil
}