Skip to content

Commit 4c0f83e

Browse files
authored
feat(server): add model registration endpoint (#3118)
* feat(server): add model registration endpoint * feat(server): add model registration endpoint * small name catch
1 parent bc5614b commit 4c0f83e

6 files changed

Lines changed: 498 additions & 150 deletions

File tree

docs/api/lemonade.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ We have designed a set of Lemonade-specific endpoints to enable client applicati
1010
| Method | Endpoint | Description |
1111
|--------|----------|-------------|
1212
| `POST` | [`/v1/pull`](#post-v1pull) | Install a model |
13+
| `POST` | [`/v1/models/register`](#post-v1modelsregister) | Register or update a user model definition without downloading it |
1314
| `GET` | [`/v1/downloads`](#get-v1downloads) | List server-owned model download jobs |
1415
| `POST` | [`/v1/downloads/control`](#post-v1downloadscontrol) | Pause, cancel, or remove server-owned model download jobs |
1516
| `GET` | [`/v1/registry/search`](#get-v1registrysearch) | Search Hugging Face or ModelScope for model repositories |
@@ -222,6 +223,72 @@ When `include_paths=true` is supplied, each file entry also includes `path`:
222223
| `files[].size_bytes` | File size in bytes. Directories are summed recursively. Missing files report `0`. |
223224
| `files[].exists` | Whether the resolved path currently exists on disk. |
224225

226+
## `POST /v1/models/register`
227+
<sub>![Status](https://img.shields.io/badge/status-fully_available-green)</sub>
228+
229+
Register or update a `user.*` model definition without downloading model files.
230+
Use this endpoint when registration and installation are separate actions.
231+
`POST /v1/pull` remains the install/download path and performs the same internal
232+
registration step before downloading.
233+
234+
The endpoint is available at:
235+
236+
- `/v1/models/register`
237+
- `/api/v1/models/register`
238+
- `/v0/models/register`
239+
- `/api/v0/models/register`
240+
241+
### Parameters
242+
243+
| Parameter | Required | Description |
244+
|-----------|----------|-------------|
245+
| `model_name` | Yes | Non-empty namespaced model name under `user.*`. |
246+
| `recipe` | Yes | Lemonade recipe associated with the model definition. |
247+
| `checkpoint` | No | Main checkpoint, when the recipe uses one. |
248+
| `checkpoints` | No | Named checkpoints for multi-checkpoint models. |
249+
| `source` | No | Registry or local source. Remote values are `huggingface` or `modelscope`. |
250+
| `labels` | No | Additional model labels. |
251+
| `components` | No | Already-registered component model names for collection recipes. |
252+
253+
A checkpoint is intentionally not universally required: registration is a model
254+
metadata operation and some present or future model types may not have local
255+
weights. `/pull` remains the operation that attempts installation/download.
256+
257+
The endpoint accepts one model definition. An embedded `models` array represents
258+
multiple definitions and remains a collection-import concern; register those
259+
component definitions first when using this endpoint.
260+
261+
Example request:
262+
263+
```bash
264+
curl -X POST http://localhost:13305/v1/models/register \\
265+
-H "Content-Type: application/json" \\
266+
-d '{
267+
"model_name": "user.Phi-4-Mini-GGUF",
268+
"checkpoint": "unsloth/Phi-4-mini-instruct-GGUF:Q4_K_M",
269+
"recipe": "llamacpp"
270+
}'
271+
```
272+
273+
Example response:
274+
275+
```json
276+
{
277+
"status": "success",
278+
"model_name": "Phi-4-Mini-GGUF",
279+
"canonical_model_name": "user.Phi-4-Mini-GGUF",
280+
"model": {
281+
"id": "Phi-4-Mini-GGUF",
282+
"recipe": "llamacpp",
283+
"downloaded": false
284+
}
285+
}
286+
```
287+
288+
`model_name` is the public ID exposed by `/v1/models`; `canonical_model_name` is
289+
the stable `user.*` registration ID. Registration updates `user_models.json` and
290+
invalidates the model cache, but does not start a model download.
291+
225292
## `POST /v1/pull`
226293
<sub>![Status](https://img.shields.io/badge/status-fully_available-green)</sub>
227294

src/cpp/include/lemon/model_manager.h

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,13 @@ class ModelManager {
210210
const json& model_data,
211211
const std::string& source = "");
212212

213+
// Register or validate a model definition without downloading its files.
214+
// Uses the same registration path as download_model.
215+
void register_model(const std::string& model_name,
216+
const json& model_data,
217+
bool allow_missing_checkpoint = false,
218+
bool replace_existing = false);
219+
213220
// Register (if needed) and download a model
214221
void download_model(const std::string& model_name,
215222
const json& model_data,
@@ -315,7 +322,10 @@ class ModelManager {
315322
const json& model_data,
316323
bool do_not_upgrade,
317324
DownloadProgressCallback progress_callback,
318-
std::set<std::string>& visited);
325+
std::set<std::string>& visited,
326+
bool register_only,
327+
bool allow_missing_checkpoint,
328+
bool replace_existing);
319329

320330
json load_server_models();
321331
json load_architecture_defaults();

src/cpp/include/lemon/server.h

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,21 @@ class Server {
107107
void handle_health(const httplib::Request& req, httplib::Response& res);
108108
void handle_live(const httplib::Request& req, httplib::Response& res);
109109
void handle_models(const httplib::Request& req, httplib::Response& res);
110+
void handle_model_register(const httplib::Request& req, httplib::Response& res);
111+
void validate_model_registration_name(const std::string& model_name,
112+
bool require_user_namespace);
113+
void normalize_model_registration_source(nlohmann::json& request_json,
114+
bool local_import);
115+
void validate_and_canonicalize_collection_registration(
116+
const std::string& model_name,
117+
nlohmann::json& request_json,
118+
bool allow_embedded_models);
119+
std::string register_model_definition_internal(
120+
const std::string& model_name,
121+
nlohmann::json& request_json,
122+
bool require_definition,
123+
bool allow_embedded_models,
124+
bool local_import);
110125
void handle_model_by_id(const httplib::Request& req, httplib::Response& res);
111126
void handle_model_update_check(const httplib::Request& req, httplib::Response& res);
112127
void handle_model_files(const httplib::Request& req, httplib::Response& res);

src/cpp/server/model_manager.cpp

Lines changed: 88 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3371,19 +3371,32 @@ void ModelManager::populate_collection_components_from_cache_locked(ModelInfo& i
33713371
}
33723372
}
33733373

3374+
void ModelManager::register_model(const std::string& model_name,
3375+
const json& model_data,
3376+
bool allow_missing_checkpoint,
3377+
bool replace_existing) {
3378+
std::set<std::string> visited;
3379+
download_model(model_name, model_data, true, nullptr, visited,
3380+
true, allow_missing_checkpoint, replace_existing);
3381+
}
3382+
33743383
void ModelManager::download_model(const std::string& model_name,
33753384
const json& model_data,
33763385
bool do_not_upgrade,
33773386
DownloadProgressCallback progress_callback) {
33783387
std::set<std::string> visited;
3379-
download_model(model_name, model_data, do_not_upgrade, progress_callback, visited);
3388+
download_model(model_name, model_data, do_not_upgrade, progress_callback, visited,
3389+
false, false, false);
33803390
}
33813391

33823392
void ModelManager::download_model(const std::string& model_name,
33833393
const json& model_data,
33843394
bool do_not_upgrade,
33853395
DownloadProgressCallback progress_callback,
3386-
std::set<std::string>& visited) {
3396+
std::set<std::string>& visited,
3397+
bool register_only,
3398+
bool allow_missing_checkpoint,
3399+
bool replace_existing) {
33873400
// Keep a mutable registration payload so legacy re-pulls that omit the
33883401
// registry retain the source recorded on the existing model. The original
33893402
// request remains untouched for validation and download semantics.
@@ -3446,8 +3459,13 @@ void ModelManager::download_model(const std::string& model_name,
34463459
}
34473460
LOG(INFO, "ModelManager") << "Registering new collection: " << model_name << std::endl;
34483461
} else {
3449-
// Check that required arguments are provided
3450-
if (actual_checkpoint.empty() || actual_recipe.empty()) {
3462+
if (actual_recipe.empty()) {
3463+
throw std::runtime_error(
3464+
"Model " + model_name + " is not registered with Lemonade Server. "
3465+
"To register it, provide the `recipe` argument."
3466+
);
3467+
}
3468+
if (actual_checkpoint.empty() && !allow_missing_checkpoint) {
34513469
throw std::runtime_error(
34523470
"Model " + model_name + " is not registered with Lemonade Server. "
34533471
"To register and install it, provide the `checkpoint` and `recipe` "
@@ -3456,10 +3474,12 @@ void ModelManager::download_model(const std::string& model_name,
34563474
}
34573475

34583476
// Backend-specific checkpoint validation (llamacpp: GGUF needs :variant).
3459-
if (auto err = backends::ops_for(actual_recipe)->validate_registration_checkpoint(
3460-
actual_checkpoint);
3461-
!err.empty()) {
3462-
throw std::runtime_error(err);
3477+
if (!actual_checkpoint.empty()) {
3478+
if (auto err = backends::ops_for(actual_recipe)->validate_registration_checkpoint(
3479+
actual_checkpoint);
3480+
!err.empty()) {
3481+
throw std::runtime_error(err);
3482+
}
34633483
}
34643484

34653485
LOG(INFO, "ModelManager") << "Registering new user model: " << model_name << std::endl;
@@ -3477,34 +3497,49 @@ void ModelManager::download_model(const std::string& model_name,
34773497
registration_data["source"] = effective_registry_source(info);
34783498
}
34793499

3480-
bool is_collection_overwrite = is_model_collection_recipe(actual_recipe) &&
3481-
model_data.contains("components");
3482-
if (is_collection_overwrite) {
3483-
// Validate the original user-authored request, not registration_data:
3484-
// the latter is enriched with the persisted registry source, which is
3485-
// not part of the public routing-policy document the parser accepts.
3486-
if (auto err = validate_collection_request(model_name, model_data)) {
3487-
throw std::runtime_error(*err);
3500+
const bool explicit_definition =
3501+
!actual_recipe.empty() || !actual_checkpoint.empty() ||
3502+
model_data.contains("checkpoints") || model_data.contains("components");
3503+
if (register_only && replace_existing &&
3504+
is_user_model_name(model_name) && explicit_definition) {
3505+
if (is_model_collection_recipe(actual_recipe)) {
3506+
if (auto err = validate_collection_request(model_name, model_data)) {
3507+
throw std::runtime_error(*err);
3508+
}
34883509
}
34893510
model_registered = false;
3490-
LOG(INFO, "ModelManager") << "Overwriting collection: "
3511+
LOG(INFO, "ModelManager") << "Replacing user model definition: "
34913512
<< model_name << std::endl;
3492-
} else if (actual_checkpoint.empty()) {
3493-
actual_checkpoint = info.checkpoint();
3494-
actual_recipe = info.recipe;
34953513
} else {
3496-
std::string conflict = describe_registration_conflict(info, registration_data);
3497-
if (!conflict.empty()) {
3498-
throw std::runtime_error(
3499-
"Model '" + model_name + "' is already registered with different "
3500-
"model metadata: " + conflict + ". Choose a different model name "
3501-
"for this registry checkpoint."
3502-
);
3503-
}
3504-
if (actual_recipe.empty()) {
3514+
bool is_collection_overwrite = is_model_collection_recipe(actual_recipe) &&
3515+
model_data.contains("components");
3516+
if (is_collection_overwrite) {
3517+
// Validate the original user-authored request, not registration_data:
3518+
// the latter is enriched with the persisted registry source, which is
3519+
// not part of the public routing-policy document the parser accepts.
3520+
if (auto err = validate_collection_request(model_name, model_data)) {
3521+
throw std::runtime_error(*err);
3522+
}
3523+
model_registered = false;
3524+
LOG(INFO, "ModelManager") << "Overwriting collection: "
3525+
<< model_name << std::endl;
3526+
} else if (actual_checkpoint.empty()) {
3527+
actual_checkpoint = info.checkpoint();
35053528
actual_recipe = info.recipe;
35063529
} else {
3507-
model_registered = false;
3530+
std::string conflict = describe_registration_conflict(info, registration_data);
3531+
if (!conflict.empty()) {
3532+
throw std::runtime_error(
3533+
"Model '" + model_name + "' is already registered with different "
3534+
"model metadata: " + conflict + ". Choose a different model name "
3535+
"for this registry checkpoint."
3536+
);
3537+
}
3538+
if (actual_recipe.empty()) {
3539+
actual_recipe = info.recipe;
3540+
} else {
3541+
model_registered = false;
3542+
}
35083543
}
35093544
}
35103545
}
@@ -3529,6 +3564,10 @@ void ModelManager::download_model(const std::string& model_name,
35293564
collection_registered_this_call = true;
35303565
}
35313566

3567+
if (register_only && is_model_collection_recipe(actual_recipe)) {
3568+
return;
3569+
}
3570+
35323571
// Collections don't have their own backend - download each component instead.
35333572
//
35343573
// Persistence follows one rule, uniform across models and collections: a
@@ -3635,7 +3674,7 @@ void ModelManager::download_model(const std::string& model_name,
36353674
}
36363675
LOG(INFO, "ModelManager") << "Downloading component: " << component << std::endl;
36373676
json comp_data = json::object();
3638-
download_model(component, comp_data, do_not_upgrade, forward, visited);
3677+
download_model(component, comp_data, do_not_upgrade, forward, visited, false, false, false);
36393678
}
36403679

36413680
// A registry-backed collection's in-memory components were empty until the
@@ -3676,20 +3715,6 @@ void ModelManager::download_model(const std::string& model_name,
36763715
);
36773716
}
36783717

3679-
LOG(INFO, "ModelManager") << "Downloading model: " << repo_id;
3680-
if (!variant.empty()) {
3681-
LOG(INFO, "ModelManager") << " (variant: " << variant << ")";
3682-
}
3683-
LOG(INFO, "ModelManager") << std::endl;
3684-
3685-
// Check if offline mode
3686-
if (auto* cfg = RuntimeConfig::global()) {
3687-
if (cfg->offline()) {
3688-
LOG(INFO, "ModelManager") << "Offline mode enabled, skipping download" << std::endl;
3689-
return;
3690-
}
3691-
}
3692-
36933718
// Persist registration and recipe options BEFORE the cache-first shortcut
36943719
// below. A registration/import/overwrite that targets an already-downloaded
36953720
// model must still update user_models.json and recipe_options.json. The
@@ -3716,6 +3741,24 @@ void ModelManager::download_model(const std::string& model_name,
37163741
save_model_options(model_info);
37173742
}
37183743

3744+
if (register_only) {
3745+
return;
3746+
}
3747+
3748+
LOG(INFO, "ModelManager") << "Downloading model: " << repo_id;
3749+
if (!variant.empty()) {
3750+
LOG(INFO, "ModelManager") << " (variant: " << variant << ")";
3751+
}
3752+
LOG(INFO, "ModelManager") << std::endl;
3753+
3754+
// Check if offline mode
3755+
if (auto* cfg = RuntimeConfig::global()) {
3756+
if (cfg->offline()) {
3757+
LOG(INFO, "ModelManager") << "Offline mode enabled, skipping download" << std::endl;
3758+
return;
3759+
}
3760+
}
3761+
37193762
// CRITICAL: If do_not_upgrade=true AND model is already downloaded, skip the
37203763
// remote-registry update check. Registration and recipe options were already
37213764
// persisted above, so an import/overwrite still takes effect on disk.

0 commit comments

Comments
 (0)