Compare commits

..

33 Commits
v1.7.0 ... dev

Author SHA1 Message Date
618eecf676 Merge pull request '_tmp_update' (#11) from _tmp_update into dev 2026-10-10 02:39:06 +08:00
8207285b23 update 1.7.22 final2
Some checks are pending
CI / Unit Tests & Lint (pull_request) Waiting to run
CI / Debug APK (pull_request) Blocked by required conditions
CI / Release APK (debug-signed) (pull_request) Blocked by required conditions
2026-10-10 02:39:01 +08:00
82bd04597d Merge pull request '_tmp_update' (#3) from _tmp_update into dev 2026-10-10 02:00:16 +08:00
5850fc452c update 1.7.22 final
Some checks are pending
CI / Unit Tests & Lint (pull_request) Waiting to run
CI / Debug APK (pull_request) Blocked by required conditions
CI / Release APK (debug-signed) (pull_request) Blocked by required conditions
2026-10-10 02:00:15 +08:00
c6a75f8fce Merge pull request '_tmp_update' (#2) from _tmp_update into dev 2026-10-10 01:47:32 +08:00
a384309689 update 1.7.22
Some checks are pending
CI / Unit Tests & Lint (pull_request) Waiting to run
CI / Debug APK (pull_request) Blocked by required conditions
CI / Release APK (debug-signed) (pull_request) Blocked by required conditions
2026-10-10 01:47:31 +08:00
8692014340 Merge pull request 'chore(update): publish 1.7.21 manifest' (#1) from _tmp_update into dev
Some checks are pending
Release / build (push) Waiting to run
2026-10-10 00:25:54 +08:00
15c6bc24c4 chore(update): 发布 1.7.21 更新清单
Some checks are pending
CI / Unit Tests & Lint (pull_request) Waiting to run
CI / Debug APK (pull_request) Blocked by required conditions
CI / Release APK (debug-signed) (pull_request) Blocked by required conditions
2026-10-10 00:25:12 +08:00
460bed7279 chore(update): 发布 1.7.20 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 23:16:10 +08:00
ecbc86fdf2 chore(update): 发布 1.7.19 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 22:43:53 +08:00
05b5107f46 chore(update): 发布 1.7.18 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 22:32:24 +08:00
bccd7bad83 chore(update): 发布 1.7.17 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 22:14:31 +08:00
c5120c5952 chore(update): 发布 1.7.16 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 18:39:17 +08:00
2c5a3b2055 chore(update): 发布 1.7.15 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 18:02:12 +08:00
c76193e892 chore(update): 发布 1.7.14 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 17:22:54 +08:00
6edd68f80e chore(update): 发布 1.7.13 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 17:12:43 +08:00
750b9d9f90 chore(update): 发布 1.7.12 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 16:42:53 +08:00
0f4d68033e chore(update): 发布 1.7.11 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 16:33:50 +08:00
7c7a23f097 chore(update): 发布 1.7.10 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 15:36:25 +08:00
7c3809e879 chore(update): 发布 1.7.9 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 15:29:08 +08:00
7ad49e994d chore(update): 发布 1.7.8 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 12:27:18 +08:00
8f40360b9f chore(update): 发布 1.7.7 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 11:51:52 +08:00
08a3b42a82 chore(update): 发布 1.7.6 更新清单 2026-10-09 11:23:01 +08:00
aab50bbe78 chore(update): 发布 1.7.5 更新清单 2026-10-09 11:04:35 +08:00
faf7f40524 chore(update): 发布 1.7.4 更新清单 2026-10-09 10:47:27 +08:00
342788056b chore(update): 发布 1.7.3 更新清单 2026-10-09 10:29:48 +08:00
b479cb2a7a chore(update): 发布 1.7.2 更新清单 2026-10-09 10:22:57 +08:00
6e0d67afb7 chore(update): 发布 1.7.1 更新清单 2026-10-09 10:13:03 +08:00
8d68c3b280 chore(update): 发布 1.7.0 更新清单 2026-10-09 10:03:32 +08:00
71f3f8d554 chore(update): 发布 1.6.9 更新清单 2026-10-09 09:47:13 +08:00
dfd91a5355 chore(update): 发布 1.6.8 更新清单 2026-10-09 09:40:01 +08:00
149fc5f125 chore(update): 发布 1.6.7 更新清单 2026-10-09 09:31:01 +08:00
95cde916ca chore(update): 发布 1.6.6 更新清单 2026-10-09 09:18:15 +08:00
24 changed files with 17 additions and 1473 deletions

4
.gitignore vendored
View File

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

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.rainy.token" applicationId = "com.rainy.token"
minSdk = 31 minSdk = 31
targetSdk = 35 targetSdk = 35
versionCode = 20 versionCode = 14
versionName = "1.7.0" versionName = "1.6.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@ -214,12 +214,6 @@ dependencies {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.retrofit.kotlinx.serialization.converter) 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 // DataStore
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)

View File

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

View File

@ -1,87 +0,0 @@
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

@ -1,265 +0,0 @@
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

@ -1,27 +0,0 @@
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

@ -1,21 +0,0 @@
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

@ -1,33 +0,0 @@
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

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

View File

@ -1,38 +0,0 @@
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

@ -1,56 +0,0 @@
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

@ -1,15 +0,0 @@
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

@ -1,30 +0,0 @@
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

@ -1,129 +0,0 @@
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

@ -1,194 +0,0 @@
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.Headers.Companion.toHeaders
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Trae CN 上游 Chat 代理。
*
* 上游为私有协议 POST {base}/api/agent/v3/llm_utils_chat。
* CN 模型网关固定走 trae-api-cn.mchost.guru(api.trae.cn 只是账号/OAuth 主机,返回 404)。
* 请求头/body 对齐 trae2api-cn 参考实现的 SOLO 协议。
*/
@Singleton
class TraeChatProxy @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
) {
private val json = Json { ignoreUnknownKeys = true }
suspend fun forwardChat(
requestBody: String,
accountId: String? = null,
forcedRegion: ProviderRegion? = null,
): ProxyUpstreamResponse? =
withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
?: return@withContext null
if (credential !is Credential.TraeCredential) return@withContext null
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() }
?: return@withContext null
val effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull()
val base = if (effectiveRegion == ProviderRegion.INTL) {
"https://a0ai-api-sg.byteintlapi.com"
} else {
"https://trae-api-cn.mchost.guru"
}
val upstreamBody = buildUpstreamBody(requestBody, credential)
val requestId = UUID.randomUUID().toString()
val builder = Request.Builder()
.url(base + "/api/agent/v3/llm_utils_chat")
.headers(soloHeaders(jwt, credential, requestId).toHeaders())
.post(upstreamBody.toRequestBody("application/json".toMediaType()))
val response = try {
okHttpClient.newCall(builder.build()).execute()
} catch (e: java.io.IOException) {
throw e
}
val bytes = try { response.body?.bytes() ?: ByteArray(0) } catch (_: Throwable) { ByteArray(0) }
val contentType = response.header("Content-Type") ?: "application/json"
val status = response.code
response.close()
ProxyUpstreamResponse(status, contentType, bytes)
}
suspend fun openStreamingChat(
requestBody: String,
accountId: String? = null,
forcedRegion: ProviderRegion? = null,
): ProxyUpstreamStream? = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
?: return@withContext null
if (credential !is Credential.TraeCredential) return@withContext null
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() }
?: return@withContext null
val effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull()
val base = if (effectiveRegion == ProviderRegion.INTL) {
"https://a0ai-api-sg.byteintlapi.com"
} else {
"https://trae-api-cn.mchost.guru"
}
val upstreamBody = buildUpstreamBody(requestBody, credential)
val requestId = UUID.randomUUID().toString()
val builder = Request.Builder()
.url(base + "/api/agent/v3/llm_utils_chat")
.headers(soloHeaders(jwt, credential, requestId).toHeaders())
.post(upstreamBody.toRequestBody("application/json".toMediaType()))
val response = try {
okHttpClient.newCall(builder.build()).execute()
} catch (e: java.io.IOException) {
throw e
}
val input = response.body?.byteStream() ?: run {
response.close()
return@withContext null
}
ProxyUpstreamStream(
status = response.code,
contentType = response.header("Content-Type") ?: "text/event-stream",
input = input,
close = { response.close() },
)
}
private fun soloHeaders(
jwt: String,
credential: Credential.TraeCredential,
requestId: String,
): Map<String, String> = linkedMapOf(
"Content-Type" to "application/json",
"Accept" to "text/event-stream",
"Connection" to "keep-alive",
"Authorization" to "Cloud-IDE-JWT $jwt",
"X-Cloudide-Token" to jwt,
"x-ide-token" to jwt,
"x-uid" to (credential.userId ?: ""),
"x-app-id" to "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8",
"x-device-id" to (credential.deviceId ?: credential.checkinDeviceId ?: ""),
"x-machine-id" to (credential.deviceId ?: credential.checkinDeviceId ?: ""),
"x-request-id" to requestId,
"x-ide-version" to "0.1.52",
"x-ide-version-code" to "20260811",
"x-ide-version-type" to "stable",
"x-app-version" to "default",
"x-app-version-code" to "20260811",
"x-version-code" to "20260811",
"x-device-cpu" to "AMD",
"x-device-brand" to "83DG",
"x-device-type" to "windows",
"x-device-platform" to "windows",
"x-os-version" to "Windows 11 Pro",
"x-system-type" to "Windows",
"package-type" to "stable_cn",
"x-lscbd-aid" to "787976",
"x-lscbd-platform" to "windows",
"x-ss-dp" to "787976",
"x-plugin-channel" to "icube-ai",
"app-version" to "0.1.52",
"x-bridge-transport" to "aha",
"x-ahanet-timeout" to "86400",
"x-lgw-req-sdk-type" to "3",
"x-net-sdk-domain-dispatch" to "1",
"x-ttnet-bypass-decompression" to "1",
"x-ttnet-bypass-cookie" to "0",
"request-traffic-type" to "prod",
"User-Agent" to "Trae/0.1.52",
)
private fun 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

@ -1,127 +0,0 @@
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,13 +20,6 @@ import com.rainy.token.data.repository.OllamaRepository
import com.rainy.token.data.repository.Sub2ApiRepository import com.rainy.token.data.repository.Sub2ApiRepository
import com.rainy.token.data.repository.TraeRepository import com.rainy.token.data.repository.TraeRepository
import com.rainy.token.data.repository.UpdateRepository 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 com.rainy.token.data.repository.WorkBuddyRepository
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -230,55 +223,6 @@ object NetworkModule {
balanceCache: BalanceCache balanceCache: BalanceCache
): Sub2ApiRepository = Sub2ApiRepository(okHttpClient, credentialRepository, 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) */ /** 余额缓存 DataStore(计划 7.1) */
@Provides @Provides
@Singleton @Singleton

View File

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

View File

@ -28,7 +28,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@ -90,10 +89,6 @@ fun SettingsScreen(
} }
val uiState by viewModel.uiState.collectAsStateWithLifecycle() 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) } var showLanguageDialog by remember { mutableStateOf(false) }
Scaffold( Scaffold(
@ -164,17 +159,6 @@ fun SettingsScreen(
item { item {
LanguageCard(onClick = { showLanguageDialog = true }) LanguageCard(onClick = { showLanguageDialog = true })
} }
item {
ProxyGatewayCard(
running = proxyRunning,
apiKey = proxyApiKey,
error = proxyError,
debug = proxyDebug,
onToggle = viewModel::setProxyEnabled,
onTestTrae = viewModel::testTraeStream,
onTestWorkBuddy = viewModel::testWorkBuddyStream
)
}
item { item {
TipsCard(onClick = { onOpenTips() }) TipsCard(onClick = { onOpenTips() })
} }
@ -197,82 +181,6 @@ 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 @Composable
private fun LanguageCard(onClick: () -> Unit) { private fun LanguageCard(onClick: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current

View File

@ -2,23 +2,14 @@ package com.rainy.token.ui.settings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.data.repository.CredentialRepository
import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.service.ServiceType
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -28,106 +19,12 @@ import javax.inject.Inject
*/ */
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor( 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() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState()) private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow() 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 { init {
refresh() refresh()
} }

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{ {
"versionCode": 19, "versionCode": 42,
"versionName": "1.6.9", "versionName": "1.7.22",
"apkUrl": "http://124.222.97.212:8443/attachments/57ab917c-7776-403c-be16-a69b64bbdbd1", "apkUrl": "http://124.222.97.212:8443/attachments/03955cf3-112f-4453-bac7-91bba405e40b",
"notes": "修复更新安装闪退:声明权限 + canRequestPackageInstalls 容错" "notes": "Trae/WorkBuddy INTL 手机浏览器登录并自动回填;WorkBuddy CN 软件内登录;WorkBuddy INTL 余额走 workbuddy.ai;国际版无独立签到"
} }