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.
coderouter/doctor.py CHANGED
@@ -321,17 +321,32 @@ def _patch_model_capabilities_yaml(*, match: str, kind: str, key: str, value: bo
321
321
 
322
322
 
323
323
  def _patch_providers_yaml_output_filters(provider_name: str, filters: list[str]) -> str:
324
- """v1.0-A: Emit a providers.yaml patch adding/extending ``output_filters``.
325
-
326
- Lists the filters verbatim so copy-paste yields a valid YAML list.
327
- The comment block above the stanza hints that this is additive with
328
- any existing filter chain — users with a bespoke chain should merge
329
- rather than replace.
324
+ """v1.0-A: Emit a providers.yaml patch setting the full ``output_filters`` chain.
325
+
326
+ ``filters`` must already be the **complete** post-fix chain — the
327
+ provider's existing filters in their existing order, plus whatever
328
+ the probe found missing appended at the end. Callers build that
329
+ union; see :func:`_union_output_filters`.
330
+
331
+ Why the complete chain and not just the additions: ``--apply``
332
+ merges this patch with ``deep_merge_dicts``, which replaces lists
333
+ wholesale (documented contract, pinned by
334
+ ``test_deep_merge_replaces_lists_wholesale``). Emitting only the
335
+ missing filters would therefore *delete* every filter the operator
336
+ already had — including ``strip_tool_call_xml`` /
337
+ ``repair_byte_fallback``, which this probe never recommends and so
338
+ could never add back. Keeping "patch YAML == the desired end state"
339
+ also means the copy-paste path and the ``--apply`` path agree, and
340
+ the ``--dry-run`` diff reads as the literal final value.
330
341
  """
331
342
  items = "\n".join(f" - {f}" for f in filters)
332
343
  return (
333
344
  "# providers.yaml — update the entry for "
334
- f"{provider_name!r} (merge if a chain already exists):\n"
345
+ f"{provider_name!r}. This is the COMPLETE chain (existing filters "
346
+ "plus the additions) —\n"
347
+ "# it replaces `output_filters` wholesale rather than appending "
348
+ "to it. Order is significant:\n"
349
+ "# filters run left to right.\n"
335
350
  "providers:\n"
336
351
  f" - name: {provider_name}\n"
337
352
  " # ... existing fields ...\n"
@@ -340,6 +355,27 @@ def _patch_providers_yaml_output_filters(provider_name: str, filters: list[str])
340
355
  )
341
356
 
342
357
 
358
+ def _union_output_filters(existing: list[str], additions: list[str]) -> list[str]:
359
+ """Existing chain + the filters it is missing, order-preserving.
360
+
361
+ ``OutputFilterChain.feed()`` applies filters left to right, so the
362
+ chain is a pipeline, not a set: reordering it changes the output
363
+ (e.g. ``strip_thinking`` before vs. after ``strip_tool_call_xml``
364
+ decides whether a tool call nested inside ``<think>`` survives).
365
+ Never sort this — the operator's existing order is authoritative and
366
+ the additions go on the end, which is the only placement that cannot
367
+ change the behavior of the filters already there.
368
+
369
+ Duplicates are dropped so a re-run of the probe cannot grow the
370
+ chain unboundedly.
371
+ """
372
+ chain = list(existing)
373
+ for name in additions:
374
+ if name not in chain:
375
+ chain.append(name)
376
+ return chain
377
+
378
+
343
379
  def _patch_providers_yaml_num_ctx(provider_name: str, desired_ctx: int = 32768) -> str:
344
380
  """v1.0-B: Emit a providers.yaml patch setting ``extra_body.options.num_ctx``.
345
381
 
@@ -470,6 +506,23 @@ _STREAMING_PROBE_MAX_TOKENS_THINKING = 1024
470
506
  # probes (same _is_reasoning_model gate).
471
507
  _TOOL_CALLS_PROBE_MAX_TOKENS_DEFAULT = 256
472
508
  _TOOL_CALLS_PROBE_MAX_TOKENS_THINKING = 1024
509
+ # How much assistant text the tool_calls probe feeds to
510
+ # ``repair_tool_calls_in_text``. This has to be sized against the token
511
+ # budgets above, not against a "preview" length: 1024 tokens is roughly
512
+ # 3-4k characters, and a thinking model routinely spends several hundred
513
+ # of them on a preamble before writing its JSON call. Scanning only the
514
+ # first 200 characters (the pre-fix behavior) made the probe conclude
515
+ # "nothing tool-shaped at all" for a model that had in fact emitted a
516
+ # perfectly repairable call — and the remediation for that verdict is a
517
+ # ``capabilities.tools: false`` patch, i.e. ``--apply`` would have
518
+ # permanently disabled tool calling for a provider that supports it.
519
+ # Bounded rather than unlimited so a pathological multi-megabyte
520
+ # response cannot turn the probe into a CPU sink; 16 KiB comfortably
521
+ # covers the largest response either budget can produce.
522
+ _TOOL_PROBE_SCAN_CHARS = 16384
523
+ # Length of the human-facing excerpt embedded in probe ``detail`` text.
524
+ # Display only — never the input to a verdict.
525
+ _TOOL_PROBE_DETAIL_PREVIEW_CHARS = 200
473
526
  # Default ``num_predict`` suggested in the emitted patch. -1 would be
474
527
  # optimal (uncapped) but "4096" communicates intent more clearly to
475
528
  # operators unfamiliar with Ollama's sentinel value, and covers Claude
@@ -1191,7 +1244,12 @@ async def _probe_tool_calls(
1191
1244
 
1192
1245
  native_tool_call = False
1193
1246
  text_json_tool_call = False
1194
- content_sample = ""
1247
+ # ``scan_text`` is what the verdict is computed from — the assistant
1248
+ # text up to _TOOL_PROBE_SCAN_CHARS. ``content_preview`` is a short
1249
+ # excerpt used only inside human-readable ``detail`` strings. Keeping
1250
+ # these two separate is the whole point: they used to be one 200-char
1251
+ # string, so the preview length silently became the detection window.
1252
+ scan_text = ""
1195
1253
  if provider.kind == "anthropic":
1196
1254
  blocks = parsed.get("content")
1197
1255
  if isinstance(blocks, list):
@@ -1199,11 +1257,11 @@ async def _probe_tool_calls(
1199
1257
  if isinstance(block, dict) and block.get("type") == "tool_use":
1200
1258
  native_tool_call = True
1201
1259
  break
1202
- content_sample = " ".join(
1260
+ scan_text = " ".join(
1203
1261
  str(b.get("text", ""))
1204
1262
  for b in blocks
1205
1263
  if isinstance(b, dict) and b.get("type") == "text"
1206
- )[:200]
1264
+ )[:_TOOL_PROBE_SCAN_CHARS]
1207
1265
  else:
1208
1266
  msg = _extract_openai_assistant_choice(parsed)
1209
1267
  if msg is not None:
@@ -1211,10 +1269,13 @@ async def _probe_tool_calls(
1211
1269
  native_tool_call = True
1212
1270
  content = msg.get("content")
1213
1271
  if isinstance(content, str):
1214
- content_sample = content[:200]
1272
+ scan_text = content[:_TOOL_PROBE_SCAN_CHARS]
1273
+
1274
+ content_preview = scan_text[:_TOOL_PROBE_DETAIL_PREVIEW_CHARS]
1275
+ scanned_chars = len(scan_text)
1215
1276
 
1216
- if not native_tool_call and content_sample:
1217
- _, repaired = repair_tool_calls_in_text(content_sample, ["echo"])
1277
+ if not native_tool_call and scan_text:
1278
+ _, repaired = repair_tool_calls_in_text(scan_text, ["echo"])
1218
1279
  text_json_tool_call = bool(repaired)
1219
1280
 
1220
1281
  # Resolve the declared support:
@@ -1293,14 +1354,24 @@ async def _probe_tool_calls(
1293
1354
  "the model produced no tool-shaped output at all."
1294
1355
  )
1295
1356
  )
1357
+ # State the detection window explicitly. This verdict's
1358
+ # remediation permanently disables tool calling, so an operator
1359
+ # who disagrees with it needs to be able to tell "the model
1360
+ # really emitted nothing" from "the probe only looked at the
1361
+ # first N characters" without reading the source.
1362
+ scan_note = (
1363
+ f"Scanned {scanned_chars} chars of assistant text "
1364
+ f"(window {_TOOL_PROBE_SCAN_CHARS})"
1365
+ + (f"; begins: {content_preview!r}." if content_preview else "; text was empty.")
1366
+ )
1296
1367
  return ProbeResult(
1297
1368
  name="tool_calls",
1298
1369
  verdict=ProbeVerdict.NEEDS_TUNING,
1299
1370
  detail=(
1300
1371
  "declaration says tools=true but model produced neither "
1301
1372
  "native `tool_calls` nor repairable tool JSON. "
1302
- f"{budget_note} Common for quantized small models "
1303
- "(plan.md §9.4 symptom #2)."
1373
+ f"{budget_note} {scan_note} Common for quantized small "
1374
+ "models (plan.md §9.4 symptom #2)."
1304
1375
  ),
1305
1376
  target_file="providers.yaml",
1306
1377
  suggested_patch=_patch_providers_yaml_capability(provider.name, "tools", False),
@@ -1526,7 +1597,10 @@ async def _probe_reasoning_leak(
1526
1597
  content_text = content if isinstance(content, str) else ""
1527
1598
  has_think = "<think>" in content_text
1528
1599
  leaked_markers: list[str] = [m for m in DEFAULT_STOP_MARKERS if m in content_text]
1529
- configured_filters = set(provider.output_filters)
1600
+ # Order matters (the chain is a left-to-right pipeline), so keep the
1601
+ # operator's list as a list — a set here would lose the order we have
1602
+ # to reproduce in the patch.
1603
+ configured_filters: list[str] = list(provider.output_filters)
1530
1604
  needs_strip_thinking = has_think and "strip_thinking" not in configured_filters
1531
1605
  needs_strip_markers = bool(leaked_markers) and "strip_stop_markers" not in configured_filters
1532
1606
 
@@ -1542,6 +1616,11 @@ async def _probe_reasoning_leak(
1542
1616
  if needs_strip_markers:
1543
1617
  recommended.append("strip_stop_markers")
1544
1618
 
1619
+ # The patch must carry the WHOLE chain: --apply replaces the
1620
+ # list wholesale, so emitting only ``recommended`` would drop
1621
+ # every filter the operator already configured.
1622
+ merged_chain = _union_output_filters(configured_filters, recommended)
1623
+
1545
1624
  found_desc: list[str] = []
1546
1625
  if has_think:
1547
1626
  found_desc.append("<think>...</think>")
@@ -1555,11 +1634,11 @@ async def _probe_reasoning_leak(
1555
1634
  "content-embedded leak detected ("
1556
1635
  + " + ".join(found_desc)
1557
1636
  + "). v1.0-A `output_filters` would scrub this; current "
1558
- f"provider chain = {sorted(configured_filters)}. Recommended: "
1559
- f"add {recommended}."
1637
+ f"provider chain = {configured_filters}. Recommended: "
1638
+ f"add {recommended} → resulting chain {merged_chain}."
1560
1639
  ),
1561
1640
  target_file="providers.yaml",
1562
- suggested_patch=_patch_providers_yaml_output_filters(provider.name, recommended),
1641
+ suggested_patch=_patch_providers_yaml_output_filters(provider.name, merged_chain),
1563
1642
  )
1564
1643
 
1565
1644
  passthrough_on = (
@@ -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(