From 72f5b2985ea5805485388de5f6bc18290bdab53f Mon Sep 17 00:00:00 2001 From: caigee-cmd <210803857+caigee-cmd@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:14:23 +0800 Subject: [PATCH] feat: align Qoder client fingerprint and WorkBuddy client versions --- .../unreleased/client-fingerprint-versions.md | 13 ++++ internal/providers/qoder/cosy.go | 13 +++- internal/providers/qoder/fingerprint_test.go | 30 ++++++++ internal/providers/qoder/qauth/fingerprint.go | 70 +++++++++++++++++ .../providers/qoder/qauth/fingerprint_test.go | 77 +++++++++++++++++++ internal/providers/qoder/qauth/qauth.go | 15 ++-- internal/providers/workbuddy/client_test.go | 2 +- internal/providers/workbuddy/credential.go | 37 ++++++--- internal/providers/workbuddy/versions_test.go | 29 +++++++ 9 files changed, 266 insertions(+), 20 deletions(-) create mode 100644 changelog/unreleased/client-fingerprint-versions.md create mode 100644 internal/providers/qoder/fingerprint_test.go create mode 100644 internal/providers/qoder/qauth/fingerprint.go create mode 100644 internal/providers/qoder/qauth/fingerprint_test.go create mode 100644 internal/providers/workbuddy/versions_test.go diff --git a/changelog/unreleased/client-fingerprint-versions.md b/changelog/unreleased/client-fingerprint-versions.md new file mode 100644 index 00000000..8a90021c --- /dev/null +++ b/changelog/unreleased/client-fingerprint-versions.md @@ -0,0 +1,13 @@ +### English + +- Qoder requests now use one shared client version (1.1.32) for `cosy-version`, the signed payload and the User-Agent, instead of the stale 0.1.43 on auth/status calls. +- Qoder requests no longer send the Go default User-Agent; they send `QoderWork/` for the China edition and `Qoder/` for the international edition. +- Qoder inference streams now include `cosy-machineos` and a per-account stable `cosy-machinehostname`, matching the official client. +- WorkBuddy client versions are updated: CLI 2.161.0, desktop 5.8.4 (international) / 5.8.3 (China), IDE 2.168.2; desktop User-Agents use the three-part form, e.g. `WorkBuddy/5.8.4 workbuddy-ai/5.8.4 CLI/2.161.0`. + +### 中文 + +- Qoder 请求的 `cosy-version`、签名载荷和 User-Agent 现统一使用同一个客户端版本(1.1.32),不再在认证/状态接口上使用过时的 0.1.43。 +- Qoder 请求不再发送 Go 默认 User-Agent,国内版使用 `QoderWork/<版本>`,国际版使用 `Qoder/<版本>`。 +- Qoder 推理流式请求新增 `cosy-machineos` 和按账号稳定派生的 `cosy-machinehostname`,与官方客户端一致。 +- WorkBuddy 客户端版本更新:CLI 2.161.0、桌面端 5.8.4(国际版)/ 5.8.3(国内版)、IDE 2.168.2;桌面端 User-Agent 改为三段式,例如 `WorkBuddy/5.8.4 workbuddy-ai/5.8.4 CLI/2.161.0`。 diff --git a/internal/providers/qoder/cosy.go b/internal/providers/qoder/cosy.go index 0077063e..47bcb678 100644 --- a/internal/providers/qoder/cosy.go +++ b/internal/providers/qoder/cosy.go @@ -16,11 +16,13 @@ import ( "net/url" "strings" "time" + + "github.com/caigee-cmd/cli2api/internal/providers/qoder/qauth" ) -// COSYVersion is the CLI protocol version sent on chat. It matches the pinned -// worker CLI, not a newer desktop SDK constant. -const COSYVersion = "1.1.32" +// COSYVersion is the CLI protocol version sent on chat. It is the single +// qauth.CosyVersion constant so headers, payloads and User-Agent never drift. +const COSYVersion = qauth.CosyVersion const cosyServerPubKeyPEM = `-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc @@ -177,6 +179,11 @@ func (s *Session) Sign(body, rawURL string, now time.Time) (headers map[string]s "Cosy-Version": COSYVersion, "Login-Version": "v2", "Accept-Encoding": "identity", + // Sign is only used for inference SSE (direct chat), where qodercli + // also sends machine OS/hostname. + "User-Agent": qauth.UserAgentForURL(rawURL), + "Cosy-MachineOS": qauth.CosyMachineOS, + "Cosy-MachineHostname": qauth.MachineHostname(s.User.uid()), }, nil } diff --git a/internal/providers/qoder/fingerprint_test.go b/internal/providers/qoder/fingerprint_test.go new file mode 100644 index 00000000..880fc366 --- /dev/null +++ b/internal/providers/qoder/fingerprint_test.go @@ -0,0 +1,30 @@ +package qoder + +import ( + "testing" + "time" + + "github.com/caigee-cmd/cli2api/internal/providers/qoder/qauth" +) + +func TestDirectSignCarriesClientFingerprint(t *testing.T) { + if COSYVersion != qauth.CosyVersion { + t.Fatal("COSYVersion must be the shared qauth constant") + } + s, err := NewSession(CLIUser{UID: "uid-fp", AccessToken: "dt"}) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct{ region, ua string }{{"global", "Qoder/"}, {"cn", "QoderWork/"}} { + h, err := s.Sign("b", ChatEndpoint(tc.region), time.Unix(1, 0)) + if err != nil { + t.Fatal(err) + } + if h["User-Agent"] != tc.ua+qauth.CosyVersion || h["Cosy-Version"] != qauth.CosyVersion { + t.Fatalf("%s ua=%q ver=%q", tc.region, h["User-Agent"], h["Cosy-Version"]) + } + if h["Cosy-MachineOS"] != qauth.CosyMachineOS || h["Cosy-MachineHostname"] != qauth.MachineHostname("uid-fp") { + t.Fatalf("%s os/host=%q/%q", tc.region, h["Cosy-MachineOS"], h["Cosy-MachineHostname"]) + } + } +} diff --git a/internal/providers/qoder/qauth/fingerprint.go b/internal/providers/qoder/qauth/fingerprint.go new file mode 100644 index 00000000..50035afa --- /dev/null +++ b/internal/providers/qoder/qauth/fingerprint.go @@ -0,0 +1,70 @@ +package qauth + +import ( + "crypto/md5" + "encoding/hex" + "net/http" + "net/url" + "strings" +) + +// CosyVersion is the single client/protocol version cli2api presents to +// Qoder: the cosy-version header, the bearer payload's cosyVersion, the chat +// body version and the User-Agent all derive from it. It mirrors the pinned +// qodercli build cli2api emulates (session_type "qodercli", product "cli"). +const CosyVersion = "1.1.32" + +// CosyMachineOS mirrors qodercli's `${arch}_${platform}` value for a Linux +// x86_64 host, which is how cli2api is deployed. +const CosyMachineOS = "x86_64_linux" + +// User-Agent product names per edition. The China edition ships as +// QoderWork, the international edition as Qoder. +const ( + userAgentProductCN = "QoderWork" + userAgentProductGlobal = "Qoder" +) + +// UserAgent returns the edition-specific User-Agent. Anything that is not the +// international region is treated as the China edition (the default region). +func UserAgent(region *RegionConfig) string { + if region != nil && region.Name == Global.Name { + return userAgentProductGlobal + "/" + CosyVersion + } + return userAgentProductCN + "/" + CosyVersion +} + +// UserAgentForURL returns the edition User-Agent for a request URL. +func UserAgentForURL(rawURL string) string { return UserAgent(regionForURL(rawURL)) } + +// regionForURL infers the edition from a request URL's host. +func regionForURL(rawURL string) *RegionConfig { + if u, err := url.Parse(rawURL); err == nil { + host := strings.ToLower(u.Hostname()) + if host == "qoder.sh" || strings.HasSuffix(host, ".qoder.sh") { + return Global + } + } + return CN +} + +// MachineHostname derives a stable, header-safe hostname for an account so +// every request from one account looks like one machine, and different +// accounts look like different machines. +func MachineHostname(uid string) string { + if uid == "" { + uid = "anonymous" + } + sum := md5.Sum([]byte("hostname:" + uid)) + return "DESKTOP-" + strings.ToUpper(hex.EncodeToString(sum[:])[:7]) +} + +// applyClientFingerprint sets the edition User-Agent and, on inference SSE +// requests only (as qodercli does), cosy-machineos/cosy-machinehostname. +func applyClientFingerprint(h http.Header, rawURL, uid string, inferSSE bool) { + h.Set("user-agent", UserAgentForURL(rawURL)) + if inferSSE { + h.Set("cosy-machineos", CosyMachineOS) + h.Set("cosy-machinehostname", MachineHostname(uid)) + } +} diff --git a/internal/providers/qoder/qauth/fingerprint_test.go b/internal/providers/qoder/qauth/fingerprint_test.go new file mode 100644 index 00000000..d7ad25a9 --- /dev/null +++ b/internal/providers/qoder/qauth/fingerprint_test.go @@ -0,0 +1,77 @@ +package qauth + +import ( + "context" + "encoding/base64" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestUserAgentSplitsByEdition(t *testing.T) { + if got := UserAgent(CN); got != "QoderWork/"+CosyVersion { + t.Fatalf("CN UA=%q", got) + } + if got := UserAgent(Global); got != "Qoder/"+CosyVersion { + t.Fatalf("global UA=%q", got) + } + if UserAgentForURL("https://api1.qoder.sh/algo/x") != UserAgent(Global) || + UserAgentForURL("https://gateway.qoder.com.cn/algo/x") != UserAgent(CN) { + t.Fatal("URL edition inference wrong") + } + if openAPIUserAgentVersion != CosyVersion { + t.Fatal("OpenAPI UA version must follow CosyVersion") + } +} + +func TestMachineHostnameStablePerAccount(t *testing.T) { + a, b, c := MachineHostname("uid-a"), MachineHostname("uid-a"), MachineHostname("uid-b") + if a != b || a == c || !strings.HasPrefix(a, "DESKTOP-") || len(a) != 15 { + t.Fatalf("a=%q b=%q c=%q", a, b, c) + } +} + +func TestBuildPayloadUsesCosyVersion(t *testing.T) { + if !strings.Contains(payloadJSON(t, BuildPayloadB64("i")), `"cosyVersion":"`+CosyVersion+`"`) { + t.Fatal("payload cosyVersion drifted") + } +} + +func TestBearerHeadersFingerprint(t *testing.T) { + var got http.Header + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Clone() + if r.Header.Get("Accept") == "text/event-stream" { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"body\":\"[DONE]\"}\n\n")) + return + } + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + sess := newTestSession(t) + + _, _ = CallGet(context.Background(), sess, srv.URL+"/algo/api/x") + if got.Get("Cosy-Version") != CosyVersion || strings.Contains(got.Get("User-Agent"), "Go-http-client") || + got.Get("User-Agent") != UserAgent(CN) { + t.Fatalf("json headers: ver=%q ua=%q", got.Get("Cosy-Version"), got.Get("User-Agent")) + } + if got.Get("Cosy-MachineOS") != "" || got.Get("Cosy-MachineHostname") != "" { + t.Fatal("machine os/hostname must be SSE-only") + } + + _ = OpenStreamLines(context.Background(), sess, srv.URL+"/algo/api/chat", []byte(`{}`), nil, func(string) error { return nil }) + if got.Get("Cosy-MachineOS") != CosyMachineOS || got.Get("Cosy-MachineHostname") != MachineHostname("uid-test") { + t.Fatalf("sse headers os=%q host=%q", got.Get("Cosy-MachineOS"), got.Get("Cosy-MachineHostname")) + } +} + +func payloadJSON(t *testing.T, b64 string) string { + t.Helper() + raw, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + t.Fatal(err) + } + return string(raw) +} diff --git a/internal/providers/qoder/qauth/qauth.go b/internal/providers/qoder/qauth/qauth.go index 5e0281e3..efec030c 100644 --- a/internal/providers/qoder/qauth/qauth.go +++ b/internal/providers/qoder/qauth/qauth.go @@ -125,8 +125,8 @@ func FetchModelCatalog(ctx context.Context, sess *SessionContext, region *Region } // openAPIUserAgentVersion is the client version the global OpenAPI endpoints -// expect in the User-Agent header. It mirrors the pinned CLI (1.1.32). -const openAPIUserAgentVersion = "1.1.32" +// expect in the User-Agent header. It mirrors the pinned CLI. +const openAPIUserAgentVersion = CosyVersion // --- Custom Encoding (Qoder Base64 variant) --- @@ -374,7 +374,7 @@ type cosyPayloadStruct struct { func BuildPayloadB64(info string) string { p := cosyPayloadStruct{ - CosyVersion: "0.1.43", + CosyVersion: CosyVersion, IdeVersion: "", Info: info, RequestID: uuid.New().String(), @@ -676,13 +676,12 @@ func commonHeaders(machineID, machineToken, machineType, date, sig string) http. h.Set("appcode", appCode) h.Set("accept", "application/json") h.Set("accept-encoding", "identity") - h.Set("cosy-version", "0.1.43") + h.Set("cosy-version", CosyVersion) h.Set("cosy-clienttype", "5") h.Set("date", date) h.Set("signature", sig) h.Set("content-type", "application/json") h.Set("cosy-machineid", machineID) - h.Set("user-agent", "Go-http-client/2.0") return h } @@ -695,6 +694,7 @@ func postEncoded(ctx context.Context, urlStr string, obj interface{}, machineID, } body := Encode(plain) headers := commonHeaders(machineID, machineToken, machineType, date, sig) + applyClientFingerprint(headers, urlStr, "", false) req, err := http.NewRequestWithContext(ctx, "POST", urlStr, strings.NewReader(body)) if err != nil { @@ -1095,11 +1095,10 @@ func makeCommonHeaders(sess *SessionContext, date, bearer, accept string) http.H h.Set("accept", accept) h.Set("authorization", bearer) h.Set("accept-encoding", "identity") - h.Set("cosy-version", "0.1.43") + h.Set("cosy-version", CosyVersion) h.Set("cosy-machineid", sess.MachineID) h.Set("cosy-machinetoken", sess.MachineToken) h.Set("login-version", "v2") - h.Set("user-agent", "Go-http-client/2.0") return h } @@ -1142,6 +1141,7 @@ func call(ctx context.Context, sess *SessionContext, method, fullURL string, jso date := strconv.FormatInt(time.Now().Unix(), 10) bearer := buildBearer(sess, date, body, pathSig) headers := makeCommonHeaders(sess, date, bearer, "application/json") + applyClientFingerprint(headers, fullURL, sess.Identity.UID, false) for k, v := range extraHeaders { headers.Set(k, v) } @@ -1393,6 +1393,7 @@ func OpenStreamLines(ctx context.Context, sess *SessionContext, fullURL string, date := strconv.FormatInt(time.Now().Unix(), 10) bearer := buildBearer(sess, date, body, pathSig) headers := makeCommonHeaders(sess, date, bearer, "text/event-stream") + applyClientFingerprint(headers, fullURL, sess.Identity.UID, true) headers.Set("cache-control", "no-cache") for k, v := range extraHeaders { headers.Set(k, v) diff --git a/internal/providers/workbuddy/client_test.go b/internal/providers/workbuddy/client_test.go index 43625fd0..5369b6eb 100644 --- a/internal/providers/workbuddy/client_test.go +++ b/internal/providers/workbuddy/client_test.go @@ -1410,7 +1410,7 @@ func TestChatHeadersStayRegionSpecificAndIncludeCLIChannel(t *testing.T) { if cn.Get("Origin") != "https://www.codebuddy.cn" || cn.Get("Referer") != "https://www.codebuddy.cn/" { t.Fatalf("CN origin/referer mixed: %+v", cn) } - if got := cn.Get("User-Agent"); got != UserAgent || !strings.Contains(got, "2.139.0") { + if got := cn.Get("User-Agent"); got != UserAgent || !strings.Contains(got, "2.161.0") { t.Fatalf("CN UA=%q", got) } if cn.Get("X-Product") != "SaaS" || cn.Get("X-IDE-Type") != "CLI" || cn.Get("X-Agent-Intent") != "craft" || diff --git a/internal/providers/workbuddy/credential.go b/internal/providers/workbuddy/credential.go index 1779f2e3..167e0a01 100644 --- a/internal/providers/workbuddy/credential.go +++ b/internal/providers/workbuddy/credential.go @@ -19,17 +19,28 @@ const ( ChatBaseGlobal = "https://www.workbuddy.ai" BillingBaseCN = "https://www.codebuddy.cn" - // UserAgent matches official @tencent-ai/codebuddy-code 2.139.0 in the - // dual-token form WorkBuddy chat still accepts. Origin/Referer stay - // region-specific and must never mix CN with Global. - CLIVersion = "2.139.0" + // UserAgent matches official @tencent-ai/codebuddy-code in the dual-token + // form WorkBuddy chat still accepts. Origin/Referer stay region-specific + // and must never mix CN with Global. All client versions live here. + CLIVersion = "2.161.0" UserAgent = "CLI/" + CLIVersion + " CodeBuddy/" + CLIVersion - // DesktopUserAgent is for /v3/config only. Live A/B showed the same - // Global account returns the IDE dropdown (incl. deepseek-v4.1-flash) - // with a desktop UA, but the CLI UA returns a different model set. - DesktopVersion = "5.4.2" - DesktopUserAgent = "WorkBuddy/" + DesktopVersion + // Desktop clients ship separately per edition. Their UA is three-part: + // app token, edition token, then the bundled CLI version. + DesktopVersionGlobal = "5.8.4" + DesktopVersionCN = "5.8.3" + DesktopUserAgentGlobal = "WorkBuddy/" + DesktopVersionGlobal + " workbuddy-ai/" + DesktopVersionGlobal + " CLI/" + CLIVersion + DesktopUserAgentCN = "WorkBuddy/" + DesktopVersionCN + " WorkBuddy/" + DesktopVersionCN + " CLI/" + CLIVersion + + // IDEVersion is the CodeBuddy IDE plugin release, kept with the others. + IDEVersion = "2.168.2" + + // DesktopVersion/DesktopUserAgent are for /v3/config only (Global). Live + // A/B showed the same Global account returns the IDE dropdown (incl. + // deepseek-v4.1-flash) with a desktop UA, but the CLI UA returns a + // different model set. + DesktopVersion = DesktopVersionGlobal + DesktopUserAgent = DesktopUserAgentGlobal productTypeCLI = "CLI" agentIntentDefault = "craft" @@ -203,3 +214,11 @@ func (c Credential) IsGlobal() bool { } return strings.Contains(domain, DomainGlobal) || strings.Contains(domain, "workbuddy") } + +// DesktopUserAgentFor returns the desktop UA for the account's edition. +func DesktopUserAgentFor(global bool) string { + if global { + return DesktopUserAgentGlobal + } + return DesktopUserAgentCN +} diff --git a/internal/providers/workbuddy/versions_test.go b/internal/providers/workbuddy/versions_test.go new file mode 100644 index 00000000..cc8c3e7b --- /dev/null +++ b/internal/providers/workbuddy/versions_test.go @@ -0,0 +1,29 @@ +package workbuddy + +import ( + "net/http" + "testing" +) + +func TestClientVersionsAndDesktopUserAgents(t *testing.T) { + if CLIVersion != "2.161.0" || IDEVersion != "2.168.2" { + t.Fatalf("CLI=%q IDE=%q", CLIVersion, IDEVersion) + } + if UserAgent != "CLI/2.161.0 CodeBuddy/2.161.0" { + t.Fatalf("CLI UA=%q", UserAgent) + } + if got := DesktopUserAgentFor(true); got != "WorkBuddy/5.8.4 workbuddy-ai/5.8.4 CLI/2.161.0" { + t.Fatalf("global desktop UA=%q", got) + } + if got := DesktopUserAgentFor(false); got != "WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0" { + t.Fatalf("CN desktop UA=%q", got) + } + if DesktopUserAgent != DesktopUserAgentGlobal { + t.Fatalf("DesktopUserAgent must alias the global UA") + } + h := http.Header{} + SetCatalogHeaders(h, Credential{AccessToken: "at", Domain: DomainGlobal}) + if h.Get("User-Agent") != DesktopUserAgentGlobal { + t.Fatalf("global catalog UA=%q", h.Get("User-Agent")) + } +}