coderouter-cli 2.9.0__py3-none-any.whl → 2.9.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coderouter/config/schemas.py +579 -3
- coderouter/ingress/app.py +29 -0
- coderouter/ingress/launcher_routes.py +476 -147
- coderouter/launcher_swap.py +466 -0
- coderouter/routing/fallback.py +225 -5
- {coderouter_cli-2.9.0.dist-info → coderouter_cli-2.9.2.dist-info}/METADATA +5 -3
- {coderouter_cli-2.9.0.dist-info → coderouter_cli-2.9.2.dist-info}/RECORD +10 -9
- {coderouter_cli-2.9.0.dist-info → coderouter_cli-2.9.2.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.9.0.dist-info → coderouter_cli-2.9.2.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.9.0.dist-info → coderouter_cli-2.9.2.dist-info}/licenses/LICENSE +0 -0
coderouter/config/schemas.py
CHANGED
|
@@ -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=
|
|
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):
|
|
@@ -1564,8 +1951,16 @@ class CodeRouterConfig(BaseModel):
|
|
|
1564
1951
|
description="Master switch. ALLOW_PAID=false blocks all paid providers (plan.md §2.3).",
|
|
1565
1952
|
)
|
|
1566
1953
|
default_profile: str = Field(default="default")
|
|
1567
|
-
|
|
1568
|
-
|
|
1954
|
+
# [Unreleased]: relaxed from ``Field(..., min_length=1)`` so a
|
|
1955
|
+
# swap-only deployment (``launcher.swap.enabled`` with a non-empty
|
|
1956
|
+
# ``models`` catalog) can omit both fields entirely instead of
|
|
1957
|
+
# writing an unreachable dummy provider/profile just to satisfy the
|
|
1958
|
+
# schema. The "at least one" invariant is NOT dropped — it moves to
|
|
1959
|
+
# ``_check_providers_and_profiles_nonempty`` below, which still
|
|
1960
|
+
# fail-fasts at load for every other deployment shape (same
|
|
1961
|
+
# philosophy as the min_length constraint it replaces).
|
|
1962
|
+
providers: list[ProviderConfig] = Field(default_factory=list)
|
|
1963
|
+
profiles: list[FallbackChain] = Field(default_factory=list)
|
|
1569
1964
|
mode_aliases: dict[str, str] = Field(
|
|
1570
1965
|
default_factory=dict,
|
|
1571
1966
|
description=(
|
|
@@ -1772,6 +2167,187 @@ class CodeRouterConfig(BaseModel):
|
|
|
1772
2167
|
)
|
|
1773
2168
|
return self
|
|
1774
2169
|
|
|
2170
|
+
@model_validator(mode="after")
|
|
2171
|
+
def _check_providers_and_profiles_nonempty(self) -> CodeRouterConfig:
|
|
2172
|
+
"""[Unreleased]: enforce "at least one" unless launcher.swap covers it.
|
|
2173
|
+
|
|
2174
|
+
``providers`` / ``profiles`` were relaxed from ``min_length=1`` to
|
|
2175
|
+
``default_factory=list`` (see the field comments above) so a
|
|
2176
|
+
swap-only deployment can omit them — every request in that shape
|
|
2177
|
+
is routed through a ``launcher-swap-<name>`` profile that
|
|
2178
|
+
:meth:`_inject_swap_profiles_and_auto_router_rules` synthesizes
|
|
2179
|
+
below, and its backing provider is registered at runtime on first
|
|
2180
|
+
spawn (``SwapManager.register_provider``), so there is genuinely
|
|
2181
|
+
nothing to declare statically.
|
|
2182
|
+
|
|
2183
|
+
Runs BEFORE the swap injection (which only ever *adds* profiles,
|
|
2184
|
+
never providers) so it observes the operator's raw, undecorated
|
|
2185
|
+
input rather than the post-injection state — the injected
|
|
2186
|
+
profiles are not a substitute for a real provider declaration in
|
|
2187
|
+
every other deployment shape, only in the swap-only one this
|
|
2188
|
+
validator carves out.
|
|
2189
|
+
|
|
2190
|
+
Outside that carve-out, an empty ``providers`` or ``profiles``
|
|
2191
|
+
list has always been a load-time error (there would be nothing to
|
|
2192
|
+
route to); this validator keeps that fail-fast guarantee instead
|
|
2193
|
+
of silently trading it away when the ``min_length=1`` field
|
|
2194
|
+
constraint was dropped.
|
|
2195
|
+
"""
|
|
2196
|
+
swap_cfg = self.launcher.swap if self.launcher is not None else None
|
|
2197
|
+
swap_covers_empty = (
|
|
2198
|
+
swap_cfg is not None and swap_cfg.enabled and bool(swap_cfg.models)
|
|
2199
|
+
)
|
|
2200
|
+
if swap_covers_empty:
|
|
2201
|
+
return self
|
|
2202
|
+
if not self.providers:
|
|
2203
|
+
raise ValueError(
|
|
2204
|
+
"providers: at least one entry is required (empty/omitted "
|
|
2205
|
+
"is only allowed when launcher.swap.enabled=true and "
|
|
2206
|
+
"launcher.swap.models has at least one entry)."
|
|
2207
|
+
)
|
|
2208
|
+
if not self.profiles:
|
|
2209
|
+
raise ValueError(
|
|
2210
|
+
"profiles: at least one entry is required (empty/omitted "
|
|
2211
|
+
"is only allowed when launcher.swap.enabled=true and "
|
|
2212
|
+
"launcher.swap.models has at least one entry)."
|
|
2213
|
+
)
|
|
2214
|
+
return self
|
|
2215
|
+
|
|
2216
|
+
@model_validator(mode="after")
|
|
2217
|
+
def _inject_swap_profiles_and_auto_router_rules(self) -> CodeRouterConfig:
|
|
2218
|
+
"""§10 Q7 + design-deviation: bootstrap swap's dedicated profiles.
|
|
2219
|
+
|
|
2220
|
+
For every ``launcher.swap.models[*]`` entry (when
|
|
2221
|
+
``launcher.swap.enabled``), pre-declares an EMPTY placeholder
|
|
2222
|
+
profile named ``SwapModelSpec.profile_name``
|
|
2223
|
+
(``launcher-swap-<name>``) so both ingress routes'
|
|
2224
|
+
"profile must already exist" pre-dispatch check passes even on
|
|
2225
|
+
the very first (cold-start) request for that model — the
|
|
2226
|
+
on-demand spawn itself only happens later, inside
|
|
2227
|
+
``FallbackEngine``'s dispatch entry points, which run AFTER that
|
|
2228
|
+
check. ``SwapManager.register_provider`` fills the chain in on
|
|
2229
|
+
first spawn (mirrors the existing lazy-profile-creation path in
|
|
2230
|
+
``FallbackEngine.register_provider``).
|
|
2231
|
+
|
|
2232
|
+
Deliberately ONE profile PER model (not a single shared "swap"
|
|
2233
|
+
profile as sketched in the design's §5.5 YAML example): a
|
|
2234
|
+
shared chain would make ``register_provider``'s "insert at
|
|
2235
|
+
front" behavior route a request for model A to whichever model
|
|
2236
|
+
was registered most recently, since chain dispatch never
|
|
2237
|
+
cross-checks the request's ``model`` against the provider it
|
|
2238
|
+
picks. See the implementation report for the full rationale.
|
|
2239
|
+
|
|
2240
|
+
When ``inject_auto_router_rules`` is True (default), also
|
|
2241
|
+
generates one ``AutoRouteRule`` per model (id ``swap:<name>``,
|
|
2242
|
+
``model_pattern=re.escape(name)`` — an exact match, independent
|
|
2243
|
+
of the catalog's own broader ``model_pattern`` field) targeting
|
|
2244
|
+
that profile, appended AFTER any user-declared
|
|
2245
|
+
``auto_router.rules`` (first-match-wins keeps hand-written rules
|
|
2246
|
+
authoritative).
|
|
2247
|
+
|
|
2248
|
+
Review fix C-1: when NO ``auto_router:`` block was declared and
|
|
2249
|
+
``default_profile == "auto"``, the operator was relying on the
|
|
2250
|
+
BUNDLED ruleset — synthesizing a swap-only block here would
|
|
2251
|
+
silently replace it (image → multi and code-fence → coding
|
|
2252
|
+
classification would vanish the moment swap was enabled). The
|
|
2253
|
+
synthesized block therefore carries
|
|
2254
|
+
``[*BUNDLED_RULES, *swap_rules]`` with the bundled fallthrough
|
|
2255
|
+
profile preserved, so enabling swap is purely additive. Outside
|
|
2256
|
+
the ``default_profile: auto`` case the bundled rules never
|
|
2257
|
+
applied anyway, so the block holds only the swap rules and
|
|
2258
|
+
falls through to ``default_profile`` itself. Runs before
|
|
2259
|
+
:meth:`_check_auto_router_profiles_exist` so the injected rules
|
|
2260
|
+
are validated too, and before
|
|
2261
|
+
:meth:`_check_bundled_auto_router_requirements` so injecting a
|
|
2262
|
+
swap-only auto_router doesn't spuriously demand the bundled
|
|
2263
|
+
trio (multi/coding/writing) for non-auto deployments.
|
|
2264
|
+
"""
|
|
2265
|
+
swap_cfg = self.launcher.swap if self.launcher is not None else None
|
|
2266
|
+
if swap_cfg is None or not swap_cfg.enabled or not swap_cfg.models:
|
|
2267
|
+
return self
|
|
2268
|
+
|
|
2269
|
+
provider_names = {p.name for p in self.providers}
|
|
2270
|
+
collisions = sorted(
|
|
2271
|
+
{
|
|
2272
|
+
spec.provider_name
|
|
2273
|
+
for spec in swap_cfg.models
|
|
2274
|
+
if spec.provider_name in provider_names
|
|
2275
|
+
}
|
|
2276
|
+
)
|
|
2277
|
+
if collisions:
|
|
2278
|
+
raise ValueError(
|
|
2279
|
+
"launcher.swap.models produce provider name(s) that "
|
|
2280
|
+
f"collide with statically-declared providers: {collisions}. "
|
|
2281
|
+
"Rename the swap model (or the conflicting provider)."
|
|
2282
|
+
)
|
|
2283
|
+
|
|
2284
|
+
existing_profiles = {p.name for p in self.profiles}
|
|
2285
|
+
for spec in swap_cfg.models:
|
|
2286
|
+
if spec.profile_name not in existing_profiles:
|
|
2287
|
+
self.profiles.append(
|
|
2288
|
+
FallbackChain.model_construct(
|
|
2289
|
+
name=spec.profile_name, providers=[]
|
|
2290
|
+
)
|
|
2291
|
+
)
|
|
2292
|
+
existing_profiles.add(spec.profile_name)
|
|
2293
|
+
|
|
2294
|
+
if not swap_cfg.inject_auto_router_rules:
|
|
2295
|
+
return self
|
|
2296
|
+
|
|
2297
|
+
swap_rules = [
|
|
2298
|
+
AutoRouteRule(
|
|
2299
|
+
id=f"swap:{spec.name}",
|
|
2300
|
+
profile=spec.profile_name,
|
|
2301
|
+
match=RuleMatcher(model_pattern=re.escape(spec.name)),
|
|
2302
|
+
)
|
|
2303
|
+
for spec in swap_cfg.models
|
|
2304
|
+
]
|
|
2305
|
+
if self.auto_router is None:
|
|
2306
|
+
if self.default_profile == "auto":
|
|
2307
|
+
# C-1: the operator is on the bundled ruleset. Merge it
|
|
2308
|
+
# into the synthesized block and keep the bundled
|
|
2309
|
+
# fallthrough profile, so enabling swap never silently
|
|
2310
|
+
# disables image/code-fence classification. SWAP RULES
|
|
2311
|
+
# FIRST (coordinator-reviewed order): a swap rule is an
|
|
2312
|
+
# ``re.escape`` exact model-name match, firing only when
|
|
2313
|
+
# the client explicitly named that model — a stronger
|
|
2314
|
+
# signal than the bundled content heuristics. Bundled-
|
|
2315
|
+
# first would let a code-fence-dense body hijack an
|
|
2316
|
+
# explicitly-named swap model over to 'coding'; swap-
|
|
2317
|
+
# first cannot affect any request that doesn't name a
|
|
2318
|
+
# swap model (exact match). User-declared auto_router
|
|
2319
|
+
# blocks keep append-after semantics below (operators
|
|
2320
|
+
# control their own ordering there).
|
|
2321
|
+
# Function-local import: routing.auto_router imports
|
|
2322
|
+
# this module at ITS module level, so schemas must not
|
|
2323
|
+
# import it at module level (cycle). By the time any
|
|
2324
|
+
# CodeRouterConfig is constructed both modules are
|
|
2325
|
+
# importable, so the runtime import here is safe.
|
|
2326
|
+
from coderouter.routing.auto_router import (
|
|
2327
|
+
BUNDLED_DEFAULT_RULE_PROFILE,
|
|
2328
|
+
BUNDLED_RULES,
|
|
2329
|
+
)
|
|
2330
|
+
|
|
2331
|
+
self.auto_router = AutoRouterConfig(
|
|
2332
|
+
rules=[*swap_rules, *BUNDLED_RULES],
|
|
2333
|
+
default_rule_profile=BUNDLED_DEFAULT_RULE_PROFILE,
|
|
2334
|
+
)
|
|
2335
|
+
else:
|
|
2336
|
+
# Non-auto deployments never consulted the bundled rules
|
|
2337
|
+
# (classify() only fires under ``default_profile: auto``),
|
|
2338
|
+
# so the block holds only the swap rules and falls
|
|
2339
|
+
# through to ``default_profile`` itself (guaranteed to
|
|
2340
|
+
# exist per ``_check_default_profile_exists``, which
|
|
2341
|
+
# already ran) — enabling swap never forces an unrelated
|
|
2342
|
+
# static-profile deployment to declare a "writing"
|
|
2343
|
+
# profile it has no other use for.
|
|
2344
|
+
self.auto_router = AutoRouterConfig(
|
|
2345
|
+
rules=swap_rules, default_rule_profile=self.default_profile
|
|
2346
|
+
)
|
|
2347
|
+
else:
|
|
2348
|
+
self.auto_router.rules = [*self.auto_router.rules, *swap_rules]
|
|
2349
|
+
return self
|
|
2350
|
+
|
|
1775
2351
|
@model_validator(mode="after")
|
|
1776
2352
|
def _check_auto_router_profiles_exist(self) -> CodeRouterConfig:
|
|
1777
2353
|
"""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
|