feat: 新增 Trae / WorkBuddy / Sub2API 三家供应商接入与凭据管理

This commit is contained in:
Liuxinyu176 2026-09-11 23:37:04 +08:00
parent 3bef8cc3f1
commit e4cd9a36f6
12 changed files with 1440 additions and 5 deletions

View File

@ -350,6 +350,23 @@ class CredentialRepository @Inject constructor(
field("refreshToken", credential.refreshToken)
}
}
is Credential.TraeCredential -> {
field("type", "trae")
field("jwt", credential.jwt)
field("region", credential.region)
}
is Credential.WorkBuddyCredential -> {
// WorkBuddy 无公开稳定账户 ID;access/refresh token 会话内会轮换,
// 若把 token 纳入身份,每次轮换都会误清余额缓存。这里用固定身份:
// 余额缓存归属同一账号(换账号场景由下一次成功刷新直接覆盖)。
field("type", "workbuddy")
field("identity", "account")
}
is Credential.Sub2ApiCredential -> {
field("type", "sub2api")
field("baseUrl", credential.baseUrl)
field("email", credential.email)
}
}
}
return sha256(material)
@ -396,6 +413,24 @@ class CredentialRepository @Inject constructor(
field("refreshToken", credential.refreshToken)
field("accountId", credential.accountId)
}
is Credential.TraeCredential -> {
field("type", "trae")
field("jwt", credential.jwt)
field("region", credential.region)
}
is Credential.WorkBuddyCredential -> {
field("type", "workbuddy")
field("accessToken", credential.accessToken)
field("refreshToken", credential.refreshToken)
field("expiresAt", credential.expiresAt.toString())
}
is Credential.Sub2ApiCredential -> {
field("type", "sub2api")
field("baseUrl", credential.baseUrl)
field("email", credential.email)
field("password", credential.password)
field("apiKey", credential.apiKey)
}
}
}
return sha256(material)

View File

@ -0,0 +1,374 @@
package com.rainy.token.data.repository
import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.model.Sub2DailyUsage
import com.rainy.token.domain.model.Sub2ModelStat
import com.rainy.token.domain.model.Sub2UsageDetail
import com.rainy.token.domain.service.ServiceType
import java.util.TimeZone
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Sub2API 余额仓库(自托管 mid 层/聚合面板,可配置任意实例)。
*
* 面向【普通用户】——任何持有本实例 `sk-` 下游 Key 的用户都能查自己的余额,
* 不需要管理员凭据(2026-09 实测 xxcsn.site 确认):
* - 认证:`Authorization: Bearer <API Key>`(sk-…)
* 备选:无 API Key 时 POST {base}/api/v1/auth/login {email,password} → Bearer token
* - 查询:GET {base}/v1/usage (部分实例是 /api/v1/usage,自动兜底)
*
* 真实响应(实测结构):
* ```json
* {
* "remaining": 302.29, "balance": 302.29, "unit": "USD", "planName": "钱包余额",
* "isValid": true, "mode": "unrestricted",
* "usage": { "total": {"cost": 32.06, "actual_cost": ...}, "today": {"cost": 4.03, ...} },
* "model_stats": [ {"model":"deepseek-v4-flash","requests":..,"total_tokens":..,"cost":..}, ... ],
* "daily_usage": [ {"date":"2026-09-04","requests":..,"cost":..}, ... ]
* }
* ```
* 字段名多版兼容(remaining/balance、unit/currency、cost/actual_cost),逐层防御扫描。
*/
@Singleton
class Sub2ApiRepository constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
private val balanceCache: BalanceCache
) {
private val json = Json { ignoreUnknownKeys = true }
private val jsonMedia = "application/json".toMediaType()
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.SUB2API)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
if (credential !is Credential.Sub2ApiCredential) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
val base = normalizeBase(credential.baseUrl)
if (base.isBlank()) return@withContext Result.failure(RepositoryError.InvalidCredential())
val auth = resolveAuth(base, credential) ?: return@withContext Result.failure(
RepositoryError.InvalidCredential("Sub2API 认证失败:请确认 API Key 有效,或用面板账号邮箱+密码")
)
// 主端点 /v1/usage;404 时兜底 /api/v1/usage。
// 带 days=90 多取每日明细(官方前端同款参数,网关不支持时忽略参数仍 200),
// timezone 保证 daily_usage 的 date 按用户所在时区聚合。
val usageQuery = "days=90&timezone=" + java.net.URLEncoder.encode(TimeZone.getDefault().id, "UTF-8")
val resp = get(base, "/v1/usage?$usageQuery", auth)
?.takeIf { it.code != 404 && it.code != 400 }
?: get(base, "/api/v1/usage?$usageQuery", auth)
?.takeIf { it.code != 404 && it.code != 400 }
// 严格网关不认 days/timezone 参数(400)时,回退无参裸端点
?: get(base, "/v1/usage", auth)
?.takeIf { it.code != 404 }
?: get(base, "/api/v1/usage", auth)
?: return@withContext Result.failure(RepositoryError.Unknown())
if (resp.code !in 200..299) {
return@withContext Result.failure(mapSubHttpError(resp.code))
}
val stats = runCatching { parseUsageStats(resp.body) }.getOrElse { e ->
return@withContext Result.failure(
RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "sub2api /v1/usage 解析失败: ${e.message}")
)
}
val unit = stats.unit ?: ServiceConfigProvider.get(ServiceType.SUB2API).displayUnit
val balance = ServiceBalance(
service = ServiceType.SUB2API,
amount = stats.remaining,
unit = unit,
isAvailable = stats.valid,
monthlySpent = stats.totalSpent,
totalQuota = stats.totalQuota,
extras = stats.extras,
sub2UsageDetail = parseUsageDetail(resp.body).takeIf { it.isNotEmpty }
)
balanceCache.put(ServiceType.SUB2API, balance)
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()))
Result.success(balance)
}
// ---------- 认证 ----------
/** 返回可用认证头;失败返回 null。有 API Key 走 Bearer(普通用户推荐),否则登录换 token。 */
private fun resolveAuth(base: String, credential: Credential.Sub2ApiCredential): AuthHeader? {
if (!credential.apiKey.isNullOrBlank()) {
return AuthHeader("Authorization", "Bearer ${credential.apiKey.trim()}")
}
val body = buildJsonObject {
put("email", credential.email)
put("password", credential.password)
}.toString()
val loginUrl = "$base/api/v1/auth/login"
val request = Request.Builder()
.url(loginUrl)
.addHeader("Content-Type", "application/json")
.post(body.toRequestBody(jsonMedia))
.build()
return try {
okHttpClient.newCall(request).execute().use { resp ->
val text = resp.body?.string().orEmpty()
val token = when {
resp.isSuccessful -> findToken(text)
resp.code == 423 -> {
// 登录前可能要求先接受合规条款
acceptCompliance(base)
okHttpClient.newCall(
Request.Builder()
.url(loginUrl)
.addHeader("Content-Type", "application/json")
.post(body.toRequestBody(jsonMedia))
.build()
).execute().use { retry ->
val retryBody = retry.body?.string().orEmpty()
if (retry.isSuccessful) findToken(retryBody) else null
}
}
else -> null
}
if (token != null) AuthHeader("Authorization", "Bearer $token") else null
}
} catch (_: Throwable) {
null
}
}
private fun findToken(text: String): String? {
return runCatching {
val obj = json.parseToJsonElement(text).jsonObject
val candidates = listOf(obj, obj["data"]?.jsonObject).filterNotNull()
var first: String? = null
outer@ for (root in candidates) {
for (key in listOf("access_token", "accessToken", "token", "api_token", "apiKey")) {
val value = root[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
if (value != null) {
first = value
break@outer
}
}
}
first
}.getOrNull()
}
/**
* 登录引导专用:个别面板要求先 POST 一次 admin 作用域的 compliance/accept 才能继续登录。
* 仅作为 login 前置步骤使用,异常被吞、失败不影响余额主链路(余额查询本身走用户级 /v1/usage,
* 不依赖任何 admin 权限)。勿据此误以为集成依赖管理员权限。
*/
private fun acceptCompliance(base: String) {
try {
okHttpClient.newCall(
Request.Builder()
.url("$base/api/v1/admin/compliance/accept")
.post("{}".toRequestBody(jsonMedia))
.build()
).execute().close()
} catch (_: Throwable) {
// 忽略;重试由外层决定
}
}
// ---------- 请求 ----------
private data class SubApiResponse(val code: Int, val body: String)
private fun get(base: String, path: String, auth: AuthHeader): SubApiResponse? =
runCatching {
okHttpClient.newCall(
Request.Builder()
.url("$base$path")
.header(auth.name, auth.value)
.get()
.build()
).execute().let { resp ->
SubApiResponse(resp.code, resp.body?.string().orEmpty())
}
}.getOrNull()
private fun mapSubHttpError(code: Int): RepositoryError = when (code) {
401, 403 -> RepositoryError.InvalidCredential()
423 -> RepositoryError.ServerError(423)
429 -> RepositoryError.RateLimited()
in 500..599 -> RepositoryError.ServerError(code)
else -> RepositoryError.Unknown()
}
// ---------- 解析 ----------
private data class UsageStats(
val remaining: Double,
val unit: String?,
val valid: Boolean,
val totalSpent: Double?,
val totalQuota: Double?,
val extras: Map<String, String>
)
private fun parseUsageStats(body: String): UsageStats {
val root = json.parseToJsonElement(body).jsonObject
val top = root["data"]?.jsonObject ?: root
// 所有余额/用量字段:面板至少给出其一才算「有数据」,否则视为无法展示(isAvailable=false),
// 避免合法 JSON 但无任何余额字段时误显示 $0.00(对齐 WorkBuddy 无数据即不可用的语义)。
val remainingRaw = top.numberOr("remaining", "balance", "credits", "quota", "amount")
val remaining = remainingRaw ?: 0.0
val unit = top.scalar("unit", "currency", "symbol")
// 消费:usage.total.cost / usage.total.actual_cost / total_cost / monthly_spent
val usageObj = top["usage"]?.jsonObject
val totalObj = usageObj?.get("total")?.jsonObject
val totalSpent = (totalObj?.numberOr("cost", "actual_cost"))
?: top.numberOr("total_cost", "monthly_spent", "spent")
// 总额度:仅当面板给出明确上限(订阅/配额型)时设置,钱包余额型不设
val totalQuota = top.numberOr("total_quota", "limit", "quota_limit")
?.takeIf { it >= 0 }
val explicitValid = top["isValid"]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull()
?: top["valid"]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull()
val hasData = remainingRaw != null || totalSpent != null || totalQuota != null
val valid = explicitValid ?: hasData
val extras = linkedMapOf<String, String>()
top.scalar("planName", "plan_name", "plan")?.let { extras["plan"] = it }
top.scalar("mode")?.let { extras["mode"] = it }
extras["valid"] = valid.toString()
top.scalar("remaining")?.let { extras["remaining"] = it }
top.scalar("unit")?.let { extras["unit"] = it }
(totalObj?.numberOr("cost", "actual_cost"))?.let { extras["total_cost"] = it.toString() }
usageObj?.get("today")?.jsonObject?.numberOr("cost", "actual_cost")?.let {
extras["today_cost"] = it.toString()
}
// 分模型:全量结构化解析(供筛选/排序),同时保留前 3 个键进 extras 兼容旧 UI。
val modelStats = top.parseModelStats()
modelStats.take(3).forEach { stat ->
val label = buildString {
if (stat.totalTokens > 0) append(stat.totalTokens).append(" tok")
if (stat.totalTokens > 0 && stat.cost > 0) append(" · ")
if (stat.cost > 0) append("$").append(trimNum(stat.cost))
}.ifBlank { "-" }
extras["model:" + stat.model] = label
}
// 每日明细:全量结构化解析(按日期升序,供时间窗口切片)。保留旧 days 键。
val dailyUsage = top.parseDailyUsage()
if (dailyUsage.isNotEmpty()) extras["days"] = dailyUsage.size.toString() + " d"
return UsageStats(remaining, unit, valid, totalSpent, totalQuota, extras)
}
/**
* 独立解析 /v1/usage 响应中的结构化明细(model_stats + daily_usage),供 UI 与单测使用。
* 响应字段多版本兼容:外层可能是 {data:{...}} 也可能是平铺;解析失败返回空明细。
*/
internal fun parseUsageDetail(body: String): Sub2UsageDetail {
val ele = runCatching { json.parseToJsonElement(body) }.getOrNull() ?: return Sub2UsageDetail()
val root = ele.jsonObject
val top = root["data"]?.jsonObject ?: root
return Sub2UsageDetail(
modelStats = top.parseModelStats(),
dailyUsage = top.parseDailyUsage()
)
}
private fun trimNum(v: Double): String = v.toString().trimEnd('0', '.')
private fun JsonObject.scalar(vararg keys: String): String? {
for (key in keys) {
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it }
}
return null
}
/** 仅当字段确实为 JSON 数组时返回(类型错误防御,不抛异常)。 */
private fun JsonObject.safeArray(key: String): kotlinx.serialization.json.JsonArray? =
runCatching { this[key]?.jsonArray }.getOrNull()
/** 平铺多候选整数值扫描(防御数字字符串/纯数字两种形态)。 */
private fun JsonObject.longOr(vararg keys: String): Long? {
for (key in keys) {
this[key]?.let { el ->
(el as? JsonPrimitive)?.contentOrNull?.toLongOrNull()?.let { return it }
}
}
return null
}
/** 解析 model_stats[]:字段名多版本兼容,行级失败跳过。 */
private fun JsonObject.parseModelStats(): List<Sub2ModelStat> =
(safeArray("model_stats") ?: return emptyList()).mapNotNull { el ->
runCatching {
val o = el.jsonObject
val name = o.scalar("model", "model_name", "name", "id") ?: return@mapNotNull null
val input = o.longOr("input_tokens", "prompt_tokens") ?: 0
val output = o.longOr("output_tokens", "completion_tokens") ?: 0
Sub2ModelStat(
model = name,
requests = o.longOr("requests", "request_count") ?: 0,
inputTokens = input,
outputTokens = output,
totalTokens = o.longOr("total_tokens") ?: (input + output),
cost = o.numberOr("cost", "actual_cost", "amount") ?: 0.0
)
}.getOrNull()
}
/** 解析 daily_usage[]:date + 请求数/tokens/cost;行级失败跳过;统一按日期升序(防御网关乱序)。 */
private fun JsonObject.parseDailyUsage(): List<Sub2DailyUsage> =
(safeArray("daily_usage") ?: return emptyList()).mapNotNull { el ->
runCatching {
val o = el.jsonObject
val date = o.scalar("date", "day") ?: return@mapNotNull null
val input = o.longOr("input_tokens", "prompt_tokens") ?: 0
val output = o.longOr("output_tokens", "completion_tokens") ?: 0
Sub2DailyUsage(
date = date,
requests = o.longOr("requests") ?: 0,
totalTokens = o.longOr("total_tokens", "tokens") ?: (input + output),
cost = o.numberOr("cost", "actual_cost") ?: 0.0
)
}.getOrNull()
}.sortedBy { it.date }
private fun JsonObject.numberOr(vararg keys: String): Double? {
for (key in keys) {
this[key]?.let { el ->
(el as? JsonPrimitive)?.doubleOrNull?.let { return it }
(el as? JsonPrimitive)?.contentOrNull?.toDoubleOrNull()?.let { return it }
}
}
return null
}
private fun normalizeBase(url: String): String {
var base = url.trim()
if (base.isBlank()) return ""
if (!base.startsWith("http://") && !base.startsWith("https://")) base = "https://$base"
while (base.endsWith("/")) base = base.dropLast(1)
return base
}
private data class AuthHeader(val name: String, val value: String)
}

View File

@ -0,0 +1,210 @@
package com.rainy.token.data.repository
import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceType
import java.io.IOException
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonArray
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
/**
* Trae(CN / Intl)余额仓库。
*
* 认证:官方 IDE/Web 用的 `Authorization: Cloud-IDE-JWT <jwt>`(RS256,~14 天)。区域只决定域名:
* - CN :POST https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage
* - INTL:POST https://grow-normal.trae.ai/trae/api/v1/pay/ide_user_ent_usage
* 2026-09 用真实登录态实测(api.trae.cn):**仅带 Authorization + Content-Type 即成功**,
* X-User-Region / X-Device-Id 均非必需(官方 Web 不带),故不再发送,避免设备绑定误伤。
*
* 真实响应(实测结构):
* ```json
* {
* "is_credits_billing": true,
* "usage_summary": {"consumed_amount":3341.17,"consumption_ratio":0.613,"total_amount":5450},
* "user_entitlement_pack_list": [ {
* "display_desc":"签到奖励",
* "entitlement_base_info":{"quota":{"credits_limit":150}, ...},
* "usage":{"credits_amount":142.18}, ...
* } ]
* }
* ```
* 余额权威口径 = `usage_summary.total_amount - consumed_amount`(>=0);usage_summary 缺失时
* 才退回包级求和(credits_limit - credits_amount,限流/无限包不计)。字段名防御式扫描。
*/
@Singleton
class TraeRepository constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
private val balanceCache: BalanceCache
) {
private val json = Json { ignoreUnknownKeys = true }
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
if (credential !is Credential.TraeCredential || credential.jwt.isBlank()) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN"
val url = when (regionKey) {
"INTL" -> "https://grow-normal.trae.ai/trae/api/v1/pay/ide_user_ent_usage"
else -> "https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage"
}
val request = Request.Builder()
.url(url)
.addHeader("Authorization", "Cloud-IDE-JWT ${credential.jwt.trim()}")
.addHeader("Content-Type", "application/json")
.post("{}".toRequestBody("application/json".toMediaType()))
.build()
val body = try {
okHttpClient.newCall(request).execute().use { resp ->
val text = resp.body?.string().orEmpty()
if (!resp.isSuccessful) {
return@withContext Result.failure(mapHttpError(resp.code))
}
text
}
} catch (e: IOException) {
return@withContext Result.failure(RepositoryError.Network(e))
} catch (e: Throwable) {
return@withContext Result.failure(RepositoryError.Unknown(e))
}
val parsed = runCatching { parseBody(body) }
.getOrElse { e ->
return@withContext Result.failure(
RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "trae 解析失败: ${e.message}")
)
}
val balance = ServiceBalance(
service = ServiceType.TRAE,
amount = parsed.amount,
unit = "Credits",
totalQuota = parsed.totalQuota,
extras = parsed.extras
)
balanceCache.put(ServiceType.TRAE, balance)
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()))
Result.success(balance)
}
/** 解析:usage_summary 权威优先;缺失时回退权益包求和。字段缺失跳过,不抛异常。 */
private fun parseBody(body: String): ParsedTraeUsage {
val root = json.parseToJsonElement(body).jsonObject
val extras = linkedMapOf<String, String>()
root["is_credits_billing"]?.jsonPrimitive?.contentOrNull?.let { extras["is_credits_billing"] = it }
root["is_dollar_usage_billing"]?.jsonPrimitive?.contentOrNull?.let { extras["is_dollar_usage_billing"] = it }
root["is_pay_freshman"]?.jsonPrimitive?.contentOrNull?.let { extras["is_new_user"] = it }
// 1) 权威汇总
val summary = root["usage_summary"]?.jsonObject
val total = summary?.numberOr("total_amount")
val consumed = summary?.numberOr("consumed_amount")
summary?.let { s ->
s.numberOr("consumed_amount")?.let { extras["consumed_credits"] = trimNum(it) }
s.numberOr("consumption_ratio")?.let { extras["consumption_ratio"] = trimNum(it) }
s.numberOr("total_amount")?.let { extras["total_credits"] = trimNum(it) }
}
// 2) 权益包明细(前若干条 + 计数),同时累计兜底求和
var fallbackSum = 0.0
var packShown = 0
var packCount = 0
val packs = root["user_entitlement_pack_list"]?.jsonArray
packs?.forEachIndexed { index, packEl ->
val pack = packEl.jsonObject
val base = pack["entitlement_base_info"]?.jsonObject
val quota = base?.get("quota")?.jsonObject
val name = pack.getString("display_desc") ?: base?.getString("name") ?: "pack[$index]"
val limit = quota?.getNumber("credits_limit")?.toLong()
val used = pack.getNumber("usage", "credits_amount")
?: base?.get("usage")?.jsonObject?.get("credits_amount")?.safeDouble()
packCount++
when {
limit == null -> { /* 结构变化:跳过 */ }
limit < 0 -> extras["$name.unlimited"] = "true"
else -> {
val remaining = (limit - (used ?: 0.0)).coerceAtLeast(0.0)
fallbackSum += remaining
if (packShown < 8) {
extras["$name.limit"] = limit.toString()
extras["$name.used"] = used?.let { trimNum(it) } ?: "?"
packShown++
}
}
}
}
if (packCount > 0) extras["pack_count"] = packCount.toString()
// 3) 金额:汇总优先,缺省回退包级
val amount = if (total != null && consumed != null) {
(total - consumed).coerceAtLeast(0.0)
} else fallbackSum
val totalQuota = total?.takeIf { it >= 0 }
return ParsedTraeUsage(amount = amount, totalQuota = totalQuota, extras = extras)
}
private fun JsonObject.getString(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull
private fun JsonElement.safeDouble(): Double? =
runCatching { jsonPrimitive.content.toDoubleOrNull() }.getOrNull()
/** 按路径逐层取数:如 getNumber("usage","credits_amount") 取 this.usage.credits_amount。 */
private fun JsonObject.getNumber(vararg keys: String): Double? {
if (keys.isEmpty()) return null
var node: JsonObject? = this
for (i in keys.indices) {
val el = node?.get(keys[i]) ?: return null
if (i == keys.lastIndex) return el.safeDouble()
node = runCatching { el.jsonObject }.getOrNull()
}
return null
}
/** 平铺多候选数值扫描。 */
private fun JsonObject.numberOr(vararg keys: String): Double? {
for (key in keys) {
this[key]?.let { el ->
(el as? JsonPrimitive)?.doubleOrNull?.let { return it }
(el as? JsonPrimitive)?.contentOrNull?.toDoubleOrNull()?.let { return it }
}
}
return null
}
private fun mapHttpError(code: Int): RepositoryError = when (code) {
401, 403 -> RepositoryError.InvalidCredential()
429 -> RepositoryError.RateLimited()
in 500..599 -> RepositoryError.ServerError(code)
else -> RepositoryError.Unknown()
}
private fun trimNum(v: Double): String = v.toString().trimEnd('0', '.')
private data class ParsedTraeUsage(
val amount: Double,
val totalQuota: Double?,
val extras: Map<String, String>
)
}

View File

@ -0,0 +1,298 @@
package com.rainy.token.data.repository
import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceType
import java.io.IOException
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* 腾讯 WorkBuddy 余额仓库。
*
* 认证:`Authorization: Bearer <accessToken>`;accessToken 过期时用 refreshToken 轮换一次并重试
* (同 Codex 语义):POST https://copilot.tencent.com/v2/plugin/auth/token/refresh(X-Refresh-Token 头)。
*
* 资源余额(2026-09 用真实登录态在官网个人中心实测,workbuddy.cn):
* - 主端点:POST https://www.workbuddy.cn/billing/meter/get-user-resource-summary body `{}`
* 真实响应:{"code":0,"data":{"Packages":[{"PackageCode":"TCACA_...","CycleTotalCapacity":"7826",
* "CycleRemainCapacity":"2671.96","CycleUsedCapacity":"5154.04","CycleFrozenCapacity":"0",
* "CapacityUnit":"credits"}, ...], "IsPaidUser":false}}
* - 兜底:POST https://www.codebuddy.cn/v2/billing/meter/get-user-resource(官方 CLI 插件模型,
* ProductCode p_tcaca / Status 0,3),汇总 Packages/Accounts[] 同族字段。
* 余额 = 各包 CycleRemainCapacity 之和;单位取 CapacityUnit(credits)。
* 字段名随官方迭代可能变化,解析保持防御式(多候选扫描)。
*/
@Singleton
class WorkBuddyRepository constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
private val balanceCache: BalanceCache
) {
private val json = Json { ignoreUnknownKeys = true }
private val jsonMedia = "application/json".toMediaType()
private val codebuddyBody = buildJsonObject {
put("PageNumber", 1)
put("PageSize", 100)
put("ProductCode", "p_tcaca")
put("Status", buildJsonArray { add(JsonPrimitive(0)); add(JsonPrimitive(3)) })
put("Channel", "P")
put("Unsubmitted", false)
put("Unit", "")
put("QueryResourceId", buildJsonArray { })
}.toString()
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.WORKBUDDY)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
if (credential !is Credential.WorkBuddyCredential) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
var access = credential.accessToken
var refresh = credential.refreshToken
// hostMode 0 = workbuddy.cn summary(实测主路径);1 = codebuddy.cn 兜底
var hostMode = 0
var attempts = 0
var lastBody = ""
while (attempts < 3) {
attempts++
val request = if (hostMode == 0) summaryRequest(access) else codebuddyRequest(access)
val resp = try {
okHttpClient.newCall(request).execute()
} catch (e: IOException) {
return@withContext Result.failure(RepositoryError.Network(e))
} catch (e: Throwable) {
return@withContext Result.failure(RepositoryError.Unknown(e))
}
val code = resp.code
val body = runCatching { resp.body?.string().orEmpty() }.getOrDefault("")
resp.close()
if (code in 200..299) {
lastBody = body
break
}
when (code) {
404 -> {
if (hostMode == 0) { hostMode = 1; continue }
return@withContext Result.failure(mapHttpError(code, body))
}
401, 403 -> {
val refreshed = tryRefesh(refresh) ?: return@withContext Result.failure(
RepositoryError.InvalidCredential()
)
// 暂存轮换后的凭据,随本次成功提交(RefreshWriteSession 机制)
credentialRepository.save(
credential.copy(
accessToken = refreshed.accessToken,
refreshToken = refreshed.refreshToken,
expiresAt = if (refreshed.expiresIn > 0) {
System.currentTimeMillis() + refreshed.expiresIn * 1000
} else {
credential.expiresAt.coerceAtLeast(System.currentTimeMillis())
}
)
)
access = refreshed.accessToken
refresh = refreshed.refreshToken
continue
}
else -> return@withContext Result.failure(mapHttpError(code, body))
}
}
if (lastBody.isBlank()) {
return@withContext Result.failure(
RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "workbuddy 响应为空")
)
}
val parsed = runCatching { parseResource(lastBody) }.getOrElse { e ->
return@withContext Result.failure(
RepositoryError.ParseError(
RepositoryError.ParseErrorReason.MALFORMED_RESPONSE,
"workbuddy 资源解析失败: ${e.message}"
)
)
}
val balance = ServiceBalance(
service = ServiceType.WORKBUDDY,
amount = parsed.amount,
unit = parsed.unit ?: "Credits",
monthlySpent = parsed.used.takeIf { it > 0 },
totalQuota = parsed.size.takeIf { it > 0 },
isAvailable = parsed.amount > 0 || parsed.size > 0,
extras = parsed.extras
)
balanceCache.put(ServiceType.WORKBUDDY, balance)
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()))
Result.success(balance)
}
/** workbuddy.cn 官网个人中心实测端点(2026-09 确认 200)。 */
private fun summaryRequest(accessToken: String): Request =
Request.Builder()
.url("https://www.workbuddy.cn/billing/meter/get-user-resource-summary")
.addHeader("Authorization", "Bearer $accessToken")
.addHeader("Content-Type", "application/json")
.post("{}".toRequestBody(jsonMedia))
.build()
/** codebuddy.cn 官方 CLI 插件模型(桌面/CLI accessToken 兜底)。 */
private fun codebuddyRequest(accessToken: String): Request =
Request.Builder()
.url("https://www.codebuddy.cn/v2/billing/meter/get-user-resource")
.addHeader("Authorization", "Bearer $accessToken")
.addHeader("Content-Type", "application/json")
.post(codebuddyBody.toRequestBody(jsonMedia))
.build()
/**
* refresh_token 单次轮换:POST token/refresh(X-Refresh-Token 头)。
* 只读取并返回新凭据;轮换后的提交交给主流程的 save(同一成功才落库)。
*/
private fun tryRefesh(refreshToken: String): RefreshedWorkBuddy? {
val request = Request.Builder()
.url("https://copilot.tencent.com/v2/plugin/auth/token/refresh")
.addHeader("X-Refresh-Token", refreshToken)
.post("{}".toRequestBody(jsonMedia))
.build()
return try {
okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return@use null
val text = resp.body?.string().orEmpty()
val obj = json.parseToJsonElement(text).jsonObject
val access = obj.readToken("access_token", "accessToken")
?: obj["data"]?.jsonObject?.readToken("access_token", "accessToken")
?: return@use null
val refresh = obj.readToken("refresh_token", "refreshToken")
?: obj["data"]?.jsonObject?.readToken("refresh_token", "refreshToken")
?: refreshToken
val expiresIn = obj["expires_in"]?.jsonPrimitive?.longOrNull ?: 0L
RefreshedWorkBuddy(access, refresh, expiresIn)
}
} catch (_: Throwable) {
null
}
}
private fun JsonObject.readToken(vararg keys: String): String? {
for (key in keys) {
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it }
}
return null
}
/** 解析 Packages/Accounts:CycleRemain/Used/TotalCapacity(多候选)。无任何包/数据时抛异常。 */
private fun parseResource(body: String): ParsedWorkBuddy {
val root = json.parseToJsonElement(body).jsonObject
val data = root["data"]?.jsonObject ?: root
val packages = data["Packages"]?.jsonArray ?: data["Accounts"]?.jsonArray ?: root["Packages"]?.jsonArray
val list = packages?.mapNotNull { it.jsonObject.takeIf { o -> o.isNotEmpty() } } ?: emptyList()
if (list.isEmpty()) {
throw IllegalArgumentException("workbuddy 响应无 Packages/Accounts 数据")
}
var amount = 0.0
var used = 0.0
var size = 0.0
var frozen = 0.0
var unitRaw: String? = null
val extras = linkedMapOf<String, String>()
for (item in list.take(20)) {
val remain = item.number("CycleRemainCapacity", "CycleCapacityRemain", "Remain", "balance") ?: 0.0
val u = item.number("CycleUsedCapacity", "CycleCapacityUsed", "Used") ?: 0.0
val s = item.number("CycleTotalCapacity", "CycleCapacitySize", "Size", "Total") ?: 0.0
val f = item.number("CycleFrozenCapacity", "Frozen") ?: 0.0
amount += remain
used += u
size += s
frozen += f
unitRaw = unitRaw ?: item.scalar("CapacityUnit", "unit", "currency", "Unit")
val name = item.scalar("ProductName", "PackageCode", "ResourceName", "name", "ResourceId")
if (!name.isNullOrBlank() && extras.size < 3) {
extras[name] = "${trimNum(remain)} ${normalizeUnit(unitRaw) ?: "Credits"}"
}
}
if (list.size > 0) extras["packages"] = list.size.toString()
if (frozen > 0) extras["frozen"] = trimNum(frozen)
data["IsPaidUser"]?.jsonPrimitive?.contentOrNull?.let { extras["is_paid_user"] = it }
data["SubscriptionPackageCode"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
?.let { extras["subscription"] = "true" }
return ParsedWorkBuddy(
amount = amount,
used = used,
size = size,
unit = normalizeUnit(unitRaw),
extras = extras
)
}
/** 官方字段 credits → 展示单位 Credits;其它原样透传。 */
private fun normalizeUnit(raw: String?): String? {
if (raw.isNullOrBlank()) return null
return if (raw.equals("credits", ignoreCase = true)) "Credits" else raw
}
private fun trimNum(v: Double): String = v.toString().trimEnd('0', '.')
private fun JsonObject.number(vararg keys: String): Double? {
for (key in keys) {
this[key]?.jsonPrimitive?.doubleOrNull?.let { return it }
this[key]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()?.let { return it }
}
return null
}
private fun JsonObject.scalar(vararg keys: String): String? {
for (key in keys) {
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it }
}
return null
}
private fun mapHttpError(code: Int, body: String): RepositoryError = when (code) {
401, 403 -> RepositoryError.InvalidCredential()
429 -> RepositoryError.RateLimited()
423 -> RepositoryError.ServerError(code)
in 500..599 -> RepositoryError.ServerError(code)
else -> RepositoryError.Unknown()
}
private data class RefreshedWorkBuddy(
val accessToken: String,
val refreshToken: String,
val expiresIn: Long
)
private data class ParsedWorkBuddy(
val amount: Double,
val used: Double,
val size: Double,
val unit: String?,
val extras: Map<String, String>
)
}

View File

@ -17,6 +17,9 @@ import com.rainy.token.data.repository.CommandCodeUsageRepository
import com.rainy.token.data.repository.OpenCodeGoRepository
import com.rainy.token.data.repository.OpenCodeUsageRepository
import com.rainy.token.data.repository.OllamaRepository
import com.rainy.token.data.repository.Sub2ApiRepository
import com.rainy.token.data.repository.TraeRepository
import com.rainy.token.data.repository.WorkBuddyRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -176,6 +179,39 @@ object NetworkModule {
balanceCache: BalanceCache
): OllamaRepository = OllamaRepository(okHttpClient, credentialRepository, balanceCache)
/**
* Trae 仓库:Cloud-IDE-JWT 认证 + REST。
*/
@Provides
@Singleton
fun provideTraeRepository(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository,
balanceCache: BalanceCache
): TraeRepository = TraeRepository(okHttpClient, credentialRepository, balanceCache)
/**
* WorkBuddy 仓库:OAuth Bearer + get-user-resource。
*/
@Provides
@Singleton
fun provideWorkBuddyRepository(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository,
balanceCache: BalanceCache
): WorkBuddyRepository = WorkBuddyRepository(okHttpClient, credentialRepository, balanceCache)
/**
* Sub2API 仓库:自托管实例(凭据里的 baseUrl)+ 管理面板 API。
*/
@Provides
@Singleton
fun provideSub2ApiRepository(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository,
balanceCache: BalanceCache
): Sub2ApiRepository = Sub2ApiRepository(okHttpClient, credentialRepository, balanceCache)
/** 余额缓存 DataStore(计划 7.1) */
@Provides
@Singleton

View File

@ -62,6 +62,55 @@ sealed class Credential {
val expiresAt: Long,
override val lastVerifiedAt: Long = 0L
) : Credential()
/**
* 用于 Trae(CN/Intl):官方 IDE 扩展使用的 Cloud-IDE-JWT。
* 单个 JWT(~14 天 RS256),配 [region] 决定域名与请求头。
*/
@Serializable
data class TraeCredential(
override val service: ServiceType,
/** `Authorization: Cloud-IDE-JWT <jwt>` 的 JWT 值 */
val jwt: String,
/** 区域:"CN"(api.trae.cn)/ "INTL"(grow-normal.trae.ai) */
val region: String = "CN",
override val lastVerifiedAt: Long = 0L
) : Credential()
/**
* 用于腾讯 WorkBuddy(AI 办公 Agent,积分与 CodeBuddy 同体系)。
* 复用 Codex 的 OAuth Bearer 模式:accessToken + refreshToken(单次轮换)。
*/
@Serializable
data class WorkBuddyCredential(
override val service: ServiceType,
val accessToken: String,
val refreshToken: String,
/** 过期时间(epoch millis),0=未知 */
val expiresAt: Long = 0L,
override val lastVerifiedAt: Long = 0L
) : Credential()
/**
* 用于 Sub2API(自建 mid 层/聚合面板,任意用户填自己的实例地址)。
* 走【用户级】查询:普通用户填实例分配给他的 sk- API Key 即可查余额,无需管理员。
* 实测(xxcsn.site):GET {base}/v1/usage + Authorization: Bearer <apiKey>
* 返回 remaining/balance/unit/usage.total.cost/model_stats 等。
* 无 API Key 时可用面板邮箱+密码登录换取 Bearer token 作为备选。
*/
@Serializable
data class Sub2ApiCredential(
override val service: ServiceType,
/** 自托管实例基址,如 https://api.example.com(自动去尾斜杠) */
val baseUrl: String,
/** 面板登录邮箱(无 API Key 的备选认证) */
val email: String,
/** 面板登录密码(无 API Key 的备选认证) */
val password: String,
/** 用户级 API Key(sk-…),存在时优先于 email/password 认证 */
val apiKey: String? = null,
override val lastVerifiedAt: Long = 0L
) : Credential()
}
/**

View File

@ -10,6 +10,9 @@ enum class FetchMethod {
/** WebView 登录后抓取 HTML/内部 JSON API */
WEBVIEW_SCRAPER,
/** 用户自托管实例的 REST API(地址在凭据里,如 Sub2API) */
SELF_HOSTED_REST,
/** 完全手动输入(已降级) */
MANUAL
}
@ -24,6 +27,8 @@ data class ServiceConfig(
val loginUrl: String,
/** 余额/配额的展示单位(如 "¥" / "$" / "Credits" / "requests") */
val displayUnit: String,
/** 外部操作页 URL(如 DeepSeek 充值页),App 内 WebView 打开,可空 */
val externalUrl: String? = null,
/** 触发降级的连续失败次数阈值(计划 5.4 / 6.3) */
val degradationFailureThreshold: Int = 3,
/** Cookie 有效期 < 该值则触发降级(小时) */
@ -40,7 +45,9 @@ object ServiceConfigProvider {
type = ServiceType.DEEPSEEK,
method = FetchMethod.REST_API,
loginUrl = "", // REST 模式不需要登录 URL
displayUnit = "¥"
displayUnit = "¥",
// App 内 WebView 打开的官方充值页
externalUrl = "https://platform.deepseek.com/top_up"
),
ServiceType.OPENCODE_GO to ServiceConfig(
type = ServiceType.OPENCODE_GO,
@ -66,6 +73,25 @@ object ServiceConfigProvider {
method = FetchMethod.WEBVIEW_SCRAPER,
loginUrl = "https://ollama.com/settings",
displayUnit = "%"
),
ServiceType.TRAE to ServiceConfig(
type = ServiceType.TRAE,
method = FetchMethod.REST_API,
loginUrl = "",
displayUnit = "Credits"
),
ServiceType.WORKBUDDY to ServiceConfig(
type = ServiceType.WORKBUDDY,
method = FetchMethod.REST_API,
loginUrl = "",
displayUnit = "Credits"
),
ServiceType.SUB2API to ServiceConfig(
type = ServiceType.SUB2API,
// 地址在用户填写的凭据里,按自托管实例调用
method = FetchMethod.SELF_HOSTED_REST,
loginUrl = "",
displayUnit = "$"
)
)

View File

@ -18,7 +18,10 @@ enum class ServiceType(
COMMANDCODE_GO("CommandCode", "commandcode_go"),
DEEPSEEK("DeepSeek", "deepseek"),
CODEX("Codex / ChatGPT", "codex"),
OLLAMA("Ollama", "ollama");
OLLAMA("Ollama", "ollama"),
TRAE("Trae", "trae"),
WORKBUDDY("WorkBuddy", "workbuddy"),
SUB2API("Sub2API", "sub2api");
companion object {
fun fromStorageKey(key: String): ServiceType? =

View File

@ -8,6 +8,9 @@ import com.rainy.token.data.repository.OllamaRepository
import com.rainy.token.data.repository.OpenCodeGoRepository
import com.rainy.token.data.repository.RefreshWriteSession
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.data.repository.Sub2ApiRepository
import com.rainy.token.data.repository.TraeRepository
import com.rainy.token.data.repository.WorkBuddyRepository
import com.rainy.token.data.repository.retryOnTransientError
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.model.TriggerSummary
@ -30,7 +33,10 @@ class RefreshBalanceUseCase @Inject constructor(
private val openCodeGoRepositoryProvider: Provider<OpenCodeGoRepository>,
private val commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>,
private val codexRepositoryProvider: Provider<CodexRepository>,
private val ollamaRepositoryProvider: Provider<OllamaRepository>
private val ollamaRepositoryProvider: Provider<OllamaRepository>,
private val traeRepositoryProvider: Provider<TraeRepository>,
private val workBuddyRepositoryProvider: Provider<WorkBuddyRepository>,
private val sub2ApiRepositoryProvider: Provider<Sub2ApiRepository>
) {
suspend operator fun invoke(service: ServiceType): Result<ServiceBalance> =
withCredentialSession(service) {
@ -50,6 +56,15 @@ class RefreshBalanceUseCase @Inject constructor(
ServiceType.OLLAMA -> retryOnTransientError {
ollamaRepositoryProvider.get().fetchBalance()
}
ServiceType.TRAE -> retryOnTransientError {
traeRepositoryProvider.get().fetchBalance()
}
ServiceType.WORKBUDDY -> retryOnTransientError {
workBuddyRepositoryProvider.get().fetchBalance()
}
ServiceType.SUB2API -> retryOnTransientError {
sub2ApiRepositoryProvider.get().fetchBalance()
}
}
}

View File

@ -13,8 +13,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.material3.Text
import com.rainy.token.R
import com.rainy.token.domain.service.ServiceType
@ -117,5 +119,56 @@ fun ServiceIcon(
)
}
}
ServiceType.TRAE -> {
LetterIcon(
letter = "T",
background = Color(0xFF20242E),
modifier = modifier,
size = size
)
}
ServiceType.WORKBUDDY -> {
LetterIcon(
letter = "W",
background = Color(0xFF3339FF),
modifier = modifier,
size = size
)
}
ServiceType.SUB2API -> {
LetterIcon(
letter = "S",
background = Color(0xFF6C5CE7),
modifier = modifier,
size = size
)
}
}
}
/**
* 品牌色字母占位图标。正式 logo(Trae / WorkBuddy / Sub2API)落地后
* 在此替换为 drawable 分支(同 DeepSeek 模式)。
*/
@Composable
private fun LetterIcon(
letter: String,
background: Color,
modifier: Modifier,
size: Int
) {
Box(
modifier = modifier
.size(size.dp)
.clip(RoundedCornerShape(10.dp))
.background(background),
contentAlignment = Alignment.Center
) {
Text(
text = letter,
color = Color.White,
fontSize = (size * 0.5f).sp,
fontWeight = FontWeight.Bold
)
}
}

View File

@ -115,7 +115,38 @@ fun CredentialEditScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (uiState.isApiKeyService) {
if (service == ServiceType.TRAE) {
TraeCredentialForm(
jwt = uiState.traeJwt,
region = uiState.traeRegion,
hasExisting = uiState.hasExisting,
onJwtChange = viewModel::updateTraeJwt,
onRegionChange = viewModel::updateTraeRegion,
onSave = viewModel::saveTraeCredential
)
} else if (service == ServiceType.WORKBUDDY) {
WorkBuddyCredentialForm(
access = uiState.workBuddyAccess,
refresh = uiState.workBuddyRefresh,
hasExisting = uiState.hasExisting,
onAccessChange = viewModel::updateWorkBuddyAccess,
onRefreshChange = viewModel::updateWorkBuddyRefresh,
onSave = viewModel::saveWorkBuddyCredential
)
} else if (service == ServiceType.SUB2API) {
Sub2ApiCredentialForm(
baseUrl = uiState.sub2BaseUrl,
email = uiState.sub2Email,
password = uiState.sub2Password,
apiKey = uiState.sub2ApiKey,
hasExisting = uiState.hasExisting,
onBaseUrlChange = viewModel::updateSub2BaseUrl,
onEmailChange = viewModel::updateSub2Email,
onPasswordChange = viewModel::updateSub2Password,
onApiKeyChange = viewModel::updateSub2ApiKey,
onSave = viewModel::saveSub2ApiCredential
)
} else if (uiState.isApiKeyService) {
if (service == ServiceType.COMMANDCODE_GO) {
CommandCodeGoForm(
apiKey = uiState.apiKey,
@ -676,6 +707,166 @@ private fun ManualCookieForm(
}
}
@Composable
private fun TraeCredentialForm(
jwt: String,
region: String,
hasExisting: Boolean,
onJwtChange: (String) -> Unit,
onRegionChange: (String) -> Unit,
onSave: () -> Unit
) {
Text(text = stringResource(R.string.credential_title_trae), style = MaterialTheme.typography.titleMedium)
Text(
text = stringResource(R.string.credential_hint_trae),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
OutlinedTextField(
value = jwt,
onValueChange = onJwtChange,
label = { Text(stringResource(R.string.field_trae_jwt)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Text(
text = stringResource(R.string.field_trae_region),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RegionButton(
label = stringResource(R.string.option_region_cn),
selected = region.uppercase() != "INTL",
onSelect = { onRegionChange("CN") }
)
RegionButton(
label = stringResource(R.string.option_region_intl),
selected = region.uppercase() == "INTL",
onSelect = { onRegionChange("INTL") }
)
}
Button(onClick = onSave, enabled = jwt.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save))
}
}
@Composable
private fun RegionButton(label: String, selected: Boolean, onSelect: () -> Unit) {
OutlinedButton(onClick = onSelect) {
Text(
text = label,
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
}
}
@Composable
private fun WorkBuddyCredentialForm(
access: String,
refresh: String,
hasExisting: Boolean,
onAccessChange: (String) -> Unit,
onRefreshChange: (String) -> Unit,
onSave: () -> Unit
) {
Text(text = stringResource(R.string.credential_title_workbuddy), style = MaterialTheme.typography.titleMedium)
Text(
text = stringResource(R.string.credential_hint_workbuddy),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
OutlinedTextField(
value = access,
onValueChange = onAccessChange,
label = { Text(stringResource(R.string.field_workbuddy_access)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = refresh,
onValueChange = onRefreshChange,
label = { Text(stringResource(R.string.field_workbuddy_refresh)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Button(onClick = onSave, enabled = access.isNotBlank() && refresh.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save))
}
}
@Composable
private fun Sub2ApiCredentialForm(
baseUrl: String,
email: String,
password: String,
apiKey: String,
hasExisting: Boolean,
onBaseUrlChange: (String) -> Unit,
onEmailChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onApiKeyChange: (String) -> Unit,
onSave: () -> Unit
) {
Text(text = stringResource(R.string.credential_title_sub2api), style = MaterialTheme.typography.titleMedium)
Text(
text = stringResource(R.string.credential_hint_sub2api),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
OutlinedTextField(
value = baseUrl,
onValueChange = onBaseUrlChange,
label = { Text(stringResource(R.string.field_sub2_base_url)) },
placeholder = { Text(stringResource(R.string.placeholder_sub2_base_url)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Text(
text = stringResource(R.string.sub2_auth_header),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline
)
OutlinedTextField(
value = apiKey,
onValueChange = onApiKeyChange,
label = { Text(stringResource(R.string.field_sub2_api_key)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Text(
text = stringResource(R.string.sub2_or_account),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline
)
OutlinedTextField(
value = email,
onValueChange = onEmailChange,
label = { Text(stringResource(R.string.field_sub2_email)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = password,
onValueChange = onPasswordChange,
label = { Text(stringResource(R.string.field_sub2_password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = onSave,
enabled = baseUrl.isNotBlank() && (apiKey.isNotBlank() || (email.isNotBlank() && password.isNotBlank())),
modifier = Modifier.fillMaxWidth()
) {
Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save))
}
}
private fun copyToClipboard(context: Context, text: String) {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("login_url", text))

View File

@ -92,6 +92,14 @@ class CredentialEditViewModel @Inject constructor(
} else "",
ollamaCookie = (existing as? Credential.SessionCredential)?.ollamaCookie.orEmpty(),
triggerApiKey = (existing as? Credential.SessionCredential)?.apiKey.orEmpty(),
traeJwt = (existing as? Credential.TraeCredential)?.jwt.orEmpty(),
traeRegion = (existing as? Credential.TraeCredential)?.region ?: "CN",
workBuddyAccess = (existing as? Credential.WorkBuddyCredential)?.accessToken.orEmpty(),
workBuddyRefresh = (existing as? Credential.WorkBuddyCredential)?.refreshToken.orEmpty(),
sub2BaseUrl = (existing as? Credential.Sub2ApiCredential)?.baseUrl.orEmpty(),
sub2Email = (existing as? Credential.Sub2ApiCredential)?.email.orEmpty(),
sub2Password = (existing as? Credential.Sub2ApiCredential)?.password.orEmpty(),
sub2ApiKey = (existing as? Credential.Sub2ApiCredential)?.apiKey.orEmpty(),
hasExisting = existing != null
)
}
@ -240,6 +248,123 @@ class CredentialEditViewModel @Inject constructor(
}
}
// ---------- Trae ----------
fun updateTraeJwt(value: String) {
_uiState.update { it.copy(traeJwt = value) }
}
fun updateTraeRegion(value: String) {
_uiState.update { it.copy(traeRegion = value) }
}
fun saveTraeCredential() {
val type = serviceType ?: return
val current = _uiState.value
val jwt = current.traeJwt.trim()
if (jwt.isBlank()) {
_uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) }
return
}
viewModelScope.launch {
val existing = credentialRepository.get(type) as? Credential.TraeCredential
credentialRepository.save(
(existing ?: Credential.TraeCredential(service = type, jwt = jwt, region = "CN")).copy(
jwt = jwt,
region = if (current.traeRegion.uppercase() == "INTL") "INTL" else "CN",
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
)
)
_uiState.update {
it.copy(hasExisting = true, message = UiText.Resource(R.string.msg_credentials_saved))
}
}
}
// ---------- WorkBuddy ----------
fun updateWorkBuddyAccess(value: String) {
_uiState.update { it.copy(workBuddyAccess = value) }
}
fun updateWorkBuddyRefresh(value: String) {
_uiState.update { it.copy(workBuddyRefresh = value) }
}
fun saveWorkBuddyCredential() {
val type = serviceType ?: return
val current = _uiState.value
val access = current.workBuddyAccess.trim()
val refresh = current.workBuddyRefresh.trim()
if (access.isBlank() || refresh.isBlank()) {
_uiState.update { it.copy(message = UiText.Resource(R.string.error_workbuddy_tokens_required)) }
return
}
viewModelScope.launch {
val existing = credentialRepository.get(type) as? Credential.WorkBuddyCredential
credentialRepository.save(
(existing ?: Credential.WorkBuddyCredential(service = type, accessToken = access, refreshToken = refresh))
.copy(
accessToken = access,
refreshToken = refresh,
expiresAt = existing?.expiresAt ?: 0L,
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
)
)
_uiState.update {
it.copy(hasExisting = true, message = UiText.Resource(R.string.msg_credentials_saved))
}
}
}
// ---------- Sub2API ----------
fun updateSub2BaseUrl(value: String) {
_uiState.update { it.copy(sub2BaseUrl = value) }
}
fun updateSub2Email(value: String) {
_uiState.update { it.copy(sub2Email = value) }
}
fun updateSub2Password(value: String) {
_uiState.update { it.copy(sub2Password = value) }
}
fun updateSub2ApiKey(value: String) {
_uiState.update { it.copy(sub2ApiKey = value) }
}
fun saveSub2ApiCredential() {
val type = serviceType ?: return
val current = _uiState.value
val base = current.sub2BaseUrl.trim()
val apiKey = current.sub2ApiKey.trim()
val email = current.sub2Email.trim()
val password = current.sub2Password.trim()
// 实例地址必填;认证需 API Key,或(邮箱+密码)二选一路径
if (base.isBlank() || (apiKey.isBlank() && (email.isBlank() || password.isBlank()))) {
_uiState.update { it.copy(message = UiText.Resource(R.string.error_sub2_required)) }
return
}
viewModelScope.launch {
val existing = credentialRepository.get(type) as? Credential.Sub2ApiCredential
credentialRepository.save(
(existing ?: Credential.Sub2ApiCredential(service = type, baseUrl = base, email = email, password = password))
.copy(
baseUrl = base,
email = email,
password = password,
apiKey = apiKey,
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
)
)
_uiState.update {
it.copy(hasExisting = true, message = UiText.Resource(R.string.msg_credentials_saved))
}
}
}
/** 把 API Key 缩成 'sk-a***xyz' 这种形式,前 4 后 4,中间用 *** 代替。 */
private fun maskedKeyPreview(key: String): UiText {
if (key.length <= 8) {
@ -572,8 +697,17 @@ class CredentialEditViewModel @Inject constructor(
authCookie = "",
workspaceId = "",
cookieCount = 0,
codexAuthJson = "",
ollamaCookie = "",
triggerApiKey = ""
triggerApiKey = "",
traeJwt = "",
traeRegion = "CN",
workBuddyAccess = "",
workBuddyRefresh = "",
sub2BaseUrl = "",
sub2Email = "",
sub2Password = "",
sub2ApiKey = ""
)
}
}
@ -651,5 +785,16 @@ data class CredentialEditUiState(
val ollamaCookie: String = "",
/** OCGO / Ollama 的一键激活用量 API Key */
val triggerApiKey: String = "",
/** Trae:Cloud-IDE-JWT + 区域 */
val traeJwt: String = "",
val traeRegion: String = "CN",
/** WorkBuddy:OAuth tokens */
val workBuddyAccess: String = "",
val workBuddyRefresh: String = "",
/** Sub2API:自托管实例连接 */
val sub2BaseUrl: String = "",
val sub2Email: String = "",
val sub2Password: String = "",
val sub2ApiKey: String = "",
val message: UiText? = null
)