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/adapters/anthropic_native.py +23 -5
- coderouter/cli.py +49 -0
- coderouter/config/loader.py +65 -0
- coderouter/config/schemas.py +228 -12
- 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 +289 -22
- coderouter/launcher_devices.py +200 -4
- coderouter/launcher_speculative.py +5 -1
- coderouter/launcher_swap.py +11 -2
- 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/vscode_init.py +4 -2
- {coderouter_cli-2.10.0.dist-info → coderouter_cli-2.11.1.dist-info}/METADATA +2 -1
- {coderouter_cli-2.10.0.dist-info → coderouter_cli-2.11.1.dist-info}/RECORD +22 -22
- {coderouter_cli-2.10.0.dist-info → coderouter_cli-2.11.1.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.10.0.dist-info → coderouter_cli-2.11.1.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.10.0.dist-info → coderouter_cli-2.11.1.dist-info}/licenses/LICENSE +0 -0
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
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
)[:
|
|
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
|
-
|
|
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
|
|
1217
|
-
_, repaired = repair_tool_calls_in_text(
|
|
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
|
|
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
|
-
|
|
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 = {
|
|
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,
|
|
1641
|
+
suggested_patch=_patch_providers_yaml_output_filters(provider.name, merged_chain),
|
|
1563
1642
|
)
|
|
1564
1643
|
|
|
1565
1644
|
passthrough_on = (
|
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(
|