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

+59 -5
View File
@@ -684,7 +684,22 @@ host) and **hosts**. The manager runs at most one llama-server per
prompt-replayed by pi against the new endpoint).
- Remote llama-server output is only reachable from the backend host, and
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
@@ -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
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
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
before: `attachment_block` base64s an uploaded image into the
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,
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
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
@@ -789,7 +840,8 @@ POST /sessions/:id/compact (llama sessions)
POST /sessions/:id/attachments multipart upload → id (referenced by /message)
GET /sessions/:id/files/:ref images the session produced or was sent
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/file?path=P content of file P, or why not
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
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
are attached by path (see "Transport" above). Still outstanding:
remote llama-server with its port forward, which comes with phase 4.
are attached by path (see "Transport" above). Remote llama-server with
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
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