feat(trae): INTL 美元计费展示 + apiHost + 充值入口 + INTL 网关修正

- Trae 凭据新增 apiHost(回调 host 自动保存,如 api-us-east.trae.ai)
- INTL 余额改走官方 v1 计费接口(grow-normal/api-us-east),解析 Free plan 请求额度(Premium fast/slow、高级、自动补全)
- 首页 INTL 不再显示空 0 Credits,改为按月配额展示
- Trae 凭据页新增「充值 / 升级」入口(CN→trae.cn/subscription,INTL→trae.ai/pricing)
- INTL 模型/对话网关修正为 core-normal.trae.ai(实测接口有效)
This commit is contained in:
Liuxinyu176 2026-10-09 22:11:48 +08:00
parent 4ae2aa01b6
commit cf6f426a36
12 changed files with 138 additions and 28 deletions

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.rainy.token" applicationId = "com.rainy.token"
minSdk = 31 minSdk = 31
targetSdk = 35 targetSdk = 35
versionCode = 36 versionCode = 37
versionName = "1.7.16" versionName = "1.7.17"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -55,11 +55,7 @@ class TraeChatProxy @Inject constructor(
val effectiveRegion = forcedRegion ?: runCatching { val effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase()) ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull() }.getOrNull()
val base = if (effectiveRegion == ProviderRegion.INTL) { val base = chatBase(effectiveRegion)
"https://a0ai-api-sg.byteintlapi.com"
} else {
"https://trae-api-cn.mchost.guru"
}
val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion) val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion)
val requestId = UUID.randomUUID().toString() val requestId = UUID.randomUUID().toString()
@ -94,11 +90,7 @@ class TraeChatProxy @Inject constructor(
val effectiveRegion = forcedRegion ?: runCatching { val effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase()) ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull() }.getOrNull()
val base = if (effectiveRegion == ProviderRegion.INTL) { val base = chatBase(effectiveRegion)
"https://a0ai-api-sg.byteintlapi.com"
} else {
"https://trae-api-cn.mchost.guru"
}
val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion) val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion)
val requestId = UUID.randomUUID().toString() val requestId = UUID.randomUUID().toString()
@ -237,6 +229,17 @@ class TraeChatProxy @Inject constructor(
return MODEL_ALIASES[id.lowercase()] ?: id return MODEL_ALIASES[id.lowercase()] ?: id
} }
/**
* INTL 统一走 Web 网关 core-normal.trae.ai(实测 api-us-east/a0ai 均不提供
* llm_utils_chat 路由);CN 走 IDE 网关 trae-api-cn.mchost.guru。
*/
private fun chatBase(region: ProviderRegion?): String =
if (region == ProviderRegion.INTL) {
"https://core-normal.trae.ai"
} else {
"https://trae-api-cn.mchost.guru"
}
private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential, region: ProviderRegion?): String { private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential, region: ProviderRegion?): String {
val src = try { val src = try {
json.parseToJsonElement(raw) as? JsonObject json.parseToJsonElement(raw) as? JsonObject

View File

@ -77,8 +77,11 @@ class TraeModelProvider @Inject constructor(
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() } ?: return@withContext false val jwt = credential.jwt.trim().takeIf { it.isNotBlank() } ?: return@withContext false
val intl = effectiveRegion == ProviderRegion.INTL val intl = effectiveRegion == ProviderRegion.INTL
val base = if (intl) "https://a0ai-api-sg.byteintlapi.com" else "https://trae-api-cn.mchost.guru" val base = if (intl) "https://core-normal.trae.ai" else "https://trae-api-cn.mchost.guru"
val origin = if (intl) "https://solo.trae.ai" else "https://solo.trae.cn" val origin = if (intl) "https://solo.trae.ai" else "https://solo.trae.cn"
val userRegion = if (intl) {
if (credential.apiHost?.contains("us", ignoreCase = true) == true) "US" else "SG"
} else "CN"
val url = base + "/api/remote/v1/models" + val url = base + "/api/remote/v1/models" +
"?functions=solo_agent_remote%2Csolo_work_remote%2Csolo_design_remote" + "?functions=solo_agent_remote%2Csolo_work_remote%2Csolo_design_remote" +
"&show_custom_model=true" "&show_custom_model=true"
@ -88,7 +91,7 @@ class TraeModelProvider @Inject constructor(
"Content-Type" to "application/json", "Content-Type" to "application/json",
"X-Trae-Client-Type" to "web", "X-Trae-Client-Type" to "web",
"X-Preferenced-Language" to "zh-CN", "X-Preferenced-Language" to "zh-CN",
"x-user-region" to if (intl) "SG" else "CN", "x-user-region" to userRegion,
"Origin" to origin, "Origin" to origin,
"Referer" to origin + "/", "Referer" to origin + "/",
"User-Agent" to ( "User-Agent" to (

View File

@ -66,9 +66,12 @@ class TraeRepository constructor(
} }
val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN" val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN"
val url = when (regionKey) { val url = if (regionKey == "INTL") {
"INTL" -> "https://grow-normal.trae.ai/trae/api/v1/pay/ide_user_ent_usage" val host = credential.apiHost?.takeIf { it.startsWith("http") }?.trimEnd('/')
else -> "https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage" ?: "https://grow-normal.trae.ai"
host + "/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() val request = Request.Builder()
.url(url) .url(url)
@ -124,6 +127,8 @@ class TraeRepository constructor(
root["is_credits_billing"]?.jsonPrimitive?.contentOrNull?.let { extras["is_credits_billing"] = it } 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_dollar_usage_billing"]?.jsonPrimitive?.contentOrNull?.let { extras["is_dollar_usage_billing"] = it }
val isDollarBilling = root["is_dollar_usage_billing"]?.jsonPrimitive?.contentOrNull?.equals("true", true) == true
if (isDollarBilling) extras["billing_mode"] = "dollar"
root["is_pay_freshman"]?.jsonPrimitive?.contentOrNull?.let { extras["is_new_user"] = it } root["is_pay_freshman"]?.jsonPrimitive?.contentOrNull?.let { extras["is_new_user"] = it }
// 1) 权威汇总 // 1) 权威汇总
@ -156,6 +161,26 @@ class TraeRepository constructor(
?: base?.get("product_extra")?.jsonObject ?: base?.get("product_extra")?.jsonObject
?.get("package_extra")?.jsonObject?.getString("package_name") ?.get("package_extra")?.jsonObject?.getString("package_name")
?: "pack[$index]" ?: "pack[$index]"
if (isDollarBilling) {
packCount++
if (packShown == 0) {
extras["intl.plan_name"] = name
extras["intl.billing_version"] = root["billing_version"]?.jsonPrimitive?.contentOrNull ?: ""
packShown++
}
quota?.let { q ->
q.getNumber("premium_model_fast_request_limit")?.let { extras["intl.premium_fast_limit"] = trimNum(it) }
q.getNumber("premium_model_slow_request_limit")?.let { extras["intl.premium_slow_limit"] = trimNum(it) }
q.getNumber("advanced_model_request_limit")?.let { extras["intl.advanced_limit"] = trimNum(it) }
q.getNumber("auto_completion_limit")?.let { extras["intl.auto_completion_limit"] = trimNum(it) }
q.getNumber("basic_usage_limit")?.let { extras["intl.basic_usage_limit"] = trimNum(it) }
q.getNumber("bonus_usage_limit")?.let { extras["intl.bonus_usage_limit"] = trimNum(it) }
}
pack.getNumber("usage", "basic_usage_amount")?.let { extras["intl.basic_used"] = trimNum(it) }
pack.getNumber("usage", "bonus_usage_amount")?.let { extras["intl.bonus_used"] = trimNum(it) }
pack.getNumber("usage", "credits_amount")?.let { extras["intl.credits_used"] = trimNum(it) }
return@forEachIndexed
}
val limit = quota?.getNumber("credits_limit")?.toLong() val limit = quota?.getNumber("credits_limit")?.toLong()
val used = pack.getNumber("usage", "credits_amount") val used = pack.getNumber("usage", "credits_amount")
?: base?.get("usage")?.jsonObject?.get("credits_amount")?.safeDouble() ?: base?.get("usage")?.jsonObject?.get("credits_amount")?.safeDouble()

View File

@ -79,8 +79,10 @@ sealed class Credential {
override val service: ServiceType, override val service: ServiceType,
/** `Authorization: Cloud-IDE-JWT <jwt>` 的 JWT 值 */ /** `Authorization: Cloud-IDE-JWT <jwt>` 的 JWT 值 */
val jwt: String, val jwt: String,
/** 区域:"CN"(api.trae.cn)/ "INTL"(grow-normal.trae.ai) */ /** 区域:"CN"(api.trae.cn)/ "INTL"(grow-normal.trae.ai / api-us-east.trae.ai) */
val region: String = "CN", val region: String = "CN",
/** 登录回调携带的账号 API 主机(如 https://api-us-east.trae.ai);null=旧凭据用区域默认 */
val apiHost: String? = null,
/** 登录会话绑定的设备 ID(App 持久化每机唯一),null=旧凭据未保存 */ /** 登录会话绑定的设备 ID(App 持久化每机唯一),null=旧凭据未保存 */
val deviceId: String? = null, val deviceId: String? = null,
/** OAuth 用户 ID(回调 userInfo.userId),null=旧凭据未保存 */ /** OAuth 用户 ID(回调 userInfo.userId),null=旧凭据未保存 */

View File

@ -146,6 +146,10 @@ internal fun BalanceMainArea(card: DashboardCardUi) {
@Composable @Composable
private fun TraeSplitMainBalance(balance: ServiceBalance) { private fun TraeSplitMainBalance(balance: ServiceBalance) {
val extras = balance.extras val extras = balance.extras
if (extras?.get("billing_mode") == "dollar") {
TraeDollarPlanSummary(extras)
return
}
val general = extras?.get("general_credits")?.toDoubleOrNull() val general = extras?.get("general_credits")?.toDoubleOrNull()
val work = extras?.get("work_credits")?.toDoubleOrNull() val work = extras?.get("work_credits")?.toDoubleOrNull()
if (general == null && work == null) { if (general == null && work == null) {
@ -176,7 +180,7 @@ private fun TraeSplitMainBalance(balance: ServiceBalance) {
/** 首页 Trae 分区一行:左侧分区名,右侧小计 + 单位。 */ /** 首页 Trae 分区一行:左侧分区名,右侧小计 + 单位。 */
@Composable @Composable
private fun TraeSplitLine(label: String, value: Double?) { private fun TraeSplitLine(label: String, value: Double?, unit: String = "Credits") {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
@ -196,7 +200,7 @@ private fun TraeSplitLine(label: String, value: Double?) {
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text( Text(
text = "Credits", text = unit,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = inkMuted(), color = inkMuted(),
modifier = Modifier.padding(bottom = 2.dp) modifier = Modifier.padding(bottom = 2.dp)
@ -205,6 +209,21 @@ private fun TraeSplitLine(label: String, value: Double?) {
} }
} }
@Composable
private fun TraeDollarPlanSummary(extras: Map<String, String>) {
val planName = extras["intl.plan_name"] ?: "Free plan"
Text(
text = planName,
style = MaterialTheme.typography.titleMedium,
color = inkMuted()
)
Spacer(modifier = Modifier.height(8.dp))
TraeSplitLine(stringResource(R.string.trae_intl_premium_fast_title), extras["intl.premium_fast_limit"]?.toDoubleOrNull(), stringResource(R.string.trae_intl_quota_unit))
TraeSplitLine(stringResource(R.string.trae_intl_premium_slow_title), extras["intl.premium_slow_limit"]?.toDoubleOrNull(), stringResource(R.string.trae_intl_quota_unit))
TraeSplitLine(stringResource(R.string.trae_intl_advanced_title), extras["intl.advanced_limit"]?.toDoubleOrNull(), stringResource(R.string.trae_intl_quota_unit))
TraeSplitLine(stringResource(R.string.trae_intl_auto_completion_title), extras["intl.auto_completion_limit"]?.toDoubleOrNull(), stringResource(R.string.trae_intl_quota_unit))
}
@Composable @Composable
internal fun OpenCodeGoMainBalance(balance: ServiceBalance) { internal fun OpenCodeGoMainBalance(balance: ServiceBalance) {
Row(verticalAlignment = Alignment.Bottom) { Row(verticalAlignment = Alignment.Bottom) {

View File

@ -163,6 +163,21 @@ fun CredentialEditScreen(
onRegionChange = viewModel::updateTraeRegion, onRegionChange = viewModel::updateTraeRegion,
onCheckinDeviceIdChange = viewModel::updateTraeCheckinDeviceId, onCheckinDeviceIdChange = viewModel::updateTraeCheckinDeviceId,
onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId, uiState.traeRegion) }, onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId, uiState.traeRegion) },
onRecharge = {
val url = if (uiState.traeRegion.uppercase() == "INTL") {
"https://www.trae.ai/pricing"
} else {
"https://www.trae.cn/subscription"
}
runCatching {
context.startActivity(
android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url)
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
},
onSave = viewModel::saveTraeCredential onSave = viewModel::saveTraeCredential
) )
} else if (service == ServiceType.WORKBUDDY) { } else if (service == ServiceType.WORKBUDDY) {
@ -792,6 +807,7 @@ private fun TraeCredentialForm(
onRegionChange: (String) -> Unit, onRegionChange: (String) -> Unit,
onCheckinDeviceIdChange: (String) -> Unit, onCheckinDeviceIdChange: (String) -> Unit,
onStartInAppLogin: () -> Unit, onStartInAppLogin: () -> Unit,
onRecharge: () -> Unit,
onSave: () -> Unit onSave: () -> Unit
) { ) {
Text(text = stringResource(R.string.credential_title_trae), style = MaterialTheme.typography.titleMedium) Text(text = stringResource(R.string.credential_title_trae), style = MaterialTheme.typography.titleMedium)
@ -847,6 +863,12 @@ private fun TraeCredentialForm(
onSelect = { onRegionChange("INTL") } onSelect = { onRegionChange("INTL") }
) )
} }
OutlinedButton(
onClick = onRecharge,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.action_trae_recharge))
}
OutlinedTextField( OutlinedTextField(
value = checkinDeviceId, value = checkinDeviceId,
onValueChange = onCheckinDeviceIdChange, onValueChange = onCheckinDeviceIdChange,

View File

@ -318,14 +318,21 @@ class CredentialEditViewModel @Inject constructor(
} }
_uiState.update { _uiState.update {
it.copy( it.copy(
traeJwt = parsed.first, traeJwt = parsed.jwt,
traeRegion = parsed.second, traeRegion = parsed.region,
traeApiHost = parsed.apiHost,
traeCallbackUrl = input traeCallbackUrl = input
) )
} }
} }
private fun parseTraeCallback(input: String): Pair<String, String>? { private data class TraeParsedCallback(
val jwt: String,
val region: String,
val apiHost: String
)
private fun parseTraeCallback(input: String): TraeParsedCallback? {
val raw = input.trim() val raw = input.trim()
val uri = runCatching { android.net.Uri.parse(raw) }.getOrNull() ?: return null val uri = runCatching { android.net.Uri.parse(raw) }.getOrNull() ?: return null
fun query(name: String): String? { fun query(name: String): String? {
@ -362,7 +369,12 @@ class CredentialEditViewModel @Inject constructor(
val intl = userRegion?.let { it != "cn" } == true || val intl = userRegion?.let { it != "cn" } == true ||
userInfoRegion?.let { !it.equals("cn", true) && !it.equals("china", true) } == true || userInfoRegion?.let { !it.equals("cn", true) && !it.equals("china", true) } == true ||
query("userRegion")?.equals("us", true) == true query("userRegion")?.equals("us", true) == true
return token to (if (intl) "INTL" else "CN") val apiHost = query("host")?.takeIf { it.startsWith("http") }?.trimEnd('/') ?: ""
return TraeParsedCallback(
jwt = token,
region = if (intl) "INTL" else "CN",
apiHost = apiHost
)
} }
fun updateTraeRegion(value: String) { fun updateTraeRegion(value: String) {
@ -377,10 +389,10 @@ class CredentialEditViewModel @Inject constructor(
val type = serviceType ?: return val type = serviceType ?: return
val current = _uiState.value val current = _uiState.value
var jwt = current.traeJwt.trim() var jwt = current.traeJwt.trim()
if (jwt.isBlank() && current.traeCallbackUrl.isNotBlank()) { val parsedCallback = if (jwt.isBlank() && current.traeCallbackUrl.isNotBlank()) {
val parsed = parseTraeCallback(current.traeCallbackUrl) parseTraeCallback(current.traeCallbackUrl)
if (parsed != null) jwt = parsed.first } else null
} if (parsedCallback != null) jwt = parsedCallback.jwt
if (jwt.isBlank()) { if (jwt.isBlank()) {
_uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) } _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) }
return return
@ -396,6 +408,9 @@ class CredentialEditViewModel @Inject constructor(
val effectiveId = credentialRepository.save( val effectiveId = credentialRepository.save(
(existing ?: Credential.TraeCredential(service = type, jwt = jwt, region = "CN")).copy( (existing ?: Credential.TraeCredential(service = type, jwt = jwt, region = "CN")).copy(
jwt = jwt, jwt = jwt,
apiHost = current.traeApiHost.takeIf { it.isNotBlank() }?.trimEnd('/')
?: parsedCallback?.apiHost?.takeIf { it.isNotBlank() }?.trimEnd('/')
?: existing?.apiHost,
region = if (current.traeRegion.uppercase() == "INTL") "INTL" else "CN", region = if (current.traeRegion.uppercase() == "INTL") "INTL" else "CN",
// 换账号(JWT 变化)时清空本地签到短路标记,避免新账号被误判「今日已签到」 // 换账号(JWT 变化)时清空本地签到短路标记,避免新账号被误判「今日已签到」
lastCheckinDate = if (existing != null && existing.jwt == jwt) existing.lastCheckinDate else null, lastCheckinDate = if (existing != null && existing.jwt == jwt) existing.lastCheckinDate else null,
@ -1052,6 +1067,8 @@ data class CredentialEditUiState(
val traeRegion: String = "CN", val traeRegion: String = "CN",
/** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */ /** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */
val traeCheckinDeviceId: String = "", val traeCheckinDeviceId: String = "",
/** Trae 登录回调携带的账号 API 主机(备用;从回调链接自动解析) */
val traeApiHost: String = "",
/** WorkBuddy:OAuth tokens + 区域 */ /** WorkBuddy:OAuth tokens + 区域 */
val workBuddyAccess: String = "", val workBuddyAccess: String = "",
val workBuddyRefresh: String = "", val workBuddyRefresh: String = "",

View File

@ -288,6 +288,7 @@ class WebViewLoginViewModel @Inject constructor(
service = ServiceType.TRAE, service = ServiceType.TRAE,
jwt = token, jwt = token,
region = if (region == "INTL") "INTL" else "CN", region = if (region == "INTL") "INTL" else "CN",
apiHost = param("host")?.takeIf { it.startsWith("http") }?.trimEnd('/'),
deviceId = deviceId, deviceId = deviceId,
userId = parsedUserId?.takeIf { it.isNotBlank() }, userId = parsedUserId?.takeIf { it.isNotBlank() },
lastVerifiedAt = System.currentTimeMillis() lastVerifiedAt = System.currentTimeMillis()

View File

@ -792,4 +792,10 @@
<string name="update_install_permission_tip">需要开启「安装未知应用」权限</string> <string name="update_install_permission_tip">需要开启「安装未知应用」权限</string>
<string name="update_enable_permission">去开启</string> <string name="update_enable_permission">去开启</string>
<string name="update_failed">更新失败:%1$s</string> <string name="update_failed">更新失败:%1$s</string>
<string name="trae_intl_premium_fast_title">Premium 快速</string>
<string name="trae_intl_premium_slow_title">Premium 慢速</string>
<string name="trae_intl_advanced_title">高级额度</string>
<string name="trae_intl_auto_completion_title">自动补全</string>
<string name="trae_intl_quota_unit">次/月</string>
<string name="action_trae_recharge">充值 / 升级</string>
</resources> </resources>

View File

@ -792,4 +792,10 @@
<string name="update_install_permission_tip">需要開啟「安裝未知應用」權限</string> <string name="update_install_permission_tip">需要開啟「安裝未知應用」權限</string>
<string name="update_enable_permission">前往開啟</string> <string name="update_enable_permission">前往開啟</string>
<string name="update_failed">更新失敗:%1$s</string> <string name="update_failed">更新失敗:%1$s</string>
<string name="trae_intl_premium_fast_title">Premium 快速</string>
<string name="trae_intl_premium_slow_title">Premium 慢速</string>
<string name="trae_intl_advanced_title">進階額度</string>
<string name="trae_intl_auto_completion_title">自動補全</string>
<string name="trae_intl_quota_unit">次/月</string>
<string name="action_trae_recharge">儲值 / 升級</string>
</resources> </resources>

View File

@ -797,4 +797,10 @@
<string name="update_install_permission_tip">Please allow installing apps from unknown sources</string> <string name="update_install_permission_tip">Please allow installing apps from unknown sources</string>
<string name="update_enable_permission">Open settings</string> <string name="update_enable_permission">Open settings</string>
<string name="update_failed">Update failed: %1$s</string> <string name="update_failed">Update failed: %1$s</string>
<string name="trae_intl_premium_fast_title">Premium fast</string>
<string name="trae_intl_premium_slow_title">Premium slow</string>
<string name="trae_intl_advanced_title">Advanced requests</string>
<string name="trae_intl_auto_completion_title">Auto completion</string>
<string name="trae_intl_quota_unit">/mo</string>
<string name="action_trae_recharge">Upgrade / Top up</string>
</resources> </resources>