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,243 @@
|
|
|
1
|
+
"""插件命令:ls / info / publish / install / remove。
|
|
2
|
+
|
|
3
|
+
插件是平台托管、digest 锁定的扩展包(algorithm 算法补丁 / data-prep 数据脚本)。
|
|
4
|
+
代码只在用户自己的作业容器(或本机)执行,控制平面只存储与分发。
|
|
5
|
+
|
|
6
|
+
sf plugin publish ./my-plugin # 发布(名字版本以 plugin.yaml 为准)
|
|
7
|
+
sf plugin install alice/myalgo --exp x # 下载到本地 + 写实验锁文件
|
|
8
|
+
sf submit x ... # 锁文件里的引用随 JobSpec 提交
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
import shutil
|
|
15
|
+
import tarfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from starforge_cli import api_client, cli_ui
|
|
22
|
+
from starforge_cli.auth import gate
|
|
23
|
+
from starforge_cli.commands import common
|
|
24
|
+
|
|
25
|
+
plugin_app = typer.Typer(
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
help="插件中心:发布 / 安装 / 管理平台托管的扩展包(算法补丁、数据脚本)",
|
|
28
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
#: 本地安装目录(仓库根下)。data-prep 插件的 prepare_*.py 从这里被发现。
|
|
32
|
+
LOCAL_PLUGINS_DIR = "forge_plugins"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _split_ref(ref: str) -> tuple[str, str, str]:
|
|
36
|
+
"""解析 <owner>/<name>[@version] → (owner, name, version)。"""
|
|
37
|
+
base, _, version = ref.strip().partition("@")
|
|
38
|
+
owner, _, name = base.partition("/")
|
|
39
|
+
if not owner or not name or "/" in name:
|
|
40
|
+
cli_ui.fail(
|
|
41
|
+
f"插件 ID 必须是 <owner>/<name>[@version],收到 {ref!r}",
|
|
42
|
+
hint="用 `sf plugin ls` 查看完整 ID",
|
|
43
|
+
)
|
|
44
|
+
return owner, name, version
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@plugin_app.command("ls", help="列出平台上的插件")
|
|
48
|
+
def plugin_ls() -> None:
|
|
49
|
+
gate()
|
|
50
|
+
rows = api_client.api_get("/api/plugins")["plugins"]
|
|
51
|
+
if not rows:
|
|
52
|
+
typer.echo("(还没有插件;`sf plugin publish <目录>` 发布一个)")
|
|
53
|
+
return
|
|
54
|
+
for p in rows:
|
|
55
|
+
state = "" if p["enabled"] else " [已禁用]"
|
|
56
|
+
typer.echo(
|
|
57
|
+
f"{p['id']:32s} {p['kind']:10s} @{p['latest_version']:<10s}{state} {p['summary']}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@plugin_app.command("info", help="查看插件详情(manifest、版本历史、digest)")
|
|
62
|
+
def plugin_info(
|
|
63
|
+
ref: str = typer.Argument(..., help="插件 ID:<owner>/<name>[@version]"),
|
|
64
|
+
) -> None:
|
|
65
|
+
gate()
|
|
66
|
+
owner, name, version = _split_ref(ref)
|
|
67
|
+
q = f"?version={version}" if version else ""
|
|
68
|
+
p = api_client.api_get(f"/api/plugins/{owner}/{name}{q}")
|
|
69
|
+
typer.echo(f"{p['id']}@{p['version']} [{p['kind']}] {'可用' if p['enabled'] else '已禁用'}")
|
|
70
|
+
if p.get("summary"):
|
|
71
|
+
typer.echo(f" {p['summary']}")
|
|
72
|
+
typer.echo(f" digest : {p['digest']}")
|
|
73
|
+
typer.echo(f" 发布者 : {p['created_by']} {p['created_at']}")
|
|
74
|
+
manifest = p.get("manifest") or {}
|
|
75
|
+
if manifest.get("entrypoint"):
|
|
76
|
+
typer.echo(f" 入口 : {manifest['entrypoint']} ({manifest.get('load') or 'eager'})")
|
|
77
|
+
if (manifest.get("requires") or {}).get("core"):
|
|
78
|
+
typer.echo(f" 要求 : core {manifest['requires']['core']}")
|
|
79
|
+
typer.echo(" 版本 :")
|
|
80
|
+
for v in p.get("versions") or []:
|
|
81
|
+
state = "" if v["enabled"] else " [已禁用]"
|
|
82
|
+
typer.echo(f" {v['version']:12s} {v['digest'][:19]}… {v['created_at']}{state}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@plugin_app.command("publish", help="发布一个插件版本(目录内须有 plugin.yaml;版本不可变)")
|
|
86
|
+
def plugin_publish(
|
|
87
|
+
path: str = typer.Argument(..., help="插件目录"),
|
|
88
|
+
owner: Optional[str] = typer.Option(
|
|
89
|
+
None, "--owner", help="目标命名空间(仅 admin 可跨;默认自己)"
|
|
90
|
+
),
|
|
91
|
+
) -> None:
|
|
92
|
+
gate()
|
|
93
|
+
from starforge_core.plugins import (
|
|
94
|
+
PluginError,
|
|
95
|
+
digest_files,
|
|
96
|
+
directory_digest,
|
|
97
|
+
load_manifest,
|
|
98
|
+
validate_package_layout,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
src = Path(path)
|
|
102
|
+
if not src.is_dir():
|
|
103
|
+
cli_ui.fail(f"不是目录: {path}")
|
|
104
|
+
try:
|
|
105
|
+
manifest = load_manifest(src)
|
|
106
|
+
# 与服务端同一份结构校验:拼错的 entrypoint / 遮蔽保留模块名的顶层文件
|
|
107
|
+
# 在上传前就报出来,不必等服务端往返。
|
|
108
|
+
validate_package_layout(src, manifest)
|
|
109
|
+
except PluginError as e:
|
|
110
|
+
cli_ui.fail(f"插件包非法:{e}", hint="检查 plugin.yaml(schema: lab-plugin/v1)")
|
|
111
|
+
digest = directory_digest(src)
|
|
112
|
+
|
|
113
|
+
buf = io.BytesIO()
|
|
114
|
+
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
115
|
+
for f in digest_files(src):
|
|
116
|
+
tar.add(f, arcname=f.relative_to(src).as_posix())
|
|
117
|
+
blob = buf.getvalue()
|
|
118
|
+
|
|
119
|
+
q = f"?owner={owner}" if owner else ""
|
|
120
|
+
row = api_client.api_post_bytes(f"/api/plugins/publish{q}", blob)
|
|
121
|
+
if row["digest"] != digest:
|
|
122
|
+
# 理论上不可能:两侧跑的是 SDK 同一份 directory_digest。真出现说明打包/解包链路有毛病。
|
|
123
|
+
cli_ui.fail(
|
|
124
|
+
f"服务端摘要 {row['digest']} 与本地 {digest} 不一致,发布结果不可信",
|
|
125
|
+
hint="检查 CLI 与服务端 SDK 版本是否一致",
|
|
126
|
+
)
|
|
127
|
+
typer.secho(
|
|
128
|
+
f"✓ 已发布 {row['id']}@{row['version']} [{row['kind']}] "
|
|
129
|
+
f"{cli_ui.human_bytes(row['size_bytes'])}",
|
|
130
|
+
fg=typer.colors.GREEN,
|
|
131
|
+
)
|
|
132
|
+
typer.echo(f" digest: {row['digest']}")
|
|
133
|
+
typer.echo(f" 实验里使用:sf plugin install {row['id']}@{row['version']} --exp <实验>")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@plugin_app.command(
|
|
137
|
+
"install",
|
|
138
|
+
help="安装插件:下载到本地 forge_plugins/,并(可选)锁定到实验的 plugins.lock.json",
|
|
139
|
+
)
|
|
140
|
+
def plugin_install(
|
|
141
|
+
ref: str = typer.Argument(..., help="插件 ID:<owner>/<name>[@version],缺省最新版"),
|
|
142
|
+
exp: Optional[str] = typer.Option(
|
|
143
|
+
None, "--exp", "-e", autocompletion=common.complete_exp,
|
|
144
|
+
help="锁定到该实验:提交时随 JobSpec 引用,由平台注入作业包",
|
|
145
|
+
),
|
|
146
|
+
) -> None:
|
|
147
|
+
gate()
|
|
148
|
+
from starforge_core.plugins import directory_digest
|
|
149
|
+
|
|
150
|
+
owner, name, version = _split_ref(ref)
|
|
151
|
+
q = f"?version={version}" if version else ""
|
|
152
|
+
meta = api_client.api_get(f"/api/plugins/{owner}/{name}{q}")
|
|
153
|
+
if not meta["enabled"]:
|
|
154
|
+
cli_ui.fail(f"插件 {meta['id']}@{meta['version']} 已被管理员禁用")
|
|
155
|
+
blob, _headers = api_client.api_get_bytes(f"/api/plugins/{owner}/{name}/package{q}")
|
|
156
|
+
|
|
157
|
+
dest = common.ROOT / LOCAL_PLUGINS_DIR / name
|
|
158
|
+
if dest.exists():
|
|
159
|
+
shutil.rmtree(dest)
|
|
160
|
+
dest.mkdir(parents=True)
|
|
161
|
+
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
|
|
162
|
+
dest_root = dest.resolve()
|
|
163
|
+
for m in tar.getmembers():
|
|
164
|
+
target = (dest / m.name).resolve()
|
|
165
|
+
# is_relative_to 而非字符串前缀:startswith 有 /a/b 匹配 /a/bc 的绕过。
|
|
166
|
+
if not target.is_relative_to(dest_root):
|
|
167
|
+
cli_ui.fail(f"插件包含非法归档成员路径: {m.name}")
|
|
168
|
+
tar.extractall(dest, filter="data")
|
|
169
|
+
local_digest = directory_digest(dest)
|
|
170
|
+
if local_digest != meta["digest"]:
|
|
171
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
172
|
+
cli_ui.fail(
|
|
173
|
+
f"下载内容摘要 {local_digest} 与平台记录 {meta['digest']} 不一致,已删除",
|
|
174
|
+
hint="重试;若持续失败请联系管理员",
|
|
175
|
+
)
|
|
176
|
+
typer.secho(
|
|
177
|
+
f"✓ 已安装 {meta['id']}@{meta['version']} → {LOCAL_PLUGINS_DIR}/{name}/",
|
|
178
|
+
fg=typer.colors.GREEN,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if meta["kind"] == "data-prep":
|
|
182
|
+
typer.echo(" 数据脚本已可用:sf dataset prepare 会自动发现其中的 prepare_*.py")
|
|
183
|
+
if not exp:
|
|
184
|
+
if meta["kind"] == "algorithm":
|
|
185
|
+
typer.echo(f" 提交训练时使用:sf plugin install {meta['id']}@{meta['version']} --exp <实验>")
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
from starforge_cli.plugins_lock import LOCK_FILE, upsert_plugin_lock
|
|
189
|
+
|
|
190
|
+
exp_path = common.resolve_exp(exp)
|
|
191
|
+
try:
|
|
192
|
+
plugins = upsert_plugin_lock(
|
|
193
|
+
common.ROOT / exp_path,
|
|
194
|
+
{"id": meta["id"], "version": meta["version"], "digest": meta["digest"]},
|
|
195
|
+
)
|
|
196
|
+
except ValueError as e:
|
|
197
|
+
cli_ui.fail(str(e))
|
|
198
|
+
typer.echo(f" 已锁定到 {exp_path}/{LOCK_FILE}({len(plugins)} 个插件),提交时自动生效")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@plugin_app.command("remove", help="从实验的 plugins.lock.json 移除一个插件引用")
|
|
202
|
+
def plugin_remove(
|
|
203
|
+
ref: str = typer.Argument(..., help="插件 ID:<owner>/<name>"),
|
|
204
|
+
exp: str = typer.Option(
|
|
205
|
+
..., "--exp", "-e", autocompletion=common.complete_exp, help="实验名或路径"
|
|
206
|
+
),
|
|
207
|
+
) -> None:
|
|
208
|
+
from starforge_cli.plugins_lock import remove_plugin_lock
|
|
209
|
+
|
|
210
|
+
owner, name, _ = _split_ref(ref)
|
|
211
|
+
exp_path = common.resolve_exp(exp)
|
|
212
|
+
try:
|
|
213
|
+
removed = remove_plugin_lock(common.ROOT / exp_path, f"{owner}/{name}")
|
|
214
|
+
except ValueError as e:
|
|
215
|
+
cli_ui.fail(str(e))
|
|
216
|
+
if not removed:
|
|
217
|
+
cli_ui.fail(f"实验 {exp_path} 未引用插件 {owner}/{name}")
|
|
218
|
+
typer.secho(f"✓ 已从 {exp_path} 移除 {owner}/{name}", fg=typer.colors.GREEN)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@plugin_app.command("enable", help="启用插件(admin)")
|
|
222
|
+
def plugin_enable(
|
|
223
|
+
ref: str = typer.Argument(..., help="插件 ID:<owner>/<name>[@version]"),
|
|
224
|
+
) -> None:
|
|
225
|
+
_set_enabled(ref, True)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@plugin_app.command("disable", help="禁用插件:新作业不可再引用,历史记录保留(admin)")
|
|
229
|
+
def plugin_disable(
|
|
230
|
+
ref: str = typer.Argument(..., help="插件 ID:<owner>/<name>[@version]"),
|
|
231
|
+
) -> None:
|
|
232
|
+
_set_enabled(ref, False)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _set_enabled(ref: str, enabled: bool) -> None:
|
|
236
|
+
gate()
|
|
237
|
+
owner, name, version = _split_ref(ref)
|
|
238
|
+
body: dict = {"enabled": enabled}
|
|
239
|
+
if version:
|
|
240
|
+
body["version"] = version
|
|
241
|
+
r = api_client.api_patch(f"/api/plugins/{owner}/{name}", body)
|
|
242
|
+
verb = "启用" if enabled else "禁用"
|
|
243
|
+
typer.secho(f"✓ 已{verb} {r['id']}({r['versions_affected']} 个版本)", fg=typer.colors.GREEN)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""recipe 锁状态与显式升级。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from starforge_cli import cli_ui
|
|
10
|
+
from starforge_cli.commands import common
|
|
11
|
+
from starforge_cli.recipe_lock import (
|
|
12
|
+
LOCK_FILE,
|
|
13
|
+
LockInspection,
|
|
14
|
+
RecipeLockBatchError,
|
|
15
|
+
RecipeLockError,
|
|
16
|
+
RecipeLockManager,
|
|
17
|
+
iter_lock_dirs,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
recipe_app = typer.Typer(
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
help="recipe 锁管理",
|
|
23
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _rel(path: Path) -> str:
|
|
28
|
+
try:
|
|
29
|
+
return str(path.parent.relative_to(common.ROOT))
|
|
30
|
+
except ValueError:
|
|
31
|
+
return str(path.parent)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _print_inspection(item: LockInspection) -> None:
|
|
35
|
+
typer.echo(f"{_rel(item.path)}")
|
|
36
|
+
typer.echo(f" state: {item.state.value}")
|
|
37
|
+
if item.recipe_name:
|
|
38
|
+
fw = f" framework: {item.framework_version}" if item.framework_version else ""
|
|
39
|
+
typer.echo(f" recipe: {item.recipe_name}{fw}")
|
|
40
|
+
for diff in item.diffs:
|
|
41
|
+
typer.echo(f" - {diff.field}: {diff.locked or '∅'} → {diff.current or '∅'}")
|
|
42
|
+
if not item.is_current:
|
|
43
|
+
typer.echo(f" hint: sf recipe upgrade {_rel(item.path)}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _config_errors(exp_dir: Path, recipe) -> list[str]:
|
|
47
|
+
from starforge_cli.commands.exp import validate_exp_config
|
|
48
|
+
|
|
49
|
+
return validate_exp_config(exp_dir, recipe, repo_root=common.ROOT)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@recipe_app.command("status", help="查看实验 recipe 锁与当前 catalog 的差异")
|
|
53
|
+
def recipe_status(
|
|
54
|
+
exp: Optional[str] = typer.Argument(
|
|
55
|
+
None, autocompletion=common.complete_exp, help="实验名或路径;省略则需 --all"
|
|
56
|
+
),
|
|
57
|
+
all_exps: bool = typer.Option(False, "--all", help="扫描 experiments/projects/smoke"),
|
|
58
|
+
server: bool = typer.Option(False, "--server", help="再与 Console catalog 握手对照"),
|
|
59
|
+
) -> None:
|
|
60
|
+
manager = RecipeLockManager()
|
|
61
|
+
if all_exps:
|
|
62
|
+
items = [manager.inspect(path) for path in iter_lock_dirs(common.ROOT)]
|
|
63
|
+
elif exp:
|
|
64
|
+
items = [manager.inspect(common.ROOT / common.resolve_exp(exp))]
|
|
65
|
+
else:
|
|
66
|
+
cli_ui.fail("指定实验名,或加 --all")
|
|
67
|
+
|
|
68
|
+
stale = 0
|
|
69
|
+
for item in items:
|
|
70
|
+
_print_inspection(item)
|
|
71
|
+
if not item.is_current:
|
|
72
|
+
stale += 1
|
|
73
|
+
if server and item.expected is not None:
|
|
74
|
+
_echo_server_drift(item)
|
|
75
|
+
if stale:
|
|
76
|
+
raise typer.Exit(1)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _echo_server_drift(item: LockInspection) -> None:
|
|
80
|
+
from starforge_cli import api_client
|
|
81
|
+
|
|
82
|
+
if item.expected is None:
|
|
83
|
+
return
|
|
84
|
+
try:
|
|
85
|
+
payload = api_client.api_get("/api/recipes")
|
|
86
|
+
except Exception as exc: # noqa: BLE001
|
|
87
|
+
typer.echo(f" server: {exc}")
|
|
88
|
+
return
|
|
89
|
+
selected = next(
|
|
90
|
+
(
|
|
91
|
+
recipe
|
|
92
|
+
for recipe in (payload.get("recipes") or [])
|
|
93
|
+
if recipe.get("name") == item.recipe_name
|
|
94
|
+
),
|
|
95
|
+
None,
|
|
96
|
+
)
|
|
97
|
+
if selected is None:
|
|
98
|
+
typer.echo(f" server: Console 未启用 {item.recipe_name}")
|
|
99
|
+
return
|
|
100
|
+
wanted = item.expected["recipe"]
|
|
101
|
+
drift = [
|
|
102
|
+
f"{field} server={selected.get(field)!r} local={wanted.get(field)!r}"
|
|
103
|
+
for field in ("version", "digest")
|
|
104
|
+
if selected.get(field) != wanted.get(field)
|
|
105
|
+
]
|
|
106
|
+
if drift:
|
|
107
|
+
typer.echo(" server: " + "; ".join(drift))
|
|
108
|
+
return
|
|
109
|
+
typer.echo(" server: current")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@recipe_app.command("upgrade", help="把实验锁升级到当前 catalog(默认不改 framework version)")
|
|
113
|
+
def recipe_upgrade(
|
|
114
|
+
exp: Optional[str] = typer.Argument(
|
|
115
|
+
None, autocompletion=common.complete_exp, help="实验名或路径;与 --all 互斥"
|
|
116
|
+
),
|
|
117
|
+
all_exps: bool = typer.Option(False, "--all", help="先全量校验,再统一写入"),
|
|
118
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="只显示 diff,不写文件"),
|
|
119
|
+
framework_version: Optional[str] = typer.Option(
|
|
120
|
+
None, "--framework-version", help="升级时改到指定精确框架版本"
|
|
121
|
+
),
|
|
122
|
+
accept_runtime_change: bool = typer.Option(
|
|
123
|
+
False,
|
|
124
|
+
"--accept-runtime-change",
|
|
125
|
+
help="锁定的框架版本已从 catalog 移除时,改用当前默认版本",
|
|
126
|
+
),
|
|
127
|
+
) -> None:
|
|
128
|
+
manager = RecipeLockManager()
|
|
129
|
+
selected = (framework_version or "").strip()
|
|
130
|
+
try:
|
|
131
|
+
if all_exps:
|
|
132
|
+
items = manager.upgrade_all(
|
|
133
|
+
common.ROOT,
|
|
134
|
+
framework_version=selected,
|
|
135
|
+
accept_runtime_change=accept_runtime_change,
|
|
136
|
+
dry_run=dry_run,
|
|
137
|
+
validate_config=_config_errors,
|
|
138
|
+
)
|
|
139
|
+
elif exp:
|
|
140
|
+
items = [
|
|
141
|
+
manager.upgrade(
|
|
142
|
+
common.ROOT / common.resolve_exp(exp),
|
|
143
|
+
framework_version=selected,
|
|
144
|
+
accept_runtime_change=accept_runtime_change,
|
|
145
|
+
dry_run=dry_run,
|
|
146
|
+
validate_config=_config_errors,
|
|
147
|
+
)
|
|
148
|
+
]
|
|
149
|
+
else:
|
|
150
|
+
cli_ui.fail("指定实验名,或加 --all")
|
|
151
|
+
except RecipeLockBatchError as exc:
|
|
152
|
+
cli_ui.emit_error(str(exc), items=[item.message for item in exc.failures])
|
|
153
|
+
raise typer.Exit(1) from exc
|
|
154
|
+
except RecipeLockError as exc:
|
|
155
|
+
cli_ui.emit_error(exc.inspection.message)
|
|
156
|
+
raise typer.Exit(1) from exc
|
|
157
|
+
|
|
158
|
+
for item in items:
|
|
159
|
+
_print_inspection(item)
|
|
160
|
+
typer.secho(
|
|
161
|
+
f"{'将' if dry_run else '已'}处理 {len(items)} 个 {LOCK_FILE}",
|
|
162
|
+
fg=typer.colors.GREEN,
|
|
163
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Playground 推理服务:训练产物一键起 vLLM 服务并试用。
|
|
2
|
+
|
|
3
|
+
服务生命周期完全由 console 管理(GPU 台账 + 闲置 TTL 自动回收);
|
|
4
|
+
CLI 只是端点的瘦封装。对话试用建议用 web「Playground」页(流式 UI)。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.parse
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from starforge_cli import api_client, cli_ui
|
|
16
|
+
from starforge_cli.auth import gate
|
|
17
|
+
|
|
18
|
+
serve_app = typer.Typer(no_args_is_help=True, help="推理服务(Playground)")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _call(method: str, path: str, body: dict | None = None) -> dict:
|
|
22
|
+
srv = api_client.current_server(None)
|
|
23
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
24
|
+
headers = {"Content-Type": "application/json"} if data is not None else None
|
|
25
|
+
try:
|
|
26
|
+
with api_client._bearer_request(srv, method, path, data=data, headers=headers) as r:
|
|
27
|
+
return json.loads(r.read() or b"{}")
|
|
28
|
+
except urllib.error.HTTPError as e:
|
|
29
|
+
cli_ui.fail_http(e, fallback="Playground 操作失败")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@serve_app.command("start", help="启动推理服务(模型:HF id / 绝对路径 / run:<run_id>)")
|
|
33
|
+
def serve_start(
|
|
34
|
+
model: str = typer.Argument(..., help="HF id / 共享盘绝对路径 / run:<run_id>"),
|
|
35
|
+
gpus: int = typer.Option(1, "--gpus", "-g", help="张数(张量并行度)"),
|
|
36
|
+
ttl_hours: Optional[float] = typer.Option(None, "--ttl-hours", help="闲置 TTL(小时),默认服务端配置"),
|
|
37
|
+
) -> None:
|
|
38
|
+
gate()
|
|
39
|
+
body: dict = {"model": model, "gpus": gpus}
|
|
40
|
+
if ttl_hours:
|
|
41
|
+
body["ttl_s"] = ttl_hours * 3600
|
|
42
|
+
res = _call("POST", "/api/playground/start", body)
|
|
43
|
+
typer.secho(f"✓ 推理服务已启动 {res.get('lab_run_id')}", fg=typer.colors.GREEN, bold=True)
|
|
44
|
+
typer.echo(f" 模型: {res.get('model')} 端口: {res.get('port')} 到期: {res.get('expires_at')}")
|
|
45
|
+
typer.echo(" 对话试用:web「Playground」页;一键停止:sf serve stop <run_id>")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@serve_app.command("ls", help="列出我的推理服务")
|
|
49
|
+
def serve_ls() -> None:
|
|
50
|
+
gate()
|
|
51
|
+
res = _call("GET", "/api/playground")
|
|
52
|
+
rows = res.get("servings") or []
|
|
53
|
+
if not rows:
|
|
54
|
+
typer.echo("没有推理服务。启动:sf serve start <模型>")
|
|
55
|
+
return
|
|
56
|
+
for r in rows:
|
|
57
|
+
flag = "●" if r.get("active") else "○"
|
|
58
|
+
typer.echo(
|
|
59
|
+
f"{flag} {r.get('lab_run_id')} {r.get('status'):<9} "
|
|
60
|
+
f"{r.get('model') or '-'} {r.get('gpus')}GPU 到期 {r.get('expires_at') or '-'}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@serve_app.command("stop", help="停止推理服务")
|
|
65
|
+
def serve_stop(run_id: str = typer.Argument(...)) -> None:
|
|
66
|
+
gate()
|
|
67
|
+
_call("POST", f"/api/playground/{urllib.parse.quote(run_id, safe='')}/stop")
|
|
68
|
+
typer.secho("✓ 已停止", fg=typer.colors.GREEN)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@serve_app.command("extend", help="续期推理服务 TTL")
|
|
72
|
+
def serve_extend(
|
|
73
|
+
run_id: str = typer.Argument(...),
|
|
74
|
+
hours: float = typer.Option(1.0, "--hours", help="续期时长(小时)"),
|
|
75
|
+
) -> None:
|
|
76
|
+
gate()
|
|
77
|
+
res = _call("POST", f"/api/playground/{urllib.parse.quote(run_id, safe='')}/extend",
|
|
78
|
+
{"extra_s": hours * 3600})
|
|
79
|
+
typer.secho(f"✓ 已续期,到期 {res.get('expires_at')}", fg=typer.colors.GREEN)
|