Model Management Spec
- Summary
- Scope
- Routing Boundary
- Architecture
- ModelManager Facade
- Model Catalogs
- Catalog Locators
- Model Store
- Hardware Filtering
- Name Resolution
- Identity: Handles And Provenance
- Two Kinds Of Staleness
- Artifact Sources
- Fetchable Repositories
- Transfer And Layout
- Local Artifact Store
- Ingest And Registration
- Sync
- Change Notification
- Recipe Options
- Concurrency
- Source Layout
- Contracts
- Generated Docs
- Tests
Summary
This spec defines how lemond answers three questions: which models exist, what a model name refers to, and where a model's bytes are. It covers catalogs, the model store, name resolution, artifact sources and repositories, transfer, ingest, sync, change notification, recipe options, and the ModelManager that composes them and faces the HTTP layer.
The three goals that decide every design choice here:
- Two axes. Which models exist and where the bytes come from are separate questions with separate interfaces, joined by one value, the
ArtifactRef. Every component sits on one axis or the other. - Extension is local. A new catalog, artifact repository, or download source is one
.cppfile, one build line, one registration line, and one descriptor. It documents itself: Generated Docs fails CI when a registered source has no docs. - Clear separation of concerns. One function resolves a name, one site binds a checkpoint to a source, one component publishes the model set, one counter announces a new set. A caller never re-derives an answer, and no component branches on another's identity to get one.
Scope
This section lists what the design covers.
- Components covered. Catalogs and their locators, the model store and its published snapshot, name resolution and aliases, artifact sources and fetchable repositories, transfer and layout, the local artifact store, ingest, sync, change notification, recipe options persistence, and the
ModelManagercomposition root and facade. - Documentation. A catalog or repository documents itself through a descriptor, and CI fails when one is missing; see Generated Docs.
- Contracts held. The on-disk files, the API fields clients read, and the cache layout keep the meanings stated in Contracts.
- Not covered: routing. See Routing Boundary.
- Tests. See Tests.
Routing Boundary
This section states which questions model management answers and which belong to routing.
Model management answers what can run: the set of models, why one cannot run now, and the helper models the policies in force require. Routing answers what does run: policy parsing and evaluation, candidate scoring, load and unload decisions, residency, session affinity, and the runtime that holds loaded models.
| Question | Answered by | Where the answer lives |
|---|---|---|
| Which models exist, and which of them can this machine run now | model management | ModelStore, filter_hardware() |
| Why a model cannot run now | model management | StoreSnapshot::rejected |
| Which helper models the policies in force require | model management | StoreSnapshot::helper_models, projected during a store build |
| That a resident helper set needs reconciling | model management | Change Notification |
| The policy a model declares | routing, parsed from the recipe document | ModelInfo::route_policy, carried |
| Whether a policy accepts a request, and what to load for it | routing | the policy engine |
- Routing reads model management through
resolve(),model_exists(),get_public_model_name()andoptions().options()carries the architecture-defaults overlay, so routing reads no options file. ModelStorecarriesModelInfo::route_policywithout interpreting it. The only part it reads is the helper-name list it projects into the snapshot.- No component in this spec evaluates a policy expression or decides which model to load.
Architecture
This section shows the two axes, the components that sit on each, and the one direction every call goes.
%%{init: {"flowchart": {"curve": "linear"}}}%%
flowchart TD
CONS["Consumers<br/>route classes · gateways<br/>router · jobs · services"]
subgraph L1["Composition root and facade"]
FAC["ModelManager"]
end
subgraph L2["Use cases"]
ING["ModelIngest<br/>pull · register · import · remove"]
SYNC["SyncService<br/>scan · sync · cancel"]
end
subgraph L3["Domain services"]
ST["ModelStore<br/>StoreSnapshot<br/>immutable, atomic swap"]
RES["ArtifactSourceResolver<br/>the only site that picks a source"]
RO["RecipeOptionsStore"]
end
subgraph L4["Shared plumbing"]
ALI["AliasManager"]
NOT["ModelChangeNotifier<br/>models changed · model updated"]
end
subgraph A1["Axis 1: which models exist?"]
CAT["ModelCatalog<br/>JsonCatalog · EnumeratedCatalog<br/>ListedCatalog"]
LOC["CatalogLocator<br/>File · Http"]
CAT --> LOC
end
subgraph A2["Axis 2: where are the bytes?"]
AS["ArtifactSource, one per checkpoint<br/>resolve · present · acquisition<br/>deletion · ref"]
FR["FetchableRepository — an ArtifactSource<br/>list · file_url · compare<br/>commit · can_go_stale"]
TE["ArtifactTransfer<br/>HttpFileTransfer · LocalCopyFileTransfer"]
LA["ArtifactLayout"]
ART["ModelArtifactStore"]
TE --> LA
TE --> ART
end
CONS --> FAC
FAC --> ING
FAC --> SYNC
FAC --> ST
FAC --> RES
FAC --> RO
FAC --> ALI
FAC --> NOT
ING --> ST
ING --> RES
ING --> TE
ING --> ART
ING --> NOT
SYNC --> ST
SYNC --> FR
SYNC --> NOT
ST -->|"enumerate() →<br/>CatalogEntry + ArtifactRef"| CAT
ST --> ALI
ST --> NOT
RES --> AS
RES --> LA
ModelManager is the composition root: from one ModelManagerConfig it builds, in this order, the change notifier, the alias manager, the recipe-options store, the artifact store, the transfer mechanism, the catalog set, the store, the artifact resolver, the ingest use case and the sync service. Server fills in the config — cache directory, config directory, extra-models directory, the installed-backend availability probe, the cloud provider registry — constructs the facade once, and hands it to routes, gateways and routing. A test or an embedder builds the same facade over fixture paths and fixture catalogs, which is the one injection point model management offers.
Catalogs and artifact sources stay separate types because a catalog entry pointing at Qwen on Hugging Face resolves to two different objects: the document that names the model, and the repository hosting its bytes. ArtifactRef is the join — the value a catalog emits and a source consumes. For an imported directory one class may implement both interfaces; the types stay split because that is the exception.
ModelManager Facade
This section defines the single model-facing interface the HTTP layer, the gateways and the router use.
ModelManager builds the components and owns none of their logic. It exists so orchestration has one owner instead of a route per feature: every step a route would otherwise write by hand — resolving a name, expanding alias rows for a listing, aggregating checkpoint status, sequencing a pull, projecting helper models — is one facade call.
struct ModelManagerConfig {
std::string cache_dir; // empty: whatever utils::get_cache_dir() resolves to
std::string extra_models_dir;
CloudProviderRegistry* cloud_providers = nullptr; // listings for ListedCatalog
std::function<std::string()> default_model_source; // registry a bare checkpoint lands on
std::function<json()> model_catalogs; // configured catalog documents
std::vector<std::shared_ptr<ModelCatalog>> extra_catalogs; // tests and embedders
// What this machine can run, read at the start of every rebuild; empty probes
// the hardware. A caller that means a particular machine says so here.
std::function<BackendAvailability(const std::map<std::string, ModelInfo>&)> availability;
// The single seam that picks the repository answering for a checkpoint, threaded
// unchanged into every consumer: pull, update scan, artifact sources and the
// checkpoint-status read alike. Empty binds the real host.
std::function<std::unique_ptr<FetchableRepository>(const ArtifactRef&)> repository_for;
};
The config is the injection point. There is no other door: the facade declares no
test hook and holds no mutable override member, and FacadeShapeTest refuses the
first _for_test in any product file and the first *_override_ in its header.
class ModelManager {
public:
explicit ModelManager(ModelManagerConfig);
~ModelManager();
// Reads. A consumer answering a question about one model, or walking rows
// without editing them, reads the snapshot pointer — never a copy of the map.
std::shared_ptr<const StoreSnapshot> snapshot();
std::optional<ResolvedModel> resolve(const std::string& requested);
ModelInfo get_model_info(const std::string& name);
bool model_exists(const std::string& name);
bool is_model_downloaded(const std::string& name);
std::string get_public_model_name(const std::string& canonical_id);
std::string canonical_for_request(const std::string& requested);
std::string resolve_model_name(const std::string& name); // the key, or the name given
// One entry per checkpoint — type, present, required, registry, path — from
// the same sources a pull would use; the wire's checkpoint_status[] is this.
std::vector<CheckpointStatus> checkpoint_status(const ModelInfo& info) const;
// Owned maps exist for the wire sites that serialize them: the key rename
// from canonical ids to listed names is what forces the copy.
std::map<std::string, ModelInfo> get_supported_models();
std::map<std::string, ModelInfo> get_downloaded_models();
// Artifacts and deletion
void delete_model(const std::string& name);
void download_model(const std::string& name, const json& model_data,
bool do_not_upgrade, DownloadProgressCallback,
const utils::CancelToken& cancel);
std::unique_ptr<ArtifactSource> source_for(const ModelInfo& info,
const std::string& checkpoint_type) const;
// Registry search, asked of the source that can answer it; a source that
// cannot is an error, not an empty list.
RegistrySearchResponse search_registry(const std::string& source, const std::string& query,
std::size_t limit, bool gguf_only) const;
// Sync — queue ids are uint64_t and status is json, because that is the wire
SyncEnqueueResult enqueue_sync(const std::vector<std::string>& targets = {},
bool attach_if_running = false);
json sync_models(const std::vector<std::string>& targets = {}, bool dry_run = false,
bool attach_if_running = false);
UpdateCheckResult check_updates(const std::vector<std::string>& targets = {});
json sync_status(uint64_t sync_id = 0) const;
void wait_until_sync_idle();
void cancel_sync();
// Options: one read, two writes. options(name[, changes]) carries the entry,
// the layering beneath it, and what changes would resolve to without
// persisting — the dry-run is a read, so preview is not a verb.
json options(const std::string& name, const json& changes = nullptr);
json set_options(const std::string& name, const json& changes); // merge; null clears
json replace_options(const std::string& name, const json& saved); // {} erases the entry
// Aliases — delegation to the single AliasManager instance
std::map<std::string, std::string> aliases() const;
std::optional<std::string> resolve_alias(const std::string& alias_or_name) const;
bool set_alias(const std::string& alias, const std::string& target, std::string& err_msg);
bool remove_alias(const std::string& alias);
// Registration, and the registry documents behind it
void register_model(const std::string& name, const json& model_data,
bool allow_missing_checkpoint = false, bool replace_existing = false);
void register_user_model(const std::string& name, const json& model_data,
const std::string& source = "");
std::optional<std::string> validate_collection_request(const std::string& name,
const json& model_data);
json model_sources();
// A registry row answers this name, filtered out or not. Paired with
// get_model_filter_reason, it is how a gateway tells "unknown" from
// "known here, cannot run here".
bool model_exists_unfiltered(const std::string& name);
std::string get_model_filter_reason(const std::string& name);
// Listing detail: the rows and files the wire serializes, and what is left per recipe
std::vector<std::pair<std::string, const ModelInfo*>> listing_rows(
const std::map<std::string, ModelInfo>& listed);
std::vector<ModelFileInfo> list_model_files(const std::string& name);
std::set<std::string> recipes_with_all_models_filtered();
std::set<std::string> active_helper_models();
backends::DownloadPolicy download_policy(const ModelInfo& info) const;
json get_architecture_defaults(const std::string& architecture) const;
std::string get_hf_cache_dir() const;
void download_registered_model(const ModelInfo& info, bool do_not_upgrade = false,
DownloadProgressCallback progress_callback = nullptr);
bool should_auto_update(const ModelInfo& info) const;
// Cloud providers
CloudProviderRegistry* cloud_registry() const;
std::size_t count_cloud_models(const std::string& provider) const;
std::size_t evict_cloud_models(const std::string& provider);
std::size_t refresh_cloud_models(const std::string& provider);
RegistryId download_source_for(const ModelInfo& info) const;
// Change: invalidate, publish, subscribe
void invalidate_models_cache();
void notify_models_changed();
void catalog_set_changed();
void on_models_changed(std::function<void(std::uint64_t)> subscriber);
void on_model_updated(std::function<void(const std::string&)> subscriber);
// Cache maintenance
json cleanup_orphaned_cache(bool dry_run);
void set_extra_models_dir(const std::string& dir);
// The published set, without composing it — rule 6b. Nothing reached through this
// waits on a writer, and a write scope needs a non-const store, so this hands out
// no way to hold one.
const models::ModelStore& store() const;
private:
// Members are exactly the components built from the config at construction:
// change notifier, alias manager, options store, artifact store, catalog set,
// registries, store, ingest, composition, cloud rows, sync service, watcher.
// Nothing else, and none is replaced afterwards.
};
| Rule | Consequence |
|---|---|
| Its only members are the components it constructs at startup, never replaced afterwards | ownership without state: no facade method mutates the facade, and nothing can drift from the store |
| Every method delegates to one component or composes at most two | a method that needs a third is a missing use case, added to the component that owns the data |
| No branch on a registry id, recipe, checkpoint type or source token | that branch is a capability method on the component instead |
The read verbs stay, and store() is const |
rule 6b: composing is a write, so a gateway holding the store would hold the writer |
| Only callers include its header; four leaf headers are what everyone else includes | The translation units that call the orchestrator — the route classes, the Ollama and Anthropic gateways, the jobs operations, the router, each backend's .cpp — include lemon/model_manager.h. No header does: one that merely names the orchestrator takes model_manager_types.h, and one that reads a row takes model_info.h, registry_types.h, store_snapshot.h or download_progress.h from lemon/models/. Every other models header reaching a consumer is named by FacadeShapeTest and retires by deletion |
| No component includes it | the dependency graph stays a DAG with the facade on top |
| It declares no test hook and holds no override member | substitution arrives through ModelManagerConfig or a component's own deps; the gate names the first _for_test anywhere in product code |
The model object a listing serializes is a row of the published snapshot plus the
facade's checkpoint_status(); there is no ModelView projection type — a second
table of the same facts would only drift from the store:
| Field | Source |
|---|---|
id |
StoreSnapshot::public_names[canonical_id], or the alias the caller used |
canonical_id |
the StoreSnapshot key |
checkpoint, checkpoints |
ModelInfo::checkpoints, each entry rendered as repo:variant |
recipe, labels, components, suggested |
the catalog definition that won precedence |
source |
local_upload, local_path, extra_models_dir, or a registry id |
registry_source |
ArtifactRef::registry of the main checkpoint, empty when the model has no registry checkpoint |
downloaded |
every checkpoint with ArtifactRef::required present, per Artifact Sources |
update_available |
any required checkpoint's repository reports newer content, per Two Kinds Of Staleness |
checkpoint_status[] |
per checkpoint: type, present, required, registry — and path only for a request that asked ?include_paths=true |
size |
the sum over present required checkpoints, in GB |
cloud_provider |
set only for a model from a ListedCatalog |
Serialization to a response body is the HTTP layer's job. checkpoint_status is additive.
path is the one entry a client asks for. It is an absolute path on the serving host, so it is absent unless include_paths=true is on the query string, and absent rather than empty when there is nothing to say — the same policy /models/{id}/files documents. present, required and registry answer every question a remote client has about whether a model can run.
Model Catalogs
This section defines the interface every source of model entries implements.
A catalog enumerates model entries. Change is not something a catalog is asked about in a probe round trip: a read hands back the state it observed, and invalidation arrives as an event — a watcher callback, a config edit, a fetched body.
// Precedence is declared, not inherited from a construction order. A lower rank
// enumerates first; the gaps leave room for a catalog that joins later.
namespace catalog_rank {
inline constexpr int builtin_registry = 0; // server_models.json
inline constexpr int registrations = 10; // user_models.json
inline constexpr int configured = 15; // config.json's model_catalogs
inline constexpr int scan = 20; // the extra models directory
inline constexpr int backend_discovery = 30; // one per dynamic recipe
inline constexpr int cloud_listing = 40; // one per provider
} // namespace catalog_rank
enum class Conflict { KeepExisting, Overwrite };
struct CatalogDescriptor {
std::string id; // stable across restarts; keys the docs region
bool required = false; // true: a load failure aborts startup
DocEntry doc; // what the docs generator renders
std::string config_key; // "" when not user-configurable
bool host_specific = false; // exists only because this machine has a
// backend or a provider: never written to a
// committed page, because the page is the
// same on every machine
int rank = catalog_rank::scan;
Conflict claim = Conflict::KeepExisting; // may it take a name an earlier
// catalog already claimed
};
class ModelCatalog {
public:
virtual ~ModelCatalog() = default;
virtual const CatalogDescriptor& descriptor() const = 0;
// Both read the descriptor, so a catalog states a fact once instead of in
// two places that can disagree with it.
const std::string& id() const { return descriptor().id; }
bool required() const { return descriptor().required; }
// Add and override entries during a store build.
virtual void enumerate(CatalogAccumulator&) const = 0;
};
The catalogs lemond ships, built by product_catalogs() (catalog_set.cpp) in one pass per store build:
| Catalog | Entries come from | Rank | claim |
required() |
Input |
|---|---|---|---|---|---|
JsonCatalog server_models.json |
the built-in registry shipped with lemond | 0 | Overwrite | false | a pinned document |
JsonCatalog user_models.json |
lemonade pull and POST /pull |
10 | Overwrite | false | a pinned document |
make_configured_catalog(record, …) |
a file or an HTTP URL naming a document of the same shape | 15 | per record | per record | a CatalogLocator |
EnumeratedCatalog extra_models_dir |
ScanCatalog, an --extra-models-dir scan plus GGUF identification |
20 | KeepExisting | false | a std::function |
EnumeratedCatalog discovered:<recipe> |
one per dynamic recipe, from BackendOps::discover_models() |
30 | KeepExisting | false | a std::function |
ListedCatalog <provider> |
one per configured cloud provider, not one for all of them, so one provider's failure costs only its own entries | 40 | KeepExisting | false | a std::function |
Rules:
- A catalog is an object built during a store build by
product_catalogs(), not a singleton. Catalog set policy is visible in one function; catalog behavior stays open for extension. - A catalog emits
CatalogEntry{catalog_id, ModelInfo definition}throughCatalogAccumulator::add(). The accumulator applies precedence by rank andclaim, rejects reserved names, and records which catalog contributed each row; a catalog never writes a shared container. A lost claim is reported through theOnConflictcallback rather than dropped in silence. - A JSON document is a catalog only when its keys name models.
architecture_defaults.jsonis keyed by architecture andrecipe_options.jsonby canonical ID; both stay out of this interface, the latter underRecipeOptionsStore. EnumeratedCatalogandListedCatalogreach their backend or provider through an injectedstd::function, so the store holds no concrete include of the provider or backend layer. This is the one extension point that is a lambda rather than a class:EnumeratedCatalog(descriptor, discover, on_conflict)is the whole addition, andproduct_catalogs()decides which descriptors to build it from.- Every catalog declares a
CatalogDescriptor, which is what puts it in the docs. - A catalog takes its input in whichever of three shapes fits it, and no shape is a subclass of another: a pinned document (
std::shared_ptr<const json>, for a registry the composition root already reads and writes), aCatalogLocator(for a document body whose owner is a file or an HTTP URL), or a callable (for entries no document holds). ACatalogLocatortherefore belongs only toJsonCatalog;ScanCatalogandListedCatalogare the data behind a callable catalog, andScanCatalogis not itself aModelCatalogbecause it needs the set of paths a registration already claims, which is not something aCatalogAccumulatoranswers. required()is honored — a catalog withrequired()true throws where the others contribute no entries — and no shipped catalog currently sets it. The built-in registry is a shipped resource read once at startup intoRegistryDocuments, so a missing one is a broken installation caught there, not a catalog failure.
Catalog Locators
This section defines how a catalog is read, which is orthogonal to what it contains.
// What one locator observed at its last read. A read hands this back so the next
// read can ask conditionally; none of it is a probe.
struct CatalogState {
bool present = false;
std::uint64_t size = 0;
std::int64_t modified = 0; // the same clock the directory watcher compares against
std::string etag;
std::string last_modified;
};
// Where a JSON catalog document comes from and how it says it changed. A body
// that is simply not there reads as nullopt; whether that is fatal belongs to the
// catalog's `required()`, not to the locator.
class CatalogLocator {
public:
virtual ~CatalogLocator() = default;
virtual const std::string& id() const = 0;
// Throws when a body exists but is not readable as a document of model rows.
// `observed` receives what the read saw, so the next read can ask
// conditionally; invalidation itself is the caller's event, not a probe.
virtual std::optional<nlohmann::json> read(CatalogState* observed) const = 0;
};
std::shared_ptr<CatalogLocator> file_catalog_locator(std::string id, std::string path);
// A document fetched over HTTP; `cache_path` is the body used when a fetch fails.
struct HttpCatalogSource {
std::string url;
std::string cache_path;
bool allow_insecure_http = false;
// Inside this window the cached body answers and the url is not asked.
std::chrono::seconds fresh_for{0};
std::shared_ptr<const CatalogFetchClock> clock;
// A document this large is not a model registry. 0 accepts any size.
std::size_t max_body_bytes = 0;
};
std::shared_ptr<CatalogLocator> http_catalog_locator(std::string id, HttpCatalogSource source);
An interface with two implementations, not a variant: a third source is a class implementing read(), and the interface is the seam a test stands in for a host. There is no resource locator — see below.
| Locator | Read policy | State the read observes | Failure policy |
|---|---|---|---|
file_catalog_locator |
read the file | mtime and size | missing or unparseable contributes no entries when required() is false; throws when it is true |
http_catalog_locator |
inside fresh_for the cached body answers with no request at all; otherwise GET url with If-None-Match / If-Modified-Since from the cached validators; a 304 keeps the cached body; a body over max_body_bytes aborts the transfer |
the ETag / Last-Modified pair, plus the cached body's mtime and size | falls back to the cached body, then to no entries, when required() is false; with required() true and no cache, throws |
The built-in registry and the user registrations do not use a locator at all: RegistryDocuments owns those two files and hands the catalog a pinned std::shared_ptr<const json>, so one build enumerates one version however a route writes meanwhile. That is why no resource locator exists — the shipped resource arrives already read, and its freshness is the process's own lifetime.
Invalidation is event-driven and fetched bodies are windowed; a locator runs no standalone probe round trip. Whatever a read observed is what the next read asks conditionally, and whatever changed outside a read announces itself — a DirectoryWatcher, a settings write, a model download.
A fetched body is cached under the catalog cache directory as <id>.json, with its validators beside it as <id>.json.validators. The body is written before the validators, so an interrupted pair re-fetches rather than answering 304 for a body that never landed.
Rules:
- One parse primitive turns a fetched body into
CatalogEntryvalues, so an error message reads the same whichever locator supplied the document. - An HTTP catalog fetches during its own catalog's enumeration, never on another catalog's startup path.
- A locator stores only the response body and its validators. It touches no model bytes and no sync queue.
- A body over
max_body_bytesis refused before it is buffered, and is never cached: the last good body stays, so a runaway or hostile host cannot grow the cache or evict a working document.configured_catalog.cppsets 8 MB for every catalog named inconfig.json. - A cached body belongs to the catalog set. When a catalog leaves the setting,
prune_cached_bodies()removes its body and validators with it; the sweep touches only files of that shape, and a file it does not recognize is left alone. allow_insecure_httpis per catalog record, off by default: a plain-HTTP document is a deliberate operator choice, never a fallback from a failed HTTPS fetch.
Model Store
This section defines the aggregate view over all catalogs and the snapshot it publishes.
The merged result of N catalogs is a store, not a catalog. ModelStore owns the published set, the rules for changing it, and the reading of it, and publishes a StoreSnapshot.
struct StoreSnapshot {
// Canonical id -> info.
std::map<std::string, ModelInfo> models;
std::map<std::string, std::string> public_model_aliases; // any accepted name -> canonical id
std::map<std::string, std::string> canonical_public_names; // canonical id -> listed name
// Rejections, carried beside the set rather than as a second map, so a reason
// and the set it explains are always the same generation.
std::shared_ptr<const FilterReasons> reasons;
std::vector<CatalogDescriptor> catalogs; // in precedence order
std::set<std::string> helper_models; // projection of route_policy
bool valid = false; // false until the first build
std::uint64_t generation = 0; // the change signal
// All four read this snapshot; none takes a lock or copies the set.
const ModelInfo* find(const std::string& canonical_id) const;
std::string canonical_for(const std::string& public_name) const;
std::string public_name_for(const std::string& canonical_id) const;
std::string rejection_reason(const std::string& canonical_id) const;
std::vector<std::string> empty_recipes() const;
};
The write protocol and the read protocol are separate, because one is a scope and the other is a value:
class ModelStore {
public:
// Never null. `valid` false means the set is empty and the next read builds.
std::shared_ptr<const StoreSnapshot> read() const; // atomic load, no lock
std::vector<CatalogDescriptor> descriptors() const;
std::uint64_t generation() const; // rises on every publish
// Marks the set stale and publishes nothing: rule 6a.
void invalidate();
// A patch: one writer at a time, published when the scope ends. The draft
// starts as the published set, so a change edits rows and leaves the rest.
class Write {
public:
explicit Write(ModelStore&);
std::map<std::string, ModelInfo>& draft();
bool cache_valid() const; // false: the next read composes
void invalidate_cache(); // for a change nothing can patch
~Write(); // derive the tables, then publish
};
// A compose: probes on construction and holds the builder while a candidate
// is put together. Committing installs the set with the catalogs that
// produced it, and refuses when a write landed after the probe.
class Build {
public:
explicit Build(ModelStore&);
bool up_to_date() const; // the published set already answers
bool commit(std::map<std::string, ModelInfo>&& composed,
std::shared_ptr<const FilterReasons> reasons,
std::vector<std::shared_ptr<ModelCatalog>> catalogs);
~Build();
};
// The compose loop, where "build the set if it is stale" lives, with the
// four-attempt yield-and-retry. Runs on the caller's thread.
void ensure_current(const std::function<CompositionResult()>& compose);
// Patches: bringing the published set in line with one thing that changed.
// Each reads disk against the published view and takes the writer only for
// the rows it touches.
void patch_download(const ModelArtifactStore&, const std::string& model_name, bool downloaded);
void patch_removed(const std::string& model_name);
static void patch_options(std::map<std::string, ModelInfo>& draft, const ModelInfo& info);
};
// The read side. Each entry point asks for a current set first, so a request that
// arrives after an invalidation waits for the one rebuild that follows it and every read
// behind that is a lookup. `read()`, `find()` and `resolved_paths_for()` are the exceptions:
// they answer from the published snapshot without waiting on a writer.
std::shared_ptr<const StoreSnapshot> current();
std::optional<ResolvedModel> resolve(const std::string& requested); // the six steps below
bool exists(const std::string& requested);
std::shared_ptr<const ModelInfo> info(const std::string& name); // aliasing, see below
std::shared_ptr<const ModelInfo> find(const std::string& canonical_id) const; // nullable, aliasing
std::string resolve_name(const std::string& name); // the key, or the name given
std::string public_name_for(const std::string& canonical_id);
std::string rejection_reason(const std::string& canonical_id);
std::map<std::string, ModelInfo> supported_models(); // what /v1/models lists
std::map<std::string, ModelInfo> downloaded_models();
std::map<std::string, std::filesystem::path> resolved_paths_for(const std::string& canonical_id) const;
bool is_downloaded(const ModelArtifactStore&, const std::string& name); // rule 6
info() throws for a name no row answers, which is what the facade's lookup has always said; the nullable question is find(), whose argument is already canonical. Both return an aliasing shared_ptr into the published snapshot, so a caller holding a ModelInfo across a request keeps a valid object after the next swap.
Holding one is the only way to hold a row. StoreSnapshot::find() hands back a raw pointer into the map, which is the right shape for a lookup that ends inside the expression that took the snapshot and the wrong one for anything else: read()->find(name) releases the set before the pointer is used, and a publish landing in between frees what the caller then reads. A verb that borrows a row answers through find() instead.
The composer that a stale set triggers is installed by the owner, because composing reads catalogs, registries, the machine probe and saved options -- everything behind ModelComposition. A store with no composer answers from what it has published and never builds, which is what a test that patches rows directly wants.
Not on the store: "does this name exist before the host filter". That question belongs to the registries rather than to the published set -- a filtered model is absent from the snapshot by construction -- so ModelIngest answers it from the RegistryDocuments it already holds. A store that grew such a method would be a second registry reader for one caller.
ModelComposition::run() (composition.cpp) is the pipeline. It composes a CompositionResult{models, reasons, catalogs, downloaded} that Build::commit either installs or throws away; nothing holds a second copy of the set between builds. Its steps, in the order they must run:
- Catalog ingest.
product_catalogs()builds the set for this pass; every catalog enumerates into oneCatalogAccumulator, which resolvesuser.overextra.overbuiltin.by the descriptor'srankandclaim. Each row's authoredrecipe_optionscomes back with it, so no document is read twice. - Deployment-label audit. A row that reached here with no deployment label is logged naming its recipe; every ingest path stamps one, so this catches a row that did not come through them.
- Options layering.
build_recipe_options(info, authored, canonical_id, saved_options)over onesaved_options()snapshot for the whole pass, so a concurrent options write cannot split the build across two states. - Pre-filter alias snapshot.
compute_model_alias_maps()over the whole pre-filter set — taken only when a router collection exists, because only a router policy resolves a model name and the rebuild is O(n log n) over the registry. - Hardware filter.
filter_for_host(models, availability), described in Hardware Filtering. This is the full-registry pass, so its reasons replace the table wholesale. - Download status. Non-collection rows first:
BackendOps::is_downloaded(), whose default is the shared completeness check over the row's artifact sources. Collections follow, aggregated from their components. Adynamic_modelsbackend already set the flag during discovery. - Metadata and size.
populate_model_metadata()fills context window and capability labels;refresh_on_disk_size()replaces the registry's size with what the checkpoints occupy, except for aSelfManagedmodel whose bytes the backend owns. - Routing policy parse. Over the candidate, not the published set — what this build publishes is not published yet. Its alias lookups use step 4, so a component that exists but cannot run here is reported unavailable rather than as a typo (#2748).
- Publish.
Build::commit()derivespublic_model_aliases,canonical_public_namesandhelper_modelsfrom the set published beside them, swaps oneshared_ptr, and bumpsgeneration— or refuses, per rule 11.
Rules:
- The snapshot is write-once: no mutators, built off-thread, published with
std::atomic_store. A reader sees the old valid set or the new valid set, never a partial one. public_model_aliasesandcanonical_public_namesare computed from the pre-filter model set, so a component's alias resolves the same way whether or not its model currently survives the filter.helper_modelsis projected from the policies of visible models.- Download status comes from artifact sources, never from a comparison on
ModelInfo::source. - The store runs no thread.
ensure_current()composes on the thread of whoever asked — the sync service's worker, the directory watcher, or the request that found the set stale — and publishes withstd::atomic_store. One compose runs at a time; a second caller that arrives while one runs waits for it rather than starting its own. - The nine steps are the body of one function. The pipeline is fixed, not a pluggable stage list: a new stage is an edit to
ModelComposition::run(), which is exactly the friction wanted here. -
A read publishes nothing.
generationis the signal clients watch for "the set changed", so a read answers from the published snapshot and never takes the store lock — a read that published would invalidate every watcher without changing anything. The one exception is named rather than accidental:is_downloaded(artifacts, name)answers from the snapshot and, when the bytes have gone missing since the build, corrects the row throughpatch_downloadso every other reader sees it -- a change to the set, not a no-op, and the collections that route through the model follow it. 6a. An invalidation publishes nothing either.ModelStore::invalidate()takes the writer lock, clearscache_valid_, returns: no draft copied, no tables derived, no generation. Marking the set stale changes no rows, and the rebuild that follows is the change — so invalidating twice and reading once moves the generation once.Write::invalidate_cache()survives for the four callers that invalidate from inside a write they are already publishing: a download completing through a backend that rebuilds its own model list, and the three ingest paths beside it. An invalidation does bumpinvalidations_, a second counter no client watches: a compose in flight records it when it starts and refuses its own candidate if it moved, so a persist that lands mid-compose cannot be buried under a set composed before it. Bumpinggenerationfor this was rejected — an invalidation publishes no set, and the generation is the signal that one was. 6b. The facade's read verbs are the read seam, and they stay.get_model_info,model_exists,resolve_model_name,get_public_model_nameandget_model_filter_reasonare one-line delegations to the store's composing reads, and composing is the point: each asks for a current set, so an invalidation costs one rebuild and every read behind it is a lookup. Moving those verbs onto the gateways was rejected — a gateway that composes needs the writer, and aModelStore&handed to a route class to save a delegation is aModelStore::Writewaiting to happen.ModelManager::store()is const, offeringread(),find(),generation(),descriptors()andresolved_paths_for()— everything that answers from the published snapshot without waiting. -
The store lock covers the commit, not the computation. Catalog reads, hardware filtering, artifact status and metadata population run over a candidate that the published set cannot see, and one builder runs at a time. On a warm host with 141 models, 52 ms of a 53 ms rebuild needs no lock at all; holding it across the whole build made every other writer — a download completing, a delete, a config write that changes the set — wait for work that touches none of its state.
- A download landing patches the rows it changed rather than rebuilding the set: the model, and the collections that carry it. It decides from the published snapshot, so the filesystem walk — resolved paths, the header a backend reads for metadata, the on-disk size — happens outside the store lock, and it keeps any options another writer committed while it worked. Held this way the patch costs 15 µs of store lock against 2.0 ms when it did its work inside the lock.
- Publishing derives what a reader could recompute — the alias maps and the helper-model projection — from the set published beside them, so no writer has to remember to update a name table and none can point at a model that is gone. Deriving costs 206 µs on a 143-model store, against 100 µs to copy the set itself; writes are rare enough (62 across a whole CLI suite) that the invariant is worth the arithmetic. The filter table is the one thing a build hands over explicitly, because a patch says nothing about hardware fit and carries the published table forward.
- Outside a write the model set exists once, in the published snapshot. A write seeds a draft from the snapshot readers hold and works on that; the publish moves the draft into the new snapshot. Nothing mutates a published snapshot in place, so a reader cannot see a set half-updated, and there is no member left holding a second copy that a later edit could read by mistake.
- A compose whose generation moved while it ran does not commit. Its candidate predates a write it never saw, so it yields: that write stays published, the set stays marked stale, and the next
ensure_current()composes again from the registries and disk the writer wrote to. Yielding costs one dropped composition; committing would cost the write. - Marking the set stale is not a write — rule 6a, including the epoch a compose in flight compares.
Hardware Filtering
This section defines how backend availability and memory fit narrow the visible model set.
filter_hardware() answers which of these models this machine can run now, and returns the visible set and the reasons for the rest together. It is a function, not a class: it holds no state, and the rebuild is its caller — over the whole set, never one entry at a time.
struct FilterResult {
std::map<std::string, ModelInfo> visible;
std::vector<RejectedModel> rejected;
std::vector<std::string> empty_recipes;
};
// store_builder.h, beside the other whole-set passes
FilterResult filter_hardware(const std::map<std::string, ModelInfo>&,
const BackendAvailability&);
BackendAvailability says which recipes can run now and how large their memory pools are. The ModelManagerConfig.availability probe produces it at the start of every rebuild, so the models layer never includes a backend header.
Rules:
- Every build produces
StoreSnapshot::rejectedandStoreSnapshot::empty_recipesfor the whole set. There is no build mode that skips them, and no side table for a caller to remember to consult. - Memory-fit inputs are
ModelInfo::sizeandModelInfo::min_resident_gb, against the streaming pools inBackendAvailability. The working-set comparison —streaming_working_set_gb()andstreaming_model_exceeds_pool()— are file-local helpers besidefilter_hardware(). - A rejection reason is retrieved from the snapshot by canonical id, so an error message and the set it describes are always the same generation.
- A filter pass writes nothing: it answers with the visible set and its reasons, and the rebuild that ran it keeps them. A pass over part of the registry would report every recipe it did not look at as having nothing runnable, which is why the incremental add path carries no filter of its own.
Name Resolution
This section defines the one function every surface calls to turn a requested name into a model.
// lemon/models/alias_manager.h — the alias map, with no interface above it.
class AliasManager {
public:
static constexpr size_t MAX_ALIAS_CHAIN_DEPTH = 10;
explicit AliasManager(std::string cache_dir = ""); // "": in-memory, no file I/O
bool set_alias(const std::string& alias, const std::string& target, std::string& err_msg);
bool remove_alias(const std::string& alias);
std::optional<std::string> resolve_alias(const std::string& alias_or_name) const;
bool has_alias(const std::string& alias) const;
std::map<std::string, std::string> get_all_aliases() const; // sorted
void load();
void save();
private:
std::string cache_dir_;
mutable std::shared_mutex mutex_;
std::map<std::string, std::string> aliases_;
};
struct ResolvedModel {
std::string canonical_id; // StoreSnapshot key
std::string wire_id; // id echoed back to the client
std::shared_ptr<const ModelInfo> info;
bool via_alias = false;
};
ModelStore::resolve(requested) runs these steps:
- Normalize with
utils::normalize_model_name()and strip a trailing:latest. - Look the name up in
StoreSnapshot::public_aliases. A hit wins: exact canonical ID, then the bare-name precedence winner, then a model'sinput_aliases. - With no snapshot hit, call
AliasManager::resolve_alias()— at mostAliasManager::MAX_ALIAS_CHAIN_DEPTHhops. A cycle yields no target, and so does a chain that still names an alias when the limit is reached: an over-long chain resolves to nothing rather than to a target it never proved. - Resolve the alias target against the snapshot, falling back to the one-hop target when the chained target is absent.
- On a hit set
canonical_idto the snapshot key,via_aliasto whether step 3 was used, andwire_idtoStoreSnapshot::public_names[canonical_id], or to the requested name whenvia_aliasis true so a client that asked by alias is answered under that alias. - On a miss return
std::nullopt, and let the caller produce its own 404 shape.
Rules:
- The snapshot wins over an alias, and
POST /internal/aliasesrejects an alias that collides with a canonical model name with 409, so a colliding pair cannot be created. AliasManagerlives atsrc/cpp/include/lemon/models/alias_manager.hwith its implementation insrc/cpp/server/models/alias_manager.cpp. It owns<cache_dir>/aliases.jsonin mode0600, writes it on every mutation, prunes cyclic chains at load, and guards its map with onestd::shared_mutex. A save holds that lock exclusively from the snapshot of the map through the rename, and names its temporary filealiases.json.<n>.tmpwith a counter under it: two mutations that each copied the map and then wrote would otherwise land newest-first, and the older file renaming last is a registered alias gone by the next restart. Both call sites arrive holding no lock.ModelManagerconstructs the one instance fromModelManagerConfig.cache_dir; the alias routes andlemonade aliasreach it throughModelManager::set_alias()andModelManager::remove_alias(), which delegate to that instance.- Aliases are consulted at resolution time, never folded into the snapshot. An alias mutation takes effect on the next request with no store rebuild and no generation bump.
ModelStoreholds aconst AliasManager&— the one instanceModelManagerconstructs — with no interface between them.get_all_aliases()returns astd::map, so/internal/aliasesand the alias rows of/v1/modelscome out in a stable order and a golden test is reproducible.ModelManager::listing_rows(listed)appends one row per alias that resolves to a listed model, withidset to the alias. The route serializes and does nothing else. (The name ships aslisting_rows, notlist:listcollides with the provider-sideArtifactSource::list()this document uses elsewhere, and the two are unrelated.)- Every gateway resolves through this function, so aliases mean the same thing on the OpenAI, Ollama, Anthropic and MCP surfaces.
Identity: Handles And Provenance
This section defines the two identity axes and which one is extensible.
| Axis | Content | Extensible | Used by |
|---|---|---|---|
| Handle | <source-token>.<bare> |
No, deliberately closed | user input, user_models.json keys, recipe_options.json keys, the API model field, Decision::route_to |
Provenance (ArtifactRef) |
{registry, checkpoint_type, repo_id, variant, revision, required} |
Yes, this is the join | cache directory naming, blob dedup, update check, .lemonade_registry.json |
struct ArtifactRef {
std::optional<RegistryId> registry; // set only when a host holds the bytes
std::string checkpoint_type; // "main", "mmproj", "npu_cache", ...
std::string repo_id; // "Qwen/Qwen3-0.6B-GGUF", or a path when no host
std::string variant; // "Q4_K_M", a file name, or empty
std::string revision; // "main", "master", or an explicit sha
bool required = true; // false: absent bytes do not clear `downloaded`
};
No recipe here, though a recipe decides which files of a repository a model wants. That is
whose policy over the bytes rather than which bytes, so it travels in SourcePlacement with
the layout: the same ref read for two backends is the same address with two opinions, and a
field that changes what a plan contains while operator== ignores it is a field that lies.
The artifact key is f(registry, repo, snapshot), computed without touching the handle grammar, which is how blob-level sharing across models works.
Handle Grammar
ModelSource and the prefixes user., extra. and builtin. stay closed. The guard is a security boundary: is_reserved_registration_name() (canonical_id.h) prevents user.builtin.Foo from taking a built-in alias slot, and precedence_rank() defines shadowing. New catalogs register into the store; they mint no new source token.
handle ::= <source-token> '.' <name> // closed set: user | extra | builtin
Each source mints handles under these rules:
| Catalog | Source token | Authored by | Handle minted |
|---|---|---|---|
JsonCatalog over server_models.json |
builtin. |
the registry shipped with lemond | builtin.Qwen3-0.6B-GGUF |
JsonCatalog over user_models.json |
user. |
a human at lemonade pull |
user.MyQwen |
ScanCatalog |
extra. |
the extra-directory scan | extra.Qwen3-0.6B-GGUF |
ListedCatalog |
extra. |
a cloud provider listing | extra.fireworks.deepseek-v3 |
EnumeratedCatalog discovered:<recipe> |
extra. |
a backend's discover_models() |
extra.deepseek-r1-FLM |
The source token is part of every handle, including a catalog's, and builtin. user. and extra. are the only three. A listing emits the bare name for a precedence winner, so extra.fireworks.deepseek-v3 is listed as fireworks.deepseek-v3 — the source token carries the precedence class, and the minter identifies itself inside the name: a listing leads with its provider id, a backend minter carries its recipe, a scan qualifies a colliding stem with its directory. Nothing in the grammar recognizes that difference. It is a naming rule each minter owns, and the minter travels on the entry as data (cloud_provider, source).
There is no locality vocabulary, and no registry of minted namespaces to validate against. A name that reads like a namespace is just a name: whether fireworks.foo means a provider's model or a user's own file is answered by precedence, which the user guide documents. A user. registration that collides with a machine-minted public name takes it, exactly as one that collides with a built-in takes it, and installing a provider afterwards never hides a model a user already registered.
Rules:
- The reserved-token guard applies at registration —
ModelManager::validate(),ModelIngest, andlemonade recipe-import— and rejects a name whose source token is reserved, or whose bare part begins with one. Nothing is rejected for the shape of a name. An entry already on disk keeps loading and resolving, so an upgrade never hides a user's model. user.holds one repository per bare name. Repointing auser.model at a different registry is a provenance change: an explicit replace that errors on conflict, never two coexisting IDs a user disambiguates.- Every registered catalog's minted IDs round-trip through
parse_canonical_id()and passis_reserved_registration_name(), asserted in a unit test that enumerates the catalogs. The grammar is a checked contract, and its generated table is the same list. extra.is the token a machine mints under, because no human authors it.builtin.anduser.are never machine-minted.- Two catalogs never contribute one ID. A collision means two minters emitted the same name, and the accumulator settles it: the first claimant keeps the ID, the loser is reported rather than dropped in silence. Startup rejects no catalog for the shape of its name.
Registry Identity
A registry is identified by an interned string, RegistryId, resolved through Fetchable Repositories. There is no registry enum.
The string form is the public interface, because it appears in four places the design does not control: user_models.json registry_source, .lemonade_registry.json source, the source field accepted by POST /pull and --source, and the literals the desktop app compares against in utils/modelData.ts, AddModelPanel.tsx and ModelOptionsModal.tsx. A new registry is simply another accepted value in all four.
An unknown RegistryId in a persisted entry logs one warning naming the model and the id, contributes no entry for that model, and leaves the rest of the catalog loaded. Startup does not abort.
Two Kinds Of Staleness
This section separates the two change questions a client can ask.
| Catalog staleness | Artifact staleness | |
|---|---|---|
| Question | did the list of models change? | did a model's bytes change? |
| Signals | file mtime, contents digest, HTTP ETag / Last-Modified, listing digest | commit SHA, LFS content id, tree fingerprint |
| Cost | one stat call or one metadata request | one or more network round trips per repository |
| Detector | an event names what changed — a DirectoryWatcher callback, a settings write, a completed ingest — plus an in-process window over a fetched body; the last read's CatalogState is what makes the next read conditional, not a probe |
|
| Result | a rebuild, then one models changed notification carrying the new generation |
update_available in the snapshot |
| Client surface | none; an event inside lemond | /v1/models, POST /models/check-updates, POST /internal/models/sync |
update_available means a repository's commit advanced. A catalog gaining an entry sets no update_available and adds no field to a response; it rebuilds the store and bumps the generation. A "new models available" surface is a later RFC.
Artifact Sources
This section defines the per-checkpoint role every model with bytes answers through. There is one source object per checkpoint, and it is built by the resolver.
enum class ArtifactKind { Registry, Filesystem, Local, SelfManaged, None };
struct Presence {
bool complete = false;
std::string path; // resolved location, empty when absent
std::string reason; // why incomplete
};
struct Acquisition {
enum class Kind { Nothing, Plan, Delegated, NotApplicable } kind = Kind::Nothing;
DownloadPlan plan; // Kind::Plan
std::string delegate; // Kind::Delegated: who fetches it, for the error text
};
struct Deletion {
// A wrong branch here deletes a user's own directory.
enum class Kind { Nothing, CacheDir, Delegate } kind;
std::string reason; // Delegate: the command the backend owns, e.g. "flm remove"
};
// A source is constructed for one checkpoint and carries its ArtifactRef, so
// the role methods below take no arguments.
class ArtifactSource {
public:
virtual ~ArtifactSource() = default;
virtual ArtifactKind kind() const = 0;
const ArtifactRef& ref() const { return ref_; }
virtual std::string resolve() const = 0; // where the bytes are now
virtual Presence present() const = 0;
virtual Acquisition acquisition() const = 0;
virtual Deletion deletion() const = 0;
// Sent with the plan's URLs; empty when the source needs no credential.
virtual std::map<std::string, std::string> auth_headers() const { return {}; }
protected:
explicit ArtifactSource(ArtifactRef ref) : ref_(std::move(ref)) {}
ArtifactRef ref_;
};
using FileValidator = std::function<std::string(const std::string& resolved_path)>;
The sources lemond ships, with the behavior each declares:
| Implementation | Roles | list() / file_url() |
Staleness | Layout | Transfer | Deletion |
|---|---|---|---|---|---|---|
RegistryBackedRepository |
both | host API, through ModelRegistry |
the host's basis, from its RegistryDescriptor |
the resolver's placement | HTTP | CacheDir; scope is decided by Local Artifact Store |
LocalPathArtifactSource |
core | none | never stale | n/a | n/a | Nothing or CacheDir by Origin, see Deletion |
SelfManagedArtifactSource |
core | none | the backend's concern | the backend's | Delegated |
Delegate |
NoArtifactsSource |
core | none | never | n/a | NotApplicable |
Nothing |
One class serves every host. RegistryBackedRepository is registered once per RegistryId (LEMONADE_REGISTER_ARTIFACT_REPOSITORY, twice today for Hugging Face and ModelScope) and delegates the protocol difference to ModelRegistry, which is private to the server. A host whose behavior diverges enough subclasses it; the common case adds a descriptor and no class.
Presence is one answer, composed of three checks. A checkpoint is present when the resolved path exists, no in-flight marker stands in it (.download_manifest.json for a multi-file download, a sibling .partial for a single file, any partial inside a directory checkpoint), and the backend that reads it accepts it — BackendOps::validate_checkpoint_file(), so a saved HTML error page named .gguf is not present. All three live in one function, checkpoint_bytes_present(path, validator), and every answer to "is this present" runs through it: LocalPathArtifactSource::present(), RegistryBackedRepository::present() (which validates with the ops of the recipe in its SourcePlacement), and ModelArtifactStore::complete(), whose downloaded is that answer over every checkpoint whose ArtifactRef::required is true. The marker half is interrupted_download() in models/download_markers.h, named nowhere else.
The function takes a path, not a source. The store decides which path a checkpoint resolves to — a complete cache in the extra models directory adopted for a registered model, a shard family resolved to the one file the engine opens — and a source asked the same question derives the location again from provenance, disagreeing with the answer the rest of the server acts on.
requiredis the one rule the loop reads from the backend, throughBackendOps::acquires_lazily(), the same call the resolver makes when it setsArtifactRef::required.Known divergence. A row that adopted an extra-directory cache still has
checkpoint_status[].presentanswered by a registry source that looks only in the managed cache, so it reads false beside adownloadedof true. Closing it costs something either way: teaching the registry source to read the extra roots risks writingrefs/maininto a directory lemonade does not own, and recording the adoption on the row changes thesourcethe API reports and makes the update scan skip a row it currently visits.
A cloud model is NoArtifactsSource: downloaded is true, size is 0, and nothing is fetched or deleted.
Selection
This section defines the one place a checkpoint is bound to a source.
class ArtifactSourceResolver {
public:
// Everything the selection needs that is not in the ModelInfo: who owns the
// bytes for this recipe, the seam that opens a repository (empty binds the
// real host), and the configured default host.
struct Hooks {
std::function<backends::DownloadPolicy(const ModelInfo&)> download_policy;
std::function<std::unique_ptr<FetchableRepository>(
const RegistryId&, const ArtifactRef&, const SourcePlacement&)> repository_for;
std::function<std::string()> default_registry_id; // empty is an error, not a default
};
explicit ArtifactSourceResolver(Hooks = {});
std::unique_ptr<ArtifactSource> source_for(const ModelInfo&,
const std::string& checkpoint_type) const;
const ArtifactLayout& layout_for(const ModelInfo&,
const std::string& checkpoint_type) const;
SourcePlacement placement_for(const ModelInfo&,
const std::string& checkpoint_type) const;
};
A binding to no source is a broken registration, not an absent model, so source_for() throws RegistrationError naming the model, the checkpoint and the value it could not place.
For each entry of ModelInfo::checkpoints, the resolver picks the first matching rule:
BackendOps::plan_download()returnsNone:NoArtifactsSource.plan_download()returnsSelfManaged:SelfManagedArtifactSource, carrying the recipe as its delegate. The delegate answers presence for the whole model and composition is where it is asked, so the resolver hands the source a probe over the row: every checkpoint of that model reportsModelInfo::downloadedand the path the row recorded, at the model's granularity rather than the checkpoint's. A probe that asked the delegate again would run its tool once per checkpoint per listing; a source built without one answersfalsebeside a row that saysdownloaded: true.- The entry's origin is
local_path,local_uploadorextra_models_dir:LocalPathArtifactSourcewith thatOrigin. - The checkpoint names a registered host scheme (
hf://,ms://): that host's repository, with the scheme stripped and the variant split off. - The checkpoint names
file://:LocalPathArtifactSource,LocalPathorigin, at the named path. - The checkpoint names a path that exists on disk:
LocalPathArtifactSource,LocalPathorigin. This runs before the repository heuristic, which cannot tell a path from anorg/repo. checkpoint_looks_like_repo_id()matches: the host fromModelInfo::registry_source, elseModelInfo::source, else thedefault_model_sourceconfig key — and aRegistrationErrorwhen none of the three names a host.- Otherwise: a registration error.
Rules:
- Selection is per checkpoint. A checkpoint whose bytes live somewhere else — an imported directory, a lazily fetched compiled cache, a cloud entry with no bytes — is an ordinary checkpoint with an ordinary source, and no function needs a clause to skip it.
- Rule 2 dispatches through
BackendOps, rules 4 and 7 through the repository registry, so a new registry adds a descriptor and a registration line, a new self-managed backend overridesplan_download(), and this list grows only for a new local scheme. It is the one list allowed to grow, because it is the product's source model. ModelInfo::sourcecarries a registry id or one of the three local origin tokens, which is what/v1/modelsreports. It selects no behavior beyond rule 3: registration validation accepts a registry id or one of those tokens, and every other decision asks the resolver.- Model-level status aggregates checkpoint-level answers.
downloadedis true when every checkpoint whoseArtifactRef::requiredis true reportsPresence::complete.update_availableis true when any required checkpoint's repository reports newer content. requireddefaults to true. A lazily acquired checkpoint sets it false;npu_cache, the.raifile a whisper backend pulls beside its.binon first NPU use, is the shipped case. Absent bytes there leave the model downloaded.- Checkpoints of one model share one registry.
apply_default_pull_source()(model_registry.cpp) rejects a body whose checkpoints name different registries, anddocs/dev/model-registries.mdstates that rule. Source kind varies per checkpoint; registry does not. The shipped Whisper models satisfy it:mainandnpu_cachename different repositories, both on Hugging Face. - The resolver is the only site that mints an
ArtifactRefand opens a repository. A caller takesrefs_for(info)andrepository_for(ref, placement)rather than assembling a ref from a checkpoint string, because the parsing rules above — scheme strip, variant split, the on-disk-before-repo-id order,requiredfromacquires_lazily()— are selection, and a second copy of them is a second opinion.FacadeShapeTestenforces the shape:create_repository(may be named only by the resolver, by the factory that declares it, and by the facade's one line wiringModelManagerConfig::repository_forinto the resolver's hook. Two things deliberately do not go throughrefs_for. The pull does not callacquisition()— it takeslist()andregistry_download_plan()because one listing is needed three times (the existence rule, the reuse merge, the manifest), andacquisition()would fetch the tree again per checkpoint; its existence refusal keeps its own words because they name the host that refused. The update check keepsgroup_aux_checkpoint_variantsrather thanrefs_for: a malformed auxiliary checkpoint is skipped silently by a check and refused loudly by a pull, and that difference is what a user sees — a check that never fails the update list for a registration nobody is pulling. - The
SourcePlacementa binding carries is resolver output, never ref content: whichArtifactLayoutapplies, the sibling file a side checkpoint must land beside, and the recipe whoseBackendOpspicks files out of a listing. Two refs that compare equal name the same bytes however they are placed, and every policy that changes what is done with those bytes is now visibly in the placement rather than half in the address.
Fetchable Repositories
This section defines the pullable role layered on an artifact source, and what a repository may own.
struct Revision { std::string id; bool pinned = false; }; // pinned: an immutable commit SHA
struct RepoListing {
std::string repo_id;
Revision revision;
std::string download_revision; // the spelling the host's file endpoint wants
std::vector<RegistryFile> files;
nlohmann::json raw_metadata; // the host's own description, verbatim: variant
// readers read fields no Lemonade struct predicts
};
struct RepoRef { std::string repo_id; Revision revision; std::string path; }; // file_url()'s argument
struct LocalRef { std::string repo_id; }; // the repo's cache dir
struct Staleness {
bool update_available = false;
std::string local_snapshot_id;
std::string remote_snapshot_id;
std::vector<std::string> changed_files;
};
// Where the resolver bound this checkpoint. A null layout means the snapshot
// layout, so the common case carries no pointer.
struct SourcePlacement {
const ArtifactLayout* layout = nullptr;
std::string sibling;
std::string recipe; // whose BackendOps selects the files of the listing
};
class FetchableRepository : public ArtifactSource {
public:
const ArtifactLayout& layout() const; // placement_.layout or the snapshot layout
const std::string& sibling() const;
const std::string& recipe() const;
virtual RegistryId id() const = 0;
virtual Revision default_revision() const = 0;
virtual RepoListing list(const std::string& repo_id,
const std::string& revision = "") const = 0;
virtual std::string file_url(const RepoRef&) const = 0;
virtual bool can_go_stale() const = 0;
virtual Revision active_revision(const LocalRef&) const = 0;
virtual void set_active_revision(const LocalRef&, const Revision&) = 0;
virtual Staleness compare(const LocalRef&, const RepoListing&) const = 0;
virtual void commit(const LocalRef&, const Revision&) = 0;
virtual std::string cache_dir_name(const std::string& repo_id) const = 0;
virtual bool supports_search() const { return false; }
// The marketplace question, answered by the host that can. Callers ask
// supports_search() first; the default throws for a source that cannot, so a
// silent one cannot pretend to have looked.
virtual RegistrySearchResponse search(const std::string& query,
std::size_t limit, bool gguf_only) const;
protected:
FetchableRepository(ArtifactRef, SourcePlacement);
};
A repository declares no descriptor method. Its facts are RegistryDescriptor fields, written once beside the registration and read back through descriptor_for(id), so a repository object carries no copy of them and a host's identity exists even where no repository is open.
// Identity, no fetch capability. Keys the docs table and the accepted spellings.
LEMONADE_REGISTER_REGISTRY_DESCRIPTOR(builtin_huggingface, builtin_registries::huggingface());
// Identity plus the factory. __LINE__ keeps one class registered for two hosts.
LEMONADE_REGISTER_ARTIFACT_REPOSITORY(RegistryBackedRepository, models::huggingface_descriptor());
LEMONADE_REGISTER_ARTIFACT_REPOSITORY(RegistryBackedRepository, models::modelscope_descriptor());
Registration is a static initializer keyed by RegistryId: no dlopen, no C ABI, no plugin boundary. Every method but the pure virtuals has a default, so adding a capability later edits no existing repository.
// The files the ref needs, placed by the layout. Shared by acquisition() and by
// the pull, so a file the update check compares is always a file the pull would fetch.
DownloadPlan registry_download_plan(const FetchableRepository&, const ArtifactRef&,
const ArtifactLayout&, const Placement&,
const RepoListing&);
// Null when no registration answers the id.
std::unique_ptr<FetchableRepository> create_repository(
const RegistryId&, const ArtifactRef&, const SourcePlacement& = {});
std::vector<RegistryId> registered_repository_ids();
std::vector<RegistryId> repository_registration_conflicts(); // ids claimed twice; first keeps serving
These value types carry data across the ports:
| Type | Contents |
|---|---|
RepoListing |
{repo_id, revision, download_revision, files, raw_metadata} where files are RegistryFile{path, size, hash_algorithm, hash, directory} |
RepoRef |
{repo_id, revision, path} — the argument to file_url() |
LocalRef |
{repo_id} resolved against the models directory — a repository's local namespace for one repository |
Revision |
{id, pinned} — pinned true for an immutable commit SHA, false for a mutable branch such as ModelScope's master |
Staleness |
{update_available, local_snapshot_id, remote_snapshot_id, changed_files} |
CheckpointRef |
{canonical_id, checkpoint_type} — the key Selection and the layout hook build from |
CatalogState |
one catalog's observation at its last read: present, size, modified, etag, last_modified — what makes the next read conditional |
CatalogAccumulator |
the sink a catalog writes to: add(CatalogEntry), plus the precedence and reservation checks in Model Store |
BackendAvailability |
which recipes can run now and their memory pools — the input to filter_hardware() |
Provenance has no value type of its own: ArtifactRef already names the registry, repository, variant and revision, and the checkpoint slot is an argument. See Provenance File for the API that writes it.
Policy versus mechanism is the line that must hold. The left column is per-source; the right column is shared, because duplicating it per source re-creates a god object.
| The repository owns | Shared mechanism |
|---|---|
auth_headers(), endpoint and env resolution: HF_ENDPOINT, HF_TOKEN, MODELSCOPE_ENDPOINT, MODELSCOPE_API_TOKEN |
transfer: retries, resume, hash verify, progress, cancel — ArtifactTransfer |
list(), file_url() |
snapshot and blob layout with journaling — ArtifactLayout |
Revision semantics: refs/main read, write and resolution, which Hugging Face needs and ModelScope's branch API does not |
the sync queue, cancellation, progress reporting |
Staleness basis: RegistryDescriptor::staleness_basis, which Hugging Face answers with the API's commit SHA and ModelScope with a tree content hash |
catalog merge, precedence, public aliasing |
cache_dir_name(), including the models-- and modelscope--models-- prefixes |
change notification and the generation counter, per Change Notification |
Artifact selection: select_main_repo_files(), group_aux_checkpoint_variants() |
model-object serialization (HTTP layer) |
search() behind supports_search(), backing /registry/search |
Rules:
- There is no
Repo::download(). The orchestrator islist→ select files → buildDownloadPlan→ArtifactTransfer::execute(plan, headers, progress, cancel)→repo.commit(). Transfer is mechanism; a repository that owns a copy of it has duplicated a protocol-independent loop. acquisition()states the same plan for a caller who has no other use for the listing. A caller that needs the listing itself — for an existence rule, for a previous-snapshot reuse merge, for provenance — takeslist()andregistry_download_plan()directly, because going throughacquisition()would fetch the listing again per checkpoint. Same builder, one site, listings reused.- A transfer protocol that HTTP cannot serve is a second
FileTransferpassed toArtifactTransfer's constructor, never a second copy of the loop.file://needs one from the start, because Selection already accepts that scheme:LocalCopyFileTransferstages through the same.partialfile under the same disk-space check and hash verification, with no socket and no retry policy. compare()is the only place file metadata is read from a provider, throughlist(). No caller assembles a provider URL or parses a provider's tree response.- A source that answers
can_go_stale()false is skipped before it is asked: no listing, no report, no flag moved. Cloud, local and imported models drop out of an update scan on their own capability, not on a caller's list. - Cache layout per registry follows
docs/dev/model-registries.md:refs/main,snapshots/<snapshot-id>/and.lemonade_registry.jsonunder a provider-qualified repository directory. Every component of that path arriving from outside the cache — a registry's snapshot id, arefs/mainread back, a repository-relative file name — passesutils::is_safe_path_component()orutils::is_safe_relative_path()— at the layout, and atregistry_download_plan, because a pull composes that same snapshot directory itself (ingest.cppbuildsrepo_cache/snapshots/<id>and creates it before any transfer runs). A destination the layout refused throws there rather than coming back as an emptydownload_path, which would either create a directory outside the repository cache or quietly fetch nothing; a transfer whose staged.partialis a symlink, or whose final name is a symlink resolving outside its own repository directory, is refused at the transfer gate instead of written through — a final link inside the repository is thesnapshots/<snapshot-id>/<name> -> ../../blobs/<oid>shapehf downloadwrites into the shared cache.
Transfer And Layout
This section defines the shared mechanism that turns a plan into bytes on disk, and where those bytes land.
struct DownloadFile {
std::string name;
std::string url;
std::uint64_t size = 0;
std::string expected_hash_algorithm; // "sha256"
std::string expected_hash; // LFS object id or git blob oid
std::string download_path; // per-file override; empty uses the plan's
};
struct DownloadPlan {
std::string download_path;
std::vector<DownloadFile> files; // files_count is files.size()
};
// Reads the resume manifest written by the registry download path.
DownloadPlan download_plan_from_manifest(const nlohmann::json& manifest);
// A token answered before or during a file throws this. `cancelled` separates a
// client cancel from a transfer failure, because the caller's answer differs.
class TransferError : public std::runtime_error {
public:
explicit TransferError(const std::string& message, bool cancelled = false);
bool cancelled;
};
// Reports bytes, nothing else. A callback that cannot take more input closes its
// own channel; it has never had the power to stop a transfer.
using ByteProgress = std::function<void(std::size_t downloaded, std::size_t total)>;
// One scheme, one implementation. Disk-space accounting, the progress total and
// cancellation belong to the caller, so every scheme inherits them.
class FileTransfer {
public:
virtual ~FileTransfer() = default;
virtual bool handles(const std::string& url) const = 0;
// Bytes moved; 0 when the target was already complete. A cancellation throws
// TransferError with cancelled set.
virtual std::uint64_t transfer(const DownloadFile&,
const std::string& target_dir,
const std::map<std::string, std::string>& headers,
const ByteProgress& progress,
const utils::CancelToken& cancel) const = 0;
};
struct FilesystemRequirement {
std::string target_path;
std::uint64_t bytes_needed = 0;
std::uint64_t bytes_available = 0;
};
// Still to write, crediting bytes already on disk; same-filesystem targets fold.
std::vector<FilesystemRequirement> plan_filesystem_requirements(const DownloadPlan&);
// The one place a plan is executed, and it is concrete.
class ArtifactTransfer {
public:
explicit ArtifactTransfer(std::vector<std::unique_ptr<FileTransfer>>);
static ArtifactTransfer with_default_transfers(); // the shipped pair
// Credentials are per call and never part of the plan, so a logged plan holds
// no secret and one plan cannot be replayed with another host's headers.
void execute(const DownloadPlan&,
const std::map<std::string, std::string>& headers,
DownloadProgressCallback progress,
const utils::CancelToken&) const;
};
There is no FileTransfer registry. The pair below is fixed, with_default_transfers() builds it, and the constructor takes another set for a test or an embedder — a third scheme is a constructor argument, not a global table, because nothing today needs to discover transfers at runtime.
utils::CancelToken is the only thing that stops a plan. It is checked before each file and inside every transfer attempt, so a cancel stops bytes on the next socket tick rather than at the next progress report. DownloadProgressCallback reports; returning false closes that reporter and cancels nothing.
ArtifactTransfer::execute() performs these steps, and they are the contract:
- Check free disk space across the whole plan before transferring anything, crediting bytes already on disk including
.partialfiles, and folding target directories that share a filesystem into one check. - Report progress in bytes with the total across all files, and check the cancel token before each file. A cancellation throws
TransferErrorwithcancelledset. - Reject a cached
.gguffile that lacks the GGUF magic bytes before treating it as complete, so a saved HTML error page never reaches a backend that would fail on it later. - Dispatch each file to the one registered
FileTransferwhosehandles()accepts its URL. A file no implementation handles is a plan error naming the scheme, raised before any byte moves. - Verify
expected_hashper file. A mismatch is a failure, not a retry.
The two shipped implementations, and what each owns:
| Implementation | Schemes | Mechanics |
|---|---|---|
HttpFileTransfer |
http, https |
max_retries = 10, initial_retry_delay_ms = 2000, max_retry_delay_ms = 120000, resume_partial = true, low_speed_limit = 1000 bytes per second, low_speed_time from utils::HttpClient::get_default_timeout(), connect_timeout = 60. Takes an utils::HttpSecurityPolicy (default ExternalHttpsOnly) that a test replaces with a loopback-trusting one; production never does |
LocalCopyFileTransfer |
file |
a staged copy into .partial then a rename; the source is opened read-only and never moved, truncated or deleted; no retries, because a short read is a disk error and a retry would hide it |
Rules:
- One plan may mix schemes — a registry checkpoint beside a
file://side checkpoint — and the space check, the progress total and the cancel token cover the plan rather than the scheme. - A repository's
auth_headers()are sent only while the origin still matches the origin that produced them.HttpFileTransferdoes not let the transport follow redirects: each hop is one request walked by hand, so the header list is freed at a cross-origin boundary and never restored, the policy's scheme set is enforced on every target (file://and friends are permanent failures), and a 3xx body is dropped rather than staged into the.partial. No credential a repository did not supply is ever added. - A plan whose files all resolve to
file://moves no byte over a network, so it succeeds whenoffline=true. - A cancellation is not a failure to retry:
TransferError::cancelledtravels with it, so a caller does not mistake a user's cancel for a host's error and re-fetch.
ArtifactLayout names the on-disk shape of a repository's cache: the directory a placement's files go to, the final path of one repository-relative name, and the call that makes a staged revision current. Staging through .partial and writing .download_manifest.json belong to ArtifactTransfer inside whatever directory the layout names, so a layout says nothing about transport. Two layouts ship:
| Layout | Placement | Used by |
|---|---|---|
HfHubLayout |
snapshots/<snapshot-id>/ holding real files, with refs/main recording the current snapshot id |
every registry checkpoint, per docs/dev/model-registries.md |
SiblingLayout |
the file lands in the directory of the checkpoint it belongs to, by its final component only, so no name can escape that directory; there is no revision to make current | the npu_cache checkpoint, which whispercpp requires beside its .bin |
Layout selection rules:
ArtifactSourceResolver::layout_for()is the only place a layout is chosen. It returnsHfHubLayoutunlessBackendOps::checkpoint_layout()returns a layout for that checkpoint type; that hook returnsnullptrby default, andwhispercppreturnsSiblingLayoutfornpu_cache.ModelArtifactStoreandArtifactTransferconsume the resolvedArtifactLayoutand nothing else, so no component branches on a checkpoint type string.
Local Artifact Store
This section defines the component that performs local disk mechanics.
ModelArtifactStore owns path resolution, completeness checks, deletion, provenance files and cache cleanup. It holds no catalog knowledge, and it asks an ArtifactSource what a checkpoint is before touching it. Its cache root and its extra-models directory are read per call, because a running server can be told about either after the store exists.
// Every model the store knows, filtered or not: a model that hardware filtering
// hides still has bytes on disk, and removal has to know that. Not the name index
// in model_index.h, which answers "which canonical id owns this bare name".
using KnownModels = std::map<std::string, ModelInfo>;
// What removal did, so the caller only has to decide what to report.
struct DeleteReport {
bool delegated = false; // a backend removed the weights it fetched
bool outside_managed_cache = false; // nothing removed; only the registration goes
bool cleaned_partial_download = false; // remnants of an interrupted download went too
};
struct OrphanedFile { std::string model, type, belongs_to, path; std::uint64_t size; };
struct CleanupReport { std::vector<OrphanedFile> orphaned; std::uint64_t total_bytes; };
class ModelArtifactStore {
public:
struct Hooks {
std::function<std::string()> cache_root; // read per call
std::function<std::unique_ptr<ArtifactSource>(const ModelInfo&, const std::string&)>
source_for; // what a checkpoint is
std::function<std::string()> extra_models_dir;
};
explicit ModelArtifactStore(Hooks);
std::string root() const;
backends::DownloadPolicy download_policy(const ModelInfo&) const;
// Where one checkpoint resolves inside the managed cache; empty for a
// collection, which is a virtual entry with no bytes of its own.
std::string resolve_path(const ModelInfo&, const std::string& type,
const std::string& checkpoint) const;
std::string resolve_path_in(const ModelInfo&, const std::string& type,
const std::string& checkpoint,
const std::string& cache_root) const;
void resolve_all(ModelInfo&) const;
// Repoint checkpoints at a complete copy found in the extra models directory,
// collecting the paths the adoption claims so discovery does not offer them again.
bool adopt_extra_cache(ModelInfo&, std::set<std::filesystem::path>* owned_paths = nullptr) const;
// Every required checkpoint present, uninterrupted and valid to the backend
// that reads it. See [Artifact Sources](#artifact-sources): one rule, and
// step S5 makes this a conjunction over present() rather than its own walk.
bool complete(const ModelInfo&) const;
static bool path_complete(const std::string& path); // exists and no in-flight marker
static std::uint64_t size_bytes(const std::filesystem::path&); // directories sum
static void refresh_on_disk_size(ModelInfo&); // registry size -> real
std::vector<ModelFileInfo> list_files(const ModelInfo&) const;
// Remove what the sources say lemond wrote, per checkpoint. `all_models` is
// the whole unfiltered map, which shared-repository scope cannot be answered
// without; the published snapshot is filtered and would hide a co-owner.
DeleteReport delete_artifacts(const ModelInfo&, const KnownModels& all_models) const;
CleanupReport cleanup_orphaned_cache(const KnownModels&, bool dry_run) const;
// A repository cache's own bookkeeping: which snapshot is active, and which
// checkpoint of which model was processed into it.
std::string active_snapshot_ref(const std::filesystem::path& repo_cache_dir) const;
std::filesystem::path active_snapshot_path(const std::filesystem::path&) const;
void write_active_snapshot(const std::filesystem::path&, const std::string& commit_hash) const;
void remove_unused_snapshot(const std::filesystem::path&, const std::string& snapshot_dir,
const std::string& active_ref) const;
// Empty when there is none, or when what was processed was for another
// selection: that mismatch is what makes a re-pull fetch again.
std::string processed_snapshot_id(const std::filesystem::path&, const ModelInfo&) const;
// checkpoint_type names the slot whose bytes landed in this repository; empty
// means main. A partial repository records only its own checkpoint, so it
// never claims a whole model.
void record_processed_model(const std::filesystem::path&, const ModelInfo&,
const Provenance&, const std::string& checkpoint_type = "") const;
};
// A checkpoint row's registry, normalized. Every read of ModelInfo::registry_source
// goes through this, because a row may name the registry in any of its aliases.
std::string registry_source_of(const ModelInfo&);
// The managed-cache directory name for one repository, in that registry's namespace.
std::string repo_id_to_cache_dir_name(const std::string& repo_id, const std::string& registry_source);
Provenance has no value type named for the record: ArtifactRef already carries the registry, repository, variant and revision, and the slot is an argument. What the file needs beyond them is Provenance{source, repo_id, revision, active_snapshot_id, processed_snapshot_id}, where active_snapshot_id is where refs/main points and processed_snapshot_id is what this run fetched — the two differ when a cancelled pull fell back to the snapshot already in place, and the difference is what the scan reads.
Deletion
Deleting a checkpoint is polymorphic, because a forgotten branch deletes a user's model directory.
| Source | Deletion |
Effect |
|---|---|---|
RegistryBackedRepository |
CacheDir |
remove the repository directory; when another model shares the repository, only this checkpoint's variant path goes |
LocalPathArtifactSource, local_path origin |
Nothing |
unregister the model; the bytes the user placed stay |
LocalPathArtifactSource, extra_models_dir origin |
Nothing |
the bytes are the user's, and rule 5 refuses the request before any of it applies |
LocalPathArtifactSource, local_upload origin |
CacheDir |
remove the copy lemond wrote inside its own cache directory |
SelfManagedArtifactSource |
Delegate("flm remove") |
the backend removes its own weights |
NoArtifactsSource |
Nothing |
unregister only |
Rules:
CacheDirrequires a containment proof: the resolved path is inside the configured models directory after resolving symlinks. A checkpoint that cannot prove containment is unregistered and logged, never deleted.- Blob reachability and shared-repository scope are computed here, not by the source: both need the whole model map, which a per-checkpoint source never sees. A source answers only
CacheDirorNothing. A shared repository loses only the variant being removed, and a checkpoint loses nothing while another model co-owns it. Co-ownership is a fact about this disk, not about the catalog: a row co-owns bytes when it is complete as it stands, when it holds a complete file in that repository that the removal does not name, or when a resume marker waits on one of its variants. A row that only names a checkpoint claims nothing — the file it resolves to is usually there because the model being deleted put it there, and a claim no delete can retire is space that never comes back. Co-ownership is read lexically, at where an entry sits in its repository rather than where its bytes are: a snapshot entry is commonly a link intoblobs/, and resolving it would move the entry out of the folder another checkpoint claims. Rule 1's containment stays resolved, because only the resolved path shows a link that leaves the managed cache. Taking bytes an installed model still reads would quietly un-download someone else's model. delete_artifacts()completes for every source, including bytes that sit outside any cache directory: the answer there isNothing, reported asoutside_managed_cache, not a failure.- A collection removes components by the same per-checkpoint rules, and a model still referenced by a loaded collection is refused.
Deletionanswers what may happen to bytes. Whether the model may be forgotten at all is a separate question, settled by who authors the definition: a row a catalog re-creates — an--extra-models-dirscan, a provider listing, a backend'sdiscover_models()— has no registration to remove. The request is refused with the path or provider named, because reporting a success the next scan contradicts is worse than refusing. Alocal_pathregistration is the opposite case: the registration is the whole record of the model, so refusing it would strand an entry nobody else can see.
Provenance File
Each repository cache directory holds one .lemonade_registry.json, version 2:
{
"version": 2,
"source": "huggingface",
"repo_id": "unsloth/Muse-Glimmer-30B-GGUF",
"revision": "main",
"snapshot_id": "…",
"processed_models": {
"Muse-Glimmer-30B-GGUF": {
"checkpoints": {
"main": {"selection": "…", "snapshot_id": "…"},
"mmproj": {"selection": "…", "snapshot_id": "…"}
}
}
}
}
Rules:
processed_models[<canonical_id>].checkpointsis keyed by checkpoint type, so two checkpoints of the same model can carry different snapshots.selectionis the artifact selection the snapshot was materialized for; an entry matches only when both fields agree, which is what makes a re-pull of a changed selection fetch again.- A file with no
version, orversion: 1, is read with its{selection, snapshot_id}entry standing for themaincheckpoint. Writes are always v2. Files on existing user disks stay readable forever, and a v2 file is never rewritten into v1. - Top-level
source,repo_id,revisionandsnapshot_iddescribe the repository and are independent of the per-checkpoint map. - The main repository's file keeps the v1 fields (
selection,snapshot_id,previous_snapshot_idwhen the pull moved the model) and fillscheckpoints.main, so a v1 reader and a v2 reader agree on one file. An auxiliary repository's file is markedversion: 2and records only its own checkpoint. A side cache shared by several models accumulates every dependent.
Ingest And Registration
This section defines the use cases that create, validate and remove model definitions.
class ModelIngest {
public:
std::optional<std::string> validate(const nlohmann::json& definition) const; // error text
PullResult pull(const nlohmann::json& request, PullMode,
DownloadProgressCallback, CancelToken&);
std::string register_model(const nlohmann::json& definition); // canonical id
std::string import_model(const std::string& path, const nlohmann::json& overrides);
void unregister_model(const std::string& canonical_id);
private:
// A collection manifest is a nested catalog: its entries go through the
// same CatalogAccumulator precedence rules as any other source.
std::vector<CatalogEntry> resolve_collection(const std::string& collection_id) const;
void register_components(const std::vector<CatalogEntry>&, const std::string& owner);
};
ModelIngest::pull() performs no HTTP. It resolves the definition, validates it, selects artifacts, and asks each selected source for acquisition(): a DownloadPlan goes to ArtifactTransfer, Delegated returns a message naming the backend that owns the fetch, NotApplicable returns success with no bytes. These invariants hold in every path:
- A collection registers before its components resolve, and rolls that registration back when component resolution fails.
- The cycle guard is scoped to the current recursion stack, so a legitimate DAG such as
A -> {B, C} -> Dis not read as a cycle. - Registration and recipe options persist before a cache shortcut returns, which is what makes a re-pull and an import idempotent.
- Progress callbacks are wrapped so a recursive per-component download emits exactly one terminal
completeevent to the SSE stream. do_not_upgradeskips a fetch, never a registration.
Backend Directives
This section keeps backend policy from depending on the model layer.
BackendOps has the right shape — every method has a default, so adding a method never edits backends that do not override it — and its context carries what it needs by const reference:
// Declared beside BackendOps; the registry and variant a download needs already
// live in the checkpoint's ArtifactRef, so the directive states only who fetches.
enum class DownloadPolicy { Repository, SelfManaged, None };
// backend_ops.h forward-declares ModelArtifactStore.
struct BackendOpsContext {
const ModelArtifactStore* artifacts; // disk state, const calls only
ArtifactFetchService* fetch; // registry choice + download engine
CloudProviderRegistry* cloud_registry; // dynamic discovery
};
class BackendOps {
public:
virtual DownloadPolicy plan_download(const ModelInfo&,
const BackendOpsContext&) const {
return DownloadPolicy::Repository;
}
virtual bool is_downloaded(const ModelInfo& info,
const BackendOpsContext& ctx) const {
return ctx.artifacts.complete(info);
}
virtual const ArtifactLayout* checkpoint_layout(const CheckpointRef&) const { return nullptr; }
};
plan_download()states what a backend needs fetched;ModelIngestperforms it. This is where a backend that pulls its own weights declares that fact, and the ingest path has no per-backend branch.- A lazily fetched side cache — the whisper
npu_cache.raifile — is a checkpoint withrequired = false, a repository, an explicit filename,SiblingLayout, and the shared engine's traversal guards, retries, resume, hash verification and cancellation. The backend keeps no download code and no path-resolution branch. BackendOpsContextnames no model-layer type.ModelArtifactStoreanswers disk state through const calls;ArtifactFetchServiceanswers the two fetch questions a backend cannot answer for itself — which registry a model reads from, and the engine that writes bytes into the models directory — and is the seamModelIngestrenames.
Sync
This section defines update scanning and the queue that applies updates.
Scanning is the only model-management work that starts on one request, runs for minutes, reports progress, and can be cancelled. That combination needs a queue, a worker and a progress state machine, and no other component here has a reason to hold them, so they live together behind this interface.
class SyncService {
public:
// The scan is a free function over its own deps, so a test drives the queue
// with a canned scan and the service never owns the network.
struct Deps {
std::function<UpdateScanResult(const std::vector<std::string>&)> scan;
std::function<void(const std::string& name, const nlohmann::json& options,
bool do_not_upgrade, DownloadProgressCallback)> download;
std::function<ModelInfo(const std::string& canonical)> model_info;
std::function<std::string(const std::string& name)> resolve_name;
std::function<std::string(const std::string& canonical)> public_name;
std::function<void(const std::string& phase)> phase;
};
// Queue ids are uint64_t and status is json, because that is the wire: an id
// a client polls with is the id, not a handle to translate.
struct Enqueued {
bool already_running = false;
uint64_t sync_id = 0;
};
explicit SyncService(Deps); // starts the one worker, which then waits
~SyncService(); // cancels and joins
Enqueued enqueue(const std::vector<std::string>& targets = {},
bool attach_if_running = false);
UpdateScanResult scan_for_updates(const std::vector<std::string>& targets = {});
nlohmann::json sync(const std::vector<std::string>& targets = {},
bool dry_run = false, bool attach_if_running = false);
nlohmann::json status(uint64_t sync_id = 0) const;
nlohmann::json wait_for(uint64_t generation) const;
void cancel();
void wait_until_idle(); // shutdown: a download must not outlive the store
bool cancel_requested() const;
bool should_auto_update(const ModelInfo&) const;
};
run_update_scan() runs these steps:
- Select targets: the named models, or every downloaded model with a required checkpoint on a remote registry.
- Open one
FetchableRepositoryper(registry, repository)group and drop every group whose source answerscan_go_stale()with false. Cloud, local and imported models drop out on their own capability, not on a caller's list: no listing, no report, no flag moved. - Take each group's latest tree from one
list(), and the previous revision's from a secondlist(repo, active). compare()per checkpoint, reusing the previous snapshot where the selected artifacts are unchanged. The artifact-aware refinement (select_main_repo_files_unionplusselected_artifacts_unchanged) composes with the snapshot-id equality rather than the other way round, so a repository that gained an unselected file does not set the flag.- Publish one rebuilt snapshot carrying the new
update_availableflags, then onemodels changednotification. A model whose check failed is reported as failed and gets no flag moved.
Rules:
- The service owns the one worker thread in model management, together with the serial queue, cancellation, the sync state and the SSE-visible phase machine. No other component creates a thread, and no caller of this service spawns work of its own.
- The worker runs only when work is queued: it drains the queue and waits on its condition variable. There is no timer, no polling interval, and no request that happens because time passed.
- Startup contributes at most one scan, gated by
auto_check_model_updates(), and that scan is an ordinary queued sync.should_auto_update()andModelInfo::auto_updateare the per-model override. - Every other scan starts with an explicit request:
POST /models/check-updatesscans without downloading,POST /internal/models/syncscans and applies. - A caller of
enqueue()while a sync runs attaches to it and receives that sync's id, so two clients of one lemond never start two passes over the same repositories. - Its mutex and condition variable are disjoint from store state, and it publishes only through the store's patch and rebuild path.
POST /models/check-updatesrejects with 409 whenoffline=true, and neither scanning nor syncing touches the network when offline.- A pass's outcome is decided in canonical ids and published from that decision, so the names reported for a publish and the rows it changed cannot disagree (
ScanDetermination{verified, updated, examined}).
Change Notification
This section defines how a new model set reaches the components that must react to it.
class ModelChangeNotifier {
public:
void on_models_changed(std::function<void(std::uint64_t)>); // one subscriber
void on_model_updated(std::function<void(const std::string&)>); // one subscriber
void models_changed(std::uint64_t generation);
void model_updated(const std::string& canonical_id);
};
Both consumers are routing, and each signal has exactly one:
| Signal | Recomputes |
|---|---|
models_changed(generation) |
which helper models the policies in force still need, from StoreSnapshot::helper_models |
model_updated(canonical_id) |
unloads that model when it is loaded, so the next request opens the new bytes |
Rules:
- Two signals and one subscriber each, registered while
ModelManageris constructed and living as long as lemond. There is no unsubscribe handle, no topic registry, and no event a subscriber must filter, because nothing here has a varying set of listeners. StoreSnapshot::generationis the change signal for everything else. A reader compares generations to decide whether to re-read, a rebuild coalesces on it, andModelManager::generation()exposes it without a callback.- Callbacks run outside every lock. A subscriber reads the snapshot published at the generation it was handed, never a draft: the notification follows the atomic swap.
- Producers of
models_changed: a catalog a watcher or settings event named, a completed ingest, deletion or sync scan, and a config change that alters the catalog set. Producers ofmodel_updated: a completed re-download whose resolved checkpoint paths differ from the paths in the published snapshot. An alias mutation produces neither. - Invalidation is event-driven, plus one window. Nothing probes: a locator's
read()hands back the state it observed (CatalogState) so a conditional GET is possible, and that is the whole freshness conversation a locator has. A file catalog is re-read when the watcher or a settings event names it; a fetched one re-reads when an in-process window over its cached body expires —CatalogFetchClock, one minute — because a rebuild is triggered by local events that cannot have changed what a remote host serves. The window is in-memory, so a restart always asks the origin, and a change to the catalog set clears it. ADirectoryWatchercallback holds no subscriber list and carries no data: it invalidates, and the rebuild that follows is what notifies. - A subscriber is added only where the snapshot cannot carry the answer. A consumer that needs currency rather than a signal compares
generation().
Recipe Options
This section defines persistence for per-model load options.
RecipeOptionsStore owns the file, the CRUD and the migration. The RecipeOptions value type (src/cpp/include/lemon/recipe_options.h) and its merge_precedence_layers() hold no persistence; the store calls them.
class RecipeOptionsStore {
public:
json entry(const std::string& canonical_id) const; // the user's own entry
json resolved(const std::string& canonical_id,
const ModelInfo&) const; // three-layer merge
json registry_defaults(const std::string& canonical_id) const;
json replace(const std::string& canonical_id, const json& entry);
json merge(const std::string& canonical_id, const json& changes);
void migrate();
};
- Keys are canonical IDs, so re-registering a model under a different source is an explicit key move.
- The component owns one mutex, which orders rewrites and is never held while calling into another component.
- It reads no catalog and no artifact state; the architecture-defaults overlay it merges arrives as a
const json&from the caller. migrate()normalizes entries to the current canonical form once at startup and logs the count it changed.
Concurrency
This section assigns each piece of state to one owner and one synchronization strategy.
| State | Owner | Synchronization |
|---|---|---|
StoreSnapshot, cache_valid_, generation_, the catalog set |
ModelStore |
the snapshot is std::atomic_load / std::atomic_store on shared_ptr<const StoreSnapshot>; writer_mutex_ covers a patch and a commit, builder_mutex_ covers one compose |
| A composed candidate | ModelComposition |
confined to the compose; never visible to a reader |
| Catalog state: mtime, size, ETag, Last-Modified | the locator that read it | the catalog's own mutex; a pinned document needs none |
| Recipe options map | RecipeOptionsStore |
one mutex for reads, one for rewrites |
| Sync queue, worker and sync state | SyncService |
one worker thread, its own mutex and condition variable |
| Per-repository download locks | ModelIngest |
one map of mutexes behind one guard |
Alias map, and the order saves of aliases.json reach the disk |
AliasManager |
one std::shared_mutex, taken exclusively across a whole save |
| Notifier callbacks | ModelChangeNotifier |
one mutex held only to read the stored callback; the callback runs unlocked, after the swap |
Rules:
- Reading the published set takes no lock.
ModelStore::read()is an atomic load, and a caller then reads a snapshot, an immutableModelInfoinside it, or a value the snapshot already holds — including a rejection reason. The paths that may need a set built (resolve,model_exists,is_model_downloaded,get_supported_models,get_downloaded_models) runensure_current()first, which probeswriter_mutex_for the stale flag and returns immediately when the set is fresh; only a caller that arrives while the set is genuinely stale waits, onbuilder_mutex_, for the one compose in progress — measured at 47 ms warm and 251 ms when a configured catalog has to be fetched. One rebuild at a time is the point: a second caller waits rather than reading a half-built set or starting a second pass over the same catalogs. - A compose never mutates the published snapshot. Steps write to a candidate, and the swap publishes a complete object.
- No component calls into another component's mutex: interactions are a value in, a value out, or one call on the change notifier. Where a lock is held across a call, the order is fixed and stated in code: a repository download lock is taken before the store's writer, never the reverse (
repo-lock → store-lock). - The facade's members are constructed once and never reassigned, so it needs no synchronization of its own. The two exceptions are named where they live:
extra_models_dir_, set byset_extra_models_dir()at startup, andrecipe_options_write_mutex_, which orders an options file rewrite together with the matching cache patch so two concurrent writers cannot leave the cache holding the earlier entry. - A write scope holds the store's writer for its extent, so it must not contain a call into another component. Everything a patch needs from disk is read against the published view before the scope opens; that is what keeps a download commit at 15 µs of lock instead of 2.0 ms, and what keeps a read that self-heals from re-entering the writer it is waiting on.
Source Layout
This section places each component in the tree and states how it is built.
src/cpp/include/lemon/models/ PUBLIC to consumers — exactly four:
model_info.h ModelInfo, GgufMetadata, ImageDefaults, ModelFileInfo
store_snapshot.h StoreSnapshot, ResolvedModel, CheckpointStatus
registry_types.h wire facts that persist on disk or cross HTTP
model_errors.h what a refused call throws
...everything else internal to the models layer; FacadeShapeTest names and
retires any other header that reaches a consumer
— identity and sources
artifact_ref.h ArtifactRef, CheckpointRef
artifact_source.h ArtifactSource, Presence, Acquisition, Deletion, FileValidator
artifact_layout.h ArtifactLayout, HfHubLayout, SiblingLayout, SourcePlacement
artifact_resolver.h ArtifactSourceResolver, RegistrationError
fetchable_repository.h FetchableRepository, registry_download_plan, create_repository,
the registration macros
registry_id.h RegistryId, RegistryDescriptor, descriptor_for()
registry_descriptors.h the shipped hosts' descriptors
registry_types.h RepoListing-side wire facts
artifact_fetch.h ArtifactFetchService — the backend-facing fetch seam
— catalogs
catalog.h ModelCatalog, CatalogEntry, CatalogAccumulator, EnumeratedCatalog
catalog_descriptor.h CatalogDescriptor, catalog_rank, Conflict
catalog_locator.h CatalogLocator, HttpCatalogSource, CatalogState
catalog_fetch_clock.h CatalogFetchClock — the window over a fetched body
json_catalog.h JsonCatalog, parse_registry_row, RegistryRowRules
scan_catalog.h ScanCatalog — the extra-directory scan
listed_catalog.h ListedCatalog
configured_catalog.h ConfiguredCatalog, make_configured_catalog()
catalog_set.h CatalogSet, CatalogSetDeps, product_catalogs()
registry_documents.h RegistryDocuments — the two registry files, pinned
— the published set
store.h ModelStore, ModelStore::Write, ModelStore::Build
store_snapshot.h (public, above)
store_builder.h filter_hardware, FilterResult, FilterReasons, alias maps
composition.h ModelComposition, CompositionResult
backend_availability.h BackendAvailability
host_availability.h the machine probe
model_index.h which canonical id owns a bare name
entry_identity.h canonical id minting and parsing
checkpoint_grammar.h repo:variant and scheme spellings
definition_labels.h deployment labels
alias_manager.h AliasManager
— use cases and plumbing
ingest.h ModelIngest
sync_service.h SyncService
update_scan.h run_update_scan, UpdateScanDeps, publish_scan_flags
artifact_store.h ModelArtifactStore, Provenance, KnownModels, DeleteReport
artifact_transfer.h ArtifactTransfer, FileTransfer, DownloadPlan, TransferError
download_progress.h DownloadProgress, DownloadProgressCallback, ByteProgress
recipe_options_store.h RecipeOptionsStore
recipe_option_layers.h build_recipe_options
routing_parse_options.h the routing-policy parse seam
change_notifier.h ModelChangeNotifier
cloud_rows.h CloudRows
model_sources.h model_sources_json() — what /internal/model-sources answers
doc_entry.h DocEntry, EnvVar
src/cpp/server/models/ one .cpp per header above, plus:
registry_repository.cpp the registry adapter — one of exactly two files allowed
to name the legacy ModelRegistry header (the other is
src/cpp/server/model_registry.{h,cpp}, private to the server)
src/cpp/server/model_registry.{h,cpp} the legacy host protocols, private to the server
src/cpp/server/model_manager.cpp the facade and its composition root
- Every
.cppundersrc/cpp/server/models/is named in thelemonade-server-coresource list in the rootCMakeLists.txt, so a new catalog or repository adds one line there. The CLI compiles its ownCOMMON_SOURCESlist insrc/cpp/cli/CMakeLists.txt; a file that reaches the backend registry stays out of it, which is what lets one binary name a host without being able to pull from it. alias_manager.{h,cpp}lives inlemon/models/andsrc/cpp/server/models/, because resolution is the only consumer of its lookup half. Its header includes the standard library alone;load()andsave()keep the file I/O in the.cpp.- Headers in
lemon/models/include the standard library,nlohmann/json, and each other. None includes a backend header,router.horserver.h. Contact with backends isBackendOps,BackendAvailability, and injectedstd::functionseams — the idiomrouting_policy.hdocuments. In the other direction, no header includes the facade header. A header that needs to name the orchestrator takesmodel_manager_types.h, the leaf that declaresModelManagerandModelInfo; the facade is included only by the translation units that call it. That is what keeps a backend's compile off the models layer — before this rule held, editing the facade recompiled three quarters of the tree becausewrapped_server.h,backend_manager.h,backend_ops.handrouter.heach passed it onward.BackendOpsContextandArtifactFetchServiceare the sanctioned seam, and a backend that needs more has found a gap in them.FacadeShapeTestmeasures the facade's transitive reach — a direct-include count can be satisfied by cosmetic edits — and fails outright if any header includes the facade. Both numbers travel down. - One component per
.cpp. A file past roughly 500 lines signals a component that is holding two responsibilities, andModelArtifactStoreandModelIngestare the ones to watch. A file with no mutex, no virtual and no injected deps is a namespace split rather than a component and belongs beside the header that owns the concept. - Four models headers are public, beside the facade's own:
model_info.h, the value object that crosses the whole server by value;store_snapshot.h, the read-only set a consumer holds while it works,ResolvedModelandCheckpointStatusincluded;registry_types.h, the wire facts that persist on disk or cross HTTP; andmodel_errors.h, what a refused call throws. The legacyModelRegistryclass is not public at all — its header sits insrc/cpp/server/and only its adapter pair may name it. The write protocol beside the snapshot stays internal, and a consumer that needs something the facade does not expose has found a gap to fix in the facade or the component behind it. Nothing inlemon/models/includes the facade header: the value object lives in the leaf, so the dependency graph points at the facade from the top, never from below. - The
ModelManagerconstructor is the only place any of these components is created. Nothing else in the process news a store, a catalog or a repository — the repository factory seamModelManagerConfig::repository_foris threaded unchanged into the pull, the scan, the resolver and the checkpoint-status read, which is what makes it the single injection point and a fixture host obeyed by every consumer.
Contracts
This section states the persistent and wire formats this design owns.
On Disk
| Artifact | Contract |
|---|---|
server_models.json |
Model definitions keyed by bare name. An entry may set required: false on a checkpoint to mark its bytes optional |
user_models.json |
Registered definitions keyed by canonical handle. source and registry_source hold registry ids and the local origin tokens local_upload, local_path, extra_models_dir |
aliases.json |
Alias to target map in the cache directory, loaded at startup, written on every mutation, mode 0600, cyclic chains pruned at load |
recipe_options.json |
Per-model options keyed by canonical ID |
.lemonade_registry.json |
Per-repository provenance, version 2 with per-checkpoint keys, per Provenance File |
| Cache directories | <repo-cache>/{refs/main, snapshots/<snapshot-id>/, .lemonade_registry.json} under a provider-qualified directory, per docs/dev/model-registries.md |
.partial, .download_manifest.json |
In-flight transfer state, cleared on commit |
ModelSource tokens, registry ids and local origin tokens are stable across releases: a value written by any release resolves in every later one. An id a build does not recognize skips its model with a warning and never aborts startup.
API Surfaces
| Surface | Contract |
|---|---|
/v1/models, GET /v1/models/{id} |
One row per precedence winner under its bare name, one canonical-prefixed row per shadowed source, and one row per alias that resolves to a listed model. id, checkpoint, checkpoints, recipe, downloaded, update_available, suggested, source, registry_source, labels, components, recipe_options, size, cloud_provider keep their meanings; checkpoint_status is additive |
POST /pull, GET /pull/variants, GET /registry/search |
source accepts a registry id or a local origin token. A registry added later is another accepted value, and /registry/search covers only registries declaring search |
POST /models/check-updates, POST /internal/models/sync, GET /internal/models/sync/status |
Request and response bodies are stable, served by SyncService; check-updates answers 409 when offline=true |
/internal/aliases |
Bodies are stable, backed by AliasManager |
/load, /unload, /delete, /models/{id}/options |
Status codes and error shapes are stable; names resolve through ModelStore::resolve() |
| Catalog staleness | No field and no route. A catalog gaining entries changes only the model list |
Generated Docs
This section defines the descriptor each catalog and registry declares, and the docs generated from it.
A source documents itself or CI fails. The descriptor is the single place a source's user-visible facts are written, and the generator emits the docs tables from it — the same loop gen_backend_boilerplate.py runs for backends and the API reference.
Descriptors
struct DocEntry {
std::string id;
std::string title;
std::string summary; // one sentence, one table cell
std::vector<std::string> notes; // one bullet each
std::string example; // URL template, e.g. "https://huggingface.co/{repo}"
};
struct EnvVar { std::string name; std::string purpose; bool required = false; };
// registry_id.h — a host's identity, its spellings, and its documented facts in
// one row. Registered before any repository, so the identity exists even where
// no repository is open.
struct RegistryDescriptor {
RegistryId id;
std::vector<std::string> aliases; // accepted spellings, lower case, "" for the default
std::string display_name;
std::string scheme_prefix; // "hf" -> "hf://" in a machine-minted id
std::string cache_dir_prefix; // "models--", the namespace in the models dir
std::string default_revision; // "main" vs "master"
std::string endpoint_default; // "" when the host serves files only via its API
std::string download_protocol;
DocEntry doc;
std::vector<EnvVar> env;
std::string revision_semantics;
std::string staleness_basis;
bool supports_search = false;
};
// catalog_descriptor.h — precedence and docs from one row, so ordering is
// declared rather than inherited from a construction order.
struct CatalogDescriptor {
std::string id;
bool required = false;
DocEntry doc;
std::string config_key; // "" when not user-configurable
bool host_specific = false; // exists only because of what this machine
// has installed: never written to a
// committed page
int rank = catalog_rank::scan;
Conflict claim = Conflict::KeepExisting;
};
A repository declares no descriptor method: it is constructed for one RegistryId and reads its facts back through descriptor_for(id). Registration carries both descriptors — LEMONADE_REGISTER_REGISTRY_DESCRIPTOR for identity alone, LEMONADE_REGISTER_ARTIFACT_REPOSITORY for identity plus the factory.
GET /internal/model-sources answers the payload the generator reads, and nothing else may serve these facts:
| Key | Contents |
|---|---|
registries |
one object per RegistryDescriptor: token, aliases, display_name, title, summary, notes, example, endpoint_default, scheme, cache_dir_prefix, default_revision, revision_semantics, staleness_basis, download_protocol, search, env[] |
catalogs |
one object per catalog the last store build composed: id, title, summary, notes, example, required, config_key, host_specific |
source_tokens |
every registry id — the tokens a provenance or source field may hold |
local_origin_tokens |
local_upload, local_path, extra_models_dir |
pull_source_values |
registry ids then local origin tokens — exactly what POST /pull's source accepts |
Because catalogs comes from the published snapshot, the endpoint builds the store on first read; ModelManager::model_sources() is the one call that does.
Generator
docs/tools/gen_model_source_boilerplate.py follows gen_backend_boilerplate.py and reuses its Lemond harness: it starts lemond with no model loaded, reads /internal/model-sources, and rewrites only BEGIN/END GENERATED regions. Markers are <!-- BEGIN GENERATED: name --> … <!-- END GENERATED: name -->, inline or on their own lines.
| Page | Regions |
|---|---|
docs/dev/model-registries.md |
model-registries (one row per registry: endpoint, schemes, environment, cache prefix, revision semantics, staleness basis, search), model-registries-notes, remote-registry-tokens, local-origin-tokens, pull-source-values |
docs/guide/configuration/custom-models.md |
model-catalogs (one row per non-host_specific catalog: title, summary, required, config key), remote-registry-tokens |
docs/guide/cli.md |
cli-pull-source-values |
Rows whose host_specific is true name an installed backend or a configured provider, so they vary by machine and are never written into a committed page; the page says so in prose between the regions. Page intros and prose that is not a table stay hand-written.
Example
This is the contract the generator implements: one descriptor in, one table row out, with no fact in the docs that the descriptor does not carry.
The extra-models catalog is declared in product_catalogs():
make_catalog_descriptor(
"extra_models_dir", catalog_rank::scan, Conflict::KeepExisting,
DocEntry{"extra-models", "Extra models directory",
"A scan of the extra models directory; GGUF files are identified "
"by their header, not their name.",
{"A directory watcher requests one rebuild when the tree changes."},
""},
"extra_models_dir"),
and becomes one row of docs/guide/configuration/custom-models.md:
<!-- BEGIN GENERATED: model-catalogs -->
| Catalog | Entries come from | Required | Config key |
| --- | --- | --- | --- |
| Built-in registry | A scan of the extra models directory; … | no | `extra_models_dir` |
<!-- END GENERATED: model-catalogs -->
(a row per non-host_specific catalog: Built-in registry, Registrations, Extra models directory — three today).
A registry descriptor renders the same way. builtin_registries::huggingface() declares the fields shown in Descriptors, and Hugging Face's row of docs/dev/model-registries.md reads:
<!-- BEGIN GENERATED: model-registries -->
| Registry | Endpoint | Schemes | Environment | Cache prefix | Revision semantics | Staleness basis | Search |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `huggingface` | `https://huggingface.co` | `hf://` | `HF_TOKEN` and `HF_ENDPOINT` | `models--` | branch, tag or commit SHA | the commit SHA the API reports for the revision | yes |
<!-- END GENERATED: model-registries -->
with a notes region beneath carrying each notes entry as a bullet, the example as a URL form and the default_revision as its own line.
- Every column is a descriptor field, and every descriptor field feeds a column or a note. A fact with no field behind it does not belong in the page.
- The generator rewrites the regions byte for byte from the live
/internal/model-sourcespayload, which is what makes the drift check a diff rather than a judgment.
Model Object Fields
One object serializes for every surface: ModelJson::to_json builds the row, and ModelJson::schema() describes it. Each model route's RouteSpec points at that schema, so the field list in docs/api is generated rather than hand-kept, and a new field is added by naming its source row or facade call in to_json and its sentence in schema(). The endpoint pins in test/server_endpoints.py are what catch drift — checkpoint_status is the worked example: a row of CheckpointStatus structs, documented entries (type, present, required, registry, path), pinned against a live server, with path behind include_paths because it belongs to one machine and so never reaches a listing that travels.
Drift Check
CI runs gen_model_source_boilerplate.py --check beside the other generators, without loading a model. It fails when:
- any generated text is stale — the committed page does not equal what the running server would write, and it names the page and the command to run
- the payload has no registries or no catalogs: a source with no descriptor must fail loudly rather than empty a page
- a page or a region marker named by the generator is missing
A source that exists but declares no descriptor cannot reach the payload at all, so the registration macros exist to make an undocumented source impossible rather than to have the checker notice one afterwards. The author fixes drift by running the generator and committing the result, which is the same loop as the API docs.
Tests
This section states the coverage that protects the design and the tests that prove the extension ports work.
| Test | Covers |
|---|---|
test_store_snapshot |
build publishes atomically; a reader sees either generation in full; rejected, empty_recipes and rejection reasons present on every build |
test_store_builder |
golden StoreSnapshot for a fixture catalog set: merge, precedence, alias maps, projection; filter_hardware() over the whole set and over one entry, with rejection reasons |
test_model_catalog |
each CatalogLocator read policy, required() throw versus contribute-nothing, the state each read observes |
test_name_resolution |
snapshot beats alias, chains to MAX_ALIAS_CHAIN_DEPTH, a cycle yields no target, a chain still naming an alias at the limit yields no target, :latest, input_aliases, wire_id under an alias |
test_model_manager_facade (test_facade_shape) |
the facade constructed over a fixture ModelManagerConfig with temp paths; a static check that no consumer reaches a models header beyond model_info.h, registry_types.h, store_snapshot.h and download_progress.h; the facade's transitive reach, and that no header includes it; the one-way ceilings — lines, declarations, members, locks — and the zero assertions: no _for_test, no *_override_, exactly two files may name the legacy registry header, no provider string literal in core |
test_artifact_resolver |
the seven selection rules, per-checkpoint independence, a model with two sources |
test_download_plan |
manifest JSON to DownloadPlan, disk-space accounting, GGUF magic rejection, hash mismatch fails without retry, cancellation throws |
test_file_transfer |
scheme dispatch across http and file, one mixed-scheme plan reporting one progress total, a file:// plan that completes with offline=true, auth_headers() stripped on a cross-origin redirect and still sent on a same-origin one |
test_model_deletion |
every Deletion row, the containment check, shared-repo variant delete |
test_model_delete_shared_repo |
who co-owns a repository: a row that only names a checkpoint does not strand bytes, an installed twin does, and the last model to go takes the shared files with it; resume markers attributed by name and by variant, sharded and directory checkpoints, adopted external caches |
test_registry_provenance |
v1 read, v2 write, per-checkpoint snapshot keys, selection mismatch triggering a fetch |
test_pull_registry |
the end-to-end pull through the repository seam: the exact URL plan per checkpoint, snapshot directories keyed by listing revision, refs/main pins, resume manifest, provenance v2 as pulled, an unmet auxiliary checkpoint refusing before any bytes move, a pre-set CancelToken stopping short of landing artifacts, and checkpoint_status() reporting each checkpoint where its source says it is |
test_identity_round_trip |
every registered catalog's minted IDs survive parse_canonical_id() and is_reserved_registration_name() |
test_model_change_notifier |
one subscriber per signal, callbacks run unlocked and after the swap, a reader at generation N never sees generation N+1 |
test_model_sync_service |
target selection, can_go_stale() filtering, per-group listing, auto-update override, cancellation |
test_model_source_descriptors |
every registered source has a descriptor with a non-empty summary, and the /internal/model-sources payload matches the compiled sets |
test_update_available |
the flag a scan publishes to the row a client reads: set for a newer snapshot, cleared when the registry answers the snapshot on disk, unmoved by a failed check, never set for a model with no remote registry to ask, and never announced for a model whose repositories did not all answer. The repository arrives through ModelManagerConfig.repository_for |
These suites cover behavior this spec pins, and they are the contract: test_model_manager_collection_validation, test_model_download_state, test_model_sync_and_auto_update, test_extra_model_discovery, test_model_registry, test_hf_update_check, test_recipe_hiding, test_streaming_memory_filter, test_config_default_source, test_model_alias, test_directory_watcher, test_recipe_options_precedence.
Acceptance criteria. These prove the axis split is real rather than cosmetic:
with no edit to ModelStore, SyncService, ModelIngest, ModelManager or ModelInfo
2. A second JsonCatalog from a fixture path appearing in the merged view with correct precedence against a built-in of the same bare name.
3. A catalog-staleness test that adds an entry to a JSON file and observes one models_changed notification carrying a new generation, with no repository network call and no change to update_available.
4. A fixture standing LocalPathArtifactSource in for a remote host, serving a directory and pulling a model with no HTTP at all and no edit outside its own file. It expresses the copy as a file:// plan that ArtifactTransfer dispatches to a FileTransfer; if that cannot be done, Transfer And Layout is wrong.
5. A model with two checkpoints from different sources — main from a fixture ArtifactSource and a required = false side cache from a LocalPathArtifactSource — resolving, reporting presence, fetching and deleting independently.
6. Every seam a test needs is a fixture: a temp cache directory, a fixture catalog in ModelManagerConfig.extra_catalogs, or a local HTTP fixture. No component exposes a *_for_test setter or an override hook on its public interface.
All new tests register through add_cpp_ci_test(<Name> CI ON ...) and become dependencies of cpp-ci-tests, so they run under ctest -L cpp-ci.