coderouter-cli 2.11.2__py3-none-any.whl → 2.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coderouter/cli.py +20 -21
- coderouter/config/loader.py +133 -34
- coderouter/config/schemas.py +143 -47
- coderouter/guards/context_budget.py +165 -20
- coderouter/guards/self_healing.py +58 -3
- coderouter/ingress/app.py +72 -29
- coderouter/ingress/launcher_routes.py +248 -62
- coderouter/launcher_devices.py +11 -4
- coderouter/py.typed +0 -0
- coderouter/token_estimation.py +264 -31
- {coderouter_cli-2.11.2.dist-info → coderouter_cli-2.13.0.dist-info}/METADATA +1 -1
- {coderouter_cli-2.11.2.dist-info → coderouter_cli-2.13.0.dist-info}/RECORD +15 -14
- {coderouter_cli-2.11.2.dist-info → coderouter_cli-2.13.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.11.2.dist-info → coderouter_cli-2.13.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.11.2.dist-info → coderouter_cli-2.13.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -552,6 +552,47 @@ def _resolve_within_model_dirs(model_path: str, model_dirs: list[str]) -> Path:
|
|
|
552
552
|
raise ValueError("model_path is not under any configured model_dirs.")
|
|
553
553
|
|
|
554
554
|
|
|
555
|
+
_model_dirs_warning_emitted = False
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _assert_model_path_allowed(
|
|
559
|
+
launcher_cfg: Any, model_path: str | None, *, field: str
|
|
560
|
+
) -> None:
|
|
561
|
+
"""model_path が launcher.model_dirs 配下か検査(パスは書き換えない)。
|
|
562
|
+
|
|
563
|
+
M-1: ``/api/launcher/start`` と ``/sweep/start`` はこれまで任意の
|
|
564
|
+
ファイルシステムパスを spawn に渡していた。ここで ``model_dirs`` 配下に
|
|
565
|
+
限定する。ただし **検証のみ**で resolve 済みパスを spawn に渡さない
|
|
566
|
+
(macOS の /var→/private/var symlink で argv が変わり既存テストが落ちる)。
|
|
567
|
+
``model_dirs`` 未設定なら従来どおり通し、一度だけ警告する。
|
|
568
|
+
"""
|
|
569
|
+
global _model_dirs_warning_emitted
|
|
570
|
+
if not model_path:
|
|
571
|
+
return
|
|
572
|
+
model_dirs: list[str] = (
|
|
573
|
+
list(getattr(launcher_cfg, "model_dirs", []) or []) if launcher_cfg else []
|
|
574
|
+
)
|
|
575
|
+
if not model_dirs:
|
|
576
|
+
if not _model_dirs_warning_emitted:
|
|
577
|
+
logger.warning(
|
|
578
|
+
"launcher-model-dirs-unconfigured",
|
|
579
|
+
extra={
|
|
580
|
+
"hint": (
|
|
581
|
+
"launcher.model_dirs is empty; POST /api/launcher/start and "
|
|
582
|
+
"/sweep/start accept any filesystem path. Configure "
|
|
583
|
+
"launcher.model_dirs when the launcher is reachable from "
|
|
584
|
+
"anything but loopback."
|
|
585
|
+
)
|
|
586
|
+
},
|
|
587
|
+
)
|
|
588
|
+
_model_dirs_warning_emitted = True
|
|
589
|
+
return
|
|
590
|
+
try:
|
|
591
|
+
_resolve_within_model_dirs(model_path, model_dirs)
|
|
592
|
+
except ValueError as exc:
|
|
593
|
+
raise HTTPException(status_code=400, detail=f"{field}: {exc}") from exc
|
|
594
|
+
|
|
595
|
+
|
|
555
596
|
def _option_tokens(options: dict[str, Any]) -> list[str]:
|
|
556
597
|
"""Flatten an ``options`` dict into CLI tokens (``{flag: val}`` semantics).
|
|
557
598
|
|
|
@@ -714,11 +755,21 @@ async def _backend_ready(backend: str, port: int, *, probe_timeout_s: float) ->
|
|
|
714
755
|
Falling through to the bare TCP connect here would silently re-introduce
|
|
715
756
|
the bug readiness gating was added to fix (provider registered before the
|
|
716
757
|
model finished loading).
|
|
758
|
+
|
|
759
|
+
The probe targets ``127.0.0.1`` literally, never ``localhost``. On a host
|
|
760
|
+
where ``localhost`` resolves to ``::1`` first — the default on GitHub's
|
|
761
|
+
macOS runners, and common on Macs generally — an httpx request to
|
|
762
|
+
``http://localhost:<port>`` fails with ``Address family not supported``
|
|
763
|
+
against a backend listening on IPv4 only, which is what ``llama-server``
|
|
764
|
+
does by default (``--host 127.0.0.1``). Readiness then never succeeds and
|
|
765
|
+
the spawn times out with ``status='loading'`` even though the backend is
|
|
766
|
+
up and serving. The bare TCP fallback below already used ``127.0.0.1``;
|
|
767
|
+
this branch was the odd one out.
|
|
717
768
|
"""
|
|
718
769
|
if base_backend(backend) in ("llama.cpp", "vllm"):
|
|
719
770
|
try:
|
|
720
771
|
async with httpx.AsyncClient(timeout=probe_timeout_s) as client:
|
|
721
|
-
resp = await client.get(f"http://
|
|
772
|
+
resp = await client.get(f"http://127.0.0.1:{port}/health")
|
|
722
773
|
return resp.status_code == 200
|
|
723
774
|
except Exception:
|
|
724
775
|
return False
|
|
@@ -1400,6 +1451,8 @@ async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
|
|
|
1400
1451
|
cfg = request.app.state.config
|
|
1401
1452
|
launcher_cfg = getattr(cfg, "launcher", None)
|
|
1402
1453
|
_assert_backend_declared(launcher_cfg, req.backend)
|
|
1454
|
+
_assert_model_path_allowed(launcher_cfg, req.model_path, field="model_path")
|
|
1455
|
+
_assert_model_path_allowed(launcher_cfg, req.draft_model_path, field="draft_model_path")
|
|
1403
1456
|
# デバイス選択 → CLI 断片(llama.cpp のみ・選択された場合のみ)。未指定なら
|
|
1404
1457
|
# None を渡し、既存の argv と完全一致(後方互換)。
|
|
1405
1458
|
device_args: list[str] | None = None
|
|
@@ -1655,8 +1708,10 @@ class SweepRequest(BaseModel):
|
|
|
1655
1708
|
options: dict[str, Any] = Field(default_factory=dict)
|
|
1656
1709
|
extra_args: str = ""
|
|
1657
1710
|
configs: list[SweepConfigItem] = Field(default_factory=list)
|
|
1658
|
-
|
|
1659
|
-
runs: int | None = None
|
|
1711
|
+
bench_preset: str | None = None # None なら launcher.bench.default_preset / 暗黙 default
|
|
1712
|
+
runs: int | None = Field(default=None, ge=1, le=1000)
|
|
1713
|
+
# 廃止フィールド。黙って捨てると事故になるので api_sweep_start で 400 にする。
|
|
1714
|
+
bench_command: str | None = None
|
|
1660
1715
|
results_dir: str | None = None
|
|
1661
1716
|
|
|
1662
1717
|
|
|
@@ -1862,25 +1917,73 @@ class _SweepRunner:
|
|
|
1862
1917
|
await self._safe_stop(mp.id)
|
|
1863
1918
|
|
|
1864
1919
|
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1920
|
+
_HARDCODED_BENCH_TEMPLATE = "llmbench run --model local-openai --runs {runs}"
|
|
1921
|
+
_HARDCODED_BENCH_RUNS = 5
|
|
1922
|
+
|
|
1923
|
+
|
|
1924
|
+
@dataclass(frozen=True)
|
|
1925
|
+
class _ResolvedBench:
|
|
1926
|
+
key: str
|
|
1927
|
+
name: str
|
|
1928
|
+
command_template: str
|
|
1929
|
+
runs: int
|
|
1930
|
+
results_dir: str | None
|
|
1931
|
+
readiness_timeout_s: float
|
|
1932
|
+
|
|
1933
|
+
|
|
1934
|
+
def _bench_preset_map(launcher_cfg: Any) -> dict[str, Any]:
|
|
1871
1935
|
bench_cfg = getattr(launcher_cfg, "bench", None) if launcher_cfg else None
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1936
|
+
return dict(getattr(bench_cfg, "presets", {}) or {})
|
|
1937
|
+
|
|
1938
|
+
|
|
1939
|
+
def _resolve_bench(launcher_cfg: Any, preset_key: str | None) -> _ResolvedBench:
|
|
1940
|
+
"""H-2: リクエストの bench_preset を config 上の実体へ解決。無いキーは ValueError(→400)。"""
|
|
1941
|
+
bench_cfg = getattr(launcher_cfg, "bench", None) if launcher_cfg else None
|
|
1942
|
+
base_template = getattr(bench_cfg, "command_template", None) or _HARDCODED_BENCH_TEMPLATE
|
|
1943
|
+
base_runs = getattr(bench_cfg, "runs", None) or _HARDCODED_BENCH_RUNS
|
|
1944
|
+
base_results = getattr(bench_cfg, "results_dir", None)
|
|
1945
|
+
readiness = getattr(bench_cfg, "readiness_timeout_s", None) or _DEFAULT_READINESS_TIMEOUT_S
|
|
1946
|
+
presets = _bench_preset_map(launcher_cfg)
|
|
1947
|
+
key = preset_key or getattr(bench_cfg, "default_preset", None)
|
|
1948
|
+
if key is None or (key == "default" and "default" not in presets):
|
|
1949
|
+
return _ResolvedBench(
|
|
1950
|
+
key="default",
|
|
1951
|
+
name="default (launcher.bench.command_template)",
|
|
1952
|
+
command_template=base_template,
|
|
1953
|
+
runs=base_runs,
|
|
1954
|
+
results_dir=base_results,
|
|
1955
|
+
readiness_timeout_s=readiness,
|
|
1956
|
+
)
|
|
1957
|
+
preset = presets.get(key)
|
|
1958
|
+
if preset is None:
|
|
1959
|
+
known = sorted(presets) or ["default"]
|
|
1960
|
+
raise ValueError(
|
|
1961
|
+
f"Unknown bench preset {key!r}. Declare it under launcher.bench.presets "
|
|
1962
|
+
f"in providers.yaml (known: {known})."
|
|
1963
|
+
)
|
|
1964
|
+
return _ResolvedBench(
|
|
1965
|
+
key=key,
|
|
1966
|
+
name=preset.name,
|
|
1967
|
+
command_template=preset.command_template,
|
|
1968
|
+
runs=preset.runs if preset.runs is not None else base_runs,
|
|
1969
|
+
results_dir=preset.results_dir if preset.results_dir is not None else base_results,
|
|
1970
|
+
readiness_timeout_s=readiness,
|
|
1971
|
+
)
|
|
1878
1972
|
|
|
1879
1973
|
|
|
1880
1974
|
@router.post("/api/launcher/sweep/start")
|
|
1881
1975
|
async def api_sweep_start(req: SweepRequest, request: Request) -> dict[str, Any]:
|
|
1882
1976
|
"""ベンチスイープを開始する(設計 §4.4 / §4.5)。書き込み系 → token 必須。"""
|
|
1883
1977
|
_require_launcher_token(request)
|
|
1978
|
+
if req.bench_command is not None or req.results_dir is not None:
|
|
1979
|
+
raise HTTPException(
|
|
1980
|
+
status_code=400,
|
|
1981
|
+
detail=(
|
|
1982
|
+
"bench_command / results_dir are no longer accepted from the request. "
|
|
1983
|
+
"Declare the command under launcher.bench.presets in providers.yaml and "
|
|
1984
|
+
"pass its key as 'bench_preset'."
|
|
1985
|
+
),
|
|
1986
|
+
)
|
|
1884
1987
|
app = request.app
|
|
1885
1988
|
|
|
1886
1989
|
existing = getattr(app.state, "launcher_sweep", None)
|
|
@@ -1891,12 +1994,12 @@ async def api_sweep_start(req: SweepRequest, request: Request) -> dict[str, Any]
|
|
|
1891
1994
|
|
|
1892
1995
|
cfg = app.state.config
|
|
1893
1996
|
launcher_cfg = getattr(cfg, "launcher", None)
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
runs = req.runs if req.runs is not None else
|
|
1899
|
-
|
|
1997
|
+
try:
|
|
1998
|
+
bench = _resolve_bench(launcher_cfg, req.bench_preset)
|
|
1999
|
+
except ValueError as exc:
|
|
2000
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
2001
|
+
runs = req.runs if req.runs is not None else bench.runs
|
|
2002
|
+
_assert_model_path_allowed(launcher_cfg, req.model_path, field="model_path")
|
|
1900
2003
|
|
|
1901
2004
|
# ポート競合: registry の使用中ポート照合 + best-effort な空きチェック。
|
|
1902
2005
|
reg = _registry_for_app(app)
|
|
@@ -1935,17 +2038,60 @@ async def api_sweep_start(req: SweepRequest, request: Request) -> dict[str, Any]
|
|
|
1935
2038
|
model_path=req.model_path,
|
|
1936
2039
|
backend=req.backend,
|
|
1937
2040
|
port=req.port,
|
|
1938
|
-
bench_cmd_template=
|
|
1939
|
-
results_dir=results_dir,
|
|
2041
|
+
bench_cmd_template=bench.command_template,
|
|
2042
|
+
results_dir=bench.results_dir,
|
|
1940
2043
|
options=req.options,
|
|
1941
2044
|
extra_args=req.extra_args,
|
|
1942
2045
|
)
|
|
1943
2046
|
runner = _SweepRunner(
|
|
1944
|
-
app, launcher_cfg, plan, runs=runs, readiness_timeout_s=
|
|
2047
|
+
app, launcher_cfg, plan, runs=runs, readiness_timeout_s=bench.readiness_timeout_s
|
|
1945
2048
|
)
|
|
1946
2049
|
app.state.launcher_sweep = runner
|
|
1947
2050
|
runner.start()
|
|
1948
|
-
return {
|
|
2051
|
+
return {
|
|
2052
|
+
"sweep_id": runner.sweep_id,
|
|
2053
|
+
"steps": [s.as_dict() for s in steps],
|
|
2054
|
+
"bench_preset": bench.key,
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
|
|
2058
|
+
@router.get("/api/launcher/bench-presets")
|
|
2059
|
+
async def api_bench_presets(request: Request) -> dict[str, Any]:
|
|
2060
|
+
"""スイープ UI が選べるベンチプリセットのキー一覧。読み取り系 → 認証なし。
|
|
2061
|
+
|
|
2062
|
+
H-2: command_template はローカルパスが混ざりうるので **返さない**。
|
|
2063
|
+
key/name/runs/has_results_dir だけ返す。presets に "default" が無ければ
|
|
2064
|
+
暗黙 default(launcher.bench.command_template 由来)を先頭に挿入する。
|
|
2065
|
+
"""
|
|
2066
|
+
cfg = request.app.state.config
|
|
2067
|
+
launcher_cfg = getattr(cfg, "launcher", None)
|
|
2068
|
+
bench_cfg = getattr(launcher_cfg, "bench", None) if launcher_cfg else None
|
|
2069
|
+
presets = _bench_preset_map(launcher_cfg)
|
|
2070
|
+
default_preset = getattr(bench_cfg, "default_preset", None)
|
|
2071
|
+
base_results = getattr(bench_cfg, "results_dir", None)
|
|
2072
|
+
base_runs = getattr(bench_cfg, "runs", None) or _HARDCODED_BENCH_RUNS
|
|
2073
|
+
items: list[dict[str, Any]] = []
|
|
2074
|
+
if "default" not in presets:
|
|
2075
|
+
items.append(
|
|
2076
|
+
{
|
|
2077
|
+
"key": "default",
|
|
2078
|
+
"name": "default (launcher.bench.command_template)",
|
|
2079
|
+
"runs": base_runs,
|
|
2080
|
+
"has_results_dir": base_results is not None,
|
|
2081
|
+
}
|
|
2082
|
+
)
|
|
2083
|
+
for key, preset in presets.items():
|
|
2084
|
+
items.append(
|
|
2085
|
+
{
|
|
2086
|
+
"key": key,
|
|
2087
|
+
"name": preset.name,
|
|
2088
|
+
"runs": preset.runs if preset.runs is not None else base_runs,
|
|
2089
|
+
"has_results_dir": (
|
|
2090
|
+
preset.results_dir is not None or base_results is not None
|
|
2091
|
+
),
|
|
2092
|
+
}
|
|
2093
|
+
)
|
|
2094
|
+
return {"presets": items, "default": default_preset}
|
|
1949
2095
|
|
|
1950
2096
|
|
|
1951
2097
|
@router.get("/api/launcher/sweep/status")
|
|
@@ -2038,6 +2184,7 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
2038
2184
|
<a href="/dashboard" class="text-slate-400 hover:text-slate-200 transition-colors">Dashboard</a>
|
|
2039
2185
|
<span class="text-slate-100 font-medium border-b border-indigo-400 pb-0.5">Launcher</span>
|
|
2040
2186
|
<span id="status-msg" class="ml-auto text-xs text-slate-500"></span>
|
|
2187
|
+
<button id="token-badge" onclick="promptToken()" class="btn-sm btn-red hidden">🔑 トークン未設定</button>
|
|
2041
2188
|
</div>
|
|
2042
2189
|
</header>
|
|
2043
2190
|
|
|
@@ -2203,18 +2350,12 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
2203
2350
|
<input id="sweep-port" type="number" value="8090" min="1024" max="65535" />
|
|
2204
2351
|
</div>
|
|
2205
2352
|
<div>
|
|
2206
|
-
<label class="block text-xs text-slate-400 mb-1"
|
|
2207
|
-
<
|
|
2353
|
+
<label class="block text-xs text-slate-400 mb-1">ベンチプリセット (providers.yaml の launcher.bench.presets)</label>
|
|
2354
|
+
<select id="sweep-preset"></select>
|
|
2208
2355
|
</div>
|
|
2209
|
-
<div
|
|
2210
|
-
<
|
|
2211
|
-
|
|
2212
|
-
<input id="sweep-runs" type="number" value="5" min="1" max="1000" />
|
|
2213
|
-
</div>
|
|
2214
|
-
<div>
|
|
2215
|
-
<label class="block text-xs text-slate-400 mb-1">results_dir (任意)</label>
|
|
2216
|
-
<input id="sweep-results" type="text" placeholder="results/" />
|
|
2217
|
-
</div>
|
|
2356
|
+
<div>
|
|
2357
|
+
<label class="block text-xs text-slate-400 mb-1">runs</label>
|
|
2358
|
+
<input id="sweep-runs" type="number" value="5" min="1" max="1000" />
|
|
2218
2359
|
</div>
|
|
2219
2360
|
</div>
|
|
2220
2361
|
</div>
|
|
@@ -2259,15 +2400,31 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
2259
2400
|
"use strict";
|
|
2260
2401
|
|
|
2261
2402
|
const POLL_MS = 3000;
|
|
2262
|
-
//
|
|
2263
|
-
//
|
|
2264
|
-
|
|
2265
|
-
|
|
2403
|
+
// H-3: the token is NEVER embedded in the HTML. The server only tells us
|
|
2404
|
+
// whether auth is required; the token itself is entered by the operator and
|
|
2405
|
+
// kept in sessionStorage (cleared when the tab closes).
|
|
2406
|
+
const AUTH_REQUIRED = __LAUNCHER_AUTH_REQUIRED__;
|
|
2407
|
+
const TOKEN_KEY = "coderouter-launcher-token";
|
|
2408
|
+
let launcherToken = sessionStorage.getItem(TOKEN_KEY) || "";
|
|
2266
2409
|
const authHeaders = (base) => {
|
|
2267
2410
|
const h = Object.assign({}, base || {});
|
|
2268
|
-
if (
|
|
2411
|
+
if (launcherToken) h["X-CodeRouter-Token"] = launcherToken;
|
|
2269
2412
|
return h;
|
|
2270
2413
|
};
|
|
2414
|
+
const updateTokenBadge = () => {
|
|
2415
|
+
const el = document.getElementById("token-badge");
|
|
2416
|
+
if (!el) return;
|
|
2417
|
+
el.textContent = launcherToken ? "🔑 トークン設定済" : "🔑 トークン未設定";
|
|
2418
|
+
el.className = "btn-sm " + (launcherToken ? "btn-slate" : "btn-red") + (AUTH_REQUIRED ? "" : " hidden");
|
|
2419
|
+
};
|
|
2420
|
+
window.promptToken = () => {
|
|
2421
|
+
const v = window.prompt("CODEROUTER_LAUNCHER_TOKEN を入力してください", "");
|
|
2422
|
+
if (v === null) return;
|
|
2423
|
+
launcherToken = v.trim();
|
|
2424
|
+
if (launcherToken) sessionStorage.setItem(TOKEN_KEY, launcherToken);
|
|
2425
|
+
else sessionStorage.removeItem(TOKEN_KEY);
|
|
2426
|
+
updateTokenBadge();
|
|
2427
|
+
};
|
|
2271
2428
|
let allProfiles = {}; // backend → [{name, args}]
|
|
2272
2429
|
const _modelCache = {}; // index → {path, name, dir, size_gb}
|
|
2273
2430
|
let selectedLogId = null;
|
|
@@ -2798,14 +2955,12 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
2798
2955
|
tensor_split: c.tensor_split, backend: c.backend || null}));
|
|
2799
2956
|
if (!configs.length) { showSweepErr("構成を 1 つ以上選択してください"); return; }
|
|
2800
2957
|
const port = parseInt(document.getElementById("sweep-port").value);
|
|
2801
|
-
const
|
|
2958
|
+
const preset = document.getElementById("sweep-preset").value || null;
|
|
2802
2959
|
const runs = parseInt(document.getElementById("sweep-runs").value);
|
|
2803
|
-
const results = document.getElementById("sweep-results").value.trim();
|
|
2804
2960
|
const body = {
|
|
2805
2961
|
backend: "llama.cpp", model_path: model, port, configs,
|
|
2806
|
-
|
|
2962
|
+
bench_preset: preset,
|
|
2807
2963
|
runs: isNaN(runs) ? null : runs,
|
|
2808
|
-
results_dir: results || null,
|
|
2809
2964
|
};
|
|
2810
2965
|
try {
|
|
2811
2966
|
const r = await fetch("/api/launcher/sweep/start", {
|
|
@@ -2960,12 +3115,12 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
2960
3115
|
const modelName = p.model_path.split("/").pop();
|
|
2961
3116
|
const isActive = p.status === "running" || p.status === "loading" || p.status === "starting";
|
|
2962
3117
|
const stopBtn = isActive
|
|
2963
|
-
? `<button
|
|
3118
|
+
? `<button data-act="stop" data-id="${esc(p.id)}" class="btn-sm btn-red">■ 停止</button>`
|
|
2964
3119
|
: "";
|
|
2965
3120
|
const delBtn = !isActive
|
|
2966
|
-
? `<button
|
|
3121
|
+
? `<button data-act="del" data-id="${esc(p.id)}" class="btn-sm btn-slate ml-1">✕</button>`
|
|
2967
3122
|
: "";
|
|
2968
|
-
const logBtn = `<button
|
|
3123
|
+
const logBtn = `<button data-act="log" data-id="${esc(p.id)}" data-name="${esc(p.name)}" class="btn-sm btn-indigo ml-1">📋 ログ</button>`;
|
|
2969
3124
|
// [Unreleased]: swap badge — marks processes SwapManager spawned
|
|
2970
3125
|
// on demand, distinct from manually-started ones. Title shows the
|
|
2971
3126
|
// catalog model name when the backend supplied it.
|
|
@@ -2984,6 +3139,18 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
2984
3139
|
}).join("");
|
|
2985
3140
|
};
|
|
2986
3141
|
|
|
3142
|
+
// M-2: no inline onclick with interpolated process fields (XSS). The row
|
|
3143
|
+
// buttons carry data-* attributes; a single delegated listener dispatches.
|
|
3144
|
+
document.getElementById("proc-table").addEventListener("click", (ev) => {
|
|
3145
|
+
const btn = ev.target.closest("button[data-act]");
|
|
3146
|
+
if (!btn) return;
|
|
3147
|
+
const id = btn.getAttribute("data-id");
|
|
3148
|
+
const act = btn.getAttribute("data-act");
|
|
3149
|
+
if (act === "stop") stopProc(id);
|
|
3150
|
+
else if (act === "del") deleteProc(id);
|
|
3151
|
+
else if (act === "log") openLog(id, btn.getAttribute("data-name") || "");
|
|
3152
|
+
});
|
|
3153
|
+
|
|
2987
3154
|
window.stopProc = async (id) => {
|
|
2988
3155
|
if (!confirm("プロセスを停止しますか?")) return;
|
|
2989
3156
|
const r = await fetch(`/api/launcher/stop/${id}`, {method:"POST", headers: authHeaders()});
|
|
@@ -3041,11 +3208,35 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
3041
3208
|
document.getElementById("log-panel").classList.add("hidden");
|
|
3042
3209
|
};
|
|
3043
3210
|
|
|
3211
|
+
// H-2: fill the sweep preset <select> from config-declared presets. The
|
|
3212
|
+
// command_template is never sent to the browser; the operator only picks a key.
|
|
3213
|
+
const fetchBenchPresets = async () => {
|
|
3214
|
+
try {
|
|
3215
|
+
const r = await fetch("/api/launcher/bench-presets");
|
|
3216
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
3217
|
+
const d = await r.json();
|
|
3218
|
+
const sel = document.getElementById("sweep-preset");
|
|
3219
|
+
if (!sel) return;
|
|
3220
|
+
const presets = d.presets || [];
|
|
3221
|
+
sel.innerHTML = presets.map((p) => {
|
|
3222
|
+
const runs = (p.runs != null) ? ` — runs ${p.runs}` : "";
|
|
3223
|
+
return `<option value="${esc(p.key)}">${esc(p.name)}${runs}</option>`;
|
|
3224
|
+
}).join("");
|
|
3225
|
+
const chosen = d.default || "default";
|
|
3226
|
+
const keys = presets.map((p) => p.key);
|
|
3227
|
+
sel.value = keys.includes(chosen) ? chosen : (keys[0] || "");
|
|
3228
|
+
} catch (e) {
|
|
3229
|
+
console.error("[Launcher] fetchBenchPresets failed:", e);
|
|
3230
|
+
}
|
|
3231
|
+
};
|
|
3232
|
+
|
|
3044
3233
|
// ── Init + polling ───────────────────────────────────────────────────────
|
|
3045
3234
|
|
|
3046
3235
|
const init = async () => {
|
|
3047
3236
|
updateDeviceBlock();
|
|
3048
|
-
|
|
3237
|
+
updateTokenBadge();
|
|
3238
|
+
if (AUTH_REQUIRED && !launcherToken) promptToken();
|
|
3239
|
+
await Promise.all([fetchModels(), fetchProfiles(), fetchBackends(), fetchProcesses(), fetchBenchPresets()]);
|
|
3049
3240
|
pollSweep(); // 既存スイープがあれば表示 (running なら以後のポーリングを継続)
|
|
3050
3241
|
};
|
|
3051
3242
|
|
|
@@ -3068,18 +3259,13 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
3068
3259
|
async def launcher_page() -> HTMLResponse:
|
|
3069
3260
|
"""Serve the launcher single-page UI.
|
|
3070
3261
|
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3262
|
+
H-3: the launcher token is NEVER embedded in the served HTML (it used to be
|
|
3263
|
+
substituted into a JS string literal, so anyone who could read the page —
|
|
3264
|
+
including a browser cache or a shoulder-surfer — recovered the shared
|
|
3265
|
+
secret). We now only substitute a boolean ``__LAUNCHER_AUTH_REQUIRED__``
|
|
3266
|
+
telling the client whether auth is on; the operator enters the token in the
|
|
3267
|
+
browser and it lives in ``sessionStorage`` only.
|
|
3076
3268
|
"""
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
token.replace("\\", "\\\\")
|
|
3080
|
-
.replace('"', '\\"')
|
|
3081
|
-
.replace("<", "\\u003c")
|
|
3082
|
-
.replace(">", "\\u003e")
|
|
3083
|
-
)
|
|
3084
|
-
html = _LAUNCHER_HTML.replace("__LAUNCHER_TOKEN__", safe_token)
|
|
3269
|
+
auth_required = "true" if os.environ.get(_LAUNCHER_TOKEN_ENV, "") else "false"
|
|
3270
|
+
html = _LAUNCHER_HTML.replace("__LAUNCHER_AUTH_REQUIRED__", auth_required)
|
|
3085
3271
|
return HTMLResponse(content=html)
|
coderouter/launcher_devices.py
CHANGED
|
@@ -629,6 +629,10 @@ def render_bench_command(
|
|
|
629
629
|
argv 化。
|
|
630
630
|
|
|
631
631
|
``str.format`` は JSON 波括弧等で誤爆するため単純置換を使う。
|
|
632
|
+
|
|
633
|
+
H-2: 置換より **先に** ``shlex.split`` でトークン化し、各トークン内でのみ
|
|
634
|
+
置換する。こうしないと ``{config}`` にスペースを含む値が入ったとき argv が
|
|
635
|
+
増殖し(引数注入)、想定外のフラグを外部ベンチに渡せてしまう。
|
|
632
636
|
"""
|
|
633
637
|
mapping = {
|
|
634
638
|
"port": str(port),
|
|
@@ -637,13 +641,16 @@ def render_bench_command(
|
|
|
637
641
|
"results_dir": results_dir or "",
|
|
638
642
|
"runs": str(runs) if runs is not None else "",
|
|
639
643
|
}
|
|
640
|
-
rendered = template
|
|
641
|
-
for k, v in mapping.items():
|
|
642
|
-
rendered = rendered.replace("{" + k + "}", v)
|
|
643
644
|
# ★ Windows: shlex の POSIX モードはバックスラッシュをエスケープ文字
|
|
644
645
|
# として食い潰し `C:\tools\llmbench.exe` を壊す。os.name で分岐して
|
|
645
646
|
# Windows では posix=False(バックスラッシュを素通し)にする。
|
|
646
|
-
|
|
647
|
+
argv: list[str] = []
|
|
648
|
+
for tok in shlex.split(template, posix=(os.name != "nt")):
|
|
649
|
+
for k, v in mapping.items():
|
|
650
|
+
tok = tok.replace("{" + k + "}", v)
|
|
651
|
+
if tok:
|
|
652
|
+
argv.append(tok)
|
|
653
|
+
return argv
|
|
647
654
|
|
|
648
655
|
|
|
649
656
|
# ---------------------------------------------------------------------------
|
coderouter/py.typed
ADDED
|
File without changes
|