Repository navigation
feat: model state in /v1/models, lifecycle API, GET /api/ps and GET /api/models/:model - #755
androidand wants to merge 20 commits into
Conversation
Add `state` and `loaded` fields to every model entry in the
`GET /v1/models` response, reflecting the actual process state
(ready/starting/stopping/stopped). This lets clients distinguish
hot models (loaded in VRAM) from cold ones without polling the
/api/events SSE stream.
Add two new REST endpoints for model lifecycle management:
DELETE /v1/models/:model
Stops the model's backing process immediately, freeing VRAM.
Returns 404 if the model is unknown, 200 with updated state.
POST /v1/models/:model/load
Warms the model by routing a minimal request through the proxy,
causing llama-swap to start the process and load weights into VRAM.
Returns the model object with updated state once ready.
Both endpoints follow the same auth (apiKeyAuth) and model-resolution
(RealModelName alias lookup) patterns as existing routes. The internal
modelProcessState helper is extracted from the getModelStatus() path to
avoid duplicating the matrix/processGroup state-lookup logic.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
GET /api/models/:model returns config + runtime state for a single model
without fetching the full list. Returns 404 for unknown model IDs.
GET /api/ps returns only loaded (state=ready) models in an
Ollama-compatible envelope {"models": [...]}, making it easy to see
which models currently occupy VRAM.
Tests added for both endpoints covering ready/stopped states and 404.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds model runtime state reporting and includes ChangesModel State Tracking and Management API
Model Storage, Pull, Delete, and Config
🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
proxy/proxymanager_test.go (1)
1947-2061: ⚡ Quick winAdd coverage for alias and slash-style model IDs on the new endpoints.
The new tests only exercise simple IDs. Please add cases for alias resolution and slash-delimited IDs (for both load and detail endpoints) to prevent route/lookup regressions.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@proxy/proxymanager_test.go` around lines 1947 - 2061, Add test coverage for alias resolution and slash-delimited model IDs for the load and detail endpoints: extend or add to TestProxyManager_LoadSingleModel (and its NotFound variant) and TestProxyManager_ModelDetail to include models defined with an "alias" (or "id" mapping) and a model whose key contains a slash; send POST requests to /api/models/load/<alias> and /api/models/load/<slash/id> and GET requests to /api/models/<alias> and /api/models/<slash/id>, then assert the same behaviors as existing checks (HTTP 200 + state transitions for load, 200 with correct metadata for detail, and 404 for unknown aliases/paths). Reference the existing test functions (TestProxyManager_LoadSingleModel, TestProxyManager_LoadSingleModel_NotFound, TestProxyManager_ModelDetail) and the POST /api/models/load/:model and GET /api/models/:model routes when locating where to add these assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@proxy/proxymanager_api.go`:
- Around line 36-37: The GET route for models is brittle with IDs that contain
slashes; change the route patterns from "/models/:model" to a wildcard like
"/models/*model" for both places where you register it (the one using
pm.apiGetModelHandler and the other occurrence at lines ~379-380) and update the
handler to extract and sanitize the wildcard param (e.g., use c.Param("model"),
strip any leading "/" and URL-decode) so IDs like "author/model" are handled
correctly.
- Around line 361-367: apiLoadSingleModelHandler currently calls
findGroupByModelName directly which bypasses swapProcessGroup and therefore
skips exclusive-group eviction and swap semantics; update this handler to use
pm.swapProcessGroup(requestedModel) (or the appropriate swap entrypoint used by
normal routing) to obtain the active processGroup before calling
processGroup.ProxyRequest(realModelName, dw, req), and preserve error handling
via pm.sendErrorResponse if swapProcessGroup returns nil or an error; ensure the
same swap-based path is used as in normal inference routing so exclusive-group
behavior is honored.
In `@proxy/proxymanager.go`:
- Around line 621-622: Alias rows are calling pm.modelProcessState(modelId) with
the alias ID, causing incorrect state/loaded values; change the alias-row logic
(the spots that build alias entries around the pm.modelProcessState call,
including the other occurrence at the block around lines 655–659) to resolve the
alias to its canonical model ID first and then call
pm.modelProcessState(canonicalID) so runtime state and loaded are computed for
the canonical model instead of the alias.
- Around line 602-613: The switch over process.CurrentState() currently maps
known states but returns "stopped" in the default, which masks unexpected
values; change the default branch in the switch (around process.CurrentState())
to return "unknown", false instead of "stopped", false so unmapped/new process
states are explicitly reported as "unknown".
---
Nitpick comments:
In `@proxy/proxymanager_test.go`:
- Around line 1947-2061: Add test coverage for alias resolution and
slash-delimited model IDs for the load and detail endpoints: extend or add to
TestProxyManager_LoadSingleModel (and its NotFound variant) and
TestProxyManager_ModelDetail to include models defined with an "alias" (or "id"
mapping) and a model whose key contains a slash; send POST requests to
/api/models/load/<alias> and /api/models/load/<slash/id> and GET requests to
/api/models/<alias> and /api/models/<slash/id>, then assert the same behaviors
as existing checks (HTTP 200 + state transitions for load, 200 with correct
metadata for detail, and 404 for unknown aliases/paths). Reference the existing
test functions (TestProxyManager_LoadSingleModel,
TestProxyManager_LoadSingleModel_NotFound, TestProxyManager_ModelDetail) and the
POST /api/models/load/:model and GET /api/models/:model routes when locating
where to add these assertions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: b38d885c-d3ff-403b-93ea-49c821bd5467
📒 Files selected for processing (3)
proxy/proxymanager.goproxy/proxymanager_api.goproxy/proxymanager_test.go
GET /api/storage — disk usage for the models directory (configured or
inferred from cmd paths). Useful for capacity checks
before downloading a new model.
POST /api/models/pull — download a GGUF from HuggingFace with streaming
progress (ndjson, Ollama-compatible envelope).
Accepts owner/repo/file.gguf shorthand or a full URL.
HF_TOKEN env var or per-request token field for gated
models. Downloads to a temp .part file and atomically
renames on completion.
DELETE /api/models/:model — unloads the model (if running) then removes the
weight file from disk. Config entry is preserved.
Config: new optional modelsDir field (yaml: modelsDir) specifies the model
storage directory. Falls back to inferring it from the first model's --model
flag when unset.
Tests cover: path parsing, HF URL resolution, storage endpoint (configured
and inferred), pull with local test server, gated model 401 propagation,
delete happy path and 404.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
There was a problem hiding this comment.
Actionable comments posted: 6
♻️ Duplicate comments (2)
proxy/proxymanager_api.go (2)
36-37:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
GET /api/models/:modelandDELETE /api/models/:modelare brittle for model IDs containing/.Both route patterns use
:modelinstead of*model, so they won't naturally support slash-style IDs (e.g.,author/model) unless clients URL-encode the slash. This is inconsistent with the load/unload routes (lines 34-35) which use wildcards.Consider changing both to wildcards and adjusting the handlers to trim the leading
/:- apiGroup.GET("/models/:model", pm.apiGetModelHandler) - apiGroup.DELETE("/models/:model", pm.apiDeleteModel) + apiGroup.GET("/models/*model", pm.apiGetModelHandler) + apiGroup.DELETE("/models/*model", pm.apiDeleteModel)Then update
apiGetModelHandler(line 382):- requestedModel := c.Param("model") + requestedModel := strings.TrimPrefix(c.Param("model"), "/")🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@proxy/proxymanager_api.go` around lines 36 - 37, Routes for GET and DELETE use :model which breaks on model IDs containing slashes; change the route definitions apiGroup.GET("/models/:model", pm.apiGetModelHandler) and apiGroup.DELETE("/models/:model", pm.apiDeleteModel) to use wildcards ("/models/*model") to match slash-containing IDs, and update the handlers apiGetModelHandler and apiDeleteModel to normalize the param by trimming any leading '/' from c.Param("model") (or equivalent) before lookup so the rest of the code receives the same model ID format as load/unload handlers.
361-369:⚠️ Potential issue | 🟠 Major | ⚡ Quick winLoad endpoint bypasses group swap and exclusive-group eviction logic.
apiLoadSingleModelHandlerdirectly usesfindGroupByModelName(line 364) instead ofswapProcessGroup, which is used by normal inference routing. This bypasses exclusive-group eviction semantics, potentially allowing multiple exclusive groups to remain loaded.Update the handler to use the same swap path as normal routing:
var loadErr error if pm.matrix != nil { loadErr = pm.matrix.ProxyRequest(realModelName, dw, req) } else { - processGroup := pm.findGroupByModelName(realModelName) - if processGroup == nil { - pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("process group not found for model %s", requestedModel)) - return - } - loadErr = processGroup.ProxyRequest(realModelName, dw, req) + processGroup, err := pm.swapProcessGroup(realModelName) + if err != nil { + pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error swapping process group: %s", err.Error())) + return + } + loadErr = processGroup.ProxyRequest(realModelName, dw, req) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@proxy/proxymanager_api.go` around lines 361 - 369, The load handler currently bypasses the group-swap/eviction logic by calling findGroupByModelName; replace that lookup with the same swap path used by normal routing: call swapProcessGroup(pm, realModelName, c.Request.Context() or existing ctx) to obtain the processGroup (fall back to pm.matrix.ProxyRequest when pm.matrix != nil as before), check for nil and return the same error via pm.sendErrorResponse, then call processGroup.ProxyRequest(realModelName, dw, req); ensure you preserve existing error handling and semantics of swapProcessGroup (exclusive-group eviction) rather than using findGroupByModelName.
🧹 Nitpick comments (3)
proxy/proxymanager_models_test.go (2)
191-216: ⚡ Quick winAssertion doesn't validate the gated error body.
The test accepts either 401 or 403, but never inspects the JSON envelope (
{"error": "..."}) produced byapiPullModelfor gated responses. A regression that flipped the body to a generic message or swallowed thegated; provide a tokenhint wouldn't be caught. Decoding the body and checking it contains the gated-token hint would make this test much stronger for very little code.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@proxy/proxymanager_models_test.go` around lines 191 - 216, Update TestAPIPullModelGated to decode and assert the JSON error envelope from the response body after pm.ServeHTTP instead of only checking status codes: read w.Body, unmarshal into a struct with an Error string (matching the apiPullModel response shape), and assert that the error message contains the gated-token hint (e.g., contains "gated" or "token" / "provide a token"); keep the existing status code check but add this body assertion in TestAPIPullModelGated to catch regressions that change or swallow the gated hint.
142-188: ⚡ Quick winPull test only covers
stream:false; default streaming path is untested.The default for
pullRequest.Streamistrue, but every pull test sets"stream":false. The ndjson framing insendJSON(line 186–193), the periodic 10 MB progress reports, the locked-200 status behavior, and the terminalsuccessevent are therefore never exercised. Consider adding one case that omitsstream(or sets it true), parses the body as newline-delimited JSON, and asserts at least adownloadingand asuccessevent are emitted.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@proxy/proxymanager_models_test.go` around lines 142 - 188, Add a streaming test variant to TestAPIPullModel that omits the "stream" field (so pullRequest.Stream defaults to true) or explicitly sets it true; call pm.ServeHTTP with the same model URL, read the response body as newline-delimited JSON (ndjson) and parse each JSON line emitted by sendJSON, asserting you see at least one "downloading" (progress) event and a final "success" event (and that the file was written). Use the same helpers (CreateTestResponseRecorder, New config with ModelsDir, pm.StopProcesses) and verify the periodic progress/framing behavior and locked-200 terminal behavior by checking event types/fields rather than a single JSON object.proxy/proxymanager_models.go (1)
299-308: ⚡ Quick winCheck
f.Close()error before rename.
f.Close()can surface deferred write errors (buffer flush, fsync on some filesystems). Ignoring it and renaming straight away means a truncated file can be promoted to the final destination. Capture the close error and treat it like any other write failure, and optionallyf.Sync()before close for stronger durability.♻️ Suggested fix
- } - f.Close() - - if err := os.Rename(tmp, dest); err != nil { + } + if err := f.Close(); err != nil { + os.Remove(tmp) + sendJSON(pullProgress{Status: "error", Error: err.Error()}) + if !stream { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } + return + } + + if err := os.Rename(tmp, dest); err != nil { os.Remove(tmp)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@proxy/proxymanager_models.go` around lines 299 - 308, The code currently ignores f.Close() before promoting the temp file; capture the result of f.Sync() (optional for durability) and f.Close(), check for errors, and treat them the same way you handle the os.Rename failure: remove tmp, call sendJSON(pullProgress{Status: "error", Error: err.Error()}), respond with c.JSON(...) when !stream, and return; update the block around f.Close(), os.Rename, tmp, dest and use the same error handling path so a failed flush/close prevents renaming the truncated file.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@proxy/proxymanager_models_test.go`:
- Around line 16-33: Rename the test functions so they follow the project's
TestProxyManager_<name> convention: change TestParseModelPath to
TestProxyManager_parseModelPath and likewise rename TestResolveHFSource,
TestAPIGetStorage, TestAPIGetStorageInferred, TestAPIPullModel,
TestAPIPullModelGated, TestAPIDeleteModel, and TestAPIDeleteModelNotFound to
TestProxyManager_resolveHFSource, TestProxyManager_apiGetStorage,
TestProxyManager_apiGetStorageInferred, TestProxyManager_apiPullModel,
TestProxyManager_apiPullModelGated, TestProxyManager_apiDeleteModel, and
TestProxyManager_apiDeleteModelNotFound respectively so they group with other
ProxyManager tests (update any references/imports if needed).
In `@proxy/proxymanager_models.go`:
- Around line 178-193: The stream handler currently locks HTTP 200 on the first
ndjson write and ignores write/flush errors; update the logic so when
upstream/gated response status is known (before sendJSON is first called) you
call c.Writer.WriteHeader(upstreamStatusCode) (or set the appropriate Gin
response status) before any c.Writer.Write/Flush to ensure the real HTTP status
is sent, and for all c.Writer.Write and c.Writer.Flush calls check their
returned error and abort/return (or close the stream) on error or if ctx.Done is
triggered; modify sendJSON (and any callers) to accept/return an error so
callers can stop on write failures and ensure non-stream paths also set
c.Status(...) before writing body.
- Around line 238-247: The current path-traversal check using strings.HasPrefix
on filepath.Clean(dest) vs filepath.Clean(dir) is unsafe; replace it with a
robust boundary check using filepath.Rel: compute rel, err :=
filepath.Rel(filepath.Clean(dir), filepath.Clean(dest)) and treat any err or any
rel that equals "."? no — treat any err or where rel starts with ".." or
contains ".." (path traversal) as invalid; keep the existing error handling
(sendJSON(pullProgress{...}) and c.JSON(...) when !stream) and reference the
variables dest, dir, filename and functions sendJSON, pullProgress, stream,
c.JSON when applying the change.
- Line 11: The file currently imports and uses Unix-only syscall types
(syscall.Statfs, syscall.Statfs_t) in apiGetStorage which breaks Windows builds;
split the platform-specific implementation: move the Statfs-based logic into a
new proxymanager_models_unix.go with a //go:build unix tag and implement a
helper used by apiGetStorage (e.g., getFilesystemStats or similar) that uses
syscall.Statfs/Statfs_t, and add proxymanager_models_windows.go with //go:build
windows that implements the same helper but returns an error or a
501-not-implemented result; update apiGetStorage to call this helper so builds
on both platforms succeed without direct syscall imports on Windows.
- Around line 62-74: modelsDir() currently iterates pm.config.Models (a map)
which is order-dependent; change it to iterate model IDs in a stable, sorted
order (same sorting used in config.go at load time): collect keys from
pm.config.Models, sort them (e.g. sort.Strings), then iterate those keys and
call parseModelPath(mc.Cmd) on pm.config.Models[id] to infer the directory;
update ProxyManager.modelsDir to return filepath.Dir(p) from the first
resolvable model in that sorted order so the result is deterministic.
- Around line 35-60: Replace the local sanitizeCommand implementation with a
call to the shared config.SanitizeCommand: remove the local sanitizeCommand
function and in parseModelPath (and any callers) call
config.SanitizeCommand(cmd) instead, handling the returned ([]string, error)
properly (i.e., check and return/log the error from config.SanitizeCommand
rather than assuming nil). Also update imports to reference the config package
and remove any now-dead error-free assumptions (delete the existing dead err !=
nil branch that expected a local error behavior).
---
Duplicate comments:
In `@proxy/proxymanager_api.go`:
- Around line 36-37: Routes for GET and DELETE use :model which breaks on model
IDs containing slashes; change the route definitions
apiGroup.GET("/models/:model", pm.apiGetModelHandler) and
apiGroup.DELETE("/models/:model", pm.apiDeleteModel) to use wildcards
("/models/*model") to match slash-containing IDs, and update the handlers
apiGetModelHandler and apiDeleteModel to normalize the param by trimming any
leading '/' from c.Param("model") (or equivalent) before lookup so the rest of
the code receives the same model ID format as load/unload handlers.
- Around line 361-369: The load handler currently bypasses the
group-swap/eviction logic by calling findGroupByModelName; replace that lookup
with the same swap path used by normal routing: call swapProcessGroup(pm,
realModelName, c.Request.Context() or existing ctx) to obtain the processGroup
(fall back to pm.matrix.ProxyRequest when pm.matrix != nil as before), check for
nil and return the same error via pm.sendErrorResponse, then call
processGroup.ProxyRequest(realModelName, dw, req); ensure you preserve existing
error handling and semantics of swapProcessGroup (exclusive-group eviction)
rather than using findGroupByModelName.
---
Nitpick comments:
In `@proxy/proxymanager_models_test.go`:
- Around line 191-216: Update TestAPIPullModelGated to decode and assert the
JSON error envelope from the response body after pm.ServeHTTP instead of only
checking status codes: read w.Body, unmarshal into a struct with an Error string
(matching the apiPullModel response shape), and assert that the error message
contains the gated-token hint (e.g., contains "gated" or "token" / "provide a
token"); keep the existing status code check but add this body assertion in
TestAPIPullModelGated to catch regressions that change or swallow the gated
hint.
- Around line 142-188: Add a streaming test variant to TestAPIPullModel that
omits the "stream" field (so pullRequest.Stream defaults to true) or explicitly
sets it true; call pm.ServeHTTP with the same model URL, read the response body
as newline-delimited JSON (ndjson) and parse each JSON line emitted by sendJSON,
asserting you see at least one "downloading" (progress) event and a final
"success" event (and that the file was written). Use the same helpers
(CreateTestResponseRecorder, New config with ModelsDir, pm.StopProcesses) and
verify the periodic progress/framing behavior and locked-200 terminal behavior
by checking event types/fields rather than a single JSON object.
In `@proxy/proxymanager_models.go`:
- Around line 299-308: The code currently ignores f.Close() before promoting the
temp file; capture the result of f.Sync() (optional for durability) and
f.Close(), check for errors, and treat them the same way you handle the
os.Rename failure: remove tmp, call sendJSON(pullProgress{Status: "error",
Error: err.Error()}), respond with c.JSON(...) when !stream, and return; update
the block around f.Close(), os.Rename, tmp, dest and use the same error handling
path so a failed flush/close prevents renaming the truncated file.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 0f2a2154-edb0-4321-adb7-e1fc44650512
📒 Files selected for processing (4)
proxy/config/config.goproxy/proxymanager_api.goproxy/proxymanager_models.goproxy/proxymanager_models_test.go
Related work — PR #569While reviewing open PRs before finalising this one, I noticed #569 (feat: add status and ttl to /v1/models endpoint) by @ghostdevv, which overlaps with part of this PR. Flagging it so the maintainer can decide how to reconcile: Overlap:
|
| PR #569 | This PR (#755) | |
|---|---|---|
| Placement | meta.llamaswap.state |
top-level state field |
| Extra fields | ttl, ttlRemaining |
loaded (bool shorthand for state == "ready") |
| Helper method | getModelState() |
modelProcessState() |
Both add the same underlying information. #569's meta.llamaswap placement is more conservative (vendor-extension namespace, won't affect strict OpenAI clients). My top-level placement is more directly machine-readable but adds non-standard keys to the OpenAI-compatible schema.
Options for the maintainer:
- A) Merge both, remove the duplicate helper, keep
state/loadedat top-level and insidemeta.llamaswap(verbose but backward-compatible either way) - B) Drop the top-level
state/loadedfrom this PR and move them undermeta.llamaswapto align with feat: add status and ttl to /v1/models endpoint #569; addloadedto feat: add status and ttl to /v1/models endpoint #569 as a convenience boolean - C) Drop the
/v1/modelsstate addition from this PR entirely (it's already covered by feat: add status and ttl to /v1/models endpoint #569) and keep only the new/api/endpoints —GET /api/ps,GET /api/models/:model,POST /api/models/load/*model, andPOST /api/models/unload/*model
I'm happy to rebase and adjust to whichever direction you prefer.
No conflict on the new /api/ endpoints
#569 adds GET /api/models (list all models with state). This PR adds GET /api/models/:model (single-model detail) and GET /api/ps (loaded-only, Ollama-compatible envelope). These coexist cleanly with no path conflicts.
The load/unload lifecycle endpoints (POST /api/models/load/*model) follow the same verb-first convention as the existing POST /api/models/unload, so no design deviation there.
…lars YAML | block scalars embed \n between arguments. parseModelPath was returning paths with trailing \n characters, causing os.Remove to fail with 'file not found' even when the file existed. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
POST /api/models/pull now accepts an optional "subdir" field that places
the downloaded file in modelsDir/subdir/filename.gguf rather than flat
in modelsDir. The subdirectory is created automatically if absent.
This matches real-world llama-swap configs where each model lives in its
own subdirectory (e.g. models/mistral-small-3.1-24b/model.gguf).
Path traversal ("..") and absolute paths are rejected with 400.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@proxy/proxymanager_models.go`:
- Around line 355-360: The current delete path swallows errors from StopProcess,
which can leave the model serving from an unlinked inode; update the handler so
that after calling pm.matrix.StopProcess(realModelName, StopImmediately) or
pg.StopProcess(realModelName, StopImmediately) you check the returned error and
only ignore it if it represents a “not running”/already-stopped condition, but
for any other error propagate/return that error and abort the delete; use the
same error inspection logic for both pm.matrix.StopProcess and pg.StopProcess
and reference pm.findGroupByModelName to locate the pg branch.
- Around line 121-129: The current branch accepts any http(s) URL (variable
model) which permits SSRF and leaking HF_TOKEN; update the logic in the
URL-handling block (where model is parsed and filename/downloadURL are set) to
parse the URL using net/url, require scheme == "https", and validate the host
against a whitelist of HuggingFace hosts (e.g., exact matches and allowed
subdomains such as *.huggingface.co and huggingface.co); if the host is not
allowed, reject the URL path instead of setting downloadURL/filename. Also
ensure code that forwards the bearer token (where token / HF_TOKEN is applied)
only attaches Authorization for validated HuggingFace hosts (and do the same fix
for the other similar URL-handling location referenced).
- Around line 317-320: The code currently calls f.Close() but ignores its error
before os.Rename(tmp, dest), which can publish a truncated file; capture and
check the error returned by f.Close() (e.g., err = f.Close()), and if non-nil
remove the tmp file (os.Remove(tmp)) and return/propagate the error instead of
proceeding to os.Rename; only call os.Rename(tmp, dest) when Close succeeded,
preserving existing cleanup on failure.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 9d9f0191-4496-415b-b632-afa06ad00819
📒 Files selected for processing (2)
proxy/proxymanager_models.goproxy/proxymanager_models_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
- proxy/proxymanager_models_test.go
Add full model lifecycle management API: - GET /api/storage — disk space for models directory - POST /api/models/pull — download from HuggingFace with streaming ndjson progress; subdir support; auto-register in config after pull - DELETE /api/models/:model — unload process then delete weight file - GET /api/config/info — config file path, models dir, per-model file existence - POST /api/config/models — add/replace a model entry in the YAML - DELETE /api/config/models/:id — remove a model entry from the YAML - POST /api/config/reload — trigger in-process config reload Wire SetConfigFile/SetReloadFn in main so all reload paths carry the config path and reload callback. Fix self-referencing closure by using var+assignment instead of :=.
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (2)
proxy/proxymanager_config.go (1)
113-120: ⚡ Quick win
apiConfigReloadreturns 200 before the reload actually runs.The handler writes
{"status":"reloading"}and then launchesreloadFnin a goroutine. If the reload fails (bad YAML, syntax error introduced by a previousapiConfigAddModel), the caller has no way to learn about it — the HTTP response already said success. Consider running the reload synchronously and surfacing the error, or at least exposing a follow-up status endpoint / log correlation ID.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@proxy/proxymanager_config.go` around lines 113 - 120, The handler currently replies 200 immediately and launches pm.reloadFn() in a goroutine so callers can’t learn if the reload actually failed; change apiConfigReload to run the reload synchronously (call pm.reloadFn() directly), adjust reloadFn to return an error (or wrap its result) and if it returns non-nil use pm.sendErrorResponse to return an appropriate HTTP error, only returning the 200 {"status":"reloading"} (or {"status":"reloaded"}) after the reload succeeded; keep apiConfigReload, pm.reloadFn, and pm.sendErrorResponse as the focal symbols to update.proxy/proxymanager_models.go (1)
356-361: 💤 Low valueUse
strings.TrimSuffixfor the extension strip.The
len(base) - len(".gguf")arithmetic re-implements whatstrings.TrimSuffix(base, ".gguf")already does, and thei > 0guard rejects the legitimate file".gguf"(a 5-char filename) by no-op — minor edge case but worth simplifying.♻️ Suggested simplification
- base := filename - if i := len(base) - len(".gguf"); i > 0 && strings.HasSuffix(base, ".gguf") { - base = base[:i] - } - id = strings.ToLower(base) + id = strings.ToLower(strings.TrimSuffix(filename, ".gguf"))🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@proxy/proxymanager_models.go` around lines 356 - 361, The code that strips the ".gguf" extension (using base := filename; if i := len(base) - len(".gguf"); i > 0 && strings.HasSuffix(base, ".gguf") { base = base[:i] }) should be replaced with strings.TrimSuffix to simplify and handle edge cases (like the filename ".gguf"); change that block to set base = strings.TrimSuffix(filename, ".gguf") and then set id = strings.ToLower(base).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@proxy/proxymanager_api.go`:
- Line 43: Change the DELETE route to accept wildcard IDs with slashes by
updating the route registration (apiGroup.DELETE "/config/models/:id" -> use a
wildcard form like "/config/models/*id") and modify the handler
pm.apiConfigRemoveModel to read the parameter with
strings.TrimPrefix(c.Param("id"), "/") so IDs like "author/model" are extracted
correctly; ensure any downstream uses of the id variable expect the decoded
value without a leading slash.
In `@proxy/proxymanager_config.go`:
- Around line 14-27: The model ID in configModelRequest is only checked for
non-empty but can contain problematic chars; in apiConfigAddModel validate
req.ID against a safe regex (e.g. ^[A-Za-z0-9._:-]+$) before persisting to the
models map, normalize/truncate if needed, and return a 400 error when validation
fails so invalid IDs never get written; reference the configModelRequest struct
and the apiConfigAddModel handler to locate where to add this validation logic
around req.ID.
- Around line 93-109: In apiConfigRemoveModel, RealModelName(id) currently
discards the resolved name and you pass the original alias to
removeModelFromConfig, so deletion is a no-op; capture the resolved real model
name (e.g., realName, found := pm.config.RealModelName(id)), use that realName
when calling pm.removeModelFromConfig(realName), and return the realName in the
success JSON (and keep pm.triggerReload() as-is) so the YAML models map is
deleted by its actual key.
- Around line 208-247: The buildCmd function currently iterates pm.config.Models
nondeterministically and calls the local sanitizeCommand; update buildCmd to
first collect the model IDs/keys from pm.config.Models, sort them
(lexicographically) and then iterate in that deterministic order to pick the
template, and replace calls to sanitizeCommand with the centralized
pm.config.SanitizeCommand (or config.SanitizeCommand) to address the
sanitizeCommand duplication; keep the rest of the logic (skipping/replacing
model args and fallback fmt.Sprintf) unchanged so rebuilt commands are stable
across runs.
In `@proxy/proxymanager_models.go`:
- Around line 224-231: sendJSON currently swallows errors from json.Marshal,
c.Writer.Write and c.Writer.Flush which lets downloads continue after clients
disconnect; change sendJSON (the closure taking pullProgress and using stream,
pullProgress and c.Writer) to return error, check and return any json.Marshal,
Write or Flush error, and in the outer read loop check c.Request.Context().Err()
and the error returned from sendJSON and break the loop on first non-nil error
so the upstream read stops promptly; propagate that error back to the caller so
the read loop can stop immediately on client disconnect.
- Around line 147-150: After computing filename := filepath.Base(clean) (and
before assigning downloadURL/model return), validate the derived filename and
reject invalid values: if filename == "" || filename == "." || filename == ".."
|| strings.ContainsRune(filename, os.PathSeparator) then return an error (or nil
plus a descriptive error) instead of proceeding. Use the filename variable,
filepath.Base(clean) and os.PathSeparator/strings.ContainsRune to implement the
guard so we never create files named "."/".." or with path separators.
- Around line 350-379: The code currently ignores req.Register when
pm.configFile == "" and silently drops the registration; change the branch that
checks req.Register to detect when pm.configFile is empty and surface a failure:
if req.Register != nil && pm.configFile == "" then call sendJSON with
pullProgress{Status: "register_failed", Error: "no config file set"} (and in the
non-stream response path return a 422 error) instead of proceeding silently;
keep existing behavior for the normal write path using pm.writeModelToConfig,
sendJSON, and pm.triggerReload.
---
Nitpick comments:
In `@proxy/proxymanager_config.go`:
- Around line 113-120: The handler currently replies 200 immediately and
launches pm.reloadFn() in a goroutine so callers can’t learn if the reload
actually failed; change apiConfigReload to run the reload synchronously (call
pm.reloadFn() directly), adjust reloadFn to return an error (or wrap its result)
and if it returns non-nil use pm.sendErrorResponse to return an appropriate HTTP
error, only returning the 200 {"status":"reloading"} (or {"status":"reloaded"})
after the reload succeeded; keep apiConfigReload, pm.reloadFn, and
pm.sendErrorResponse as the focal symbols to update.
In `@proxy/proxymanager_models.go`:
- Around line 356-361: The code that strips the ".gguf" extension (using base :=
filename; if i := len(base) - len(".gguf"); i > 0 && strings.HasSuffix(base,
".gguf") { base = base[:i] }) should be replaced with strings.TrimSuffix to
simplify and handle edge cases (like the filename ".gguf"); change that block to
set base = strings.TrimSuffix(filename, ".gguf") and then set id =
strings.ToLower(base).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: ab65e27a-1b26-4033-922c-af8dafe7f8f6
📒 Files selected for processing (5)
llama-swap.goproxy/proxymanager.goproxy/proxymanager_api.goproxy/proxymanager_config.goproxy/proxymanager_models.go
Add GET /api/tags, POST /api/show, DELETE /api/delete so Ollama-mode frontends (Open WebUI, Chatbox, Msty) work against llama-swap without any configuration changes. Add GET /api/resources — single point-in-time snapshot combining disk storage, system memory, and GPU stats. On Apple Silicon (darwin/arm64) synthesises a unified-memory GPU entry from SysStat when no discrete GPU stats are available, showing actual VRAM pressure. inferModelDetails parses quantization level, parameter size, and model family from both the model ID and the GGUF filename so quantization shows correctly even when the model ID omits it.
ModelsPanel additions: - StorageBar component: disk usage + VRAM/unified memory progress bars, refreshed every 30s via GET /api/resources - Green/red dot per model row indicating file existence from /api/config/info; refreshed on mount and whenever the SSE model list updates - Pull Model button: opens modal with HF identifier input, optional subdir, register-in-config toggle with ID and extra flags fields; streams ndjson progress with a live progress bar - Delete model button per row (trash icon, faded until hover) with a single- click confirm step; calls DELETE /api/models/:id New components: StorageBar.svelte, PullModelModal.svelte New API functions: fetchResources, fetchConfigInfo, deleteModelFile, removeModelFromConfig, pullModel (with ndjson streaming) New types: ResourcesResponse, ResourceStorage, ResourceMemory, ResourceGpu, ConfigInfo, ModelFileInfo, PullProgress
Security: - SSRF: resolveHFSource now validates full URLs to HTTPS + huggingface.co only; loopback (127.0.0.1/localhost) allowed for tests/local servers - Token scoping: HF bearer token only attached to HuggingFace hosts - Path traversal: use filepath.Rel instead of strings.HasPrefix for dest check - HTTP status committed only after upstream 200 confirmed, so gated/error responses always carry the correct status code (not locked at 200) Correctness: - apiLoadSingleModelHandler: use swapProcessGroup instead of findGroupByModelName so exclusive-group eviction is honoured - GET/DELETE /api/models/*model: wildcard routes support slash-containing IDs (e.g. author/model); handlers trim leading '/' - modelProcessState: resolve aliases to canonical ID before process lookup - modelProcessState: add StateStopped case; default → "unknown" (not "stopped") - f.Close() error checked before os.Rename to prevent promoting truncated files Quality: - apiGetStorage split into proxymanager_storage_unix.go (!windows) and stub _windows.go; removes syscall import from the shared models file - modelsDir() iterates config.Models in sorted key order (deterministic) - sanitizeCommand deduplication: all callers now use config.SanitizeCommand Tests: - Rename all test functions to TestProxyManager_* convention - Add SSRF rejection cases (non-HF host, plain HTTP to remote) to resolveHFSource - TestProxyManager_apiPullModelGated: assert error body contains gated/token hint - TestProxyManager_apiPullModelStreaming: new test for ndjson streaming path Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Covers: wildcard routes for slash IDs, swapProcessGroup semantics, state values + unknown default, ndjson streaming commit order, f.Close before rename, path-traversal via filepath.Rel, SSRF allowlist, bearer token scoping, platform-specific syscall split, sanitizeCommand dedup, deterministic map iteration, and test naming + coverage checklist. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…iscovery Adds mDNS/Bonjour service advertisement so Skein can find llama-swap instances without manual IP configuration. Instance name is hostname:port to stay unique when multiple instances share a machine. Also adds proxymanager_config_test.go covering patchCommandFlags (from the CodeRabbit review fixes that landed in the prior commit). Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- apiConfigRemoveModel: resolve alias to realID before calling removeModelFromConfig (alias key doesn't exist in the YAML models map, so the delete was a silent no-op) - apiConfigAddModel: validate model ID chars (A-Za-z0-9 . _ : / -) before writing to YAML to prevent broken keys - writeModelToConfig / patchModelInConfig / removeModelFromConfig: use atomic temp+rename instead of direct os.WriteFile so a crash or disk-full between truncate and write cannot empty the config file - resolveHFSource: reject filenames derived from URL that are ".", "..", empty, or contain path separators - apiPullModel sendJSON: track write failures; break the download loop on first write error or client context cancellation so a disconnected client doesn't keep the download running - apiPullModel register: surface register_failed when configFile is unset instead of silently skipping - apiDeleteModel: only ignore "not found" from StopProcess; any other unload error now aborts the delete instead of being swallowed Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Parse --ctx-size / -c and --n-predict / -n from each model's cmd and include them in the /v1/models and /api/models/:model responses so OpenAI-compatible clients can discover context and output limits without a separate config lookup. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Rewrite writeModelToConfig/patchModelInConfig/removeModelFromConfig to
use yaml.Node instead of map[string]any, preserving user comments, key
ordering, and node styles on every add/patch/remove.
- Sort model IDs before iterating in buildCmd so template selection is
deterministic across calls.
- Extract syscall.Statfs disk stats into proxymanager_disk_{unix,windows}.go
so the package builds cleanly on Windows (fixes CI failure).
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…eparator in test - proxymanager_disk_windows.go: use golang.org/x/sys/windows.GetDiskFreeSpaceEx instead of the non-existent syscall.GetDiskFreeSpaceEx so /api/storage and /api/resources return real disk usage on Windows. - proxymanager_storage_windows.go: delegate to diskStorageStats instead of returning 501 Not Implemented. - proxymanager_models_test.go: use filepath.Join for expected subdir path so the comparison works on Windows (backslash) and Unix (forward slash). Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Add autoUnload setting to GroupConfig (default: true). When enabled in swap-mode groups, loading a model automatically unloads non-persistent sibling models, preventing accidental dual-loading and memory exhaustion. - Add autoUnload bool to GroupConfig, marshal with YAML - Implement auto-unload logic in swapProcessGroup() for swap-mode groups - Add PATCH /api/config/groups/:id endpoint to manage group settings - Update yamlBool() helper for boolean YAML marshaling - Add patchGroupInConfig() for partial group config updates - Include tests for auto-unload behavior in swap vs non-swap groups This resolves the issue where skein/opencode loading models without coordination could hang the service due to memory exhaustion. Auto-unload respects existing exclusive/persistent semantics and is disabled when swap: false (allowing parallel model loading). Co-Authored-By: Claude Haiku 4.5 <[email protected]>
|
I have been testing this for a while with my modified version of opencode which auto detects multiple llama-swap instances and their models, works perfectly. I am also working on an agentic system which uses both opencode and llama-swap and noticed that we needed an autoUnload feature, so added that now as well, it is configurable true/false and works with groups. |
|
Hi @androidand, this PR has a bunch of different features packed into it. I'm not willing to review new 3,172 lines of code for contribution. To add features to contributors are encouraged to open an issue first for discussion and one PR per feature. Thank you |
…ame, temp fix - Rename `gpu` → `gpus` in /api/resources response (it is an array) - Add `cpu` section: cores, util_avg_pct, util_per_core, load_avg 1/5/15 - Add `vram_free_mb` per GPU entry - Add top-level `vram` aggregate: total_mb, used_mb, free_mb across all GPUs - Expose swap_total/swap_used and load_avg 5/15 in memory section - Fix modelsDir() inference: find common ancestor of all model paths so per-model subdirectories no longer return the wrong dir - Fix ROCm temp_c = 0: accept any Temperature sensor, prefer 'edge' - Fix ROCm channel indentation (ch := make was missing a tab) - Add GPU utilization polling via rocm-smi --showuse - Merge GpuUtilPct in mergeROCmStats; only overwrite non-zero fields Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Runs every Monday 06:00 UTC and on manual trigger. Dry-run option available to check for conflicts without pushing. Fails visibly if rebase has conflicts — manual resolution required. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Summary
This PR adds four related capabilities to the management API:
1. Model state and loaded flag in
GET /v1/modelsEach model entry in the OpenAI-compatible
/v1/modelsresponse now includes:{ "id": "my-model", "object": "model", "state": "ready", "loaded": true, "context_length": 65536, "max_output_tokens": 4096 }state: one ofready,starting,stopping,shutdown,stopped, orunknownloaded:trueonly whenstate == "ready"(the model is actively serving requests)context_length: parsed from--ctx-size/-cin the model's cmd (omitted if not set)max_output_tokens: parsed from--n-predict/-nin the model's cmd (omitted if not set)These fields are additive — existing clients that ignore unknown fields are unaffected.
2. Lifecycle endpoints: load and unload a single model
```
POST /api/models/load/{model} — warm a model (start its backing process)
POST /api/models/unload/{model} — evict a model from memory (stop its backing process)
POST /api/models/unload — unload all models (existing endpoint, unchanged)
```
`/load` works by routing a minimal chat-completion request through the proxy, which triggers llama-swap's normal on-demand startup path. This is intentional — it reuses the existing readiness logic rather than duplicating it.
Both endpoints support aliases. 404 is returned for unknown model IDs.
3. `GET /api/models/:model` — single-model detail
Returns config and runtime state for one model without fetching the full list. Useful for polling a specific model's readiness.
{ "id": "my-model", "object": "model", "state": "stopped", "loaded": false, "name": "My Model", "description": "...", "context_length": 32768, "max_output_tokens": 2048, "meta": { "llamaswap": { ... } } }`name`, `description`, `context_length`, `max_output_tokens`, and `meta` are only included when present.
4. `GET /api/ps` — currently loaded models (Ollama-compatible)
Returns only models that are actively loaded (`state == ready`), using the same envelope as Ollama's `/api/ps`:
{ "models": [{ "name": "My Model", "model": "my-model", "state": "ready" }] }This gives clients a quick VRAM-occupancy view without parsing the full model list.
5. Auto-unload for model groups
Added `autoUnload` setting to group configuration (default: `true`). When enabled in swap-mode groups, loading a model automatically unloads non-persistent sibling models, preventing accidental dual-loading and memory exhaustion. Configurable via `PATCH /api/config/groups/:id`.
Test plan
Related
The `name`, `context_length`, and `max_output_tokens` fields are consumed by anomalyco/opencode#27554, which auto-discovers models from OpenAI-compatible providers at startup. The auto-unload feature prevents the memory exhaustion issue observed when opencode/skein load models without coordination.