Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,21 @@ import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.long
import kotlinx.serialization.json.double
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import java.util.concurrent.TimeUnit
import javax.inject.Singleton

/**
Expand All @@ -28,6 +33,10 @@ import javax.inject.Singleton
* 调 subscription 端点获取计划信息(用来算已用/总量百分比):
* GET https://api.commandcode.ai/alpha/billing/subscriptions
* Authorization: Bearer <API Key>
*
* 调 usage summary 端点获取账单周期内真实月消耗(网页版 Monthly Usage 数据源):
* GET https://api.commandcode.ai/alpha/usage/summary
* Authorization: Bearer <API Key>
*/
@Singleton
class CommandCodeGoRepository(
Expand All @@ -52,9 +61,16 @@ class CommandCodeGoRepository(
return@withContext Result.failure(RepositoryError.InvalidCredential())
}

// 顺序拉取 credits + subscriptions(credits 是主要数据源,subscription 用于补充计划信息)
val creditsResult = runCatching { fetchCredits(apiKey) }
val subResult = runCatching { fetchSubscription(apiKey) }
// 并行拉取 credits + subscriptions + usage summary。
// summary 是可选增强(网页版 Monthly Usage 同源),用独立短超时;
// 即使 summary 慢/失败也绝不影响主流程(credits 是主数据源)。
val creditsDeferred = async { runCatching { fetchCredits(apiKey) } }
val subDeferred = async { runCatching { fetchSubscription(apiKey) } }
val summaryDeferred = async { runCatching { fetchUsageSummaryFast(apiKey) } }

val creditsResult = creditsDeferred.await()
val subResult = subDeferred.await()
val summaryResult = summaryDeferred.await()

val creditsPayload = creditsResult.getOrElse { e ->
return@withContext Result.failure(
Expand All @@ -71,9 +87,12 @@ class CommandCodeGoRepository(

val config = ServiceConfigProvider.get(ServiceType.COMMANDCODE_GO)

val used = monthlyTotal?.let { total ->
maxOf(0.0, total - creditsPayload.monthlyCredits)
}
// 优先用 usage/summary 返回的账单周期真实已用(网页版 Monthly Usage 同源);
// 拉取失败时回退到 总量 - 剩余 的反推值(此时可能不完整,但至少能显示)
val used = summaryResult.getOrNull()?.totalMonthlyCredits
?: monthlyTotal?.let { total ->
maxOf(0.0, total - creditsPayload.monthlyCredits)
}

val extras = buildMap {
put("monthlyRemaining", creditsPayload.monthlyCredits.toString())
Expand All @@ -87,7 +106,7 @@ class CommandCodeGoRepository(
creditsPayload.weeklyUsed?.let { put("weekly.used", it.toString()) }
creditsPayload.weeklyCap?.let { put("weekly.cap", it.toString()) }
creditsPayload.weeklyResetAt?.let { put("weekly.resetInSec", epochToRemainingSec(it).toString()) }
billingPeriodEndMillis?.let {
billingPeriodEndMillis?.let {
put("billingPeriodEnd", it.toString())
put("monthly.resetInSec", epochToRemainingSec(it).toString())
}
Expand Down Expand Up @@ -136,12 +155,12 @@ class CommandCodeGoRepository(
obj[key]?.jsonPrimitive?.long

val fiveHour = root["windowLimits"]?.jsonObject?.let { limits ->
if (limits["limited"]?.jsonPrimitive?.content == "true") {
if (limits["limited"]?.jsonPrimitive?.booleanOrNull == true) {
limits["fiveHour"]?.jsonObject
} else null
}
val weekly = root["windowLimits"]?.jsonObject?.let { limits ->
if (limits["limited"]?.jsonPrimitive?.content == "true") {
if (limits["limited"]?.jsonPrimitive?.booleanOrNull == true) {
limits["weekly"]?.jsonObject
} else null
}
Expand Down Expand Up @@ -187,22 +206,68 @@ class CommandCodeGoRepository(
} catch (_: Exception) { null }
}

/**
* 拉取账单周期内真实月消耗(网页版 Monthly Usage 的数据源)。
*
* 返回示例(periodBasis=billing-period 表示按订阅账单周期统计):
* { "totalCount":226, "totalCredits":3.1711, "totalMonthlyCredits":3.1711,
* "totalPurchasedCredits":0, "totalFreeCredits":0, "periodBasis":"billing-period" }
*
* 使用独立短超时(5s),慢/失败直接返回 null,绝不影响主流程。
*/
private fun fetchUsageSummaryFast(apiKey: String): UsageSummaryPayload? {
val client = okHttpClient.newBuilder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
val request = Request.Builder()
.url("$apiBase/alpha/usage/summary")
.header("Authorization", "Bearer $apiKey")
.header("Accept", "application/json")
.get()
.build()

val response = try {
client.newCall(request).execute()
} catch (_: Exception) {
return null
}

if (!response.isSuccessful) return null
val body = response.body?.string() ?: return null

return try {
val root = json.parseToJsonElement(body).jsonObject
UsageSummaryPayload(
totalMonthlyCredits = root["totalMonthlyCredits"]?.jsonPrimitive?.doubleOrNull,
totalPurchasedCredits = root["totalPurchasedCredits"]?.jsonPrimitive?.doubleOrNull,
totalFreeCredits = root["totalFreeCredits"]?.jsonPrimitive?.doubleOrNull,
periodBasis = root["periodBasis"]?.jsonPrimitive?.contentOrNull
)
} catch (_: Exception) { null }
}

private fun planDisplayName(planId: String): String =
PLAN_NAMES[planId.lowercase()] ?: planId

companion object {
/** 官方月度额度(USD credits):https://commandcode.ai/docs/resources/pricing-limits */
private val PLANS = mapOf(
"individual-go" to 10.0,
"individual-pro" to 30.0,
"individual-goat" to 70.0,
"individual-pro" to 80.0,
"individual-max" to 150.0,
"individual-ultra" to 300.0
"individual-ultra" to 300.0,
"team-pro" to 40.0
)

private val PLAN_NAMES = mapOf(
"individual-go" to "Go",
"individual-goat" to "GOAT",
"individual-pro" to "Pro",
"individual-max" to "Max",
"individual-ultra" to "Ultra"
"individual-ultra" to "Ultra",
"team-pro" to "Team Pro"
)

/** API 返回的是 epoch millis,转为距现在的剩余秒数 */
Expand Down Expand Up @@ -235,4 +300,12 @@ class CommandCodeGoRepository(
val planId: String,
val currentPeriodEnd: String?
)

/** alpha/usage/summary 响应:账单周期内的真实月消耗。 */
private data class UsageSummaryPayload(
val totalMonthlyCredits: Double?,
val totalPurchasedCredits: Double?,
val totalFreeCredits: Double?,
val periodBasis: String?
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,18 @@ class CommandCodeUsageRepository(
if (c !is Credential.SessionCredential) {
throw RepositoryError.InvalidCredential()
}
// 优先用 cookies 列表,否则尝试从 authCookie 字段解析
// 优先用 cookies 列表
if (c.cookies.isNotEmpty()) {
return c.cookies.joinToString("; ") { "${it.name}=${it.value}" }
}
// fallback: 如果用户通过旧版接口存了 authCookie,尝试恢复
throw RepositoryError.InvalidCredential()
// fallback 1: authCookie 字段(OpenCode Go 遗留格式,可能是 commandcode 会话值)
c.authCookie?.takeIf { it.isNotBlank() }?.let { raw ->
val decoded = runCatching { java.net.URLDecoder.decode(raw, "UTF-8") }.getOrDefault(raw)
return "__Secure-commandcode_prod_.session_token=$decoded"
}
// fallback 2: token 字段(用户把 cookie 值填到了 API Key 框?不会走到这里,
// 因为 API Key 在 token 字段用于 Bearer 认证;这里仅防御性处理)
throw RepositoryError.InvalidCredential("Cookie 无效:请在凭据页粘贴 commandcode.ai 的完整 Cookie 字符串(F12 → Application → Cookies → commandcode.ai 全选复制)")
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import com.rainy.token.ui.components.formatAmount
import com.rainy.token.ui.components.formatResetInSec
import com.rainy.token.ui.components.normalizeWindowLabel
import com.rainy.token.ui.theme.StrawberryPink
import com.rainy.token.ui.theme.StatusGreen
import com.rainy.token.ui.theme.StatusOrange
import com.rainy.token.ui.theme.inkMuted
import java.text.SimpleDateFormat
Expand Down Expand Up @@ -208,7 +209,11 @@ internal fun CommandCodeGoUsageWindows(balance: ServiceBalance) {
val windows = listOf(
Triple("5 小时", calcPct(extras["fiveHour.used"]?.toDoubleOrNull(), extras["fiveHour.cap"]?.toDoubleOrNull()), extras["fiveHour.resetInSec"]?.toLongOrNull()),
Triple("本周", calcPct(extras["weekly.used"]?.toDoubleOrNull(), extras["weekly.cap"]?.toDoubleOrNull()), extras["weekly.resetInSec"]?.toLongOrNull()),
Triple("本月", calcPct(balance.monthlySpent, balance.totalQuota), extras["monthly.resetInSec"]?.toLongOrNull())
// 本月:优先 summary 真实已用,回退 总量-剩余
Triple("本月", calcPct(
extras["monthlyUsed"]?.toDoubleOrNull() ?: balance.monthlySpent,
balance.totalQuota
), extras["monthly.resetInSec"]?.toLongOrNull())
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
windows.forEach { (label, pct, resetSec) ->
Expand Down Expand Up @@ -436,7 +441,7 @@ internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) {
color = when {
pct >= 80 -> MaterialTheme.colorScheme.error
pct >= 50 -> StatusOrange
else -> MaterialTheme.colorScheme.onSurface
else -> StatusGreen
}
)
}
Expand All @@ -450,7 +455,7 @@ internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) {
color = when {
pctValue >= 80f -> MaterialTheme.colorScheme.error
pctValue >= 50f -> StatusOrange
else -> StrawberryPink
else -> StatusGreen
},
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Butt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,9 @@ private fun CommandCodeGoUsageCard(state: State) {
val extras = balance?.extras ?: return
val monthlyRemaining = extras["monthlyRemaining"]?.toDoubleOrNull() ?: return
val monthlyTotal = extras["monthlyTotal"]?.toDoubleOrNull()
// 真实月消耗(usage/summary);缺失时回退 总量-剩余
val monthlyUsed = extras["monthlyUsed"]?.toDoubleOrNull()
?: monthlyTotal?.let { total -> total - monthlyRemaining }
val purchased = extras["purchasedCredits"]?.toDoubleOrNull() ?: 0.0
val planName = extras["planName"].orEmpty()

Expand Down Expand Up @@ -295,14 +298,16 @@ private fun CommandCodeGoUsageCard(state: State) {
Spacer(modifier = Modifier.height(14.dp))
}

// 3. 本月(最下面)
if (monthlyTotal != null && monthlyTotal > 0) {
val used = monthlyTotal - monthlyRemaining
// 3. 本月(最下面):已用优先用 summary 真实值
if (monthlyTotal != null && monthlyTotal > 0 && monthlyUsed != null) {
val used = monthlyUsed.coerceIn(0.0, monthlyTotal)
val pct = ((used / monthlyTotal) * 100).toFloat().coerceIn(0f, 100f)
UsageWindowRow(
label = "本月",
pct = pct,
resetInSec = extras["billingPeriodEnd"]?.let { parseIsoDuration(it) }
resetInSec = extras["billingPeriodEnd"]?.toLongOrNull()
?.let { maxOf(0L, (it - System.currentTimeMillis()) / 1000) }
?.takeIf { it > 0 }
)
Spacer(modifier = Modifier.height(4.dp))
Text(
Expand All @@ -329,18 +334,6 @@ private fun CommandCodeGoUsageCard(state: State) {
}
}

/**
* 尝试从 ISO8601 时间戳计算剩余秒数。
*/
private fun parseIsoDuration(isoStr: String): Long? {
return try {
val sdf = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US)
sdf.timeZone = java.util.TimeZone.getTimeZone("UTC")
val end = sdf.parse(isoStr.take(19))?.time ?: return null
maxOf(0L, (end - System.currentTimeMillis()) / 1000)
} catch (_: Exception) { null }
}

/**
* OpenCode Go 专属:3 个窗口(rolling 5h / weekly / monthly)独立用量卡。
*
Expand Down Expand Up @@ -398,6 +391,12 @@ private fun OpenCodeGoWindowsCard(state: State) {
@Composable
private fun UsageWindowRow(label: String, pct: Float?, resetInSec: Long?, decimals: Int = 0) {
val pctValue = (pct ?: 0f).coerceIn(0f, 100f)
// 状态语义色:<50% 绿,50-80% 橙,≥80% 红(进度条 + 百分比数字同色)
val statusColor = when {
pctValue >= 80f -> MaterialTheme.colorScheme.error
pctValue >= 50f -> com.rainy.token.ui.theme.StatusOrange
else -> com.rainy.token.ui.theme.StatusGreen
}
Column {
Row(
modifier = Modifier.fillMaxWidth(),
Expand All @@ -413,7 +412,8 @@ private fun UsageWindowRow(label: String, pct: Float?, resetInSec: Long?, decima
Text(
text = String.format(Locale.US, "%.${decimals}f", pctValue),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
color = statusColor
)
Text(
text = "%",
Expand All @@ -430,11 +430,7 @@ private fun UsageWindowRow(label: String, pct: Float?, resetInSec: Long?, decima
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(3.dp)),
color = when {
pctValue >= 80f -> MaterialTheme.colorScheme.error
pctValue >= 50f -> com.rainy.token.ui.theme.StatusOrange
else -> StrawberryPink
},
color = statusColor,
trackColor = MaterialTheme.colorScheme.surfaceVariant
)
if (resetInSec != null && resetInSec > 0) {
Expand Down
Loading