workbuddy2api 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codebuddy_proxy/__main__.py +1572 -0
- codebuddy_proxy/anthropic_adapter.py +439 -0
- codebuddy_proxy/codebuddy_client_demo.py +312 -0
- codebuddy_proxy/desensitize.py +532 -0
- codebuddy_proxy/dsml_parser.py +888 -0
- codebuddy_proxy/projection_metadata.py +410 -0
- codebuddy_proxy/responses_adapter.py +487 -0
- codebuddy_proxy/responses_projection.py +746 -0
- workbuddy2api-2.0.0.dist-info/METADATA +634 -0
- workbuddy2api-2.0.0.dist-info/RECORD +14 -0
- workbuddy2api-2.0.0.dist-info/WHEEL +5 -0
- workbuddy2api-2.0.0.dist-info/entry_points.txt +2 -0
- workbuddy2api-2.0.0.dist-info/licenses/LICENSE +21 -0
- workbuddy2api-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Minimal CodeBuddy external-link-v2 client demo.
|
|
3
|
+
|
|
4
|
+
The protocol is extracted from Tencent Cloud CodeBuddy's VSIX. This demo
|
|
5
|
+
uses only the Python standard library and stores the session locally with
|
|
6
|
+
0600 permissions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import pathlib
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import urllib.request
|
|
20
|
+
import webbrowser
|
|
21
|
+
from typing import Any, Iterator
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CodeBuddyError(RuntimeError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CodeBuddyClient:
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
endpoint: str = "https://copilot.tencent.com",
|
|
32
|
+
platform: str = "VSCode",
|
|
33
|
+
session_file: pathlib.Path | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.endpoint = endpoint.rstrip("/")
|
|
36
|
+
self.platform = platform
|
|
37
|
+
self.prefix = "/plugin"
|
|
38
|
+
self.session_file = session_file or pathlib.Path.home() / ".codebuddy-session.json"
|
|
39
|
+
self.session: dict[str, Any] = self._load_session()
|
|
40
|
+
|
|
41
|
+
def _load_session(self) -> dict[str, Any]:
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(self.session_file.read_text())
|
|
44
|
+
except FileNotFoundError:
|
|
45
|
+
return {}
|
|
46
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
47
|
+
raise CodeBuddyError(f"无法读取 session 文件: {self.session_file}: {exc}") from exc
|
|
48
|
+
|
|
49
|
+
def _save_session(self, session: dict[str, Any]) -> None:
|
|
50
|
+
self.session_file.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
52
|
+
fd = os.open(self.session_file, flags, 0o600)
|
|
53
|
+
try:
|
|
54
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
55
|
+
json.dump(session, stream, ensure_ascii=False, indent=2)
|
|
56
|
+
stream.write("\n")
|
|
57
|
+
finally:
|
|
58
|
+
try:
|
|
59
|
+
os.chmod(self.session_file, 0o600)
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
self.session = session
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _unwrap(payload: Any) -> Any:
|
|
66
|
+
# CodeBuddy responses observed in the extension use {data: {data: ...}}.
|
|
67
|
+
if isinstance(payload, dict) and isinstance(payload.get("data"), dict):
|
|
68
|
+
nested = payload["data"]
|
|
69
|
+
if "data" in nested:
|
|
70
|
+
return nested["data"]
|
|
71
|
+
if isinstance(payload, dict) and "data" in payload:
|
|
72
|
+
return payload["data"]
|
|
73
|
+
return payload
|
|
74
|
+
|
|
75
|
+
def _request(
|
|
76
|
+
self,
|
|
77
|
+
method: str,
|
|
78
|
+
path: str,
|
|
79
|
+
*,
|
|
80
|
+
headers: dict[str, str] | None = None,
|
|
81
|
+
body: Any = None,
|
|
82
|
+
timeout: float = 30,
|
|
83
|
+
) -> Any:
|
|
84
|
+
request_headers = {"User-Agent": "CodeBuddyClientDemo/1.0"}
|
|
85
|
+
request_headers.update(headers or {})
|
|
86
|
+
data = None
|
|
87
|
+
if body is not None:
|
|
88
|
+
data = json.dumps(body).encode("utf-8")
|
|
89
|
+
request_headers.setdefault("Content-Type", "application/json")
|
|
90
|
+
request = urllib.request.Request(
|
|
91
|
+
urllib.parse.urljoin(self.endpoint + "/", path.lstrip("/")),
|
|
92
|
+
data=data,
|
|
93
|
+
headers=request_headers,
|
|
94
|
+
method=method,
|
|
95
|
+
)
|
|
96
|
+
try:
|
|
97
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
98
|
+
raw = response.read()
|
|
99
|
+
content_type = response.headers.get("Content-Type", "")
|
|
100
|
+
except urllib.error.HTTPError as exc:
|
|
101
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
102
|
+
raise CodeBuddyError(f"HTTP {exc.code} {path}: {detail[:1000]}") from exc
|
|
103
|
+
except urllib.error.URLError as exc:
|
|
104
|
+
raise CodeBuddyError(f"请求失败 {path}: {exc.reason}") from exc
|
|
105
|
+
if "json" not in content_type and not raw:
|
|
106
|
+
return None
|
|
107
|
+
try:
|
|
108
|
+
return json.loads(raw.decode("utf-8"))
|
|
109
|
+
except json.JSONDecodeError as exc:
|
|
110
|
+
raise CodeBuddyError(f"{path} 返回的不是 JSON: {raw[:300]!r}") from exc
|
|
111
|
+
|
|
112
|
+
def auth_headers(self, *, access: bool = True, refresh: bool = False) -> dict[str, str]:
|
|
113
|
+
account = self.session.get("account") or {}
|
|
114
|
+
auth = self.session.get("auth") or {}
|
|
115
|
+
headers: dict[str, str] = {}
|
|
116
|
+
if account.get("uid"):
|
|
117
|
+
headers["X-User-Id"] = str(account["uid"])
|
|
118
|
+
if access and auth.get("accessToken"):
|
|
119
|
+
headers["Authorization"] = f"Bearer {auth['accessToken']}"
|
|
120
|
+
if refresh and auth.get("refreshToken"):
|
|
121
|
+
headers["X-Refresh-Token"] = str(auth["refreshToken"])
|
|
122
|
+
if account.get("enterpriseId"):
|
|
123
|
+
headers["X-Enterprise-Id"] = str(account["enterpriseId"])
|
|
124
|
+
headers["X-Tenant-Id"] = str(account["enterpriseId"])
|
|
125
|
+
if auth.get("domain"):
|
|
126
|
+
# The extension calls this the domain header. The server accepts
|
|
127
|
+
# X-Domain for the plugin protocol.
|
|
128
|
+
headers["X-Domain"] = str(auth["domain"])
|
|
129
|
+
return headers
|
|
130
|
+
|
|
131
|
+
def login(self, *, open_browser: bool = True, timeout: int = 300) -> None:
|
|
132
|
+
no_auth = {
|
|
133
|
+
"X-No-Authorization": "true",
|
|
134
|
+
"X-No-User-Id": "true",
|
|
135
|
+
"X-No-Enterprise-Id": "true",
|
|
136
|
+
"X-No-Department-Info": "true",
|
|
137
|
+
}
|
|
138
|
+
state_payload = self._unwrap(
|
|
139
|
+
self._request(
|
|
140
|
+
"POST",
|
|
141
|
+
f"/v2{self.prefix}/auth/state?platform={urllib.parse.quote(self.platform)}",
|
|
142
|
+
headers=no_auth,
|
|
143
|
+
body={},
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
if not isinstance(state_payload, dict) or not state_payload.get("authUrl"):
|
|
147
|
+
raise CodeBuddyError(f"登录状态响应缺少 authUrl: {state_payload!r}")
|
|
148
|
+
auth_url = str(state_payload["authUrl"])
|
|
149
|
+
state = state_payload.get("state")
|
|
150
|
+
if not state:
|
|
151
|
+
raise CodeBuddyError("登录状态响应缺少 state")
|
|
152
|
+
print(f"请在浏览器中完成登录:\n{auth_url}")
|
|
153
|
+
if open_browser:
|
|
154
|
+
webbrowser.open(auth_url)
|
|
155
|
+
deadline = time.monotonic() + timeout
|
|
156
|
+
while time.monotonic() < deadline:
|
|
157
|
+
time.sleep(1)
|
|
158
|
+
try:
|
|
159
|
+
token = self._unwrap(
|
|
160
|
+
self._request(
|
|
161
|
+
"GET",
|
|
162
|
+
f"/v2{self.prefix}/auth/token?state={urllib.parse.quote(str(state))}",
|
|
163
|
+
headers=no_auth,
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
except CodeBuddyError:
|
|
167
|
+
continue
|
|
168
|
+
if isinstance(token, dict) and token.get("accessToken"):
|
|
169
|
+
account = self._unwrap(
|
|
170
|
+
self._request(
|
|
171
|
+
"GET",
|
|
172
|
+
f"/v2{self.prefix}/login/account?state={urllib.parse.quote(str(state))}",
|
|
173
|
+
headers={
|
|
174
|
+
# The extension sends the bearer token here, but
|
|
175
|
+
# only suppresses user/enterprise/department
|
|
176
|
+
# headers; X-No-Authorization is not combined
|
|
177
|
+
# with Authorization on this request.
|
|
178
|
+
"X-No-User-Id": "true",
|
|
179
|
+
"X-No-Enterprise-Id": "true",
|
|
180
|
+
"X-No-Department-Info": "true",
|
|
181
|
+
**self._token_headers(token),
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
if not isinstance(account, dict):
|
|
186
|
+
raise CodeBuddyError(f"登录账户响应格式异常: {account!r}")
|
|
187
|
+
self._save_session({"auth": token, "account": account})
|
|
188
|
+
print(f"登录成功,用户: {account.get('nickname') or account.get('uid', '<unknown>')}")
|
|
189
|
+
return
|
|
190
|
+
raise CodeBuddyError("登录超时")
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def _token_headers(token: dict[str, Any]) -> dict[str, str]:
|
|
194
|
+
headers: dict[str, str] = {}
|
|
195
|
+
if token.get("accessToken"):
|
|
196
|
+
headers["Authorization"] = f"Bearer {token['accessToken']}"
|
|
197
|
+
if token.get("domain"):
|
|
198
|
+
headers["X-Domain"] = str(token["domain"])
|
|
199
|
+
return headers
|
|
200
|
+
|
|
201
|
+
def refresh(self) -> bool:
|
|
202
|
+
auth = self.session.get("auth") or {}
|
|
203
|
+
refresh_token = auth.get("refreshToken")
|
|
204
|
+
if not refresh_token:
|
|
205
|
+
return False
|
|
206
|
+
payload = self._unwrap(
|
|
207
|
+
self._request(
|
|
208
|
+
"POST",
|
|
209
|
+
f"/v2{self.prefix}/auth/token/refresh",
|
|
210
|
+
headers={
|
|
211
|
+
**self.auth_headers(access=False, refresh=True),
|
|
212
|
+
"X-Auth-Refresh-Source": "plugin",
|
|
213
|
+
},
|
|
214
|
+
body={},
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
if not isinstance(payload, dict) or not payload.get("accessToken"):
|
|
218
|
+
return False
|
|
219
|
+
self._save_session({**self.session, "auth": payload})
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
def ensure_authenticated(self, *, open_browser: bool = True) -> None:
|
|
223
|
+
auth = self.session.get("auth") or {}
|
|
224
|
+
now_ms = int(time.time() * 1000)
|
|
225
|
+
expires_at = int(auth.get("expiresAt") or 0)
|
|
226
|
+
if auth.get("accessToken") and (not expires_at or expires_at > now_ms + 60_000):
|
|
227
|
+
return
|
|
228
|
+
if self.refresh():
|
|
229
|
+
print("access token 已刷新")
|
|
230
|
+
return
|
|
231
|
+
self.login(open_browser=open_browser)
|
|
232
|
+
|
|
233
|
+
def stream_chat(
|
|
234
|
+
self,
|
|
235
|
+
prompt: str,
|
|
236
|
+
*,
|
|
237
|
+
model: str = "default",
|
|
238
|
+
temperature: float = 0.7,
|
|
239
|
+
max_tokens: int = 2048,
|
|
240
|
+
) -> Iterator[str]:
|
|
241
|
+
self.ensure_authenticated()
|
|
242
|
+
payload = {
|
|
243
|
+
"model": model,
|
|
244
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
245
|
+
"temperature": temperature,
|
|
246
|
+
"max_tokens": max_tokens,
|
|
247
|
+
"stream": True,
|
|
248
|
+
}
|
|
249
|
+
request = urllib.request.Request(
|
|
250
|
+
f"{self.endpoint}/v2/chat/completions",
|
|
251
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
252
|
+
headers={
|
|
253
|
+
**self.auth_headers(),
|
|
254
|
+
"Content-Type": "application/json",
|
|
255
|
+
"Accept": "text/event-stream",
|
|
256
|
+
},
|
|
257
|
+
method="POST",
|
|
258
|
+
)
|
|
259
|
+
try:
|
|
260
|
+
response = urllib.request.urlopen(request, timeout=180)
|
|
261
|
+
except urllib.error.HTTPError as exc:
|
|
262
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
263
|
+
if exc.code == 401 and self.refresh():
|
|
264
|
+
yield from self.stream_chat(
|
|
265
|
+
prompt, model=model, temperature=temperature, max_tokens=max_tokens
|
|
266
|
+
)
|
|
267
|
+
return
|
|
268
|
+
raise CodeBuddyError(f"聊天请求 HTTP {exc.code}: {detail[:1000]}") from exc
|
|
269
|
+
with response:
|
|
270
|
+
for raw_line in response:
|
|
271
|
+
line = raw_line.decode("utf-8", errors="replace").strip()
|
|
272
|
+
if not line.startswith("data:"):
|
|
273
|
+
continue
|
|
274
|
+
data = line[5:].strip()
|
|
275
|
+
if data == "[DONE]":
|
|
276
|
+
return
|
|
277
|
+
try:
|
|
278
|
+
chunk = json.loads(data)
|
|
279
|
+
except json.JSONDecodeError:
|
|
280
|
+
continue
|
|
281
|
+
for choice in chunk.get("choices", []):
|
|
282
|
+
delta = choice.get("delta") or {}
|
|
283
|
+
content = delta.get("content")
|
|
284
|
+
if content:
|
|
285
|
+
yield str(content)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def main() -> int:
|
|
289
|
+
parser = argparse.ArgumentParser(description="CodeBuddy login/refresh/chat demo")
|
|
290
|
+
parser.add_argument("prompt", nargs="?", help="要发送的问题")
|
|
291
|
+
parser.add_argument("--endpoint", default=os.getenv("CODEBUDDY_ENDPOINT", "https://copilot.tencent.com"))
|
|
292
|
+
parser.add_argument("--model", default=os.getenv("CODEBUDDY_MODEL", "default"))
|
|
293
|
+
parser.add_argument("--session-file", type=pathlib.Path)
|
|
294
|
+
parser.add_argument("--no-browser", action="store_true", help="只打印登录 URL,不自动打开浏览器")
|
|
295
|
+
parser.add_argument("--login", action="store_true", help="强制重新登录")
|
|
296
|
+
args = parser.parse_args()
|
|
297
|
+
client = CodeBuddyClient(args.endpoint, session_file=args.session_file)
|
|
298
|
+
try:
|
|
299
|
+
if args.login:
|
|
300
|
+
client.login(open_browser=not args.no_browser)
|
|
301
|
+
prompt = args.prompt or input("Prompt: ")
|
|
302
|
+
for text in client.stream_chat(prompt, model=args.model):
|
|
303
|
+
print(text, end="", flush=True)
|
|
304
|
+
print()
|
|
305
|
+
return 0
|
|
306
|
+
except (CodeBuddyError, KeyboardInterrupt) as exc:
|
|
307
|
+
print(f"\n错误: {exc}", file=sys.stderr)
|
|
308
|
+
return 1
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
if __name__ == "__main__":
|
|
312
|
+
raise SystemExit(main())
|