From cf6f426a36dde5b8910c7d0aa4a1a33ff220716b Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 22:11:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(trae):=20INTL=20=E7=BE=8E=E5=85=83?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9=E5=B1=95=E7=A4=BA=20+=20apiHost=20+=20?= =?UTF-8?q?=E5=85=85=E5=80=BC=E5=85=A5=E5=8F=A3=20+=20INTL=20=E7=BD=91?= =?UTF-8?q?=E5=85=B3=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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(实测接口有效) --- app/build.gradle.kts | 4 +-- .../rainy/token/data/proxy/TraeChatProxy.kt | 23 +++++++------ .../token/data/proxy/TraeModelProvider.kt | 7 ++-- .../token/data/repository/TraeRepository.kt | 31 +++++++++++++++-- .../rainy/token/domain/model/Credential.kt | 4 ++- .../token/ui/dashboard/ServiceBalanceCards.kt | 23 +++++++++++-- .../token/ui/settings/CredentialEditScreen.kt | 22 +++++++++++++ .../ui/settings/CredentialEditViewModel.kt | 33 ++++++++++++++----- .../token/ui/webview/WebViewLoginViewModel.kt | 1 + app/src/main/res/values-b+zh+Hans/strings.xml | 6 ++++ app/src/main/res/values-b+zh+Hant/strings.xml | 6 ++++ app/src/main/res/values/strings.xml | 6 ++++ 12 files changed, 138 insertions(+), 28 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7e97029..66751c3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.rainy.token" minSdk = 31 targetSdk = 35 - versionCode = 36 - versionName = "1.7.16" + versionCode = 37 + versionName = "1.7.17" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt index 7a84c27..3f791a4 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt @@ -55,11 +55,7 @@ class TraeChatProxy @Inject constructor( val effectiveRegion = forcedRegion ?: runCatching { ProviderRegion.valueOf(credential.region.uppercase()) }.getOrNull() - val base = if (effectiveRegion == ProviderRegion.INTL) { - "https://a0ai-api-sg.byteintlapi.com" - } else { - "https://trae-api-cn.mchost.guru" - } + val base = chatBase(effectiveRegion) val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion) val requestId = UUID.randomUUID().toString() @@ -94,11 +90,7 @@ class TraeChatProxy @Inject constructor( val effectiveRegion = forcedRegion ?: runCatching { ProviderRegion.valueOf(credential.region.uppercase()) }.getOrNull() - val base = if (effectiveRegion == ProviderRegion.INTL) { - "https://a0ai-api-sg.byteintlapi.com" - } else { - "https://trae-api-cn.mchost.guru" - } + val base = chatBase(effectiveRegion) val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion) val requestId = UUID.randomUUID().toString() @@ -237,6 +229,17 @@ class TraeChatProxy @Inject constructor( 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 { val src = try { json.parseToJsonElement(raw) as? JsonObject diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt index 86f0f48..51a931c 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt @@ -77,8 +77,11 @@ class TraeModelProvider @Inject constructor( val jwt = credential.jwt.trim().takeIf { it.isNotBlank() } ?: return@withContext false 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 userRegion = if (intl) { + if (credential.apiHost?.contains("us", ignoreCase = true) == true) "US" else "SG" + } else "CN" val url = base + "/api/remote/v1/models" + "?functions=solo_agent_remote%2Csolo_work_remote%2Csolo_design_remote" + "&show_custom_model=true" @@ -88,7 +91,7 @@ class TraeModelProvider @Inject constructor( "Content-Type" to "application/json", "X-Trae-Client-Type" to "web", "X-Preferenced-Language" to "zh-CN", - "x-user-region" to if (intl) "SG" else "CN", + "x-user-region" to userRegion, "Origin" to origin, "Referer" to origin + "/", "User-Agent" to ( diff --git a/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt b/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt index 99c9187..2f43437 100644 --- a/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt @@ -66,9 +66,12 @@ class TraeRepository constructor( } 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 url = if (regionKey == "INTL") { + val host = credential.apiHost?.takeIf { it.startsWith("http") }?.trimEnd('/') + ?: "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() .url(url) @@ -124,6 +127,8 @@ class TraeRepository constructor( 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 } + 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 } // 1) 权威汇总 @@ -156,6 +161,26 @@ class TraeRepository constructor( ?: base?.get("product_extra")?.jsonObject ?.get("package_extra")?.jsonObject?.getString("package_name") ?: "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 used = pack.getNumber("usage", "credits_amount") ?: base?.get("usage")?.jsonObject?.get("credits_amount")?.safeDouble() 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 a2a9b1d..faf63ec 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 @@ -79,8 +79,10 @@ sealed class Credential { override val service: ServiceType, /** `Authorization: Cloud-IDE-JWT ` 的 JWT 值 */ 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", + /** 登录回调携带的账号 API 主机(如 https://api-us-east.trae.ai);null=旧凭据用区域默认 */ + val apiHost: String? = null, /** 登录会话绑定的设备 ID(App 持久化每机唯一),null=旧凭据未保存 */ val deviceId: String? = null, /** OAuth 用户 ID(回调 userInfo.userId),null=旧凭据未保存 */ diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt index 37f912e..4ab00e1 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt @@ -146,6 +146,10 @@ internal fun BalanceMainArea(card: DashboardCardUi) { @Composable private fun TraeSplitMainBalance(balance: ServiceBalance) { val extras = balance.extras + if (extras?.get("billing_mode") == "dollar") { + TraeDollarPlanSummary(extras) + return + } val general = extras?.get("general_credits")?.toDoubleOrNull() val work = extras?.get("work_credits")?.toDoubleOrNull() if (general == null && work == null) { @@ -176,7 +180,7 @@ private fun TraeSplitMainBalance(balance: ServiceBalance) { /** 首页 Trae 分区一行:左侧分区名,右侧小计 + 单位。 */ @Composable -private fun TraeSplitLine(label: String, value: Double?) { +private fun TraeSplitLine(label: String, value: Double?, unit: String = "Credits") { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom, @@ -196,7 +200,7 @@ private fun TraeSplitLine(label: String, value: Double?) { ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = "Credits", + text = unit, style = MaterialTheme.typography.titleMedium, color = inkMuted(), modifier = Modifier.padding(bottom = 2.dp) @@ -205,6 +209,21 @@ private fun TraeSplitLine(label: String, value: Double?) { } } +@Composable +private fun TraeDollarPlanSummary(extras: Map) { + 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 internal fun OpenCodeGoMainBalance(balance: ServiceBalance) { Row(verticalAlignment = Alignment.Bottom) { 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 da8c0cf..7448125 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 @@ -163,6 +163,21 @@ fun CredentialEditScreen( onRegionChange = viewModel::updateTraeRegion, onCheckinDeviceIdChange = viewModel::updateTraeCheckinDeviceId, 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 ) } else if (service == ServiceType.WORKBUDDY) { @@ -792,6 +807,7 @@ private fun TraeCredentialForm( onRegionChange: (String) -> Unit, onCheckinDeviceIdChange: (String) -> Unit, onStartInAppLogin: () -> Unit, + onRecharge: () -> Unit, onSave: () -> Unit ) { Text(text = stringResource(R.string.credential_title_trae), style = MaterialTheme.typography.titleMedium) @@ -847,6 +863,12 @@ private fun TraeCredentialForm( onSelect = { onRegionChange("INTL") } ) } + OutlinedButton( + onClick = onRecharge, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.action_trae_recharge)) + } OutlinedTextField( value = checkinDeviceId, onValueChange = onCheckinDeviceIdChange, 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 17e5645..77aecd7 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 @@ -318,14 +318,21 @@ class CredentialEditViewModel @Inject constructor( } _uiState.update { it.copy( - traeJwt = parsed.first, - traeRegion = parsed.second, + traeJwt = parsed.jwt, + traeRegion = parsed.region, + traeApiHost = parsed.apiHost, traeCallbackUrl = input ) } } - private fun parseTraeCallback(input: String): Pair? { + private data class TraeParsedCallback( + val jwt: String, + val region: String, + val apiHost: String + ) + + private fun parseTraeCallback(input: String): TraeParsedCallback? { val raw = input.trim() val uri = runCatching { android.net.Uri.parse(raw) }.getOrNull() ?: return null fun query(name: String): String? { @@ -362,7 +369,12 @@ class CredentialEditViewModel @Inject constructor( val intl = userRegion?.let { it != "cn" } == true || userInfoRegion?.let { !it.equals("cn", true) && !it.equals("china", 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) { @@ -377,10 +389,10 @@ class CredentialEditViewModel @Inject constructor( val type = serviceType ?: return val current = _uiState.value var jwt = current.traeJwt.trim() - if (jwt.isBlank() && current.traeCallbackUrl.isNotBlank()) { - val parsed = parseTraeCallback(current.traeCallbackUrl) - if (parsed != null) jwt = parsed.first - } + val parsedCallback = if (jwt.isBlank() && current.traeCallbackUrl.isNotBlank()) { + parseTraeCallback(current.traeCallbackUrl) + } else null + if (parsedCallback != null) jwt = parsedCallback.jwt if (jwt.isBlank()) { _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) } return @@ -396,6 +408,9 @@ class CredentialEditViewModel @Inject constructor( val effectiveId = credentialRepository.save( (existing ?: Credential.TraeCredential(service = type, jwt = jwt, region = "CN")).copy( 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", // 换账号(JWT 变化)时清空本地签到短路标记,避免新账号被误判「今日已签到」 lastCheckinDate = if (existing != null && existing.jwt == jwt) existing.lastCheckinDate else null, @@ -1052,6 +1067,8 @@ data class CredentialEditUiState( val traeRegion: String = "CN", /** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */ val traeCheckinDeviceId: String = "", + /** Trae 登录回调携带的账号 API 主机(备用;从回调链接自动解析) */ + val traeApiHost: String = "", /** WorkBuddy:OAuth tokens + 区域 */ val workBuddyAccess: String = "", val workBuddyRefresh: String = "", diff --git a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt index 3570806..9010707 100644 --- a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt @@ -288,6 +288,7 @@ class WebViewLoginViewModel @Inject constructor( service = ServiceType.TRAE, jwt = token, region = if (region == "INTL") "INTL" else "CN", + apiHost = param("host")?.takeIf { it.startsWith("http") }?.trimEnd('/'), deviceId = deviceId, userId = parsedUserId?.takeIf { it.isNotBlank() }, lastVerifiedAt = System.currentTimeMillis() 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 0c9ce51..ef3fe77 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -792,4 +792,10 @@ 需要开启「安装未知应用」权限 去开启 更新失败:%1$s + Premium 快速 + Premium 慢速 + 高级额度 + 自动补全 + 次/月 + 充值 / 升级 \ No newline at end of file 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 67b3637..2fe0e33 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -792,4 +792,10 @@ 需要開啟「安裝未知應用」權限 前往開啟 更新失敗:%1$s + Premium 快速 + Premium 慢速 + 進階額度 + 自動補全 + 次/月 + 儲值 / 升級 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f5b151f..749e51a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -797,4 +797,10 @@ Please allow installing apps from unknown sources Open settings Update failed: %1$s + Premium fast + Premium slow + Advanced requests + Auto completion + /mo + Upgrade / Top up \ No newline at end of file