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.
Files changed (55) hide show
  1. starforge_cli/__init__.py +3 -0
  2. starforge_cli/api_client.py +589 -0
  3. starforge_cli/auth.py +349 -0
  4. starforge_cli/catalog.py +124 -0
  5. starforge_cli/cli.py +74 -0
  6. starforge_cli/cli_ui.py +469 -0
  7. starforge_cli/client_device.py +104 -0
  8. starforge_cli/commands/__init__.py +1 -0
  9. starforge_cli/commands/admin.py +140 -0
  10. starforge_cli/commands/bench.py +94 -0
  11. starforge_cli/commands/common.py +178 -0
  12. starforge_cli/commands/dataset.py +150 -0
  13. starforge_cli/commands/exp.py +213 -0
  14. starforge_cli/commands/init.py +52 -0
  15. starforge_cli/commands/jobs.py +223 -0
  16. starforge_cli/commands/login.py +54 -0
  17. starforge_cli/commands/plugin.py +243 -0
  18. starforge_cli/commands/recipe.py +163 -0
  19. starforge_cli/commands/serve.py +79 -0
  20. starforge_cli/commands/submit.py +467 -0
  21. starforge_cli/commands/sweep.py +154 -0
  22. starforge_cli/config_resolve.py +17 -0
  23. starforge_cli/data_prep.py +60 -0
  24. starforge_cli/new_experiment.py +195 -0
  25. starforge_cli/packing.py +179 -0
  26. starforge_cli/plugins_lock.py +73 -0
  27. starforge_cli/project.py +130 -0
  28. starforge_cli/recipe_lock.py +453 -0
  29. starforge_cli/scaffold/agent-run.py.tmpl +146 -0
  30. starforge_cli/scaffold/custom-framework/train.sh +56 -0
  31. starforge_cli/scaffold/experiment-template/.gitkeep +0 -0
  32. starforge_cli/scaffold/experiment-template/README.md +36 -0
  33. starforge_cli/scaffold/experiment-template/config.yaml +44 -0
  34. starforge_cli/scaffold/project/common/README.md +12 -0
  35. starforge_cli/scaffold/project/common/__init__.py +0 -0
  36. starforge_cli/scaffold/project/configs/README.md +103 -0
  37. starforge_cli/scaffold/project/configs/base/README.md +24 -0
  38. starforge_cli/scaffold/project/configs/base/distillation_math.yaml +284 -0
  39. starforge_cli/scaffold/project/configs/base/grpo_lora.yaml +30 -0
  40. starforge_cli/scaffold/project/configs/base/grpo_math_1B.yaml +470 -0
  41. starforge_cli/scaffold/project/configs/base/grpo_megatron.yaml +43 -0
  42. starforge_cli/scaffold/project/configs/base/grpo_noncolocated.yaml +18 -0
  43. starforge_cli/scaffold/project/configs/base/grpo_sliding_puzzle.yaml +81 -0
  44. starforge_cli/scaffold/project/configs/base/ppo_math_1B.yaml +454 -0
  45. starforge_cli/scaffold/project/configs/base/rm.yaml +224 -0
  46. starforge_cli/scaffold/project/configs/base/sft.yaml +294 -0
  47. starforge_cli/scaffold/project/configs/models/README.md +16 -0
  48. starforge_cli/scaffold/project/configs/models/qwen3.5-4b.yaml +12 -0
  49. starforge_cli/scaffold/project/configs/models/qwen3.5-9b.yaml +10 -0
  50. starforge_cli/scaffold/project/gitignore +11 -0
  51. starforge_cli/spec_builder.py +372 -0
  52. starforge_cli-0.1.6.dist-info/METADATA +40 -0
  53. starforge_cli-0.1.6.dist-info/RECORD +55 -0
  54. starforge_cli-0.1.6.dist-info/WHEEL +4 -0
  55. 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
+ }
@@ -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()