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.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.service.ServiceType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
@ -21,6 +26,7 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException import java.io.IOException
import java.net.URLEncoder import java.net.URLEncoder
import java.util.concurrent.TimeUnit
import javax.inject.Singleton import javax.inject.Singleton
/** /**
@ -126,6 +132,7 @@ class OpenCodeGoRepository(
} }
} }
// 主窗口数据先落缓存并返回(关键路径只依赖 HTML 解析一次请求)
val balance = ServiceBalance( val balance = ServiceBalance(
service = ServiceType.OPENCODE_GO, service = ServiceType.OPENCODE_GO,
amount = primary.usagePercent.toDouble(), amount = primary.usagePercent.toDouble(),
@ -136,11 +143,22 @@ class OpenCodeGoRepository(
nextResetAt = System.currentTimeMillis() + primary.resetInSec * 1000L, nextResetAt = System.currentTimeMillis() + primary.resetInSec * 1000L,
extras = extras extras = extras
) )
balanceCache.put(ServiceType.OPENCODE_GO, balance) balanceCache.put(ServiceType.OPENCODE_GO, balance)
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis())) 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 TAG = "OCGO"
private const val MODELS_API = "https://models.dev/api.json" private const val MODELS_API = "https://models.dev/api.json"
private const val CHAT_API = "https://opencode.ai/zen/go/v1/chat/completions" 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") private val SCRAPED_FIELDS = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
/** /**
@ -344,9 +368,173 @@ class OpenCodeGoRepository(
val usage: Long? = null, val usage: Long? = null,
val limit: 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 响应,提取回复文本和用量统计。 * 解析 OpenAI 兼容的 chat completions 响应,提取回复文本和用量统计。
*/ */

View File

@ -60,6 +60,9 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R 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.ServiceBalance
import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.domain.model.TriggerSummary
import com.rainy.token.domain.service.FetchMethod 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.components.isFiveHourLabel
import com.rainy.token.ui.theme.inkMuted import com.rainy.token.ui.theme.inkMuted
import com.rainy.token.ui.theme.StrawberryPink import com.rainy.token.ui.theme.StrawberryPink
import kotlinx.serialization.json.Json
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@ -363,6 +367,7 @@ private fun parseIsoDuration(isoStr: String): Long? {
* *
* 数据从 balance.extras["rolling.pct"] / ["weekly.pct"] / ["monthly.pct"] 取, * 数据从 balance.extras["rolling.pct"] / ["weekly.pct"] / ["monthly.pct"] 取,
* 进度条颜色按 % 自动切换:< 50% 草莓粉,50-80% 暖橙,> 80% 玫红。 * 进度条颜色按 % 自动切换:< 50% 草莓粉,50-80% 暖橙,> 80% 玫红。
* 每个窗口下方展示模型级用量(_server 接口,像 Ollama 一样按模型列出)。
*/ */
@Composable @Composable
private fun OpenCodeGoWindowsCard(state: State) { private fun OpenCodeGoWindowsCard(state: State) {
@ -392,6 +397,11 @@ private fun OpenCodeGoWindowsCard(state: State) {
pct = extras["rolling.pct"]?.toFloatOrNull(), pct = extras["rolling.pct"]?.toFloatOrNull(),
resetInSec = extras["rolling.resetInSec"]?.toLongOrNull() 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)) Spacer(modifier = Modifier.height(14.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(14.dp)) Spacer(modifier = Modifier.height(14.dp))
@ -400,6 +410,11 @@ private fun OpenCodeGoWindowsCard(state: State) {
pct = extras["weekly.pct"]?.toFloatOrNull(), pct = extras["weekly.pct"]?.toFloatOrNull(),
resetInSec = extras["weekly.resetInSec"]?.toLongOrNull() 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)) Spacer(modifier = Modifier.height(14.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(14.dp)) Spacer(modifier = Modifier.height(14.dp))
@ -408,9 +423,96 @@ private fun OpenCodeGoWindowsCard(state: State) {
pct = extras["monthly.pct"]?.toFloatOrNull(), pct = extras["monthly.pct"]?.toFloatOrNull(),
resetInSec = extras["monthly.resetInSec"]?.toLongOrNull() 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 @Composable
private fun UsageWindowRow(label: String, pct: Float?, resetInSec: Long?, decimals: Int = 0) { 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="window_usage">用量</string>
<string name="usage_window_title">用量窗口%1$s</string> <string name="usage_window_title">用量窗口%1$s</string>
<string name="usage_window_title_plain">用量窗口</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> <string name="usage_title_opencode">OpenCode 用量</string>

View File

@ -125,6 +125,8 @@
<string name="window_usage">用量</string> <string name="window_usage">用量</string>
<string name="usage_window_title">用量視窗%1$s</string> <string name="usage_window_title">用量視窗%1$s</string>
<string name="usage_window_title_plain">用量視窗</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> <string name="usage_title_opencode">OpenCode 用量</string>

View File

@ -128,6 +128,8 @@
<string name="window_usage">Usage</string> <string name="window_usage">Usage</string>
<string name="usage_window_title">Usage window%1$s</string> <string name="usage_window_title">Usage window%1$s</string>
<string name="usage_window_title_plain">Usage window</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> <string name="usage_title_opencode">OpenCode Usage</string>

View File

@ -233,4 +233,108 @@ class OpenCodeGoRepositoryTest {
assertNull(result["rollingUsage"]!!.usage) assertNull(result["rollingUsage"]!!.usage)
assertNull(result["rollingUsage"]!!.limit) 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))
}
} }