Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa796d57b5 | |||
| 8e692a0c6e | |||
| 4597fb0f8e | |||
| 40af5db889 | |||
| 5adf053b6f | |||
| e2bf924246 | |||
| d0b79cf1bd | |||
| 15408a6640 | |||
| 8da1f2b377 | |||
| a4d854271f | |||
| 67e1f060c5 | |||
| f5162bbf74 | |||
| de95013164 | |||
| 072f7efa55 | |||
| 1609e210bd | |||
| a1605e6993 | |||
| 5aa2d8f116 | |||
| 11b9065f82 | |||
| 85c6cf4283 | |||
| 9d3c004617 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -23,3 +23,7 @@ release.jks
|
|||||||
tmp_cc/
|
tmp_cc/
|
||||||
goal.md
|
goal.md
|
||||||
/reference/
|
/reference/
|
||||||
|
|
||||||
|
# 调试临时文件(可能包含真实接口响应/账号数据,禁止提交)
|
||||||
|
_tmp_*
|
||||||
|
build_compile.log
|
||||||
|
|||||||
@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.rainy.token"
|
applicationId = "com.rainy.token"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 14
|
versionCode = 20
|
||||||
versionName = "1.6.4"
|
versionName = "1.7.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@ -214,6 +214,12 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
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" />
|
||||||
|
|
||||||
|
|||||||
87
app/src/main/java/com/rainy/token/data/proxy/AccountPool.kt
Normal file
87
app/src/main/java/com/rainy/token/data/proxy/AccountPool.kt
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
package com.rainy.token.data.proxy
|
||||||
|
|
||||||
|
import com.rainy.token.data.repository.CredentialRepository
|
||||||
|
import com.rainy.token.domain.model.Credential
|
||||||
|
import com.rainy.token.domain.service.ServiceType
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账号池:从 CredentialRepository 实时取号,负责轮询与会话粘性。
|
||||||
|
*
|
||||||
|
* - 多账号服务(Trae / WorkBuddy / Sub2API)按 ProviderKind 分组;
|
||||||
|
* - 会话粘性:同一 conversation 头持续命中同一账号,避免上下文错乱;
|
||||||
|
* - 区域匹配:优先选 credential.region 与路由 region 一致的账号,找不到时退回全部账号。
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AccountPool @Inject constructor(
|
||||||
|
private val credentialRepository: CredentialRepository,
|
||||||
|
) {
|
||||||
|
|
||||||
|
data class PooledAccount(
|
||||||
|
val kind: ProviderKind,
|
||||||
|
val region: ProviderRegion? = null,
|
||||||
|
val accountId: String? = null,
|
||||||
|
val label: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private val sessionSticky = mutableMapOf<String, String>()
|
||||||
|
private val cursor = mutableMapOf<ProviderKind, Int>()
|
||||||
|
|
||||||
|
suspend fun next(
|
||||||
|
kind: ProviderKind,
|
||||||
|
region: ProviderRegion? = null,
|
||||||
|
sessionKey: String? = null,
|
||||||
|
): PooledAccount? = mutex.withLock {
|
||||||
|
val service = serviceFor(kind) ?: return null
|
||||||
|
val accounts = credentialRepository.accountsFor(service)
|
||||||
|
val allIds = accounts.map { it.id }
|
||||||
|
val matchedIds = if (region == null) {
|
||||||
|
allIds
|
||||||
|
} else {
|
||||||
|
allIds.filter { accountId -> regionMatches(service, accountId, region) }
|
||||||
|
.ifEmpty { allIds }
|
||||||
|
}
|
||||||
|
if (matchedIds.isEmpty()) return null
|
||||||
|
|
||||||
|
val accountId = if (sessionKey != null) {
|
||||||
|
sessionSticky[sessionKey]
|
||||||
|
?.takeIf { it in matchedIds }
|
||||||
|
?: pickRoundRobin(kind, matchedIds).also { sessionSticky[sessionKey] = it }
|
||||||
|
} else {
|
||||||
|
pickRoundRobin(kind, matchedIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
PooledAccount(
|
||||||
|
kind = kind,
|
||||||
|
region = region,
|
||||||
|
accountId = accountId,
|
||||||
|
label = accounts.firstOrNull { it.id == accountId }?.label,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun regionMatches(service: ServiceType, accountId: String, region: ProviderRegion): Boolean {
|
||||||
|
val credential = credentialRepository.get(service, accountId) ?: return false
|
||||||
|
return when (credential) {
|
||||||
|
is Credential.TraeCredential -> credential.region.equals(region.name, ignoreCase = true)
|
||||||
|
is Credential.WorkBuddyCredential -> credential.region.equals(region.name, ignoreCase = true)
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pickRoundRobin(kind: ProviderKind, ids: List<String>): String {
|
||||||
|
val idx = cursor[kind] ?: 0
|
||||||
|
cursor[kind] = (idx + 1) % ids.size
|
||||||
|
return ids[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serviceFor(kind: ProviderKind): ServiceType? = when (kind) {
|
||||||
|
ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL -> ServiceType.WORKBUDDY
|
||||||
|
ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL -> ServiceType.TRAE
|
||||||
|
ProviderKind.OPENAI_COMPATIBLE -> ServiceType.SUB2API
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
}
|
||||||
33
app/src/main/java/com/rainy/token/data/proxy/ProviderKind.kt
Normal file
33
app/src/main/java/com/rainy/token/data/proxy/ProviderKind.kt
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package com.rainy.token.data.proxy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网关支持的 Provider 清单。
|
||||||
|
*
|
||||||
|
* 命名规则:按「产品 + 区域」区分(同一个产品在国内版/国际版使用不同域名与鉴权体系)。
|
||||||
|
* supportsCheckin 表示该平台官方是否有每日签到类活动;是否已实测由各自 Repository 决定。
|
||||||
|
*/
|
||||||
|
enum class ProviderKind(
|
||||||
|
val displayName: String,
|
||||||
|
val regions: Set<ProviderRegion> = ProviderRegion.entries.toSet(),
|
||||||
|
val supportsChat: Boolean = true,
|
||||||
|
val supportsCheckin: Boolean = false,
|
||||||
|
) {
|
||||||
|
WORKBUDDY_CN("WorkBuddy 国内版", setOf(ProviderRegion.CN), supportsCheckin = true),
|
||||||
|
WORKBUDDY_INTL("WorkBuddy 国际版", setOf(ProviderRegion.INTL)),
|
||||||
|
TRAE_CN("Trae CN", setOf(ProviderRegion.CN), supportsCheckin = true),
|
||||||
|
TRAE_INTL("Trae INTL", setOf(ProviderRegion.INTL)),
|
||||||
|
TRAE_WORK("TraeWork 桌面版", setOf(ProviderRegion.CN)),
|
||||||
|
QODER_CN("Qoder CN", setOf(ProviderRegion.CN), supportsCheckin = true),
|
||||||
|
RACCOON_CN("小浣熊(商汤)", setOf(ProviderRegion.CN), supportsCheckin = true),
|
||||||
|
CATPAW_CN("CatPaw(美团)", setOf(ProviderRegion.CN)),
|
||||||
|
AUTOCLAW_CN("AutoClaw 国内版", setOf(ProviderRegion.CN), supportsCheckin = true),
|
||||||
|
AUTOCLAW_INTL("AutoClaw 国际版", setOf(ProviderRegion.INTL)),
|
||||||
|
CLINE("Cline", setOf(ProviderRegion.INTL)),
|
||||||
|
ACCIO_CN("Accio 国内版", setOf(ProviderRegion.CN)),
|
||||||
|
ACCIO_INTL("Accio 国际版", setOf(ProviderRegion.INTL)),
|
||||||
|
ZCODE_CN("ZCode 国内版", setOf(ProviderRegion.CN)),
|
||||||
|
ZCODE_INTL("ZCode 国际版", setOf(ProviderRegion.INTL)),
|
||||||
|
CODEARTS_CN("CodeArts(华为云)", setOf(ProviderRegion.CN)),
|
||||||
|
LOOMY_CN("Loomy(讯飞)", setOf(ProviderRegion.CN), supportsCheckin = true),
|
||||||
|
OPENAI_COMPATIBLE("OpenAI 兼容自定义", ProviderRegion.entries.toSet()),
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package com.rainy.token.data.proxy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 区域:CN=国内版(中国大陆域名),INTL=国际版(海外域名)。
|
||||||
|
*/
|
||||||
|
enum class ProviderRegion { CN, INTL }
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/src/main/java/com/rainy/token/data/proxy/ProxyModels.kt
Normal file
56
app/src/main/java/com/rainy/token/data/proxy/ProxyModels.kt
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package com.rainy.token.data.proxy
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网关对外的最小 OpenAI 兼容模型。
|
||||||
|
* 后续按协议(Chat Completions / Responses / Anthropic)扩展。
|
||||||
|
*/
|
||||||
|
data class ProxyChatRequest(
|
||||||
|
val model: String,
|
||||||
|
val messages: List<ProxyChatMessage> = emptyList(),
|
||||||
|
val stream: Boolean = false,
|
||||||
|
val tools: List<JsonElement>? = null,
|
||||||
|
val toolChoice: JsonElement? = null,
|
||||||
|
val reasoningEffort: String? = null,
|
||||||
|
val maxTokens: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ProxyChatMessage(
|
||||||
|
val role: String,
|
||||||
|
val content: String? = null,
|
||||||
|
val toolCalls: List<JsonElement>? = null,
|
||||||
|
val toolCallId: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ProxyModel(
|
||||||
|
val id: String,
|
||||||
|
val provider: ProviderKind,
|
||||||
|
val region: ProviderRegion? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 流式/非流式统一输出块,由 StreamNormalizer 归一。 */
|
||||||
|
sealed interface ProxyChatChunk {
|
||||||
|
data class Done(
|
||||||
|
val text: String? = null,
|
||||||
|
val usage: ProxyUsage? = null,
|
||||||
|
) : ProxyChatChunk
|
||||||
|
|
||||||
|
data class Delta(
|
||||||
|
val text: String? = null,
|
||||||
|
val reasoning: String? = null,
|
||||||
|
val toolCalls: List<JsonElement>? = null,
|
||||||
|
) : ProxyChatChunk
|
||||||
|
|
||||||
|
data class Error(
|
||||||
|
val message: String,
|
||||||
|
val code: Int? = null,
|
||||||
|
) : ProxyChatChunk
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ProxyUsage(
|
||||||
|
val promptTokens: Long? = null,
|
||||||
|
val completionTokens: Long? = null,
|
||||||
|
val totalTokens: Long? = null,
|
||||||
|
)
|
||||||
@ -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,
|
||||||
|
)
|
||||||
30
app/src/main/java/com/rainy/token/data/proxy/RegionRouter.kt
Normal file
30
app/src/main/java/com/rainy/token/data/proxy/RegionRouter.kt
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/src/main/java/com/rainy/token/data/proxy/Sub2ApiChatProxy.kt
Normal file
129
app/src/main/java/com/rainy/token/data/proxy/Sub2ApiChatProxy.kt
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
package com.rainy.token.data.proxy
|
||||||
|
|
||||||
|
import com.rainy.token.data.repository.CredentialRepository
|
||||||
|
import com.rainy.token.domain.model.Credential
|
||||||
|
import com.rainy.token.domain.service.ServiceType
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub2API Chat 透传代理。
|
||||||
|
*
|
||||||
|
* Sub2API 实例本身暴露 OpenAI 兼容接口,因此这里不做协议转换:
|
||||||
|
* 取出用户保存的 Sub2ApiCredential(优先 sk- API Key,其次面板 authToken),
|
||||||
|
* 把客户端请求原样转发到 {base}/v1/chat/completions,
|
||||||
|
* 并把上游响应(含 SSE 流式内容)原样返回给本地客户端。
|
||||||
|
*
|
||||||
|
* M1a 阶段:先做整包透传(流式也先缓冲),后续由 StreamNormalizer 升级为逐块转发。
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class Sub2ApiChatProxy @Inject constructor(
|
||||||
|
private val okHttpClient: OkHttpClient,
|
||||||
|
private val credentialRepository: CredentialRepository,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** 转发 POST /v1/chat/completions。 */
|
||||||
|
suspend fun forwardChat(requestBody: String, accountId: String? = null): ProxyUpstreamResponse? {
|
||||||
|
val base = resolveBase(accountId) ?: return null
|
||||||
|
return forward(base = base, path = "/v1/chat/completions", requestBody = requestBody, accountId = accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 转发流式 POST /v1/chat/completions(上游 SSE 原样转发)。 */
|
||||||
|
suspend fun openStreamingChat(
|
||||||
|
requestBody: String,
|
||||||
|
accountId: String? = null,
|
||||||
|
): ProxyUpstreamStream? = withContext(Dispatchers.IO) {
|
||||||
|
val credential = credentialRepository.get(ServiceType.SUB2API, accountId)
|
||||||
|
?: return@withContext null
|
||||||
|
if (credential !is Credential.Sub2ApiCredential) return@withContext null
|
||||||
|
val auth = resolveAuth(credential) ?: return@withContext null
|
||||||
|
val base = normalizeBase(credential.baseUrl) ?: return@withContext null
|
||||||
|
|
||||||
|
val builder = Request.Builder()
|
||||||
|
.url(base + "/v1/chat/completions")
|
||||||
|
.addHeader("Authorization", auth)
|
||||||
|
.addHeader("Content-Type", "application/json")
|
||||||
|
.post(requestBody.toRequestBody("application/json".toMediaType()))
|
||||||
|
|
||||||
|
val response = okHttpClient.newCall(builder.build()).execute()
|
||||||
|
val input = response.body?.byteStream() ?: run {
|
||||||
|
response.close()
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
ProxyUpstreamStream(
|
||||||
|
status = response.code,
|
||||||
|
contentType = response.header("Content-Type") ?: "text/event-stream",
|
||||||
|
input = input,
|
||||||
|
close = { response.close() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 转发 GET /v1/models。 */
|
||||||
|
suspend fun forwardModels(accountId: String? = null): ProxyUpstreamResponse? {
|
||||||
|
val base = resolveBase(accountId) ?: return null
|
||||||
|
return forward(base = base, path = "/v1/models", requestBody = null, accountId = accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveBase(accountId: String?): String? {
|
||||||
|
val credential = credentialRepository.get(ServiceType.SUB2API, accountId) ?: return null
|
||||||
|
if (credential !is Credential.Sub2ApiCredential) return null
|
||||||
|
if (resolveAuth(credential) == null) return null
|
||||||
|
return normalizeBase(credential.baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun forward(
|
||||||
|
base: String,
|
||||||
|
path: String,
|
||||||
|
requestBody: String?,
|
||||||
|
accountId: String?,
|
||||||
|
): ProxyUpstreamResponse? = withContext(Dispatchers.IO) {
|
||||||
|
val credential = credentialRepository.get(ServiceType.SUB2API, accountId)
|
||||||
|
?: return@withContext null
|
||||||
|
if (credential !is Credential.Sub2ApiCredential) return@withContext null
|
||||||
|
val auth = resolveAuth(credential) ?: return@withContext null
|
||||||
|
|
||||||
|
val builder = Request.Builder()
|
||||||
|
.url(base + path)
|
||||||
|
.addHeader("Authorization", auth)
|
||||||
|
val body = requestBody?.takeIf { it.isNotBlank() }
|
||||||
|
if (body != null) {
|
||||||
|
builder
|
||||||
|
.addHeader("Content-Type", "application/json")
|
||||||
|
.post(body.toRequestBody("application/json".toMediaType()))
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = okHttpClient.newCall(builder.build()).execute()
|
||||||
|
val bytes = response.body?.bytes() ?: ByteArray(0)
|
||||||
|
val contentType = response.header("Content-Type") ?: "application/json"
|
||||||
|
val status = response.code
|
||||||
|
response.close()
|
||||||
|
ProxyUpstreamResponse(status, contentType, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveAuth(credential: Credential.Sub2ApiCredential): String? {
|
||||||
|
credential.apiKey?.trim()?.takeIf { it.isNotBlank() }?.let { return "Bearer $it" }
|
||||||
|
credential.authToken?.trim()?.takeIf { it.isNotBlank() }?.let { return "Bearer $it" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeBase(raw: String): String? {
|
||||||
|
var s = raw.trim()
|
||||||
|
while (s.endsWith("/")) s = s.dropLast(1)
|
||||||
|
return s.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上游 HTTP 响应(透传用)。
|
||||||
|
*/
|
||||||
|
data class ProxyUpstreamResponse(
|
||||||
|
val status: Int,
|
||||||
|
val contentType: String,
|
||||||
|
val body: ByteArray,
|
||||||
|
)
|
||||||
194
app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt
Normal file
194
app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -20,6 +20,13 @@ 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
|
||||||
@ -223,6 +230,55 @@ 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
|
||||||
|
|||||||
@ -106,6 +106,8 @@ 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
|
||||||
|
|||||||
@ -28,6 +28,7 @@ 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
|
||||||
@ -89,6 +90,10 @@ 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(
|
||||||
@ -159,6 +164,17 @@ 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() })
|
||||||
}
|
}
|
||||||
@ -181,6 +197,82 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProxyGatewayCard(
|
||||||
|
running: Boolean,
|
||||||
|
apiKey: String,
|
||||||
|
error: String?,
|
||||||
|
debug: String?,
|
||||||
|
onToggle: (Boolean) -> Unit,
|
||||||
|
onTestTrae: () -> Unit,
|
||||||
|
onTestWorkBuddy: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "🛰️",
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "代理网关",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (running) "运行中 · http://127.0.0.1:8787/v1" else "已停止",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = InkMuted,
|
||||||
|
modifier = Modifier.padding(top = 2.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "API Key:$apiKey",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = InkMuted,
|
||||||
|
modifier = Modifier.padding(top = 2.dp)
|
||||||
|
)
|
||||||
|
if (error != null) {
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(top = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(modifier = Modifier.padding(top = 4.dp)) {
|
||||||
|
TextButton(onClick = onTestTrae) {
|
||||||
|
Text("测试 Trae 流")
|
||||||
|
}
|
||||||
|
TextButton(onClick = onTestWorkBuddy) {
|
||||||
|
Text("测试 WorkBuddy 流")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (debug != null) {
|
||||||
|
Text(
|
||||||
|
text = debug,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = InkMuted,
|
||||||
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = running,
|
||||||
|
onCheckedChange = onToggle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LanguageCard(onClick: () -> Unit) {
|
private fun LanguageCard(onClick: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|||||||
@ -2,14 +2,23 @@ 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
|
||||||
|
|
||||||
@ -19,12 +28,106 @@ 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -80,19 +80,33 @@ class UpdateViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun install(file: File) {
|
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) }
|
||||||
|
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 = FileProvider.getUriForFile(context, authority, file)
|
val uri = try {
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
FileProvider.getUriForFile(context, authority, file)
|
||||||
|
} 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)
|
||||||
}
|
}
|
||||||
runCatching { context.startActivity(intent) }.onFailure { e ->
|
val started = runCatching { context.startActivity(actionView) }
|
||||||
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "cannot start installer")) }
|
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.Error(e.message ?: "无法启动安装器")) }
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
_uiState.update { it.copy(phase = Phase.NeedInstallPermission) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
143
docs/PROXY-ROADMAP.md
Normal file
143
docs/PROXY-ROADMAP.md
Normal 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
|
||||||
@ -15,6 +15,9 @@ 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"
|
||||||
@ -67,6 +70,10 @@ 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" }
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"versionCode": 42,
|
"versionCode": 19,
|
||||||
"versionName": "1.7.22",
|
"versionName": "1.6.9",
|
||||||
"apkUrl": "http://124.222.97.212:8443/attachments/03955cf3-112f-4453-bac7-91bba405e40b",
|
"apkUrl": "http://124.222.97.212:8443/attachments/57ab917c-7776-403c-be16-a69b64bbdbd1",
|
||||||
"notes": "Trae/WorkBuddy INTL 手机浏览器登录并自动回填;WorkBuddy CN 软件内登录;WorkBuddy INTL 余额走 workbuddy.ai;国际版无独立签到"
|
"notes": "修复更新安装闪退:声明权限 + canRequestPackageInstalls 容错"
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user