coderouter-cli 2.9.3__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
 
@@ -512,6 +527,7 @@ def _build_cmd(
512
527
  extra_args: str,
513
528
  binary: str | None = None,
514
529
  spec_tokens: list[str] | None = None,
530
+ device_args: list[str] | None = None,
515
531
  ) -> list[str]:
516
532
  """Build the CLI command list for the given backend and options.
517
533
 
@@ -525,11 +541,22 @@ def _build_cmd(
525
541
  the profile / extra args. They are trusted (the launcher, not the
526
542
  caller, produced them) so they bypass the model-override guard even
527
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 非対応)。
528
553
  """
529
554
  exe = _resolve_binary(backend, binary)
530
555
 
531
556
  if backend == "llama.cpp":
532
557
  cmd: list[str] = [exe, "-m", model_path, "--port", str(port)]
558
+ if device_args:
559
+ cmd.extend(device_args)
533
560
  if spec_tokens:
534
561
  cmd.extend(spec_tokens)
535
562
  elif backend == "vllm":
@@ -972,6 +999,11 @@ class StartRequest(BaseModel):
972
999
  # or "off" (never emit speculative flags).
973
1000
  draft_model_path: str | None = None
974
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)
975
1007
 
976
1008
 
977
1009
  # ---------------------------------------------------------------------------
@@ -1116,6 +1148,7 @@ async def spawn_process(
1116
1148
  mtp_mode: str = "auto",
1117
1149
  swap_managed: bool = False,
1118
1150
  swap_model: str | None = None,
1151
+ device_args: list[str] | None = None,
1119
1152
  ) -> ManagedProcess:
1120
1153
  """Build argv, spawn the child process, and arm readiness/log tailing.
1121
1154
 
@@ -1164,6 +1197,7 @@ async def spawn_process(
1164
1197
  cmd = _build_cmd(
1165
1198
  backend, model_path, port, options, extra_args,
1166
1199
  binary=configured_binary, spec_tokens=spec_tokens,
1200
+ device_args=device_args,
1167
1201
  )
1168
1202
 
1169
1203
  # Speculative flags qualify for the one-shot startup-crash fallback only
@@ -1178,6 +1212,7 @@ async def spawn_process(
1178
1212
  fallback_cmd = _build_cmd(
1179
1213
  backend, model_path, port, options, extra_args,
1180
1214
  binary=configured_binary, spec_tokens=None,
1215
+ device_args=device_args,
1181
1216
  )
1182
1217
  except ValueError:
1183
1218
  # A failure to rebuild the non-spec command simply disables the
@@ -1288,12 +1323,20 @@ async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
1288
1323
  _require_launcher_token(request)
1289
1324
  cfg = request.app.state.config
1290
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()
1291
1333
  try:
1292
1334
  proc = await spawn_process(
1293
1335
  request.app, launcher_cfg,
1294
1336
  name=req.name, backend=req.backend, model_path=req.model_path,
1295
1337
  port=req.port, options=req.options, extra_args=req.extra_args,
1296
1338
  draft_model_path=req.draft_model_path, mtp_mode=req.mtp_mode,
1339
+ device_args=device_args,
1297
1340
  )
1298
1341
  except ValueError as exc:
1299
1342
  raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -1387,6 +1430,408 @@ async def api_suggest(request: Request, model_path: str = "",
1387
1430
  }
1388
1431
 
1389
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
+
1390
1835
  # ---------------------------------------------------------------------------
1391
1836
  # HTML UI
1392
1837
  # ---------------------------------------------------------------------------
@@ -1525,6 +1970,22 @@ _LAUNCHER_HTML = r"""<!doctype html>
1525
1970
  <input id="f-extra" type="text" placeholder="-ngl 99 --threads 8" />
1526
1971
  </div>
1527
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
+
1528
1989
  <button id="btn-launch" onclick="launchProcess()" class="btn-primary w-full mt-1">
1529
1990
  ▶ 起動
1530
1991
  </button>
@@ -1555,6 +2016,67 @@ _LAUNCHER_HTML = r"""<!doctype html>
1555
2016
  </div>
1556
2017
  </section>
1557
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
+
1558
2080
  <!-- Row 3: Log viewer (hidden until a process is selected) -->
1559
2081
  <section id="log-panel" class="bg-slate-900/60 border border-slate-800 rounded-lg p-4 hidden">
1560
2082
  <div class="flex items-center justify-between mb-2">
@@ -1800,6 +2322,7 @@ _LAUNCHER_HTML = r"""<!doctype html>
1800
2322
  window.onBackendChange = () => {
1801
2323
  populateProfileSelect();
1802
2324
  renderBinaryHint();
2325
+ updateDeviceBlock();
1803
2326
  };
1804
2327
 
1805
2328
  // renderProfileArgs は下で const 宣言されるため、宣言前参照(TDZ)を避けて
@@ -1830,6 +2353,263 @@ _LAUNCHER_HTML = r"""<!doctype html>
1830
2353
  return p ? p.args : {};
1831
2354
  };
1832
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
+
1833
2613
  // ── Launch ───────────────────────────────────────────────────────────────
1834
2614
 
1835
2615
  window.launchProcess = async () => {
@@ -1846,6 +2626,16 @@ _LAUNCHER_HTML = r"""<!doctype html>
1846
2626
  if (!model) { showLaunchErr("モデルパスを入力してください"); return; }
1847
2627
  if (!port || port < 1024 || port > 65535) { showLaunchErr("ポートは 1024-65535"); return; }
1848
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
+
1849
2639
  const btn = document.getElementById("btn-launch");
1850
2640
  btn.disabled = true;
1851
2641
  btn.textContent = "起動中…";
@@ -1856,7 +2646,8 @@ _LAUNCHER_HTML = r"""<!doctype html>
1856
2646
  headers: authHeaders({"Content-Type": "application/json"}),
1857
2647
  body: JSON.stringify({name, backend, model_path: model, port,
1858
2648
  options: selectedProfileArgs(), extra_args: extra,
1859
- draft_model_path: draft || null, mtp_mode: mtp}),
2649
+ draft_model_path: draft || null, mtp_mode: mtp,
2650
+ device_ids: deviceIds, tensor_split: tensorSplit}),
1860
2651
  });
1861
2652
  const d = await res.json();
1862
2653
  if (!res.ok) { showLaunchErr(d.detail || "起動失敗"); return; }
@@ -1986,7 +2777,9 @@ _LAUNCHER_HTML = r"""<!doctype html>
1986
2777
  // ── Init + polling ───────────────────────────────────────────────────────
1987
2778
 
1988
2779
  const init = async () => {
2780
+ updateDeviceBlock();
1989
2781
  await Promise.all([fetchModels(), fetchProfiles(), fetchBackends(), fetchProcesses()]);
2782
+ pollSweep(); // 既存スイープがあれば表示 (running なら以後のポーリングを継続)
1990
2783
  };
1991
2784
 
1992
2785
  const poll = async () => {