yuxi-cli 0.1.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.
- yuxi_cli/__init__.py +3 -0
- yuxi_cli/__main__.py +3 -0
- yuxi_cli/agent_eval.py +138 -0
- yuxi_cli/client.py +195 -0
- yuxi_cli/commands.py +201 -0
- yuxi_cli/config.py +173 -0
- yuxi_cli/discovery.py +43 -0
- yuxi_cli/kb_upload.py +594 -0
- yuxi_cli/main.py +186 -0
- yuxi_cli-0.1.0.dist-info/METADATA +43 -0
- yuxi_cli-0.1.0.dist-info/RECORD +14 -0
- yuxi_cli-0.1.0.dist-info/WHEEL +5 -0
- yuxi_cli-0.1.0.dist-info/entry_points.txt +2 -0
- yuxi_cli-0.1.0.dist-info/top_level.txt +1 -0
yuxi_cli/__init__.py
ADDED
yuxi_cli/__main__.py
ADDED
yuxi_cli/agent_eval.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from langfuse import Langfuse
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from yuxi_cli.client import YuxiClient
|
|
13
|
+
from yuxi_cli.config import ConfigStore
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AgentEvalError(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class AgentEvalOptions:
|
|
22
|
+
dataset_name: str
|
|
23
|
+
agent_slug: str
|
|
24
|
+
experiment_name: str | None = None
|
|
25
|
+
max_concurrency: int = 1
|
|
26
|
+
timeout_seconds: float = 900
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _env(name: str, default: str | None = None) -> str | None:
|
|
30
|
+
value = os.getenv(name)
|
|
31
|
+
return value if value else default
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_langfuse_client() -> Langfuse:
|
|
35
|
+
kwargs: dict[str, Any] = {
|
|
36
|
+
"public_key": _env("LANGFUSE_PUBLIC_KEY"),
|
|
37
|
+
"secret_key": _env("LANGFUSE_SECRET_KEY"),
|
|
38
|
+
}
|
|
39
|
+
host = _env("LANGFUSE_BASE_URL")
|
|
40
|
+
if host:
|
|
41
|
+
kwargs["host"] = host
|
|
42
|
+
return Langfuse(**kwargs)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def extract_query(item_input: Any) -> str:
|
|
46
|
+
if isinstance(item_input, str):
|
|
47
|
+
return item_input
|
|
48
|
+
if isinstance(item_input, dict):
|
|
49
|
+
for key in ("input", "query", "question", "prompt"):
|
|
50
|
+
value = item_input.get(key)
|
|
51
|
+
if isinstance(value, str) and value.strip():
|
|
52
|
+
return value
|
|
53
|
+
raise AgentEvalError(f"无法从 Langfuse dataset item input 中提取 query: {item_input!r}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_langfuse_agent_experiment(
|
|
57
|
+
store: ConfigStore,
|
|
58
|
+
remote_name: str | None,
|
|
59
|
+
options: AgentEvalOptions,
|
|
60
|
+
console: Console,
|
|
61
|
+
*,
|
|
62
|
+
langfuse_factory=build_langfuse_client,
|
|
63
|
+
client_factory=YuxiClient,
|
|
64
|
+
) -> None:
|
|
65
|
+
config = store.load()
|
|
66
|
+
remote = config.get_remote(remote_name)
|
|
67
|
+
if not remote.api_key:
|
|
68
|
+
raise AgentEvalError(f"remote 尚未登录: {remote.name}。请先运行 yuxi login。")
|
|
69
|
+
|
|
70
|
+
if options.max_concurrency < 1:
|
|
71
|
+
raise AgentEvalError("--max-concurrency 必须大于等于 1")
|
|
72
|
+
if options.timeout_seconds <= 0:
|
|
73
|
+
raise AgentEvalError("--timeout-seconds 必须大于 0")
|
|
74
|
+
|
|
75
|
+
experiment_name = options.experiment_name or f"yuxi-agent-eval-{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}"
|
|
76
|
+
langfuse_client = langfuse_factory()
|
|
77
|
+
dataset = langfuse_client.get_dataset(options.dataset_name)
|
|
78
|
+
|
|
79
|
+
def task(*, item, **_kwargs):
|
|
80
|
+
return _run_agent_eval_item(
|
|
81
|
+
remote=remote,
|
|
82
|
+
agent_slug=options.agent_slug,
|
|
83
|
+
dataset_name=options.dataset_name,
|
|
84
|
+
experiment_name=experiment_name,
|
|
85
|
+
item=item,
|
|
86
|
+
timeout_seconds=options.timeout_seconds,
|
|
87
|
+
client_factory=client_factory,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
result = dataset.run_experiment(
|
|
91
|
+
name=experiment_name,
|
|
92
|
+
task=task,
|
|
93
|
+
max_concurrency=options.max_concurrency,
|
|
94
|
+
metadata={
|
|
95
|
+
"source": "agent_evaluation",
|
|
96
|
+
"agent_slug": options.agent_slug,
|
|
97
|
+
"dataset_name": options.dataset_name,
|
|
98
|
+
"remote": remote.name,
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
console.print(result.format(include_item_results=True))
|
|
102
|
+
langfuse_client.flush()
|
|
103
|
+
processed_count = len(result.item_results)
|
|
104
|
+
total_count = len(dataset.items)
|
|
105
|
+
if processed_count != total_count:
|
|
106
|
+
raise AgentEvalError(f"Langfuse experiment 部分失败: {processed_count}/{total_count} 个 item 成功写入")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _run_agent_eval_item(
|
|
110
|
+
*,
|
|
111
|
+
remote,
|
|
112
|
+
agent_slug: str,
|
|
113
|
+
dataset_name: str,
|
|
114
|
+
experiment_name: str,
|
|
115
|
+
item: Any,
|
|
116
|
+
timeout_seconds: float,
|
|
117
|
+
client_factory,
|
|
118
|
+
) -> str:
|
|
119
|
+
query = extract_query(item.input)
|
|
120
|
+
item_id = str(getattr(item, "id", "") or "")
|
|
121
|
+
request_id = f"eval-{uuid.uuid4()}"
|
|
122
|
+
evaluation = {
|
|
123
|
+
"dataset_name": dataset_name,
|
|
124
|
+
"dataset_item_id": item_id,
|
|
125
|
+
"experiment_name": experiment_name,
|
|
126
|
+
}
|
|
127
|
+
with client_factory(remote, timeout=timeout_seconds) as client:
|
|
128
|
+
result = client.run_agent_eval(
|
|
129
|
+
query=query,
|
|
130
|
+
agent_slug=agent_slug,
|
|
131
|
+
evaluation=evaluation,
|
|
132
|
+
meta={"request_id": request_id},
|
|
133
|
+
timeout_seconds=timeout_seconds,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if result.get("status") != "completed":
|
|
137
|
+
raise AgentEvalError(f"Agent eval run failed for dataset item {item_id}: {result}")
|
|
138
|
+
return str(result.get("output") or "")
|
yuxi_cli/client.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from yuxi_cli.config import Remote, build_url
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClientError(Exception):
|
|
14
|
+
def __init__(self, message: str, *, error_code: str | None = None, status_code: int | None = None):
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.error_code = error_code
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CLIAuthSession:
|
|
22
|
+
device_code: str
|
|
23
|
+
user_code: str
|
|
24
|
+
verification_uri: str
|
|
25
|
+
expires_in: int
|
|
26
|
+
interval: int
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def authorize_path(self) -> str:
|
|
30
|
+
params = urlencode({"user_code": self.user_code})
|
|
31
|
+
separator = "&" if "?" in self.verification_uri else "?"
|
|
32
|
+
return f"{self.verification_uri}{separator}{params}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class YuxiClient:
|
|
36
|
+
def __init__(self, remote: Remote, timeout: float = 30.0):
|
|
37
|
+
self.remote = remote
|
|
38
|
+
self.client = httpx.Client(timeout=timeout)
|
|
39
|
+
|
|
40
|
+
def close(self) -> None:
|
|
41
|
+
self.client.close()
|
|
42
|
+
|
|
43
|
+
def __enter__(self) -> YuxiClient:
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def __exit__(self, *_exc) -> None:
|
|
47
|
+
self.close()
|
|
48
|
+
|
|
49
|
+
def health(self) -> dict:
|
|
50
|
+
return self._request("GET", "/system/health", auth=False)
|
|
51
|
+
|
|
52
|
+
def discovery(self) -> dict:
|
|
53
|
+
return self._request("GET", "/system/discovery", auth=False)
|
|
54
|
+
|
|
55
|
+
def me(self, api_key: str | None = None) -> dict:
|
|
56
|
+
return self._request("GET", "/auth/me", api_key=api_key)
|
|
57
|
+
|
|
58
|
+
def create_cli_session(self) -> CLIAuthSession:
|
|
59
|
+
data = self._request("POST", "/auth/cli/sessions", json={}, auth=False)
|
|
60
|
+
return CLIAuthSession(
|
|
61
|
+
device_code=data["device_code"],
|
|
62
|
+
user_code=data["user_code"],
|
|
63
|
+
verification_uri=data["verification_uri"],
|
|
64
|
+
expires_in=int(data.get("expires_in") or 600),
|
|
65
|
+
interval=int(data.get("interval") or 2),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def exchange_cli_token(self, device_code: str) -> dict:
|
|
69
|
+
return self._request("POST", "/auth/cli/sessions/token", json={"device_code": device_code}, auth=False)
|
|
70
|
+
|
|
71
|
+
def delete_api_key(self, api_key_id: str) -> dict:
|
|
72
|
+
return self._request("DELETE", f"/user/apikey/{api_key_id}")
|
|
73
|
+
|
|
74
|
+
def get_database(self, kb_id: str) -> dict:
|
|
75
|
+
return self._request("GET", f"/knowledge/databases/{kb_id}")
|
|
76
|
+
|
|
77
|
+
def list_databases(self) -> dict:
|
|
78
|
+
return self._request("GET", "/knowledge/databases")
|
|
79
|
+
|
|
80
|
+
def get_knowledge_base_types(self) -> dict:
|
|
81
|
+
return self._request("GET", "/knowledge/types")
|
|
82
|
+
|
|
83
|
+
def get_supported_file_types(self) -> dict:
|
|
84
|
+
return self._request("GET", "/knowledge/files/supported-types")
|
|
85
|
+
|
|
86
|
+
def upload_knowledge_file(self, kb_id: str, path: Path, *, timeout_seconds: float = 300) -> dict:
|
|
87
|
+
with path.open("rb") as fp:
|
|
88
|
+
return self._request(
|
|
89
|
+
"POST",
|
|
90
|
+
"/knowledge/files/upload",
|
|
91
|
+
params={"kb_id": kb_id},
|
|
92
|
+
files={"file": (path.name, fp, "application/octet-stream")},
|
|
93
|
+
timeout=timeout_seconds,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def add_uploaded_documents(self, kb_id: str, items: list[str], params: dict) -> dict:
|
|
97
|
+
return self._request(
|
|
98
|
+
"POST",
|
|
99
|
+
f"/knowledge/databases/{kb_id}/documents/add",
|
|
100
|
+
json={"items": items, "params": params},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def run_agent_eval(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
query: str,
|
|
107
|
+
agent_slug: str,
|
|
108
|
+
evaluation: dict,
|
|
109
|
+
meta: dict | None = None,
|
|
110
|
+
image_content: str | None = None,
|
|
111
|
+
model_spec: str | None = None,
|
|
112
|
+
timeout_seconds: float = 900,
|
|
113
|
+
) -> dict:
|
|
114
|
+
payload = {
|
|
115
|
+
"query": query,
|
|
116
|
+
"agent_slug": agent_slug,
|
|
117
|
+
"evaluation": evaluation,
|
|
118
|
+
"meta": meta or {},
|
|
119
|
+
"image_content": image_content,
|
|
120
|
+
"model_spec": model_spec,
|
|
121
|
+
}
|
|
122
|
+
return self._request("POST", "/agent/eval/runs", json=payload, timeout=timeout_seconds)
|
|
123
|
+
|
|
124
|
+
def authorize_url(self, session: CLIAuthSession) -> str:
|
|
125
|
+
return build_url(self.remote.url, session.authorize_path)
|
|
126
|
+
|
|
127
|
+
def _request(
|
|
128
|
+
self,
|
|
129
|
+
method: str,
|
|
130
|
+
path: str,
|
|
131
|
+
*,
|
|
132
|
+
auth: bool = True,
|
|
133
|
+
api_key: str | None = None,
|
|
134
|
+
json: Any | None = None,
|
|
135
|
+
params: dict | None = None,
|
|
136
|
+
files: dict | None = None,
|
|
137
|
+
data: dict | None = None,
|
|
138
|
+
timeout: float | None = None,
|
|
139
|
+
) -> dict:
|
|
140
|
+
headers = {}
|
|
141
|
+
token = api_key if api_key is not None else self.remote.api_key
|
|
142
|
+
if auth and token:
|
|
143
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
144
|
+
|
|
145
|
+
url = f"{self.remote.api_base_url}{path if path.startswith('/') else f'/{path}'}"
|
|
146
|
+
request_kwargs: dict[str, Any] = {"headers": headers}
|
|
147
|
+
if params is not None:
|
|
148
|
+
request_kwargs["params"] = params
|
|
149
|
+
if files is not None:
|
|
150
|
+
request_kwargs["files"] = files
|
|
151
|
+
if data is not None:
|
|
152
|
+
request_kwargs["data"] = data
|
|
153
|
+
if json is not None:
|
|
154
|
+
request_kwargs["json"] = json
|
|
155
|
+
if timeout is not None:
|
|
156
|
+
request_kwargs["timeout"] = timeout
|
|
157
|
+
try:
|
|
158
|
+
response = self.client.request(method, url, **request_kwargs)
|
|
159
|
+
except httpx.HTTPError as exc:
|
|
160
|
+
# 网络层错误(连接失败、超时等)没有 HTTP 状态码,视为可重试的瞬时错误。
|
|
161
|
+
raise ClientError(f"请求远程失败: {exc}") from exc
|
|
162
|
+
|
|
163
|
+
if response.status_code >= 400:
|
|
164
|
+
error_code, error_message = _parse_http_error(response)
|
|
165
|
+
raise ClientError(error_message, error_code=error_code, status_code=response.status_code)
|
|
166
|
+
if not response.content:
|
|
167
|
+
return {}
|
|
168
|
+
try:
|
|
169
|
+
data = response.json()
|
|
170
|
+
except ValueError as exc:
|
|
171
|
+
raise ClientError("远程响应不是 JSON") from exc
|
|
172
|
+
if not isinstance(data, dict):
|
|
173
|
+
raise ClientError("远程响应格式无效")
|
|
174
|
+
return data
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _parse_http_error(response: httpx.Response) -> tuple[str | None, str]:
|
|
178
|
+
"""解析远程错误,返回 (机器可读 error code, 人类可读 message)。"""
|
|
179
|
+
try:
|
|
180
|
+
detail = response.json().get("detail")
|
|
181
|
+
except ValueError:
|
|
182
|
+
detail = response.text.strip()
|
|
183
|
+
|
|
184
|
+
if isinstance(detail, dict):
|
|
185
|
+
error = detail.get("error")
|
|
186
|
+
message = detail.get("message")
|
|
187
|
+
if error and message:
|
|
188
|
+
return str(error), f"{error}: {message}"
|
|
189
|
+
if error:
|
|
190
|
+
return str(error), str(error)
|
|
191
|
+
if message:
|
|
192
|
+
return None, str(message)
|
|
193
|
+
if detail:
|
|
194
|
+
return None, str(detail)
|
|
195
|
+
return None, f"HTTP {response.status_code}"
|
yuxi_cli/commands.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
import webbrowser
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from yuxi_cli.client import ClientError, YuxiClient
|
|
12
|
+
from yuxi_cli.config import ConfigStore, Remote
|
|
13
|
+
from yuxi_cli.discovery import ServerCompatibilityError, ensure_server_compatible
|
|
14
|
+
|
|
15
|
+
PENDING_ERRORS = ("authorization_pending", "slow_down")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CommandError(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def remote_add(store: ConfigStore, name: str, url: str) -> Remote:
|
|
23
|
+
config = store.load()
|
|
24
|
+
remote = config.set_remote(name, url)
|
|
25
|
+
store.save(config)
|
|
26
|
+
return remote
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def remote_use(store: ConfigStore, name: str) -> Remote:
|
|
30
|
+
config = store.load()
|
|
31
|
+
remote = config.use_remote(name)
|
|
32
|
+
store.save(config)
|
|
33
|
+
return remote
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def remote_list(store: ConfigStore, console: Console) -> None:
|
|
37
|
+
config = store.load()
|
|
38
|
+
table = Table(show_header=True, header_style="bold")
|
|
39
|
+
table.add_column("Current", width=7)
|
|
40
|
+
table.add_column("Name")
|
|
41
|
+
table.add_column("URL")
|
|
42
|
+
table.add_column("Auth")
|
|
43
|
+
for name, remote in config.remotes.items():
|
|
44
|
+
table.add_row("*" if name == config.current else "", name, remote.url, "API Key" if remote.has_api_key else "-")
|
|
45
|
+
console.print(table)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def remote_ping(store: ConfigStore, name: str | None, console: Console, client_factory=YuxiClient) -> None:
|
|
49
|
+
config = store.load()
|
|
50
|
+
remote = config.get_remote(name)
|
|
51
|
+
with client_factory(remote) as client:
|
|
52
|
+
data = client.health()
|
|
53
|
+
console.print(f"{remote.name}: {data.get('status', 'ok')} {data.get('version', '')}".rstrip())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def login_with_api_key(
|
|
57
|
+
store: ConfigStore,
|
|
58
|
+
remote_name: str | None,
|
|
59
|
+
api_key: str,
|
|
60
|
+
console: Console,
|
|
61
|
+
client_factory=YuxiClient,
|
|
62
|
+
) -> Remote:
|
|
63
|
+
if not api_key.startswith("yxkey_"):
|
|
64
|
+
raise CommandError("API Key 格式无效,应以 yxkey_ 开头")
|
|
65
|
+
|
|
66
|
+
config = store.load()
|
|
67
|
+
remote = config.get_remote(remote_name)
|
|
68
|
+
with client_factory(remote) as client:
|
|
69
|
+
_ensure_server_compatible(client, "cli.api_key_auth")
|
|
70
|
+
client.me(api_key=api_key) # 校验 Key 是否可用
|
|
71
|
+
|
|
72
|
+
remote.api_key = api_key
|
|
73
|
+
remote.api_key_id = ""
|
|
74
|
+
store.save(config)
|
|
75
|
+
console.print(f"已保存 {remote.name} 的 API Key。")
|
|
76
|
+
return remote
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def login_with_browser(
|
|
80
|
+
store: ConfigStore,
|
|
81
|
+
remote_name: str | None,
|
|
82
|
+
no_open: bool,
|
|
83
|
+
console: Console,
|
|
84
|
+
*,
|
|
85
|
+
client_factory=YuxiClient,
|
|
86
|
+
open_browser: Callable[[str], bool] = webbrowser.open,
|
|
87
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
88
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
89
|
+
) -> Remote:
|
|
90
|
+
config = store.load()
|
|
91
|
+
remote = config.get_remote(remote_name)
|
|
92
|
+
|
|
93
|
+
with client_factory(remote) as client:
|
|
94
|
+
_ensure_server_compatible(client, "cli.browser_login")
|
|
95
|
+
session = client.create_cli_session()
|
|
96
|
+
authorize_url = client.authorize_url(session)
|
|
97
|
+
console.print(f"授权码: {session.user_code}")
|
|
98
|
+
console.print(f"浏览器授权地址: {authorize_url}")
|
|
99
|
+
if not no_open:
|
|
100
|
+
open_browser(authorize_url)
|
|
101
|
+
|
|
102
|
+
deadline = monotonic() + session.expires_in
|
|
103
|
+
while monotonic() < deadline:
|
|
104
|
+
try:
|
|
105
|
+
data = client.exchange_cli_token(session.device_code)
|
|
106
|
+
api_key = data.get("secret") or ""
|
|
107
|
+
api_key_meta = data.get("api_key") or {}
|
|
108
|
+
if not api_key:
|
|
109
|
+
raise CommandError("远程未返回 API Key secret")
|
|
110
|
+
|
|
111
|
+
remote.api_key = api_key
|
|
112
|
+
remote.api_key_id = str(api_key_meta.get("id") or "")
|
|
113
|
+
store.save(config)
|
|
114
|
+
console.print(f"已完成 {remote.name} 的浏览器登录。")
|
|
115
|
+
return remote
|
|
116
|
+
except ClientError as exc:
|
|
117
|
+
if not _should_keep_polling(exc):
|
|
118
|
+
raise
|
|
119
|
+
sleep(max(1, session.interval))
|
|
120
|
+
|
|
121
|
+
raise CommandError("浏览器授权超时")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _should_keep_polling(exc: ClientError) -> bool:
|
|
125
|
+
"""轮询期间是否应继续重试:等待授权、限流,或瞬时网络/服务端错误。"""
|
|
126
|
+
if exc.error_code in PENDING_ERRORS or str(exc).startswith(PENDING_ERRORS):
|
|
127
|
+
return True
|
|
128
|
+
# status_code 为 None 表示网络层错误;5xx 为服务端瞬时错误,均可重试。
|
|
129
|
+
return exc.status_code is None or exc.status_code >= 500
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def whoami(store: ConfigStore, remote_name: str | None, console: Console, client_factory=YuxiClient) -> None:
|
|
133
|
+
config = store.load()
|
|
134
|
+
remote = config.get_remote(remote_name)
|
|
135
|
+
if not remote.api_key:
|
|
136
|
+
raise CommandError(f"remote 尚未登录: {remote.name}")
|
|
137
|
+
with client_factory(remote) as client:
|
|
138
|
+
user = client.me()
|
|
139
|
+
console.print(f"{user.get('username')} ({user.get('uid')}) - {user.get('role')}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def status(store: ConfigStore, remote_name: str | None, console: Console, client_factory=YuxiClient) -> None:
|
|
143
|
+
config = store.load()
|
|
144
|
+
remote = config.get_remote(remote_name)
|
|
145
|
+
with client_factory(remote) as client:
|
|
146
|
+
health = client.health()
|
|
147
|
+
auth = "未登录"
|
|
148
|
+
if remote.api_key:
|
|
149
|
+
try:
|
|
150
|
+
user = client.me()
|
|
151
|
+
auth = f"{user.get('username')} ({user.get('uid')})"
|
|
152
|
+
except ClientError:
|
|
153
|
+
auth = "API Key 无效"
|
|
154
|
+
|
|
155
|
+
table = Table(show_header=False)
|
|
156
|
+
table.add_row("Remote", remote.name)
|
|
157
|
+
table.add_row("URL", remote.url)
|
|
158
|
+
table.add_row("Health", f"{health.get('status', 'ok')} {health.get('version', '')}".rstrip())
|
|
159
|
+
table.add_row("Auth", auth)
|
|
160
|
+
console.print(table)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def logout(
|
|
164
|
+
store: ConfigStore,
|
|
165
|
+
remote_name: str | None,
|
|
166
|
+
local_only: bool,
|
|
167
|
+
console: Console,
|
|
168
|
+
client_factory=YuxiClient,
|
|
169
|
+
) -> Remote:
|
|
170
|
+
config = store.load()
|
|
171
|
+
remote = config.get_remote(remote_name)
|
|
172
|
+
if remote.api_key and remote.api_key_id and not local_only:
|
|
173
|
+
with client_factory(remote) as client:
|
|
174
|
+
client.delete_api_key(remote.api_key_id)
|
|
175
|
+
|
|
176
|
+
remote.api_key = ""
|
|
177
|
+
remote.api_key_id = ""
|
|
178
|
+
store.save(config)
|
|
179
|
+
console.print(f"已退出 {remote.name}。")
|
|
180
|
+
return remote
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def select_login_mode(console: Console) -> str:
|
|
184
|
+
console.print("选择登录方式:")
|
|
185
|
+
console.print("> 1. 浏览器登录(推荐)")
|
|
186
|
+
console.print(" 2. API Key")
|
|
187
|
+
if not sys.stdin.isatty():
|
|
188
|
+
return "browser"
|
|
189
|
+
value = input("直接回车使用浏览器登录,输入 2 使用 API Key: ").strip()
|
|
190
|
+
return "api_key" if value == "2" else "browser"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _ensure_server_compatible(client: YuxiClient, required_capability: str) -> None:
|
|
194
|
+
try:
|
|
195
|
+
discovery = client.discovery()
|
|
196
|
+
except ClientError as exc:
|
|
197
|
+
raise CommandError(f"无法读取服务端 discovery,请确认远程是 Yuxi 0.7.1 或更高版本: {exc}") from exc
|
|
198
|
+
try:
|
|
199
|
+
ensure_server_compatible(discovery, required_capability)
|
|
200
|
+
except ServerCompatibilityError as exc:
|
|
201
|
+
raise CommandError(str(exc)) from exc
|