diff --git a/openspec/changes/add-hf-outfit-command/.openspec.yaml b/openspec/changes/add-hf-outfit-command/.openspec.yaml new file mode 100644 index 00000000..d7bc0110 --- /dev/null +++ b/openspec/changes/add-hf-outfit-command/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-10 diff --git a/openspec/changes/add-hf-outfit-command/design.md b/openspec/changes/add-hf-outfit-command/design.md new file mode 100644 index 00000000..71c08a1b --- /dev/null +++ b/openspec/changes/add-hf-outfit-command/design.md @@ -0,0 +1,225 @@ +## Context + +See proposal.md — Why. The constraints that shape the approach: + +- **outfit has almost nothing to install.** Three direct module dependencies + today (`hujson`, `yaml.v3`, `aws-sdk-go-v2`), and the AWS SDK is called out in + AGENTS.md as "the repo's only AWS/network dependency". Anything added here is + measured against that. +- **`internal/discovery` already sets the pattern for outbound HTTP**: a small + client, a bounded timeout, an in-process cache, and failures that never spew. + Hub access should look like its sibling, not like a new subsystem. +- **The pieces the command produces already exist.** `outfit.Selection` and + `outfit.Format` render an Outfit; `applySelection` writes one to a harness; + `contextsize.Parse` reads `128k`. This change is a resolver feeding parts that + are already built. +- **The engines already download.** `llama-server -hf`, vLLM and mlx all fetch + their own weights on first use. There is no gap to fill there, only a second + copy to avoid. + +## Goals / Non-Goals + +**Goals:** + +- One command from a pasted model reference to a working Outfit, with every + inference visible and individually overridable. +- Use the Hugging Face cache that is already on the machine — for the weights + and, when possible, for the metadata, so a downloaded model resolves offline. +- Keep the dependency footprint where it is. + +**Non-Goals:** + +- Downloading weights (see the spec — `outfit hf` never transfers a weights + file). No `--pull`, no progress bars, no resumable transfers. +- Writing the Hugging Face cache in any way. Reading it is a stable, documented + layout; writing it correctly means blob dedup, symlinks, locking and etag + bookkeeping, and there is nothing to gain from owning that. +- Searching or browsing the Hub (`outfit hf --search qwen`). A reference is + something you paste, and the model page is a better browser than a terminal. +- Datasets, spaces, adapters, or non-model repo types. +- Teaching the catalogue about Hugging Face. `providers.yaml` stays plumbing + only; nothing here adds a provider. + +## Decisions + +### A new `outfit hf` command rather than `--hf` on `add`/`apply` + +The output of this work is a *file* — a reproducible description of a model +choice — and the existing commands take a selection rather than produce one. +Making it a separate command also keeps its flag set honest: `-q`, `--no-cache` +and `--output-file` are meaningless on `add`. + +`--apply` closes the one-liner gap by routing the resolved `Selection` through +the same `applySelection` that `add` and `apply` use, so there is one code path +that dresses a harness, not two. + +*Alternative considered:* `outfit add --hf `. Rejected because it produces +no artefact — the user ends up running `outfit export` to get back the file the +resolver already had in hand. + +### `-o` means `--output-file` here, and there is no output-tokens flag + +Every other command spells `-o` as `--output` (max output tokens). On `hf` it +names the file to write, as requested. To make that unambiguous rather than +merely inconsistent, `outfit hf` **omits an output-tokens flag entirely**: the +Outfit it writes carries no `OUTPUT` line, and applying one defaults output to a +quarter of the context (`contextsize.DefaultOutput`), which is what an +unspecified `OUTPUT` already means. There is therefore no reading of `-o` on +this command that silently does the other thing. The docs page states the +difference explicitly. + +### A small in-repo Hub client, not a Go SDK + +The closest thing to a Go SDK is `github.com/gomlx/go-huggingface` +(Apache-2.0), which does share the Python cache layout. Its module requires +`gomlx/gomlx`, `gomlx/compute`, `parquet-go`, `protobuf`, `go-sentencepiece`, +`lipgloss` and `klog` — a machine-learning framework's dependency graph, taken +on for a CLI that reads two JSON endpoints and lists a directory. The other +candidates (`bodaay/HuggingFaceModelDownloader`, `cozy-creator/hf-hub`, +`sgl-project/ome`) are downloader-shaped: their value is the write path this +change explicitly does not want. + +`internal/hf` is therefore stdlib-only, importing nothing of ours except +`internal/contextsize`. What it needs: + +- `GET {endpoint}/api/models/{repo}/revision/{rev}` — `siblings[].rfilename` + for the file list, plus `tags`, `library_name` and `sha`. +- `GET {endpoint}/{repo}/resolve/{rev}/config.json` — the model's declared + window. +- The cache layout: `models--{org}--{name}/refs/{rev}` holds the commit sha; + `snapshots/{sha}/{path}` is a symlink into `blobs/`. + +If the write path is ever wanted, the package boundary is where a library would +slot in without the command changing. + +### The cache is read through injected roots, never a package-level default + +`internal/hf` resolves cache roots in one place (`HF_HUB_CACHE`, else +`$HF_HOME/hub`, else `~/.cache/huggingface/hub`; `LLAMA_CACHE`, else the +platform cache dir) and every lookup takes them as arguments. That is not +tidiness: `internal/config`'s alias registry has the same rule, for the same +reason — a test that silently reads the developer's real cache passes on their +machine and fails in CI, or worse, the reverse. Tests point the roots at temp +dirs; nothing reaches `$HOME` unless the caller asked for it. + +A snapshot entry counts as cached only when the symlink resolves to a readable +regular file. An interrupted download leaves either a `.incomplete` blob or a +dangling snapshot link, and both must read as "not cached" — the same rule the +`model-weights` capability already applies to the cloud's fetch marker. + +### llama.cpp's cache is matched loosely, and a miss is free + +`llama-server -hf` writes into `LLAMA_CACHE` (else its platform cache dir), not +the Hugging Face cache, so a model downloaded by `serve` is invisible to the HF +layout and vice versa. Its filename convention is not a documented contract, so +the lookup is a case-insensitive scan of that directory for a `.gguf` whose name +carries the repo's owner, name and the chosen quant. A false negative costs +nothing — the Outfit falls back to the repo reference and `llama-server` finds +its own cached copy anyway. A false positive is what must not happen, hence +requiring all three parts to match. + +### Quantisation: parse the filenames, prefer a mid-sized K-quant + +Quant names live in GGUF filenames (`…-Q4_K_M.gguf`, +`…-UD-Q4_K_XL.gguf`, `…-Q4_K_M-00001-of-00003.gguf`) and sometimes in a +directory level (`Q4_K_M/…gguf`). The parser strips the extension and any +`-000NN-of-000NN` shard suffix, then matches a quant token +(`IQ*`/`Q*_*`/`UD-*`/`F16`/`BF16`/`F32`/`MXFP4`), preferring a directory name +when the repo uses one. Files under one quant name group into a single choice. + +The default preference order is an explicit, documented list — `Q4_K_M`, +`UD-Q4_K_XL`, `Q4_K_S`, `Q5_K_M`, `Q6_K`, `Q8_0` — falling back to the smallest +non-full-precision group, with the name as tie-break so the choice is stable. +Roughly 4-bit is the size that fits the machines people run local models on; +the narration lists what else was there, so the default never has to be right, +only defensible. + +### Provider inference from files and library, in that order + +- any `.gguf` → `llamacpp` +- else `library_name` is `mlx`, or the repo carries an `mlx` tag → `omlx` +- else any `.safetensors` → `vllm` +- else fail, naming what the repo appears to hold + +Files come before tags because a repo's tags are freely edited and its files are +not. `-p` overrides the whole chain and is not validated against the repo's +contents beyond existing in the catalogue. + +### `MODEL` is written in each engine's own vocabulary + +| Engine | Cached | Not cached | +| --- | --- | --- | +| `llamacpp` | path to the (first) `.gguf` on disk | `org/model:QUANT` | +| `omlx` | `org/model` | `org/model` | +| `vllm` | `org/model` | `org/model` | + +`llama-server` given the first shard of a split GGUF loads the rest itself, so a +path is safe for sharded quants. oMLX serves a whole model directory and picks +per request, and vLLM resolves repo ids through the HF cache on its own, so +neither gains from a path — and `local-serving` already says `MODEL` keeps its +harness-facing meaning for oMLX. + +`--no-cache` forces the right-hand column. A path makes an Outfit +machine-specific, and Outfits get committed (`remote/Outfit` is, deliberately), +so the escape hatch is a flag rather than a comment in the docs. + +### Context comes from the config, or not at all + +`max_position_embeddings` (falling back to `text_config.max_position_embeddings` +for multimodal configs) is read from the repo's `config.json`, which GGUF and +MLX repos generally republish. Nothing is derived from the GGUF header — that +would mean a ranged read of a weights file, which the spec forbids for good +reason. No window declared means no `CONTEXT` line, matching `export`'s existing +refusal to invent one. + +The declared maximum is written as-is even when it is large. It is a published +fact rather than a guess, the narration shows it, and `-c` overrides it; picking +a "sensible" smaller number would be outfit inventing a policy it cannot justify +per machine. + +### stdout is the artefact, stderr is the reasoning + +`outfit hf > Outfit` has to produce a clean file, so every explanation — +provider and why, quant chosen and alternatives, context and its source, cache +hit and which cache — goes to stderr. This matches `export` (pure stdout) and +`serve` (which narrates before it runs). + +## Risks / Trade-offs + +**A cached-path `MODEL` is not portable** → the narration says so at the moment +it happens, and `--no-cache` produces the shareable form. The default favours +the common case: an Outfit in a working directory, on the machine that has the +model. + +**Hub API responses could change shape** → only three fields are relied on +(`siblings[].rfilename`, `library_name`/`tags`, `sha`), all long-standing. +Unparseable responses fail with a message naming the repo and the endpoint, and +the cache path keeps working regardless. + +**llama.cpp's cache filenames are a convention, not a contract** → matching is +deliberately conservative and a miss degrades to the repo reference, which is +what the command would have written anyway. + +**The default quant may not be what the user wanted** → it is named on stderr +with the alternatives beside it, and both `:QUANT` and `-q` override it. A wrong +default costs one re-run, not a download. + +**A second outbound host** → `huggingface.co` joins the provider endpoints +`discovery` already contacts. Same discipline: bounded timeout, no retries, no +credentials sent unless a token was explicitly configured, nothing logged. + +**Test suites that read a real cache** → all cache roots are injected, and the +test helpers set `HF_ENDPOINT`, `HF_HUB_CACHE` and `LLAMA_CACHE` to temp +locations. No test may depend on what the developer has downloaded. + +## Migration Plan + +Purely additive: a new command, a new leaf package, no change to the Outfit +format, the catalogue, or any harness adapter. Nothing to migrate and nothing to +roll back beyond reverting the commit. + +## Open Questions + +- Whether a later change should add `--pull` (fetch into the HF cache through + outfit) or `outfit hf list` (what is already cached). Both sit on top of this + package without altering it, and neither is needed to make the command useful. diff --git a/openspec/changes/add-hf-outfit-command/proposal.md b/openspec/changes/add-hf-outfit-command/proposal.md new file mode 100644 index 00000000..a411c490 --- /dev/null +++ b/openspec/changes/add-hf-outfit-command/proposal.md @@ -0,0 +1,92 @@ +## Why + +Hugging Face is where local models actually come from, but `outfit` knows +nothing about it. Writing an Outfit for one is a manual research job: open the +model page, work out whether it is GGUF, MLX or safetensors (which decides the +`PROVIDER`), scroll the file list for the quantisation you want, find +`max_position_embeddings` in `config.json` for the `CONTEXT`, and invent an +`ALIAS`. Get any of it wrong and the first thing you learn is that `serve` +fails, or that a 40 GB download has started for a quant you did not want. + +Every fact needed to write that file is already published by the Hub — and for +a model you have downloaded before, it is already on your disk in the Hugging +Face cache. This change reads both and writes the Outfit for you. + +## What Changes + +- **`outfit hf `**: a new command that turns a Hugging Face model + reference into an Outfit. It inspects the repo, infers the `PROVIDER`, + `MODEL`, `ALIAS` and `CONTEXT`, prints the file to stdout, and says what it + inferred and why on stderr. +- **References are written the way people paste them**: `org/model`, + `org/model:QUANT`, `hf.co/org/model`, a full `https://huggingface.co/...` + URL (including a `/tree/` or `/blob//` suffix), and an + explicit `@revision`. +- **The provider is inferred from the repo's files**: `*.gguf` means + `llamacpp`, an MLX repo means `omlx`, plain safetensors means `vllm`. + `--provider`/`-p` overrides it, and a repo that fits none of them fails + saying so rather than guessing. +- **Quantisation selection**: for a GGUF repo, the `:QUANT` suffix (or + `--quant`/`-q`) picks the file; with neither, a preference order picks a + sensible default and the alternatives are listed. An ambiguous or unmatched + quant fails listing what the repo actually offers. +- **The existing Hugging Face cache is used, never bypassed**: `outfit hf` + looks the model up in the local HF cache (`HF_HOME`/`HF_HUB_CACHE`, else + `~/.cache/huggingface/hub`) and in llama.cpp's own cache. A cached GGUF + becomes a `MODEL` pointing at the file on disk, so the engine does not + download a second copy; anything already cached also means the metadata can + be read locally, so a fully cached model resolves with no network at all. +- **Optional Hugging Face token**: a token is resolved from `HF_TOKEN`, then + `HUGGING_FACE_HUB_TOKEN`, then the CLI's stored token file, and sent as a + bearer only when one exists — so gated and private repos work for a logged-in + user, and everyone else is unaffected. The token is never written to disk or + printed. +- **`--output-file`/`-o` writes the Outfit** instead of printing it, refusing + to overwrite an existing file without `--force`. +- **`--apply` dresses the harness straight away**, going through the same path + `outfit apply` uses, so `outfit hf --apply` is the one-liner from model + page to working agent. +- **No weights are downloaded by `outfit`.** It reads the cache and the Hub's + metadata; fetching weights stays the engine's job, as it is today. +- **No new module dependency**: the Hub client is a small leaf package using + the two JSON endpoints and the documented cache layout, in keeping with a CLI + whose whole point is having nothing to install. + +## Capabilities + +### New Capabilities + +- `huggingface-hub`: reading Hugging Face — parsing a model reference in its + several written forms, fetching repo metadata and `config.json` over the + Hub's API, resolving an optional token, locating an already-cached repo in + the HF and llama.cpp caches, and the bounded, quiet failure behaviour that + keeps a slow or unreachable Hub from hanging or breaking a command. +- `huggingface-outfits`: the `outfit hf` command — how a resolved repo becomes + a `Selection` (provider inference, quantisation choice, context and alias + derivation, cached-file preference), the flags that override each inference, + and how the result is printed, written or applied. + +### Modified Capabilities + +_None._ The Outfit file format, `apply`, `serve` and completion all consume the +result exactly as they already do: `outfit hf` emits an ordinary Outfit, and a +cached-file `MODEL` is the local-path form `serve` already understands. + +## Impact + +- New package `internal/hf`: reference parsing, the Hub metadata client, token + resolution, cache lookup, and the inference rules that turn a repo into a + `Selection`. A leaf package — stdlib only, importing nothing of ours but + `internal/contextsize`. +- New `cmd/outfit/hf.go` holding the command and its flag set, kept out of + `main.go` for the same reason `serve.go` is (the dispatch-coverage scan reads + `main.go`'s switch). +- `cmd/outfit/complete.go` gains the command, its flags and provider-name + completion for `-p`; `TestCompletionCoversDispatch` requires it. +- Docs: a new `docs/commands/hf.md`, entries in `docs/README.md` and + `docs/env-vars.md` (`HF_TOKEN`, `HF_HOME`, `HF_HUB_CACHE`, `LLAMA_CACHE`), + a README quickstart line, and an `AGENTS.md` layout entry. +- Network: a second outbound host beyond the AWS control plane and provider + endpoints — `huggingface.co`, over the same best-effort, timeout-bounded + discipline `internal/discovery` already follows. +- No change to any harness adapter, to the catalogue, or to the Outfit format. diff --git a/openspec/changes/add-hf-outfit-command/specs/huggingface-hub/spec.md b/openspec/changes/add-hf-outfit-command/specs/huggingface-hub/spec.md new file mode 100644 index 00000000..5457a3bf --- /dev/null +++ b/openspec/changes/add-hf-outfit-command/specs/huggingface-hub/spec.md @@ -0,0 +1,156 @@ +## Purpose + +Define how outfit reads Hugging Face: the reference forms it accepts, the repo +metadata it fetches, the optional token it sends, the already-downloaded copies +it finds in the local caches, and the bounded, explained failures that keep a +slow or unreachable Hub from hanging a command. + +## ADDED Requirements + +### Requirement: Model reference forms + +A Hugging Face model reference SHALL be accepted in every form a user is likely +to paste: a bare `org/model`, a `hf.co/` or `huggingface.co/` prefixed form, a +full `https://huggingface.co/org/model` URL including a trailing `/tree/` +or `/blob//` path, and any of those with llama.cpp's `:QUANT` +suffix or an `@` suffix. Parsing SHALL yield the repo id, the +revision (defaulting to `main`), and the quantisation or file when one was +named. A reference naming no organisation, or carrying both a `@revision` and a +`/tree/` that disagree, SHALL fail naming what was wrong with it. + +#### Scenario: A pasted model page URL + +- **WHEN** `https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF/tree/main` is + parsed +- **THEN** it yields repo `unsloth/Qwen3.6-35B-A3B-GGUF` at revision `main` + +#### Scenario: A quantisation suffix + +- **WHEN** `unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q4_K_XL` is parsed +- **THEN** it yields that repo with the quantisation `UD-Q4_K_XL` + +#### Scenario: A reference that names no repo + +- **WHEN** `qwen3.6` is parsed +- **THEN** it fails saying a reference is `org/model`, and shows an example + +### Requirement: Reading a repo from the Hub + +The system SHALL read a repo's published metadata over the Hub's API: the list +of files it holds, its tags and library, and its `config.json` where one +exists. The endpoint SHALL be overridable by the conventional `HF_ENDPOINT` +environment variable so a mirror or a private deployment can be used. Every +request SHALL carry a bounded timeout, so an unreachable or slow Hub cannot +hang a command. + +Metadata SHALL be read without downloading any weights: a repo's file list and +`config.json` are enough to describe it, and reading a multi-gigabyte file to +learn what it is SHALL never happen. + +#### Scenario: A repo's files and config are read + +- **WHEN** metadata is read for a repo that publishes `config.json` +- **THEN** the file list and the config's contents are available, and no + weights file was fetched + +#### Scenario: A mirror endpoint is honoured + +- **WHEN** `HF_ENDPOINT` names another host +- **THEN** metadata requests go to that host rather than `huggingface.co` + +#### Scenario: An unreachable Hub does not hang + +- **WHEN** the endpoint does not respond within the timeout +- **THEN** the read is abandoned and reported as a failure to reach the Hub + +### Requirement: Optional Hugging Face token + +A token SHALL be resolved, when one exists, from `HF_TOKEN`, then +`HUGGING_FACE_HUB_TOKEN`, then the token file the Hugging Face CLI writes under +its home directory. A resolved token SHALL be sent as the request's bearer +credential; with no token, requests SHALL be made unauthenticated and public +repos SHALL work exactly as before. A token SHALL NOT be written to any file +outfit produces, printed, or included in narration. + +Where a repo is unreadable because it is private or gated, the failure SHALL +say which — and, when no token was resolved, SHALL say that setting `HF_TOKEN` +or logging in with the Hugging Face CLI is what makes it readable — rather than +reporting a bare HTTP status. + +#### Scenario: No token is needed for a public repo + +- **WHEN** metadata is read for a public repo with no token set anywhere +- **THEN** the request is made unauthenticated and succeeds + +#### Scenario: A stored token is used + +- **WHEN** no token variable is set but the Hugging Face CLI's token file holds + one +- **THEN** that token is sent as the request's bearer credential + +#### Scenario: A gated repo without a token + +- **WHEN** a gated repo is read and no token was resolved +- **THEN** the failure says the repo is gated and how to authenticate + +#### Scenario: The token stays out of everything written + +- **WHEN** a command that resolved a token produces its output +- **THEN** neither the output, the written file, nor the narration contains it + +### Requirement: Finding an already-downloaded copy + +Before anything is fetched, the local caches SHALL be consulted for the +reference: the Hugging Face cache (`HF_HUB_CACHE`, else `HF_HOME`'s hub +directory, else the conventional `~/.cache/huggingface/hub`) in the layout the +Hugging Face libraries write, and llama.cpp's own model cache (`LLAMA_CACHE`, +else its conventional location) which is where `llama-server` puts what it +downloads itself. A file found SHALL be reported with its path on disk. + +A cache entry SHALL count only when the file it names is genuinely present and +readable — a snapshot entry whose stored content is missing, and a +partly-downloaded file, SHALL be treated as not cached, so an interrupted +download is never mistaken for a model that is ready. + +Where the cache holds the repo's metadata too, it SHALL be read from there, so +a model that is already downloaded resolves without a network request at all. + +#### Scenario: A downloaded model is found + +- **WHEN** the reference names a repo whose file is already in the Hugging Face + cache +- **THEN** the file's path on disk is reported + +#### Scenario: llama.cpp's own cache counts + +- **WHEN** the model was downloaded by `llama-server` rather than by a Hugging + Face tool +- **THEN** it is found in llama.cpp's cache and its path reported + +#### Scenario: An interrupted download is not a cached model + +- **WHEN** the cache holds only a partial file for the reference +- **THEN** the reference is reported as not cached + +#### Scenario: A cached model needs no network + +- **WHEN** the reference is fully present in the cache, including its metadata +- **THEN** it resolves with no request to the Hub + +### Requirement: Failures name what went wrong + +Reading Hugging Face SHALL fail with a message naming the cause and the repo: +a repo that does not exist, a repo that exists but is gated or private, a +network or timeout failure, and a response that cannot be understood SHALL each +be distinguishable. No failure SHALL surface as a bare status code or a raw +transport error. + +#### Scenario: A repo that does not exist + +- **WHEN** metadata is read for a repo the Hub does not have +- **THEN** the failure names the repo and says it was not found + +#### Scenario: Offline + +- **WHEN** metadata is read with no network available and nothing cached +- **THEN** the failure says the Hub could not be reached, naming the endpoint diff --git a/openspec/changes/add-hf-outfit-command/specs/huggingface-outfits/spec.md b/openspec/changes/add-hf-outfit-command/specs/huggingface-outfits/spec.md new file mode 100644 index 00000000..8656204c --- /dev/null +++ b/openspec/changes/add-hf-outfit-command/specs/huggingface-outfits/spec.md @@ -0,0 +1,221 @@ +## Purpose + +Define `outfit hf`: turning a Hugging Face model reference into a working +Outfit — inferring the engine, the quantisation, the context window and the +name from what the repo actually holds, preferring a copy already on disk, and +printing, writing or applying the result. + +## ADDED Requirements + +### Requirement: Creating an Outfit from a reference + +`outfit hf ` SHALL read the named Hugging Face model, derive a provider +selection from it, and render it as an Outfit on stdout. Rendered output SHALL +be the same canonical form `outfit export` produces, so `outfit hf > +Outfit` yields a file every other command accepts. A missing reference SHALL +fail showing the command's usage. + +What was inferred, and from what, SHALL be reported on stderr — the provider +and why, the quantisation chosen and the alternatives available, the context +window and where it came from, and whether a local copy was used — so stdout +stays a clean Outfit while the reasoning is still visible. + +#### Scenario: A reference becomes an Outfit + +- **WHEN** the user runs `outfit hf unsloth/Qwen3.6-35B-A3B-GGUF` +- **THEN** an Outfit naming a provider, model, alias and context is printed on + stdout, and the reasoning is printed on stderr + +#### Scenario: Redirecting produces a usable file + +- **WHEN** the printed output is redirected to `./Outfit` and `outfit apply` is + run +- **THEN** the harness is configured from it with no editing + +#### Scenario: No reference + +- **WHEN** the user runs `outfit hf` with no argument +- **THEN** it fails showing how the command is called + +### Requirement: The provider is inferred from the repo + +The `PROVIDER` SHALL be inferred from what the repo holds: a repo publishing +GGUF files SHALL yield `llamacpp`; a repo published for MLX SHALL yield `omlx`; +a repo publishing plain safetensors weights SHALL yield `vllm`. A repo matching +none of these SHALL fail saying what it appears to hold and which providers +`outfit hf` can infer, rather than guessing one. + +`--provider`/`-p` SHALL override the inference, and SHALL be accepted even when +inference would have chosen otherwise, so a repo carrying both GGUF and +safetensors can be pointed at either engine. A `-p` naming a provider that is +not a self-hosted engine SHALL still be honoured — the Outfit describes what a +harness talks to, and only `serve` requires a local engine. + +#### Scenario: A GGUF repo + +- **WHEN** the reference names a repo whose files are GGUF +- **THEN** the Outfit says `PROVIDER llamacpp` + +#### Scenario: An MLX repo + +- **WHEN** the reference names a repo published for MLX +- **THEN** the Outfit says `PROVIDER omlx` + +#### Scenario: A safetensors repo + +- **WHEN** the reference names a repo of plain safetensors weights +- **THEN** the Outfit says `PROVIDER vllm` + +#### Scenario: A repo that is not a model + +- **WHEN** the reference names a repo holding none of these +- **THEN** the command fails saying what it found and which providers can be + inferred + +#### Scenario: Overriding the inference + +- **WHEN** the user passes `-p vllm` for a repo carrying both GGUF and + safetensors files +- **THEN** the Outfit says `PROVIDER vllm` + +### Requirement: Choosing a quantisation + +For a repo offering several quantisations, the one served SHALL be chosen in +this order: the reference's own `:QUANT` suffix, then `--quant`/`-q`, then a +deterministic, documented preference order favouring a mid-sized K-quant. The +chosen quantisation SHALL be named in the narration alongside the alternatives +the repo offers, so the default can be seen and overridden. + +A named quantisation that the repo does not offer SHALL fail listing the ones +it does, matched case-insensitively so `q4_k_m` finds `Q4_K_M`. A +quantisation split across several files SHALL be treated as one choice, and the +`MODEL` written for it SHALL be the form the engine loads the whole set from, +never a single shard in isolation. + +#### Scenario: A default is chosen and explained + +- **WHEN** a repo offers `Q4_K_M` and `Q8_0` and neither a suffix nor `-q` was + given +- **THEN** `Q4_K_M` is chosen, and the narration names it and lists `Q8_0` as + an alternative + +#### Scenario: An explicit quantisation wins + +- **WHEN** the reference ends `:Q8_0` +- **THEN** the Outfit's `MODEL` names that quantisation + +#### Scenario: A quantisation the repo does not have + +- **WHEN** the reference ends `:Q3_K_XXL` and the repo has no such file +- **THEN** the command fails listing the quantisations the repo offers + +#### Scenario: A sharded quantisation + +- **WHEN** the chosen quantisation is published as several numbered shards +- **THEN** the `MODEL` written loads the whole set + +### Requirement: Context and name are derived, never invented + +The `CONTEXT` SHALL be the window the model itself declares in its published +configuration, and `--context`/`-c` SHALL override it, parsed by the same +lenient size format every other command uses. Where the model declares no +window, `CONTEXT` SHALL be omitted and the narration SHALL say so, rather than +a plausible number being invented. + +The `ALIAS` SHALL be a short, lower-cased name derived from the repo's own +name with a packaging suffix such as `-GGUF` or `-MLX` removed, and +`--alias`/`-a` SHALL override it. `OUTPUT` SHALL NOT be written: it already +defaults to a quarter of the context when an Outfit is applied. + +#### Scenario: The declared window is used + +- **WHEN** the model's configuration declares a 262144-token window +- **THEN** the Outfit says `CONTEXT 262144` + +#### Scenario: An overridden window + +- **WHEN** the user passes `-c 32k` +- **THEN** the Outfit says `CONTEXT 32000` whatever the model declares + +#### Scenario: No declared window + +- **WHEN** the model publishes no configuration stating a window +- **THEN** the Outfit has no `CONTEXT` line and the narration says why + +#### Scenario: The alias drops the packaging suffix + +- **WHEN** the repo is `unsloth/Qwen3.6-35B-A3B-GGUF` +- **THEN** the `ALIAS` is `qwen3.6-35b-a3b` + +### Requirement: A copy already on disk is preferred + +Where the chosen model is already in a local cache, the `MODEL` written SHALL +name the file on disk rather than the repo, so the engine loads what is there +instead of downloading a second copy, and the narration SHALL say which cache +it came from. Where it is not cached, the `MODEL` SHALL be the repo reference +in the form the engine understands, leaving the download to the engine as it +happens today. + +This SHALL apply only where naming a file is meaningful to the engine: for an +engine that loads a repo or a directory rather than a single weights file, the +`MODEL` SHALL stay the repo reference whether or not a copy is cached. + +Because a path names one machine's disk, `--no-cache` SHALL write the repo +reference even when a copy is cached, so an Outfit meant to be committed and +shared can be produced deliberately. The narration SHALL note when a path was +written for exactly this reason. + +`outfit hf` SHALL NOT download weights under any circumstances. Reading a repo +is metadata only, and a command that describes a model SHALL never begin a +multi-gigabyte transfer as a side effect. + +#### Scenario: A cached GGUF is named directly + +- **WHEN** the chosen quantisation is already in a local cache +- **THEN** the `MODEL` is that file's path, and the narration names the cache + +#### Scenario: An uncached model keeps the repo reference + +- **WHEN** nothing for the reference is cached +- **THEN** the `MODEL` is the repo reference, and nothing is downloaded + +#### Scenario: A portable Outfit is asked for + +- **WHEN** the chosen quantisation is cached and the user passes `--no-cache` +- **THEN** the `MODEL` is the repo reference rather than the local path + +#### Scenario: Describing a model never downloads it + +- **WHEN** `outfit hf` runs for a repo holding many gigabytes of weights +- **THEN** only metadata is fetched and no weights file is transferred + +### Requirement: Printing, writing and applying + +By default the Outfit SHALL be printed to stdout. `--output-file`/`-o` SHALL +write it to the named path instead, reporting where it went; an existing file +SHALL NOT be overwritten unless `--force` is given, so a hand-edited Outfit +cannot be lost to a mistyped command. `--apply` SHALL additionally configure +the active harness from the selection, by the same path `outfit apply` uses and +honouring `--harness`/`-H`, so one command goes from a model page to a dressed +agent. + +#### Scenario: Writing to a file + +- **WHEN** the user runs `outfit hf -o ./Outfit` and no such file exists +- **THEN** the Outfit is written there and the path is reported + +#### Scenario: An existing file is not clobbered + +- **WHEN** `-o` names a file that already exists and `--force` was not given +- **THEN** nothing is written and the command fails saying `--force` overwrites + +#### Scenario: Applying directly + +- **WHEN** the user runs `outfit hf --apply` +- **THEN** the active harness is configured exactly as applying the printed + Outfit would have configured it + +#### Scenario: Applying to a named harness + +- **WHEN** the user runs `outfit hf --apply --harness pi` +- **THEN** the Pi harness is configured rather than the active default diff --git a/openspec/changes/add-hf-outfit-command/tasks.md b/openspec/changes/add-hf-outfit-command/tasks.md new file mode 100644 index 00000000..1d9c7977 --- /dev/null +++ b/openspec/changes/add-hf-outfit-command/tasks.md @@ -0,0 +1,116 @@ +## 1. Reference parsing + +- [ ] 1.1 Create `internal/hf` (stdlib only, plus `internal/contextsize`) with a + `Ref` type holding repo owner, name, revision, and an optional quant or file. +- [ ] 1.2 Implement `ParseRef`: bare `org/model`, `hf.co/`/`huggingface.co/` + prefixes, full `https://huggingface.co/...` URLs with `/tree/` and + `/blob//`, `:QUANT` suffixes, and `@` suffixes. +- [ ] 1.3 Reject a reference naming no organisation, and one whose `@revision` + contradicts a `/tree/`, with messages naming what was wrong. +- [ ] 1.4 Table-driven tests covering every accepted form and each rejection. + +## 2. Hub client + +- [ ] 2.1 Resolve the endpoint from `HF_ENDPOINT`, defaulting to + `https://huggingface.co`; give the client a bounded timeout and no retries, + following `internal/discovery`'s shape. +- [ ] 2.2 Fetch repo metadata (`/api/models/{repo}/revision/{rev}`), reading + `siblings[].rfilename`, `tags`, `library_name` and `sha`. +- [ ] 2.3 Fetch `config.json` via `/{repo}/resolve/{rev}/config.json` and read + `max_position_embeddings`, falling back to + `text_config.max_position_embeddings`; a missing file or field means "no + declared window", not an error. +- [ ] 2.4 Resolve an optional token: `HF_TOKEN`, then `HUGGING_FACE_HUB_TOKEN`, + then `$HF_HOME/token` (else `~/.cache/huggingface/token`); send it as a bearer + only when one was found. +- [ ] 2.5 Map failures to distinct, named errors: not found, gated/private (with + the authenticate-or-set-`HF_TOKEN` hint when no token was resolved), rate + limited, unreachable/timed out, and unparseable — each naming the repo and, + for transport failures, the endpoint. +- [ ] 2.6 Tests against an `httptest` server with `HF_ENDPOINT` pointed at it: + metadata parsing, config parsing and its fallbacks, each failure mapping, the + bearer header sent only when a token resolves, and the token absent from every + produced value. + +## 3. Cache lookup + +- [ ] 3.1 Resolve cache roots in one function — `HF_HUB_CACHE`, else + `$HF_HOME/hub`, else `~/.cache/huggingface/hub`; `LLAMA_CACHE`, else the + platform cache dir — and pass them into every lookup as arguments rather than + reading the environment at the point of use. +- [ ] 3.2 Look a ref up in the Hugging Face cache: + `models--{owner}--{name}/refs/{rev}` for the sha, then + `snapshots/{sha}/{path}`; count an entry only when the link resolves to a + readable regular file, so a dangling link or a `.incomplete` blob reads as not + cached. +- [ ] 3.3 Read the cached `config.json` from the snapshot when present, so a + fully cached model resolves with no network request. +- [ ] 3.4 Look a chosen quant up in llama.cpp's cache: a case-insensitive scan + for a `.gguf` whose name carries the repo owner, the repo name and the quant — + all three, so a near-match never produces a wrong path. +- [ ] 3.5 Tests over temp-dir caches: a hit in each cache, a dangling snapshot + link, an `.incomplete` blob, a near-miss llama.cpp filename that must not + match, and the no-network path for a fully cached model. + +## 4. Inference rules + +- [ ] 4.1 Infer the provider: any `.gguf` → `llamacpp`; else `library_name`/tag + `mlx` → `omlx`; else any `.safetensors` → `vllm`; else an error naming what the + repo appears to hold and the providers that can be inferred. +- [ ] 4.2 Parse quant names from the file list: strip `.gguf` and any + `-000NN-of-000NN` shard suffix, match the quant token + (`IQ*`/`Q*_*`/`UD-*`/`F16`/`BF16`/`F32`/`MXFP4`), prefer a directory level + where the repo uses one, and group shards into one choice. +- [ ] 4.3 Select the quant: reference suffix, then `-q`, then the documented + preference order (`Q4_K_M`, `UD-Q4_K_XL`, `Q4_K_S`, `Q5_K_M`, `Q6_K`, `Q8_0`), + then the smallest non-full-precision group with the name as tie-break. Match + case-insensitively; an unmatched name fails listing what the repo offers. +- [ ] 4.4 Derive the alias from the repo name: lower-cased, with a trailing + `-GGUF`/`-MLX` packaging suffix removed. +- [ ] 4.5 Build the `outfit.Selection`: provider, the `MODEL` per the design's + engine table (cached path for `llamacpp`, repo reference otherwise), alias, + context from the declared window, and no `OUTPUT`. +- [ ] 4.6 Return the reasoning alongside the selection (provider and why, quant + chosen and alternatives, context and its source, cache hit and which cache) as + data, so the command formats it and tests assert on it. +- [ ] 4.7 Tests for each inference rule, including a repo carrying both GGUF and + safetensors, a sharded quant, a repo declaring no window, and a repo matching + no provider. + +## 5. The command + +- [ ] 5.1 Add `cmd/outfit/hf.go` with `cmdHF`: flags `-p/--provider`, + `-q/--quant`, `-c/--context`, `-a/--alias`, `-o/--output-file`, `--force`, + `--no-cache`, `--apply`, `-H/--harness`; no output-tokens flag. Register + `case "hf":` in `run`'s switch in `main.go` (the command body stays out of + `main.go` so the dispatch-coverage scan sees only that switch). +- [ ] 5.2 Render the selection with `outfit.Format` to stdout; write the + reasoning to stderr. +- [ ] 5.3 Implement `-o`: write the rendered Outfit to the path, report where it + went, and refuse to overwrite an existing file unless `--force` is given. +- [ ] 5.4 Implement `--apply`: route the resolved selection through + `applySelection`, honouring `--harness`/`-H`. +- [ ] 5.5 Fail with usage when no reference is given. +- [ ] 5.6 Tests in `cmd/outfit/hf_test.go` against a stub Hub and temp caches: + the printed Outfit round-trips through `outfit.Parse`, `-o` writes and refuses + to clobber, `--force` overwrites, `--apply` configures the harness, `--no-cache` + forces the repo form, and stdout stays free of narration. + +## 6. Completion, docs and checks + +- [ ] 6.1 Add `hf` to the `commands` table in `cmd/outfit/complete.go` with its + flags, provider-name candidates for `-p`, and a file directive for `-o`; check + `TestCompletionCoversDispatch` passes. +- [ ] 6.2 Write `docs/commands/hf.md`: the reference forms, each inference and + how to override it, the quant preference order, cache behaviour and + `--no-cache`, token setup, and an explicit note that `-o` here is the output + *file* (unlike `add`/`apply`, where it is max output tokens). +- [ ] 6.3 Link the new page from `docs/README.md`, document `HF_TOKEN`, + `HUGGING_FACE_HUB_TOKEN`, `HF_HOME`, `HF_HUB_CACHE`, `HF_ENDPOINT` and + `LLAMA_CACHE` in `docs/env-vars.md`, and add a quickstart line to the root + `README.md`. +- [ ] 6.4 Add an `internal/hf` entry and a `cmd/outfit/hf.go` note to AGENTS.md's + Layout, plus a Traps entry for the two caches being separate (a model + downloaded by `llama-server` is not in the Hugging Face cache, and vice versa). +- [ ] 6.5 Run `gofmt -w ./...`, `go vet ./...` and `go test ./... -cover`, + keeping total coverage at or above 80%.