coderouter-cli 2.9.0__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
@@ -559,7 +560,14 @@ class FallbackChain(BaseModel):
559
560
  providers: list[str] = Field(
560
561
  ...,
561
562
  min_length=1,
562
- 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
+ ),
563
571
  )
564
572
  timeout_s: float | None = Field(
565
573
  default=None,
@@ -1452,6 +1460,231 @@ class LauncherOptionProfile(BaseModel):
1452
1460
  )
1453
1461
 
1454
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
+
1455
1688
  class LauncherConfig(BaseModel):
1456
1689
  """The ``launcher:`` block in providers.yaml.
1457
1690
 
@@ -1507,6 +1740,160 @@ class LauncherConfig(BaseModel):
1507
1740
  "for one-off overrides without touching this config."
1508
1741
  ),
1509
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
1510
1897
 
1511
1898
 
1512
1899
  class PluginsConfig(BaseModel):
@@ -1772,6 +2159,141 @@ class CodeRouterConfig(BaseModel):
1772
2159
  )
1773
2160
  return self
1774
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
+
1775
2297
  @model_validator(mode="after")
1776
2298
  def _check_auto_router_profiles_exist(self) -> CodeRouterConfig:
1777
2299
  """v1.6-A: every ``auto_router.rules[*].profile`` must be declared.
coderouter/ingress/app.py CHANGED
@@ -305,6 +305,13 @@ def create_app(config_path: str | None = None) -> FastAPI:
305
305
  },
306
306
  )
307
307
 
308
+ # launcher-model-swap.md §6.6 known-trap #9: the TTL sweeper is a
309
+ # background task like continuous-probe above — start it once the
310
+ # event loop is actually running, not at app-construction time.
311
+ swap_manager = getattr(app.state, "swap", None)
312
+ if swap_manager is not None:
313
+ await swap_manager.start()
314
+
308
315
  yield
309
316
 
310
317
  # Graceful shutdown of probe task
@@ -313,6 +320,14 @@ def create_app(config_path: str | None = None) -> FastAPI:
313
320
  with contextlib.suppress(Exception):
314
321
  await probe_task
315
322
 
323
+ # Cancel the swap TTL sweeper *before* shutdown_launcher tears down
324
+ # every ManagedProcess below — shutdown_launcher already stops
325
+ # swap-spawned processes too (they're in the same registry), so
326
+ # this only needs to stop the sweeper loop itself, not race it.
327
+ if swap_manager is not None:
328
+ with contextlib.suppress(Exception):
329
+ await swap_manager.stop()
330
+
316
331
  # Launcher: stop child llama.cpp / vllm processes so they don't orphan.
317
332
  from coderouter.ingress.launcher_routes import shutdown_launcher
318
333
 
@@ -371,6 +386,20 @@ def create_app(config_path: str | None = None) -> FastAPI:
371
386
  app.state.engine = engine
372
387
  app.state.config = config
373
388
 
389
+ # Phase 1 on-demand model swap (docs/designs/launcher-model-swap.md).
390
+ # None (default) leaves the engine's swap dispatch hooks as cheap
391
+ # no-ops — zero behavior change for deployments that don't opt in.
392
+ # Constructed here (needs the real ``app`` for app.state.launcher /
393
+ # app.state.engine) but the TTL sweeper task itself is only started
394
+ # from the lifespan below, once the event loop is actually running.
395
+ swap_cfg = config.launcher.swap if config.launcher is not None else None
396
+ if swap_cfg is not None and swap_cfg.enabled:
397
+ from coderouter.launcher_swap import SwapManager
398
+
399
+ swap_manager = SwapManager(app, swap_cfg, config.launcher)
400
+ app.state.swap = swap_manager
401
+ engine.attach_swap_manager(swap_manager)
402
+
374
403
  # H8: DNS-rebinding protection. Applied to every route so a hostname an
375
404
  # attacker controls cannot be pointed at loopback and used to drive the
376
405
  # local API from a victim's browser. Extra hostnames for deliberate