Merge branch 'main' of git.arirex.me:iris/ai-app
# Conflicts: # AGENTS.md # PLAN.md # app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt # app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt # server/src/config.rs # server/src/main.rs # server/src/routes.rs # server/src/session/echo.rs # server/src/session/llama.rs # server/src/session/transport.rs # server/src/ssh.rs # server/src/usage.rs
This commit is contained in:
commit
3c0214ece8
94 files changed
+8299
-9454
No files matched your search
+94
-114
@@ -3,36 +3,31 @@
|
||||
//! Polls `https://api.anthropic.com/api/oauth/usage` with the OAuth access
|
||||
//! token from Claude Code's local credential store. The endpoint is
|
||||
//! undocumented and has changed before, so everything here is best-effort:
|
||||
//! every field is optional, and failure degrades to an "unavailable"
|
||||
//! snapshot with the reason, never an error that breaks the screen.
|
||||
//! every field is optional, and failure degrades to an "unavailable" snapshot
|
||||
//! with the reason, never an error that breaks the screen.
|
||||
//!
|
||||
//! Two rules learned from others hitting this endpoint (see PLAN.md's
|
||||
//! references): send `User-Agent: claude-code/<version>` (without it,
|
||||
//! requests land in an aggressively rate-limited bucket) and poll no more
|
||||
//! often than every 180 s. The cache below enforces the latter across any
|
||||
//! number of phone refreshes; there is no background poll at all -- the
|
||||
//! screen's fetch is the trigger, so no session activity means no traffic.
|
||||
//! Two rules learned from others hitting this endpoint: send `User-Agent:
|
||||
//! claude-code/<version>` (without it, requests land in an aggressively
|
||||
//! rate-limited bucket) and poll no more often than every 180 s. The cache
|
||||
//! below enforces the latter across any number of phone refreshes; there is no
|
||||
//! background poll at all.
|
||||
//!
|
||||
//! One [`UsageProvider`] per paid service, so a second service later is a
|
||||
//! new impl behind the same snapshot shape, not a parallel screen.
|
||||
//! One [`UsageProvider`] per paid service, so a second service later is a new
|
||||
//! impl behind the same snapshot shape, not a parallel screen.
|
||||
//!
|
||||
//! **Asked of the machine that spends the tokens, not of this one.** A
|
||||
//! session runs wherever its setup says, so the account being billed is
|
||||
//! that machine's, and reading this machine's credentials reports on an
|
||||
//! account that may have run nothing. In the layout this project is aiming
|
||||
//! at that is not a rounding error: `ai-server` belongs on the host, the
|
||||
//! host has no `claude` CLI, and the CLI machine is a remote -- so the one
|
||||
//! set of numbers the screen could show would be the numbers of an account
|
||||
//! with no sessions. Credentials are therefore read through the session
|
||||
//! `Transport`, one snapshot per setup that offers Claude.
|
||||
//! **Asked of the machine that spends the tokens, not of this one.** A session
|
||||
//! runs wherever its setup says, so the account being billed is that machine's.
|
||||
//! In the layout this project aims at, `ai-server` is on the host, the host has
|
||||
//! no `claude` CLI, and the CLI machine is a remote -- so the one set of numbers
|
||||
//! the screen could show would be an account with no sessions. Credentials are
|
||||
//! read through the session `Transport`, one snapshot per setup that offers
|
||||
//! Claude.
|
||||
//!
|
||||
//! The token is read *to* the backend and the HTTP call is made from here,
|
||||
//! rather than running the request on the far machine: it needs no tooling
|
||||
//! there beyond a shell, and it keeps the one place that knows the wire
|
||||
//! format in one place. The cost is that a remote machine's token is in
|
||||
//! this process's memory for the length of a fetch, which is the same
|
||||
//! trust the backend already has over that machine (it can start processes
|
||||
//! on it).
|
||||
//! The token is read *to* the backend and the HTTP call is made from here, so
|
||||
//! the far machine needs nothing beyond a shell and the wire format stays in
|
||||
//! one place. The cost is that a remote machine's token is in this process's
|
||||
//! memory for the length of a fetch, which is the same trust the backend
|
||||
//! already has over that machine.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -54,15 +49,13 @@ const USER_AGENT: &str = "claude-code/2.1.237";
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageWindow {
|
||||
/// The API's own word for which window this is -- `session` for the
|
||||
/// five-hour one, `weekly_all`, `weekly_scoped`, or whatever new kind
|
||||
/// it starts sending.
|
||||
/// five-hour one, `weekly_all`, `weekly_scoped`, or whatever new kind it
|
||||
/// starts sending.
|
||||
///
|
||||
/// Carried beside the label because a caller that wants one
|
||||
/// particular window has to be able to ask for it without matching on
|
||||
/// display text: the label is written for a person, is translated the
|
||||
/// moment anybody translates this app, and would silently select
|
||||
/// nothing the day it changes. The session screen's bar picks
|
||||
/// `session` by this field.
|
||||
/// Carried beside the label because a caller that wants one particular
|
||||
/// window has to ask for it without matching on display text: the label is
|
||||
/// written for a person and would silently select nothing the day it
|
||||
/// changes.
|
||||
pub kind: String,
|
||||
pub label: String,
|
||||
/// 0-100.
|
||||
@@ -77,13 +70,11 @@ pub struct UsageWindow {
|
||||
/// What came back when a machine was asked about its limits.
|
||||
///
|
||||
/// Four answers rather than a flag and a message, because the screen has to
|
||||
/// treat them differently and a reader has to. "Nobody is logged in here"
|
||||
/// is a machine working exactly as configured -- somebody chose not to put
|
||||
/// an account on it -- while "I could not reach it" is a fault worth
|
||||
/// chasing, and "the endpoint refused me" is a third thing that says
|
||||
/// nothing about the machine at all. Collapsing them into one `error`
|
||||
/// string made the first look like the last, so a perfectly healthy setup
|
||||
/// read as broken.
|
||||
/// treat them differently. "Nobody is logged in here" is a machine working
|
||||
/// exactly as configured, while "I could not reach it" is a fault worth
|
||||
/// chasing, and "the endpoint refused me" says nothing about the machine at
|
||||
/// all. Collapsing them into one `error` string made the first look like the
|
||||
/// last, so a perfectly healthy setup read as broken.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(tag = "state", rename_all = "camelCase")]
|
||||
pub enum UsageState {
|
||||
@@ -102,11 +93,11 @@ pub enum UsageState {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageSnapshot {
|
||||
pub provider: String,
|
||||
/// Which machine these are the numbers for. The point of the whole
|
||||
/// module: they belong to an account on a particular box.
|
||||
/// Which machine these are the numbers for. The point of the whole module:
|
||||
/// they belong to an account on a particular box.
|
||||
pub setup: String,
|
||||
/// That machine's current label, resolved when the snapshot is built,
|
||||
/// so renaming a setup renames it here too.
|
||||
/// That machine's current label, resolved when the snapshot is built, so
|
||||
/// renaming a setup renames it here too.
|
||||
pub setup_name: String,
|
||||
#[serde(flatten)]
|
||||
pub state: UsageState,
|
||||
@@ -142,9 +133,9 @@ pub trait UsageProvider: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the numbers behind Claude Code's `/usage` from one machine, using
|
||||
/// the credentials that machine stores -- nothing to configure, and it
|
||||
/// reports on exactly the account whose CLI runs the sessions there.
|
||||
/// Reads the numbers behind Claude Code's `/usage` from one machine, using the
|
||||
/// credentials that machine stores -- nothing to configure, and it reports on
|
||||
/// exactly the account whose CLI runs the sessions there.
|
||||
pub struct ClaudeUsage {
|
||||
pub setup: String,
|
||||
pub setup_name: String,
|
||||
@@ -152,9 +143,9 @@ pub struct ClaudeUsage {
|
||||
pub transport: Transport,
|
||||
}
|
||||
|
||||
/// Where Claude Code keeps its credentials, as a shell word rather than a
|
||||
/// path: `$HOME` is expanded by the shell on the machine being asked,
|
||||
/// which is the only place that knows what it is.
|
||||
/// Where Claude Code keeps its credentials, as a shell word rather than a path:
|
||||
/// `$HOME` is expanded by the shell on the machine being asked, which is the
|
||||
/// only place that knows what it is.
|
||||
const CREDENTIALS: &str = "$HOME/.claude/.credentials.json";
|
||||
|
||||
impl ClaudeUsage {
|
||||
@@ -169,12 +160,9 @@ impl ClaudeUsage {
|
||||
}
|
||||
}
|
||||
|
||||
/// The machine's stored OAuth token, or which of the two ways of not
|
||||
/// having one this is.
|
||||
///
|
||||
/// Read through `sh -c` so `$HOME` resolves on the far machine; a path
|
||||
/// built here would be this machine's home directory, which over ssh
|
||||
/// is somebody else's.
|
||||
/// The machine's stored OAuth token, or which of the two ways of not having
|
||||
/// one this is. Read through `sh -c` so `$HOME` resolves on the far machine;
|
||||
/// a path built here would be this machine's home directory.
|
||||
fn access_token(&self) -> Result<String, UsageState> {
|
||||
let launch = Launch::new(
|
||||
"sh",
|
||||
@@ -194,8 +182,8 @@ impl ClaudeUsage {
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
})
|
||||
// A file that exists but carries no token is the same situation
|
||||
// as no file: nobody has logged in here yet.
|
||||
// A file that exists but carries no token is the same situation as
|
||||
// no file: nobody has logged in here yet.
|
||||
.ok_or(UsageState::NotLoggedIn)
|
||||
}
|
||||
}
|
||||
@@ -245,19 +233,17 @@ impl UsageProvider for ClaudeUsage {
|
||||
|
||||
/// Which kind of "no credentials" a failed read was.
|
||||
///
|
||||
/// The distinction is the point of having both states. `cat` failing
|
||||
/// because the file is not there is a machine nobody has logged in on --
|
||||
/// a decision somebody made, with nothing to fix. Anything else is a
|
||||
/// machine this server could not ask, which is a fault and reads as one.
|
||||
/// The distinction is the point of having both states. `cat` failing because
|
||||
/// the file is not there is a machine nobody has logged in on -- a decision
|
||||
/// somebody made, with nothing to fix. Anything else is a machine this server
|
||||
/// could not ask, which is a fault and reads as one.
|
||||
///
|
||||
/// Matched on the shell's own words rather than an exit status because
|
||||
/// there is only one: `cat` exits 1 for a missing file and ssh exits 255
|
||||
/// for a connection it could not make, but the message is what survives
|
||||
/// being wrapped in `sh -c` and passed back through ssh.
|
||||
/// Matched on the shell's own words rather than an exit status because there is
|
||||
/// only one that survives being wrapped in `sh -c` and passed back through ssh.
|
||||
fn why_no_credentials(detail: &str) -> UsageState {
|
||||
// "No such file or directory" is GNU and BSD coreutils; busybox says
|
||||
// "can't open". Anything unrecognised is treated as unreachable,
|
||||
// which is the answer that gets looked at rather than ignored.
|
||||
// "No such file or directory" is GNU and BSD coreutils; busybox says "can't
|
||||
// open". Anything unrecognised is treated as unreachable, which is the
|
||||
// answer that gets looked at rather than ignored.
|
||||
let missing = ["No such file", "no such file", "can't open", "cannot open"];
|
||||
if missing.iter().any(|phrase| detail.contains(phrase)) {
|
||||
UsageState::NotLoggedIn
|
||||
@@ -268,10 +254,9 @@ fn why_no_credentials(detail: &str) -> UsageState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pulls the `limits` array apart, defensively: entries with no percent
|
||||
/// are skipped, unknown kinds keep their raw name as the label rather
|
||||
/// than being dropped -- a new window appearing should show up, not
|
||||
/// vanish.
|
||||
/// Pulls the `limits` array apart, defensively: entries with no percent are
|
||||
/// skipped, and unknown kinds keep their raw name as the label rather than
|
||||
/// being dropped -- a new window appearing should show up, not vanish.
|
||||
fn parse_windows(body: &Value) -> Vec<UsageWindow> {
|
||||
let Some(limits) = body.get("limits").and_then(Value::as_array) else {
|
||||
return Vec::new();
|
||||
@@ -529,16 +514,15 @@ impl UsageProvider for EchoUsage {
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
///
|
||||
/// 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.
|
||||
/// 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 disagreed would leave a
|
||||
/// session looking for a snapshot nothing produces. 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();
|
||||
for provider in &setup.providers {
|
||||
@@ -571,19 +555,17 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsagePro
|
||||
found
|
||||
}
|
||||
|
||||
/// The cache in front of whatever machines exist: at most one real fetch
|
||||
/// per machine per service per [`MIN_POLL_INTERVAL`], no matter how often
|
||||
/// the phone asks.
|
||||
///
|
||||
/// One machine's numbers for one service, and when they were fetched.
|
||||
///
|
||||
/// Keyed by the machine and the service rather than by position: the set
|
||||
/// is no longer fixed at startup -- setups are added, renamed and removed
|
||||
/// from the phone -- and a positional cache would hand one machine's
|
||||
/// numbers to another the moment the list shifted.
|
||||
/// Keyed by the machine and the service rather than by position: the set is not
|
||||
/// fixed at startup -- setups are added, renamed and removed from the phone --
|
||||
/// and a positional cache would hand one machine's numbers to another the
|
||||
/// moment the list shifted.
|
||||
type Cached = HashMap<(String, &'static str), (Instant, UsageSnapshot)>;
|
||||
|
||||
#[derive(Default)]
|
||||
/// The cache in front of whatever machines exist: at most one real fetch per
|
||||
/// machine per service per [`MIN_POLL_INTERVAL`], however often the phone asks.
|
||||
pub struct UsageMonitor {
|
||||
cache: Mutex<Cached>,
|
||||
/// The invented meter an echo session can put up; empty unless one
|
||||
@@ -600,12 +582,12 @@ impl UsageMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
/// One snapshot per machine that offers a paid service, in the order
|
||||
/// the machines are configured.
|
||||
/// One snapshot per machine that offers a paid service, in the order the
|
||||
/// machines are configured.
|
||||
///
|
||||
/// Blocking -- call via `spawn_blocking`. Takes the setups rather than
|
||||
/// holding the manager, so this module stays below the session layer
|
||||
/// rather than reaching up into it.
|
||||
/// holding the manager, so this module stays below the session layer rather
|
||||
/// than reaching up into it.
|
||||
pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec<UsageSnapshot> {
|
||||
let mut fresh = Vec::new();
|
||||
for setup in setups {
|
||||
@@ -614,19 +596,17 @@ impl UsageMonitor {
|
||||
if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key)
|
||||
&& fetched.elapsed() < provider.poll_interval()
|
||||
{
|
||||
// Cached numbers, but the machine's *name* is read
|
||||
// fresh: a rename should show immediately rather than
|
||||
// waiting out the poll interval it has nothing to do
|
||||
// with.
|
||||
// Cached numbers, but the machine's *name* is read fresh: a
|
||||
// rename should show immediately rather than waiting out a
|
||||
// poll interval it has nothing to do with.
|
||||
let mut snapshot = snapshot.clone();
|
||||
snapshot.setup_name = setup.name.clone();
|
||||
fresh.push(snapshot);
|
||||
continue;
|
||||
}
|
||||
// Fetched without the lock held: this makes a network call
|
||||
// per machine, and holding the cache across them would
|
||||
// serialise every phone asking for the screen behind the
|
||||
// slowest ssh connection.
|
||||
// Fetched without the lock held: this makes a network call per
|
||||
// machine, and holding the cache across them would serialise
|
||||
// every phone asking for the screen behind the slowest ssh.
|
||||
let snapshot = provider.fetch();
|
||||
self.cache
|
||||
.lock()
|
||||
@@ -676,8 +656,8 @@ mod tests {
|
||||
assert_eq!(windows[3].resets_at, None);
|
||||
}
|
||||
|
||||
/// A setup naming a machine that cannot be dialled, so nothing here
|
||||
/// touches the network beyond ssh failing to resolve it.
|
||||
/// A setup naming a machine that cannot be dialled, so nothing here touches
|
||||
/// the network beyond ssh failing to resolve it.
|
||||
fn unreachable_setup() -> SetupConfig {
|
||||
SetupConfig {
|
||||
id: "far".to_string(),
|
||||
@@ -707,9 +687,9 @@ mod tests {
|
||||
transport: Transport::for_setup(&unreachable_setup()),
|
||||
};
|
||||
let snapshot = provider.fetch();
|
||||
// The distinction the old single `error` string could not make:
|
||||
// this machine was never reached, which is not the same as a
|
||||
// machine that answered and has nobody logged in.
|
||||
// The distinction the old single `error` string could not make: this
|
||||
// machine was never reached, which is not the same as a machine that
|
||||
// answered and has nobody logged in.
|
||||
assert!(
|
||||
matches!(snapshot.state, UsageState::Unreachable { .. }),
|
||||
"{:?}",
|
||||
@@ -722,8 +702,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_missing_credential_file_is_a_choice_and_anything_else_is_a_fault() {
|
||||
// What a real shell says when nobody has logged in on that
|
||||
// machine. Nothing to fix, so it must not read as an error.
|
||||
// What a real shell says when nobody has logged in on that machine.
|
||||
// Nothing to fix, so it must not read as an error.
|
||||
assert_eq!(
|
||||
why_no_credentials("cat: /home/x/.claude/.credentials.json: No such file or directory"),
|
||||
UsageState::NotLoggedIn
|
||||
@@ -733,16 +713,16 @@ mod tests {
|
||||
UsageState::NotLoggedIn
|
||||
);
|
||||
|
||||
// What ssh says when the machine is not there. Worth chasing, and
|
||||
// the detail is carried so somebody can.
|
||||
// What ssh says when the machine is not there. Worth chasing, and the
|
||||
// detail is carried so somebody can.
|
||||
let refused = why_no_credentials("ssh: connect to host vm port 22: Connection refused");
|
||||
assert!(
|
||||
matches!(&refused, UsageState::Unreachable { detail } if detail.contains("refused")),
|
||||
"{refused:?}"
|
||||
);
|
||||
|
||||
// Anything unrecognised errs towards the state that gets looked
|
||||
// at, rather than silently claiming nobody is logged in.
|
||||
// Anything unrecognised errs towards the state that gets looked at,
|
||||
// rather than silently claiming nobody is logged in.
|
||||
assert!(matches!(
|
||||
why_no_credentials("something nobody has seen before"),
|
||||
UsageState::Unreachable { .. }
|
||||
|
||||
Reference in new issue
Block a user