diff --git a/app/buddy_task.py b/app/buddy_task.py index 3d4e9b2..087a159 100644 --- a/app/buddy_task.py +++ b/app/buddy_task.py @@ -49,7 +49,8 @@ def _chat(client, headers, record, model): "X-Parent-Conversation-ID": conversation, "X-Request-ID": request_id, "X-Root-Request-ID": request_id, "X-Conversation-Request-ID": request_id, "X-Conversation-Message-ID": request_id, "X-Agent-Intent": "craft", - "X-Agent-Purpose": "conversation", "X-Agent-Type": "main"} + "X-Agent-Purpose": "conversation", "X-Agent-Type": "auxiliary", + "X-Agent-Runtime": "local-agent"} event = {"eventCode": "chat_request_send", "id": conversation, "extra": { "inputLength": len(PROMPT), "requestModelId": model["id"], "requestModelName": model["name"], "mode": "craft", "command": "", "expertId": ""}} diff --git a/app/client_profiles.py b/app/client_profiles.py index 665954a..9542446 100644 --- a/app/client_profiles.py +++ b/app/client_profiles.py @@ -1,12 +1,15 @@ """Build separate CLI and WorkBuddy identity headers with shared authentication fields.""" +import base64 +import binascii import hashlib import json +import uuid from .site_routing import domain_for_auth, profile_for_auth, profile_product, profile_region CLI_VERSION = "2.165.0" -WORKBUDDY_VERSION = "5.9.2" +WORKBUDDY_VERSION = "5.9.3" WORKBUDDY_CLI_VERSION = CLI_VERSION INTL_CLI_VERSION = "2.161.0" INTL_WORKBUDDY_VERSION = "5.8.4" @@ -61,23 +64,89 @@ def _auth_headers(auth: dict, account: dict) -> dict: "X-Domain": domain_for_auth(auth), "X-Product": "SaaS", "X-Requested-With": "XMLHttpRequest", } - headers.update(identity_headers(profile_for_auth(auth))) + profile = profile_for_auth(auth) + headers.update(identity_headers(profile)) + if profile in ("cn-cli", "cn-work"): + # CLI 2.165+ disables model optimization for enterprise accounts. + headers["X-Private-Data"] = "true" if is_enterprise_account(account) else "false" + return headers + + +def _jwt_claims(auth: dict) -> dict: + """Read unsigned claims only as client metadata; upstream still authenticates the token.""" + token = auth.get("accessToken") + if not isinstance(token, str) or token.count(".") != 2: + return {} + try: + encoded = token.split(".")[1] + decoded = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)) + payload = json.loads(decoded) + except (ValueError, binascii.Error, UnicodeError): + return {} + return payload if isinstance(payload, dict) else {} + + +def _identity_value(value) -> str: + if not isinstance(value, str): + return "" + value = value.strip() + return value if value and len(value) <= 128 and value.isascii() and value.isprintable() else "" + + +def _identity_claim_headers(auth: dict, account: dict) -> dict: + claims = _jwt_claims(auth) + id_source = _identity_value(account.get("idSource") or account.get("id_source") or claims.get("id_source")) + token_source = _identity_value(claims.get("token_source") or auth.get("method")) + enterprise_id = enterprise_id_of(account) or "" + uid = canonical_uid(account) + headers = {} + if id_source: + headers["X-Id-Source"] = id_source + if token_source: + headers["X-Auth-Method"] = token_source + if uid and (enterprise_id or id_source or token_source): + userinfo = {"uin": uid} + if enterprise_id: + userinfo["owner_uin"] = enterprise_id + if id_source: + userinfo["id_source"] = id_source + if token_source: + userinfo["token_source"] = token_source + encoded = json.dumps(userinfo, separators=(",", ":")).encode("utf-8") + headers["X-Userinfo"] = base64.b64encode(encoded).decode("ascii") return headers def credential_headers(auth: dict, account: dict) -> dict: - return {**SDK_HEADERS, **_auth_headers(auth, account)} + return {**SDK_HEADERS, **_auth_headers(auth, account), **_identity_claim_headers(auth, account)} + + +def _cn_work_nonchat_headers(headers: dict, profile: str) -> dict: + if profile == "cn-work": + headers["Accept"] = "application/json, text/plain, */*" + headers["X-Request-ID"] = uuid.uuid4().hex + headers.pop("X-CodeBuddy-Request", None) + return headers + + +def credential_refresh_headers(auth: dict, account: dict) -> dict: + headers = credential_headers(auth, account) + return _cn_work_nonchat_headers(headers, profile_for_auth(auth)) + def refresh_headers(domain: str, uid: str = "") -> dict: """Build headers for a refresh exchange that has no access token yet.""" - headers = {**SDK_HEADERS, **_auth_headers({"accessToken": "", "domain": domain}, {"uid": uid})} + auth = {"accessToken": "", "domain": domain} + headers = {**SDK_HEADERS, **_auth_headers(auth, {"uid": uid})} headers.pop("Authorization", None) # The refresh token is the only credential in this request. - return headers + return _cn_work_nonchat_headers(headers, profile_for_auth(auth)) + def catalog_headers(auth: dict, account: dict | None = None, *, user_agent="") -> dict: - headers = _auth_headers(auth, account or {}) + profile = profile_for_auth(auth) + headers = _cn_work_nonchat_headers(_auth_headers(auth, account or {}), profile) headers["Connection"] = "close" - if profile_product(profile_for_auth(auth)) == "cli": + if profile_product(profile) == "cli": headers["x-client-platform"] = "cli" if user_agent: headers["User-Agent"] = user_agent diff --git a/app/credits.py b/app/credits.py index 5be23f1..4e5603a 100644 --- a/app/credits.py +++ b/app/credits.py @@ -8,6 +8,7 @@ import math import os import re +import secrets import threading import time from pathlib import Path @@ -166,6 +167,8 @@ def _checkin_request(access_token, uid, domain, path): headers = {"accept": "application/json", "content-type": "application/json", "authorization": f"Bearer {access_token}", "x-user-id": str(uid or ""), "x-domain": str(domain or "")} + if profile_for_auth({"accessToken": access_token, "domain": domain}) == "cn-work": + headers.update({"X-Request-ID": secrets.token_hex(16), "User-Agent": "axios/1.16.1"}) url = host + path with httpx.Client() as client: try: diff --git a/app/growth_events.py b/app/growth_events.py index 684fa42..6895ba8 100644 --- a/app/growth_events.py +++ b/app/growth_events.py @@ -1,6 +1,6 @@ """Growth-center behavior chains: desktop/web event reporting and real-chat helpers. -Payload shapes mirror the official WorkBuddy desktop client (5.5.6) field-for-field, as +Payload shapes mirror the official WorkBuddy desktop client (5.9.3) field-for-field, as validated by the upstream panel across multiple live accounts. Report hosts follow the account's own product endpoint; the desktop fingerprint is stable per uid so repeated chains look like one persistent device. @@ -14,8 +14,8 @@ import httpx -DESKTOP_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) WorkBuddyDesktop/5.5.6 " - "Chrome/132.0.6834.83 Electron/34.0.0 Safari/537.36") +DESKTOP_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) WorkBuddyDesktop/5.9.3 " + "Chrome/148.0.7778.97 Electron/41.8.1 Safari/537.36") WEB_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36") REPORT_PATH = "/v2/report" @@ -42,13 +42,13 @@ def fingerprint(uid, nickname=""): now = int(time.time() * 1000) return {"timezone": "Asia/Shanghai", "reportDelay": 2000, "userId": uid, "username": nickname, "userNickname": nickname, "product": "SaaS", - "releaseDate": 1789036585355, - "commit": "5f9692923c93033111c51ad7b003eb80204a9b75", - "ideName": "WorkBuddy", "ideType": "WorkBuddy", "ideVersion": "5.5.6", + "releaseDate": 1789632000000, + "commit": "c41253385fa07bebfcae8a7a4728914f09af96d0", + "ideName": "WorkBuddy", "ideType": "WorkBuddy", "ideVersion": "5.9.3", "machineId": derive_id(uid, "machine"), "sessionId": derive_id(uid, "session"), - "extName": "workbuddy-desktop", "extVersion": "5.5.6", - "os": "win32", "arch": "x64", "osVersion": "10.0.26220", - "cpuCores": 20, "memorySize": 24, "timestamp": now, "presentAt": now} + "extName": "workbuddy-desktop", "extVersion": "5.9.3", + "os": "win32", "arch": "x64", "osVersion": "10.0.26200", + "cpuCores": 24, "memorySize": 32, "timestamp": now, "presentAt": now} def _post_json(base, path, headers, payload): @@ -106,13 +106,15 @@ def chat_request_event(uid, conversation_id, request_id="", model_id="", model_n "inputLength": 12, "requestModelId": model_id, "requestModelName": model_name or model_id, "isPlan": False, "isAutoExecuteTerminal": False, "isAutoModify": False, "codebaseEnable": False, - "maxToken": 0, "maxSteps": 0, "temperature": 0, "maxRetries": 0, + "maxToken": 0, "maxSteps": 0, "temperature": 0.7, "maxRetries": 2, "mentionContexts": [], "knowledgeId": [], "knowledgeName": [], "codebaseId": "", "mentionContextCount": 0, "command": "", "expertId": "", "recommendId": "", "skillId": "", "skillCount": 0, "totalCount": 0, "fileUri": "", "presentAt": now, "traceId": "", "rootRequestId": request_id, "parentConversationId": conversation_id, "agentName": "default", - "agentType": "conversation", "userId": uid} + "agentType": "conversation", "device_id": uid, "device_type": "desktop", + "sdk_version": "5.9.3", "chat_session_uuid": conversation_id, + "platform": "windows", "title": "对话", "userId": uid} 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等 "stream_options": {"include_usage": True}} outgoing = {**headers, "X-Domain": base, "X-Product": "SaaS", "X-Conversation-ID": conversation_id, "X-Request-ID": str(int(time.time() * 1000)), "X-Agent-Intent": "craft", - "X-Agent-Type": "main", "X-IDE-Name": "WorkBuddy", "X-IDE-Type": "WorkBuddy", - "X-IDE-Version": "5.5.6", "x-codebuddy-request": "1", + "X-Agent-Type": "auxiliary", "X-Agent-Runtime": "local-agent", + "X-IDE-Name": "WorkBuddy", "X-IDE-Type": "WorkBuddy", + "X-IDE-Version": "5.9.3", "x-codebuddy-request": "1", + "X-Requested-With": "XMLHttpRequest", "User-Agent": DESKTOP_UA, "Accept": "text/event-stream"} outgoing.pop("X-Client-Platform", None) if expert_id: diff --git a/app/rate_measurement.py b/app/rate_measurement.py index 32c2b61..d898e75 100644 --- a/app/rate_measurement.py +++ b/app/rate_measurement.py @@ -26,6 +26,7 @@ import httpx from .observability import normalize_usage +from .site_routing import profile_for_headers from .upstream_compat import channel_fallback_url from .upstream_io import open_backend_stream_sync @@ -201,9 +202,12 @@ def probe_once(url, headers, model, *, timeout=120.0): "messages": [{"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": probe_prompt()}]} usage = None + request_headers = {**headers, "Accept": "text/event-stream"} + if profile_for_headers(headers) == "cn-work": + request_headers.update({"X-Agent-Type": "auxiliary", "X-Agent-Runtime": "local-agent"}) with httpx.Client(follow_redirects=False, timeout=httpx.Timeout(timeout, connect=10)) as client: with open_backend_stream_sync( - client, url, {**headers, "Accept": "text/event-stream"}, body, + client, url, request_headers, body, fallback_url=channel_fallback_url(url)) as (response, raw): if response.status_code != 200: raise _upstream_error(response, raw) diff --git a/app/request_context.py b/app/request_context.py index f54c2b0..1b36f7b 100644 --- a/app/request_context.py +++ b/app/request_context.py @@ -69,23 +69,53 @@ class Attempt: _WORKBUDDY_PROFILES = frozenset({"cn-work", "intl-work"}) +_UUID_TIME_ANCHOR_MS = 1767225600000 +_UUID_TIME_WINDOW_MS = 24 * 30 * 24 * 60 * 60 * 1000 def _uuid_text(value): return str(uuid.UUID(hex=value)) if isinstance(value, str) and len(value) == 32 else str(value) -def build_attempt_headers(headers, profile, *, root, message_id, request_id, trace_id, span, parent): +def _stable_uuid7(parts): + """Derive a stable UUIDv7-shaped identifier without persisting client session state.""" + raw = bytearray.fromhex(_digest(parts)[:32]) + timestamp = _UUID_TIME_ANCHOR_MS + int.from_bytes(raw[:8], "big") % _UUID_TIME_WINDOW_MS + raw[:6] = timestamp.to_bytes(6, "big") + raw[6] = (raw[6] & 0x0F) | 0x70 + raw[8] = (raw[8] & 0x3F) | 0x80 + return str(uuid.UUID(bytes=bytes(raw))) + + +def session_conversation_id(profile, account, session_key): + parts = [profile, account, session_key] + return _stable_uuid7(parts) if profile == "cn-work" else str(uuid.UUID(hex=_digest(parts)[:32])) + + +def build_attempt_headers(headers, profile, *, root, message_id, request_id, trace_id, span, parent, + conversation_id=None): """Build one upstream attempt with profile-specific ID relationships.""" if profile in _WORKBUDDY_PROFILES: message_id, request_id = _uuid_text(message_id), _uuid_text(request_id) else: request_id, trace_id = message_id, root - return {**headers, "X-Request-ID": request_id, "X-Conversation-Message-ID": message_id, - "X-Conversation-Request-ID": root, "X-Root-Request-ID": root, "X-Trace-ID": trace_id, - "traceparent": f"00-{trace_id}-{span}-01", "b3": f"{trace_id}-{span}-1-{parent}", - "X-B3-TraceId": trace_id, "X-B3-SpanId": span, "X-B3-ParentSpanId": parent, - "X-B3-Sampled": "1"} + outgoing = {**headers, "X-Request-ID": request_id, "X-Conversation-Message-ID": message_id, + "X-Conversation-Request-ID": root, "X-Root-Request-ID": root, "X-Trace-ID": trace_id, + "traceparent": f"00-{trace_id}-{span}-01", "b3": f"{trace_id}-{span}-1-{parent}", + "X-B3-TraceId": trace_id, "X-B3-SpanId": span, "X-B3-ParentSpanId": parent, + "X-B3-Sampled": "1"} + if profile == "cn-work": + outgoing["X-Agent-Runtime"] = "local-agent" + if conversation_id: + outgoing["X-Session-ID"] = conversation_id + outgoing["X-Root-Request-ID"] = _stable_uuid7(["root", conversation_id]).replace("-", "") + outgoing["acp-connection-id"] = _stable_uuid7(["acp", conversation_id]) + outgoing["baggage"] = ( + f"buddyId=,buddyName=,codebuddy.session_id={conversation_id}," + f"codebuddy.conversation_request_id={root},x-agent-runtime=local-agent," + f"x-agent-intent={headers.get('X-Agent-Intent', 'craft')}" + ) + return outgoing class RequestContext: @@ -136,7 +166,7 @@ def bind_session(self, payload, messages): self._bound = True def conversation_id(self, profile, account): - return str(uuid.UUID(hex=_digest([profile, account, self.session_key])[:32])) + return session_conversation_id(profile, account, self.session_key) def start_attempt(self): with self._lock: @@ -153,6 +183,7 @@ def attempt_headers(self, headers, attempt, profile=None): headers, profile, root=self.request_id, message_id=attempt.id, request_id=attempt.request_id, trace_id=attempt.trace_id, span=attempt.span, parent=self._root_span, + conversation_id=headers.get("X-Conversation-ID"), ) def attempt_metadata(self): diff --git a/app/upstream_io.py b/app/upstream_io.py index 8f54fe6..7d2e0f9 100644 --- a/app/upstream_io.py +++ b/app/upstream_io.py @@ -1,6 +1,7 @@ """Bound upstream retries and prohibit replay after a response has opened.""" import asyncio +import gzip from contextlib import asynccontextmanager, contextmanager from datetime import timezone from email.utils import parsedate_to_datetime @@ -12,6 +13,7 @@ from app.content_filter import ContentFilterDetector from app.upstream_compat import should_channel_fallback +from app.site_routing import profile_for_headers class UpstreamResponseError(Exception): @@ -394,6 +396,26 @@ def read_bounded_sync_error(response, limit: int = ERROR_BODY_LIMIT) -> bytes: return buffer.bytes() +def _json_request_kwargs(headers, body, url): + """Serialize replay-safe chat bytes using the profile's gzip contract.""" + if httpx.URL(url).path not in ("/v2/chat/completions", "/console/chat/completions"): + return {"headers": dict(headers), "json": body} + request_headers = {**headers, "Accept-Encoding": "identity"} + names = {str(name).lower() for name in headers} + if not names.intersection({"x-domain", "x-ide-type"}): + return {"headers": request_headers, "json": body} + profile = profile_for_headers(headers) + raw = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + compressed = (profile in ("cn-cli", "intl-cli", "intl-work") or + (profile == "cn-work" and len(raw) >= 1024)) + if not compressed: + return {"headers": request_headers, "json": body} + if not any(str(name).lower() == "content-type" for name in request_headers): + request_headers["Content-Type"] = "application/json" + request_headers["Content-Encoding"] = "gzip" + return {"headers": request_headers, "content": gzip.compress(raw, compresslevel=6, mtime=0)} + + @contextmanager def open_backend_stream_sync(client, url, headers, body, *, timeout=None, fallback_url=None): """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 """ while True: raw = None - with client.stream("POST", url, headers=headers, json=body, timeout=timeout) as response: + with client.stream("POST", url, timeout=timeout, **_json_request_kwargs(headers, body, url)) as response: if fallback_url and response.status_code == 400: raw = read_bounded_sync_error(response) 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= try: async with _attempt_client(url, timeout, clients if attempt == 0 else None) as client: attempt_headers = headers_for_attempt() if headers_for_attempt is not None else headers - async with client.stream("POST", url, headers=attempt_headers, json=body, timeout=timeout) as response: + async with client.stream("POST", url, timeout=timeout, + **_json_request_kwargs(attempt_headers, body, url)) as response: opened = True yield response return diff --git a/converter.py b/converter.py index 5b35027..07b062c 100644 --- a/converter.py +++ b/converter.py @@ -84,7 +84,7 @@ def apply_system_prompt_mode(body, profile, mode): read_bounded_error) from app.inference_resources import (AccountCapacity, InferenceResourcesMiddleware, inference_lifespan, request_resources, release_credential) -from app.request_context import SessionIdentifierError, build_attempt_headers, current_context +from app.request_context import SessionIdentifierError, build_attempt_headers, current_context, session_conversation_id from app.reasoning import resolve_reasoning_effort, thinking_mode from app import model_capabilities from app.message_normalization import merge_intl_user_images @@ -112,8 +112,8 @@ def apply_system_prompt_mode(body, profile, mode): refresh_url_for_auth, media_url_for_headers) from app import dsml, mcp_server, server_tools, web_fetch, web_search, media_generation, media_store from app.inbound_limits import anthropic_path -from app.client_profiles import (CLI_VERSION, CLI_USER_AGENT, credential_headers, catalog_cache_key, - account_key, canonical_uid, enterprise_id_of, identity_email, +from app.client_profiles import (CLI_VERSION, CLI_USER_AGENT, credential_headers, credential_refresh_headers, + catalog_cache_key, account_key, canonical_uid, enterprise_id_of, identity_email, is_enterprise_account, normalize_uid) try: from app import credits as credits_mod @@ -377,7 +377,7 @@ def _refresh_locked(self): """Share the import lock so token refresh cannot overwrite a newer login.""" s = self._session() auth = s.get("auth") or {} - headers = self._build_headers_from(auth, _credential_account(s)) + headers = credential_refresh_headers(auth, _credential_account(s)) headers["X-Refresh-Token"] = auth.get("refreshToken", "") headers["X-Auth-Refresh-Source"] = "plugin" url = refresh_url_for_auth(auth) @@ -630,10 +630,13 @@ def _dynamic_request_headers(skey: str | None, profile=None) -> dict: message_id, request_id, trace_id = (uuid.uuid4().hex for _ in range(3)) root = secrets.token_hex(16) span, parent = secrets.token_hex(8), secrets.token_hex(8) - conv = str(uuid.UUID(hex=hashlib.sha256(skey.encode()).hexdigest()[:32])) if skey else str(uuid.uuid4()) + if profile == "cn-work": + conv = session_conversation_id(profile, "", skey or uuid.uuid4().hex) + else: + conv = str(uuid.UUID(hex=hashlib.sha256(skey.encode()).hexdigest()[:32])) if skey else str(uuid.uuid4()) return {"X-Conversation-ID": conv, **build_attempt_headers( {}, profile, root=root, message_id=message_id, request_id=request_id, - trace_id=trace_id, span=span, parent=parent, + trace_id=trace_id, span=span, parent=parent, conversation_id=conv, )} diff --git a/docs/advanced.md b/docs/advanced.md index 9eac581..553de88 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -137,7 +137,7 @@ Generation responses carry a gateway-generated `X-Request-ID` for correlation wi In scoped mode, optionally send `X-Codebuddy-Session-ID`, `metadata.conversation_id` / `metadata.conversationId`, or top-level `conversation_id` / `conversationId`. Values must agree; conflicting, non-string, control-character or over-512-UTF-8-byte values return 400. Empty values fall back to a fingerprint of adapted instructions and the first user input, including image references; URLs are not fetched. Without reliable input a temporary session is used. Identical inputs without explicit IDs remain indistinguishable; `user`, `metadata.user_id` and `prompt_cache_key` are not session IDs. Raw hints are neither logged nor forwarded upstream. Switch back to `legacy` to restore old behavior for new requests; in-flight requests retain their initial mode. -Upstream client identities follow verified official packages: domestic CLI 2.165.0 / WorkBuddy 5.9.2 and international CLI 2.161.0 / WorkBuddy 5.8.4. WorkBuddy attempts use independent message/request UUIDs and trace IDs; CLI keeps its existing ID relationships. Headers are generated locally without reading hardware IDs. +Upstream client identities follow verified official packages: domestic CLI 2.165.0 / WorkBuddy 5.9.3 and international CLI 2.161.0 / WorkBuddy 5.8.4. Domestic WorkBuddy uses session-stable UUIDv7 conversation IDs, per-attempt request/tracing headers and gzip for JSON bodies of at least 1024 bytes; CLI and international WorkBuddy use their existing ID relationships and always gzip chat JSON. Headers are generated locally without reading hardware IDs. ## Automation and rewards diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md index 8fbf3f1..5f2ae1a 100644 --- a/docs/advanced.zh-CN.md +++ b/docs/advanced.zh-CN.md @@ -137,7 +137,7 @@ WebUI 系统设置可配置这两项;环境变量为 `CODEBUDDY2API_UPSTREAM_K scoped 模式可选传入 `X-Codebuddy-Session-ID`、`metadata.conversation_id` / `metadata.conversationId` 或顶层 `conversation_id` / `conversationId`。多处值须一致;冲突、非字符串、控制字符或超过 512 UTF-8 字节时返回 400。空值回退为协议适配后的指令与首条用户输入指纹,包含图片引用但不抓取 URL;缺少可靠输入时使用临时会话。无显式 ID 的相同输入仍无法区分;`user`、`metadata.user_id`、`prompt_cache_key` 不是会话 ID。原始标识不记录、不转发上游。 设回 `legacy` 即恢复新请求的旧行为,在途请求保留入口模式。 -上游客户端身份按已核实的官方包对齐:国内 CLI 2.165.0/WorkBuddy 5.9.2,国际 CLI 2.161.0/WorkBuddy 5.8.4。WorkBuddy 请求使用独立的 message/request UUID 和 trace ID,CLI 保持原有 ID 关系。所有身份头均在本地生成,不读取硬件 ID。 +上游客户端身份按已核实的官方包对齐:国内 CLI 2.165.0/WorkBuddy 5.9.3,国际 CLI 2.161.0/WorkBuddy 5.8.4。国内 WorkBuddy 使用会话稳定的 UUIDv7 会话 ID、逐次请求/追踪头,并在 JSON 请求体达到 1024 字节时使用 gzip;CLI 与国际 WorkBuddy 保持原有 ID 关系,聊天 JSON 始终使用 gzip。所有身份头均在本地生成,不读取硬件 ID。 ## 自动化与奖励 diff --git a/preview/upstream.py b/preview/upstream.py index be50c9f..11752a8 100644 --- a/preview/upstream.py +++ b/preview/upstream.py @@ -7,6 +7,7 @@ """ import asyncio +import gzip from dataclasses import dataclass, field import itertools import json @@ -217,6 +218,11 @@ async def handle_async(self, request: httpx.Request) -> httpx.Response: def dispatch(self, request: httpx.Request, raw: bytes) -> Reply: path = request.url.path + if request.headers.get("content-encoding", "").lower() == "gzip": + try: + raw = gzip.decompress(raw) + except (OSError, EOFError): + return fail(400, "gzip 请求体无法解压") if request.url.host == "api.github.com": return self.releases() if path.endswith(".tar.gz"): diff --git a/tests/test_api_flow.py b/tests/test_api_flow.py index 52302ec..9774742 100644 --- a/tests/test_api_flow.py +++ b/tests/test_api_flow.py @@ -19,6 +19,7 @@ from app import upstream_io from app.adapters.responses_adapter import responses_request_to_chat import test_region_routing as fixtures +request_json = fixtures.request_json REAL_ASYNC_CLIENT = httpx.AsyncClient @@ -231,7 +232,7 @@ def test_chat_and_responses_preserve_keys_through_projection_and_routing(self): payload["prompt_cache_key"] = "synthetic-cache" response = self.fx.client.post("/v1/" + route, json=payload) self.assertEqual(response.status_code, 200, response.text) - self.assertEqual(json.loads(self.fx.requests[-1].content)["prompt_cache_key"], "synthetic-cache") + self.assertEqual(request_json(self.fx.requests[-1])["prompt_cache_key"], "synthetic-cache") class PreparationResponsivenessTests(GatewayFixture, unittest.IsolatedAsyncioTestCase): diff --git a/tests/test_buddy_task.py b/tests/test_buddy_task.py index 1c37103..ae18fa7 100644 --- a/tests/test_buddy_task.py +++ b/tests/test_buddy_task.py @@ -76,6 +76,8 @@ def test_one_checkbox_completes_unaccepted_task_without_acceptance_endpoint(self "/v2/chat/completions", "/activity/growth/buddy/agreement", "/activity/growth/buddy/first", "/activity/growth/buddy/travel/depart"]) chat = self.writes()[0] + self.assertEqual(chat.headers["X-Agent-Type"], "auxiliary") + self.assertEqual(chat.headers["X-Agent-Runtime"], "local-agent") body = json.loads(chat.content) self.assertEqual(body["max_tokens"], 32) self.assertNotIn("tools", body) diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 897e562..d6329af 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -218,8 +218,13 @@ def test_fetch_profile_endpoints_and_headers(self): client.get.assert_called_once() args, kwargs = client.get.call_args self.assertEqual(args, (host + "/v3/config",)) - self.assertEqual(kwargs["headers"], catalog_headers( - {"accessToken": token, "domain": hint}, {"uid": "user", "enterpriseId": "enterprise"})) + expected_headers = catalog_headers( + {"accessToken": token, "domain": hint}, {"uid": "user", "enterpriseId": "enterprise"}) + actual_headers = kwargs["headers"] + if profile == "cn-work": + self.assertRegex(actual_headers["X-Request-ID"], r"^[0-9a-f]{32}$") + actual_headers = {**actual_headers, "X-Request-ID": expected_headers["X-Request-ID"]} + self.assertEqual(actual_headers, expected_headers) headers = httpx.Headers(kwargs["headers"]) self.assertEqual(headers["authorization"], f"Bearer {token}") self.assertEqual(headers["x-domain"], domain) diff --git a/tests/test_chat_input.py b/tests/test_chat_input.py index 8785f3d..da04862 100644 --- a/tests/test_chat_input.py +++ b/tests/test_chat_input.py @@ -243,7 +243,7 @@ def setUp(self): def test_mixed_history_is_normalized_before_upstream_in_both_modes(self): def reply(request): - body = json.loads(request.content) + body = routing.request_json(request) for message in body["messages"]: if isinstance(message.get("content"), list): for index, block in enumerate(message["content"]): @@ -263,7 +263,7 @@ def reply(request): "model": "auto", "messages": messages, "tools": TOOLS, "stream": stream}) self.assertEqual(response.status_code, 200, response.text) self.assertEqual(len(self.fx.requests), 1) - body = json.loads(self.fx.requests[0].content) + body = routing.request_json(self.fx.requests[0]) assistant_index = next(index for index, message in enumerate(body["messages"]) if message["role"] == "assistant" and message.get("tool_calls")) assistant = body["messages"][assistant_index] @@ -281,7 +281,7 @@ def test_late_system_and_developer_tool_history_reaches_upstream(self): "model": "auto", "messages": messages, "stream": stream}) self.assertEqual(response.status_code, 200, response.text) self.assertEqual(len(self.fx.requests), 1) - sent = json.loads(self.fx.requests[0].content)["messages"] + sent = routing.request_json(self.fx.requests[0])["messages"] self.assertEqual(sent[0], {"role": "system", "content": "late instructions"}) self.assertEqual([m["role"] for m in sent], ["system", "user", "assistant", "tool"]) self.assertEqual(sent[2]["tool_calls"][0]["id"], sent[3]["tool_call_id"]) @@ -323,7 +323,7 @@ def test_nested_image_policy_precedes_conversion(self): response = self.fx.client.post("/v1/chat/completions", json={"model": "auto", "messages": messages}) self.assertEqual(response.status_code, status, response.text) if policy == "truncate": - body = json.loads(self.fx.requests[0].content) + body = routing.request_json(self.fx.requests[0]) self.assertEqual(body["messages"][-1]["content"], [IMAGE]) else: self.assertEqual(self.fx.requests, []) @@ -350,7 +350,7 @@ def test_converted_history_reaches_each_profile_and_releases_capacity(self): payload = self.fx.payload("chat/completions", stream=stream) payload.update(messages=history(), tools=TOOLS) self.fx.post_ok("chat/completions", payload, {profile}) - body = json.loads(self.fx.requests[-1].content) + body = routing.request_json(self.fx.requests[-1]) assistant = next(m for m in body["messages"] if m["role"] == "assistant") tool = next(m for m in body["messages"] if m["role"] == "tool") self.assertEqual(assistant["tool_calls"][0]["id"], tool["tool_call_id"]) diff --git a/tests/test_credits.py b/tests/test_credits.py index 11a4776..e4c4618 100644 --- a/tests/test_credits.py +++ b/tests/test_credits.py @@ -103,6 +103,12 @@ def test_financial_profile_hosts_and_web_headers(): headers = httpx.Headers(kwargs["headers"]) if operation is credits.daily_checkin: assert "x-client-platform" not in headers and "origin" not in headers and "referer" not in headers + if domain == "www.workbuddy.cn": + assert len(headers["x-request-id"]) == 32 + int(headers["x-request-id"], 16) + assert headers["user-agent"] == "axios/1.16.1" + else: + assert "x-request-id" not in headers else: assert headers["x-client-platform"] == "web" assert headers["origin"] == host diff --git a/tests/test_dsml.py b/tests/test_dsml.py index eb2e179..4401874 100755 --- a/tests/test_dsml.py +++ b/tests/test_dsml.py @@ -283,7 +283,7 @@ def setUp(self): def handle_upstream(self, request): if self.upstream_parts: - body = json.loads(request.content) + body = fixtures.request_json(request) self.assertNotIn("tools", body, "泄漏场景的前提就是本请求未声明 tools") lines = sse_lines_for_block(self.upstream_parts) payload = ("".join(line + "\n\n" for line in lines)).encode() diff --git a/tests/test_growth_chains.py b/tests/test_growth_chains.py index 39867f8..6ae59d9 100755 --- a/tests/test_growth_chains.py +++ b/tests/test_growth_chains.py @@ -124,6 +124,16 @@ def test_chat_5_reports_only_the_gap(self): self.assertEqual(len(reports), 2) # 3/5 → 补 2 条 self.assertEqual(reports[0]["body"][0]["eventCode"], "chat_request_send") self.assertEqual(reports[0]["body"][0]["userId"], "u1") + event = reports[0]["body"][0] + self.assertEqual(event["ideVersion"], "5.9.3") + self.assertEqual(event["sdk_version"], "5.9.3") + self.assertEqual(event["platform"], "windows") + self.assertEqual(event["chat_session_uuid"], event["conversationId"]) + self.assertEqual(event["title"], "对话") + self.assertEqual(event["temperature"], 0.7) + self.assertEqual(event["maxRetries"], 2) + self.assertEqual(event["cpuCores"], 24) + self.assertIn("WorkBuddyDesktop/5.9.3", reports[0]["headers"]["user-agent"]) def test_library_read_uses_web_shape_on_account_domain(self): result = self.run_chain("Library_read") diff --git a/tests/test_identity_sync.py b/tests/test_identity_sync.py index 82521d9..6ba9c5f 100644 --- a/tests/test_identity_sync.py +++ b/tests/test_identity_sync.py @@ -4,6 +4,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. +import base64 import io import json import os @@ -93,7 +94,7 @@ def test_official_versions_and_private_data_are_profile_scoped(self): expected = { "cn-cli": ("CLI/2.165.0 CodeBuddy/2.165.0", "2.165.0", "false", "2.165.0"), "intl-cli": ("CLI/2.161.0 CodeBuddy/2.161.0", "2.161.0", "false", "2.161.0"), - "cn-work": ("WorkBuddy/5.9.2 WorkBuddy/5.9.2 CLI/2.165.0", "5.9.2", "true", "5.9.2:2.165.0"), + "cn-work": ("WorkBuddy/5.9.3 WorkBuddy/5.9.3 CLI/2.165.0", "5.9.3", "false", "5.9.3:2.165.0"), "intl-work": ("WorkBuddy/5.8.4 WorkBuddy AI/5.8.4 CLI/2.161.0", "5.8.4", "true", "5.8.4:2.161.0"), } for profile, (user_agent, ide_version, private_data, revision) in expected.items(): @@ -105,6 +106,45 @@ def test_official_versions_and_private_data_are_profile_scoped(self): self.assertEqual(headers["X-Private-Data"], private_data) self.assertEqual(client_profiles.catalog_cache_key(profile), f"{profile}:{revision}") + def test_private_data_and_identity_headers_follow_the_real_account(self): + for profile, domain in DOMAINS.items(): + with self.subTest(profile=profile): + headers = client_profiles.credential_headers( + {"domain": domain, "accessToken": "synthetic"}, + {"uid": "A", "enterpriseId": "tenant"}) + expected = "true" if profile in ("cn-cli", "cn-work", "intl-work") else "false" + self.assertEqual(headers["X-Private-Data"], expected) + self.assertEqual(headers["X-Enterprise-Id"], "tenant") + self.assertNotIn("X-Id-Source", headers) + + payload = base64.urlsafe_b64encode(json.dumps({ + "id_source": "sso_login", "token_source": "qq"}).encode()).rstrip(b"=").decode() + token = f"header.{payload}.signature" + headers = client_profiles.credential_headers( + {"domain": "www.workbuddy.cn", "accessToken": token, "method": "oauth"}, + {"uid": "A", "enterpriseId": "tenant"}) + self.assertEqual(headers["X-Id-Source"], "sso_login") + self.assertEqual(headers["X-Auth-Method"], "qq") + userinfo = json.loads(base64.b64decode(headers["X-Userinfo"])) + self.assertEqual(userinfo, { + "uin": "A", "owner_uin": "tenant", "id_source": "sso_login", "token_source": "qq"}) + self.assertNotIn(token, headers["X-Userinfo"]) + + plain = client_profiles.credential_headers( + {"domain": "www.codebuddy.cn", "accessToken": "not-a-jwt"}, {"uid": "A"}) + self.assertNotIn("X-Userinfo", plain) + self.assertNotIn("X-Auth-Method", plain) + + auth = {"domain": "www.workbuddy.cn", "accessToken": "synthetic"} + for headers in (client_profiles.catalog_headers(auth, {"uid": "A"}), + client_profiles.refresh_headers("www.workbuddy.cn", "A"), + client_profiles.credential_refresh_headers(auth, {"uid": "A"})): + self.assertRegex(headers["X-Request-ID"], r"^[0-9a-f]{32}$") + self.assertEqual(headers["Accept"], "application/json, text/plain, */*") + self.assertNotIn("X-CodeBuddy-Request", headers) + self.assertNotIn("X-Request-ID", client_profiles.catalog_headers( + {"domain": "www.codebuddy.cn", "accessToken": "synthetic"}, {"uid": "A"})) + def test_account_fingerprint_is_stable_and_unambiguous(self): key = client_profiles.account_key self.assertEqual(key("cn-cli", "A", "tenant"), key("cn-cli", "A", "tenant")) diff --git a/tests/test_message_normalization.py b/tests/test_message_normalization.py index 853767e..3e62374 100644 --- a/tests/test_message_normalization.py +++ b/tests/test_message_normalization.py @@ -91,7 +91,7 @@ def test_three_protocols_both_modes_only_international_bodies_are_merged(self): body[key].append(user("question")) original = deepcopy(body) self.fx.post_ok(protocol, body, {profile}) - sent = json.loads(self.fx.requests[-1].content) + sent = fixtures.request_json(self.fx.requests[-1]) self.assertEqual(sum(m["role"] == "user" for m in sent["messages"]), 1 if profile.startswith("intl") else 2) self.assertEqual(len(images(sent)), 1) self.assertEqual(body, original) @@ -103,7 +103,7 @@ def test_guard_off_does_not_disable_image_normalization(self): body["messages"].append(user("question")) with patch.dict(gateway.CONFIG, model_capability_guard=False): self.fx.post_ok("chat/completions", body, {"intl-work"}) - self.assertEqual(sum(m["role"] == "user" for m in json.loads(self.fx.requests[-1].content)["messages"]), 1) + self.assertEqual(sum(m["role"] == "user" for m in fixtures.request_json(self.fx.requests[-1])["messages"]), 1) def test_unmergeable_runs_release_capacity_without_calling_upstream(self): self.fx.configure(profiles=("intl-work",)) @@ -136,7 +136,7 @@ def test_international_to_domestic_failover_uses_the_unmerged_canonical_body(sel self.fx.account_catalogs({"intl-work": [model()], "cn-cli": [model()]}) seen = [] def reply(request): - seen.append((request.headers["x-domain"], json.loads(request.content))) + seen.append((request.headers["x-domain"], fixtures.request_json(request))) if len(seen) == 1: return httpx.Response(429, json={"error": {"message": "synthetic quota", "code": "quota"}}) return httpx.Response(200, content=fixtures.fixtures.success_sse()) diff --git a/tests/test_model_rates.py b/tests/test_model_rates.py index bc59c7d..ac556eb 100644 --- a/tests/test_model_rates.py +++ b/tests/test_model_rates.py @@ -5,6 +5,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. +import gzip import json import os import tempfile @@ -188,9 +189,15 @@ def test_reads_credit_from_the_streamed_usage(self): with patch("httpx.Client") as factory: factory.return_value.__enter__ = Mock(return_value=client) factory.return_value.__exit__ = Mock(return_value=False) - result = rate_measurement.probe_once("https://upstream/chat", {"Authorization": "Bearer t"}, "promo-f") + result = rate_measurement.probe_once( + "https://upstream/chat", + {"Authorization": "Bearer t", "X-Domain": "www.workbuddy.cn", "X-IDE-Type": "WorkBuddy"}, + "promo-f") self.assertEqual(result, {"credit": 0.06, "input_tokens": 8100, "cache_read_tokens": 0, "output_tokens": 2}) - body = client.stream.call_args.kwargs["json"] + kwargs = client.stream.call_args.kwargs + body = json.loads(gzip.decompress(kwargs["content"])) if "content" in kwargs else kwargs["json"] + self.assertEqual(client.stream.call_args.kwargs["headers"]["X-Agent-Type"], "auxiliary") + self.assertEqual(client.stream.call_args.kwargs["headers"]["X-Agent-Runtime"], "local-agent") self.assertEqual(body["model"], "promo-f") self.assertGreater(len(body["messages"][1]["content"]), 20000) diff --git a/tests/test_nonstream_disconnect.py b/tests/test_nonstream_disconnect.py index df9ca87..1342c4b 100644 --- a/tests/test_nonstream_disconnect.py +++ b/tests/test_nonstream_disconnect.py @@ -77,7 +77,7 @@ def reset_upstream(self): # Inject a pending upstream read, optionally preceded by a quota rejection. def handle_upstream(self, request): - if HANG_MARKER in request.content.decode("utf-8", "replace"): + if HANG_MARKER in fixtures.request_content(request).decode("utf-8", "replace"): self.hang_attempts += 1 self.hang_uids.append(request.headers.get("x-user-id")) if self.hang_attempts == 1 and self.fail_over_first: diff --git a/tests/test_preview.py b/tests/test_preview.py index 6fa712f..2933f82 100644 --- a/tests/test_preview.py +++ b/tests/test_preview.py @@ -9,6 +9,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. import asyncio +import gzip import json import os import socket @@ -149,6 +150,15 @@ def test_chat_streams_text_with_usage(self): self.assertIn("模拟回复", text) self.assertGreater(events[-1]["usage"]["credit"], 0) + def test_gzip_chat_body_is_decoded_before_dispatch(self): + token, _, _ = login("aurora") + body = json.dumps({"model": "glm-5.1", "messages": [{"role": "user", "content": "你好"}]}).encode() + response = httpx.post("https://copilot.tencent.com/v2/chat/completions", + headers={"Authorization": f"Bearer {token}", "Content-Encoding": "gzip"}, + content=gzip.compress(body)) + self.assertEqual(response.status_code, 200, response.text) + self.assertIn("data: [DONE]", response.text) + def test_console_chat_uses_the_same_simulated_upstream(self): token, _, _ = login("aurora") response = httpx.post("https://copilot.tencent.com/console/chat/completions", diff --git a/tests/test_reasoning_requests.py b/tests/test_reasoning_requests.py index 94ff06d..09fcda7 100644 --- a/tests/test_reasoning_requests.py +++ b/tests/test_reasoning_requests.py @@ -74,7 +74,7 @@ def setUp(self): self.fx.respond = self.respond def respond(self, request): - body = json.loads(request.content) + body = fixtures.request_json(request) delta = {"content": "answer"} if body.get("reasoning_effort") not in (None, "none"): delta["reasoning_content"] = "new reasoning" @@ -86,7 +86,7 @@ def post(self, protocol, body, *, stream=False, mode="compatible", projection="b with patch.dict(gateway.CONFIG, stream_mode=mode, responses_projection_mode=projection, desensitize=desensitize): response = self.fx.client.post("/v1/" + protocol, json={**body, "stream": stream}) self.assertEqual(body, original) - upstream = [json.loads(request.content) for request in self.fx.requests[before:]] + upstream = [fixtures.request_json(request) for request in self.fx.requests[before:]] return response, upstream def test_controls_and_output_in_all_modes(self): @@ -350,7 +350,7 @@ def respond(request): self.assertNotEqual(seen[0].headers["x-user-id"], seen[1].headers["x-user-id"]) for request in seen: expected = {"cn-cli": "low", "intl-work": "high"}[request.headers["x-user-id"]] - self.assertEqual(json.loads(request.content)["reasoning_effort"], expected) + self.assertEqual(fixtures.request_json(request)["reasoning_effort"], expected) self.assertEqual(self.fx.pool._capacity._counts, {}) def test_activation_cannot_escape_free_tier_or_strict_binding(self): diff --git a/tests/test_region_routing.py b/tests/test_region_routing.py index 82714f8..7c0ae7d 100644 --- a/tests/test_region_routing.py +++ b/tests/test_region_routing.py @@ -5,6 +5,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. from copy import deepcopy +import gzip import json import os import tempfile @@ -29,6 +30,14 @@ GENERATIONS = ("chat/completions", "responses", "messages") +def request_content(request): + return gzip.decompress(request.content) if request.headers.get("Content-Encoding") == "gzip" else request.content + + +def request_json(request): + return json.loads(request_content(request)) + + def model(identifier, credits=None): value = {"id": identifier, "name": identifier, "supportsToolCall": True, "supportsImages": True, "credits": {"input": 1, "output": 2}} @@ -150,7 +159,13 @@ def handle_upstream(self, request): self.assertEqual(request.url.host, HOSTS[profile]) self.assertEqual(request.headers["x-domain"], self.credentials[uid]["auth"]["domain"]) self.assertEqual(request.headers["authorization"], "Bearer synthetic-access-" + uid) - for name, value in client_profiles.identity_headers(profile).items(): + expected_private = "true" if request.headers.get("X-Enterprise-Id") else "false" + if profile in ("cn-cli", "cn-work"): + self.assertEqual(request.headers["X-Private-Data"], expected_private, profile) + expected_identity = client_profiles.identity_headers(profile) + if profile == "cn-work": + expected_identity["X-Private-Data"] = expected_private + for name, value in expected_identity.items(): self.assertEqual(request.headers[name], value, (profile, name)) self.assertEqual(request.headers["x-ide-type"], "CLI" if profile.endswith("-cli") else "WorkBuddy") @@ -216,7 +231,7 @@ def post_ok(self, endpoint, payload, allowed): self.assertEqual(data["content"][0]["text"], "ok") self.assertEqual(len(self.requests), before + 1, response.text) request = self.requests[-1] - return request, json.loads(request.content) + return request, request_json(request) def post_rejected(self, endpoint, payload, statuses=(404, 503)): self.allowed_profiles = set() diff --git a/tests/test_request_context.py b/tests/test_request_context.py index 7f614f2..d7a6ba6 100644 --- a/tests/test_request_context.py +++ b/tests/test_request_context.py @@ -1,11 +1,13 @@ """Verify request/session isolation and tracing with synthetic credentials and offline upstreams.""" import asyncio from concurrent.futures import ThreadPoolExecutor +import gzip from copy import deepcopy import json from pathlib import Path import sys import unittest +import uuid from unittest.mock import AsyncMock, patch from types import SimpleNamespace @@ -33,6 +35,10 @@ def context(payload, *, protocol="chat", headers=(), messages=None): return ctx +def request_body(request): + return gzip.decompress(request.content) if request.headers.get("Content-Encoding") == "gzip" else request.content + + class SessionTests(unittest.TestCase): def test_explicit_aliases_and_header_share_a_key_without_disclosing_the_value(self): value = "synthetic-private-conversation" @@ -135,6 +141,37 @@ def test_attempt_ids_are_unique_and_request_mode_is_a_snapshot(self): self.assertEqual(headers["traceparent"], f"00-{ctx.request_id}-{attempt.span}-01") self.assertEqual(headers["X-B3-SpanId"], attempt.span) self.assertEqual(base, {"Authorization": "synthetic", "X-Conversation-ID": "conversation"}) + def test_cn_work_session_ids_are_stable_uuid7_and_attempt_scoped(self): + first = context({"conversation_id": "same"}) + second = context({"conversation_id": "same"}) + conversation = first.conversation_id("cn-work", "account") + self.assertEqual(conversation, second.conversation_id("cn-work", "account")) + self.assertNotEqual(conversation, first.conversation_id("cn-work", "other")) + self.assertEqual(uuid.UUID(conversation).version, 7) + + base = {"X-Conversation-ID": conversation, "X-Agent-Intent": "craft"} + headers = first.attempt_headers(base, first.start_attempt(), profile="cn-work") + later = second.attempt_headers(base, second.start_attempt(), profile="cn-work") + self.assertEqual(headers["X-Session-ID"], conversation) + self.assertEqual(headers["X-Agent-Runtime"], "local-agent") + self.assertEqual(uuid.UUID(headers["acp-connection-id"]).version, 7) + self.assertEqual(headers["acp-connection-id"], later["acp-connection-id"]) + self.assertEqual(headers["X-Root-Request-ID"], later["X-Root-Request-ID"]) + self.assertNotEqual(headers["X-Conversation-Request-ID"], later["X-Conversation-Request-ID"]) + self.assertNotEqual(headers["X-Root-Request-ID"], headers["X-Conversation-Request-ID"]) + baggage = dict(item.split("=", 1) for item in headers["baggage"].split(",")) + self.assertEqual(set(baggage), { + "buddyId", "buddyName", "codebuddy.session_id", "codebuddy.conversation_request_id", + "x-agent-runtime", "x-agent-intent"}) + self.assertEqual(baggage["codebuddy.session_id"], conversation) + self.assertEqual(baggage["codebuddy.conversation_request_id"], headers["X-Conversation-Request-ID"]) + + for profile in ("cn-cli", "intl-cli", "intl-work"): + other = first.attempt_headers(base, first.start_attempt(), profile=profile) + self.assertNotIn("X-Agent-Runtime", other) + self.assertNotIn("acp-connection-id", other) + self.assertNotIn("baggage", other) + class EndpointContextTests(fixtures.GatewayFixture, unittest.TestCase): @@ -146,13 +183,20 @@ def assert_attempts(self, response, seen, *, same_account=True): self.assertEqual(response.status_code, 200, response.text) root = response.headers["X-Request-ID"] self.assertRegex(root, r"^[0-9a-f]{32}$") - self.assertEqual({r.headers["X-Root-Request-ID"] for r in seen}, {root}) self.assertEqual(len({r.headers["X-Request-ID"] for r in seen}), len(seen)) self.assertEqual(len({r.headers["X-Conversation-ID"] for r in seen}), 1 if same_account else len(seen)) for request in seen: headers = request.headers workbuddy = headers["X-IDE-Type"] == "WorkBuddy" trace = headers["X-Trace-ID"] + if gateway.profile_for_headers(headers) == "cn-work": + self.assertRegex(headers["X-Root-Request-ID"], r"^[0-9a-f]{32}$") + self.assertNotEqual(headers["X-Root-Request-ID"], root) + self.assertEqual(headers["X-Session-ID"], headers["X-Conversation-ID"]) + self.assertEqual(headers["X-Agent-Runtime"], "local-agent") + else: + self.assertEqual(headers["X-Root-Request-ID"], root) + self.assertNotIn("X-Agent-Runtime", headers) if workbuddy: self.assertNotEqual(trace, root) self.assertNotEqual(headers["X-Request-ID"], headers["X-Conversation-Message-ID"]) @@ -185,7 +229,7 @@ def test_profiles_protocols_and_modes_preserve_body_ids_and_isolate_requests(sel prefix = {"chat/completions": "chatcmpl-", "responses": "resp_", "messages": "msg_"}[route] self.assertTrue(first.json()["id"].startswith(prefix)) self.assertNotEqual(first.json()["id"], first.headers["X-Request-ID"]) - self.assertNotIn("metadata", json.loads(request.content)) + self.assertNotIn("metadata", json.loads(request_body(request))) def test_channel_identity_guard_reaches_all_protocols_when_desensitize_off(self): source = "You are an AI agent powered by DeepSeek Harness." @@ -199,7 +243,7 @@ def test_channel_identity_guard_reaches_all_protocols_when_desensitize_off(self) with patch.dict(gateway.CONFIG, {"desensitize": False}): response = self.fx.client.post("/v1/" + route, json=body) self.assertEqual(response.status_code, 200, response.text) - upstream = self.fx.requests[-1].content.decode("utf-8") + upstream = request_body(self.fx.requests[-1]).decode("utf-8") self.assertNotIn(source, upstream) self.assertGreaterEqual(upstream.count("You are WorkBuddy, a powerful AI assistant"), 2) @@ -220,7 +264,7 @@ def test_prompt_modes_cover_profiles_protocols_and_client_instructions(self): with patch.dict(gateway.CONFIG, {"prompt_mode": mode, "desensitize": False}): response = self.fx.client.post("/v1/" + route, json=body) self.assertEqual(response.status_code, 200, response.text) - upstream = json.loads(self.fx.requests[-1].content) + upstream = json.loads(request_body(self.fx.requests[-1])) messages = upstream["messages"] systems = [m for m in messages if m.get("role") == "system"] if mode == "prepend": @@ -394,7 +438,10 @@ def respond(request): response = self.fx.client.post("/v1/" + route, json=self.fx.payload(route, stream=True)) self.assertIn("late", response.text) self.assertEqual(len(seen), 1) - self.assertEqual(seen[0].headers["X-Root-Request-ID"], response.headers["X-Request-ID"]) + if gateway.profile_for_headers(seen[0].headers) == "cn-work": + self.assertNotEqual(seen[0].headers["X-Root-Request-ID"], response.headers["X-Request-ID"]) + else: + self.assertEqual(seen[0].headers["X-Root-Request-ID"], response.headers["X-Request-ID"]) self.assertEqual(self.fx.pool._capacity._counts, {}) diff --git a/tests/test_server_tools.py b/tests/test_server_tools.py index 658a759..9a5977b 100644 --- a/tests/test_server_tools.py +++ b/tests/test_server_tools.py @@ -905,7 +905,7 @@ def handle_upstream(self, request): return super().handle_upstream(request) def posted_tools(self): - return [json.loads(request.content).get("tools") for request in self.chat_requests] + return [fixtures.request_json(request).get("tools") for request in self.chat_requests] def test_messages_web_search_roundtrip(self): self.chat_rounds = [tool_call_round("WebSearch", {"query": QUERY}), final_round()] @@ -925,11 +925,11 @@ def test_messages_web_search_roundtrip(self): for tools in self.posted_tools(): self.assertFalse(any(t["function"]["name"].startswith("_") for t in tools)) # The agenttool search carries the declared domain filter. - sent = json.loads(self.tool_requests[0].content) + sent = fixtures.request_json(self.tool_requests[0]) self.assertEqual(sent["query"], QUERY) self.assertEqual(sent["allowed_domains"], ["example.com"]) # Round two carries the executed tool result back upstream. - second = json.loads(self.chat_requests[1].content) + second = fixtures.request_json(self.chat_requests[1]) self.assertEqual(second["messages"][-1]["role"], "tool") self.assertIn(URL, second["messages"][-1]["content"]) @@ -945,7 +945,7 @@ def test_messages_web_fetch_stream_roundtrip(self): '"type": "web_fetch_tool_result"', '"type": "web_fetch_result"', '"type": "document"', '"type": "text_delta"', "message_stop"): self.assertIn(fragment, text) - sent = json.loads(self.tool_requests[0].content) + sent = fixtures.request_json(self.tool_requests[0]) self.assertEqual(sent["url"], URL) self.assertEqual(sent["prompt"], "summarize") @@ -973,7 +973,7 @@ def test_chat_web_search_options(self): self.assertEqual(data["choices"][0]["message"]["content"], "ok") self.assertNotIn("tool_calls", data["choices"][0]["message"]) self.assertIn("WebSearch", [t["function"]["name"] for t in self.posted_tools()[0]]) - self.assertEqual(json.loads(self.tool_requests[0].content)["max_results"], 3) + self.assertEqual(fixtures.request_json(self.tool_requests[0])["max_results"], 3) def assert_valid_chat_sse(self, text): """Events must be framed by blank lines and the stream must end with [DONE].""" @@ -1010,10 +1010,10 @@ def test_chat_hosted_tool_choice_forces_websearch(self): self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.json()["choices"][0]["message"]["content"], "ok") self.assertEqual(len(self.tool_requests), 1) - first = json.loads(self.chat_requests[0].content) + first = fixtures.request_json(self.chat_requests[0]) self.assertEqual(first["tool_choice"], "required") self.assertEqual([t["function"]["name"] for t in first["tools"]], ["WebSearch"]) - self.assertEqual(json.loads(self.chat_requests[1].content)["tool_choice"], "auto") + self.assertEqual(fixtures.request_json(self.chat_requests[1])["tool_choice"], "auto") def test_chat_hosted_tool_choice_without_hosted_tool_is_auto(self): payload = self.payload() @@ -1022,7 +1022,7 @@ def test_chat_hosted_tool_choice_without_hosted_tool_is_auto(self): payload["tool_choice"] = {"type": "web_search"} response = self.client.post("/v1/chat/completions", json=payload) self.assertEqual(response.status_code, 200, response.text) - sent = json.loads(self.chat_requests[0].content) + sent = fixtures.request_json(self.chat_requests[0]) self.assertEqual(sent["tool_choice"], "auto") self.assertEqual([t["function"]["name"] for t in sent["tools"]], ["client_tool"]) self.assertEqual(self.tool_requests, []) @@ -1045,7 +1045,7 @@ def test_no_declaration_keeps_tools_untouched(self): "input_schema": {"type": "object", "properties": {}}}] response = self.client.post("/v1/messages", json=payload) self.assertEqual(response.status_code, 200, response.text) - sent = json.loads(self.chat_requests[0].content) + sent = fixtures.request_json(self.chat_requests[0]) self.assertEqual([t["function"]["name"] for t in sent["tools"]], ["client_tool"]) self.assertEqual(self.tool_requests, []) @@ -1077,7 +1077,7 @@ def test_messages_tool_choice_any_completes(self): self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.json()["content"][-1]["type"], "text") # The continuation round ran with a relaxed choice. - second = json.loads(self.chat_requests[1].content) + second = fixtures.request_json(self.chat_requests[1]) self.assertEqual(second["tool_choice"], "auto") def test_messages_tool_choice_named_web_search(self): @@ -1087,7 +1087,7 @@ def test_messages_tool_choice_named_web_search(self): payload["tool_choice"] = {"type": "tool", "name": "web_search"} response = self.client.post("/v1/messages", json=payload) self.assertEqual(response.status_code, 200, response.text) - first = json.loads(self.chat_requests[0].content) + first = fixtures.request_json(self.chat_requests[0]) self.assertEqual(first["tool_choice"], "required") self.assertEqual([t["function"]["name"] for t in first["tools"]], ["WebSearch"]) @@ -1098,7 +1098,7 @@ def test_responses_hosted_tool_choice(self): payload["tool_choice"] = {"type": "web_search"} response = self.client.post("/v1/responses", json=payload) self.assertEqual(response.status_code, 200, response.text) - sent = json.loads(self.chat_requests[0].content) + sent = fixtures.request_json(self.chat_requests[0]) self.assertEqual(sent["tool_choice"], "required") def post_realtime(self, endpoint, tools, rounds): @@ -1171,7 +1171,7 @@ def test_client_owned_websearch_name_stays_client(self): response = self.client.post("/v1/messages", json=payload) self.assertEqual(response.status_code, 200, response.text) # No gateway injection or execution; the client's own tool call passes through. - sent = json.loads(self.chat_requests[0].content) + sent = fixtures.request_json(self.chat_requests[0]) self.assertEqual([t["function"]["name"] for t in sent["tools"]], ["WebSearch"]) self.assertEqual(self.tool_requests, []) self.assertEqual(response.json()["stop_reason"], "tool_use") @@ -1234,7 +1234,7 @@ def test_dated_hosted_tool_choice_forces_websearch(self): payload["tool_choice"] = dict(tool) response = self.client.post("/v1/" + endpoint, json=payload) self.assertEqual(response.status_code, 200, response.text) - first = json.loads(self.chat_requests[0].content) + first = fixtures.request_json(self.chat_requests[0]) self.assertEqual(first["tool_choice"], "required") self.assertEqual([t["function"]["name"] for t in first["tools"]], ["WebSearch"]) @@ -1245,7 +1245,7 @@ def test_chat_dated_hosted_tool_choice_without_hosted_tool_is_auto(self): payload["tool_choice"] = {"type": "web_search_preview_2025_03_11"} response = self.client.post("/v1/chat/completions", json=payload) self.assertEqual(response.status_code, 200, response.text) - self.assertEqual(json.loads(self.chat_requests[0].content)["tool_choice"], "auto") + self.assertEqual(fixtures.request_json(self.chat_requests[0])["tool_choice"], "auto") def test_search_snippets_reach_the_model(self): self.chat_rounds = [tool_call_round("WebSearch", {"query": QUERY}), final_round()] @@ -1253,11 +1253,11 @@ def test_search_snippets_reach_the_model(self): payload["tools"] = [{"type": "web_search_20250305", "name": "web_search"}] response = self.client.post("/v1/messages", json=payload) self.assertEqual(response.status_code, 200, response.text) - second = json.loads(self.chat_requests[1].content) + second = fixtures.request_json(self.chat_requests[1]) self.assertEqual(second["messages"][-1]["content"], f"1. [Example]({URL})\n\n[1] Snippet.") def last_upstream_messages(self): - return json.loads(self.chat_requests[-1].content)["messages"] + return fixtures.request_json(self.chat_requests[-1])["messages"] def test_messages_history_echo_carries_search_and_fetch_to_the_next_turn(self): self.chat_rounds = [tool_call_round("WebSearch", {"query": QUERY}), final_round("found it")] @@ -1279,7 +1279,7 @@ def test_messages_history_echo_carries_search_and_fetch_to_the_next_turn(self): {"role": "user", "content": "and then?"}] response = self.client.post("/v1/messages", json=follow_up) self.assertEqual(response.status_code, 200, response.text) - sent = json.loads(self.chat_requests[-1].content) + sent = fixtures.request_json(self.chat_requests[-1]) # Echoed server blocks reach the model as text, never as tool calls. self.assertNotIn("tools", sent) messages = [m for m in sent["messages"] if m["role"] != "system"] @@ -1538,7 +1538,7 @@ def test_failover_retry_does_not_carry_the_failed_attempts_tool_turns(self): response = self.client.post("/v1/messages", json=self.search_payload()) self.assertEqual(response.status_code, 200, response.text) self.assertEqual(len(self.chat_requests), 4) - messages = [json.loads(request.content)["messages"] for request in self.chat_requests] + messages = [fixtures.request_json(request)["messages"] for request in self.chat_requests] roles = [message["role"] for message in messages[0]] self.assertEqual(roles[-1], "user") self.assertEqual([message["role"] for message in messages[1]], diff --git a/tests/test_stream_failover.py b/tests/test_stream_failover.py index f0da597..9461135 100644 --- a/tests/test_stream_failover.py +++ b/tests/test_stream_failover.py @@ -236,8 +236,7 @@ def test_auto_is_rewritten_on_the_international_site(self): response, sent = self.auto_post() self.assertEqual(response.status_code, 200, response.text) self.assertEqual(len(sent), 1) - import json as _json - self.assertEqual(_json.loads(sent[0].content)["model"], "default-model") + self.assertEqual(fixtures.request_json(sent[0])["model"], "default-model") def test_failover_cannot_widen_the_credential_binding(self): """Preserve auto-model credential restrictions when failover reroutes an aliased request.""" @@ -498,7 +497,7 @@ def test_prompt_prepend_is_not_duplicated_across_failover(self): self.assertEqual(response.status_code, 200, response.text) self.assertEqual(len(sent), 2) for request in sent: - body = json.loads(request.content) + body = fixtures.request_json(request) systems = [m for m in body["messages"] if m.get("role") == "system"] self.assertTrue(systems[0]["content"].startswith("You are CodeBuddy Code.\n")) self.assertEqual(sum(m["content"] == systems[0]["content"] for m in systems), 1) diff --git a/tests/test_upstream_compat.py b/tests/test_upstream_compat.py index 107c7a4..36dc45c 100644 --- a/tests/test_upstream_compat.py +++ b/tests/test_upstream_compat.py @@ -6,6 +6,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. from copy import deepcopy +import gzip import json import time import unittest @@ -27,6 +28,11 @@ MODEL_MISSING = {"code": 11102, "msg": "model [shared-model] service info not found"} +def request_json(request): + raw = gzip.decompress(request.content) if request.headers.get("Content-Encoding") == "gzip" else request.content + return json.loads(raw) + + class ToolPatternTests(unittest.TestCase): def test_nested_patterns_are_normalized_without_mutating_input_or_instance_data(self): schema = { @@ -190,7 +196,7 @@ def test_tool_patterns_are_normalized_across_protocols(self): "name": "agent_run", "parameters": schema}}] response = self.client.post("/v1/" + endpoint, json=payload) self.assertEqual(response.status_code, 200, response.text) - sent = json.loads(self.requests[-1].content) + sent = request_json(self.requests[-1]) pattern = sent["tools"][0]["function"]["parameters"]["properties"]["runId"]["pattern"] self.assertEqual(pattern, "^agent_run_") @@ -203,7 +209,7 @@ def test_response_format_patterns_are_normalized_end_to_end(self): "name": "result", "schema": {"properties": {"runId": {"pattern": r"^agent\_run\_"}}}}} response = self.client.post("/v1/" + endpoint, json=payload) self.assertEqual(response.status_code, 200, response.text) - sent = json.loads(self.requests[-1].content) + sent = request_json(self.requests[-1]) schema = sent["response_format"]["json_schema"]["schema"] self.assertEqual(schema["properties"]["runId"]["pattern"], "^agent_run_") @@ -248,7 +254,7 @@ def test_channel_rejection_moves_system_to_user_once_on_same_account(self): self.assertEqual([request.url.path for request in self.requests], [ "/v2/chat/completions", "/console/chat/completions", "/console/chat/completions"]) self.assertEqual(len({request.headers["x-user-id"] for request in self.requests}), 1) - moved = json.loads(self.requests[-1].content) + moved = request_json(self.requests[-1]) self.assertEqual(moved["messages"][0], {"role": "system", "content": "You are a helpful assistant."}) self.assertEqual(moved["messages"][1]["role"], "user") self.assertIn("\nprivate system instructions", moved["messages"][1]["content"]) @@ -324,6 +330,9 @@ def handle(request): with self.assertRaises(growth_events.Failure): growth_events.desktop_chat("https://synthetic.invalid", {}) self.assertEqual([request.url.path for request in calls], ["/v2/chat/completions"]) + self.assertEqual(calls[0].headers["X-Agent-Type"], "auxiliary") + self.assertEqual(calls[0].headers["X-Agent-Runtime"], "local-agent") + self.assertEqual(calls[0].headers["X-IDE-Version"], "5.9.3") def test_rate_probe_uses_console_after_channel_rejection(self): calls = [] diff --git a/tests/test_upstream_io.py b/tests/test_upstream_io.py index 2a5300b..2ca3b1e 100644 --- a/tests/test_upstream_io.py +++ b/tests/test_upstream_io.py @@ -5,6 +5,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. +import gzip import json import unittest from unittest.mock import patch @@ -400,6 +401,71 @@ def handler(request): await response.aread() self.assertEqual(len(attempts), 1, "歧义请求不得重放 POST") + def test_sync_profile_body_uses_gzip(self): + body = {"messages": [{"role": "user", "content": "你好"}]} + headers = {"X-Domain": "www.codebuddy.cn", "X-IDE-Type": "CLI"} + + def handler(request): + self.assertEqual(request.headers["Content-Encoding"], "gzip") + self.assertEqual(json.loads(gzip.decompress(request.content)), body) + return httpx.Response(200, content=b"data: [DONE]\n\n") + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with upstream_io.open_backend_stream_sync(client, "https://synthetic.invalid/v2/chat/completions", headers, body) as (response, _): + self.assertEqual(response.status_code, 200) + + def test_non_chat_json_keeps_the_original_transport_contract(self): + body = {"query": "synthetic", "type": "text2text"} + headers = {"X-Domain": "www.codebuddy.cn", "X-IDE-Type": "CLI"} + + def handler(request): + self.assertNotIn("Content-Encoding", request.headers) + self.assertNotEqual(request.headers.get("Accept-Encoding"), "identity") + self.assertEqual(json.loads(request.content), body) + return httpx.Response(200, json={"code": 0, "data": {}}) + + with httpx.Client(transport=httpx.MockTransport(handler)) as client: + with upstream_io.open_backend_stream_sync( + client, "https://synthetic.invalid/agenttool/v1/search", headers, body) as (response, _): + self.assertEqual(response.status_code, 200) + + async def test_gzip_contract_and_retry_body_are_stable(self): + real_client = httpx.AsyncClient + profiles = { + "cn-cli": {"X-Domain": "www.codebuddy.cn", "X-IDE-Type": "CLI"}, + "cn-work": {"X-Domain": "www.workbuddy.cn", "X-IDE-Type": "WorkBuddy"}, + "intl-cli": {"X-Domain": "www.codebuddy.ai", "X-IDE-Type": "CLI"}, + "intl-work": {"X-Domain": "www.workbuddy.ai", "X-IDE-Type": "WorkBuddy"}, + } + large = {"messages": [{"role": "user", "content": "x" * 1400}]} + small = {"messages": [{"role": "user", "content": "small"}]} + cases = (("cn-cli", small, True), ("intl-cli", small, True), + ("intl-work", small, True), ("cn-work", small, False), + ("cn-work", large, True), (None, large, False)) + for profile, body, expected_gzip in cases: + with self.subTest(profile=profile, compressed=expected_gzip): + attempts = [] + + def handler(request): + attempts.append(request) + if len(attempts) == 1: + raise httpx.ConnectError("synthetic retry") + return httpx.Response(200, content=b"data: [DONE]\n\n") + + transport = httpx.MockTransport(handler) + with patch.object(upstream_io.httpx, "AsyncClient", + side_effect=lambda **kw: real_client(transport=transport, **kw)): + async with upstream_io.open_backend_stream( + "https://synthetic.invalid/v2/chat/completions", profiles.get(profile, {}), body) as response: + await response.aread() + self.assertEqual(len(attempts), 2) + self.assertEqual(attempts[0].content, attempts[1].content) + for request in attempts: + self.assertEqual(request.headers["Accept-Encoding"], "identity") + self.assertEqual("content-encoding" in request.headers, expected_gzip) + raw = gzip.decompress(request.content) if expected_gzip else request.content + self.assertEqual(json.loads(raw), body) + if __name__ == "__main__": unittest.main(verbosity=2)