Compare commits

...

14 Commits
dev ... v1.6.7

Author SHA1 Message Date
d0b79cf1bd fix(proxy): Trae CN 上游换用 trae-api-cn.mchost.guru,修正 404
Some checks are pending
Release / build (push) Waiting to run
- trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat 才是模型网关
- api.trae.cn 只是账号/OAuth 主机,不暴露 IDE 模型路由
- 请求头对齐参考实现:真实 x-app-id / x-ide-version / 设备指纹
- llm_utils_chat body 收敛为 SOLO 结构(config_name/model/function/stream)
2026-10-09 09:29:40 +08:00
15408a6640 chore(update): 发布 1.6.6 更新清单 2026-10-09 09:18:12 +08:00
8da1f2b377 chore: 版本号升至 1.6.6 (versionCode 16)
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 09:13:39 +08:00
a4d854271f feat(proxy): 设置页新增 Trae/WorkBuddy 流式探针
- 用默认账号直接请求 llm_utils_chat/v2 chat,抓取上游原始 SSE 帧
- 调试输出显示 HTTP 状态、Content-Type 与最多 80 行原始帧
- 为 StreamNormalizer 提供真实协议样本
2026-10-09 08:08:47 +08:00
67e1f060c5 feat(proxy): 网关随机 API Key + 启动失败提示
- SettingsViewModel 每次进程生成随机本机 Key,start 失败时暴露错误信息
- Settings 页显示 API Key 与错误提示
2026-10-09 00:04:55 +08:00
f5162bbf74 docs: 更新流式转发与 Qoder 攻坚状态 2026-10-08 23:53:30 +08:00
de95013164 feat(proxy): stream=true 实时透传上游 SSE 字节流
- Ktor respondOutputStream 逐块转发上游 body,不再整包缓冲
- 流式/非流式按客户端 body 的 stream 字段分流
- openStreamingChat 入口正式接线
2026-10-08 23:52:12 +08:00
072f7efa55 docs: 更新反代网关开发进度 2026-10-08 23:46:18 +08:00
1609e210bd fix(proxy): 修复 WorkBuddy/Trae 鉴权头、401、模型前缀 + 流式入口骨架
- WorkBuddyChatProxy/TraeChatProxy:Authorization 插值去掉误转义
- KtorLocalProxyServer:authorized() 失败返回 401
- 路由剥离 wbcn-/traecn- 等前缀后再转发,Trae config_name/model_name 不再带前缀
- Sub2ApiChatProxy 去掉 IOException 吞掉逻辑
- 新增 ProxyUpstreamStream 与三个代理的 openStreamingChat 入口(暂未接入实时转发)
2026-10-08 23:44:44 +08:00
a1605e6993 feat(proxy): M2 账号池轮询 + 会话粘性 + 双区域路由
- ProviderRouter:前缀路由到 WorkBuddy CN/INTL、Trae CN/INTL、OpenAI 兼容
- AccountPool:从 CredentialRepository 实时取号,round-robin + 会话粘性(X-Conversation-Id / user)
- 账号按 credential.region 与路由区域匹配,找不到时回退全部账号
- WorkBuddy/Trae 代理支持 forcedRegion 覆盖
- Ktor 路由接入账号池
2026-10-08 23:34:35 +08:00
5aa2d8f116 chore: 忽略 build_compile.log 2026-10-08 23:24:06 +08:00
11b9065f82 feat(proxy): M1b WorkBuddy CN/Trae CN 适配 + 设置页网关开关
- KtorLocalProxyServer:修复审计阻断项(start/stop 加锁、apiKey Bearer 鉴权、IOException 502、请求体上限、错误 JSON 规范化)
- WorkBuddyChatProxy:转发到 copilot.tencent.com/v2/chat/completions,Bearer accessToken + IDE 请求头
- TraeChatProxy:转发到 api.trae.cn/api/agent/v3/llm_utils_chat,Cloud-IDE-JWT 身份/设备头,最小 body 转换
- Credential.WorkBuddyCredential 增加 region 字段(默认 CN,兼容旧数据)
- Settings 页新增代理网关开关(默认 127.0.0.1:8787,本机 Key: rainytoken)
2026-10-08 23:23:16 +08:00
85c6cf4283 feat(proxy): M1a 本地 HTTP 服务 + Sub2API 透传
- 引入 Ktor 3.0.3(server-core / cio / content-negotiation / kotlinx-json)
- Sub2ApiChatProxy:把 /v1/chat/completions 与 /v1/models 透传到用户配置的 Sub2API 实例
- KtorLocalProxyServer:绑定 127.0.0.1:8787,提供 /health、/v1/models、/v1/chat/completions
- NetworkModule 提供 Hilt 注入
2026-10-08 22:58:33 +08:00
9d3c004617 feat(proxy): 新增反代网关骨架与开发路线图
- docs/PROXY-ROADMAP.md:M0-M4 里程碑、Provider 矩阵、风险与参考仓库
- data/proxy/:ProviderKind/ProviderRegion/ProxyModels/ProviderAdapter/AccountPool/RegionRouter/LocalProxyServer 接口骨架
- .gitignore:忽略 _tmp_* 调试文件,防止真实接口响应/账号数据入库
2026-10-08 22:22:45 +08:00
22 changed files with 1443 additions and 7 deletions

4
.gitignore vendored
View File

@ -23,3 +23,7 @@ release.jks
tmp_cc/
goal.md
/reference/
# 调试临时文件(可能包含真实接口响应/账号数据,禁止提交)
_tmp_*
build_compile.log

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.rainy.token"
minSdk = 31
targetSdk = 35
versionCode = 14
versionName = "1.6.4"
versionCode = 17
versionName = "1.6.7"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -214,6 +214,12 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.retrofit.kotlinx.serialization.converter)
// Ktor 本地反代 HTTP 服务
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.cio)
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
// DataStore
implementation(libs.androidx.datastore.preferences)

View File

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

View File

@ -0,0 +1,265 @@
package com.rainy.token.data.proxy
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.request.receiveText
import io.ktor.server.response.respond
import io.ktor.server.response.respondBytes
import io.ktor.server.response.respondOutputStream
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.put
import kotlinx.serialization.json.buildJsonObject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* 基于 Ktor CIO 的本地 HTTP 反代服务。
*
* M1b 能力:
* - GET /health
* - GET /v1/models
* - POST /v1/chat/completions
* 按模型前缀路由:wbcn- → WorkBuddy 国内版;traecn- → Trae CN;其余 → Sub2API 透传
*
* 安全:
* - 只绑定 127.0.0.1
* - config.apiKey 非空时,所有 v1 业务路由要求 Bearer Key 一致,否则 401
*/
@Singleton
class KtorLocalProxyServer @Inject constructor(
private val sub2ApiChatProxy: Sub2ApiChatProxy,
private val workBuddyChatProxy: WorkBuddyChatProxy,
private val traeChatProxy: TraeChatProxy,
private val providerRouter: ProviderRouter,
private val accountPool: AccountPool,
) : LocalProxyServer {
private val lock = Any()
@Volatile
private var server: EmbeddedServer<*, *>? = null
private val _isRunning = MutableStateFlow(false)
override val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
override fun start(config: ProxyServerConfig): Result<Unit> {
synchronized(lock) {
if (_isRunning.value) return Result.success(Unit)
return try {
val engine = embeddedServer(CIO, host = "127.0.0.1", port = config.port) {
proxyModule(config.apiKey, sub2ApiChatProxy, workBuddyChatProxy, traeChatProxy, providerRouter, accountPool)
}
engine.start(wait = false)
server = engine
_isRunning.value = true
Result.success(Unit)
} catch (e: Throwable) {
server = null
_isRunning.value = false
Result.failure(e)
}
}
}
override fun stop() {
synchronized(lock) {
runCatching { server?.stop(gracePeriodMillis = 500, timeoutMillis = 2000) }
server = null
_isRunning.value = false
}
}
private fun Application.proxyModule(
apiKey: String?,
sub2Api: Sub2ApiChatProxy,
workBuddy: WorkBuddyChatProxy,
trae: TraeChatProxy,
router: ProviderRouter,
pool: AccountPool,
) {
install(ContentNegotiation) {
json()
}
routing {
get("/health") {
call.respond(mapOf("status" to "ok"))
}
get("/v1/models") {
if (!authorized(call, apiKey)) {
call.respond(HttpStatusCode.Unauthorized, errorBody("未授权"))
return@get
}
val result = sub2Api.forwardModels()
if (result == null) {
call.respond(HttpStatusCode.BadRequest, errorBody("Sub2API 未配置或未登录,请在设置中填写 API Key"))
} else {
call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, ""))
}
}
post("/v1/chat/completions") {
if (!authorized(call, apiKey)) {
call.respond(HttpStatusCode.Unauthorized, errorBody("未授权"))
return@post
}
val rawBody = call.receiveText()
if (rawBody.length > MAX_REQUEST_BYTES) {
call.respond(HttpStatusCode(413, ""), errorBody("请求体过大"))
return@post
}
val model = extractModel(rawBody)
val route = router.route(model)
val upstreamModel = stripModelPrefix(model)
val body = if (upstreamModel != model) rewriteModelBody(rawBody, upstreamModel) else rawBody
val conversationId = call.request.headers["X-Conversation-Id"] ?: extractUser(rawBody)
val pooled = pool.next(route.kind, route.region, conversationId)
val accountId = pooled?.accountId
try {
if (extractStream(body)) {
val stream: ProxyUpstreamStream? = when (route.kind) {
ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL ->
workBuddy.openStreamingChat(body, accountId, route.region)
ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL ->
trae.openStreamingChat(body, accountId, route.region)
else -> sub2Api.openStreamingChat(body, accountId)
}
if (stream == null) {
call.respond(
HttpStatusCode.BadRequest,
errorBody("${route.kind.displayName} 未配置或未登录,请先在设置中配置")
)
} else {
call.respondOutputStream(
contentType = contentTypeOf(stream.contentType),
status = HttpStatusCode(stream.status, "")
) {
try {
val buffer = ByteArray(8192)
val input = stream.input
while (true) {
val read = input.read(buffer)
if (read < 0) break
write(buffer, 0, read)
flush()
}
} finally {
stream.close()
}
}
}
} else {
val result: ProxyUpstreamResponse? = when (route.kind) {
ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL ->
workBuddy.forwardChat(body, accountId, route.region)
ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL ->
trae.forwardChat(body, accountId, route.region)
else -> sub2Api.forwardChat(body, accountId)
}
if (result == null) {
call.respond(
HttpStatusCode.BadRequest,
errorBody("${route.kind.displayName} 未配置或未登录,请先在设置中配置")
)
} else {
call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, ""))
}
}
} catch (e: IOException) {
val detail = e.message ?: "未知错误"
call.respond(
HttpStatusCode.BadGateway,
errorBody("上游网络错误:$detail")
)
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
val detail = e.message ?: "未知错误"
call.respond(
HttpStatusCode.InternalServerError,
errorBody("网关内部错误:$detail")
)
}
}
}
}
private fun authorized(call: ApplicationCall, apiKey: String?): Boolean {
if (apiKey.isNullOrBlank()) return true
val header = call.request.headers[HttpHeaders.Authorization] ?: return false
val expect = "Bearer $apiKey"
return header.trim() == expect
}
private fun errorBody(message: String): Map<String, Any> =
mapOf("error" to mapOf("message" to message, "type" to "invalid_request_error"))
private fun contentTypeOf(raw: String): ContentType =
runCatching { ContentType.parse(raw) }.getOrDefault(ContentType.Application.Json)
private fun extractStream(body: String): Boolean = runCatching {
val el = kotlinx.serialization.json.Json.parseToJsonElement(body)
(el as? kotlinx.serialization.json.JsonObject)
?.get("stream")
?.let { if (it is kotlinx.serialization.json.JsonPrimitive) it.contentOrNull?.toBooleanStrictOrNull() else null }
}.getOrNull() ?: false
private fun stripModelPrefix(model: String): String {
val m = model.trim()
val lower = m.lowercase()
val prefix = listOf(
"wbcn-", "workbuddy-cn", "codebuddy-",
"wbintl-", "workbuddy-intl", "workbuddy-",
"traeintl-", "trae-intl", "traecn-", "trae-cn", "trae-",
"sub2api-", "openai-",
).firstOrNull { lower.startsWith(it) }
return if (prefix != null) m.substring(prefix.length).ifBlank { m } else m
}
private fun rewriteModelBody(body: String, newModel: String): String = try {
val obj = kotlinx.serialization.json.Json.parseToJsonElement(body)
as? kotlinx.serialization.json.JsonObject ?: return body
kotlinx.serialization.json.buildJsonObject {
obj.forEach { (key, value) ->
put(key, if (key == "model") kotlinx.serialization.json.JsonPrimitive(newModel) else value)
}
}.toString()
} catch (_: Throwable) {
body
}
private fun extractModel(body: String): String = runCatching {
val el = kotlinx.serialization.json.Json.parseToJsonElement(body)
(el as? kotlinx.serialization.json.JsonObject)
?.get("model")
?.let { if (it is kotlinx.serialization.json.JsonPrimitive) it.content else null }
}.getOrNull() ?: ""
private fun extractUser(body: String): String? = runCatching {
val el = kotlinx.serialization.json.Json.parseToJsonElement(body)
(el as? kotlinx.serialization.json.JsonObject)
?.get("user")
?.let { if (it is kotlinx.serialization.json.JsonPrimitive) it.content else null }
}.getOrNull()
companion object {
private const val MAX_REQUEST_BYTES = 10 * 1024 * 1024
}
}

View File

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

View File

@ -0,0 +1,21 @@
package com.rainy.token.data.proxy
import kotlinx.coroutines.flow.Flow
/**
* 统一的上游 Provider 适配器。
*
* 实现类负责把 [ProxyChatRequest] 翻译成各家私有协议
* (llm_utils_chat / v2/plugin / ACP 等),并把上游响应/SSE 流翻译回 [Flow]。
*
* 账号池、熔断、会话粘性由网关上层统一处理,不在适配器内重复实现。
*/
interface ProviderAdapter {
val kind: ProviderKind
/** 该适配器支持的模型列表;空列表表示暂未加载/不支持。 */
suspend fun models(): List<ProxyModel>
/** 发起对话,内部处理流式/非流式。 */
fun chat(request: ProxyChatRequest, accountId: String?): Flow<ProxyChatChunk>
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,30 @@
package com.rainy.token.data.proxy
/**
* 模型名 → Provider + Region 路由。
*
* 骨架策略:按模型家族前缀映射;后续可用 agent2api / proxy-hub 的远端模型目录替换。
*/
class RegionRouter {
data class Route(
val kind: ProviderKind,
val region: ProviderRegion,
)
fun route(model: String): Route? {
val normalized = model.lowercase()
return when {
// 国内/字节/智谱系模型默认 CN
CN_MODEL_FAMILIES.any { normalized.contains(it) } -> Route(ProviderKind.OPENAI_COMPATIBLE, ProviderRegion.CN)
// 国际模型默认 INTL
INTL_MODEL_FAMILIES.any { normalized.contains(it) } -> Route(ProviderKind.OPENAI_COMPATIBLE, ProviderRegion.INTL)
else -> null
}
}
companion object {
private val CN_MODEL_FAMILIES = listOf("glm", "deepseek", "kimi", "qwen", "doubao", "hunyuan", "minimax")
private val INTL_MODEL_FAMILIES = listOf("gpt", "o1", "o3", "o4", "claude", "gemini", "grok")
}
}

View File

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

View File

@ -0,0 +1,189 @@
package com.rainy.token.data.proxy
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Trae CN 上游 Chat 代理。
*
* 上游为私有协议 POST {base}/api/agent/v3/llm_utils_chat,
* 请求头需要 Cloud-IDE-JWT / x-device-id / x-uid 等设备与身份指纹。
* M1b 实现最小 body 转换:保留 messages/tools,注入 config_name/function/stream/session。
*/
@Singleton
class TraeChatProxy @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
) {
private val json = Json { ignoreUnknownKeys = true }
suspend fun forwardChat(
requestBody: String,
accountId: String? = null,
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 sessionId = UUID.randomUUID().toString().replace("-", "")
val builder = Request.Builder()
.url(base + "/api/agent/v3/llm_utils_chat")
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "text/event-stream")
.addHeader("Authorization", "Cloud-IDE-JWT jwt")
.addHeader("X-Cloudide-Token", jwt)
.addHeader("x-uid", credential.userId ?: "")
.addHeader("x-app-id", "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8")
.addHeader("x-device-id", credential.deviceId ?: credential.checkinDeviceId ?: "")
.addHeader("x-machine-id", credential.deviceId ?: credential.checkinDeviceId ?: "")
.addHeader("x-request-id", requestId)
.addHeader("x-ide-version", "3.3.67")
.addHeader("x-ide-version-code", "20260401")
.addHeader("x-ide-version-type", "stable")
.addHeader("x-device-cpu", "AMD")
.addHeader("x-device-brand", "83DG")
.addHeader("x-device-type", "windows")
.addHeader("x-ide-token", jwt)
.addHeader("x-os-version", "Windows 10")
.addHeader("x-system-type", "Windows")
.addHeader("Connection", "keep-alive")
.addHeader("User-Agent", "")
.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://grow-normal.trae.ai"
} else {
"https://api.trae.cn"
}
val upstreamBody = buildUpstreamBody(requestBody, credential)
val requestId = UUID.randomUUID().toString()
val sessionId = UUID.randomUUID().toString().replace("-", "")
val builder = Request.Builder()
.url(base + "/api/agent/v3/llm_utils_chat")
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "text/event-stream")
.addHeader("Authorization", "Cloud-IDE-JWT jwt")
.addHeader("X-Cloudide-Token", jwt)
.addHeader("x-uid", credential.userId ?: "")
.addHeader("x-app-id", "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8")
.addHeader("x-device-id", credential.deviceId ?: credential.checkinDeviceId ?: "")
.addHeader("x-machine-id", credential.deviceId ?: credential.checkinDeviceId ?: "")
.addHeader("x-request-id", requestId)
.addHeader("x-ide-version", "3.3.67")
.addHeader("x-ide-version-code", "20260401")
.addHeader("x-ide-version-type", "stable")
.addHeader("x-device-cpu", "AMD")
.addHeader("x-device-brand", "83DG")
.addHeader("x-device-type", "windows")
.addHeader("x-ide-token", jwt)
.addHeader("x-os-version", "Windows 10")
.addHeader("x-system-type", "Windows")
.addHeader("Connection", "keep-alive")
.addHeader("User-Agent", "")
.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 buildUpstreamBody(raw: String, credential: Credential.TraeCredential): String {
val src = try {
json.parseToJsonElement(raw) as? JsonObject
} catch (_: Throwable) {
null
} ?: return raw
val model = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() }
?: "glm-5.2"
val messages = src["messages"] as? JsonArray ?: JsonArray(emptyList())
val sessionId = UUID.randomUUID().toString().replace("-", "")
return buildJsonObject {
put("messages", messages)
put("config_name", model)
put("model", model)
put("function", "solo_work_lite")
put("stream", true)
put("request_id", sessionId)
put("session_id", sessionId)
src["tools"]?.let { put("tools", it) }
src["tool_choice"]?.let { put("tool_choice", it) }
(src["max_tokens"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull()?.let {
put("max_tokens", it)
}
}.toString()
}
}

View File

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

View File

@ -20,6 +20,13 @@ import com.rainy.token.data.repository.OllamaRepository
import com.rainy.token.data.repository.Sub2ApiRepository
import com.rainy.token.data.repository.TraeRepository
import com.rainy.token.data.repository.UpdateRepository
import com.rainy.token.data.proxy.AccountPool
import com.rainy.token.data.proxy.ProviderRouter
import com.rainy.token.data.proxy.KtorLocalProxyServer
import com.rainy.token.data.proxy.LocalProxyServer
import com.rainy.token.data.proxy.Sub2ApiChatProxy
import com.rainy.token.data.proxy.TraeChatProxy
import com.rainy.token.data.proxy.WorkBuddyChatProxy
import com.rainy.token.data.repository.WorkBuddyRepository
import dagger.Module
import dagger.Provides
@ -223,6 +230,55 @@ object NetworkModule {
balanceCache: BalanceCache
): Sub2ApiRepository = Sub2ApiRepository(okHttpClient, credentialRepository, balanceCache)
// ---- 本地反代网关 ----
@Provides
@Singleton
fun provideSub2ApiChatProxy(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository
): Sub2ApiChatProxy = Sub2ApiChatProxy(okHttpClient, credentialRepository)
@Provides
@Singleton
fun provideWorkBuddyChatProxy(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository
): WorkBuddyChatProxy = WorkBuddyChatProxy(okHttpClient, credentialRepository)
@Provides
@Singleton
fun provideTraeChatProxy(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository
): TraeChatProxy = TraeChatProxy(okHttpClient, credentialRepository)
@Provides
@Singleton
fun provideProviderRouter(): ProviderRouter = ProviderRouter()
@Provides
@Singleton
fun provideAccountPool(
credentialRepository: CredentialRepository
): AccountPool = AccountPool(credentialRepository)
@Provides
@Singleton
fun provideLocalProxyServer(
sub2ApiChatProxy: Sub2ApiChatProxy,
workBuddyChatProxy: WorkBuddyChatProxy,
traeChatProxy: TraeChatProxy,
providerRouter: ProviderRouter,
accountPool: AccountPool
): LocalProxyServer = KtorLocalProxyServer(
sub2ApiChatProxy,
workBuddyChatProxy,
traeChatProxy,
providerRouter,
accountPool
)
/** 余额缓存 DataStore(计划 7.1) */
@Provides
@Singleton

View File

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

View File

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

View File

@ -2,14 +2,23 @@ package com.rainy.token.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.data.proxy.LocalProxyServer
import com.rainy.token.data.proxy.ProviderRegion
import com.rainy.token.data.proxy.ProxyUpstreamStream
import com.rainy.token.data.proxy.ProxyServerConfig
import com.rainy.token.data.proxy.TraeChatProxy
import com.rainy.token.data.proxy.WorkBuddyChatProxy
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.service.ServiceType
import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -19,12 +28,106 @@ import javax.inject.Inject
*/
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val credentialRepository: CredentialRepository
private val credentialRepository: CredentialRepository,
private val localProxyServer: LocalProxyServer,
private val traeChatProxy: TraeChatProxy,
private val workBuddyChatProxy: WorkBuddyChatProxy
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
/** 本地反代服务运行状态。 */
val proxyRunning: StateFlow<Boolean> = localProxyServer.isRunning
/** 当前本机 API Key(每次进程随机生成,仅提示用)。 */
val proxyApiKey: StateFlow<String> = MutableStateFlow(generateProxyKey())
/** 启动失败时的错误信息。 */
private val _proxyError = MutableStateFlow<String?>(null)
val proxyError: StateFlow<String?> = _proxyError.asStateFlow()
/** 启动/停止本地反代服务。 */
fun setProxyEnabled(enabled: Boolean) {
if (enabled) {
localProxyServer.start(ProxyServerConfig(apiKey = proxyApiKey.value))
.onSuccess { _proxyError.value = null }
.onFailure { e -> _proxyError.value = e.message ?: "代理网关启动失败" }
} else {
localProxyServer.stop()
_proxyError.value = null
}
}
/** 代理调试输出(原始 SSE 帧)。 */
private val _proxyDebug = MutableStateFlow<String?>(null)
val proxyDebug: StateFlow<String?> = _proxyDebug.asStateFlow()
/** 用 Trae CN 默认账号发一条 stream=true 测试,抓上游原始 SSE。 */
fun testTraeStream() {
viewModelScope.launch {
_proxyDebug.value = "正在请求 Trae CN 流..."
val body = """{"model":"glm-5.2","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}"""
try {
val stream = traeChatProxy.openStreamingChat(body, null, ProviderRegion.CN)
if (stream == null) {
_proxyDebug.value = "Trae CN 未配置或未登录"
return@launch
}
_proxyDebug.value = readStreamSample(stream, "Trae CN")
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
_proxyDebug.value = "Trae CN 异常:" + (e.message ?: "未知错误")
}
}
}
/** 用 WorkBuddy CN 默认账号发一条 stream=true 测试,抓上游原始 SSE。 */
fun testWorkBuddyStream() {
viewModelScope.launch {
_proxyDebug.value = "正在请求 WorkBuddy CN 流..."
val body = """{"model":"glm-5.2","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}"""
try {
val stream = workBuddyChatProxy.openStreamingChat(body, null, ProviderRegion.CN)
if (stream == null) {
_proxyDebug.value = "WorkBuddy CN 未配置或未登录"
return@launch
}
_proxyDebug.value = readStreamSample(stream, "WorkBuddy CN")
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
_proxyDebug.value = "WorkBuddy CN 异常:" + (e.message ?: "未知错误")
}
}
}
private suspend fun readStreamSample(
stream: ProxyUpstreamStream,
label: String,
): String = withContext(Dispatchers.IO) {
val sb = StringBuilder()
sb.append(label).append(" HTTP ").append(stream.status)
sb.append(" Content-Type=").append(stream.contentType).append("\n")
try {
val reader = stream.input.bufferedReader()
repeat(80) {
val line = reader.readLine() ?: return@repeat
sb.append(line).append("\n")
if (line.trim() == "data: [DONE]") return@repeat
if (sb.length > 6000) return@repeat
}
} finally {
stream.close()
}
sb.toString()
}
private fun generateProxyKey(): String =
"rainy-" + UUID.randomUUID().toString().replace("-", "").take(16)
init {
refresh()
}

143
docs/PROXY-ROADMAP.md Normal file
View File

@ -0,0 +1,143 @@
# RainyToken 反代网关开发路线图(PROXY-ROADMAP)
> 分支:dev-proxy
> 目标:在雨晴Token App 内嵌一个多提供商 OpenAI 兼容反代网关,支持双区、签到、反代、多账号池,并尽量覆盖 agent2api / fleet-kit 等开源项目已支持的平台。
---
## 1. 最终目标
App 内启动一个本地 HTTP 服务(默认 `127.0.0.1:8787`),对外暴露:
- `GET /v1/models`
- `POST /v1/chat/completions`(OpenAI 兼容,流式/非流式)
- `POST /v1/responses`(Codex Responses API)
- `POST /v1/messages`(Anthropic Messages,Claude Code 直连)
请求按「模型名 → Provider + Region + 账号」路由,复用现有 `CredentialRepository` / `SecureStorage` / 各 Repository。
## 2. 架构
```
LocalProxyServer (127.0.0.1:8787)
│
├── /v1/chat/completions
├── /v1/responses
├── /v1/messages
└── /v1/models
│
▼
ProviderRouter(模型名 → Provider + Region + Account)
│
┌──────┼──────────┬─────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼
WBAdpt TraeAdpt QoderAdpt RaccoonAdpt OfficialPassthrough
(CN/INTL)(CN/INTL)(CN) (CN) (DeepSeek/Gemini/Kimi…)
│
▼
AccountPool(多账号轮询 + 熔断冷却 + 会话粘性)
│
▼
StreamNormalizer(各家上游 SSE 分片 → 标准 OpenAI/Anthropic 流)
```
## 3. 已编码骨架(data/proxy)
- `ProviderKind.kt`:全部计划支持的 Provider 枚举
- `ProviderRegion.kt`:CN / INTL
- `ProxyModels.kt`:统一 Chat 请求 / 响应 / 模型 / 用量
- `ProviderAdapter.kt`:统一 Provider 适配器接口
- `AccountPool.kt`:账号池骨架(round-robin,后续加权)
- `RegionRouter.kt`:模型 → Provider/Region 路由骨架
- `LocalProxyServer.kt`:本地服务接口 + 配置
## 3.5 当前进度(dev-proxy 分支)
| 提交 | 内容 |
|---|---|
| 85c6cf4 | M1a:Ktor 本地服务 + Sub2API 透传 |
| 11b9065 | M1b:WorkBuddy CN / Trae CN 适配 + 设置页开关 |
| a1605e6 | M2:账号池轮询 + 会话粘性 + 双区域路由 |
| 1609e21 | 审计修复:鉴权头/401/模型前缀剥离 + 流式入口骨架 |
**已打通**:
- 设置页「代理网关」开关,默认 127.0.0.1:8787,本机 Key:rainytoken
- 路由前缀:wbcn- / workbuddy-cn / codebuddy- → WorkBuddy CN;wbintl- / workbuddy-intl → WorkBuddy INTL;traecn- / trae-cn / trae- → Trae CN;traeintl- / trae-intl → Trae INTL;其余 → Sub2API
- 账号池按区内匹配 + round-robin + 会话粘性(X-Conversation-Id / user)
- stream=true 已接实时字节流转发(respondOutputStream);各上游私有 SSE 信封仍待归一
**未完成**:
- 各家私有 SSE 信封 → 标准 OpenAI/Anthropic 流的 StreamNormalizer 仍待实现
- WorkBuddy INTL / Trae SOLO/Work 实际协议归一化
- M3 agent2api 全家桶(Qoder / 小浣熊 / CatPaw / AutoClaw / Cline / Accio / ZCode / CodeArts / Loomy)
- Qoder 需 COSY 请求签名/编码(ed25519 类原语),已列为攻坚项
- M4 长尾 + 官方透传 + 网关配置 UI 完善(已做:随机 API Key / 启动失败提示;待做:端口配置 / 状态持久化)
## 4. 里程碑
### M0:地基(当前)
- 新增 `data/proxy` 骨架,能编译
- 新增本路线图
- 验收:`./gradlew :app:assembleDebug` 绿
### M1:先打通“已经有凭据体系”的三家
- WorkBuddy 国内版 Chat 适配器(参考 workbuddy2api / workbuddy-gateway)
- Trae CN Chat 适配器(参考 Trae2api-cn / trae2api)
- Sub2API 透传(本身是 OpenAI 兼容协议)
- 本地 HTTP 服务可用,任意 OpenAI 客户端可连
- 验收:真机 + 真凭据,WorkBuddy / Trae / Sub2API 各能回复一条消息
### M2:双区 + 多账号治理
- WorkBuddy 国际版、Trae INTL/Solo/Work 适配器
- RegionRouter 按模型家族自动选区(glm/deepseek/kimi → CN,gpt/claude/gemini → INTL)
- AccountPool 加权轮询 + 429 熔断 + 会话粘性
- 验收:国内/国际账号混挂,同模型自动走对应区,单号故障自动换号
### M3:覆盖 agent2api 全家桶
- Qoder CN、小浣熊、CatPaw、AutoClaw CN/INTL、Cline、Accio CN/INTL、ZCode CN/INTL、CodeArts、Loomy
- 每家一个 Adapter,抄 agent2api `providers/` 目录
- 验收:每家至少能在有凭据的情况下完成一次对话;签到/余额尽量复用现有页
### M4:长尾 + 官方直连
- 灵犀、团结AI、TokenDance、Kimi Code、MiniMax、Gemini
- 官方 OpenAI 兼容平台统一走 `OfficialPassthrough`(配置 base_url + key)
- 设置页新增「代理网关」管理:端口、启动/停止、API Key、运行状态
- 验收:所有支持平台可在 UI 中启用/禁用
## 5. Provider 支持矩阵(规划)
| Provider | 区域 | Chat | 签到 | 参考实现 |
|---|---|---|---|---|
| WorkBuddy | CN / INTL | ✅ | CN 有 | agent2api / fleet-kit / proxy-hub |
| Trae / SOLO / Work | CN / INTL | ✅ | CN 有 | agent2api / proxy-hub |
| Qoder | CN | ✅ | CN 有 | agent2api / proxy-hub |
| 小浣熊 | CN | ✅ | 桌面登录积分 | agent2api / fleet-kit |
| CatPaw | CN | ✅ | - | agent2api |
| AutoClaw | CN / INTL | ✅ | CN 有 | agent2api |
| Cline | INTL | ✅ | - | agent2api |
| Accio | CN / INTL | ✅ | - | agent2api |
| ZCode | CN / INTL | ✅ | - | agent2api |
| CodeArts | CN | ✅ | 每日福利 | agent2api |
| Loomy | CN | ✅ | 每日积分 | agent2api / fleet-kit |
| 灵犀 / 团结AI / TokenDance | CN | ✅ | 待确认 | fleet-kit |
| Kimi Code / MiniMax / Gemini | INTL/CN | ✅ | - | 官方 API 透传 |
## 6. 关键风险与对策
1. **上游协议会变**:所有解析做防御式多候选扫描(项目现有惯例);接口变动时快速适配。
2. **设备指纹/风控**:Trae 签到 9074、WorkBuddy 设备指纹;沿用现有 `checkinDeviceId` / `derive_id` 思路。
3. **Token 单次轮换**:WorkBuddy refresh 单次消费,沿用 `RefreshWriteSession` 落库再用的机制。
4. **SSE 分片差异**:各家流式事件格式不同,用统一 `StreamNormalizer` 归一。
5. **Android 后台**:若只给本机内客户端用,App 前台运行即可;若希望后台常驻,需前台服务 + 通知。
## 7. 参考仓库
- agent2api:https://github.com/aimod-cc/agent2api
- workbuddy2api-hub:https://github.com/ardeyouxipianyi/workbuddy2api-hub
- workbuddy-gateway:https://github.com/CangShui/workbuddy-gateway
- workbuddy2api-panel:https://github.com/linguo2625469/workbuddy2api-panel
- Trae2api-cn:https://github.com/autumnsentiment/Trae2api-cn
- trae2api (Go):https://github.com/JeffHu0912/trae2api
- proxy-hub:https://github.com/1416277987/proxy-hub
- fleet-kit:https://github.com/cpufreestyle/fleet-kit

View File

@ -15,6 +15,9 @@ navigationCompose = "2.9.8"
# Network (Retrofit 2.11.0 配 OkHttp 4.12.0 — 稳定组合,避开 Retrofit 3.x + OkHttp 5.x 的前沿兼容性问题)
retrofit = "2.11.0"
# Ktor (本地反代 HTTP 服务)
ktor = "3.0.3"
okhttp = "4.12.0"
kotlinxSerializationJson = "1.7.3"
retrofitKotlinxSerializationConverter = "1.0.0"
@ -67,6 +70,10 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" }
ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
retrofit-kotlinx-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationConverter" }

View File

@ -1,6 +1,6 @@
{
"versionCode": 14,
"versionName": "1.6.4",
"apkUrl": "http://124.222.97.212:8443/attachments/47c454a6-72ea-4449-9e4a-2875d207c149",
"notes": "包含签到记录重构(日期/状态/id/数量),失败可当天重试"
"versionCode": 16,
"versionName": "1.6.6",
"apkUrl": "http://124.222.97.212:8443/attachments/39eef1fe-2ec6-46d0-974c-255adb24c015",
"notes": "代理网关 dev-proxy:新增本地 OpenAI 兼容反代、WorkBuddy/Trae 适配、账号池路由、流式透传、随机 API Key"
}