coderouter-cli 2.11.0__py3-none-any.whl → 2.11.2__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/vscode_init.py +172 -16
- {coderouter_cli-2.11.0.dist-info → coderouter_cli-2.11.2.dist-info}/METADATA +1 -1
- {coderouter_cli-2.11.0.dist-info → coderouter_cli-2.11.2.dist-info}/RECORD +20 -20
- {coderouter_cli-2.11.0.dist-info → coderouter_cli-2.11.2.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.11.0.dist-info → coderouter_cli-2.11.2.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.11.0.dist-info → coderouter_cli-2.11.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -53,11 +53,16 @@ from coderouter.translation.anthropic import (
|
|
|
53
53
|
AnthropicResponse,
|
|
54
54
|
AnthropicStreamEvent,
|
|
55
55
|
)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
)
|
|
56
|
+
|
|
57
|
+
# NOTE: `coderouter.translation.convert` is intentionally NOT imported here
|
|
58
|
+
# at module scope (see H-7). The import chain is:
|
|
59
|
+
# translation/__init__.py -> convert.py -> adapters/__init__.py
|
|
60
|
+
# -> registry.py -> anthropic_native.py -> translation.convert (again)
|
|
61
|
+
# A top-level import here closes that cycle and makes standalone imports of
|
|
62
|
+
# `coderouter.translation.*` fail with a partial-initialization ImportError
|
|
63
|
+
# unless `coderouter.adapters` happened to be imported first. The three
|
|
64
|
+
# symbols used from `convert` are imported lazily inside `generate()` /
|
|
65
|
+
# `stream()` instead, which breaks the cycle without changing behavior.
|
|
61
66
|
|
|
62
67
|
logger = get_logger(__name__)
|
|
63
68
|
|
|
@@ -210,6 +215,12 @@ class AnthropicAdapter(BaseAdapter):
|
|
|
210
215
|
``to_chat_response``. The ``coderouter_provider`` tag is preserved
|
|
211
216
|
on both sides.
|
|
212
217
|
"""
|
|
218
|
+
# Local import to avoid a circular import at module load time (H-7):
|
|
219
|
+
# convert.py transitively imports coderouter.adapters, which imports
|
|
220
|
+
# this module. Importing convert.py here (call time, not load time)
|
|
221
|
+
# keeps `coderouter.translation.*` importable standalone.
|
|
222
|
+
from coderouter.translation.convert import to_anthropic_request, to_chat_response
|
|
223
|
+
|
|
213
224
|
anth_req = to_anthropic_request(request)
|
|
214
225
|
anth_resp = await self.generate_anthropic(anth_req, overrides=overrides)
|
|
215
226
|
chat_resp = to_chat_response(anth_resp)
|
|
@@ -241,6 +252,13 @@ class AnthropicAdapter(BaseAdapter):
|
|
|
241
252
|
- Anthropic ``event: error`` → translator raises
|
|
242
253
|
AdapterError(retryable=False), same treatment as above.
|
|
243
254
|
"""
|
|
255
|
+
# Local import to avoid a circular import at module load time (H-7);
|
|
256
|
+
# see the matching comment in generate() above.
|
|
257
|
+
from coderouter.translation.convert import (
|
|
258
|
+
stream_anthropic_to_chat_chunks,
|
|
259
|
+
to_anthropic_request,
|
|
260
|
+
)
|
|
261
|
+
|
|
244
262
|
anth_req = to_anthropic_request(request)
|
|
245
263
|
events = self.stream_anthropic(anth_req, overrides=overrides)
|
|
246
264
|
async for chunk in stream_anthropic_to_chat_chunks(events, provider_name=self.name):
|
coderouter/cli.py
CHANGED
|
@@ -624,6 +624,24 @@ def _resolve_config_path(explicit: str | None) -> Path:
|
|
|
624
624
|
return candidates[-1]
|
|
625
625
|
|
|
626
626
|
|
|
627
|
+
def _write_verdict_line(*, write: bool, files_written: int) -> None:
|
|
628
|
+
"""Print the one-line "did we touch the disk?" verdict.
|
|
629
|
+
|
|
630
|
+
Called on **every** exit path of :func:`_run_apply_or_dry_run`.
|
|
631
|
+
The v1.x regression this guards against: an ``--apply`` run that
|
|
632
|
+
silently rewrote providers.yaml while the summary said "already
|
|
633
|
+
applied" and printed neither a diff nor a ``Backup:`` line. Making
|
|
634
|
+
the statement unconditional means no future early-return can go
|
|
635
|
+
quiet about a write again.
|
|
636
|
+
"""
|
|
637
|
+
if not write:
|
|
638
|
+
print(" 0 files written (dry-run).")
|
|
639
|
+
elif files_written == 0:
|
|
640
|
+
print(" 0 files written — no file contents changed.")
|
|
641
|
+
else:
|
|
642
|
+
print(f" {files_written} file(s) written.")
|
|
643
|
+
|
|
644
|
+
|
|
627
645
|
def _run_apply_or_dry_run(
|
|
628
646
|
*,
|
|
629
647
|
report: object,
|
|
@@ -655,9 +673,13 @@ def _run_apply_or_dry_run(
|
|
|
655
673
|
)
|
|
656
674
|
except MissingDependencyError as exc:
|
|
657
675
|
print(f"doctor --apply: {exc}", file=sys.stderr)
|
|
676
|
+
_write_verdict_line(write=write, files_written=0)
|
|
658
677
|
return 1
|
|
659
678
|
except DoctorApplyError as exc:
|
|
679
|
+
# Every merge happens before the first byte is written, so an
|
|
680
|
+
# abort here always leaves the disk untouched.
|
|
660
681
|
print(f"doctor --apply: {exc}", file=sys.stderr)
|
|
682
|
+
_write_verdict_line(write=write, files_written=0)
|
|
661
683
|
return 1
|
|
662
684
|
|
|
663
685
|
label = "Apply" if write else "Dry-run"
|
|
@@ -680,6 +702,8 @@ def _run_apply_or_dry_run(
|
|
|
680
702
|
f" All {result.no_op_patches} patch(es) already applied "
|
|
681
703
|
f"— providers.yaml is up to date."
|
|
682
704
|
)
|
|
705
|
+
_write_verdict_line(write=write, files_written=len(result.written_paths))
|
|
706
|
+
_print_reformat_notice(result, write=write)
|
|
683
707
|
return 0
|
|
684
708
|
|
|
685
709
|
print(
|
|
@@ -694,16 +718,41 @@ def _run_apply_or_dry_run(
|
|
|
694
718
|
print()
|
|
695
719
|
print(diff, end="" if diff.endswith("\n") else "\n")
|
|
696
720
|
|
|
721
|
+
_write_verdict_line(write=write, files_written=len(result.written_paths))
|
|
697
722
|
if write:
|
|
698
723
|
for orig, bak in result.backups.items():
|
|
699
724
|
print(f" Backup: {orig} → {bak}")
|
|
700
725
|
else:
|
|
726
|
+
_print_reformat_notice(result, write=write)
|
|
701
727
|
print()
|
|
702
728
|
print(" (dry-run — no files were modified. Re-run with --apply to write.)")
|
|
703
729
|
|
|
704
730
|
return 0
|
|
705
731
|
|
|
706
732
|
|
|
733
|
+
def _print_reformat_notice(result: object, *, write: bool) -> None:
|
|
734
|
+
"""Dry-run-only heads-up about cosmetic re-serialization deltas.
|
|
735
|
+
|
|
736
|
+
``reformat_only`` holds targets whose re-dump differs from disk
|
|
737
|
+
without any patch having changed a value — today that is explicit
|
|
738
|
+
``key: null`` scalars, which ruamel re-emits as empty scalars.
|
|
739
|
+
These bytes are never written, so the note is purely so an operator
|
|
740
|
+
comparing "the diff I expected" with "the file I have" is not
|
|
741
|
+
surprised. Suppressed under ``--apply``, where nothing about them
|
|
742
|
+
is actionable.
|
|
743
|
+
"""
|
|
744
|
+
reformat = getattr(result, "reformat_only", None) or {}
|
|
745
|
+
if write or not reformat:
|
|
746
|
+
return
|
|
747
|
+
print(
|
|
748
|
+
f" note: {len(reformat)} file(s) would re-serialize with cosmetic "
|
|
749
|
+
"differences (e.g. explicit `null` → empty scalar). These are NOT "
|
|
750
|
+
"written — --apply only rewrites files whose values actually changed."
|
|
751
|
+
)
|
|
752
|
+
for path in sorted(reformat):
|
|
753
|
+
print(f" - {path}")
|
|
754
|
+
|
|
755
|
+
|
|
707
756
|
|
|
708
757
|
|
|
709
758
|
def _run_audit(args: argparse.Namespace) -> int:
|
coderouter/config/loader.py
CHANGED
|
@@ -7,6 +7,17 @@ Search order (first hit wins):
|
|
|
7
7
|
4. ~/.coderouter/providers.yaml
|
|
8
8
|
|
|
9
9
|
Secrets are resolved by reading the env var named by `api_key_env`.
|
|
10
|
+
|
|
11
|
+
.. note::
|
|
12
|
+
**[Unreleased] deprecation notice (planned v2.12.0):** step 3 above
|
|
13
|
+
(implicit CWD discovery) is slated to become opt-in, gated behind
|
|
14
|
+
``CODEROUTER_ALLOW_CWD_CONFIG``. Landing that change without warning
|
|
15
|
+
first would silently break anyone currently relying on "run from the
|
|
16
|
+
directory with providers.yaml in it" with no diagnostic. Until that
|
|
17
|
+
lands, :func:`load_config` emits a one-time ``cwd-config-loaded``
|
|
18
|
+
warning whenever step 3 is the one that actually resolved — never
|
|
19
|
+
when an explicit ``--config`` / ``CODEROUTER_CONFIG`` path was used,
|
|
20
|
+
even if it happens to point at the same file.
|
|
10
21
|
"""
|
|
11
22
|
|
|
12
23
|
from __future__ import annotations
|
|
@@ -17,6 +28,14 @@ from pathlib import Path
|
|
|
17
28
|
import yaml
|
|
18
29
|
|
|
19
30
|
from coderouter.config.schemas import CodeRouterConfig
|
|
31
|
+
from coderouter.logging import get_logger
|
|
32
|
+
|
|
33
|
+
logger = get_logger(__name__)
|
|
34
|
+
|
|
35
|
+
# Guards the one-time [Unreleased]/v2.12.0 CWD-config deprecation warning
|
|
36
|
+
# (see module docstring) so a process that calls load_config() repeatedly
|
|
37
|
+
# (tests, hot-reload, multiple create_app() calls) only logs it once.
|
|
38
|
+
_cwd_config_warning_emitted = False
|
|
20
39
|
|
|
21
40
|
|
|
22
41
|
def _candidate_paths(explicit: str | os.PathLike[str] | None) -> list[Path]:
|
|
@@ -30,6 +49,51 @@ def _candidate_paths(explicit: str | os.PathLike[str] | None) -> list[Path]:
|
|
|
30
49
|
return paths
|
|
31
50
|
|
|
32
51
|
|
|
52
|
+
def _warn_if_cwd_config(
|
|
53
|
+
chosen: Path, *, explicit: str | os.PathLike[str] | None
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Emit the one-time [Unreleased]/v2.12.0 CWD-config deprecation warning.
|
|
56
|
+
|
|
57
|
+
Only fires when ``chosen`` was resolved via the *implicit* CWD-search
|
|
58
|
+
step. Compares ``chosen`` against the *literal* explicit / env
|
|
59
|
+
candidate paths (not merely whether those inputs were set) so that:
|
|
60
|
+
|
|
61
|
+
* an explicit ``--config`` (or ``CODEROUTER_CONFIG``) path that
|
|
62
|
+
happens to coincide with ``./providers.yaml`` stays quiet — an
|
|
63
|
+
explicit choice, however coincidental, is not the thing being
|
|
64
|
+
deprecated; but
|
|
65
|
+
* an explicit path/env var that was set but did NOT resolve (file
|
|
66
|
+
missing, so the search fell through to CWD) still warns, because
|
|
67
|
+
the CWD step is what actually served the config in that case.
|
|
68
|
+
"""
|
|
69
|
+
global _cwd_config_warning_emitted
|
|
70
|
+
if _cwd_config_warning_emitted:
|
|
71
|
+
return
|
|
72
|
+
cwd_path = Path.cwd() / "providers.yaml"
|
|
73
|
+
if chosen != cwd_path:
|
|
74
|
+
return
|
|
75
|
+
if explicit and Path(explicit) == chosen:
|
|
76
|
+
return
|
|
77
|
+
if (env_path := os.environ.get("CODEROUTER_CONFIG")) and Path(env_path) == chosen:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
_cwd_config_warning_emitted = True
|
|
81
|
+
logger.warning(
|
|
82
|
+
"cwd-config-loaded",
|
|
83
|
+
extra={
|
|
84
|
+
"path": str(chosen),
|
|
85
|
+
"hint": (
|
|
86
|
+
f"loaded providers.yaml from the current working "
|
|
87
|
+
f"directory ({chosen}). Starting in v2.12.0, this "
|
|
88
|
+
"implicit CWD discovery becomes opt-in (gated behind "
|
|
89
|
+
"CODEROUTER_ALLOW_CWD_CONFIG) — pass --config or set "
|
|
90
|
+
"CODEROUTER_CONFIG to pin the file explicitly and avoid "
|
|
91
|
+
"a startup break when that lands."
|
|
92
|
+
),
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
33
97
|
def load_config(path: str | os.PathLike[str] | None = None) -> CodeRouterConfig:
|
|
34
98
|
"""Load providers.yaml + apply ALLOW_PAID env override."""
|
|
35
99
|
candidates = _candidate_paths(path)
|
|
@@ -40,6 +104,7 @@ def load_config(path: str | os.PathLike[str] | None = None) -> CodeRouterConfig:
|
|
|
40
104
|
f"providers.yaml not found. Searched:\n {searched}\n"
|
|
41
105
|
f"Hint: copy examples/providers.yaml to ~/.coderouter/providers.yaml"
|
|
42
106
|
)
|
|
107
|
+
_warn_if_cwd_config(chosen, explicit=path)
|
|
43
108
|
|
|
44
109
|
with chosen.open("r", encoding="utf-8") as f:
|
|
45
110
|
raw = yaml.safe_load(f) or {}
|
coderouter/config/schemas.py
CHANGED
|
@@ -26,6 +26,10 @@ from pydantic import (
|
|
|
26
26
|
model_validator,
|
|
27
27
|
)
|
|
28
28
|
|
|
29
|
+
from coderouter.logging import get_logger
|
|
30
|
+
|
|
31
|
+
logger = get_logger(__name__)
|
|
32
|
+
|
|
29
33
|
|
|
30
34
|
class Capabilities(BaseModel):
|
|
31
35
|
"""Capability flags per provider (plan.md §2.5)."""
|
|
@@ -350,6 +354,17 @@ class AgentCliConfig(BaseModel):
|
|
|
350
354
|
return self
|
|
351
355
|
|
|
352
356
|
|
|
357
|
+
# [Unreleased]/planned v2.12.0: patterns used by ProviderConfig's
|
|
358
|
+
# ``_warn_restart_command_shell_syntax`` validator to flag
|
|
359
|
+
# ``restart_command`` values that rely on shell syntax not honored by
|
|
360
|
+
# the planned ``shlex.split`` + ``shell=False`` dispatch (see that
|
|
361
|
+
# validator's docstring and the field description below for the full
|
|
362
|
+
# rationale). Compiled once at module scope since every ProviderConfig
|
|
363
|
+
# validation reuses them.
|
|
364
|
+
_RESTART_COMMAND_SHELL_META_RE = re.compile(r"(&&|\|\||\||;|>|<|\$\(|`)")
|
|
365
|
+
_RESTART_COMMAND_ENV_PREFIX_RE = re.compile(r"^\w+=")
|
|
366
|
+
|
|
367
|
+
|
|
353
368
|
class ProviderConfig(BaseModel):
|
|
354
369
|
"""A single provider entry from providers.yaml.
|
|
355
370
|
|
|
@@ -497,15 +512,82 @@ class ProviderConfig(BaseModel):
|
|
|
497
512
|
restart_command: str | None = Field(
|
|
498
513
|
default=None,
|
|
499
514
|
description=(
|
|
500
|
-
"v2.0-J (Self-healing):
|
|
501
|
-
"
|
|
515
|
+
"v2.0-J (Self-healing): command to restart this provider's "
|
|
516
|
+
"backend process. Examples: 'ollama serve', "
|
|
502
517
|
"'open -a LM\\ Studio'. Only executed when the profile's "
|
|
503
518
|
"backend_health_action is 'exclude' and the provider "
|
|
504
519
|
"transitions to UNHEALTHY. Unset = no automatic restart "
|
|
505
|
-
"(recovery probe still runs, waiting for manual restart)."
|
|
520
|
+
"(recovery probe still runs, waiting for manual restart). "
|
|
521
|
+
"\n\n"
|
|
522
|
+
"[Unreleased]/planned v2.12.0: currently run as "
|
|
523
|
+
"``subprocess.run(command, shell=True)`` (coderouter/guards/"
|
|
524
|
+
"self_healing.py), so shell syntax works today — pipelines "
|
|
525
|
+
"(``pkill ollama && ollama serve``), ``~`` expansion, and "
|
|
526
|
+
"leading env-var assignments (``OLLAMA_HOST=0.0.0.0 ollama "
|
|
527
|
+
"serve``) are all honored. A future minor switches this to "
|
|
528
|
+
"``shlex.split(command)`` + ``shell=False`` (argv dispatch, "
|
|
529
|
+
"no shell in between), which does NOT support any of the "
|
|
530
|
+
"above — ``&&`` becomes a literal argument to the first "
|
|
531
|
+
"command instead of a separator, ``~/...`` paths are not "
|
|
532
|
+
"expanded, and ``FOO=bar cmd`` fails to exec. If your "
|
|
533
|
+
"command needs shell features, wrap it in a small script "
|
|
534
|
+
"and point restart_command at that script instead, e.g. "
|
|
535
|
+
"``restart_command: /path/to/restart-ollama.sh``."
|
|
506
536
|
),
|
|
507
537
|
)
|
|
508
538
|
|
|
539
|
+
@model_validator(mode="after")
|
|
540
|
+
def _warn_restart_command_shell_syntax(self) -> ProviderConfig:
|
|
541
|
+
"""[Unreleased]/planned v2.12.0 breaking-change heads-up (warning only).
|
|
542
|
+
|
|
543
|
+
``coderouter/guards/self_healing.py`` currently runs
|
|
544
|
+
``restart_command`` via ``subprocess.run(command, shell=True)``.
|
|
545
|
+
The planned v2.12.0 change switches that to
|
|
546
|
+
``shlex.split(command)`` + ``shell=False`` — a shell-syntax
|
|
547
|
+
``restart_command`` that works fine today would then either
|
|
548
|
+
crash (``FileNotFoundError`` for ``~/...`` paths or an
|
|
549
|
+
``ENV=val cmd`` prefix) or, worse, silently misbehave (``&&`` /
|
|
550
|
+
``||`` / ``|`` / ``;`` / redirects becoming literal argv tokens
|
|
551
|
+
instead of shell operators — a provider "restart" that quietly
|
|
552
|
+
does the wrong thing is worse than one that errors loudly).
|
|
553
|
+
|
|
554
|
+
Deliberately never raises: this validator does not enforce
|
|
555
|
+
anything, it only warns ahead of the wire change so operators
|
|
556
|
+
can migrate ``restart_command`` to a plain argv (or a wrapper
|
|
557
|
+
script) before v2.12.0 lands, instead of discovering the break
|
|
558
|
+
the next time a provider actually goes UNHEALTHY.
|
|
559
|
+
"""
|
|
560
|
+
cmd = self.restart_command
|
|
561
|
+
if not cmd:
|
|
562
|
+
return self
|
|
563
|
+
stripped = cmd.strip()
|
|
564
|
+
reasons: list[str] = []
|
|
565
|
+
if _RESTART_COMMAND_SHELL_META_RE.search(cmd):
|
|
566
|
+
reasons.append("shell metacharacter (&&, ||, |, ;, >, <, $(...), or `...`)")
|
|
567
|
+
if stripped.startswith("~/"):
|
|
568
|
+
reasons.append("leading '~/' path (no shell expansion under shell=False)")
|
|
569
|
+
if _RESTART_COMMAND_ENV_PREFIX_RE.match(stripped):
|
|
570
|
+
reasons.append("leading environment-variable assignment (FOO=bar ...)")
|
|
571
|
+
if reasons:
|
|
572
|
+
logger.warning(
|
|
573
|
+
"restart-command-shell-syntax",
|
|
574
|
+
extra={
|
|
575
|
+
"provider": self.name,
|
|
576
|
+
"restart_command": cmd,
|
|
577
|
+
"reasons": reasons,
|
|
578
|
+
"hint": (
|
|
579
|
+
"restart_command currently runs via "
|
|
580
|
+
"subprocess.run(..., shell=True); a planned "
|
|
581
|
+
"v2.12.0 change switches to shlex.split(...) + "
|
|
582
|
+
"shell=False, which does not support this "
|
|
583
|
+
"command's shell syntax. Rewrite it as plain "
|
|
584
|
+
"argv, or wrap it in a script and point "
|
|
585
|
+
"restart_command at that script."
|
|
586
|
+
),
|
|
587
|
+
},
|
|
588
|
+
)
|
|
589
|
+
return self
|
|
590
|
+
|
|
509
591
|
@model_validator(mode="after")
|
|
510
592
|
def _check_output_filters_known(self) -> ProviderConfig:
|
|
511
593
|
"""v1.0-A: fail at config-load on a typo'd filter name.
|
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 = (
|