coderouter-cli 2.8.1__py3-none-any.whl → 2.9.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.
@@ -14,6 +14,7 @@ Design notes (see plan.md §2 / §5.4):
14
14
  from __future__ import annotations
15
15
 
16
16
  import re
17
+ import warnings
17
18
  from typing import Any, Literal, Self
18
19
 
19
20
  from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator
@@ -166,8 +167,21 @@ class CostConfig(BaseModel):
166
167
  class AgentCliConfig(BaseModel):
167
168
  """External coding-agent CLI settings for ``kind="agent_cli"`` providers.
168
169
 
169
- Introduced by the external-agents-adapter design (Phase 1). One
170
- ``agent_cli`` sub-config drives the :class:`AgentCliAdapter`, which
170
+ Introduced by the external-agents-adapter design (Phase 1). As of the
171
+ agent_cli plugin extraction's Phase 2c
172
+ (``docs/designs/agent-cli-plugin-extraction.md`` §4.4 case (b), §7),
173
+ the adapter that actually invokes the CLI (``AgentCliAdapter``,
174
+ argv construction / output parsing / sandbox flag tables) has moved
175
+ to the separately-distributed ``coderouter-plugin-agents`` plugin
176
+ (``pip install coderouter-plugin-agents`` + ``plugins.enabled:
177
+ [agents]``) — Core no longer ships it in-core. ``AgentCliConfig``
178
+ itself REMAINS in Core: it is a stable schema contract that has not
179
+ changed since Phase 1 (unlike the adapter body, which absorbs
180
+ per-CLI churn), so keeping it here preserves ``extra="forbid"``
181
+ fail-fast validation at config-load time regardless of whether the
182
+ plugin is installed.
183
+
184
+ One ``agent_cli`` sub-config drives the plugin's adapter, which
171
185
  invokes an external coding-agent CLI (codex / gemini / grok / claude /
172
186
  antigravity) in a single one-shot ``exec`` and returns the final answer
173
187
  as one ``prompt in → text out`` transformation. ``claude`` (Claude Code
@@ -355,10 +369,13 @@ class ProviderConfig(BaseModel):
355
369
  description=(
356
370
  "Adapter type. 'openai_compat' covers llama.cpp / Ollama / "
357
371
  "OpenRouter / LM Studio / Together / Groq. 'anthropic' is the "
358
- "native Anthropic Messages API passthrough (v0.3.x). 'agent_cli' "
359
- "invokes an external coding-agent CLI one-shot (see AgentCliConfig). "
360
- "Other values must be served by an adapter plugin listed in "
361
- "plugins.enabled."
372
+ "native Anthropic Messages API passthrough (v0.3.x). These two "
373
+ "are Core's only in-core kinds. 'agent_cli' invokes an external "
374
+ "coding-agent CLI one-shot (see AgentCliConfig) but, as of "
375
+ "Phase 2c, is served by the coderouter-plugin-agents adapter "
376
+ "plugin — install it and list 'agents' in plugins.enabled. "
377
+ "Any other value must likewise be served by an adapter plugin "
378
+ "listed in plugins.enabled."
362
379
  ),
363
380
  )
364
381
  # base_url is required for HTTP-backed adapters (openai_compat / anthropic)
@@ -428,9 +445,18 @@ class ProviderConfig(BaseModel):
428
445
  # kind="agent_cli": required external coding-agent CLI settings. Opt-in
429
446
  # (default None) exactly like ``restart_command`` — only meaningful when
430
447
  # ``kind == "agent_cli"``, and enforced by ``_check_kind_requirements``.
448
+ # The schema (this field + AgentCliConfig + the kind literal) stays in
449
+ # Core; the adapter that consumes it is the coderouter-plugin-agents
450
+ # plugin as of Phase 2c (docs/designs/agent-cli-plugin-extraction.md
451
+ # §4.4 case (b)).
431
452
  agent_cli: AgentCliConfig | None = Field(
432
453
  default=None,
433
- description="Required when kind='agent_cli': external agent CLI settings.",
454
+ description=(
455
+ "Required when kind='agent_cli': external agent CLI settings. "
456
+ "Served by the coderouter-plugin-agents adapter plugin — "
457
+ "`pip install coderouter-plugin-agents` and add 'agents' to "
458
+ "plugins.enabled."
459
+ ),
434
460
  )
435
461
 
436
462
  cost: CostConfig | None = Field(
@@ -534,7 +560,14 @@ class FallbackChain(BaseModel):
534
560
  providers: list[str] = Field(
535
561
  ...,
536
562
  min_length=1,
537
- description="Provider names in fallback order. First success wins.",
563
+ description=(
564
+ "Provider names in fallback order. First success wins. "
565
+ "(The launcher-swap placeholder profiles are the sole "
566
+ "empty-chain exception; they bypass this validator via "
567
+ "``model_construct`` in "
568
+ "``CodeRouterConfig._inject_swap_profiles_and_auto_router_rules`` "
569
+ "— user-declared chains still fail fast at load when empty.)"
570
+ ),
538
571
  )
539
572
  timeout_s: float | None = Field(
540
573
  default=None,
@@ -1427,6 +1460,231 @@ class LauncherOptionProfile(BaseModel):
1427
1460
  )
1428
1461
 
1429
1462
 
1463
+ class SwapModelSpec(BaseModel):
1464
+ """One entry in ``launcher.swap.models`` (docs/designs/launcher-model-swap.md).
1465
+
1466
+ Security (§7 of the design): this is the ONLY surface through which
1467
+ the on-demand swap manager (``coderouter/launcher_swap.py``) is
1468
+ allowed to start a process. ``model_path`` is static config here —
1469
+ never taken from a request body — and is re-validated against
1470
+ ``launcher.model_dirs`` via ``_resolve_within_model_dirs`` at spawn
1471
+ time, exactly like the manual ``/api/launcher/start`` UI. A
1472
+ request's ``model`` field is purely a catalog lookup key; it can
1473
+ never select an arbitrary path, backend flag, or command.
1474
+ """
1475
+
1476
+ model_config = ConfigDict(extra="forbid")
1477
+
1478
+ name: str = Field(
1479
+ ...,
1480
+ min_length=1,
1481
+ description=(
1482
+ "Logical model name matched against the request body's "
1483
+ "``model`` field. Also becomes the provider name and the "
1484
+ "dedicated single-model profile name "
1485
+ "(``launcher-swap-<name>``, see :attr:`provider_name` / "
1486
+ ":attr:`profile_name`) — the profile is pre-declared at "
1487
+ "config load (empty) so both ingress routes' "
1488
+ "\"profile must exist\" check passes even before the first "
1489
+ "on-demand spawn."
1490
+ ),
1491
+ )
1492
+ model_pattern: str | None = Field(
1493
+ default=None,
1494
+ description=(
1495
+ "Optional additional catalog match: ``re.fullmatch`` against "
1496
+ "the request's ``model`` field, checked when an exact "
1497
+ "``name`` match fails. Compiled at load time, same as "
1498
+ "``AutoRouteRule.model_pattern``. NOTE: the auto-injected "
1499
+ "auto_router rule (§10 Q7) always keys on ``re.escape(name)`` "
1500
+ "— an exact match — regardless of this field; this field "
1501
+ "only widens *catalog* matching for ``SwapManager.match``."
1502
+ ),
1503
+ )
1504
+ backend: Literal["llama.cpp", "vllm", "mlx"] = Field(
1505
+ ..., description="Same backend set as the manual launcher UI / _build_cmd.",
1506
+ )
1507
+ model_path: str = Field(
1508
+ ...,
1509
+ description=(
1510
+ "Absolute or ``~``-relative model file path. Re-validated "
1511
+ "against launcher.model_dirs at spawn time via "
1512
+ "_resolve_within_model_dirs — never taken from a request."
1513
+ ),
1514
+ )
1515
+ port: int | None = Field(
1516
+ default=None,
1517
+ ge=1024,
1518
+ le=65535,
1519
+ description=(
1520
+ "Fixed port (recommended — §10 Q2). When unset, the swap "
1521
+ "manager picks an OS-assigned ephemeral port and retries "
1522
+ "once on a different port if the backend fails to become "
1523
+ "ready. Best-effort only — no strong TOCTOU guarantee "
1524
+ "(§6.6 known-trap #4)."
1525
+ ),
1526
+ )
1527
+ option_profile: str | None = Field(
1528
+ default=None,
1529
+ description=(
1530
+ "Name of a ``launcher.option_profiles[backend]`` preset to "
1531
+ "apply. Must exist — checked at load by "
1532
+ "``LauncherConfig._check_swap_option_profiles_exist``."
1533
+ ),
1534
+ )
1535
+ extra_args: str = Field(
1536
+ default="",
1537
+ description="One-off extra CLI flags (shlex.split; model-override guarded).",
1538
+ )
1539
+ draft_model_path: str | None = Field(
1540
+ default=None, description="MTP/draft gguf companion model (resolve_speculative).",
1541
+ )
1542
+ mtp_mode: Literal["auto", "off"] = Field(default="auto")
1543
+
1544
+ # ---- Phase 2 (schema only for Phase 1 — no eviction/accounting logic
1545
+ # wired up yet; declared now so the config surface is forward-
1546
+ # compatible without a second migration) ----
1547
+ group: Literal["swap", "persistent", "exclusive"] = Field(
1548
+ default="swap",
1549
+ description=(
1550
+ "llama-swap groups equivalent (§10 Q3: 3 fixed values). "
1551
+ "Not consulted by Phase 1 (every model is treated as 'swap')."
1552
+ ),
1553
+ )
1554
+ est_weights_gb: float | None = Field(default=None, ge=0.0)
1555
+ num_ctx: int = Field(default=8192, ge=256)
1556
+
1557
+ @property
1558
+ def provider_name(self) -> str:
1559
+ """Runtime ``ProviderConfig.name`` this model registers under."""
1560
+ return f"launcher-swap-{self.name}"
1561
+
1562
+ @property
1563
+ def profile_name(self) -> str:
1564
+ """Dedicated single-model profile name (pre-declared at load).
1565
+
1566
+ Deliberately identical to :attr:`provider_name` — each swap
1567
+ model gets its OWN single-provider chain rather than sharing one
1568
+ "swap" profile across models (a shared chain would let requests
1569
+ for model A route to whichever model was registered most
1570
+ recently, since ``register_provider`` only reorders providers
1571
+ within one chain — see the design-deviation note in
1572
+ docs/designs/launcher-model-swap.md's implementation report).
1573
+ """
1574
+ return f"launcher-swap-{self.name}"
1575
+
1576
+ @model_validator(mode="after")
1577
+ def _compile_model_pattern(self) -> SwapModelSpec:
1578
+ """Fail fast on a bad ``model_pattern`` regex (load time, not first request)."""
1579
+ if self.model_pattern is not None:
1580
+ try:
1581
+ re.compile(self.model_pattern)
1582
+ except re.error as exc:
1583
+ raise ValueError(
1584
+ f"swap model {self.name!r}: invalid model_pattern "
1585
+ f"{self.model_pattern!r}: {exc}"
1586
+ ) from exc
1587
+ return self
1588
+
1589
+
1590
+ class LauncherSwapConfig(BaseModel):
1591
+ """The ``launcher.swap`` block — Phase 1 on-demand model swap.
1592
+
1593
+ See docs/designs/launcher-model-swap.md. Disabled by default
1594
+ (``enabled: false``); existing manual-launcher deployments are
1595
+ unaffected until an operator opts in.
1596
+ """
1597
+
1598
+ model_config = ConfigDict(extra="forbid")
1599
+
1600
+ enabled: bool = Field(
1601
+ default=False,
1602
+ description=(
1603
+ "Enable on-demand swap. False (default): Launcher behaves "
1604
+ "exactly as before (manual start/stop only)."
1605
+ ),
1606
+ )
1607
+ ttl_seconds: float | None = Field(
1608
+ default=1800.0,
1609
+ ge=0.0,
1610
+ description=(
1611
+ "Seconds of no in-flight requests after which an idle swap "
1612
+ "process is auto-stopped. None = TTL disabled (runs until "
1613
+ "explicitly stopped). 0 = unload as soon as the last "
1614
+ "in-flight lease releases. §10 Q1: one GLOBAL value for "
1615
+ "Phase 1 — no per-model override yet."
1616
+ ),
1617
+ )
1618
+ readiness_timeout_s: float = Field(
1619
+ default=120.0,
1620
+ ge=1.0,
1621
+ le=1800.0,
1622
+ description=(
1623
+ "Upper bound (seconds) a request will wait for an on-demand "
1624
+ "spawn to become ready before the dispatch hook raises a "
1625
+ "retryable AdapterError."
1626
+ ),
1627
+ )
1628
+ sweep_interval_s: float = Field(
1629
+ default=15.0,
1630
+ ge=1.0,
1631
+ le=600.0,
1632
+ description="How often the TTL sweeper background task scans for idle processes.",
1633
+ )
1634
+ inject_auto_router_rules: bool = Field(
1635
+ default=True,
1636
+ description=(
1637
+ "§10 Q7: auto-generate one auto_router rule per catalog "
1638
+ "model (id ``swap:<name>``, ``model_pattern=re.escape(name)``) "
1639
+ "so a request naming the model reaches it without any "
1640
+ "manual profile/header wiring. Inserted after any "
1641
+ "user-declared auto_router.rules (first-match-wins keeps "
1642
+ "hand-written rules authoritative) and only consulted when "
1643
+ "``default_profile: auto`` (existing auto_router constraint, "
1644
+ "unchanged). Set False to wire routing yourself (e.g. "
1645
+ "X-CodeRouter-Profile: launcher-swap-<name>)."
1646
+ ),
1647
+ )
1648
+ # ---- Phase 2 (schema only) ----
1649
+ memory_budget_gb: float | None = Field(
1650
+ default=None,
1651
+ ge=0.0,
1652
+ description="Phase 2: explicit combined-memory budget override (GB). Not used by Phase 1.",
1653
+ )
1654
+ max_loaded: int | None = Field(
1655
+ default=None,
1656
+ ge=1,
1657
+ description="Phase 2: cap on simultaneously loaded swap models. Not used by Phase 1.",
1658
+ )
1659
+ models: list[SwapModelSpec] = Field(
1660
+ default_factory=list,
1661
+ description="The static swap catalog. Only models listed here can ever be spawned on demand.",
1662
+ )
1663
+
1664
+ @model_validator(mode="after")
1665
+ def _check_models(self) -> LauncherSwapConfig:
1666
+ """Fail fast on duplicate names / ports; warn on a useless enable."""
1667
+ names = [m.name for m in self.models]
1668
+ dupe_names = sorted({n for n in names if names.count(n) > 1})
1669
+ if dupe_names:
1670
+ raise ValueError(
1671
+ f"launcher.swap.models: duplicate name(s): {dupe_names}"
1672
+ )
1673
+ ports = [m.port for m in self.models if m.port is not None]
1674
+ dupe_ports = sorted({p for p in ports if ports.count(p) > 1})
1675
+ if dupe_ports:
1676
+ raise ValueError(
1677
+ f"launcher.swap.models: duplicate port(s): {dupe_ports}"
1678
+ )
1679
+ if self.enabled and not self.models:
1680
+ warnings.warn(
1681
+ "launcher.swap.enabled is True but launcher.swap.models "
1682
+ "is empty — swap has nothing to serve.",
1683
+ stacklevel=2,
1684
+ )
1685
+ return self
1686
+
1687
+
1430
1688
  class LauncherConfig(BaseModel):
1431
1689
  """The ``launcher:`` block in providers.yaml.
1432
1690
 
@@ -1482,6 +1740,160 @@ class LauncherConfig(BaseModel):
1482
1740
  "for one-off overrides without touching this config."
1483
1741
  ),
1484
1742
  )
1743
+ # --- readiness gating (fixes: launcher registered a provider the
1744
+ # instant the child process spawned, before llama-server / vllm had
1745
+ # actually finished loading the model into memory — requests routed
1746
+ # there during load returned connection-refused / 503) ---------------
1747
+ readiness_timeout_s: float = Field(
1748
+ default=300.0,
1749
+ ge=5.0,
1750
+ le=3600.0,
1751
+ description=(
1752
+ "Maximum seconds to wait for a launched backend to become "
1753
+ "ready (llama.cpp / vllm: GET /health returns 200; other "
1754
+ "backends: a bare TCP connect succeeds) before registering it "
1755
+ "as a routable provider. Default 5 min accounts for large "
1756
+ "GGUF model loads. The process is left running but the "
1757
+ "provider is never registered and status becomes 'error' if "
1758
+ "the deadline is exceeded — see ``ManagedProcess.status``."
1759
+ ),
1760
+ )
1761
+ readiness_poll_interval_s: float = Field(
1762
+ default=2.0,
1763
+ ge=0.2,
1764
+ le=60.0,
1765
+ description=(
1766
+ "Seconds between readiness probes while a launched backend's "
1767
+ "status is 'loading'."
1768
+ ),
1769
+ )
1770
+ # --- auto-restart (fixes: a crashed launcher process was left in
1771
+ # status='error' forever — only the one-shot MTP startup-crash
1772
+ # retry existed, and it does not apply to ordinary crashes) --------
1773
+ auto_restart: bool = Field(
1774
+ default=False,
1775
+ description=(
1776
+ "When True, a launcher-managed backend that crashes (non-zero "
1777
+ "exit, after any MTP startup-crash fallback has already been "
1778
+ "tried) is automatically relaunched with the same command, "
1779
+ "up to ``auto_restart_max_attempts`` times with exponential "
1780
+ "backoff. An intentional stop (the UI's Stop button, or "
1781
+ "server shutdown) is never treated as a crash. Default False: "
1782
+ "unlike ``readiness_timeout_s`` above (a pure bugfix with no "
1783
+ "new side effect), automatically re-spawning a process is a "
1784
+ "new side effect — a backend that crashes because it is "
1785
+ "genuinely misconfigured (bad flags, missing model file) "
1786
+ "would otherwise be silently re-spawned every few seconds "
1787
+ "until the attempt budget is exhausted, burning CPU/ports "
1788
+ "without the operator noticing. Mirrors the opt-in posture of "
1789
+ "``ProviderConfig.restart_command`` in the self-healing "
1790
+ "guard (v2.0-J) — set this to True once the launch config is "
1791
+ "known-stable."
1792
+ ),
1793
+ )
1794
+ auto_restart_max_attempts: int = Field(
1795
+ default=3,
1796
+ ge=0,
1797
+ le=20,
1798
+ description=(
1799
+ "Maximum consecutive auto-restart attempts before giving up "
1800
+ "and leaving the process in status='error'. Resets to 0 once "
1801
+ "a restarted process passes its readiness check. Ignored "
1802
+ "when ``auto_restart`` is False."
1803
+ ),
1804
+ )
1805
+ auto_restart_backoff_s: float = Field(
1806
+ default=2.0,
1807
+ ge=0.1,
1808
+ le=300.0,
1809
+ description=(
1810
+ "Initial backoff (seconds) before the first auto-restart "
1811
+ "attempt. Doubles on each subsequent attempt up to "
1812
+ "``auto_restart_backoff_max_s``."
1813
+ ),
1814
+ )
1815
+ auto_restart_backoff_max_s: float = Field(
1816
+ default=30.0,
1817
+ ge=1.0,
1818
+ le=600.0,
1819
+ description=(
1820
+ "Cap (seconds) on the exponential auto-restart backoff."
1821
+ ),
1822
+ )
1823
+ swap: LauncherSwapConfig | None = Field(
1824
+ default=None,
1825
+ description=(
1826
+ "Phase 1 on-demand model swap (docs/designs/launcher-model-"
1827
+ "swap.md). None (default) = disabled, identical to pre-swap "
1828
+ "behavior."
1829
+ ),
1830
+ )
1831
+
1832
+ @model_validator(mode="after")
1833
+ def _check_auto_restart_backoff_ordered(self) -> LauncherConfig:
1834
+ """Fail fast when the backoff floor exceeds its own ceiling.
1835
+
1836
+ Same fast-fail philosophy as
1837
+ ``FallbackChain._check_recovery_probe_interval_ordered``.
1838
+ """
1839
+ if self.auto_restart_backoff_s > self.auto_restart_backoff_max_s:
1840
+ raise ValueError(
1841
+ "launcher: auto_restart_backoff_s "
1842
+ f"({self.auto_restart_backoff_s}) must be <= "
1843
+ f"auto_restart_backoff_max_s ({self.auto_restart_backoff_max_s})."
1844
+ )
1845
+ return self
1846
+
1847
+ @model_validator(mode="after")
1848
+ def _check_swap_option_profiles_exist(self) -> LauncherConfig:
1849
+ """§5.4 #2: a swap model's ``option_profile`` must be a real preset."""
1850
+ if self.swap is None:
1851
+ return self
1852
+ for spec in self.swap.models:
1853
+ if spec.option_profile is None:
1854
+ continue
1855
+ profiles = self.option_profiles.get(spec.backend, [])
1856
+ if not any(p.name == spec.option_profile for p in profiles):
1857
+ raise ValueError(
1858
+ f"launcher.swap.models[{spec.name!r}]: option_profile "
1859
+ f"{spec.option_profile!r} not found in "
1860
+ f"launcher.option_profiles[{spec.backend!r}]."
1861
+ )
1862
+ return self
1863
+
1864
+ @model_validator(mode="after")
1865
+ def _check_swap_model_paths_within_model_dirs(self) -> LauncherConfig:
1866
+ """Review fix L-1: fail fast on a swap ``model_path`` outside model_dirs.
1867
+
1868
+ The spawn path re-validates via ``_resolve_within_model_dirs``
1869
+ (defense in depth, M14 traversal guard), but that only surfaces
1870
+ at the first request for the model — as a retryable 502. A
1871
+ static catalog entry is fully checkable at load, so check it
1872
+ here with the same containment rule (resolve ``~`` / symlinks /
1873
+ ``..`` on both sides, then require the path to be the dir or
1874
+ under it). Only enforced when swap is enabled — a disabled swap
1875
+ block stays zero-impact.
1876
+ """
1877
+ if self.swap is None or not self.swap.enabled or not self.swap.models:
1878
+ return self
1879
+ from pathlib import Path
1880
+
1881
+ if not self.model_dirs:
1882
+ raise ValueError(
1883
+ "launcher.swap is enabled but launcher.model_dirs is empty — "
1884
+ "swap model_path values cannot be validated (and spawn would "
1885
+ "refuse them at request time). Configure model_dirs."
1886
+ )
1887
+ bases = [Path(d).expanduser().resolve() for d in self.model_dirs]
1888
+ for spec in self.swap.models:
1889
+ candidate = Path(spec.model_path).expanduser().resolve()
1890
+ if not any(candidate == b or b in candidate.parents for b in bases):
1891
+ raise ValueError(
1892
+ f"launcher.swap.models[{spec.name!r}]: model_path "
1893
+ f"{spec.model_path!r} is not under any configured "
1894
+ f"launcher.model_dirs {self.model_dirs!r}."
1895
+ )
1896
+ return self
1485
1897
 
1486
1898
 
1487
1899
  class PluginsConfig(BaseModel):
@@ -1747,6 +2159,141 @@ class CodeRouterConfig(BaseModel):
1747
2159
  )
1748
2160
  return self
1749
2161
 
2162
+ @model_validator(mode="after")
2163
+ def _inject_swap_profiles_and_auto_router_rules(self) -> CodeRouterConfig:
2164
+ """§10 Q7 + design-deviation: bootstrap swap's dedicated profiles.
2165
+
2166
+ For every ``launcher.swap.models[*]`` entry (when
2167
+ ``launcher.swap.enabled``), pre-declares an EMPTY placeholder
2168
+ profile named ``SwapModelSpec.profile_name``
2169
+ (``launcher-swap-<name>``) so both ingress routes'
2170
+ "profile must already exist" pre-dispatch check passes even on
2171
+ the very first (cold-start) request for that model — the
2172
+ on-demand spawn itself only happens later, inside
2173
+ ``FallbackEngine``'s dispatch entry points, which run AFTER that
2174
+ check. ``SwapManager.register_provider`` fills the chain in on
2175
+ first spawn (mirrors the existing lazy-profile-creation path in
2176
+ ``FallbackEngine.register_provider``).
2177
+
2178
+ Deliberately ONE profile PER model (not a single shared "swap"
2179
+ profile as sketched in the design's §5.5 YAML example): a
2180
+ shared chain would make ``register_provider``'s "insert at
2181
+ front" behavior route a request for model A to whichever model
2182
+ was registered most recently, since chain dispatch never
2183
+ cross-checks the request's ``model`` against the provider it
2184
+ picks. See the implementation report for the full rationale.
2185
+
2186
+ When ``inject_auto_router_rules`` is True (default), also
2187
+ generates one ``AutoRouteRule`` per model (id ``swap:<name>``,
2188
+ ``model_pattern=re.escape(name)`` — an exact match, independent
2189
+ of the catalog's own broader ``model_pattern`` field) targeting
2190
+ that profile, appended AFTER any user-declared
2191
+ ``auto_router.rules`` (first-match-wins keeps hand-written rules
2192
+ authoritative).
2193
+
2194
+ Review fix C-1: when NO ``auto_router:`` block was declared and
2195
+ ``default_profile == "auto"``, the operator was relying on the
2196
+ BUNDLED ruleset — synthesizing a swap-only block here would
2197
+ silently replace it (image → multi and code-fence → coding
2198
+ classification would vanish the moment swap was enabled). The
2199
+ synthesized block therefore carries
2200
+ ``[*BUNDLED_RULES, *swap_rules]`` with the bundled fallthrough
2201
+ profile preserved, so enabling swap is purely additive. Outside
2202
+ the ``default_profile: auto`` case the bundled rules never
2203
+ applied anyway, so the block holds only the swap rules and
2204
+ falls through to ``default_profile`` itself. Runs before
2205
+ :meth:`_check_auto_router_profiles_exist` so the injected rules
2206
+ are validated too, and before
2207
+ :meth:`_check_bundled_auto_router_requirements` so injecting a
2208
+ swap-only auto_router doesn't spuriously demand the bundled
2209
+ trio (multi/coding/writing) for non-auto deployments.
2210
+ """
2211
+ swap_cfg = self.launcher.swap if self.launcher is not None else None
2212
+ if swap_cfg is None or not swap_cfg.enabled or not swap_cfg.models:
2213
+ return self
2214
+
2215
+ provider_names = {p.name for p in self.providers}
2216
+ collisions = sorted(
2217
+ {
2218
+ spec.provider_name
2219
+ for spec in swap_cfg.models
2220
+ if spec.provider_name in provider_names
2221
+ }
2222
+ )
2223
+ if collisions:
2224
+ raise ValueError(
2225
+ "launcher.swap.models produce provider name(s) that "
2226
+ f"collide with statically-declared providers: {collisions}. "
2227
+ "Rename the swap model (or the conflicting provider)."
2228
+ )
2229
+
2230
+ existing_profiles = {p.name for p in self.profiles}
2231
+ for spec in swap_cfg.models:
2232
+ if spec.profile_name not in existing_profiles:
2233
+ self.profiles.append(
2234
+ FallbackChain.model_construct(
2235
+ name=spec.profile_name, providers=[]
2236
+ )
2237
+ )
2238
+ existing_profiles.add(spec.profile_name)
2239
+
2240
+ if not swap_cfg.inject_auto_router_rules:
2241
+ return self
2242
+
2243
+ swap_rules = [
2244
+ AutoRouteRule(
2245
+ id=f"swap:{spec.name}",
2246
+ profile=spec.profile_name,
2247
+ match=RuleMatcher(model_pattern=re.escape(spec.name)),
2248
+ )
2249
+ for spec in swap_cfg.models
2250
+ ]
2251
+ if self.auto_router is None:
2252
+ if self.default_profile == "auto":
2253
+ # C-1: the operator is on the bundled ruleset. Merge it
2254
+ # into the synthesized block and keep the bundled
2255
+ # fallthrough profile, so enabling swap never silently
2256
+ # disables image/code-fence classification. SWAP RULES
2257
+ # FIRST (coordinator-reviewed order): a swap rule is an
2258
+ # ``re.escape`` exact model-name match, firing only when
2259
+ # the client explicitly named that model — a stronger
2260
+ # signal than the bundled content heuristics. Bundled-
2261
+ # first would let a code-fence-dense body hijack an
2262
+ # explicitly-named swap model over to 'coding'; swap-
2263
+ # first cannot affect any request that doesn't name a
2264
+ # swap model (exact match). User-declared auto_router
2265
+ # blocks keep append-after semantics below (operators
2266
+ # control their own ordering there).
2267
+ # Function-local import: routing.auto_router imports
2268
+ # this module at ITS module level, so schemas must not
2269
+ # import it at module level (cycle). By the time any
2270
+ # CodeRouterConfig is constructed both modules are
2271
+ # importable, so the runtime import here is safe.
2272
+ from coderouter.routing.auto_router import (
2273
+ BUNDLED_DEFAULT_RULE_PROFILE,
2274
+ BUNDLED_RULES,
2275
+ )
2276
+
2277
+ self.auto_router = AutoRouterConfig(
2278
+ rules=[*swap_rules, *BUNDLED_RULES],
2279
+ default_rule_profile=BUNDLED_DEFAULT_RULE_PROFILE,
2280
+ )
2281
+ else:
2282
+ # Non-auto deployments never consulted the bundled rules
2283
+ # (classify() only fires under ``default_profile: auto``),
2284
+ # so the block holds only the swap rules and falls
2285
+ # through to ``default_profile`` itself (guaranteed to
2286
+ # exist per ``_check_default_profile_exists``, which
2287
+ # already ran) — enabling swap never forces an unrelated
2288
+ # static-profile deployment to declare a "writing"
2289
+ # profile it has no other use for.
2290
+ self.auto_router = AutoRouterConfig(
2291
+ rules=swap_rules, default_rule_profile=self.default_profile
2292
+ )
2293
+ else:
2294
+ self.auto_router.rules = [*self.auto_router.rules, *swap_rules]
2295
+ return self
2296
+
1750
2297
  @model_validator(mode="after")
1751
2298
  def _check_auto_router_profiles_exist(self) -> CodeRouterConfig:
1752
2299
  """v1.6-A: every ``auto_router.rules[*].profile`` must be declared.