feat(ocgo): 用量窗口新增模型级用量列表(_server 接口 + 窗口配额)
This commit is contained in:
parent
2b2ef941a9
commit
75c6ce9568
@ -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 响应,提取回复文本和用量统计。
|
||||
*/
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user