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}