feat(proxy): Trae 模型切换 + 在线拉取模型列表
Some checks are pending
Release / build (push) Waiting to run

- 设置页新增模型下拉选择,内置参考映射兜底
- TraeModelProvider 在线拉取账号可用模型(/api/remote/v1/models)
- 选中模型持久化,作为反代缺省模型
- 常用 OpenAI/Claude 模型名自动映射到 Trae 内部模型
- 修复探针测试请求体混入反斜杠导致 JSON 无效的问题
- 版本升至 1.7.5
This commit is contained in:
Liuxinyu176 2026-10-09 11:03:27 +08:00
parent 8a9d9432fe
commit 0241840f9c
6 changed files with 297 additions and 9 deletions

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.rainy.token"
minSdk = 31
targetSdk = 35
versionCode = 24
versionName = "1.7.4"
versionCode = 25
versionName = "1.7.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@ -35,6 +35,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
class TraeChatProxy @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
private val modelProvider: TraeModelProvider,
) {
private val json = Json { ignoreUnknownKeys = true }
@ -228,6 +229,13 @@ class TraeChatProxy @Inject constructor(
return null
}
/** 外部模型名 -> Trae 内部模型名;空/auto 用设置页选择的默认模型。 */
private fun normalizeModel(raw: String?): String {
val id = raw?.trim()?.takeIf { it.isNotBlank() } ?: return modelProvider.selectedModel
if (id.equals("auto", ignoreCase = true)) return modelProvider.selectedModel
return MODEL_ALIASES[id.lowercase()] ?: id
}
private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential): String {
val src = try {
json.parseToJsonElement(raw) as? JsonObject
@ -235,8 +243,8 @@ class TraeChatProxy @Inject constructor(
null
} ?: return raw
val model = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() }
?: "glm-5.2"
val rawModel = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() }
val model = normalizeModel(rawModel)
val messages = convertNativeMessages(src["messages"])
val sessionId = UUID.randomUUID().toString().replace("-", "")
@ -255,4 +263,50 @@ class TraeChatProxy @Inject constructor(
}
}.toString()
}
companion object {
/** OpenAI/Claude 常用名 -> Trae CN 内部模型名(参考 trae2api-cn)。 */
private val MODEL_ALIASES = mapOf(
"auto" to "glm-5.2",
"gpt-4o" to "DeepSeek-V4-Pro",
"gpt-4o-latest" to "DeepSeek-V4-Pro",
"gpt-4.1" to "DeepSeek-V4-Pro",
"deepseek-v3" to "DeepSeek-V4-Pro",
"deepseek-r1" to "DeepSeek-V4-Pro",
"gpt-4o-mini" to "DeepSeek-V4-Flash",
"claude-opus-4-7" to "glm-5.2",
"claude-opus-4-6" to "glm-5.2",
"claude-opus-4-5" to "glm-5.2",
"claude-sonnet-4-6" to "glm-5.2",
"claude-sonnet-4-5" to "glm-5.2",
"claude-sonnet-4" to "glm-5.2",
"claude-3.7-sonnet" to "glm-5.2",
"claude-3-7-sonnet" to "glm-5.2",
"claude-3.5-sonnet" to "glm-5.2",
"claude-haiku-4-5" to "glm-5.1",
"deepseek-v4-flash-official" to "DeepSeek-V4-Flash-Official",
"deepseek-v4-pro-official" to "DeepSeek-V4-Pro-Official",
"doubao-seed-evolving" to "Doubao-Seed-Evolving",
"deepseek-v4-pro" to "DeepSeek-V4-Pro",
"deepseek-v4-flash" to "DeepSeek-V4-Flash",
"seed-2.1-pro" to "Doubao-Seed-2.1-Pro",
"seed-2.1-turbo" to "Doubao-Seed-2.1-Turbo",
"seed-code" to "Doubao-Seed-Code",
"seed-evolving" to "Doubao-Seed-Evolving",
"qwen3.7-plus" to "qwen-3.7-plus",
"kimi-k2.6" to "kimi-k2.6",
"kimi-k3" to "kimi-k3",
"kimi-k2.7-code" to "kimi-k2.7-code",
"kimi-k2" to "kimi-k2",
"kimi-k2.5" to "kimi-k2.5",
"minimax-m3" to "minimax-m3",
"minimax-m2.7" to "minimax-m2.7",
"minimax-m2.1" to "minimax-m2.1",
"mimo-v2.5" to "mimo-v2.5",
"mimo-v2.5-pro" to "mimo-v2.5-pro",
"minimax-m25" to "minimax-m25",
"qwen36-35b" to "qwen36-35b",
)
}
}

View File

@ -0,0 +1,161 @@
package com.rainy.token.data.proxy
import android.content.Context
import android.content.SharedPreferences
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import okhttp3.Headers.Companion.toHeaders
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* Trae 模型列表:内置参考映射 + 在线拉取账号可用模型 + 记住用户选择。
* 选择结果同时供设置页测试和 [TraeChatProxy] 作为缺省模型使用。
*/
@Singleton
class TraeModelProvider @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
@ApplicationContext private val context: Context,
) {
data class TraeModel(val id: String, val label: String? = null)
private val prefs: SharedPreferences =
context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE)
private val _models = MutableStateFlow(defaultModels())
val models: StateFlow<List<TraeModel>> = _models.asStateFlow()
/** 当前默认模型(用户在设置页选择的 Trae 模型名)。 */
var selectedModel: String = prefs.getString(KEY_MODEL, "glm-5.2") ?: "glm-5.2"
private set
private val json = Json { ignoreUnknownKeys = true }
fun setSelectedModel(id: String) {
if (id.isBlank()) return
selectedModel = id
prefs.edit().putString(KEY_MODEL, id).apply()
}
/** 拉取账号在线模型列表;失败时保留内置列表并返回 false。 */
suspend fun refreshFor(forcedRegion: ProviderRegion?): Boolean = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, null)
?: return@withContext false
if (credential !is Credential.TraeCredential) return@withContext false
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() } ?: return@withContext false
val effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull()
val intl = effectiveRegion == ProviderRegion.INTL
val base = if (intl) "https://a0ai-api-sg.byteintlapi.com" else "https://trae-api-cn.mchost.guru"
val origin = if (intl) "https://solo.trae.ai" else "https://solo.trae.cn"
val url = base + "/api/remote/v1/models" +
"?functions=solo_agent_remote%2Csolo_work_remote%2Csolo_design_remote" +
"&show_custom_model=true"
val headers = linkedMapOf(
"Authorization" to "Cloud-IDE-JWT $jwt",
"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",
"Origin" to origin,
"Referer" to origin + "/",
"User-Agent" to (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
),
)
val request = Request.Builder().url(url).headers(headers.toHeaders()).get().build()
val parsed = try {
okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return@use null
val body = resp.body?.string() ?: return@use null
parseModelList(body)
}
} catch (_: Exception) {
null
}
if (parsed.isNullOrEmpty()) return@withContext false
_models.value = parsed
true
}
private fun parseModelList(body: String): List<TraeModel>? {
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
?: return null
val list = (root["data"] as? JsonObject)?.get("list") as? JsonArray ?: return null
val seen = LinkedHashMap<String, TraeModel>()
for (groupEl in list) {
val group = groupEl as? JsonObject ?: continue
val models = group["models"] as? JsonArray ?: continue
for (rawEl in models) {
val raw = rawEl as? JsonObject ?: continue
val id = (raw["name"] as? JsonPrimitive)?.contentOrNull?.trim()
?.takeIf { it.isNotEmpty() } ?: continue
val label = (raw["display_name"] as? JsonPrimitive)?.contentOrNull
?: (raw["display_model_name"] as? JsonPrimitive)?.contentOrNull
?: id
seen[id] = TraeModel(id, label)
}
}
return seen.values.toList().ifEmpty { null }
}
companion object {
private const val KEY_MODEL = "trae_default_model"
/** 内置兜底模型(参考 trae2api-cn 的模型映射 /v1/models 列表)。 */
fun defaultModels(): List<TraeModel> = listOf(
TraeModel("glm-5.2", "GLM-5.2"),
TraeModel("glm-5.3", "GLM-5.3"),
TraeModel("glm-5.1", "GLM-5.1"),
TraeModel("glm-5", "GLM-5"),
TraeModel("glm-4.7", "GLM-4.7"),
TraeModel("glm-4.6", "GLM-4.6"),
TraeModel("DeepSeek-V4-Pro", "DeepSeek-V4-Pro"),
TraeModel("DeepSeek-V4-Pro-Official", "DeepSeek-V4-Pro 正式版"),
TraeModel("DeepSeek-V4-Flash", "DeepSeek-V4-Flash"),
TraeModel("DeepSeek-V4-Flash-Official", "DeepSeek-V4-Flash 正式版"),
TraeModel("Doubao-Seed-2.1-Pro", "Doubao-Seed-2.1-Pro"),
TraeModel("Doubao-Seed-2.1-Turbo", "Doubao-Seed-2.1-Turbo"),
TraeModel("Doubao-Seed-Code", "Doubao-Seed-Code"),
TraeModel("Doubao-Seed-Evolving", "Doubao-Seed-Evolving"),
TraeModel("qwen-3.7-plus", "qwen-3.7-plus"),
TraeModel("qwen-3.6-plus", "qwen-3.6-plus"),
TraeModel("qwen3.8-max", "qwen3.8-max"),
TraeModel("qwen-3.5", "qwen-3.5"),
TraeModel("qwen3-coder", "qwen3-coder"),
TraeModel("kimi-k2.6", "kimi-k2.6"),
TraeModel("kimi-k3", "kimi-k3"),
TraeModel("kimi-k2.7-code", "kimi-k2.7-code"),
TraeModel("kimi-k2", "kimi-k2"),
TraeModel("kimi-k2.5", "kimi-k2.5"),
TraeModel("minimax-m3", "minimax-m3"),
TraeModel("minimax-m2.7", "minimax-m2.7"),
TraeModel("minimax-m2.1", "minimax-m2.1"),
TraeModel("mimo-v2.5-pro", "mimo-v2.5-pro"),
TraeModel("mimo-v2.5", "mimo-v2.5"),
TraeModel("minimax-m25", "minimax-m25"),
TraeModel("qwen36-35b", "qwen36-35b"),
TraeModel("work", "Work(200K 档)"),
)
}
}

View File

@ -26,6 +26,7 @@ import com.rainy.token.data.proxy.KtorLocalProxyServer
import com.rainy.token.data.proxy.LocalProxyServer
import com.rainy.token.data.proxy.Sub2ApiChatProxy
import com.rainy.token.data.proxy.TraeChatProxy
import com.rainy.token.data.proxy.TraeModelProvider
import com.rainy.token.data.proxy.WorkBuddyChatProxy
import com.rainy.token.data.repository.WorkBuddyRepository
import dagger.Module
@ -250,8 +251,9 @@ object NetworkModule {
@Singleton
fun provideTraeChatProxy(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository
): TraeChatProxy = TraeChatProxy(okHttpClient, credentialRepository)
credentialRepository: CredentialRepository,
traeModelProvider: TraeModelProvider,
): TraeChatProxy = TraeChatProxy(okHttpClient, credentialRepository, traeModelProvider)
@Provides
@Singleton

View File

@ -33,6 +33,8 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -60,6 +62,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.data.proxy.TraeModelProvider
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.ServiceIcon
import com.rainy.token.ui.components.StatusChip
@ -105,6 +108,8 @@ fun SettingsScreen(
val proxyError by viewModel.proxyError.collectAsStateWithLifecycle()
val proxyDebug by viewModel.proxyDebug.collectAsStateWithLifecycle()
val proxyDebugRaw by viewModel.proxyDebugRaw.collectAsStateWithLifecycle()
val proxyModels by viewModel.proxyModels.collectAsStateWithLifecycle()
val proxyModel by viewModel.proxyModel.collectAsStateWithLifecycle()
var showLanguageDialog by remember { mutableStateOf(false) }
Scaffold(
@ -182,6 +187,10 @@ fun SettingsScreen(
error = proxyError,
debug = proxyDebug,
debugRaw = proxyDebugRaw,
models = proxyModels,
selectedModel = proxyModel,
onSelectModel = viewModel::selectProxyModel,
onRefreshModels = viewModel::refreshProxyModels,
onToggle = viewModel::setProxyEnabled,
onTestTrae = viewModel::testTraeStream,
onTestWorkBuddy = viewModel::testWorkBuddyStream
@ -216,11 +225,16 @@ private fun ProxyGatewayCard(
error: String?,
debug: String?,
debugRaw: String?,
models: List<TraeModelProvider.TraeModel>,
selectedModel: String,
onSelectModel: (String) -> Unit,
onRefreshModels: () -> Unit,
onToggle: (Boolean) -> Unit,
onTestTrae: () -> Unit,
onTestWorkBuddy: () -> Unit
) {
val context = LocalContext.current
var modelMenuExpanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
@ -270,6 +284,39 @@ private fun ProxyGatewayCard(
Text("测试 WorkBuddy 流")
}
}
Row(
modifier = Modifier.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "模型",
style = MaterialTheme.typography.labelSmall,
color = InkMuted
)
Spacer(modifier = Modifier.width(8.dp))
Box {
TextButton(onClick = { modelMenuExpanded = true }) {
Text(selectedModel + " ▾")
}
DropdownMenu(
expanded = modelMenuExpanded,
onDismissRequest = { modelMenuExpanded = false }
) {
models.forEach { m ->
DropdownMenuItem(
text = { Text(m.label ?: m.id) },
onClick = {
onSelectModel(m.id)
modelMenuExpanded = false
}
)
}
}
}
TextButton(onClick = onRefreshModels) {
Text("刷新")
}
}
if (debug != null) {
Spacer(modifier = Modifier.padding(top = 8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {

View File

@ -7,6 +7,7 @@ import com.rainy.token.data.proxy.ProviderRegion
import com.rainy.token.data.proxy.ProxyUpstreamStream
import com.rainy.token.data.proxy.ProxyServerConfig
import com.rainy.token.data.proxy.TraeChatProxy
import com.rainy.token.data.proxy.TraeModelProvider
import com.rainy.token.data.proxy.WorkBuddyChatProxy
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.CredentialStatus
@ -38,7 +39,8 @@ class SettingsViewModel @Inject constructor(
private val credentialRepository: CredentialRepository,
private val localProxyServer: LocalProxyServer,
private val traeChatProxy: TraeChatProxy,
private val workBuddyChatProxy: WorkBuddyChatProxy
private val workBuddyChatProxy: WorkBuddyChatProxy,
private val traeModelProvider: TraeModelProvider,
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
@ -74,12 +76,34 @@ class SettingsViewModel @Inject constructor(
private val _proxyDebugRaw = MutableStateFlow<String?>(null)
val proxyDebugRaw: StateFlow<String?> = _proxyDebugRaw.asStateFlow()
/** Trae 可用模型列表(内置兜底 + 在线拉取账号模型)。 */
val proxyModels: StateFlow<List<TraeModelProvider.TraeModel>> = traeModelProvider.models
/** 当前选择的 Trae 默认模型。 */
private val _proxyModel = MutableStateFlow(traeModelProvider.selectedModel)
val proxyModel: StateFlow<String> = _proxyModel.asStateFlow()
/** 切换 Trae 默认模型并持久化。 */
fun selectProxyModel(id: String) {
traeModelProvider.setSelectedModel(id)
_proxyModel.value = id
}
/** 拉取当前账号可用的 Trae 模型列表。 */
fun refreshProxyModels() {
viewModelScope.launch {
val ok = traeModelProvider.refreshFor(ProviderRegion.CN)
if (!ok) _proxyDebug.value = "模型列表更新失败(当前显示内置列表)"
}
}
/** 用 Trae CN 默认账号发一条 stream=true 测试。 */
fun testTraeStream() {
viewModelScope.launch {
_proxyDebug.value = "正在请求 Trae CN 流..."
_proxyDebugRaw.value = null
val body = """{\"model\":\"glm-5.2\",\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"只回复OK两个字\"}],\"max_tokens\":100}\"""
val model = traeModelProvider.selectedModel
val body = """{"model":"$model","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}"""
try {
val stream = traeChatProxy.openStreamingChat(body, null, ProviderRegion.CN)
if (stream == null) {
@ -108,7 +132,7 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch {
_proxyDebug.value = "正在请求 WorkBuddy CN 流..."
_proxyDebugRaw.value = null
val body = """{\"model\":\"glm-5.2\",\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"只回复OK两个字\"}],\"max_tokens\":100}\"""
val body = """{"model":"glm-5.2","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}"""
try {
val stream = workBuddyChatProxy.openStreamingChat(body, null, ProviderRegion.CN)
if (stream == null) {