coderouter-cli 2.9.2__py3-none-any.whl → 2.9.4__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/config/schemas.py +91 -6
- coderouter/ingress/launcher_routes.py +856 -6
- coderouter/launcher_devices.py +518 -0
- coderouter/launcher_swap.py +100 -12
- coderouter/translation/anthropic.py +26 -5
- {coderouter_cli-2.9.2.dist-info → coderouter_cli-2.9.4.dist-info}/METADATA +2 -2
- {coderouter_cli-2.9.2.dist-info → coderouter_cli-2.9.4.dist-info}/RECORD +10 -9
- {coderouter_cli-2.9.2.dist-info → coderouter_cli-2.9.4.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.9.2.dist-info → coderouter_cli-2.9.4.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.9.2.dist-info → coderouter_cli-2.9.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -40,8 +40,23 @@ from typing import Any
|
|
|
40
40
|
import httpx
|
|
41
41
|
from fastapi import APIRouter, HTTPException, Request
|
|
42
42
|
from fastapi.responses import HTMLResponse
|
|
43
|
-
from pydantic import BaseModel
|
|
44
|
-
|
|
43
|
+
from pydantic import BaseModel, Field
|
|
44
|
+
|
|
45
|
+
from coderouter.launcher_devices import (
|
|
46
|
+
DeviceSelection,
|
|
47
|
+
SweepPlan,
|
|
48
|
+
SweepState,
|
|
49
|
+
SweepStep,
|
|
50
|
+
build_auto_sweep_configs,
|
|
51
|
+
build_sweep_steps,
|
|
52
|
+
detect_llama_devices,
|
|
53
|
+
group_by_backend,
|
|
54
|
+
is_port_free,
|
|
55
|
+
load_latest_results,
|
|
56
|
+
render_bench_command,
|
|
57
|
+
selectable_devices,
|
|
58
|
+
suggest_tensor_split,
|
|
59
|
+
)
|
|
45
60
|
from coderouter.launcher_speculative import resolve_speculative
|
|
46
61
|
from coderouter.logging import get_logger
|
|
47
62
|
|
|
@@ -171,6 +186,13 @@ class ManagedProcess:
|
|
|
171
186
|
# under its per-model lock); a launcher auto-restart racing that
|
|
172
187
|
# re-spawn would fight over the same fixed port.
|
|
173
188
|
swap_managed: bool = False
|
|
189
|
+
# [Unreleased]: the swap catalog model name (SwapModelSpec.name) this
|
|
190
|
+
# process backs, set by SwapManager._spawn via spawn_process's
|
|
191
|
+
# swap_model kwarg. None for a manually-started process (swap_managed
|
|
192
|
+
# is False) or if a future swap-managed spawn path omits it. Purely
|
|
193
|
+
# informational — surfaced by GET /api/launcher/processes so the
|
|
194
|
+
# /launcher UI can label which catalog entry a running process is.
|
|
195
|
+
swap_model: str | None = None
|
|
174
196
|
# asyncio subprocess handle — not serialised
|
|
175
197
|
_proc: Any = field(default=None, repr=False, compare=False)
|
|
176
198
|
|
|
@@ -505,6 +527,7 @@ def _build_cmd(
|
|
|
505
527
|
extra_args: str,
|
|
506
528
|
binary: str | None = None,
|
|
507
529
|
spec_tokens: list[str] | None = None,
|
|
530
|
+
device_args: list[str] | None = None,
|
|
508
531
|
) -> list[str]:
|
|
509
532
|
"""Build the CLI command list for the given backend and options.
|
|
510
533
|
|
|
@@ -518,11 +541,22 @@ def _build_cmd(
|
|
|
518
541
|
the profile / extra args. They are trusted (the launcher, not the
|
|
519
542
|
caller, produced them) so they bypass the model-override guard even
|
|
520
543
|
though they may contain ``--model-draft``.
|
|
544
|
+
|
|
545
|
+
``device_args`` are pre-resolved llama.cpp device-selection flags
|
|
546
|
+
(``--device`` / ``--tensor-split``) produced by
|
|
547
|
+
:meth:`coderouter.launcher_devices.DeviceSelection.to_cli_args`. They
|
|
548
|
+
are inserted right after the port args and BEFORE ``spec_tokens`` for
|
|
549
|
+
llama.cpp only. When ``None``/empty the argv is byte-for-byte identical
|
|
550
|
+
to the pre-feature output (後方互換の核心 — 未選択なら ``--device`` を
|
|
551
|
+
一切足さない). They are launcher-produced (trusted) so they bypass the
|
|
552
|
+
model-override guard, and never apply to vllm/mlx (device 非対応)。
|
|
521
553
|
"""
|
|
522
554
|
exe = _resolve_binary(backend, binary)
|
|
523
555
|
|
|
524
556
|
if backend == "llama.cpp":
|
|
525
557
|
cmd: list[str] = [exe, "-m", model_path, "--port", str(port)]
|
|
558
|
+
if device_args:
|
|
559
|
+
cmd.extend(device_args)
|
|
526
560
|
if spec_tokens:
|
|
527
561
|
cmd.extend(spec_tokens)
|
|
528
562
|
elif backend == "vllm":
|
|
@@ -965,6 +999,11 @@ class StartRequest(BaseModel):
|
|
|
965
999
|
# or "off" (never emit speculative flags).
|
|
966
1000
|
draft_model_path: str | None = None
|
|
967
1001
|
mtp_mode: str = "auto"
|
|
1002
|
+
# デバイス選択(llama.cpp のみ)。既定は空 → ``DeviceSelection.to_cli_args``
|
|
1003
|
+
# が ``[]`` を返し、``_build_cmd`` の argv は現行と 1 バイトも変わらない
|
|
1004
|
+
# (既存 Web クライアント/テスト完全不変)。設計 §4.1。
|
|
1005
|
+
device_ids: list[str] = Field(default_factory=list)
|
|
1006
|
+
tensor_split: list[float] = Field(default_factory=list)
|
|
968
1007
|
|
|
969
1008
|
|
|
970
1009
|
# ---------------------------------------------------------------------------
|
|
@@ -1027,7 +1066,13 @@ async def api_launcher_config_debug(request: Request) -> dict[str, Any]:
|
|
|
1027
1066
|
|
|
1028
1067
|
@router.get("/api/launcher/processes")
|
|
1029
1068
|
async def api_processes(request: Request) -> dict[str, Any]:
|
|
1030
|
-
"""List all managed processes.
|
|
1069
|
+
"""List all managed processes.
|
|
1070
|
+
|
|
1071
|
+
[Unreleased]: ``swap_managed`` / ``swap_model`` let the ``/launcher``
|
|
1072
|
+
UI (and other API clients) tell an on-demand SwapManager-spawned
|
|
1073
|
+
process apart from a manually-started one, and label which swap
|
|
1074
|
+
catalog model it backs.
|
|
1075
|
+
"""
|
|
1031
1076
|
reg = _registry(request)
|
|
1032
1077
|
return {
|
|
1033
1078
|
"processes": [
|
|
@@ -1040,6 +1085,8 @@ async def api_processes(request: Request) -> dict[str, Any]:
|
|
|
1040
1085
|
"status": p.status,
|
|
1041
1086
|
"pid": p.pid,
|
|
1042
1087
|
"returncode": p.returncode,
|
|
1088
|
+
"swap_managed": p.swap_managed,
|
|
1089
|
+
"swap_model": p.swap_model,
|
|
1043
1090
|
}
|
|
1044
1091
|
for p in reg.all()
|
|
1045
1092
|
]
|
|
@@ -1100,6 +1147,8 @@ async def spawn_process(
|
|
|
1100
1147
|
draft_model_path: str | None = None,
|
|
1101
1148
|
mtp_mode: str = "auto",
|
|
1102
1149
|
swap_managed: bool = False,
|
|
1150
|
+
swap_model: str | None = None,
|
|
1151
|
+
device_args: list[str] | None = None,
|
|
1103
1152
|
) -> ManagedProcess:
|
|
1104
1153
|
"""Build argv, spawn the child process, and arm readiness/log tailing.
|
|
1105
1154
|
|
|
@@ -1116,6 +1165,12 @@ async def spawn_process(
|
|
|
1116
1165
|
exist. Any other exception from ``create_subprocess_exec`` propagates
|
|
1117
1166
|
as-is. Callers decide how to surface these (HTTP 400/500 for
|
|
1118
1167
|
``api_start``, a retryable ``AdapterError`` for SwapManager).
|
|
1168
|
+
|
|
1169
|
+
``swap_model`` ([Unreleased]) is purely informational — the swap
|
|
1170
|
+
catalog model name (``SwapModelSpec.name``), recorded on the
|
|
1171
|
+
resulting ``ManagedProcess`` for ``GET /api/launcher/processes`` /
|
|
1172
|
+
the ``/launcher`` UI. Only ``SwapManager._spawn`` passes it; the
|
|
1173
|
+
manual ``POST /api/launcher/start`` path leaves it ``None``.
|
|
1119
1174
|
"""
|
|
1120
1175
|
options = options if options is not None else {}
|
|
1121
1176
|
configured_binary: str | None = None
|
|
@@ -1142,6 +1197,7 @@ async def spawn_process(
|
|
|
1142
1197
|
cmd = _build_cmd(
|
|
1143
1198
|
backend, model_path, port, options, extra_args,
|
|
1144
1199
|
binary=configured_binary, spec_tokens=spec_tokens,
|
|
1200
|
+
device_args=device_args,
|
|
1145
1201
|
)
|
|
1146
1202
|
|
|
1147
1203
|
# Speculative flags qualify for the one-shot startup-crash fallback only
|
|
@@ -1156,6 +1212,7 @@ async def spawn_process(
|
|
|
1156
1212
|
fallback_cmd = _build_cmd(
|
|
1157
1213
|
backend, model_path, port, options, extra_args,
|
|
1158
1214
|
binary=configured_binary, spec_tokens=None,
|
|
1215
|
+
device_args=device_args,
|
|
1159
1216
|
)
|
|
1160
1217
|
except ValueError:
|
|
1161
1218
|
# A failure to rebuild the non-spec command simply disables the
|
|
@@ -1178,6 +1235,7 @@ async def spawn_process(
|
|
|
1178
1235
|
spec_auto=spec_auto and fallback_cmd is not None,
|
|
1179
1236
|
fallback_cmd=fallback_cmd,
|
|
1180
1237
|
swap_managed=swap_managed,
|
|
1238
|
+
swap_model=swap_model,
|
|
1181
1239
|
)
|
|
1182
1240
|
proc.log_tail.append(f"[launcher] cmd: {' '.join(cmd)}")
|
|
1183
1241
|
for note in spec_notes:
|
|
@@ -1265,12 +1323,20 @@ async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
|
|
|
1265
1323
|
_require_launcher_token(request)
|
|
1266
1324
|
cfg = request.app.state.config
|
|
1267
1325
|
launcher_cfg = getattr(cfg, "launcher", None)
|
|
1326
|
+
# デバイス選択 → CLI 断片(llama.cpp のみ・選択された場合のみ)。未指定なら
|
|
1327
|
+
# None を渡し、既存の argv と完全一致(後方互換)。
|
|
1328
|
+
device_args: list[str] | None = None
|
|
1329
|
+
if req.backend == "llama.cpp" and req.device_ids:
|
|
1330
|
+
device_args = DeviceSelection(
|
|
1331
|
+
device_ids=list(req.device_ids), tensor_split=list(req.tensor_split)
|
|
1332
|
+
).to_cli_args()
|
|
1268
1333
|
try:
|
|
1269
1334
|
proc = await spawn_process(
|
|
1270
1335
|
request.app, launcher_cfg,
|
|
1271
1336
|
name=req.name, backend=req.backend, model_path=req.model_path,
|
|
1272
1337
|
port=req.port, options=req.options, extra_args=req.extra_args,
|
|
1273
1338
|
draft_model_path=req.draft_model_path, mtp_mode=req.mtp_mode,
|
|
1339
|
+
device_args=device_args,
|
|
1274
1340
|
)
|
|
1275
1341
|
except ValueError as exc:
|
|
1276
1342
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
@@ -1364,6 +1430,408 @@ async def api_suggest(request: Request, model_path: str = "",
|
|
|
1364
1430
|
}
|
|
1365
1431
|
|
|
1366
1432
|
|
|
1433
|
+
# ---------------------------------------------------------------------------
|
|
1434
|
+
# デバイス検出 + ベンチスイープ(設計 §4.4 / §4.5)
|
|
1435
|
+
# ---------------------------------------------------------------------------
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
def _configured_binary_for(launcher_cfg: Any, backend: str) -> str | None:
|
|
1439
|
+
"""launcher.backends[backend].binary を取り出す(未設定なら None)。"""
|
|
1440
|
+
if launcher_cfg and getattr(launcher_cfg, "backends", None):
|
|
1441
|
+
bc = launcher_cfg.backends.get(backend)
|
|
1442
|
+
if bc and bc.binary:
|
|
1443
|
+
return bc.binary
|
|
1444
|
+
return None
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
@router.get("/api/launcher/devices")
|
|
1448
|
+
async def api_devices(
|
|
1449
|
+
request: Request, backend: str = "llama.cpp", refresh: int = 0
|
|
1450
|
+
) -> dict[str, Any]:
|
|
1451
|
+
"""``{binary} --list-devices`` を実行してデバイス一覧を返す(設計 §4.4)。
|
|
1452
|
+
|
|
1453
|
+
``?refresh=1`` で検出キャッシュを無視して再取得する。検出失敗
|
|
1454
|
+
(``ok=false``)時は空リスト + error を返し、UI は手入力へフォールバック
|
|
1455
|
+
する。
|
|
1456
|
+
|
|
1457
|
+
レスポンス:
|
|
1458
|
+
- ``devices``: 検出した全デバイス(表示用。``BLAS: Accelerate`` 等の
|
|
1459
|
+
``total_mib==0`` デバイスも情報として含む)。
|
|
1460
|
+
- ``suggested_tensor_split``: **バックエンド別**の VRAM 比提案。
|
|
1461
|
+
``{"CUDA": [0.57, 0.43], "Vulkan": [...]}`` の形。同一バックエンドに
|
|
1462
|
+
selectable(``total_mib>0``)が 2 枚以上あるものだけを含む。実機で
|
|
1463
|
+
CUDA+Vulkan が同一物理 GPU を重複列挙する/BLAS が 0 MiB で並ぶため、
|
|
1464
|
+
跨バックエンド・0 MiB 込みのフラット提案は不正になるのを避ける。
|
|
1465
|
+
- ``auto_configs``: スイープ構成候補(``build_auto_sweep_configs``)を
|
|
1466
|
+
JSON 化。``[{"label", "device_ids", "tensor_split"}]``。構成生成ロジック
|
|
1467
|
+
をサーバ側に一本化し、フロントはこれを使う。
|
|
1468
|
+
"""
|
|
1469
|
+
cfg = request.app.state.config
|
|
1470
|
+
launcher_cfg = getattr(cfg, "launcher", None)
|
|
1471
|
+
configured = _configured_binary_for(launcher_cfg, backend)
|
|
1472
|
+
binary = _resolve_binary(backend, configured)
|
|
1473
|
+
# --list-devices はブロッキング subprocess → イベントループを止めない。
|
|
1474
|
+
probe = await asyncio.to_thread(
|
|
1475
|
+
detect_llama_devices, binary, use_cache=(refresh == 0)
|
|
1476
|
+
)
|
|
1477
|
+
# selectable(total_mib>0)だけをバックエンドごとに束ね、2 枚以上のときのみ
|
|
1478
|
+
# tensor-split を提案。跨バックエンド混成・BLAS(0 MiB)は除外される。
|
|
1479
|
+
per_backend_split: dict[str, list[float]] = {}
|
|
1480
|
+
if probe.ok:
|
|
1481
|
+
for backend_prefix, members in group_by_backend(
|
|
1482
|
+
selectable_devices(probe.devices)
|
|
1483
|
+
).items():
|
|
1484
|
+
if len(members) >= 2:
|
|
1485
|
+
per_backend_split[backend_prefix] = suggest_tensor_split(members)
|
|
1486
|
+
auto_configs = [
|
|
1487
|
+
{
|
|
1488
|
+
"label": label,
|
|
1489
|
+
"device_ids": sel.device_ids,
|
|
1490
|
+
"tensor_split": sel.tensor_split,
|
|
1491
|
+
}
|
|
1492
|
+
for label, sel in build_auto_sweep_configs(probe.devices)
|
|
1493
|
+
]
|
|
1494
|
+
return {
|
|
1495
|
+
**probe.as_dict(),
|
|
1496
|
+
"suggested_tensor_split": per_backend_split,
|
|
1497
|
+
"auto_configs": auto_configs,
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
class SweepConfigItem(BaseModel):
|
|
1502
|
+
"""スイープの 1 デバイス構成(ラベル + 選択デバイス)。"""
|
|
1503
|
+
|
|
1504
|
+
label: str
|
|
1505
|
+
device_ids: list[str] = Field(default_factory=list)
|
|
1506
|
+
tensor_split: list[float] = Field(default_factory=list)
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
class SweepRequest(BaseModel):
|
|
1510
|
+
"""``POST /api/launcher/sweep/start`` のリクエストボディ(設計 §4.4)。"""
|
|
1511
|
+
|
|
1512
|
+
backend: str = "llama.cpp"
|
|
1513
|
+
model_path: str
|
|
1514
|
+
port: int = Field(ge=1024, le=65535)
|
|
1515
|
+
options: dict[str, Any] = Field(default_factory=dict)
|
|
1516
|
+
extra_args: str = ""
|
|
1517
|
+
configs: list[SweepConfigItem] = Field(default_factory=list)
|
|
1518
|
+
bench_command: str | None = None # None なら launcher.bench 既定
|
|
1519
|
+
runs: int | None = None
|
|
1520
|
+
results_dir: str | None = None
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
class _SweepRunner:
|
|
1524
|
+
"""デバイス構成を順に「起動→readiness→外部ベンチ→停止→次」で回す。
|
|
1525
|
+
|
|
1526
|
+
既存の :func:`spawn_process` / :func:`stop_process` / ``proc.ready``
|
|
1527
|
+
(asyncio.Event)を再利用する薄い asyncio ランナー。同時に 1 スイープ
|
|
1528
|
+
のみ(``app.state.launcher_sweep``、swap と同じ排他方針)。ポートは
|
|
1529
|
+
スイープ専用に 1 本を構成間で使い回す(各 step は ``stop_process`` が
|
|
1530
|
+
プロセス終了=ポート解放まで待ってから次へ進む)。
|
|
1531
|
+
"""
|
|
1532
|
+
|
|
1533
|
+
def __init__(
|
|
1534
|
+
self,
|
|
1535
|
+
app: Any,
|
|
1536
|
+
launcher_cfg: Any,
|
|
1537
|
+
plan: SweepPlan,
|
|
1538
|
+
*,
|
|
1539
|
+
runs: int | None,
|
|
1540
|
+
readiness_timeout_s: float,
|
|
1541
|
+
) -> None:
|
|
1542
|
+
self.app = app
|
|
1543
|
+
self.launcher_cfg = launcher_cfg
|
|
1544
|
+
self.plan = plan
|
|
1545
|
+
self.runs = runs
|
|
1546
|
+
self.readiness_timeout_s = readiness_timeout_s
|
|
1547
|
+
self.sweep_id = uuid.uuid4().hex[:8]
|
|
1548
|
+
self.running = False
|
|
1549
|
+
self.current_index = -1
|
|
1550
|
+
self.log_tail: deque[str] = deque(maxlen=1000)
|
|
1551
|
+
self._abort = asyncio.Event()
|
|
1552
|
+
self._task: asyncio.Task[Any] | None = None
|
|
1553
|
+
|
|
1554
|
+
# -- lifecycle -----------------------------------------------------------
|
|
1555
|
+
|
|
1556
|
+
def start(self) -> None:
|
|
1557
|
+
self.running = True
|
|
1558
|
+
self._task = asyncio.create_task(self._run())
|
|
1559
|
+
_background_tasks.add(self._task)
|
|
1560
|
+
self._task.add_done_callback(_background_tasks.discard)
|
|
1561
|
+
|
|
1562
|
+
def abort(self) -> None:
|
|
1563
|
+
self._abort.set()
|
|
1564
|
+
|
|
1565
|
+
def status(self) -> dict[str, Any]:
|
|
1566
|
+
return {
|
|
1567
|
+
"sweep_id": self.sweep_id,
|
|
1568
|
+
"running": self.running,
|
|
1569
|
+
"current_index": self.current_index,
|
|
1570
|
+
"steps": [s.as_dict() for s in self.plan.steps],
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
# -- internals -----------------------------------------------------------
|
|
1574
|
+
|
|
1575
|
+
async def _run(self) -> None:
|
|
1576
|
+
try:
|
|
1577
|
+
for i, step in enumerate(self.plan.steps):
|
|
1578
|
+
self.current_index = i
|
|
1579
|
+
if self._abort.is_set():
|
|
1580
|
+
step.state = SweepState.ABORTED
|
|
1581
|
+
self.log_tail.append(f"[sweep] {step.label}: aborted (skipped)")
|
|
1582
|
+
continue
|
|
1583
|
+
await self._run_one(step)
|
|
1584
|
+
finally:
|
|
1585
|
+
self.current_index = -1
|
|
1586
|
+
self.running = False
|
|
1587
|
+
self.log_tail.append("[sweep] finished")
|
|
1588
|
+
|
|
1589
|
+
async def _await_ready(self, mp: ManagedProcess) -> str:
|
|
1590
|
+
"""ready / abort / timeout のいずれかまで待つ。
|
|
1591
|
+
|
|
1592
|
+
``proc.ready`` の待機に abort を絡めることで、readiness 待ち中でも
|
|
1593
|
+
中断要求に即応する(単に ``wait_for`` するだけだと timeout まで
|
|
1594
|
+
止まらない)。
|
|
1595
|
+
"""
|
|
1596
|
+
ready_wait = asyncio.ensure_future(mp.ready.wait())
|
|
1597
|
+
abort_wait = asyncio.ensure_future(self._abort.wait())
|
|
1598
|
+
try:
|
|
1599
|
+
done, _pending = await asyncio.wait(
|
|
1600
|
+
{ready_wait, abort_wait},
|
|
1601
|
+
timeout=self.readiness_timeout_s,
|
|
1602
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
1603
|
+
)
|
|
1604
|
+
finally:
|
|
1605
|
+
for t in (ready_wait, abort_wait):
|
|
1606
|
+
if not t.done():
|
|
1607
|
+
t.cancel()
|
|
1608
|
+
if abort_wait in done:
|
|
1609
|
+
return "aborted"
|
|
1610
|
+
if ready_wait in done:
|
|
1611
|
+
return "ready"
|
|
1612
|
+
return "timeout"
|
|
1613
|
+
|
|
1614
|
+
async def _safe_stop(self, proc_id: str) -> None:
|
|
1615
|
+
# stop_process は SIGTERM→5s→SIGKILL でポート解放まで待つ。次構成の
|
|
1616
|
+
# 起動前に確実に空ける。失敗しても握りつぶす(スイープを止めない)。
|
|
1617
|
+
with contextlib.suppress(Exception):
|
|
1618
|
+
await stop_process(self.app, proc_id)
|
|
1619
|
+
|
|
1620
|
+
async def _run_one(self, step: SweepStep) -> None:
|
|
1621
|
+
step.state = SweepState.STARTING
|
|
1622
|
+
self.log_tail.append(f"[sweep] {step.label}: starting")
|
|
1623
|
+
device_args = step.selection.to_cli_args() or None
|
|
1624
|
+
try:
|
|
1625
|
+
mp = await spawn_process(
|
|
1626
|
+
self.app,
|
|
1627
|
+
self.launcher_cfg,
|
|
1628
|
+
name=f"sweep-{step.label}",
|
|
1629
|
+
backend=self.plan.backend,
|
|
1630
|
+
model_path=self.plan.model_path,
|
|
1631
|
+
port=self.plan.port,
|
|
1632
|
+
options=self.plan.options,
|
|
1633
|
+
extra_args=self.plan.extra_args,
|
|
1634
|
+
mtp_mode="off",
|
|
1635
|
+
device_args=device_args,
|
|
1636
|
+
)
|
|
1637
|
+
except Exception as exc: # spawn 自体が失敗 → FAILED(次構成へ継続)
|
|
1638
|
+
step.state = SweepState.FAILED
|
|
1639
|
+
step.error = f"spawn failed: {exc}"
|
|
1640
|
+
self.log_tail.append(f"[sweep] {step.label}: spawn failed: {exc}")
|
|
1641
|
+
return
|
|
1642
|
+
|
|
1643
|
+
outcome = await self._await_ready(mp)
|
|
1644
|
+
if outcome == "aborted":
|
|
1645
|
+
step.state = SweepState.ABORTED
|
|
1646
|
+
self.log_tail.append(f"[sweep] {step.label}: aborted during startup")
|
|
1647
|
+
await self._safe_stop(mp.id)
|
|
1648
|
+
return
|
|
1649
|
+
if outcome == "timeout":
|
|
1650
|
+
step.state = SweepState.FAILED
|
|
1651
|
+
step.error = "readiness timeout"
|
|
1652
|
+
self.log_tail.append(f"[sweep] {step.label}: readiness timeout")
|
|
1653
|
+
await self._safe_stop(mp.id)
|
|
1654
|
+
return
|
|
1655
|
+
if mp.status != "running":
|
|
1656
|
+
step.state = SweepState.FAILED
|
|
1657
|
+
step.error = f"status={mp.status}"
|
|
1658
|
+
self.log_tail.append(
|
|
1659
|
+
f"[sweep] {step.label}: not running (status={mp.status})"
|
|
1660
|
+
)
|
|
1661
|
+
await self._safe_stop(mp.id)
|
|
1662
|
+
return
|
|
1663
|
+
|
|
1664
|
+
if self._abort.is_set():
|
|
1665
|
+
step.state = SweepState.ABORTED
|
|
1666
|
+
self.log_tail.append(f"[sweep] {step.label}: aborted before bench")
|
|
1667
|
+
await self._safe_stop(mp.id)
|
|
1668
|
+
return
|
|
1669
|
+
|
|
1670
|
+
# ── 外部ベンチ実行 ──
|
|
1671
|
+
step.state = SweepState.BENCHING
|
|
1672
|
+
step.started_at = time.time()
|
|
1673
|
+
self.log_tail.append(f"[sweep] {step.label}: benching")
|
|
1674
|
+
argv = render_bench_command(
|
|
1675
|
+
self.plan.bench_cmd_template,
|
|
1676
|
+
port=self.plan.port,
|
|
1677
|
+
config_label=step.label,
|
|
1678
|
+
results_dir=self.plan.results_dir,
|
|
1679
|
+
runs=self.runs,
|
|
1680
|
+
)
|
|
1681
|
+
env = {
|
|
1682
|
+
**os.environ,
|
|
1683
|
+
"OPENAI_BASE_URL": f"http://localhost:{self.plan.port}/v1",
|
|
1684
|
+
}
|
|
1685
|
+
try:
|
|
1686
|
+
proc = await asyncio.create_subprocess_exec(
|
|
1687
|
+
*argv,
|
|
1688
|
+
env=env,
|
|
1689
|
+
cwd=self.plan.results_dir or None,
|
|
1690
|
+
stdout=asyncio.subprocess.PIPE,
|
|
1691
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
1692
|
+
limit=_LOG_STREAM_LIMIT,
|
|
1693
|
+
)
|
|
1694
|
+
except (FileNotFoundError, OSError) as exc:
|
|
1695
|
+
# bench 起動自体が例外 → FAILED(exit code とは別扱い、設計 §2.4)
|
|
1696
|
+
step.state = SweepState.FAILED
|
|
1697
|
+
step.error = f"bench spawn failed: {exc}"
|
|
1698
|
+
step.ended_at = time.time()
|
|
1699
|
+
self.log_tail.append(f"[sweep] {step.label}: bench spawn failed: {exc}")
|
|
1700
|
+
await self._safe_stop(mp.id)
|
|
1701
|
+
return
|
|
1702
|
+
|
|
1703
|
+
if proc.stdout is not None:
|
|
1704
|
+
async for raw in proc.stdout:
|
|
1705
|
+
self.log_tail.append(raw.decode("utf-8", "replace").rstrip())
|
|
1706
|
+
await proc.wait()
|
|
1707
|
+
step.bench_exit_code = proc.returncode
|
|
1708
|
+
step.ended_at = time.time()
|
|
1709
|
+
self.log_tail.append(
|
|
1710
|
+
f"[sweep] {step.label}: bench exit code {proc.returncode}"
|
|
1711
|
+
)
|
|
1712
|
+
# results 解析(results_dir 指定時のみ)。非ゼロ exit でも比較のため
|
|
1713
|
+
# DONE 扱い(失敗ではなく exit code で判別、設計 §2.4)。
|
|
1714
|
+
if self.plan.results_dir:
|
|
1715
|
+
step.results_path, step.summary = await asyncio.to_thread(
|
|
1716
|
+
load_latest_results, self.plan.results_dir, since=step.started_at
|
|
1717
|
+
)
|
|
1718
|
+
step.state = SweepState.DONE
|
|
1719
|
+
await self._safe_stop(mp.id)
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
def _bench_defaults(launcher_cfg: Any) -> tuple[str, int, str | None, float]:
|
|
1723
|
+
"""launcher.bench(あれば)からベンチ既定を取り出す。無ければハードコード。"""
|
|
1724
|
+
default_template = "llmbench run --model local-openai --runs {runs}"
|
|
1725
|
+
default_runs = 5
|
|
1726
|
+
default_results_dir: str | None = None
|
|
1727
|
+
default_readiness = _DEFAULT_READINESS_TIMEOUT_S
|
|
1728
|
+
bench_cfg = getattr(launcher_cfg, "bench", None) if launcher_cfg else None
|
|
1729
|
+
if bench_cfg is not None:
|
|
1730
|
+
default_template = bench_cfg.command_template
|
|
1731
|
+
default_runs = bench_cfg.runs
|
|
1732
|
+
default_results_dir = bench_cfg.results_dir
|
|
1733
|
+
default_readiness = bench_cfg.readiness_timeout_s
|
|
1734
|
+
return default_template, default_runs, default_results_dir, default_readiness
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
@router.post("/api/launcher/sweep/start")
|
|
1738
|
+
async def api_sweep_start(req: SweepRequest, request: Request) -> dict[str, Any]:
|
|
1739
|
+
"""ベンチスイープを開始する(設計 §4.4 / §4.5)。書き込み系 → token 必須。"""
|
|
1740
|
+
_require_launcher_token(request)
|
|
1741
|
+
app = request.app
|
|
1742
|
+
|
|
1743
|
+
existing = getattr(app.state, "launcher_sweep", None)
|
|
1744
|
+
if existing is not None and existing.running:
|
|
1745
|
+
raise HTTPException(status_code=409, detail="A sweep is already running.")
|
|
1746
|
+
if not req.configs:
|
|
1747
|
+
raise HTTPException(status_code=400, detail="configs must not be empty.")
|
|
1748
|
+
|
|
1749
|
+
cfg = app.state.config
|
|
1750
|
+
launcher_cfg = getattr(cfg, "launcher", None)
|
|
1751
|
+
default_template, default_runs, default_results_dir, default_readiness = (
|
|
1752
|
+
_bench_defaults(launcher_cfg)
|
|
1753
|
+
)
|
|
1754
|
+
bench_command = req.bench_command or default_template
|
|
1755
|
+
runs = req.runs if req.runs is not None else default_runs
|
|
1756
|
+
results_dir = req.results_dir if req.results_dir is not None else default_results_dir
|
|
1757
|
+
|
|
1758
|
+
# ポート競合: registry の使用中ポート照合 + best-effort な空きチェック。
|
|
1759
|
+
reg = _registry_for_app(app)
|
|
1760
|
+
for p in reg.all():
|
|
1761
|
+
if p.port == req.port and p.status in ("running", "loading", "starting"):
|
|
1762
|
+
raise HTTPException(
|
|
1763
|
+
status_code=400,
|
|
1764
|
+
detail=f"Port {req.port} is in use by process {p.name!r}.",
|
|
1765
|
+
)
|
|
1766
|
+
if not is_port_free(req.port):
|
|
1767
|
+
raise HTTPException(
|
|
1768
|
+
status_code=400, detail=f"Port {req.port} is not free."
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
labeled = [
|
|
1772
|
+
(
|
|
1773
|
+
item.label,
|
|
1774
|
+
DeviceSelection(
|
|
1775
|
+
device_ids=list(item.device_ids),
|
|
1776
|
+
tensor_split=list(item.tensor_split),
|
|
1777
|
+
),
|
|
1778
|
+
)
|
|
1779
|
+
for item in req.configs
|
|
1780
|
+
]
|
|
1781
|
+
steps = build_sweep_steps(labeled)
|
|
1782
|
+
plan = SweepPlan(
|
|
1783
|
+
steps=steps,
|
|
1784
|
+
model_path=req.model_path,
|
|
1785
|
+
backend=req.backend,
|
|
1786
|
+
port=req.port,
|
|
1787
|
+
bench_cmd_template=bench_command,
|
|
1788
|
+
results_dir=results_dir,
|
|
1789
|
+
options=req.options,
|
|
1790
|
+
extra_args=req.extra_args,
|
|
1791
|
+
)
|
|
1792
|
+
runner = _SweepRunner(
|
|
1793
|
+
app, launcher_cfg, plan, runs=runs, readiness_timeout_s=default_readiness
|
|
1794
|
+
)
|
|
1795
|
+
app.state.launcher_sweep = runner
|
|
1796
|
+
runner.start()
|
|
1797
|
+
return {"sweep_id": runner.sweep_id, "steps": [s.as_dict() for s in steps]}
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
@router.get("/api/launcher/sweep/status")
|
|
1801
|
+
async def api_sweep_status(request: Request) -> dict[str, Any]:
|
|
1802
|
+
"""現在(または直近)のスイープ状態を返す。読み取り系 → 認証なし。"""
|
|
1803
|
+
runner = getattr(request.app.state, "launcher_sweep", None)
|
|
1804
|
+
if runner is None:
|
|
1805
|
+
return {
|
|
1806
|
+
"sweep_id": None,
|
|
1807
|
+
"running": False,
|
|
1808
|
+
"current_index": -1,
|
|
1809
|
+
"steps": [],
|
|
1810
|
+
}
|
|
1811
|
+
return runner.status()
|
|
1812
|
+
|
|
1813
|
+
|
|
1814
|
+
@router.post("/api/launcher/sweep/abort")
|
|
1815
|
+
async def api_sweep_abort(request: Request) -> dict[str, Any]:
|
|
1816
|
+
"""進行中スイープに中断要求を出す(設計 §4.4)。書き込み系 → token 必須。"""
|
|
1817
|
+
_require_launcher_token(request)
|
|
1818
|
+
runner = getattr(request.app.state, "launcher_sweep", None)
|
|
1819
|
+
if runner is None:
|
|
1820
|
+
raise HTTPException(status_code=404, detail="No sweep to abort.")
|
|
1821
|
+
runner.abort()
|
|
1822
|
+
return {"aborted": True}
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
@router.get("/api/launcher/sweep/logs")
|
|
1826
|
+
async def api_sweep_logs(request: Request, n: int = 200) -> dict[str, Any]:
|
|
1827
|
+
"""スイープの進行ログ末尾 N 行。読み取り系 → 認証なし。"""
|
|
1828
|
+
runner = getattr(request.app.state, "launcher_sweep", None)
|
|
1829
|
+
if runner is None:
|
|
1830
|
+
return {"logs": [], "total": 0}
|
|
1831
|
+
tail = list(runner.log_tail)
|
|
1832
|
+
return {"logs": tail[-n:], "total": len(tail)}
|
|
1833
|
+
|
|
1834
|
+
|
|
1367
1835
|
# ---------------------------------------------------------------------------
|
|
1368
1836
|
# HTML UI
|
|
1369
1837
|
# ---------------------------------------------------------------------------
|
|
@@ -1378,6 +1846,10 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1378
1846
|
<style>
|
|
1379
1847
|
.dot { width:.5rem;height:.5rem;border-radius:9999px;display:inline-block; }
|
|
1380
1848
|
.tabnum { font-variant-numeric:tabular-nums; }
|
|
1849
|
+
.badge-swap { font-size:10px; text-transform:uppercase; letter-spacing:.02em;
|
|
1850
|
+
background:rgba(99,102,241,.2); color:#a5b4fc; padding:1px 6px;
|
|
1851
|
+
border-radius:9999px; margin-left:6px; vertical-align:middle;
|
|
1852
|
+
white-space:nowrap; }
|
|
1381
1853
|
.log-box { font-family:monospace;font-size:.75rem;line-height:1.4;
|
|
1382
1854
|
overflow-y:auto;max-height:14rem;white-space:pre-wrap;word-break:break-all; }
|
|
1383
1855
|
.model-row:hover { background:rgba(255,255,255,.04);cursor:pointer; }
|
|
@@ -1498,6 +1970,22 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1498
1970
|
<input id="f-extra" type="text" placeholder="-ngl 99 --threads 8" />
|
|
1499
1971
|
</div>
|
|
1500
1972
|
|
|
1973
|
+
<!-- デバイス選択 (llama.cpp のみ) -->
|
|
1974
|
+
<div id="device-block" class="hidden">
|
|
1975
|
+
<div class="flex items-center justify-between mb-1">
|
|
1976
|
+
<label class="block text-xs text-slate-400">デバイス (llama.cpp)</label>
|
|
1977
|
+
<button onclick="fetchDevices(true)" class="btn-sm btn-slate">🔍 検出</button>
|
|
1978
|
+
</div>
|
|
1979
|
+
<div id="device-list" class="text-xs text-slate-400 space-y-1 bg-slate-800/40 rounded p-2">
|
|
1980
|
+
<span class="text-slate-600">「🔍 検出」で --list-devices を実行</span>
|
|
1981
|
+
</div>
|
|
1982
|
+
<div id="tsplit-row" class="mt-2 hidden">
|
|
1983
|
+
<label class="block text-xs text-slate-400 mb-1">tensor-split (複数選択時・自動提案は上書き可)</label>
|
|
1984
|
+
<input id="f-tsplit" type="text" placeholder="0.57,0.43" oninput="markTsplitManual()" />
|
|
1985
|
+
<div id="tsplit-note" class="mt-1 text-xs text-yellow-400 hidden"></div>
|
|
1986
|
+
</div>
|
|
1987
|
+
</div>
|
|
1988
|
+
|
|
1501
1989
|
<button id="btn-launch" onclick="launchProcess()" class="btn-primary w-full mt-1">
|
|
1502
1990
|
▶ 起動
|
|
1503
1991
|
</button>
|
|
@@ -1528,6 +2016,67 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1528
2016
|
</div>
|
|
1529
2017
|
</section>
|
|
1530
2018
|
|
|
2019
|
+
<!-- Row 2.5: Bench sweep -->
|
|
2020
|
+
<section class="bg-slate-900/60 border border-slate-800 rounded-lg p-4">
|
|
2021
|
+
<div class="flex items-center justify-between mb-3">
|
|
2022
|
+
<h2 class="text-sm font-semibold uppercase tracking-wider text-slate-400">📊 Bench Sweep</h2>
|
|
2023
|
+
<div class="flex gap-2">
|
|
2024
|
+
<button id="sweep-start" onclick="startSweep()" class="btn-sm btn-indigo">▶ 開始</button>
|
|
2025
|
+
<button id="sweep-abort" onclick="abortSweep()" class="btn-sm btn-red">■ 中断</button>
|
|
2026
|
+
</div>
|
|
2027
|
+
</div>
|
|
2028
|
+
<p class="text-xs text-slate-500 mb-2">
|
|
2029
|
+
起動フォームの「モデルパス」を使用。各構成を 起動→readiness→ベンチ→停止 で順に回します。
|
|
2030
|
+
構成は「デバイス検出」後に候補が自動生成されます (単一デバイス環境では tensor-split 構成は出ません)。
|
|
2031
|
+
</p>
|
|
2032
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
2033
|
+
<div>
|
|
2034
|
+
<label class="block text-xs text-slate-400 mb-1">構成候補</label>
|
|
2035
|
+
<div id="sweep-configs" class="text-xs text-slate-400 space-y-1 bg-slate-800/40 rounded p-2 max-h-40 overflow-y-auto">
|
|
2036
|
+
<span class="text-slate-600">先に「🔍 検出」でデバイスを取得してください</span>
|
|
2037
|
+
</div>
|
|
2038
|
+
</div>
|
|
2039
|
+
<div class="space-y-2">
|
|
2040
|
+
<div>
|
|
2041
|
+
<label class="block text-xs text-slate-400 mb-1">ポート (スイープ専用・構成間で共用)</label>
|
|
2042
|
+
<input id="sweep-port" type="number" value="8090" min="1024" max="65535" />
|
|
2043
|
+
</div>
|
|
2044
|
+
<div>
|
|
2045
|
+
<label class="block text-xs text-slate-400 mb-1">ベンチコマンド ({port} {config} {base_url} {results_dir} {runs} を置換)</label>
|
|
2046
|
+
<input id="sweep-cmd" type="text" placeholder="llmbench run --model local-openai --runs {runs}" />
|
|
2047
|
+
</div>
|
|
2048
|
+
<div class="grid grid-cols-2 gap-2">
|
|
2049
|
+
<div>
|
|
2050
|
+
<label class="block text-xs text-slate-400 mb-1">runs</label>
|
|
2051
|
+
<input id="sweep-runs" type="number" value="5" min="1" max="1000" />
|
|
2052
|
+
</div>
|
|
2053
|
+
<div>
|
|
2054
|
+
<label class="block text-xs text-slate-400 mb-1">results_dir (任意)</label>
|
|
2055
|
+
<input id="sweep-results" type="text" placeholder="results/" />
|
|
2056
|
+
</div>
|
|
2057
|
+
</div>
|
|
2058
|
+
</div>
|
|
2059
|
+
</div>
|
|
2060
|
+
<div id="sweep-err" class="text-xs text-red-400 mt-2 hidden"></div>
|
|
2061
|
+
<div class="overflow-x-auto mt-3">
|
|
2062
|
+
<table class="w-full text-sm tabnum">
|
|
2063
|
+
<thead class="text-slate-500 text-left">
|
|
2064
|
+
<tr>
|
|
2065
|
+
<th class="pb-2 font-medium">CONFIG</th>
|
|
2066
|
+
<th class="pb-2 font-medium">DEVICES</th>
|
|
2067
|
+
<th class="pb-2 font-medium">STATE</th>
|
|
2068
|
+
<th class="pb-2 font-medium text-right">EXIT</th>
|
|
2069
|
+
<th class="pb-2 font-medium text-right">tok/s</th>
|
|
2070
|
+
<th class="pb-2 font-medium text-right">ttft(ms)</th>
|
|
2071
|
+
</tr>
|
|
2072
|
+
</thead>
|
|
2073
|
+
<tbody id="sweep-table" class="divide-y divide-slate-800">
|
|
2074
|
+
<tr><td colspan="6" class="py-3 text-slate-500 text-xs">スイープ未実行</td></tr>
|
|
2075
|
+
</tbody>
|
|
2076
|
+
</table>
|
|
2077
|
+
</div>
|
|
2078
|
+
</section>
|
|
2079
|
+
|
|
1531
2080
|
<!-- Row 3: Log viewer (hidden until a process is selected) -->
|
|
1532
2081
|
<section id="log-panel" class="bg-slate-900/60 border border-slate-800 rounded-lg p-4 hidden">
|
|
1533
2082
|
<div class="flex items-center justify-between mb-2">
|
|
@@ -1773,6 +2322,7 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1773
2322
|
window.onBackendChange = () => {
|
|
1774
2323
|
populateProfileSelect();
|
|
1775
2324
|
renderBinaryHint();
|
|
2325
|
+
updateDeviceBlock();
|
|
1776
2326
|
};
|
|
1777
2327
|
|
|
1778
2328
|
// renderProfileArgs は下で const 宣言されるため、宣言前参照(TDZ)を避けて
|
|
@@ -1803,6 +2353,263 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1803
2353
|
return p ? p.args : {};
|
|
1804
2354
|
};
|
|
1805
2355
|
|
|
2356
|
+
// ── Devices (llama.cpp) ────────────────────────────────────────────────────
|
|
2357
|
+
|
|
2358
|
+
let _devices = []; // [{id,name,total_mib,free_mib,total_gb,free_gb}]
|
|
2359
|
+
let _deviceSel = {}; // id -> checked
|
|
2360
|
+
let _deviceOk = false;
|
|
2361
|
+
let _autoConfigs = []; // サーバ生成のスイープ構成候補
|
|
2362
|
+
|
|
2363
|
+
const isLlama = () => document.getElementById("f-backend").value === "llama.cpp";
|
|
2364
|
+
|
|
2365
|
+
// デバイス id → バックエンド接頭辞(末尾数字を除く)。launcher_devices の
|
|
2366
|
+
// backend_of と同じ規則。"CUDA0"→"CUDA" / "Vulkan2"→"Vulkan" / "MTL0"→"MTL"。
|
|
2367
|
+
const backendOf = (id) => id.replace(/\d+$/, "");
|
|
2368
|
+
|
|
2369
|
+
const updateDeviceBlock = () => {
|
|
2370
|
+
const blk = document.getElementById("device-block");
|
|
2371
|
+
if (isLlama()) blk.classList.remove("hidden");
|
|
2372
|
+
else blk.classList.add("hidden");
|
|
2373
|
+
};
|
|
2374
|
+
|
|
2375
|
+
// VRAM (total) 比の tensor-split を末尾で辻褄合わせして 1.0 にする。
|
|
2376
|
+
const splitByTotal = (devs) => {
|
|
2377
|
+
const total = devs.reduce((a, d) => a + d.total_mib, 0);
|
|
2378
|
+
if (!(total > 0) || devs.length < 2) return [];
|
|
2379
|
+
const s = devs.map(d => Math.round((d.total_mib / total) * 100) / 100);
|
|
2380
|
+
const head = s.slice(0, -1).reduce((a, b) => a + b, 0);
|
|
2381
|
+
s[s.length - 1] = Math.round((1 - head) * 100) / 100;
|
|
2382
|
+
return s;
|
|
2383
|
+
};
|
|
2384
|
+
|
|
2385
|
+
window.fetchDevices = async (force) => {
|
|
2386
|
+
if (!isLlama()) { statusMsg("デバイス選択は llama.cpp のみ"); return; }
|
|
2387
|
+
const backend = document.getElementById("f-backend").value;
|
|
2388
|
+
const url = "/api/launcher/devices?backend=" + encodeURIComponent(backend)
|
|
2389
|
+
+ (force ? "&refresh=1" : "");
|
|
2390
|
+
const box = document.getElementById("device-list");
|
|
2391
|
+
box.innerHTML = '<span class="text-slate-600">検出中…</span>';
|
|
2392
|
+
try {
|
|
2393
|
+
const r = await fetch(url);
|
|
2394
|
+
const d = await r.json();
|
|
2395
|
+
_deviceOk = !!d.ok;
|
|
2396
|
+
_devices = d.devices || [];
|
|
2397
|
+
_autoConfigs = d.auto_configs || [];
|
|
2398
|
+
_deviceSel = {};
|
|
2399
|
+
renderDevices(d);
|
|
2400
|
+
buildSweepConfigs();
|
|
2401
|
+
} catch (e) {
|
|
2402
|
+
box.innerHTML = '<span class="text-red-400">検出失敗: ' + esc(e.message) + '</span>';
|
|
2403
|
+
}
|
|
2404
|
+
};
|
|
2405
|
+
|
|
2406
|
+
const renderDevices = (d) => {
|
|
2407
|
+
const box = document.getElementById("device-list");
|
|
2408
|
+
if (!d.ok) {
|
|
2409
|
+
// 検出失敗 → カンマ区切り手入力へフォールバック
|
|
2410
|
+
box.innerHTML =
|
|
2411
|
+
'<div class="text-yellow-400 mb-1">検出失敗: ' + esc(d.error || "不明")
|
|
2412
|
+
+ ' — 手入力してください</div>'
|
|
2413
|
+
+ '<input id="device-fallback" type="text" placeholder="CUDA0,CUDA1 (カンマ区切り)" />';
|
|
2414
|
+
document.getElementById("tsplit-row").classList.add("hidden");
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
if (!_devices.length) {
|
|
2418
|
+
box.innerHTML = '<span class="text-slate-600">デバイスなし</span>';
|
|
2419
|
+
document.getElementById("tsplit-row").classList.add("hidden");
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
// total_mib==0 (BLAS: Accelerate 等) は GPU オフロード先にできないので
|
|
2423
|
+
// チェックボックスを無効化(表示は残す)。
|
|
2424
|
+
box.innerHTML = _devices.map(dev => {
|
|
2425
|
+
const disabled = !(dev.total_mib > 0);
|
|
2426
|
+
return `<label class="flex items-center gap-2 ${disabled ? "opacity-50" : "cursor-pointer"}">
|
|
2427
|
+
<input type="checkbox" style="width:auto" data-devid="${esc(dev.id)}" ${disabled ? "disabled" : ""} onchange="onDeviceToggle()" />
|
|
2428
|
+
<span class="font-mono text-slate-300">${esc(dev.id)}</span>
|
|
2429
|
+
<span class="text-slate-500 truncate">${esc(dev.name)}</span>
|
|
2430
|
+
<span class="text-slate-500 tabnum ml-auto shrink-0">${disabled ? "— (0 MiB)" : dev.free_gb + "/" + dev.total_gb + " GB"}</span>
|
|
2431
|
+
</label>`;
|
|
2432
|
+
}).join("");
|
|
2433
|
+
updateTsplitVisibility();
|
|
2434
|
+
};
|
|
2435
|
+
|
|
2436
|
+
window.onDeviceToggle = () => {
|
|
2437
|
+
_deviceSel = {};
|
|
2438
|
+
document.querySelectorAll('#device-list input[data-devid]').forEach(cb => {
|
|
2439
|
+
_deviceSel[cb.getAttribute("data-devid")] = cb.checked;
|
|
2440
|
+
});
|
|
2441
|
+
updateTsplitVisibility();
|
|
2442
|
+
};
|
|
2443
|
+
|
|
2444
|
+
const selectedDeviceIds = () => {
|
|
2445
|
+
if (_deviceOk) return _devices.map(d => d.id).filter(id => _deviceSel[id]);
|
|
2446
|
+
const fb = document.getElementById("device-fallback");
|
|
2447
|
+
if (!fb) return [];
|
|
2448
|
+
return fb.value.split(",").map(s => s.trim()).filter(Boolean);
|
|
2449
|
+
};
|
|
2450
|
+
|
|
2451
|
+
// 単一デバイス環境 (総数<=1) or 選択<=1 では tensor-split 欄を隠す (§8.5)。
|
|
2452
|
+
// tensor-split の自動提案はバックエンド単位。跨バックエンド選択時は提案せず
|
|
2453
|
+
// その旨を表示する(手入力は可)。
|
|
2454
|
+
const updateTsplitVisibility = () => {
|
|
2455
|
+
const ids = selectedDeviceIds();
|
|
2456
|
+
const row = document.getElementById("tsplit-row");
|
|
2457
|
+
const note = document.getElementById("tsplit-note");
|
|
2458
|
+
if (_devices.length <= 1 || ids.length <= 1) { row.classList.add("hidden"); return; }
|
|
2459
|
+
row.classList.remove("hidden");
|
|
2460
|
+
const el = document.getElementById("f-tsplit");
|
|
2461
|
+
const backends = new Set(ids.map(backendOf));
|
|
2462
|
+
if (backends.size > 1) {
|
|
2463
|
+
// 跨バックエンド → 自動提案しない(自動値は消す。手入力は保持)。
|
|
2464
|
+
note.textContent = "バックエンド跨ぎのため tensor-split 自動提案なし(手入力可)";
|
|
2465
|
+
note.classList.remove("hidden");
|
|
2466
|
+
if (el.dataset.auto === "1") { el.value = ""; }
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
note.classList.add("hidden");
|
|
2470
|
+
// 同一バックエンド。ユーザーが手入力していれば尊重 (dataset.auto で判別)。
|
|
2471
|
+
if (!el.value.trim() || el.dataset.auto === "1") {
|
|
2472
|
+
const sel = _devices.filter(d => _deviceSel[d.id]);
|
|
2473
|
+
el.value = splitByTotal(sel).join(",");
|
|
2474
|
+
el.dataset.auto = "1";
|
|
2475
|
+
}
|
|
2476
|
+
};
|
|
2477
|
+
|
|
2478
|
+
window.markTsplitManual = () => {
|
|
2479
|
+
document.getElementById("f-tsplit").dataset.auto = "0";
|
|
2480
|
+
};
|
|
2481
|
+
|
|
2482
|
+
// ── Bench sweep ────────────────────────────────────────────────────────────
|
|
2483
|
+
|
|
2484
|
+
let _sweepConfigs = []; // [{label, device_ids, tensor_split, checked}]
|
|
2485
|
+
let _sweepPollTimer = null;
|
|
2486
|
+
|
|
2487
|
+
// スイープ構成候補はサーバの auto_configs(build_auto_sweep_configs)を使う。
|
|
2488
|
+
// 0 MiB 除外・バックエンド単位のグループ化・跨バックエンド混成の除外は
|
|
2489
|
+
// すべてサーバ側ロジックに一本化されている。
|
|
2490
|
+
const buildSweepConfigs = () => {
|
|
2491
|
+
_sweepConfigs = (_autoConfigs || []).map(c => ({
|
|
2492
|
+
label: c.label,
|
|
2493
|
+
device_ids: c.device_ids || [],
|
|
2494
|
+
tensor_split: c.tensor_split || [],
|
|
2495
|
+
checked: true,
|
|
2496
|
+
}));
|
|
2497
|
+
renderSweepConfigs();
|
|
2498
|
+
};
|
|
2499
|
+
|
|
2500
|
+
const renderSweepConfigs = () => {
|
|
2501
|
+
const box = document.getElementById("sweep-configs");
|
|
2502
|
+
if (!_sweepConfigs.length) {
|
|
2503
|
+
box.innerHTML = '<span class="text-slate-600">先に「🔍 検出」でデバイスを取得してください</span>';
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
box.innerHTML = _sweepConfigs.map((c, i) =>
|
|
2507
|
+
`<label class="flex items-center gap-2 cursor-pointer">
|
|
2508
|
+
<input type="checkbox" style="width:auto" data-cfg="${i}" ${c.checked ? "checked" : ""} onchange="onSweepCfgToggle()" />
|
|
2509
|
+
<span class="text-slate-300">${esc(c.label)}</span>
|
|
2510
|
+
<span class="text-slate-500 font-mono ml-auto">${esc(c.device_ids.join(","))}${c.tensor_split.length ? " · " + c.tensor_split.join(",") : ""}</span>
|
|
2511
|
+
</label>`
|
|
2512
|
+
).join("");
|
|
2513
|
+
};
|
|
2514
|
+
|
|
2515
|
+
window.onSweepCfgToggle = () => {
|
|
2516
|
+
document.querySelectorAll('#sweep-configs input[data-cfg]').forEach(cb => {
|
|
2517
|
+
_sweepConfigs[parseInt(cb.getAttribute("data-cfg"))].checked = cb.checked;
|
|
2518
|
+
});
|
|
2519
|
+
};
|
|
2520
|
+
|
|
2521
|
+
const showSweepErr = (msg) => {
|
|
2522
|
+
const el = document.getElementById("sweep-err");
|
|
2523
|
+
if (msg) { el.textContent = msg; el.classList.remove("hidden"); }
|
|
2524
|
+
else { el.textContent = ""; el.classList.add("hidden"); }
|
|
2525
|
+
};
|
|
2526
|
+
|
|
2527
|
+
window.startSweep = async () => {
|
|
2528
|
+
showSweepErr("");
|
|
2529
|
+
const model = document.getElementById("f-model").value.trim();
|
|
2530
|
+
if (!model) { showSweepErr("起動フォームでモデルパスを選択してください"); return; }
|
|
2531
|
+
const configs = _sweepConfigs.filter(c => c.checked)
|
|
2532
|
+
.map(c => ({label: c.label, device_ids: c.device_ids, tensor_split: c.tensor_split}));
|
|
2533
|
+
if (!configs.length) { showSweepErr("構成を 1 つ以上選択してください"); return; }
|
|
2534
|
+
const port = parseInt(document.getElementById("sweep-port").value);
|
|
2535
|
+
const cmd = document.getElementById("sweep-cmd").value.trim();
|
|
2536
|
+
const runs = parseInt(document.getElementById("sweep-runs").value);
|
|
2537
|
+
const results = document.getElementById("sweep-results").value.trim();
|
|
2538
|
+
const body = {
|
|
2539
|
+
backend: "llama.cpp", model_path: model, port, configs,
|
|
2540
|
+
bench_command: cmd || null,
|
|
2541
|
+
runs: isNaN(runs) ? null : runs,
|
|
2542
|
+
results_dir: results || null,
|
|
2543
|
+
};
|
|
2544
|
+
try {
|
|
2545
|
+
const r = await fetch("/api/launcher/sweep/start", {
|
|
2546
|
+
method: "POST", headers: authHeaders({"Content-Type": "application/json"}),
|
|
2547
|
+
body: JSON.stringify(body),
|
|
2548
|
+
});
|
|
2549
|
+
const d = await r.json();
|
|
2550
|
+
if (!r.ok) { showSweepErr(d.detail || "スイープ開始失敗"); return; }
|
|
2551
|
+
statusMsg("スイープ開始: " + d.sweep_id);
|
|
2552
|
+
startSweepPolling();
|
|
2553
|
+
} catch (e) { showSweepErr(e.message); }
|
|
2554
|
+
};
|
|
2555
|
+
|
|
2556
|
+
window.abortSweep = async () => {
|
|
2557
|
+
try {
|
|
2558
|
+
const r = await fetch("/api/launcher/sweep/abort", {method: "POST", headers: authHeaders()});
|
|
2559
|
+
if (r.ok) statusMsg("スイープ中断要求"); else statusMsg("中断対象なし", false);
|
|
2560
|
+
} catch (e) { showSweepErr(e.message); }
|
|
2561
|
+
};
|
|
2562
|
+
|
|
2563
|
+
const startSweepPolling = () => {
|
|
2564
|
+
if (_sweepPollTimer) return;
|
|
2565
|
+
_sweepPollTimer = setInterval(pollSweep, POLL_MS);
|
|
2566
|
+
pollSweep();
|
|
2567
|
+
};
|
|
2568
|
+
|
|
2569
|
+
const pollSweep = async () => {
|
|
2570
|
+
try {
|
|
2571
|
+
const r = await fetch("/api/launcher/sweep/status");
|
|
2572
|
+
const d = await r.json();
|
|
2573
|
+
renderSweepSteps(d.steps || []);
|
|
2574
|
+
if (d.running && !_sweepPollTimer) {
|
|
2575
|
+
// リロードでスイープ進行中を検知 → ポーリング再開。
|
|
2576
|
+
_sweepPollTimer = setInterval(pollSweep, POLL_MS);
|
|
2577
|
+
} else if (!d.running && _sweepPollTimer) {
|
|
2578
|
+
clearInterval(_sweepPollTimer); _sweepPollTimer = null;
|
|
2579
|
+
}
|
|
2580
|
+
} catch (_) {}
|
|
2581
|
+
};
|
|
2582
|
+
|
|
2583
|
+
const sweepStateColor = (s) => ({
|
|
2584
|
+
pending: "text-slate-500", starting: "text-yellow-400",
|
|
2585
|
+
benching: "text-indigo-300", done: "text-green-400",
|
|
2586
|
+
failed: "text-red-400", aborted: "text-slate-400"
|
|
2587
|
+
}[s] || "text-slate-400");
|
|
2588
|
+
|
|
2589
|
+
const renderSweepSteps = (steps) => {
|
|
2590
|
+
const tb = document.getElementById("sweep-table");
|
|
2591
|
+
if (!steps.length) {
|
|
2592
|
+
tb.innerHTML = '<tr><td colspan="6" class="py-3 text-slate-500 text-xs">スイープ未実行</td></tr>';
|
|
2593
|
+
return;
|
|
2594
|
+
}
|
|
2595
|
+
tb.innerHTML = steps.map(s => {
|
|
2596
|
+
const sm = s.summary || {};
|
|
2597
|
+
const tok = sm.tokens_per_sec != null ? sm.tokens_per_sec : "—";
|
|
2598
|
+
const ttft = sm.ttft_ms != null ? sm.ttft_ms : "—";
|
|
2599
|
+
const dev = (s.device_ids || []).join(",")
|
|
2600
|
+
+ ((s.tensor_split || []).length ? " · " + s.tensor_split.join(",") : "");
|
|
2601
|
+
const exit = s.bench_exit_code != null ? s.bench_exit_code : "—";
|
|
2602
|
+
return `<tr>
|
|
2603
|
+
<td class="py-2 pr-3">${esc(s.label)}</td>
|
|
2604
|
+
<td class="py-2 pr-3 font-mono text-slate-400">${esc(dev)}</td>
|
|
2605
|
+
<td class="py-2 pr-3 ${sweepStateColor(s.state)}">${esc(s.state)}</td>
|
|
2606
|
+
<td class="py-2 pr-3 text-right">${esc(String(exit))}</td>
|
|
2607
|
+
<td class="py-2 pr-3 text-right">${esc(String(tok))}</td>
|
|
2608
|
+
<td class="py-2 pr-3 text-right">${esc(String(ttft))}</td>
|
|
2609
|
+
</tr>`;
|
|
2610
|
+
}).join("");
|
|
2611
|
+
};
|
|
2612
|
+
|
|
1806
2613
|
// ── Launch ───────────────────────────────────────────────────────────────
|
|
1807
2614
|
|
|
1808
2615
|
window.launchProcess = async () => {
|
|
@@ -1819,6 +2626,16 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1819
2626
|
if (!model) { showLaunchErr("モデルパスを入力してください"); return; }
|
|
1820
2627
|
if (!port || port < 1024 || port > 65535) { showLaunchErr("ポートは 1024-65535"); return; }
|
|
1821
2628
|
|
|
2629
|
+
// デバイス選択 (llama.cpp のみ)。未選択なら空 → サーバ側で --device 不付与。
|
|
2630
|
+
let deviceIds = [], tensorSplit = [];
|
|
2631
|
+
if (backend === "llama.cpp") {
|
|
2632
|
+
deviceIds = selectedDeviceIds();
|
|
2633
|
+
const ts = document.getElementById("f-tsplit").value.trim();
|
|
2634
|
+
if (ts && deviceIds.length > 1) {
|
|
2635
|
+
tensorSplit = ts.split(",").map(x => parseFloat(x)).filter(x => !isNaN(x));
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
|
|
1822
2639
|
const btn = document.getElementById("btn-launch");
|
|
1823
2640
|
btn.disabled = true;
|
|
1824
2641
|
btn.textContent = "起動中…";
|
|
@@ -1829,7 +2646,8 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1829
2646
|
headers: authHeaders({"Content-Type": "application/json"}),
|
|
1830
2647
|
body: JSON.stringify({name, backend, model_path: model, port,
|
|
1831
2648
|
options: selectedProfileArgs(), extra_args: extra,
|
|
1832
|
-
draft_model_path: draft || null, mtp_mode: mtp
|
|
2649
|
+
draft_model_path: draft || null, mtp_mode: mtp,
|
|
2650
|
+
device_ids: deviceIds, tensor_split: tensorSplit}),
|
|
1833
2651
|
});
|
|
1834
2652
|
const d = await res.json();
|
|
1835
2653
|
if (!res.ok) { showLaunchErr(d.detail || "起動失敗"); return; }
|
|
@@ -1851,7 +2669,17 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1851
2669
|
try {
|
|
1852
2670
|
const r = await fetch("/api/launcher/processes");
|
|
1853
2671
|
const d = await r.json();
|
|
1854
|
-
|
|
2672
|
+
const procs = d.processes || [];
|
|
2673
|
+
renderProcesses(procs);
|
|
2674
|
+
// [Unreleased] 404-loop fix: if the process whose logs we're
|
|
2675
|
+
// polling is no longer in the registry (deleted from another
|
|
2676
|
+
// client, swap TTL unload removed it, or the server restarted
|
|
2677
|
+
// under a still-open tab), stop the log polling BEFORE it even
|
|
2678
|
+
// issues a 404 — otherwise poll() would hit
|
|
2679
|
+
// /api/launcher/logs/<stale-id> every POLL_MS forever.
|
|
2680
|
+
if (selectedLogId && !procs.some(p => p.id === selectedLogId)) {
|
|
2681
|
+
markLogGone();
|
|
2682
|
+
}
|
|
1855
2683
|
} catch (_) {}
|
|
1856
2684
|
};
|
|
1857
2685
|
|
|
@@ -1871,8 +2699,14 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1871
2699
|
? `<button onclick="deleteProc('${p.id}')" class="btn-sm btn-slate ml-1">✕</button>`
|
|
1872
2700
|
: "";
|
|
1873
2701
|
const logBtn = `<button onclick="openLog('${p.id}','${esc(p.name)}')" class="btn-sm btn-indigo ml-1">📋 ログ</button>`;
|
|
2702
|
+
// [Unreleased]: swap badge — marks processes SwapManager spawned
|
|
2703
|
+
// on demand, distinct from manually-started ones. Title shows the
|
|
2704
|
+
// catalog model name when the backend supplied it.
|
|
2705
|
+
const swapBadge = p.swap_managed
|
|
2706
|
+
? `<span class="badge-swap" title="${esc(p.swap_model ? "swap model: " + p.swap_model : "swap-managed")}">swap</span>`
|
|
2707
|
+
: "";
|
|
1874
2708
|
return `<tr>
|
|
1875
|
-
<td class="py-2 pr-3 font-medium">${esc(p.name)}</td>
|
|
2709
|
+
<td class="py-2 pr-3 font-medium">${esc(p.name)}${swapBadge}</td>
|
|
1876
2710
|
<td class="py-2 pr-3 text-slate-400">${esc(p.backend)}</td>
|
|
1877
2711
|
<td class="py-2 pr-3 text-slate-400 truncate max-w-[10rem]" title="${esc(p.model_path)}">${esc(modelName)}</td>
|
|
1878
2712
|
<td class="py-2 pr-3 text-right">${p.port}</td>
|
|
@@ -1912,6 +2746,11 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1912
2746
|
if (!selectedLogId) return;
|
|
1913
2747
|
try {
|
|
1914
2748
|
const r = await fetch(`/api/launcher/logs/${selectedLogId}?n=200`);
|
|
2749
|
+
// [Unreleased] 404-loop fix: the id is gone from the registry —
|
|
2750
|
+
// stop polling instead of retrying (and 404-spamming the serve
|
|
2751
|
+
// log) every POLL_MS forever. The server keeps answering 404 for
|
|
2752
|
+
// unknown ids (correct); the client just has to take the hint.
|
|
2753
|
+
if (r.status === 404) { markLogGone(); return; }
|
|
1915
2754
|
const d = await r.json();
|
|
1916
2755
|
const box = document.getElementById("log-box");
|
|
1917
2756
|
box.textContent = d.logs.join("\n") || "(ログなし)";
|
|
@@ -1921,6 +2760,15 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1921
2760
|
}
|
|
1922
2761
|
};
|
|
1923
2762
|
|
|
2763
|
+
// [Unreleased] 404-loop fix: the process we were tailing no longer
|
|
2764
|
+
// exists server-side. Clear selectedLogId (which is what stops both
|
|
2765
|
+
// poll()'s refreshLogs call and any manual ↻) but keep the panel open
|
|
2766
|
+
// with a terminal message so the user sees WHY the logs stopped.
|
|
2767
|
+
const markLogGone = () => {
|
|
2768
|
+
selectedLogId = null;
|
|
2769
|
+
document.getElementById("log-box").textContent = "(process removed)";
|
|
2770
|
+
};
|
|
2771
|
+
|
|
1924
2772
|
window.closeLog = () => {
|
|
1925
2773
|
selectedLogId = null;
|
|
1926
2774
|
document.getElementById("log-panel").classList.add("hidden");
|
|
@@ -1929,7 +2777,9 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1929
2777
|
// ── Init + polling ───────────────────────────────────────────────────────
|
|
1930
2778
|
|
|
1931
2779
|
const init = async () => {
|
|
2780
|
+
updateDeviceBlock();
|
|
1932
2781
|
await Promise.all([fetchModels(), fetchProfiles(), fetchBackends(), fetchProcesses()]);
|
|
2782
|
+
pollSweep(); // 既存スイープがあれば表示 (running なら以後のポーリングを継続)
|
|
1933
2783
|
};
|
|
1934
2784
|
|
|
1935
2785
|
const poll = async () => {
|