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:
WaterRain 2026-09-22 10:29:40 +00:00
parent 67cb816ec7
commit d205ff2ea4
No known key found for this signature in database
10 changed files with 275 additions and 330 deletions

View File

@ -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). 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.
--- ---

View File

@ -72,7 +72,7 @@ RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Famil
前往 [Releases](https://github.com/CATMIAOZHI/Rainytoken/releases) 下载最新 APK。 前往 [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 选填,仅用于用量记录与模型级明细。
--- ---

View File

@ -5,7 +5,6 @@ import com.rainy.token.data.debug.DebugLog
import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.Credential
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.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.async
@ -13,31 +12,32 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString 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
import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request 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.time.Instant
import java.time.OffsetDateTime
import java.time.format.DateTimeParseException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Singleton 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) * 余额(5h/周/月 三档窗口)通过 **API Key** 直查官方接口:
* - 登录后访问 dashboard,URL 形如 `https://opencode.ai/workspace/{workspaceId}/go` * `GET https://opencode.ai/zen/go/v1/usage`(Bearer 鉴权),不再抓取 dashboard HTML。
* - 用户从 dashboard URL 中复制 `workspaceId` + 浏览器 DevTools 的 `auth` cookie 值 *
* - 粘贴到 APP,APP 用 OkHttp 携带 Cookie 抓取该 URL * - API Key:设置页填写(opencode.ai/settings → API Keys),查询余额必填
* - 解析 HTML 中 SolidJS SSR hydration 字段 `rollingUsage` / `weeklyUsage` / `monthlyUsage` * - auth cookie + workspaceId:**选填**,仅用于模型级明细增强(`_server` 接口)
* 与 [OpenCodeUsageRepository] 用量记录页;缺失时余额仍正常返回
* *
* 不在类上加 @Inject constructor —— 在 [com.rainy.token.di.NetworkModule] 里 @Provides 显式提供。 * 不在类上加 @Inject constructor —— 在 [com.rainy.token.di.NetworkModule] 里 @Provides 显式提供。
* 规避 KSP 2.x 多文件 @Inject 跨依赖的"could not be resolved"误报。 * 规避 KSP 2.x 多文件 @Inject 跨依赖的"could not be resolved"误报。
@ -50,7 +50,12 @@ class OpenCodeGoRepository(
) { ) {
private val json = Json { ignoreUnknownKeys = true } 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) { suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.OPENCODE_GO) val credential = credentialRepository.get(ServiceType.OPENCODE_GO)
?: return@withContext Result.failure(RepositoryError.InvalidCredential()) ?: return@withContext Result.failure(RepositoryError.InvalidCredential())
@ -58,20 +63,15 @@ class OpenCodeGoRepository(
if (credential !is Credential.SessionCredential) { if (credential !is Credential.SessionCredential) {
return@withContext Result.failure(RepositoryError.InvalidCredential()) return@withContext Result.failure(RepositoryError.InvalidCredential())
} }
val apiKey = credential.apiKey?.trim()
val authCookie = credential.authCookie if (apiKey.isNullOrEmpty()) {
val workspaceId = credential.workspaceId return@withContext Result.failure(RepositoryError.InvalidCredential("未配置 API Key"))
if (authCookie.isNullOrBlank() || workspaceId.isNullOrBlank()) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
} }
val url = "https://opencode.ai/workspace/${URLEncoder.encode(workspaceId, "UTF-8")}/go"
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(USAGE_API)
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/148.0") .header("Accept", "application/json")
.header("Accept", "text/html") .header("Authorization", "Bearer $apiKey")
.header("Cookie", "auth=$authCookie")
.get() .get()
.build() .build()
@ -85,54 +85,48 @@ class OpenCodeGoRepository(
response.use { resp -> response.use { resp ->
if (!resp.isSuccessful) { if (!resp.isSuccessful) {
if (resp.code == 401 || resp.code == 403) { DebugLog.e(TAG, "fetchBalance: HTTP ${resp.code}")
return@withContext Result.failure(RepositoryError.InvalidCredential()) 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, "响应体为空") RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
) )
val windows = parseWindows(html) val now = System.currentTimeMillis()
val windows = parseUsageResponse(body, now)
if (windows.isEmpty()) { if (windows.isEmpty()) {
return@withContext Result.failure( return@withContext Result.failure(
RepositoryError.ParseError( RepositoryError.ParseError(
RepositoryError.ParseErrorReason.NO_WINDOWS, RepositoryError.ParseErrorReason.NO_WINDOWS,
"解析失败:未找到任何 OpenCode Go 配额窗口。HTML=${html.length} 字节。" "解析失败:未找到任何 OpenCode Go 配额窗口。body=${body.length} 字节。"
) )
) )
} }
// 主体数据用 rollingUsage(5h 滚动窗口),这是用户最关心的"实时配额" // 主体数据用 rollingUsage(5h 滚动窗口),这是用户最关心的"实时配额"
val primary = windows["rollingUsage"] ?: windows.values.first() val primary = windows["rollingUsage"] ?: windows.values.first()
val config = ServiceConfigProvider.get(ServiceType.OPENCODE_GO)
// 把 3 个窗口的用量百分比 + 重置时间全部塞进 extras(详情页按窗口渲染) // 把 3 个窗口的用量百分比 + 重置倒计时塞进 extras(详情页/小组件按窗口渲染)
// usage/limit 为页面新增的用量与限额字段(单位以服务端定义为准,保留供详情页展示,UI 兼容缺失场景)
val extras = buildMap { val extras = buildMap {
windows["rollingUsage"]?.let { w -> windows["rollingUsage"]?.let { w ->
put("rolling.pct", w.usagePercent.toString()) put("rolling.pct", w.usagePercent.toString())
put("rolling.resetInSec", w.resetInSec.toString()) w.resetInSec?.let { put("rolling.resetInSec", it.toString()) }
w.usage?.let { put("rolling.usage", it.toString()) }
w.limit?.let { put("rolling.limit", it.toString()) }
} }
windows["weeklyUsage"]?.let { w -> windows["weeklyUsage"]?.let { w ->
put("weekly.pct", w.usagePercent.toString()) put("weekly.pct", w.usagePercent.toString())
put("weekly.resetInSec", w.resetInSec.toString()) w.resetInSec?.let { put("weekly.resetInSec", it.toString()) }
w.usage?.let { put("weekly.usage", it.toString()) }
w.limit?.let { put("weekly.limit", it.toString()) }
} }
windows["monthlyUsage"]?.let { w -> windows["monthlyUsage"]?.let { w ->
put("monthly.pct", w.usagePercent.toString()) put("monthly.pct", w.usagePercent.toString())
put("monthly.resetInSec", w.resetInSec.toString()) w.resetInSec?.let { put("monthly.resetInSec", it.toString()) }
w.usage?.let { put("monthly.usage", it.toString()) }
w.limit?.let { put("monthly.limit", it.toString()) }
} }
} }
// 主窗口数据先落缓存并返回(关键路径只依赖 HTML 解析一次请求)
val balance = ServiceBalance( val balance = ServiceBalance(
service = ServiceType.OPENCODE_GO, service = ServiceType.OPENCODE_GO,
amount = primary.usagePercent.toDouble(), amount = primary.usagePercent.toDouble(),
@ -140,13 +134,18 @@ class OpenCodeGoRepository(
isAvailable = true, isAvailable = true,
monthlySpent = windows["monthlyUsage"]?.usagePercent?.toDouble(), monthlySpent = windows["monthlyUsage"]?.usagePercent?.toDouble(),
totalQuota = null, totalQuota = null,
nextResetAt = System.currentTimeMillis() + primary.resetInSec * 1000L, nextResetAt = primary.resetAtMillis,
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()))
// 模型级用量作为增量增强:并行拉取 + 短超时,失败/超时不影响主窗口数据与缓存 // 模型级用量作为增量增强:需要 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) val modelUsage = fetchModelWindows(workspaceId, authCookie)
if (modelUsage.isEmpty()) { if (modelUsage.isEmpty()) {
return@withContext Result.success(balance) return@withContext Result.success(balance)
@ -253,53 +252,68 @@ 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"
/** 余额/三档窗口用量查询(Bearer API Key) */
private const val USAGE_API = "https://opencode.ai/zen/go/v1/usage"
/** _server 端点:模型级用量接口(f:31 + 窗口参数,需 x-server-id / x-server-instance 头) */ /** _server 端点:模型级用量接口(f:31 + 窗口参数,需 x-server-id / x-server-instance 头) */
private const val SERVER_ENDPOINT = "https://opencode.ai/_server" private const val SERVER_ENDPOINT = "https://opencode.ai/_server"
/** 窗口模型用量的 server function id(与请求头 x-server-id 一致,取自网页端实测) */ /** 窗口模型用量的 server function id(与请求头 x-server-id 一致,取自网页端实测) */
private const val MODEL_USAGE_SERVER_ID = "ba154d05c4028a885b8c753f9def7e45d87eb982e65fa8b14254cbe636168914" private const val MODEL_USAGE_SERVER_ID = "ba154d05c4028a885b8c753f9def7e45d87eb982e65fa8b14254cbe636168914"
/** 模型级用量单请求超时(毫秒):只影响增强数据,避免拖慢主窗口刷新 */ /** 模型级用量单请求超时(毫秒):只影响增强数据,避免拖慢主窗口刷新 */
private const val MODEL_REQUEST_TIMEOUT_MS = 5_000L private const val MODEL_REQUEST_TIMEOUT_MS = 5_000L
private val SCRAPED_FIELDS = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
/** /**
* 解析 SolidJS SSR hydration 输出。匹配模式形如: * 解析 `GET /zen/go/v1/usage` JSON 响应为三档窗口。
* `rollingUsage:$R[0]={usagePercent:42,resetInSec:12345}`
* *
* 用精确前缀 "field:$R[" 定位 hydration 数据中的字段声明, * - `percent` 缺失或非数字 → 跳过该窗口(不猜测数值);有值则钳制到 0..100
* 避免命中 HTML 其他位置(如 JS 代码注释、模板字符串)的同名文本。 * - `resetsAt` 支持 ISO-8601 与 epoch(秒/毫秒自动识别);缺失时窗口仍保留(倒计时为 null)
* 用括号计数匹配闭合 "}",正确处理嵌套对象。 * - 非 JSON / 缺 `usage` 节点 → 空 map(调用方映射 NO_WINDOWS 错误)
*
* @param nowMillis 计算重置倒计时的基准时间(测试可注入)
*/ */
internal fun parseWindows(html: String): Map<String, ScrapedWindow> { internal fun parseUsageResponse(body: String, nowMillis: Long): Map<String, UsageWindow> {
val result = mutableMapOf<String, ScrapedWindow>() val root = try {
val fields = listOf("rollingUsage", "weeklyUsage", "monthlyUsage") Json.parseToJsonElement(body) as? JsonObject
} catch (_: SerializationException) {
null
} ?: return emptyMap()
val usage = root["usage"] as? JsonObject ?: return emptyMap()
for (field in fields) { val result = mutableMapOf<String, UsageWindow>()
// 精确模式:找 "field:$R[N]={" —— 这是 hydration 数据块的独有格式 for ((apiField, usageKey) in listOf(
val keyIdx = html.indexOf("$field:\$R[") "rolling" to "rollingUsage",
if (keyIdx < 0) continue "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
}
// 找 '=' 然后找 '{' /** `resetsAt` → epoch millis:数字(秒/毫秒)或 ISO-8601;无法解析返回 null。 */
val eqIdx = html.indexOf('=', keyIdx) private fun parseResetEpochMillis(raw: String?): Long? {
if (eqIdx < 0 || eqIdx - keyIdx > 60) continue if (raw.isNullOrBlank()) return null
val braceStart = html.indexOf('{', eqIdx) val trimmed = raw.trim()
if (braceStart < 0 || braceStart - eqIdx > 10) continue 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()
val braceEnd = findMatchingBrace(html, braceStart) ?: continue }
val body = html.substring(braceStart, braceEnd + 1) return try {
Instant.parse(trimmed).toEpochMilli()
val pct = extractNumberAfterKey(body, "usagePercent")?.toFloatOrNull() } catch (_: DateTimeParseException) {
val reset = extractNumberAfterKey(body, "resetInSec")?.toLongOrNull() try {
// usage/limit 是页面新增的用量与限额字段(单位以服务端定义为准,缺失不影响窗口识别) OffsetDateTime.parse(trimmed).toInstant().toEpochMilli()
// exactKey=true:避免 "usage" 误命中 "usagePercent" 前缀 } catch (_: DateTimeParseException) {
val usage = extractNumberAfterKey(body, "usage", exactKey = true)?.toLongOrNull() null
val limit = extractNumberAfterKey(body, "limit", exactKey = true)?.toLongOrNull()
if (pct != null && reset != null) {
result[field] = ScrapedWindow(pct, reset, usage, limit)
} }
} }
return result
} }
/** /**
@ -458,6 +472,8 @@ class OpenCodeGoRepository(
* 拉取三个窗口(rolling/weekly/monthly)的模型级用量。 * 拉取三个窗口(rolling/weekly/monthly)的模型级用量。
* 并行发起 + 每个请求独立短超时(MODEL_REQUEST_TIMEOUT_MS), * 并行发起 + 每个请求独立短超时(MODEL_REQUEST_TIMEOUT_MS),
* 任一窗口失败不影响其他窗口;整体失败返回空 Map(主窗口数据不受影响)。 * 任一窗口失败不影响其他窗口;整体失败返回空 Map(主窗口数据不受影响)。
*
* 依赖 auth cookie + workspaceId(选填凭据);仅在两者齐备时由 [fetchBalance] 调用。
*/ */
private suspend fun fetchModelWindows(workspaceId: String, authCookie: String): Map<String, WindowModelUsage> = private suspend fun fetchModelWindows(workspaceId: String, authCookie: String): Map<String, WindowModelUsage> =
coroutineScope { 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)。 * 窗口模型级用量(_server 接口 payload)。
* cost/quotaCost 单位为 1e-8 美元;multiplier 为计费倍率(quotaCost = cost × multiplier)。 * cost/quotaCost 单位为 1e-8 美元;multiplier 为计费倍率(quotaCost = cost × multiplier)。

View File

@ -37,13 +37,13 @@ sealed class Credential {
val token: String? = null, val token: String? = null,
/** Token 过期时间(epoch millis),null 表示未知。 */ /** Token 过期时间(epoch millis),null 表示未知。 */
val expiresAt: Long? = null, val expiresAt: Long? = null,
/** OpenCode Go 专用:dashboard auth cookie 值(简化为单字段,便于用户粘贴) */ /** OpenCode Go(选填):dashboard auth cookie 值,仅用于用量记录与模型级明细(简化为单字段,便于用户粘贴) */
val authCookie: String? = null, val authCookie: String? = null,
/** OpenCode Go 专用:workspace ID */ /** OpenCode Go(选填):workspace ID,仅用于用量记录与模型级明细 */
val workspaceId: String? = null, val workspaceId: String? = null,
/** Ollama 专用:完整的 Cookie 字符串(用户从浏览器 DevTools 复制) */ /** Ollama 专用:完整的 Cookie 字符串(用户从浏览器 DevTools 复制) */
val ollamaCookie: String? = null, val ollamaCookie: String? = null,
/** OpenCode Go / Ollama 专用:API Key,用于一键激活用量 */ /** OpenCode Go 主凭据 / Ollama:API Key(OpenCode Go 用它查余额 + 一键激活;Ollama 用于一键激活) */
val apiKey: String? = null, val apiKey: String? = null,
override val lastVerifiedAt: Long = 0L override val lastVerifiedAt: Long = 0L
) : Credential() ) : Credential()

View File

@ -428,6 +428,40 @@ private fun OpenCodeGoForm(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline 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()) { OutlinedButton(onClick = onImportFromClipboard, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.action_import_from_clipboard)) Text(text = stringResource(R.string.action_import_from_clipboard))
} }
@ -459,7 +493,7 @@ private fun OpenCodeGoForm(
OutlinedButton( OutlinedButton(
onClick = onTestAndSave, onClick = onTestAndSave,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = authCookie.isNotBlank() && workspaceId.isNotBlank() enabled = triggerApiKey.isNotBlank() && (authCookie.isBlank() == workspaceId.isBlank())
) { ) {
Text(text = stringResource(R.string.action_test_and_save)) Text(text = stringResource(R.string.action_test_and_save))
} }
@ -475,35 +509,6 @@ private fun OpenCodeGoForm(
TextButton(onClick = onCopyLoginUrl, modifier = Modifier.fillMaxWidth()) { TextButton(onClick = onCopyLoginUrl, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.action_copy_login_url)) 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 @Composable

View File

@ -270,15 +270,22 @@ class CredentialEditViewModel @Inject constructor(
fun testAndSaveOpenCodeGo() { fun testAndSaveOpenCodeGo() {
val type = serviceType ?: return val type = serviceType ?: return
val current = _uiState.value val current = _uiState.value
if (current.authCookie.isBlank() || current.workspaceId.isBlank()) { // 余额只认 API Key:必填;cookie 对可选,但只填一半视为填写错误
_uiState.update { if (current.triggerApiKey.isBlank()) {
it.copy(message = UiText.Resource(R.string.error_auth_cookie_workspace)) _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 return
} }
viewModelScope.launch { viewModelScope.launch {
val previous = credentialRepository.get(type) 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( testAndRollback(
type = type, type = type,
saveAndPrep = { previous to { refreshBalanceUseCaseProvider.get().invoke(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 type = serviceType ?: return
val existing = credentialRepository.get(type) as? Credential.SessionCredential val existing = credentialRepository.get(type) as? Credential.SessionCredential
val updated = (existing ?: Credential.SessionCredential( val updated = (existing ?: Credential.SessionCredential(
service = type, service = type,
cookies = emptyList() cookies = emptyList()
)).copy( )).copy(
authCookie = authCookie, authCookie = authCookie ?: existing?.authCookie,
workspaceId = workspaceId, workspaceId = workspaceId ?: existing?.workspaceId,
apiKey = apiKey ?: existing?.apiKey,
lastVerifiedAt = System.currentTimeMillis() lastVerifiedAt = System.currentTimeMillis()
) )
credentialRepository.save(updated) credentialRepository.save(updated)

View File

@ -199,7 +199,7 @@
<string name="help_cookie_3">3. 登录后按 F12 → Application → Cookies</string> <string name="help_cookie_3">3. 登录后按 F12 → Application → Cookies</string>
<string name="help_cookie_4">4. 复制该站点下的所有 Cookie 粘贴到上面输入框</string> <string name="help_cookie_4">4. 复制该站点下的所有 Cookie 粘贴到上面输入框</string>
<string name="help_cookie_5">格式:name1=value1; name2=value2</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_1">1. 桌面 Chrome 打开 opencode.ai/auth</string>
<string name="help_ocgo_2">2. 用 GitHub / Google 登录(Google 不会拦截 Chrome)</string> <string name="help_ocgo_2">2. 用 GitHub / Google 登录(Google 不会拦截 Chrome)</string>
<string name="help_ocgo_3">3. 登录后跳转到 dashboard,URL 形如:</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="placeholder_ollama_api_key">ollama-xxx 或 sk-xxx</string>
<string name="action_save_api_key">保存 API Key</string> <string name="action_save_api_key">保存 API Key</string>
<string name="credential_title_opencode_go">OpenCode Go 凭据</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="action_import_from_clipboard">从剪贴板自动导入</string>
<string name="placeholder_workspace_id">dashboard URL 中的 {id} 段</string> <string name="placeholder_workspace_id">dashboard URL 中的 {id} 段</string>
<string name="field_auth_cookie_value">auth Cookie 值</string> <string name="field_auth_cookie_value">auth Cookie 值</string>
<string name="placeholder_auth_cookie">F12 → Cookies → opencode.ai → auth 的 Value</string> <string name="placeholder_auth_cookie">F12 → Cookies → opencode.ai → auth 的 Value</string>
<string name="action_how_get_two_values">如何获取这两个值?</string> <string name="action_how_get_two_values">如何获取这两个值?</string>
<string name="action_open_login_entry">在浏览器中打开登录入口</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_title_ccgo">CommandCode 凭据</string>
<string name="credential_hint_ccgo">需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。</string> <string name="credential_hint_ccgo">需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。</string>
<string name="placeholder_api_key_from_settings">从 Settings → API Keys 生成</string> <string name="placeholder_api_key_from_settings">从 Settings → API Keys 生成</string>

View File

@ -199,7 +199,7 @@
<string name="help_cookie_3">3. 登入後按 F12 → Application → Cookies</string> <string name="help_cookie_3">3. 登入後按 F12 → Application → Cookies</string>
<string name="help_cookie_4">4. 複製該站點下的所有 Cookie 貼上到上面輸入框</string> <string name="help_cookie_4">4. 複製該站點下的所有 Cookie 貼上到上面輸入框</string>
<string name="help_cookie_5">格式:name1=value1; name2=value2</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_1">1. 桌面 Chrome 開啟 opencode.ai/auth</string>
<string name="help_ocgo_2">2. 用 GitHub / Google 登入(Google 不會攔截 Chrome)</string> <string name="help_ocgo_2">2. 用 GitHub / Google 登入(Google 不會攔截 Chrome)</string>
<string name="help_ocgo_3">3. 登入後跳轉到 dashboard,URL 形如:</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="placeholder_ollama_api_key">ollama-xxx 或 sk-xxx</string>
<string name="action_save_api_key">儲存 API Key</string> <string name="action_save_api_key">儲存 API Key</string>
<string name="credential_title_opencode_go">OpenCode Go 憑據</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="action_import_from_clipboard">從剪貼簿自動匯入</string>
<string name="placeholder_workspace_id">dashboard URL 中的 {id} 段</string> <string name="placeholder_workspace_id">dashboard URL 中的 {id} 段</string>
<string name="field_auth_cookie_value">auth Cookie 值</string> <string name="field_auth_cookie_value">auth Cookie 值</string>
<string name="placeholder_auth_cookie">F12 → Cookies → opencode.ai → auth 的 Value</string> <string name="placeholder_auth_cookie">F12 → Cookies → opencode.ai → auth 的 Value</string>
<string name="action_how_get_two_values">如何獲取這兩個值?</string> <string name="action_how_get_two_values">如何獲取這兩個值?</string>
<string name="action_open_login_entry">在瀏覽器中開啟登入入口</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_title_ccgo">CommandCode 憑據</string>
<string name="credential_hint_ccgo">需要 API Key(拉餘額)。Session Cookie(可選)在瀏覽器 DevTools 複製,用於拉用量記錄。不填也可正常檢視餘額。</string> <string name="credential_hint_ccgo">需要 API Key(拉餘額)。Session Cookie(可選)在瀏覽器 DevTools 複製,用於拉用量記錄。不填也可正常檢視餘額。</string>
<string name="placeholder_api_key_from_settings">從 Settings → API Keys 生成</string> <string name="placeholder_api_key_from_settings">從 Settings → API Keys 生成</string>

View File

@ -202,7 +202,7 @@
<string name="help_cookie_3">3. After login, press F12 → Application → Cookies</string> <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_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_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_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_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> <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="placeholder_ollama_api_key">ollama-xxx or sk-xxx</string>
<string name="action_save_api_key">Save API Key</string> <string name="action_save_api_key">Save API Key</string>
<string name="credential_title_opencode_go">OpenCode Go credentials</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="action_import_from_clipboard">Auto-import from clipboard</string>
<string name="placeholder_workspace_id">The {id} segment of the dashboard URL</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="field_auth_cookie_value">auth Cookie value</string>
<string name="placeholder_auth_cookie">F12 → Cookies → opencode.ai → Value of auth</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_how_get_two_values">How to get these two?</string>
<string name="action_open_login_entry">Open login page in browser</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_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="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> <string name="placeholder_api_key_from_settings">Generate at Settings → API Keys</string>

View File

@ -5,233 +5,127 @@ import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test 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. * All tests are pure JVM — no Android framework, no network.
*/ */
class OpenCodeGoRepositoryTest { class OpenCodeGoRepositoryTest {
/** /** 固定"现在",保证倒计时断言稳定。 */
* Build a minimal hydration HTML with the given window data. private val now = 1_700_000_000_000L
*
* The real format looks like: /** 构造 /zen/go/v1/usage 响应;参数为 null 时该窗口不出现。 */
* rollingUsage:$R[0]={status:"ok",usagePercent:42,resetInSec:12345} private fun usageJson(
* rolling: String? = """{"status":"ok","percent":42,"resetsAt":"${Instant.ofEpochMilli(now + 3_600_000)}"}""",
* @param usage 可选:页面新增的用量字段(usage) weekly: String? = """{"status":"ok","percent":15,"resetsAt":"${Instant.ofEpochMilli(now + 86_400_000)}"}""",
* @param limit 可选:页面新增的限额字段(limit) monthly: String? = """{"status":"ok","percent":80,"resetsAt":"${Instant.ofEpochMilli(now + 2_592_000_000)}"}"""
*/
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
): String { ): String {
fun windowJs(name: String, data: Pair<Int, Long>?): String { val parts = listOfNotNull(
if (data == null) return "" rolling?.let { """"rolling":$it""" },
val extra = if (usage != null && limit != null) ",usage:$usage,limit:$limit" else "" weekly?.let { """"weekly":$it""" },
return """$name:${'$'}R[0]={status:"ok",usagePercent:${data.first},resetInSec:${data.second}$extra}""" monthly?.let { """"monthly":$it""" }
} )
return """{"usage":{${parts.joinToString(",")}}}"""
return """
<html>
<body>
<script>
${windowJs("rollingUsage", rolling)}
${windowJs("weeklyUsage", weekly)}
${windowJs("monthlyUsage", monthly)}
</script>
</body>
</html>
""".trimIndent()
} }
// ─────────────── parseUsageResponse(余额 API JSON)───────────────
@Test @Test
fun `parses all three windows correctly`() { fun `parses all three windows from usage api`() {
val html = buildHtml( val result = OpenCodeGoRepository.parseUsageResponse(usageJson(), now)
rolling = Pair(42, 3600),
weekly = Pair(15, 86400),
monthly = Pair(80, 2592000)
)
val result = OpenCodeGoRepository.parseWindows(html)
assertEquals(3, result.size) 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"]!! val rolling = result["rollingUsage"]!!
assertEquals(1.1f, rolling.usagePercent) assertEquals(42f, rolling.usagePercent)
assertEquals(1406L, rolling.resetInSec) assertEquals(3600L, rolling.resetInSec)
assertEquals(13314666L, rolling.usage) assertEquals(now + 3_600_000L, rolling.resetAtMillis)
assertEquals(1200000000L, rolling.limit)
val weekly = result["weeklyUsage"]!! assertEquals(15f, result["weeklyUsage"]!!.usagePercent)
assertEquals(2.5f, weekly.usagePercent) assertEquals(86_400L, result["weeklyUsage"]!!.resetInSec)
assertEquals(339417L, weekly.resetInSec) assertEquals(80f, result["monthlyUsage"]!!.usagePercent)
assertEquals(74497156L, weekly.usage) assertEquals(2_592_000L, result["monthlyUsage"]!!.resetInSec)
assertEquals(3000000000L, weekly.limit) }
val monthly = result["monthlyUsage"]!! @Test
assertEquals(7.2f, monthly.usagePercent) fun `parses rolling window only`() {
assertEquals(1229614L, monthly.resetInSec) val result = OpenCodeGoRepository.parseUsageResponse(usageJson(weekly = null, monthly = null), now)
assertEquals(434167747L, monthly.usage) assertEquals(1, result.size)
assertEquals(6000000000L, monthly.limit) 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 @Test
fun `keeps decimal percent without rounding to int`() { fun `keeps decimal percent without rounding to int`() {
// 回归保护:usagePercent 为小数时不能被 toIntOrNull 丢掉 val result = OpenCodeGoRepository.parseUsageResponse(
val html = """ """{"usage":{"rolling":{"percent":1.1,"resetsAt":"${Instant.ofEpochMilli(now + 60_000)}"}}}""", now
<html><body><script> )
rollingUsage:${'$'}R[0]={status:"ok",resetInSec:60,usagePercent:0.5} assertEquals(1.1f, result["rollingUsage"]!!.usagePercent)
</script></body></html>
""".trimIndent()
val result = OpenCodeGoRepository.parseWindows(html)
assertEquals(1, result.size)
assertEquals(0.5f, result["rollingUsage"]!!.usagePercent)
assertEquals(60L, result["rollingUsage"]!!.resetInSec) assertEquals(60L, result["rollingUsage"]!!.resetInSec)
} }
@Test @Test
fun `tolerates missing usage and limit fields`() { fun `coerces past reset time to zero`() {
// 旧页面/旧缓存格式:无 usage/limit,不应影响窗口解析 val result = OpenCodeGoRepository.parseUsageResponse(
val html = buildHtml( """{"usage":{"rolling":{"percent":5,"resetsAt":"${Instant.ofEpochMilli(now - 60_000)}"}}}""", now
rolling = Pair(42, 3600),
weekly = null,
monthly = null
) )
val result = OpenCodeGoRepository.parseWindows(html) assertEquals(0L, result["rollingUsage"]!!.resetInSec)
}
assertEquals(1, result.size) @Test
assertEquals(42f, result["rollingUsage"]!!.usagePercent) fun `returns empty map for empty or invalid json`() {
assertNull(result["rollingUsage"]!!.usage) assertTrue(OpenCodeGoRepository.parseUsageResponse("", now).isEmpty())
assertNull(result["rollingUsage"]!!.limit) assertTrue(OpenCodeGoRepository.parseUsageResponse("<html>login</html>", now).isEmpty())
assertTrue(OpenCodeGoRepository.parseUsageResponse("""{"foo":1}""", now).isEmpty())
} }
// ─────────────── parseModelRows(_server 模型级用量)─────────────── // ─────────────── parseModelRows(_server 模型级用量)───────────────