coderouter-cli 2.9.4__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.
@@ -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)