init: 反代原理与核心代码提取(纯文档 + Kotlin 片段)
This commit is contained in:
commit
8d149ec70c
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# 本地生成物
|
||||
build/
|
||||
*.log
|
||||
48
README.md
Normal file
48
README.md
Normal 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"))
|
||||
|
||||
## 免责声明
|
||||
|
||||
- 仅供学习 / 个人自动化研究
|
||||
- 上游协议可能随时变化,提取时以对应参考仓库为基准
|
||||
- 请遵守各家服务条款,账号风险自负
|
||||
265
code/local_server/KtorLocalProxyServer.kt
Normal file
265
code/local_server/KtorLocalProxyServer.kt
Normal 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
|
||||
}
|
||||
}
|
||||
27
code/local_server/LocalProxyServer.kt
Normal file
27
code/local_server/LocalProxyServer.kt
Normal 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()
|
||||
}
|
||||
33
code/model/ProviderKind.kt
Normal file
33
code/model/ProviderKind.kt
Normal 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()),
|
||||
}
|
||||
6
code/model/ProviderRegion.kt
Normal file
6
code/model/ProviderRegion.kt
Normal 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
56
code/model/ProxyModels.kt
Normal 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,
|
||||
)
|
||||
15
code/model/ProxyUpstreamStream.kt
Normal file
15
code/model/ProxyUpstreamStream.kt
Normal 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
87
code/pool/AccountPool.kt
Normal 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
|
||||
}
|
||||
}
|
||||
38
code/routing/ProviderRouter.kt
Normal file
38
code/routing/ProviderRouter.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
129
code/upstream/Sub2ApiChatProxy.kt
Normal file
129
code/upstream/Sub2ApiChatProxy.kt
Normal 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,
|
||||
)
|
||||
258
code/upstream/TraeChatProxy.kt
Normal file
258
code/upstream/TraeChatProxy.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
127
code/upstream/WorkBuddyChatProxy.kt
Normal file
127
code/upstream/WorkBuddyChatProxy.kt
Normal 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
47
docs/ARCHITECTURE.md
Normal 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
50
docs/SSE_NORMALIZATION.md
Normal 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
98
docs/TRAE_PROTOCOL.md
Normal 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 | 免费模型繁忙,业务层面的排队状态 |
|
||||
42
docs/WORKBUDDY_PROTOCOL.md
Normal file
42
docs/WORKBUDDY_PROTOCOL.md
Normal 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 可以直接字节流透传,客户端能直接解析。
|
||||
Loading…
Reference in New Issue
Block a user