init: 反代原理与核心代码提取(纯文档 + Kotlin 片段)

This commit is contained in:
maya 2026-10-09 10:44:24 +08:00
commit 8d149ec70c
17 changed files with 1329 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# 本地生成物
build/
*.log

48
README.md Normal file
View File

@ -0,0 +1,48 @@
# Proxy Playbook · 反代原理与代码提取
> 纯原理 + 关键代码,不包含完整项目。
> 代码提取自 RainyToken App 的本地反向代理模块,用于研究“把登录态账号变成本地 OpenAI 兼容 API”的实现思路。
## 这是什么
一个 **本地 OpenAI 兼容反向代理** 的核心:
- App 内起一个本地 HTTP 服务(http://127.0.0.1:8787/v1)
- 客户端(任何 OpenAI SDK)把请求发给本地端口
- 代理根据**模型名前缀**路由到不同上游(Trae / WorkBuddy / 通用 Sub2API)
- 从**账号池**里按会话/地区选出可用账号,携带真实登录态请求上游
OpenAI Client ---> Local Proxy ---> ProviderRouter ---> TraeChatProxy ---> api.trae...
| WorkBuddyChatProxy ---> copilot.tencent.com
---> AccountPool ---> Sub2ApiChatProxy ---> 任意 OpenAI 兼容站
## 目录
README.md
docs/
ARCHITECTURE.md # 整体架构、路由表、请求生命周期
TRAE_PROTOCOL.md # Trae CN/INTL 上游协议(llm_utils_chat / SOLO)
WORKBUDDY_PROTOCOL.md # WorkBuddy 上游协议(/v2/chat/completions)
SSE_NORMALIZATION.md # 私有 SSE 信封 → OpenAI 事件流
code/
local_server/ # Ktor 本地服务(接口 + 实现)
model/ # 路由/地区/请求/响应/SSE 模型
routing/ # 模型前缀 → ProviderKind/Region
pool/ # 账号池(轮询、会话粘性、地区匹配)
upstream/ # Sub2API / Trae / WorkBuddy 上游适配器
## 快速使用
这些 Kotlin 文件是从 Android App 里按原样提取的,不是独立可构建项目。
接入你自己的项目时:
1. 引入依赖:ktor-server-core / ktor-server-cio / ktor-server-content-negotiation / ktor-serialization-kotlinx-json / OkHttp / kotlinx.serialization
2. 用 Hilt/Dagger/Koin 把 KtorLocalProxyServer 注入为单例
3. 实现 CredentialRepository,返回各家的登录态凭据
4. 调用 localProxyServer.start(ProxyServerConfig(apiKey = "xxx"))
## 免责声明
- 仅供学习 / 个人自动化研究
- 上游协议可能随时变化,提取时以对应参考仓库为基准
- 请遵守各家服务条款,账号风险自负

View File

@ -0,0 +1,265 @@
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
import io.ktor.server.engine.embeddedServer
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.request.receiveText
import io.ktor.server.response.respond
import io.ktor.server.response.respondBytes
import io.ktor.server.response.respondOutputStream
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.serialization.json.contentOrNull
import kotlinx.serialization.json.put
import kotlinx.serialization.json.buildJsonObject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* 基于 Ktor CIO 的本地 HTTP 反代服务。
*
* M1b 能力:
* - GET /health
* - GET /v1/models
* - POST /v1/chat/completions
* 按模型前缀路由:wbcn- → WorkBuddy 国内版;traecn- → Trae CN;其余 → Sub2API 透传
*
* 安全:
* - 只绑定 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,
private val providerRouter: ProviderRouter,
private val accountPool: AccountPool,
) : LocalProxyServer {
private val lock = Any()
@Volatile
private var server: EmbeddedServer<*, *>? = null
private val _isRunning = MutableStateFlow(false)
override val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
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.apiKey, sub2ApiChatProxy, workBuddyChatProxy, traeChatProxy, providerRouter, accountPool)
}
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(
apiKey: String?,
sub2Api: Sub2ApiChatProxy,
workBuddy: WorkBuddyChatProxy,
trae: TraeChatProxy,
router: ProviderRouter,
pool: AccountPool,
) {
install(ContentNegotiation) {
json()
}
routing {
get("/health") {
call.respond(mapOf("status" to "ok"))
}
get("/v1/models") {
if (!authorized(call, apiKey)) {
call.respond(HttpStatusCode.Unauthorized, errorBody("未授权"))
return@get
}
val result = sub2Api.forwardModels()
if (result == null) {
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)) {
call.respond(HttpStatusCode.Unauthorized, errorBody("未授权"))
return@post
}
val rawBody = call.receiveText()
if (rawBody.length > MAX_REQUEST_BYTES) {
call.respond(HttpStatusCode(413, ""), errorBody("请求体过大"))
return@post
}
val model = extractModel(rawBody)
val route = router.route(model)
val upstreamModel = stripModelPrefix(model)
val body = if (upstreamModel != model) rewriteModelBody(rawBody, upstreamModel) else rawBody
val conversationId = call.request.headers["X-Conversation-Id"] ?: extractUser(rawBody)
val pooled = pool.next(route.kind, route.region, conversationId)
val accountId = pooled?.accountId
try {
if (extractStream(body)) {
val stream: ProxyUpstreamStream? = when (route.kind) {
ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL ->
workBuddy.openStreamingChat(body, accountId, route.region)
ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL ->
trae.openStreamingChat(body, accountId, route.region)
else -> sub2Api.openStreamingChat(body, accountId)
}
if (stream == null) {
call.respond(
HttpStatusCode.BadRequest,
errorBody("${route.kind.displayName} 未配置或未登录,请先在设置中配置")
)
} else {
call.respondOutputStream(
contentType = contentTypeOf(stream.contentType),
status = HttpStatusCode(stream.status, "")
) {
try {
val buffer = ByteArray(8192)
val input = stream.input
while (true) {
val read = input.read(buffer)
if (read < 0) break
write(buffer, 0, read)
flush()
}
} finally {
stream.close()
}
}
}
} else {
val result: ProxyUpstreamResponse? = when (route.kind) {
ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL ->
workBuddy.forwardChat(body, accountId, route.region)
ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL ->
trae.forwardChat(body, accountId, route.region)
else -> sub2Api.forwardChat(body, accountId)
}
if (result == null) {
call.respond(
HttpStatusCode.BadRequest,
errorBody("${route.kind.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 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 extractStream(body: String): Boolean = runCatching {
val el = kotlinx.serialization.json.Json.parseToJsonElement(body)
(el as? kotlinx.serialization.json.JsonObject)
?.get("stream")
?.let { if (it is kotlinx.serialization.json.JsonPrimitive) it.contentOrNull?.toBooleanStrictOrNull() else null }
}.getOrNull() ?: false
private fun stripModelPrefix(model: String): String {
val m = model.trim()
val lower = m.lowercase()
val prefix = listOf(
"wbcn-", "workbuddy-cn", "codebuddy-",
"wbintl-", "workbuddy-intl", "workbuddy-",
"traeintl-", "trae-intl", "traecn-", "trae-cn", "trae-",
"sub2api-", "openai-",
).firstOrNull { lower.startsWith(it) }
return if (prefix != null) m.substring(prefix.length).ifBlank { m } else m
}
private fun rewriteModelBody(body: String, newModel: String): String = try {
val obj = kotlinx.serialization.json.Json.parseToJsonElement(body)
as? kotlinx.serialization.json.JsonObject ?: return body
kotlinx.serialization.json.buildJsonObject {
obj.forEach { (key, value) ->
put(key, if (key == "model") kotlinx.serialization.json.JsonPrimitive(newModel) else value)
}
}.toString()
} catch (_: Throwable) {
body
}
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 extractUser(body: String): String? = runCatching {
val el = kotlinx.serialization.json.Json.parseToJsonElement(body)
(el as? kotlinx.serialization.json.JsonObject)
?.get("user")
?.let { if (it is kotlinx.serialization.json.JsonPrimitive) it.content else null }
}.getOrNull()
companion object {
private const val MAX_REQUEST_BYTES = 10 * 1024 * 1024
}
}

View File

@ -0,0 +1,27 @@
package com.rainy.token.data.proxy
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* 本地 HTTP 反代服务配置。
*/
data class ProxyServerConfig(
val port: Int = 8787,
/** 客户端必须携带的 Bearer Key;null = 关闭鉴权(仅本机建议)。 */
val apiKey: String? = null,
)
/**
* 本地 HTTP 反代服务。
*
* 具体实现建议用 Ktor CIO(协程友好,方便 SSE 流式转发);
* 若 APK 体积敏感,可换 NanoHTTPD,但流式转发需要额外桥接。
*/
interface LocalProxyServer {
val isRunning: StateFlow<Boolean>
fun start(config: ProxyServerConfig): Result<Unit>
fun stop()
}

View File

@ -0,0 +1,33 @@
package com.rainy.token.data.proxy
/**
* 网关支持的 Provider 清单。
*
* 命名规则:按「产品 + 区域」区分(同一个产品在国内版/国际版使用不同域名与鉴权体系)。
* supportsCheckin 表示该平台官方是否有每日签到类活动;是否已实测由各自 Repository 决定。
*/
enum class ProviderKind(
val displayName: String,
val regions: Set<ProviderRegion> = ProviderRegion.entries.toSet(),
val supportsChat: Boolean = true,
val supportsCheckin: Boolean = false,
) {
WORKBUDDY_CN("WorkBuddy 国内版", setOf(ProviderRegion.CN), supportsCheckin = true),
WORKBUDDY_INTL("WorkBuddy 国际版", setOf(ProviderRegion.INTL)),
TRAE_CN("Trae CN", setOf(ProviderRegion.CN), supportsCheckin = true),
TRAE_INTL("Trae INTL", setOf(ProviderRegion.INTL)),
TRAE_WORK("TraeWork 桌面版", setOf(ProviderRegion.CN)),
QODER_CN("Qoder CN", setOf(ProviderRegion.CN), supportsCheckin = true),
RACCOON_CN("小浣熊(商汤)", setOf(ProviderRegion.CN), supportsCheckin = true),
CATPAW_CN("CatPaw(美团)", setOf(ProviderRegion.CN)),
AUTOCLAW_CN("AutoClaw 国内版", setOf(ProviderRegion.CN), supportsCheckin = true),
AUTOCLAW_INTL("AutoClaw 国际版", setOf(ProviderRegion.INTL)),
CLINE("Cline", setOf(ProviderRegion.INTL)),
ACCIO_CN("Accio 国内版", setOf(ProviderRegion.CN)),
ACCIO_INTL("Accio 国际版", setOf(ProviderRegion.INTL)),
ZCODE_CN("ZCode 国内版", setOf(ProviderRegion.CN)),
ZCODE_INTL("ZCode 国际版", setOf(ProviderRegion.INTL)),
CODEARTS_CN("CodeArts(华为云)", setOf(ProviderRegion.CN)),
LOOMY_CN("Loomy(讯飞)", setOf(ProviderRegion.CN), supportsCheckin = true),
OPENAI_COMPATIBLE("OpenAI 兼容自定义", ProviderRegion.entries.toSet()),
}

View File

@ -0,0 +1,6 @@
package com.rainy.token.data.proxy
/**
* 区域:CN=国内版(中国大陆域名),INTL=国际版(海外域名)。
*/
enum class ProviderRegion { CN, INTL }

56
code/model/ProxyModels.kt Normal file
View File

@ -0,0 +1,56 @@
package com.rainy.token.data.proxy
import kotlinx.serialization.json.JsonElement
/**
* 网关对外的最小 OpenAI 兼容模型。
* 后续按协议(Chat Completions / Responses / Anthropic)扩展。
*/
data class ProxyChatRequest(
val model: String,
val messages: List<ProxyChatMessage> = emptyList(),
val stream: Boolean = false,
val tools: List<JsonElement>? = null,
val toolChoice: JsonElement? = null,
val reasoningEffort: String? = null,
val maxTokens: Int? = null,
)
data class ProxyChatMessage(
val role: String,
val content: String? = null,
val toolCalls: List<JsonElement>? = null,
val toolCallId: String? = null,
val name: String? = null,
)
data class ProxyModel(
val id: String,
val provider: ProviderKind,
val region: ProviderRegion? = null,
)
/** 流式/非流式统一输出块,由 StreamNormalizer 归一。 */
sealed interface ProxyChatChunk {
data class Done(
val text: String? = null,
val usage: ProxyUsage? = null,
) : ProxyChatChunk
data class Delta(
val text: String? = null,
val reasoning: String? = null,
val toolCalls: List<JsonElement>? = null,
) : ProxyChatChunk
data class Error(
val message: String,
val code: Int? = null,
) : ProxyChatChunk
}
data class ProxyUsage(
val promptTokens: Long? = null,
val completionTokens: Long? = null,
val totalTokens: Long? = null,
)

View File

@ -0,0 +1,15 @@
package com.rainy.token.data.proxy
import java.io.InputStream
/**
* 上游流式响应句柄:由本地网关在收到客户端 stream=true 请求时创建,
* 把上游 SSE/原始字节流实时转发给本地客户端。
* 使用方必须在 finally 中调用 [close]。
*/
class ProxyUpstreamStream(
val status: Int,
val contentType: String,
val input: InputStream,
val close: () -> Unit,
)

87
code/pool/AccountPool.kt Normal file
View File

@ -0,0 +1,87 @@
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 javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* 账号池:从 CredentialRepository 实时取号,负责轮询与会话粘性。
*
* - 多账号服务(Trae / WorkBuddy / Sub2API)按 ProviderKind 分组;
* - 会话粘性:同一 conversation 头持续命中同一账号,避免上下文错乱;
* - 区域匹配:优先选 credential.region 与路由 region 一致的账号,找不到时退回全部账号。
*/
@Singleton
class AccountPool @Inject constructor(
private val credentialRepository: CredentialRepository,
) {
data class PooledAccount(
val kind: ProviderKind,
val region: ProviderRegion? = null,
val accountId: String? = null,
val label: String? = null,
)
private val mutex = Mutex()
private val sessionSticky = mutableMapOf<String, String>()
private val cursor = mutableMapOf<ProviderKind, Int>()
suspend fun next(
kind: ProviderKind,
region: ProviderRegion? = null,
sessionKey: String? = null,
): PooledAccount? = mutex.withLock {
val service = serviceFor(kind) ?: return null
val accounts = credentialRepository.accountsFor(service)
val allIds = accounts.map { it.id }
val matchedIds = if (region == null) {
allIds
} else {
allIds.filter { accountId -> regionMatches(service, accountId, region) }
.ifEmpty { allIds }
}
if (matchedIds.isEmpty()) return null
val accountId = if (sessionKey != null) {
sessionSticky[sessionKey]
?.takeIf { it in matchedIds }
?: pickRoundRobin(kind, matchedIds).also { sessionSticky[sessionKey] = it }
} else {
pickRoundRobin(kind, matchedIds)
}
PooledAccount(
kind = kind,
region = region,
accountId = accountId,
label = accounts.firstOrNull { it.id == accountId }?.label,
)
}
private suspend fun regionMatches(service: ServiceType, accountId: String, region: ProviderRegion): Boolean {
val credential = credentialRepository.get(service, accountId) ?: return false
return when (credential) {
is Credential.TraeCredential -> credential.region.equals(region.name, ignoreCase = true)
is Credential.WorkBuddyCredential -> credential.region.equals(region.name, ignoreCase = true)
else -> true
}
}
private fun pickRoundRobin(kind: ProviderKind, ids: List<String>): String {
val idx = cursor[kind] ?: 0
cursor[kind] = (idx + 1) % ids.size
return ids[idx]
}
private fun serviceFor(kind: ProviderKind): ServiceType? = when (kind) {
ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL -> ServiceType.WORKBUDDY
ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL -> ServiceType.TRAE
ProviderKind.OPENAI_COMPATIBLE -> ServiceType.SUB2API
else -> null
}
}

View File

@ -0,0 +1,38 @@
package com.rainy.token.data.proxy
/**
* 模型名 → Provider + Region 路由。
*
* 约定前缀:
* - wbcn- / workbuddy-cn / codebuddy- → WorkBuddy 国内版
* - wbintl- / workbuddy-intl → WorkBuddy 国际版
* - traecn- / trae-cn / trae- → Trae CN
* - traeintl- / trae-intl → Trae INTL
* - 其余默认 OpenAI 兼容透传(Sub2API)
*/
class ProviderRouter {
data class Route(
val kind: ProviderKind,
val region: ProviderRegion?,
)
fun route(model: String): Route {
val m = model.trim().lowercase()
return when {
m.startsWith("wbcn-") || m.startsWith("workbuddy-cn") || m.startsWith("codebuddy-") ->
Route(ProviderKind.WORKBUDDY_CN, ProviderRegion.CN)
m.startsWith("wbintl-") || m.startsWith("workbuddy-intl") || m.startsWith("workbuddy-") ->
Route(ProviderKind.WORKBUDDY_INTL, ProviderRegion.INTL)
m.startsWith("traeintl-") || m.startsWith("trae-intl") ->
Route(ProviderKind.TRAE_INTL, ProviderRegion.INTL)
m.startsWith("traecn-") || m.startsWith("trae-cn") || m.startsWith("trae-") ->
Route(ProviderKind.TRAE_CN, ProviderRegion.CN)
else -> Route(ProviderKind.OPENAI_COMPATIBLE, null)
}
}
}

View File

@ -0,0 +1,129 @@
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 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
/**
* Sub2API Chat 透传代理。
*
* Sub2API 实例本身暴露 OpenAI 兼容接口,因此这里不做协议转换:
* 取出用户保存的 Sub2ApiCredential(优先 sk- API Key,其次面板 authToken),
* 把客户端请求原样转发到 {base}/v1/chat/completions,
* 并把上游响应(含 SSE 流式内容)原样返回给本地客户端。
*
* M1a 阶段:先做整包透传(流式也先缓冲),后续由 StreamNormalizer 升级为逐块转发。
*/
@Singleton
class Sub2ApiChatProxy @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
) {
/** 转发 POST /v1/chat/completions。 */
suspend fun forwardChat(requestBody: String, accountId: String? = null): ProxyUpstreamResponse? {
val base = resolveBase(accountId) ?: return null
return forward(base = base, path = "/v1/chat/completions", requestBody = requestBody, accountId = accountId)
}
/** 转发流式 POST /v1/chat/completions(上游 SSE 原样转发)。 */
suspend fun openStreamingChat(
requestBody: String,
accountId: String? = null,
): ProxyUpstreamStream? = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.SUB2API, accountId)
?: return@withContext null
if (credential !is Credential.Sub2ApiCredential) return@withContext null
val auth = resolveAuth(credential) ?: return@withContext null
val base = normalizeBase(credential.baseUrl) ?: return@withContext null
val builder = Request.Builder()
.url(base + "/v1/chat/completions")
.addHeader("Authorization", auth)
.addHeader("Content-Type", "application/json")
.post(requestBody.toRequestBody("application/json".toMediaType()))
val response = okHttpClient.newCall(builder.build()).execute()
val input = response.body?.byteStream() ?: run {
response.close()
return@withContext null
}
ProxyUpstreamStream(
status = response.code,
contentType = response.header("Content-Type") ?: "text/event-stream",
input = input,
close = { response.close() },
)
}
/** 转发 GET /v1/models。 */
suspend fun forwardModels(accountId: String? = null): ProxyUpstreamResponse? {
val base = resolveBase(accountId) ?: return null
return forward(base = base, path = "/v1/models", requestBody = null, accountId = accountId)
}
private suspend fun resolveBase(accountId: String?): String? {
val credential = credentialRepository.get(ServiceType.SUB2API, accountId) ?: return null
if (credential !is Credential.Sub2ApiCredential) return null
if (resolveAuth(credential) == null) return null
return normalizeBase(credential.baseUrl)
}
private suspend fun forward(
base: String,
path: String,
requestBody: String?,
accountId: String?,
): ProxyUpstreamResponse? = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.SUB2API, accountId)
?: return@withContext null
if (credential !is Credential.Sub2ApiCredential) return@withContext null
val auth = resolveAuth(credential) ?: return@withContext null
val builder = Request.Builder()
.url(base + path)
.addHeader("Authorization", auth)
val body = requestBody?.takeIf { it.isNotBlank() }
if (body != null) {
builder
.addHeader("Content-Type", "application/json")
.post(body.toRequestBody("application/json".toMediaType()))
}
val response = okHttpClient.newCall(builder.build()).execute()
val bytes = response.body?.bytes() ?: ByteArray(0)
val contentType = response.header("Content-Type") ?: "application/json"
val status = response.code
response.close()
ProxyUpstreamResponse(status, contentType, bytes)
}
private fun resolveAuth(credential: Credential.Sub2ApiCredential): String? {
credential.apiKey?.trim()?.takeIf { it.isNotBlank() }?.let { return "Bearer $it" }
credential.authToken?.trim()?.takeIf { it.isNotBlank() }?.let { return "Bearer $it" }
return null
}
private fun normalizeBase(raw: String): String? {
var s = raw.trim()
while (s.endsWith("/")) s = s.dropLast(1)
return s.takeIf { it.isNotBlank() }
}
}
/**
* 上游 HTTP 响应(透传用)。
*/
data class ProxyUpstreamResponse(
val status: Int,
val contentType: String,
val body: ByteArray,
)

View File

@ -0,0 +1,258 @@
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.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.put
import okhttp3.Headers.Companion.toHeaders
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。
* CN 模型网关固定走 trae-api-cn.mchost.guru(api.trae.cn 只是账号/OAuth 主机,返回 404)。
* 请求头/body 对齐 trae2api-cn 参考实现的 SOLO 协议。
*/
@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,
forcedRegion: ProviderRegion? = 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 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 upstreamBody = buildUpstreamBody(requestBody, credential)
val requestId = UUID.randomUUID().toString()
val builder = Request.Builder()
.url(base + "/api/agent/v3/llm_utils_chat")
.headers(soloHeaders(jwt, credential, requestId).toHeaders())
.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)
}
suspend fun openStreamingChat(
requestBody: String,
accountId: String? = null,
forcedRegion: ProviderRegion? = null,
): ProxyUpstreamStream? = 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 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 upstreamBody = buildUpstreamBody(requestBody, credential)
val requestId = UUID.randomUUID().toString()
val builder = Request.Builder()
.url(base + "/api/agent/v3/llm_utils_chat")
.headers(soloHeaders(jwt, credential, requestId).toHeaders())
.post(upstreamBody.toRequestBody("application/json".toMediaType()))
val response = try {
okHttpClient.newCall(builder.build()).execute()
} catch (e: java.io.IOException) {
throw e
}
val input = response.body?.byteStream() ?: run {
response.close()
return@withContext null
}
ProxyUpstreamStream(
status = response.code,
contentType = response.header("Content-Type") ?: "text/event-stream",
input = input,
close = { response.close() },
)
}
private fun soloHeaders(
jwt: String,
credential: Credential.TraeCredential,
requestId: String,
): Map<String, String> = linkedMapOf(
"Content-Type" to "application/json",
"Accept" to "text/event-stream",
"Connection" to "keep-alive",
"Authorization" to "Cloud-IDE-JWT $jwt",
"X-Cloudide-Token" to jwt,
"x-ide-token" to jwt,
"x-uid" to (credential.userId ?: ""),
"x-app-id" to "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8",
"x-device-id" to (credential.deviceId ?: credential.checkinDeviceId ?: ""),
"x-machine-id" to (credential.deviceId ?: credential.checkinDeviceId ?: ""),
"x-request-id" to requestId,
"x-ide-version" to "0.1.52",
"x-ide-version-code" to "20260811",
"x-ide-version-type" to "stable",
"x-app-version" to "default",
"x-app-version-code" to "20260811",
"x-version-code" to "20260811",
"x-device-cpu" to "AMD",
"x-device-brand" to "83DG",
"x-device-type" to "windows",
"x-device-platform" to "windows",
"x-os-version" to "Windows 11 Pro",
"x-system-type" to "Windows",
"package-type" to "stable_cn",
"x-lscbd-aid" to "787976",
"x-lscbd-platform" to "windows",
"x-ss-dp" to "787976",
"x-plugin-channel" to "icube-ai",
"app-version" to "0.1.52",
"x-bridge-transport" to "aha",
"x-ahanet-timeout" to "86400",
"x-lgw-req-sdk-type" to "3",
"x-net-sdk-domain-dispatch" to "1",
"x-ttnet-bypass-decompression" to "1",
"x-ttnet-bypass-cookie" to "0",
"request-traffic-type" to "prod",
"User-Agent" to "Trae/0.1.52",
)
private fun convertNativeMessages(src: JsonElement?): List<JsonElement> {
val arr = src as? JsonArray ?: return emptyList()
return arr.mapNotNull { el ->
val m = el as? JsonObject ?: return@mapNotNull null
val rawRole = (m["role"] as? JsonPrimitive)?.contentOrNull?.lowercase() ?: "user"
val role = when (rawRole) {
"developer" -> "system"
"system", "user", "assistant", "tool", "function" -> rawRole
else -> "user"
}
buildJsonObject {
put("role", role)
val content = nativeContent(m["content"])
if (content != null) put("content", content)
m["name"]?.let { put("name", it) }
m["tool_call_id"]?.let { put("tool_call_id", it) }
m["tool_calls"]?.let { put("tool_calls", it) }
}
}
}
private fun nativeContent(content: JsonElement?): JsonElement? {
if (content == null || content is JsonNull) return null
if (content is JsonPrimitive) {
return buildJsonArray {
add(buildJsonObject {
put("type", "text")
put("text", content.content)
})
}
}
if (content is JsonArray) {
return buildJsonArray {
content.forEach { block ->
when (block) {
is JsonPrimitive -> add(buildJsonObject {
put("type", "text")
put("text", block.content)
})
is JsonObject -> {
val type = (block["type"] as? JsonPrimitive)?.contentOrNull?.lowercase()
if (type == "text" || type == "input_text") {
val text = (block["text"] as? JsonPrimitive)?.contentOrNull
?: (block["content"] as? JsonPrimitive)?.contentOrNull
?: ""
add(buildJsonObject {
put("type", "text")
put("text", text)
})
} else {
add(block)
}
}
else -> add(block)
}
}
}
}
return null
}
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 = convertNativeMessages(src["messages"])
val sessionId = UUID.randomUUID().toString().replace("-", "")
return buildJsonObject {
put("messages", JsonArray(messages))
put("config_name", model)
put("model", model)
put("function", "solo_work_lite")
put("stream", true)
put("request_id", sessionId)
put("session_id", sessionId)
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,127 @@
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,
forcedRegion: ProviderRegion? = 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 effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull()
val base = if (effectiveRegion == ProviderRegion.INTL) {
"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)
}
suspend fun openStreamingChat(
requestBody: String,
accountId: String? = null,
forcedRegion: ProviderRegion? = null,
): ProxyUpstreamStream? = 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 effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull()
val base = if (effectiveRegion == ProviderRegion.INTL) {
"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 input = response.body?.byteStream() ?: run {
response.close()
return@withContext null
}
ProxyUpstreamStream(
status = response.code,
contentType = response.header("Content-Type") ?: "text/event-stream",
input = input,
close = { response.close() },
)
}
}

47
docs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,47 @@
# 整体架构
## 核心流程
1. 客户端 POST /v1/chat/completions 到本地端口
2. 中间件做三件事:
- 校验本地 API Key(Authorization: Bearer <key>)
- 读 body 里的 model,按前缀路由
- 如果带前缀,把 model 改成去掉前缀后的上游模型名
3. 根据 stream 字段选择转发方式:
- stream=true → 打开上游 SSE,字节流实时转发
- 非流式 → 缓冲上游完整响应后返回
## 模型前缀路由
| 前缀 | ProviderKind | Region |
|---|---|---|
| wbcn- / workbuddy-cn / codebuddy- | WORKBUDDY_CN | CN |
| wbintl- / workbuddy-intl / workbuddy- | WORKBUDDY_INTL | INTL |
| traecn- / trae-cn / trae- | TRAE_CN | CN |
| traeintl- / trae-intl | TRAE_INTL | INTL |
| 其它 | OPENAI_COMPATIBLE(Sub2API 透传) | - |
## 请求生命周期
POST /v1/chat/completions
-> authorized() // 401 if invalid key
-> extractModel(rawBody) // "traecn-glm-5.2"
-> router.route(model) // (TRAE_CN, CN)
-> stripModelPrefix(model) // "glm-5.2"
-> rewriteModelBody() // 替换 body.model
-> pooled = pool.next(kind, region, sessionKey)
-> traeChatProxy.openStreamingChat(body, pooled?.accountId, region)
-> respondOutputStream(...) // 逐块写上游字节
## 账号池
- 从 CredentialRepository.accountsFor(service) 读取实时账号
- 每个 ProviderKind 一个轮询游标(round-robin)
- 会话粘性:X-Conversation-Id 或 body user 作为 key,同会话复用同一账号
- 地区匹配:优先选择 credential.region == route.region,没有则回退全部
## 本地服务
- GET /health → 存活检查
- GET /v1/models → 透传 Sub2API 模型列表
- POST /v1/chat/completions → 聊天代理

50
docs/SSE_NORMALIZATION.md Normal file
View File

@ -0,0 +1,50 @@
# SSE 归一化(StreamNormalizer)设计
## 为什么需要
- WorkBuddy 上游 ≈ OpenAI SSE,透传即可
- Trae SOLO 上游是私有事件协议,字段/信封和 OpenAI 不一样
- 客户端只认 data: {"choices":[{"delta":{"content":...}}]} 和 data: [DONE]
## 当前实现模式
先做 raw passthrough(实时转发上游字节),再做归一:
上游 InputStream -> respondOutputStream -> 客户端
好处:先解决“能不能连上/能不能出字”,再解决“客户端能不能解析”。
## 归一化目标映射
把 Trae 私有事件翻译成 OpenAI Chat Completions chunk:
| Trae 事件 | OpenAI chunk |
|---|---|
| 正文增量事件 | data: {"choices":[{"delta":{"content":"..."}}]} |
| 推理/思考块 | delta.reasoning_content(部分客户端支持) |
| 结束事件 | delta {} finish_reason stop + data: [DONE] |
| 用量事件 | 合并到 finish chunk 的 usage |
| 业务错误 | data: {"error":{...}} + data: [DONE] |
| 心跳注释 | : relay-keepalive(客户端自动忽略) |
## 设计原则
1. 见到样本再实现:每个上游私有格式千差万别,不要盲抄
2. 未知事件透传:宁可让客户端看到未知 data,也不要吞掉内容
3. 工具调用增量合并:Trae 可能发累计快照或增量,两种都要能收敛成 tool_calls[].function.arguments
4. 心跳:上游长时间停顿时发 SSE 注释行防超时
5. 断流兜底:上游异常断开时补一个 finish_reason,避免客户端永远 loading
## 最小实现骨架
fun normalizeLine(raw: String): String? {
if (!raw.startsWith("data:")) return null // 忽略注释/空行
val payload = raw.removePrefix("data:").trim()
if (payload == "[DONE]") return raw
val json = Json.parseToJsonElement(payload) as? JsonObject ?: return null
// 根据 event/type 字段抽取 content/reasoning/tool_calls
val content = extractTraeText(json) ?: return null
return """data: {"choices":[{"delta":{"content":"$content"}}]}"""
}
> 具体 Trae 事件字段需要以真实抓包/探针输出为准(仓库里自带“测试 Trae 流”探针)。

98
docs/TRAE_PROTOCOL.md Normal file
View File

@ -0,0 +1,98 @@
# Trae 上游协议(llm_utils_chat / SOLO)
## 关键教训:主机不是 api.trae.cn
api.trae.cn 只是账号/OAuth 主机,直接请求 /api/agent/v3/llm_utils_chat 会 404。
模型网关是:
| 地区 | 主机 |
|---|---|
| CN | https://trae-api-cn.mchost.guru |
| INTL | https://a0ai-api-sg.byteintlapi.com |
路径统一:POST /api/agent/v3/llm_utils_chat
## 请求头(CN SOLO)
参考 trae2api-cn 实现,完整头集合如下(缺头/老版本头会被网关拒绝,甚至伪装成 404):
Authorization: Cloud-IDE-JWT <jwt>
X-Cloudide-Token: <jwt>
x-ide-token: <jwt>
x-uid: <user_id>
x-app-id: 6eefa01c-1036-4c7e-9ca5-d891f63bfcd8
x-device-id: <device_id>
x-machine-id: <device_id>
x-request-id: <uuid>
x-ide-version: 0.1.52
x-ide-version-code: 20260811
x-ide-version-type: stable
x-app-version: default
x-app-version-code: 20260811
x-version-code: 20260811
x-device-cpu: AMD
x-device-brand: 83DG
x-device-type: windows
x-device-platform: windows
x-os-version: Windows 11 Pro
x-system-type: Windows
package-type: stable_cn
x-lscbd-aid: 787976
x-lscbd-platform: windows
x-ss-dp: 787976
x-plugin-channel: icube-ai
app-version: 0.1.52
x-bridge-transport: aha
x-ahanet-timeout: 86400
x-lgw-req-sdk-type: 3
x-net-sdk-domain-dispatch: 1
x-ttnet-bypass-decompression: 1
x-ttnet-bypass-cookie: 0
request-traffic-type: prod
User-Agent: Trae/0.1.52
Accept: text/event-stream
> 注意:Authorization 头里的 $jwt 是变量插值,不是字符串 jwt。
> 曾经因此修了一个“一直 404”的 bug。
## 请求体(SOLO)
{
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "你好"}
]
}
],
"config_name": "glm-5.2",
"model": "glm-5.2",
"function": "solo_work_lite",
"stream": true,
"request_id": "<session_id>",
"session_id": "<session_id>",
"max_tokens": 4096
}
### content 必须是数组
上游 Go 结构体:
type LLMRawMessage struct {
Role string `json:"role"`
Content []*LLMRawMessageContent `json:"content"`
}
所以 OpenAI 风格字符串 "content": "你好" 会报:
{"code":4001,"message":"bad request: json: cannot unmarshal string into Go struct field LLMRawMessage.messages.content of type []*idecopilot.LLMRawMessageContent"}
## 常见坑
| 现象 | 原因 |
|---|---|
| 404 | 发到 api.trae.cn;或缺 SOLO 路由头 |
| 400 4001 | content 是字符串不是块数组 |
| 401 | jwt 过期 / Authorization 头拼错 |
| 403 排队码 10605 | 免费模型繁忙,业务层面的排队状态 |

View File

@ -0,0 +1,42 @@
# WorkBuddy 上游协议
## 端点
| 地区 | 主机 |
|---|---|
| CN | https://copilot.tencent.com/v2/chat/completions |
| INTL | https://www.workbuddy.ai(同路径 /v2/chat/completions) |
## 请求头
Authorization: Bearer <accessToken>
Origin: https://www.codebuddy.cn // CN
Content-Type: application/json
Accept: text/event-stream // stream 时
## Body
基本就是 OpenAI 兼容格式透传,这也是 WorkBuddy 适配器比 Trae 简单很多的原因:
{
"model": "glm-5.2",
"stream": true,
"messages": [
{"role": "user", "content": "你好"}
],
"max_tokens": 4096
}
## SSE 响应
上游返回的就是 OpenAI 风格的 SSE:
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{...}}
data: [DONE]
所以 WorkBuddy 可以直接字节流透传,客户端能直接解析。