feat(proxy): 按代理/区域区分模型列表与选择
Some checks are pending
Release / build (push) Waiting to run

- 新增“代理”下拉:Trae CN/INTL、WorkBuddy CN/INTL
- WorkBuddyModelProvider 在线拉取 /v2/enterprises/personal/models
- Trae/WorkBuddy 各区域独立记住所选测试模型
- 真实代理转发仍按前缀自动路由,不受测试选择影响
- 版本升至 1.7.6
This commit is contained in:
Liuxinyu176 2026-10-09 11:21:53 +08:00
parent 0283ce6bdc
commit f13b38c02b
6 changed files with 239 additions and 27 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 = 25 versionCode = 26
versionName = "1.7.5" versionName = "1.7.6"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -60,7 +60,7 @@ class TraeChatProxy @Inject constructor(
} else { } else {
"https://trae-api-cn.mchost.guru" "https://trae-api-cn.mchost.guru"
} }
val upstreamBody = buildUpstreamBody(requestBody, credential) val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion)
val requestId = UUID.randomUUID().toString() val requestId = UUID.randomUUID().toString()
val builder = Request.Builder() val builder = Request.Builder()
@ -99,7 +99,7 @@ class TraeChatProxy @Inject constructor(
} else { } else {
"https://trae-api-cn.mchost.guru" "https://trae-api-cn.mchost.guru"
} }
val upstreamBody = buildUpstreamBody(requestBody, credential) val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion)
val requestId = UUID.randomUUID().toString() val requestId = UUID.randomUUID().toString()
val builder = Request.Builder() val builder = Request.Builder()
@ -230,13 +230,14 @@ class TraeChatProxy @Inject constructor(
} }
/** 外部模型名 -> Trae 内部模型名;空/auto 用设置页选择的默认模型。 */ /** 外部模型名 -> Trae 内部模型名;空/auto 用设置页选择的默认模型。 */
private fun normalizeModel(raw: String?): String { private fun normalizeModel(raw: String?, region: ProviderRegion?): String {
val id = raw?.trim()?.takeIf { it.isNotBlank() } ?: return modelProvider.selectedModel val selected = modelProvider.selectedFor(region ?: ProviderRegion.CN)
if (id.equals("auto", ignoreCase = true)) return modelProvider.selectedModel val id = raw?.trim()?.takeIf { it.isNotBlank() } ?: return selected
if (id.equals("auto", ignoreCase = true)) return selected
return MODEL_ALIASES[id.lowercase()] ?: id return MODEL_ALIASES[id.lowercase()] ?: id
} }
private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential): 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
} catch (_: Throwable) { } catch (_: Throwable) {
@ -244,7 +245,7 @@ class TraeChatProxy @Inject constructor(
} ?: return raw } ?: return raw
val rawModel = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } val rawModel = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() }
val model = normalizeModel(rawModel) val model = normalizeModel(rawModel, region)
val messages = convertNativeMessages(src["messages"]) val messages = convertNativeMessages(src["messages"])
val sessionId = UUID.randomUUID().toString().replace("-", "") val sessionId = UUID.randomUUID().toString().replace("-", "")

View File

@ -41,16 +41,20 @@ class TraeModelProvider @Inject constructor(
private val _models = MutableStateFlow(defaultModels()) private val _models = MutableStateFlow(defaultModels())
val models: StateFlow<List<TraeModel>> = _models.asStateFlow() 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 } private val json = Json { ignoreUnknownKeys = true }
fun setSelectedModel(id: String) { /** 当前区域选择的默认模型(未设置时 CN 用 GLM-5.2)。 */
fun selectedFor(region: ProviderRegion): String {
if (region == ProviderRegion.INTL) {
return prefs.getString(KEY_MODEL_INTL, DEFAULT_MODEL_INTL) ?: DEFAULT_MODEL_INTL
}
return prefs.getString(KEY_MODEL_CN, "glm-5.2") ?: "glm-5.2"
}
fun setSelectedFor(region: ProviderRegion, id: String) {
if (id.isBlank()) return if (id.isBlank()) return
selectedModel = id val key = if (region == ProviderRegion.INTL) KEY_MODEL_INTL else KEY_MODEL_CN
prefs.edit().putString(KEY_MODEL, id).apply() prefs.edit().putString(key, id).apply()
} }
/** 拉取账号在线模型列表;失败时保留内置列表并返回 false。 */ /** 拉取账号在线模型列表;失败时保留内置列表并返回 false。 */
@ -120,7 +124,9 @@ class TraeModelProvider @Inject constructor(
} }
companion object { companion object {
private const val KEY_MODEL = "trae_default_model" private const val KEY_MODEL_CN = "trae_default_model_cn"
private const val KEY_MODEL_INTL = "trae_default_model_intl"
private const val DEFAULT_MODEL_INTL = "glm-5.2"
/** 内置兜底模型(参考 trae2api-cn 的模型映射 /v1/models 列表)。 */ /** 内置兜底模型(参考 trae2api-cn 的模型映射 /v1/models 列表)。 */
fun defaultModels(): List<TraeModel> = listOf( fun defaultModels(): List<TraeModel> = listOf(

View File

@ -0,0 +1,119 @@
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
/**
* WorkBuddy / CodeBuddy 模型列表:
* 在线接口 GET {base}/v2/enterprises/personal/models + 内置兜底 + 按区域记住选择。
*/
@Singleton
class WorkBuddyModelProvider @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
@ApplicationContext private val context: Context,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE)
private val _models = MutableStateFlow(defaultModels())
val models: StateFlow<List<TraeModelProvider.TraeModel>> = _models.asStateFlow()
private val json = Json { ignoreUnknownKeys = true }
fun selectedFor(region: ProviderRegion): String {
val key = if (region == ProviderRegion.INTL) KEY_MODEL_INTL else KEY_MODEL_CN
return prefs.getString(key, DEFAULT_MODEL) ?: DEFAULT_MODEL
}
fun setSelectedFor(region: ProviderRegion, id: String) {
if (id.isBlank()) return
val key = if (region == ProviderRegion.INTL) KEY_MODEL_INTL else KEY_MODEL_CN
prefs.edit().putString(key, id).apply()
}
suspend fun refreshFor(region: ProviderRegion): Boolean = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.WORKBUDDY, null)
?: return@withContext false
if (credential !is Credential.WorkBuddyCredential) return@withContext false
val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() }
?: return@withContext false
val intl = region == ProviderRegion.INTL
val base = if (intl) "https://www.workbuddy.ai" else "https://copilot.tencent.com"
val url = base + "/v2/enterprises/personal/models"
val headers = linkedMapOf(
"Authorization" to "Bearer $accessToken",
"Content-Type" to "application/json",
"Accept" to "application/json",
"X-Client-ID" to "codebuddy-cli",
"X-Client-Version" to "2.143.1",
"X-Product" to "SaaS",
"User-Agent" to "CLI/2.143.1 CodeBuddy/2.143.1",
"Origin" to (if (intl) "https://www.workbuddy.ai" else "https://www.codebuddy.cn"),
)
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<TraeModelProvider.TraeModel>? {
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
?: return null
val data = root["data"] as? JsonObject ?: return null
val models = data["models"] as? JsonArray ?: return null
val seen = LinkedHashMap<String, TraeModelProvider.TraeModel>()
for (rawEl in models) {
val raw = rawEl as? JsonObject ?: continue
val id = (raw["id"] as? JsonPrimitive)?.contentOrNull?.trim()
?.takeIf { it.isNotEmpty() } ?: continue
val label = (raw["name"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } ?: id
seen[id] = TraeModelProvider.TraeModel(id, label)
}
return seen.values.toList().ifEmpty { null }
}
companion object {
private const val KEY_MODEL_CN = "wb_default_model_cn"
private const val KEY_MODEL_INTL = "wb_default_model_intl"
private const val DEFAULT_MODEL = "hy4-preview"
/** 内置兜底(参考 workbuddy-gateway 模型目录,在线列表成功后会被替换)。 */
fun defaultModels(): List<TraeModelProvider.TraeModel> = listOf(
TraeModelProvider.TraeModel("hy4-preview", "hy4-preview"),
TraeModelProvider.TraeModel("hy3", "hy3"),
TraeModelProvider.TraeModel("hy3-preview", "hy3-preview"),
TraeModelProvider.TraeModel("deepseek-v4.1-flash", "deepseek-v4.1-flash"),
)
}
}

View File

@ -109,6 +109,7 @@ fun SettingsScreen(
val proxyDebug by viewModel.proxyDebug.collectAsStateWithLifecycle() val proxyDebug by viewModel.proxyDebug.collectAsStateWithLifecycle()
val proxyDebugRaw by viewModel.proxyDebugRaw.collectAsStateWithLifecycle() val proxyDebugRaw by viewModel.proxyDebugRaw.collectAsStateWithLifecycle()
val proxyModels by viewModel.proxyModels.collectAsStateWithLifecycle() val proxyModels by viewModel.proxyModels.collectAsStateWithLifecycle()
val selectedProxy by viewModel.selectedProxy.collectAsStateWithLifecycle()
val proxyModel by viewModel.proxyModel.collectAsStateWithLifecycle() val proxyModel by viewModel.proxyModel.collectAsStateWithLifecycle()
var showLanguageDialog by remember { mutableStateOf(false) } var showLanguageDialog by remember { mutableStateOf(false) }
@ -187,6 +188,9 @@ fun SettingsScreen(
error = proxyError, error = proxyError,
debug = proxyDebug, debug = proxyDebug,
debugRaw = proxyDebugRaw, debugRaw = proxyDebugRaw,
proxyTargets = viewModel.proxyTargets,
selectedProxy = selectedProxy,
onSelectProxy = viewModel::selectProxy,
models = proxyModels, models = proxyModels,
selectedModel = proxyModel, selectedModel = proxyModel,
onSelectModel = viewModel::selectProxyModel, onSelectModel = viewModel::selectProxyModel,
@ -225,6 +229,9 @@ private fun ProxyGatewayCard(
error: String?, error: String?,
debug: String?, debug: String?,
debugRaw: String?, debugRaw: String?,
proxyTargets: List<ProxyTarget>,
selectedProxy: ProxyTarget,
onSelectProxy: (ProxyTarget) -> Unit,
models: List<TraeModelProvider.TraeModel>, models: List<TraeModelProvider.TraeModel>,
selectedModel: String, selectedModel: String,
onSelectModel: (String) -> Unit, onSelectModel: (String) -> Unit,
@ -234,6 +241,7 @@ private fun ProxyGatewayCard(
onTestWorkBuddy: () -> Unit onTestWorkBuddy: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
var proxyMenuExpanded by remember { mutableStateOf(false) }
var modelMenuExpanded by remember { mutableStateOf(false) } var modelMenuExpanded by remember { mutableStateOf(false) }
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -284,6 +292,36 @@ private fun ProxyGatewayCard(
Text("测试 WorkBuddy 流") 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 = { proxyMenuExpanded = true }) {
Text(selectedProxy.label + " ▾")
}
DropdownMenu(
expanded = proxyMenuExpanded,
onDismissRequest = { proxyMenuExpanded = false }
) {
proxyTargets.forEach { t ->
DropdownMenuItem(
text = { Text(t.label) },
onClick = {
onSelectProxy(t)
proxyMenuExpanded = false
}
)
}
}
}
}
Row( Row(
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically

View File

@ -8,6 +8,7 @@ import com.rainy.token.data.proxy.ProxyUpstreamStream
import com.rainy.token.data.proxy.ProxyServerConfig import com.rainy.token.data.proxy.ProxyServerConfig
import com.rainy.token.data.proxy.TraeChatProxy import com.rainy.token.data.proxy.TraeChatProxy
import com.rainy.token.data.proxy.TraeModelProvider import com.rainy.token.data.proxy.TraeModelProvider
import com.rainy.token.data.proxy.WorkBuddyModelProvider
import com.rainy.token.data.proxy.WorkBuddyChatProxy import com.rainy.token.data.proxy.WorkBuddyChatProxy
import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.model.CredentialStatus
@ -41,6 +42,7 @@ class SettingsViewModel @Inject constructor(
private val traeChatProxy: TraeChatProxy, private val traeChatProxy: TraeChatProxy,
private val workBuddyChatProxy: WorkBuddyChatProxy, private val workBuddyChatProxy: WorkBuddyChatProxy,
private val traeModelProvider: TraeModelProvider, private val traeModelProvider: TraeModelProvider,
private val workBuddyModelProvider: WorkBuddyModelProvider,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState()) private val _uiState = MutableStateFlow(SettingsUiState())
@ -76,23 +78,56 @@ class SettingsViewModel @Inject constructor(
private val _proxyDebugRaw = MutableStateFlow<String?>(null) private val _proxyDebugRaw = MutableStateFlow<String?>(null)
val proxyDebugRaw: StateFlow<String?> = _proxyDebugRaw.asStateFlow() val proxyDebugRaw: StateFlow<String?> = _proxyDebugRaw.asStateFlow()
/** Trae 可用模型列表(内置兜底 + 在线拉取账号模型)。 */ /** 代理目标(用于测试和模型选择;真实转发仍由 ProviderRouter 自动路由)。 */
val proxyModels: StateFlow<List<TraeModelProvider.TraeModel>> = traeModelProvider.models val proxyTargets: List<ProxyTarget> = ProxyTarget.entries
/** 当前选择的 Trae 默认模型。 */ private val _selectedProxy = MutableStateFlow(ProxyTarget.TRAE_CN)
private val _proxyModel = MutableStateFlow(traeModelProvider.selectedModel) val selectedProxy: StateFlow<ProxyTarget> = _selectedProxy.asStateFlow()
private val _proxyModels = MutableStateFlow<List<TraeModelProvider.TraeModel>>(traeModelProvider.models.value)
val proxyModels: StateFlow<List<TraeModelProvider.TraeModel>> = _proxyModels.asStateFlow()
private val _proxyModel = MutableStateFlow(traeModelProvider.selectedFor(ProviderRegion.CN))
val proxyModel: StateFlow<String> = _proxyModel.asStateFlow() val proxyModel: StateFlow<String> = _proxyModel.asStateFlow()
/** 切换 Trae 默认模型并持久化。 */ /** 切换当前测试目标代理。 */
fun selectProxy(target: ProxyTarget) {
_selectedProxy.value = target
val region = target.region()
_proxyModels.value = when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.models.value
else -> workBuddyModelProvider.models.value
}
_proxyModel.value = when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.selectedFor(region)
else -> workBuddyModelProvider.selectedFor(region)
}
}
/** 切换当前代理的测试模型并持久化。 */
fun selectProxyModel(id: String) { fun selectProxyModel(id: String) {
traeModelProvider.setSelectedModel(id) val target = _selectedProxy.value
val region = target.region()
when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.setSelectedFor(region, id)
else -> workBuddyModelProvider.setSelectedFor(region, id)
}
_proxyModel.value = id _proxyModel.value = id
} }
/** 拉取当前账号可用的 Trae 模型列表。 */ /** 拉取当前代理可用模型;失败保留内置列表。 */
fun refreshProxyModels() { fun refreshProxyModels() {
val target = _selectedProxy.value
val region = target.region()
viewModelScope.launch { viewModelScope.launch {
val ok = traeModelProvider.refreshFor(ProviderRegion.CN) val ok = when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.refreshFor(region)
else -> workBuddyModelProvider.refreshFor(region)
}
_proxyModels.value = when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.models.value
else -> workBuddyModelProvider.models.value
}
if (!ok) _proxyDebug.value = "模型列表更新失败(当前显示内置列表)" if (!ok) _proxyDebug.value = "模型列表更新失败(当前显示内置列表)"
} }
} }
@ -102,7 +137,7 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
_proxyDebug.value = "正在请求 Trae CN 流..." _proxyDebug.value = "正在请求 Trae CN 流..."
_proxyDebugRaw.value = null _proxyDebugRaw.value = null
val model = traeModelProvider.selectedModel val model = traeModelProvider.selectedFor(ProviderRegion.CN)
val body = """{"model":"$model","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}""" val body = """{"model":"$model","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}"""
try { try {
val stream = traeChatProxy.openStreamingChat(body, null, ProviderRegion.CN) val stream = traeChatProxy.openStreamingChat(body, null, ProviderRegion.CN)
@ -132,7 +167,8 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
_proxyDebug.value = "正在请求 WorkBuddy CN 流..." _proxyDebug.value = "正在请求 WorkBuddy CN 流..."
_proxyDebugRaw.value = null _proxyDebugRaw.value = null
val body = """{"model":"glm-5.2","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}""" val model = workBuddyModelProvider.selectedFor(ProviderRegion.CN)
val body = """{"model":"$model","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}"""
try { try {
val stream = workBuddyChatProxy.openStreamingChat(body, null, ProviderRegion.CN) val stream = workBuddyChatProxy.openStreamingChat(body, null, ProviderRegion.CN)
if (stream == null) { if (stream == null) {
@ -313,6 +349,18 @@ class SettingsViewModel @Inject constructor(
} }
} }
enum class ProxyTarget(val key: String, val label: String) {
TRAE_CN("trae_cn", "Trae CN"),
TRAE_INTL("trae_intl", "Trae INTL"),
WORKBUDDY_CN("wb_cn", "WorkBuddy CN"),
WORKBUDDY_INTL("wb_intl", "WorkBuddy INTL");
fun region(): ProviderRegion = when (this) {
TRAE_CN, WORKBUDDY_CN -> ProviderRegion.CN
else -> ProviderRegion.INTL
}
}
data class StreamProbeResult( data class StreamProbeResult(
val summary: String, val summary: String,
val raw: String, val raw: String,