Download a model onto the machine that will serve it

The Models tab was about this backend's own disk, which is the wrong disk
for every session that runs anywhere else: llama.cpp reads the file where
it runs. So the models of a machine live under that machine's llama.cpp
provider now, beside the settings deciding how each is loaded, and the
download that produces one happens there.

A download is a detached `curl` on that machine, started by a script this
server writes and never spoken to again. Its state is a file beside the
partial, so nothing about it is held here: it survives the app closing,
this backend restarting and a second device watching, and the progress is
`wc -c` of the partial against the size HuggingFace published rather than
anything remembered. A run whose process is gone is reported failed, since
`kill -0` is asked at each listing, and there is no "finished" state -- a
download that finished is a model, in the list beside the ones still
going. Resuming is guarded by the published sha256, which is also checked
before the file takes its real name.

Two other things the same screens wanted:

A provider is drawn as a card rather than as a line of text, bordered
against the machine card it sits in -- the tint it had was one step along
the surface ladder and rendered as one flat block -- with room to tap and
no chevron.

Nothing in a raw block wraps any more; the block scrolls sideways
instead, one offset for all its lines, so a diff or a column-aligned test
run still reads as one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-19 18:55:51 -04:00
1 parent 8c323fc7a9
commit 81c30dcda1
14 files changed
+1224 -1073

No files matched your search

@@ -350,6 +350,8 @@ fun deleteSubagents(settings: ServerSettings, sessionId: String, subagentIds: Li
data class Provider(
val name: String,
val kind: String,
/** The program discovery found on that machine, or null for a provider that is not one. */
val command: String?,
val models: List<String>,
val permissionModes: List<String>,
val defaultPermissionMode: String?,
@@ -399,6 +401,7 @@ private fun parseProvider(provider: JSONObject): Provider {
return Provider(
name = provider.getString("name"),
kind = kind,
command = provider.optString("command").ifEmpty { null },
// Omitted entirely when the provider offers none.
models = provider.optJSONArray("models")?.strings().orEmpty(),
permissionModes = provider.optJSONArray("permissionModes")?.strings().orEmpty(),
@@ -1397,7 +1400,9 @@ fun deleteSession(settings: ServerSettings, sessionId: String, deleteForeign: Bo
requestFromServer(settings, "/sessions/$sessionId$query", method = "DELETE") {}
}
// Models: what this backend has downloaded, what it is downloading, and what HuggingFace offers.
// Models: what one machine has, what it is fetching onto itself, and what HuggingFace offers.
// Under a machine because that is whose disk the file is on -- `llama-server` reads it where it
// runs, so a list or a download naming anywhere else would be about the wrong filesystem.
// Browsing is proxied by the server rather than done here, because this app trusts exactly one
// certificate and has no general internet trust to spend on huggingface.co.
@@ -1415,12 +1420,14 @@ data class LocalModel(
)
/**
* A download in flight or finished. [total] is null when the server never said how big the file is
* A download in flight or stopped. [total] is null when HuggingFace never said how big the file is
* -- which must render as "not known", never as a bar at some invented position.
*
* There is no "finished": a download that finished is a model, and it is in [Models.local] beside
* this one.
*/
data class Download(
val key: String,
val run: Long,
val repo: String,
val file: String,
val state: String,
@@ -1435,18 +1442,98 @@ data class RemoteRepo(val id: String, val downloads: Long, val likes: Long)
data class RemoteFile(val path: String, val bytes: Long, val have: Boolean)
private fun parseDownload(o: JSONObject) =
Download(
key = o.getString("key"),
run = o.getLong("run"),
repo = o.getString("repo"),
file = o.getString("file"),
state = o.getString("state"),
done = o.getLong("done"),
// Absent rather than zero when unknown; see the field's comment.
total = if (o.has("total")) o.getLong("total") else null,
error = if (o.has("error")) o.getString("error") else null,
)
private fun modelsPath(machineId: String, tail: String = "") =
"/machines/${machineId.urlEncoded()}/models$tail"
/** Every GGUF on one machine, and every download putting one there. */
fun fetchMachineModels(settings: ServerSettings, machineId: String): Models =
requestFromServer(settings, modelsPath(machineId), readTimeoutMs = 40000) { connection ->
val body = connection.jsonObject()
Models(
local =
body.getJSONArray("local").mapObjects { m ->
LocalModel(
key = m.getString("key"),
repo = m.getString("repo"),
file = m.getString("file"),
bytes = m.getLong("bytes"),
name = m.optString("name").ifEmpty { null },
)
},
downloads =
body.getJSONArray("downloads").mapObjects { o ->
Download(
key = o.getString("key"),
repo = o.getString("repo"),
file = o.getString("file"),
state = o.getString("state"),
done = o.getLong("done"),
// Absent rather than zero when unknown; see the field's comment.
total = if (o.has("total")) o.getLong("total") else null,
error = if (o.has("error")) o.getString("error") else null,
)
},
)
}
fun searchModels(settings: ServerSettings, query: String): List<RemoteRepo> =
requestFromServer(settings, "/models/search?q=${query.urlEncoded()}") { connection ->
connection.jsonObjects { r ->
RemoteRepo(
id = r.getString("id"),
downloads = r.getLong("downloads"),
likes = r.getLong("likes"),
)
}
}
fun fetchRepoFiles(settings: ServerSettings, machineId: String, repo: String): List<RemoteFile> =
requestFromServer(
settings,
modelsPath(machineId, "/files?repo=${repo.urlEncoded()}"),
readTimeoutMs = 40000,
) { connection ->
connection.jsonObjects { f ->
RemoteFile(
path = f.getString("path"),
bytes = f.getLong("bytes"),
have = f.getBoolean("have"),
)
}
}
/** Starts one on that machine, or joins the run already going for the same model. */
fun startDownload(settings: ServerSettings, machineId: String, repo: String, file: String) {
requestFromServer(
settings,
modelsPath(machineId, "/download"),
method = "POST",
jsonBody = JSONObject().put("repo", repo).put("file", file).toString(),
readTimeoutMs = 40000,
) {}
}
/** Stops one. The partial stays on the machine, so starting again carries on from there. */
fun cancelDownload(settings: ServerSettings, machineId: String, key: String) {
requestFromServer(
settings,
modelsPath(machineId, "/cancel"),
method = "POST",
jsonBody = JSONObject().put("key", key).toString(),
readTimeoutMs = 40000,
) {}
}
/** Takes a model off that machine, downloaded or half-downloaded. */
fun deleteModel(settings: ServerSettings, machineId: String, key: String) {
requestFromServer(
settings,
modelsPath(machineId, "/delete"),
method = "POST",
jsonBody = JSONObject().put("key", key).toString(),
readTimeoutMs = 40000,
) {}
}
/**
* One model a picker can offer.
@@ -1612,71 +1699,3 @@ fun unloadProviderModel(
readTimeoutMs = 40000,
) {}
}
fun fetchModels(settings: ServerSettings): Models =
requestFromServer(settings, "/models") { connection ->
val body = JSONObject(connection.inputStream.bufferedReader().readText())
Models(
local =
body.getJSONArray("local").mapObjects { m ->
LocalModel(
key = m.getString("key"),
repo = m.getString("repo"),
file = m.getString("file"),
bytes = m.getLong("bytes"),
name = m.optString("name").ifEmpty { null },
)
},
downloads = body.getJSONArray("downloads").mapObjects(::parseDownload),
)
}
fun searchModels(settings: ServerSettings, query: String): List<RemoteRepo> =
requestFromServer(settings, "/models/search?q=${query.urlEncoded()}") { connection ->
connection.jsonObjects { r ->
RemoteRepo(
id = r.getString("id"),
downloads = r.getLong("downloads"),
likes = r.getLong("likes"),
)
}
}
fun fetchRepoFiles(settings: ServerSettings, repo: String): List<RemoteFile> =
requestFromServer(settings, "/models/files?repo=${repo.urlEncoded()}") { connection ->
connection.jsonObjects { f ->
RemoteFile(
path = f.getString("path"),
bytes = f.getLong("bytes"),
have = f.getBoolean("have"),
)
}
}
fun startDownload(settings: ServerSettings, repo: String, file: String): Download =
requestFromServer(
settings,
"/models/download",
method = "POST",
jsonBody = JSONObject().put("repo", repo).put("file", file).toString(),
) { connection ->
parseDownload(JSONObject(connection.inputStream.bufferedReader().readText()))
}
fun cancelDownload(settings: ServerSettings, key: String) {
requestFromServer(
settings,
"/models/cancel",
method = "POST",
jsonBody = JSONObject().put("key", key).toString(),
) {}
}
fun deleteModel(settings: ServerSettings, key: String) {
requestFromServer(
settings,
"/models/delete",
method = "POST",
jsonBody = JSONObject().put("key", key).toString(),
) {}
}