gcli2api / internal /upstream /headers.go
a3216's picture
chore: 同步到上游 1.12.0-panel + 凭证同步/独立启动器/保活
6d60378 verified
Raw History Blame Contribute Delete
18.9 kB
// 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")
}