whiskers.git / android / app / src / main / kotlin / app / whiskers / VoiceUsage.kt
VoiceUsage.kt93 lines · 3.7 KB · raw
1package app.whiskers
2
3import androidx.compose.runtime.Immutable
4import org.json.JSONObject
5import java.time.Instant
6import java.time.ZoneId
7import java.time.format.DateTimeFormatter
8import java.net.HttpURLConnection
9import java.net.URL
10
11/** What the parents see about the natural voice: monthly credits, today's allowance, and why it last failed. */
12@Immutable
13data class VoiceUsage(
14    val configured: Boolean,
15    val dailyCap: Int,
16    val dailySpent: Int,
17    val lastError: String?,
18    val tier: String?,
19    val used: Long?,
20    val limit: Long?,
21    val resetsAtUnix: Long?,
22    val planError: String?,
23    /** The thinking allowance (tokens in a rolling window), from the same reply. */
24    val thinking: ThinkingUsage? = null,
25) {
26    /** Share of the month's credits used, 0..1, or null when unknown. */
27    val monthFraction: Float? get() = if (used != null && limit != null && limit > 0) (used.toFloat() / limit).coerceIn(0f, 1f) else null
28
29    fun resetText(zone: ZoneId = ZoneId.systemDefault()): String? =
30        resetsAtUnix?.let { DateTimeFormatter.ofPattern("MMMM d").withZone(zone).format(Instant.ofEpochSecond(it)) }
31}
32
33/** The thinking allowance: tokens used in the last [windowHours] hours against [limit] (null for none). */
34@Immutable
35data class ThinkingUsage(val used: Long, val limit: Long?, val windowHours: Long, val freesUpAtMs: Long?) {
36    val fraction: Float? get() = if (limit != null && limit > 0) (used.toFloat() / limit).coerceIn(0f, 1f) else null
37}
38
39/** Reads the service's `/usage` reply; null when it is not one. */
40fun parseVoiceUsage(json: String): VoiceUsage? = try {
41    val v = JSONObject(json).getJSONObject("voice")
42    fun str(k: String) = if (v.isNull(k)) null else v.getString(k)
43    fun num(k: String) = if (v.isNull(k)) null else v.getLong(k)
44    VoiceUsage(
45        configured = v.getBoolean("configured"),
46        dailyCap = v.getInt("daily_cap"),
47        dailySpent = v.getInt("daily_spent"),
48        lastError = str("last_error"),
49        tier = str("tier"),
50        used = num("used"),
51        limit = num("limit"),
52        resetsAtUnix = num("resets_at_unix"),
53        planError = str("plan_error"),
54        thinking = JSONObject(json).optJSONObject("tokens")?.let { t ->
55            ThinkingUsage(
56                used = t.getLong("used"),
57                limit = if (t.isNull("limit")) null else t.getLong("limit"),
58                windowHours = t.getLong("window_hours"),
59                freesUpAtMs = if (t.isNull("frees_up_at_ms")) null else t.getLong("frees_up_at_ms"),
60            )
61        },
62    )
63} catch (e: Exception) {
64    Wlog.w(Area.Parents, "usage reply could not be read (${json.length} chars of JSON)", e)
65    null
66}
67
68/** Asks the service for [VoiceUsage]. Blocking: call off the main thread. */
69fun fetchVoiceUsage(address: ServiceAddress): VoiceUsage? {
70    val began = System.nanoTime()
71    val conn = URL(address.endpoint("/usage")).openConnection() as HttpURLConnection
72    return try {
73        conn.requestMethod = "POST"
74        conn.connectTimeout = 4_000
75        conn.readTimeout = 10_000
76        conn.doOutput = true
77        conn.outputStream.use { it.write("{}".toByteArray()) }
78        val code = conn.responseCode
79        if (code != 200) {
80            Wlog.w(Area.Parents, "usage: service answered HTTP $code")
81            null
82        } else {
83            val body = conn.inputStream.use { it.readBytes() }
84            Wlog.d(Area.Parents, "usage: HTTP 200, ${body.size} bytes in ${(System.nanoTime() - began) / 1_000_000} ms")
85            parseVoiceUsage(body.toString(Charsets.UTF_8))
86        }
87    } catch (e: Exception) {
88        Wlog.w(Area.Parents, "usage: service unreachable after ${(System.nanoTime() - began) / 1_000_000} ms (${e.javaClass.simpleName})", e)
89        null
90    } finally {
91        conn.disconnect()
92    }
93}