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.
- coderouter/adapters/anthropic_native.py +23 -5
- coderouter/cli.py +49 -0
- coderouter/config/loader.py +65 -0
- coderouter/config/schemas.py +85 -3
- coderouter/doctor.py +98 -19
- coderouter/doctor_apply.py +72 -3
- coderouter/gguf_introspect.py +132 -25
- coderouter/guards/drift_detection.py +13 -1
- coderouter/ingress/launcher_routes.py +3 -3
- coderouter/launcher_devices.py +21 -9
- coderouter/routing/fallback.py +14 -2
- coderouter/translation/anthropic.py +14 -1
- coderouter/translation/convert.py +7 -0
- coderouter/translation/tool_repair.py +193 -76
- {coderouter_cli-2.11.0.dist-info → coderouter_cli-2.11.1.dist-info}/METADATA +1 -1
- {coderouter_cli-2.11.0.dist-info → coderouter_cli-2.11.1.dist-info}/RECORD +19 -19
- {coderouter_cli-2.11.0.dist-info → coderouter_cli-2.11.1.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.11.0.dist-info → coderouter_cli-2.11.1.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.11.0.dist-info → coderouter_cli-2.11.1.dist-info}/licenses/LICENSE +0 -0
coderouter/doctor_apply.py
CHANGED
|
@@ -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
|
-
|
|
591
|
-
if
|
|
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
|
)
|
coderouter/gguf_introspect.py
CHANGED
|
@@ -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
|
|
201
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:`
|
|
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 =
|
|
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(
|
coderouter/launcher_devices.py
CHANGED
|
@@ -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
|
|
373
|
-
"""``device_ids`` のうち
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
387
|
-
return [d for d in device_ids if d not in
|
|
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
|
# ---------------------------------------------------------------------------
|
coderouter/routing/fallback.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|