coderouter-cli 2.10.0__py3-none-any.whl → 2.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,14 @@ import re
17
17
  import warnings
18
18
  from typing import Any, Literal, Self
19
19
 
20
- from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator
20
+ from pydantic import (
21
+ BaseModel,
22
+ ConfigDict,
23
+ Field,
24
+ HttpUrl,
25
+ field_validator,
26
+ model_validator,
27
+ )
21
28
 
22
29
 
23
30
  class Capabilities(BaseModel):
@@ -1414,6 +1421,29 @@ class LauncherBackendConfig(BaseModel):
1414
1421
  binary: ~/llama.cpp/build/bin/llama-server
1415
1422
  vllm:
1416
1423
  binary: ~/.venv/bin/python
1424
+
1425
+ **Backend variants** (v2.11.0+, docs/designs/launcher-multi-build.md):
1426
+ a key may also be ``<base>-<variant>`` to register an additional build of
1427
+ the same backend — typically llama.cpp compiled for a specific GPU
1428
+ runtime. Each variant appears as its own entry in the Launcher's backend
1429
+ select, gets its own ``--list-devices`` probe (device IDs differ per
1430
+ build: ``CUDA0`` and ``Vulkan0`` are not the same GPU) and can carry its
1431
+ own ``option_profiles``. ``binary`` is **required** for a variant::
1432
+
1433
+ backends:
1434
+ llama.cpp: # 素のビルド (binary 省略可 = PATH)
1435
+ binary: ~/llm/apps/llama.cpp/build/bin/llama-server
1436
+ llama.cpp-cuda:
1437
+ binary: ~/llm/apps/llama.cpp/build-cuda/bin/llama-server
1438
+ llama.cpp-vulkan:
1439
+ binary: ~/llm/apps/llama.cpp/build-vulkan/bin/llama-server
1440
+ llama.cpp-rocm:
1441
+ binary: ~/llm/apps/llama.cpp/build-rocm/bin/llama-server
1442
+
1443
+ Variants are an advanced option: the matching GPU runtime (CUDA Toolkit /
1444
+ Vulkan runtime + ICD / ROCm) must already be installed by the operator.
1445
+ CodeRouter never selects a variant implicitly — omit them and both the UI
1446
+ and the resulting argv are identical to before.
1417
1447
  """
1418
1448
 
1419
1449
  model_config = ConfigDict(extra="forbid")
@@ -1544,9 +1574,39 @@ class SwapModelSpec(BaseModel):
1544
1574
  "only widens *catalog* matching for ``SwapManager.match``."
1545
1575
  ),
1546
1576
  )
1547
- backend: Literal["llama.cpp", "vllm", "mlx"] = Field(
1548
- ..., description="Same backend set as the manual launcher UI / _build_cmd.",
1577
+ backend: str = Field(
1578
+ ...,
1579
+ description=(
1580
+ "Same backend set as the manual launcher UI / _build_cmd: "
1581
+ "'llama.cpp' / 'vllm' / 'mlx', optionally with a '-<variant>' "
1582
+ "suffix naming a specific build declared in launcher.backends "
1583
+ "(e.g. 'llama.cpp-cuda' to pin this model to the CUDA build). "
1584
+ "Validated by the field validator below plus "
1585
+ "LauncherConfig._check_swap_backends_declared."
1586
+ ),
1549
1587
  )
1588
+
1589
+ @field_validator("backend")
1590
+ @classmethod
1591
+ def _check_backend_name(cls, v: str) -> str:
1592
+ """基底名 or 既知基底名 + 妥当なバリアントのみ許す。
1593
+
1594
+ 以前は ``Literal["llama.cpp","vllm","mlx"]`` だった。バリアント
1595
+ (``llama.cpp-cuda``) を通すため str に緩めたので、代わりに形式検証を
1596
+ ここで行う。既存の 3 値はそのまま通る (後方互換)。
1597
+ """
1598
+ from coderouter.launcher_devices import (
1599
+ KNOWN_BASE_BACKENDS,
1600
+ is_valid_backend_name,
1601
+ )
1602
+
1603
+ if not is_valid_backend_name(v):
1604
+ raise ValueError(
1605
+ f"backend {v!r} is not valid. Expected one of "
1606
+ f"{list(KNOWN_BASE_BACKENDS)} or '<base>-<variant>' "
1607
+ "(e.g. 'llama.cpp-cuda')."
1608
+ )
1609
+ return v
1550
1610
  model_path: str = Field(
1551
1611
  ...,
1552
1612
  description=(
@@ -1802,10 +1862,13 @@ class LauncherConfig(BaseModel):
1802
1862
  default_factory=dict,
1803
1863
  description=(
1804
1864
  "Per-backend binary path overrides. "
1805
- "Keys are backend names ('llama.cpp', 'vllm'). "
1806
- "When a key is absent, the default executable is used "
1807
- "('llama-server' / 'python') and resolved via PATH. "
1808
- "Useful when running a from-source build or a venv-specific binary."
1865
+ "Keys are backend names ('llama.cpp', 'vllm', 'mlx') or backend "
1866
+ "variants ('llama.cpp-cuda', 'llama.cpp-vulkan', ...) naming an "
1867
+ "additional build of the same backend — see "
1868
+ "LauncherBackendConfig. When a base key is absent, the default "
1869
+ "executable is used ('llama-server' / 'python') and resolved via "
1870
+ "PATH. Variants must set 'binary' explicitly and additionally "
1871
+ "appear in the Launcher's backend select."
1809
1872
  ),
1810
1873
  )
1811
1874
  option_profiles: dict[str, list[LauncherOptionProfile]] = Field(
@@ -1813,6 +1876,10 @@ class LauncherConfig(BaseModel):
1813
1876
  description=(
1814
1877
  "Named option presets per backend. "
1815
1878
  "Keys should be backend names: 'llama.cpp', 'vllm'. "
1879
+ "A backend-variant key ('llama.cpp-cuda') is also accepted: its "
1880
+ "presets are appended to the base backend's list, and a preset "
1881
+ "whose 'name' collides replaces the inherited one in place "
1882
+ "(see launcher_devices.resolve_option_profiles). "
1816
1883
  "Each key maps to an ordered list of named presets. "
1817
1884
  "A free-form 'extra args' field is always available in the UI "
1818
1885
  "for one-off overrides without touching this config."
@@ -1929,15 +1996,82 @@ class LauncherConfig(BaseModel):
1929
1996
  )
1930
1997
  return self
1931
1998
 
1999
+ @model_validator(mode="after")
2000
+ def _check_backend_names_and_variant_binaries(self) -> LauncherConfig:
2001
+ """``backends`` のキー形式と、バリアントの ``binary`` 必須を検証する。
2002
+
2003
+ 設計: docs/designs/launcher-multi-build.md §5.2。
2004
+
2005
+ 1. キーは既知基底名 (``llama.cpp`` / ``vllm`` / ``mlx``) そのもの、
2006
+ または ``<既知基底名>-<バリアント>`` (``llama.cpp-cuda`` 等) のみ。
2007
+ **破壊的変更**: これまで ``llamacpp:`` のような typo は黙って無視
2008
+ されていた (バックエンド一覧が固定 3 キーだったため)。今後は
2009
+ ロード時エラーになる。
2010
+ 2. **バリアントは ``binary`` 必須**。``llama.cpp-cuda: {}`` を許すと
2011
+ 既定名フォールバックで PATH の ``llama-server`` が使われ、CUDA
2012
+ ビルドを指定したつもりで素のビルドが静かに動く —— 本機能で最も
2013
+ 気づきにくい事故なのでロード時に殺す。基底名は従来どおり
2014
+ ``binary`` 省略可 (PATH 解決)。
2015
+ """
2016
+ from coderouter.launcher_devices import (
2017
+ KNOWN_BASE_BACKENDS,
2018
+ is_valid_backend_name,
2019
+ is_variant,
2020
+ )
2021
+
2022
+ for name, bc in self.backends.items():
2023
+ if not is_valid_backend_name(name):
2024
+ raise ValueError(
2025
+ f"launcher.backends: invalid backend key {name!r}. "
2026
+ f"Expected one of {list(KNOWN_BASE_BACKENDS)} or "
2027
+ "'<base>-<variant>' where variant matches "
2028
+ "[a-z0-9][a-z0-9._-]* (e.g. 'llama.cpp-cuda')."
2029
+ )
2030
+ if is_variant(name) and not bc.binary:
2031
+ raise ValueError(
2032
+ f"launcher.backends[{name!r}]: 'binary' is required for a "
2033
+ "backend variant. Without it the launcher would fall back "
2034
+ "to the base default on PATH and silently run a different "
2035
+ "build than the one you selected."
2036
+ )
2037
+ return self
2038
+
2039
+ @model_validator(mode="after")
2040
+ def _check_swap_backends_declared(self) -> LauncherConfig:
2041
+ """swap のモデルがバリアントを指すなら ``backends`` に実在すること。
2042
+
2043
+ 設計 §5.2-3。基底名 (``llama.cpp`` 等) は ``backends`` に書かなくても
2044
+ PATH 解決で動くので従来どおり不要。バリアントは実行ファイルパスが
2045
+ ``backends`` にしか無いので、宣言漏れをロード時に弾く。
2046
+ """
2047
+ if self.swap is None:
2048
+ return self
2049
+ from coderouter.launcher_devices import is_variant
2050
+
2051
+ for spec in self.swap.models:
2052
+ if is_variant(spec.backend) and spec.backend not in self.backends:
2053
+ raise ValueError(
2054
+ f"launcher.swap.models[{spec.name!r}]: backend "
2055
+ f"{spec.backend!r} is a variant but is not declared in "
2056
+ "launcher.backends (its binary path is unknown)."
2057
+ )
2058
+ return self
2059
+
1932
2060
  @model_validator(mode="after")
1933
2061
  def _check_swap_option_profiles_exist(self) -> LauncherConfig:
1934
- """§5.4 #2: a swap model's ``option_profile`` must be a real preset."""
2062
+ """§5.4 #2: a swap model's ``option_profile`` must be a real preset.
2063
+
2064
+ バリアント backend の場合は基底名のプロファイルも継承されるので
2065
+ (:func:`resolve_option_profiles`)、マージ後の一覧で判定する。
2066
+ """
1935
2067
  if self.swap is None:
1936
2068
  return self
2069
+ from coderouter.launcher_devices import resolve_option_profiles
2070
+
1937
2071
  for spec in self.swap.models:
1938
2072
  if spec.option_profile is None:
1939
2073
  continue
1940
- profiles = self.option_profiles.get(spec.backend, [])
2074
+ profiles = resolve_option_profiles(self.option_profiles, spec.backend)
1941
2075
  if not any(p.name == spec.option_profile for p in profiles):
1942
2076
  raise ValueError(
1943
2077
  f"launcher.swap.models[{spec.name!r}]: option_profile "
@@ -47,6 +47,7 @@ from coderouter.launcher_devices import (
47
47
  SweepPlan,
48
48
  SweepState,
49
49
  SweepStep,
50
+ base_backend,
50
51
  build_auto_sweep_configs,
51
52
  build_sweep_steps,
52
53
  detect_llama_devices,
@@ -54,8 +55,11 @@ from coderouter.launcher_devices import (
54
55
  is_port_free,
55
56
  load_latest_results,
56
57
  render_bench_command,
58
+ resolve_option_profiles,
57
59
  selectable_devices,
58
60
  suggest_tensor_split,
61
+ unknown_device_ids,
62
+ variant_of,
59
63
  )
60
64
  from coderouter.launcher_speculative import resolve_speculative
61
65
  from coderouter.logging import get_logger
@@ -281,6 +285,9 @@ def _scan_models(model_dirs: list[str]) -> list[dict[str, Any]]:
281
285
  # ---------------------------------------------------------------------------
282
286
 
283
287
 
288
+ # 基底バックエンド名 → 既定の実行ファイル名。キーは常に**基底名**であり、
289
+ # ``llama.cpp-cuda`` のようなバリアント名は入らない。参照側は必ず
290
+ # ``base_backend()`` を通してから引くこと(launcher_devices §2.0)。
284
291
  _BACKEND_DEFAULTS: dict[str, str] = {
285
292
  "llama.cpp": "llama-server",
286
293
  "vllm": "python",
@@ -315,8 +322,15 @@ def _assert_no_model_override(backend: str, tokens: list[str]) -> None:
315
322
  ``tokens`` is the flat list of argument tokens sourced from ``options``
316
323
  keys or ``shlex.split(extra_args)``. Matching is exact on the flag name;
317
324
  ``--model=foo`` style is caught by comparing the part before ``=``.
325
+
326
+ ``backend`` may be a variant name (``llama.cpp-cuda``), so the lookup goes
327
+ through :func:`base_backend`. A missing entry must NOT silently disable the
328
+ guard — an unknown base falls back to the union of every banned set, i.e.
329
+ the strictest possible check (fail-closed).
318
330
  """
319
- banned = _MODEL_FLAGS.get(backend, frozenset())
331
+ banned = _MODEL_FLAGS.get(base_backend(backend))
332
+ if banned is None:
333
+ banned = frozenset().union(*_MODEL_FLAGS.values())
320
334
  for token in tokens:
321
335
  name = token.split("=", 1)[0]
322
336
  if name in banned:
@@ -328,11 +342,36 @@ def _assert_no_model_override(backend: str, tokens: list[str]) -> None:
328
342
 
329
343
 
330
344
  def _resolve_binary(backend: str, configured: str | None) -> str:
331
- """Return the executable to use, expanding ~ and env vars."""
332
- raw = configured or _BACKEND_DEFAULTS.get(backend, backend)
345
+ """Return the executable to use, expanding ~ and env vars.
346
+
347
+ ``backend`` may be a variant name; the default-executable fallback is
348
+ keyed by :func:`base_backend` so ``llama.cpp-cuda`` without a configured
349
+ ``binary`` would fall back to ``llama-server`` rather than to the literal
350
+ backend name. In practice variants require ``binary`` (validated at config
351
+ load — see ``LauncherConfig``), so that fallback should be unreachable.
352
+ """
353
+ raw = configured or _BACKEND_DEFAULTS.get(base_backend(backend), backend)
333
354
  return str(Path(raw).expanduser())
334
355
 
335
356
 
357
+ def _backend_names(backends_cfg: dict[str, Any] | None) -> list[str]:
358
+ """Launcher が提示するバックエンド名の一覧 (順序が UI の並び順)。
359
+
360
+ 基底 3 つを常に先頭に (従来どおりの順)、そのあとに ``launcher.backends``
361
+ に書かれたバリアントを記述順で並べる。設計 §6。
362
+
363
+ ``launcher.backends`` にバリアントを書かない利用者には従来と同じ 3 要素が
364
+ 返るので、UI の選択肢も API 応答も完全に不変 —— 特化ビルドは「書いた人に
365
+ だけ見える上級者向けオプション」であり、バッジや注記ではなく構造でそれを
366
+ 担保する。
367
+ """
368
+ names = list(_BACKEND_DEFAULTS)
369
+ for name in backends_cfg or {}:
370
+ if name not in names:
371
+ names.append(name)
372
+ return names
373
+
374
+
336
375
  def _resolve_backends_sync(
337
376
  backends_cfg: dict[str, Any] | None,
338
377
  ) -> dict[str, dict[str, Any]]:
@@ -340,9 +379,16 @@ def _resolve_backends_sync(
340
379
 
341
380
  Performs blocking filesystem I/O (``is_file`` / ``shutil.which``),
342
381
  so async-route callers must invoke it via ``asyncio.to_thread``.
382
+
383
+ Covers the base backends plus any variant declared in
384
+ ``launcher.backends`` (see :func:`_backend_names`). Each entry gains
385
+ ``base`` / ``variant`` so the UI can label a variant without re-parsing
386
+ the name client-side.
343
387
  """
344
388
  result: dict[str, dict[str, Any]] = {}
345
- for backend, default_bin in _BACKEND_DEFAULTS.items():
389
+ for backend in _backend_names(backends_cfg):
390
+ base = base_backend(backend)
391
+ default_bin = _BACKEND_DEFAULTS.get(base, backend)
346
392
  configured: str | None = None
347
393
  if backends_cfg:
348
394
  bc = backends_cfg.get(backend)
@@ -360,6 +406,9 @@ def _resolve_backends_sync(
360
406
  "default": default_bin,
361
407
  "is_custom": configured is not None,
362
408
  "found": found,
409
+ # 以下 2 キーはバリアント機能で追加 (基底名では variant=None)。
410
+ "base": base,
411
+ "variant": variant_of(backend),
363
412
  }
364
413
  return result
365
414
 
@@ -436,6 +485,8 @@ def _suggest_launch_flags(backend: str, size_gb: float,
436
485
  - mlx : 統合メモリ前提で起動時フラグ不要 (空文字)
437
486
  あくまで目安。他プロセスのメモリ使用や量子化方式までは考慮しない。
438
487
  """
488
+ # バリアント名 (llama.cpp-cuda 等) でも同じフラグ体系なので基底名で判定。
489
+ backend = base_backend(backend)
439
490
  if backend == "mlx":
440
491
  # MLX は統合メモリ + Metal 前提。llama.cpp の -ngl に相当する
441
492
  # レイヤーオフロードの概念がなく、mlx_lm.server は起動時の
@@ -552,20 +603,23 @@ def _build_cmd(
552
603
  model-override guard, and never apply to vllm/mlx (device 非対応)。
553
604
  """
554
605
  exe = _resolve_binary(backend, binary)
606
+ # argv の形はバリアントによらず基底バックエンドで決まる
607
+ # (llama.cpp-cuda も llama.cpp と同じフラグ体系)。
608
+ base = base_backend(backend)
555
609
 
556
- if backend == "llama.cpp":
610
+ if base == "llama.cpp":
557
611
  cmd: list[str] = [exe, "-m", model_path, "--port", str(port)]
558
612
  if device_args:
559
613
  cmd.extend(device_args)
560
614
  if spec_tokens:
561
615
  cmd.extend(spec_tokens)
562
- elif backend == "vllm":
616
+ elif base == "vllm":
563
617
  cmd = [
564
618
  exe, "-m", "vllm.entrypoints.openai.api_server",
565
619
  "--model", model_path,
566
620
  "--port", str(port),
567
621
  ]
568
- elif backend == "mlx":
622
+ elif base == "mlx":
569
623
  cmd = [
570
624
  exe, "-m", "mlx_lm.server",
571
625
  "--model", model_path,
@@ -574,7 +628,8 @@ def _build_cmd(
574
628
  else:
575
629
  raise ValueError(
576
630
  f"Unknown backend: {backend!r}. "
577
- "Expected 'llama.cpp', 'vllm' or 'mlx'."
631
+ "Expected 'llama.cpp', 'vllm' or 'mlx' "
632
+ "(optionally with a '-<variant>' suffix)."
578
633
  )
579
634
 
580
635
  # H8: reject model-path re-specification via options keys.
@@ -653,8 +708,14 @@ async def _backend_ready(backend: str, port: int, *, probe_timeout_s: float) ->
653
708
  documented health endpoint) fall back to a bare TCP connect: it can't
654
709
  distinguish "loaded" from "listening", but it is a strict improvement
655
710
  over registering the provider before the port is even open.
711
+
712
+ ``backend`` may be a variant name (``llama.cpp-cuda``): the check goes
713
+ through :func:`base_backend` so a variant keeps the ``/health`` probe.
714
+ Falling through to the bare TCP connect here would silently re-introduce
715
+ the bug readiness gating was added to fix (provider registered before the
716
+ model finished loading).
656
717
  """
657
- if backend in ("llama.cpp", "vllm"):
718
+ if base_backend(backend) in ("llama.cpp", "vllm"):
658
719
  try:
659
720
  async with httpx.AsyncClient(timeout=probe_timeout_s) as client:
660
721
  resp = await client.get(f"http://localhost:{port}/health")
@@ -1032,15 +1093,30 @@ async def api_models(request: Request) -> dict[str, Any]:
1032
1093
 
1033
1094
  @router.get("/api/launcher/option-profiles")
1034
1095
  async def api_option_profiles(request: Request) -> dict[str, Any]:
1035
- """Return option_profiles from providers.yaml launcher config."""
1096
+ """Return option_profiles from providers.yaml launcher config.
1097
+
1098
+ A backend-variant key inherits the base backend's presets: the response
1099
+ for ``llama.cpp-cuda`` is the ``llama.cpp`` list followed by the
1100
+ variant-specific list, with same-``name`` presets replaced in place
1101
+ (:func:`resolve_option_profiles`). Every declared backend gets an entry
1102
+ so the UI can key on the selected backend name directly, and a config
1103
+ without variants yields exactly the pre-feature payload.
1104
+ """
1036
1105
  cfg = request.app.state.config
1037
1106
  launcher_cfg = getattr(cfg, "launcher", None)
1038
1107
  if not launcher_cfg:
1039
1108
  return {"profiles": {}, "_note": "launcher: block not found in providers.yaml"}
1040
1109
  if not launcher_cfg.option_profiles:
1041
1110
  return {"profiles": {}, "_note": "option_profiles is empty — add option_profiles: under launcher: in providers.yaml"}
1111
+ names = list(launcher_cfg.option_profiles)
1112
+ for name in _backend_names(launcher_cfg.backends):
1113
+ if name not in names:
1114
+ names.append(name)
1042
1115
  result: dict[str, list[dict]] = {}
1043
- for backend, profiles in launcher_cfg.option_profiles.items():
1116
+ for backend in names:
1117
+ profiles = resolve_option_profiles(launcher_cfg.option_profiles, backend)
1118
+ if not profiles and backend not in launcher_cfg.option_profiles:
1119
+ continue # 継承もローカル定義も無いバックエンドはキーを作らない
1044
1120
  result[backend] = [{"name": p.name, "args": p.args} for p in profiles]
1045
1121
  return {"profiles": result}
1046
1122
 
@@ -1323,10 +1399,12 @@ async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
1323
1399
  _require_launcher_token(request)
1324
1400
  cfg = request.app.state.config
1325
1401
  launcher_cfg = getattr(cfg, "launcher", None)
1402
+ _assert_backend_declared(launcher_cfg, req.backend)
1326
1403
  # デバイス選択 → CLI 断片(llama.cpp のみ・選択された場合のみ)。未指定なら
1327
1404
  # None を渡し、既存の argv と完全一致(後方互換)。
1328
1405
  device_args: list[str] | None = None
1329
- if req.backend == "llama.cpp" and req.device_ids:
1406
+ if base_backend(req.backend) == "llama.cpp" and req.device_ids:
1407
+ await _assert_device_ids_known(launcher_cfg, req.backend, req.device_ids)
1330
1408
  device_args = DeviceSelection(
1331
1409
  device_ids=list(req.device_ids), tensor_split=list(req.tensor_split)
1332
1410
  ).to_cli_args()
@@ -1436,7 +1514,11 @@ async def api_suggest(request: Request, model_path: str = "",
1436
1514
 
1437
1515
 
1438
1516
  def _configured_binary_for(launcher_cfg: Any, backend: str) -> str | None:
1439
- """launcher.backends[backend].binary を取り出す(未設定なら None)。"""
1517
+ """launcher.backends[backend].binary を取り出す(未設定なら None)。
1518
+
1519
+ ``backend`` はバリアント名 (``llama.cpp-cuda``) でもよい —— ``backends`` は
1520
+ バリアント名もキーに取るので、そのまま引ける。
1521
+ """
1440
1522
  if launcher_cfg and getattr(launcher_cfg, "backends", None):
1441
1523
  bc = launcher_cfg.backends.get(backend)
1442
1524
  if bc and bc.binary:
@@ -1444,6 +1526,61 @@ def _configured_binary_for(launcher_cfg: Any, backend: str) -> str | None:
1444
1526
  return None
1445
1527
 
1446
1528
 
1529
+ def _assert_backend_declared(launcher_cfg: Any, backend: str) -> None:
1530
+ """バリアント名は ``launcher.backends`` に宣言済みでなければ 400。
1531
+
1532
+ 設計 §8。バリアントの実行ファイルパスは ``launcher.backends`` にしか無い
1533
+ ので、宣言されていない名前を受け取ったら**フォールバックせずに拒否**する。
1534
+ 基底名 (``llama.cpp`` 等) は PATH 解決で動くため従来どおり宣言不要。
1535
+
1536
+ これは同時に「API はバックエンド名しか受け取らず、実行ファイルパスは常に
1537
+ オペレータの静的設定から来る」という不変則の実装でもある (§11)。
1538
+ """
1539
+ if not variant_of(backend):
1540
+ return
1541
+ declared = getattr(launcher_cfg, "backends", None) or {}
1542
+ if backend not in declared:
1543
+ raise HTTPException(
1544
+ status_code=400,
1545
+ detail=(
1546
+ f"Unknown backend variant {backend!r}: not declared in "
1547
+ "launcher.backends. Add it (with a 'binary' path) to "
1548
+ "providers.yaml first."
1549
+ ),
1550
+ )
1551
+
1552
+
1553
+ async def _assert_device_ids_known(
1554
+ launcher_cfg: Any, backend: str, device_ids: list[str]
1555
+ ) -> None:
1556
+ """選択デバイス ID がそのビルドに実在しなければ 400 (設計 §7.2)。
1557
+
1558
+ デバイス ID の名前空間はビルドごとに違う (``CUDA0`` と ``Vulkan0`` は同じ
1559
+ GPU を指さない)。CUDA ビルドで ``CUDA0`` を選んだまま Vulkan ビルドで起動
1560
+ すると ``--device CUDA0`` が渡って llama-server が起動失敗するので、spawn
1561
+ 前に弾く。
1562
+
1563
+ ``--list-devices`` 自体が失敗した環境では検証をスキップする
1564
+ (:func:`unknown_device_ids` の best-effort 契約)。
1565
+ """
1566
+ if not device_ids:
1567
+ return
1568
+ configured = _configured_binary_for(launcher_cfg, backend)
1569
+ binary = _resolve_binary(backend, configured)
1570
+ probe = await asyncio.to_thread(detect_llama_devices, binary)
1571
+ unknown = unknown_device_ids(list(device_ids), probe)
1572
+ if unknown:
1573
+ known = [d.id for d in probe.devices]
1574
+ raise HTTPException(
1575
+ status_code=400,
1576
+ detail=(
1577
+ f"Device id(s) {unknown} do not exist in backend {backend!r} "
1578
+ f"(detected: {known}). Device ids are build-specific — "
1579
+ "re-select devices after switching build."
1580
+ ),
1581
+ )
1582
+
1583
+
1447
1584
  @router.get("/api/launcher/devices")
1448
1585
  async def api_devices(
1449
1586
  request: Request, backend: str = "llama.cpp", refresh: int = 0
@@ -1504,6 +1641,9 @@ class SweepConfigItem(BaseModel):
1504
1641
  label: str
1505
1642
  device_ids: list[str] = Field(default_factory=list)
1506
1643
  tensor_split: list[float] = Field(default_factory=list)
1644
+ # バリアント横断スイープ: このステップだけ別ビルドで起動する。None なら
1645
+ # ``SweepRequest.backend`` を使う=従来と完全に同一の argv。
1646
+ backend: str | None = None
1507
1647
 
1508
1648
 
1509
1649
  class SweepRequest(BaseModel):
@@ -1621,12 +1761,15 @@ class _SweepRunner:
1621
1761
  step.state = SweepState.STARTING
1622
1762
  self.log_tail.append(f"[sweep] {step.label}: starting")
1623
1763
  device_args = step.selection.to_cli_args() or None
1764
+ # ステップ個別のバックエンド(バリアント横断スイープ)。未指定なら
1765
+ # プラン既定 = 従来の挙動。
1766
+ step_backend = step.backend or self.plan.backend
1624
1767
  try:
1625
1768
  mp = await spawn_process(
1626
1769
  self.app,
1627
1770
  self.launcher_cfg,
1628
1771
  name=f"sweep-{step.label}",
1629
- backend=self.plan.backend,
1772
+ backend=step_backend,
1630
1773
  model_path=self.plan.model_path,
1631
1774
  port=self.plan.port,
1632
1775
  options=self.plan.options,
@@ -1768,6 +1911,13 @@ async def api_sweep_start(req: SweepRequest, request: Request) -> dict[str, Any]
1768
1911
  status_code=400, detail=f"Port {req.port} is not free."
1769
1912
  )
1770
1913
 
1914
+ # バリアント横断スイープ: 各構成が別ビルドを指せる。宣言されていない
1915
+ # バリアント名は通常起動と同じく 400 で弾く(フォールバックしない)。
1916
+ _assert_backend_declared(launcher_cfg, req.backend)
1917
+ for item in req.configs:
1918
+ if item.backend:
1919
+ _assert_backend_declared(launcher_cfg, item.backend)
1920
+
1771
1921
  labeled = [
1772
1922
  (
1773
1923
  item.label,
@@ -1775,6 +1925,7 @@ async def api_sweep_start(req: SweepRequest, request: Request) -> dict[str, Any]
1775
1925
  device_ids=list(item.device_ids),
1776
1926
  tensor_split=list(item.tensor_split),
1777
1927
  ),
1928
+ item.backend,
1778
1929
  )
1779
1930
  for item in req.configs
1780
1931
  ]
@@ -1927,6 +2078,10 @@ _LAUNCHER_HTML = r"""<!doctype html>
1927
2078
 
1928
2079
  <div>
1929
2080
  <label class="block text-xs text-slate-400 mb-1">バックエンド</label>
2081
+ <!-- 選択肢は fetchBackends() が /api/launcher/backends の応答から
2082
+ 動的生成する。providers.yaml に llama.cpp-cuda のようなバリアント
2083
+ を書くとここに増える。書かなければ基底 3 つのまま(従来と同一)。
2084
+ 初期値は JS が届く前でも表示が崩れないための土台。 -->
1930
2085
  <select id="f-backend" onchange="onBackendChange()">
1931
2086
  <option value="llama.cpp">llama.cpp</option>
1932
2087
  <option value="vllm">vllm</option>
@@ -2031,7 +2186,13 @@ _LAUNCHER_HTML = r"""<!doctype html>
2031
2186
  </p>
2032
2187
  <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
2033
2188
  <div>
2034
- <label class="block text-xs text-slate-400 mb-1">構成候補</label>
2189
+ <div class="flex items-center justify-between mb-1">
2190
+ <label class="block text-xs text-slate-400">構成候補</label>
2191
+ <!-- 宣言済みの llama.cpp バリアントを全部プローブして横断構成を作る。
2192
+ 「CUDA ビルドと Vulkan ビルドどちらが速いか」を 1 回で回すための入口。 -->
2193
+ <button id="btn-cross-variant" onclick="buildCrossVariantConfigs()"
2194
+ class="btn-sm btn-slate hidden">⚙ ビルド横断</button>
2195
+ </div>
2035
2196
  <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
2197
  <span class="text-slate-600">先に「🔍 検出」でデバイスを取得してください</span>
2037
2198
  </div>
@@ -2254,12 +2415,30 @@ _LAUNCHER_HTML = r"""<!doctype html>
2254
2415
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
2255
2416
  const d = await r.json();
2256
2417
  allBackends = d.backends || {};
2418
+ populateBackendSelect();
2419
+ updateCrossVariantButton();
2257
2420
  } catch (e) {
2258
2421
  console.error("[Launcher] fetchBackends failed:", e);
2259
2422
  }
2260
2423
  renderBinaryHint(); // always call outside try-catch so errors surface
2261
2424
  };
2262
2425
 
2426
+ // バックエンドセレクトをサーバ応答から作り直す。バリアント
2427
+ // (llama.cpp-cuda 等) には ⚙ を付けて「対応ランタイムを自分で入れる
2428
+ // 上級者向け」であることを示す。バリアントが 0 個なら従来と同じ 3 択。
2429
+ const populateBackendSelect = () => {
2430
+ const sel = document.getElementById("f-backend");
2431
+ const names = Object.keys(allBackends);
2432
+ if (!names.length) return;
2433
+ const prev = sel.value;
2434
+ sel.innerHTML = names.map((n) => {
2435
+ const info = allBackends[n] || {};
2436
+ const label = info.variant ? `${n} ⚙` : n;
2437
+ return `<option value="${esc(n)}">${esc(label)}</option>`;
2438
+ }).join("");
2439
+ sel.value = names.includes(prev) ? prev : names[0];
2440
+ };
2441
+
2263
2442
  const renderBinaryHint = () => {
2264
2443
  const backend = document.getElementById("f-backend").value;
2265
2444
  const hint = document.getElementById("binary-hint");
@@ -2276,10 +2455,20 @@ _LAUNCHER_HTML = r"""<!doctype html>
2276
2455
  const pathColor = info.found
2277
2456
  ? (info.is_custom ? "#818cf8" : "#4ade80") // indigo-400 / green-400
2278
2457
  : "#f87171"; // red-400
2279
- hint.innerHTML = dot
2458
+ // バリアント (特化ビルド) は対応 GPU ランタイムをユーザー自身が入れて
2459
+ // いる前提なので、選択中はその旨を一行添える。基底名では出さない
2460
+ // (通常利用者の画面を汚さない)。
2461
+ const runtimeNote = info.variant
2462
+ ? `<div style="color:#fbbf24;margin-top:2px">⚙ ${esc(info.variant)} ビルド`
2463
+ + ` — 対応ランタイム(CUDA / Vulkan / ROCm)が導入済みの環境でのみ動作します</div>`
2464
+ : "";
2465
+ hint.innerHTML = "<div style=\"display:flex;align-items:center;overflow:hidden\">"
2466
+ + dot
2280
2467
  + `<span style="font-family:monospace;color:${pathColor};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(info.resolved)}</span>`
2281
- + `<span style="color:#64748b;margin-left:6px;white-space:nowrap;flex-shrink:0">(${label} — ${statusText})</span>`;
2282
- hint.style.cssText = "display:flex;align-items:center;gap:0;overflow:hidden";
2468
+ + `<span style="color:#64748b;margin-left:6px;white-space:nowrap;flex-shrink:0">(${label} — ${statusText})</span>`
2469
+ + "</div>"
2470
+ + runtimeNote;
2471
+ hint.style.cssText = "overflow:hidden";
2283
2472
  // Enable/disable launch button based on binary availability
2284
2473
  if (!info.found) {
2285
2474
  btn.disabled = true;
@@ -2320,6 +2509,20 @@ _LAUNCHER_HTML = r"""<!doctype html>
2320
2509
  };
2321
2510
 
2322
2511
  window.onBackendChange = () => {
2512
+ // ★ デバイス id の名前空間はビルドごとに違う ("CUDA0" と "Vulkan0" は
2513
+ // 同じ GPU ではない)。バックエンド/バリアントを切り替えたら選択を破棄
2514
+ // して検出やり直しにする。残したままだと --device CUDA0 が Vulkan
2515
+ // ビルドに渡って起動失敗する (サーバ側でも 400 で弾くが、UI 側で
2516
+ // 先に消しておくのが本筋)。
2517
+ _devices = [];
2518
+ _deviceSel = {};
2519
+ _deviceOk = false;
2520
+ _autoConfigs = [];
2521
+ const box = document.getElementById("device-list");
2522
+ if (box) {
2523
+ box.innerHTML =
2524
+ '<span class="text-slate-600">「検出」を押してデバイスを取得</span>';
2525
+ }
2323
2526
  populateProfileSelect();
2324
2527
  renderBinaryHint();
2325
2528
  updateDeviceBlock();
@@ -2360,7 +2563,17 @@ _LAUNCHER_HTML = r"""<!doctype html>
2360
2563
  let _deviceOk = false;
2361
2564
  let _autoConfigs = []; // サーバ生成のスイープ構成候補
2362
2565
 
2363
- const isLlama = () => document.getElementById("f-backend").value === "llama.cpp";
2566
+ // バックエンド名は "llama.cpp" だけでなく "llama.cpp-cuda" のようなバリアント
2567
+ // 名も来る (docs/designs/launcher-multi-build.md)。素の === 比較にすると
2568
+ // バリアント選択時にデバイス欄が出なくなるので基底名で判定する。
2569
+ const baseBackend = (name) => {
2570
+ for (const b of ["llama.cpp", "vllm", "mlx"]) {
2571
+ if (name === b || name.startsWith(b + "-")) return b;
2572
+ }
2573
+ return name;
2574
+ };
2575
+ const isLlama = () =>
2576
+ baseBackend(document.getElementById("f-backend").value) === "llama.cpp";
2364
2577
 
2365
2578
  // デバイス id → バックエンド接頭辞(末尾数字を除く)。launcher_devices の
2366
2579
  // backend_of と同じ規則。"CUDA0"→"CUDA" / "Vulkan2"→"Vulkan" / "MTL0"→"MTL"。
@@ -2488,15 +2701,58 @@ _LAUNCHER_HTML = r"""<!doctype html>
2488
2701
  // 0 MiB 除外・バックエンド単位のグループ化・跨バックエンド混成の除外は
2489
2702
  // すべてサーバ側ロジックに一本化されている。
2490
2703
  const buildSweepConfigs = () => {
2704
+ const backend = document.getElementById("f-backend").value;
2491
2705
  _sweepConfigs = (_autoConfigs || []).map(c => ({
2492
2706
  label: c.label,
2493
2707
  device_ids: c.device_ids || [],
2494
2708
  tensor_split: c.tensor_split || [],
2709
+ backend: backend,
2495
2710
  checked: true,
2496
2711
  }));
2497
2712
  renderSweepConfigs();
2498
2713
  };
2499
2714
 
2715
+ // 宣言済みの llama.cpp バリアントを全部プローブし、ビルド横断の構成を作る。
2716
+ // ラベルにビルド名を前置するので {config} 経由でベンチ結果も見分けられる。
2717
+ // ビルド間の混成構成は作らない(1 プロセスは 1 実行ファイルなので原理的に不可)。
2718
+ window.buildCrossVariantConfigs = async () => {
2719
+ const names = Object.keys(allBackends).filter(n => baseBackend(n) === "llama.cpp");
2720
+ if (names.length < 2) {
2721
+ showSweepErr("横断できるビルドが 1 つしかありません。providers.yaml の "
2722
+ + "launcher.backends に llama.cpp-cuda などを追加してください");
2723
+ return;
2724
+ }
2725
+ showSweepErr("");
2726
+ const box = document.getElementById("sweep-configs");
2727
+ box.innerHTML = '<span class="text-slate-600">全ビルドを検出中…</span>';
2728
+ const collected = [];
2729
+ for (const name of names) {
2730
+ try {
2731
+ const r = await fetch("/api/launcher/devices?backend=" + encodeURIComponent(name));
2732
+ const d = await r.json();
2733
+ if (!r.ok || !d.ok) continue; // 検出できないビルドは黙って飛ばす
2734
+ const prefix = (allBackends[name] || {}).variant || name;
2735
+ for (const c of (d.auto_configs || [])) {
2736
+ collected.push({
2737
+ label: prefix + " / " + c.label,
2738
+ device_ids: c.device_ids || [],
2739
+ tensor_split: c.tensor_split || [],
2740
+ backend: name,
2741
+ checked: true,
2742
+ });
2743
+ }
2744
+ } catch (_) {}
2745
+ }
2746
+ if (!collected.length) {
2747
+ showSweepErr("どのビルドでもデバイスを検出できませんでした");
2748
+ renderSweepConfigs();
2749
+ return;
2750
+ }
2751
+ _sweepConfigs = collected;
2752
+ renderSweepConfigs();
2753
+ statusMsg(`ビルド横断構成 ${collected.length} 件を生成 (${names.length} ビルド)`);
2754
+ };
2755
+
2500
2756
  const renderSweepConfigs = () => {
2501
2757
  const box = document.getElementById("sweep-configs");
2502
2758
  if (!_sweepConfigs.length) {
@@ -2512,6 +2768,15 @@ _LAUNCHER_HTML = r"""<!doctype html>
2512
2768
  ).join("");
2513
2769
  };
2514
2770
 
2771
+ // 「⚙ ビルド横断」ボタンは llama.cpp のビルドが 2 つ以上宣言されている
2772
+ // ときだけ出す(バリアントを書いていない利用者の画面は従来どおり)。
2773
+ const updateCrossVariantButton = () => {
2774
+ const btn = document.getElementById("btn-cross-variant");
2775
+ if (!btn) return;
2776
+ const n = Object.keys(allBackends).filter(x => baseBackend(x) === "llama.cpp").length;
2777
+ btn.classList.toggle("hidden", n < 2);
2778
+ };
2779
+
2515
2780
  window.onSweepCfgToggle = () => {
2516
2781
  document.querySelectorAll('#sweep-configs input[data-cfg]').forEach(cb => {
2517
2782
  _sweepConfigs[parseInt(cb.getAttribute("data-cfg"))].checked = cb.checked;
@@ -2529,7 +2794,8 @@ _LAUNCHER_HTML = r"""<!doctype html>
2529
2794
  const model = document.getElementById("f-model").value.trim();
2530
2795
  if (!model) { showSweepErr("起動フォームでモデルパスを選択してください"); return; }
2531
2796
  const configs = _sweepConfigs.filter(c => c.checked)
2532
- .map(c => ({label: c.label, device_ids: c.device_ids, tensor_split: c.tensor_split}));
2797
+ .map(c => ({label: c.label, device_ids: c.device_ids,
2798
+ tensor_split: c.tensor_split, backend: c.backend || null}));
2533
2799
  if (!configs.length) { showSweepErr("構成を 1 つ以上選択してください"); return; }
2534
2800
  const port = parseInt(document.getElementById("sweep-port").value);
2535
2801
  const cmd = document.getElementById("sweep-cmd").value.trim();
@@ -2597,7 +2863,8 @@ _LAUNCHER_HTML = r"""<!doctype html>
2597
2863
  const tok = sm.tokens_per_sec != null ? sm.tokens_per_sec : "—";
2598
2864
  const ttft = sm.ttft_ms != null ? sm.ttft_ms : "—";
2599
2865
  const dev = (s.device_ids || []).join(",")
2600
- + ((s.tensor_split || []).length ? " · " + s.tensor_split.join(",") : "");
2866
+ + ((s.tensor_split || []).length ? " · " + s.tensor_split.join(",") : "")
2867
+ + (s.backend ? " [" + s.backend + "]" : "");
2601
2868
  const exit = s.bench_exit_code != null ? s.bench_exit_code : "—";
2602
2869
  return `<tr>
2603
2870
  <td class="py-2 pr-3">${esc(s.label)}</td>
@@ -2628,7 +2895,7 @@ _LAUNCHER_HTML = r"""<!doctype html>
2628
2895
 
2629
2896
  // デバイス選択 (llama.cpp のみ)。未選択なら空 → サーバ側で --device 不付与。
2630
2897
  let deviceIds = [], tensorSplit = [];
2631
- if (backend === "llama.cpp") {
2898
+ if (baseBackend(backend) === "llama.cpp") {
2632
2899
  deviceIds = selectedDeviceIds();
2633
2900
  const ts = document.getElementById("f-tsplit").value.trim();
2634
2901
  if (ts && deviceIds.length > 1) {
@@ -23,13 +23,14 @@ import socket
23
23
  import subprocess # controlled: fixed argv, no shell
24
24
  import threading
25
25
  import time
26
- from collections.abc import Callable, Sequence
26
+ from collections.abc import Callable, Mapping, Sequence
27
27
  from dataclasses import dataclass, field
28
28
  from enum import StrEnum
29
29
  from pathlib import Path
30
30
  from typing import Any
31
31
 
32
32
  __all__ = [
33
+ "KNOWN_BASE_BACKENDS",
33
34
  "DeviceProbe",
34
35
  "DeviceSelection",
35
36
  "LlamaDevice",
@@ -37,21 +38,140 @@ __all__ = [
37
38
  "SweepState",
38
39
  "SweepStep",
39
40
  "backend_of",
41
+ "base_backend",
40
42
  "build_auto_sweep_configs",
43
+ "build_cross_variant_sweep_configs",
41
44
  "build_sweep_steps",
42
45
  "detect_llama_devices",
43
46
  "group_by_backend",
44
47
  "is_port_free",
48
+ "is_valid_backend_name",
49
+ "is_variant",
45
50
  "load_latest_results",
46
51
  "parse_list_devices",
47
52
  "render_bench_command",
48
53
  "reset_device_cache",
54
+ "resolve_option_profiles",
49
55
  "selectable_devices",
50
56
  "suggest_tensor_split",
51
57
  "summarize_results",
58
+ "unknown_device_ids",
59
+ "variant_of",
52
60
  ]
53
61
 
54
62
 
63
+ # ---------------------------------------------------------------------------
64
+ # 2.0 バックエンド名の正規化 (基底名 + バリアント)
65
+ # ---------------------------------------------------------------------------
66
+
67
+ # llama.cpp を CUDA / Vulkan / ROCm 向けに個別ビルドしている環境では、同じ
68
+ # "llama.cpp" でもビルドごとに実行ファイルも列挙されるデバイスも違う。これを
69
+ # 設定・UI・API の全面で扱うため、バックエンド名に "<基底名>-<バリアント>"
70
+ # 形式を認める:
71
+ #
72
+ # llama.cpp 基底名 (素のビルド / PATH の llama-server)
73
+ # llama.cpp-cuda build-cuda/bin/llama-server
74
+ # llama.cpp-vulkan build-vulkan/bin/llama-server
75
+ # llama.cpp-rocm build-rocm/bin/llama-server
76
+ #
77
+ # ★重要: バックエンド名は実行ファイルの選択だけでなく「どのフラグ体系か」
78
+ # 「readiness を /health で見るか」「MTP を使えるか」といった挙動の分岐にも
79
+ # 使われている。それらの分岐は必ず :func:`base_backend` を通して判定すること。
80
+ # 素の文字列比較のままバリアント名が届くと、モデル上書きガードが無効化され
81
+ # たり readiness が TCP connect に退行したりと、例外を出さずに壊れる
82
+ # (docs/designs/launcher-multi-build.md §4 に全分岐の一覧がある)。
83
+ KNOWN_BASE_BACKENDS: tuple[str, ...] = ("llama.cpp", "vllm", "mlx")
84
+
85
+ # バリアント部分に許す文字。プロバイダ名・プロファイル名・ログに混ざっても
86
+ # 壊れない集合に限定し、パス区切りやシェルメタ文字を通さない。
87
+ _VARIANT_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
88
+
89
+
90
+ def base_backend(name: str) -> str:
91
+ """``"llama.cpp-cuda"`` → ``"llama.cpp"``。基底名そのものはそのまま返す。
92
+
93
+ 既知基底名との**最長一致**で判定する。``name.split("-", 1)[0]`` にしない
94
+ のは意図的で、基底名自体がハイフンを含むようになった日に静かに誤判定する
95
+ のを防ぐため。既知基底名で始まらない名前は加工せずそのまま返し、呼び出し
96
+ 側の既存 "Unknown backend" 経路に委ねる。
97
+ """
98
+ best = ""
99
+ for base in KNOWN_BASE_BACKENDS:
100
+ if name == base:
101
+ return base
102
+ if name.startswith(base + "-") and len(base) > len(best):
103
+ best = base
104
+ return best or name
105
+
106
+
107
+ def variant_of(name: str) -> str | None:
108
+ """``"llama.cpp-cuda"`` → ``"cuda"``。基底名そのものなら ``None``。
109
+
110
+ ``"llama.cpp-"`` のようにバリアント部が空の場合も ``None`` を返す
111
+ (妥当性判定は :func:`is_valid_backend_name` が担う)。
112
+ """
113
+ base = base_backend(name)
114
+ if base == name or not name.startswith(base + "-"):
115
+ return None
116
+ return name[len(base) + 1 :] or None
117
+
118
+
119
+ def is_variant(name: str) -> bool:
120
+ """``name`` が ``<既知基底名>-<バリアント>`` 形式かどうか。"""
121
+ return variant_of(name) is not None
122
+
123
+
124
+ def is_valid_backend_name(name: str) -> bool:
125
+ """既知基底名そのもの、または既知基底名 + 妥当なバリアントかどうか。
126
+
127
+ ``launcher.backends`` のキー検証に使う。``"llamacpp"`` (typo) や
128
+ ``"llama.cpp-CUDA"`` (大文字) / ``"llama.cpp-"`` (空) は False。
129
+ """
130
+ if name in KNOWN_BASE_BACKENDS:
131
+ return True
132
+ variant = variant_of(name)
133
+ return variant is not None and _VARIANT_RE.match(variant) is not None
134
+
135
+
136
+ def resolve_option_profiles[P](
137
+ profiles_by_backend: Mapping[str, Sequence[P]], backend: str
138
+ ) -> list[P]:
139
+ """バックエンド(バリアント可)に適用する option_profiles を解決する。
140
+
141
+ 基底名のリスト(継承)を先に、バリアント固有のリストを後に連結する。
142
+ ``name`` が衝突したらバリアント固有が**同じ位置で置き換える** —— 並び順を
143
+ 安定させ、末尾に重複を作らないため。``backend`` が基底名そのものなら
144
+ ``profiles_by_backend.get(backend, [])`` と完全に同一の結果になる
145
+ (後方互換)。
146
+
147
+ 要素は ``.name`` 属性を持つ任意の型でよい。pydantic の
148
+ ``LauncherOptionProfile`` と GUI の dataclass ``OptionProfile`` の双方から
149
+ 使うため、この層は pydantic に依存しない (モジュール冒頭の方針どおり)。
150
+ """
151
+ base = base_backend(backend)
152
+ inherited = list(profiles_by_backend.get(base, []))
153
+ if backend == base:
154
+ return inherited
155
+ own = list(profiles_by_backend.get(backend, []))
156
+ if not own:
157
+ return inherited
158
+
159
+ by_name = {p.name: p for p in own} # type: ignore[attr-defined]
160
+ merged: list[P] = []
161
+ replaced: set[str] = set()
162
+ for p in inherited:
163
+ name = p.name # type: ignore[attr-defined]
164
+ if name in by_name:
165
+ merged.append(by_name[name]) # 継承分と同じ位置で差し替え
166
+ replaced.add(name)
167
+ else:
168
+ merged.append(p)
169
+ merged.extend(
170
+ p for p in own if p.name not in replaced # type: ignore[attr-defined]
171
+ )
172
+ return merged
173
+
174
+
55
175
  # ---------------------------------------------------------------------------
56
176
  # 2.1 デバイス検出
57
177
  # ---------------------------------------------------------------------------
@@ -249,6 +369,24 @@ class DeviceSelection:
249
369
  return args
250
370
 
251
371
 
372
+ def unknown_device_ids(device_ids: Sequence[str], probe: DeviceProbe) -> list[str]:
373
+ """``device_ids`` のうち ``probe`` に存在しないものを返す(空なら健全)。
374
+
375
+ バックエンドバリアントを切り替えるとデバイス ID の名前空間も変わる
376
+ (``CUDA0`` と ``Vulkan0`` は同じ GPU を指さない)。CUDA ビルドで
377
+ ``CUDA0`` を選んだまま Vulkan ビルドで起動すると ``--device CUDA0`` が
378
+ Vulkan ビルドに渡って起動失敗するので、spawn 前に弾くための判定。
379
+
380
+ ``probe.ok`` が False (``--list-devices`` 自体が失敗) のときは **常に空
381
+ リスト** を返す。デバイスを列挙できない環境で機能を殺さないための
382
+ best-effort 原則 (``hardware.py`` の 5-deps 不変則と同じ姿勢)。
383
+ """
384
+ if not probe.ok:
385
+ return []
386
+ known = {d.id for d in probe.devices}
387
+ return [d for d in device_ids if d not in known]
388
+
389
+
252
390
  # ---------------------------------------------------------------------------
253
391
  # 2.3 tensor-split 自動提案
254
392
  # ---------------------------------------------------------------------------
@@ -357,6 +495,36 @@ def build_auto_sweep_configs(
357
495
  return configs
358
496
 
359
497
 
498
+ def build_cross_variant_sweep_configs(
499
+ probes: Sequence[tuple[str, Sequence[LlamaDevice]]],
500
+ *,
501
+ by: str = "total",
502
+ ) -> list[tuple[str, str, DeviceSelection]]:
503
+ """複数ビルド(バリアント)を横断するスイープ構成を生成する。
504
+
505
+ ``probes`` は ``[(backend_name, devices), ...]`` —— 各バリアントを
506
+ ``--list-devices`` でプローブした結果。返すのは
507
+ ``[(label, backend_name, DeviceSelection), ...]`` で、ラベルには
508
+ バリアント名を前置する (``"cuda / CUDA0 単体"``)。``{config}`` プレース
509
+ ホルダ経由でベンチ結果 JSON もビルド別に見分けられる。
510
+
511
+ ``build_auto_sweep_configs`` を各ビルドに対して呼ぶだけなので、
512
+ 「バックエンド跨ぎの混成構成は作らない」という既存規則はビルド内でも
513
+ そのまま維持される。ビルド間の混成 (CUDA ビルドの CUDA0 と Vulkan
514
+ ビルドの Vulkan2 を同時に、など) は **原理的に不可能** ——1 プロセスは
515
+ 1 つの実行ファイルでしか動かないので、ここでも作らない。
516
+
517
+ これで「同一モデルを CUDA ビルドと Vulkan ビルドで順に起動してベンチし、
518
+ どちらが速いか比較する」が 1 回のスイープで回る。
519
+ """
520
+ out: list[tuple[str, str, DeviceSelection]] = []
521
+ for backend, devices in probes:
522
+ prefix = variant_of(backend) or backend
523
+ for label, selection in build_auto_sweep_configs(devices, by=by):
524
+ out.append((f"{prefix} / {label}", backend, selection))
525
+ return out
526
+
527
+
360
528
  # ---------------------------------------------------------------------------
361
529
  # 2.4 スイープ計画・状態
362
530
  # ---------------------------------------------------------------------------
@@ -382,6 +550,10 @@ class SweepStep:
382
550
  error: str | None = None
383
551
  started_at: float = 0.0
384
552
  ended_at: float = 0.0
553
+ # ステップ個別のバックエンド(バリアント横断スイープ用)。None なら
554
+ # ``SweepPlan.backend`` を使う=従来と完全に同じ argv。末尾の任意
555
+ # フィールドなので既存の位置指定コンストラクタ呼び出しは無影響。
556
+ backend: str | None = None
385
557
 
386
558
  def as_dict(self) -> dict[str, Any]:
387
559
  return {
@@ -393,6 +565,7 @@ class SweepStep:
393
565
  "results_path": self.results_path,
394
566
  "summary": self.summary,
395
567
  "error": self.error,
568
+ "backend": self.backend,
396
569
  }
397
570
 
398
571
 
@@ -400,7 +573,7 @@ class SweepStep:
400
573
  class SweepPlan:
401
574
  steps: list[SweepStep]
402
575
  model_path: str
403
- backend: str # 当面 "llama.cpp"
576
+ backend: str # プラン既定。step.backend が None のとき使われる
404
577
  port: int
405
578
  bench_cmd_template: str
406
579
  results_dir: str | None = None
@@ -409,9 +582,20 @@ class SweepPlan:
409
582
 
410
583
 
411
584
  def build_sweep_steps(
412
- labeled: Sequence[tuple[str, DeviceSelection]],
585
+ labeled: Sequence[tuple[str, DeviceSelection]]
586
+ | Sequence[tuple[str, DeviceSelection, str | None]],
413
587
  ) -> list[SweepStep]:
414
- return [SweepStep(label=lbl, selection=sel) for lbl, sel in labeled]
588
+ """``(label, selection)`` または ``(label, selection, backend)`` から生成。
589
+
590
+ 3 要素形はバリアント横断スイープ用 (ステップごとに実行ファイルが違う)。
591
+ 2 要素形は従来どおり ``backend=None`` になり、プラン既定で動く。
592
+ """
593
+ steps: list[SweepStep] = []
594
+ for item in labeled:
595
+ lbl, sel = item[0], item[1]
596
+ backend = item[2] if len(item) == 3 else None
597
+ steps.append(SweepStep(label=lbl, selection=sel, backend=backend))
598
+ return steps
415
599
 
416
600
 
417
601
  # ---------------------------------------------------------------------------
@@ -45,6 +45,7 @@ import re
45
45
  from pathlib import Path
46
46
 
47
47
  from coderouter.gguf_introspect import try_read_gguf_metadata
48
+ from coderouter.launcher_devices import base_backend
48
49
 
49
50
  __all__ = ["find_draft_companion", "resolve_speculative"]
50
51
 
@@ -248,7 +249,10 @@ def resolve_speculative(
248
249
 
249
250
  # 1. Non-llama.cpp backends do not support speculation. Reject explicit
250
251
  # use; otherwise (auto default, no draft) stay a silent no-op.
251
- if backend != _LLAMA_CPP:
252
+ # ``backend`` may be a variant (``llama.cpp-cuda``) — every llama.cpp
253
+ # build supports speculation, so compare the base name. A plain ``!=``
254
+ # here would silently drop the spec flags for variants.
255
+ if base_backend(backend) != _LLAMA_CPP:
252
256
  if draft_model_path or mtp_mode == "off":
253
257
  raise ValueError(
254
258
  "draft_model_path / mtp_mode are only supported for llama.cpp"
@@ -50,6 +50,7 @@ from coderouter.ingress.launcher_routes import (
50
50
  spawn_process,
51
51
  stop_process,
52
52
  )
53
+ from coderouter.launcher_devices import resolve_option_profiles
53
54
  from coderouter.logging import get_logger
54
55
 
55
56
  logger = get_logger(__name__)
@@ -353,10 +354,18 @@ class SwapManager:
353
354
  reg.remove(proc_id)
354
355
 
355
356
  def _resolve_options(self, spec: SwapModelSpec) -> dict[str, Any]:
356
- """Resolve ``spec.option_profile`` into the launcher options dict."""
357
+ """Resolve ``spec.option_profile`` into the launcher options dict.
358
+
359
+ ``spec.backend`` may be a variant (``llama.cpp-cuda``), in which case
360
+ the base backend's presets are inherited — the same merge the manual
361
+ UI applies, so a swap entry can reference a shared preset without
362
+ duplicating it under every variant key.
363
+ """
357
364
  if not spec.option_profile or self._launcher_cfg is None:
358
365
  return {}
359
- profiles = self._launcher_cfg.option_profiles.get(spec.backend, [])
366
+ profiles = resolve_option_profiles(
367
+ self._launcher_cfg.option_profiles, spec.backend
368
+ )
360
369
  for p in profiles:
361
370
  if p.name == spec.option_profile:
362
371
  return dict(p.args)
coderouter/vscode_init.py CHANGED
@@ -57,8 +57,10 @@ DEFAULT_PORT = 8088
57
57
  # Bogus token — CodeRouter's chat ingress does not validate ANTHROPIC_AUTH_TOKEN
58
58
  # (real API keys are managed on the ``providers.yaml`` side). Using a
59
59
  # fixed sentinel here means we never accidentally embed the operator's
60
- # real token into a workspace file.
61
- DEFAULT_TOKEN = "dummy" # noqa: S105 — intentional placeholder, not a secret
60
+ # real token into a workspace file. (Not annotated with `noqa: S105`
61
+ # because this project doesn't enable that ruff rule — RUF100 would
62
+ # then flag the unused directive.)
63
+ DEFAULT_TOKEN = "dummy"
62
64
 
63
65
  # VSCode reads terminal env from a separate key per host OS. Writing all
64
66
  # three means the same ``.vscode/`` folder works whether the developer
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coderouter-cli
3
- Version: 2.10.0
3
+ Version: 2.11.0
4
4
  Summary: Local-first, free-first, fallback-built-in LLM router. Claude Code / OpenAI compatible.
5
5
  Project-URL: Homepage, https://github.com/zephel01/CodeRouter
6
6
  Project-URL: Repository, https://github.com/zephel01/CodeRouter
@@ -224,6 +224,7 @@ providers:
224
224
  | **ログビューア** | 各プロセスの stdout/stderr をブラウザ内でリアルタイム確認 |
225
225
  | **provider 自動同期** (v2.7.4) | 起動したバックエンドを provider として自動登録(`launcher-llamacpp-8085` 等)。providers.yaml 無編集で `X-CodeRouter-Profile: launcher` からルーティング可能。メモリ内のみ・serve と同寿命 |
226
226
  | **モデル名パススルー** (v2.7.4) | `model: ""` の provider は `/v1/models` が上流のロード中モデル ID(gguf 名)をそのまま返す。gguf を差し替えても config 編集不要 — 外部ベンチからモデルを識別できる |
227
+ | **特化ビルドの切り替え** (v2.11.0) | `llama.cpp-cuda` / `-vulkan` / `-rocm` を `backends` に登録すると、起動ごとにどのビルドの `llama-server` を使うか選べる。ビルド別にデバイス検出・option_profiles・ベンチスイープが独立(書かなければ従来どおり) |
227
228
 
228
229
  ```yaml
229
230
  # providers.yaml に追記するだけで有効になる
@@ -10,14 +10,14 @@ coderouter/errors.py,sha256=Xmq67lheyw8iv3Ox39jh2c4tvNI5RcUR4QkoxVDN6l4,1130
10
10
  coderouter/gguf_introspect.py,sha256=KeE00CfbYRa4gSTrzuwC6zVuHi20IzLC4nUQa98BEXI,10387
11
11
  coderouter/hardware.py,sha256=gn3_9qbVcGRR81yKMn1lJE_8-YDRau0LxIH_M-f7pxE,8356
12
12
  coderouter/language_tax.py,sha256=LTbE3tIfoJuV2O3T0NixRKhzq_dEOTUuPEerJv2q9uk,9360
13
- coderouter/launcher_devices.py,sha256=SoGpzknRqvnmvg_IeXy1TkeE7aLc_MOFqLzXdd3CjVs,19275
14
- coderouter/launcher_speculative.py,sha256=kWaHNmhzBsbWuM_lRGDlbWkgJ1suuxBWQNTKhJOC-yg,12644
15
- coderouter/launcher_swap.py,sha256=W7qpdB-8Npr8yHGfx0N3SgZ2vDJ6BR9JuxW0I3TaZ-4,23783
13
+ coderouter/launcher_devices.py,sha256=_5R7dA8tHEhwLn9VkXccLEXPJREfpHu90nYd6-cm8Xs,28180
14
+ coderouter/launcher_speculative.py,sha256=h0RTGsD1Y56sJVywDJwybrT75_Ta3GF1Jz_B1d42aPg,12931
15
+ coderouter/launcher_swap.py,sha256=tMW5ZElDdf9T1VQGsUkvfLiSCdj_oXN_UtkV7DE8ySY,24175
16
16
  coderouter/logging.py,sha256=5rfi4_R4aoeeUYDc642dgs61wA0aBX7wvubyB-k3Uyo,58074
17
17
  coderouter/output_filters.py,sha256=0ry_rPiS_kC-FnHgaNVP6v7e6Al2djxzu9vBzZ8kEkE,25314
18
18
  coderouter/token_estimation.py,sha256=iz22vZEEW2P7uKLB2pYvPNpIbZGbgXRO5MtfkS_-9Sk,7531
19
19
  coderouter/token_estimation_accurate.py,sha256=GTfzrBVnvAGjeVzmzAeUdOYZvWZKLAxcxPpFiJGlzjk,4609
20
- coderouter/vscode_init.py,sha256=fAcxJmwGXX8u0ylW0GHGACe_Co9VL6wdAKNFw7-ulwg,19952
20
+ coderouter/vscode_init.py,sha256=ATQ1FzeQRAXfBBNcPwpEHRP4l4Yjn4hYgE-TjkcVvck,20034
21
21
  coderouter/adapters/__init__.py,sha256=7dIDSZ-FE_0iSqLSDc_lK1idRdLTKcM2hP9tCJipgPI,463
22
22
  coderouter/adapters/anthropic_native.py,sha256=CT9Hitun-c3z83YHT2zZXgwZpY3_t6eRnOfAmNud2aw,23610
23
23
  coderouter/adapters/base.py,sha256=ykKMaiIVVGr23oFFXilK2iP_YhI8-CtsEiJyFXaC9bE,10396
@@ -27,7 +27,7 @@ coderouter/config/__init__.py,sha256=FODEn74fN-qZnt4INPSHswqhOlEgpL6-_onxsitSx8g
27
27
  coderouter/config/capability_registry.py,sha256=RZgzHF54dXy0CCDocRCz2ee21qXGbbgTSKTkRI9iLL4,16936
28
28
  coderouter/config/env_file.py,sha256=CoMK27fuAXm-NtoLzXb8yN2E-wDFjHQuFwiIlmgTBQw,10356
29
29
  coderouter/config/loader.py,sha256=FUEe8m4Tnmj_aul0vSctD8vKvNW-oLRoMRbTpSKqSmc,4077
30
- coderouter/config/schemas.py,sha256=X2LeonDTTIkesbqzmmo3QYcD1dwmjGiCalMyS5X-A1o,120986
30
+ coderouter/config/schemas.py,sha256=nPwaG4uIdhqoOs74nay4fPTNvEHxpsx7RhqFTZzBX-E,127468
31
31
  coderouter/data/__init__.py,sha256=uNyfD9jaCvTWsBAWtaw1Fr25OSxzv3psGMfBjT1z0Cc,328
32
32
  coderouter/data/model-capabilities.yaml,sha256=S9jt6SC6-3s2-icZ_n-a14iEMnc2yB1C2R6q-N_tZWQ,19309
33
33
  coderouter/guards/__init__.py,sha256=5qliYBqygvVPneej7nx0uSjxDKsz7t8VzvrDgVBJlvU,1170
@@ -45,7 +45,7 @@ coderouter/ingress/__init__.py,sha256=WQsCH2CGJCAhy0mS6GSEdeYZRkkQu2OHDsP4CJWTLu
45
45
  coderouter/ingress/anthropic_routes.py,sha256=1ThXzx8VgxRV7UCi3NF5ey7kbr0ADPKTXyQuYS5gK1w,27063
46
46
  coderouter/ingress/app.py,sha256=6e_qNlYZIyjLKhY20t0ZNL-x5Fv8R8oaIH_HCdxa9Ic,20921
47
47
  coderouter/ingress/dashboard_routes.py,sha256=1SqNNrVTfQiZcx5n2MvjAzWirittX0e61tCt-MKp4Ag,25617
48
- coderouter/ingress/launcher_routes.py,sha256=lU2EILVAi-lvHBHIxyixsMTRZlp-Rv7-2sw-eW4ccYg,119687
48
+ coderouter/ingress/launcher_routes.py,sha256=CGD_Uq8jz1qwFPQD-HXfHdB7-nD1rvPaHkpXzmTzB1w,133287
49
49
  coderouter/ingress/metrics_routes.py,sha256=M22dwOGn24P05Ge4W3c7d7mYytSGWjIR-pPSPOAiHJY,3965
50
50
  coderouter/ingress/openai_routes.py,sha256=TrugsQNJfNPgKKLzfT1aXzrs4emWYGE4XebEBuaZoWk,12505
51
51
  coderouter/metrics/__init__.py,sha256=7Es351DPS7yLM0yVF_F0eesmiD83n7Zzhie44chht38,1465
@@ -71,8 +71,8 @@ coderouter/translation/__init__.py,sha256=PYXN7XVEwpG1uC8RLy6fvnGbzEZhhrEuUapH8I
71
71
  coderouter/translation/anthropic.py,sha256=uEScq-icLn6ZXFWaxsKROv7feaq3zD0ZogjbTJ3Y7gQ,12578
72
72
  coderouter/translation/convert.py,sha256=VV4kpu4dvLmGBmJfQnCLY5ryy_AnQcV2NuwjqRtaR8Q,52633
73
73
  coderouter/translation/tool_repair.py,sha256=DGOKArM53yfVbIfiAveoul1vGq9NEKmjagf43Qh4IkM,57630
74
- coderouter_cli-2.10.0.dist-info/METADATA,sha256=quMG0dkYOOZpkWxidZQIxREB-vHl3-U7VktyA00bE4Y,16578
75
- coderouter_cli-2.10.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
76
- coderouter_cli-2.10.0.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
77
- coderouter_cli-2.10.0.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
78
- coderouter_cli-2.10.0.dist-info/RECORD,,
74
+ coderouter_cli-2.11.0.dist-info/METADATA,sha256=KIJ1syHgU0_eFQlBpZvaasysCEzQE5LLsITNQYP9x3o,16897
75
+ coderouter_cli-2.11.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
76
+ coderouter_cli-2.11.0.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
77
+ coderouter_cli-2.11.0.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
78
+ coderouter_cli-2.11.0.dist-info/RECORD,,