coderouter-cli 2.11.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.
@@ -332,6 +332,18 @@ def merge_capabilities_rule_into_doc(doc: Any, patch_dict: dict[str, Any]) -> bo
332
332
  # ---------------------------------------------------------------------------
333
333
 
334
334
 
335
+ # ruamel's emitter defaults to folding at 80 columns: any quoted scalar
336
+ # longer than that gets wrapped onto a continuation line on dump, even
337
+ # when the operator never touched it. That reformat is invisible to the
338
+ # "did the merge change anything?" bookkeeping, so it used to leak into
339
+ # the written file (and from there into the ``.bak`` on the *next* run,
340
+ # destroying the only pristine copy). Applied on BOTH the load and the
341
+ # dump side — ruamel stores the width on the instance, and an asymmetric
342
+ # setting silently re-introduces the folding whenever a doc travels
343
+ # through two differently-configured instances.
344
+ _YAML_DUMP_WIDTH = 4096
345
+
346
+
335
347
  def _load_yaml_with_comments(path: Path) -> tuple[Any, str]:
336
348
  """Load a YAML file via ruamel preserving comments. Returns (doc, raw_text).
337
349
 
@@ -361,6 +373,7 @@ def _load_yaml_with_comments(path: Path) -> tuple[Any, str]:
361
373
  # Match the indentation style used in examples/providers.yaml so
362
374
  # lines we add line up with hand-written ones in unified diffs.
363
375
  yaml_rt.indent(mapping=2, sequence=4, offset=2)
376
+ yaml_rt.width = _YAML_DUMP_WIDTH
364
377
  doc = yaml_rt.load(io.StringIO(raw_text))
365
378
  return doc, raw_text
366
379
 
@@ -372,6 +385,7 @@ def _dump_yaml_with_comments(doc: Any) -> str:
372
385
  yaml_rt = YAML()
373
386
  yaml_rt.preserve_quotes = True
374
387
  yaml_rt.indent(mapping=2, sequence=4, offset=2)
388
+ yaml_rt.width = _YAML_DUMP_WIDTH
375
389
  out = io.StringIO()
376
390
  yaml_rt.dump(doc, out)
377
391
  return out.getvalue()
@@ -421,7 +435,10 @@ class ApplyResult:
421
435
  diffs
422
436
  Per-target unified-diff text. Empty string for a target that
423
437
  had only no-op merges. Indexed by str(path) for stable lookup
424
- in tests / CLI rendering.
438
+ in tests / CLI rendering. Only ever non-empty for a target
439
+ whose merge actually changed something — a pure re-serialization
440
+ difference goes to ``reformat_only`` instead, so the diff an
441
+ operator reviews is exactly the diff of their intent.
425
442
  changes_applied
426
443
  Number of probe patches that produced a change. Zero means
427
444
  idempotent re-run.
@@ -434,6 +451,12 @@ class ApplyResult:
434
451
  True if at least one file was actually written to disk
435
452
  (``--apply`` mode). False for ``--dry-run`` and for cases
436
453
  where every patch was a no-op.
454
+ written_paths
455
+ The files actually written, in target order. Empty in dry-run
456
+ mode and on a no-op run. Distinct from ``backups`` because a
457
+ freshly-created file (user-side model-capabilities.yaml) is
458
+ written without a backup — the CLI needs the true count for its
459
+ "N file(s) written" line.
437
460
  backups
438
461
  Map of original-path → backup-path for every file that was
439
462
  written. Empty in dry-run mode.
@@ -442,6 +465,18 @@ class ApplyResult:
442
465
  Currently empty (we cover all known target_file values), but
443
466
  kept for forward-compat: if a future probe emits a new
444
467
  target_file value, we should report it without crashing.
468
+ reformat_only
469
+ Per-target unified diff of a *cosmetic* delta — the file
470
+ re-serializes differently from what is on disk even though no
471
+ patch changed a value. Purely informational: these bytes are
472
+ NEVER written (see the ``changed_by_path`` gate in
473
+ :func:`apply_doctor_patches`), because writing them would
474
+ silently rewrite an untouched file and, on the next real
475
+ ``--apply``, hand ``.bak`` a copy that is no longer the
476
+ operator's original. Surfaced in ``--dry-run`` only, as a
477
+ heads-up that the file uses a style ruamel does not reproduce
478
+ byte-for-byte (today: explicit ``key: null``, which ruamel
479
+ re-emits as an empty scalar).
445
480
  """
446
481
 
447
482
  target_paths: list[Path]
@@ -451,6 +486,8 @@ class ApplyResult:
451
486
  written: bool
452
487
  backups: dict[str, str] = field(default_factory=dict)
453
488
  skipped_unknown_target: list[str] = field(default_factory=list)
489
+ reformat_only: dict[str, str] = field(default_factory=dict)
490
+ written_paths: list[str] = field(default_factory=list)
454
491
 
455
492
  @property
456
493
  def is_no_op(self) -> bool:
@@ -502,6 +539,11 @@ def apply_doctor_patches(
502
539
  The per-target merge keeps a single in-memory doc per file so two
503
540
  probes targeting the same file compose into one final diff.
504
541
 
542
+ A file is written **only** when at least one patch targeting it
543
+ reported ``changed=True``. A re-dump that differs from disk for
544
+ purely cosmetic reasons is never written; it is reported through
545
+ ``ApplyResult.reformat_only`` instead.
546
+
505
547
  ``user_capabilities_path`` is a test-only injection point — leave
506
548
  None in production to use the standard
507
549
  ``~/.coderouter/model-capabilities.yaml`` resolution.
@@ -513,7 +555,18 @@ def apply_doctor_patches(
513
555
 
514
556
  docs_by_path: dict[Path, tuple[Any, str]] = {}
515
557
  diffs: dict[str, str] = {}
558
+ reformat_only: dict[str, str] = {}
516
559
  target_order: list[Path] = []
560
+ # Per-target "did any merge actually change a value?". This — NOT
561
+ # "does the re-dump differ from the bytes on disk" — is what gates
562
+ # the write below. The two used to be conflated, and any file whose
563
+ # style ruamel does not reproduce verbatim (an explicit
564
+ # ``api_key_env: null``, a >80-column quoted scalar) was silently
565
+ # rewritten on a run that reported "0 changes applied". The next
566
+ # real ``--apply`` then copied that already-reformatted file into
567
+ # ``.bak``, so the operator's pristine original no longer existed
568
+ # anywhere.
569
+ changed_by_path: dict[Path, bool] = {}
517
570
  changes_applied = 0
518
571
  no_op_patches = 0
519
572
  skipped: list[str] = []
@@ -552,6 +605,7 @@ def apply_doctor_patches(
552
605
  before_text = ""
553
606
  docs_by_path[target_path] = (doc, before_text)
554
607
  target_order.append(target_path)
608
+ changed_by_path[target_path] = False
555
609
 
556
610
  doc, _ = docs_by_path[target_path]
557
611
 
@@ -575,11 +629,13 @@ def apply_doctor_patches(
575
629
 
576
630
  if changed:
577
631
  changes_applied += 1
632
+ changed_by_path[target_path] = True
578
633
  else:
579
634
  no_op_patches += 1
580
635
 
581
636
  # Render diffs and write (or just report).
582
637
  written = False
638
+ written_paths: list[str] = []
583
639
  backups: dict[str, str] = {}
584
640
  for target_path in target_order:
585
641
  doc, before_text = docs_by_path[target_path]
@@ -587,8 +643,18 @@ def apply_doctor_patches(
587
643
  diff_text = render_unified_diff(
588
644
  before=before_text, after=after_text, path=target_path
589
645
  )
590
- diffs[str(target_path)] = diff_text
591
- if write and diff_text:
646
+ target_changed = changed_by_path.get(target_path, False)
647
+ if target_changed:
648
+ diffs[str(target_path)] = diff_text
649
+ else:
650
+ # Nothing semantic changed. Whatever ``diff_text`` holds is
651
+ # pure re-serialization noise; keep it out of ``diffs`` (so
652
+ # the CLI's "here is what will change" section stays honest)
653
+ # and park it in ``reformat_only`` for the dry-run notice.
654
+ diffs[str(target_path)] = ""
655
+ if diff_text:
656
+ reformat_only[str(target_path)] = diff_text
657
+ if write and target_changed:
592
658
  # Backup first (only when we have a pre-existing file).
593
659
  if target_path.is_file():
594
660
  backup_path = target_path.with_suffix(
@@ -600,6 +666,7 @@ def apply_doctor_patches(
600
666
  target_path.parent.mkdir(parents=True, exist_ok=True)
601
667
  target_path.write_text(after_text, encoding="utf-8")
602
668
  written = True
669
+ written_paths.append(str(target_path))
603
670
 
604
671
  return ApplyResult(
605
672
  target_paths=target_order,
@@ -609,4 +676,6 @@ def apply_doctor_patches(
609
676
  written=written,
610
677
  backups=backups,
611
678
  skipped_unknown_target=skipped,
679
+ reformat_only=reformat_only,
680
+ written_paths=written_paths,
612
681
  )
@@ -34,7 +34,19 @@ The parser treats the file as **untrusted input**:
34
34
  :data:`_MAX_STR_BYTES` / :data:`_MAX_ARRAY_LEN` so a corrupt or
35
35
  hostile header cannot trigger a multi-GB allocation (DoS).
36
36
  * Reads past EOF raise :class:`GGUFParseError`, never an unbounded
37
- loop.
37
+ loop. This includes ``seek`` past EOF: every skip (array element
38
+ count x element size, or a lone scalar) is checked against the
39
+ file's actual size *before* the seek happens, so a truncated file
40
+ that declares a huge array count fails fast instead of spinning a
41
+ multi-million-iteration loop over bytes that were never written.
42
+ * Array nesting depth is capped at :data:`_MAX_ARRAY_DEPTH`. Skipping
43
+ a value uses an explicit work-stack (``_skip_value``), not Python
44
+ recursion, so there is no ``RecursionError`` failure mode to begin
45
+ with — the depth cap is a belt-and-suspenders DoS guard (a
46
+ pathological file can otherwise spend 12 bytes per nesting level to
47
+ force millions of stack-machine iterations), not a stack-overflow
48
+ guard. Every llama.cpp-written GGUF array (tokenizer tokens/scores/
49
+ merges, etc.) nests exactly one level deep.
38
50
  * No ``mmap``, no tensor payload read, no code execution path — we
39
51
  only seek/read a small prefix.
40
52
  """
@@ -86,6 +98,16 @@ _SCALAR: dict[int, tuple[str, int]] = {
86
98
  _MAX_STR_BYTES: int = 1 << 20 # 1 MiB key/value string ceiling
87
99
  _MAX_ARRAY_LEN: int = 1 << 24 # element-count ceiling for arrays
88
100
  _MAX_KV_PAIRS: int = 1 << 20 # metadata pair ceiling
101
+ # Array-of-array nesting ceiling. Every array we've ever seen llama.cpp
102
+ # emit (tokenizer.ggml.tokens / token_type / scores / merges, ...) nests
103
+ # exactly one level deep, and this repo's synthetic test fixtures never
104
+ # exceed that either — real headers are nowhere near this limit. It exists
105
+ # purely as a DoS guard: each extra nesting level costs an attacker only
106
+ # 12 bytes (elem_type u32 + count u64), so an unbounded depth lets a
107
+ # ~12 KiB file blow the interpreter's C stack. ``_skip_value`` walks an
108
+ # explicit work-stack rather than recursing, so this cap is enforced
109
+ # directly (no reliance on hitting Python's recursion limit).
110
+ _MAX_ARRAY_DEPTH: int = 8
89
111
 
90
112
  # Human-readable names for the GGUF ``general.file_type`` enum (subset).
91
113
  _FILE_TYPE_NAMES: dict[int, str] = {
@@ -197,31 +219,97 @@ def _read_gguf_string(fh: BinaryIO) -> str:
197
219
  return _read_exact(fh, length).decode("utf-8", errors="replace")
198
220
 
199
221
 
200
- def _skip_value(fh: BinaryIO, type_tag: int) -> None:
201
- """Consume a metadata value of ``type_tag`` without retaining it."""
202
- if type_tag == _GGUF_TYPE_STRING:
203
- _read_gguf_string(fh)
204
- return
205
- if type_tag == _GGUF_TYPE_ARRAY:
206
- elem_type = _read_u32(fh)
207
- count = _read_u64(fh)
208
- if count > _MAX_ARRAY_LEN:
209
- raise GGUFParseError(f"array length {count} exceeds cap")
210
- for _ in range(count):
211
- _skip_value(fh, elem_type)
212
- return
213
- fmt_size = _SCALAR.get(type_tag)
214
- if fmt_size is None:
215
- raise GGUFParseError(f"unknown value type tag {type_tag}")
216
- fh.seek(fmt_size[1], 1) # skip scalar bytes
217
-
222
+ def _seek_checked(fh: BinaryIO, nbytes: int, file_size: int) -> None:
223
+ """Skip ``nbytes`` forward, refusing to seek past the file's real end.
218
224
 
219
- def _read_scalar_value(fh: BinaryIO, type_tag: int) -> object:
225
+ ``seek`` never fails on a plain file even when the target offset is
226
+ past EOF — the OS happily "succeeds" and a subsequent read just
227
+ returns fewer bytes than expected. That silence is what let a
228
+ 49-byte file with a declared 16M-element array parse "successfully"
229
+ (see module docstring). Checking the target offset against the
230
+ ``file_size`` captured once via ``stat()`` at the top of
231
+ :func:`read_gguf_metadata` turns that into an immediate, cheap
232
+ :class:`GGUFParseError` instead of either an unbounded loop or a
233
+ silently-wrong result.
234
+ """
235
+ pos = fh.tell()
236
+ if pos + nbytes > file_size:
237
+ raise GGUFParseError(
238
+ f"value extends past EOF (offset {pos} + {nbytes} bytes "
239
+ f"> file size {file_size})"
240
+ )
241
+ fh.seek(nbytes, 1)
242
+
243
+
244
+ def _skip_value(fh: BinaryIO, type_tag: int, file_size: int, *, depth: int = 0) -> None:
245
+ """Consume a metadata value of ``type_tag`` without retaining it.
246
+
247
+ Iterative (explicit work-stack), not recursive: a metadata value can
248
+ be an array of arrays, and a corrupt/hostile file can nest that
249
+ arbitrarily deep for only 12 bytes per level (``elem_type`` u32 +
250
+ ``count`` u64). Recursing one Python frame per level let a
251
+ ~12 KiB file blow the interpreter's stack with an uncaught
252
+ ``RecursionError`` (see module docstring) — the C-level exception
253
+ that walks straight through :class:`GGUFParseError`'s ``except``
254
+ clauses. Doing the walk with a stack of ``(type_tag, remaining,
255
+ depth)`` frames instead makes ``RecursionError`` structurally
256
+ impossible here, and :data:`_MAX_ARRAY_DEPTH` still bounds how deep
257
+ a legitimate-looking nested array is allowed to go (DoS guard,
258
+ independent of the recursion fix).
259
+
260
+ Arrays of fixed-width scalars (the common case: token ids, scores,
261
+ ``token_type``, ...) are skipped with a single bounds-checked
262
+ ``seek`` for the whole array rather than one iteration per element —
263
+ this is what turns an 8s parse of a 150K-element array into a
264
+ sub-millisecond one.
265
+ """
266
+ # Each frame: (type_tag_to_process, how_many_times_left, nesting_depth).
267
+ stack: list[tuple[int, int, int]] = [(type_tag, 1, depth)]
268
+ while stack:
269
+ tag, remaining, d = stack.pop()
270
+ if remaining > 1:
271
+ stack.append((tag, remaining - 1, d))
272
+
273
+ if tag == _GGUF_TYPE_STRING:
274
+ _read_gguf_string(fh)
275
+ continue
276
+
277
+ if tag == _GGUF_TYPE_ARRAY:
278
+ if d >= _MAX_ARRAY_DEPTH:
279
+ raise GGUFParseError(
280
+ f"array nesting depth exceeds cap ({_MAX_ARRAY_DEPTH}); "
281
+ "refusing to descend further"
282
+ )
283
+ elem_type = _read_u32(fh)
284
+ count = _read_u64(fh)
285
+ if count > _MAX_ARRAY_LEN:
286
+ raise GGUFParseError(f"array length {count} exceeds cap")
287
+ fmt_size = _SCALAR.get(elem_type)
288
+ if fmt_size is not None:
289
+ # Fixed-width elements: one bounds-checked bulk seek,
290
+ # not a per-element loop.
291
+ _seek_checked(fh, count * fmt_size[1], file_size)
292
+ elif elem_type in (_GGUF_TYPE_STRING, _GGUF_TYPE_ARRAY):
293
+ if count > 0:
294
+ stack.append((elem_type, count, d + 1))
295
+ else:
296
+ raise GGUFParseError(f"unknown value type tag {elem_type}")
297
+ continue
298
+
299
+ # Lone scalar (only reachable if _skip_value is ever invoked
300
+ # directly on a scalar type_tag; kept for defense in depth).
301
+ fmt_size = _SCALAR.get(tag)
302
+ if fmt_size is None:
303
+ raise GGUFParseError(f"unknown value type tag {tag}")
304
+ _seek_checked(fh, fmt_size[1], file_size)
305
+
306
+
307
+ def _read_scalar_value(fh: BinaryIO, type_tag: int, file_size: int) -> object:
220
308
  """Read (and return) a value, skipping arrays/strings we don't need."""
221
309
  if type_tag == _GGUF_TYPE_STRING:
222
310
  return _read_gguf_string(fh)
223
311
  if type_tag == _GGUF_TYPE_ARRAY:
224
- _skip_value(fh, type_tag)
312
+ _skip_value(fh, type_tag, file_size)
225
313
  return None
226
314
  return _read_scalar(fh, type_tag)
227
315
 
@@ -262,7 +350,16 @@ def read_gguf_metadata(path: str | Path) -> GGUFInfo:
262
350
  file_type: int | None = None
263
351
  n_nextn: int | None = None
264
352
 
265
- with p.open("rb") as fh:
353
+ try:
354
+ fh = p.open("rb")
355
+ except OSError as exc:
356
+ # TOCTOU: the file can vanish (or become unreadable) between the
357
+ # stat() above and this open() — e.g. a concurrent model-directory
358
+ # scan racing a download/cleanup. Convert to GGUFParseError so
359
+ # callers only need to catch one exception type.
360
+ raise GGUFParseError(f"cannot open {path}: {exc}") from exc
361
+
362
+ with fh:
266
363
  magic = fh.read(4)
267
364
  if magic != _GGUF_MAGIC:
268
365
  raise GGUFParseError(f"bad magic {magic!r} (not a GGUF file)")
@@ -277,7 +374,7 @@ def read_gguf_metadata(path: str | Path) -> GGUFInfo:
277
374
  for _ in range(kv_count):
278
375
  key = _read_gguf_string(fh)
279
376
  value_type = _read_u32(fh)
280
- value = _read_scalar_value(fh, value_type)
377
+ value = _read_scalar_value(fh, value_type, file_size)
281
378
 
282
379
  if key == "general.architecture" and isinstance(value, str):
283
380
  arch = value
@@ -308,10 +405,20 @@ def read_gguf_metadata(path: str | Path) -> GGUFInfo:
308
405
 
309
406
  def try_read_gguf_metadata(path: str | Path) -> GGUFInfo | None:
310
407
  """Like :func:`read_gguf_metadata` but returns None on any parse
311
- failure — convenient for best-effort advisory paths."""
408
+ failure — convenient for best-effort advisory paths.
409
+
410
+ Catches ``OSError`` in addition to :class:`GGUFParseError` because a
411
+ TOCTOU race can still surface a raw ``OSError`` from ``open()`` in
412
+ principle, and ``RecursionError`` / ``MemoryError`` as defense in
413
+ depth: with the iterative :func:`_skip_value` walk and the depth cap
414
+ above, neither should actually be reachable from this module
415
+ anymore, but a caller in a best-effort advisory path (e.g. scanning
416
+ every ``.gguf`` in a model directory) should never be taken down by
417
+ one regardless.
418
+ """
312
419
  try:
313
420
  return read_gguf_metadata(path)
314
- except GGUFParseError:
421
+ except (GGUFParseError, OSError, RecursionError, MemoryError):
315
422
  return None
316
423
 
317
424
 
@@ -260,7 +260,19 @@ def detect_drift(
260
260
  mild_flags.append(f"tool_silence_rate={tool_silence_rate:.2f}")
261
261
 
262
262
  # --- Signal 4: Stop reason anomaly rate ---
263
- _EXPECTED_STOP = {"end_turn", "tool_use", "max_tokens"}
263
+ # H-6: `stop_sequence` (a normal, expected terminator when the caller
264
+ # configures a stop sequence) was missing here, so every stop-sequence
265
+ # terminated response counted as an anomaly. `pause_turn` and `refusal`
266
+ # are legitimate Anthropic stop reasons too (see translation/anthropic.py
267
+ # for the forward-compat rationale) and are expected, not anomalous.
268
+ _EXPECTED_STOP = {
269
+ "end_turn",
270
+ "tool_use",
271
+ "max_tokens",
272
+ "stop_sequence",
273
+ "pause_turn",
274
+ "refusal",
275
+ }
264
276
  non_error_obs = [obs for obs in window if not obs.is_error]
265
277
  if non_error_obs:
266
278
  anomaly_count = sum(
@@ -51,6 +51,7 @@ from coderouter.launcher_devices import (
51
51
  build_auto_sweep_configs,
52
52
  build_sweep_steps,
53
53
  detect_llama_devices,
54
+ foreign_device_ids,
54
55
  group_by_backend,
55
56
  is_port_free,
56
57
  load_latest_results,
@@ -58,7 +59,6 @@ from coderouter.launcher_devices import (
58
59
  resolve_option_profiles,
59
60
  selectable_devices,
60
61
  suggest_tensor_split,
61
- unknown_device_ids,
62
62
  variant_of,
63
63
  )
64
64
  from coderouter.launcher_speculative import resolve_speculative
@@ -1561,14 +1561,14 @@ async def _assert_device_ids_known(
1561
1561
  前に弾く。
1562
1562
 
1563
1563
  ``--list-devices`` 自体が失敗した環境では検証をスキップする
1564
- (:func:`unknown_device_ids` の best-effort 契約)。
1564
+ (:func:`foreign_device_ids` の best-effort 契約)。
1565
1565
  """
1566
1566
  if not device_ids:
1567
1567
  return
1568
1568
  configured = _configured_binary_for(launcher_cfg, backend)
1569
1569
  binary = _resolve_binary(backend, configured)
1570
1570
  probe = await asyncio.to_thread(detect_llama_devices, binary)
1571
- unknown = unknown_device_ids(list(device_ids), probe)
1571
+ unknown = foreign_device_ids(list(device_ids), probe)
1572
1572
  if unknown:
1573
1573
  known = [d.id for d in probe.devices]
1574
1574
  raise HTTPException(
@@ -43,6 +43,7 @@ __all__ = [
43
43
  "build_cross_variant_sweep_configs",
44
44
  "build_sweep_steps",
45
45
  "detect_llama_devices",
46
+ "foreign_device_ids",
46
47
  "group_by_backend",
47
48
  "is_port_free",
48
49
  "is_valid_backend_name",
@@ -55,7 +56,6 @@ __all__ = [
55
56
  "selectable_devices",
56
57
  "suggest_tensor_split",
57
58
  "summarize_results",
58
- "unknown_device_ids",
59
59
  "variant_of",
60
60
  ]
61
61
 
@@ -369,22 +369,34 @@ class DeviceSelection:
369
369
  return args
370
370
 
371
371
 
372
- def unknown_device_ids(device_ids: Sequence[str], probe: DeviceProbe) -> list[str]:
373
- """``device_ids`` のうち ``probe`` に存在しないものを返す(空なら健全)。
372
+ def foreign_device_ids(device_ids: Sequence[str], probe: DeviceProbe) -> list[str]:
373
+ """``device_ids`` のうち **別ビルドの名前空間**に属するものを返す。
374
374
 
375
375
  バックエンドバリアントを切り替えるとデバイス ID の名前空間も変わる
376
376
  (``CUDA0`` と ``Vulkan0`` は同じ GPU を指さない)。CUDA ビルドで
377
377
  ``CUDA0`` を選んだまま Vulkan ビルドで起動すると ``--device CUDA0`` が
378
378
  Vulkan ビルドに渡って起動失敗するので、spawn 前に弾くための判定。
379
379
 
380
- ``probe.ok`` が False (``--list-devices`` 自体が失敗) のときは **常に空
381
- リスト** を返す。デバイスを列挙できない環境で機能を殺さないための
382
- best-effort 原則 (``hardware.py`` の 5-deps 不変則と同じ姿勢)。
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
+ 同じ姿勢)。
383
395
  """
384
- if not probe.ok:
396
+ if not probe.ok or not probe.devices:
385
397
  return []
386
- known = {d.id for d in probe.devices}
387
- return [d for d in device_ids if d not in known]
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]
388
400
 
389
401
 
390
402
  # ---------------------------------------------------------------------------
@@ -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