Skip to content

Commit d68cce4

Browse files
ngxsonpapamoose
authored andcommitted
server: (router) add model management API (ggml-org#23976)
* wip * server: (router) add SSE realtime updates API * nits * wip * add download API * add download api * update docs * add delete endpoint * fix std::terminate * fix crash * fix 2 * add tests * nits
1 parent b71acc6 commit d68cce4

16 files changed

Lines changed: 854 additions & 62 deletions

common/download.cpp

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -997,3 +997,87 @@ std::vector<common_cached_model_info> common_list_cached_models() {
997997

998998
return result;
999999
}
1000+
1001+
bool common_download_remove(const std::string & hf_repo_with_tag) {
1002+
namespace fs = std::filesystem;
1003+
1004+
auto [repo_id, tag] = common_download_split_repo_tag(hf_repo_with_tag);
1005+
1006+
if (tag.empty()) {
1007+
return hf_cache::remove_cached_repo(repo_id);
1008+
}
1009+
1010+
std::string tag_upper = tag;
1011+
for (char & c : tag_upper) {
1012+
c = (char) std::toupper((unsigned char) c);
1013+
}
1014+
1015+
auto files = hf_cache::get_cached_files(repo_id);
1016+
if (files.empty()) {
1017+
return false;
1018+
}
1019+
1020+
// collect snapshot entries whose tag matches
1021+
std::vector<fs::path> to_remove;
1022+
for (const auto & f : files) {
1023+
auto split = get_gguf_split_info(f.path);
1024+
if (split.tag == tag_upper) {
1025+
to_remove.emplace_back(f.local_path);
1026+
}
1027+
}
1028+
1029+
if (to_remove.empty()) {
1030+
return false;
1031+
}
1032+
1033+
// resolve blob paths from symlinks before deleting snapshot entries
1034+
std::vector<fs::path> blobs_to_check;
1035+
for (const auto & p : to_remove) {
1036+
std::error_code ec;
1037+
if (fs::is_symlink(p, ec)) {
1038+
auto target = fs::read_symlink(p, ec);
1039+
if (!ec) {
1040+
blobs_to_check.push_back((p.parent_path() / target).lexically_normal());
1041+
}
1042+
}
1043+
}
1044+
1045+
// remove snapshot entries
1046+
for (const auto & p : to_remove) {
1047+
std::error_code ec;
1048+
fs::remove(p, ec);
1049+
if (ec) {
1050+
LOG_WRN("%s: failed to remove %s: %s\n", __func__, p.string().c_str(), ec.message().c_str());
1051+
}
1052+
}
1053+
1054+
if (blobs_to_check.empty()) {
1055+
return true;
1056+
}
1057+
1058+
// collect blobs still referenced by remaining snapshot entries
1059+
std::unordered_set<std::string> still_referenced;
1060+
for (const auto & f : hf_cache::get_cached_files(repo_id)) {
1061+
fs::path p(f.local_path);
1062+
std::error_code ec;
1063+
if (fs::is_symlink(p, ec)) {
1064+
auto target = fs::read_symlink(p, ec);
1065+
if (!ec) {
1066+
still_referenced.insert((p.parent_path() / target).lexically_normal().string());
1067+
}
1068+
}
1069+
}
1070+
1071+
// remove orphaned blobs
1072+
for (const auto & blob : blobs_to_check) {
1073+
if (still_referenced.find(blob.string()) == still_referenced.end()) {
1074+
std::error_code ec;
1075+
fs::remove(blob, ec);
1076+
if (ec) {
1077+
LOG_WRN("%s: failed to remove blob %s: %s\n", __func__, blob.string().c_str(), ec.message().c_str());
1078+
}
1079+
}
1080+
}
1081+
1082+
return true;
1083+
}

common/download.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,10 @@ int common_download_file_single(const std::string & url,
115115
// resolve and download model from Docker registry
116116
// return local path to downloaded model file
117117
std::string common_docker_resolve_model(const std::string & docker);
118+
119+
// Remove a cached model from disk
120+
// input format: "user/model" or "user/model:tag"
121+
// - if tag is omitted, removes the entire repo cache directory
122+
// - if tag is present, removes only files matching that tag (and orphaned blobs)
123+
// returns true if anything was removed
124+
bool common_download_remove(const std::string & hf_repo_with_tag);

common/hf-cache.cpp

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,4 +495,19 @@ std::string finalize_file(const hf_file & file) {
495495
return file.final_path;
496496
}
497497

498+
bool remove_cached_repo(const std::string & repo_id) {
499+
if (!is_valid_repo_id(repo_id)) {
500+
LOG_WRN("%s: invalid repository: %s\n", __func__, repo_id.c_str());
501+
return false;
502+
}
503+
fs::path repo_path = get_repo_path(repo_id);
504+
std::error_code ec;
505+
auto removed = fs::remove_all(repo_path, ec);
506+
if (ec) {
507+
LOG_ERR("%s: failed to remove repo cache %s: %s\n", __func__, repo_path.string().c_str(), ec.message().c_str());
508+
return false;
509+
}
510+
return removed > 0;
511+
}
512+
498513
} // namespace hf_cache

common/hf-cache.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,7 @@ hf_files get_cached_files(const std::string & repo_id = {});
2929
// Create snapshot path (link or move/copy) and return it
3030
std::string finalize_file(const hf_file & file);
3131

32+
// Remove the entire cached directory for a repo, returns true if removed
33+
bool remove_cached_repo(const std::string & repo_id);
34+
3235
} // namespace hf_cache

tools/server/README-dev.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,24 @@ That requires `JSON.stringify` when formatted to message content:
180180
}
181181
```
182182

183+
### Model management API (router mode)
184+
185+
Model management API was added via PR [#23976](https://github.com/ggml-org/llama.cpp/pull/23976)
186+
187+
The main goal of this API is to allow downloading models and/or removing models from the web UI. It relies on the model cache infrastructure under the hood to manage the list of models dynamically.
188+
189+
Instead of building everything from the ground up (like what most AI agents will do when you ask them to implement a similar feature), we built on top of existing, already well-engineered components inside the codebase:
190+
- Model cache infrastructure as mentioned above (`common/download.h`)
191+
- Server response queue (`server-queue.h`). We use this feature to broadcast events to SSE clients.
192+
- Server router thread management (`server-models.h`). We re-use the same thread model that is used for managing subprocess life cycle, except that we don't create a new subprocess, but launch the download right inside the thread.
193+
194+
The flow for downloading a new model:
195+
- POST request comes in --> `post_router_models` --> validation
196+
- `server_models::download()` is called
197+
- Sets up a new thread `inst.th` and runs the download inside
198+
- If a stop request comes in, set `stop_download` to `true`
199+
- Otherwise, upon completion, we call `load_models()` to refresh the list of models
200+
183201
### Notable Related PRs
184202

185203
- Initial server implementation: https://github.com/ggml-org/llama.cpp/pull/1443

tools/server/README.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1778,6 +1778,20 @@ The `status` object can be:
17781778
}
17791779
```
17801780

1781+
Note: for "downloading" state, there can be multiple files be downloading in parallel
1782+
1783+
```json
1784+
"status": {
1785+
"value": "downloading",
1786+
"progress": {
1787+
"https://...model.gguf": {
1788+
"done": 195963406,
1789+
"total": 219307424
1790+
}
1791+
}
1792+
}
1793+
```
1794+
17811795
### POST `/models/load`: Load a model
17821796

17831797
Load a model
@@ -1820,6 +1834,107 @@ Response:
18201834
}
18211835
```
18221836

1837+
### GET `/models/sse`: Real-time events
1838+
1839+
Example events:
1840+
1841+
```js
1842+
{
1843+
"model": "...",
1844+
"event": "model_status",
1845+
"data": {
1846+
"status": "loading"
1847+
}
1848+
}
1849+
1850+
{
1851+
"model": "...",
1852+
"event": "download_progress",
1853+
"data": {
1854+
// note: there can be multiple files being downloaded in parallel
1855+
"https://...model.gguf": {
1856+
"done": 195963406,
1857+
"total": 219307424
1858+
}
1859+
}
1860+
}
1861+
1862+
{
1863+
"model": "...",
1864+
"event": "download_finished",
1865+
"data": {
1866+
"status": "loading"
1867+
}
1868+
}
1869+
1870+
{
1871+
"model": "...",
1872+
"event": "model_remove"
1873+
}
1874+
1875+
// special event: reload of the list of all models
1876+
{
1877+
"model": "*",
1878+
"event": "models_reload"
1879+
}
1880+
```
1881+
1882+
### POST `/models`: Download new model
1883+
1884+
Trigger a new download (non-blocking), the progress can be tracked via SSE endpoint `/models/sse`
1885+
1886+
To cancel model downloading, send an event to `/models/unload`
1887+
1888+
Download procedure:
1889+
- Send POST request to `/models`
1890+
- Subscribe to `/models/sse` for updates
1891+
- On downloading completed, you will receive either `download_finished` or `download_failed` event
1892+
- Call GET `/models` to trigger model list update. If the download success, you should see the new model in the list
1893+
1894+
Payload:
1895+
1896+
```json
1897+
{
1898+
"model": "ggml-org/gemma-3-4b-it-GGUF:Q4_K_M",
1899+
}
1900+
```
1901+
1902+
Response (download is started in the background):
1903+
1904+
```json
1905+
{
1906+
"success": true
1907+
}
1908+
```
1909+
1910+
Response (error, cannot start the download):
1911+
1912+
```json
1913+
{
1914+
"error": {
1915+
"code": 400,
1916+
"message": "model validation failed, unable to download",
1917+
"type": "invalid_request_error"
1918+
}
1919+
}
1920+
```
1921+
1922+
### DELETE `/models`: Delete a model from cache
1923+
1924+
IMPORTANT: only model stored in cache can be deleted. You cannot delete models in a preset.
1925+
1926+
Model name must be passed via query param: `?model={name}`
1927+
1928+
If delete success, it will send an SSE event of type `model_remove`
1929+
1930+
Response:
1931+
1932+
```json
1933+
{
1934+
"success": true
1935+
}
1936+
```
1937+
18231938
## API errors
18241939

18251940
`llama-server` returns errors in the same format as OAI: https://github.com/openai/openai-openapi

tools/server/server-http.cpp

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,23 @@ void server_http_context::post(const std::string & path, const server_http_conte
588588
});
589589
}
590590

591+
void server_http_context::del(const std::string & path, const server_http_context::handler_t & handler) const {
592+
handlers.emplace(path, handler);
593+
pimpl->srv->Delete(path_prefix + path, [handler](const httplib::Request & req, httplib::Response & res) {
594+
server_http_req_ptr request = std::make_unique<server_http_req>(server_http_req{
595+
get_params(req),
596+
get_headers(req),
597+
req.path,
598+
build_query_string(req),
599+
req.body,
600+
{},
601+
req.is_connection_closed
602+
});
603+
server_http_res_ptr response = handler(*request);
604+
process_handler_response(std::move(request), response, res);
605+
});
606+
}
607+
591608
//
592609
// Vertex AI Prediction protocol (AIP_PREDICT_ROUTE)
593610
// https://cloud.google.com/vertex-ai/docs/predictions/custom-container-requirements

tools/server/server-http.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ struct server_http_context {
8686

8787
void get(const std::string & path, const handler_t & handler) const;
8888
void post(const std::string & path, const handler_t & handler) const;
89+
void del(const std::string & path, const handler_t & handler) const;
8990

9091
// Register the Google Cloud Platform (Vertex AI) compat (AIP_PREDICT_ROUTE env var, or /predict)
9192
// Must be called AFTER all other API routes are registered

0 commit comments

Comments
 (0)