coderouter-cli 2.10.0__py3-none-any.whl → 2.11.1__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",
46
+ "foreign_device_ids",
43
47
  "group_by_backend",
44
48
  "is_port_free",
49
+ "is_valid_backend_name",
50
+ "is_variant",
45
51
  "load_latest_results",
46
52
  "parse_list_devices",
47
53
  "render_bench_command",
48
54
  "reset_device_cache",
55
+ "resolve_option_profiles",
49
56
  "selectable_devices",
50
57
  "suggest_tensor_split",
51
58
  "summarize_results",
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,36 @@ class DeviceSelection:
249
369
  return args
250
370
 
251
371
 
372
+ def foreign_device_ids(device_ids: Sequence[str], probe: DeviceProbe) -> list[str]:
373
+ """``device_ids`` のうち **別ビルドの名前空間**に属するものを返す。
374
+
375
+ バックエンドバリアントを切り替えるとデバイス ID の名前空間も変わる
376
+ (``CUDA0`` と ``Vulkan0`` は同じ GPU を指さない)。CUDA ビルドで
377
+ ``CUDA0`` を選んだまま Vulkan ビルドで起動すると ``--device CUDA0`` が
378
+ Vulkan ビルドに渡って起動失敗するので、spawn 前に弾くための判定。
379
+
380
+ 判定は **id の完全一致ではなくバックエンド接頭辞** (:func:`backend_of` —
381
+ ``"CUDA0"`` → ``"CUDA"``) で行う。理由は 2 つある:
382
+
383
+ 1. 検出したいのは「ビルド違い」= 名前空間そのものの不一致であって、
384
+ 同一ビルド内の番号ズレではない。UI はチェックボックスから id を渡す
385
+ ので番号ズレは実際には起きない。
386
+ 2. 完全一致にすると ``--list-devices`` の出力形式が将来変わって
387
+ :func:`parse_list_devices` が一部の行だけ取りこぼした場合に、**正しい
388
+ id を誤って拒否してしまう**。接頭辞単位なら 1 行でも同じ接頭辞が
389
+ 拾えていれば通る。
390
+
391
+ ``probe.ok`` が False (``--list-devices`` 自体が失敗) のときと、デバイス
392
+ を 1 つも拾えなかったときは **常に空リスト** を返す。列挙できない環境で
393
+ 機能を殺さないための best-effort 原則 (``hardware.py`` の 5-deps 不変則と
394
+ 同じ姿勢)。
395
+ """
396
+ if not probe.ok or not probe.devices:
397
+ return []
398
+ known_prefixes = {backend_of(d.id) for d in probe.devices}
399
+ return [d for d in device_ids if backend_of(d) not in known_prefixes]
400
+
401
+
252
402
  # ---------------------------------------------------------------------------
253
403
  # 2.3 tensor-split 自動提案
254
404
  # ---------------------------------------------------------------------------
@@ -357,6 +507,36 @@ def build_auto_sweep_configs(
357
507
  return configs
358
508
 
359
509
 
510
+ def build_cross_variant_sweep_configs(
511
+ probes: Sequence[tuple[str, Sequence[LlamaDevice]]],
512
+ *,
513
+ by: str = "total",
514
+ ) -> list[tuple[str, str, DeviceSelection]]:
515
+ """複数ビルド(バリアント)を横断するスイープ構成を生成する。
516
+
517
+ ``probes`` は ``[(backend_name, devices), ...]`` —— 各バリアントを
518
+ ``--list-devices`` でプローブした結果。返すのは
519
+ ``[(label, backend_name, DeviceSelection), ...]`` で、ラベルには
520
+ バリアント名を前置する (``"cuda / CUDA0 単体"``)。``{config}`` プレース
521
+ ホルダ経由でベンチ結果 JSON もビルド別に見分けられる。
522
+
523
+ ``build_auto_sweep_configs`` を各ビルドに対して呼ぶだけなので、
524
+ 「バックエンド跨ぎの混成構成は作らない」という既存規則はビルド内でも
525
+ そのまま維持される。ビルド間の混成 (CUDA ビルドの CUDA0 と Vulkan
526
+ ビルドの Vulkan2 を同時に、など) は **原理的に不可能** ——1 プロセスは
527
+ 1 つの実行ファイルでしか動かないので、ここでも作らない。
528
+
529
+ これで「同一モデルを CUDA ビルドと Vulkan ビルドで順に起動してベンチし、
530
+ どちらが速いか比較する」が 1 回のスイープで回る。
531
+ """
532
+ out: list[tuple[str, str, DeviceSelection]] = []
533
+ for backend, devices in probes:
534
+ prefix = variant_of(backend) or backend
535
+ for label, selection in build_auto_sweep_configs(devices, by=by):
536
+ out.append((f"{prefix} / {label}", backend, selection))
537
+ return out
538
+
539
+
360
540
  # ---------------------------------------------------------------------------
361
541
  # 2.4 スイープ計画・状態
362
542
  # ---------------------------------------------------------------------------
@@ -382,6 +562,10 @@ class SweepStep:
382
562
  error: str | None = None
383
563
  started_at: float = 0.0
384
564
  ended_at: float = 0.0
565
+ # ステップ個別のバックエンド(バリアント横断スイープ用)。None なら
566
+ # ``SweepPlan.backend`` を使う=従来と完全に同じ argv。末尾の任意
567
+ # フィールドなので既存の位置指定コンストラクタ呼び出しは無影響。
568
+ backend: str | None = None
385
569
 
386
570
  def as_dict(self) -> dict[str, Any]:
387
571
  return {
@@ -393,6 +577,7 @@ class SweepStep:
393
577
  "results_path": self.results_path,
394
578
  "summary": self.summary,
395
579
  "error": self.error,
580
+ "backend": self.backend,
396
581
  }
397
582
 
398
583
 
@@ -400,7 +585,7 @@ class SweepStep:
400
585
  class SweepPlan:
401
586
  steps: list[SweepStep]
402
587
  model_path: str
403
- backend: str # 当面 "llama.cpp"
588
+ backend: str # プラン既定。step.backend が None のとき使われる
404
589
  port: int
405
590
  bench_cmd_template: str
406
591
  results_dir: str | None = None
@@ -409,9 +594,20 @@ class SweepPlan:
409
594
 
410
595
 
411
596
  def build_sweep_steps(
412
- labeled: Sequence[tuple[str, DeviceSelection]],
597
+ labeled: Sequence[tuple[str, DeviceSelection]]
598
+ | Sequence[tuple[str, DeviceSelection, str | None]],
413
599
  ) -> list[SweepStep]:
414
- return [SweepStep(label=lbl, selection=sel) for lbl, sel in labeled]
600
+ """``(label, selection)`` または ``(label, selection, backend)`` から生成。
601
+
602
+ 3 要素形はバリアント横断スイープ用 (ステップごとに実行ファイルが違う)。
603
+ 2 要素形は従来どおり ``backend=None`` になり、プラン既定で動く。
604
+ """
605
+ steps: list[SweepStep] = []
606
+ for item in labeled:
607
+ lbl, sel = item[0], item[1]
608
+ backend = item[2] if len(item) == 3 else None
609
+ steps.append(SweepStep(label=lbl, selection=sel, backend=backend))
610
+ return steps
415
611
 
416
612
 
417
613
  # ---------------------------------------------------------------------------
@@ -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)
@@ -2911,7 +2911,15 @@ class FallbackEngine:
2911
2911
  chat_req = to_chat_request(effective_request)
2912
2912
  chat_req.stream = False
2913
2913
  chat_resp = await adapter.generate(chat_req, overrides=overrides)
2914
- resp = to_anthropic_response(chat_resp, allowed_tool_names=tool_names)
2914
+ # H-4: the translation hop runs the tool-call repair
2915
+ # scanners over the whole assistant message. That is CPU
2916
+ # work, not I/O, so it is pushed onto a worker thread —
2917
+ # a pathological message must not stall the event loop
2918
+ # for every other in-flight request. `to_anthropic_response`
2919
+ # itself stays synchronous (many callers depend on it).
2920
+ resp = await asyncio.to_thread(
2921
+ to_anthropic_response, chat_resp, allowed_tool_names=tool_names
2922
+ )
2915
2923
  except AdapterError as exc:
2916
2924
  # v1.9-C: record the failure with its observed latency.
2917
2925
  # Auth-flavored failures (401 / 403) carry no useful
@@ -3223,7 +3231,11 @@ class FallbackEngine:
3223
3231
  chat_req = to_chat_request(effective_request)
3224
3232
  chat_req.stream = False
3225
3233
  chat_resp = await adapter.generate(chat_req, overrides=overrides)
3226
- anth_resp = to_anthropic_response(chat_resp, allowed_tool_names=tool_names)
3234
+ # H-4: same as the non-streaming branch — keep the
3235
+ # CPU-bound repair scan off the event loop.
3236
+ anth_resp = await asyncio.to_thread(
3237
+ to_anthropic_response, chat_resp, allowed_tool_names=tool_names
3238
+ )
3227
3239
  event_iter = synthesize_anthropic_stream_from_response(anth_resp)
3228
3240
  first = await anext(event_iter)
3229
3241
  else:
@@ -321,7 +321,20 @@ class AnthropicResponse(BaseModel):
321
321
  role: Literal["assistant"] = "assistant"
322
322
  model: str
323
323
  content: list[dict[str, Any]]
324
- stop_reason: Literal["end_turn", "max_tokens", "stop_sequence", "tool_use"] | None = None
324
+ # H-6: intentionally `str | None`, NOT a closed `Literal[...]`. Anthropic
325
+ # adds new stop_reason values over time (observed additions: pause_turn
326
+ # for long-running server-side-tool turns, refusal for safety-classifier
327
+ # stops, model_context_window_exceeded); a closed Literal rejects any
328
+ # response using a value minted after this code was written, which
329
+ # pydantic surfaces as a ValidationError that the adapter turns into a
330
+ # retryable AdapterError — silently discarding an already-billed,
331
+ # perfectly valid response and falling back to another provider. Known
332
+ # values as of writing: end_turn, max_tokens, stop_sequence, tool_use,
333
+ # pause_turn, refusal, model_context_window_exceeded. Other models in
334
+ # this module already use `extra="allow"` for the same forward-
335
+ # compatibility reason; this keeps that policy consistent for a
336
+ # declared field, which `extra="allow"` alone does not cover.
337
+ stop_reason: str | None = None
325
338
  stop_sequence: str | None = None
326
339
  usage: AnthropicUsage = Field(default_factory=AnthropicUsage)
327
340
 
@@ -846,6 +846,13 @@ _REVERSE_FINISH_REASON_MAP = {
846
846
  "max_tokens": "length",
847
847
  "tool_use": "tool_calls",
848
848
  "stop_sequence": "stop",
849
+ # H-6: forward-compat stop_reason values (see translation/anthropic.py).
850
+ # The `.get(..., "stop")` fallback at each call site already keeps these
851
+ # from raising; mapping them explicitly just makes the OpenAI-shaped
852
+ # finish_reason semantically accurate instead of a generic "stop".
853
+ "pause_turn": "stop",
854
+ "refusal": "content_filter",
855
+ "model_context_window_exceeded": "length",
849
856
  }
850
857
 
851
858