diff --git a/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt index c22a7cc..e72c758 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt @@ -12,12 +12,17 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull /** * WorkBuddy / CodeBuddy 上游 Chat 代理(国内版)。 * * 上游协议为 OpenAI 兼容的 POST {base}/v2/chat/completions,Bearer accessToken。 - * 这里保留客户端原始 body(M1b 最小实现),并补齐桌面端请求头;完整归一化在后续迭代。 + * 这里补齐桌面端请求头与上游 body 归一化,国际版先走 console 路由再退回 v2。 */ @Singleton class WorkBuddyChatProxy @Inject constructor( @@ -45,32 +50,28 @@ class WorkBuddyChatProxy @Inject constructor( } 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 upstreamBody = prepareChatBody(requestBody, effectiveRegion == ProviderRegion.INTL) + val intl = effectiveRegion == ProviderRegion.INTL + val paths = if (intl) listOf("/console/chat/completions", "/v2/chat/completions") else listOf("/v2/chat/completions") + for (path in paths) { + val request = buildChatRequest(base, path, upstreamBody, accessToken, intl) + val response = try { + okHttpClient.newCall(request).execute() + } catch (e: java.io.IOException) { + throw e + } + val status = response.code + // 国际站 console 路由返回 404/405 时再退回共享 v2 路径 + if (status == 404 || status == 405) { + response.close() + continue + } + val bytes = try { response.body?.bytes() ?: ByteArray(0) } catch (_: Throwable) { ByteArray(0) } + val contentType = response.header("Content-Type") ?: "application/json" + response.close() + return@withContext ProxyUpstreamResponse(status, contentType, bytes) } - 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) + return@withContext null } suspend fun openStreamingChat( requestBody: String, @@ -91,37 +92,161 @@ class WorkBuddyChatProxy @Inject constructor( } else { "https://copilot.tencent.com" } + val upstreamBody = prepareChatBody(requestBody, effectiveRegion == ProviderRegion.INTL) + val intl = effectiveRegion == ProviderRegion.INTL + val paths = if (intl) listOf("/console/chat/completions", "/v2/chat/completions") else listOf("/v2/chat/completions") + for (path in paths) { + val request = buildChatRequest(base, path, upstreamBody, accessToken, intl) + val response = try { + okHttpClient.newCall(request).execute() + } catch (e: java.io.IOException) { + throw e + } + if (response.code == 404 || response.code == 405) { + response.close() + continue + } + val input = response.body?.byteStream() ?: run { + response.close() + return@withContext null + } + return@withContext ProxyUpstreamStream( + status = response.code, + contentType = response.header("Content-Type") ?: "text/event-stream", + input = input, + close = { response.close() }, + ) + } + return@withContext null + } + + private fun buildChatRequest( + base: String, + path: String, + upstreamBody: String, + accessToken: String, + intl: Boolean, + ): Request { + val origin = if (intl) "https://www.workbuddy.ai" else "https://www.codebuddy.cn" val requestId = UUID.randomUUID().toString() val builder = Request.Builder() - .url(base + "/v2/chat/completions") + .url(base + path) .addHeader("Content-Type", "application/json") - .addHeader("Accept", "text/event-stream") + .addHeader("Accept", "application/json, 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("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2") + .addHeader("X-Requested-With", "XMLHttpRequest") + .addHeader("Origin", origin) + .addHeader("Referer", origin + "/") + .addHeader("X-CodeBuddy-Request", "1") + .addHeader("Accept-Language", if (intl) "en-US" else "zh-CN") + .addHeader("X-No-User-Id", "1") + .addHeader("X-Agent-Purpose", "conversation") + .addHeader("X-IDE-Name", "WorkBuddy") + .addHeader("X-IDE-Type", "WorkBuddy") + .addHeader("X-IDE-Version", "2.63.2") + .addHeader("X-Product", "WorkBuddy") .addHeader("X-Request-ID", requestId) .addHeader("X-Conv-Request-ID", requestId) - .post(requestBody.toRequestBody("application/json".toMediaType())) + if (intl) { + // 国际站强制声明个人客户端:不携带 enterprise,域固定为 www.workbuddy.ai + builder.addHeader("X-No-Enterprise-Id", "1") + builder.addHeader("X-Domain", "www.workbuddy.ai") + } else { + builder.addHeader("X-No-Enterprise-Id", "1") + builder.addHeader("X-No-Department-Info", "1") + } + return builder.post(upstreamBody.toRequestBody("application/json".toMediaType())).build() + } - val response = try { - okHttpClient.newCall(builder.build()).execute() - } catch (e: java.io.IOException) { - throw e + /** + * 对齐 dsh-workbuddy-connect 的上游归一化: + * - 上游拒绝非流式,强制 stream=true; + * - developer -> system(国内站直接拒绝 developer role); + * - 国际站首条必须 system,缺省则前置空 system(避免 11128); + * - max_completion_tokens 翻译成 max_tokens; + * - tool_choice 只接受字符串,对象形式拍平成 auto/工具名/none。 + */ + private fun prepareChatBody(body: String, intl: Boolean): String { + val root = runCatching { Json.parseToJsonElement(body) as? JsonObject }.getOrNull() ?: return body + if (root.isEmpty()) return body + val out = LinkedHashMap(root) + out["stream"] = JsonPrimitive(true) + if (!out.containsKey("stream_options")) { + out["stream_options"] = JsonObject( + mapOf("include_usage" to JsonPrimitive(true)) + ) } - val input = response.body?.byteStream() ?: run { - response.close() - return@withContext null + val maxCompletion = (root["max_completion_tokens"] as? JsonPrimitive)?.contentOrNull + ?.toLongOrNull() + if (maxCompletion != null && !out.containsKey("max_tokens")) { + out["max_tokens"] = JsonPrimitive(maxCompletion) } - ProxyUpstreamStream( - status = response.code, - contentType = response.header("Content-Type") ?: "text/event-stream", - input = input, - close = { response.close() }, - ) + val messages = root["messages"] as? JsonArray + if (messages != null) { + var list = messages.map { msg -> + val obj = msg as? JsonObject ?: return@map msg + if ((obj["role"] as? JsonPrimitive)?.contentOrNull == "developer") { + JsonObject(obj.entries.associate { (key, value) -> + key to (if (key == "role") JsonPrimitive("system") else value) + }) + } else { + obj + } + }.toMutableList() + if (intl) { + val firstRole = (list.firstOrNull() as? JsonObject)?.get("role") as? JsonPrimitive + if (firstRole?.contentOrNull != "system") { + val fallback = JsonObject( + mapOf( + "role" to JsonPrimitive("system"), + "content" to JsonPrimitive("You are a helpful assistant."), + ) + ) + list.add(0, fallback) + } + } + out["messages"] = JsonArray(list) + } + normalizeToolChoice(out) + return JsonObject(out).toString() + } + + private fun normalizeToolChoice(out: MutableMap) { + val choice = out["tool_choice"] ?: return + val suppress = { + out.remove("tools") + out.remove("functions") + } + if (choice is JsonPrimitive) { + val s = choice.contentOrNull?.trim()?.lowercase() + if (s == "none") { + out.remove("tool_choice") + suppress() + } + return + } + val obj = choice as? JsonObject + if (obj != null) { + val type = (obj["type"] as? JsonPrimitive)?.contentOrNull?.trim()?.lowercase() + when (type) { + "none" -> { + out.remove("tool_choice") + suppress() + } + "auto", "required" -> out["tool_choice"] = JsonPrimitive(type) + "function" -> { + val fn = obj["function"] as? JsonObject + val name = ((fn?.get("name") as? JsonPrimitive)?.contentOrNull + ?: (obj["name"] as? JsonPrimitive)?.contentOrNull + ?: "").trim() + out["tool_choice"] = JsonPrimitive(if (name.isNotEmpty()) name else "auto") + } + else -> out.remove("tool_choice") + } + return + } + out.remove("tool_choice") } } diff --git a/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt index f89e1f1..d3f4948 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt @@ -37,12 +37,12 @@ class WorkBuddyModelProvider @Inject constructor( context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE) private val _modelsByRegion = MutableStateFlow( - ProviderRegion.entries.associateWith { defaultModels() } + ProviderRegion.entries.associateWith { defaultModels(it) } ) /** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */ fun modelsFor(region: ProviderRegion): List = - _modelsByRegion.value[region] ?: defaultModels() + _modelsByRegion.value[region] ?: defaultModels(region) private val json = Json { ignoreUnknownKeys = true } @@ -70,17 +70,23 @@ class WorkBuddyModelProvider @Inject constructor( ?: return@withContext false val intl = region == ProviderRegion.INTL val base = if (intl) "https://www.workbuddy.ai" else "https://copilot.tencent.com" - val url = base + "/v2/enterprises/personal/models" - + // 国内站官方 CLI 吃 /console/enterprises/personal/models;国际站产品目录在 /v3/config, + // 且必须用 App 形态 UA,否则网关拒发(实测 code 12403)。 + val url = base + if (intl) "/v3/config" else "/console/enterprises/personal/models" + val origin = if (intl) "https://www.workbuddy.ai" else "https://www.codebuddy.cn" val headers = linkedMapOf( "Authorization" to "Bearer $accessToken", "Content-Type" to "application/json", "Accept" to "application/json", + "X-Requested-With" to "XMLHttpRequest", + "Origin" to origin, + "Referer" to "$origin/", + "X-CodeBuddy-Request" to "1", + "Accept-Language" to (if (intl) "en-US" else "zh-CN"), "X-Client-ID" to "codebuddy-cli", - "X-Client-Version" to "2.143.1", - "X-Product" to "SaaS", - "User-Agent" to "CLI/2.143.1 CodeBuddy/2.143.1", - "Origin" to (if (intl) "https://www.workbuddy.ai" else "https://www.codebuddy.cn"), + "X-Client-Version" to "2.63.2", + "X-Product" to (if (intl) "SaaS" else "WorkBuddy"), + "User-Agent" to (if (intl) "WorkBuddyAI/5.5.2" else "CLI/2.63.2 CodeBuddy/2.63.2"), ) val request = Request.Builder().url(url).headers(headers.toHeaders()).get().build() val parsed = try { @@ -112,13 +118,24 @@ class WorkBuddyModelProvider @Inject constructor( private fun parseModelList(body: String): List? { val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull() ?: return null - val data = root["data"] as? JsonObject ?: return null - val models = data["models"] as? JsonArray ?: return null + // 国内站永远是 {code,msg,data} 包络;国际站 /v3/config 有时裸返回产品文档。 + val data = (root["data"] as? JsonObject) ?: root + val rawModels = data["models"] as? JsonArray ?: return null + val agents = data["agents"] as? JsonArray + // 只暴露 cli agent 可用的模型(国际站 App 目录里 agents[].name=="cli" 限定模型名单) + val cliIds = agents?.mapNotNull { it as? JsonObject }?.firstOrNull { agent -> + (agent["name"] as? JsonPrimitive)?.contentOrNull == "cli" + }?.get("models") as? JsonArray + // 有 agents 文档但找不到 cli 列表时,不猜模型,避免把 App 只读模型塞给 CLI 反代 + if (agents != null && cliIds == null) return null + val allowed = cliIds?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }?.toSet() val seen = LinkedHashMap() - for (rawEl in models) { + for (rawEl in rawModels) { val raw = rawEl as? JsonObject ?: continue val id = (raw["id"] as? JsonPrimitive)?.contentOrNull?.trim() ?.takeIf { it.isNotEmpty() } ?: continue + if (allowed != null && id !in allowed) continue + if (raw["disabled"] as? JsonPrimitive == JsonPrimitive(true)) continue val label = (raw["name"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } ?: id seen[id] = TraeModelProvider.TraeModel(id, label) } @@ -130,12 +147,31 @@ class WorkBuddyModelProvider @Inject constructor( private const val KEY_MODEL_INTL = "wb_default_model_intl" private const val DEFAULT_MODEL = "hy4-preview" - /** 内置兜底(参考 workbuddy-gateway 模型目录,在线列表成功后会被替换)。 */ - fun defaultModels(): List = listOf( + /** 内置兜底(在线列表成功后会被替换)。 */ + fun defaultModels(region: ProviderRegion): List = + if (region == ProviderRegion.INTL) intlDefaultModels() else cnDefaultModels() + + /** 国内站 CLI 模型兜底(参考 workbuddy-gateway 模型目录)。 */ + private fun cnDefaultModels(): List = listOf( TraeModelProvider.TraeModel("hy4-preview", "hy4-preview"), TraeModelProvider.TraeModel("hy3", "hy3"), TraeModelProvider.TraeModel("hy3-preview", "hy3-preview"), TraeModelProvider.TraeModel("deepseek-v4.1-flash", "deepseek-v4.1-flash"), ) + + /** 国际站 App 模型兜底(参考 cpa-multi-plugins 实测目录,避免刷新前误用国内模型名)。 */ + private fun intlDefaultModels(): List = listOf( + TraeModelProvider.TraeModel("default-model", "Auto"), + TraeModelProvider.TraeModel("fast-model", "Fast"), + TraeModelProvider.TraeModel("balanced-model", "Balanced"), + TraeModelProvider.TraeModel("primary-model", "Primary"), + TraeModelProvider.TraeModel("deep-model", "Deep"), + TraeModelProvider.TraeModel("hy4-preview-f", "Hy4 preview"), + TraeModelProvider.TraeModel("hy3", "Hy3"), + TraeModelProvider.TraeModel("deepseek-v4.1-flash", "Deepseek-V4.1-Flash"), + TraeModelProvider.TraeModel("gpt-6-astra", "GPT-6-Astra"), + TraeModelProvider.TraeModel("gpt-5.6-luna", "GPT-5.6-Luna"), + TraeModelProvider.TraeModel("gemini-3.5-flash", "Gemini-3.5-Flash"), + ) } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt b/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt index 515d0ae..ab39c65 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt @@ -214,6 +214,9 @@ class AutoCheckinManager @Inject constructor( ServiceResult(service, label, false, true, 0, "", false) WorkBuddyRepository.CheckinOutcome.QUERY -> ServiceResult(service, label, false, true, 0, "", false) + WorkBuddyRepository.CheckinOutcome.NO_CAMPAIGN -> + // 国际站没有签到活动不是账号失败:静默跳过,不记失败日志 + ServiceResult(service, label, false, false, 0, s.message, false) WorkBuddyRepository.CheckinOutcome.FAILED -> { recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED) ServiceResult(service, label, false, false, 0, s.message, failed = true) diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt index bfdef13..1771620 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt @@ -184,6 +184,8 @@ class ServiceDetailViewModel @Inject constructor( if (s.message.isNotBlank()) UiText.Dynamic(s.message) else UiText.Resource(R.string.checkin_failed) ) + WorkBuddyRepository.CheckinOutcome.NO_CAMPAIGN -> + CheckinState.Notice(UiText.Resource(R.string.checkin_wb_no_campaign)) // QUERY 不该出现在签到动作结果里,兜底为通用成功 WorkBuddyRepository.CheckinOutcome.QUERY -> CheckinState.Done(UiText.Resource(R.string.checkin_success))