cheso-cli 0.2.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.
- cheso_cli/__init__.py +12 -0
- cheso_cli/__main__.py +5 -0
- cheso_cli/auth.py +128 -0
- cheso_cli/cli.py +864 -0
- cheso_cli/client.py +534 -0
- cheso_cli/config.py +108 -0
- cheso_cli/credentials.py +235 -0
- cheso_cli/errors.py +56 -0
- cheso_cli/legacy_commands.py +93 -0
- cheso_cli/oauth.py +797 -0
- cheso_cli/skill/SKILL.md +83 -0
- cheso_cli/skill_install.py +119 -0
- cheso_cli/tasks.py +834 -0
- cheso_cli/transfer.py +294 -0
- cheso_cli-0.2.0.dist-info/METADATA +164 -0
- cheso_cli-0.2.0.dist-info/RECORD +18 -0
- cheso_cli-0.2.0.dist-info/WHEEL +4 -0
- cheso_cli-0.2.0.dist-info/entry_points.txt +4 -0
cheso_cli/client.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
# pylint: disable=broad-except
|
|
2
|
+
"""精简的 Cheso Manager HTTP 客户端,使用 X-Cheso-Token 认证。"""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import contextlib
|
|
7
|
+
import queue
|
|
8
|
+
import threading
|
|
9
|
+
from collections.abc import Callable, Iterator
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from cheso_cli.config import USER_AGENT
|
|
17
|
+
|
|
18
|
+
# 查询忙碌项目中运行任务时的默认分页大小;列表按 updated_at 降序排列。
|
|
19
|
+
PLAN_LIST_PAGE_SIZE = 20
|
|
20
|
+
# 发起任务需要分配运行环境,比其他接口调用更慢。
|
|
21
|
+
CREATE_PLAN_TIMEOUT = 60.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ApiError(Exception):
|
|
25
|
+
"""Manager API 返回的业务错误或传输错误。"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
message: str,
|
|
30
|
+
*,
|
|
31
|
+
code: int | None = None,
|
|
32
|
+
http_status: int | None = None,
|
|
33
|
+
span_id: str | None = None,
|
|
34
|
+
body: Any = None,
|
|
35
|
+
retry_after: int | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.message = message
|
|
39
|
+
self.code = code
|
|
40
|
+
self.http_status = http_status
|
|
41
|
+
self.span_id = span_id
|
|
42
|
+
self.body = body
|
|
43
|
+
self.retry_after = retry_after
|
|
44
|
+
|
|
45
|
+
def __str__(self) -> str:
|
|
46
|
+
parts = [self.message]
|
|
47
|
+
if self.code is not None:
|
|
48
|
+
parts.append(f"code={self.code}")
|
|
49
|
+
if self.http_status is not None:
|
|
50
|
+
parts.append(f"http={self.http_status}")
|
|
51
|
+
if self.span_id:
|
|
52
|
+
parts.append(f"span_id={self.span_id}")
|
|
53
|
+
return " ".join(parts)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def details(self) -> dict[str, Any]:
|
|
57
|
+
"""错误响应中的结构化详情,即响应封装里的 ``data`` 字段;没有时返回空字典。"""
|
|
58
|
+
if isinstance(self.body, dict) and isinstance(self.body.get("data"), dict):
|
|
59
|
+
return self.body["data"]
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TimeLimitReached(httpx.TimeoutException):
|
|
64
|
+
"""命令的 ``--timeout`` 已到,不再发送下一个请求。"""
|
|
65
|
+
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
super().__init__("--timeout reached")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Deadline:
|
|
72
|
+
"""命令 ``--timeout`` 对应的截止时刻,使用 ``clock`` 计时。"""
|
|
73
|
+
|
|
74
|
+
at: float
|
|
75
|
+
clock: Callable[[], float]
|
|
76
|
+
|
|
77
|
+
def left(self) -> float:
|
|
78
|
+
"""返回剩余秒数;没有剩余时间时抛出 TimeLimitReached。"""
|
|
79
|
+
left = self.at - self.clock()
|
|
80
|
+
if left <= 0:
|
|
81
|
+
raise TimeLimitReached()
|
|
82
|
+
return left
|
|
83
|
+
|
|
84
|
+
def expired(self) -> bool:
|
|
85
|
+
return self.clock() >= self.at
|
|
86
|
+
|
|
87
|
+
def cap(self, timeout: httpx.Timeout) -> httpx.Timeout:
|
|
88
|
+
"""将 ``timeout`` 的每个阶段都限制在剩余时间内。"""
|
|
89
|
+
left = self.left()
|
|
90
|
+
return httpx.Timeout(
|
|
91
|
+
connect=min(timeout.connect or left, left),
|
|
92
|
+
read=min(timeout.read or left, left),
|
|
93
|
+
write=min(timeout.write or left, left),
|
|
94
|
+
pool=min(timeout.pool or left, left),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_END = object()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def iterate_until(deadline: Deadline, items: Callable[[], Iterator[Any]]) -> Iterator[Any]:
|
|
102
|
+
"""依次返回 ``items()`` 产生的数据,每次最多等到 ``deadline``。
|
|
103
|
+
|
|
104
|
+
httpx 的超时限制每次套接字读取,而非整个响应;服务端持续少量发送数据时,请求仍可能超过截止时间。
|
|
105
|
+
因此,将 ``items()`` 中阻塞的网络 I/O 放到守护线程执行,当前线程只等到截止时间。
|
|
106
|
+
超时后抛出 TimeLimitReached,并通知工作线程停止;工作线程自行关闭响应,后续产出的数据直接丢弃。
|
|
107
|
+
"""
|
|
108
|
+
deadline.left() # 超过截止时间后不再启动操作。
|
|
109
|
+
handoff: queue.Queue[tuple[bool, Any]] = queue.Queue(maxsize=4)
|
|
110
|
+
stop = threading.Event()
|
|
111
|
+
|
|
112
|
+
def hand_over(entry: tuple[bool, Any]) -> bool:
|
|
113
|
+
while not stop.is_set():
|
|
114
|
+
try:
|
|
115
|
+
handoff.put(entry, timeout=0.1)
|
|
116
|
+
return True
|
|
117
|
+
except queue.Full:
|
|
118
|
+
continue
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
def produce() -> None:
|
|
122
|
+
source: Iterator[Any] | None = None
|
|
123
|
+
try:
|
|
124
|
+
source = items()
|
|
125
|
+
for item in source:
|
|
126
|
+
if not hand_over((True, item)):
|
|
127
|
+
return
|
|
128
|
+
hand_over((True, _END))
|
|
129
|
+
except Exception as e: # pylint: disable=broad-except # 异常会在等待线程中重新抛出
|
|
130
|
+
hand_over((False, e))
|
|
131
|
+
finally:
|
|
132
|
+
close = getattr(source, "close", None)
|
|
133
|
+
if close is not None:
|
|
134
|
+
with contextlib.suppress(Exception):
|
|
135
|
+
close()
|
|
136
|
+
|
|
137
|
+
threading.Thread(target=produce, name="cheso-io", daemon=True).start()
|
|
138
|
+
try:
|
|
139
|
+
while True:
|
|
140
|
+
try:
|
|
141
|
+
ok, value = handoff.get(timeout=deadline.left())
|
|
142
|
+
except queue.Empty:
|
|
143
|
+
raise TimeLimitReached() from None
|
|
144
|
+
if not ok:
|
|
145
|
+
raise value
|
|
146
|
+
if value is _END:
|
|
147
|
+
return
|
|
148
|
+
yield value
|
|
149
|
+
finally:
|
|
150
|
+
stop.set()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def call_until(deadline: Deadline, fn: Callable[[], Any]) -> Any:
|
|
154
|
+
"""等待阻塞网络操作 ``fn()``,最多等到 ``deadline``;实现见 iterate_until。"""
|
|
155
|
+
with contextlib.closing(iterate_until(deadline, lambda: iter((fn(),)))) as results:
|
|
156
|
+
return next(results)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class TokenSource(Protocol):
|
|
160
|
+
"""提供 X-Cheso-Token;OAuth 登录收到 401 后,可提供刷新后的令牌。"""
|
|
161
|
+
|
|
162
|
+
def token(self) -> str:
|
|
163
|
+
"""返回下一个请求要发送的令牌。"""
|
|
164
|
+
...
|
|
165
|
+
|
|
166
|
+
def refresh_after_unauthorized(self, rejected: str) -> str | None:
|
|
167
|
+
"""返回新令牌供调用方重试一次;无法续期时返回 None。"""
|
|
168
|
+
...
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class StaticToken:
|
|
172
|
+
"""固定令牌,来自 ``--token`` 或 ``CHESO_API_TOKEN``,无需刷新。"""
|
|
173
|
+
|
|
174
|
+
def __init__(self, value: str) -> None:
|
|
175
|
+
self._value = value
|
|
176
|
+
|
|
177
|
+
def token(self) -> str:
|
|
178
|
+
return self._value
|
|
179
|
+
|
|
180
|
+
def refresh_after_unauthorized(self, rejected: str) -> str | None:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _parse_retry_after(value: str | None) -> int | None:
|
|
185
|
+
try:
|
|
186
|
+
seconds = int((value or "").strip())
|
|
187
|
+
except ValueError:
|
|
188
|
+
return None
|
|
189
|
+
return seconds if seconds >= 0 else None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _path_id(value: str) -> str:
|
|
193
|
+
return quote(value, safe="")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class ChesoClient:
|
|
197
|
+
"""封装 Manager 公开 API 请求和响应的精简客户端。"""
|
|
198
|
+
|
|
199
|
+
def __init__(
|
|
200
|
+
self,
|
|
201
|
+
base_url: str,
|
|
202
|
+
token: str | None = None,
|
|
203
|
+
*,
|
|
204
|
+
auth: TokenSource | None = None,
|
|
205
|
+
timeout: float = 30.0,
|
|
206
|
+
transport: httpx.BaseTransport | None = None,
|
|
207
|
+
headers: dict[str, str] | None = None,
|
|
208
|
+
) -> None:
|
|
209
|
+
if auth is None:
|
|
210
|
+
if not token:
|
|
211
|
+
raise ValueError("token is required (CHESO_API_TOKEN / --token)")
|
|
212
|
+
auth = StaticToken(token)
|
|
213
|
+
self._auth = auth
|
|
214
|
+
self.base_url = base_url.rstrip("/")
|
|
215
|
+
default_headers = {
|
|
216
|
+
"Accept": "application/json",
|
|
217
|
+
"Content-Type": "application/json",
|
|
218
|
+
"User-Agent": USER_AGENT,
|
|
219
|
+
}
|
|
220
|
+
default_headers.update(headers or {})
|
|
221
|
+
self._timeout = timeout
|
|
222
|
+
self._deadline: Deadline | None = None
|
|
223
|
+
# API 调用不跟随重定向,避免将令牌发送到其他地址。
|
|
224
|
+
self._client = httpx.Client(
|
|
225
|
+
base_url=self.base_url,
|
|
226
|
+
headers=default_headers,
|
|
227
|
+
timeout=timeout,
|
|
228
|
+
transport=transport,
|
|
229
|
+
follow_redirects=False,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def close(self) -> None:
|
|
233
|
+
self._client.close()
|
|
234
|
+
|
|
235
|
+
def __enter__(self) -> ChesoClient:
|
|
236
|
+
return self
|
|
237
|
+
|
|
238
|
+
def __exit__(self, *args: object) -> None:
|
|
239
|
+
self.close()
|
|
240
|
+
|
|
241
|
+
@contextlib.contextmanager
|
|
242
|
+
def time_limit(self, deadline: Deadline) -> Iterator[None]:
|
|
243
|
+
"""作用域内不在 ``deadline`` 之后发起请求,等待请求也不超过该时刻,见 call_until。
|
|
244
|
+
|
|
245
|
+
令牌刷新不会被截断:丢失刷新响应后就无法知道轮换后的刷新令牌,下一次刷新会使登录失效。
|
|
246
|
+
"""
|
|
247
|
+
previous, self._deadline = self._deadline, deadline
|
|
248
|
+
try:
|
|
249
|
+
yield
|
|
250
|
+
finally:
|
|
251
|
+
self._deadline = previous
|
|
252
|
+
|
|
253
|
+
def request(
|
|
254
|
+
self,
|
|
255
|
+
method: str,
|
|
256
|
+
path: str,
|
|
257
|
+
*,
|
|
258
|
+
params: dict[str, Any] | None = None,
|
|
259
|
+
json_body: dict[str, Any] | None = None,
|
|
260
|
+
timeout: float | None = None,
|
|
261
|
+
) -> Any:
|
|
262
|
+
if self._deadline is not None:
|
|
263
|
+
self._deadline.left()
|
|
264
|
+
token = self._auth.token()
|
|
265
|
+
resp = self._send(method, path, token, params=params, json_body=json_body, timeout=timeout)
|
|
266
|
+
if resp.status_code == 401:
|
|
267
|
+
fresh = self._auth.refresh_after_unauthorized(token)
|
|
268
|
+
if fresh and fresh != token:
|
|
269
|
+
resp = self._send(method, path, fresh, params=params, json_body=json_body, timeout=timeout)
|
|
270
|
+
return self._decode(resp)
|
|
271
|
+
|
|
272
|
+
def _send(
|
|
273
|
+
self,
|
|
274
|
+
method: str,
|
|
275
|
+
path: str,
|
|
276
|
+
token: str,
|
|
277
|
+
*,
|
|
278
|
+
params: dict[str, Any] | None,
|
|
279
|
+
json_body: dict[str, Any] | None,
|
|
280
|
+
timeout: float | None,
|
|
281
|
+
) -> httpx.Response:
|
|
282
|
+
extra: dict[str, Any] = {} if timeout is None else {"timeout": timeout}
|
|
283
|
+
deadline = self._deadline
|
|
284
|
+
if deadline is not None:
|
|
285
|
+
# 同时限制各阶段超时,避免已放弃的请求在后台滞留过久。
|
|
286
|
+
extra["timeout"] = deadline.cap(httpx.Timeout(self._timeout if timeout is None else timeout))
|
|
287
|
+
|
|
288
|
+
def send() -> httpx.Response:
|
|
289
|
+
return self._client.request(
|
|
290
|
+
method, path, params=params, json=json_body, headers={"X-Cheso-Token": token}, **extra
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
return send() if deadline is None else call_until(deadline, send)
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _decode(resp: httpx.Response) -> Any:
|
|
297
|
+
retry_after = _parse_retry_after(resp.headers.get("Retry-After"))
|
|
298
|
+
try:
|
|
299
|
+
payload = resp.json()
|
|
300
|
+
except ValueError as e:
|
|
301
|
+
span_hdr = resp.headers.get("X-Span-Id")
|
|
302
|
+
raise ApiError(
|
|
303
|
+
"request failed",
|
|
304
|
+
http_status=resp.status_code,
|
|
305
|
+
span_id=span_hdr if isinstance(span_hdr, str) and span_hdr else None,
|
|
306
|
+
body=resp.text[:500] if resp.text else None,
|
|
307
|
+
retry_after=retry_after,
|
|
308
|
+
) from e
|
|
309
|
+
|
|
310
|
+
if not isinstance(payload, dict):
|
|
311
|
+
raise ApiError(
|
|
312
|
+
"unexpected response shape",
|
|
313
|
+
http_status=resp.status_code,
|
|
314
|
+
body=payload,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
code = payload.get("code")
|
|
318
|
+
message = str(payload.get("message") or "")
|
|
319
|
+
span_id = payload.get("span_id")
|
|
320
|
+
if resp.status_code >= 400 or (isinstance(code, int) and code != 0):
|
|
321
|
+
raise ApiError(
|
|
322
|
+
message or "request failed",
|
|
323
|
+
code=code if isinstance(code, int) else None,
|
|
324
|
+
http_status=resp.status_code,
|
|
325
|
+
span_id=span_id if isinstance(span_id, str) else None,
|
|
326
|
+
body=payload,
|
|
327
|
+
retry_after=retry_after,
|
|
328
|
+
)
|
|
329
|
+
return payload.get("data")
|
|
330
|
+
|
|
331
|
+
# ---- 账号 ----
|
|
332
|
+
|
|
333
|
+
def profile(self) -> Any:
|
|
334
|
+
return self.request("GET", "/api/v1/profile")
|
|
335
|
+
|
|
336
|
+
def whoami(self) -> Any:
|
|
337
|
+
return self.request("GET", "/api/v1/whoami")
|
|
338
|
+
|
|
339
|
+
def credit_grants(self) -> Any:
|
|
340
|
+
"""当前可用的积分授权:``{total, grants: [{amount_available, valid_from, expires_at, ...}]}``。"""
|
|
341
|
+
return self.request("GET", "/api/v1/billing/credits")
|
|
342
|
+
|
|
343
|
+
# ---- 项目 ----
|
|
344
|
+
|
|
345
|
+
def project_create(self, *, description: str | None = None) -> Any:
|
|
346
|
+
body: dict[str, Any] = {}
|
|
347
|
+
if description is not None:
|
|
348
|
+
body["description"] = description
|
|
349
|
+
return self.request("POST", "/api/v1/projects/create", json_body=body)
|
|
350
|
+
|
|
351
|
+
def project_list(
|
|
352
|
+
self,
|
|
353
|
+
*,
|
|
354
|
+
status: str | None = None,
|
|
355
|
+
starred: bool | None = None,
|
|
356
|
+
sort_by: str | None = None,
|
|
357
|
+
page: int | None = None,
|
|
358
|
+
page_size: int | None = None,
|
|
359
|
+
) -> Any:
|
|
360
|
+
params: dict[str, Any] = {}
|
|
361
|
+
if status is not None:
|
|
362
|
+
params["status"] = status
|
|
363
|
+
if starred is not None:
|
|
364
|
+
params["starred"] = "true" if starred else "false"
|
|
365
|
+
if sort_by is not None:
|
|
366
|
+
params["sort_by"] = sort_by
|
|
367
|
+
if page is not None:
|
|
368
|
+
params["page"] = page
|
|
369
|
+
if page_size is not None:
|
|
370
|
+
params["page_size"] = page_size
|
|
371
|
+
return self.request("GET", "/api/v1/projects", params=params or None)
|
|
372
|
+
|
|
373
|
+
def project_get(self, project_id: str) -> Any:
|
|
374
|
+
return self.request("GET", f"/api/v1/projects/{project_id}")
|
|
375
|
+
|
|
376
|
+
def project_update(
|
|
377
|
+
self,
|
|
378
|
+
project_id: str,
|
|
379
|
+
*,
|
|
380
|
+
description: str | None = None,
|
|
381
|
+
starred: bool | None = None,
|
|
382
|
+
cover_file_id: str | None = None,
|
|
383
|
+
cover_file_version: int | None = None,
|
|
384
|
+
) -> Any:
|
|
385
|
+
body: dict[str, Any] = {}
|
|
386
|
+
if description is not None:
|
|
387
|
+
body["description"] = description
|
|
388
|
+
if starred is not None:
|
|
389
|
+
body["starred"] = starred
|
|
390
|
+
if cover_file_id is not None:
|
|
391
|
+
body["cover_file_id"] = cover_file_id
|
|
392
|
+
if cover_file_version is not None:
|
|
393
|
+
body["cover_file_version"] = cover_file_version
|
|
394
|
+
return self.request("PUT", f"/api/v1/projects/{project_id}", json_body=body)
|
|
395
|
+
|
|
396
|
+
def project_delete(self, project_id: str) -> Any:
|
|
397
|
+
return self.request("DELETE", f"/api/v1/projects/{project_id}")
|
|
398
|
+
|
|
399
|
+
def project_fork(self, project_id: str, *, description: str | None = None) -> Any:
|
|
400
|
+
body: dict[str, Any] = {}
|
|
401
|
+
if description is not None:
|
|
402
|
+
body["description"] = description
|
|
403
|
+
return self.request(
|
|
404
|
+
"POST",
|
|
405
|
+
f"/api/v1/projects/{project_id}/fork",
|
|
406
|
+
json_body=body,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
def project_list_all(
|
|
410
|
+
self,
|
|
411
|
+
*,
|
|
412
|
+
status: str | None = "active",
|
|
413
|
+
page_size: int = 100,
|
|
414
|
+
) -> list[dict[str, Any]]:
|
|
415
|
+
"""逐页读取项目列表,直到没有更多结果。"""
|
|
416
|
+
items: list[dict[str, Any]] = []
|
|
417
|
+
page = 1
|
|
418
|
+
while True:
|
|
419
|
+
data = self.project_list(status=status, page=page, page_size=page_size)
|
|
420
|
+
batch = list((data or {}).get("projects") or [])
|
|
421
|
+
items.extend(p for p in batch if isinstance(p, dict))
|
|
422
|
+
total = int((data or {}).get("total") or 0)
|
|
423
|
+
if len(items) >= total or not batch:
|
|
424
|
+
break
|
|
425
|
+
page += 1
|
|
426
|
+
return items
|
|
427
|
+
|
|
428
|
+
def session_create(self, *, project_id: str, title: str | None = None) -> Any:
|
|
429
|
+
body: dict[str, Any] = {"project_id": project_id}
|
|
430
|
+
if title is not None:
|
|
431
|
+
body["title"] = title
|
|
432
|
+
return self.request("POST", "/api/v1/sessions/create", json_body=body)
|
|
433
|
+
|
|
434
|
+
# ---- Plan(任务)----
|
|
435
|
+
|
|
436
|
+
def plan_create(
|
|
437
|
+
self,
|
|
438
|
+
*,
|
|
439
|
+
prompt: str,
|
|
440
|
+
project_id: str | None = None,
|
|
441
|
+
session_id: str | None = None,
|
|
442
|
+
contexts: list[dict[str, Any]] | None = None,
|
|
443
|
+
agent_type: str | None = None,
|
|
444
|
+
) -> Any:
|
|
445
|
+
"""发起 Plan。始终不发送 ``on_running``;项目忙时返回 2614,不排队。"""
|
|
446
|
+
body: dict[str, Any] = {"prompt": prompt}
|
|
447
|
+
if project_id is not None:
|
|
448
|
+
body["project_id"] = project_id
|
|
449
|
+
if session_id is not None:
|
|
450
|
+
body["session_id"] = session_id
|
|
451
|
+
if contexts:
|
|
452
|
+
body["contexts"] = contexts
|
|
453
|
+
if agent_type:
|
|
454
|
+
body["agent_type"] = agent_type
|
|
455
|
+
return self.request("POST", "/api/v1/plans/create", json_body=body, timeout=CREATE_PLAN_TIMEOUT)
|
|
456
|
+
|
|
457
|
+
def plan_get(self, plan_id: str) -> Any:
|
|
458
|
+
return self.request("GET", f"/api/v1/plans/{plan_id}")
|
|
459
|
+
|
|
460
|
+
def plan_list(self, *, project_id: str, page_size: int = PLAN_LIST_PAGE_SIZE) -> Any:
|
|
461
|
+
return self.request("GET", "/api/v1/plans", params={"project_id": project_id, "page_size": page_size})
|
|
462
|
+
|
|
463
|
+
def plan_result(self, plan_id: str) -> Any:
|
|
464
|
+
"""返回字段:``{plan_id, status, result{text,text_truncated,files[]}, interaction, next_plan_id, reason}``。"""
|
|
465
|
+
return self.request("GET", f"/api/v1/plans/{_path_id(plan_id)}/result")
|
|
466
|
+
|
|
467
|
+
def plan_interact(self, plan_id: str, *, args: dict[str, Any], skip: bool) -> Any:
|
|
468
|
+
return self.request(
|
|
469
|
+
"POST",
|
|
470
|
+
f"/api/v1/plans/{_path_id(plan_id)}/interact",
|
|
471
|
+
json_body={"args": args, "skip": skip},
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def plan_terminate(self, plan_id: str) -> Any:
|
|
475
|
+
return self.request("POST", f"/api/v1/plans/{_path_id(plan_id)}/terminate", json_body={})
|
|
476
|
+
|
|
477
|
+
def plan_credit_get(self, *, project_id: str, plan_id: str) -> Any:
|
|
478
|
+
return self.request(
|
|
479
|
+
"GET",
|
|
480
|
+
"/api/v1/billing/credit-transactions/plans",
|
|
481
|
+
params={"project_id": project_id, "plan_id": plan_id},
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# ---- 文件 ----
|
|
485
|
+
|
|
486
|
+
def file_presign(self, body: dict[str, Any]) -> Any:
|
|
487
|
+
"""返回 ``{file_id, version, upload_url, instant}``;``instant`` 表示内容已经存储,可秒传。"""
|
|
488
|
+
return self.request("POST", "/api/v1/files/presign", json_body=body)
|
|
489
|
+
|
|
490
|
+
def file_confirm(self, *, file_id: str, version: int) -> Any:
|
|
491
|
+
return self.request("POST", "/api/v1/files/confirm", json_body={"file_id": file_id, "version": version})
|
|
492
|
+
|
|
493
|
+
def file_download_url(self, file_id: str, *, version: int | None = None) -> Any:
|
|
494
|
+
"""返回 ``{file_id, download_url, attachment_url}``;这些 URL 带预签名,6 小时后过期。"""
|
|
495
|
+
body: dict[str, Any] = {"file_id": file_id}
|
|
496
|
+
if version:
|
|
497
|
+
body["version"] = version
|
|
498
|
+
return self.request("POST", "/api/v1/files/download_url", json_body=body)
|
|
499
|
+
|
|
500
|
+
def file_get(self, file_id: str) -> Any:
|
|
501
|
+
return self.request("GET", f"/api/v1/files/{_path_id(file_id)}")
|
|
502
|
+
|
|
503
|
+
def project_file_tree(self, project_id: str) -> Any:
|
|
504
|
+
return self.request("GET", "/api/v1/project/files/tree", params={"project_id": project_id})
|
|
505
|
+
|
|
506
|
+
# ---- 转换与翻译 ----
|
|
507
|
+
|
|
508
|
+
def convert_supported(self) -> Any:
|
|
509
|
+
return self.request("GET", "/api/v1/converts/supported")
|
|
510
|
+
|
|
511
|
+
def convert_create(self, *, file_id: str, source_type: str, target_type: str) -> Any:
|
|
512
|
+
return self.request(
|
|
513
|
+
"POST",
|
|
514
|
+
"/api/v1/converts/create",
|
|
515
|
+
json_body={"file_id": file_id, "source_type": source_type, "target_type": target_type},
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
def convert_get(self, job_id: str) -> Any:
|
|
519
|
+
return self.request("GET", f"/api/v1/converts/{_path_id(job_id)}")
|
|
520
|
+
|
|
521
|
+
def translation_create(
|
|
522
|
+
self,
|
|
523
|
+
*,
|
|
524
|
+
file_id: str,
|
|
525
|
+
target_language: str,
|
|
526
|
+
source_language: str | None = None,
|
|
527
|
+
) -> Any:
|
|
528
|
+
body: dict[str, Any] = {"file_id": file_id, "target_language": target_language}
|
|
529
|
+
if source_language:
|
|
530
|
+
body["source_language"] = source_language
|
|
531
|
+
return self.request("POST", "/api/v1/translations/create", json_body=body)
|
|
532
|
+
|
|
533
|
+
def translation_get(self, job_id: str) -> Any:
|
|
534
|
+
return self.request("GET", f"/api/v1/translations/{_path_id(job_id)}")
|
cheso_cli/config.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""站点、基础 URL 的解析及其他进程级配置。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from importlib.metadata import version
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
from cheso_cli.errors import UsageError
|
|
13
|
+
|
|
14
|
+
# ``pyproject.toml`` 是唯一版本来源;安装包和可编辑安装均通过发行包 metadata 读取。
|
|
15
|
+
VERSION = version("cheso-cli")
|
|
16
|
+
USER_AGENT = f"cheso-cli/{VERSION}"
|
|
17
|
+
|
|
18
|
+
# 公开站点:API 网关、OAuth issuer 和网站使用同一源。
|
|
19
|
+
SITES: dict[str, str] = {
|
|
20
|
+
"global": "https://cheso.ai",
|
|
21
|
+
"cn": "https://zhishi.qq.com",
|
|
22
|
+
}
|
|
23
|
+
DEFAULT_SITE = "global"
|
|
24
|
+
|
|
25
|
+
ENV_BASE_URL = "CHESO_BASE_URL"
|
|
26
|
+
ENV_TOKEN = "CHESO_API_TOKEN"
|
|
27
|
+
# 仅供测试:将请求路由到测试环境中的开发容器。
|
|
28
|
+
ENV_DEV_SET = "CHESO_DEV_SET"
|
|
29
|
+
HEADER_DEV_SET = "X-Dev-Set"
|
|
30
|
+
|
|
31
|
+
# 网站页面路径,见 services/frontend/src/router/routes.ts。
|
|
32
|
+
PROJECT_PAGE = "/agent/"
|
|
33
|
+
SUBSCRIBE_PAGE = "/agent/subscribe"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def normalize_base_url(raw: str) -> str:
|
|
37
|
+
"""校验基础 URL 并去掉末尾斜杠;只接受 http(s) 地址,可以带路径。"""
|
|
38
|
+
value = raw.strip().rstrip("/")
|
|
39
|
+
parts = urlsplit(value)
|
|
40
|
+
if parts.scheme not in ("https", "http") or not parts.hostname:
|
|
41
|
+
raise UsageError(f"invalid base URL {raw!r}: expected https://host")
|
|
42
|
+
if parts.query or parts.fragment or parts.username or parts.password:
|
|
43
|
+
raise UsageError(f"invalid base URL {raw!r}: must not contain credentials, query or fragment")
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Settings:
|
|
49
|
+
"""目标服务地址,以及可选的测试路由请求头。"""
|
|
50
|
+
|
|
51
|
+
base_url: str
|
|
52
|
+
dev_set: str = ""
|
|
53
|
+
# 记录本次选择站点的 --site / --base-url;来自 CHESO_BASE_URL、上次登录或默认值时为空。
|
|
54
|
+
# 给调用方建议的后续命令会重复这些选项,确保仍然访问同一站点。
|
|
55
|
+
option: str = ""
|
|
56
|
+
|
|
57
|
+
def command(self, args: str) -> str:
|
|
58
|
+
"""生成访问当前站点的 ``cheso`` 命令行。"""
|
|
59
|
+
return f"cheso {self.option} {args}" if self.option else f"cheso {args}"
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def web_url(self) -> str:
|
|
63
|
+
return self.base_url
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def topup_url(self) -> str:
|
|
67
|
+
return self.web_url + SUBSCRIBE_PAGE
|
|
68
|
+
|
|
69
|
+
def project_url(self, project_id: str | None) -> str:
|
|
70
|
+
return f"{self.web_url}{PROJECT_PAGE}{project_id}" if project_id else ""
|
|
71
|
+
|
|
72
|
+
def extra_headers(self) -> dict[str, str]:
|
|
73
|
+
"""向 Cheso 发送请求时附加的请求头;预签名存储 URL 不使用这些请求头。"""
|
|
74
|
+
headers = {"User-Agent": USER_AGENT}
|
|
75
|
+
if self.dev_set:
|
|
76
|
+
headers[HEADER_DEV_SET] = self.dev_set
|
|
77
|
+
return headers
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_settings(
|
|
81
|
+
*,
|
|
82
|
+
site: str | None,
|
|
83
|
+
base_url: str | None,
|
|
84
|
+
env: Mapping[str, str] | None = None,
|
|
85
|
+
logged_in_default: Callable[[], str] | None = None,
|
|
86
|
+
) -> Settings:
|
|
87
|
+
"""优先级:``--base-url`` > ``--site`` > ``CHESO_BASE_URL`` > 上次 ``cheso login`` 的站点 > 国际站。"""
|
|
88
|
+
env = os.environ if env is None else env
|
|
89
|
+
option = ""
|
|
90
|
+
if base_url:
|
|
91
|
+
resolved = normalize_base_url(base_url)
|
|
92
|
+
option = f"--base-url {shlex.quote(resolved)}"
|
|
93
|
+
elif site:
|
|
94
|
+
if site not in SITES:
|
|
95
|
+
raise UsageError(f"unknown site {site!r}; choose one of: {', '.join(SITES)}")
|
|
96
|
+
resolved = SITES[site]
|
|
97
|
+
option = f"--site {site}"
|
|
98
|
+
elif env.get(ENV_BASE_URL, "").strip():
|
|
99
|
+
resolved = normalize_base_url(env[ENV_BASE_URL])
|
|
100
|
+
else:
|
|
101
|
+
resolved = SITES[DEFAULT_SITE]
|
|
102
|
+
remembered = logged_in_default() if logged_in_default else ""
|
|
103
|
+
if remembered:
|
|
104
|
+
try:
|
|
105
|
+
resolved = normalize_base_url(remembered)
|
|
106
|
+
except UsageError:
|
|
107
|
+
pass
|
|
108
|
+
return Settings(base_url=resolved, dev_set=env.get(ENV_DEV_SET, "").strip(), option=option)
|