diff --git a/README.en.md b/README.en.md index 4721367..ebac6a2 100644 --- a/README.en.md +++ b/README.en.md @@ -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. --- diff --git a/README.md b/README.md index 00b1515..8f2ac8d 100644 --- a/README.md +++ b/README.md @@ -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 选填,仅用于用量记录与模型级明细。 --- diff --git a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt index 0778862..8239157 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt @@ -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 = 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 { - val result = mutableMapOf() - val fields = listOf("rollingUsage", "weeklyUsage", "monthlyUsage") + internal fun parseUsageResponse(body: String, nowMillis: Long): Map { + 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() + 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 = 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)。 diff --git a/app/src/main/java/com/rainy/token/domain/model/Credential.kt b/app/src/main/java/com/rainy/token/domain/model/Credential.kt index 44c82ab..12a1f28 100644 --- a/app/src/main/java/com/rainy/token/domain/model/Credential.kt +++ b/app/src/main/java/com/rainy/token/domain/model/Credential.kt @@ -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() diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt index aee9edb..ddfc98c 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt @@ -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 diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt index 8c18888..35698ee 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt @@ -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) diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index d19c520..6953670 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -199,7 +199,7 @@ 3. 登录后按 F12 → Application → Cookies 4. 复制该站点下的所有 Cookie 粘贴到上面输入框 格式:name1=value1; name2=value2 - 如何获取 OpenCode Go 凭据? + 如何获取 auth cookie 与 workspaceId? 1. 桌面 Chrome 打开 opencode.ai/auth 2. 用 GitHub / Google 登录(Google 不会拦截 Chrome) 3. 登录后跳转到 dashboard,URL 形如: @@ -400,14 +400,16 @@ ollama-xxx 或 sk-xxx 保存 API Key OpenCode Go 凭据 - 需要 auth cookie + workspaceId 两个值。APP 会用 OkHttp 抓 dashboard 并自动解析 5h/周/月 三档用量。 + 余额与 5h/周/月 三档用量通过 API Key 查询(opencode.ai/settings → API Keys 获取)。下方 auth cookie + workspaceId 为选填,仅用于用量记录页与模型级明细。 从剪贴板自动导入 dashboard URL 中的 {id} 段 auth Cookie 值 F12 → Cookies → opencode.ai → auth 的 Value 如何获取这两个值? 在浏览器中打开登录入口 - 填入 OpenCode Zen API Key 后,可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。 + API Key 用于查询余额,也可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。 + API Key(查询余额) + 用量记录与模型级明细(选填) CommandCode 凭据 需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。 从 Settings → API Keys 生成 diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index 60dd8bc..d5ab442 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -199,7 +199,7 @@ 3. 登入後按 F12 → Application → Cookies 4. 複製該站點下的所有 Cookie 貼上到上面輸入框 格式:name1=value1; name2=value2 - 如何獲取 OpenCode Go 憑據? + 如何獲取 auth cookie 與 workspaceId? 1. 桌面 Chrome 開啟 opencode.ai/auth 2. 用 GitHub / Google 登入(Google 不會攔截 Chrome) 3. 登入後跳轉到 dashboard,URL 形如: @@ -399,14 +399,16 @@ ollama-xxx 或 sk-xxx 儲存 API Key OpenCode Go 憑據 - 需要 auth cookie + workspaceId 兩個值。APP 會用 OkHttp 抓 dashboard 並自動解析 5h/周/月 三檔用量。 + 餘額與 5h/週/月 三檔用量透過 API Key 查詢(opencode.ai/settings → API Keys 獲取)。下方 auth cookie + workspaceId 為選填,僅用於用量記錄頁與模型級明細。 從剪貼簿自動匯入 dashboard URL 中的 {id} 段 auth Cookie 值 F12 → Cookies → opencode.ai → auth 的 Value 如何獲取這兩個值? 在瀏覽器中開啟登入入口 - 填入 OpenCode Zen API Key 後,可在詳情頁一鍵傳送請求啟用用量統計。從 opencode.ai/settings → API Keys 獲取。 + API Key 用於查詢餘額,也可在詳情頁一鍵傳送請求啟用用量統計。從 opencode.ai/settings → API Keys 獲取。 + API Key(查詢餘額) + 用量記錄與模型級明細(選填) CommandCode 憑據 需要 API Key(拉餘額)。Session Cookie(可選)在瀏覽器 DevTools 複製,用於拉用量記錄。不填也可正常檢視餘額。 從 Settings → API Keys 生成 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 80b818f..bfe044d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -202,7 +202,7 @@ 3. After login, press F12 → Application → Cookies 4. Copy all cookies of the site and paste them above Format: name1=value1; name2=value2 - How to get OpenCode Go credentials? + How to get the auth cookie and workspaceId? 1. Open opencode.ai/auth in desktop Chrome 2. Sign in with GitHub / Google (Google won\'t block Chrome) 3. You\'ll land on the dashboard, URL looks like: @@ -403,14 +403,16 @@ ollama-xxx or sk-xxx Save API Key OpenCode Go credentials - Needs both auth cookie and workspaceId. The app fetches the dashboard via OkHttp and parses 5h/week/month usage. + 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. Auto-import from clipboard The {id} segment of the dashboard URL auth Cookie value F12 → Cookies → opencode.ai → Value of auth How to get these two? Open login page in browser - 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. + 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. + API Key (fetches balance) + Usage records and per-model breakdown (optional) CommandCode credentials Needs an API Key (for balance). Session Cookie (optional) for usage records, copied from browser DevTools. Balance works without it. Generate at Settings → API Keys diff --git a/app/src/test/java/com/rainy/token/data/repository/OpenCodeGoRepositoryTest.kt b/app/src/test/java/com/rainy/token/data/repository/OpenCodeGoRepositoryTest.kt index 9386201..a0cea24 100644 --- a/app/src/test/java/com/rainy/token/data/repository/OpenCodeGoRepositoryTest.kt +++ b/app/src/test/java/com/rainy/token/data/repository/OpenCodeGoRepositoryTest.kt @@ -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? = Pair(42, 3600), - weekly: Pair? = Pair(15, 86400), - monthly: Pair? = 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?): 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 """ - - - - - - """.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 = "

Welcome

" - 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 = """ - - """.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 = """ - - """.trimIndent() - val result = OpenCodeGoRepository.parseWindows(html) - assertTrue(result.isEmpty()) - } - - @Test - fun `skips window when resetInSec is missing`() { - val 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 = """ - - """.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 = """ - - """.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 = """ - - """.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("login", now).isEmpty()) + assertTrue(OpenCodeGoRepository.parseUsageResponse("""{"foo":1}""", now).isEmpty()) } // ─────────────── parseModelRows(_server 模型级用量)───────────────