fix(wb): 国际版反代走 console 路由并补齐请求头;模型目录改 /v3/config 与 CLI 名单过滤

This commit is contained in:
Liuxinyu176 2026-10-10 08:29:50 +08:00
parent f917e5bfd6
commit 027eb6185d
4 changed files with 227 additions and 61 deletions

View File

@ -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<String, kotlinx.serialization.json.JsonElement>) {
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")
}
}

View File

@ -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<TraeModelProvider.TraeModel> =
_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<TraeModelProvider.TraeModel>? {
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<String, TraeModelProvider.TraeModel>()
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<TraeModelProvider.TraeModel> = listOf(
/** 内置兜底(在线列表成功后会被替换)。 */
fun defaultModels(region: ProviderRegion): List<TraeModelProvider.TraeModel> =
if (region == ProviderRegion.INTL) intlDefaultModels() else cnDefaultModels()
/** 国内站 CLI 模型兜底(参考 workbuddy-gateway 模型目录)。 */
private fun cnDefaultModels(): List<TraeModelProvider.TraeModel> = 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<TraeModelProvider.TraeModel> = 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"),
)
}
}

View File

@ -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)

View File

@ -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))