diff --git a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt index 9b187fa..0778862 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt @@ -8,7 +8,12 @@ import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceType import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -21,6 +26,7 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.io.IOException import java.net.URLEncoder +import java.util.concurrent.TimeUnit import javax.inject.Singleton /** @@ -126,6 +132,7 @@ class OpenCodeGoRepository( } } + // 主窗口数据先落缓存并返回(关键路径只依赖 HTML 解析一次请求) val balance = ServiceBalance( service = ServiceType.OPENCODE_GO, amount = primary.usagePercent.toDouble(), @@ -136,11 +143,22 @@ class OpenCodeGoRepository( nextResetAt = System.currentTimeMillis() + primary.resetInSec * 1000L, extras = extras ) - balanceCache.put(ServiceType.OPENCODE_GO, balance) credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis())) - Result.success(balance) + // 模型级用量作为增量增强:并行拉取 + 短超时,失败/超时不影响主窗口数据与缓存 + val modelUsage = fetchModelWindows(workspaceId, authCookie) + if (modelUsage.isEmpty()) { + return@withContext Result.success(balance) + } + val enriched = balance.copy( + extras = buildMap { + putAll(extras) + modelUsage.forEach { (window, usage) -> put("$window.models", json.encodeToString(usage)) } + } + ) + balanceCache.put(ServiceType.OPENCODE_GO, enriched) + Result.success(enriched) } } @@ -235,6 +253,12 @@ class OpenCodeGoRepository( private const val TAG = "OCGO" private const val MODELS_API = "https://models.dev/api.json" private const val CHAT_API = "https://opencode.ai/zen/go/v1/chat/completions" + /** _server 端点:模型级用量接口(f:31 + 窗口参数,需 x-server-id / x-server-instance 头) */ + private const val SERVER_ENDPOINT = "https://opencode.ai/_server" + /** 窗口模型用量的 server function id(与请求头 x-server-id 一致,取自网页端实测) */ + private const val MODEL_USAGE_SERVER_ID = "ba154d05c4028a885b8c753f9def7e45d87eb982e65fa8b14254cbe636168914" + /** 模型级用量单请求超时(毫秒):只影响增强数据,避免拖慢主窗口刷新 */ + private const val MODEL_REQUEST_TIMEOUT_MS = 5_000L private val SCRAPED_FIELDS = listOf("rollingUsage", "weeklyUsage", "monthlyUsage") /** @@ -344,9 +368,173 @@ class OpenCodeGoRepository( val usage: Long? = null, val limit: Long? = null ) + + /** + * 解析 _server 返回的模型级用量 flight 响应。 + * + * 实际响应形如: + * ``` + * ;0x000003bc;((self.$R=self.$R||{})["server-fn:2"]=[],($R=>$R[0]={ + * usage:1889462439,limit:6000000000,usagePercent:31.5, + * rows:$R[1]=[$R[2]={model:"deepseek-v4-flash",name:"DeepSeek V4 Flash", + * cost:1002607535,quotaCost:2005215070,multiplier:2, + * estimated:!0,contributionPercent:30.5},...] + * })($R["server-fn:2"])) + * ``` + * + * 返回窗口模型用量(usage/limit/usagePercent + 每模型行),解析失败返回 null。 + * cost/quotaCost 单位为 1e-8 美元(cost × 10⁻⁸ = 美元)。 + */ + internal fun parseModelRows(flight: String): WindowModelUsage? { + // 定位 "$R[0]={" 数据块(窗口模型用量的固定 hydration 格式) + val rootIdx = flight.indexOf("\$R[0]={") + if (rootIdx < 0) return null + val braceStart = rootIdx + "\$R[0]=".length + val braceEnd = findMatchingBrace(flight, braceStart) ?: return null + val body = flight.substring(braceStart, braceEnd + 1) + + val usage = extractNumberAfterKey(body, "usage", exactKey = true)?.toLongOrNull() + val limit = extractNumberAfterKey(body, "limit", exactKey = true)?.toLongOrNull() + val usagePercent = extractNumberAfterKey(body, "usagePercent")?.toFloatOrNull() + + return WindowModelUsage( + usage = usage, + limit = limit, + usagePercent = usagePercent, + rows = extractModelRows(body) + ) + } + + /** 从外层对象 body 中提取所有 {model:"..."} 模型行对象。 */ + private fun extractModelRows(body: String): List { + val rows = mutableListOf() + var idx = body.indexOf("{model:") + while (idx >= 0) { + val braceEnd = findMatchingBrace(body, idx) ?: break + val obj = body.substring(idx, braceEnd + 1) + val model = extractQuoted(obj, "model") ?: "" + val name = extractQuoted(obj, "name") ?: "" + val cost = extractNumberAfterKey(obj, "cost")?.toLongOrNull() ?: 0L + val quotaCost = extractNumberAfterKey(obj, "quotaCost")?.toLongOrNull() ?: 0L + val multiplier = extractNumberAfterKey(obj, "multiplier")?.toDoubleOrNull() ?: 1.0 + val estimated = obj.contains("estimated:!0") + val contributionPercent = extractNumberAfterKey(obj, "contributionPercent")?.toDoubleOrNull() ?: 0.0 + rows += ModelUsageRow( + model = model, + name = name, + cost = cost, + quotaCost = quotaCost, + multiplier = multiplier, + estimated = estimated, + contributionPercent = contributionPercent + ) + idx = body.indexOf("{model:", braceEnd + 1) + } + return rows + } + + /** 提取 "key:" 后面紧跟的 "..." 引号字符串值。 */ + private fun extractQuoted(body: String, key: String): String? { + val keyIdx = body.indexOf("$key:\"") + if (keyIdx < 0) return null + val start = keyIdx + key.length + 2 + val end = body.indexOf('"', start) + if (end < 0) return null + return body.substring(start, end) + } + + /** + * 窗口配额(1e-8 美元整数):窗口 limit(token 数)÷ multiplier。 + * 与网页端各窗口"配额"列口径一致(实测:5h=12 亿÷2=$6、周=30 亿÷2=$15、月=60 亿÷2=$30;hy3 月=60 亿÷0.125=$480)。 + * multiplier 缺失或非正时安全降级为月度基础 $60(与网页默认一致)。 + */ + internal fun windowQuotaRaw(windowLimit: Long, multiplier: Double): Long { + if (multiplier <= 0.0) return 60L * 100_000_000L + return (windowLimit.toDouble() / multiplier).toLong() + } + } + + /** + * 拉取三个窗口(rolling/weekly/monthly)的模型级用量。 + * 并行发起 + 每个请求独立短超时(MODEL_REQUEST_TIMEOUT_MS), + * 任一窗口失败不影响其他窗口;整体失败返回空 Map(主窗口数据不受影响)。 + */ + private suspend fun fetchModelWindows(workspaceId: String, authCookie: String): Map = + coroutineScope { + listOf("rolling", "weekly", "monthly").map { window -> + async { + try { + requestModelWindow(workspaceId, authCookie, window)?.let { parseModelRows(it) } + } catch (e: Exception) { + DebugLog.e(TAG, "fetchModelWindows($window) 异常: ${e.message}") + null + } + } + }.awaitAll().let { results -> + listOf("rolling", "weekly", "monthly").zip(results).mapNotNull { (window, usage) -> + usage?.let { window to it } + }.toMap() + } + } + + /** 请求 _server 的窗口模型用量接口,返回 flight 文本;HTTP 失败/超时返回 null。 */ + private fun requestModelWindow(workspaceId: String, authCookie: String, window: String): String? { + val args = """{"t":{"t":9,"i":0,"l":2,"a":[{"t":1,"s":"$workspaceId"},{"t":1,"s":"$window"}],"o":0},"f":31,"m":[]}""" + val url = "$SERVER_ENDPOINT?id=$MODEL_USAGE_SERVER_ID&args=${URLEncoder.encode(args, "UTF-8")}" + val request = Request.Builder() + .url(url) + .header("Accept", "*/*") + .header("Cookie", "auth=$authCookie") + .header("x-server-id", MODEL_USAGE_SERVER_ID) + .header("x-server-instance", "server-fn:2") + .header("Referer", "https://opencode.ai/workspace/$workspaceId/go") + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/152.0.0.0 Safari/537.36") + .get().build() + val call = okHttpClient.newCall(request) + call.timeout().timeout(MODEL_REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS) + return try { + call.execute().use { resp -> + if (!resp.isSuccessful) { + DebugLog.e(TAG, "requestModelWindow($window): HTTP ${resp.code}") + null + } else { + resp.body?.string() + } + } + } catch (e: IOException) { + DebugLog.e(TAG, "requestModelWindow($window) 网络异常: ${e.message}") + null + } catch (e: Throwable) { + DebugLog.e(TAG, "requestModelWindow($window) 异常: ${e.message}") + null + } } } +/** + * 窗口模型级用量(_server 接口 payload)。 + * cost/quotaCost 单位为 1e-8 美元;multiplier 为计费倍率(quotaCost = cost × multiplier)。 + */ +@Serializable +internal data class WindowModelUsage( + val usage: Long? = null, + val limit: Long? = null, + val usagePercent: Float? = null, + val rows: List = emptyList() +) + +/** 单模型用量行。contributionPercent 为该模型占窗口用量的百分比(如 30.5 = 30.5%)。 */ +@Serializable +internal data class ModelUsageRow( + val model: String = "", + val name: String = "", + val cost: Long = 0L, + val quotaCost: Long = 0L, + val multiplier: Double = 1.0, + val estimated: Boolean = false, + val contributionPercent: Double = 0.0 +) + /** * 解析 OpenAI 兼容的 chat completions 响应,提取回复文本和用量统计。 */ diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt index 3a39e23..ebb7791 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt @@ -60,6 +60,9 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.rainy.token.R +import com.rainy.token.data.repository.ModelUsageRow +import com.rainy.token.data.repository.OpenCodeGoRepository +import com.rainy.token.data.repository.WindowModelUsage import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.domain.service.FetchMethod @@ -76,6 +79,7 @@ import com.rainy.token.ui.components.formatResetInSec import com.rainy.token.ui.components.isFiveHourLabel import com.rainy.token.ui.theme.inkMuted import com.rainy.token.ui.theme.StrawberryPink +import kotlinx.serialization.json.Json import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -363,6 +367,7 @@ private fun parseIsoDuration(isoStr: String): Long? { * * 数据从 balance.extras["rolling.pct"] / ["weekly.pct"] / ["monthly.pct"] 取, * 进度条颜色按 % 自动切换:< 50% 草莓粉,50-80% 暖橙,> 80% 玫红。 + * 每个窗口下方展示模型级用量(_server 接口,像 Ollama 一样按模型列出)。 */ @Composable private fun OpenCodeGoWindowsCard(state: State) { @@ -392,6 +397,11 @@ private fun OpenCodeGoWindowsCard(state: State) { pct = extras["rolling.pct"]?.toFloatOrNull(), resetInSec = extras["rolling.resetInSec"]?.toLongOrNull() ) + val rollingWin = parseModelUsage(extras["rolling.models"]) + if (!rollingWin.rows.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(10.dp)) + ModelUsageList(rows = rollingWin.rows.orEmpty(), windowLimit = rollingWin.limit) + } Spacer(modifier = Modifier.height(14.dp)) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(14.dp)) @@ -400,6 +410,11 @@ private fun OpenCodeGoWindowsCard(state: State) { pct = extras["weekly.pct"]?.toFloatOrNull(), resetInSec = extras["weekly.resetInSec"]?.toLongOrNull() ) + val weeklyWin = parseModelUsage(extras["weekly.models"]) + if (!weeklyWin.rows.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(10.dp)) + ModelUsageList(rows = weeklyWin.rows.orEmpty(), windowLimit = weeklyWin.limit) + } Spacer(modifier = Modifier.height(14.dp)) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(14.dp)) @@ -408,10 +423,97 @@ private fun OpenCodeGoWindowsCard(state: State) { pct = extras["monthly.pct"]?.toFloatOrNull(), resetInSec = extras["monthly.resetInSec"]?.toLongOrNull() ) + val monthlyWin = parseModelUsage(extras["monthly.models"]) + if (!monthlyWin.rows.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(10.dp)) + ModelUsageList(rows = monthlyWin.rows.orEmpty(), windowLimit = monthlyWin.limit) + } } } } +/** + * 解析 extras 中模型级用量 JSON([OpenCodeGoRepository] 序列化的 WindowModelUsage)。 + * 返回整个窗口对象(含 limit,供配额计算);解析失败返回空对象(UI 隐藏列表)。 + */ +private fun parseModelUsage(raw: String?): WindowModelUsage { + if (raw.isNullOrBlank()) return WindowModelUsage() + return try { + Json { ignoreUnknownKeys = true } + .decodeFromString(raw) + } catch (_: Exception) { + WindowModelUsage() + } +} + +/** + * 模型级用量列表(Ollama 风格):模型名 + 用量金额 + 占比 + 配额。 + * 每行显示模型显示名、已用金额($XX.XX)、占窗口百分比与窗口配额(limit ÷ multiplier)。 + * estimated 行(用量为估算值)在模型名后加 "~" 标识。 + * windowLimit 缺失时隐藏配额列(用量与占比照常显示)。 + */ +@Composable +private fun ModelUsageList(rows: List, windowLimit: Long?) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = stringResource(R.string.msg_model_usage), + style = MaterialTheme.typography.labelSmall, + color = inkMuted() + ) + rows.sortedByDescending { it.contributionPercent }.forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = row.name.ifBlank { row.model } + if (row.estimated) " ~" else "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + modifier = Modifier.weight(1f) + ) + if (windowLimit != null && windowLimit > 0) { + Text( + text = stringResource( + R.string.msg_model_quota, + formatUsd(OpenCodeGoRepository.windowQuotaRaw(windowLimit, row.multiplier)) + ), + style = MaterialTheme.typography.labelSmall, + color = inkMuted(), + modifier = Modifier.padding(end = 8.dp) + ) + } + Text( + text = "$" + formatUsd(row.cost), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = StrawberryPink, + modifier = Modifier.padding(end = 8.dp) + ) + Text( + text = String.format(Locale.US, "%.1f%%", row.contributionPercent), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +/** + * 把 1e-8 美元整数(cost/quotaCost)格式化为美元字符串。 + * 例:52650887 → "0.53";1002607535 → "10.03"。 + * 小额(<$0.01)自动提升精度避免显示 "$0.00":11310 → "0.0001"。 + */ +private fun formatUsd(rawCost: Long): String { + val dollars = rawCost / 100_000_000.0 + return if (dollars > 0.0 && dollars < 0.01) { + String.format(Locale.US, "%.4f", dollars) + } else { + String.format(Locale.US, "%.2f", dollars) + } +} + @Composable private fun UsageWindowRow(label: String, pct: Float?, resetInSec: Long?, decimals: Int = 0) { val durationText = DurationText( diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 0f9c702..244efbf 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -125,6 +125,8 @@ 用量 用量窗口%1$s 用量窗口 + 模型用量 + 配额 $%1$s OpenCode 用量 diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index 10ed321..9f65f55 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -125,6 +125,8 @@ 用量 用量視窗%1$s 用量視窗 + 模型用量 + 配額 $%1$s OpenCode 用量 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e86ef98..98894b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -128,6 +128,8 @@ Usage Usage window%1$s Usage window + Model usage + Quota $%1$s OpenCode Usage diff --git a/app/src/test/java/com/rainy/token/data/repository/OpenCodeGoRepositoryTest.kt b/app/src/test/java/com/rainy/token/data/repository/OpenCodeGoRepositoryTest.kt index bcd7536..9386201 100644 --- a/app/src/test/java/com/rainy/token/data/repository/OpenCodeGoRepositoryTest.kt +++ b/app/src/test/java/com/rainy/token/data/repository/OpenCodeGoRepositoryTest.kt @@ -233,4 +233,108 @@ class OpenCodeGoRepositoryTest { assertNull(result["rollingUsage"]!!.usage) assertNull(result["rollingUsage"]!!.limit) } + + // ─────────────── parseModelRows(_server 模型级用量)─────────────── + + /** 真实 response 形态的 flight 片段:monthly 窗口、6 个模型。 */ + private fun monthlyFlight(): String = + """ + ;0x000003bc;((self.${'$'}R=self.${'$'}R||{})["server-fn:2"]=[],(${'$'}R=>${'$'}R[0]={usage:1889462439,limit:6000000000,usagePercent:31.5,rows:${'$'}R[1]=[${'$'}R[2]={model:"deepseek-v4-flash",name:"DeepSeek V4 Flash",cost:1002607535,quotaCost:2005215070,multiplier:2,estimated:!0,contributionPercent:30.5},${'$'}R[3]={model:"muse-spark-1.2-contributor",name:"Muse Spark 1.2 Contributor",cost:52650887,quotaCost:52650887,multiplier:1,estimated:!1,contributionPercent:0.8},${'$'}R[4]={model:"deepseek-v4-flash-vision-exp",name:"DeepSeek V4 Flash Vision Exp",cost:3721059,quotaCost:14884236,multiplier:4,estimated:!0,contributionPercent:0.2},${'$'}R[5]={model:"mimo-v2.5",name:"MiMo V2.5",cost:247184,quotaCost:247184,multiplier:1,estimated:!0,contributionPercent:0},${'$'}R[6]={model:"minimax-m3",name:"MiniMax M3",cost:11310,quotaCost:11310,multiplier:1,estimated:!1,contributionPercent:0},${'$'}R[7]={model:"hy3",name:"Hy3",cost:2676,quotaCost:335,multiplier:0.125,estimated:!1,contributionPercent:0}]})(${'$'}R["server-fn:2"])) + """.trimIndent() + + @Test + fun `parses monthly model rows with all fields`() { + val result = OpenCodeGoRepository.parseModelRows(monthlyFlight()) + assertNotNull(result) + val usage = result!! + assertEquals(1889462439L, usage.usage) + assertEquals(6000000000L, usage.limit) + assertEquals(31.5f, usage.usagePercent) + assertEquals(6, usage.rows.size) + + val deepseek = usage.rows[0] + assertEquals("deepseek-v4-flash", deepseek.model) + assertEquals("DeepSeek V4 Flash", deepseek.name) + assertEquals(1002607535L, deepseek.cost) + assertEquals(2005215070L, deepseek.quotaCost) + assertEquals(2.0, deepseek.multiplier, 0.0) + assertTrue(deepseek.estimated) + assertEquals(30.5, deepseek.contributionPercent, 0.0) + + val muse = usage.rows[1] + assertEquals("muse-spark-1.2-contributor", muse.model) + assertEquals(52650887L, muse.cost) + assertEquals(1.0, muse.multiplier, 0.0) + assertTrue(!muse.estimated) + assertEquals(0.8, muse.contributionPercent, 0.0) + } + + @Test + fun `parses fractional multiplier`() { + // hy3 multiplier:0.125 + val usage = OpenCodeGoRepository.parseModelRows(monthlyFlight())!! + val hy3 = usage.rows.first { it.model == "hy3" } + assertEquals(0.125, hy3.multiplier, 0.0) + assertEquals(335L, hy3.quotaCost) + } + + @Test + fun `parses rolling window with single model`() { + val flight = """ + ;0x0000011b;((self.${'$'}R=self.${'$'}R||{})["server-fn:2"]=[],(${'$'}R=>${'$'}R[0]={usage:331489076,limit:1200000000,usagePercent:27.6,rows:${'$'}R[1]=[${'$'}R[2]={model:"deepseek-v4-flash",name:"DeepSeek V4 Flash",cost:165744538,quotaCost:331489076,multiplier:2,estimated:!1,contributionPercent:27.6}]})(${'$'}R["server-fn:2"])) + """.trimIndent() + val usage = OpenCodeGoRepository.parseModelRows(flight)!! + assertEquals(1, usage.rows.size) + assertEquals("deepseek-v4-flash", usage.rows[0].model) + assertEquals(331489076L, usage.rows[0].quotaCost) + assertTrue(!usage.rows[0].estimated) + } + + @Test + fun `returns null for non-flight input`() { + assertNull(OpenCodeGoRepository.parseModelRows("")) + assertNull(OpenCodeGoRepository.parseModelRows("no data")) + assertNull(OpenCodeGoRepository.parseModelRows(";0x00000001;{}")) + } + + @Test + fun `handles missing rows gracefully`() { + val flight = + """ + ;0x000003bc;((self.${'$'}R=self.${'$'}R||{})["server-fn:2"]=[],(${'$'}R=>${'$'}R[0]={usage:100,limit:200,usagePercent:50,rows:${'$'}R[1]=[]})(${'$'}R["server-fn:2"])) + """.trimIndent() + val usage = OpenCodeGoRepository.parseModelRows(flight)!! + assertTrue(usage.rows.isEmpty()) + assertEquals(100L, usage.usage) + assertEquals(50f, usage.usagePercent) + } + + // ─────────────── windowQuotaRaw(窗口配额,网页实测口径)─────────────── + + @Test + fun `window quota for deepseek matches web across all windows`() { + // 实测:DeepSeek multiplier=2,5h=12 亿→$6、周=30 亿→$15、月=60 亿→$30(与网页表格一致) + assertEquals(600_000_000L, OpenCodeGoRepository.windowQuotaRaw(1_200_000_000L, 2.0)) + assertEquals(1_500_000_000L, OpenCodeGoRepository.windowQuotaRaw(3_000_000_000L, 2.0)) + assertEquals(3_000_000_000L, OpenCodeGoRepository.windowQuotaRaw(6_000_000_000L, 2.0)) + } + + @Test + fun `window quota for hy3 fractional multiplier`() { + // 实测:Hy3 multiplier=0.125,月 limit=60 亿→$480(与网页表格一致) + assertEquals(48_000_000_000L, OpenCodeGoRepository.windowQuotaRaw(6_000_000_000L, 0.125)) + } + + @Test + fun `window quota falls back to 60 usd on invalid multiplier`() { + assertEquals(6_000_000_000L, OpenCodeGoRepository.windowQuotaRaw(3_000_000_000L, 0.0)) + assertEquals(6_000_000_000L, OpenCodeGoRepository.windowQuotaRaw(3_000_000_000L, -1.0)) + } + + @Test + fun `window quota with multiplier one equals window limit`() { + // Muse Spark multiplier=1:配额 = 窗口 limit 本身 + assertEquals(1_200_000_000L, OpenCodeGoRepository.windowQuotaRaw(1_200_000_000L, 1.0)) + assertEquals(6_000_000_000L, OpenCodeGoRepository.windowQuotaRaw(6_000_000_000L, 1.0)) + } } \ No newline at end of file