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.
- coderouter/config/schemas.py +50 -0
- coderouter/ingress/launcher_routes.py +796 -3
- coderouter/launcher_devices.py +518 -0
- coderouter/translation/anthropic.py +26 -5
- {coderouter_cli-2.9.3.dist-info → coderouter_cli-2.9.4.dist-info}/METADATA +2 -2
- {coderouter_cli-2.9.3.dist-info → coderouter_cli-2.9.4.dist-info}/RECORD +9 -8
- {coderouter_cli-2.9.3.dist-info → coderouter_cli-2.9.4.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.9.3.dist-info → coderouter_cli-2.9.4.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.9.3.dist-info → coderouter_cli-2.9.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"""llama.cpp デバイス検出 + tensor-split 提案 + ベンチスイープ・コアロジック。
|
|
2
|
+
|
|
3
|
+
GUI版 (launcher_gui.py) と Web版 (ingress/launcher_routes.py) の双方から
|
|
4
|
+
共有する純粋ロジック層。実際の起動/停止/readiness はランタイムモデルが
|
|
5
|
+
異なる (threading vs asyncio) ため各側で薄く実装し、ここには「データ構造 +
|
|
6
|
+
純関数 + best-effort な検出プリミティブ」だけを置く。hardware.py の
|
|
7
|
+
5-deps 不変則(標準ライブラリのみ・best-effort・失敗は握りつぶして degrade)
|
|
8
|
+
に合わせる。
|
|
9
|
+
|
|
10
|
+
このモジュールは pydantic を使わず dataclass のみで構成する。standalone
|
|
11
|
+
GUI(coderouter パッケージ非同梱)からも import できることが要件で、かつ
|
|
12
|
+
検出・提案・展開・解析はいずれも純粋関数なので、外部依存を持たせない。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import shlex
|
|
22
|
+
import socket
|
|
23
|
+
import subprocess # controlled: fixed argv, no shell
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from collections.abc import Callable, Sequence
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from enum import StrEnum
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"DeviceProbe",
|
|
34
|
+
"DeviceSelection",
|
|
35
|
+
"LlamaDevice",
|
|
36
|
+
"SweepPlan",
|
|
37
|
+
"SweepState",
|
|
38
|
+
"SweepStep",
|
|
39
|
+
"backend_of",
|
|
40
|
+
"build_auto_sweep_configs",
|
|
41
|
+
"build_sweep_steps",
|
|
42
|
+
"detect_llama_devices",
|
|
43
|
+
"group_by_backend",
|
|
44
|
+
"is_port_free",
|
|
45
|
+
"load_latest_results",
|
|
46
|
+
"parse_list_devices",
|
|
47
|
+
"render_bench_command",
|
|
48
|
+
"reset_device_cache",
|
|
49
|
+
"selectable_devices",
|
|
50
|
+
"suggest_tensor_split",
|
|
51
|
+
"summarize_results",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# 2.1 デバイス検出
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
# llama.cpp の --list-devices は ggml バックエンドを問わず
|
|
60
|
+
# "<name>: <description> (<total> MiB, <free> MiB free)"
|
|
61
|
+
# の共通形式で出力する(ggml_backend_dev_name / _description / メモリ)。
|
|
62
|
+
# バックエンド名は CUDA0 / Metal / Vulkan0 / SYCL0 / CPU 等さまざま
|
|
63
|
+
# なので id は \S+ で汎用に受ける(CUDA 固定にしない)。行末は $ で
|
|
64
|
+
# 固定せず余分な後置トークンにも耐える。
|
|
65
|
+
# name(description)は貪欲最小 (.+?) で受け、メモリ括弧は「行末側の
|
|
66
|
+
# (<total> MiB, <free> MiB free)」に固定する。これにより description 中の
|
|
67
|
+
# 入れ子括弧(例 "AMD Radeon Graphics (RADV GFX1151)")を巻き込まず、
|
|
68
|
+
# 最後のメモリ括弧だけを取り出す。
|
|
69
|
+
# 例(実機出力):
|
|
70
|
+
# " CUDA0: NVIDIA GeForce RTX 5090 (32149 MiB, 31610 MiB free)" (Linux/Win CUDA)
|
|
71
|
+
# " Vulkan2: AMD Radeon Graphics (RADV GFX1151) (114166 MiB, 113602 MiB free)" (入れ子括弧)
|
|
72
|
+
# " MTL0: Apple M3 Max (53084 MiB, 53083 MiB free)" (macOS Metal, id は MTL0)
|
|
73
|
+
# " BLAS: Accelerate (0 MiB, 0 MiB free)" (0 MiB=選択不可・情報のみ)
|
|
74
|
+
# " SYCL0: Intel Arc ... (16384 MiB, 16000 MiB free)" (SYCL)
|
|
75
|
+
# id は末尾数字を含む物理バックエンド名(CUDA0/Vulkan1/MTL0/SYCL0/BLAS/CPU)。
|
|
76
|
+
# 同一物理 GPU が CUDA0 と Vulkan1 のように複数バックエンドで重複列挙される
|
|
77
|
+
# ことがある(backend_of / group_by_backend でグループ化)。
|
|
78
|
+
_DEVICE_LINE_RE = re.compile(
|
|
79
|
+
r"^\s+(?P<id>\S+):\s+(?P<name>.+?)\s+"
|
|
80
|
+
r"\((?P<total>\d+)\s*MiB,\s+(?P<free>\d+)\s*MiB\s+free\)"
|
|
81
|
+
)
|
|
82
|
+
_LIST_DEVICES_TIMEOUT_S = 5.0
|
|
83
|
+
_DEVICE_CACHE_TTL_S = 60.0
|
|
84
|
+
_MIB_PER_GB = 1024.0 # MiB→GiB 表示用(1024 MiB ≒ 1 GiB)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class LlamaDevice:
|
|
89
|
+
"""``llama-server --list-devices`` の 1 行分。"""
|
|
90
|
+
|
|
91
|
+
id: str # 例 "CUDA0" — そのまま --device に渡す名前(--list-devices が正)
|
|
92
|
+
name: str # 例 "NVIDIA GeForce RTX 5090"
|
|
93
|
+
total_mib: int
|
|
94
|
+
free_mib: int
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def total_gb(self) -> float:
|
|
98
|
+
return round(self.total_mib / _MIB_PER_GB, 1)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def free_gb(self) -> float:
|
|
102
|
+
return round(self.free_mib / _MIB_PER_GB, 1)
|
|
103
|
+
|
|
104
|
+
def as_dict(self) -> dict[str, Any]:
|
|
105
|
+
return {
|
|
106
|
+
"id": self.id,
|
|
107
|
+
"name": self.name,
|
|
108
|
+
"total_mib": self.total_mib,
|
|
109
|
+
"free_mib": self.free_mib,
|
|
110
|
+
"total_gb": self.total_gb,
|
|
111
|
+
"free_gb": self.free_gb,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True, slots=True)
|
|
116
|
+
class DeviceProbe:
|
|
117
|
+
"""検出結果。ok=False のとき UI は手入力フォールバックへ。"""
|
|
118
|
+
|
|
119
|
+
devices: list[LlamaDevice]
|
|
120
|
+
ok: bool
|
|
121
|
+
error: str | None = None
|
|
122
|
+
raw: str = ""
|
|
123
|
+
|
|
124
|
+
def as_dict(self) -> dict[str, Any]:
|
|
125
|
+
return {
|
|
126
|
+
"ok": self.ok,
|
|
127
|
+
"error": self.error,
|
|
128
|
+
"devices": [d.as_dict() for d in self.devices],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_list_devices(output: str) -> list[LlamaDevice]:
|
|
133
|
+
"""``--list-devices`` 標準出力をパース。
|
|
134
|
+
|
|
135
|
+
マッチしない行は無視する(前後の ``Available devices:`` ヘッダや将来の
|
|
136
|
+
追記、VRAM 表記の無い CPU 行に耐える)。
|
|
137
|
+
"""
|
|
138
|
+
devices: list[LlamaDevice] = []
|
|
139
|
+
for line in output.splitlines():
|
|
140
|
+
m = _DEVICE_LINE_RE.match(line)
|
|
141
|
+
if not m:
|
|
142
|
+
continue
|
|
143
|
+
devices.append(
|
|
144
|
+
LlamaDevice(
|
|
145
|
+
id=m["id"],
|
|
146
|
+
name=m["name"].strip(),
|
|
147
|
+
total_mib=int(m["total"]),
|
|
148
|
+
free_mib=int(m["free"]),
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
return devices
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ── キャッシュ(hardware.py と同じ RLock+TTL パターン。binary パス別) ──
|
|
155
|
+
_cache_lock = threading.RLock()
|
|
156
|
+
_cache: dict[str, tuple[float, DeviceProbe]] = {}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def detect_llama_devices(
|
|
160
|
+
binary: str,
|
|
161
|
+
*,
|
|
162
|
+
timeout_s: float = _LIST_DEVICES_TIMEOUT_S,
|
|
163
|
+
use_cache: bool = True,
|
|
164
|
+
runner: Callable[[], subprocess.CompletedProcess[str]] | None = None,
|
|
165
|
+
) -> DeviceProbe:
|
|
166
|
+
"""``{binary} --list-devices`` を実行してデバイス一覧を返す。
|
|
167
|
+
|
|
168
|
+
失敗(バイナリ無し/タイムアウト/非ゼロ終了/パース 0 件)時は
|
|
169
|
+
``ok=False`` の :class:`DeviceProbe` を返し、呼び出し側は手入力へ
|
|
170
|
+
フォールバックする。``runner`` はテスト用に ``subprocess.run`` を
|
|
171
|
+
差し替えるフック。
|
|
172
|
+
"""
|
|
173
|
+
key = str(Path(binary).expanduser())
|
|
174
|
+
now = time.monotonic()
|
|
175
|
+
if use_cache:
|
|
176
|
+
with _cache_lock:
|
|
177
|
+
hit = _cache.get(key)
|
|
178
|
+
if hit and (now - hit[0]) < _DEVICE_CACHE_TTL_S:
|
|
179
|
+
return hit[1]
|
|
180
|
+
|
|
181
|
+
def _default_runner() -> subprocess.CompletedProcess[str]:
|
|
182
|
+
return subprocess.run( # fixed argv, no shell
|
|
183
|
+
[key, "--list-devices"],
|
|
184
|
+
capture_output=True,
|
|
185
|
+
text=True,
|
|
186
|
+
encoding="utf-8",
|
|
187
|
+
errors="replace", # ★ Windows cp932 対策
|
|
188
|
+
timeout=timeout_s,
|
|
189
|
+
check=False,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
run = runner or _default_runner
|
|
193
|
+
try:
|
|
194
|
+
out = run()
|
|
195
|
+
raw = (out.stdout or "") + (out.stderr or "") # 版により stderr 出力
|
|
196
|
+
devices = parse_list_devices(raw)
|
|
197
|
+
if not devices:
|
|
198
|
+
probe = DeviceProbe(
|
|
199
|
+
[], ok=False, error="デバイスを検出できませんでした", raw=raw
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
probe = DeviceProbe(devices, ok=True, raw=raw)
|
|
203
|
+
except FileNotFoundError:
|
|
204
|
+
probe = DeviceProbe([], ok=False, error=f"バイナリが見つかりません: {key}")
|
|
205
|
+
except subprocess.TimeoutExpired:
|
|
206
|
+
probe = DeviceProbe([], ok=False, error="--list-devices がタイムアウトしました")
|
|
207
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
208
|
+
probe = DeviceProbe([], ok=False, error=str(exc))
|
|
209
|
+
|
|
210
|
+
if use_cache:
|
|
211
|
+
with _cache_lock:
|
|
212
|
+
_cache[key] = (now, probe)
|
|
213
|
+
return probe
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def reset_device_cache() -> None:
|
|
217
|
+
"""テスト用。検出キャッシュを破棄。"""
|
|
218
|
+
with _cache_lock:
|
|
219
|
+
_cache.clear()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# 2.2 デバイス選択 → CLI
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _fmt_split(x: float) -> str:
|
|
228
|
+
return f"{x:g}" # 0.57 / 0.43 のように末尾ゼロを落とす
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass
|
|
232
|
+
class DeviceSelection:
|
|
233
|
+
"""通常起動 / スイープ 1 構成のデバイス指定。"""
|
|
234
|
+
|
|
235
|
+
device_ids: list[str] = field(default_factory=list) # ["CUDA0","CUDA1"]
|
|
236
|
+
tensor_split: list[float] = field(default_factory=list)
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def active(self) -> bool:
|
|
240
|
+
return bool(self.device_ids)
|
|
241
|
+
|
|
242
|
+
def to_cli_args(self) -> list[str]:
|
|
243
|
+
"""llama.cpp 用 CLI 断片。選択が無ければ空(=現状挙動を完全維持)。"""
|
|
244
|
+
if not self.device_ids:
|
|
245
|
+
return [] # ★ 未選択時は何も足さない
|
|
246
|
+
args = ["--device", ",".join(self.device_ids)]
|
|
247
|
+
if len(self.device_ids) > 1 and self.tensor_split:
|
|
248
|
+
args += ["--tensor-split", ",".join(_fmt_split(x) for x in self.tensor_split)]
|
|
249
|
+
return args
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# 2.3 tensor-split 自動提案
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def suggest_tensor_split(
|
|
258
|
+
devices: Sequence[LlamaDevice],
|
|
259
|
+
*,
|
|
260
|
+
by: str = "total", # "total" | "free"
|
|
261
|
+
decimals: int = 2,
|
|
262
|
+
) -> list[float]:
|
|
263
|
+
"""VRAM 比の tensor-split を提案。
|
|
264
|
+
|
|
265
|
+
合計が丸め後もちょうど 1.0 になるよう最後の要素で辻褄を合わせる
|
|
266
|
+
(llama.cpp 側でも正規化されるが表示の一貫性のため)。単一デバイス
|
|
267
|
+
(Metal 単体・単基 CUDA 等)は ``[]`` を返し tensor-split を提案しない。
|
|
268
|
+
"""
|
|
269
|
+
if len(devices) <= 1:
|
|
270
|
+
return []
|
|
271
|
+
caps = [float(d.free_mib if by == "free" else d.total_mib) for d in devices]
|
|
272
|
+
total = sum(caps)
|
|
273
|
+
if total <= 0:
|
|
274
|
+
return []
|
|
275
|
+
props = [round(c / total, decimals) for c in caps]
|
|
276
|
+
props[-1] = round(1.0 - sum(props[:-1]), decimals) # 丸め誤差を末尾に集約
|
|
277
|
+
return props
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
# 2.3b デバイス分類ヘルパ(選択可否 / バックエンドグループ化)
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
# 末尾の連番(CUDA0→CUDA / Vulkan12→Vulkan / MTL0→MTL)を落として
|
|
285
|
+
# 「バックエンド接頭辞」を得る。数字を持たない BLAS / CPU / Metal は
|
|
286
|
+
# そのまま返す。
|
|
287
|
+
_BACKEND_SUFFIX_RE = re.compile(r"\d+$")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def selectable_devices(devices: Sequence[LlamaDevice]) -> list[LlamaDevice]:
|
|
291
|
+
"""GPU オフロード先として選べるデバイスだけを返す。
|
|
292
|
+
|
|
293
|
+
``total_mib == 0`` のデバイス(macOS の ``BLAS: Accelerate (0 MiB, ...)``
|
|
294
|
+
等)は VRAM を持たず tensor-split やスイープ構成に参加できないため除外
|
|
295
|
+
する。一覧表示からは落とさない(:func:`parse_list_devices` は情報として
|
|
296
|
+
全行を返す)——本関数は「選択・提案・スイープ自動生成」の入力を絞るための
|
|
297
|
+
フィルタ。
|
|
298
|
+
"""
|
|
299
|
+
return [d for d in devices if d.total_mib > 0]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def backend_of(device_id: str) -> str:
|
|
303
|
+
"""デバイス id からバックエンド接頭辞を返す。
|
|
304
|
+
|
|
305
|
+
``"CUDA0"→"CUDA"`` / ``"Vulkan2"→"Vulkan"`` / ``"MTL0"→"MTL"`` /
|
|
306
|
+
``"SYCL0"→"SYCL"``。末尾に数字を持たない ``"BLAS"`` / ``"CPU"`` /
|
|
307
|
+
``"Metal"`` はそのまま返す。同一物理 GPU が CUDA と Vulkan の両方で
|
|
308
|
+
列挙されるケースをバックエンド単位で束ねるための正規化。
|
|
309
|
+
"""
|
|
310
|
+
return _BACKEND_SUFFIX_RE.sub("", device_id)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def group_by_backend(
|
|
314
|
+
devices: Sequence[LlamaDevice],
|
|
315
|
+
) -> dict[str, list[LlamaDevice]]:
|
|
316
|
+
"""デバイスをバックエンド接頭辞ごとにグループ化する(挿入順を保持)。"""
|
|
317
|
+
groups: dict[str, list[LlamaDevice]] = {}
|
|
318
|
+
for d in devices:
|
|
319
|
+
groups.setdefault(backend_of(d.id), []).append(d)
|
|
320
|
+
return groups
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def build_auto_sweep_configs(
|
|
324
|
+
devices: Sequence[LlamaDevice],
|
|
325
|
+
*,
|
|
326
|
+
by: str = "total",
|
|
327
|
+
) -> list[tuple[str, DeviceSelection]]:
|
|
328
|
+
"""検出デバイスからスイープ構成(ラベル + :class:`DeviceSelection`)を自動生成。
|
|
329
|
+
|
|
330
|
+
生成規則:
|
|
331
|
+
1. **単体構成**: selectable な各デバイスにつき 1 構成(``"CUDA0 単体"`` 等)。
|
|
332
|
+
2. **バックエンド内複数枚構成**: 同一バックエンド(:func:`backend_of`)に
|
|
333
|
+
selectable が 2 枚以上あるバックエンドのみ、その全枚数を束ねた 1 構成を
|
|
334
|
+
追加し、そのバックエンド内 VRAM 比で tensor-split を自動提案。
|
|
335
|
+
|
|
336
|
+
**バックエンド跨ぎの混成構成は自動生成しない**(CUDA と Vulkan、あるいは
|
|
337
|
+
同一物理 GPU が両バックエンドで重複列挙されるケースを混ぜない)。手動での
|
|
338
|
+
任意選択は :class:`DeviceSelection` を直接構築すれば妨げられない。
|
|
339
|
+
``total_mib == 0`` のデバイス(BLAS 等)は :func:`selectable_devices` で
|
|
340
|
+
除外され、単体構成にもグループにも入らない。
|
|
341
|
+
"""
|
|
342
|
+
sel = selectable_devices(devices)
|
|
343
|
+
configs: list[tuple[str, DeviceSelection]] = [
|
|
344
|
+
(f"{d.id} 単体", DeviceSelection(device_ids=[d.id])) for d in sel
|
|
345
|
+
]
|
|
346
|
+
for backend, members in group_by_backend(sel).items():
|
|
347
|
+
if len(members) < 2:
|
|
348
|
+
continue
|
|
349
|
+
ids = [m.id for m in members]
|
|
350
|
+
split = suggest_tensor_split(members, by=by)
|
|
351
|
+
configs.append(
|
|
352
|
+
(
|
|
353
|
+
f"{backend} x{len(members)}",
|
|
354
|
+
DeviceSelection(device_ids=ids, tensor_split=split),
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
return configs
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
# 2.4 スイープ計画・状態
|
|
362
|
+
# ---------------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class SweepState(StrEnum):
|
|
366
|
+
PENDING = "pending"
|
|
367
|
+
STARTING = "starting" # サーバ起動→readiness 待ち
|
|
368
|
+
BENCHING = "benching" # 外部ベンチ実行中
|
|
369
|
+
DONE = "done"
|
|
370
|
+
FAILED = "failed"
|
|
371
|
+
ABORTED = "aborted"
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@dataclass
|
|
375
|
+
class SweepStep:
|
|
376
|
+
label: str # "CUDA0 単体" 等(表示 + {config} 置換に使用)
|
|
377
|
+
selection: DeviceSelection
|
|
378
|
+
state: SweepState = SweepState.PENDING
|
|
379
|
+
bench_exit_code: int | None = None
|
|
380
|
+
results_path: str | None = None # 読めた llmbench results JSON
|
|
381
|
+
summary: dict[str, Any] | None = None # 抽出済み主要メトリクス
|
|
382
|
+
error: str | None = None
|
|
383
|
+
started_at: float = 0.0
|
|
384
|
+
ended_at: float = 0.0
|
|
385
|
+
|
|
386
|
+
def as_dict(self) -> dict[str, Any]:
|
|
387
|
+
return {
|
|
388
|
+
"label": self.label,
|
|
389
|
+
"device_ids": self.selection.device_ids,
|
|
390
|
+
"tensor_split": self.selection.tensor_split,
|
|
391
|
+
"state": self.state.value,
|
|
392
|
+
"bench_exit_code": self.bench_exit_code,
|
|
393
|
+
"results_path": self.results_path,
|
|
394
|
+
"summary": self.summary,
|
|
395
|
+
"error": self.error,
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@dataclass
|
|
400
|
+
class SweepPlan:
|
|
401
|
+
steps: list[SweepStep]
|
|
402
|
+
model_path: str
|
|
403
|
+
backend: str # 当面 "llama.cpp"
|
|
404
|
+
port: int
|
|
405
|
+
bench_cmd_template: str
|
|
406
|
+
results_dir: str | None = None
|
|
407
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
408
|
+
extra_args: str = ""
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def build_sweep_steps(
|
|
412
|
+
labeled: Sequence[tuple[str, DeviceSelection]],
|
|
413
|
+
) -> list[SweepStep]:
|
|
414
|
+
return [SweepStep(label=lbl, selection=sel) for lbl, sel in labeled]
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
# 2.5 ベンチコマンド展開
|
|
419
|
+
# ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
_BENCH_PLACEHOLDERS = ("port", "config", "base_url", "results_dir", "runs")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def render_bench_command(
|
|
425
|
+
template: str,
|
|
426
|
+
*,
|
|
427
|
+
port: int,
|
|
428
|
+
config_label: str,
|
|
429
|
+
results_dir: str | None = None,
|
|
430
|
+
runs: int | None = None,
|
|
431
|
+
) -> list[str]:
|
|
432
|
+
"""テンプレ内の {port} {config} {base_url} {results_dir} {runs} を置換して
|
|
433
|
+
argv 化。
|
|
434
|
+
|
|
435
|
+
``str.format`` は JSON 波括弧等で誤爆するため単純置換を使う。
|
|
436
|
+
"""
|
|
437
|
+
mapping = {
|
|
438
|
+
"port": str(port),
|
|
439
|
+
"config": config_label,
|
|
440
|
+
"base_url": f"http://localhost:{port}/v1",
|
|
441
|
+
"results_dir": results_dir or "",
|
|
442
|
+
"runs": str(runs) if runs is not None else "",
|
|
443
|
+
}
|
|
444
|
+
rendered = template
|
|
445
|
+
for k, v in mapping.items():
|
|
446
|
+
rendered = rendered.replace("{" + k + "}", v)
|
|
447
|
+
# ★ Windows: shlex の POSIX モードはバックスラッシュをエスケープ文字
|
|
448
|
+
# として食い潰し `C:\tools\llmbench.exe` を壊す。os.name で分岐して
|
|
449
|
+
# Windows では posix=False(バックスラッシュを素通し)にする。
|
|
450
|
+
return shlex.split(rendered, posix=(os.name != "nt"))
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ---------------------------------------------------------------------------
|
|
454
|
+
# 2.6 results 解析
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def load_latest_results(
|
|
459
|
+
results_dir: str | Path,
|
|
460
|
+
*,
|
|
461
|
+
since: float,
|
|
462
|
+
) -> tuple[str | None, dict[str, Any] | None]:
|
|
463
|
+
"""``results_dir`` 内で mtime>=since の最新 ``*.json`` を読む。
|
|
464
|
+
|
|
465
|
+
``(path, summary)`` を返す。読めなければ ``(None, None)``。llmbench の
|
|
466
|
+
スキーマに強く依存せず、既知キーを防御的に拾う。
|
|
467
|
+
"""
|
|
468
|
+
base = Path(results_dir).expanduser()
|
|
469
|
+
if not base.is_dir():
|
|
470
|
+
return None, None
|
|
471
|
+
cands = [p for p in base.glob("*.json") if p.stat().st_mtime >= since - 1.0]
|
|
472
|
+
if not cands:
|
|
473
|
+
return None, None
|
|
474
|
+
newest = max(cands, key=lambda p: p.stat().st_mtime)
|
|
475
|
+
try:
|
|
476
|
+
data = json.loads(newest.read_text(encoding="utf-8"))
|
|
477
|
+
except (OSError, ValueError):
|
|
478
|
+
return str(newest), None
|
|
479
|
+
return str(newest), summarize_results(data)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# llmbench の JSON スキーマは変わりうるので、複数の別名を許容して拾う
|
|
483
|
+
_SUMMARY_KEYS = {
|
|
484
|
+
"tokens_per_sec": ("tokens_per_sec", "tok_s", "throughput", "tps"),
|
|
485
|
+
"ttft_ms": ("ttft_ms", "time_to_first_token_ms", "ttft"),
|
|
486
|
+
"latency_ms": ("latency_ms", "avg_latency_ms", "latency"),
|
|
487
|
+
"runs": ("runs", "n_runs", "count"),
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def summarize_results(data: dict[str, Any]) -> dict[str, Any]:
|
|
492
|
+
"""llmbench results JSON から比較用の主要メトリクスを best-effort 抽出。"""
|
|
493
|
+
flat = data.get("summary", data) if isinstance(data, dict) else {}
|
|
494
|
+
out: dict[str, Any] = {}
|
|
495
|
+
for canon, aliases in _SUMMARY_KEYS.items():
|
|
496
|
+
for a in aliases:
|
|
497
|
+
if isinstance(flat, dict) and a in flat:
|
|
498
|
+
out[canon] = flat[a]
|
|
499
|
+
break
|
|
500
|
+
out["_raw_keys"] = sorted(flat.keys()) if isinstance(flat, dict) else []
|
|
501
|
+
return out
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
# ---------------------------------------------------------------------------
|
|
505
|
+
# 2.7 ポート補助
|
|
506
|
+
# ---------------------------------------------------------------------------
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def is_port_free(port: int, host: str = "127.0.0.1") -> bool:
|
|
510
|
+
"""best-effort な空きポート判定(TOCTOU 窓は残る)。"""
|
|
511
|
+
with (
|
|
512
|
+
contextlib.suppress(OSError),
|
|
513
|
+
socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s,
|
|
514
|
+
):
|
|
515
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
516
|
+
s.bind((host, port))
|
|
517
|
+
return True
|
|
518
|
+
return False
|
|
@@ -143,9 +143,18 @@ def normalize_message_roles(payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
143
143
|
(see anthropics/claude-code#63469, vllm-project/vllm#44000).
|
|
144
144
|
|
|
145
145
|
Policy:
|
|
146
|
-
- ``role: "system"``
|
|
147
|
-
|
|
148
|
-
same join rule as
|
|
146
|
+
- leading ``role: "system"`` (before any user/assistant turn) →
|
|
147
|
+
text content merged into the top-level ``system`` field
|
|
148
|
+
(appended after any existing system prompt; same join rule as
|
|
149
|
+
``convert.to_anthropic_request``). This is a genuine system
|
|
150
|
+
prompt from an OpenAI-style client, and it stays at position 0.
|
|
151
|
+
- mid-conversation ``role: "system"`` → coerced to ``user`` **in
|
|
152
|
+
place**, like any other non-spec role. Claude Code's
|
|
153
|
+
system-reminders are volatile per-turn text; hoisting them to
|
|
154
|
+
the top-level field moves content that changes every turn ahead
|
|
155
|
+
of the whole conversation, which invalidates the prefix cache of
|
|
156
|
+
local backends (llama.cpp / LM Studio) and forces a full prompt
|
|
157
|
+
reprocess on every request.
|
|
149
158
|
- any other non-spec role (``ctx``, ``msg``, ...) → coerced to
|
|
150
159
|
``user`` so conversation position is preserved. Anthropic
|
|
151
160
|
merges consecutive same-role turns, so this is safe.
|
|
@@ -175,9 +184,21 @@ def normalize_message_roles(payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
175
184
|
continue
|
|
176
185
|
if role == "system":
|
|
177
186
|
text = _content_as_text(msg.get("content"))
|
|
178
|
-
if text:
|
|
179
|
-
system_texts.append(text)
|
|
180
187
|
coerced_roles.append("system")
|
|
188
|
+
if text:
|
|
189
|
+
if not messages_out:
|
|
190
|
+
# Leading system message — a genuine system prompt from an
|
|
191
|
+
# OpenAI-style client. Hoisting to the top-level field is
|
|
192
|
+
# correct and prefix-safe (it stays at position 0).
|
|
193
|
+
system_texts.append(text)
|
|
194
|
+
else:
|
|
195
|
+
# Mid-conversation system message — volatile per-turn text
|
|
196
|
+
# (Claude Code >= 2.1.154 system-reminders). Keep it where
|
|
197
|
+
# the client put it. Hoisting moves text that changes every
|
|
198
|
+
# turn ahead of the entire conversation, which invalidates
|
|
199
|
+
# the prefix cache of local backends (llama.cpp / LM Studio)
|
|
200
|
+
# and forces a full prompt reprocess on every request.
|
|
201
|
+
messages_out.append({"role": "user", "content": text})
|
|
181
202
|
continue
|
|
182
203
|
# Unknown role (ctx / msg / future surprises): keep its position
|
|
183
204
|
# in the conversation as a user turn; drop if nothing salvageable.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coderouter-cli
|
|
3
|
-
Version: 2.9.
|
|
3
|
+
Version: 2.9.4
|
|
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
|
|
@@ -40,7 +40,7 @@ Requires-Dist: types-pyyaml>=6.0.12; extra == 'dev'
|
|
|
40
40
|
Provides-Extra: doctor
|
|
41
41
|
Requires-Dist: ruamel-yaml>=0.18.6; extra == 'doctor'
|
|
42
42
|
Provides-Extra: repair
|
|
43
|
-
Requires-Dist: json-repair>=0.
|
|
43
|
+
Requires-Dist: json-repair>=0.60.1; extra == 'repair'
|
|
44
44
|
Description-Content-Type: text/markdown
|
|
45
45
|
|
|
46
46
|
<h1 align="center">CodeRouter</h1>
|
|
@@ -10,6 +10,7 @@ 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
|
|
13
14
|
coderouter/launcher_speculative.py,sha256=kWaHNmhzBsbWuM_lRGDlbWkgJ1suuxBWQNTKhJOC-yg,12644
|
|
14
15
|
coderouter/launcher_swap.py,sha256=W7qpdB-8Npr8yHGfx0N3SgZ2vDJ6BR9JuxW0I3TaZ-4,23783
|
|
15
16
|
coderouter/logging.py,sha256=5rfi4_R4aoeeUYDc642dgs61wA0aBX7wvubyB-k3Uyo,58074
|
|
@@ -25,7 +26,7 @@ coderouter/config/__init__.py,sha256=FODEn74fN-qZnt4INPSHswqhOlEgpL6-_onxsitSx8g
|
|
|
25
26
|
coderouter/config/capability_registry.py,sha256=RZgzHF54dXy0CCDocRCz2ee21qXGbbgTSKTkRI9iLL4,16936
|
|
26
27
|
coderouter/config/env_file.py,sha256=CoMK27fuAXm-NtoLzXb8yN2E-wDFjHQuFwiIlmgTBQw,10356
|
|
27
28
|
coderouter/config/loader.py,sha256=FUEe8m4Tnmj_aul0vSctD8vKvNW-oLRoMRbTpSKqSmc,4077
|
|
28
|
-
coderouter/config/schemas.py,sha256=
|
|
29
|
+
coderouter/config/schemas.py,sha256=wHaOqbehppjtL-01eLh8DNmm0A3rZXdJ-cNbBcS2yM8,120984
|
|
29
30
|
coderouter/data/__init__.py,sha256=uNyfD9jaCvTWsBAWtaw1Fr25OSxzv3psGMfBjT1z0Cc,328
|
|
30
31
|
coderouter/data/model-capabilities.yaml,sha256=S9jt6SC6-3s2-icZ_n-a14iEMnc2yB1C2R6q-N_tZWQ,19309
|
|
31
32
|
coderouter/guards/__init__.py,sha256=5qliYBqygvVPneej7nx0uSjxDKsz7t8VzvrDgVBJlvU,1170
|
|
@@ -43,7 +44,7 @@ coderouter/ingress/__init__.py,sha256=WQsCH2CGJCAhy0mS6GSEdeYZRkkQu2OHDsP4CJWTLu
|
|
|
43
44
|
coderouter/ingress/anthropic_routes.py,sha256=1ThXzx8VgxRV7UCi3NF5ey7kbr0ADPKTXyQuYS5gK1w,27063
|
|
44
45
|
coderouter/ingress/app.py,sha256=6e_qNlYZIyjLKhY20t0ZNL-x5Fv8R8oaIH_HCdxa9Ic,20921
|
|
45
46
|
coderouter/ingress/dashboard_routes.py,sha256=1SqNNrVTfQiZcx5n2MvjAzWirittX0e61tCt-MKp4Ag,25617
|
|
46
|
-
coderouter/ingress/launcher_routes.py,sha256=
|
|
47
|
+
coderouter/ingress/launcher_routes.py,sha256=lU2EILVAi-lvHBHIxyixsMTRZlp-Rv7-2sw-eW4ccYg,119687
|
|
47
48
|
coderouter/ingress/metrics_routes.py,sha256=M22dwOGn24P05Ge4W3c7d7mYytSGWjIR-pPSPOAiHJY,3965
|
|
48
49
|
coderouter/ingress/openai_routes.py,sha256=TrugsQNJfNPgKKLzfT1aXzrs4emWYGE4XebEBuaZoWk,12505
|
|
49
50
|
coderouter/metrics/__init__.py,sha256=7Es351DPS7yLM0yVF_F0eesmiD83n7Zzhie44chht38,1465
|
|
@@ -66,11 +67,11 @@ coderouter/state/request_log.py,sha256=mNxQJ9IDGpUtT5rZbCo_-CTyhxmvig7C2Gfvdn9rc
|
|
|
66
67
|
coderouter/state/store.py,sha256=h-rsMJq8GILsOfCP94nI40cuHaj4Vqycsm9UNN77REI,7445
|
|
67
68
|
coderouter/state/suggest_rules.py,sha256=FvdhEvao5NvdKp9zs8AkcoFKHY4yqqXY2HekvSjpDFA,16670
|
|
68
69
|
coderouter/translation/__init__.py,sha256=PYXN7XVEwpG1uC8RLy6fvnGbzEZhhrEuUapH8IYOtG8,1788
|
|
69
|
-
coderouter/translation/anthropic.py,sha256=
|
|
70
|
+
coderouter/translation/anthropic.py,sha256=uEScq-icLn6ZXFWaxsKROv7feaq3zD0ZogjbTJ3Y7gQ,12578
|
|
70
71
|
coderouter/translation/convert.py,sha256=VV4kpu4dvLmGBmJfQnCLY5ryy_AnQcV2NuwjqRtaR8Q,52633
|
|
71
72
|
coderouter/translation/tool_repair.py,sha256=DGOKArM53yfVbIfiAveoul1vGq9NEKmjagf43Qh4IkM,57630
|
|
72
|
-
coderouter_cli-2.9.
|
|
73
|
-
coderouter_cli-2.9.
|
|
74
|
-
coderouter_cli-2.9.
|
|
75
|
-
coderouter_cli-2.9.
|
|
76
|
-
coderouter_cli-2.9.
|
|
73
|
+
coderouter_cli-2.9.4.dist-info/METADATA,sha256=E7EiSxxEYfkchQLvqlvdQzADFsnwbReGwi0Patjfk8I,15996
|
|
74
|
+
coderouter_cli-2.9.4.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
75
|
+
coderouter_cli-2.9.4.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
|
|
76
|
+
coderouter_cli-2.9.4.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
|
|
77
|
+
coderouter_cli-2.9.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|