Phase 3: usage screen

GET /usage serves the numbers behind Claude Code's /usage, read with the
CLI's own stored OAuth credentials (nothing to configure). The endpoint
is undocumented, so parsing is defensive -- the generic limits[] array
becomes labeled window bars, unknown kinds surface under their raw name,
and any failure degrades to an 'unavailable' snapshot with the reason.
One UsageProvider per paid service behind a caching monitor that
enforces the >=180s minimum poll regardless of phone refreshes; no
background polling at all. ureq (rustls) does the outbound call, with
the process-level CryptoProvider now chosen explicitly in main -- ureq
brings ring while axum-server brings aws-lc-rs, and with both in the
graph rustls refuses to guess.

App: a Usage screen off the session list -- per-window bars colored by
utilization with relative reset times. Verified live: 74%/26%/16%
windows rendered against the real endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-24 21:47:51 -04:00
1 parent f2430671a2
commit 3c97a5ef28
10 files changed
+603 -7

No files matched your search

+14 -4
View File
@@ -222,9 +222,9 @@ POST /sessions/:id/answer {question_id, answer} (questions and per
POST /sessions/:id/interrupt POST /sessions/:id/interrupt
POST /sessions/:id/model {model} POST /sessions/:id/model {model}
POST /sessions/:id/compact (llama sessions) 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 DELETE /sessions/:id kill process, release llama-server, delete transcript+files
POST /attachments multipart upload → id (referenced by /message)
GET /files/:session/:id images/files the session produced
GET /usage cached usage windows GET /usage cached usage windows
GET/PUT /hosts, /models config editing from the phone GET/PUT /hosts, /models config editing from the phone
``` ```
@@ -395,9 +395,19 @@ window just fills.
numbers, delete); the app on the `tdep` emulator against the real numbers, delete); the app on the `tdep` emulator against the real
server (QR-style enrollment via deep link, spawn, streamed echo turn, server (QR-style enrollment via deep link, spawn, streamed echo turn,
question answer, tool card). question answer, tool card).
2. **Claude local** — ClaudeDriver: spawn, stream text/tools, mid-run send, 2. **Claude local** — *done 2026-08-24.* ClaudeDriver: spawn, stream
interrupt, permission questions, AskUserQuestion, images both ways, delete. text/tools, mid-run send, interrupt, permission questions,
AskUserQuestion, images both ways, delete.
*Milestone: daily-drivable Claude replacement on localhost.* *Milestone: daily-drivable Claude replacement on localhost.*
Wire-format notes live in `session/claude.rs`'s module doc (pinned
against CLI 2.1.237): permissions need the hidden
`--permission-prompt-tool stdio` flag; AskUserQuestion answers ride
`updatedInput.answers` keyed by question text; `set_model`/`interrupt`
are control requests; 2.x permission modes are acceptEdits / auto /
bypassPermissions / manual / dontAsk / plan (no more "default").
Attachments/files were re-homed under `/sessions/:id/…` (table above)
so their lifecycle is the session directory's — delete stays the
complete path out.
3. **Usage screen.** 3. **Usage screen.**
4. **llama.cpp** — LlamaServerManager (local), PiDriver, model change, 4. **llama.cpp** — LlamaServerManager (local), PiDriver, model change,
compaction controls. compaction controls.
@@ -195,6 +195,45 @@ fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String):
it.inputStream.readBytes() it.inputStream.readBytes()
} }
// One rate-limit window, rendered as a labeled bar on the usage screen.
data class UsageWindow(
val label: String,
val percent: Double,
val resetsAt: String?,
val active: Boolean,
)
data class UsageSnapshot(
val provider: String,
val available: Boolean,
val windows: List<UsageWindow>,
val error: String?,
)
/** The backend caches; refreshing more often than its poll interval just re-reads the cache. */
fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
requestFromServer(settings, "/usage", readTimeoutMs = 30000) { connection ->
val snapshots = JSONArray(connection.inputStream.bufferedReader().readText())
(0 until snapshots.length()).map { i ->
val snapshot = snapshots.getJSONObject(i)
val windows = snapshot.getJSONArray("windows")
UsageSnapshot(
provider = snapshot.getString("provider"),
available = snapshot.getBoolean("available"),
error = snapshot.optString("error").ifEmpty { null },
windows = (0 until windows.length()).map { j ->
val window = windows.getJSONObject(j)
UsageWindow(
label = window.getString("label"),
percent = window.getDouble("percent"),
resetsAt = window.optString("resetsAt").ifEmpty { null },
active = window.getBoolean("active"),
)
},
)
}
}
fun answerQuestion(settings: ServerSettings, sessionId: String, questionId: String, answer: String) { fun answerQuestion(settings: ServerSettings, sessionId: String, questionId: String, answer: String) {
requestFromServer( requestFromServer(
settings, settings,
@@ -16,6 +16,7 @@ private sealed class Screen {
data object SessionList : Screen() data object SessionList : Screen()
data class Session(val summary: SessionSummary) : Screen() data class Session(val summary: SessionSummary) : Screen()
data object Spawn : Screen() data object Spawn : Screen()
data object Usage : Screen()
data object Settings : Screen() data object Settings : Screen()
} }
@@ -54,6 +55,7 @@ fun AppRoot(settingsVersion: Int) {
reloadToken = reloadToken, reloadToken = reloadToken,
onOpen = { screen = Screen.Session(it) }, onOpen = { screen = Screen.Session(it) },
onSpawn = { screen = Screen.Spawn }, onSpawn = { screen = Screen.Spawn },
onUsage = { screen = Screen.Usage },
onSettings = { screen = Screen.Settings }, onSettings = { screen = Screen.Settings },
) )
is Screen.Session -> { is Screen.Session -> {
@@ -81,6 +83,10 @@ fun AppRoot(settingsVersion: Int) {
onBack = { screen = Screen.SessionList }, onBack = { screen = Screen.SessionList },
) )
} }
is Screen.Usage -> {
BackHandler { screen = Screen.SessionList }
UsageScreen(settings = current, onBack = { screen = Screen.SessionList })
}
is Screen.Settings -> { is Screen.Settings -> {
BackHandler { screen = Screen.SessionList } BackHandler { screen = Screen.SessionList }
SettingsScreen( SettingsScreen(
@@ -56,6 +56,7 @@ fun SessionListScreen(
reloadToken: Int, reloadToken: Int,
onOpen: (SessionSummary) -> Unit, onOpen: (SessionSummary) -> Unit,
onSpawn: () -> Unit, onSpawn: () -> Unit,
onUsage: () -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -83,6 +84,7 @@ fun SessionListScreen(
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
TextButton(onClick = onUsage) { Text("Usage") }
TextButton(onClick = onSettings) { Text("Settings") } TextButton(onClick = onSettings) { Text("Settings") }
TextButton(onClick = { refresh() }) { Text("Refresh") } TextButton(onClick = { refresh() }) { Text("Refresh") }
} }
@@ -0,0 +1,145 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Duration
import java.time.OffsetDateTime
private val WARN_COLOR = Color(0xFFB26A00)
private val OVER_COLOR = Color(0xFFB3261E)
private sealed class UsageState {
data object Loading : UsageState()
data class Loaded(val snapshots: List<UsageSnapshot>) : UsageState()
data class Error(val message: String) : UsageState()
}
/** Window bars for the account's rate limits, with reset times. */
@Composable
fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<UsageState>(UsageState.Loading) }
fun refresh() {
state = UsageState.Loading
scope.launch {
state = try {
withContext(Dispatchers.IO) { UsageState.Loaded(fetchUsage(settings)) }
} catch (e: ApiException) {
UsageState.Error(e.message ?: "Unknown error")
}
}
}
LaunchedEffect(Unit) { refresh() }
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
"Usage",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onBack) { Text("Back") }
TextButton(onClick = { refresh() }) { Text("Refresh") }
}
Spacer(Modifier.height(16.dp))
when (val current = state) {
is UsageState.Loading -> CircularProgressIndicator()
is UsageState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is UsageState.Loaded -> current.snapshots.forEach { snapshot ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(snapshot.provider, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
if (!snapshot.available) {
Text(
snapshot.error ?: "Unavailable",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
snapshot.windows.forEach { window ->
WindowBar(window)
Spacer(Modifier.height(12.dp))
}
}
}
Spacer(Modifier.height(12.dp))
}
}
}
}
@Composable
private fun WindowBar(window: UsageWindow) {
val color = when {
window.percent >= 95 -> OVER_COLOR
window.percent >= 75 -> WARN_COLOR
else -> MaterialTheme.colorScheme.primary
}
Column {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
window.label + if (window.active) " (active)" else "",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium)
}
Spacer(Modifier.height(4.dp))
LinearProgressIndicator(
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
color = color,
modifier = Modifier.fillMaxWidth(),
)
window.resetsAt?.let {
Spacer(Modifier.height(2.dp))
Text(
"resets ${formatReset(it)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** "in 3h 12m" -- close enough for deciding whether to start a big task. */
private fun formatReset(resetsAt: String): String = try {
val until = Duration.between(OffsetDateTime.now(), OffsetDateTime.parse(resetsAt))
when {
until.isNegative -> "soon"
until.toHours() >= 24 -> "in ${until.toDays()}d ${until.toHours() % 24}h"
until.toHours() > 0 -> "in ${until.toHours()}h ${until.toMinutes() % 60}m"
else -> "in ${until.toMinutes()}m"
}
} catch (_: Exception) {
"at $resetsAt"
}
+89
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.5" version = "1.1.5"
@@ -23,6 +29,7 @@ dependencies = [
"if-addrs", "if-addrs",
"qrcode", "qrcode",
"rand", "rand",
"rustls",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@@ -34,6 +41,7 @@ dependencies = [
"tower", "tower",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"ureq",
] ]
[[package]] [[package]]
@@ -337,6 +345,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crc32fast"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.2.2" version = "0.2.2"
@@ -406,6 +423,16 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@@ -713,6 +740,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.2" version = "1.2.2"
@@ -878,7 +915,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"log",
"once_cell", "once_cell",
"ring",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki",
"subtle", "subtle",
@@ -1020,6 +1059,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -1300,6 +1345,41 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
dependencies = [
"base64",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
dependencies = [
"base64",
"http",
"httparse",
"log",
]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]] [[package]]
name = "utf8parse" name = "utf8parse"
version = "0.2.2" version = "0.2.2"
@@ -1324,6 +1404,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
+8
View File
@@ -30,6 +30,14 @@ qrcode = { version = "0.14", default-features = false }
# The wg0-bound listener needs the interface's address; the stdlib has no # The wg0-bound listener needs the interface's address; the stdlib has no
# getifaddrs. This is the smallest crate that wraps just that. # getifaddrs. This is the smallest crate that wraps just that.
if-addrs = "0.15" if-addrs = "0.15"
# Outbound HTTPS for the usage endpoint. A small blocking client fits an
# every-few-minutes poll better than pulling in reqwest's tower stack;
# rustls-backed like the rest of the TLS here.
ureq = "3"
# Direct dependency only to pick the process-level CryptoProvider in main:
# ureq pulls rustls-with-ring, axum-server rustls-with-aws-lc-rs, and with
# both in the graph rustls refuses to auto-select one.
rustls = "0.23"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
+17 -1
View File
@@ -17,6 +17,7 @@ mod auth;
mod config; mod config;
mod routes; mod routes;
mod session; mod session;
mod usage;
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -119,6 +120,13 @@ fn print_enrollment(host: IpAddr, port: u16, token: &str) -> Result<()> {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// Both rustls crypto providers are in the dependency graph (ureq
// brings ring, axum-server brings aws-lc-rs), so rustls refuses to
// pick one itself; choose before anything touches TLS.
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.expect("no other TLS crypto provider is installed before main");
tracing_subscriber::fmt().with_env_filter("info").init(); tracing_subscriber::fmt().with_env_filter("info").init();
let args = Args::parse(); let args = Args::parse();
@@ -174,10 +182,18 @@ async fn main() -> Result<()> {
.await .await
.context("failed to load TLS cert/key")?; .context("failed to load TLS cert/key")?;
let monitor = Arc::new(usage::UsageMonitor::new(vec![Box::new(usage::ClaudeUsage {
credentials_path: std::env::home_dir()
.unwrap_or_else(|| PathBuf::from("/"))
.join(".claude/.credentials.json"),
})]));
// 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
// auth. Zero unauthenticated endpoints. // auth. Zero unauthenticated endpoints.
let app = routes::router(Arc::clone(&manager)).layer(axum::middleware::from_fn_with_state( let app = routes::router(Arc::clone(&manager))
.merge(routes::usage_router(monitor))
.layer(axum::middleware::from_fn_with_state(
Arc::clone(&manager), Arc::clone(&manager),
auth::require_token, auth::require_token,
)); ));
+28 -2
View File
@@ -14,10 +14,10 @@
//! POST /sessions/{id}/attachments multipart image upload -> {id}, referenced by /message //! POST /sessions/{id}/attachments multipart image upload -> {id}, referenced by /message
//! GET /sessions/{id}/files/{name} images the session produced or was sent //! GET /sessions/{id}/files/{name} images the session produced or was sent
//! DELETE /sessions/{id} kill process, delete transcript + files //! DELETE /sessions/{id} kill process, delete transcript + files
//! GET /usage cached usage windows per provider
//! ``` //! ```
//! //!
//! Later phases add: `GET /usage`, `GET|PUT /hosts` and `/models` -- see //! Later phases add: `GET|PUT /hosts` and `/models` -- see PLAN.md's table.
//! PLAN.md's table.
//! //!
//! Everything here works purely in the common event model; nothing may //! Everything here works purely in the common event model; nothing may
//! branch on the session kind (that's what drivers are for). //! branch on the session kind (that's what drivers are for).
@@ -26,6 +26,8 @@ use std::convert::Infallible;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use anyhow::Context;
use axum::Router; use axum::Router;
use axum::extract::{Path as UrlPath, Query, State}; use axum::extract::{Path as UrlPath, Query, State};
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
@@ -69,6 +71,8 @@ enum ApiError {
UnknownRoute, UnknownRoute,
#[error("{0}")] #[error("{0}")]
BadRequest(String), BadRequest(String),
#[error(transparent)]
Internal(#[from] anyhow::Error),
} }
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
@@ -76,6 +80,12 @@ impl IntoResponse for ApiError {
let status = match self { let status = match self {
Self::UnknownSession(_) | Self::UnknownRoute => StatusCode::NOT_FOUND, Self::UnknownSession(_) | Self::UnknownRoute => StatusCode::NOT_FOUND,
Self::BadRequest(_) => StatusCode::BAD_REQUEST, Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::Internal(err) => {
// The only variant whose real cause isn't safe to hand
// back verbatim, and the only one worth a log line.
tracing::error!("{err:#}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
}; };
(status, self.to_string()).into_response() (status, self.to_string()).into_response()
} }
@@ -186,6 +196,22 @@ async fn interrupt(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
/// Separate router because its state is the usage monitor, not the
/// session manager; merged (and auth-wrapped) with the rest in `main`.
pub fn usage_router(monitor: Arc<crate::usage::UsageMonitor>) -> Router {
Router::new().route("/usage", get(usage)).with_state(monitor)
}
async fn usage(
State(monitor): State<Arc<crate::usage::UsageMonitor>>,
) -> Result<axum::Json<Vec<crate::usage::UsageSnapshot>>, ApiError> {
// The fetch is blocking by design (see `usage`); off the workers.
let snapshots = tokio::task::spawn_blocking(move || monitor.snapshots())
.await
.context("usage fetch panicked")?;
Ok(axum::Json(snapshots))
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct ModelRequest { struct ModelRequest {
model: String, model: String,
+255
View File
@@ -0,0 +1,255 @@
//! Usage-limit reporting -- the same numbers as Claude Code's `/usage`.
//!
//! 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.
//!
//! 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.
//!
//! One [`UsageProvider`] per paid service, so a second service later is a
//! new impl behind the same snapshot shape, not a parallel screen.
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde::Serialize;
use serde_json::Value;
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
const MIN_POLL_INTERVAL: Duration = Duration::from_secs(180);
/// Matched to the CLI version the wire formats were pinned against.
const USER_AGENT: &str = "claude-code/2.1.237";
/// One rate-limit window, as the phone renders it: a labeled bar.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageWindow {
pub label: String,
/// 0-100.
pub percent: f64,
/// ISO-8601, as the API sends it; absent for windows that never reset.
#[serde(skip_serializing_if = "Option::is_none")]
pub resets_at: Option<String>,
/// Whether this window is currently the binding one.
pub active: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageSnapshot {
pub provider: String,
pub available: bool,
pub windows: Vec<UsageWindow>,
/// Why `available` is false, written for the screen.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// Epoch seconds the numbers were fetched (they can be up to the poll
/// interval old).
pub fetched_at: f64,
}
pub trait UsageProvider: Send + Sync {
fn name(&self) -> &'static str;
/// Blocking -- call off the async workers.
fn fetch(&self) -> UsageSnapshot;
}
/// Reads the numbers behind Claude Code's `/usage` using its own stored
/// credentials -- nothing to configure, and it reports on exactly the
/// account the local CLI runs as.
pub struct ClaudeUsage {
pub credentials_path: PathBuf,
}
impl ClaudeUsage {
fn unavailable(&self, error: String) -> UsageSnapshot {
UsageSnapshot {
provider: self.name().to_string(),
available: false,
windows: Vec::new(),
error: Some(error),
fetched_at: crate::session::now(),
}
}
fn access_token(&self) -> Result<String, String> {
let text = std::fs::read_to_string(&self.credentials_path).map_err(|err| {
format!(
"couldn't read {} ({err}) -- is Claude Code logged in on this machine?",
self.credentials_path.display()
)
})?;
serde_json::from_str::<Value>(&text)
.ok()
.and_then(|creds| {
creds.get("claudeAiOauth")?.get("accessToken")?.as_str().map(String::from)
})
.ok_or_else(|| {
"credential file has no claudeAiOauth.accessToken -- log in with `claude` once"
.to_string()
})
}
}
impl UsageProvider for ClaudeUsage {
fn name(&self) -> &'static str {
"claude"
}
fn fetch(&self) -> UsageSnapshot {
let token = match self.access_token() {
Ok(token) => token,
Err(error) => return self.unavailable(error),
};
let text = match ureq::get(USAGE_URL)
.header("Authorization", &format!("Bearer {token}"))
.header("anthropic-beta", "oauth-2025-04-20")
.header("User-Agent", USER_AGENT)
.call()
.and_then(|mut response| response.body_mut().read_to_string())
{
Ok(text) => text,
Err(err) => {
// The error string can embed the URL but never the token.
return self.unavailable(format!("usage endpoint unreachable: {err}"));
}
};
let body: Value = match serde_json::from_str(&text) {
Ok(body) => body,
Err(err) => return self.unavailable(format!("usage endpoint sent non-JSON: {err}")),
};
UsageSnapshot {
provider: self.name().to_string(),
available: true,
windows: parse_windows(&body),
error: None,
fetched_at: crate::session::now(),
}
}
}
/// 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.
fn parse_windows(body: &Value) -> Vec<UsageWindow> {
let Some(limits) = body.get("limits").and_then(Value::as_array) else {
return Vec::new();
};
limits
.iter()
.filter_map(|limit| {
let percent = limit.get("percent")?.as_f64()?;
let kind = limit.get("kind").and_then(Value::as_str).unwrap_or("unknown");
let scope_model = limit
.get("scope")
.and_then(|scope| scope.get("model"))
.and_then(|model| model.get("display_name"))
.and_then(Value::as_str);
let label = match (kind, scope_model) {
("session", _) => "5-hour window".to_string(),
("weekly_all", _) => "Weekly (all models)".to_string(),
("weekly_scoped", Some(model)) => format!("Weekly ({model})"),
(other, Some(model)) => format!("{other} ({model})"),
(other, None) => other.to_string(),
};
Some(UsageWindow {
label,
percent,
resets_at: limit
.get("resets_at")
.and_then(Value::as_str)
.map(String::from),
active: limit.get("is_active").and_then(Value::as_bool).unwrap_or(false),
})
})
.collect()
}
/// The cache in front of whatever providers exist: at most one real fetch
/// per provider per [`MIN_POLL_INTERVAL`], no matter how often the phone
/// asks.
pub struct UsageMonitor {
providers: Vec<Box<dyn UsageProvider>>,
cache: Mutex<Vec<(Instant, UsageSnapshot)>>,
}
impl UsageMonitor {
pub fn new(providers: Vec<Box<dyn UsageProvider>>) -> Self {
Self { providers, cache: Mutex::new(Vec::new()) }
}
/// Blocking -- call via `spawn_blocking`.
pub fn snapshots(&self) -> Vec<UsageSnapshot> {
let mut cache = self.cache.lock().unwrap();
self.providers
.iter()
.enumerate()
.map(|(i, provider)| {
if let Some((fetched, snapshot)) = cache.get(i)
&& fetched.elapsed() < MIN_POLL_INTERVAL
{
return snapshot.clone();
}
let snapshot = provider.fetch();
match cache.get_mut(i) {
Some(slot) => *slot = (Instant::now(), snapshot.clone()),
None => cache.push((Instant::now(), snapshot.clone())),
}
snapshot
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_limits_array_defensively() {
// Trimmed from a live 2026-08-24 response.
let body: Value = serde_json::from_str(
r#"{"limits":[
{"kind":"session","group":"session","percent":70,"severity":"normal","resets_at":"2026-08-25T04:29:59+00:00","scope":null,"is_active":true},
{"kind":"weekly_all","group":"weekly","percent":25,"resets_at":"2026-08-28T21:59:59+00:00","is_active":false},
{"kind":"weekly_scoped","percent":15,"resets_at":"2026-08-28T21:59:59+00:00","scope":{"model":{"id":null,"display_name":"Fable"}},"is_active":false},
{"kind":"mystery_new_window","percent":5},
{"kind":"broken_entry_without_percent"}
]}"#,
)
.expect("json");
let windows = parse_windows(&body);
assert_eq!(windows.len(), 4);
assert_eq!(windows[0].label, "5-hour window");
assert_eq!(windows[0].percent, 70.0);
assert!(windows[0].active);
assert_eq!(windows[1].label, "Weekly (all models)");
assert_eq!(windows[2].label, "Weekly (Fable)");
// Unknown kinds surface under their raw name instead of vanishing.
assert_eq!(windows[3].label, "mystery_new_window");
assert_eq!(windows[3].resets_at, None);
}
#[test]
fn missing_credentials_degrade_to_unavailable() {
let provider = ClaudeUsage { credentials_path: PathBuf::from("/nonexistent/creds.json") };
let snapshot = provider.fetch();
assert!(!snapshot.available);
assert!(snapshot.error.expect("reason").contains("logged in"));
}
#[test]
fn an_empty_or_alien_body_yields_no_windows() {
assert!(parse_windows(&serde_json::json!({})).is_empty());
assert!(parse_windows(&serde_json::json!({"limits": "what"})).is_empty());
}
}