Compare commits

..

30 Commits
dev ... v1.7.5

Author SHA1 Message Date
0241840f9c feat(proxy): Trae 模型切换 + 在线拉取模型列表
Some checks are pending
Release / build (push) Waiting to run
- 设置页新增模型下拉选择,内置参考映射兜底
- TraeModelProvider 在线拉取账号可用模型(/api/remote/v1/models)
- 选中模型持久化,作为反代缺省模型
- 常用 OpenAI/Claude 模型名自动映射到 Trae 内部模型
- 修复探针测试请求体混入反斜杠导致 JSON 无效的问题
- 版本升至 1.7.5
2026-10-09 11:03:27 +08:00
8a9d9432fe chore(update): 发布 1.7.4 更新清单 2026-10-09 10:47:24 +08:00
fbbca4a03b feat(proxy): 探针结果结构化摘要,不再刷屏原始参数
Some checks are pending
Release / build (push) Waiting to run
- 显示:请求行/模型/messages数/stream、HTTP、事件帧数、模型、文本摘要、用量、错误
- 原始请求+响应仍完整保留,点“复制原始”一键复制
- 版本升至 1.7.4
2026-10-09 10:46:30 +08:00
7de7111b18 chore(update): 发布 1.7.3 更新清单 2026-10-09 10:29:43 +08:00
dcd5cab2b2 feat(proxy): 探针输出美化 + 一键复制原始输出
Some checks are pending
Release / build (push) Waiting to run
- 调试输出改为滚动等宽代码块,状态一眼可读
- 新增“复制全部”按钮,一键把原始输出写入剪贴板
- 版本升至 1.7.3
2026-10-09 10:28:19 +08:00
a804d46acd chore(update): 发布 1.7.2 更新清单 2026-10-09 10:22:52 +08:00
ada750df7e fix(proxy): Trae messages.content 转原生块数组,修复 4001
Some checks are pending
Release / build (push) Waiting to run
- 上游 LLMRawMessage.messages.content 要求 []*LLMRawMessageContent
- 字符串转 [{"type":"text","text":...}],列表块按 text/input_text 归一
- 版本升至 1.7.2
2026-10-09 10:21:48 +08:00
b5ef9249d8 chore(update): 发布 1.7.1 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 10:12:59 +08:00
481c57c535 feat(update): 已下载 APK 直接复用 + 安装失败可重试不重下
Some checks are pending
Release / build (push) Waiting to run
- downloadApk 检测同版本已完成文件,存在则跳过下载
- 下载先写 .part 再改名,避免半截文件被误用
- install 失败保留 Ready(file, error),UI 显示重试安装
- NeedInstallPermission 携带文件,开启权限后可继续安装
2026-10-09 10:11:26 +08:00
571b270cb6 chore(update): 发布 1.7.0 更新清单 2026-10-09 10:03:27 +08:00
fa796d57b5 fix(proxy): 修复 Trae Authorization 头发成字面量 jwt + 补全 SOLO 头
Some checks are pending
Release / build (push) Waiting to run
- 上一版把 \ 写成了字符串 jwt,Trae 收到 Cloud-IDE-JWT jwt 必然被网关拒绝
- 完整 SOLO 头集合对齐 trae2api-cn:x-app-version/x-lscbd-*/x-ttnet-* 等
- 版本升至 1.7.0
2026-10-09 10:02:07 +08:00
8e692a0c6e chore(update): 发布 1.6.9 更新清单 2026-10-09 09:47:09 +08:00
4597fb0f8e fix(update): canRequestPackageInstalls 包 try/catch,旧包也不闪退
Some checks are pending
Release / build (push) Waiting to run
- 旧版本未声明 REQUEST_INSTALL_PACKAGES 时调该 API 本身会抛 SecurityException
- 现在降级为 capture 后按 false 处理,引导去系统设置开启未知来源
- 版本升至 1.6.9
2026-10-09 09:46:06 +08:00
40af5db889 chore(update): 发布 1.6.8 更新清单 2026-10-09 09:39:57 +08:00
5adf053b6f fix(update): 声明 REQUEST_INSTALL_PACKAGES 权限并加固安装流程
Some checks are pending
Release / build (push) Waiting to run
- 缺少该权限时部分 ROM 启动系统安装器会被拒绝/闪退
- install() 整体容错:FileProvider URI 失败、ACTION_VIEW 失败均捕获并回退 ACTION_INSTALL_PACKAGE
- 版本升至 1.6.8
2026-10-09 09:38:52 +08:00
e2bf924246 chore(update): 发布 1.6.7 更新清单 2026-10-09 09:30:50 +08:00
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
26 changed files with 2036 additions and 22 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 = 25
versionName = "1.7.5"
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

@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

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,312 @@
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 modelProvider: TraeModelProvider,
) {
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
}
/** 外部模型名 -> Trae 内部模型名;空/auto 用设置页选择的默认模型。 */
private fun normalizeModel(raw: String?): String {
val id = raw?.trim()?.takeIf { it.isNotBlank() } ?: return modelProvider.selectedModel
if (id.equals("auto", ignoreCase = true)) return modelProvider.selectedModel
return MODEL_ALIASES[id.lowercase()] ?: id
}
private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential): String {
val src = try {
json.parseToJsonElement(raw) as? JsonObject
} catch (_: Throwable) {
null
} ?: return raw
val rawModel = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() }
val model = normalizeModel(rawModel)
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()
}
companion object {
/** OpenAI/Claude 常用名 -> Trae CN 内部模型名(参考 trae2api-cn)。 */
private val MODEL_ALIASES = mapOf(
"auto" to "glm-5.2",
"gpt-4o" to "DeepSeek-V4-Pro",
"gpt-4o-latest" to "DeepSeek-V4-Pro",
"gpt-4.1" to "DeepSeek-V4-Pro",
"deepseek-v3" to "DeepSeek-V4-Pro",
"deepseek-r1" to "DeepSeek-V4-Pro",
"gpt-4o-mini" to "DeepSeek-V4-Flash",
"claude-opus-4-7" to "glm-5.2",
"claude-opus-4-6" to "glm-5.2",
"claude-opus-4-5" to "glm-5.2",
"claude-sonnet-4-6" to "glm-5.2",
"claude-sonnet-4-5" to "glm-5.2",
"claude-sonnet-4" to "glm-5.2",
"claude-3.7-sonnet" to "glm-5.2",
"claude-3-7-sonnet" to "glm-5.2",
"claude-3.5-sonnet" to "glm-5.2",
"claude-haiku-4-5" to "glm-5.1",
"deepseek-v4-flash-official" to "DeepSeek-V4-Flash-Official",
"deepseek-v4-pro-official" to "DeepSeek-V4-Pro-Official",
"doubao-seed-evolving" to "Doubao-Seed-Evolving",
"deepseek-v4-pro" to "DeepSeek-V4-Pro",
"deepseek-v4-flash" to "DeepSeek-V4-Flash",
"seed-2.1-pro" to "Doubao-Seed-2.1-Pro",
"seed-2.1-turbo" to "Doubao-Seed-2.1-Turbo",
"seed-code" to "Doubao-Seed-Code",
"seed-evolving" to "Doubao-Seed-Evolving",
"qwen3.7-plus" to "qwen-3.7-plus",
"kimi-k2.6" to "kimi-k2.6",
"kimi-k3" to "kimi-k3",
"kimi-k2.7-code" to "kimi-k2.7-code",
"kimi-k2" to "kimi-k2",
"kimi-k2.5" to "kimi-k2.5",
"minimax-m3" to "minimax-m3",
"minimax-m2.7" to "minimax-m2.7",
"minimax-m2.1" to "minimax-m2.1",
"mimo-v2.5" to "mimo-v2.5",
"mimo-v2.5-pro" to "mimo-v2.5-pro",
"minimax-m25" to "minimax-m25",
"qwen36-35b" to "qwen36-35b",
)
}
}

View File

@ -0,0 +1,161 @@
package com.rainy.token.data.proxy
import android.content.Context
import android.content.SharedPreferences
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
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.contentOrNull
import okhttp3.Headers.Companion.toHeaders
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* Trae 模型列表:内置参考映射 + 在线拉取账号可用模型 + 记住用户选择。
* 选择结果同时供设置页测试和 [TraeChatProxy] 作为缺省模型使用。
*/
@Singleton
class TraeModelProvider @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
@ApplicationContext private val context: Context,
) {
data class TraeModel(val id: String, val label: String? = null)
private val prefs: SharedPreferences =
context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE)
private val _models = MutableStateFlow(defaultModels())
val models: StateFlow<List<TraeModel>> = _models.asStateFlow()
/** 当前默认模型(用户在设置页选择的 Trae 模型名)。 */
var selectedModel: String = prefs.getString(KEY_MODEL, "glm-5.2") ?: "glm-5.2"
private set
private val json = Json { ignoreUnknownKeys = true }
fun setSelectedModel(id: String) {
if (id.isBlank()) return
selectedModel = id
prefs.edit().putString(KEY_MODEL, id).apply()
}
/** 拉取账号在线模型列表;失败时保留内置列表并返回 false。 */
suspend fun refreshFor(forcedRegion: ProviderRegion?): Boolean = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, null)
?: return@withContext false
if (credential !is Credential.TraeCredential) return@withContext false
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() } ?: return@withContext false
val effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull()
val intl = effectiveRegion == ProviderRegion.INTL
val base = if (intl) "https://a0ai-api-sg.byteintlapi.com" else "https://trae-api-cn.mchost.guru"
val origin = if (intl) "https://solo.trae.ai" else "https://solo.trae.cn"
val url = base + "/api/remote/v1/models" +
"?functions=solo_agent_remote%2Csolo_work_remote%2Csolo_design_remote" +
"&show_custom_model=true"
val headers = linkedMapOf(
"Authorization" to "Cloud-IDE-JWT $jwt",
"Content-Type" to "application/json",
"X-Trae-Client-Type" to "web",
"X-Preferenced-Language" to "zh-CN",
"x-user-region" to if (intl) "SG" else "CN",
"Origin" to origin,
"Referer" to origin + "/",
"User-Agent" to (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
),
)
val request = Request.Builder().url(url).headers(headers.toHeaders()).get().build()
val parsed = try {
okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return@use null
val body = resp.body?.string() ?: return@use null
parseModelList(body)
}
} catch (_: Exception) {
null
}
if (parsed.isNullOrEmpty()) return@withContext false
_models.value = parsed
true
}
private fun parseModelList(body: String): List<TraeModel>? {
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
?: return null
val list = (root["data"] as? JsonObject)?.get("list") as? JsonArray ?: return null
val seen = LinkedHashMap<String, TraeModel>()
for (groupEl in list) {
val group = groupEl as? JsonObject ?: continue
val models = group["models"] as? JsonArray ?: continue
for (rawEl in models) {
val raw = rawEl as? JsonObject ?: continue
val id = (raw["name"] as? JsonPrimitive)?.contentOrNull?.trim()
?.takeIf { it.isNotEmpty() } ?: continue
val label = (raw["display_name"] as? JsonPrimitive)?.contentOrNull
?: (raw["display_model_name"] as? JsonPrimitive)?.contentOrNull
?: id
seen[id] = TraeModel(id, label)
}
}
return seen.values.toList().ifEmpty { null }
}
companion object {
private const val KEY_MODEL = "trae_default_model"
/** 内置兜底模型(参考 trae2api-cn 的模型映射 /v1/models 列表)。 */
fun defaultModels(): List<TraeModel> = listOf(
TraeModel("glm-5.2", "GLM-5.2"),
TraeModel("glm-5.3", "GLM-5.3"),
TraeModel("glm-5.1", "GLM-5.1"),
TraeModel("glm-5", "GLM-5"),
TraeModel("glm-4.7", "GLM-4.7"),
TraeModel("glm-4.6", "GLM-4.6"),
TraeModel("DeepSeek-V4-Pro", "DeepSeek-V4-Pro"),
TraeModel("DeepSeek-V4-Pro-Official", "DeepSeek-V4-Pro 正式版"),
TraeModel("DeepSeek-V4-Flash", "DeepSeek-V4-Flash"),
TraeModel("DeepSeek-V4-Flash-Official", "DeepSeek-V4-Flash 正式版"),
TraeModel("Doubao-Seed-2.1-Pro", "Doubao-Seed-2.1-Pro"),
TraeModel("Doubao-Seed-2.1-Turbo", "Doubao-Seed-2.1-Turbo"),
TraeModel("Doubao-Seed-Code", "Doubao-Seed-Code"),
TraeModel("Doubao-Seed-Evolving", "Doubao-Seed-Evolving"),
TraeModel("qwen-3.7-plus", "qwen-3.7-plus"),
TraeModel("qwen-3.6-plus", "qwen-3.6-plus"),
TraeModel("qwen3.8-max", "qwen3.8-max"),
TraeModel("qwen-3.5", "qwen-3.5"),
TraeModel("qwen3-coder", "qwen3-coder"),
TraeModel("kimi-k2.6", "kimi-k2.6"),
TraeModel("kimi-k3", "kimi-k3"),
TraeModel("kimi-k2.7-code", "kimi-k2.7-code"),
TraeModel("kimi-k2", "kimi-k2"),
TraeModel("kimi-k2.5", "kimi-k2.5"),
TraeModel("minimax-m3", "minimax-m3"),
TraeModel("minimax-m2.7", "minimax-m2.7"),
TraeModel("minimax-m2.1", "minimax-m2.1"),
TraeModel("mimo-v2.5-pro", "mimo-v2.5-pro"),
TraeModel("mimo-v2.5", "mimo-v2.5"),
TraeModel("minimax-m25", "minimax-m25"),
TraeModel("qwen36-35b", "qwen36-35b"),
TraeModel("work", "Work(200K 档)"),
)
}
}

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

@ -56,7 +56,7 @@ class UpdateRepository @Inject constructor(
}
}
/** 下载 APK 到缓存目录。onProgress 回调下载字节数/总字节数(total 可能为 -1)。 */
/** 下载 APK 到缓存目录;同版本已下载完成时直接复用,不重复下载。 */
suspend fun downloadApk(
info: UpdateInfo,
onProgress: (downloaded: Long, total: Long) -> Unit
@ -64,6 +64,11 @@ class UpdateRepository @Inject constructor(
try {
val dir = File(appContext.cacheDir, "apk_downloads").apply { mkdirs() }
val file = File(dir, "rainytoken-" + info.versionName + ".apk")
if (file.exists() && file.length() > 0L) {
return@withContext Result.success(file)
}
// 先写 .part,下载完整后再替换正式文件名,避免半截文件被当成完整包
val part = File(dir, file.name + ".part")
val request = Request.Builder().url(info.apkUrl).get().build()
okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
@ -71,7 +76,7 @@ class UpdateRepository @Inject constructor(
}
val body = resp.body ?: return@withContext Result.failure(IOException("empty body"))
val total = body.contentLength()
file.outputStream().buffered().use { output ->
part.outputStream().buffered().use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var downloaded = 0L
@ -84,6 +89,8 @@ class UpdateRepository @Inject constructor(
}
}
}
if (file.exists()) file.delete()
part.renameTo(file)
Result.success(file)
} catch (e: Exception) {
Result.failure(e)

View File

@ -20,6 +20,14 @@ 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.TraeModelProvider
import com.rainy.token.data.proxy.WorkBuddyChatProxy
import com.rainy.token.data.repository.WorkBuddyRepository
import dagger.Module
import dagger.Provides
@ -223,6 +231,56 @@ 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,
traeModelProvider: TraeModelProvider,
): TraeChatProxy = TraeChatProxy(okHttpClient, credentialRepository, traeModelProvider)
@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

@ -2,7 +2,17 @@ package com.rainy.token.ui.settings
import android.app.Activity
import android.os.Build
import android.widget.Toast
import android.content.ClipboardManager
import android.content.ClipData
import android.content.Context
import androidx.compose.foundation.clickable
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -23,11 +33,14 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
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
@ -49,6 +62,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.data.proxy.TraeModelProvider
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.ServiceIcon
import com.rainy.token.ui.components.StatusChip
@ -89,6 +103,13 @@ 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()
val proxyDebugRaw by viewModel.proxyDebugRaw.collectAsStateWithLifecycle()
val proxyModels by viewModel.proxyModels.collectAsStateWithLifecycle()
val proxyModel by viewModel.proxyModel.collectAsStateWithLifecycle()
var showLanguageDialog by remember { mutableStateOf(false) }
Scaffold(
@ -159,6 +180,22 @@ fun SettingsScreen(
item {
LanguageCard(onClick = { showLanguageDialog = true })
}
item {
ProxyGatewayCard(
running = proxyRunning,
apiKey = proxyApiKey,
error = proxyError,
debug = proxyDebug,
debugRaw = proxyDebugRaw,
models = proxyModels,
selectedModel = proxyModel,
onSelectModel = viewModel::selectProxyModel,
onRefreshModels = viewModel::refreshProxyModels,
onToggle = viewModel::setProxyEnabled,
onTestTrae = viewModel::testTraeStream,
onTestWorkBuddy = viewModel::testWorkBuddyStream
)
}
item {
TipsCard(onClick = { onOpenTips() })
}
@ -181,6 +218,147 @@ fun SettingsScreen(
}
}
@Composable
private fun ProxyGatewayCard(
running: Boolean,
apiKey: String,
error: String?,
debug: String?,
debugRaw: String?,
models: List<TraeModelProvider.TraeModel>,
selectedModel: String,
onSelectModel: (String) -> Unit,
onRefreshModels: () -> Unit,
onToggle: (Boolean) -> Unit,
onTestTrae: () -> Unit,
onTestWorkBuddy: () -> Unit
) {
val context = LocalContext.current
var modelMenuExpanded by remember { mutableStateOf(false) }
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 流")
}
}
Row(
modifier = Modifier.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "模型",
style = MaterialTheme.typography.labelSmall,
color = InkMuted
)
Spacer(modifier = Modifier.width(8.dp))
Box {
TextButton(onClick = { modelMenuExpanded = true }) {
Text(selectedModel + " ▾")
}
DropdownMenu(
expanded = modelMenuExpanded,
onDismissRequest = { modelMenuExpanded = false }
) {
models.forEach { m ->
DropdownMenuItem(
text = { Text(m.label ?: m.id) },
onClick = {
onSelectModel(m.id)
modelMenuExpanded = false
}
)
}
}
}
TextButton(onClick = onRefreshModels) {
Text("刷新")
}
}
if (debug != null) {
Spacer(modifier = Modifier.padding(top = 8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "探针摘要",
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
modifier = Modifier.weight(1f)
)
TextButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("代理网关探针输出", debugRaw ?: debug))
Toast.makeText(context, "已复制全部原始输出", Toast.LENGTH_SHORT).show()
}) {
Text("复制原始")
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 220.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f))
.verticalScroll(rememberScrollState())
.padding(10.dp)
) {
Text(
text = debug,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = InkMuted
)
}
}
}
Switch(
checked = running,
onCheckedChange = onToggle
)
}
}
}
@Composable
private fun LanguageCard(onClick: () -> Unit) {
val context = LocalContext.current
@ -416,9 +594,23 @@ private fun UpdateCard(
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
if (phase.error != null) {
Spacer(modifier = Modifier.padding(top = 4.dp))
Text(
text = phase.error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Spacer(modifier = Modifier.padding(top = 8.dp))
Button(onClick = { viewModel.install(phase.file) }) {
Text(stringResource(R.string.update_install))
Text(
if (phase.error == null) {
stringResource(R.string.update_install)
} else {
"重试安装"
}
)
}
}
is UpdateViewModel.Phase.NeedInstallPermission -> {
@ -437,6 +629,9 @@ private fun UpdateCard(
}) {
Text(stringResource(R.string.update_enable_permission))
}
TextButton(onClick = { viewModel.install(phase.file) }) {
Text("已开启,继续安装")
}
}
is UpdateViewModel.Phase.Error -> {
Text(

View File

@ -2,15 +2,32 @@ 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.TraeModelProvider
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 kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.Json
import javax.inject.Inject
/**
@ -19,12 +36,260 @@ 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,
private val traeModelProvider: TraeModelProvider,
) : 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
}
}
/** 代理调试输出(结构化摘要)。 */
private val _proxyDebug = MutableStateFlow<String?>(null)
val proxyDebug: StateFlow<String?> = _proxyDebug.asStateFlow()
/** 代理调试完整原始输出(请求 + 响应帧),供一键复制。 */
private val _proxyDebugRaw = MutableStateFlow<String?>(null)
val proxyDebugRaw: StateFlow<String?> = _proxyDebugRaw.asStateFlow()
/** Trae 可用模型列表(内置兜底 + 在线拉取账号模型)。 */
val proxyModels: StateFlow<List<TraeModelProvider.TraeModel>> = traeModelProvider.models
/** 当前选择的 Trae 默认模型。 */
private val _proxyModel = MutableStateFlow(traeModelProvider.selectedModel)
val proxyModel: StateFlow<String> = _proxyModel.asStateFlow()
/** 切换 Trae 默认模型并持久化。 */
fun selectProxyModel(id: String) {
traeModelProvider.setSelectedModel(id)
_proxyModel.value = id
}
/** 拉取当前账号可用的 Trae 模型列表。 */
fun refreshProxyModels() {
viewModelScope.launch {
val ok = traeModelProvider.refreshFor(ProviderRegion.CN)
if (!ok) _proxyDebug.value = "模型列表更新失败(当前显示内置列表)"
}
}
/** 用 Trae CN 默认账号发一条 stream=true 测试。 */
fun testTraeStream() {
viewModelScope.launch {
_proxyDebug.value = "正在请求 Trae CN 流..."
_proxyDebugRaw.value = null
val model = traeModelProvider.selectedModel
val body = """{"model":"$model","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
}
val result = readStreamSample(
stream,
"Trae CN",
body,
"POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat"
)
_proxyDebug.value = result.summary
_proxyDebugRaw.value = result.raw
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
_proxyDebug.value = "Trae CN 异常:" + (e.message ?: "未知错误")
_proxyDebugRaw.value = null
}
}
}
/** 用 WorkBuddy CN 默认账号发一条 stream=true 测试。 */
fun testWorkBuddyStream() {
viewModelScope.launch {
_proxyDebug.value = "正在请求 WorkBuddy CN 流..."
_proxyDebugRaw.value = null
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
}
val result = readStreamSample(
stream,
"WorkBuddy CN",
body,
"POST https://copilot.tencent.com/v2/chat/completions"
)
_proxyDebug.value = result.summary
_proxyDebugRaw.value = result.raw
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
_proxyDebug.value = "WorkBuddy CN 异常:" + (e.message ?: "未知错误")
_proxyDebugRaw.value = null
}
}
}
private suspend fun readStreamSample(
stream: ProxyUpstreamStream,
label: String,
requestBody: String,
requestLine: String,
): StreamProbeResult = withContext(Dispatchers.IO) {
val lines = mutableListOf<String>()
val raw = StringBuilder()
raw.append(label).append(" HTTP ").append(stream.status)
raw.append(" Content-Type=").append(stream.contentType).append("\n")
raw.append("请求: ").append(requestLine).append("\n")
raw.append("请求体: ").append(requestBody).append("\n")
raw.append("---- 上游响应原始帧 ----\n")
try {
val reader = stream.input.bufferedReader()
repeat(120) {
val line = reader.readLine() ?: return@repeat
lines.add(line)
raw.append(line).append("\n")
if (line.trim() == "data: [DONE]") return@repeat
if (raw.length > 12000) return@repeat
}
} finally {
stream.close()
}
StreamProbeResult(
summary = summarizeProbe(label, stream.status, stream.contentType, requestBody, requestLine, lines),
raw = raw.toString()
)
}
private fun summarizeProbe(
label: String,
status: Int,
contentType: String,
requestBody: String,
requestLine: String,
lines: List<String>,
): String {
val sb = StringBuilder()
sb.append(label).append(" 探针摘要")
sb.append("\n请求: ").append(requestLine)
runCatching {
val body = json.parseToJsonElement(requestBody) as? JsonObject
if (body != null) {
val model = (body["model"] as? JsonPrimitive)?.contentOrNull ?: "?"
val count = (body["messages"] as? JsonArray)?.size ?: 0
val stream = (body["stream"] as? JsonPrimitive)?.contentOrNull ?: "?"
sb.append("\n 模型=").append(model)
sb.append(" · messages=").append(count)
sb.append(" · stream=").append(stream)
(body["tools"] as? JsonArray)?.let { sb.append(" · tools=").append(it.size) }
}
}
sb.append("\n回复: HTTP ").append(status).append(" · ").append(contentType)
var events = 0
val texts = mutableListOf<String>()
val models = linkedSetOf<String>()
val usage = mutableListOf<String>()
val errors = mutableListOf<String>()
for (line in lines) {
val trimmed = line.trim()
if (!trimmed.startsWith("data:")) continue
val payload = trimmed.removePrefix("data:").trim()
if (payload == "[DONE]") continue
val el = runCatching { json.parseToJsonElement(payload) as? JsonObject }.getOrNull() ?: continue
events++
collectProbeText(el, texts)
collectProbeModel(el, models)
collectProbeUsage(el, usage)
(el["error"] as? JsonObject)?.let { e ->
val msg = (e["message"] as? JsonPrimitive)?.contentOrNull ?: e.toString()
errors.add(msg)
}
}
if (events > 0) sb.append("\n事件帧: ").append(events)
if (models.isNotEmpty()) sb.append("\n模型: ").append(models.joinToString(" / "))
if (texts.isNotEmpty()) {
val joined = texts.joinToString("").trim().replace("\n", " ").take(200)
sb.append("\n文本(截断): ").append(if (joined.isEmpty()) "(空)" else joined)
}
if (usage.isNotEmpty()) sb.append("\n用量: ").append(usage.distinct().joinToString(" · "))
if (errors.isNotEmpty()) sb.append("\n错误: ").append(errors.take(2).joinToString(" | "))
return sb.toString()
}
private fun collectProbeText(el: JsonObject, out: MutableList<String>) {
fun add(v: JsonElement?) {
when (v) {
is JsonPrimitive -> if (v.isString) out.add(v.content)
is JsonObject -> {
(v["content"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out.add(it.content) }
(v["text"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out.add(it.content) }
(v["delta"] as? JsonObject)?.let { add(it) }
}
is JsonArray -> v.forEach { add(it) }
else -> Unit
}
}
(el["data"] as? JsonObject)?.let { add(it) }
(el["content"] as? JsonElement)?.let { add(it) }
(el["text"] as? JsonElement)?.let { add(it) }
(el["message"] as? JsonObject)?.let { add(it) }
(el["choices"] as? JsonArray)?.forEach { c ->
val co = c as? JsonObject ?: return@forEach
(co["delta"] as? JsonObject)?.let { add(it) }
(co["message"] as? JsonObject)?.let { add(it) }
}
}
private fun collectProbeModel(el: JsonObject, out: MutableSet<String>) {
for (key in listOf("model_name", "modelName", "config_name", "model", "provider_model_name", "modelProviderName")) {
(el[key] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() }?.let { out.add(it) }
}
(el["data"] as? JsonObject)?.let { collectProbeModel(it, out) }
(el["message"] as? JsonObject)?.let { collectProbeModel(it, out) }
}
private fun collectProbeUsage(el: JsonObject, out: MutableList<String>) {
val usage = (el["usage"] as? JsonObject) ?: (el["token_usage"] as? JsonObject) ?: return
fun num(key: String, label: String) {
(usage[key] as? JsonPrimitive)?.let { out.add(label + "=" + it.content) }
}
num("prompt_tokens", "输入")
num("completion_tokens", "输出")
num("total_tokens", "合计")
}
private val json = Json { ignoreUnknownKeys = true }
private fun generateProxyKey(): String =
"rainy-" + UUID.randomUUID().toString().replace("-", "").take(16)
init {
refresh()
}
@ -48,6 +313,11 @@ class SettingsViewModel @Inject constructor(
}
}
data class StreamProbeResult(
val summary: String,
val raw: String,
)
data class SettingsUiState(
val loading: Boolean = false,
val credentialStatuses: List<CredentialStatusLine> = emptyList()

View File

@ -30,8 +30,8 @@ class UpdateViewModel @Inject constructor(
object Latest : Phase
data class Available(val info: UpdateRepository.UpdateInfo) : Phase
data class Downloading(val progress: Float) : Phase
data class Ready(val file: File) : Phase
object NeedInstallPermission : Phase
data class Ready(val file: File, val error: String? = null) : Phase
data class NeedInstallPermission(val file: File) : Phase
data class Error(val message: String) : Phase
}
@ -80,19 +80,33 @@ class UpdateViewModel @Inject constructor(
}
fun install(file: File) {
if (context.packageManager.canRequestPackageInstalls()) {
val canInstall = runCatching { context.packageManager.canRequestPackageInstalls() }
.getOrDefault(false)
if (!canInstall) {
_uiState.update { it.copy(phase = Phase.NeedInstallPermission(file)) }
return
}
_uiState.update { it.copy(phase = Phase.Ready(file)) }
val authority = context.packageName + ".fileprovider"
val uri = FileProvider.getUriForFile(context, authority, file)
val intent = Intent(Intent.ACTION_VIEW).apply {
val uri = try {
FileProvider.getUriForFile(context, authority, file)
} catch (e: Exception) {
_uiState.update { it.copy(phase = Phase.Ready(file, "安装文件无效:" + (e.message ?: "未知错误"))) }
return
}
val actionView = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(intent) }.onFailure { e ->
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "cannot start installer")) }
val started = runCatching { context.startActivity(actionView) }
if (started.isFailure) {
val actionInstall = Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(actionInstall) }.onFailure { e ->
_uiState.update { it.copy(phase = Phase.Ready(file, "无法启动安装器:" + (e.message ?: "未知错误"))) }
}
} else {
_uiState.update { it.copy(phase = Phase.NeedInstallPermission) }
}
}
}

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": 42,
"versionName": "1.7.22",
"apkUrl": "http://124.222.97.212:8443/attachments/03955cf3-112f-4453-bac7-91bba405e40b",
"notes": "Trae/WorkBuddy INTL 手机浏览器登录并自动回填;WorkBuddy CN 软件内登录;WorkBuddy INTL 余额走 workbuddy.ai;国际版无独立签到"
"versionCode": 24,
"versionName": "1.7.4",
"apkUrl": "http://124.222.97.212:8443/attachments/65c61faa-9b04-4ae2-b705-0c6d0ed303f7",
"notes": "探针结果改为结构化摘要,原始输出可一键复制"
}