feat(ocgo): 用量窗口新增模型级用量列表(_server 接口 + 窗口配额)

This commit is contained in:
WaterRain 2026-08-30 06:56:49 +00:00
parent 2b2ef941a9
commit 75c6ce9568
No known key found for this signature in database
6 changed files with 402 additions and 2 deletions

View File

@ -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<ModelUsageRow> {
val rows = mutableListOf<ModelUsageRow>()
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<String, WindowModelUsage> =
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<ModelUsageRow> = 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 响应,提取回复文本和用量统计。
*/

View File

@ -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,9 +423,96 @@ 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<WindowModelUsage>(raw)
} catch (_: Exception) {
WindowModelUsage()
}
}
/**
* 模型级用量列表(Ollama 风格):模型名 + 用量金额 + 占比 + 配额。
* 每行显示模型显示名、已用金额($XX.XX)、占窗口百分比与窗口配额(limit ÷ multiplier)。
* estimated 行(用量为估算值)在模型名后加 "~" 标识。
* windowLimit 缺失时隐藏配额列(用量与占比照常显示)。
*/
@Composable
private fun ModelUsageList(rows: List<ModelUsageRow>, 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) {

View File

@ -125,6 +125,8 @@
<string name="window_usage">用量</string>
<string name="usage_window_title">用量窗口%1$s</string>
<string name="usage_window_title_plain">用量窗口</string>
<string name="msg_model_usage">模型用量</string>
<string name="msg_model_quota">配额 $%1$s</string>
<!-- ══════════ 用量统计 ══════════ -->
<string name="usage_title_opencode">OpenCode 用量</string>

View File

@ -125,6 +125,8 @@
<string name="window_usage">用量</string>
<string name="usage_window_title">用量視窗%1$s</string>
<string name="usage_window_title_plain">用量視窗</string>
<string name="msg_model_usage">模型用量</string>
<string name="msg_model_quota">配額 $%1$s</string>
<!-- ══════════ 用量统计 ══════════ -->
<string name="usage_title_opencode">OpenCode 用量</string>

View File

@ -128,6 +128,8 @@
<string name="window_usage">Usage</string>
<string name="usage_window_title">Usage window%1$s</string>
<string name="usage_window_title_plain">Usage window</string>
<string name="msg_model_usage">Model usage</string>
<string name="msg_model_quota">Quota $%1$s</string>
<!-- ══════════ 用量统计 ══════════ -->
<string name="usage_title_opencode">OpenCode Usage</string>

View File

@ -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("<html>no data</html>"))
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))
}
}