File size: 18,871 Bytes
6d60378 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 | // Package headers 构造三类上游请求头(common / chat / billing / refresh)。
// 规则来自 docs/api-reference.md §0/§4/§6。
package upstream
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
"github.com/linguo2625469/workbuddy2api-panel/internal/session"
)
const (
// defaultClientVersion 出站 WorkBuddy 客户端版本段(UA 的 `WorkBuddy/<ver>` 与
// 白名单头组的 X-IDE-Version)。对齐官方 WorkBuddy Desktop 分发包版本(5.5.4)。
// config upstream.client_version 可覆盖(空 = 内置默认)。
defaultClientVersion = "5.5.4"
// defaultCliVersion 出站 UA 中 `CLI/<ver>` 段版本。对齐官方内置 CLI(2.137.1)。
// config upstream.cli_version 可覆盖(空 = 内置默认)。
defaultCliVersion = "2.137.1"
originRefererCN = "https://www.codebuddy.cn"
originRefererGlobal = "https://www.workbuddy.ai"
)
// originRefererFor 按账号 realm 返回 Origin/Referer 基础域:
// global → https://www.workbuddy.ai;cn(含全局开关未开)→ https://www.codebuddy.cn。
func originRefererFor(a *auth.Auth) string {
if a != nil && a.IsGlobal() {
return originRefererGlobal
}
return originRefererCN
}
// clientVersion 生效的 WorkBuddy 客户端版本:Client.ClientVersion 非空则取之,
// 否则内置默认 defaultClientVersion。
func (c *Client) clientVersion() string {
if c != nil && c.ClientVersion != "" {
return c.ClientVersion
}
return defaultClientVersion
}
// cliVersion 生效的 CLI 版本:Client.CliVersion 非空则取之,否则内置默认 defaultCliVersion。
func (c *Client) cliVersion() string {
if c != nil && c.CliVersion != "" {
return c.CliVersion
}
return defaultCliVersion
}
// defaultWorkBuddyUAFor 组装默认客户端出站 UA(官方桌面端 RestOperations 层形状):
// `WorkBuddy/<clientVersion> <platform>/<clientVersion> CLI/<cliVersion>`。
// 平台段(第二段)品牌按 realm 切换——CN 用 applicationName 同值 `WorkBuddy`,
// global 用官方国际版 productName `WorkBuddy AI`(intl 项目逆向证据:
// `WorkBuddy/5.5.2 WorkBuddy AI/5.5.2 CLI/5.5.2`)。
// global 账号送错平台段(`WorkBuddy` 非 `WorkBuddy AI`)可能触发上游 403 code 11140
// "request illegal" 风控。官方无任何 UA 随机化,故默认确定性。
func (c *Client) defaultWorkBuddyUAFor(a *auth.Auth) string {
platform := "WorkBuddy"
if a != nil && a.IsGlobal() {
platform = "WorkBuddy AI"
}
return "WorkBuddy/" + c.clientVersion() + " " + platform + "/" + c.clientVersion() + " CLI/" + c.cliVersion()
}
// defaultWorkBuddyUA 返回 CN 形态的默认 UA(默认账号形态即 CN,零回归兼容既有调用/测试)。
func (c *Client) defaultWorkBuddyUA() string {
return c.defaultWorkBuddyUAFor(nil)
}
// userAgent 返回当前出站 UA(客户端出站路径:chat/refresh/FetchModels)。
// 优先级:Client.UserAgent(config user_agent)显式覆盖 > 按账号 realm 的默认 WorkBuddy 三段式。
// 显式覆盖兼容既有覆盖逻辑:用户配了即以用户值为准(自定义品牌/版本),
// 未配则走官方桌面端默认形态(global 换 `WorkBuddy AI` 平台段)。
func (c *Client) userAgent(a *auth.Auth) string {
if c != nil && c.UserAgent != "" {
return c.UserAgent
}
return c.defaultWorkBuddyUAFor(a)
}
// billingUA 白名单类(billing/checkin/banner)出站 UA:单段 `WorkBuddy/<clientVersion>`
// (官方 banner 显式覆写形态,不带 CLI 段)。默认生效(伪造官方桌面端指纹);
// 显式 client_name="SaaS" 才不设 UA(还原旧行为,Go 默认 UA)。
func (c *Client) billingUA() string {
if c == nil || c.attributionClientName() == "SaaS" {
return ""
}
return "WorkBuddy/" + c.clientVersion()
}
// resolveDeviceToken 解析本次请求的 X-Device-Token 取值。
// 优先级:auth.Auth.DeviceToken(每号)> Client.DeviceToken(config 全局)> 文件兜底。
// 三者皆空/读失败则返回空串(调用方不注入该头,优雅降级)。
func (c *Client) resolveDeviceToken(a *auth.Auth) string {
if a != nil && a.DeviceToken != "" {
return a.DeviceToken
}
if c != nil && c.DeviceToken != "" {
return c.DeviceToken
}
if c != nil && c.DeviceTokenFile != "" {
return readDeviceTokenFile(c.DeviceTokenFile)
}
return ""
}
// injectDeviceToken 在 req 注入 X-Device-Token 头(仅当取到非空 token)。
func (c *Client) injectDeviceToken(req *http.Request, a *auth.Auth) {
if tok := c.resolveDeviceToken(a); tok != "" {
req.Header.Set("X-Device-Token", tok)
}
}
// deriveAccountStableID 按 uid + 用途盐稳定派生 36 hex 设备/会话标识。
// 跨重启稳定(固定盐 "wb2a:",不随进程换——这是与 session 包派生盐的本质差异:
// 那是会话键维度的进程级随机盐,重启换新;本函数是账号维度,必须跨重启恒定)、
// 账号间互异(uid 不同则不同)、同 uid 同用途恒同值(幂等)。用 sha256 与项目
// 既有派生(session/ids.go、cache_key.go)保持一致;截 36 hex 提供更长熵。
//
// 两个用途:
// - purpose="machine" → X-Machine-ID(设备级,跨会话稳定)
// - purpose="session" → X-Session-ID(账号固定会话,跨重启稳定)
//
// 与 injectDeviceToken 的 X-Device-Token 并存不冲突:那是登录时上游签发的
// 真实设备令牌(有则发,权威);本对头是「每账号一台固定虚拟设备」的稳定指纹,
// 防多号被上游按设备指纹缺失/漂移关联风控。两者是不同头族,官方桌面端都发。
func deriveAccountStableID(uid, purpose string) string {
sum := sha256.Sum256([]byte("wb2a:" + purpose + ":" + uid))
return hex.EncodeToString(sum[:18]) // 36 hex chars
}
// injectAccountStableHeaders 在 req 注入 X-Machine-ID / X-Session-ID:按 uid 稳定
// 派生,跨重启固定、账号间互异。uid 为空时不注入(匿名请求无设备标识,上游不要求)。
func (c *Client) injectAccountStableHeaders(req *http.Request, a *auth.Auth) {
if a == nil || a.UID == "" {
return
}
req.Header.Set("X-Machine-ID", deriveAccountStableID(a.UID, "machine"))
req.Header.Set("X-Session-ID", deriveAccountStableID(a.UID, "session"))
}
// CommonHeaders 设置所有 API 共享的请求头。
func (c *Client) CommonHeaders(req *http.Request, a *auth.Auth) {
req.Header.Set("Content-Type", "application/json")
// Accept 非流式默认 application/json(D6:去掉宽松的 text/plain, */*)。
// chat 流式路径在 ChatHeaders 覆盖为 event-stream。
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
origin := originRefererFor(a)
req.Header.Set("Origin", origin)
req.Header.Set("Referer", origin+"/")
req.Header.Set("User-Agent", c.userAgent(a))
// X-CodeBuddy-Request: 1(官方客户端风控闸门头,所有 API 请求必带,D1)。
req.Header.Set("X-CodeBuddy-Request", "1")
// Accept-Language 按 realm 切(D5):CN zh-CN,global en-US。官方客户端按账号域
// 发对应语言标识,对齐避免上游风控按语言缺失误判。
req.Header.Set("Accept-Language", acceptLanguageFor(a))
// X-Machine-ID / X-Session-ID:按 uid 稳定派生的账号级设备头(见
// injectAccountStableHeaders)。注入在 CommonHeaders——chat 经 ChatHeaders
// 叠加 CommonHeaders 天然继承;billing 域另行注入,全出站覆盖。
c.injectAccountStableHeaders(req, a)
}
// acceptLanguageFor 按账号 realm 返回 Accept-Language:global → en-US,cn → zh-CN。
func acceptLanguageFor(a *auth.Auth) string {
if a != nil && a.IsGlobal() {
return "en-US"
}
return "zh-CN"
}
// injectCodeBuddyRequest 在 req 注入 X-CodeBuddy-Request: 1。
// billing 域未走 CommonHeaders,单独注入保证全出站覆盖(D1)。
func (c *Client) injectCodeBuddyRequest(req *http.Request) {
req.Header.Set("X-CodeBuddy-Request", "1")
}
// ChatMeta 一次 chat 出站的会话头族元数据(issue #35:后台按 X-Conversation-Request-ID
// 聚合请求,官方客户端一次 user send 内所有 tool call/重试/换号复用同一个 ID)。
// handler 在轮转循环外生成 conversationID / conversationRequestID,循环内每次出站
// 复用同值;TraceID 透传入站值(空 = 回落 conversationRequestID)。
// messageID(消息级,每条独立)由 ChatHeaders 内部生成,无需外部可见。
type ChatMeta struct {
ConversationID string // X-Conversation-ID:body 提取的入站值,空则不发(透传优先,不伪造)
ConversationRequestID string // X-Conversation-Request-ID / X-Root-Request-ID:聚合主键,必发
TraceID string // X-Trace-ID:入站透传值,空则回落 conversationRequestID
}
// ChatHeaders 在 common 之上加 chat 专属的账号头。
// 缺省字段用 X-No-* 约定(与 CodeBuddy 官方 CLI 一致)。
// clientIP 为本次请求的客户端 IP(按参数传递,不读共享字段——避免并发串扰);
// PassthroughIP=false 或 clientIP 为空时不注入 IP 头。
// meta 为会话头族元数据(纯新增,不改既有头),见 injectConversationHeaders。
func (c *Client) ChatHeaders(req *http.Request, a *auth.Auth, clientIP string, meta ChatMeta) {
c.CommonHeaders(req, a)
// chat 流式 Accept 覆盖 CommonHeaders 的非流式默认(D6)。
req.Header.Set("Accept", "application/json, text/event-stream")
// AccessToken 加锁快照:keepalive 定时刷新会在 a.mu 内改写它,锁外直读构成数据竞争
// (见 auth.AccessTokenValue 注释)。
if at := a.AccessTokenValue(); at != "" {
req.Header.Set("Authorization", "Bearer "+at)
} else {
req.Header.Set("X-No-Authorization", "1")
}
if a.UID != "" {
req.Header.Set("X-User-Id", a.UID)
} else {
req.Header.Set("X-No-User-Id", "1")
}
// 安全红线:绝不在 chat 请求里携带 X-Refresh-Token。
// 企业与域头按 realm 分发:CN 走既有分支(EnterpriseID/Domain 原样透传,缺省 X-No-*);
// global 账号由 injectGlobalChatHeaders 统一覆写为国际客户端形态
// (X-No-Enterprise-Id=1 声明无企业 + X-Domain=www.workbuddy.ai 声明国际版域),
// 且不回退 X-Domain 到登录会话原值——对齐 intl 项目出站头。
if a != nil && !a.IsGlobal() {
if a.EnterpriseID != "" {
req.Header.Set("X-Enterprise-Id", a.EnterpriseID)
} else {
req.Header.Set("X-No-Enterprise-Id", "1")
}
if d := a.DomainValue(); d != "" {
req.Header.Set("X-Domain", d)
} else {
req.Header.Set("X-No-Department-Info", "1")
}
} else {
c.injectGlobalChatHeaders(req, a)
}
// 用量归属头:默认伪造 WorkBuddy 桌面端指纹(client_name="SaaS" 还原旧行为)。
c.injectAttribution(req)
// 客户端 IP 透传(仅 PassthroughIP=true 且本次请求带 IP)。
c.injectClientIP(req, clientIP)
// 设备风控头:auth 每号 > config 全局 > 文件兜底;空则不注入。
c.injectDeviceToken(req, a)
// 会话头族(对话/请求/消息/B3 链路),见 injectConversationHeaders。
c.injectConversationHeaders(req, meta)
}
// injectGlobalChatHeaders global 账号(无企业 ID)的 chat 专属声明头,对齐 intl 项目
// (ANALYSIS-global-chat-solutions.md):
// - X-No-Enterprise-Id: 1 个人账号无企业 ID,显式声明(避免上游按缺省/可疑判定)
// - X-Domain: www.workbuddy.ai 显式声明国际版域(与 Origin/Referer 同域)
//
// 仅 global realm 注入;CN 账号走既有 X-No-Department-Info 等分支,零回归。
func (c *Client) injectGlobalChatHeaders(req *http.Request, a *auth.Auth) {
if a == nil || !a.IsGlobal() {
return
}
req.Header.Set("X-No-Enterprise-Id", "1")
req.Header.Set("X-Domain", "www.workbuddy.ai")
}
// injectConversationHeaders 注入官方客户端会话头族(issue #35 后台聚合)。
// 头族分四层,各司其职:
// - X-Conversation-ID:会话级,多轮稳定(body 的 conversationId)。空则不发——
// 透传客户端原值优先,客户端没给就不伪造,避免误导后台建错会话。
// - X-Conversation-Request-ID:**对话轮级聚合主键**,必发。一次 user send 内的
// 所有 tool call/重试/换号/降级复用同一个 → 后台按它聚合成一条(不再碎片化)。
// - X-Conversation-Message-ID = X-Request-ID:消息级,每条独立(32 位 hex)。
// - X-Root-Request-ID:= conversationRequestID(根请求追踪)。
// - X-Trace-ID:入站透传或 = conversationRequestID。
// - X-B3-TraceId / X-B3-SpanId / X-B3-Sampled:链路族。B3 规范只认 16/32 hex
// TraceId 与 16 hex SpanId;入站 conversationRequestID 非法时 TraceId 回落
// messageID(恒 32 hex),SpanId 取 messageID[:16](每消息新)。
func (c *Client) injectConversationHeaders(req *http.Request, meta ChatMeta) {
convReqID := meta.ConversationRequestID
if convReqID == "" {
// 零值 meta(直接调 ChatHeaders 的调用方/测试)也要保证聚合主键必发:
// 本级补一个 32 hex,调用方(handler)已生成稳定的不走到这里。
convReqID = session.NewMessageID()
}
messageID := session.NewMessageID()
if meta.ConversationID != "" {
req.Header.Set("X-Conversation-ID", meta.ConversationID)
}
req.Header.Set("X-Conversation-Request-ID", convReqID)
req.Header.Set("X-Conversation-Message-ID", messageID)
req.Header.Set("X-Request-ID", messageID)
req.Header.Set("X-Root-Request-ID", convReqID)
traceID := meta.TraceID
if traceID == "" {
traceID = convReqID
}
req.Header.Set("X-Trace-ID", traceID)
b3Trace := convReqID
if !validTraceID(b3Trace) {
b3Trace = messageID // 非法 B3 TraceId → 回落恒 32 hex 的消息级 ID
}
req.Header.Set("X-B3-TraceId", b3Trace)
req.Header.Set("X-B3-SpanId", messageID[:16])
req.Header.Set("X-B3-Sampled", "1")
}
// validTraceID 判断 B3 TraceId 是否合法:16 或 32 位 hex(大小写均可)。
// 官方客户端生成的 conversationRequestId 是 32 位 hex(UUID 去横线),入站透传值
// 可能是任意形状(含横线/超长/非 hex),直接塞进 B3 头会破坏链路关联(issue #35)。
func validTraceID(s string) bool {
if len(s) != 16 && len(s) != 32 {
return false
}
for i := 0; i < len(s); i++ {
ch := s[i]
if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')) {
return false
}
}
return true
}
// attributionClientName 生效的用量归属名:ClientName 非空取之;
// 空默认 "WorkBuddy"(伪造官方桌面端指纹;显式配 "SaaS" 可还原旧行为)。
func (c *Client) attributionClientName() string {
if c != nil && c.ClientName != "" {
return c.ClientName
}
return "WorkBuddy"
}
// injectAttribution 注入用量归属头(X-Agent-Purpose / X-IDE-* / X-Product)。
// 仅在 chat/completions 路径生效(ChatHeaders 调用)。
//
// 默认(ClientName 空)即对齐官方 WorkBuddy 桌面端指纹:X-Agent-Purpose="conversation"
// + X-IDE-Name/Type/Product="WorkBuddy" + X-IDE-Version=client_version,上游用量归因
// 不再出现 client/agentPurpose 为空的「网关特征」。显式 ClientName="SaaS" 还原旧行为
// (仅 X-Product="SaaS",不设 X-IDE-*);配其他值则四头跟随该值。
func (c *Client) injectAttribution(req *http.Request) {
name := c.attributionClientName()
if name == "SaaS" {
req.Header.Set("X-Product", "SaaS")
return
}
req.Header.Set("X-Agent-Purpose", "conversation")
req.Header.Set("X-IDE-Name", name)
req.Header.Set("X-IDE-Type", name)
req.Header.Set("X-IDE-Version", c.clientVersion())
req.Header.Set("X-Product", name)
}
// injectClientIP 在 PassthroughIP 开启时把 clientIP 参数透传给上游(三等价头)。
func (c *Client) injectClientIP(req *http.Request, clientIP string) {
if c == nil || !c.PassthroughIP || clientIP == "" {
return
}
req.Header.Set("X-Forwarded-For", clientIP)
req.Header.Set("X-Real-IP", clientIP)
req.Header.Set("X-Client-IP", clientIP)
}
// ExtractClientIP 从入站请求提取客户端 IP 首段(X-Forwarded-For 首段,回落 X-Real-IP)。
func ExtractClientIP(r *http.Request) string {
if r == nil {
return ""
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
for i := 0; i < len(xff); i++ {
if xff[i] == ',' {
return strings.TrimSpace(xff[:i])
}
}
return strings.TrimSpace(xff)
}
if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" {
return real
}
return ""
}
// BillingHeaders billing 接口请求头。
// UA 语义:默认**不设置**(保持现状,Go 客户端自带默认 UA);仅当显式配置
// c.UserAgent 非空才覆盖——避免默认路径给 billing 引入新的 UA 指纹。
func (c *Client) BillingHeaders(req *http.Request, a *auth.Auth) {
// AccessToken 加锁快照(同 ChatHeaders:keepalive 可在 a.mu 内改写)。
req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue())
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
c.injectCodeBuddyRequest(req)
// Accept-Language 按 realm 切(D5,billing 域未走 CommonHeaders,单独注入)。
req.Header.Set("Accept-Language", acceptLanguageFor(a))
if c != nil && c.UserAgent != "" {
req.Header.Set("User-Agent", c.UserAgent)
} else if ua := c.billingUA(); ua != "" {
req.Header.Set("User-Agent", ua)
}
if a.UID != "" {
req.Header.Set("X-User-Id", a.UID)
}
if a.EnterpriseID != "" {
req.Header.Set("X-Enterprise-Id", a.EnterpriseID)
req.Header.Set("X-Tenant-Id", a.EnterpriseID)
}
if d := a.DomainValue(); d != "" {
req.Header.Set("X-Domain", d)
}
// 设备风控头:billing 域(report/travel/balance/checkin)同样注入。
c.injectDeviceToken(req, a)
}
// RefreshHeaders refresh 端点专属头(X-Refresh-Token 只允许出现在这里)。
func (c *Client) RefreshHeaders(req *http.Request, a *auth.Auth) {
c.CommonHeaders(req, a)
req.Header.Set("X-Refresh-Token", a.RefreshToken)
if a.EnterpriseID != "" {
req.Header.Set("X-Enterprise-Id", a.EnterpriseID)
}
// X-Auth-Refresh-Source 对齐官方客户端 refresh 渠道标识 "plugin"(D3)。
req.Header.Set("X-Auth-Refresh-Source", "plugin")
}
|