Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions changelog/unreleased/client-fingerprint-versions.md
Original file line number Diff line number Diff line change
@@ -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/<version>` for the China edition and `Qoder/<version>` 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`。
13 changes: 10 additions & 3 deletions internal/providers/qoder/cosy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
30 changes: 30 additions & 0 deletions internal/providers/qoder/fingerprint_test.go
Original file line number Diff line number Diff line change
@@ -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"])
}
}
}
70 changes: 70 additions & 0 deletions internal/providers/qoder/qauth/fingerprint.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
77 changes: 77 additions & 0 deletions internal/providers/qoder/qauth/fingerprint_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
15 changes: 8 additions & 7 deletions internal/providers/qoder/qauth/qauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) ---

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -692,13 +692,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
}

Expand All @@ -711,6 +710,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 {
Expand Down Expand Up @@ -1111,11 +1111,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
}

Expand Down Expand Up @@ -1158,6 +1157,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)
}
Expand Down Expand Up @@ -1409,6 +1409,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)
Expand Down
2 changes: 1 addition & 1 deletion internal/providers/workbuddy/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" ||
Expand Down
37 changes: 28 additions & 9 deletions internal/providers/workbuddy/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
29 changes: 29 additions & 0 deletions internal/providers/workbuddy/versions_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
}
Loading