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:
1 parent
f2430671a2
commit
3c97a5ef28
10 files changed
+606
-10
No files matched your search
@@ -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"
|
||||||
|
}
|
||||||
Generated
+89
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+20
-4
@@ -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,13 +182,21 @@ 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))
|
||||||
Arc::clone(&manager),
|
.merge(routes::usage_router(monitor))
|
||||||
auth::require_token,
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
));
|
Arc::clone(&manager),
|
||||||
|
auth::require_token,
|
||||||
|
));
|
||||||
|
|
||||||
let addr = SocketAddr::new(bind_ip, args.port);
|
let addr = SocketAddr::new(bind_ip, args.port);
|
||||||
tracing::info!("serving https://{addr}");
|
tracing::info!("serving https://{addr}");
|
||||||
|
|||||||
+28
-2
@@ -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,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user