From 8d149ec70c8205f51a9c0cf12877fe20fd51b013 Mon Sep 17 00:00:00 2001 From: maya Date: Fri, 9 Oct 2026 10:44:24 +0800 Subject: [PATCH] =?UTF-8?q?init:=20=E5=8F=8D=E4=BB=A3=E5=8E=9F=E7=90=86?= =?UTF-8?q?=E4=B8=8E=E6=A0=B8=E5=BF=83=E4=BB=A3=E7=A0=81=E6=8F=90=E5=8F=96?= =?UTF-8?q?=EF=BC=88=E7=BA=AF=E6=96=87=E6=A1=A3=20+=20Kotlin=20=E7=89=87?= =?UTF-8?q?=E6=AE=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + README.md | 48 ++++ code/local_server/KtorLocalProxyServer.kt | 265 ++++++++++++++++++++++ code/local_server/LocalProxyServer.kt | 27 +++ code/model/ProviderKind.kt | 33 +++ code/model/ProviderRegion.kt | 6 + code/model/ProxyModels.kt | 56 +++++ code/model/ProxyUpstreamStream.kt | 15 ++ code/pool/AccountPool.kt | 87 +++++++ code/routing/ProviderRouter.kt | 38 ++++ code/upstream/Sub2ApiChatProxy.kt | 129 +++++++++++ code/upstream/TraeChatProxy.kt | 258 +++++++++++++++++++++ code/upstream/WorkBuddyChatProxy.kt | 127 +++++++++++ docs/ARCHITECTURE.md | 47 ++++ docs/SSE_NORMALIZATION.md | 50 ++++ docs/TRAE_PROTOCOL.md | 98 ++++++++ docs/WORKBUDDY_PROTOCOL.md | 42 ++++ 17 files changed, 1329 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 code/local_server/KtorLocalProxyServer.kt create mode 100644 code/local_server/LocalProxyServer.kt create mode 100644 code/model/ProviderKind.kt create mode 100644 code/model/ProviderRegion.kt create mode 100644 code/model/ProxyModels.kt create mode 100644 code/model/ProxyUpstreamStream.kt create mode 100644 code/pool/AccountPool.kt create mode 100644 code/routing/ProviderRouter.kt create mode 100644 code/upstream/Sub2ApiChatProxy.kt create mode 100644 code/upstream/TraeChatProxy.kt create mode 100644 code/upstream/WorkBuddyChatProxy.kt create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/SSE_NORMALIZATION.md create mode 100644 docs/TRAE_PROTOCOL.md create mode 100644 docs/WORKBUDDY_PROTOCOL.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed1a29a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# 本地生成物 +build/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee47870 --- /dev/null +++ b/README.md @@ -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")) + +## 免责声明 + +- 仅供学习 / 个人自动化研究 +- 上游协议可能随时变化,提取时以对应参考仓库为基准 +- 请遵守各家服务条款,账号风险自负 diff --git a/code/local_server/KtorLocalProxyServer.kt b/code/local_server/KtorLocalProxyServer.kt new file mode 100644 index 0000000..48be89a --- /dev/null +++ b/code/local_server/KtorLocalProxyServer.kt @@ -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 = _isRunning.asStateFlow() + + override fun start(config: ProxyServerConfig): Result { + 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 = + 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 + } +} diff --git a/code/local_server/LocalProxyServer.kt b/code/local_server/LocalProxyServer.kt new file mode 100644 index 0000000..441d361 --- /dev/null +++ b/code/local_server/LocalProxyServer.kt @@ -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 + + fun start(config: ProxyServerConfig): Result + + fun stop() +} diff --git a/code/model/ProviderKind.kt b/code/model/ProviderKind.kt new file mode 100644 index 0000000..21ae760 --- /dev/null +++ b/code/model/ProviderKind.kt @@ -0,0 +1,33 @@ +package com.rainy.token.data.proxy + +/** + * 网关支持的 Provider 清单。 + * + * 命名规则:按「产品 + 区域」区分(同一个产品在国内版/国际版使用不同域名与鉴权体系)。 + * supportsCheckin 表示该平台官方是否有每日签到类活动;是否已实测由各自 Repository 决定。 + */ +enum class ProviderKind( + val displayName: String, + val regions: Set = 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()), +} diff --git a/code/model/ProviderRegion.kt b/code/model/ProviderRegion.kt new file mode 100644 index 0000000..c8183fa --- /dev/null +++ b/code/model/ProviderRegion.kt @@ -0,0 +1,6 @@ +package com.rainy.token.data.proxy + +/** + * 区域:CN=国内版(中国大陆域名),INTL=国际版(海外域名)。 + */ +enum class ProviderRegion { CN, INTL } diff --git a/code/model/ProxyModels.kt b/code/model/ProxyModels.kt new file mode 100644 index 0000000..8ae399a --- /dev/null +++ b/code/model/ProxyModels.kt @@ -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 = emptyList(), + val stream: Boolean = false, + val tools: List? = 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? = 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? = 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, +) diff --git a/code/model/ProxyUpstreamStream.kt b/code/model/ProxyUpstreamStream.kt new file mode 100644 index 0000000..2718439 --- /dev/null +++ b/code/model/ProxyUpstreamStream.kt @@ -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, +) diff --git a/code/pool/AccountPool.kt b/code/pool/AccountPool.kt new file mode 100644 index 0000000..4de1876 --- /dev/null +++ b/code/pool/AccountPool.kt @@ -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() + private val cursor = mutableMapOf() + + 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 { + 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 + } +} diff --git a/code/routing/ProviderRouter.kt b/code/routing/ProviderRouter.kt new file mode 100644 index 0000000..126de77 --- /dev/null +++ b/code/routing/ProviderRouter.kt @@ -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) + } + } +} diff --git a/code/upstream/Sub2ApiChatProxy.kt b/code/upstream/Sub2ApiChatProxy.kt new file mode 100644 index 0000000..34f4480 --- /dev/null +++ b/code/upstream/Sub2ApiChatProxy.kt @@ -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, +) diff --git a/code/upstream/TraeChatProxy.kt b/code/upstream/TraeChatProxy.kt new file mode 100644 index 0000000..beb034b --- /dev/null +++ b/code/upstream/TraeChatProxy.kt @@ -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 = 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 { + 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() + } +} diff --git a/code/upstream/WorkBuddyChatProxy.kt b/code/upstream/WorkBuddyChatProxy.kt new file mode 100644 index 0000000..c22a7cc --- /dev/null +++ b/code/upstream/WorkBuddyChatProxy.kt @@ -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() }, + ) + } + +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..b1d90ff --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,47 @@ +# 整体架构 + +## 核心流程 + +1. 客户端 POST /v1/chat/completions 到本地端口 +2. 中间件做三件事: + - 校验本地 API Key(Authorization: Bearer ) + - 读 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 → 聊天代理 diff --git a/docs/SSE_NORMALIZATION.md b/docs/SSE_NORMALIZATION.md new file mode 100644 index 0000000..3844587 --- /dev/null +++ b/docs/SSE_NORMALIZATION.md @@ -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 流”探针)。 diff --git a/docs/TRAE_PROTOCOL.md b/docs/TRAE_PROTOCOL.md new file mode 100644 index 0000000..f590901 --- /dev/null +++ b/docs/TRAE_PROTOCOL.md @@ -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 + X-Cloudide-Token: + x-ide-token: + x-uid: + x-app-id: 6eefa01c-1036-4c7e-9ca5-d891f63bfcd8 + x-device-id: + x-machine-id: + x-request-id: + 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": "", + "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 | 免费模型繁忙,业务层面的排队状态 | diff --git a/docs/WORKBUDDY_PROTOCOL.md b/docs/WORKBUDDY_PROTOCOL.md new file mode 100644 index 0000000..0c2e52a --- /dev/null +++ b/docs/WORKBUDDY_PROTOCOL.md @@ -0,0 +1,42 @@ +# WorkBuddy 上游协议 + +## 端点 + +| 地区 | 主机 | +|---|---| +| CN | https://copilot.tencent.com/v2/chat/completions | +| INTL | https://www.workbuddy.ai(同路径 /v2/chat/completions) | + +## 请求头 + + Authorization: Bearer + 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 可以直接字节流透传,客户端能直接解析。