feat(ocgo): 余额改版为 API Key 直查 /usage 端点,cookie+workspaceId 降级为选填增强
- fetchBalance 改为 GET https://opencode.ai/zen/go/v1/usage(Bearer API Key),移除 dashboard HTML 抓取与 hydration 解析(parseWindows/ScrapedWindow) - 新增 parseUsageResponse:percent 钳制 0-100,resetsAt 兼容 ISO-8601 与 epoch 秒/毫秒,倒计时可注入基准时间并 coerce >=0 - 错误映射:无 Key/401/403→凭据失效、429→限流(Retry-After)、其余 4xx/5xx→服务端异常,瞬时重试沿用 RetryHelper - auth cookie + workspaceId 仅保留给用量记录页与 _server 模型级明细增强,缺失时跳过不影响余额主数据 - 设置页表单重排 API Key 主位 + 选填分区标题;测试并保存按钮 enabled 与 ViewModel 校验四象限自洽 - 三语言文案与 README 同步;单测重写为 parseUsageResponse JSON 解析,保留 parseModelRows/windowQuotaRaw 独立 subagent 审计两轮通过(阻断项与按钮自洽性已修复);testDebugUnitTest 160 测试 0 失败
This commit is contained in:
parent
67cb816ec7
commit
d205ff2ea4
@ -72,7 +72,7 @@ RainyToken (雨晴Token) — AI Balance & Usage Quota Query · the Rainy Family
|
||||
|
||||
Download the latest APK from [Releases](https://github.com/CATMIAOZHI/Rainytoken/releases).
|
||||
|
||||
> ⚠️ You need a DeepSeek API Key, OpenCode Go login credentials, CommandCode API Key, Codex (OAuth sign-in or pasted auth.json), or an Ollama Cookie to fetch data.
|
||||
> ⚠️ You need a DeepSeek API Key, an OpenCode Go API Key, a CommandCode API Key, Codex (OAuth sign-in or pasted auth.json), or an Ollama Cookie to fetch data. The OpenCode Go auth cookie and workspaceId are optional — only for usage records and the per-model breakdown.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -72,7 +72,7 @@ RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Famil
|
||||
|
||||
前往 [Releases](https://github.com/CATMIAOZHI/Rainytoken/releases) 下载最新 APK。
|
||||
|
||||
> ⚠️ 需要配置 DeepSeek API Key、OpenCode Go 登录凭据、CommandCode API Key、Codex(OAuth 登录或粘贴 auth.json)或 Ollama Cookie 才能拉取数据。
|
||||
> ⚠️ 需要配置 DeepSeek API Key、OpenCode Go API Key、CommandCode API Key、Codex(OAuth 登录或粘贴 auth.json)或 Ollama Cookie 才能拉取数据。OpenCode Go 的 auth cookie + workspaceId 选填,仅用于用量记录与模型级明细。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ import com.rainy.token.data.debug.DebugLog
|
||||
import com.rainy.token.domain.model.Credential
|
||||
import com.rainy.token.domain.model.ServiceBalance
|
||||
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
|
||||
@ -13,31 +12,32 @@ import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.net.URLEncoder
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.format.DateTimeParseException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* OpenCode Go 配额仓库。计划阶段 5.3(已根据 [slkiser/opencode-quota](https://github.com/slkiser/opencode-quota)
|
||||
* 调整实现):
|
||||
* OpenCode Go 配额仓库。
|
||||
*
|
||||
* - 用户在浏览器登录 https://opencode.ai/auth(GitHub / Google)
|
||||
* - 登录后访问 dashboard,URL 形如 `https://opencode.ai/workspace/{workspaceId}/go`
|
||||
* - 用户从 dashboard URL 中复制 `workspaceId` + 浏览器 DevTools 的 `auth` cookie 值
|
||||
* - 粘贴到 APP,APP 用 OkHttp 携带 Cookie 抓取该 URL
|
||||
* - 解析 HTML 中 SolidJS SSR hydration 字段 `rollingUsage` / `weeklyUsage` / `monthlyUsage`
|
||||
* 余额(5h/周/月 三档窗口)通过 **API Key** 直查官方接口:
|
||||
* `GET https://opencode.ai/zen/go/v1/usage`(Bearer 鉴权),不再抓取 dashboard HTML。
|
||||
*
|
||||
* - API Key:设置页填写(opencode.ai/settings → API Keys),查询余额必填
|
||||
* - auth cookie + workspaceId:**选填**,仅用于模型级明细增强(`_server` 接口)
|
||||
* 与 [OpenCodeUsageRepository] 用量记录页;缺失时余额仍正常返回
|
||||
*
|
||||
* 不在类上加 @Inject constructor —— 在 [com.rainy.token.di.NetworkModule] 里 @Provides 显式提供。
|
||||
* 规避 KSP 2.x 多文件 @Inject 跨依赖的"could not be resolved"误报。
|
||||
@ -50,7 +50,12 @@ class OpenCodeGoRepository(
|
||||
) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* 拉取余额:`GET /zen/go/v1/usage`(Bearer API Key)。
|
||||
*
|
||||
* 响应形如 `{"usage":{"rolling":{"percent":42,"resetsAt":"…"},"weekly":{…},"monthly":{…}}}`。
|
||||
* auth cookie + workspaceId 仅作模型级明细增强,缺失不影响余额主数据。
|
||||
*/
|
||||
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
||||
val credential = credentialRepository.get(ServiceType.OPENCODE_GO)
|
||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
@ -58,20 +63,15 @@ class OpenCodeGoRepository(
|
||||
if (credential !is Credential.SessionCredential) {
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
}
|
||||
|
||||
val authCookie = credential.authCookie
|
||||
val workspaceId = credential.workspaceId
|
||||
if (authCookie.isNullOrBlank() || workspaceId.isNullOrBlank()) {
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
val apiKey = credential.apiKey?.trim()
|
||||
if (apiKey.isNullOrEmpty()) {
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential("未配置 API Key"))
|
||||
}
|
||||
|
||||
val url = "https://opencode.ai/workspace/${URLEncoder.encode(workspaceId, "UTF-8")}/go"
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/148.0")
|
||||
.header("Accept", "text/html")
|
||||
.header("Cookie", "auth=$authCookie")
|
||||
.url(USAGE_API)
|
||||
.header("Accept", "application/json")
|
||||
.header("Authorization", "Bearer $apiKey")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
@ -85,54 +85,48 @@ class OpenCodeGoRepository(
|
||||
|
||||
response.use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
if (resp.code == 401 || resp.code == 403) {
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
DebugLog.e(TAG, "fetchBalance: HTTP ${resp.code}")
|
||||
return@withContext when (resp.code) {
|
||||
401, 403 -> Result.failure(RepositoryError.InvalidCredential("HTTP ${resp.code}"))
|
||||
429 -> Result.failure(RepositoryError.RateLimited(resp.header("Retry-After")?.toLongOrNull()))
|
||||
else -> Result.failure(RepositoryError.ServerError(resp.code))
|
||||
}
|
||||
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
|
||||
}
|
||||
|
||||
val html = resp.body?.string() ?: return@withContext Result.failure(
|
||||
val body = resp.body?.string() ?: return@withContext Result.failure(
|
||||
RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
|
||||
)
|
||||
|
||||
val windows = parseWindows(html)
|
||||
val now = System.currentTimeMillis()
|
||||
val windows = parseUsageResponse(body, now)
|
||||
if (windows.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
RepositoryError.ParseError(
|
||||
RepositoryError.ParseErrorReason.NO_WINDOWS,
|
||||
"解析失败:未找到任何 OpenCode Go 配额窗口。HTML=${html.length} 字节。"
|
||||
"解析失败:未找到任何 OpenCode Go 配额窗口。body=${body.length} 字节。"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 主体数据用 rollingUsage(5h 滚动窗口),这是用户最关心的"实时配额"
|
||||
val primary = windows["rollingUsage"] ?: windows.values.first()
|
||||
val config = ServiceConfigProvider.get(ServiceType.OPENCODE_GO)
|
||||
|
||||
// 把 3 个窗口的用量百分比 + 重置时间全部塞进 extras(详情页按窗口渲染)
|
||||
// usage/limit 为页面新增的用量与限额字段(单位以服务端定义为准,保留供详情页展示,UI 兼容缺失场景)
|
||||
// 把 3 个窗口的用量百分比 + 重置倒计时塞进 extras(详情页/小组件按窗口渲染)
|
||||
val extras = buildMap {
|
||||
windows["rollingUsage"]?.let { w ->
|
||||
put("rolling.pct", w.usagePercent.toString())
|
||||
put("rolling.resetInSec", w.resetInSec.toString())
|
||||
w.usage?.let { put("rolling.usage", it.toString()) }
|
||||
w.limit?.let { put("rolling.limit", it.toString()) }
|
||||
w.resetInSec?.let { put("rolling.resetInSec", it.toString()) }
|
||||
}
|
||||
windows["weeklyUsage"]?.let { w ->
|
||||
put("weekly.pct", w.usagePercent.toString())
|
||||
put("weekly.resetInSec", w.resetInSec.toString())
|
||||
w.usage?.let { put("weekly.usage", it.toString()) }
|
||||
w.limit?.let { put("weekly.limit", it.toString()) }
|
||||
w.resetInSec?.let { put("weekly.resetInSec", it.toString()) }
|
||||
}
|
||||
windows["monthlyUsage"]?.let { w ->
|
||||
put("monthly.pct", w.usagePercent.toString())
|
||||
put("monthly.resetInSec", w.resetInSec.toString())
|
||||
w.usage?.let { put("monthly.usage", it.toString()) }
|
||||
w.limit?.let { put("monthly.limit", it.toString()) }
|
||||
w.resetInSec?.let { put("monthly.resetInSec", it.toString()) }
|
||||
}
|
||||
}
|
||||
|
||||
// 主窗口数据先落缓存并返回(关键路径只依赖 HTML 解析一次请求)
|
||||
val balance = ServiceBalance(
|
||||
service = ServiceType.OPENCODE_GO,
|
||||
amount = primary.usagePercent.toDouble(),
|
||||
@ -140,13 +134,18 @@ class OpenCodeGoRepository(
|
||||
isAvailable = true,
|
||||
monthlySpent = windows["monthlyUsage"]?.usagePercent?.toDouble(),
|
||||
totalQuota = null,
|
||||
nextResetAt = System.currentTimeMillis() + primary.resetInSec * 1000L,
|
||||
nextResetAt = primary.resetAtMillis,
|
||||
extras = extras
|
||||
)
|
||||
balanceCache.put(ServiceType.OPENCODE_GO, balance)
|
||||
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()))
|
||||
|
||||
// 模型级用量作为增量增强:并行拉取 + 短超时,失败/超时不影响主窗口数据与缓存
|
||||
// 模型级用量作为增量增强:需要 auth cookie + workspaceId;缺失或失败不影响主数据
|
||||
val workspaceId = credential.workspaceId
|
||||
val authCookie = credential.authCookie
|
||||
if (workspaceId.isNullOrBlank() || authCookie.isNullOrBlank()) {
|
||||
return@withContext Result.success(balance)
|
||||
}
|
||||
val modelUsage = fetchModelWindows(workspaceId, authCookie)
|
||||
if (modelUsage.isEmpty()) {
|
||||
return@withContext Result.success(balance)
|
||||
@ -253,53 +252,68 @@ 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"
|
||||
/** 余额/三档窗口用量查询(Bearer API Key) */
|
||||
private const val USAGE_API = "https://opencode.ai/zen/go/v1/usage"
|
||||
/** _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")
|
||||
|
||||
/**
|
||||
* 解析 SolidJS SSR hydration 输出。匹配模式形如:
|
||||
* `rollingUsage:$R[0]={usagePercent:42,resetInSec:12345}`
|
||||
* 解析 `GET /zen/go/v1/usage` JSON 响应为三档窗口。
|
||||
*
|
||||
* 用精确前缀 "field:$R[" 定位 hydration 数据中的字段声明,
|
||||
* 避免命中 HTML 其他位置(如 JS 代码注释、模板字符串)的同名文本。
|
||||
* 用括号计数匹配闭合 "}",正确处理嵌套对象。
|
||||
* - `percent` 缺失或非数字 → 跳过该窗口(不猜测数值);有值则钳制到 0..100
|
||||
* - `resetsAt` 支持 ISO-8601 与 epoch(秒/毫秒自动识别);缺失时窗口仍保留(倒计时为 null)
|
||||
* - 非 JSON / 缺 `usage` 节点 → 空 map(调用方映射 NO_WINDOWS 错误)
|
||||
*
|
||||
* @param nowMillis 计算重置倒计时的基准时间(测试可注入)
|
||||
*/
|
||||
internal fun parseWindows(html: String): Map<String, ScrapedWindow> {
|
||||
val result = mutableMapOf<String, ScrapedWindow>()
|
||||
val fields = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
|
||||
internal fun parseUsageResponse(body: String, nowMillis: Long): Map<String, UsageWindow> {
|
||||
val root = try {
|
||||
Json.parseToJsonElement(body) as? JsonObject
|
||||
} catch (_: SerializationException) {
|
||||
null
|
||||
} ?: return emptyMap()
|
||||
val usage = root["usage"] as? JsonObject ?: return emptyMap()
|
||||
|
||||
for (field in fields) {
|
||||
// 精确模式:找 "field:$R[N]={" —— 这是 hydration 数据块的独有格式
|
||||
val keyIdx = html.indexOf("$field:\$R[")
|
||||
if (keyIdx < 0) continue
|
||||
val result = mutableMapOf<String, UsageWindow>()
|
||||
for ((apiField, usageKey) in listOf(
|
||||
"rolling" to "rollingUsage",
|
||||
"weekly" to "weeklyUsage",
|
||||
"monthly" to "monthlyUsage"
|
||||
)) {
|
||||
val node = usage[apiField] as? JsonObject ?: continue
|
||||
val percent = (node["percent"] as? JsonPrimitive)?.content?.toDoubleOrNull()
|
||||
if (percent == null || !percent.isFinite()) continue
|
||||
val resetAt = parseResetEpochMillis((node["resetsAt"] as? JsonPrimitive)?.content)
|
||||
result[usageKey] = UsageWindow(
|
||||
usagePercent = percent.coerceIn(0.0, 100.0).toFloat(),
|
||||
resetInSec = resetAt?.let { ((it - nowMillis) / 1000).coerceAtLeast(0L) },
|
||||
resetAtMillis = resetAt
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 找 '=' 然后找 '{'
|
||||
val eqIdx = html.indexOf('=', keyIdx)
|
||||
if (eqIdx < 0 || eqIdx - keyIdx > 60) continue
|
||||
val braceStart = html.indexOf('{', eqIdx)
|
||||
if (braceStart < 0 || braceStart - eqIdx > 10) continue
|
||||
|
||||
// 括号计数找正确的闭合 "}"(处理嵌套对象)
|
||||
val braceEnd = findMatchingBrace(html, braceStart) ?: continue
|
||||
val body = html.substring(braceStart, braceEnd + 1)
|
||||
|
||||
val pct = extractNumberAfterKey(body, "usagePercent")?.toFloatOrNull()
|
||||
val reset = extractNumberAfterKey(body, "resetInSec")?.toLongOrNull()
|
||||
// usage/limit 是页面新增的用量与限额字段(单位以服务端定义为准,缺失不影响窗口识别)
|
||||
// exactKey=true:避免 "usage" 误命中 "usagePercent" 前缀
|
||||
val usage = extractNumberAfterKey(body, "usage", exactKey = true)?.toLongOrNull()
|
||||
val limit = extractNumberAfterKey(body, "limit", exactKey = true)?.toLongOrNull()
|
||||
if (pct != null && reset != null) {
|
||||
result[field] = ScrapedWindow(pct, reset, usage, limit)
|
||||
/** `resetsAt` → epoch millis:数字(秒/毫秒)或 ISO-8601;无法解析返回 null。 */
|
||||
private fun parseResetEpochMillis(raw: String?): Long? {
|
||||
if (raw.isNullOrBlank()) return null
|
||||
val trimmed = raw.trim()
|
||||
val numeric = trimmed.toDoubleOrNull()
|
||||
if (numeric != null && numeric.isFinite() && numeric > 0) {
|
||||
return if (numeric >= 10_000_000_000.0) numeric.toLong() else (numeric * 1000).toLong()
|
||||
}
|
||||
return try {
|
||||
Instant.parse(trimmed).toEpochMilli()
|
||||
} catch (_: DateTimeParseException) {
|
||||
try {
|
||||
OffsetDateTime.parse(trimmed).toInstant().toEpochMilli()
|
||||
} catch (_: DateTimeParseException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
@ -458,6 +472,8 @@ class OpenCodeGoRepository(
|
||||
* 拉取三个窗口(rolling/weekly/monthly)的模型级用量。
|
||||
* 并行发起 + 每个请求独立短超时(MODEL_REQUEST_TIMEOUT_MS),
|
||||
* 任一窗口失败不影响其他窗口;整体失败返回空 Map(主窗口数据不受影响)。
|
||||
*
|
||||
* 依赖 auth cookie + workspaceId(选填凭据);仅在两者齐备时由 [fetchBalance] 调用。
|
||||
*/
|
||||
private suspend fun fetchModelWindows(workspaceId: String, authCookie: String): Map<String, WindowModelUsage> =
|
||||
coroutineScope {
|
||||
@ -511,6 +527,18 @@ class OpenCodeGoRepository(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个用量窗口(`GET /zen/go/v1/usage` 解析结果)。
|
||||
* @param usagePercent 已用百分比(0..100)
|
||||
* @param resetInSec 距离重置的倒计时秒数(相对 nowMillis,已钳制 ≥0;null = 接口未返回重置时间)
|
||||
* @param resetAtMillis 重置时刻(epoch millis;null = 接口未返回)
|
||||
*/
|
||||
internal data class UsageWindow(
|
||||
val usagePercent: Float,
|
||||
val resetInSec: Long? = null,
|
||||
val resetAtMillis: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 窗口模型级用量(_server 接口 payload)。
|
||||
* cost/quotaCost 单位为 1e-8 美元;multiplier 为计费倍率(quotaCost = cost × multiplier)。
|
||||
|
||||
@ -37,13 +37,13 @@ sealed class Credential {
|
||||
val token: String? = null,
|
||||
/** Token 过期时间(epoch millis),null 表示未知。 */
|
||||
val expiresAt: Long? = null,
|
||||
/** OpenCode Go 专用:dashboard auth cookie 值(简化为单字段,便于用户粘贴) */
|
||||
/** OpenCode Go(选填):dashboard auth cookie 值,仅用于用量记录与模型级明细(简化为单字段,便于用户粘贴) */
|
||||
val authCookie: String? = null,
|
||||
/** OpenCode Go 专用:workspace ID */
|
||||
/** OpenCode Go(选填):workspace ID,仅用于用量记录与模型级明细 */
|
||||
val workspaceId: String? = null,
|
||||
/** Ollama 专用:完整的 Cookie 字符串(用户从浏览器 DevTools 复制) */
|
||||
val ollamaCookie: String? = null,
|
||||
/** OpenCode Go / Ollama 专用:API Key,用于一键激活用量 */
|
||||
/** OpenCode Go 主凭据 / Ollama:API Key(OpenCode Go 用它查余额 + 一键激活;Ollama 用于一键激活) */
|
||||
val apiKey: String? = null,
|
||||
override val lastVerifiedAt: Long = 0L
|
||||
) : Credential()
|
||||
|
||||
@ -428,6 +428,40 @@ private fun OpenCodeGoForm(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
// ── API Key(查余额,必填)──
|
||||
Text(
|
||||
text = stringResource(R.string.opencode_api_key_title),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.hint_opencode_trigger_key),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = triggerApiKey,
|
||||
onValueChange = onApiKeyChange,
|
||||
label = { Text(stringResource(R.string.field_api_key)) },
|
||||
placeholder = { Text("opencode-xxx") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = onSaveApiKey,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = triggerApiKey.isNotBlank()
|
||||
) {
|
||||
Text(stringResource(R.string.action_save_api_key))
|
||||
}
|
||||
|
||||
// ── 用量记录 + 模型级明细(选填):auth cookie + workspaceId ──
|
||||
androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.opencode_session_optional_title),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
OutlinedButton(onClick = onImportFromClipboard, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = stringResource(R.string.action_import_from_clipboard))
|
||||
}
|
||||
@ -459,7 +493,7 @@ private fun OpenCodeGoForm(
|
||||
OutlinedButton(
|
||||
onClick = onTestAndSave,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = authCookie.isNotBlank() && workspaceId.isNotBlank()
|
||||
enabled = triggerApiKey.isNotBlank() && (authCookie.isBlank() == workspaceId.isBlank())
|
||||
) {
|
||||
Text(text = stringResource(R.string.action_test_and_save))
|
||||
}
|
||||
@ -475,35 +509,6 @@ private fun OpenCodeGoForm(
|
||||
TextButton(onClick = onCopyLoginUrl, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = stringResource(R.string.action_copy_login_url))
|
||||
}
|
||||
|
||||
// ── 一键激活用量 API Key ──
|
||||
androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.activate_api_key_optional),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.hint_opencode_trigger_key),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = triggerApiKey,
|
||||
onValueChange = onApiKeyChange,
|
||||
label = { Text(stringResource(R.string.field_api_key)) },
|
||||
placeholder = { Text("opencode-xxx") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = onSaveApiKey,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = triggerApiKey.isNotBlank()
|
||||
) {
|
||||
Text(stringResource(R.string.action_save_api_key))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@ -270,15 +270,22 @@ class CredentialEditViewModel @Inject constructor(
|
||||
fun testAndSaveOpenCodeGo() {
|
||||
val type = serviceType ?: return
|
||||
val current = _uiState.value
|
||||
if (current.authCookie.isBlank() || current.workspaceId.isBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(message = UiText.Resource(R.string.error_auth_cookie_workspace))
|
||||
}
|
||||
// 余额只认 API Key:必填;cookie 对可选,但只填一半视为填写错误
|
||||
if (current.triggerApiKey.isBlank()) {
|
||||
_uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) }
|
||||
return
|
||||
}
|
||||
if (current.authCookie.isBlank() != current.workspaceId.isBlank()) {
|
||||
_uiState.update { it.copy(message = UiText.Resource(R.string.error_auth_cookie_workspace)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val previous = credentialRepository.get(type)
|
||||
doSaveOpenCodeGo(current.workspaceId.trim(), current.authCookie.trim())
|
||||
doSaveOpenCodeGo(
|
||||
workspaceId = current.workspaceId.trim().ifBlank { null },
|
||||
authCookie = current.authCookie.trim().ifBlank { null },
|
||||
apiKey = current.triggerApiKey.trim()
|
||||
)
|
||||
testAndRollback(
|
||||
type = type,
|
||||
saveAndPrep = { previous to { refreshBalanceUseCaseProvider.get().invoke(type) } },
|
||||
@ -288,15 +295,20 @@ class CredentialEditViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doSaveOpenCodeGo(workspaceId: String, authCookie: String) {
|
||||
/**
|
||||
* 保存 OpenCode Go 凭据。参数为 `null` 表示该项不修改(保留已存值),
|
||||
* 便于"仅更新 API Key"时不冲掉用户已配置的 cookie 对。
|
||||
*/
|
||||
private suspend fun doSaveOpenCodeGo(workspaceId: String?, authCookie: String?, apiKey: String? = null) {
|
||||
val type = serviceType ?: return
|
||||
val existing = credentialRepository.get(type) as? Credential.SessionCredential
|
||||
val updated = (existing ?: Credential.SessionCredential(
|
||||
service = type,
|
||||
cookies = emptyList()
|
||||
)).copy(
|
||||
authCookie = authCookie,
|
||||
workspaceId = workspaceId,
|
||||
authCookie = authCookie ?: existing?.authCookie,
|
||||
workspaceId = workspaceId ?: existing?.workspaceId,
|
||||
apiKey = apiKey ?: existing?.apiKey,
|
||||
lastVerifiedAt = System.currentTimeMillis()
|
||||
)
|
||||
credentialRepository.save(updated)
|
||||
|
||||
@ -199,7 +199,7 @@
|
||||
<string name="help_cookie_3">3. 登录后按 F12 → Application → Cookies</string>
|
||||
<string name="help_cookie_4">4. 复制该站点下的所有 Cookie 粘贴到上面输入框</string>
|
||||
<string name="help_cookie_5">格式:name1=value1; name2=value2</string>
|
||||
<string name="help_ocgo_title">如何获取 OpenCode Go 凭据?</string>
|
||||
<string name="help_ocgo_title">如何获取 auth cookie 与 workspaceId?</string>
|
||||
<string name="help_ocgo_1">1. 桌面 Chrome 打开 opencode.ai/auth</string>
|
||||
<string name="help_ocgo_2">2. 用 GitHub / Google 登录(Google 不会拦截 Chrome)</string>
|
||||
<string name="help_ocgo_3">3. 登录后跳转到 dashboard,URL 形如:</string>
|
||||
@ -400,14 +400,16 @@
|
||||
<string name="placeholder_ollama_api_key">ollama-xxx 或 sk-xxx</string>
|
||||
<string name="action_save_api_key">保存 API Key</string>
|
||||
<string name="credential_title_opencode_go">OpenCode Go 凭据</string>
|
||||
<string name="credential_hint_opencode_go">需要 auth cookie + workspaceId 两个值。APP 会用 OkHttp 抓 dashboard 并自动解析 5h/周/月 三档用量。</string>
|
||||
<string name="credential_hint_opencode_go">余额与 5h/周/月 三档用量通过 API Key 查询(opencode.ai/settings → API Keys 获取)。下方 auth cookie + workspaceId 为选填,仅用于用量记录页与模型级明细。</string>
|
||||
<string name="action_import_from_clipboard">从剪贴板自动导入</string>
|
||||
<string name="placeholder_workspace_id">dashboard URL 中的 {id} 段</string>
|
||||
<string name="field_auth_cookie_value">auth Cookie 值</string>
|
||||
<string name="placeholder_auth_cookie">F12 → Cookies → opencode.ai → auth 的 Value</string>
|
||||
<string name="action_how_get_two_values">如何获取这两个值?</string>
|
||||
<string name="action_open_login_entry">在浏览器中打开登录入口</string>
|
||||
<string name="hint_opencode_trigger_key">填入 OpenCode Zen API Key 后,可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。</string>
|
||||
<string name="hint_opencode_trigger_key">API Key 用于查询余额,也可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。</string>
|
||||
<string name="opencode_api_key_title">API Key(查询余额)</string>
|
||||
<string name="opencode_session_optional_title">用量记录与模型级明细(选填)</string>
|
||||
<string name="credential_title_ccgo">CommandCode 凭据</string>
|
||||
<string name="credential_hint_ccgo">需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。</string>
|
||||
<string name="placeholder_api_key_from_settings">从 Settings → API Keys 生成</string>
|
||||
|
||||
@ -199,7 +199,7 @@
|
||||
<string name="help_cookie_3">3. 登入後按 F12 → Application → Cookies</string>
|
||||
<string name="help_cookie_4">4. 複製該站點下的所有 Cookie 貼上到上面輸入框</string>
|
||||
<string name="help_cookie_5">格式:name1=value1; name2=value2</string>
|
||||
<string name="help_ocgo_title">如何獲取 OpenCode Go 憑據?</string>
|
||||
<string name="help_ocgo_title">如何獲取 auth cookie 與 workspaceId?</string>
|
||||
<string name="help_ocgo_1">1. 桌面 Chrome 開啟 opencode.ai/auth</string>
|
||||
<string name="help_ocgo_2">2. 用 GitHub / Google 登入(Google 不會攔截 Chrome)</string>
|
||||
<string name="help_ocgo_3">3. 登入後跳轉到 dashboard,URL 形如:</string>
|
||||
@ -399,14 +399,16 @@
|
||||
<string name="placeholder_ollama_api_key">ollama-xxx 或 sk-xxx</string>
|
||||
<string name="action_save_api_key">儲存 API Key</string>
|
||||
<string name="credential_title_opencode_go">OpenCode Go 憑據</string>
|
||||
<string name="credential_hint_opencode_go">需要 auth cookie + workspaceId 兩個值。APP 會用 OkHttp 抓 dashboard 並自動解析 5h/周/月 三檔用量。</string>
|
||||
<string name="credential_hint_opencode_go">餘額與 5h/週/月 三檔用量透過 API Key 查詢(opencode.ai/settings → API Keys 獲取)。下方 auth cookie + workspaceId 為選填,僅用於用量記錄頁與模型級明細。</string>
|
||||
<string name="action_import_from_clipboard">從剪貼簿自動匯入</string>
|
||||
<string name="placeholder_workspace_id">dashboard URL 中的 {id} 段</string>
|
||||
<string name="field_auth_cookie_value">auth Cookie 值</string>
|
||||
<string name="placeholder_auth_cookie">F12 → Cookies → opencode.ai → auth 的 Value</string>
|
||||
<string name="action_how_get_two_values">如何獲取這兩個值?</string>
|
||||
<string name="action_open_login_entry">在瀏覽器中開啟登入入口</string>
|
||||
<string name="hint_opencode_trigger_key">填入 OpenCode Zen API Key 後,可在詳情頁一鍵傳送請求啟用用量統計。從 opencode.ai/settings → API Keys 獲取。</string>
|
||||
<string name="hint_opencode_trigger_key">API Key 用於查詢餘額,也可在詳情頁一鍵傳送請求啟用用量統計。從 opencode.ai/settings → API Keys 獲取。</string>
|
||||
<string name="opencode_api_key_title">API Key(查詢餘額)</string>
|
||||
<string name="opencode_session_optional_title">用量記錄與模型級明細(選填)</string>
|
||||
<string name="credential_title_ccgo">CommandCode 憑據</string>
|
||||
<string name="credential_hint_ccgo">需要 API Key(拉餘額)。Session Cookie(可選)在瀏覽器 DevTools 複製,用於拉用量記錄。不填也可正常檢視餘額。</string>
|
||||
<string name="placeholder_api_key_from_settings">從 Settings → API Keys 生成</string>
|
||||
|
||||
@ -202,7 +202,7 @@
|
||||
<string name="help_cookie_3">3. After login, press F12 → Application → Cookies</string>
|
||||
<string name="help_cookie_4">4. Copy all cookies of the site and paste them above</string>
|
||||
<string name="help_cookie_5">Format: name1=value1; name2=value2</string>
|
||||
<string name="help_ocgo_title">How to get OpenCode Go credentials?</string>
|
||||
<string name="help_ocgo_title">How to get the auth cookie and workspaceId?</string>
|
||||
<string name="help_ocgo_1">1. Open opencode.ai/auth in desktop Chrome</string>
|
||||
<string name="help_ocgo_2">2. Sign in with GitHub / Google (Google won\'t block Chrome)</string>
|
||||
<string name="help_ocgo_3">3. You\'ll land on the dashboard, URL looks like:</string>
|
||||
@ -403,14 +403,16 @@
|
||||
<string name="placeholder_ollama_api_key">ollama-xxx or sk-xxx</string>
|
||||
<string name="action_save_api_key">Save API Key</string>
|
||||
<string name="credential_title_opencode_go">OpenCode Go credentials</string>
|
||||
<string name="credential_hint_opencode_go">Needs both auth cookie and workspaceId. The app fetches the dashboard via OkHttp and parses 5h/week/month usage.</string>
|
||||
<string name="credential_hint_opencode_go">Balance and 5h/week/month usage are fetched with an API Key (get it at opencode.ai/settings → API Keys). The auth cookie and workspaceId below are optional — only needed for usage records and the per-model breakdown.</string>
|
||||
<string name="action_import_from_clipboard">Auto-import from clipboard</string>
|
||||
<string name="placeholder_workspace_id">The {id} segment of the dashboard URL</string>
|
||||
<string name="field_auth_cookie_value">auth Cookie value</string>
|
||||
<string name="placeholder_auth_cookie">F12 → Cookies → opencode.ai → Value of auth</string>
|
||||
<string name="action_how_get_two_values">How to get these two?</string>
|
||||
<string name="action_open_login_entry">Open login page in browser</string>
|
||||
<string name="hint_opencode_trigger_key">With an OpenCode Zen API Key, the details page can send one request to activate usage stats. Get it at opencode.ai/settings → API Keys.</string>
|
||||
<string name="hint_opencode_trigger_key">The API Key fetches your balance; the details page can also send one request to activate usage stats. Get it at opencode.ai/settings → API Keys.</string>
|
||||
<string name="opencode_api_key_title">API Key (fetches balance)</string>
|
||||
<string name="opencode_session_optional_title">Usage records and per-model breakdown (optional)</string>
|
||||
<string name="credential_title_ccgo">CommandCode credentials</string>
|
||||
<string name="credential_hint_ccgo">Needs an API Key (for balance). Session Cookie (optional) for usage records, copied from browser DevTools. Balance works without it.</string>
|
||||
<string name="placeholder_api_key_from_settings">Generate at Settings → API Keys</string>
|
||||
|
||||
@ -5,233 +5,127 @@ import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Unit tests for [OpenCodeGoRepository.parseWindows].
|
||||
* Unit tests for [OpenCodeGoRepository.parseUsageResponse] / [OpenCodeGoRepository.parseModelRows] /
|
||||
* [OpenCodeGoRepository.windowQuotaRaw].
|
||||
*
|
||||
* Uses synthetic HTML snippets that mimic the SolidJS SSR hydration data format.
|
||||
* All tests are pure JVM — no Android framework, no network.
|
||||
*/
|
||||
class OpenCodeGoRepositoryTest {
|
||||
|
||||
/**
|
||||
* Build a minimal hydration HTML with the given window data.
|
||||
*
|
||||
* The real format looks like:
|
||||
* rollingUsage:$R[0]={status:"ok",usagePercent:42,resetInSec:12345}
|
||||
*
|
||||
* @param usage 可选:页面新增的用量字段(usage)
|
||||
* @param limit 可选:页面新增的限额字段(limit)
|
||||
*/
|
||||
private fun buildHtml(
|
||||
rolling: Pair<Int, Long>? = Pair(42, 3600),
|
||||
weekly: Pair<Int, Long>? = Pair(15, 86400),
|
||||
monthly: Pair<Int, Long>? = Pair(80, 2592000),
|
||||
usage: Long? = null,
|
||||
limit: Long? = null
|
||||
/** 固定"现在",保证倒计时断言稳定。 */
|
||||
private val now = 1_700_000_000_000L
|
||||
|
||||
/** 构造 /zen/go/v1/usage 响应;参数为 null 时该窗口不出现。 */
|
||||
private fun usageJson(
|
||||
rolling: String? = """{"status":"ok","percent":42,"resetsAt":"${Instant.ofEpochMilli(now + 3_600_000)}"}""",
|
||||
weekly: String? = """{"status":"ok","percent":15,"resetsAt":"${Instant.ofEpochMilli(now + 86_400_000)}"}""",
|
||||
monthly: String? = """{"status":"ok","percent":80,"resetsAt":"${Instant.ofEpochMilli(now + 2_592_000_000)}"}"""
|
||||
): String {
|
||||
fun windowJs(name: String, data: Pair<Int, Long>?): String {
|
||||
if (data == null) return ""
|
||||
val extra = if (usage != null && limit != null) ",usage:$usage,limit:$limit" else ""
|
||||
return """$name:${'$'}R[0]={status:"ok",usagePercent:${data.first},resetInSec:${data.second}$extra}"""
|
||||
}
|
||||
|
||||
return """
|
||||
<html>
|
||||
<body>
|
||||
<script>
|
||||
${windowJs("rollingUsage", rolling)}
|
||||
${windowJs("weeklyUsage", weekly)}
|
||||
${windowJs("monthlyUsage", monthly)}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
val parts = listOfNotNull(
|
||||
rolling?.let { """"rolling":$it""" },
|
||||
weekly?.let { """"weekly":$it""" },
|
||||
monthly?.let { """"monthly":$it""" }
|
||||
)
|
||||
return """{"usage":{${parts.joinToString(",")}}}"""
|
||||
}
|
||||
|
||||
// ─────────────── parseUsageResponse(余额 API JSON)───────────────
|
||||
|
||||
@Test
|
||||
fun `parses all three windows correctly`() {
|
||||
val html = buildHtml(
|
||||
rolling = Pair(42, 3600),
|
||||
weekly = Pair(15, 86400),
|
||||
monthly = Pair(80, 2592000)
|
||||
)
|
||||
val result = OpenCodeGoRepository.parseWindows(html)
|
||||
fun `parses all three windows from usage api`() {
|
||||
val result = OpenCodeGoRepository.parseUsageResponse(usageJson(), now)
|
||||
|
||||
assertEquals(3, result.size)
|
||||
assertNotNull(result["rollingUsage"])
|
||||
assertEquals(42f, result["rollingUsage"]!!.usagePercent)
|
||||
assertEquals(3600L, result["rollingUsage"]!!.resetInSec)
|
||||
|
||||
assertNotNull(result["weeklyUsage"])
|
||||
assertEquals(15f, result["weeklyUsage"]!!.usagePercent)
|
||||
assertEquals(86400L, result["weeklyUsage"]!!.resetInSec)
|
||||
|
||||
assertNotNull(result["monthlyUsage"])
|
||||
assertEquals(80f, result["monthlyUsage"]!!.usagePercent)
|
||||
assertEquals(2592000L, result["monthlyUsage"]!!.resetInSec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses rolling usage only`() {
|
||||
val html = buildHtml(weekly = null, monthly = null)
|
||||
val result = OpenCodeGoRepository.parseWindows(html)
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertNotNull(result["rollingUsage"])
|
||||
assertEquals(42f, result["rollingUsage"]!!.usagePercent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns empty map for HTML without hydration data`() {
|
||||
val html = "<html><body><h1>Welcome</h1></body></html>"
|
||||
val result = OpenCodeGoRepository.parseWindows(html)
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns empty map for empty string`() {
|
||||
val result = OpenCodeGoRepository.parseWindows("")
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles nested objects in hydration data`() {
|
||||
val html = """
|
||||
<html><body><script>
|
||||
rollingUsage:${'$'}R[0]={status:"ok",metadata:{version:2},usagePercent:42,resetInSec:3600}
|
||||
</script></body></html>
|
||||
""".trimIndent()
|
||||
val result = OpenCodeGoRepository.parseWindows(html)
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertNotNull(result["rollingUsage"])
|
||||
assertEquals(42f, result["rollingUsage"]!!.usagePercent)
|
||||
assertEquals(3600L, result["rollingUsage"]!!.resetInSec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips window when usagePercent is missing`() {
|
||||
val html = """
|
||||
<html><body><script>
|
||||
rollingUsage:${'$'}R[0]={status:"ok",resetInSec:3600}
|
||||
</script></body></html>
|
||||
""".trimIndent()
|
||||
val result = OpenCodeGoRepository.parseWindows(html)
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips window when resetInSec is missing`() {
|
||||
val html = """
|
||||
<html><body><script>
|
||||
rollingUsage:${'$'}R[0]={status:"ok",usagePercent:42}
|
||||
</script></body></html>
|
||||
""".trimIndent()
|
||||
val result = OpenCodeGoRepository.parseWindows(html)
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles zero percent and reset`() {
|
||||
val html = buildHtml(
|
||||
rolling = Pair(0, 0),
|
||||
weekly = null,
|
||||
monthly = null
|
||||
)
|
||||
val result = OpenCodeGoRepository.parseWindows(html)
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(0f, result["rollingUsage"]!!.usagePercent)
|
||||
assertEquals(0L, result["rollingUsage"]!!.resetInSec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles large reset values`() {
|
||||
val html = buildHtml(
|
||||
rolling = Pair(99, 9999999999L),
|
||||
weekly = null,
|
||||
monthly = null
|
||||
)
|
||||
val result = OpenCodeGoRepository.parseWindows(html)
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(99f, result["rollingUsage"]!!.usagePercent)
|
||||
assertEquals(9999999999L, result["rollingUsage"]!!.resetInSec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not match field without dollar R prefix`() {
|
||||
val html = """
|
||||
<html><body><script>
|
||||
rollingUsage:{usagePercent:42,resetInSec:3600}
|
||||
</script></body></html>
|
||||
""".trimIndent()
|
||||
val result = OpenCodeGoRepository.parseWindows(html)
|
||||
// Should not match because the prefix "rollingUsage:$R[" is missing
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses new page format with decimal percent and usage fields`() {
|
||||
// 新页面(2026-08)实测格式:usagePercent 为小数,并新增 usage/limit 字段
|
||||
val html = """
|
||||
<html><body><script>
|
||||
rollingUsage:${'$'}R[37]={status:"ok",resetInSec:1406,usagePercent:1.1,usage:13314666,limit:1200000000}
|
||||
weeklyUsage:${'$'}R[38]={status:"ok",resetInSec:339417,usagePercent:2.5,usage:74497156,limit:3000000000}
|
||||
monthlyUsage:${'$'}R[39]={status:"ok",resetInSec:1229614,usagePercent:7.2,usage:434167747,limit:6000000000}
|
||||
</script></body></html>
|
||||
""".trimIndent()
|
||||
val result = OpenCodeGoRepository.parseWindows(html)
|
||||
|
||||
assertEquals(3, result.size)
|
||||
|
||||
val rolling = result["rollingUsage"]!!
|
||||
assertEquals(1.1f, rolling.usagePercent)
|
||||
assertEquals(1406L, rolling.resetInSec)
|
||||
assertEquals(13314666L, rolling.usage)
|
||||
assertEquals(1200000000L, rolling.limit)
|
||||
assertEquals(42f, rolling.usagePercent)
|
||||
assertEquals(3600L, rolling.resetInSec)
|
||||
assertEquals(now + 3_600_000L, rolling.resetAtMillis)
|
||||
|
||||
val weekly = result["weeklyUsage"]!!
|
||||
assertEquals(2.5f, weekly.usagePercent)
|
||||
assertEquals(339417L, weekly.resetInSec)
|
||||
assertEquals(74497156L, weekly.usage)
|
||||
assertEquals(3000000000L, weekly.limit)
|
||||
assertEquals(15f, result["weeklyUsage"]!!.usagePercent)
|
||||
assertEquals(86_400L, result["weeklyUsage"]!!.resetInSec)
|
||||
assertEquals(80f, result["monthlyUsage"]!!.usagePercent)
|
||||
assertEquals(2_592_000L, result["monthlyUsage"]!!.resetInSec)
|
||||
}
|
||||
|
||||
val monthly = result["monthlyUsage"]!!
|
||||
assertEquals(7.2f, monthly.usagePercent)
|
||||
assertEquals(1229614L, monthly.resetInSec)
|
||||
assertEquals(434167747L, monthly.usage)
|
||||
assertEquals(6000000000L, monthly.limit)
|
||||
@Test
|
||||
fun `parses rolling window only`() {
|
||||
val result = OpenCodeGoRepository.parseUsageResponse(usageJson(weekly = null, monthly = null), now)
|
||||
assertEquals(1, result.size)
|
||||
assertNotNull(result["rollingUsage"])
|
||||
assertNull(result["weeklyUsage"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps window when resetsAt is missing`() {
|
||||
// resetsAt 可选:窗口仍展示百分比,只是没有倒计时
|
||||
val json = """{"usage":{"rolling":{"status":"ok","percent":42}}}"""
|
||||
val result = OpenCodeGoRepository.parseUsageResponse(json, now)
|
||||
assertEquals(1, result.size)
|
||||
val rolling = result["rollingUsage"]!!
|
||||
assertEquals(42f, rolling.usagePercent)
|
||||
assertNull(rolling.resetInSec)
|
||||
assertNull(rolling.resetAtMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips window when percent is missing or invalid`() {
|
||||
val json = """{"usage":{"rolling":{"status":"ok","resetsAt":"2026-08-12T20:00:00Z"},"weekly":{"percent":"abc"}}}"""
|
||||
val result = OpenCodeGoRepository.parseUsageResponse(json, now)
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses epoch seconds and epoch millis resetsAt`() {
|
||||
val seconds = (now + 3_600_000L) / 1000
|
||||
val viaSeconds = OpenCodeGoRepository.parseUsageResponse(
|
||||
"""{"usage":{"rolling":{"percent":10,"resetsAt":$seconds}}}""", now
|
||||
)
|
||||
assertEquals(3600L, viaSeconds["rollingUsage"]!!.resetInSec)
|
||||
|
||||
val viaMillis = OpenCodeGoRepository.parseUsageResponse(
|
||||
"""{"usage":{"rolling":{"percent":10,"resetsAt":${now + 3_600_000L}}}}""", now
|
||||
)
|
||||
assertEquals(3600L, viaMillis["rollingUsage"]!!.resetInSec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clamps percent into valid range`() {
|
||||
val over = OpenCodeGoRepository.parseUsageResponse(
|
||||
"""{"usage":{"rolling":{"percent":150}}}""", now
|
||||
)
|
||||
assertEquals(100f, over["rollingUsage"]!!.usagePercent)
|
||||
|
||||
val under = OpenCodeGoRepository.parseUsageResponse(
|
||||
"""{"usage":{"rolling":{"percent":-5}}}""", now
|
||||
)
|
||||
assertEquals(0f, under["rollingUsage"]!!.usagePercent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps decimal percent without rounding to int`() {
|
||||
// 回归保护:usagePercent 为小数时不能被 toIntOrNull 丢掉
|
||||
val html = """
|
||||
<html><body><script>
|
||||
rollingUsage:${'$'}R[0]={status:"ok",resetInSec:60,usagePercent:0.5}
|
||||
</script></body></html>
|
||||
""".trimIndent()
|
||||
val result = OpenCodeGoRepository.parseWindows(html)
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(0.5f, result["rollingUsage"]!!.usagePercent)
|
||||
val result = OpenCodeGoRepository.parseUsageResponse(
|
||||
"""{"usage":{"rolling":{"percent":1.1,"resetsAt":"${Instant.ofEpochMilli(now + 60_000)}"}}}""", now
|
||||
)
|
||||
assertEquals(1.1f, result["rollingUsage"]!!.usagePercent)
|
||||
assertEquals(60L, result["rollingUsage"]!!.resetInSec)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tolerates missing usage and limit fields`() {
|
||||
// 旧页面/旧缓存格式:无 usage/limit,不应影响窗口解析
|
||||
val html = buildHtml(
|
||||
rolling = Pair(42, 3600),
|
||||
weekly = null,
|
||||
monthly = null
|
||||
fun `coerces past reset time to zero`() {
|
||||
val result = OpenCodeGoRepository.parseUsageResponse(
|
||||
"""{"usage":{"rolling":{"percent":5,"resetsAt":"${Instant.ofEpochMilli(now - 60_000)}"}}}""", now
|
||||
)
|
||||
val result = OpenCodeGoRepository.parseWindows(html)
|
||||
assertEquals(0L, result["rollingUsage"]!!.resetInSec)
|
||||
}
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(42f, result["rollingUsage"]!!.usagePercent)
|
||||
assertNull(result["rollingUsage"]!!.usage)
|
||||
assertNull(result["rollingUsage"]!!.limit)
|
||||
@Test
|
||||
fun `returns empty map for empty or invalid json`() {
|
||||
assertTrue(OpenCodeGoRepository.parseUsageResponse("", now).isEmpty())
|
||||
assertTrue(OpenCodeGoRepository.parseUsageResponse("<html>login</html>", now).isEmpty())
|
||||
assertTrue(OpenCodeGoRepository.parseUsageResponse("""{"foo":1}""", now).isEmpty())
|
||||
}
|
||||
|
||||
// ─────────────── parseModelRows(_server 模型级用量)───────────────
|
||||
|
||||
Loading…
Reference in New Issue
Block a user