Meter a session by its provider, and let llama.cpp run over ssh
The rate-limit bar answered a question about an account, and picked the
answer by machine. One machine runs echo, the Claude CLI and a local
model side by side, so every echo session on it drew the CLI's five-hour
window: a quota that session cannot spend and could never run down. A
session now names its meter (`usageProvider`, from
`DriverKind::usage_provider`, which `usage::providers_for` reads too so
the two lists cannot disagree), and the phone matches on machine *and*
provider. Nothing meters echo or llama, and nothing at all is drawn --
including while the first fetch is out, since "checking" under a session
that turns out to meter nothing is a row the screen then withdraws.
Echo gets a meter it can be *told* about instead: `/usage 42`,
`/usage 95 20`, `/usage 42 never`, `/usage notloggedin`,
`/usage unreachable`, `/usage failed`, `/usage off`. Those states cost
real quota to arrange, which is why none of them had been looked at.
And llama.cpp runs wherever a setup says, which was the last of phase 5.
`Transport::reserve_port` is the second half of what a transport is --
"run this" plus "reach this port" -- returning the port the server binds
there and the port that reaches it here, and `Launch::reaching` puts the
`-L` tunnel on the connection that already carries the command. Three
things that came out of building it:
- A forwarded launch gets a pty and every other one keeps `-T`. Killing
the ssh client ends a CLI by closing the stdin it reads; llama-server
never reads its stdin, so the same kill left it running on the far
machine with the model loaded -- one orphan per stopped session.
- The model is looked for on the machine that will serve it, at that
machine's own models directory, so `GET /setups/{id}/models` is what
the spawn screen offers rather than the backend's own downloads.
- The readiness poll watches the process, not only the port: a model
that will not load exits in a second and would otherwise have been
reported as "gave up after 300s". The failure carries the log's tail.
Exercised end to end against this VM over ssh to itself: spawn, load,
answer, outlive a backend restart, be adopted, answer again, and stop --
with both the ssh client and the far llama-server gone afterwards. The
local path, the Claude bar and the spawn screen checked on the emulator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
74110b4d72
commit
127b25e60a
20 files changed
+1207
-138
No files matched your search
@@ -203,15 +203,44 @@ images both ways), and the usage screen.
|
|||||||
host, `session::transport` turns that into an `ssh host …` invocation, and
|
host, `session::transport` turns that into an `ssh host …` invocation, and
|
||||||
the driver never learns which it got.
|
the driver never learns which it got.
|
||||||
|
|
||||||
**Phase 4 (llama.cpp)** works end to end, phone included (2026-08-28).
|
**Phase 4 (llama.cpp)** works end to end, phone included (2026-08-28),
|
||||||
Models are browsed and downloaded from HuggingFace (`models.rs`, resumable
|
**on any machine a setup names** (2026-09-04). Models are browsed and
|
||||||
and verified), and `session::llama` runs one through `llama-server` over
|
downloaded from HuggingFace (`models.rs`, resumable and verified), and
|
||||||
its OpenAI-compatible streaming endpoint. Two things are deliberate and
|
`session::llama` runs one through `llama-server` over its
|
||||||
easy to undo by accident: the conversation is rebuilt from the
|
OpenAI-compatible streaming endpoint. The conversation is rebuilt from the
|
||||||
**transcript** rather than kept in the driver, because driver memory is
|
**transcript** rather than kept in the driver, because driver memory is
|
||||||
invisible to a second device; and a llama session is refused on an ssh
|
invisible to a second device -- deliberate, and easy to undo by accident.
|
||||||
host, because the model is reached over HTTP and forwarding that port is
|
|
||||||
not built.
|
A remote llama session is the same command through the same transport
|
||||||
|
plus the second half of what a transport is: `Transport::reserve_port`
|
||||||
|
hands back a port the server binds *there* and a port that reaches it
|
||||||
|
*here*, and the ssh connection carrying the command carries the `-L`
|
||||||
|
tunnel between them (`llama-server` binds loopback on the far machine, so
|
||||||
|
nothing is served to its network). Three things that came out of building
|
||||||
|
it, each of which is easy to get wrong again:
|
||||||
|
|
||||||
|
- **A forwarded launch gets a pty (`-tt`); every other one keeps `-T`.**
|
||||||
|
Killing the ssh client ends a CLI because it closes the stdin that CLI
|
||||||
|
is reading. `llama-server` never reads its stdin, so the same kill left
|
||||||
|
it running on the far machine holding the model in memory -- measured
|
||||||
|
2026-09-04, one orphan per stopped session. A pty is what makes sshd
|
||||||
|
hang the far side up. Its log then arrives through a line discipline,
|
||||||
|
which nothing parses.
|
||||||
|
- **The model is looked for on the machine that will serve it**, at that
|
||||||
|
machine's own models directory (`SshConfig::models_dir`, defaulting to
|
||||||
|
`~/.local/share/ai-app/models` expanded *there*). What this backend has
|
||||||
|
downloaded is on that machine only when they are the same machine, so
|
||||||
|
`GET /setups/{id}/models` is what the spawn screen offers rather than
|
||||||
|
`GET /models`, and a model that is not there is refused at the spawn
|
||||||
|
with a sentence saying so. Downloading *to* another machine is not
|
||||||
|
built; the file gets there however anything else does.
|
||||||
|
- **A readiness poll watches the process, not only the port.** A model
|
||||||
|
that will not load, a port already taken, a flag an older build does not
|
||||||
|
know: all of them exit within a second and none will ever answer
|
||||||
|
`/health`, so waiting out the 300s timeout turned the server's own
|
||||||
|
account of the problem into "gave up". The failure now carries the last
|
||||||
|
few lines of `llama-server.log`, which on a remote session is the only
|
||||||
|
copy anybody reading the phone can see.
|
||||||
|
|
||||||
Setups — machines, each carrying what it can run — are added, renamed,
|
Setups — machines, each carrying what it can run — are added, renamed,
|
||||||
re-probed and removed from the app; providers are **discovered by asking
|
re-probed and removed from the app; providers are **discovered by asking
|
||||||
@@ -226,12 +255,25 @@ symlink into `~/.local/bin` before a setup finds it. The escape hatch for
|
|||||||
anything odder is editing `config.ron` on the backend, deliberately the one
|
anything odder is editing `config.ron` on the backend, deliberately the one
|
||||||
authority the phone does not have.
|
authority the phone does not have.
|
||||||
|
|
||||||
**Testing llama.cpp here:** the prebuilt CPU build lives outside the repo
|
**llama.cpp is set up in this VM** (2026-09-04) and needs nothing typed:
|
||||||
at `~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset). It
|
the prebuilt CPU build is at `~/.local/opt/llama.cpp` (the 15 MB
|
||||||
needs its own directory on `LD_LIBRARY_PATH`, so start the server as
|
`ubuntu-x64` release asset), symlinked as `/usr/local/bin/llama-server` so
|
||||||
`LD_LIBRARY_PATH=~/.local/opt/llama.cpp ai-server …` and point a provider's
|
that **discovery finds it over ssh too** -- `~/.local/bin` is not on the
|
||||||
`command` at `~/.local/opt/llama.cpp/llama-server`. A 0.6B Q8_0 answers at
|
PATH a non-interactive ssh session gets, which is why the symlink is
|
||||||
usable speed on this VM's 8 cores. **Do not test with a 2-bit quant**: the
|
there and not only in `~/.local/bin`. It resolves its own libraries
|
||||||
|
through `$ORIGIN`, so no `LD_LIBRARY_PATH` is needed. One model is
|
||||||
|
downloaded, `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf` (639 MB, under
|
||||||
|
`~/.local/share/ai-app/models`), which answers at usable speed on this
|
||||||
|
VM's 8 cores.
|
||||||
|
|
||||||
|
**And the ssh path is exercisable here**, because this VM can ssh to
|
||||||
|
itself: the key is `~/.config/ai-app/ssh-self` (its public half is in
|
||||||
|
`~/.ssh/authorized_keys`, labelled removable), and a setup naming
|
||||||
|
`bob@127.0.0.1` with that `identityFile` plus
|
||||||
|
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]`
|
||||||
|
discovers `claude-cli` and `llama-cpp` on it. That is the whole rig for
|
||||||
|
"does a remote llama session work", since the far machine is this one and
|
||||||
|
the model file is the same file. **Do not test with a 2-bit quant**: the
|
||||||
IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
|
IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
|
||||||
broken driver — `llama-cli` produces the same from the file directly, which
|
broken driver — `llama-cli` produces the same from the file directly, which
|
||||||
is how to tell the two apart in a hurry.
|
is how to tell the two apart in a hurry.
|
||||||
@@ -292,6 +334,24 @@ first if a remote spawn ever mangles an argument.
|
|||||||
Dev Updater lists every variant under `build/outputs/apk`, so pick
|
Dev Updater lists every variant under `build/outputs/apk`, so pick
|
||||||
`release` there; a phone still holding the debug build has to uninstall
|
`release` there; a phone still holding the debug build has to uninstall
|
||||||
it first, since the two are signed differently.
|
it first, since the two are signed differently.
|
||||||
|
- **A rate-limit bar belongs to a session's provider, not to its
|
||||||
|
machine.** One machine offers echo, the Claude CLI and a local model at
|
||||||
|
once and only the CLI spends anything, so a session says which meter
|
||||||
|
reports on it (`usageProvider`, from `DriverKind::usage_provider`, which
|
||||||
|
`usage::providers_for` reads too so the two lists cannot disagree) and
|
||||||
|
the phone matches a snapshot on machine *and* provider. Nothing meters
|
||||||
|
a llama or echo session, and the phone draws **nothing at all** for one
|
||||||
|
-- not a zero, and not "unknown". The bar also draws nothing while the
|
||||||
|
first fetch is out: "checking" under a session that turns out to meter
|
||||||
|
nothing is a row the screen then has to withdraw.
|
||||||
|
- **`/usage` in an echo session puts up an invented meter**, which is how
|
||||||
|
those screens' states are reached without spending quota:
|
||||||
|
`/usage 42`, `/usage 95 20` (minutes left), `/usage 42 never` (the
|
||||||
|
between-blocks window with no reset time), `/usage 42 unreadable`,
|
||||||
|
`/usage notloggedin`, `/usage unreachable`, `/usage failed`,
|
||||||
|
`/usage off`. The vocabulary is `usage::Fixture`'s, since those are its
|
||||||
|
states; with none set an echo session meters nothing, which is the
|
||||||
|
ordinary case.
|
||||||
- **A row something is happening to is dimmed, drained of colour, inert,
|
- **A row something is happening to is dimmed, drained of colour, inert,
|
||||||
and says which operation in a word** -- `BusyItem`, used by both the
|
and says which operation in a word** -- `BusyItem`, used by both the
|
||||||
session list and the import list so the appearance is learned once. The
|
session list and the import list so the appearance is learned once. The
|
||||||
|
|||||||
@@ -684,7 +684,22 @@ host) and **hosts**. The manager runs at most one llama-server per
|
|||||||
prompt-replayed by pi against the new endpoint).
|
prompt-replayed by pi against the new endpoint).
|
||||||
- Remote llama-server output is only reachable from the backend host, and
|
- Remote llama-server output is only reachable from the backend host, and
|
||||||
binds localhost on the remote side with an SSH local port forward
|
binds localhost on the remote side with an SSH local port forward
|
||||||
(`ssh -L`) held by the manager — no LAN-exposed inference ports.
|
(`ssh -L`) held by the manager — no LAN-exposed inference ports. Built
|
||||||
|
2026-09-04, held by the session's own ssh client rather than by a
|
||||||
|
manager: there is one server per session (not per `(host, model)`), so
|
||||||
|
the process that runs it is the process that owns the tunnel, and the
|
||||||
|
two die together.
|
||||||
|
- **The model file lives on the machine that serves it** (2026-09-04).
|
||||||
|
Each setup names its own models directory (`SshConfig::models_dir`,
|
||||||
|
default `~/.local/share/ai-app/models` expanded on that machine), and a
|
||||||
|
spawn resolves the key there — one round trip that answers "at
|
||||||
|
/abs/path" or "missing", so a model that is not there is refused at the
|
||||||
|
spawn instead of becoming a server that never becomes ready. The spawn
|
||||||
|
screen offers `GET /setups/{id}/models`, which is that machine's list,
|
||||||
|
rather than `GET /models`, which is the backend's downloads. Downloading
|
||||||
|
*to* another machine is deliberately not built: it would be a
|
||||||
|
multi-gigabyte transfer with no progress anywhere, and the file gets
|
||||||
|
there however anything else on that machine got there.
|
||||||
|
|
||||||
### SSH
|
### SSH
|
||||||
|
|
||||||
@@ -712,7 +727,23 @@ host) and **hosts**. The manager runs at most one llama-server per
|
|||||||
as a process but then spoken to over HTTP, so a remote one needs a
|
as a process but then spoken to over HTTP, so a remote one needs a
|
||||||
forwarded port (`ssh -L`) as well as a spawned process. A transport is
|
forwarded port (`ssh -L`) as well as a spawned process. A transport is
|
||||||
therefore "run this" plus "reach this port", and the second operation is
|
therefore "run this" plus "reach this port", and the second operation is
|
||||||
a no-op locally.
|
a no-op locally. **Built 2026-09-04**: `Transport::reserve_port` returns
|
||||||
|
a `Forward { there, here }` — the port the program binds on its own
|
||||||
|
machine and the port that reaches it from the backend, the same number
|
||||||
|
when that machine is this one — and `Launch::reaching` carries it, so
|
||||||
|
the connection that runs the command also carries the tunnel. The far
|
||||||
|
end is a guess from a range below the ephemeral one, because no
|
||||||
|
portable way to ask a machine for a free port avoids racing with the
|
||||||
|
bind anyway; a collision is not silent, since the program fails to bind
|
||||||
|
and the readiness poll reports what its log said.
|
||||||
|
- **A forwarded launch gets a pty and every other one does not** (measured
|
||||||
|
2026-09-04). Killing the ssh client ends a CLI because it closes the
|
||||||
|
stdin that CLI is reading; `llama-server` never reads its stdin, so the
|
||||||
|
same kill left it running on the far machine with the model loaded —
|
||||||
|
one orphan per stopped session. With `-tt` the far side takes SIGHUP
|
||||||
|
when the connection goes. Its log then arrives through a line
|
||||||
|
discipline, which nothing parses. `-T` stays everywhere else, where a
|
||||||
|
pty would rewrite the JSONL.
|
||||||
- Images need no file transfer, contrary to what this section said
|
- Images need no file transfer, contrary to what this section said
|
||||||
before: `attachment_block` base64s an uploaded image into the
|
before: `attachment_block` base64s an uploaded image into the
|
||||||
stream-json message itself, and produced images come back the same way
|
stream-json message itself, and produced images come back the same way
|
||||||
@@ -746,6 +777,26 @@ optional and degrades rather than erroring. Structure it as one
|
|||||||
`UsageProvider` per paid service so a second service later is a new impl,
|
`UsageProvider` per paid service so a second service later is a new impl,
|
||||||
not a parallel screen (rule 9).
|
not a parallel screen (rule 9).
|
||||||
|
|
||||||
|
**Per provider, not per machine (decided 2026-09-04).** A machine is not
|
||||||
|
what is metered; the provider a session runs is. One machine offers echo,
|
||||||
|
the Claude CLI and a local model side by side, and only the second of them
|
||||||
|
spends anything — so pairing a session with a snapshot by machine alone
|
||||||
|
drew the CLI's five-hour window under every echo session on it, reporting
|
||||||
|
a quota that session cannot spend and could never run down. A session now
|
||||||
|
names its meter (`usageProvider`, from `DriverKind::usage_provider`, which
|
||||||
|
`usage::providers_for` also reads so the two lists cannot disagree), and
|
||||||
|
`GET /usage` is matched on machine *and* provider. `None` is a session
|
||||||
|
that meters nothing, and the phone draws nothing at all for it — not a
|
||||||
|
zero, and not "unknown".
|
||||||
|
|
||||||
|
`DriverKind::Echo` names a meter of its own, and it exists only when a
|
||||||
|
test has asked for one: `/usage` in an echo session sets an invented
|
||||||
|
answer (`usage::Fixture`), and with none set there is no snapshot and no
|
||||||
|
bar. That is what makes the states of those screens reachable — a number
|
||||||
|
near the top, a window between blocks with no reset time, a machine
|
||||||
|
nobody logged into, one that could not be reached — without spending real
|
||||||
|
quota to arrange them, which is why none of them had ever been looked at.
|
||||||
|
|
||||||
**Per machine, not per backend (decided 2026-08-29).** The credential store
|
**Per machine, not per backend (decided 2026-08-29).** The credential store
|
||||||
that matters is the one on the machine the session runs on, because that is
|
that matters is the one on the machine the session runs on, because that is
|
||||||
the account being billed. Reading this machine's was right only while the
|
the account being billed. Reading this machine's was right only while the
|
||||||
@@ -789,7 +840,8 @@ POST /sessions/:id/compact (llama sessions)
|
|||||||
POST /sessions/:id/attachments multipart upload → id (referenced by /message)
|
POST /sessions/:id/attachments multipart upload → id (referenced by /message)
|
||||||
GET /sessions/:id/files/:ref images the session produced or was sent
|
GET /sessions/:id/files/:ref images the session produced or was sent
|
||||||
DELETE /sessions/:id kill process, release llama-server, delete transcript+files
|
DELETE /sessions/:id kill process, release llama-server, delete transcript+files
|
||||||
GET /usage cached usage windows
|
GET /usage cached usage windows, per machine and provider
|
||||||
|
GET /setups/:id/models GGUFs on that machine, for a llama session there
|
||||||
GET /setups/:id/dir?path=P entries of directory P, and P resolved
|
GET /setups/:id/dir?path=P entries of directory P, and P resolved
|
||||||
GET /setups/:id/file?path=P content of file P, or why not
|
GET /setups/:id/file?path=P content of file P, or why not
|
||||||
PUT /setups/:id/file {path, content, ifSha256}; 409 if it moved on
|
PUT /setups/:id/file {path, content, ifSha256}; 409 if it moved on
|
||||||
@@ -1140,8 +1192,10 @@ window just fills.
|
|||||||
shell-quoted). Attachment shipping turned out to be unnecessary for
|
shell-quoted). Attachment shipping turned out to be unnecessary for
|
||||||
images — they ride the stdio JSONL as base64 in both directions, so
|
images — they ride the stdio JSONL as base64 in both directions, so
|
||||||
nothing needs `scp` — and was built on 2026-09-03 for files, which
|
nothing needs `scp` — and was built on 2026-09-03 for files, which
|
||||||
are attached by path (see "Transport" above). Still outstanding:
|
are attached by path (see "Transport" above). Remote llama-server with
|
||||||
remote llama-server with its port forward, which comes with phase 4.
|
its port forward landed 2026-09-04 — see "Transport" above for the
|
||||||
|
forward and the pty, and "llama-server management" for where the model
|
||||||
|
file has to be.
|
||||||
Two things learned doing it: a remote session inherits ssh's non-login
|
Two things learned doing it: a remote session inherits ssh's non-login
|
||||||
PATH, which is narrower than an interactive shell's (point `command` at
|
PATH, which is narrower than an interactive shell's (point `command` at
|
||||||
an absolute path if a CLI isn't found), and the remote command is run
|
an absolute path if a CLI isn't found), and the remote command is run
|
||||||
|
|||||||
@@ -193,6 +193,17 @@ data class SessionSummary(
|
|||||||
* server because that is where a provider's kind is known -- see `uploadPickedImage`.
|
* server because that is where a provider's kind is known -- see `uploadPickedImage`.
|
||||||
*/
|
*/
|
||||||
val maxImageEdge: Int?,
|
val maxImageEdge: Int?,
|
||||||
|
/**
|
||||||
|
* Which of `GET /usage`'s snapshots is about this session, and null where nothing meters it.
|
||||||
|
*
|
||||||
|
* The rate-limit bar answers a question about an *account*, and what decides which account --
|
||||||
|
* if any -- is the provider this session runs, not the machine it runs on. Pairing by machine
|
||||||
|
* alone drew the Claude CLI's five-hour window under every echo session on a machine that also
|
||||||
|
* has the CLI: a quota that session cannot spend and could never run down. Decided by the
|
||||||
|
* server for the same reason [maxImageEdge] is -- it is a fact about the provider's kind, and
|
||||||
|
* this app has only its name.
|
||||||
|
*/
|
||||||
|
val usageProvider: String?,
|
||||||
val status: String,
|
val status: String,
|
||||||
val lastActivity: Double,
|
val lastActivity: Double,
|
||||||
)
|
)
|
||||||
@@ -213,6 +224,7 @@ private fun parseSession(session: JSONObject) =
|
|||||||
contextTokens =
|
contextTokens =
|
||||||
if (session.has("contextTokens")) session.getLong("contextTokens") else null,
|
if (session.has("contextTokens")) session.getLong("contextTokens") else null,
|
||||||
maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 },
|
maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 },
|
||||||
|
usageProvider = session.optString("usageProvider").ifEmpty { null },
|
||||||
status = session.getString("status"),
|
status = session.getString("status"),
|
||||||
lastActivity = session.getDouble("lastActivity"),
|
lastActivity = session.getDouble("lastActivity"),
|
||||||
)
|
)
|
||||||
@@ -402,6 +414,12 @@ data class SshDetails(
|
|||||||
* Where files attached from here land on that machine; null for the session's own directory.
|
* Where files attached from here land on that machine; null for the session's own directory.
|
||||||
*/
|
*/
|
||||||
val attachmentsDir: String? = null,
|
val attachmentsDir: String? = null,
|
||||||
|
/**
|
||||||
|
* Where that machine keeps its GGUF models; null for the same place the backend keeps its own
|
||||||
|
* (`~/.local/share/ai-app/models`, read on that machine). A llama.cpp session serves the file
|
||||||
|
* from the machine it runs on, so this is where its models are looked for and listed.
|
||||||
|
*/
|
||||||
|
val modelsDir: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun SshDetails.toJson() =
|
private fun SshDetails.toJson() =
|
||||||
@@ -409,6 +427,7 @@ private fun SshDetails.toJson() =
|
|||||||
if (port != null) put("port", port)
|
if (port != null) put("port", port)
|
||||||
if (!identityFile.isNullOrBlank()) put("identityFile", identityFile)
|
if (!identityFile.isNullOrBlank()) put("identityFile", identityFile)
|
||||||
if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir)
|
if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir)
|
||||||
|
if (!modelsDir.isNullOrBlank()) put("modelsDir", modelsDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a machine turns out to have, without saving anything. */
|
/** What a machine turns out to have, without saving anything. */
|
||||||
@@ -1107,6 +1126,26 @@ private fun parseDownload(o: JSONObject) =
|
|||||||
error = if (o.has("error")) o.getString("error") else null,
|
error = if (o.has("error")) o.getString("error") else null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The models on one machine, which is the list a llama.cpp session there can choose from.
|
||||||
|
*
|
||||||
|
* Not [fetchModels], which is what the *backend* has downloaded. A session serves its model from
|
||||||
|
* the machine it runs on, so for a machine reached over ssh those are two different lists -- and
|
||||||
|
* offering the backend's would name files that are not there, turning a choice that cannot work
|
||||||
|
* into a session that fails when it tries to load one.
|
||||||
|
*/
|
||||||
|
fun fetchSetupModels(settings: ServerSettings, setupId: String): List<LocalModel> =
|
||||||
|
requestFromServer(settings, "/setups/${setupId.urlEncoded()}/models") { connection ->
|
||||||
|
JSONArray(connection.inputStream.bufferedReader().readText()).mapObjects { m ->
|
||||||
|
LocalModel(
|
||||||
|
key = m.getString("key"),
|
||||||
|
repo = m.getString("repo"),
|
||||||
|
file = m.getString("file"),
|
||||||
|
bytes = m.getLong("bytes"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun fetchModels(settings: ServerSettings): Models =
|
fun fetchModels(settings: ServerSettings): Models =
|
||||||
requestFromServer(settings, "/models") { connection ->
|
requestFromServer(settings, "/models") { connection ->
|
||||||
val body = JSONObject(connection.inputStream.bufferedReader().readText())
|
val body = JSONObject(connection.inputStream.bufferedReader().readText())
|
||||||
|
|||||||
@@ -1193,7 +1193,7 @@ fun SessionScreen(
|
|||||||
// One poll for the machines' limits, read by everything on this screen that reports them:
|
// One poll for the machines' limits, read by everything on this screen that reports them:
|
||||||
// the bar under the header, the colour of the button that opens the dialog, and the dialog.
|
// the bar under the header, the colour of the button that opens the dialog, and the dialog.
|
||||||
val usageFeed = rememberUsageFeed(settings)
|
val usageFeed = rememberUsageFeed(settings)
|
||||||
val usage = usageFeed.forSetup(summary.setup)
|
val usage = usageFeed.forSession(summary)
|
||||||
RecordFrames()
|
RecordFrames()
|
||||||
var usageOpen by remember { mutableStateOf(false) }
|
var usageOpen by remember { mutableStateOf(false) }
|
||||||
var settingsOpen by remember { mutableStateOf(false) }
|
var settingsOpen by remember { mutableStateOf(false) }
|
||||||
|
|||||||
@@ -74,12 +74,21 @@ class UsageFeed(
|
|||||||
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
|
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
|
||||||
val refresh: () -> Unit,
|
val refresh: () -> Unit,
|
||||||
) {
|
) {
|
||||||
/** What [setup]'s own limits came back as. See [usageFor] for why the states are these. */
|
/**
|
||||||
fun forSetup(setup: String): SessionUsage =
|
* What meters [session], and what that meter came back as. See [usageFor] for the states.
|
||||||
when (val state = snapshots) {
|
*
|
||||||
|
* A session rather than a machine, because a machine is not what is metered: one machine runs
|
||||||
|
* the Claude CLI and an echo session side by side, and only the first of them spends anything.
|
||||||
|
*/
|
||||||
|
fun forSession(session: SessionSummary): SessionUsage {
|
||||||
|
// Settled without asking anybody: a session nothing meters has nothing to check, and
|
||||||
|
// "checking" is what the fetch's own states would say about it for as long as one is out.
|
||||||
|
val provider = session.usageProvider ?: return SessionUsage.NotMetered
|
||||||
|
return when (val state = snapshots) {
|
||||||
is LoadState.Loading -> SessionUsage.Waiting
|
is LoadState.Loading -> SessionUsage.Waiting
|
||||||
is LoadState.Error -> SessionUsage.Unavailable(state.message)
|
is LoadState.Error -> SessionUsage.Unavailable(state.message)
|
||||||
is LoadState.Loaded -> usageFor(state.value, setup)
|
is LoadState.Loaded -> usageFor(state.value, session.setup, provider)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,9 +174,16 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nothing at all for a machine that meters nothing: a row saying "unknown" there would
|
// Nothing at all for a session that meters nothing: a row saying "unknown" there would
|
||||||
// report a problem about a setup somebody chose, on every screen, forever.
|
// report a problem about a setup somebody chose, on every screen, forever.
|
||||||
if (usage is SessionUsage.NotMetered) {
|
//
|
||||||
|
// And nothing while the first fetch is out, which is not the same kind of silence. A
|
||||||
|
// request in flight is not a state to report -- and the session that meters nothing is
|
||||||
|
// exactly the one this cannot yet tell apart, so "5-hour usage: checking" appeared under
|
||||||
|
// an echo session for half a second and was then taken away. A row that has to be
|
||||||
|
// withdrawn is worse than one that arrives late, and this is the only state here whose
|
||||||
|
// wrongness is a matter of timing rather than of fact.
|
||||||
|
if (usage is SessionUsage.NotMetered || usage is SessionUsage.Waiting) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,9 +194,10 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|||||||
// Words, not a colour and not an empty bar: every one of these is a different kind of
|
// Words, not a colour and not an empty bar: every one of these is a different kind of
|
||||||
// answer from "this much is used", and only words carry a difference in kind.
|
// answer from "this much is used", and only words carry a difference in kind.
|
||||||
when (val state = usage) {
|
when (val state = usage) {
|
||||||
SessionUsage.NotMetered -> Unit
|
// Both handled above, before the row exists at all.
|
||||||
|
SessionUsage.NotMetered,
|
||||||
|
SessionUsage.Waiting -> Unit
|
||||||
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
|
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
|
||||||
SessionUsage.Waiting -> UsageNote("5-hour usage: checking")
|
|
||||||
is SessionUsage.Known -> {
|
is SessionUsage.Known -> {
|
||||||
val window = state.windows.firstOrNull { it.kind == "session" }
|
val window = state.windows.firstOrNull { it.kind == "session" }
|
||||||
if (window == null) {
|
if (window == null) {
|
||||||
@@ -242,17 +259,23 @@ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One machine's snapshot, out of every machine's.
|
* One meter's snapshot, out of every machine's: [setup]'s row for [provider].
|
||||||
|
*
|
||||||
|
* Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's
|
||||||
|
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one
|
||||||
|
* service on one machine.
|
||||||
*
|
*
|
||||||
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it:
|
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it:
|
||||||
* a machine nobody logged into, one that could not be reached, a snapshot that came back empty.
|
* a machine nobody logged into, one that could not be reached, a snapshot that came back empty.
|
||||||
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
|
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
|
||||||
* machine having no quota rather than the question going unanswered.
|
* machine having no quota rather than the question going unanswered.
|
||||||
*/
|
*/
|
||||||
fun usageFor(snapshots: List<UsageSnapshot>, setup: String): SessionUsage {
|
fun usageFor(snapshots: List<UsageSnapshot>, setup: String, provider: String): SessionUsage {
|
||||||
// No snapshot at all means the backend never asked, which it only does for a machine with
|
// No snapshot at all means the backend never asked, which it only does where there is nothing
|
||||||
// nothing metered on it. That is a different answer from having asked and failed.
|
// to ask about. That is a different answer from having asked and failed.
|
||||||
val mine = snapshots.firstOrNull { it.setup == setup } ?: return SessionUsage.NotMetered
|
val mine =
|
||||||
|
snapshots.firstOrNull { it.setup == setup && it.provider == provider }
|
||||||
|
?: return SessionUsage.NotMetered
|
||||||
if (mine.state != "ok") {
|
if (mine.state != "ok") {
|
||||||
return SessionUsage.Unavailable(mine.detail ?: mine.state)
|
return SessionUsage.Unavailable(mine.detail ?: mine.state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ private fun AddSetupDialog(
|
|||||||
var address by remember { mutableStateOf("") }
|
var address by remember { mutableStateOf("") }
|
||||||
var identity by remember { mutableStateOf("") }
|
var identity by remember { mutableStateOf("") }
|
||||||
var attachmentsDir by remember { mutableStateOf("") }
|
var attachmentsDir by remember { mutableStateOf("") }
|
||||||
|
var modelsDir by remember { mutableStateOf("") }
|
||||||
var tested by remember { mutableStateOf<String?>(null) }
|
var tested by remember { mutableStateOf<String?>(null) }
|
||||||
var testing by remember { mutableStateOf(false) }
|
var testing by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -251,6 +252,7 @@ private fun AddSetupDialog(
|
|||||||
port = typedPort,
|
port = typedPort,
|
||||||
identityFile = identity.trim().ifEmpty { null },
|
identityFile = identity.trim().ifEmpty { null },
|
||||||
attachmentsDir = attachmentsDir.trim().ifEmpty { null },
|
attachmentsDir = attachmentsDir.trim().ifEmpty { null },
|
||||||
|
modelsDir = modelsDir.trim().ifEmpty { null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +298,14 @@ private fun AddSetupDialog(
|
|||||||
label = { Text("Folder for attached files (optional)") },
|
label = { Text("Folder for attached files (optional)") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
)
|
)
|
||||||
|
// Where that machine's GGUFs are, for a llama.cpp session on it. Blank means
|
||||||
|
// the same place this backend keeps its own downloads, read on that machine.
|
||||||
|
OutlinedTextField(
|
||||||
|
value = modelsDir,
|
||||||
|
onValueChange = { modelsDir = it },
|
||||||
|
label = { Text("Folder for models (optional)") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
tested?.let {
|
tested?.let {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||||
|
|||||||
@@ -70,9 +70,10 @@ fun SpawnScreen(
|
|||||||
// one leaves a filled-in form worth keeping, and that one leaves
|
// one leaves a filled-in form worth keeping, and that one leaves
|
||||||
// nothing to fill in.
|
// nothing to fill in.
|
||||||
var spawnError by remember { mutableStateOf<String?>(null) }
|
var spawnError by remember { mutableStateOf<String?>(null) }
|
||||||
// Downloaded models, for a llama provider to choose between. Fetched
|
// The models on the *chosen machine*, for a llama provider to choose between. Kept separate
|
||||||
// beside the setups but kept separate: a Claude session needs none, so
|
// from the setups: a Claude session needs none, so failing to list them must not stop the
|
||||||
// failing to list them must not stop the screen rendering.
|
// screen rendering. Refetched when the machine changes, because a model is a file on one
|
||||||
|
// machine -- see [fetchSetupModels].
|
||||||
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
|
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
|
||||||
var modelKey by remember { mutableStateOf<String?>(null) }
|
var modelKey by remember { mutableStateOf<String?>(null) }
|
||||||
var contextSize by remember { mutableStateOf("") }
|
var contextSize by remember { mutableStateOf("") }
|
||||||
@@ -89,9 +90,6 @@ fun SpawnScreen(
|
|||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
LoadState.failed(e)
|
LoadState.failed(e)
|
||||||
}
|
}
|
||||||
models =
|
|
||||||
runCatching { withContext(Dispatchers.IO) { fetchModels(settings).local } }
|
|
||||||
.getOrDefault(emptyList())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||||
@@ -122,6 +120,17 @@ fun SpawnScreen(
|
|||||||
is LoadState.Loaded -> state.value
|
is LoadState.Loaded -> state.value
|
||||||
}
|
}
|
||||||
val setup = setups.firstOrNull { it.name == setupName }
|
val setup = setups.firstOrNull { it.name == setupName }
|
||||||
|
// Whichever machine is chosen now, asked again when that changes. The old machine's list
|
||||||
|
// is dropped first rather than left on screen: a file name from another machine looks
|
||||||
|
// exactly like one from this one.
|
||||||
|
LaunchedEffect(setup?.id) {
|
||||||
|
models = emptyList()
|
||||||
|
modelKey = null
|
||||||
|
val id = setup?.id ?: return@LaunchedEffect
|
||||||
|
models =
|
||||||
|
runCatching { withContext(Dispatchers.IO) { fetchSetupModels(settings, id) } }
|
||||||
|
.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
val current = setup?.providers?.firstOrNull { it.name == providerName }
|
val current = setup?.providers?.firstOrNull { it.name == providerName }
|
||||||
// Only the Claude CLI has models, a working directory and
|
// Only the Claude CLI has models, a working directory and
|
||||||
// permission modes; keying the extra fields on the kind rather
|
// permission modes; keying the extra fields on the kind rather
|
||||||
@@ -183,13 +192,14 @@ fun SpawnScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (isLlama) {
|
if (isLlama) {
|
||||||
// A llama session names one of the models this backend has
|
// A llama session names one of the models on the machine it will run on, so the
|
||||||
// downloaded, so the choice is that list rather than free
|
// choice is that list rather than free text -- there is nothing sensible to type
|
||||||
// text -- there is nothing sensible to type here, and a name
|
// here, and a name that is not on that machine's disk is a session that cannot
|
||||||
// that is not on disk is a session that cannot start.
|
// start.
|
||||||
if (models.isEmpty()) {
|
if (models.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
"No models downloaded yet. Get one from the Models screen first.",
|
"No models on ${setup?.name ?: "this machine"}. The Models screen downloads " +
|
||||||
|
"to the backend; another machine needs the file put there itself.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|||||||
Generated
+1
@@ -35,6 +35,7 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tower",
|
"tower",
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ ureq = { version = "3", features = ["json"] }
|
|||||||
# both in the graph rustls refuses to auto-select one.
|
# both in the graph rustls refuses to auto-select one.
|
||||||
rustls = "0.23"
|
rustls = "0.23"
|
||||||
libc = "0.2.189"
|
libc = "0.2.189"
|
||||||
|
# One ISO-8601 timestamp: the reset time on the invented rate-limit window
|
||||||
|
# an echo session's `/usage` puts up. Already in the tree behind the
|
||||||
|
# certificate machinery, so this is a direct name for what is compiled
|
||||||
|
# anyway rather than a new crate -- and the alternative was hand-rolling a
|
||||||
|
# civil-from-days conversion to print one line.
|
||||||
|
time = { version = "0.3", features = ["formatting"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -106,6 +106,19 @@ pub struct SshConfig {
|
|||||||
/// Extra `-o` settings, each written as `Key=value`.
|
/// Extra `-o` settings, each written as `Key=value`.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub options: Vec<String>,
|
pub options: Vec<String>,
|
||||||
|
/// Where this machine keeps the GGUF models it can serve, absent for
|
||||||
|
/// the same default this backend uses (`~/.local/share/ai-app/models`
|
||||||
|
/// -- `$XDG_DATA_HOME` is not read on the far side, since it is this
|
||||||
|
/// machine's environment that would answer). A `~` prefix is the
|
||||||
|
/// remote home.
|
||||||
|
///
|
||||||
|
/// Here rather than on the provider because it is a fact about the
|
||||||
|
/// machine, and because a machine reached over ssh is where the model
|
||||||
|
/// has to be: a llama.cpp session serves the file from the machine
|
||||||
|
/// that runs `llama-server`, and this backend's own downloads are on
|
||||||
|
/// whichever machine that is only when they are the same one.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub models_dir: Option<PathBuf>,
|
||||||
/// Where a file attached from the phone is put on this machine so the
|
/// Where a file attached from the phone is put on this machine so the
|
||||||
/// session can read it. Absent means the session's own working
|
/// session can read it. Absent means the session's own working
|
||||||
/// directory, or the login home for a session that has none. A `~`
|
/// directory, or the login home for a session that has none. A `~`
|
||||||
@@ -167,6 +180,38 @@ impl DriverKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which paid service meters a session of this kind, and `None` for
|
||||||
|
/// one that costs nothing.
|
||||||
|
///
|
||||||
|
/// The rate-limit bars answer a question about an *account*, and what
|
||||||
|
/// decides which account -- if any -- is the provider a session runs,
|
||||||
|
/// not the machine it runs on. Those were the same thing only for as
|
||||||
|
/// long as a machine ran one kind of session: an echo session on a
|
||||||
|
/// laptop that also has the Claude CLI was drawn with that CLI's
|
||||||
|
/// five-hour window under its header, reporting a quota it cannot
|
||||||
|
/// spend and could not run down. A llama.cpp session is the same
|
||||||
|
/// story with the model on the far side.
|
||||||
|
///
|
||||||
|
/// [`DriverKind::Echo`] names a meter of its own, which exists only
|
||||||
|
/// when a test has asked for one (`/usage` in `session::echo`). That
|
||||||
|
/// is what makes the bar's states -- a number, a machine nobody
|
||||||
|
/// logged into, one that could not be reached -- reachable without an
|
||||||
|
/// account and without spending a turn on somebody else's. With no
|
||||||
|
/// fixture set there is no snapshot for it, which the phone draws as
|
||||||
|
/// nothing at all.
|
||||||
|
///
|
||||||
|
/// The string is a [`crate::usage::UsageProvider::name`], and it is
|
||||||
|
/// what pairs a session with one of the snapshots `GET /usage`
|
||||||
|
/// returns; the two lists have to agree, so `usage::providers_for`
|
||||||
|
/// reads this rather than matching on kinds a second time.
|
||||||
|
pub fn usage_provider(self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
Self::ClaudeCli => Some(crate::usage::CLAUDE),
|
||||||
|
Self::Echo => Some(crate::usage::ECHO),
|
||||||
|
Self::LlamaCpp => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the conversation exists outside this app, so that deleting
|
/// Whether the conversation exists outside this app, so that deleting
|
||||||
/// the session here does not end it.
|
/// the session here does not end it.
|
||||||
///
|
///
|
||||||
@@ -442,6 +487,7 @@ mod tests {
|
|||||||
port: Some(2222),
|
port: Some(2222),
|
||||||
identity_file: None,
|
identity_file: None,
|
||||||
options: Vec::new(),
|
options: Vec::new(),
|
||||||
|
models_dir: None,
|
||||||
attachments_dir: None,
|
attachments_dir: None,
|
||||||
}),
|
}),
|
||||||
providers: vec![ProviderConfig {
|
providers: vec![ProviderConfig {
|
||||||
|
|||||||
+3
-1
@@ -284,7 +284,9 @@ async fn main() -> Result<()> {
|
|||||||
// -- so a machine added from the phone reports its limits without a
|
// -- so a machine added from the phone reports its limits without a
|
||||||
// restart, and the backend's own account stops standing in for every
|
// restart, and the backend's own account stops standing in for every
|
||||||
// machine's.
|
// machine's.
|
||||||
let monitor = Arc::new(usage::UsageMonitor::new());
|
// The fixture is the manager's, because that is where the `/usage`
|
||||||
|
// command that sets it is typed; the monitor is what serves it.
|
||||||
|
let monitor = Arc::new(usage::UsageMonitor::new(manager.usage_fixture()));
|
||||||
|
|
||||||
// The bearer-token middleware wraps the entire router -- routes and
|
// The bearer-token middleware wraps the entire router -- routes and
|
||||||
// fallback alike -- here and only here, so a new route can't forget
|
// fallback alike -- here and only here, so a new route can't forget
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ use serde::Serialize;
|
|||||||
|
|
||||||
use wg_app_link::private;
|
use wg_app_link::private;
|
||||||
|
|
||||||
|
use crate::session::transport::{Launch, Transport};
|
||||||
|
|
||||||
/// Identifies this client to HuggingFace. They ask for one, and a request
|
/// Identifies this client to HuggingFace. They ask for one, and a request
|
||||||
/// without it is more likely to be rate-limited.
|
/// without it is more likely to be rate-limited.
|
||||||
const USER_AGENT: &str = concat!("ai-server/", env!("CARGO_PKG_VERSION"));
|
const USER_AGENT: &str = concat!("ai-server/", env!("CARGO_PKG_VERSION"));
|
||||||
@@ -551,6 +553,83 @@ fn collect(root: &Path, dir: &Path, found: &mut Vec<LocalModel>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a machine reached over ssh keeps its models, when its setup does
|
||||||
|
/// not say.
|
||||||
|
///
|
||||||
|
/// The same place this backend puts its own downloads, written out rather
|
||||||
|
/// than derived: `$XDG_DATA_HOME` here describes *this* machine's
|
||||||
|
/// environment, and the far machine's is the far machine's business. A
|
||||||
|
/// setup whose models are elsewhere says so (`SshConfig::models_dir`).
|
||||||
|
const FAR_MODELS_DIR: &str = "~/.local/share/ai-app/models";
|
||||||
|
|
||||||
|
/// Which directory holds the models on the machine `transport` reaches.
|
||||||
|
///
|
||||||
|
/// One answer, because two things ask: the list a spawn screen offers,
|
||||||
|
/// and the path a session hands `llama-server`. A machine that listed one
|
||||||
|
/// directory and served from another would offer models that then failed
|
||||||
|
/// to load, which reads as the model being broken.
|
||||||
|
pub fn dir_on(transport: &Transport, local: &Path) -> String {
|
||||||
|
match transport {
|
||||||
|
Transport::Here => local.to_string_lossy().into_owned(),
|
||||||
|
Transport::Ssh { ssh, .. } => ssh
|
||||||
|
.models_dir
|
||||||
|
.as_ref()
|
||||||
|
.map_or(FAR_MODELS_DIR.to_string(), |dir| {
|
||||||
|
dir.to_string_lossy().into_owned()
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every GGUF on the machine a setup names, which is the machine that
|
||||||
|
/// would have to serve it.
|
||||||
|
///
|
||||||
|
/// The local half of this is [`ModelStore::list`], reading the same shape
|
||||||
|
/// off this machine's disk; a caller picks by transport, since a setup
|
||||||
|
/// with no ssh *is* this machine and asking a shell about it would be a
|
||||||
|
/// slower way to the same answer. What must not happen is offering this
|
||||||
|
/// backend's downloads for a session on another machine: the file has to
|
||||||
|
/// be where `llama-server` runs, and a list that says otherwise is a
|
||||||
|
/// claim about the wrong filesystem.
|
||||||
|
///
|
||||||
|
/// `dir` is that machine's models directory, `~` included -- expanded on
|
||||||
|
/// the far side, which is the only place that knows what it is. A
|
||||||
|
/// directory that is not there is an empty list rather than a failure: a
|
||||||
|
/// machine that has never had a model put on it is an ordinary state, and
|
||||||
|
/// the same one as a machine whose directory exists and is empty.
|
||||||
|
pub async fn on_machine(transport: &Transport, dir: &str) -> Result<Vec<LocalModel>> {
|
||||||
|
let script = "p=$1; case $p in \"~\") p=$HOME;; \"~/\"*) p=$HOME/${p#\"~/\"};; esac; \
|
||||||
|
[ -d \"$p\" ] || exit 0; \
|
||||||
|
find \"$p\" -type f -name '*.gguf' -printf '%s\\t%P\\0'";
|
||||||
|
let launch = Launch::new(
|
||||||
|
"sh",
|
||||||
|
vec![
|
||||||
|
"-c".to_string(),
|
||||||
|
script.to_string(),
|
||||||
|
"sh".to_string(),
|
||||||
|
dir.to_string(),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let out = transport.capture(&launch).await?;
|
||||||
|
let mut found: Vec<LocalModel> = out
|
||||||
|
.split('\0')
|
||||||
|
.filter(|record| !record.is_empty())
|
||||||
|
// Two fields, and the name last, so a `\t` in a filename survives.
|
||||||
|
.filter_map(|record| record.split_once('\t'))
|
||||||
|
.filter_map(|(bytes, key)| {
|
||||||
|
let (repo, file) = key.rsplit_once('/')?;
|
||||||
|
Some(LocalModel {
|
||||||
|
key: key.to_string(),
|
||||||
|
repo: repo.to_string(),
|
||||||
|
file: file.to_string(),
|
||||||
|
bytes: bytes.trim().parse().unwrap_or(0),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
found.sort_by(|a, b| a.key.cmp(&b.key));
|
||||||
|
Ok(found)
|
||||||
|
}
|
||||||
|
|
||||||
/// A model repository on HuggingFace, as the browse screen shows it.
|
/// A model repository on HuggingFace, as the browse screen shows it.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//! POST /setups add {name, ssh?} -- providers are discovered
|
//! POST /setups add {name, ssh?} -- providers are discovered
|
||||||
//! POST /setups/probe dry run {ssh?}: what would be found there
|
//! POST /setups/probe dry run {ssh?}: what would be found there
|
||||||
//! GET /setups/{id} one machine, for refetching after a change
|
//! GET /setups/{id} one machine, for refetching after a change
|
||||||
|
//! GET /setups/{id}/models GGUFs on that machine, for a llama session
|
||||||
//! GET /setups/{id}/dir?path=P entries of directory P, and P resolved
|
//! GET /setups/{id}/dir?path=P entries of directory P, and P resolved
|
||||||
//! GET /setups/{id}/file?path=P content of file P, or why not
|
//! GET /setups/{id}/file?path=P content of file P, or why not
|
||||||
//! PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
|
//! PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
|
||||||
@@ -102,6 +103,7 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
|
|||||||
// `crate::files`. Under the setup rather than under a session
|
// `crate::files`. Under the setup rather than under a session
|
||||||
// because a filesystem is a property of a machine; a session only
|
// because a filesystem is a property of a machine; a session only
|
||||||
// says where to start looking.
|
// says where to start looking.
|
||||||
|
.route("/setups/{id}/models", get(setup_models))
|
||||||
.route("/setups/{id}/dir", get(list_dir).post(create_dir))
|
.route("/setups/{id}/dir", get(list_dir).post(create_dir))
|
||||||
.route(
|
.route(
|
||||||
"/setups/{id}/file",
|
"/setups/{id}/file",
|
||||||
@@ -285,6 +287,9 @@ struct SshRequest {
|
|||||||
/// Where attached files land on that machine; see `SshConfig`.
|
/// Where attached files land on that machine; see `SshConfig`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
attachments_dir: Option<String>,
|
attachments_dir: Option<String>,
|
||||||
|
/// Where that machine keeps its GGUF models; see `SshConfig`.
|
||||||
|
#[serde(default)]
|
||||||
|
models_dir: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SshRequest {
|
impl SshRequest {
|
||||||
@@ -315,6 +320,14 @@ impl SshRequest {
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|dir| !dir.is_empty())
|
.filter(|dir| !dir.is_empty())
|
||||||
.map(std::path::PathBuf::from),
|
.map(std::path::PathBuf::from),
|
||||||
|
// The same rule, and for the same reason: this directory is
|
||||||
|
// on the other machine, so a `~` in it is that machine's home.
|
||||||
|
models_dir: self
|
||||||
|
.models_dir
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|dir| !dir.is_empty())
|
||||||
|
.map(std::path::PathBuf::from),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -499,6 +512,27 @@ struct PathQuery {
|
|||||||
path: String,
|
path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The models **that machine** has, which is the list a llama.cpp session
|
||||||
|
/// on it can choose from.
|
||||||
|
///
|
||||||
|
/// Not `GET /models`, which is this backend's own downloads: those are on
|
||||||
|
/// the machine a session runs on only when they are the same machine. A
|
||||||
|
/// spawn screen offering this backend's list for a remote setup would be
|
||||||
|
/// naming files that are not there, and the session would fail at the
|
||||||
|
/// point of loading rather than at the point of choosing.
|
||||||
|
async fn setup_models(
|
||||||
|
State(manager): State<Arc<SessionManager>>,
|
||||||
|
UrlPath(id): UrlPath<String>,
|
||||||
|
) -> Result<axum::Json<Vec<crate::models::LocalModel>>, ApiError> {
|
||||||
|
let setup = setup_by_id(&manager, &id)?;
|
||||||
|
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||||
|
let dir = crate::models::dir_on(&transport, manager.models_dir());
|
||||||
|
crate::models::on_machine(&transport, &dir)
|
||||||
|
.await
|
||||||
|
.map(axum::Json)
|
||||||
|
.map_err(from_machine)
|
||||||
|
}
|
||||||
|
|
||||||
/// What is in a directory, and what that directory resolved to.
|
/// What is in a directory, and what that directory resolved to.
|
||||||
async fn list_dir(
|
async fn list_dir(
|
||||||
State(manager): State<Arc<SessionManager>>,
|
State(manager): State<Arc<SessionManager>>,
|
||||||
|
|||||||
@@ -28,6 +28,13 @@
|
|||||||
//! - `/error [text]` -- a failure, which is otherwise awkward to cause.
|
//! - `/error [text]` -- a failure, which is otherwise awkward to cause.
|
||||||
//! - `/peer [text]` -- a message from another agent, which otherwise takes
|
//! - `/peer [text]` -- a message from another agent, which otherwise takes
|
||||||
//! two live sessions and one of them deciding to write.
|
//! two live sessions and one of them deciding to write.
|
||||||
|
//! - `/usage [what]` -- puts up an invented rate-limit answer, or takes
|
||||||
|
//! it away again (`/usage off`). An echo session meters nothing, so it
|
||||||
|
//! draws no usage bar at all until this is set; what it exists for is
|
||||||
|
//! the states the bar can be in, which otherwise cost real quota to
|
||||||
|
//! reach. `/usage 42`, `/usage 95 20`, `/usage 42 never`,
|
||||||
|
//! `/usage notloggedin`, `/usage unreachable`, `/usage failed`. The
|
||||||
|
//! vocabulary is `usage::Fixture`'s, which is where the states live.
|
||||||
//! - `/compact` -- a compaction, start to finish. Typed rather than
|
//! - `/compact` -- a compaction, start to finish. Typed rather than
|
||||||
//! pressed, because the real dialects take it as a typed command too and
|
//! pressed, because the real dialects take it as a typed command too and
|
||||||
//! the phone no longer has a button for it.
|
//! the phone no longer has a button for it.
|
||||||
@@ -106,6 +113,11 @@ pub struct EchoDriver {
|
|||||||
/// way AskUserQuestion does, and the turn resumes when the last of
|
/// way AskUserQuestion does, and the turn resumes when the last of
|
||||||
/// them is answered rather than the first.
|
/// them is answered rather than the first.
|
||||||
pending_questions: Mutex<Vec<PendingQuestion>>,
|
pending_questions: Mutex<Vec<PendingQuestion>>,
|
||||||
|
/// The invented rate-limit answer `/usage` sets, shared with the
|
||||||
|
/// usage monitor that serves it. An echo session meters nothing, so
|
||||||
|
/// this is unset until a test asks for something -- see
|
||||||
|
/// [`crate::usage::Fixture`].
|
||||||
|
usage: crate::usage::Fixture,
|
||||||
/// A pretend context, so the status row has something that behaves the
|
/// A pretend context, so the status row has something that behaves the
|
||||||
/// way a real one does: it grows with each turn, drops to what the
|
/// way a real one does: it grows with each turn, drops to what the
|
||||||
/// compaction says it recovered, and a clear leaves it unmeasured. The
|
/// compaction says it recovered, and a clear leaves it unmeasured. The
|
||||||
@@ -345,6 +357,29 @@ impl EchoDriver {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Answered here rather than in the turn below, because it is not
|
||||||
|
// a turn: nothing is generated, and what is being exercised is
|
||||||
|
// the *other* screens -- the bar under the header, the button
|
||||||
|
// beside it and the dialog it opens, all of which read the usage
|
||||||
|
// route rather than this transcript.
|
||||||
|
if let Some(rest) = text.strip_prefix("/usage") {
|
||||||
|
if announce {
|
||||||
|
self.emit(Event::MessageTaken {
|
||||||
|
id: None,
|
||||||
|
text: text.clone(),
|
||||||
|
attachments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let said = self.usage.command(rest);
|
||||||
|
self.emit(Event::AssistantText {
|
||||||
|
delta: format!("{said}\n"),
|
||||||
|
});
|
||||||
|
self.emit(Event::Status {
|
||||||
|
state: SessionStatus::Idle,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// The same word the real CLI takes, so a phone drives both the same
|
// The same word the real CLI takes, so a phone drives both the same
|
||||||
// way. `Driver::compact` is what the manager's own route calls;
|
// way. `Driver::compact` is what the manager's own route calls;
|
||||||
// this is the typed path onto it.
|
// this is the typed path onto it.
|
||||||
@@ -651,7 +686,7 @@ impl EchoDriver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new(sink: EventSink, session_dir: PathBuf) -> Self {
|
pub fn new(sink: EventSink, session_dir: PathBuf, usage: crate::usage::Fixture) -> Self {
|
||||||
let driver = Self {
|
let driver = Self {
|
||||||
sink,
|
sink,
|
||||||
pending_questions: Mutex::new(Vec::new()),
|
pending_questions: Mutex::new(Vec::new()),
|
||||||
@@ -659,6 +694,7 @@ impl EchoDriver {
|
|||||||
busy: Arc::new(AtomicBool::new(false)),
|
busy: Arc::new(AtomicBool::new(false)),
|
||||||
queued: Arc::new(Mutex::new(Vec::new())),
|
queued: Arc::new(Mutex::new(Vec::new())),
|
||||||
session_dir,
|
session_dir,
|
||||||
|
usage,
|
||||||
};
|
};
|
||||||
driver.emit(Event::Status {
|
driver.emit(Event::Status {
|
||||||
state: SessionStatus::Idle,
|
state: SessionStatus::Idle,
|
||||||
|
|||||||
+147
-30
@@ -7,10 +7,24 @@
|
|||||||
//!
|
//!
|
||||||
//! **It is spawned but not spoken to over stdio.** The process is started
|
//! **It is spawned but not spoken to over stdio.** The process is started
|
||||||
//! through the same [`Transport`] as any other, and then reached over
|
//! through the same [`Transport`] as any other, and then reached over
|
||||||
//! HTTP on a loopback port. That is the case the transport's doc comment
|
//! HTTP on a loopback port. That is the second half of what a transport
|
||||||
//! flags: a remote llama-server would need its port forwarded as well as
|
//! is -- "run this" plus "reach this port" -- and it is what lets a
|
||||||
//! its command wrapped, which is not built, so a session on an ssh host
|
//! session run on another machine: [`Transport::reserve_port`] hands back
|
||||||
//! is refused rather than silently talking to the wrong machine.
|
//! a port the server binds *there* and a port that reaches it *here*, and
|
||||||
|
//! the ssh connection carrying the command carries the tunnel between
|
||||||
|
//! them. The far `llama-server` binds loopback only, so a model is never
|
||||||
|
//! served to that machine's network.
|
||||||
|
//!
|
||||||
|
//! **The model file is the far machine's, not this one's.** A session
|
||||||
|
//! serves a GGUF from the machine that runs `llama-server`, so a remote
|
||||||
|
//! setup names its own models directory (`SshConfig::models_dir`,
|
||||||
|
//! defaulting to the same place this backend keeps its own downloads).
|
||||||
|
//! What this backend has downloaded is on that machine only when they are
|
||||||
|
//! the same machine -- so the file is looked for *there*, and a session
|
||||||
|
//! that names a model the machine does not have says so instead of
|
||||||
|
//! starting a server that will never load one. Downloading to another
|
||||||
|
//! machine is not built; the model gets there however anything else
|
||||||
|
//! gets there.
|
||||||
//!
|
//!
|
||||||
//! **The server is stateless between requests**, so the whole
|
//! **The server is stateless between requests**, so the whole
|
||||||
//! conversation goes with every one. It is rebuilt from the session's
|
//! conversation goes with every one. It is rebuilt from the session's
|
||||||
@@ -85,16 +99,10 @@ impl LlamaDriver {
|
|||||||
session_dir: &Path,
|
session_dir: &Path,
|
||||||
sink: EventSink,
|
sink: EventSink,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
if !matches!(transport, Transport::Here) {
|
|
||||||
bail!(
|
|
||||||
"llama.cpp sessions can only run on this machine for now: the model is served \
|
|
||||||
over HTTP, and forwarding that port to another host isn't built yet."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let model = meta.model.as_deref().context(
|
let model = meta.model.as_deref().context(
|
||||||
"a llama.cpp session needs a model -- one of the downloaded ones, by its key",
|
"a llama.cpp session needs a model -- one of the downloaded ones, by its key",
|
||||||
)?;
|
)?;
|
||||||
let path = model_path(models_dir, model)?;
|
let path = model_on(transport, models_dir, model)?;
|
||||||
|
|
||||||
// Already loaded and still running: keep talking to it. The
|
// Already loaded and still running: keep talking to it. The
|
||||||
// health poll below is what confirms it is really answering, so
|
// health poll below is what confirms it is really answering, so
|
||||||
@@ -120,14 +128,21 @@ impl LlamaDriver {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let port = free_port().context("finding a port for llama-server")?;
|
// Where it listens on its own machine, and where that is reached
|
||||||
|
// from here -- the same number when that machine is this one.
|
||||||
|
let forward = transport
|
||||||
|
.reserve_port()
|
||||||
|
.context("finding a port for llama-server")?;
|
||||||
let mut args: Vec<String> = vec![
|
let mut args: Vec<String> = vec![
|
||||||
"-m".into(),
|
"-m".into(),
|
||||||
path.to_string_lossy().into_owned(),
|
path.clone(),
|
||||||
|
// Loopback there, whichever machine there is: what reaches it
|
||||||
|
// from outside that machine is the ssh tunnel and nothing
|
||||||
|
// else.
|
||||||
"--host".into(),
|
"--host".into(),
|
||||||
"127.0.0.1".into(),
|
"127.0.0.1".into(),
|
||||||
"--port".into(),
|
"--port".into(),
|
||||||
port.to_string(),
|
forward.there.to_string(),
|
||||||
];
|
];
|
||||||
// Settings that belong to the server because they decide how the
|
// Settings that belong to the server because they decide how the
|
||||||
// model is loaded; the sampling ones ride on each request instead,
|
// model is loaded; the sampling ones ride on each request instead,
|
||||||
@@ -144,7 +159,7 @@ impl LlamaDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let program = provider.command.as_deref().unwrap_or("llama-server");
|
let program = provider.command.as_deref().unwrap_or("llama-server");
|
||||||
let launch = Launch::new(program, args, meta.cwd.as_deref());
|
let launch = Launch::new(program, args, meta.cwd.as_deref()).reaching(forward);
|
||||||
// Its output goes to files, not pipes. Not only so the process can
|
// Its output goes to files, not pipes. Not only so the process can
|
||||||
// outlive this server: nothing ever read those pipes, so a chatty
|
// outlive this server: nothing ever read those pipes, so a chatty
|
||||||
// llama-server filled the 64 KB buffer and blocked mid-load with
|
// llama-server filled the 64 KB buffer and blocked mid-load with
|
||||||
@@ -161,8 +176,12 @@ impl LlamaDriver {
|
|||||||
.id()
|
.id()
|
||||||
.context("llama-server exited before it could be recorded")?;
|
.context("llama-server exited before it could be recorded")?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"session {} running {program} for {model} on 127.0.0.1:{port} as pid {pid}",
|
"session {} running {program} for {model} {} on 127.0.0.1:{} there, \
|
||||||
meta.id
|
reached at 127.0.0.1:{} here, as pid {pid}",
|
||||||
|
meta.id,
|
||||||
|
transport.describe(),
|
||||||
|
forward.there,
|
||||||
|
forward.here,
|
||||||
);
|
);
|
||||||
// Reaped so it does not become a zombie while this server is still
|
// Reaped so it does not become a zombie while this server is still
|
||||||
// its parent; the health poll and the record are what actually say
|
// its parent; the health poll and the record are what actually say
|
||||||
@@ -173,12 +192,18 @@ impl LlamaDriver {
|
|||||||
let _ = child.wait().await;
|
let _ = child.wait().await;
|
||||||
});
|
});
|
||||||
|
|
||||||
let record = process::Record::of(pid, process::Detail::Http { port })
|
// The *near* port, because that is the one anything reaching this
|
||||||
|
// server has to dial -- including a later run of this backend,
|
||||||
|
// which adopts the record without knowing which machine the server
|
||||||
|
// is on. For a remote session the recorded pid is the ssh
|
||||||
|
// client's, which is the process this machine owns and which holds
|
||||||
|
// the tunnel open for exactly as long as the far server lives.
|
||||||
|
let record = process::Record::of(pid, process::Detail::Http { port: forward.here })
|
||||||
.context("llama-server was gone before its start time could be read")?;
|
.context("llama-server was gone before its start time could be read")?;
|
||||||
process::write(session_dir, &record);
|
process::write(session_dir, &record);
|
||||||
|
|
||||||
Ok(Self::attached(
|
Ok(Self::attached(
|
||||||
format!("http://127.0.0.1:{port}"),
|
format!("http://127.0.0.1:{}", forward.here),
|
||||||
meta,
|
meta,
|
||||||
model,
|
model,
|
||||||
transcript,
|
transcript,
|
||||||
@@ -212,7 +237,7 @@ impl LlamaDriver {
|
|||||||
let endpoint = endpoint.clone();
|
let endpoint = endpoint.clone();
|
||||||
let model = model.to_string();
|
let model = model.to_string();
|
||||||
let session_dir = session_dir.to_path_buf();
|
let session_dir = session_dir.to_path_buf();
|
||||||
std::thread::spawn(move || match wait_until_ready(&endpoint) {
|
std::thread::spawn(move || match wait_until_ready(&endpoint, &session_dir) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
tracing::info!("{model} loaded and answering at {endpoint}");
|
tracing::info!("{model} loaded and answering at {endpoint}");
|
||||||
let _ = sink.send(Event::Status {
|
let _ = sink.send(Event::Status {
|
||||||
@@ -518,19 +543,76 @@ fn model_path(models_dir: &Path, key: &str) -> Result<PathBuf> {
|
|||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An unused loopback port, by asking the OS for one and letting it go.
|
/// The model file's path **on the machine that will serve it**, confirmed
|
||||||
|
/// to be there.
|
||||||
///
|
///
|
||||||
/// Racy in principle: something else could take it between here and
|
/// Local and remote answer the same question and it has to be asked of
|
||||||
/// llama-server binding. In practice nothing on this machine is hunting
|
/// two different filesystems, which is why this is one function rather
|
||||||
/// for ports, and the alternative -- parsing the port back out of the
|
/// than a check beside the local path and hope for the other case. The
|
||||||
/// server's log -- couples us to its output format for no real gain.
|
/// remote answer is measured for the same reason the local one is: a
|
||||||
fn free_port() -> Result<u16> {
|
/// missing file otherwise becomes a `llama-server` that starts, fails to
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
|
/// load, and reports as a session that never became ready -- which reads
|
||||||
Ok(listener.local_addr()?.port())
|
/// as the machine being slow.
|
||||||
|
///
|
||||||
|
/// One blocking round trip on a remote spawn, which is the same cost the
|
||||||
|
/// spawn is already paying to start ssh. The alternative is a path built
|
||||||
|
/// here from a `~` this machine cannot expand.
|
||||||
|
fn model_on(transport: &Transport, models_dir: &Path, key: &str) -> Result<String> {
|
||||||
|
let Transport::Ssh { name, .. } = transport else {
|
||||||
|
return Ok(model_path(models_dir, key)?.to_string_lossy().into_owned());
|
||||||
|
};
|
||||||
|
// The same directory the spawn screen listed for this machine, and
|
||||||
|
// for the same reason it is one function: a list from one place and a
|
||||||
|
// load from another is a model that appears and then fails.
|
||||||
|
let dir = crate::models::dir_on(transport, models_dir);
|
||||||
|
// Checked here rather than in the script: `..` in a key would walk
|
||||||
|
// out of the models directory on a machine this server can start
|
||||||
|
// processes on, and the phone is where the key comes from.
|
||||||
|
for part in key.split('/') {
|
||||||
|
if part.is_empty() || part == "." || part == ".." {
|
||||||
|
bail!("\"{key}\" is not a model key this can resolve");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let path = format!("{}/{key}", dir.trim_end_matches('/'));
|
||||||
|
// `$HOME` on the far side, which is the only machine that knows what
|
||||||
|
// it is -- and the resolved path is printed back so the launch below
|
||||||
|
// hands `llama-server` something absolute.
|
||||||
|
//
|
||||||
|
// "the file is not there" is answered rather than failed, because the
|
||||||
|
// two are different things to a reader and only one of them is a
|
||||||
|
// fault: a machine that could not be asked at all has to say so in
|
||||||
|
// its own words, and it would otherwise arrive as this same sentence
|
||||||
|
// about a missing model.
|
||||||
|
let script = "p=$1; case $p in \"~\") p=$HOME;; \"~/\"*) p=$HOME/${p#\"~/\"};; esac; \
|
||||||
|
[ -f \"$p\" ] && printf 'at\\t%s\\n' \"$p\" || printf 'missing\\n'"
|
||||||
|
.to_string();
|
||||||
|
let launch = Launch::new(
|
||||||
|
"sh",
|
||||||
|
vec!["-c".to_string(), script, "sh".to_string(), path.clone()],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let answer = transport
|
||||||
|
.capture_blocking(&launch)
|
||||||
|
.with_context(|| format!("couldn't ask {name} where its models are"))?;
|
||||||
|
match answer.trim().split_once('\t') {
|
||||||
|
Some(("at", resolved)) => Ok(resolved.to_string()),
|
||||||
|
_ => bail!(
|
||||||
|
"{name} has no model at {path}. A llama.cpp session serves the file from the \
|
||||||
|
machine it runs on, so the model has to be on {name} -- what this backend has \
|
||||||
|
downloaded is somewhere else."
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Polls until the server says it is ready, or gives up.
|
/// Polls until the server says it is ready, or gives up.
|
||||||
fn wait_until_ready(endpoint: &str) -> Result<()> {
|
///
|
||||||
|
/// Watches the process as well as the port, because the two failures need
|
||||||
|
/// different words and one of them is common: a model that will not load,
|
||||||
|
/// a port already taken on the far machine, a `llama-server` too old for
|
||||||
|
/// a flag. All of those exit within a second and none of them will ever
|
||||||
|
/// answer `/health`, so waiting out the timeout turns a server that said
|
||||||
|
/// exactly what was wrong into "gave up after 300s".
|
||||||
|
fn wait_until_ready(endpoint: &str, session_dir: &Path) -> Result<()> {
|
||||||
let deadline = std::time::Instant::now() + READY_TIMEOUT;
|
let deadline = std::time::Instant::now() + READY_TIMEOUT;
|
||||||
let url = format!("{endpoint}/health");
|
let url = format!("{endpoint}/health");
|
||||||
loop {
|
loop {
|
||||||
@@ -539,13 +621,48 @@ fn wait_until_ready(endpoint: &str) -> Result<()> {
|
|||||||
{
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
// `None` is the session having been stopped or deleted while this
|
||||||
|
// waited, which is nobody's fault and still not worth waiting on.
|
||||||
|
match process::recorded(session_dir) {
|
||||||
|
Some((_, process::Liveness::Alive | process::Liveness::Unknown)) => {}
|
||||||
|
Some((_, process::Liveness::Dead)) | None => {
|
||||||
|
bail!("it exited before it answered.{}", log_tail(session_dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
if std::time::Instant::now() > deadline {
|
if std::time::Instant::now() > deadline {
|
||||||
bail!("gave up after {}s", READY_TIMEOUT.as_secs());
|
bail!(
|
||||||
|
"gave up after {}s.{}",
|
||||||
|
READY_TIMEOUT.as_secs(),
|
||||||
|
log_tail(session_dir)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The end of `llama-server`'s own log, for a failure message.
|
||||||
|
///
|
||||||
|
/// Its account of what went wrong is the useful half -- "failed to load
|
||||||
|
/// model", "bind: Address already in use" -- and on a remote session it
|
||||||
|
/// is the only half, since nobody reading the phone can open a file on
|
||||||
|
/// that machine. Bounded, because this ends up in an event a phone draws.
|
||||||
|
fn log_tail(session_dir: &Path) -> String {
|
||||||
|
let Ok(text) = std::fs::read_to_string(session_dir.join(SERVER_LOG)) else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
let tail: Vec<&str> = text.lines().rev().take(LOG_TAIL_LINES).collect();
|
||||||
|
if tail.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
" It last said: {}",
|
||||||
|
tail.into_iter().rev().collect::<Vec<_>>().join(" / ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much of that log to carry into a message somebody reads on a phone.
|
||||||
|
const LOG_TAIL_LINES: usize = 6;
|
||||||
|
|
||||||
/// One streamed completion: posts the conversation, emits each delta as it
|
/// One streamed completion: posts the conversation, emits each delta as it
|
||||||
/// arrives. Emits rather than returns: the transcript those events land
|
/// arrives. Emits rather than returns: the transcript those events land
|
||||||
/// in is what the next turn reads back, so there is nothing to hand up.
|
/// in is what the next turn reads back, so there is nothing to hand up.
|
||||||
|
|||||||
+86
-24
@@ -163,6 +163,17 @@ pub struct SessionInfo {
|
|||||||
/// answers and only one of them stays true.
|
/// answers and only one of them stays true.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub max_image_edge: Option<u32>,
|
pub max_image_edge: Option<u32>,
|
||||||
|
/// Which of `GET /usage`'s snapshots reports on this session, and
|
||||||
|
/// absent where nothing meters it -- see
|
||||||
|
/// [`DriverKind::usage_provider`].
|
||||||
|
///
|
||||||
|
/// Reported for the same reason `keeps_own_transcript` is: it is a
|
||||||
|
/// fact about the provider's *kind*, and the phone has only its name.
|
||||||
|
/// Pairing by machine alone was the bug it exists to fix -- one
|
||||||
|
/// machine runs echo and the Claude CLI, so every echo session drew
|
||||||
|
/// the CLI's five-hour window as if it were its own.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage_provider: Option<&'static str>,
|
||||||
/// Whether this session announces itself -- reported for the same
|
/// Whether this session announces itself -- reported for the same
|
||||||
/// reason `permission_mode` is: a switch that guesses its own position
|
/// reason `permission_mode` is: a switch that guesses its own position
|
||||||
/// is how you turn something off while believing you are reading it.
|
/// is how you turn something off while believing you are reading it.
|
||||||
@@ -549,6 +560,7 @@ impl LiveSession {
|
|||||||
context_tokens: *self.shared.context_tokens.lock().unwrap(),
|
context_tokens: *self.shared.context_tokens.lock().unwrap(),
|
||||||
notify: *self.shared.notify.lock().unwrap(),
|
notify: *self.shared.notify.lock().unwrap(),
|
||||||
max_image_edge: kind.and_then(DriverKind::max_image_edge),
|
max_image_edge: kind.and_then(DriverKind::max_image_edge),
|
||||||
|
usage_provider: kind.and_then(DriverKind::usage_provider),
|
||||||
imported,
|
imported,
|
||||||
keeps_own_transcript: kind.is_some_and(DriverKind::keeps_own_transcript),
|
keeps_own_transcript: kind.is_some_and(DriverKind::keeps_own_transcript),
|
||||||
cwd: cwd.map(Path::to_path_buf),
|
cwd: cwd.map(Path::to_path_buf),
|
||||||
@@ -585,6 +597,11 @@ pub struct SessionManager {
|
|||||||
/// [`SessionManager::marking_new_sessions_throwaway`] and
|
/// [`SessionManager::marking_new_sessions_throwaway`] and
|
||||||
/// [`SessionConfig::throwaway`].
|
/// [`SessionConfig::throwaway`].
|
||||||
spawn_throwaway: bool,
|
spawn_throwaway: bool,
|
||||||
|
/// The invented rate-limit answer an echo session's `/usage` sets,
|
||||||
|
/// shared with the usage monitor that serves it. Held here because
|
||||||
|
/// every echo driver this manager builds is handed a clone -- see
|
||||||
|
/// [`SessionManager::reporting_usage_fixture`].
|
||||||
|
usage_fixture: crate::usage::Fixture,
|
||||||
inner: RwLock<Inner>,
|
inner: RwLock<Inner>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,6 +620,11 @@ impl SessionManager {
|
|||||||
wg_app_link::private::create_dir(&data_dir)?;
|
wg_app_link::private::create_dir(&data_dir)?;
|
||||||
|
|
||||||
let (notifications, _) = broadcast::channel(NOTIFICATION_BUFFER);
|
let (notifications, _) = broadcast::channel(NOTIFICATION_BUFFER);
|
||||||
|
// Made here rather than passed in, and handed *out* to the usage
|
||||||
|
// monitor by whoever wires the two together: every echo driver
|
||||||
|
// this manager builds gets a clone, including the ones built
|
||||||
|
// below, so it has to exist before the first session does.
|
||||||
|
let usage_fixture = crate::usage::Fixture::new();
|
||||||
let mut live = HashMap::new();
|
let mut live = HashMap::new();
|
||||||
for meta in &config.sessions {
|
for meta in &config.sessions {
|
||||||
// One unlaunchable session -- a corrupt transcript, an
|
// One unlaunchable session -- a corrupt transcript, an
|
||||||
@@ -614,8 +636,11 @@ impl SessionManager {
|
|||||||
meta.clone(),
|
meta.clone(),
|
||||||
&setup,
|
&setup,
|
||||||
&provider,
|
&provider,
|
||||||
&data_dir,
|
Env {
|
||||||
&models_dir,
|
data_dir: &data_dir,
|
||||||
|
models_dir: &models_dir,
|
||||||
|
usage: &usage_fixture,
|
||||||
|
},
|
||||||
notifications.clone(),
|
notifications.clone(),
|
||||||
// Nothing is started here. See `Launching`: a restart
|
// Nothing is started here. See `Launching`: a restart
|
||||||
// picks up the processes that are still running and
|
// picks up the processes that are still running and
|
||||||
@@ -638,11 +663,41 @@ impl SessionManager {
|
|||||||
notifications,
|
notifications,
|
||||||
pending: Arc::new(pending::Registry::default()),
|
pending: Arc::new(pending::Registry::default()),
|
||||||
spawn_throwaway: false,
|
spawn_throwaway: false,
|
||||||
|
usage_fixture,
|
||||||
inner: RwLock::new(Inner { config, live }),
|
inner: RwLock::new(Inner { config, live }),
|
||||||
};
|
};
|
||||||
Ok(manager)
|
Ok(manager)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where this backend's own model downloads live. The machine a
|
||||||
|
/// session runs on may keep its elsewhere -- see `models::dir_on`.
|
||||||
|
pub fn models_dir(&self) -> &Path {
|
||||||
|
&self.models_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What this manager lends a session it launches. Borrowed from the
|
||||||
|
/// manager rather than cloned, so there is one answer to where things
|
||||||
|
/// are kept.
|
||||||
|
fn env(&self) -> Env<'_> {
|
||||||
|
Env {
|
||||||
|
data_dir: &self.data_dir,
|
||||||
|
models_dir: &self.models_dir,
|
||||||
|
usage: &self.usage_fixture,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The invented rate-limit answer this manager's echo sessions set
|
||||||
|
/// with `/usage`, for the usage monitor to serve.
|
||||||
|
///
|
||||||
|
/// Handed out rather than taken in because the drivers built inside
|
||||||
|
/// the constructor need it, and because the direction is the one the
|
||||||
|
/// layering allows: `usage` sits below the session layer, so a
|
||||||
|
/// session can hold one of its types while it holds nothing of a
|
||||||
|
/// session's.
|
||||||
|
pub fn usage_fixture(&self) -> crate::usage::Fixture {
|
||||||
|
self.usage_fixture.clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Marks every session spawned from here on as one whose process is
|
/// Marks every session spawned from here on as one whose process is
|
||||||
/// stopped when this server exits -- see [`SessionConfig::throwaway`]
|
/// stopped when this server exits -- see [`SessionConfig::throwaway`]
|
||||||
/// and [`SessionManager::stop_throwaway_sessions`].
|
/// and [`SessionManager::stop_throwaway_sessions`].
|
||||||
@@ -1035,6 +1090,8 @@ impl SessionManager {
|
|||||||
context_tokens: None,
|
context_tokens: None,
|
||||||
max_image_edge: kind_of(&inner.config, &meta.setup, &meta.provider)
|
max_image_edge: kind_of(&inner.config, &meta.setup, &meta.provider)
|
||||||
.and_then(DriverKind::max_image_edge),
|
.and_then(DriverKind::max_image_edge),
|
||||||
|
usage_provider: kind_of(&inner.config, &meta.setup, &meta.provider)
|
||||||
|
.and_then(DriverKind::usage_provider),
|
||||||
notify: meta.notify,
|
notify: meta.notify,
|
||||||
imported: import::read_cursor(&self.data_dir.join(&meta.id)).is_some(),
|
imported: import::read_cursor(&self.data_dir.join(&meta.id)).is_some(),
|
||||||
keeps_own_transcript: keeps_own_transcript(
|
keeps_own_transcript: keeps_own_transcript(
|
||||||
@@ -1162,8 +1219,7 @@ impl SessionManager {
|
|||||||
meta.clone(),
|
meta.clone(),
|
||||||
&setup,
|
&setup,
|
||||||
&provider,
|
&provider,
|
||||||
&self.data_dir,
|
self.env(),
|
||||||
&self.models_dir,
|
|
||||||
self.notifications.clone(),
|
self.notifications.clone(),
|
||||||
Launching::Asked(seed),
|
Launching::Asked(seed),
|
||||||
)?;
|
)?;
|
||||||
@@ -1605,7 +1661,7 @@ impl SessionManager {
|
|||||||
&meta,
|
&meta,
|
||||||
&setup,
|
&setup,
|
||||||
&provider,
|
&provider,
|
||||||
&self.models_dir,
|
self.env(),
|
||||||
session.dir(),
|
session.dir(),
|
||||||
session.transcript_path(),
|
session.transcript_path(),
|
||||||
&session.sink,
|
&session.sink,
|
||||||
@@ -1619,8 +1675,7 @@ impl SessionManager {
|
|||||||
meta,
|
meta,
|
||||||
&setup,
|
&setup,
|
||||||
&provider,
|
&provider,
|
||||||
&self.data_dir,
|
self.env(),
|
||||||
&self.models_dir,
|
|
||||||
self.notifications.clone(),
|
self.notifications.clone(),
|
||||||
Launching::Asked(None),
|
Launching::Asked(None),
|
||||||
)?;
|
)?;
|
||||||
@@ -2020,6 +2075,19 @@ enum Launching {
|
|||||||
Restart,
|
Restart,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the server around a session lends it: where sessions and models
|
||||||
|
/// are kept, and the usage fixture an echo session's `/usage` sets.
|
||||||
|
///
|
||||||
|
/// One parameter rather than three because they travel together through
|
||||||
|
/// every launch path and none of them is a fact about the session --
|
||||||
|
/// they are this server's belongings, handed down.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct Env<'a> {
|
||||||
|
data_dir: &'a Path,
|
||||||
|
models_dir: &'a Path,
|
||||||
|
usage: &'a crate::usage::Fixture,
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates the session directory, opens its transcript (continuing the
|
/// Creates the session directory, opens its transcript (continuing the
|
||||||
/// sequence numbering if one exists), settles what the session is doing,
|
/// sequence numbering if one exists), settles what the session is doing,
|
||||||
/// and spawns the event pump -- with a driver behind it where there is a
|
/// and spawns the event pump -- with a driver behind it where there is a
|
||||||
@@ -2028,12 +2096,11 @@ fn launch(
|
|||||||
meta: SessionConfig,
|
meta: SessionConfig,
|
||||||
setup: &SetupConfig,
|
setup: &SetupConfig,
|
||||||
provider: &ProviderConfig,
|
provider: &ProviderConfig,
|
||||||
data_dir: &Path,
|
env: Env<'_>,
|
||||||
models_dir: &Path,
|
|
||||||
notifications: broadcast::Sender<Notification>,
|
notifications: broadcast::Sender<Notification>,
|
||||||
why: Launching,
|
why: Launching,
|
||||||
) -> Result<Arc<LiveSession>> {
|
) -> Result<Arc<LiveSession>> {
|
||||||
let dir = data_dir.join(&meta.id);
|
let dir = env.data_dir.join(&meta.id);
|
||||||
wg_app_link::private::create_dir(&dir)?;
|
wg_app_link::private::create_dir(&dir)?;
|
||||||
let transcript_path = dir.join("transcript.jsonl");
|
let transcript_path = dir.join("transcript.jsonl");
|
||||||
let mut transcript = Transcript::open(&transcript_path)?;
|
let mut transcript = Transcript::open(&transcript_path)?;
|
||||||
@@ -2163,17 +2230,7 @@ fn launch(
|
|||||||
|
|
||||||
let driver = Arc::new(Mutex::new(
|
let driver = Arc::new(Mutex::new(
|
||||||
driving
|
driving
|
||||||
.then(|| {
|
.then(|| make_driver(&meta, setup, provider, env, &dir, &transcript_path, &sink))
|
||||||
make_driver(
|
|
||||||
&meta,
|
|
||||||
setup,
|
|
||||||
provider,
|
|
||||||
models_dir,
|
|
||||||
&dir,
|
|
||||||
&transcript_path,
|
|
||||||
&sink,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.transpose()?,
|
.transpose()?,
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -2216,18 +2273,22 @@ fn make_driver(
|
|||||||
meta: &SessionConfig,
|
meta: &SessionConfig,
|
||||||
setup: &SetupConfig,
|
setup: &SetupConfig,
|
||||||
provider: &ProviderConfig,
|
provider: &ProviderConfig,
|
||||||
models_dir: &Path,
|
env: Env<'_>,
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
transcript_path: &Path,
|
transcript_path: &Path,
|
||||||
sink: &EventSink,
|
sink: &EventSink,
|
||||||
) -> Result<Arc<dyn Driver>> {
|
) -> Result<Arc<dyn Driver>> {
|
||||||
Ok(match provider.kind {
|
Ok(match provider.kind {
|
||||||
DriverKind::Echo => Arc::new(EchoDriver::new(sink.clone(), dir.to_path_buf())),
|
DriverKind::Echo => Arc::new(EchoDriver::new(
|
||||||
|
sink.clone(),
|
||||||
|
dir.to_path_buf(),
|
||||||
|
env.usage.clone(),
|
||||||
|
)),
|
||||||
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
|
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
|
||||||
meta,
|
meta,
|
||||||
provider,
|
provider,
|
||||||
&Transport::for_setup(setup),
|
&Transport::for_setup(setup),
|
||||||
models_dir,
|
env.models_dir,
|
||||||
transcript_path,
|
transcript_path,
|
||||||
dir,
|
dir,
|
||||||
sink.clone(),
|
sink.clone(),
|
||||||
@@ -2584,6 +2645,7 @@ mod tests {
|
|||||||
driver: Arc::new(Mutex::new(Some(Arc::new(EchoDriver::new(
|
driver: Arc::new(Mutex::new(Some(Arc::new(EchoDriver::new(
|
||||||
sink.clone(),
|
sink.clone(),
|
||||||
dir.path().to_path_buf(),
|
dir.path().to_path_buf(),
|
||||||
|
crate::usage::Fixture::new(),
|
||||||
))))),
|
))))),
|
||||||
sink,
|
sink,
|
||||||
waiting: Mutex::new(VecDeque::new()),
|
waiting: Mutex::new(VecDeque::new()),
|
||||||
|
|||||||
@@ -13,11 +13,14 @@
|
|||||||
//! this module decides *which* transport, that one knows what a correct
|
//! this module decides *which* transport, that one knows what a correct
|
||||||
//! ssh invocation is.
|
//! ssh invocation is.
|
||||||
//!
|
//!
|
||||||
//! Known second operation, not built because nothing needs it yet: a
|
//! A transport is therefore two operations rather than one: **run this**,
|
||||||
//! managed `llama-server` is spawned as a process but then spoken to over
|
//! and **reach this port**. The second is what a managed `llama-server`
|
||||||
//! HTTP, so a remote one needs a forwarded port (`ssh -L`) as well. A
|
//! needs -- it is spawned as a process and then spoken to over HTTP -- and
|
||||||
//! transport is eventually "run this" plus "reach this port", where the
|
//! it is a no-op locally, where the port a program binds is already a port
|
||||||
//! second is a no-op locally. See PLAN.md's SSH section.
|
//! this machine can dial. Over ssh it is an `-L` tunnel carried by the
|
||||||
|
//! same connection that runs the command, so the model server binds
|
||||||
|
//! loopback on the far machine and is never exposed to its network. See
|
||||||
|
//! [`Transport::reserve_port`] and PLAN.md's SSH section.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
@@ -26,16 +29,28 @@ use anyhow::{Context, Result};
|
|||||||
use tokio::process::Child;
|
use tokio::process::Child;
|
||||||
|
|
||||||
use crate::config::SshConfig;
|
use crate::config::SshConfig;
|
||||||
|
pub use crate::ssh::Forward;
|
||||||
|
|
||||||
/// What a driver needs run in order to exist as a process.
|
/// What a driver needs run in order to exist as a process.
|
||||||
///
|
///
|
||||||
/// Deliberately just the three things every transport can carry. Anything
|
/// Deliberately just what every transport can carry: the command, where
|
||||||
/// a particular machine needs -- a port, a key, extra ssh options -- is
|
/// it runs, and a port the caller needs to reach. Anything a particular
|
||||||
|
/// machine needs -- a key, extra ssh options, which address to dial -- is
|
||||||
/// the transport's own configuration, not something a driver states.
|
/// the transport's own configuration, not something a driver states.
|
||||||
pub struct Launch {
|
pub struct Launch {
|
||||||
pub program: String,
|
pub program: String,
|
||||||
pub args: Vec<String>,
|
pub args: Vec<String>,
|
||||||
pub cwd: Option<PathBuf>,
|
pub cwd: Option<PathBuf>,
|
||||||
|
/// A port this program will listen on, and the port that reaches it
|
||||||
|
/// from here -- see [`Transport::reserve_port`], which is the only
|
||||||
|
/// thing that should produce one.
|
||||||
|
///
|
||||||
|
/// On the launch rather than in [`Transport::spawn`]'s signature
|
||||||
|
/// because it is part of what is being run: a caller that needs to
|
||||||
|
/// reach the process it is starting says so once, where it says
|
||||||
|
/// everything else about it, and every transport reads it the same
|
||||||
|
/// way.
|
||||||
|
pub forward: Option<Forward>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Launch {
|
impl Launch {
|
||||||
@@ -44,8 +59,16 @@ impl Launch {
|
|||||||
program: program.into(),
|
program: program.into(),
|
||||||
args,
|
args,
|
||||||
cwd: cwd.map(Path::to_path_buf),
|
cwd: cwd.map(Path::to_path_buf),
|
||||||
|
forward: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Says that this program serves `forward.there`, and that the caller
|
||||||
|
/// will reach it at `forward.here`.
|
||||||
|
pub fn reaching(mut self, forward: Forward) -> Self {
|
||||||
|
self.forward = Some(forward);
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How a launched process's standard streams are connected.
|
/// How a launched process's standard streams are connected.
|
||||||
@@ -113,6 +136,7 @@ impl Transport {
|
|||||||
&launch.program,
|
&launch.program,
|
||||||
&launch.args,
|
&launch.args,
|
||||||
launch.cwd.as_deref(),
|
launch.cwd.as_deref(),
|
||||||
|
launch.forward,
|
||||||
));
|
));
|
||||||
match streams {
|
match streams {
|
||||||
Streams::Piped => {
|
Streams::Piped => {
|
||||||
@@ -169,12 +193,15 @@ impl Transport {
|
|||||||
Self::Here => None,
|
Self::Here => None,
|
||||||
Self::Ssh { ssh, .. } => Some(ssh),
|
Self::Ssh { ssh, .. } => Some(ssh),
|
||||||
};
|
};
|
||||||
let output =
|
let output = crate::ssh::command(
|
||||||
crate::ssh::command(host, &launch.program, &launch.args, launch.cwd.as_deref())
|
host,
|
||||||
|
&launch.program,
|
||||||
|
&launch.args,
|
||||||
|
launch.cwd.as_deref(),
|
||||||
|
launch.forward,
|
||||||
|
)
|
||||||
.output()
|
.output()
|
||||||
.with_context(|| {
|
.with_context(|| format!("couldn't run \"{}\" {}", launch.program, self.describe()))?;
|
||||||
format!("couldn't run \"{}\" {}", launch.program, self.describe())
|
|
||||||
})?;
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
anyhow::bail!(if stderr.is_empty() {
|
anyhow::bail!(if stderr.is_empty() {
|
||||||
@@ -237,6 +264,34 @@ impl Transport {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Picks a port for a launched program to serve on, and the port that
|
||||||
|
/// reaches it from here.
|
||||||
|
///
|
||||||
|
/// The "reach this port" half of what a transport is. Locally there is
|
||||||
|
/// one port and the OS chooses it, by binding and letting go -- racy
|
||||||
|
/// in principle, and nothing on this machine is hunting for ports.
|
||||||
|
///
|
||||||
|
/// Over ssh the near end is chosen the same way and the far end is a
|
||||||
|
/// guess, because there is no portable way to ask a machine for a free
|
||||||
|
/// port that does not race with binding it anyway. It is taken from
|
||||||
|
/// [`FAR_PORTS`], below the range Linux hands out to outgoing
|
||||||
|
/// connections, so a collision means something else deliberately
|
||||||
|
/// listening there. That is not silent: the program fails to bind and
|
||||||
|
/// exits, and `session::llama` reports what its log said rather than
|
||||||
|
/// waiting out its readiness timeout.
|
||||||
|
pub fn reserve_port(&self) -> Result<Forward> {
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.context("asking this machine for a free port")?;
|
||||||
|
let here = listener.local_addr()?.port();
|
||||||
|
Ok(match self {
|
||||||
|
Self::Here => Forward { there: here, here },
|
||||||
|
Self::Ssh { .. } => Forward {
|
||||||
|
there: rand::random_range(FAR_PORTS),
|
||||||
|
here,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// How to say where this runs, for a log line a person reads.
|
/// How to say where this runs, for a log line a person reads.
|
||||||
pub fn describe(&self) -> String {
|
pub fn describe(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
@@ -246,6 +301,11 @@ impl Transport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a port on another machine is guessed from: high enough to be out
|
||||||
|
/// of the way of services, and below the 32768-60999 Linux hands out to
|
||||||
|
/// outgoing connections, which is where a guess would most often collide.
|
||||||
|
const FAR_PORTS: std::ops::Range<u16> = 20000..30000;
|
||||||
|
|
||||||
/// What a command is given on its standard input.
|
/// What a command is given on its standard input.
|
||||||
///
|
///
|
||||||
/// Three cases rather than an `Option<Stdio>` because they are three
|
/// Three cases rather than an `Option<Stdio>` because they are three
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ use crate::session::transport::{Launch, Transport};
|
|||||||
/// what the phone shows and what a session stores.
|
/// what the phone shows and what a session stores.
|
||||||
const PROBES: &[(&str, &str, DriverKind)] = &[
|
const PROBES: &[(&str, &str, DriverKind)] = &[
|
||||||
("claude-cli", "claude", DriverKind::ClaudeCli),
|
("claude-cli", "claude", DriverKind::ClaudeCli),
|
||||||
("local-llama", "llama-server", DriverKind::LlamaCpp),
|
// Named for the program rather than for where it runs: it runs
|
||||||
|
// wherever the setup is, and "local" was true only while a llama
|
||||||
|
// session could not be spawned on another machine.
|
||||||
|
("llama-cpp", "llama-server", DriverKind::LlamaCpp),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Models offered for a discovered Claude CLI. A shortcut list for the
|
/// Models offered for a discovered Claude CLI. A shortcut list for the
|
||||||
|
|||||||
+114
-5
@@ -15,6 +15,23 @@ use std::process::Command;
|
|||||||
|
|
||||||
use crate::config::SshConfig;
|
use crate::config::SshConfig;
|
||||||
|
|
||||||
|
/// A port on the machine a command runs on, and the port that reaches it
|
||||||
|
/// from the backend.
|
||||||
|
///
|
||||||
|
/// The second half of what a transport is (PLAN.md's SSH section): "run
|
||||||
|
/// this" plus "reach this port". Locally the two numbers are the same one
|
||||||
|
/// and nothing is forwarded; over ssh the connection carries an `-L`
|
||||||
|
/// tunnel, so a model server binds loopback on the far machine and is
|
||||||
|
/// never exposed to its network.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Forward {
|
||||||
|
/// What the launched program should listen on, on its own machine.
|
||||||
|
pub there: u16,
|
||||||
|
/// What this machine connects to. The same number as `there` when the
|
||||||
|
/// program runs here.
|
||||||
|
pub here: u16,
|
||||||
|
}
|
||||||
|
|
||||||
/// Options forced onto every connection. `BatchMode` makes a missing key
|
/// Options forced onto every connection. `BatchMode` makes a missing key
|
||||||
/// fail immediately with a readable message instead of hanging on a
|
/// fail immediately with a readable message instead of hanging on a
|
||||||
/// password prompt that nothing can answer; the keepalives turn a silently
|
/// password prompt that nothing can answer; the keepalives turn a silently
|
||||||
@@ -45,6 +62,7 @@ pub fn command(
|
|||||||
program: &str,
|
program: &str,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
cwd: Option<&Path>,
|
cwd: Option<&Path>,
|
||||||
|
forward: Option<Forward>,
|
||||||
) -> Command {
|
) -> Command {
|
||||||
let Some(ssh) = remote else {
|
let Some(ssh) = remote else {
|
||||||
let mut command = Command::new(program);
|
let mut command = Command::new(program);
|
||||||
@@ -64,9 +82,42 @@ pub fn command(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut command = Command::new("ssh");
|
let mut command = Command::new("ssh");
|
||||||
// -T: no pty. This carries JSONL, and a pty would rewrite it (echo,
|
if let Some(forward) = forward {
|
||||||
// CRLF translation, ^C handling) into something the parser can't read.
|
// A forwarded process is not spoken to over stdio, and that
|
||||||
|
// changes how it has to be shut down. Everything else here is a
|
||||||
|
// CLI reading its stdin, so killing the ssh client closes that
|
||||||
|
// stdin and the far process ends; a `llama-server` never reads
|
||||||
|
// its own, so the same kill left it running on the far machine
|
||||||
|
// holding the model in memory -- measured 2026-09-04, an orphan
|
||||||
|
// per stopped session. A pty is what makes sshd hang the far side
|
||||||
|
// up: when the connection goes, the master closes and the session
|
||||||
|
// takes SIGHUP. `-tt` because this client has no terminal of its
|
||||||
|
// own to inherit one from.
|
||||||
|
//
|
||||||
|
// The cost is that its log arrives through a line discipline
|
||||||
|
// (CRLF, and whatever the program does when it thinks it is on a
|
||||||
|
// terminal). Nothing parses that log, so it is a fair trade for a
|
||||||
|
// process that reliably goes away.
|
||||||
|
command.arg("-tt");
|
||||||
|
// Loopback on both ends: the far side binds 127.0.0.1, so the
|
||||||
|
// port it serves is reachable only through this connection and
|
||||||
|
// never from that machine's network -- and the near end is bound
|
||||||
|
// to this host alone for the same reason.
|
||||||
|
command.args([
|
||||||
|
"-L",
|
||||||
|
&format!("127.0.0.1:{}:127.0.0.1:{}", forward.here, forward.there),
|
||||||
|
]);
|
||||||
|
// Without this a forward that cannot be set up is a warning on
|
||||||
|
// stderr and a session that runs anyway, answering nothing: the
|
||||||
|
// failure would arrive as "the model never became ready", which
|
||||||
|
// is the wrong thing to go looking at.
|
||||||
|
command.args(["-o", "ExitOnForwardFailure=yes"]);
|
||||||
|
} else {
|
||||||
|
// -T: no pty. This carries JSONL, and a pty would rewrite it
|
||||||
|
// (echo, CRLF translation, ^C handling) into something the parser
|
||||||
|
// can't read.
|
||||||
command.arg("-T");
|
command.arg("-T");
|
||||||
|
}
|
||||||
for option in SSH_OPTIONS {
|
for option in SSH_OPTIONS {
|
||||||
command.args(["-o", option]);
|
command.args(["-o", option]);
|
||||||
}
|
}
|
||||||
@@ -200,6 +251,7 @@ mod tests {
|
|||||||
port: None,
|
port: None,
|
||||||
identity_file: None,
|
identity_file: None,
|
||||||
options: vec![],
|
options: vec![],
|
||||||
|
models_dir: None,
|
||||||
attachments_dir: None,
|
attachments_dir: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,6 +263,7 @@ mod tests {
|
|||||||
"claude",
|
"claude",
|
||||||
&args(["-p", "--verbose"]),
|
&args(["-p", "--verbose"]),
|
||||||
Some(Path::new("/tmp/x")),
|
Some(Path::new("/tmp/x")),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
assert_eq!(argv(&command), ["claude", "-p", "--verbose"]);
|
assert_eq!(argv(&command), ["claude", "-p", "--verbose"]);
|
||||||
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/x")));
|
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/x")));
|
||||||
@@ -223,6 +276,7 @@ mod tests {
|
|||||||
port: Some(2222),
|
port: Some(2222),
|
||||||
identity_file: Some("/home/me/.ssh/id_ai".into()),
|
identity_file: Some("/home/me/.ssh/id_ai".into()),
|
||||||
options: vec!["StrictHostKeyChecking=accept-new".to_string()],
|
options: vec!["StrictHostKeyChecking=accept-new".to_string()],
|
||||||
|
models_dir: None,
|
||||||
attachments_dir: None,
|
attachments_dir: None,
|
||||||
};
|
};
|
||||||
let rendered = argv(&command(
|
let rendered = argv(&command(
|
||||||
@@ -230,6 +284,7 @@ mod tests {
|
|||||||
"claude",
|
"claude",
|
||||||
&args(["-p", "--model", "haiku"]),
|
&args(["-p", "--model", "haiku"]),
|
||||||
Some(Path::new("/home/bob/work")),
|
Some(Path::new("/home/bob/work")),
|
||||||
|
None,
|
||||||
));
|
));
|
||||||
|
|
||||||
assert_eq!(rendered[0], "ssh");
|
assert_eq!(rendered[0], "ssh");
|
||||||
@@ -250,12 +305,60 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_remote_command_without_a_cwd_just_execs() {
|
fn a_remote_command_without_a_cwd_just_execs() {
|
||||||
let ssh = bare_host();
|
let ssh = bare_host();
|
||||||
let rendered = argv(&command(Some(&ssh), "claude", &args(["-p"]), None));
|
let rendered = argv(&command(Some(&ssh), "claude", &args(["-p"]), None, None));
|
||||||
assert_eq!(rendered.last().unwrap(), "exec 'claude' '-p'");
|
assert_eq!(rendered.last().unwrap(), "exec 'claude' '-p'");
|
||||||
// No -i means no IdentitiesOnly: ~/.ssh/config decides instead.
|
// No -i means no IdentitiesOnly: ~/.ssh/config decides instead.
|
||||||
assert!(!rendered.contains(&"IdentitiesOnly=yes".to_string()));
|
assert!(!rendered.contains(&"IdentitiesOnly=yes".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The second half of a transport: the connection that runs the
|
||||||
|
/// command also carries the port that reaches it.
|
||||||
|
///
|
||||||
|
/// Both ends are pinned to loopback, which is the property that keeps
|
||||||
|
/// a model server off the far machine's network -- asserted here
|
||||||
|
/// rather than trusted, because dropping the addresses is a one-word
|
||||||
|
/// edit that still works on a machine nobody else can reach.
|
||||||
|
#[test]
|
||||||
|
fn a_forwarded_port_rides_the_same_connection_as_the_command() {
|
||||||
|
let ssh = bare_host();
|
||||||
|
let rendered = argv(&command(
|
||||||
|
Some(&ssh),
|
||||||
|
"llama-server",
|
||||||
|
&args(["--port", "24242"]),
|
||||||
|
None,
|
||||||
|
Some(Forward {
|
||||||
|
there: 24242,
|
||||||
|
here: 41000,
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
let forward = rendered
|
||||||
|
.iter()
|
||||||
|
.position(|arg| arg == "-L")
|
||||||
|
.expect("a forward");
|
||||||
|
assert_eq!(rendered[forward + 1], "127.0.0.1:41000:127.0.0.1:24242");
|
||||||
|
assert!(rendered.contains(&"ExitOnForwardFailure=yes".to_string()));
|
||||||
|
// The half that is easy to lose: without a pty the far process
|
||||||
|
// outlives the connection, because nothing closes a stdin it
|
||||||
|
// never reads.
|
||||||
|
assert!(rendered.contains(&"-tt".to_string()));
|
||||||
|
assert!(!rendered.contains(&"-T".to_string()));
|
||||||
|
// Options come before the host, or ssh reads them as part of the
|
||||||
|
// remote command.
|
||||||
|
assert!(forward < rendered.len() - 2);
|
||||||
|
assert_eq!(
|
||||||
|
rendered.last().unwrap(),
|
||||||
|
"exec 'llama-server' '--port' '24242'"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nothing forwarded is nothing added: every other session is one
|
||||||
|
// of these, and an -L on it would bind a port for no reason.
|
||||||
|
let plain = argv(&command(Some(&ssh), "claude", &args(["-p"]), None, None));
|
||||||
|
assert!(!plain.contains(&"-L".to_string()));
|
||||||
|
// And a session that *is* spoken to over stdio keeps its raw pipe.
|
||||||
|
assert!(plain.contains(&"-T".to_string()));
|
||||||
|
assert!(!plain.contains(&"-tt".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
/// The one character quoting must not swallow.
|
/// The one character quoting must not swallow.
|
||||||
///
|
///
|
||||||
/// A working directory typed as `~/repos/ai-app` was arriving as the
|
/// A working directory typed as `~/repos/ai-app` was arriving as the
|
||||||
@@ -300,7 +403,13 @@ mod tests {
|
|||||||
assert_eq!(expand_home(Path::new("/tmp/~/x")), Path::new("/tmp/~/x"));
|
assert_eq!(expand_home(Path::new("/tmp/~/x")), Path::new("/tmp/~/x"));
|
||||||
assert_eq!(expand_home(Path::new("~user/x")), Path::new("~user/x"));
|
assert_eq!(expand_home(Path::new("~user/x")), Path::new("~user/x"));
|
||||||
|
|
||||||
let local = command(None, "claude", &args(["-p"]), Some(Path::new("~/work")));
|
let local = command(
|
||||||
|
None,
|
||||||
|
"claude",
|
||||||
|
&args(["-p"]),
|
||||||
|
Some(Path::new("~/work")),
|
||||||
|
None,
|
||||||
|
);
|
||||||
assert_eq!(local.get_current_dir(), Some(home.join("work").as_path()));
|
assert_eq!(local.get_current_dir(), Some(home.join("work").as_path()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +433,7 @@ mod tests {
|
|||||||
// that tries to close the quote and start a new command.
|
// that tries to close the quote and start a new command.
|
||||||
let ssh = bare_host();
|
let ssh = bare_host();
|
||||||
let evil = Path::new("/tmp/'; touch /tmp/pwned; '");
|
let evil = Path::new("/tmp/'; touch /tmp/pwned; '");
|
||||||
let rendered = argv(&command(Some(&ssh), "claude", &[], Some(evil)));
|
let rendered = argv(&command(Some(&ssh), "claude", &[], Some(evil), None));
|
||||||
let script = rendered.last().unwrap();
|
let script = rendered.last().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
script,
|
script,
|
||||||
|
|||||||
+338
-20
@@ -35,13 +35,13 @@
|
|||||||
//! on it).
|
//! on it).
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::config::{DriverKind, SetupConfig};
|
use crate::config::SetupConfig;
|
||||||
use crate::session::transport::{Launch, Transport};
|
use crate::session::transport::{Launch, Transport};
|
||||||
|
|
||||||
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
|
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
|
||||||
@@ -116,10 +116,30 @@ pub struct UsageSnapshot {
|
|||||||
pub fetched_at: f64,
|
pub fetched_at: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The name of each meter, said in one place because two lists have to
|
||||||
|
/// agree on it: [`UsageSnapshot::provider`], which is what `GET /usage`
|
||||||
|
/// labels a row with, and [`crate::config::DriverKind::usage_provider`],
|
||||||
|
/// which is how a session says which of those rows is about it.
|
||||||
|
pub const CLAUDE: &str = "claude";
|
||||||
|
/// The invented one, for testing the screens that draw these -- see
|
||||||
|
/// [`Fixture`].
|
||||||
|
pub const ECHO: &str = "echo";
|
||||||
|
|
||||||
pub trait UsageProvider: Send + Sync {
|
pub trait UsageProvider: Send + Sync {
|
||||||
fn name(&self) -> &'static str;
|
fn name(&self) -> &'static str;
|
||||||
/// Blocking -- call off the async workers.
|
/// Blocking -- call off the async workers.
|
||||||
fn fetch(&self) -> UsageSnapshot;
|
fn fetch(&self) -> UsageSnapshot;
|
||||||
|
/// How long an answer from this one may be reused.
|
||||||
|
///
|
||||||
|
/// A property of the provider rather than of the cache, because what
|
||||||
|
/// sets it is what asking costs: [`ClaudeUsage`] makes a network call
|
||||||
|
/// against an endpoint that rate-limits impatient callers, and the
|
||||||
|
/// fixture below reads a mutex. Caching the fixture for three minutes
|
||||||
|
/// would mean a test setting a number and watching the old one for
|
||||||
|
/// most of that, which reads exactly like the command not working.
|
||||||
|
fn poll_interval(&self) -> Duration {
|
||||||
|
MIN_POLL_INTERVAL
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads the numbers behind Claude Code's `/usage` from one machine, using
|
/// Reads the numbers behind Claude Code's `/usage` from one machine, using
|
||||||
@@ -182,7 +202,7 @@ impl ClaudeUsage {
|
|||||||
|
|
||||||
impl UsageProvider for ClaudeUsage {
|
impl UsageProvider for ClaudeUsage {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"claude"
|
CLAUDE
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch(&self) -> UsageSnapshot {
|
fn fetch(&self) -> UsageSnapshot {
|
||||||
@@ -293,24 +313,260 @@ fn parse_windows(body: &Value) -> Vec<UsageWindow> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An invented answer, so the screens that draw these can be exercised
|
||||||
|
/// without an account.
|
||||||
|
///
|
||||||
|
/// Every state the usage bar and the usage dialog can be in is otherwise
|
||||||
|
/// reachable only by spending somebody's quota or by breaking a machine:
|
||||||
|
/// a number near the top, a machine nobody has logged into, one that
|
||||||
|
/// cannot be reached, a window between blocks with no reset time. Those
|
||||||
|
/// are exactly the states worth looking at, and the ones nobody looks at
|
||||||
|
/// because arranging them costs real turns. An echo session sets this
|
||||||
|
/// with `/usage` (see `session::echo`), which is the same bargain the
|
||||||
|
/// rest of that driver makes: the fixture is invented, what is real is
|
||||||
|
/// the path it travels.
|
||||||
|
///
|
||||||
|
/// Shared by the session layer, which writes it, and [`UsageMonitor`],
|
||||||
|
/// which reads it. Empty until something sets it, and an empty fixture
|
||||||
|
/// produces no snapshot at all -- an echo session meters nothing, and
|
||||||
|
/// nothing is what the phone should draw.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct Fixture {
|
||||||
|
said: Arc<Mutex<Option<Reported>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a meter answered: which of the four states it is in, and whatever
|
||||||
|
/// windows go with it. Empty for every state but [`UsageState::Ok`].
|
||||||
|
type Reported = (UsageState, Vec<UsageWindow>);
|
||||||
|
|
||||||
|
/// How long the invented five-hour window has left, when nothing says.
|
||||||
|
const FIXTURE_MINUTES: i64 = 125;
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_set(&self) -> bool {
|
||||||
|
self.said.lock().unwrap().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&self) -> Option<Reported> {
|
||||||
|
self.said.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acts on the words typed after `/usage`, and says what it did.
|
||||||
|
///
|
||||||
|
/// The vocabulary lives here rather than in the echo driver because
|
||||||
|
/// these are this module's states: a driver spelling them out would
|
||||||
|
/// be a second place that has to learn about a fifth one.
|
||||||
|
pub fn command(&self, words: &str) -> String {
|
||||||
|
let mut words = words.split_whitespace();
|
||||||
|
let Some(first) = words.next() else {
|
||||||
|
return match self.read() {
|
||||||
|
Some((state, windows)) => format!("usage fixture: {}", describe(&state, &windows)),
|
||||||
|
None => "usage fixture: unset, so this session meters nothing. \
|
||||||
|
`/usage 42` puts up a five-hour window at 42%."
|
||||||
|
.to_string(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let rest: Vec<&str> = words.collect();
|
||||||
|
let detail = || {
|
||||||
|
if rest.is_empty() {
|
||||||
|
"set by /usage".to_string()
|
||||||
|
} else {
|
||||||
|
rest.join(" ")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (state, windows) = match first {
|
||||||
|
"off" | "none" | "clear" => {
|
||||||
|
*self.said.lock().unwrap() = None;
|
||||||
|
return "usage fixture cleared: this session meters nothing again".to_string();
|
||||||
|
}
|
||||||
|
"notloggedin" | "logged-out" => (UsageState::NotLoggedIn, Vec::new()),
|
||||||
|
"unreachable" => (UsageState::Unreachable { detail: detail() }, Vec::new()),
|
||||||
|
"failed" => (UsageState::Failed { detail: detail() }, Vec::new()),
|
||||||
|
percent => match percent.parse::<f64>() {
|
||||||
|
Ok(percent) => (
|
||||||
|
UsageState::Ok,
|
||||||
|
fixture_windows(percent.clamp(0.0, 100.0), rest.first().copied()),
|
||||||
|
),
|
||||||
|
Err(_) => {
|
||||||
|
return format!(
|
||||||
|
"\"{percent}\" is not one of this fixture's answers. Say a percentage \
|
||||||
|
(`/usage 42`, optionally with `90` minutes left, `never` for a window \
|
||||||
|
between blocks, or `unreadable` for a reset time that cannot be read), \
|
||||||
|
or one of `notloggedin`, `unreachable`, `failed`, `off`."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let said = describe(&state, &windows);
|
||||||
|
*self.said.lock().unwrap() = Some((state, windows));
|
||||||
|
format!("usage fixture set: {said}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The three windows Claude reports today, invented around one number.
|
||||||
|
///
|
||||||
|
/// Three rather than one because the bar under a session header reads the
|
||||||
|
/// five-hour window and the dialog behind the button draws all of them,
|
||||||
|
/// and a fixture with one window leaves half the screen untested. The
|
||||||
|
/// weekly ones are derived from the same figure so that the worst of them
|
||||||
|
/// -- which is what colours the button -- is still the one asked for.
|
||||||
|
fn fixture_windows(percent: f64, reset: Option<&str>) -> Vec<UsageWindow> {
|
||||||
|
let resets_at = match reset {
|
||||||
|
// The state a real response is in between blocks: there is no
|
||||||
|
// window running, so there is nothing to reset. It is not a
|
||||||
|
// missing value, and the phone words it differently.
|
||||||
|
Some("never") | Some("none") => None,
|
||||||
|
// A timestamp that arrives and cannot be read, which is the one
|
||||||
|
// case that really is "we could not find out".
|
||||||
|
Some("unreadable") | Some("bad") => Some("whenever it feels like it".to_string()),
|
||||||
|
other => Some(reset_in(
|
||||||
|
other
|
||||||
|
.and_then(|word| word.parse().ok())
|
||||||
|
.unwrap_or(FIXTURE_MINUTES),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
UsageWindow {
|
||||||
|
kind: "session".to_string(),
|
||||||
|
label: "5-hour window".to_string(),
|
||||||
|
percent,
|
||||||
|
resets_at: resets_at.clone(),
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
UsageWindow {
|
||||||
|
kind: "weekly_all".to_string(),
|
||||||
|
label: "Weekly (all models)".to_string(),
|
||||||
|
percent: percent / 2.0,
|
||||||
|
resets_at: resets_at.as_ref().map(|_| reset_in(FIXTURE_MINUTES * 40)),
|
||||||
|
active: false,
|
||||||
|
},
|
||||||
|
UsageWindow {
|
||||||
|
kind: "weekly_scoped".to_string(),
|
||||||
|
label: "Weekly (Echo)".to_string(),
|
||||||
|
percent: percent / 4.0,
|
||||||
|
resets_at: resets_at.as_ref().map(|_| reset_in(FIXTURE_MINUTES * 40)),
|
||||||
|
active: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `minutes` from now, in the format the real endpoint sends.
|
||||||
|
fn reset_in(minutes: i64) -> String {
|
||||||
|
let at = time::OffsetDateTime::now_utc() + time::Duration::minutes(minutes);
|
||||||
|
at.format(&time::format_description::well_known::Rfc3339)
|
||||||
|
// Formatting a timestamp cannot fail for any input this builds;
|
||||||
|
// saying so beats a fixture that silently has no reset time.
|
||||||
|
.unwrap_or_else(|_| "unformattable".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line naming what a fixture is currently claiming, for the reply
|
||||||
|
/// the echo session writes back.
|
||||||
|
fn describe(state: &UsageState, windows: &[UsageWindow]) -> String {
|
||||||
|
match state {
|
||||||
|
UsageState::Ok => match windows.first() {
|
||||||
|
Some(window) => format!(
|
||||||
|
"{}% of the five-hour window, {}",
|
||||||
|
window.percent,
|
||||||
|
match &window.resets_at {
|
||||||
|
Some(at) => format!("resetting at {at}"),
|
||||||
|
None => "with no reset time (the between-blocks state)".to_string(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
None => "no windows at all".to_string(),
|
||||||
|
},
|
||||||
|
UsageState::NotLoggedIn => "nobody is logged in on this machine".to_string(),
|
||||||
|
UsageState::Unreachable { detail } => format!("machine unreachable ({detail})"),
|
||||||
|
UsageState::Failed { detail } => format!("the meter failed ({detail})"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixture, as a provider, so it travels the same route and the same
|
||||||
|
/// cache as a real meter rather than being spliced in at the screen.
|
||||||
|
struct EchoUsage {
|
||||||
|
setup: String,
|
||||||
|
setup_name: String,
|
||||||
|
fixture: Fixture,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsageProvider for EchoUsage {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
ECHO
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch(&self) -> UsageSnapshot {
|
||||||
|
let (state, windows) = self
|
||||||
|
.fixture
|
||||||
|
.read()
|
||||||
|
// Only ever built for a fixture that is set; a race with
|
||||||
|
// `/usage off` between the two reads lands here, and "the
|
||||||
|
// machine could not be asked" is the honest word for it.
|
||||||
|
.unwrap_or((
|
||||||
|
UsageState::Unreachable {
|
||||||
|
detail: "the usage fixture was cleared".to_string(),
|
||||||
|
},
|
||||||
|
Vec::new(),
|
||||||
|
));
|
||||||
|
UsageSnapshot {
|
||||||
|
provider: self.name().to_string(),
|
||||||
|
setup: self.setup.clone(),
|
||||||
|
setup_name: self.setup_name.clone(),
|
||||||
|
state,
|
||||||
|
windows,
|
||||||
|
fetched_at: crate::session::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read from memory, and set by somebody who is about to look at the
|
||||||
|
/// screen it changes.
|
||||||
|
fn poll_interval(&self) -> Duration {
|
||||||
|
Duration::ZERO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Which paid services a machine can be asked about.
|
/// Which paid services a machine can be asked about.
|
||||||
///
|
///
|
||||||
/// Derived from what the setup says it can run, so a machine with no
|
/// Derived from what the setup says it can run, so a machine with no
|
||||||
/// Claude provider is not asked about Claude limits -- it has none, and a
|
/// Claude provider is not asked about Claude limits -- it has none, and a
|
||||||
/// row saying so would be a fact about nothing. A second service later
|
/// row saying so would be a fact about nothing.
|
||||||
/// adds a branch here and an impl beside [`ClaudeUsage`], not a screen.
|
///
|
||||||
fn providers_for(setup: &SetupConfig) -> Vec<Box<dyn UsageProvider>> {
|
/// Which meter a provider has is [`DriverKind::usage_provider`]'s answer
|
||||||
|
/// rather than a second match on kinds here, because the phone pairs a
|
||||||
|
/// session with one of these rows by that same name: two lists that
|
||||||
|
/// disagree would leave a session looking for a snapshot nothing
|
||||||
|
/// produces, and nothing on screen could say why. A second service later
|
||||||
|
/// is a name there and an impl beside [`ClaudeUsage`], not a screen.
|
||||||
|
fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsageProvider>> {
|
||||||
let mut found: Vec<Box<dyn UsageProvider>> = Vec::new();
|
let mut found: Vec<Box<dyn UsageProvider>> = Vec::new();
|
||||||
if setup
|
for provider in &setup.providers {
|
||||||
.providers
|
let Some(name) = provider.kind.usage_provider() else {
|
||||||
.iter()
|
continue;
|
||||||
.any(|provider| provider.kind == DriverKind::ClaudeCli)
|
};
|
||||||
{
|
// A machine offering two Claude providers has one account, not
|
||||||
found.push(Box::new(ClaudeUsage {
|
// two: the meter belongs to the machine and the service, which is
|
||||||
|
// exactly what the cache is keyed by.
|
||||||
|
if found.iter().any(|already| already.name() == name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match name {
|
||||||
|
CLAUDE => found.push(Box::new(ClaudeUsage {
|
||||||
setup: setup.id.clone(),
|
setup: setup.id.clone(),
|
||||||
setup_name: setup.name.clone(),
|
setup_name: setup.name.clone(),
|
||||||
transport: Transport::for_setup(setup),
|
transport: Transport::for_setup(setup),
|
||||||
}));
|
})),
|
||||||
|
// Nothing at all until a test has asked for something: an
|
||||||
|
// echo session costs nothing, so the honest answer is no row
|
||||||
|
// rather than a row saying zero.
|
||||||
|
ECHO if fixture.is_set() => found.push(Box::new(EchoUsage {
|
||||||
|
setup: setup.id.clone(),
|
||||||
|
setup_name: setup.name.clone(),
|
||||||
|
fixture: fixture.clone(),
|
||||||
|
})),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
found
|
found
|
||||||
}
|
}
|
||||||
@@ -330,11 +586,18 @@ type Cached = HashMap<(String, &'static str), (Instant, UsageSnapshot)>;
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct UsageMonitor {
|
pub struct UsageMonitor {
|
||||||
cache: Mutex<Cached>,
|
cache: Mutex<Cached>,
|
||||||
|
/// The invented meter an echo session can put up; empty unless one
|
||||||
|
/// has. Shared with the session layer, which is where the command
|
||||||
|
/// that sets it is typed -- see [`Fixture`].
|
||||||
|
fixture: Fixture,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UsageMonitor {
|
impl UsageMonitor {
|
||||||
pub fn new() -> Self {
|
pub fn new(fixture: Fixture) -> Self {
|
||||||
Self::default()
|
Self {
|
||||||
|
cache: Mutex::new(Cached::new()),
|
||||||
|
fixture,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One snapshot per machine that offers a paid service, in the order
|
/// One snapshot per machine that offers a paid service, in the order
|
||||||
@@ -346,10 +609,10 @@ impl UsageMonitor {
|
|||||||
pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec<UsageSnapshot> {
|
pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec<UsageSnapshot> {
|
||||||
let mut fresh = Vec::new();
|
let mut fresh = Vec::new();
|
||||||
for setup in setups {
|
for setup in setups {
|
||||||
for provider in providers_for(setup) {
|
for provider in providers_for(setup, &self.fixture) {
|
||||||
let key = (setup.id.clone(), provider.name());
|
let key = (setup.id.clone(), provider.name());
|
||||||
if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key)
|
if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key)
|
||||||
&& fetched.elapsed() < MIN_POLL_INTERVAL
|
&& fetched.elapsed() < provider.poll_interval()
|
||||||
{
|
{
|
||||||
// Cached numbers, but the machine's *name* is read
|
// Cached numbers, but the machine's *name* is read
|
||||||
// fresh: a rename should show immediately rather than
|
// fresh: a rename should show immediately rather than
|
||||||
@@ -386,6 +649,7 @@ impl UsageMonitor {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::DriverKind;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_the_limits_array_defensively() {
|
fn parses_the_limits_array_defensively() {
|
||||||
@@ -423,6 +687,7 @@ mod tests {
|
|||||||
port: None,
|
port: None,
|
||||||
identity_file: None,
|
identity_file: None,
|
||||||
options: vec!["ConnectTimeout=1".to_string()],
|
options: vec!["ConnectTimeout=1".to_string()],
|
||||||
|
models_dir: None,
|
||||||
attachments_dir: None,
|
attachments_dir: None,
|
||||||
}),
|
}),
|
||||||
providers: vec![crate::config::ProviderConfig {
|
providers: vec![crate::config::ProviderConfig {
|
||||||
@@ -494,9 +759,62 @@ mod tests {
|
|||||||
models: vec![],
|
models: vec![],
|
||||||
}];
|
}];
|
||||||
// A machine with no Claude on it has no Claude limits, and a row
|
// A machine with no Claude on it has no Claude limits, and a row
|
||||||
// reporting on it would be a fact about nothing.
|
// reporting on it would be a fact about nothing. Echo included:
|
||||||
assert!(providers_for(&echo_only).is_empty());
|
// an echo session spends nothing, so until a fixture says
|
||||||
assert_eq!(providers_for(&unreachable_setup()).len(), 1);
|
// otherwise there is no meter to report.
|
||||||
|
let unset = Fixture::new();
|
||||||
|
assert!(providers_for(&echo_only, &unset).is_empty());
|
||||||
|
assert_eq!(providers_for(&unreachable_setup(), &unset).len(), 1);
|
||||||
|
|
||||||
|
// And with one set, that machine has exactly the invented meter
|
||||||
|
// -- under the name the session's `usageProvider` will name.
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
fixture.command("42");
|
||||||
|
let found = providers_for(&echo_only, &fixture);
|
||||||
|
assert_eq!(found.len(), 1);
|
||||||
|
assert_eq!(found[0].name(), ECHO);
|
||||||
|
assert_eq!(DriverKind::Echo.usage_provider(), Some(ECHO));
|
||||||
|
assert_eq!(DriverKind::ClaudeCli.usage_provider(), Some(CLAUDE));
|
||||||
|
// A local model costs nothing to run, so it meters nothing.
|
||||||
|
assert_eq!(DriverKind::LlamaCpp.usage_provider(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The states the fixture exists to make reachable, and the one thing
|
||||||
|
/// it must not do: invent a reset time for a window that has none.
|
||||||
|
#[test]
|
||||||
|
fn the_fixture_says_each_state_the_screens_have_to_draw() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
assert!(fixture.read().is_none(), "unset until somebody sets it");
|
||||||
|
|
||||||
|
fixture.command("42 90");
|
||||||
|
let (state, windows) = fixture.read().expect("set");
|
||||||
|
assert_eq!(state, UsageState::Ok);
|
||||||
|
assert_eq!(windows[0].kind, "session");
|
||||||
|
assert_eq!(windows[0].percent, 42.0);
|
||||||
|
assert!(windows[0].resets_at.is_some());
|
||||||
|
|
||||||
|
// Between blocks: no reset time, which the phone words as the
|
||||||
|
// window not running rather than as a time it could not read.
|
||||||
|
fixture.command("42 never");
|
||||||
|
assert_eq!(fixture.read().expect("set").1[0].resets_at, None);
|
||||||
|
|
||||||
|
fixture.command("unreachable no route to host");
|
||||||
|
assert!(matches!(
|
||||||
|
fixture.read().expect("set").0,
|
||||||
|
UsageState::Unreachable { detail } if detail == "no route to host",
|
||||||
|
));
|
||||||
|
|
||||||
|
fixture.command("off");
|
||||||
|
assert!(fixture.read().is_none());
|
||||||
|
|
||||||
|
// A word it does not know changes nothing and says what it takes.
|
||||||
|
fixture.command("42");
|
||||||
|
let refused = fixture.command("sideways");
|
||||||
|
assert!(
|
||||||
|
refused.contains("not one of this fixture's answers"),
|
||||||
|
"{refused}"
|
||||||
|
);
|
||||||
|
assert_eq!(fixture.read().expect("still set").1[0].percent, 42.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in new issue
Block a user