feat(proxy): M1b WorkBuddy CN/Trae CN 适配 + 设置页网关开关

- KtorLocalProxyServer:修复审计阻断项(start/stop 加锁、apiKey Bearer 鉴权、IOException 502、请求体上限、错误 JSON 规范化)
- WorkBuddyChatProxy:转发到 copilot.tencent.com/v2/chat/completions,Bearer accessToken + IDE 请求头
- TraeChatProxy:转发到 api.trae.cn/api/agent/v3/llm_utils_chat,Cloud-IDE-JWT 身份/设备头,最小 body 转换
- Credential.WorkBuddyCredential 增加 region 字段(默认 CN,兼容旧数据)
- Settings 页新增代理网关开关(默认 127.0.0.1:8787,本机 Key: rainytoken)
This commit is contained in:
Liuxinyu176 2026-10-08 23:23:16 +08:00
parent 85c6cf4283
commit 11b9065f82
7 changed files with 382 additions and 39 deletions

View File

@ -1,9 +1,11 @@
package com.rainy.token.data.proxy
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
@ -15,6 +17,7 @@ import io.ktor.server.response.respondBytes
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableStateFlow
@ -24,45 +27,63 @@ import kotlinx.coroutines.flow.asStateFlow
/**
* 基于 Ktor CIO 的本地 HTTP 反代服务。
*
* M1a 能力:
* M1b 能力:
* - GET /health
* - GET /v1/models → Sub2API 透传
* - POST /v1/chat/completions → Sub2API 透传
* - GET /v1/models
* - POST /v1/chat/completions
* 按模型前缀路由:wbcn- → WorkBuddy 国内版;traecn- → Trae CN;其余 → Sub2API 透传
*
* 只绑定 127.0.0.1,默认端口 8787。
* 安全:
* - 只绑定 127.0.0.1
* - config.apiKey 非空时,所有 v1 业务路由要求 Bearer Key 一致,否则 401
*/
@Singleton
class KtorLocalProxyServer @Inject constructor(
private val sub2ApiChatProxy: Sub2ApiChatProxy,
private val workBuddyChatProxy: WorkBuddyChatProxy,
private val traeChatProxy: TraeChatProxy,
) : LocalProxyServer {
private val lock = Any()
@Volatile
private var server: EmbeddedServer<*, *>? = null
private val _isRunning = MutableStateFlow(false)
override val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
private var server: EmbeddedServer<*, *>? = null
override fun start(config: ProxyServerConfig): Result<Unit> = try {
override fun start(config: ProxyServerConfig): Result<Unit> {
synchronized(lock) {
if (_isRunning.value) return Result.success(Unit)
return try {
val engine = embeddedServer(CIO, host = "127.0.0.1", port = config.port) {
proxyModule(config, sub2ApiChatProxy)
proxyModule(config.apiKey, sub2ApiChatProxy, workBuddyChatProxy, traeChatProxy)
}
engine.start(wait = false)
server = engine
_isRunning.value = true
Result.success(Unit)
} catch (e: Throwable) {
server = null
_isRunning.value = false
Result.failure(e)
}
}
}
override fun stop() {
synchronized(lock) {
runCatching { server?.stop(gracePeriodMillis = 500, timeoutMillis = 2000) }
server = null
_isRunning.value = false
}
}
private fun Application.proxyModule(
config: ProxyServerConfig,
apiKey: String?,
sub2Api: Sub2ApiChatProxy,
workBuddy: WorkBuddyChatProxy,
trae: TraeChatProxy,
) {
install(ContentNegotiation) {
json()
@ -72,38 +93,91 @@ class KtorLocalProxyServer @Inject constructor(
call.respond(mapOf("status" to "ok"))
}
get("/v1/models") {
if (!authorized(call, apiKey)) return@get
val result = sub2Api.forwardModels()
if (result == null) {
call.respondBytes(
errorBody("Sub2API 未配置或未登录,请在设置中填写 API Key"),
ContentType.Application.Json,
HttpStatusCode.BadRequest
)
call.respond(HttpStatusCode.BadRequest, errorBody("Sub2API 未配置或未登录,请在设置中填写 API Key"))
} else {
call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, ""))
}
}
post("/v1/chat/completions") {
if (!authorized(call, apiKey)) return@post
val body = call.receiveText()
val result = sub2Api.forwardChat(body)
if (body.length > MAX_REQUEST_BYTES) {
call.respond(HttpStatusCode(413, ""), errorBody("请求体过大"))
return@post
}
val model = extractModel(body)
val provider = routeProvider(model)
try {
val result = when (provider) {
ChatRoute.WORKBUDDY_CN -> workBuddy.forwardChat(body)
ChatRoute.TRAE_CN -> trae.forwardChat(body)
ChatRoute.SUB2API -> sub2Api.forwardChat(body)
}
if (result == null) {
call.respondBytes(
errorBody("Sub2API 未配置或未登录,请在设置中填写 API Key"),
ContentType.Application.Json,
HttpStatusCode.BadRequest
call.respond(
HttpStatusCode.BadRequest,
errorBody("${provider.displayName} 未配置或未登录,请先在设置中配置")
)
} else {
call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, ""))
}
} catch (e: IOException) {
val detail = e.message ?: "未知错误"
call.respond(
HttpStatusCode.BadGateway,
errorBody("上游网络错误:$detail")
)
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
val detail = e.message ?: "未知错误"
call.respond(
HttpStatusCode.InternalServerError,
errorBody("网关内部错误:$detail")
)
}
}
}
}
private fun errorBody(message: String): ByteArray {
val safe = message.replace("\"", "'")
return "{\"error\":{\"message\":\"$safe\",\"type\":\"invalid_request_error\"}}".toByteArray()
private fun authorized(call: ApplicationCall, apiKey: String?): Boolean {
if (apiKey.isNullOrBlank()) return true
val header = call.request.headers[HttpHeaders.Authorization] ?: return false
val expect = "Bearer $apiKey"
return header.trim() == expect
}
private fun errorBody(message: String): Map<String, Any> =
mapOf("error" to mapOf("message" to message, "type" to "invalid_request_error"))
private fun contentTypeOf(raw: String): ContentType =
runCatching { ContentType.parse(raw) }.getOrDefault(ContentType.Application.Json)
private fun extractModel(body: String): String = runCatching {
val el = kotlinx.serialization.json.Json.parseToJsonElement(body)
(el as? kotlinx.serialization.json.JsonObject)
?.get("model")
?.let { if (it is kotlinx.serialization.json.JsonPrimitive) it.content else null }
}.getOrNull() ?: ""
private fun routeProvider(model: String): ChatRoute {
val m = model.lowercase()
return when {
m.startsWith("wbcn-") -> ChatRoute.WORKBUDDY_CN
m.startsWith("traecn-") || m.startsWith("trae-") -> ChatRoute.TRAE_CN
else -> ChatRoute.SUB2API
}
}
private enum class ChatRoute(val displayName: String) {
WORKBUDDY_CN("WorkBuddy 国内版"),
TRAE_CN("Trae CN"),
SUB2API("Sub2API"),
}
companion object {
private const val MAX_REQUEST_BYTES = 10 * 1024 * 1024
}
}

View File

@ -0,0 +1,117 @@
package com.rainy.token.data.proxy
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
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.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Trae CN 上游 Chat 代理。
*
* 上游为私有协议 POST {base}/api/agent/v3/llm_utils_chat,
* 请求头需要 Cloud-IDE-JWT / x-device-id / x-uid 等设备与身份指纹。
* M1b 实现最小 body 转换:保留 messages/tools,注入 config_name/function/stream/session。
*/
@Singleton
class TraeChatProxy @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
) {
private val json = Json { ignoreUnknownKeys = true }
suspend fun forwardChat(requestBody: String, accountId: String? = null): ProxyUpstreamResponse? =
withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
?: return@withContext null
if (credential !is Credential.TraeCredential) return@withContext null
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() }
?: return@withContext null
val base = if (credential.region.equals("INTL", ignoreCase = true)) {
"https://grow-normal.trae.ai"
} else {
"https://api.trae.cn"
}
val upstreamBody = buildUpstreamBody(requestBody, credential)
val requestId = UUID.randomUUID().toString()
val sessionId = UUID.randomUUID().toString().replace("-", "")
val builder = Request.Builder()
.url(base + "/api/agent/v3/llm_utils_chat")
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "text/event-stream")
.addHeader("Authorization", "Cloud-IDE-JWT \$jwt")
.addHeader("X-Cloudide-Token", jwt)
.addHeader("x-uid", credential.userId ?: "")
.addHeader("x-device-id", credential.deviceId ?: credential.checkinDeviceId ?: "")
.addHeader("x-machine-id", credential.deviceId ?: credential.checkinDeviceId ?: "")
.addHeader("x-request-id", requestId)
.addHeader("x-app-id", "trae")
.addHeader("x-ide-version", "1.0.0")
.addHeader("x-ide-version-code", "1000000")
.addHeader("x-ide-version-type", "stable")
.addHeader("x-os-version", "Android")
.addHeader("x-system-type", "Android")
.addHeader("User-Agent", "RainyToken/1.0")
.post(upstreamBody.toRequestBody("application/json".toMediaType()))
val response = try {
okHttpClient.newCall(builder.build()).execute()
} catch (e: java.io.IOException) {
throw e
}
val bytes = try { response.body?.bytes() ?: ByteArray(0) } catch (_: Throwable) { ByteArray(0) }
val contentType = response.header("Content-Type") ?: "application/json"
val status = response.code
response.close()
ProxyUpstreamResponse(status, contentType, bytes)
}
private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential): String {
val src = try {
json.parseToJsonElement(raw) as? JsonObject
} catch (_: Throwable) {
null
} ?: return raw
val model = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() }
?: "glm-5.2"
val messages = src["messages"] as? JsonArray ?: JsonArray(emptyList())
val sessionId = UUID.randomUUID().toString().replace("-", "")
return buildJsonObject {
put("conversation_id", "")
put("session_id", sessionId)
put("user_id", credential.userId ?: "")
put("device_id", credential.deviceId ?: credential.checkinDeviceId ?: "")
put("agent_type", "solo_work_lite")
put("model_name", model + "__dev")
put("config_name", model)
put("function", "solo_work_lite")
put("stream", true)
put("request_id", sessionId)
put("messages", messages)
src["tools"]?.let { put("tools", it) }
src["tool_choice"]?.let { put("tool_choice", it) }
(src["max_tokens"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull()?.let {
put("max_tokens", it)
}
}.toString()
}
}

View File

@ -0,0 +1,68 @@
package com.rainy.token.data.proxy
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* WorkBuddy / CodeBuddy 上游 Chat 代理(国内版)。
*
* 上游协议为 OpenAI 兼容的 POST {base}/v2/chat/completions,Bearer accessToken。
* 这里保留客户端原始 body(M1b 最小实现),并补齐桌面端请求头;完整归一化在后续迭代。
*/
@Singleton
class WorkBuddyChatProxy @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
) {
suspend fun forwardChat(requestBody: String, accountId: String? = null): ProxyUpstreamResponse? =
withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId)
?: return@withContext null
if (credential !is Credential.WorkBuddyCredential) return@withContext null
val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() }
?: return@withContext null
val base = if (credential.region.equals("INTL", ignoreCase = true)) {
"https://www.workbuddy.ai"
} else {
"https://copilot.tencent.com"
}
val requestId = UUID.randomUUID().toString()
val builder = Request.Builder()
.url(base + "/v2/chat/completions")
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "text/event-stream")
.addHeader("Authorization", "Bearer \$accessToken")
.addHeader("User-Agent", "RainyToken/1.0")
.addHeader("X-IDE-Type", "VSCode")
.addHeader("X-IDE-Name", "CodeBuddy")
.addHeader("X-IDE-Version", "3.0.0")
.addHeader("X-Product", "CodeBuddy")
.addHeader("X-Agent-Intent", "craft")
.addHeader("X-Request-ID", requestId)
.addHeader("X-Conv-Request-ID", requestId)
.post(requestBody.toRequestBody("application/json".toMediaType()))
val response = try {
okHttpClient.newCall(builder.build()).execute()
} catch (e: java.io.IOException) {
throw e
}
val bytes = try { response.body?.bytes() ?: ByteArray(0) } catch (_: Throwable) { ByteArray(0) }
val contentType = response.header("Content-Type") ?: "application/json"
val status = response.code
response.close()
ProxyUpstreamResponse(status, contentType, bytes)
}
}

View File

@ -23,6 +23,8 @@ import com.rainy.token.data.repository.UpdateRepository
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.WorkBuddyChatProxy
import com.rainy.token.data.repository.WorkBuddyRepository
import dagger.Module
import dagger.Provides
@ -235,11 +237,27 @@ object NetworkModule {
credentialRepository: CredentialRepository
): Sub2ApiChatProxy = Sub2ApiChatProxy(okHttpClient, credentialRepository)
@Provides
@Singleton
fun provideWorkBuddyChatProxy(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository
): WorkBuddyChatProxy = WorkBuddyChatProxy(okHttpClient, credentialRepository)
@Provides
@Singleton
fun provideTraeChatProxy(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository
): TraeChatProxy = TraeChatProxy(okHttpClient, credentialRepository)
@Provides
@Singleton
fun provideLocalProxyServer(
sub2ApiChatProxy: Sub2ApiChatProxy
): LocalProxyServer = KtorLocalProxyServer(sub2ApiChatProxy)
sub2ApiChatProxy: Sub2ApiChatProxy,
workBuddyChatProxy: WorkBuddyChatProxy,
traeChatProxy: TraeChatProxy
): LocalProxyServer = KtorLocalProxyServer(sub2ApiChatProxy, workBuddyChatProxy, traeChatProxy)
/** 余额缓存 DataStore(计划 7.1) */
@Provides

View File

@ -106,6 +106,8 @@ sealed class Credential {
override val service: ServiceType,
val accessToken: String,
val refreshToken: String,
/** 区域:"CN"(copilot.tencent.com / codebuddy.cn)/ "INTL"(workbuddy.ai) */
val region: String = "CN",
/** 过期时间(epoch millis),0=未知 */
val expiresAt: Long = 0L,
override val lastVerifiedAt: Long = 0L

View File

@ -28,6 +28,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@ -89,6 +90,7 @@ fun SettingsScreen(
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val proxyRunning by viewModel.proxyRunning.collectAsStateWithLifecycle()
var showLanguageDialog by remember { mutableStateOf(false) }
Scaffold(
@ -159,6 +161,12 @@ fun SettingsScreen(
item {
LanguageCard(onClick = { showLanguageDialog = true })
}
item {
ProxyGatewayCard(
running = proxyRunning,
onToggle = viewModel::setProxyEnabled
)
}
item {
TipsCard(onClick = { onOpenTips() })
}
@ -181,6 +189,47 @@ fun SettingsScreen(
}
}
@Composable
private fun ProxyGatewayCard(
running: Boolean,
onToggle: (Boolean) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🛰️",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "代理网关",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = if (running) "运行中 · http://127.0.0.1:8787/v1" else "已停止",
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(top = 2.dp)
)
}
Switch(
checked = running,
onCheckedChange = onToggle
)
}
}
}
@Composable
private fun LanguageCard(onClick: () -> Unit) {
val context = LocalContext.current

View File

@ -2,6 +2,8 @@ package com.rainy.token.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.data.proxy.LocalProxyServer
import com.rainy.token.data.proxy.ProxyServerConfig
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.service.ServiceType
@ -19,12 +21,25 @@ import javax.inject.Inject
*/
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val credentialRepository: CredentialRepository
private val credentialRepository: CredentialRepository,
private val localProxyServer: LocalProxyServer
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
/** 本地反代服务运行状态。 */
val proxyRunning: StateFlow<Boolean> = localProxyServer.isRunning
/** 启动/停止本地反代服务。M1b 使用固定本地 Key,后续改为可配置。 */
fun setProxyEnabled(enabled: Boolean) {
if (enabled) {
localProxyServer.start(ProxyServerConfig(apiKey = "rainytoken"))
} else {
localProxyServer.stop()
}
}
init {
refresh()
}