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