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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""管理员命令:用户 / 配额 / 维护模式(需 admin 权限)。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import urllib.parse
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from starforge_cli import api_client
|
|
11
|
+
|
|
12
|
+
admin_app = typer.Typer(
|
|
13
|
+
no_args_is_help=True,
|
|
14
|
+
help="管理员:用户与配额管理(需 admin 权限)。",
|
|
15
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@admin_app.command("users", help="列出所有用户")
|
|
20
|
+
def admin_users() -> None:
|
|
21
|
+
data = api_client._admin_call("GET", "/api/admin/users")
|
|
22
|
+
for u in data.get("users", []):
|
|
23
|
+
flag = " [disabled]" if u.get("disabled") else ""
|
|
24
|
+
typer.echo(f"{u['id']:>3} {u['username']:<20} {u['role']:<10} {u.get('auth_source','')}{flag}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@admin_app.command("user-add", help="新建本地账号")
|
|
28
|
+
def admin_user_add(
|
|
29
|
+
username: str = typer.Argument(...),
|
|
30
|
+
password: str = typer.Option(..., "--password", "-p", prompt=True, hide_input=True),
|
|
31
|
+
role: str = typer.Option("operator", "--role", help="admin | operator | viewer"),
|
|
32
|
+
email: Optional[str] = typer.Option(None, "--email"),
|
|
33
|
+
) -> None:
|
|
34
|
+
u = api_client._admin_call(
|
|
35
|
+
"POST", "/api/admin/users",
|
|
36
|
+
body={"username": username, "password": password, "role": role, "email": email},
|
|
37
|
+
)
|
|
38
|
+
typer.secho(f"✓ 已创建 {u['username']}({u['role']})", fg=typer.colors.GREEN)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@admin_app.command("set-role", help="修改用户角色")
|
|
42
|
+
def admin_set_role(username: str = typer.Argument(...), role: str = typer.Argument(...)) -> None:
|
|
43
|
+
u = api_client._admin_call("PATCH", f"/api/admin/users/{username}/role?role={urllib.parse.quote(role)}")
|
|
44
|
+
typer.secho(f"✓ {u['username']} → {u['role']}", fg=typer.colors.GREEN)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@admin_app.command("disable", help="停用/启用用户(--on 停用,--off 启用)")
|
|
48
|
+
def admin_disable(
|
|
49
|
+
username: str = typer.Argument(...),
|
|
50
|
+
disabled: bool = typer.Option(True, "--on/--off", help="--on 停用,--off 启用"),
|
|
51
|
+
) -> None:
|
|
52
|
+
u = api_client._admin_call("PATCH", f"/api/admin/users/{username}/disabled?disabled={str(disabled).lower()}")
|
|
53
|
+
typer.secho(f"✓ {u['username']} disabled={u['disabled']}", fg=typer.colors.GREEN)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@admin_app.command("set-quota", help="设置用户算力配额")
|
|
57
|
+
def admin_set_quota(
|
|
58
|
+
username: str = typer.Argument(...),
|
|
59
|
+
gpus: int = typer.Option(8, "--gpus", help="并发 GPU 上限"),
|
|
60
|
+
jobs: int = typer.Option(4, "--jobs", help="并发作业上限"),
|
|
61
|
+
daily_gpu_hours: int = typer.Option(0, "--daily-gpu-hours", help="每日 GPU-时(0=不限)"),
|
|
62
|
+
profiles: Optional[str] = typer.Option(None, "--profiles", help="允许的 profile(逗号分隔,空=全部)"),
|
|
63
|
+
priority: int = typer.Option(0, "--priority", help="排队优先级"),
|
|
64
|
+
) -> None:
|
|
65
|
+
allowed = [p.strip() for p in profiles.split(",") if p.strip()] if profiles else []
|
|
66
|
+
q = api_client._admin_call("POST", "/api/admin/quotas", body={
|
|
67
|
+
"username": username, "max_concurrent_gpus": gpus, "max_concurrent_jobs": jobs,
|
|
68
|
+
"daily_gpu_hours": daily_gpu_hours, "allowed_profiles": allowed, "priority": priority,
|
|
69
|
+
})
|
|
70
|
+
typer.secho(f"✓ 已设置 {username} 配额:{json.dumps(q, ensure_ascii=False)}", fg=typer.colors.GREEN)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ----------------------------- 维护模式(滚动升级集群镜像)-----------------------------
|
|
74
|
+
# 完整流程见 console 仓库 deploy/ray-cluster/README.md「升级」一节:
|
|
75
|
+
# sf admin maintenance drain --note "升级镜像至 0.7.0-20260805"
|
|
76
|
+
# sf admin maintenance status # 等到「可以重启」
|
|
77
|
+
# (各节点 docker compose pull && up -d)
|
|
78
|
+
# sf admin maintenance resume # 作业自动从 checkpoint 续训
|
|
79
|
+
maintenance_app = typer.Typer(
|
|
80
|
+
no_args_is_help=True,
|
|
81
|
+
help="维护模式:排空集群 → 升级 → 恢复(作业从 checkpoint 续训,不丢进度)",
|
|
82
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
83
|
+
)
|
|
84
|
+
admin_app.add_typer(maintenance_app, name="maintenance")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _print_maintenance(data: dict) -> None:
|
|
88
|
+
on = data.get("maintenance_mode")
|
|
89
|
+
typer.secho(
|
|
90
|
+
f"维护模式:{'开启' if on else '关闭'}" + (f"({data['note']})" if data.get("note") else ""),
|
|
91
|
+
fg=typer.colors.YELLOW if on else typer.colors.GREEN,
|
|
92
|
+
)
|
|
93
|
+
if paused := data.get("paused"):
|
|
94
|
+
typer.echo(f"本次暂停 {len(paused)} 个训练作业:")
|
|
95
|
+
for rid in paused:
|
|
96
|
+
typer.echo(f" · {rid}")
|
|
97
|
+
if pending := data.get("paused_awaiting_resume"):
|
|
98
|
+
typer.echo(f"待自动续训:{pending} 个")
|
|
99
|
+
|
|
100
|
+
# blockers / failed 是决定「能不能动集群」的关键,务必显眼
|
|
101
|
+
for key, label, color in (
|
|
102
|
+
("blockers", "仍占卡(不支持 checkpoint 续跑,需等它跑完或手动停)", typer.colors.YELLOW),
|
|
103
|
+
("failed", "停止失败(重跑 drain 或手动处理)", typer.colors.RED),
|
|
104
|
+
):
|
|
105
|
+
for item in data.get(key) or []:
|
|
106
|
+
typer.secho(
|
|
107
|
+
f" ⚠ [{label}] {item.get('lab_run_id')} ({item.get('username')})"
|
|
108
|
+
+ (f" — {item['reason']}" if item.get("reason") else ""),
|
|
109
|
+
fg=color,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if "safe_to_restart" in data:
|
|
113
|
+
if data["safe_to_restart"]:
|
|
114
|
+
typer.secho("\n✓ 集群已排空,可以重启节点了", fg=typer.colors.GREEN, bold=True)
|
|
115
|
+
typer.echo(" 各节点:docker compose --profile <head|worker> pull && up -d")
|
|
116
|
+
typer.echo(" 升级完成后:sf admin maintenance resume")
|
|
117
|
+
else:
|
|
118
|
+
n = data.get("remaining_active", data.get("active_jobs", "?"))
|
|
119
|
+
typer.secho(f"\n✗ 还有 {n} 个作业占着卡,现在重启会打断它们", fg=typer.colors.RED, bold=True)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@maintenance_app.command("status", help="查看维护状态与是否可以安全重启集群")
|
|
123
|
+
def maintenance_status() -> None:
|
|
124
|
+
_print_maintenance(api_client._admin_call("GET", "/api/admin/maintenance"))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@maintenance_app.command("drain", help="进入维护模式并排空集群(幂等,没排干净就再跑一次)")
|
|
128
|
+
def maintenance_drain(
|
|
129
|
+
note: str = typer.Option("", "--note", help="维护说明,会回显给被拦下的提交者"),
|
|
130
|
+
) -> None:
|
|
131
|
+
_print_maintenance(api_client._admin_call("POST", "/api/admin/maintenance/drain", body={"note": note}))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@maintenance_app.command("resume", help="退出维护模式,被暂停的作业自动从 checkpoint 续训")
|
|
135
|
+
def maintenance_resume() -> None:
|
|
136
|
+
data = api_client._admin_call("POST", "/api/admin/maintenance/resume")
|
|
137
|
+
typer.secho(
|
|
138
|
+
f"✓ 已退出维护模式,{data.get('resuming', 0)} 个作业将由队列按原优先级自动续训",
|
|
139
|
+
fg=typer.colors.GREEN,
|
|
140
|
+
)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""标准基准评测提交:`sf bench <exp> --model … --suites …`。
|
|
2
|
+
|
|
3
|
+
评测作业就是一次普通提交(evalkit/benchmark recipe,同一条配额/排队/产物链路);
|
|
4
|
+
本命令只做三件事:解析模型引用(`run:<run_id>` → 该 run 的 hf_export 产物路径)、
|
|
5
|
+
把评测参数翻成超参、走标准提交。分数自动入库平台 benchmark 看板。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import urllib.error
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from starforge_cli import api_client, cli_ui, packing
|
|
16
|
+
from starforge_cli.auth import gate
|
|
17
|
+
from starforge_cli.commands import common
|
|
18
|
+
from starforge_cli.commands.submit import (
|
|
19
|
+
_build_spec_or_exit,
|
|
20
|
+
_echo_submit_result,
|
|
21
|
+
_materialize_profile_or_exit,
|
|
22
|
+
_require_clean_tree,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _resolve_model_ref(model: str) -> str:
|
|
27
|
+
"""`run:<run_id>` → 该 run 已登记的 hf_export 产物路径;其余原样返回。"""
|
|
28
|
+
ref = (model or "").strip()
|
|
29
|
+
if not ref.startswith("run:"):
|
|
30
|
+
return ref
|
|
31
|
+
run_id = ref[len("run:"):].strip()
|
|
32
|
+
if not run_id:
|
|
33
|
+
cli_ui.fail("run: 引用缺少 run_id(形如 run:grpo-alice-20260101-120000)")
|
|
34
|
+
try:
|
|
35
|
+
with api_client._bearer_request(
|
|
36
|
+
api_client.current_server(None), "GET",
|
|
37
|
+
f"/api/jobs/{run_id}/artifacts",
|
|
38
|
+
) as r:
|
|
39
|
+
data = json.loads(r.read() or b"{}")
|
|
40
|
+
except urllib.error.HTTPError as e:
|
|
41
|
+
cli_ui.fail_http(e, fallback=f"查询 run {run_id} 产物失败")
|
|
42
|
+
exports = [a for a in data.get("artifacts") or [] if a.get("kind") == "hf_export"]
|
|
43
|
+
if not exports:
|
|
44
|
+
cli_ui.fail(
|
|
45
|
+
f"run {run_id} 没有 hf_export 产物",
|
|
46
|
+
hint=f"先导出:sf export {run_id}",
|
|
47
|
+
)
|
|
48
|
+
return str(exports[-1]["path"])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def bench(
|
|
52
|
+
exp: str = typer.Argument(..., autocompletion=common.complete_exp,
|
|
53
|
+
help="评测实验(sf new <名字> --method evalkit/benchmark 创建)"),
|
|
54
|
+
model: str = typer.Option(..., "--model", "-m",
|
|
55
|
+
help="HF 模型 id / 共享盘绝对路径 / run:<run_id>(取该 run 的 hf_export)"),
|
|
56
|
+
suites: str = typer.Option(..., "--suites",
|
|
57
|
+
help="逗号分隔的基准名(如 gsm8k,mmlu / ceval)"),
|
|
58
|
+
runner: str = typer.Option("lm-eval", "--runner", help="评测后端:lm-eval | evalscope"),
|
|
59
|
+
limit: Optional[int] = typer.Option(None, "--limit", help="每基准样本上限(冒烟用)"),
|
|
60
|
+
extra_args: str = typer.Option("", "--extra-args", help="透传给 runner 的附加参数"),
|
|
61
|
+
profile: list[str] = common.PROFILE_EXPR_OPT,
|
|
62
|
+
allow_dirty: bool = typer.Option(False, "--allow-dirty", help="允许工作区有未提交改动"),
|
|
63
|
+
) -> None:
|
|
64
|
+
"""提交基准评测作业(lm-eval / evalscope),分数自动入库平台看板。"""
|
|
65
|
+
gate()
|
|
66
|
+
exp_path = common.resolve_exp(exp)
|
|
67
|
+
resolved_model = _resolve_model_ref(model)
|
|
68
|
+
|
|
69
|
+
exprs = [e for e in (profile or []) if e.strip()]
|
|
70
|
+
if not exprs:
|
|
71
|
+
exprs = [common.resolve_profile(exp_path, None)]
|
|
72
|
+
resolved_profile, pools, roles = _materialize_profile_or_exit(exprs)
|
|
73
|
+
prov = packing.git_provenance(common.ROOT, exp_path)
|
|
74
|
+
_require_clean_tree(allow_dirty, prov)
|
|
75
|
+
|
|
76
|
+
sets = [f"runner={runner}", f"suites={suites}"]
|
|
77
|
+
if limit:
|
|
78
|
+
sets.append(f"limit={limit}")
|
|
79
|
+
if extra_args:
|
|
80
|
+
sets.append(f"extra_args={extra_args}")
|
|
81
|
+
# evalkit 实验没有 config 树,跳过 config 校验;超参仍由 recipe schema 严格校验。
|
|
82
|
+
project = common.project_name()
|
|
83
|
+
spec = _build_spec_or_exit(
|
|
84
|
+
exp_path, method="evalkit/benchmark", project=project, sets=sets,
|
|
85
|
+
pools=pools, roles=roles, init_from=None, then=[],
|
|
86
|
+
model=resolved_model, provenance=prov, validate=False,
|
|
87
|
+
)
|
|
88
|
+
with cli_ui.submit_progress() as reporter:
|
|
89
|
+
res = api_client.submit_via_server(
|
|
90
|
+
exp_path, resolved_profile, common.ROOT,
|
|
91
|
+
project=project, reporter=reporter, spec=spec,
|
|
92
|
+
)
|
|
93
|
+
_echo_submit_result(res, label="(基准评测)")
|
|
94
|
+
typer.echo(" 评完看分数:web「Benchmarks」页,或 GET /api/benchmarks")
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""命令层共享:项目根定位、实验/profile 解析、Tab 补全回调。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from starforge_cli import cli_ui
|
|
12
|
+
|
|
13
|
+
#: StarForge 项目标记文件:`sf init` 生成,项目发现的唯一依据。
|
|
14
|
+
#: CLI 已 pip 化分发(starforge-cli),不再假设自己被 clone 在项目仓库里。
|
|
15
|
+
PROJECT_MARKER = "starforge.yaml"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def project_root() -> Path:
|
|
19
|
+
"""从 cwd 向上定位含 starforge.yaml 的项目根(类比 git 的仓库发现)。
|
|
20
|
+
|
|
21
|
+
找不到即失败并给出可执行指引——所有需要项目上下文的命令都经此收口;
|
|
22
|
+
login / status 等全局命令不触碰它。SF_PROJECT_ROOT 环境变量可显式覆盖
|
|
23
|
+
(CI / 脚本场景免 cd)。
|
|
24
|
+
"""
|
|
25
|
+
if env := os.environ.get("SF_PROJECT_ROOT"):
|
|
26
|
+
p = Path(env).resolve()
|
|
27
|
+
if (p / PROJECT_MARKER).is_file():
|
|
28
|
+
return p
|
|
29
|
+
cli_ui.fail(f"SF_PROJECT_ROOT={env} 不是 StarForge 项目(缺 {PROJECT_MARKER})")
|
|
30
|
+
cur = Path.cwd().resolve()
|
|
31
|
+
for cand in (cur, *cur.parents):
|
|
32
|
+
if (cand / PROJECT_MARKER).is_file():
|
|
33
|
+
return cand
|
|
34
|
+
cli_ui.fail(
|
|
35
|
+
"当前目录不在 StarForge 项目内",
|
|
36
|
+
hint="sf init <项目名> 创建新项目,或 cd 进已有项目目录(含 starforge.yaml)",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def __getattr__(name: str):
|
|
41
|
+
"""PEP 562:`common.ROOT` 惰性解析为当前项目根。
|
|
42
|
+
|
|
43
|
+
保持属性形态是刻意的——测试用 monkeypatch.setattr(common, "ROOT", tmp)
|
|
44
|
+
注入后,真实属性优先于 __getattr__,注入语义不变。
|
|
45
|
+
"""
|
|
46
|
+
if name == "ROOT":
|
|
47
|
+
return project_root()
|
|
48
|
+
raise AttributeError(name)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _root() -> Path:
|
|
52
|
+
"""模块内部取项目根:优先被注入的 ROOT 属性(测试),否则实时发现。"""
|
|
53
|
+
injected = globals().get("ROOT")
|
|
54
|
+
return injected if isinstance(injected, Path) else project_root()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def project_name() -> str:
|
|
58
|
+
"""当前项目的 starforge.yaml name。提交到 console 的 project 只走这里。"""
|
|
59
|
+
from starforge_cli.project import ProjectError, load_project_name
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
return load_project_name(_root())
|
|
63
|
+
except ProjectError as exc:
|
|
64
|
+
cli_ui.fail(str(exc))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Kind(str, Enum):
|
|
68
|
+
experiments = "experiments"
|
|
69
|
+
projects = "projects"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_exp(name: str) -> str:
|
|
73
|
+
"""把实验名解析为相对仓库根的路径,接受 'experiments/x' / 'projects/x' / 'x'。"""
|
|
74
|
+
cands = [name] if "/" in name else [f"experiments/{name}", f"projects/{name}"]
|
|
75
|
+
for c in cands:
|
|
76
|
+
if (_root() / c).is_dir():
|
|
77
|
+
return c
|
|
78
|
+
cli_ui.fail(f"找不到实验「{name}」", hint="运行 sf ls 查看可用实验")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def list_exps() -> list[str]:
|
|
82
|
+
out: list[str] = []
|
|
83
|
+
for kind in ("experiments", "projects"):
|
|
84
|
+
base = _root() / kind
|
|
85
|
+
if base.is_dir():
|
|
86
|
+
out += [p.name for p in base.iterdir() if p.is_dir()]
|
|
87
|
+
return sorted(set(out))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def list_profiles() -> list[str]:
|
|
91
|
+
"""可用硬件 profile 名,来自服务端注册表(本仓库已无 cluster/ 目录)。
|
|
92
|
+
|
|
93
|
+
仅用于补全与提示:拿不到(未登录/服务不可达)就静默返回空,不阻断主流程。
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
from starforge_cli import api_client
|
|
97
|
+
|
|
98
|
+
data = api_client.cluster_status_via_server()
|
|
99
|
+
return sorted(
|
|
100
|
+
str(p.get("name")) for p in (data.get("profiles") or []) if p.get("name")
|
|
101
|
+
)
|
|
102
|
+
except Exception: # 网络/鉴权失败只影响补全,不阻断主流程
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def profile_registry() -> dict[str, dict]:
|
|
107
|
+
"""服务端 profile 注册表:{名称: {series, num_nodes, gpus_per_node, ...}}。
|
|
108
|
+
|
|
109
|
+
提交时把 `--profile 名称[:总卡数]` 物化成 JobSpec 资源池要用(series 与
|
|
110
|
+
默认形状的唯一来源)。拿不到就显式失败——提交本来就离不开服务端。
|
|
111
|
+
"""
|
|
112
|
+
from starforge_cli import api_client
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
data = api_client.cluster_status_via_server()
|
|
116
|
+
except Exception as e: # noqa: BLE001
|
|
117
|
+
cli_ui.fail(
|
|
118
|
+
"无法从服务端获取 profile 注册表",
|
|
119
|
+
hint=f"提交需要在线解析 --profile 的卡型与默认形状;请先 sf login 或检查服务可达性({e})",
|
|
120
|
+
)
|
|
121
|
+
return {
|
|
122
|
+
str(p.get("name")): p
|
|
123
|
+
for p in (data.get("profiles") or [])
|
|
124
|
+
if p.get("name")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def resolve_profile(exp_path: str, profile: Optional[str]) -> str:
|
|
129
|
+
"""确定作业的硬件 profile:--profile 优先,否则读实验目录遗留的 cluster 标注(兼容旧实验)。
|
|
130
|
+
|
|
131
|
+
profile 的 env/overrides/拓扑都在服务端注册表,名字的合法性也由服务端裁决;
|
|
132
|
+
客户端只负责把选择传上去。
|
|
133
|
+
"""
|
|
134
|
+
p = (profile or "").strip()
|
|
135
|
+
if not p:
|
|
136
|
+
legacy = _root() / exp_path / "cluster"
|
|
137
|
+
if legacy.is_file():
|
|
138
|
+
p = legacy.read_text(encoding="utf-8").strip()
|
|
139
|
+
if not p:
|
|
140
|
+
opts = " ".join(list_profiles())
|
|
141
|
+
cli_ui.fail(
|
|
142
|
+
"无法确定硬件 profile",
|
|
143
|
+
hint=f"加 --profile <名称>{f'(可选: {opts})' if opts else ''}",
|
|
144
|
+
)
|
|
145
|
+
return p
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ----------------------------- 动态补全回调 -----------------------------
|
|
149
|
+
def complete_exp(incomplete: str) -> list[str]:
|
|
150
|
+
return [e for e in list_exps() if e.startswith(incomplete)]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def complete_profile(incomplete: str) -> list[str]:
|
|
154
|
+
return [p for p in list_profiles() if p.startswith(incomplete)]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def complete_method(incomplete: str) -> list[str]:
|
|
158
|
+
"""补全两段式方法标识;也按叶子名前缀匹配(输入 gr 可补出 nemo-rl/grpo)。"""
|
|
159
|
+
from starforge_core.recipes import recipe_names
|
|
160
|
+
|
|
161
|
+
return [
|
|
162
|
+
name for name in recipe_names()
|
|
163
|
+
if name.startswith(incomplete) or name.split("/", 1)[1].startswith(incomplete)
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# 共享的 profile 选项(export/eval 用:资源形状由 recipe 固定,只选卡型/环境)。
|
|
168
|
+
PROF_OPT = typer.Option(
|
|
169
|
+
None, "--profile", autocompletion=complete_profile,
|
|
170
|
+
help="硬件 profile(服务端注册表管理;`sf status` 可查看可用值)",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# submit 用的统一资源参数:profile 即资源入口,形状用 :总卡数 修饰。
|
|
174
|
+
PROFILE_EXPR_OPT = typer.Option(
|
|
175
|
+
[], "--profile", metavar="[ROLE=]名称[:总卡数]", autocompletion=complete_profile,
|
|
176
|
+
help="目标硬件与资源,一个参数说清:h200(注册表默认形状)、h200:4(4 张卡)、"
|
|
177
|
+
"h200:16(2 满节点)。可重复以按角色分池(异构扩展位):--profile train=h200:8 --profile rollout=h100:2",
|
|
178
|
+
)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""数据集生命周期:prepare(本地预处理)→ push(上传对象存储)→ ls(查看版本)。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from starforge_cli import api_client, cli_ui
|
|
11
|
+
from starforge_cli.auth import gate
|
|
12
|
+
from starforge_cli.commands import common
|
|
13
|
+
|
|
14
|
+
dataset_app = typer.Typer(
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
help="数据集:本地预处理、上传到对象存储、按版本分发给作业",
|
|
17
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def _complete_dataset(incomplete: str) -> list[str]:
|
|
21
|
+
from starforge_cli.data_prep import discover
|
|
22
|
+
|
|
23
|
+
return [d for d in sorted(discover()) if d.startswith(incomplete)]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataset_app.command(
|
|
27
|
+
"prepare",
|
|
28
|
+
help="本地预处理数据集(按约定发现 common/data/prepare_*.py,`sf dataset prepare` 不带参数列出可选)",
|
|
29
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
30
|
+
)
|
|
31
|
+
def dataset_prepare(
|
|
32
|
+
ctx: typer.Context,
|
|
33
|
+
dataset: str = typer.Argument(
|
|
34
|
+
"", autocompletion=_complete_dataset, help="数据集名;留空列出全部可选"
|
|
35
|
+
),
|
|
36
|
+
) -> None:
|
|
37
|
+
import os
|
|
38
|
+
import subprocess
|
|
39
|
+
|
|
40
|
+
from starforge_cli.data_prep import discover
|
|
41
|
+
|
|
42
|
+
preps = discover()
|
|
43
|
+
if not dataset:
|
|
44
|
+
if not preps:
|
|
45
|
+
cli_ui.fail("没有可用的数据预处理脚本", hint="往 common/data/ 放 prepare_<name>.py")
|
|
46
|
+
for p in preps.values():
|
|
47
|
+
typer.echo(f"{p.name:16s} {p.summary}")
|
|
48
|
+
return
|
|
49
|
+
prep = preps.get(dataset)
|
|
50
|
+
if not prep:
|
|
51
|
+
cli_ui.fail(f"未知数据集「{dataset}」", hint=f"可选:{', '.join(sorted(preps))}")
|
|
52
|
+
cmd = [sys.executable, str(prep.script), *ctx.args]
|
|
53
|
+
typer.echo("› " + " ".join(cmd))
|
|
54
|
+
# 用当前解释器(项目 uv 环境,含 datasets)跑数据脚本。
|
|
55
|
+
raise typer.Exit(
|
|
56
|
+
subprocess.run(cmd, env=os.environ.copy(), cwd=str(common.ROOT)).returncode
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataset_app.command("ls", help="列出可见的数据集(公开的 + 自己的)")
|
|
61
|
+
def dataset_ls(
|
|
62
|
+
dataset: Optional[str] = typer.Argument(
|
|
63
|
+
None, help="数据集 ID(<owner>/<name>);不传则列全部可见的"
|
|
64
|
+
),
|
|
65
|
+
version: str = typer.Option("", "--version", "-v", help="看某个版本的文件清单;默认最新"),
|
|
66
|
+
) -> None:
|
|
67
|
+
gate()
|
|
68
|
+
if dataset:
|
|
69
|
+
if "/" not in dataset:
|
|
70
|
+
cli_ui.fail(
|
|
71
|
+
f"数据集 ID 必须是 <owner>/<name>,得到 {dataset!r}",
|
|
72
|
+
hint="用 `sf dataset ls` 查看完整 ID",
|
|
73
|
+
)
|
|
74
|
+
owner, _, name = dataset.partition("/")
|
|
75
|
+
q = f"?version={version}" if version else ""
|
|
76
|
+
d = api_client.api_get(f"/api/datasets/{owner}/{name}{q}")
|
|
77
|
+
vis = "公开" if d["visibility"] == "public" else "私有"
|
|
78
|
+
typer.echo(
|
|
79
|
+
f"{d['id']}@{d['version']} [{vis}] "
|
|
80
|
+
f"{d['total_bytes'] / 1e6:.1f} MB {len(d['files'])} 个文件"
|
|
81
|
+
)
|
|
82
|
+
for f in d["files"]:
|
|
83
|
+
typer.echo(f" {f['name']:40s} {f['size'] / 1e6:8.2f} MB {(f.get('sha256') or '')[:12]}")
|
|
84
|
+
return
|
|
85
|
+
rows = api_client.api_get("/api/datasets")["datasets"]
|
|
86
|
+
if not rows:
|
|
87
|
+
typer.echo("(还没有可见的数据集;`sf dataset push` 上传一个)")
|
|
88
|
+
return
|
|
89
|
+
for d in rows:
|
|
90
|
+
vis = "公开" if d["visibility"] == "public" else "私有"
|
|
91
|
+
typer.echo(f"{d['id']:32s} [{vis}] {', '.join(d['versions']) or '(无版本)'}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataset_app.command("push", help="上传一个数据集版本(默认私有,--public 公开)")
|
|
95
|
+
def dataset_push(
|
|
96
|
+
dataset: str = typer.Argument(
|
|
97
|
+
..., help="数据集名(归到自己命名空间),或完整 <owner>/<name>(admin 可跨命名空间)"
|
|
98
|
+
),
|
|
99
|
+
version: str = typer.Argument(..., help="版本,如 v1 / 20260812"),
|
|
100
|
+
path: str = typer.Argument(..., help="本地目录"),
|
|
101
|
+
public: bool = typer.Option(
|
|
102
|
+
False, "--public", help="首次创建时设为公开(所有人可在训练中引用)"
|
|
103
|
+
),
|
|
104
|
+
) -> None:
|
|
105
|
+
"""上传目录下的全部文件,并生成带 sha256 的 index.json。
|
|
106
|
+
|
|
107
|
+
校验和不是可选项:作业侧靠它判断下载是否被截断。静默接受一个截断的
|
|
108
|
+
train.jsonl,就是拿脏数据训练几小时后才发现结果不对。
|
|
109
|
+
|
|
110
|
+
版本不可变:已完整上传的版本不能覆写,要更新请换新版本号。
|
|
111
|
+
"""
|
|
112
|
+
gate()
|
|
113
|
+
root = Path(path)
|
|
114
|
+
if not root.is_dir():
|
|
115
|
+
cli_ui.fail(f"不是目录: {path}")
|
|
116
|
+
files = sorted(p for p in root.rglob("*") if p.is_file())
|
|
117
|
+
if not files:
|
|
118
|
+
cli_ui.fail(f"目录为空: {path}")
|
|
119
|
+
ds_id = api_client.dataset_push(
|
|
120
|
+
dataset, version, root, files, visibility="public" if public else None,
|
|
121
|
+
)
|
|
122
|
+
short = ds_id.rpartition("/")[2]
|
|
123
|
+
env_var = f"{short.upper().replace('-', '_').replace('.', '_')}_DATA_DIR"
|
|
124
|
+
typer.echo(f"已上传 {ds_id}@{version}({len(files)} 个文件)")
|
|
125
|
+
# 数据集是实验的属性:推荐声明在 config(submit 自动拾取),CLI 参数仅作临时覆盖。
|
|
126
|
+
typer.echo(
|
|
127
|
+
f"训练里引用它:实验 config 里声明 data.train.dataset: {ds_id}@{version}"
|
|
128
|
+
f",数据文件用 ${{oc.env:{env_var}}}/train.jsonl 指到"
|
|
129
|
+
f"(提交时自动拉到共享缓存并注入 {env_var};--train-dataset 可临时覆盖版本)"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataset_app.command("visibility", help="改数据集可见性(owner 或 admin)")
|
|
134
|
+
def dataset_visibility(
|
|
135
|
+
dataset: str = typer.Argument(..., help="数据集 ID(<owner>/<name>)"),
|
|
136
|
+
public: bool = typer.Option(False, "--public", help="设为公开"),
|
|
137
|
+
private: bool = typer.Option(False, "--private", help="设为私有"),
|
|
138
|
+
) -> None:
|
|
139
|
+
gate()
|
|
140
|
+
if public == private:
|
|
141
|
+
cli_ui.fail("必须且只能指定 --public 或 --private 之一")
|
|
142
|
+
if "/" not in dataset:
|
|
143
|
+
cli_ui.fail(
|
|
144
|
+
f"数据集 ID 必须是 <owner>/<name>,得到 {dataset!r}",
|
|
145
|
+
hint="用 `sf dataset ls` 查看完整 ID",
|
|
146
|
+
)
|
|
147
|
+
owner, _, name = dataset.partition("/")
|
|
148
|
+
vis = "public" if public else "private"
|
|
149
|
+
r = api_client.api_patch(f"/api/datasets/{owner}/{name}", {"visibility": vis})
|
|
150
|
+
typer.echo(f"{r['id']} 现在是{'公开' if r['visibility'] == 'public' else '私有'}数据集")
|