coderouter-cli 2.6.1__py3-none-any.whl → 2.7.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.
@@ -35,6 +35,7 @@ from collections.abc import AsyncIterator
35
35
  from typing import Any
36
36
 
37
37
  import httpx
38
+ from pydantic import ValidationError
38
39
 
39
40
  from coderouter.adapters.base import (
40
41
  AdapterError,
@@ -68,6 +69,26 @@ _RETRYABLE_STATUSES = {404, 408, 425, 429, 500, 502, 503, 504}
68
69
  _DEFAULT_ANTHROPIC_VERSION = "2023-06-01"
69
70
 
70
71
 
72
+ def anthropic_messages_url(base_url: str) -> str:
73
+ """Resolve the ``/v1/messages`` endpoint URL from a ``base_url``.
74
+
75
+ ``base_url`` may be given with or without a trailing ``/v1`` — users
76
+ may point it at either ``https://api.anthropic.com`` or
77
+ ``https://api.anthropic.com/v1`` (LM Studio and similar servers
78
+ commonly advertise the latter). We normalize by stripping a trailing
79
+ ``/v1`` so appending ``/v1/messages`` always yields a valid,
80
+ non-duplicated URL.
81
+
82
+ Shared by :class:`AnthropicAdapter` and the continuous-probe guard so
83
+ both hit the exact same endpoint (see bug H4: a mismatch made the
84
+ probe target ``/v1/v1/messages`` and 404 on healthy backends).
85
+ """
86
+ base = base_url.rstrip("/")
87
+ if base.endswith("/v1"):
88
+ base = base[: -len("/v1")]
89
+ return f"{base}/v1/messages"
90
+
91
+
71
92
  class AnthropicAdapter(BaseAdapter):
72
93
  """Native Anthropic Messages API adapter (passthrough).
73
94
 
@@ -87,15 +108,10 @@ class AnthropicAdapter(BaseAdapter):
87
108
 
88
109
  ``base_url`` may be given with or without a trailing ``/v1`` —
89
110
  we normalize by stripping it so appending ``/v1/messages``
90
- always yields a valid URL.
111
+ always yields a valid URL. Delegates to the shared
112
+ :func:`anthropic_messages_url` helper.
91
113
  """
92
- base = str(self.config.base_url).rstrip("/")
93
- # Users may point base_url at either `https://api.anthropic.com`
94
- # or `https://api.anthropic.com/v1`. We normalize to the former so
95
- # we can always append /v1/messages.
96
- if base.endswith("/v1"):
97
- base = base[: -len("/v1")]
98
- return f"{base}/v1/messages"
114
+ return anthropic_messages_url(str(self.config.base_url))
99
115
 
100
116
  def _headers(self, request: AnthropicRequest | None = None) -> dict[str, str]:
101
117
  """Build Anthropic-native HTTP headers, including beta-header forwarding.
@@ -161,21 +177,21 @@ class AnthropicAdapter(BaseAdapter):
161
177
  that auth works and the endpoint is reachable.
162
178
  """
163
179
  try:
164
- async with httpx.AsyncClient(timeout=5.0) as client:
165
- resp = await client.post(
166
- self._url(),
167
- headers=self._headers(),
168
- json={
169
- "model": self.config.model,
170
- "max_tokens": 1,
171
- "messages": [{"role": "user", "content": "ping"}],
172
- },
173
- )
174
- # 200 is clearly healthy. 4xx auth errors indicate the
175
- # endpoint is reachable even if the key is bad — still a
176
- # "the server answered" signal for healthcheck purposes.
177
- # 5xx is upstream trouble.
178
- return resp.status_code < 500
180
+ resp = await self.client().post(
181
+ self._url(),
182
+ headers=self._headers(),
183
+ json={
184
+ "model": self.config.model,
185
+ "max_tokens": 1,
186
+ "messages": [{"role": "user", "content": "ping"}],
187
+ },
188
+ timeout=5.0,
189
+ )
190
+ # 200 is clearly healthy. 4xx auth errors indicate the
191
+ # endpoint is reachable even if the key is bad — still a
192
+ # "the server answered" signal for healthcheck purposes.
193
+ # 5xx is upstream trouble.
194
+ return resp.status_code < 500
179
195
  except httpx.HTTPError:
180
196
  return False
181
197
 
@@ -331,8 +347,9 @@ class AnthropicAdapter(BaseAdapter):
331
347
  timeout = self.effective_timeout(overrides)
332
348
 
333
349
  try:
334
- async with httpx.AsyncClient(timeout=timeout) as client:
335
- resp = await client.post(url, json=payload, headers=self._headers(request))
350
+ resp = await self.client().post(
351
+ url, json=payload, headers=self._headers(request), timeout=timeout
352
+ )
336
353
  except httpx.TimeoutException as exc:
337
354
  raise AdapterError(
338
355
  f"timeout contacting {url}", provider=self.name, retryable=True
@@ -394,7 +411,22 @@ class AnthropicAdapter(BaseAdapter):
394
411
  # (future additions like thinking blocks) pass through via
395
412
  # extra="allow" on AnthropicResponse.
396
413
  data["coderouter_provider"] = self.name
397
- return AnthropicResponse.model_validate(data)
414
+ # M6: guard the model validation. A 200 body that isn't a valid
415
+ # Anthropic Messages response (missing ``content``/``id``, an
416
+ # error envelope returned with a 200, etc.) would otherwise raise
417
+ # a bare pydantic ValidationError that escapes the engine's
418
+ # AdapterError-based retry/fallback path. Convert to a retryable
419
+ # AdapterError so the chain can fall through (mirrors the
420
+ # invalid-JSON branch above, but retryable — a malformed shape is
421
+ # usually transient upstream noise rather than a config fault).
422
+ try:
423
+ return AnthropicResponse.model_validate(data)
424
+ except ValidationError as exc:
425
+ raise AdapterError(
426
+ f"malformed response shape from upstream: {exc}",
427
+ provider=self.name,
428
+ retryable=True,
429
+ ) from exc
398
430
 
399
431
  async def stream_anthropic(
400
432
  self,
@@ -424,10 +456,14 @@ class AnthropicAdapter(BaseAdapter):
424
456
  logged_flag: list[bool] = [False]
425
457
 
426
458
  try:
427
- async with (
428
- httpx.AsyncClient(timeout=timeout) as client,
429
- client.stream("POST", url, json=payload, headers=self._headers(request)) as resp,
430
- ):
459
+ # H3: stream over the shared client (pool / keep-alive / TLS
460
+ # reuse). Only the ``stream(...)`` context is scoped here; the
461
+ # client persists and is closed via ``aclose`` on shutdown. The
462
+ # ``async with`` releases the response's borrowed connection even
463
+ # if the caller abandons this generator mid-stream — no GC leak.
464
+ async with self.client().stream(
465
+ "POST", url, json=payload, headers=self._headers(request), timeout=timeout
466
+ ) as resp:
431
467
  if resp.status_code >= 400:
432
468
  body = await resp.aread()
433
469
  raise AdapterError(
@@ -11,6 +11,7 @@ from abc import ABC, abstractmethod
11
11
  from collections.abc import AsyncIterator
12
12
  from typing import Any, Literal
13
13
 
14
+ import httpx
14
15
  from pydantic import BaseModel, ConfigDict, Field
15
16
 
16
17
  from coderouter.config.schemas import ProviderConfig
@@ -164,17 +165,59 @@ class BaseAdapter(ABC):
164
165
  def __init__(self, config: ProviderConfig) -> None:
165
166
  """Bind the adapter to a :class:`ProviderConfig`.
166
167
 
167
- Subclasses do not need to override this; HTTP clients are
168
- constructed lazily inside :meth:`generate` / :meth:`stream` so
169
- each call can honor a per-call timeout override.
168
+ Subclasses do not need to override this. A single shared
169
+ :class:`httpx.AsyncClient` is created lazily on first use (see
170
+ :meth:`client`) and reused across every call on this adapter so
171
+ the connection pool, HTTP keep-alive, and TLS session are all
172
+ reused. Per-call timeouts are still honored by passing
173
+ ``timeout=`` to ``client.post`` / ``client.stream`` — they do
174
+ NOT require a fresh client.
170
175
  """
171
176
  self.config = config
177
+ # Shared client, created lazily inside the running event loop the
178
+ # first time an adapter method needs it (see ``client``). Kept as
179
+ # ``None`` until then so constructing an adapter has no I/O cost and
180
+ # is safe outside an event loop (e.g. at import / config time).
181
+ self._client: httpx.AsyncClient | None = None
172
182
 
173
183
  @property
174
184
  def name(self) -> str:
175
185
  """Shortcut for ``self.config.name`` — used in log trails and errors."""
176
186
  return self.config.name
177
187
 
188
+ # ---- H3: shared HTTP client (connection-pool / keep-alive reuse) ----
189
+ def client(self) -> httpx.AsyncClient:
190
+ """Return the shared :class:`httpx.AsyncClient`, creating it lazily.
191
+
192
+ The client is created on first use so construction happens inside
193
+ the running event loop and carries no I/O cost at adapter-build
194
+ time. Subsequent calls return the same instance, which is what
195
+ lets httpx reuse pooled connections, keep-alive, and the TLS
196
+ session across requests.
197
+
198
+ No default timeout is baked in here: every call site passes an
199
+ explicit per-call ``timeout=`` (resolved from the active profile
200
+ via :meth:`effective_timeout`), so leaving the client timeout
201
+ unset avoids a surprising default clamping long-running calls.
202
+ """
203
+ if self._client is None:
204
+ self._client = httpx.AsyncClient(timeout=None)
205
+ return self._client
206
+
207
+ async def aclose(self) -> None:
208
+ """Close the shared HTTP client and drop the reference.
209
+
210
+ Idempotent: safe to call when no client was ever created, and
211
+ safe to call more than once. Invoked from the app lifespan
212
+ shutdown path so pooled connections are released cleanly rather
213
+ than left to garbage collection. After ``aclose`` a later call
214
+ re-creates the client on demand via :meth:`client`.
215
+ """
216
+ client = self._client
217
+ self._client = None
218
+ if client is not None:
219
+ await client.aclose()
220
+
178
221
  # ---- v0.6-B override resolution helpers -----------------------------
179
222
  def effective_timeout(self, overrides: ProviderCallOverrides | None) -> float:
180
223
  """Profile override wins when set; else provider default."""
@@ -19,6 +19,7 @@ from collections.abc import AsyncIterator
19
19
  from typing import Any
20
20
 
21
21
  import httpx
22
+ from pydantic import ValidationError
22
23
 
23
24
  from coderouter.adapters.base import (
24
25
  AdapterError,
@@ -197,9 +198,8 @@ class OpenAICompatAdapter(BaseAdapter):
197
198
  base = str(self.config.base_url).rstrip("/")
198
199
  url = f"{base}/models"
199
200
  try:
200
- async with httpx.AsyncClient(timeout=5.0) as client:
201
- resp = await client.get(url, headers=self._headers())
202
- return resp.status_code < 500
201
+ resp = await self.client().get(url, headers=self._headers(), timeout=5.0)
202
+ return resp.status_code < 500
203
203
  except httpx.HTTPError:
204
204
  return False
205
205
 
@@ -221,8 +221,9 @@ class OpenAICompatAdapter(BaseAdapter):
221
221
  payload = self._payload(request, stream=False, overrides=overrides)
222
222
  timeout = self.effective_timeout(overrides)
223
223
  try:
224
- async with httpx.AsyncClient(timeout=timeout) as client:
225
- resp = await client.post(url, json=payload, headers=self._headers())
224
+ resp = await self.client().post(
225
+ url, json=payload, headers=self._headers(), timeout=timeout
226
+ )
226
227
  except httpx.TimeoutException as exc:
227
228
  raise AdapterError(
228
229
  f"timeout contacting {url}", provider=self.name, retryable=True
@@ -287,7 +288,22 @@ class OpenAICompatAdapter(BaseAdapter):
287
288
 
288
289
  # Tag the response with which provider answered
289
290
  data.setdefault("object", "chat.completion")
290
- return ChatResponse(coderouter_provider=self.name, **data)
291
+ # M6: a syntactically valid JSON body may still not be a valid
292
+ # Chat Completions response (e.g. an OpenAI-compat server that
293
+ # returns ``{"error": ...}`` with a 200, or omits ``choices``).
294
+ # pydantic raises ValidationError here; if left uncaught it would
295
+ # propagate past the engine's AdapterError-based retry/fallback
296
+ # logic. Convert to a retryable AdapterError so the chain can fall
297
+ # through to the next provider (mirrors the invalid-JSON branch,
298
+ # but retryable since a malformed shape is often transient).
299
+ try:
300
+ return ChatResponse(coderouter_provider=self.name, **data)
301
+ except ValidationError as exc:
302
+ raise AdapterError(
303
+ f"malformed response shape from upstream: {exc}",
304
+ provider=self.name,
305
+ retryable=True,
306
+ ) from exc
291
307
 
292
308
  async def stream(
293
309
  self,
@@ -312,6 +328,10 @@ class OpenAICompatAdapter(BaseAdapter):
312
328
  # produce dozens of duplicate log lines.
313
329
  strip_reasoning = not self.config.capabilities.reasoning_passthrough
314
330
  reasoning_logged = False
331
+ # M6: one-shot dedupe flag for the malformed-chunk warning. A single
332
+ # broken stream can emit many non-conforming chunks; we skip each one
333
+ # but log only on the first to avoid a log flood.
334
+ malformed_chunk_logged = False
315
335
 
316
336
  # v1.0-A: stateful output_filters chain for the duration of this
317
337
  # stream. Handles `<think>...</think>` / stop markers that split
@@ -325,10 +345,16 @@ class OpenAICompatAdapter(BaseAdapter):
325
345
  # seen chunk's id/model so the flush emission looks native.
326
346
  last_chunk_template: dict[str, Any] | None = None
327
347
  try:
328
- async with (
329
- httpx.AsyncClient(timeout=timeout) as client,
330
- client.stream("POST", url, json=payload, headers=self._headers()) as resp,
331
- ):
348
+ # H3: stream over the shared client so the connection pool /
349
+ # keep-alive / TLS session are reused. Only the ``stream(...)``
350
+ # context is entered here — the client itself outlives the
351
+ # stream and is closed via ``aclose`` on app shutdown. The
352
+ # ``async with`` guarantees the response (and its borrowed
353
+ # connection) is released even if the consumer abandons this
354
+ # generator mid-stream, so no connection leaks on GC.
355
+ async with self.client().stream(
356
+ "POST", url, json=payload, headers=self._headers(), timeout=timeout
357
+ ) as resp:
332
358
  if resp.status_code >= 400:
333
359
  body = await resp.aread()
334
360
  raise AdapterError(
@@ -375,7 +401,23 @@ class OpenAICompatAdapter(BaseAdapter):
375
401
  if isinstance(content, str) and content:
376
402
  delta["content"] = filter_chain.feed(content)
377
403
  last_chunk_template = payload_obj
378
- yield StreamChunk(**payload_obj)
404
+ # M6: a chunk may be valid JSON yet not a valid
405
+ # StreamChunk (e.g. a mid-stream ``{"error": ...}``
406
+ # frame, or a chunk missing required fields). Rather
407
+ # than let the ValidationError abort the whole stream
408
+ # (bypassing the engine's AdapterError handling), skip
409
+ # the bad chunk and keep consuming. Warn once per stream.
410
+ try:
411
+ chunk = StreamChunk(**payload_obj)
412
+ except ValidationError as exc:
413
+ if not malformed_chunk_logged:
414
+ logger.warning(
415
+ "malformed-stream-chunk",
416
+ extra={"provider": self.name, "error": str(exc)},
417
+ )
418
+ malformed_chunk_logged = True
419
+ continue
420
+ yield chunk
379
421
 
380
422
  # v1.0-A: flush the chain at end-of-stream. If filters held
381
423
  # back a partial-tag suffix that turned out NOT to be a tag,
@@ -115,6 +115,20 @@ class RegistryCapabilities(BaseModel):
115
115
  "startup check)."
116
116
  ),
117
117
  )
118
+ tool_choice: bool | None = Field(
119
+ default=None,
120
+ description=(
121
+ "S2 (shim): does the upstream honor a forced Anthropic "
122
+ "``tool_choice`` (``{type: any}`` / ``{type: tool}``) on the "
123
+ "wire? When True, the fallback engine's ``tool_choice_action`` "
124
+ "leaves the request untouched. ``None`` (default) = no opinion "
125
+ "→ the gate falls back to ``provider.kind == 'anthropic'``. "
126
+ "``False`` hard-disables even on an anthropic-kind provider "
127
+ "(useful when a specific model regresses). Independent from "
128
+ "``providers.yaml capabilities.tool_choice`` (explicit "
129
+ "per-provider opt-in, highest precedence)."
130
+ ),
131
+ )
118
132
  cache_control: bool | None = Field(
119
133
  default=None,
120
134
  description=(
@@ -201,6 +215,7 @@ class ResolvedCapabilities:
201
215
  max_context_tokens: int | None = None
202
216
  claude_code_suitability: Literal["ok", "degraded"] | None = None
203
217
  cache_control: bool | None = None
218
+ tool_choice: bool | None = None
204
219
 
205
220
 
206
221
  # ---------------------------------------------------------------------------
@@ -253,6 +268,7 @@ class CapabilityRegistry:
253
268
  resolved_max_ctx: int | None = None
254
269
  resolved_suitability: Literal["ok", "degraded"] | None = None
255
270
  resolved_cache_control: bool | None = None
271
+ resolved_tool_choice: bool | None = None
256
272
 
257
273
  thinking_locked = False
258
274
  reasoning_locked = False
@@ -260,6 +276,7 @@ class CapabilityRegistry:
260
276
  max_ctx_locked = False
261
277
  suitability_locked = False
262
278
  cache_control_locked = False
279
+ tool_choice_locked = False
263
280
 
264
281
  for rule in self._rules:
265
282
  if not rule.kind_matches(kind):
@@ -285,6 +302,9 @@ class CapabilityRegistry:
285
302
  if not cache_control_locked and caps.cache_control is not None:
286
303
  resolved_cache_control = caps.cache_control
287
304
  cache_control_locked = True
305
+ if not tool_choice_locked and caps.tool_choice is not None:
306
+ resolved_tool_choice = caps.tool_choice
307
+ tool_choice_locked = True
288
308
  if (
289
309
  thinking_locked
290
310
  and reasoning_locked
@@ -292,6 +312,7 @@ class CapabilityRegistry:
292
312
  and max_ctx_locked
293
313
  and suitability_locked
294
314
  and cache_control_locked
315
+ and tool_choice_locked
295
316
  ):
296
317
  break
297
318
 
@@ -302,6 +323,7 @@ class CapabilityRegistry:
302
323
  max_context_tokens=resolved_max_ctx,
303
324
  claude_code_suitability=resolved_suitability,
304
325
  cache_control=resolved_cache_control,
326
+ tool_choice=resolved_tool_choice,
305
327
  )
306
328
 
307
329
  # ------------------------------------------------------------------
@@ -43,6 +43,18 @@ class Capabilities(BaseModel):
43
43
  # you explicitly want the raw reasoning text to flow to the client
44
44
  # (e.g. CodeRouter is fronting a reasoning-aware downstream).
45
45
  reasoning_passthrough: bool = False
46
+ # S2 (shim): Anthropic's ``tool_choice`` forcing modes (``{type: any}``
47
+ # / ``{type: tool, name: ...}``). Only a subset of backends honor a
48
+ # forced tool_choice on the wire; openai_compat translation drops it.
49
+ # This narrow per-model flag mirrors ``thinking`` / ``prompt_cache``:
50
+ # when unset (None), the capability gate falls back to the registry and
51
+ # then to a ``kind == "anthropic"`` heuristic (see
52
+ # ``coderouter/routing/capability.py``). Motivation: let the fallback
53
+ # engine's ``tool_choice_action`` emulate forced tool calls via a
54
+ # system-prompt directive on backends that would otherwise silently
55
+ # ignore the field. Backward compatible — None leaves v2.x behavior
56
+ # untouched (no gate, no emulation).
57
+ tool_choice: bool | None = None
46
58
  # v1.0+ fields, declared early so providers.yaml can future-proof
47
59
  reasoning_control: Literal["none", "openai", "anthropic", "provider_specific"] = "none"
48
60
  mcp: Literal["none", "anthropic", "provider_specific"] = "none"
@@ -706,6 +718,129 @@ class FallbackChain(BaseModel):
706
718
  ),
707
719
  )
708
720
 
721
+ # --- S2 (shim): tool_choice capability gate + emulation -----------------
722
+ #
723
+ # Anthropic clients (Claude Code, SDKs) can pin the model to a specific
724
+ # tool with ``tool_choice: {type: "tool", name: "X"}`` or force *some*
725
+ # tool with ``{type: "any"}``. Native Anthropic honors this; most
726
+ # openai_compat backends silently ignore it after translation, so the
727
+ # model may answer with plain text where the client expected a tool
728
+ # call. This knob decides what the fallback engine does when a forced
729
+ # tool_choice request is routed to a provider that does not support it
730
+ # (per ``provider_supports_tool_choice``):
731
+ #
732
+ # * ``off`` — no detection / no mutation / no log. Backward-compat
733
+ # default (identical to pre-shim behavior).
734
+ # * ``warn`` — emit a ``capability-degraded`` log line only; the
735
+ # request is sent unchanged.
736
+ # * ``emulate`` — strip the ``tool_choice`` field and inject an English
737
+ # directive into the system prompt instructing the
738
+ # model to call the requested tool. Best-effort
739
+ # forcing for backends without native support. The
740
+ # original request object is left untouched so a later
741
+ # capable provider in the chain still receives the real
742
+ # ``tool_choice``.
743
+ tool_choice_action: Literal["off", "warn", "emulate"] = Field(
744
+ default="off",
745
+ description=(
746
+ "S2 (shim): action when a request carries a forced "
747
+ "``tool_choice`` (``{type: any}`` or ``{type: tool}``) and the "
748
+ "target provider does not support it. ``off`` (default) leaves "
749
+ "the request unchanged. ``warn`` emits a ``capability-degraded`` "
750
+ "log only. ``emulate`` strips ``tool_choice`` and injects a "
751
+ "system-prompt directive to coax the model into calling the "
752
+ "tool. Per-provider — capable providers are never mutated."
753
+ ),
754
+ )
755
+
756
+ # --- S3 (shim): cache_control strip -------------------------------------
757
+ #
758
+ # By default cache_control markers are simply lost during Anthropic →
759
+ # OpenAI translation and the ``capability-degraded`` gate logs it
760
+ # (v0.5-B, observability only). Some strict openai_compat backends,
761
+ # however, 400 when an unexpected ``cache_control`` key rides along on a
762
+ # content block (rather than silently ignoring it). ``strip`` proactively
763
+ # removes those keys from a deep copy of the request before dispatch to
764
+ # a non-supporting provider, so the marker never reaches the wire.
765
+ #
766
+ # * ``off`` — legacy behavior: leave the request as-is and rely on
767
+ # the existing ``capability-degraded`` observability log.
768
+ # * ``strip`` — deep-copy the request and remove every ``cache_control``
769
+ # key from system / tools / message blocks before sending
770
+ # to a non-supporting provider; emit a
771
+ # ``cache-control-stripped`` log with the marker count.
772
+ # Does NOT emit a tokens-saved event (this is not token
773
+ # savings, just a wire-compatibility strip).
774
+ cache_control_action: Literal["off", "strip"] = Field(
775
+ default="off",
776
+ description=(
777
+ "S3 (shim): action when a request carries ``cache_control`` "
778
+ "markers and the target provider does not support them. ``off`` "
779
+ "(default) keeps legacy behavior (marker dropped in translation, "
780
+ "``capability-degraded`` log only). ``strip`` removes the "
781
+ "``cache_control`` keys from a deep copy before dispatch and "
782
+ "emits a ``cache-control-stripped`` log. Per-provider — capable "
783
+ "providers are never mutated."
784
+ ),
785
+ )
786
+
787
+ @model_validator(mode="after")
788
+ def _check_context_budget_thresholds_ordered(self) -> FallbackChain:
789
+ """v2.0-F (mE): cross-field sanity of the context-budget ratios.
790
+
791
+ The three ratios describe a strict staircase: a warning fires
792
+ first, then trimming kicks in at a higher usage, and trimming
793
+ reclaims space down to a target below the trim point. If they are
794
+ mis-ordered the guard is either a dead knob (warn above trim never
795
+ fires) or an infinite trim loop (target >= trim never converges).
796
+ Same fast-fail philosophy as ``_check_default_profile_exists`` —
797
+ surface the mistake at load with a concrete pointer to the fix
798
+ rather than at the first request that trips the guard.
799
+
800
+ Required invariants:
801
+ * ``context_budget_warn_threshold`` <= ``context_budget_trim_threshold``
802
+ * ``context_budget_trim_target`` < ``context_budget_trim_threshold``
803
+ """
804
+ if self.context_budget_warn_threshold > self.context_budget_trim_threshold:
805
+ raise ValueError(
806
+ f"profile {self.name!r}: context_budget_warn_threshold "
807
+ f"({self.context_budget_warn_threshold}) must be <= "
808
+ f"context_budget_trim_threshold "
809
+ f"({self.context_budget_trim_threshold}) — the warning has to "
810
+ f"fire at or before trimming, otherwise it can never fire. "
811
+ f"Lower warn_threshold or raise trim_threshold."
812
+ )
813
+ if self.context_budget_trim_target >= self.context_budget_trim_threshold:
814
+ raise ValueError(
815
+ f"profile {self.name!r}: context_budget_trim_target "
816
+ f"({self.context_budget_trim_target}) must be < "
817
+ f"context_budget_trim_threshold "
818
+ f"({self.context_budget_trim_threshold}) — trimming must "
819
+ f"reclaim space *below* the trigger point, otherwise trim "
820
+ f"never converges. Lower trim_target below trim_threshold."
821
+ )
822
+ return self
823
+
824
+ @model_validator(mode="after")
825
+ def _check_recovery_probe_interval_ordered(self) -> FallbackChain:
826
+ """v2.0-J (mE): the recovery-probe backoff floor must not exceed its cap.
827
+
828
+ ``recovery_probe_initial_s`` is the first probe interval and each
829
+ failed probe doubles it up to ``recovery_probe_max_s``. When the
830
+ initial interval already exceeds the max, the exponential-backoff
831
+ ceiling is below its own floor — a nonsensical configuration that
832
+ the backoff loop would silently clamp. Surface it at load instead.
833
+ """
834
+ if self.recovery_probe_initial_s > self.recovery_probe_max_s:
835
+ raise ValueError(
836
+ f"profile {self.name!r}: recovery_probe_initial_s "
837
+ f"({self.recovery_probe_initial_s}) must be <= "
838
+ f"recovery_probe_max_s ({self.recovery_probe_max_s}) — the "
839
+ f"initial probe interval cannot exceed the backoff ceiling. "
840
+ f"Lower recovery_probe_initial_s or raise recovery_probe_max_s."
841
+ )
842
+ return self
843
+
709
844
 
710
845
  # ---------------------------------------------------------------------------
711
846
  # v1.6-A: auto_router — declarative request-body classifier
@@ -720,6 +855,14 @@ class RuleMatcher(BaseModel):
720
855
  adding a new optional field — the single-field invariant enforces
721
856
  discriminated-union semantics without pydantic's tagged-union syntax.
722
857
 
858
+ Boolean matchers (``has_image`` / ``has_tools``) only carry meaning at
859
+ ``True``: the runtime evaluator matches with ``is True`` (see
860
+ ``coderouter.routing.auto_router._match_rule``), so a ``False`` value
861
+ would construct without error yet never match anything — a dead rule
862
+ that silently shadows nothing and confuses operators. ``_exactly_one``
863
+ therefore rejects ``False`` for these fields at load (``None`` remains
864
+ the "unset" sentinel).
865
+
723
866
  Variants (v1.6-A):
724
867
 
725
868
  - ``has_image: True`` — any ``image_url`` / ``image`` /
@@ -822,8 +965,29 @@ class RuleMatcher(BaseModel):
822
965
  "cjk_ratio_min",
823
966
  )
824
967
 
968
+ # Boolean matcher fields carry meaning only at ``True`` — the runtime
969
+ # evaluator uses ``is True`` — so a ``False`` value is a dead rule that
970
+ # never matches. ``_exactly_one`` rejects it explicitly (see below).
971
+ _BOOL_MATCHER_FIELDS: tuple[str, ...] = ("has_image", "has_tools")
972
+
825
973
  @model_validator(mode="after")
826
974
  def _exactly_one(self) -> Self:
975
+ # Reject the dead-rule shape first so the error names the specific
976
+ # field the operator got wrong, rather than the generic
977
+ # "exactly one" message (``False`` counts as "set" below).
978
+ false_bools = [
979
+ name
980
+ for name in self._BOOL_MATCHER_FIELDS
981
+ if getattr(self, name) is False
982
+ ]
983
+ if false_bools:
984
+ raise ValueError(
985
+ f"RuleMatcher boolean matcher(s) set to False: {false_bools}. "
986
+ f"These matchers are evaluated with ``is True``, so a False "
987
+ f"value is a dead rule that never matches (it would silently "
988
+ f"shadow nothing). Use True to match, or omit the field "
989
+ f"(leave it None) to not use this matcher."
990
+ )
827
991
  set_fields = [
828
992
  name for name in self._MATCHER_FIELDS if getattr(self, name) is not None
829
993
  ]
@@ -1391,6 +1555,74 @@ class CodeRouterConfig(BaseModel):
1391
1555
  )
1392
1556
  return self
1393
1557
 
1558
+ @model_validator(mode="after")
1559
+ def _check_names_unique(self) -> CodeRouterConfig:
1560
+ """mE: provider and profile names must each be unique.
1561
+
1562
+ ``provider_by_name`` / ``profile_by_name`` return the *first*
1563
+ match, so a duplicate name silently shadows every later entry with
1564
+ the same key — the operator's second ``local:`` block is loaded,
1565
+ validated, and then never reachable. Reject duplicates at load with
1566
+ the offending names, matching the fast-fail philosophy of the other
1567
+ ``_check_*`` validators.
1568
+ """
1569
+ seen_p: set[str] = set()
1570
+ dup_p: list[str] = []
1571
+ for p in self.providers:
1572
+ if p.name in seen_p and p.name not in dup_p:
1573
+ dup_p.append(p.name)
1574
+ seen_p.add(p.name)
1575
+ if dup_p:
1576
+ raise ValueError(
1577
+ f"duplicate provider name(s): {sorted(dup_p)}. Provider names "
1578
+ f"must be unique — a duplicate silently shadows the later "
1579
+ f"entry (provider_by_name returns the first match). Rename or "
1580
+ f"remove the duplicate(s)."
1581
+ )
1582
+
1583
+ seen_f: set[str] = set()
1584
+ dup_f: list[str] = []
1585
+ for prof in self.profiles:
1586
+ if prof.name in seen_f and prof.name not in dup_f:
1587
+ dup_f.append(prof.name)
1588
+ seen_f.add(prof.name)
1589
+ if dup_f:
1590
+ raise ValueError(
1591
+ f"duplicate profile name(s): {sorted(dup_f)}. Profile names "
1592
+ f"must be unique — a duplicate silently shadows the later "
1593
+ f"entry (profile_by_name returns the first match). Rename or "
1594
+ f"remove the duplicate(s)."
1595
+ )
1596
+ return self
1597
+
1598
+ @model_validator(mode="after")
1599
+ def _check_profile_providers_exist(self) -> CodeRouterConfig:
1600
+ """mE: every provider named in a profile chain must be declared.
1601
+
1602
+ Previously a typo in ``profiles[].providers`` was tolerated at load
1603
+ and only surfaced at runtime as a ``skip-unknown-provider`` warning;
1604
+ if *every* entry in a chain was typo'd the profile silently had no
1605
+ usable providers and only failed once fully drained. Rejecting at
1606
+ load — same philosophy as ``_check_default_profile_exists`` and
1607
+ ``_check_mode_alias_targets_exist`` — turns a silent-until-drained
1608
+ misconfig into a startup error naming the profile and the bad
1609
+ provider(s).
1610
+ """
1611
+ names = {p.name for p in self.providers}
1612
+ errors: list[str] = []
1613
+ for prof in self.profiles:
1614
+ missing = [name for name in prof.providers if name not in names]
1615
+ if missing:
1616
+ errors.append(f"{prof.name!r} -> {missing}")
1617
+ if errors:
1618
+ raise ValueError(
1619
+ f"profile(s) reference undeclared provider(s): "
1620
+ f"{'; '.join(errors)}. known providers={sorted(names)}. "
1621
+ f"Fix the typo in profiles[].providers or add the missing "
1622
+ f"provider(s)."
1623
+ )
1624
+ return self
1625
+
1394
1626
  def provider_by_name(self, name: str) -> ProviderConfig:
1395
1627
  """Look up a provider config by name. Raises KeyError if not found."""
1396
1628
  for p in self.providers: