Skip to content

Commit 8ccc17e

Browse files
authored
Merge pull request #103 from maiphucgiang/feat/cn-work-593-contract
Align WorkBuddy 5.9.3 client contract
2 parents 2fa9b7c + b484d9d commit 8ccc17e

30 files changed

Lines changed: 451 additions & 90 deletions

‎app/buddy_task.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ def _chat(client, headers, record, model):
4949
"X-Parent-Conversation-ID": conversation, "X-Request-ID": request_id,
5050
"X-Root-Request-ID": request_id, "X-Conversation-Request-ID": request_id,
5151
"X-Conversation-Message-ID": request_id, "X-Agent-Intent": "craft",
52-
"X-Agent-Purpose": "conversation", "X-Agent-Type": "main"}
52+
"X-Agent-Purpose": "conversation", "X-Agent-Type": "auxiliary",
53+
"X-Agent-Runtime": "local-agent"}
5354
event = {"eventCode": "chat_request_send", "id": conversation, "extra": {
5455
"inputLength": len(PROMPT), "requestModelId": model["id"], "requestModelName": model["name"],
5556
"mode": "craft", "command": "", "expertId": ""}}

‎app/client_profiles.py‎

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
"""Build separate CLI and WorkBuddy identity headers with shared authentication fields."""
22

3+
import base64
4+
import binascii
35
import hashlib
46
import json
7+
import uuid
58

69
from .site_routing import domain_for_auth, profile_for_auth, profile_product, profile_region
710

811
CLI_VERSION = "2.165.0"
9-
WORKBUDDY_VERSION = "5.9.2"
12+
WORKBUDDY_VERSION = "5.9.3"
1013
WORKBUDDY_CLI_VERSION = CLI_VERSION
1114
INTL_CLI_VERSION = "2.161.0"
1215
INTL_WORKBUDDY_VERSION = "5.8.4"
@@ -61,23 +64,89 @@ def _auth_headers(auth: dict, account: dict) -> dict:
6164
"X-Domain": domain_for_auth(auth),
6265
"X-Product": "SaaS", "X-Requested-With": "XMLHttpRequest",
6366
}
64-
headers.update(identity_headers(profile_for_auth(auth)))
67+
profile = profile_for_auth(auth)
68+
headers.update(identity_headers(profile))
69+
if profile in ("cn-cli", "cn-work"):
70+
# CLI 2.165+ disables model optimization for enterprise accounts.
71+
headers["X-Private-Data"] = "true" if is_enterprise_account(account) else "false"
72+
return headers
73+
74+
75+
def _jwt_claims(auth: dict) -> dict:
76+
"""Read unsigned claims only as client metadata; upstream still authenticates the token."""
77+
token = auth.get("accessToken")
78+
if not isinstance(token, str) or token.count(".") != 2:
79+
return {}
80+
try:
81+
encoded = token.split(".")[1]
82+
decoded = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
83+
payload = json.loads(decoded)
84+
except (ValueError, binascii.Error, UnicodeError):
85+
return {}
86+
return payload if isinstance(payload, dict) else {}
87+
88+
89+
def _identity_value(value) -> str:
90+
if not isinstance(value, str):
91+
return ""
92+
value = value.strip()
93+
return value if value and len(value) <= 128 and value.isascii() and value.isprintable() else ""
94+
95+
96+
def _identity_claim_headers(auth: dict, account: dict) -> dict:
97+
claims = _jwt_claims(auth)
98+
id_source = _identity_value(account.get("idSource") or account.get("id_source") or claims.get("id_source"))
99+
token_source = _identity_value(claims.get("token_source") or auth.get("method"))
100+
enterprise_id = enterprise_id_of(account) or ""
101+
uid = canonical_uid(account)
102+
headers = {}
103+
if id_source:
104+
headers["X-Id-Source"] = id_source
105+
if token_source:
106+
headers["X-Auth-Method"] = token_source
107+
if uid and (enterprise_id or id_source or token_source):
108+
userinfo = {"uin": uid}
109+
if enterprise_id:
110+
userinfo["owner_uin"] = enterprise_id
111+
if id_source:
112+
userinfo["id_source"] = id_source
113+
if token_source:
114+
userinfo["token_source"] = token_source
115+
encoded = json.dumps(userinfo, separators=(",", ":")).encode("utf-8")
116+
headers["X-Userinfo"] = base64.b64encode(encoded).decode("ascii")
65117
return headers
66118

67119

68120
def credential_headers(auth: dict, account: dict) -> dict:
69-
return {**SDK_HEADERS, **_auth_headers(auth, account)}
121+
return {**SDK_HEADERS, **_auth_headers(auth, account), **_identity_claim_headers(auth, account)}
122+
123+
124+
def _cn_work_nonchat_headers(headers: dict, profile: str) -> dict:
125+
if profile == "cn-work":
126+
headers["Accept"] = "application/json, text/plain, */*"
127+
headers["X-Request-ID"] = uuid.uuid4().hex
128+
headers.pop("X-CodeBuddy-Request", None)
129+
return headers
130+
131+
132+
def credential_refresh_headers(auth: dict, account: dict) -> dict:
133+
headers = credential_headers(auth, account)
134+
return _cn_work_nonchat_headers(headers, profile_for_auth(auth))
135+
70136

71137
def refresh_headers(domain: str, uid: str = "") -> dict:
72138
"""Build headers for a refresh exchange that has no access token yet."""
73-
headers = {**SDK_HEADERS, **_auth_headers({"accessToken": "", "domain": domain}, {"uid": uid})}
139+
auth = {"accessToken": "", "domain": domain}
140+
headers = {**SDK_HEADERS, **_auth_headers(auth, {"uid": uid})}
74141
headers.pop("Authorization", None) # The refresh token is the only credential in this request.
75-
return headers
142+
return _cn_work_nonchat_headers(headers, profile_for_auth(auth))
143+
76144

77145
def catalog_headers(auth: dict, account: dict | None = None, *, user_agent="") -> dict:
78-
headers = _auth_headers(auth, account or {})
146+
profile = profile_for_auth(auth)
147+
headers = _cn_work_nonchat_headers(_auth_headers(auth, account or {}), profile)
79148
headers["Connection"] = "close"
80-
if profile_product(profile_for_auth(auth)) == "cli":
149+
if profile_product(profile) == "cli":
81150
headers["x-client-platform"] = "cli"
82151
if user_agent:
83152
headers["User-Agent"] = user_agent

‎app/credits.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import math
99
import os
1010
import re
11+
import secrets
1112
import threading
1213
import time
1314
from pathlib import Path
@@ -166,6 +167,8 @@ def _checkin_request(access_token, uid, domain, path):
166167
headers = {"accept": "application/json", "content-type": "application/json",
167168
"authorization": f"Bearer {access_token}", "x-user-id": str(uid or ""),
168169
"x-domain": str(domain or "")}
170+
if profile_for_auth({"accessToken": access_token, "domain": domain}) == "cn-work":
171+
headers.update({"X-Request-ID": secrets.token_hex(16), "User-Agent": "axios/1.16.1"})
169172
url = host + path
170173
with httpx.Client() as client:
171174
try:

‎app/growth_events.py‎

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Growth-center behavior chains: desktop/web event reporting and real-chat helpers.
22
3-
Payload shapes mirror the official WorkBuddy desktop client (5.5.6) field-for-field, as
3+
Payload shapes mirror the official WorkBuddy desktop client (5.9.3) field-for-field, as
44
validated by the upstream panel across multiple live accounts. Report hosts follow the
55
account's own product endpoint; the desktop fingerprint is stable per uid so repeated
66
chains look like one persistent device.
@@ -14,8 +14,8 @@
1414

1515
import httpx
1616

17-
DESKTOP_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) WorkBuddyDesktop/5.5.6 "
18-
"Chrome/132.0.6834.83 Electron/34.0.0 Safari/537.36")
17+
DESKTOP_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) WorkBuddyDesktop/5.9.3 "
18+
"Chrome/148.0.7778.97 Electron/41.8.1 Safari/537.36")
1919
WEB_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
2020
"(KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36")
2121
REPORT_PATH = "/v2/report"
@@ -42,13 +42,13 @@ def fingerprint(uid, nickname=""):
4242
now = int(time.time() * 1000)
4343
return {"timezone": "Asia/Shanghai", "reportDelay": 2000, "userId": uid,
4444
"username": nickname, "userNickname": nickname, "product": "SaaS",
45-
"releaseDate": 1789036585355,
46-
"commit": "5f9692923c93033111c51ad7b003eb80204a9b75",
47-
"ideName": "WorkBuddy", "ideType": "WorkBuddy", "ideVersion": "5.5.6",
45+
"releaseDate": 1789632000000,
46+
"commit": "c41253385fa07bebfcae8a7a4728914f09af96d0",
47+
"ideName": "WorkBuddy", "ideType": "WorkBuddy", "ideVersion": "5.9.3",
4848
"machineId": derive_id(uid, "machine"), "sessionId": derive_id(uid, "session"),
49-
"extName": "workbuddy-desktop", "extVersion": "5.5.6",
50-
"os": "win32", "arch": "x64", "osVersion": "10.0.26220",
51-
"cpuCores": 20, "memorySize": 24, "timestamp": now, "presentAt": now}
49+
"extName": "workbuddy-desktop", "extVersion": "5.9.3",
50+
"os": "win32", "arch": "x64", "osVersion": "10.0.26200",
51+
"cpuCores": 24, "memorySize": 32, "timestamp": now, "presentAt": now}
5252

5353

5454
def _post_json(base, path, headers, payload):
@@ -106,13 +106,15 @@ def chat_request_event(uid, conversation_id, request_id="", model_id="", model_n
106106
"inputLength": 12, "requestModelId": model_id,
107107
"requestModelName": model_name or model_id, "isPlan": False,
108108
"isAutoExecuteTerminal": False, "isAutoModify": False, "codebaseEnable": False,
109-
"maxToken": 0, "maxSteps": 0, "temperature": 0, "maxRetries": 0,
109+
"maxToken": 0, "maxSteps": 0, "temperature": 0.7, "maxRetries": 2,
110110
"mentionContexts": [], "knowledgeId": [], "knowledgeName": [], "codebaseId": "",
111111
"mentionContextCount": 0, "command": "", "expertId": "", "recommendId": "",
112112
"skillId": "", "skillCount": 0, "totalCount": 0, "fileUri": "",
113113
"presentAt": now, "traceId": "", "rootRequestId": request_id,
114114
"parentConversationId": conversation_id, "agentName": "default",
115-
"agentType": "conversation", "userId": uid}
115+
"agentType": "conversation", "device_id": uid, "device_type": "desktop",
116+
"sdk_version": "5.9.3", "chat_session_uuid": conversation_id,
117+
"platform": "windows", "title": "对话", "userId": uid}
116118

117119

118120
def report_chat_activity(base, token, uid, conversation_id, request_id="", model_id="",
@@ -260,8 +262,10 @@ def desktop_chat(base, headers, expert_id="", model=FAST_MODEL, prompt="1+1等
260262
"stream_options": {"include_usage": True}}
261263
outgoing = {**headers, "X-Domain": base, "X-Product": "SaaS", "X-Conversation-ID": conversation_id,
262264
"X-Request-ID": str(int(time.time() * 1000)), "X-Agent-Intent": "craft",
263-
"X-Agent-Type": "main", "X-IDE-Name": "WorkBuddy", "X-IDE-Type": "WorkBuddy",
264-
"X-IDE-Version": "5.5.6", "x-codebuddy-request": "1",
265+
"X-Agent-Type": "auxiliary", "X-Agent-Runtime": "local-agent",
266+
"X-IDE-Name": "WorkBuddy", "X-IDE-Type": "WorkBuddy",
267+
"X-IDE-Version": "5.9.3", "x-codebuddy-request": "1",
268+
"X-Requested-With": "XMLHttpRequest", "User-Agent": DESKTOP_UA,
265269
"Accept": "text/event-stream"}
266270
outgoing.pop("X-Client-Platform", None)
267271
if expert_id:

‎app/rate_measurement.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import httpx
2727

2828
from .observability import normalize_usage
29+
from .site_routing import profile_for_headers
2930
from .upstream_compat import channel_fallback_url
3031
from .upstream_io import open_backend_stream_sync
3132

@@ -201,9 +202,12 @@ def probe_once(url, headers, model, *, timeout=120.0):
201202
"messages": [{"role": "system", "content": "You are a helpful assistant."},
202203
{"role": "user", "content": probe_prompt()}]}
203204
usage = None
205+
request_headers = {**headers, "Accept": "text/event-stream"}
206+
if profile_for_headers(headers) == "cn-work":
207+
request_headers.update({"X-Agent-Type": "auxiliary", "X-Agent-Runtime": "local-agent"})
204208
with httpx.Client(follow_redirects=False, timeout=httpx.Timeout(timeout, connect=10)) as client:
205209
with open_backend_stream_sync(
206-
client, url, {**headers, "Accept": "text/event-stream"}, body,
210+
client, url, request_headers, body,
207211
fallback_url=channel_fallback_url(url)) as (response, raw):
208212
if response.status_code != 200:
209213
raise _upstream_error(response, raw)

‎app/request_context.py‎

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,23 +69,53 @@ class Attempt:
6969

7070

7171
_WORKBUDDY_PROFILES = frozenset({"cn-work", "intl-work"})
72+
_UUID_TIME_ANCHOR_MS = 1767225600000
73+
_UUID_TIME_WINDOW_MS = 24 * 30 * 24 * 60 * 60 * 1000
7274

7375

7476
def _uuid_text(value):
7577
return str(uuid.UUID(hex=value)) if isinstance(value, str) and len(value) == 32 else str(value)
7678

7779

78-
def build_attempt_headers(headers, profile, *, root, message_id, request_id, trace_id, span, parent):
80+
def _stable_uuid7(parts):
81+
"""Derive a stable UUIDv7-shaped identifier without persisting client session state."""
82+
raw = bytearray.fromhex(_digest(parts)[:32])
83+
timestamp = _UUID_TIME_ANCHOR_MS + int.from_bytes(raw[:8], "big") % _UUID_TIME_WINDOW_MS
84+
raw[:6] = timestamp.to_bytes(6, "big")
85+
raw[6] = (raw[6] & 0x0F) | 0x70
86+
raw[8] = (raw[8] & 0x3F) | 0x80
87+
return str(uuid.UUID(bytes=bytes(raw)))
88+
89+
90+
def session_conversation_id(profile, account, session_key):
91+
parts = [profile, account, session_key]
92+
return _stable_uuid7(parts) if profile == "cn-work" else str(uuid.UUID(hex=_digest(parts)[:32]))
93+
94+
95+
def build_attempt_headers(headers, profile, *, root, message_id, request_id, trace_id, span, parent,
96+
conversation_id=None):
7997
"""Build one upstream attempt with profile-specific ID relationships."""
8098
if profile in _WORKBUDDY_PROFILES:
8199
message_id, request_id = _uuid_text(message_id), _uuid_text(request_id)
82100
else:
83101
request_id, trace_id = message_id, root
84-
return {**headers, "X-Request-ID": request_id, "X-Conversation-Message-ID": message_id,
85-
"X-Conversation-Request-ID": root, "X-Root-Request-ID": root, "X-Trace-ID": trace_id,
86-
"traceparent": f"00-{trace_id}-{span}-01", "b3": f"{trace_id}-{span}-1-{parent}",
87-
"X-B3-TraceId": trace_id, "X-B3-SpanId": span, "X-B3-ParentSpanId": parent,
88-
"X-B3-Sampled": "1"}
102+
outgoing = {**headers, "X-Request-ID": request_id, "X-Conversation-Message-ID": message_id,
103+
"X-Conversation-Request-ID": root, "X-Root-Request-ID": root, "X-Trace-ID": trace_id,
104+
"traceparent": f"00-{trace_id}-{span}-01", "b3": f"{trace_id}-{span}-1-{parent}",
105+
"X-B3-TraceId": trace_id, "X-B3-SpanId": span, "X-B3-ParentSpanId": parent,
106+
"X-B3-Sampled": "1"}
107+
if profile == "cn-work":
108+
outgoing["X-Agent-Runtime"] = "local-agent"
109+
if conversation_id:
110+
outgoing["X-Session-ID"] = conversation_id
111+
outgoing["X-Root-Request-ID"] = _stable_uuid7(["root", conversation_id]).replace("-", "")
112+
outgoing["acp-connection-id"] = _stable_uuid7(["acp", conversation_id])
113+
outgoing["baggage"] = (
114+
f"buddyId=,buddyName=,codebuddy.session_id={conversation_id},"
115+
f"codebuddy.conversation_request_id={root},x-agent-runtime=local-agent,"
116+
f"x-agent-intent={headers.get('X-Agent-Intent', 'craft')}"
117+
)
118+
return outgoing
89119

90120

91121
class RequestContext:
@@ -136,7 +166,7 @@ def bind_session(self, payload, messages):
136166
self._bound = True
137167

138168
def conversation_id(self, profile, account):
139-
return str(uuid.UUID(hex=_digest([profile, account, self.session_key])[:32]))
169+
return session_conversation_id(profile, account, self.session_key)
140170

141171
def start_attempt(self):
142172
with self._lock:
@@ -153,6 +183,7 @@ def attempt_headers(self, headers, attempt, profile=None):
153183
headers, profile, root=self.request_id, message_id=attempt.id,
154184
request_id=attempt.request_id, trace_id=attempt.trace_id,
155185
span=attempt.span, parent=self._root_span,
186+
conversation_id=headers.get("X-Conversation-ID"),
156187
)
157188

158189
def attempt_metadata(self):

‎app/upstream_io.py‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Bound upstream retries and prohibit replay after a response has opened."""
22

33
import asyncio
4+
import gzip
45
from contextlib import asynccontextmanager, contextmanager
56
from datetime import timezone
67
from email.utils import parsedate_to_datetime
@@ -12,6 +13,7 @@
1213

1314
from app.content_filter import ContentFilterDetector
1415
from app.upstream_compat import should_channel_fallback
16+
from app.site_routing import profile_for_headers
1517

1618

1719
class UpstreamResponseError(Exception):
@@ -394,6 +396,26 @@ def read_bounded_sync_error(response, limit: int = ERROR_BODY_LIMIT) -> bytes:
394396
return buffer.bytes()
395397

396398

399+
def _json_request_kwargs(headers, body, url):
400+
"""Serialize replay-safe chat bytes using the profile's gzip contract."""
401+
if httpx.URL(url).path not in ("/v2/chat/completions", "/console/chat/completions"):
402+
return {"headers": dict(headers), "json": body}
403+
request_headers = {**headers, "Accept-Encoding": "identity"}
404+
names = {str(name).lower() for name in headers}
405+
if not names.intersection({"x-domain", "x-ide-type"}):
406+
return {"headers": request_headers, "json": body}
407+
profile = profile_for_headers(headers)
408+
raw = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
409+
compressed = (profile in ("cn-cli", "intl-cli", "intl-work") or
410+
(profile == "cn-work" and len(raw) >= 1024))
411+
if not compressed:
412+
return {"headers": request_headers, "json": body}
413+
if not any(str(name).lower() == "content-type" for name in request_headers):
414+
request_headers["Content-Type"] = "application/json"
415+
request_headers["Content-Encoding"] = "gzip"
416+
return {"headers": request_headers, "content": gzip.compress(raw, compresslevel=6, mtime=0)}
417+
418+
397419
@contextmanager
398420
def open_backend_stream_sync(client, url, headers, body, *, timeout=None, fallback_url=None):
399421
"""Open a sync upstream stream, retrying once on the console endpoint after a channel rejection.
@@ -404,7 +426,7 @@ def open_backend_stream_sync(client, url, headers, body, *, timeout=None, fallba
404426
"""
405427
while True:
406428
raw = None
407-
with client.stream("POST", url, headers=headers, json=body, timeout=timeout) as response:
429+
with client.stream("POST", url, timeout=timeout, **_json_request_kwargs(headers, body, url)) as response:
408430
if fallback_url and response.status_code == 400:
409431
raw = read_bounded_sync_error(response)
410432
if should_channel_fallback(fallback_url, response.status_code, raw):
@@ -443,7 +465,8 @@ async def open_backend_stream(url, headers, body, *, read_timeout=300, on_retry=
443465
try:
444466
async with _attempt_client(url, timeout, clients if attempt == 0 else None) as client:
445467
attempt_headers = headers_for_attempt() if headers_for_attempt is not None else headers
446-
async with client.stream("POST", url, headers=attempt_headers, json=body, timeout=timeout) as response:
468+
async with client.stream("POST", url, timeout=timeout,
469+
**_json_request_kwargs(attempt_headers, body, url)) as response:
447470
opened = True
448471
yield response
449472
return

0 commit comments

Comments
 (0)