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:
irisandClaude Opus 5 committed 2026-09-04 17:45:32 -04:00
1 parent 74110b4d72
commit 127b25e60a
20 files changed
+1212 -143

No files matched your search

+74 -14
View File
@@ -203,15 +203,44 @@ images both ways), and the usage screen.
host, `session::transport` turns that into an `ssh host …` invocation, and
the driver never learns which it got.
**Phase 4 (llama.cpp)** works end to end, phone included (2026-08-28).
Models are browsed and downloaded from HuggingFace (`models.rs`, resumable
and verified), and `session::llama` runs one through `llama-server` over
its OpenAI-compatible streaming endpoint. Two things are deliberate and
easy to undo by accident: the conversation is rebuilt from the
**Phase 4 (llama.cpp)** works end to end, phone included (2026-08-28),
**on any machine a setup names** (2026-09-04). Models are browsed and
downloaded from HuggingFace (`models.rs`, resumable and verified), and
`session::llama` runs one through `llama-server` over its
OpenAI-compatible streaming endpoint. The conversation is rebuilt from the
**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
host, because the model is reached over HTTP and forwarding that port is
not built.
invisible to a second device -- deliberate, and easy to undo by accident.
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,
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
authority the phone does not have.
**Testing llama.cpp here:** the prebuilt CPU build lives outside the repo
at `~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset). It
needs its own directory on `LD_LIBRARY_PATH`, so start the server as
`LD_LIBRARY_PATH=~/.local/opt/llama.cpp ai-server …` and point a provider's
`command` at `~/.local/opt/llama.cpp/llama-server`. A 0.6B Q8_0 answers at
usable speed on this VM's 8 cores. **Do not test with a 2-bit quant**: the
**llama.cpp is set up in this VM** (2026-09-04) and needs nothing typed:
the prebuilt CPU build is at `~/.local/opt/llama.cpp` (the 15 MB
`ubuntu-x64` release asset), symlinked as `/usr/local/bin/llama-server` so
that **discovery finds it over ssh too** -- `~/.local/bin` is not on the
PATH a non-interactive ssh session gets, which is why the symlink is
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
broken driver — `llama-cli` produces the same from the file directly, which
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
`release` there; a phone still holding the debug build has to uninstall
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,
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