coderouter-cli 2.11.2__py3-none-any.whl → 2.13.0__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/cli.py CHANGED
@@ -600,28 +600,27 @@ def _run_check_model(args: argparse.Namespace) -> int:
600
600
 
601
601
 
602
602
  def _resolve_config_path(explicit: str | None) -> Path:
603
- """Mirror loader._candidate_paths and return the file actually used.
604
-
605
- Used by ``--apply`` to write back to the same path the loader
606
- picked up when it parsed providers.yaml. Falls through the same
607
- search order so a ``CODEROUTER_CONFIG`` env or default-path lookup
608
- matches the live config.
603
+ """Return the file the loader actually read, for ``--apply`` write-back.
604
+
605
+ Delegates the search order to
606
+ :func:`coderouter.config.loader.resolve_config_path` rather than
607
+ re-implementing it. Re-implementing it here is exactly how the
608
+ v2.13.0 CWD opt-in change would introduce a silent bug: if the loader
609
+ gates ``./providers.yaml`` behind ``CODEROUTER_ALLOW_CWD_CONFIG`` but
610
+ this copy still searched CWD unconditionally, ``doctor --apply`` would
611
+ write patches back into a file that ``load_config`` never read. Keeping
612
+ one source of truth makes that impossible.
613
+
614
+ When nothing exists, falls back to ``~/.coderouter/providers.yaml``
615
+ (the loader's last candidate) — the apply step surfaces a clearer
616
+ error against that path than this resolver would.
609
617
  """
610
- import os
611
-
612
- candidates: list[Path] = []
613
- if explicit:
614
- candidates.append(Path(explicit))
615
- if env_path := os.environ.get("CODEROUTER_CONFIG"):
616
- candidates.append(Path(env_path))
617
- candidates.append(Path.cwd() / "providers.yaml")
618
- candidates.append(Path.home() / ".coderouter" / "providers.yaml")
619
- for p in candidates:
620
- if p.is_file():
621
- return p
622
- # Fall back to the last candidate even if absent — the apply step
623
- # will surface a clearer error than this resolver would.
624
- return candidates[-1]
618
+ from coderouter.config.loader import resolve_config_path
619
+
620
+ resolved = resolve_config_path(explicit)
621
+ if resolved is not None:
622
+ return resolved
623
+ return Path.home() / ".coderouter" / "providers.yaml"
625
624
 
626
625
 
627
626
  def _write_verdict_line(*, write: bool, files_written: int) -> None:
@@ -3,21 +3,26 @@
3
3
  Search order (first hit wins):
4
4
  1. Path passed explicitly (CLI --config flag)
5
5
  2. $CODEROUTER_CONFIG env var
6
- 3. ./providers.yaml (current working dir)
6
+ 3. ./providers.yaml (current working dir) — opt-in since v2.13.0,
7
+ gated behind ``CODEROUTER_ALLOW_CWD_CONFIG`` (truthy: 1/true/yes/on)
7
8
  4. ~/.coderouter/providers.yaml
8
9
 
9
10
  Secrets are resolved by reading the env var named by `api_key_env`.
10
11
 
11
12
  .. 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.
13
+ **v2.13.0 (security):** step 3 above (implicit CWD discovery) is now
14
+ opt-in, gated behind ``CODEROUTER_ALLOW_CWD_CONFIG``. It was a code
15
+ execution vector: a hostile ``providers.yaml`` dropped into a repo
16
+ could steer ``restart_command`` / ``launcher.backends[*].binary`` /
17
+ ``launcher.bench.command_template`` — all of which name executables —
18
+ simply because CodeRouter happened to be started from that directory.
19
+ When the opt-in is enabled and step 3 resolves the config, a one-time
20
+ ``cwd-config-loaded`` warning fires (never when an explicit
21
+ ``--config`` / ``CODEROUTER_CONFIG`` path was used, even if it happens
22
+ to point at the same file). When the opt-in is NOT set but a
23
+ ``./providers.yaml`` exists and was not explicitly named, a one-time
24
+ ``cwd-config-skipped`` warning fires so the operator understands why
25
+ that file was ignored.
21
26
  """
22
27
 
23
28
  from __future__ import annotations
@@ -32,10 +37,25 @@ from coderouter.logging import get_logger
32
37
 
33
38
  logger = get_logger(__name__)
34
39
 
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.
40
+ # Name of the env var that opts CWD ``providers.yaml`` discovery back in
41
+ # (see module docstring). v2.13.0 gates step 3 of the search behind it.
42
+ CWD_CONFIG_ENV = "CODEROUTER_ALLOW_CWD_CONFIG"
43
+ # Truthy vocabulary shared by every CWD-opt-in / ALLOW_PAID style toggle.
44
+ _TRUTHY = {"1", "true", "yes", "on"}
45
+
46
+ # Guards the one-time ``cwd-config-loaded`` warning (opt-in enabled and the
47
+ # CWD step actually resolved) so a process that calls load_config()
48
+ # repeatedly (tests, hot-reload, multiple create_app() calls) only logs it
49
+ # once.
38
50
  _cwd_config_warning_emitted = False
51
+ # Guards the one-time ``cwd-config-skipped`` warning (opt-in NOT set but a
52
+ # ./providers.yaml exists and was ignored) — same once-per-process rationale.
53
+ _cwd_config_skip_warning_emitted = False
54
+
55
+
56
+ def cwd_config_allowed() -> bool:
57
+ """True iff the CWD ``providers.yaml`` opt-in is enabled (v2.13.0)."""
58
+ return os.environ.get(CWD_CONFIG_ENV, "").strip().lower() in _TRUTHY
39
59
 
40
60
 
41
61
  def _candidate_paths(explicit: str | os.PathLike[str] | None) -> list[Path]:
@@ -44,27 +64,58 @@ def _candidate_paths(explicit: str | os.PathLike[str] | None) -> list[Path]:
44
64
  paths.append(Path(explicit))
45
65
  if env_path := os.environ.get("CODEROUTER_CONFIG"):
46
66
  paths.append(Path(env_path))
47
- paths.append(Path.cwd() / "providers.yaml")
67
+ # v2.13.0: implicit CWD discovery is opt-in — a hostile providers.yaml
68
+ # dropped into a repo could otherwise steer restart_command / launcher
69
+ # binaries simply because CodeRouter was started from that directory.
70
+ if cwd_config_allowed():
71
+ paths.append(Path.cwd() / "providers.yaml")
48
72
  paths.append(Path.home() / ".coderouter" / "providers.yaml")
49
73
  return paths
50
74
 
51
75
 
76
+ def resolve_config_path(
77
+ explicit: str | os.PathLike[str] | None = None,
78
+ ) -> Path | None:
79
+ """Return the config file the loader would pick, or None if none exists.
80
+
81
+ Single source of truth for the search order so callers (e.g.
82
+ ``coderouter.cli._resolve_config_path`` driving ``doctor --apply``)
83
+ never re-implement it. Re-implementing it risks writing back to a
84
+ different file than the one :func:`load_config` actually read — the
85
+ exact regression the v2.13.0 CWD opt-in would otherwise introduce.
86
+ """
87
+ return next((p for p in _candidate_paths(explicit) if p.is_file()), None)
88
+
89
+
90
+ def _explicitly_named(
91
+ path: Path, explicit: str | os.PathLike[str] | None
92
+ ) -> bool:
93
+ """True iff ``path`` matches the literal --config or CODEROUTER_CONFIG value.
94
+
95
+ Used to keep the CWD warnings quiet when ``./providers.yaml`` was
96
+ chosen on purpose (an explicit choice, however coincidental, is not
97
+ the implicit-discovery behaviour being gated).
98
+ """
99
+ if explicit and Path(explicit) == path:
100
+ return True
101
+ env_path = os.environ.get("CODEROUTER_CONFIG")
102
+ return env_path is not None and Path(env_path) == path
103
+
104
+
52
105
  def _warn_if_cwd_config(
53
106
  chosen: Path, *, explicit: str | os.PathLike[str] | None
54
107
  ) -> None:
55
- """Emit the one-time [Unreleased]/v2.12.0 CWD-config deprecation warning.
108
+ """Emit the one-time ``cwd-config-loaded`` warning (opt-in is enabled).
56
109
 
57
110
  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.
111
+ step — which, since v2.13.0, only runs when
112
+ ``CODEROUTER_ALLOW_CWD_CONFIG`` is set. Compares ``chosen`` against the
113
+ *literal* explicit / env candidate paths (not merely whether those
114
+ inputs were set) so that an explicit ``--config`` (or
115
+ ``CODEROUTER_CONFIG``) path that happens to coincide with
116
+ ``./providers.yaml`` stays quiet, while an explicit path/env var that
117
+ was set but did NOT resolve (file missing, search fell through to CWD)
118
+ still warns — the CWD step is what actually served the config there.
68
119
  """
69
120
  global _cwd_config_warning_emitted
70
121
  if _cwd_config_warning_emitted:
@@ -72,9 +123,7 @@ def _warn_if_cwd_config(
72
123
  cwd_path = Path.cwd() / "providers.yaml"
73
124
  if chosen != cwd_path:
74
125
  return
75
- if explicit and Path(explicit) == chosen:
76
- return
77
- if (env_path := os.environ.get("CODEROUTER_CONFIG")) and Path(env_path) == chosen:
126
+ if _explicitly_named(chosen, explicit):
78
127
  return
79
128
 
80
129
  _cwd_config_warning_emitted = True
@@ -84,26 +133,76 @@ def _warn_if_cwd_config(
84
133
  "path": str(chosen),
85
134
  "hint": (
86
135
  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."
136
+ f"directory ({chosen}) because CODEROUTER_ALLOW_CWD_CONFIG "
137
+ "opt-in is enabled. This file's restart_command / "
138
+ "launcher.backends[*].binary / "
139
+ "launcher.bench.command_template decide which executables "
140
+ "run — only enable the opt-in in directories you trust."
92
141
  ),
93
142
  },
94
143
  )
95
144
 
96
145
 
146
+ def _warn_if_cwd_config_skipped(
147
+ explicit: str | os.PathLike[str] | None,
148
+ ) -> Path | None:
149
+ """Warn once when a ./providers.yaml exists but was skipped (no opt-in).
150
+
151
+ Returns the skipped CWD path (so :func:`load_config` can name it in a
152
+ later ``FileNotFoundError``), or None when there is nothing to skip:
153
+ the opt-in is enabled, no ``./providers.yaml`` exists, or the file was
154
+ explicitly named (``--config`` / ``CODEROUTER_CONFIG``) — in which case
155
+ it is not being skipped at all.
156
+ """
157
+ global _cwd_config_skip_warning_emitted
158
+ if cwd_config_allowed():
159
+ return None
160
+ cwd_path = Path.cwd() / "providers.yaml"
161
+ if not cwd_path.is_file():
162
+ return None
163
+ if _explicitly_named(cwd_path, explicit):
164
+ return None
165
+
166
+ if not _cwd_config_skip_warning_emitted:
167
+ _cwd_config_skip_warning_emitted = True
168
+ logger.warning(
169
+ "cwd-config-skipped",
170
+ extra={
171
+ "path": str(cwd_path),
172
+ "hint": (
173
+ f"found providers.yaml in the current working "
174
+ f"directory ({cwd_path}) but did NOT load it: implicit "
175
+ "CWD discovery is opt-in since v2.13.0 (a hostile "
176
+ "providers.yaml can steer restart_command / launcher "
177
+ "binaries). To use it, set CODEROUTER_ALLOW_CWD_CONFIG=1 "
178
+ "(only in directories you trust) or pass --config "
179
+ f"{cwd_path} to name it explicitly."
180
+ ),
181
+ },
182
+ )
183
+ return cwd_path
184
+
185
+
97
186
  def load_config(path: str | os.PathLike[str] | None = None) -> CodeRouterConfig:
98
187
  """Load providers.yaml + apply ALLOW_PAID env override."""
188
+ skipped_cwd = _warn_if_cwd_config_skipped(path)
99
189
  candidates = _candidate_paths(path)
100
190
  chosen: Path | None = next((p for p in candidates if p.is_file()), None)
101
191
  if chosen is None:
102
192
  searched = "\n ".join(str(p) for p in candidates)
103
- raise FileNotFoundError(
193
+ message = (
104
194
  f"providers.yaml not found. Searched:\n {searched}\n"
105
195
  f"Hint: copy examples/providers.yaml to ~/.coderouter/providers.yaml"
106
196
  )
197
+ if skipped_cwd is not None:
198
+ message += (
199
+ f"\nNote: {skipped_cwd} exists but was NOT read — implicit "
200
+ "CWD discovery is opt-in since v2.13.0. Three ways to use "
201
+ f"it: pass --config {skipped_cwd}, set "
202
+ f"CODEROUTER_CONFIG={skipped_cwd}, or set "
203
+ "CODEROUTER_ALLOW_CWD_CONFIG=1 (only in directories you trust)."
204
+ )
205
+ raise FileNotFoundError(message)
107
206
  _warn_if_cwd_config(chosen, explicit=path)
108
207
 
109
208
  with chosen.open("r", encoding="utf-8") as f:
@@ -27,6 +27,7 @@ from pydantic import (
27
27
  )
28
28
 
29
29
  from coderouter.logging import get_logger
30
+ from coderouter.token_estimation import set_include_tool_content
30
31
 
31
32
  logger = get_logger(__name__)
32
33
 
@@ -354,15 +355,20 @@ class AgentCliConfig(BaseModel):
354
355
  return self
355
356
 
356
357
 
357
- # [Unreleased]/planned v2.12.0: patterns used by ProviderConfig's
358
+ # v2.13.0: patterns used by ProviderConfig's
358
359
  # ``_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+=")
360
+ # ``restart_command`` values that rely on shell syntax not honored by the
361
+ # ``shlex.split`` + ``shell=False`` dispatch (see that validator's
362
+ # docstring and the field description below for the full rationale).
363
+ # Public (no leading underscore) because ``coderouter.guards.self_healing``
364
+ # imports ``RESTART_COMMAND_SHELL_META_RE`` to *refuse* to run any
365
+ # restart_command containing shell metacharacters — under shell=False a
366
+ # value like ``touch a; b`` would create a file literally named ``a;`` and
367
+ # exit 0, i.e. report a "successful" restart while only half-executing.
368
+ # Compiled once at module scope since every ProviderConfig validation
369
+ # reuses them.
370
+ RESTART_COMMAND_SHELL_META_RE = re.compile(r"(&&|\|\||\||;|>|<|\$\(|`)")
371
+ RESTART_COMMAND_ENV_PREFIX_RE = re.compile(r"^\w+=")
366
372
 
367
373
 
368
374
  class ProviderConfig(BaseModel):
@@ -519,54 +525,56 @@ class ProviderConfig(BaseModel):
519
525
  "transitions to UNHEALTHY. Unset = no automatic restart "
520
526
  "(recovery probe still runs, waiting for manual restart). "
521
527
  "\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``."
528
+ "v2.13.0: run as ``subprocess.run(shlex.split(command), "
529
+ "shell=False)`` (coderouter/guards/self_healing.py) — argv "
530
+ "dispatch, no shell in between. Pipelines "
531
+ "(``pkill ollama && ollama serve``), redirects, ``~`` "
532
+ "expansion, and leading env-var assignments "
533
+ "(``OLLAMA_HOST=0.0.0.0 ollama serve``) are NOT supported: "
534
+ "``~/...`` paths are not expanded and ``FOO=bar cmd`` fails to "
535
+ "exec. A value containing shell metacharacters (``&&``, ``||``, "
536
+ "``|``, ``;``, ``>``, ``<``, ``$(...)``, ```...```) is refused "
537
+ "outright (not run) rather than half-executed. If your command "
538
+ "needs shell features, wrap it in a small script and point "
539
+ "restart_command at that script (e.g. "
540
+ "``restart_command: /path/to/restart-ollama.sh``), or write it "
541
+ "as ``/bin/sh -c '...'`` explicitly."
536
542
  ),
537
543
  )
538
544
 
539
545
  @model_validator(mode="after")
540
546
  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).
547
+ """v2.13.0: warn that this ``restart_command`` will not run as written.
548
+
549
+ ``coderouter/guards/self_healing.py`` runs ``restart_command`` via
550
+ ``subprocess.run(shlex.split(command), shell=False)`` — argv
551
+ dispatch, no shell. A shell-syntax ``restart_command`` therefore
552
+ does NOT do what the shell would do:
553
+
554
+ * shell metacharacters (``&&`` / ``||`` / ``|`` / ``;`` /
555
+ redirects / ``$(...)`` / backticks) → the value is *refused*
556
+ (not run) by self_healing, because under shell=False they would
557
+ otherwise become literal argv tokens and half-execute a
558
+ provider "restart" that quietly does the wrong thing;
559
+ * a leading ``~/...`` path or an ``ENV=val cmd`` prefix →
560
+ ``exec`` fails (``FileNotFoundError``) because there is no shell
561
+ to expand ``~`` or apply the assignment.
553
562
 
554
563
  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.
564
+ anything, it only warns so an existing providers.yaml still loads
565
+ (rather than becoming un-startable). Migrate ``restart_command`` to
566
+ a plain argv, a wrapper script, or an explicit ``/bin/sh -c '...'``.
559
567
  """
560
568
  cmd = self.restart_command
561
569
  if not cmd:
562
570
  return self
563
571
  stripped = cmd.strip()
564
572
  reasons: list[str] = []
565
- if _RESTART_COMMAND_SHELL_META_RE.search(cmd):
573
+ if RESTART_COMMAND_SHELL_META_RE.search(cmd):
566
574
  reasons.append("shell metacharacter (&&, ||, |, ;, >, <, $(...), or `...`)")
567
575
  if stripped.startswith("~/"):
568
576
  reasons.append("leading '~/' path (no shell expansion under shell=False)")
569
- if _RESTART_COMMAND_ENV_PREFIX_RE.match(stripped):
577
+ if RESTART_COMMAND_ENV_PREFIX_RE.match(stripped):
570
578
  reasons.append("leading environment-variable assignment (FOO=bar ...)")
571
579
  if reasons:
572
580
  logger.warning(
@@ -576,13 +584,13 @@ class ProviderConfig(BaseModel):
576
584
  "restart_command": cmd,
577
585
  "reasons": reasons,
578
586
  "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."
587
+ "restart_command runs via "
588
+ "subprocess.run(shlex.split(...), shell=False) as of "
589
+ "v2.13.0, which does not support this command's "
590
+ "shell syntax: values with shell metacharacters are "
591
+ "refused (not run), and '~/' / 'FOO=bar' prefixes "
592
+ "fail to exec. Rewrite it as plain argv, wrap it in "
593
+ "a script, or use /bin/sh -c '...'."
586
594
  ),
587
595
  },
588
596
  )
@@ -1572,6 +1580,28 @@ class LauncherOptionProfile(BaseModel):
1572
1580
  )
1573
1581
 
1574
1582
 
1583
+ class LauncherBenchPreset(BaseModel):
1584
+ """launcher.bench.presets[<key>] の1エントリ。H-2: スイープAPIが受け付けるのはキー名だけ。"""
1585
+
1586
+ model_config = ConfigDict(extra="forbid")
1587
+ name: str = Field(..., min_length=1, description="Launcher UI に出す表示名。")
1588
+ command_template: str = Field(
1589
+ ...,
1590
+ min_length=1,
1591
+ description="外部ベンチコマンドのテンプレ。{port} {config} {base_url} {results_dir} {runs} を単純置換で展開。",
1592
+ )
1593
+ runs: int | None = Field(
1594
+ default=None,
1595
+ ge=1,
1596
+ le=1000,
1597
+ description="このプリセット固有の実行回数。None なら launcher.bench.runs。",
1598
+ )
1599
+ results_dir: str | None = Field(
1600
+ default=None,
1601
+ description="このプリセット固有の results ディレクトリ。None なら launcher.bench.results_dir。リクエストからは指定できない。",
1602
+ )
1603
+
1604
+
1575
1605
  class LauncherBenchConfig(BaseModel):
1576
1606
  """The ``launcher.bench:`` block — defaults for the bench sweep feature.
1577
1607
 
@@ -1613,6 +1643,23 @@ class LauncherBenchConfig(BaseModel):
1613
1643
  "大きな GGUF ロードを考慮した既定 5 分。"
1614
1644
  ),
1615
1645
  )
1646
+ presets: dict[str, LauncherBenchPreset] = Field(
1647
+ default_factory=dict,
1648
+ description="名前付きベンチコマンド。キーが POST /api/launcher/sweep/start の bench_preset が取りうる唯一の値。",
1649
+ )
1650
+ default_preset: str | None = Field(
1651
+ default=None,
1652
+ description="bench_preset 未指定時に使うプリセットキー。None なら command_template 由来の暗黙 default。",
1653
+ )
1654
+
1655
+ @model_validator(mode="after")
1656
+ def _check_default_preset(self) -> Self:
1657
+ if self.default_preset is not None and self.default_preset not in self.presets:
1658
+ raise ValueError(
1659
+ f"launcher.bench.default_preset={self.default_preset!r} is not a key of "
1660
+ f"launcher.bench.presets (known: {sorted(self.presets) or 'none'})"
1661
+ )
1662
+ return self
1616
1663
 
1617
1664
 
1618
1665
  class SwapModelSpec(BaseModel):
@@ -2306,6 +2353,36 @@ class CodeRouterConfig(BaseModel):
2306
2353
  ),
2307
2354
  )
2308
2355
 
2356
+ # H-5 escape hatch: token-estimation scope.
2357
+ #
2358
+ # Deliberately top-level rather than per-``FallbackChain``, even
2359
+ # though ``context_budget_action`` lives on the chain. The shared
2360
+ # char/4 estimator has four consumers and three of them run with no
2361
+ # profile in hand: the auto-router runs *before* a profile exists
2362
+ # (it is what picks one), ``POST /v1/messages/count_tokens`` answers
2363
+ # a client question that must not depend on which chain would have
2364
+ # served it, and language-tax measurement is profile-agnostic. A
2365
+ # per-profile switch would let the router and the guard disagree
2366
+ # about the size of the very same request — exactly the class of bug
2367
+ # this key exists to let operators back out of.
2368
+ token_estimation_include_tool_content: bool = Field(
2369
+ default=True,
2370
+ description=(
2371
+ "v2.12 (H-5): count ``tool_result`` / ``tool_use`` / "
2372
+ "``thinking`` content blocks in the shared char/4 token "
2373
+ "estimator. True (default) is the correct behavior — up to "
2374
+ "v2.11.x these blocks estimated to 0 chars, under-counting a "
2375
+ "Claude Code style session by 5x at 20 turns and ~29x at 200. "
2376
+ "Image blocks stay at 0 either way. Set to false as an escape "
2377
+ "hatch to restore v2.11.x-identical estimates (and therefore "
2378
+ "v2.11.x context-budget firing, auto-router "
2379
+ "``content_token_count_min`` matching and "
2380
+ "``/v1/messages/count_tokens`` numbers) if the corrected "
2381
+ "estimate disrupts a tuned deployment. Compatibility shim "
2382
+ "only — scheduled for removal in a future release."
2383
+ ),
2384
+ )
2385
+
2309
2386
  # v2.0-I: Continuous probing — background health checks for idle periods.
2310
2387
  continuous_probe: Literal["off", "active"] = Field(
2311
2388
  default="off",
@@ -2816,6 +2893,25 @@ class CodeRouterConfig(BaseModel):
2816
2893
  )
2817
2894
  return self
2818
2895
 
2896
+ @model_validator(mode="after")
2897
+ def _apply_token_estimation_scope(self) -> CodeRouterConfig:
2898
+ """Publish ``token_estimation_include_tool_content`` process-wide.
2899
+
2900
+ The char/4 estimator is a dependency-free leaf module that every
2901
+ consumer imports directly as a plain function; there is no
2902
+ object graph to thread a flag through, and three of the four
2903
+ consumers have no profile/config handle at their call site. So
2904
+ the loaded config pushes the setting into the module once, here.
2905
+
2906
+ Idempotent and deterministic: the value always reflects the most
2907
+ recently constructed config, which in a server process is the
2908
+ single config that was loaded at startup. Declared last so a
2909
+ config that fails any other validator never takes effect. Tests
2910
+ that flip it should restore the default (True) afterwards.
2911
+ """
2912
+ set_include_tool_content(self.token_estimation_include_tool_content)
2913
+ return self
2914
+
2819
2915
  def provider_by_name(self, name: str) -> ProviderConfig:
2820
2916
  """Look up a provider config by name. Raises KeyError if not found."""
2821
2917
  for p in self.providers: