starforge-cli 0.1.6__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.
- starforge_cli/__init__.py +3 -0
- starforge_cli/api_client.py +589 -0
- starforge_cli/auth.py +349 -0
- starforge_cli/catalog.py +124 -0
- starforge_cli/cli.py +74 -0
- starforge_cli/cli_ui.py +469 -0
- starforge_cli/client_device.py +104 -0
- starforge_cli/commands/__init__.py +1 -0
- starforge_cli/commands/admin.py +140 -0
- starforge_cli/commands/bench.py +94 -0
- starforge_cli/commands/common.py +178 -0
- starforge_cli/commands/dataset.py +150 -0
- starforge_cli/commands/exp.py +213 -0
- starforge_cli/commands/init.py +52 -0
- starforge_cli/commands/jobs.py +223 -0
- starforge_cli/commands/login.py +54 -0
- starforge_cli/commands/plugin.py +243 -0
- starforge_cli/commands/recipe.py +163 -0
- starforge_cli/commands/serve.py +79 -0
- starforge_cli/commands/submit.py +467 -0
- starforge_cli/commands/sweep.py +154 -0
- starforge_cli/config_resolve.py +17 -0
- starforge_cli/data_prep.py +60 -0
- starforge_cli/new_experiment.py +195 -0
- starforge_cli/packing.py +179 -0
- starforge_cli/plugins_lock.py +73 -0
- starforge_cli/project.py +130 -0
- starforge_cli/recipe_lock.py +453 -0
- starforge_cli/scaffold/agent-run.py.tmpl +146 -0
- starforge_cli/scaffold/custom-framework/train.sh +56 -0
- starforge_cli/scaffold/experiment-template/.gitkeep +0 -0
- starforge_cli/scaffold/experiment-template/README.md +36 -0
- starforge_cli/scaffold/experiment-template/config.yaml +44 -0
- starforge_cli/scaffold/project/common/README.md +12 -0
- starforge_cli/scaffold/project/common/__init__.py +0 -0
- starforge_cli/scaffold/project/configs/README.md +103 -0
- starforge_cli/scaffold/project/configs/base/README.md +24 -0
- starforge_cli/scaffold/project/configs/base/distillation_math.yaml +284 -0
- starforge_cli/scaffold/project/configs/base/grpo_lora.yaml +30 -0
- starforge_cli/scaffold/project/configs/base/grpo_math_1B.yaml +470 -0
- starforge_cli/scaffold/project/configs/base/grpo_megatron.yaml +43 -0
- starforge_cli/scaffold/project/configs/base/grpo_noncolocated.yaml +18 -0
- starforge_cli/scaffold/project/configs/base/grpo_sliding_puzzle.yaml +81 -0
- starforge_cli/scaffold/project/configs/base/ppo_math_1B.yaml +454 -0
- starforge_cli/scaffold/project/configs/base/rm.yaml +224 -0
- starforge_cli/scaffold/project/configs/base/sft.yaml +294 -0
- starforge_cli/scaffold/project/configs/models/README.md +16 -0
- starforge_cli/scaffold/project/configs/models/qwen3.5-4b.yaml +12 -0
- starforge_cli/scaffold/project/configs/models/qwen3.5-9b.yaml +10 -0
- starforge_cli/scaffold/project/gitignore +11 -0
- starforge_cli/spec_builder.py +372 -0
- starforge_cli-0.1.6.dist-info/METADATA +40 -0
- starforge_cli-0.1.6.dist-info/RECORD +55 -0
- starforge_cli-0.1.6.dist-info/WHEEL +4 -0
- starforge_cli-0.1.6.dist-info/entry_points.txt +2 -0
starforge_cli/auth.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""登录 / 凭据 / 命令门控(客户端核心,不含任何业务 API)。
|
|
2
|
+
|
|
3
|
+
仅用标准库(http.server / urllib / webbrowser)+ typer,不依赖 web extra,
|
|
4
|
+
保证未装 fastapi 的纯客户端也能 `sf login`。
|
|
5
|
+
|
|
6
|
+
本地状态:
|
|
7
|
+
~/.forge/config.json {"server": "https://starforge.gcoreinc.com"}
|
|
8
|
+
~/.forge/credentials.json {"<server>": {access_token, refresh_token, expires_at, user}}
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import secrets
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import urllib.request
|
|
23
|
+
import webbrowser
|
|
24
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
import typer
|
|
29
|
+
|
|
30
|
+
from starforge_cli import cli_ui
|
|
31
|
+
|
|
32
|
+
# 官方中心化 Lab 服务(sf login 默认;未配置时 CLI 亦指向此地址)
|
|
33
|
+
DEFAULT_FORGE_SERVER = "https://starforge.gcoreinc.com"
|
|
34
|
+
|
|
35
|
+
MSG_NOT_LOGGED_IN = "请先运行 sf login"
|
|
36
|
+
|
|
37
|
+
FORGE_DIR = Path(os.environ.get("FORGE_HOME") or (Path.home() / ".forge"))
|
|
38
|
+
CONFIG_PATH = FORGE_DIR / "config.json"
|
|
39
|
+
CRED_PATH = FORGE_DIR / "credentials.json"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ----------------------------- PKCE(stdlib)-----------------------------
|
|
43
|
+
def pkce_pair() -> tuple[str, str]:
|
|
44
|
+
verifier = secrets.token_urlsafe(64)
|
|
45
|
+
digest = hashlib.sha256(verifier.encode()).digest()
|
|
46
|
+
challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
|
47
|
+
return verifier, challenge
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ----------------------------- 本地配置/凭据 -----------------------------
|
|
51
|
+
def _read_json(path: Path) -> dict:
|
|
52
|
+
"""读本地状态文件;文件损坏直接报错,不静默当空处理。"""
|
|
53
|
+
if not path.is_file():
|
|
54
|
+
return {}
|
|
55
|
+
try:
|
|
56
|
+
return json.loads(path.read_text())
|
|
57
|
+
except json.JSONDecodeError:
|
|
58
|
+
cli_ui.fail(
|
|
59
|
+
f"本地状态文件损坏(非法 JSON): {path}",
|
|
60
|
+
hint=f"删除该文件后重新 sf login:rm {path}",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _write_json(path: Path, data: dict) -> None:
|
|
65
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
66
|
+
# 凭据文件必须在创建瞬间就是 0600:先 write_text 再 chmod 会留下一个
|
|
67
|
+
# 按默认 umask(常为 0644,世界可读)写入完整 token 的窗口。
|
|
68
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
69
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
70
|
+
f.write(json.dumps(data, ensure_ascii=False, indent=2))
|
|
71
|
+
try:
|
|
72
|
+
path.chmod(0o600) # 收紧历史版本以 0644 创建的旧文件
|
|
73
|
+
except OSError as e:
|
|
74
|
+
print(f"[lab] 警告:无法收紧本地状态文件权限 {path}: {e}", file=sys.stderr)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def current_server(explicit: Optional[str] = None) -> Optional[str]:
|
|
78
|
+
"""server 地址优先级:显式 > 环境 FORGE_SERVER > config.json > 官方默认。"""
|
|
79
|
+
s = (
|
|
80
|
+
explicit
|
|
81
|
+
or os.environ.get("FORGE_SERVER")
|
|
82
|
+
or _read_json(CONFIG_PATH).get("server")
|
|
83
|
+
or DEFAULT_FORGE_SERVER
|
|
84
|
+
)
|
|
85
|
+
return s.rstrip("/") if s else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_server_mode() -> bool:
|
|
89
|
+
return current_server() is not None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _save_server(server: str) -> None:
|
|
93
|
+
cfg = _read_json(CONFIG_PATH)
|
|
94
|
+
cfg["server"] = server
|
|
95
|
+
_write_json(CONFIG_PATH, cfg)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _save_creds(server: str, creds: dict) -> None:
|
|
99
|
+
all_creds = _read_json(CRED_PATH)
|
|
100
|
+
all_creds[server] = creds
|
|
101
|
+
_write_json(CRED_PATH, all_creds)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _load_creds(server: str) -> Optional[dict]:
|
|
105
|
+
return _read_json(CRED_PATH).get(server)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _clear_creds(server: str) -> None:
|
|
109
|
+
all_creds = _read_json(CRED_PATH)
|
|
110
|
+
if server in all_creds:
|
|
111
|
+
del all_creds[server]
|
|
112
|
+
_write_json(CRED_PATH, all_creds)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ----------------------------- HTTP(stdlib)-----------------------------
|
|
116
|
+
def _api(server: str, method: str, path: str, *, token: Optional[str] = None, body: Optional[dict] = None,
|
|
117
|
+
timeout: float = 10.0) -> dict:
|
|
118
|
+
url = f"{server}{path}"
|
|
119
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
120
|
+
headers = {"Content-Type": "application/json"} if data else {}
|
|
121
|
+
if token:
|
|
122
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
123
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
124
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
125
|
+
return json.loads(r.read() or b"{}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _http_json(server: str, method: str, path: str, *, body: Optional[dict] = None, timeout: float = 10.0) -> tuple[int, dict]:
|
|
129
|
+
"""HTTP 请求并返回 (status, json);不抛 HTTPError,便于轮询 pending。"""
|
|
130
|
+
url = f"{server}{path}"
|
|
131
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
132
|
+
headers = {"Content-Type": "application/json"} if data else {}
|
|
133
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
134
|
+
try:
|
|
135
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
136
|
+
return r.status, json.loads(r.read() or b"{}")
|
|
137
|
+
except urllib.error.HTTPError as e:
|
|
138
|
+
raw = e.read() or b"{}"
|
|
139
|
+
try:
|
|
140
|
+
payload = json.loads(raw)
|
|
141
|
+
except json.JSONDecodeError:
|
|
142
|
+
payload = {"detail": raw.decode(errors="ignore") or e.reason}
|
|
143
|
+
return e.code, payload
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ----------------------------- token 生命周期 -----------------------------
|
|
147
|
+
def _refresh(server: str, creds: dict) -> Optional[dict]:
|
|
148
|
+
rt = creds.get("refresh_token")
|
|
149
|
+
if not rt:
|
|
150
|
+
return None
|
|
151
|
+
# 限流(429)视为瞬时错误,短退避重试,避免误判为凭据失效而强制重登。
|
|
152
|
+
for attempt in range(3):
|
|
153
|
+
try:
|
|
154
|
+
resp = _api(server, "POST", "/api/auth/refresh", body={"refresh_token": rt})
|
|
155
|
+
except urllib.error.HTTPError as e:
|
|
156
|
+
if e.code == 429 and attempt < 2:
|
|
157
|
+
time.sleep(1.5 * (attempt + 1))
|
|
158
|
+
continue
|
|
159
|
+
return None
|
|
160
|
+
creds["access_token"] = resp["access_token"]
|
|
161
|
+
# 服务端轮转 refresh 时回传新 token,必须持久化,否则下次续期会用已吊销的旧 token 失败。
|
|
162
|
+
if resp.get("refresh_token"):
|
|
163
|
+
creds["refresh_token"] = resp["refresh_token"]
|
|
164
|
+
creds["expires_at"] = time.time() + resp.get("expires_in", 3600) - 60
|
|
165
|
+
_save_creds(server, creds)
|
|
166
|
+
return creds
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_access_token(server: str, *, auto_refresh: bool = True) -> Optional[str]:
|
|
171
|
+
"""返回有效 access token;过期则用 refresh 续期;都不行返回 None。"""
|
|
172
|
+
creds = _load_creds(server)
|
|
173
|
+
if not creds:
|
|
174
|
+
return None
|
|
175
|
+
exp = creds.get("expires_at")
|
|
176
|
+
if exp is None or time.time() < exp:
|
|
177
|
+
return creds.get("access_token")
|
|
178
|
+
if auto_refresh:
|
|
179
|
+
refreshed = _refresh(server, creds)
|
|
180
|
+
if refreshed:
|
|
181
|
+
return refreshed["access_token"]
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ----------------------------- 命令门控 -----------------------------
|
|
186
|
+
def gate() -> None:
|
|
187
|
+
"""集群类命令执行前的登录门槛:未登录直接报错,不隐式发起登录流程。"""
|
|
188
|
+
server = current_server()
|
|
189
|
+
if not get_access_token(server):
|
|
190
|
+
cli_ui.fail(MSG_NOT_LOGGED_IN, hint="运行 sf login 登录")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ----------------------------- 环境检测 / 设备码登录 -----------------------------
|
|
194
|
+
def prefer_device_flow(*, force: bool = False, no_browser: bool = False) -> bool:
|
|
195
|
+
"""SSH / 无图形环境优先走 RFC 8628 设备码流程。"""
|
|
196
|
+
if force or no_browser:
|
|
197
|
+
return True
|
|
198
|
+
if os.environ.get("FORGE_DEVICE_FLOW", "").lower() in ("1", "true", "yes"):
|
|
199
|
+
return True
|
|
200
|
+
if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"):
|
|
201
|
+
return True
|
|
202
|
+
if sys.platform.startswith("linux") and not os.environ.get("DISPLAY"):
|
|
203
|
+
return True
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _device_login(server: str, timeout: float = 900.0) -> dict:
|
|
208
|
+
from starforge_cli.client_device import collect_cli_device, encode_device_param
|
|
209
|
+
|
|
210
|
+
device = encode_device_param(collect_cli_device())
|
|
211
|
+
status, resp = _http_json(server, "POST", "/api/cli/device/code", body={"device": device})
|
|
212
|
+
if status != 200:
|
|
213
|
+
detail = resp.get("detail", resp)
|
|
214
|
+
cli_ui.fail(f"无法启动登录:{detail}")
|
|
215
|
+
|
|
216
|
+
device_code = resp["device_code"]
|
|
217
|
+
user_code = resp["user_code"]
|
|
218
|
+
verification_uri = resp.get("verification_uri_complete") or resp.get("verification_uri", f"{server}/cli/device")
|
|
219
|
+
interval = int(resp.get("interval", 5))
|
|
220
|
+
expires_at = time.time() + float(resp.get("expires_in", timeout))
|
|
221
|
+
|
|
222
|
+
typer.echo("")
|
|
223
|
+
typer.secho("请用浏览器完成登录:", fg=typer.colors.YELLOW)
|
|
224
|
+
typer.echo(f" 打开 {verification_uri}")
|
|
225
|
+
typer.secho(f" 验证码:{user_code}", fg=typer.colors.CYAN, bold=True)
|
|
226
|
+
typer.echo("")
|
|
227
|
+
|
|
228
|
+
if not (os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY")):
|
|
229
|
+
try:
|
|
230
|
+
webbrowser.open(verification_uri)
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
while time.time() < expires_at:
|
|
235
|
+
time.sleep(interval)
|
|
236
|
+
status, tok = _http_json(server, "POST", "/api/cli/device/token", body={"device_code": device_code})
|
|
237
|
+
if status == 200:
|
|
238
|
+
return {
|
|
239
|
+
"access_token": tok["access_token"],
|
|
240
|
+
"refresh_token": tok.get("refresh_token"),
|
|
241
|
+
"expires_at": time.time() + tok.get("expires_in", 3600) - 60,
|
|
242
|
+
"user": tok.get("user"),
|
|
243
|
+
}
|
|
244
|
+
detail = tok.get("detail", "")
|
|
245
|
+
if detail == "authorization_pending":
|
|
246
|
+
continue
|
|
247
|
+
if detail == "slow_down" or status == 429: # 限流:放慢轮询而非中止授权
|
|
248
|
+
interval = min(interval + 5, 60)
|
|
249
|
+
continue
|
|
250
|
+
cli_ui.fail(f"登录失败:{detail or status}")
|
|
251
|
+
|
|
252
|
+
cli_ui.fail("登录超时,请重试。")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _interactive_login(server: str, *, device_flow: bool = False, no_browser: bool = False) -> dict:
|
|
256
|
+
if prefer_device_flow(force=device_flow, no_browser=no_browser):
|
|
257
|
+
return _device_login(server)
|
|
258
|
+
return _browser_login(server)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ----------------------------- 回环登录流 -----------------------------
|
|
262
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
263
|
+
result: dict = {}
|
|
264
|
+
success_redirect: str = ""
|
|
265
|
+
|
|
266
|
+
def do_GET(self): # noqa: N802
|
|
267
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
268
|
+
if parsed.path != "/callback":
|
|
269
|
+
self.send_response(404)
|
|
270
|
+
self.end_headers()
|
|
271
|
+
return
|
|
272
|
+
qs = urllib.parse.parse_qs(parsed.query)
|
|
273
|
+
type(self).result = {
|
|
274
|
+
"code": (qs.get("code") or [None])[0],
|
|
275
|
+
"state": (qs.get("state") or [None])[0],
|
|
276
|
+
"error": (qs.get("error") or [None])[0],
|
|
277
|
+
}
|
|
278
|
+
redirect = type(self).success_redirect
|
|
279
|
+
if redirect and not type(self).result.get("error"):
|
|
280
|
+
self.send_response(302)
|
|
281
|
+
self.send_header("Location", redirect)
|
|
282
|
+
self.end_headers()
|
|
283
|
+
return
|
|
284
|
+
self.send_response(200)
|
|
285
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
286
|
+
self.end_headers()
|
|
287
|
+
self.wfile.write(
|
|
288
|
+
"<html><body style='font-family:sans-serif;text-align:center;margin-top:80px'>"
|
|
289
|
+
"<h2>授权失败</h2><p>请关闭此页面并在终端重试 sf login。</p>"
|
|
290
|
+
"</body></html>".encode()
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def log_message(self, *a): # 静默
|
|
294
|
+
pass
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _browser_login(server: str, timeout: float = 180.0) -> dict:
|
|
298
|
+
from starforge_cli.client_device import collect_cli_device, encode_device_param
|
|
299
|
+
|
|
300
|
+
verifier, challenge = pkce_pair()
|
|
301
|
+
state = secrets.token_urlsafe(16)
|
|
302
|
+
httpd = HTTPServer(("127.0.0.1", 0), _CallbackHandler)
|
|
303
|
+
port = httpd.server_address[1]
|
|
304
|
+
redirect_uri = f"http://127.0.0.1:{port}/callback"
|
|
305
|
+
_CallbackHandler.result = {}
|
|
306
|
+
_CallbackHandler.success_redirect = f"{server.rstrip('/')}/cli/success"
|
|
307
|
+
|
|
308
|
+
device = encode_device_param(collect_cli_device())
|
|
309
|
+
q = urllib.parse.urlencode(
|
|
310
|
+
{"redirect_uri": redirect_uri, "state": state, "challenge": challenge, "device": device},
|
|
311
|
+
)
|
|
312
|
+
auth_url = f"{server}/cli/authorize?{q}"
|
|
313
|
+
typer.echo("正在打开浏览器…")
|
|
314
|
+
webbrowser.open(auth_url)
|
|
315
|
+
|
|
316
|
+
deadline = time.time() + timeout
|
|
317
|
+
|
|
318
|
+
def _serve():
|
|
319
|
+
while not _CallbackHandler.result and time.time() < deadline:
|
|
320
|
+
httpd.handle_request()
|
|
321
|
+
|
|
322
|
+
t = threading.Thread(target=_serve, daemon=True)
|
|
323
|
+
t.start()
|
|
324
|
+
t.join(timeout)
|
|
325
|
+
httpd.server_close()
|
|
326
|
+
|
|
327
|
+
res = _CallbackHandler.result
|
|
328
|
+
if not res:
|
|
329
|
+
cli_ui.fail("登录超时,请重试。")
|
|
330
|
+
if res.get("error"):
|
|
331
|
+
cli_ui.fail(f"登录失败:{res['error']}")
|
|
332
|
+
if res.get("state") != state:
|
|
333
|
+
cli_ui.fail("登录校验失败,请重试。")
|
|
334
|
+
|
|
335
|
+
resp = _api(
|
|
336
|
+
server, "POST", "/api/cli/token",
|
|
337
|
+
body={
|
|
338
|
+
"code": res["code"],
|
|
339
|
+
"verifier": verifier,
|
|
340
|
+
"redirect_uri": redirect_uri,
|
|
341
|
+
"device": device,
|
|
342
|
+
},
|
|
343
|
+
)
|
|
344
|
+
return {
|
|
345
|
+
"access_token": resp["access_token"],
|
|
346
|
+
"refresh_token": resp.get("refresh_token"),
|
|
347
|
+
"expires_at": time.time() + resp.get("expires_in", 3600) - 60,
|
|
348
|
+
"user": resp.get("user"),
|
|
349
|
+
}
|
starforge_cli/catalog.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""与 Console recipe catalog 的契约校验(纯函数,不联网)。
|
|
2
|
+
|
|
3
|
+
v2 握手:SDK 用兼容范围,recipe bundle / framework / runtime_id 仍精确匹配。
|
|
4
|
+
v1 握手保留给尚未升级的 Console,仍要求 SDK 字符串全等。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
12
|
+
from packaging.version import InvalidVersion, Version
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CatalogCompatibilityError(ValueError):
|
|
16
|
+
"""CLI、Console 与 recipe catalog 不是同一份精确契约。"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_CATALOG_VERSIONS = ("forge/recipe-catalog/v1", "forge/recipe-catalog/v2")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def verify_catalog_compatibility(spec, payload: dict) -> None:
|
|
23
|
+
"""在上传前验证 Console 公布的 SDK/recipe/adapter 契约。"""
|
|
24
|
+
from starforge_core import __version__ as core_version
|
|
25
|
+
from starforge_core.contract import API_VERSION
|
|
26
|
+
|
|
27
|
+
api_version = payload.get("apiVersion")
|
|
28
|
+
if api_version not in _CATALOG_VERSIONS:
|
|
29
|
+
raise CatalogCompatibilityError("Console recipe catalog apiVersion 不兼容")
|
|
30
|
+
versions = (payload.get("contract") or {}).get("versions")
|
|
31
|
+
if versions != [API_VERSION]:
|
|
32
|
+
raise CatalogCompatibilityError(
|
|
33
|
+
f"Console JobSpec contract 不兼容:server={versions!r}, cli={[API_VERSION]!r}"
|
|
34
|
+
)
|
|
35
|
+
_verify_core_handshake(payload.get("core") or {}, core_version, api_version=api_version)
|
|
36
|
+
recipes = payload.get("recipes")
|
|
37
|
+
if not isinstance(recipes, list):
|
|
38
|
+
raise CatalogCompatibilityError("Console recipe catalog 缺少 recipes 数组")
|
|
39
|
+
canonical = []
|
|
40
|
+
for item in recipes:
|
|
41
|
+
if not isinstance(item, dict):
|
|
42
|
+
raise CatalogCompatibilityError("Console recipe catalog 含非法 recipe 项")
|
|
43
|
+
canonical.append({
|
|
44
|
+
"name": item.get("name"),
|
|
45
|
+
"version": item.get("version"),
|
|
46
|
+
"digest": item.get("digest"),
|
|
47
|
+
})
|
|
48
|
+
digest = hashlib.sha256(
|
|
49
|
+
json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
50
|
+
).hexdigest()
|
|
51
|
+
if payload.get("catalog_digest") != f"sha256:{digest}":
|
|
52
|
+
raise CatalogCompatibilityError("Console recipe catalog digest 校验失败")
|
|
53
|
+
|
|
54
|
+
selected = next((item for item in recipes if item.get("name") == spec.recipe_name), None)
|
|
55
|
+
if selected is None:
|
|
56
|
+
raise CatalogCompatibilityError(f"Console 未启用 recipe {spec.recipe_name!r}")
|
|
57
|
+
expected = {
|
|
58
|
+
"version": spec.spec.recipe.version,
|
|
59
|
+
"digest": spec.spec.recipe.digest,
|
|
60
|
+
"framework": spec.spec.framework.kind,
|
|
61
|
+
"adapter": spec.spec.framework.kind,
|
|
62
|
+
}
|
|
63
|
+
drift = {
|
|
64
|
+
key: {"server": selected.get(key), "cli": value}
|
|
65
|
+
for key, value in expected.items()
|
|
66
|
+
if selected.get(key) != value
|
|
67
|
+
}
|
|
68
|
+
if drift:
|
|
69
|
+
raise CatalogCompatibilityError(
|
|
70
|
+
f"recipe {spec.recipe_name!r} 精确契约不一致: {json.dumps(drift, ensure_ascii=False)}"
|
|
71
|
+
)
|
|
72
|
+
framework_version = spec.spec.framework.version
|
|
73
|
+
supported = selected.get("supported_framework_versions")
|
|
74
|
+
if not isinstance(supported, list) or framework_version not in supported:
|
|
75
|
+
raise CatalogCompatibilityError(
|
|
76
|
+
f"Console 未发布 {spec.spec.framework.kind}@{framework_version};server={supported!r}"
|
|
77
|
+
)
|
|
78
|
+
runtime = selected.get("runtime") or {}
|
|
79
|
+
variants = runtime.get("versions") if isinstance(runtime, dict) else None
|
|
80
|
+
variant = variants.get(framework_version) if isinstance(variants, dict) else None
|
|
81
|
+
if not isinstance(variant, dict):
|
|
82
|
+
raise CatalogCompatibilityError(
|
|
83
|
+
f"Console catalog 缺少 {spec.spec.framework.kind}@{framework_version} 的 runtime 变体"
|
|
84
|
+
)
|
|
85
|
+
# runtime_id 为空是合法状态(custom/user-managed 没有部署执行工件);
|
|
86
|
+
# 序列化会省略空字段,两侧都归一化成 "" 再比,避免 None != "" 假阳性。
|
|
87
|
+
server_runtime_id = str(variant.get("runtime_id") or "").strip()
|
|
88
|
+
if server_runtime_id != (spec.spec.framework.runtime_id or "").strip():
|
|
89
|
+
raise CatalogCompatibilityError(
|
|
90
|
+
f"Console runtime_id 不兼容:server={server_runtime_id!r}, "
|
|
91
|
+
f"cli={spec.spec.framework.runtime_id!r}"
|
|
92
|
+
)
|
|
93
|
+
recipe_requires = str(selected.get("core_requires") or "").strip()
|
|
94
|
+
if recipe_requires:
|
|
95
|
+
_require_core_in_range(core_version, recipe_requires, where="recipe.core_requires")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _verify_core_handshake(server_core: dict, cli_version: str, *, api_version: str) -> None:
|
|
99
|
+
server_version = str(server_core.get("version") or "").strip()
|
|
100
|
+
requirement = str(server_core.get("requirement") or "").strip()
|
|
101
|
+
if api_version == "forge/recipe-catalog/v1":
|
|
102
|
+
if server_version != cli_version or requirement != f"=={cli_version}":
|
|
103
|
+
raise CatalogCompatibilityError(
|
|
104
|
+
f"core 版本不兼容:server={server_core!r}, cli=={cli_version}"
|
|
105
|
+
)
|
|
106
|
+
return
|
|
107
|
+
if not server_version or not requirement:
|
|
108
|
+
raise CatalogCompatibilityError(f"core 握手缺少 version/requirement:{server_core!r}")
|
|
109
|
+
_require_core_in_range(cli_version, requirement, where="catalog.core.requirement")
|
|
110
|
+
_require_core_in_range(server_version, requirement, where="catalog.core.requirement")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _require_core_in_range(version: str, requirement: str, *, where: str) -> None:
|
|
114
|
+
try:
|
|
115
|
+
parsed = Version(version)
|
|
116
|
+
spec = SpecifierSet(requirement, prereleases=True)
|
|
117
|
+
except (InvalidVersion, InvalidSpecifier) as exc:
|
|
118
|
+
raise CatalogCompatibilityError(
|
|
119
|
+
f"非法 core 兼容声明 {where}={requirement!r} version={version!r}"
|
|
120
|
+
) from exc
|
|
121
|
+
if parsed not in spec:
|
|
122
|
+
raise CatalogCompatibilityError(
|
|
123
|
+
f"core 版本不兼容:{where}={requirement},实际 {version}"
|
|
124
|
+
)
|
starforge_cli/cli.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""starforge 统一 CLI:app 组装(命令实现见 starforge_cli/commands/)。
|
|
2
|
+
|
|
3
|
+
命令面即架构:CLI 是 Console 的瘦客户端,只做四件事——
|
|
4
|
+
身份 login / logout
|
|
5
|
+
实验资产 ls / new / methods / validate(纯本地 + SDK catalog)
|
|
6
|
+
作业契约 submit / export / eval / clean(JobSpec + 清单式打包,经 Console)
|
|
7
|
+
观测控制 status / job *(经 Console;集群细节客户端不可见)
|
|
8
|
+
外加 dataset *(数据生命周期)、plugin *(插件中心)与 admin *(管理员)三个子组。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from starforge_cli.commands import (
|
|
15
|
+
admin,
|
|
16
|
+
bench,
|
|
17
|
+
dataset,
|
|
18
|
+
exp,
|
|
19
|
+
jobs,
|
|
20
|
+
login,
|
|
21
|
+
plugin,
|
|
22
|
+
recipe,
|
|
23
|
+
serve,
|
|
24
|
+
submit,
|
|
25
|
+
sweep,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(
|
|
29
|
+
add_completion=True,
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
rich_markup_mode="rich",
|
|
32
|
+
help="StarForge(星锻)· 大模型后训练平台 CLI",
|
|
33
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# ----------------------------- 项目与身份 -----------------------------
|
|
37
|
+
from starforge_cli.commands import init as init_cmd # noqa: E402
|
|
38
|
+
|
|
39
|
+
app.command(help="创建 StarForge 微调项目(experiments/ + 官方基底 + common 骨架)")(init_cmd.init)
|
|
40
|
+
app.command(help="登录 StarForge")(login.login)
|
|
41
|
+
app.command(help="登出")(login.logout)
|
|
42
|
+
|
|
43
|
+
# ----------------------------- 实验资产(纯本地)-----------------------------
|
|
44
|
+
app.command(help="列出实验 / 项目")(exp.ls)
|
|
45
|
+
app.command(help="新建实验(--from fork 现成实验;--method 来自 SDK recipe catalog)")(exp.new)
|
|
46
|
+
app.command(name="methods", help="列出可用的后训练方法与它们的超参")(exp.methods)
|
|
47
|
+
app.command(help="校验实验 config(提交前本地检查)")(exp.validate)
|
|
48
|
+
|
|
49
|
+
# ----------------------------- 作业契约(经 Console)-----------------------------
|
|
50
|
+
app.command(help="提交训练作业(提交前自动校验 config 与超参)")(submit.submit)
|
|
51
|
+
app.command(help="超参 sweep:网格展开批量提交(每变体一次标准提交,配额/排队照常生效)")(sweep.sweep)
|
|
52
|
+
app.command(help="标准基准评测(lm-eval / evalscope),分数入库平台看板")(bench.bench)
|
|
53
|
+
app.command(name="export", help="将 checkpoint 转为 HuggingFace 格式(可推 Hub)")(submit.export_ckpt)
|
|
54
|
+
app.command(
|
|
55
|
+
name="eval",
|
|
56
|
+
help="按 recipe 的原生评测入口执行;NeMo-RL 用 --model/--eval-config,verl 用 --data",
|
|
57
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
58
|
+
)(submit.eval_ckpt)
|
|
59
|
+
app.command(help="清理实验在集群上的 checkpoint 与日志(不可恢复)")(submit.clean)
|
|
60
|
+
|
|
61
|
+
# ----------------------------- 观测控制(经 Console)-----------------------------
|
|
62
|
+
app.command(help="账号、配额、用量与活跃作业")(jobs.status)
|
|
63
|
+
app.add_typer(jobs.job_app, name="job")
|
|
64
|
+
|
|
65
|
+
# ----------------------------- 数据集 / 插件 / 管理员 -----------------------------
|
|
66
|
+
app.add_typer(recipe.recipe_app, name="recipe")
|
|
67
|
+
app.add_typer(serve.serve_app, name="serve")
|
|
68
|
+
app.add_typer(dataset.dataset_app, name="dataset")
|
|
69
|
+
app.add_typer(plugin.plugin_app, name="plugin")
|
|
70
|
+
app.add_typer(admin.admin_app, name="admin")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
app()
|