Skip to content

feat: model state in /v1/models, lifecycle API, GET /api/ps and GET /api/models/:model - #755

Open
androidand wants to merge 20 commits into
mostlygeek:mainfrom
androidand:feat/model-state-and-lifecycle-api
Open

androidand wants to merge 20 commits into
mostlygeek:mainfrom
androidand:feat/model-state-and-lifecycle-api

Conversation

@androidand

@androidand androidand commented May 14, 2026 •

Copy link
Copy Markdown

Summary

This PR adds four related capabilities to the management API:

1. Model state and loaded flag in GET /v1/models

Each model entry in the OpenAI-compatible /v1/models response now includes:

{
  "id": "my-model",
  "object": "model",
  "state": "ready",
  "loaded": true,
  "context_length": 65536,
  "max_output_tokens": 4096
}
  • state: one of ready, starting, stopping, shutdown, stopped, or unknown
  • loaded: true only when state == "ready" (the model is actively serving requests)
  • context_length: parsed from --ctx-size / -c in the model's cmd (omitted if not set)
  • max_output_tokens: parsed from --n-predict / -n in 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.

Note on `GET /running`: the existing `/running` endpoint serves a similar purpose (all known processes with state). `/api/ps` is the management-API counterpart scoped to loaded-only models with an Ollama-compatible shape.

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

  • `TestProxyManager_ListModels_IncludesState` — stopped model has `state`/`loaded` in `/v1/models`
  • `TestProxyManager_ListModels_StateReady` — ready model shows `state: ready, loaded: true`
  • `TestProxyManager_ListModelsHandler_WithRuntimeHints` — `context_length` and `max_output_tokens` parsed from cmd flags
  • `TestProxyManager_LoadSingleModel` — load endpoint starts the process
  • `TestProxyManager_LoadSingleModel_NotFound` — 404 for unknown model
  • `TestProxyManager_PS` — only ready models appear in `/api/ps`; stopped models excluded
  • `TestProxyManager_ModelDetail` — config + state + context hints returned; 404 for unknown
  • `TestProxyManager_AutoUnload` — loading a new model auto-unloads siblings in swap-mode groups
  • `TestProxyManager_AutoUnloadDisabledWhenSwapFalse` — auto-unload respects swap semantics
  • Full `go test ./...` passes with race detector
  • Deployed and manually verified on two Linux backends (Rocky/systemd, Proxmox/systemd) and one macOS backend (Apple Silicon M5, launchd)

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.

androidand and others added 2 commits May 14, 2026 02:31
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]>
@coderabbitai

coderabbitai Bot commented May 14, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8f5658ce-99b9-4a0d-901f-b7eb88ee44bd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds model runtime state reporting and includes state/loaded in /v1/models; registers API endpoints to load models, fetch model details, list running models, and manage model files (storage, pull, delete); adds command-parsing and models-dir inference plus tests.

Changes

Model State Tracking and Management API

Layer / File(s) Summary
State tracking, config wiring, and list inclusion
proxy/proxymanager.go, llama-swap.go
Adds ProxyManager config file and reload hooks (SetConfigFile, SetReloadFn), modelProcessState helper, and includes state/loaded in /v1/models.
API route registrations and handlers
proxy/proxymanager_api.go
Registers /api routes for model load/detail/ps/storage/pull/delete and implements apiLoadSingleModelHandler, apiGetModelHandler, and apiPSHandler.
Integration tests for state & management APIs
proxy/proxymanager_test.go
Integration tests that verify /v1/models includes state/loaded, warming/loading via chat proxy and /api/models/load/*, /api/ps, and per-model detail endpoints.

Model Storage, Pull, Delete, and Config

Layer / File(s) Summary
Command parsing and modelsDir inference
proxy/proxymanager_models.go, proxy/config/config.go
Adds parseModelPath/sanitizeCommand and ProxyManager.modelsDir(); Config gains ModelsDir for explicit model storage location.
Storage metrics and config handlers
proxy/proxymanager_models.go, proxy/proxymanager_config.go
apiGetStorage reports filesystem usage; adds /api/config/* endpoints plus helpers to read/write/remove model entries and trigger reloads.
Pull helpers and types
proxy/proxymanager_models.go
Adds pull payloads, HF resolution (resolveHFSource), token selection, and NDJSON progress struct.
Pull implementation
proxy/proxymanager_models.go
apiPullModel performs authenticated download (URL or HF short form), streams progress, writes to .part, enforces safe paths, finalizes file, and optionally registers model then triggers reload.
Delete model and stop process
proxy/proxymanager_models.go
apiDeleteModel resolves configured model, stops process if running, deletes weight file, and returns deleted path/name.
Storage and pull tests
proxy/proxymanager_models_test.go
Tests for parse/resolve helpers, /api/storage explicit/inferred, pull (success, subdir, gated 401/403), and delete (success/404).

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • mostlygeek

"A rabbit hopped in with a tiny scroll,
I warmed models and watched them roll.
With hops and hops the files were found,
Progress blinked in streaming sound.
Hooray — models loaded, tidy and whole! 🐰🎉"

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main additions: model state exposure in /v1/models, new lifecycle API endpoints (load/unload), and the GET /api/ps and GET /api/models/:model endpoints. It is specific, concise, and clearly reflects the primary change.
Docstring Coverage ✅ Passed Docstring coverage is 82.61% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request description clearly explains the changes, detailing four related capabilities added to the management API: model state/loaded fields in GET /v1/models, lifecycle endpoints for loading/unloading models, a single-model detail endpoint, and a currently-loaded models endpoint.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
proxy/proxymanager_test.go (1)

1947-2061: ⚡ Quick win

Add 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

📥 Commits

Reviewing files that changed from the base of the PR and between 3e3646f and bc37b76.

📒 Files selected for processing (3)
  • proxy/proxymanager.go
  • proxy/proxymanager_api.go
  • proxy/proxymanager_test.go

Comment thread proxy/proxymanager_api.go Resolved
Comment thread proxy/proxymanager_api.go Resolved
Comment thread proxy/proxymanager.go Resolved
Comment thread proxy/proxymanager.go Resolved
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]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (2)
proxy/proxymanager_api.go (2)

36-37: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

GET /api/models/:model and DELETE /api/models/:model are brittle for model IDs containing /.

Both route patterns use :model instead 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 win

Load endpoint bypasses group swap and exclusive-group eviction logic.

apiLoadSingleModelHandler directly uses findGroupByModelName (line 364) instead of swapProcessGroup, 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 win

Assertion doesn't validate the gated error body.

The test accepts either 401 or 403, but never inspects the JSON envelope ({"error": "..."}) produced by apiPullModel for gated responses. A regression that flipped the body to a generic message or swallowed the gated; provide a token hint 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 win

Pull test only covers stream:false; default streaming path is untested.

The default for pullRequest.Stream is true, but every pull test sets "stream":false. The ndjson framing in sendJSON (line 186–193), the periodic 10 MB progress reports, the locked-200 status behavior, and the terminal success event are therefore never exercised. Consider adding one case that omits stream (or sets it true), parses the body as newline-delimited JSON, and asserts at least a downloading and a success event 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 win

Check 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 optionally f.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

📥 Commits

Reviewing files that changed from the base of the PR and between bc37b76 and 0f2b438.

📒 Files selected for processing (4)
  • proxy/config/config.go
  • proxy/proxymanager_api.go
  • proxy/proxymanager_models.go
  • proxy/proxymanager_models_test.go

Comment thread proxy/proxymanager_models_test.go Resolved
Comment thread proxy/proxymanager_models.go Resolved
Comment thread proxy/proxymanager_models.go Resolved
Comment thread proxy/proxymanager_models.go Resolved
Comment thread proxy/proxymanager_models.go Resolved
Comment thread proxy/proxymanager_models.go Resolved
@androidand

Copy link
Copy Markdown
Author

Related work — PR #569

While 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: state in GET /v1/models

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:

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.

androidand and others added 2 commits May 14, 2026 03:22
…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]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between f4cdd2f and e95ff42.

📒 Files selected for processing (2)
  • proxy/proxymanager_models.go
  • proxy/proxymanager_models_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • proxy/proxymanager_models_test.go

Comment thread proxy/proxymanager_models.go Resolved
Comment thread proxy/proxymanager_models.go Resolved
Comment thread proxy/proxymanager_models.go Resolved
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 :=.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (2)
proxy/proxymanager_config.go (1)

113-120: ⚡ Quick win

apiConfigReload returns 200 before the reload actually runs.

The handler writes {"status":"reloading"} and then launches reloadFn in a goroutine. If the reload fails (bad YAML, syntax error introduced by a previous apiConfigAddModel), 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 value

Use strings.TrimSuffix for the extension strip.

The len(base) - len(".gguf") arithmetic re-implements what strings.TrimSuffix(base, ".gguf") already does, and the i > 0 guard 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

📥 Commits

Reviewing files that changed from the base of the PR and between e95ff42 and d719a31.

📒 Files selected for processing (5)
  • llama-swap.go
  • proxy/proxymanager.go
  • proxy/proxymanager_api.go
  • proxy/proxymanager_config.go
  • proxy/proxymanager_models.go

Comment thread proxy/proxymanager_api.go Resolved
Comment thread proxy/proxymanager_config.go Resolved
Comment thread proxy/proxymanager_config.go Resolved
Comment thread proxy/proxymanager_config.go Resolved
Comment thread proxy/proxymanager_config.go Resolved
Comment thread proxy/proxymanager_models.go Resolved
Comment thread proxy/proxymanager_models.go Resolved
Comment thread proxy/proxymanager_models.go Resolved
androidand and others added 6 commits May 14, 2026 10:52
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]>
androidand and others added 6 commits May 14, 2026 16:39
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]>
@androidand

Copy link
Copy Markdown
Author

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.

@mostlygeek

Copy link
Copy Markdown
Owner

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

androidand and others added 2 commits May 18, 2026 23:50
…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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants