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.
@@ -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())