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
+1212
-143
No files matched your search
+341
-23
@@ -35,13 +35,13 @@
|
||||
//! on it).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::config::{DriverKind, SetupConfig};
|
||||
use crate::config::SetupConfig;
|
||||
use crate::session::transport::{Launch, Transport};
|
||||
|
||||
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
|
||||
@@ -116,10 +116,30 @@ pub struct UsageSnapshot {
|
||||
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 {
|
||||
fn name(&self) -> &'static str;
|
||||
/// Blocking -- call off the async workers.
|
||||
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
|
||||
@@ -182,7 +202,7 @@ impl ClaudeUsage {
|
||||
|
||||
impl UsageProvider for ClaudeUsage {
|
||||
fn name(&self) -> &'static str {
|
||||
"claude"
|
||||
CLAUDE
|
||||
}
|
||||
|
||||
fn fetch(&self) -> UsageSnapshot {
|
||||
@@ -293,24 +313,260 @@ fn parse_windows(body: &Value) -> Vec<UsageWindow> {
|
||||
.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.
|
||||
///
|
||||
/// 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
|
||||
/// row saying so would be a fact about nothing. A second service later
|
||||
/// adds a branch here and an impl beside [`ClaudeUsage`], not a screen.
|
||||
fn providers_for(setup: &SetupConfig) -> Vec<Box<dyn UsageProvider>> {
|
||||
/// row saying so would be a fact about nothing.
|
||||
///
|
||||
/// 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();
|
||||
if setup
|
||||
.providers
|
||||
.iter()
|
||||
.any(|provider| provider.kind == DriverKind::ClaudeCli)
|
||||
{
|
||||
found.push(Box::new(ClaudeUsage {
|
||||
setup: setup.id.clone(),
|
||||
setup_name: setup.name.clone(),
|
||||
transport: Transport::for_setup(setup),
|
||||
}));
|
||||
for provider in &setup.providers {
|
||||
let Some(name) = provider.kind.usage_provider() else {
|
||||
continue;
|
||||
};
|
||||
// A machine offering two Claude providers has one account, not
|
||||
// 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_name: setup.name.clone(),
|
||||
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
|
||||
}
|
||||
@@ -330,11 +586,18 @@ type Cached = HashMap<(String, &'static str), (Instant, UsageSnapshot)>;
|
||||
#[derive(Default)]
|
||||
pub struct UsageMonitor {
|
||||
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 {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
pub fn new(fixture: Fixture) -> Self {
|
||||
Self {
|
||||
cache: Mutex::new(Cached::new()),
|
||||
fixture,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let mut fresh = Vec::new();
|
||||
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());
|
||||
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
|
||||
// fresh: a rename should show immediately rather than
|
||||
@@ -386,6 +649,7 @@ impl UsageMonitor {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::DriverKind;
|
||||
|
||||
#[test]
|
||||
fn parses_the_limits_array_defensively() {
|
||||
@@ -423,6 +687,7 @@ mod tests {
|
||||
port: None,
|
||||
identity_file: None,
|
||||
options: vec!["ConnectTimeout=1".to_string()],
|
||||
models_dir: None,
|
||||
attachments_dir: None,
|
||||
}),
|
||||
providers: vec![crate::config::ProviderConfig {
|
||||
@@ -494,9 +759,62 @@ mod tests {
|
||||
models: vec![],
|
||||
}];
|
||||
// A machine with no Claude on it has no Claude limits, and a row
|
||||
// reporting on it would be a fact about nothing.
|
||||
assert!(providers_for(&echo_only).is_empty());
|
||||
assert_eq!(providers_for(&unreachable_setup()).len(), 1);
|
||||
// reporting on it would be a fact about nothing. Echo included:
|
||||
// an echo session spends nothing, so until a fixture says
|
||||
// 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]
|
||||
|
||||
Reference in new issue
Block a user