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.
@@ -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
- renderProcesses(d.processes || []);
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 () => {