coderouter-cli 2.9.1__py3-none-any.whl → 2.9.3__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 +97 -8
- coderouter/ingress/launcher_routes.py +60 -3
- coderouter/launcher_swap.py +100 -12
- {coderouter_cli-2.9.1.dist-info → coderouter_cli-2.9.3.dist-info}/METADATA +1 -1
- {coderouter_cli-2.9.1.dist-info → coderouter_cli-2.9.3.dist-info}/RECORD +8 -8
- {coderouter_cli-2.9.1.dist-info → coderouter_cli-2.9.3.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.9.1.dist-info → coderouter_cli-2.9.3.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.9.1.dist-info → coderouter_cli-2.9.3.dist-info}/licenses/LICENSE +0 -0
coderouter/config/schemas.py
CHANGED
|
@@ -1518,10 +1518,25 @@ class SwapModelSpec(BaseModel):
|
|
|
1518
1518
|
le=65535,
|
|
1519
1519
|
description=(
|
|
1520
1520
|
"Fixed port (recommended — §10 Q2). When unset, the swap "
|
|
1521
|
-
"manager picks an OS-assigned ephemeral port and retries "
|
|
1522
|
-
"
|
|
1523
|
-
"
|
|
1524
|
-
"
|
|
1521
|
+
"manager picks an OS-assigned ephemeral port and retries, "
|
|
1522
|
+
"each time on a freshly picked port, up to "
|
|
1523
|
+
"``LauncherSwapConfig.port_retry_attempts`` additional "
|
|
1524
|
+
"times if the backend fails to become ready. Best-effort "
|
|
1525
|
+
"only — no strong TOCTOU guarantee (§6.6 known-trap #4)."
|
|
1526
|
+
),
|
|
1527
|
+
)
|
|
1528
|
+
ttl_seconds: float | None = Field(
|
|
1529
|
+
default=None,
|
|
1530
|
+
ge=0.0,
|
|
1531
|
+
description=(
|
|
1532
|
+
"[Unreleased] Per-model override of "
|
|
1533
|
+
"``LauncherSwapConfig.ttl_seconds``. ``None`` (default) = "
|
|
1534
|
+
"use the global value. ``0`` = unload as soon as the last "
|
|
1535
|
+
"in-flight lease for THIS model releases — same meaning as "
|
|
1536
|
+
"the global field's ``0``, just scoped to one catalog "
|
|
1537
|
+
"entry. Lets a large/expensive model unload sooner (or "
|
|
1538
|
+
"stay resident longer) than the rest of the catalog "
|
|
1539
|
+
"without changing the global default."
|
|
1525
1540
|
),
|
|
1526
1541
|
)
|
|
1527
1542
|
option_profile: str | None = Field(
|
|
@@ -1611,8 +1626,10 @@ class LauncherSwapConfig(BaseModel):
|
|
|
1611
1626
|
"Seconds of no in-flight requests after which an idle swap "
|
|
1612
1627
|
"process is auto-stopped. None = TTL disabled (runs until "
|
|
1613
1628
|
"explicitly stopped). 0 = unload as soon as the last "
|
|
1614
|
-
"in-flight lease releases. §10 Q1:
|
|
1615
|
-
"
|
|
1629
|
+
"in-flight lease releases. §10 Q1: this is the GLOBAL "
|
|
1630
|
+
"default; a catalog entry's ``SwapModelSpec.ttl_seconds`` "
|
|
1631
|
+
"overrides it for that one model when set "
|
|
1632
|
+
"([Unreleased] per-model TTL override)."
|
|
1616
1633
|
),
|
|
1617
1634
|
)
|
|
1618
1635
|
readiness_timeout_s: float = Field(
|
|
@@ -1631,6 +1648,24 @@ class LauncherSwapConfig(BaseModel):
|
|
|
1631
1648
|
le=600.0,
|
|
1632
1649
|
description="How often the TTL sweeper background task scans for idle processes.",
|
|
1633
1650
|
)
|
|
1651
|
+
port_retry_attempts: int = Field(
|
|
1652
|
+
default=2,
|
|
1653
|
+
ge=0,
|
|
1654
|
+
le=5,
|
|
1655
|
+
description=(
|
|
1656
|
+
"[Unreleased] Number of ADDITIONAL spawn attempts (each on a "
|
|
1657
|
+
"freshly picked ephemeral port) after the first, used only "
|
|
1658
|
+
"when a catalog entry's ``port`` is unset and the previous "
|
|
1659
|
+
"attempt fails to become ready. 0 = no retry. Ignored when "
|
|
1660
|
+
"``port`` is set — a fixed port never retries (a second "
|
|
1661
|
+
"attempt would just collide on the same port again). This "
|
|
1662
|
+
"does not close the pick-then-bind TOCTOU window (see "
|
|
1663
|
+
"``coderouter.launcher_swap._pick_ephemeral_port``); it only "
|
|
1664
|
+
"bounds how many times the swap manager re-rolls the dice. "
|
|
1665
|
+
"A fixed ``port`` remains the recommended way to eliminate "
|
|
1666
|
+
"the race entirely (§10 Q2)."
|
|
1667
|
+
),
|
|
1668
|
+
)
|
|
1634
1669
|
inject_auto_router_rules: bool = Field(
|
|
1635
1670
|
default=True,
|
|
1636
1671
|
description=(
|
|
@@ -1951,8 +1986,16 @@ class CodeRouterConfig(BaseModel):
|
|
|
1951
1986
|
description="Master switch. ALLOW_PAID=false blocks all paid providers (plan.md §2.3).",
|
|
1952
1987
|
)
|
|
1953
1988
|
default_profile: str = Field(default="default")
|
|
1954
|
-
|
|
1955
|
-
|
|
1989
|
+
# [Unreleased]: relaxed from ``Field(..., min_length=1)`` so a
|
|
1990
|
+
# swap-only deployment (``launcher.swap.enabled`` with a non-empty
|
|
1991
|
+
# ``models`` catalog) can omit both fields entirely instead of
|
|
1992
|
+
# writing an unreachable dummy provider/profile just to satisfy the
|
|
1993
|
+
# schema. The "at least one" invariant is NOT dropped — it moves to
|
|
1994
|
+
# ``_check_providers_and_profiles_nonempty`` below, which still
|
|
1995
|
+
# fail-fasts at load for every other deployment shape (same
|
|
1996
|
+
# philosophy as the min_length constraint it replaces).
|
|
1997
|
+
providers: list[ProviderConfig] = Field(default_factory=list)
|
|
1998
|
+
profiles: list[FallbackChain] = Field(default_factory=list)
|
|
1956
1999
|
mode_aliases: dict[str, str] = Field(
|
|
1957
2000
|
default_factory=dict,
|
|
1958
2001
|
description=(
|
|
@@ -2159,6 +2202,52 @@ class CodeRouterConfig(BaseModel):
|
|
|
2159
2202
|
)
|
|
2160
2203
|
return self
|
|
2161
2204
|
|
|
2205
|
+
@model_validator(mode="after")
|
|
2206
|
+
def _check_providers_and_profiles_nonempty(self) -> CodeRouterConfig:
|
|
2207
|
+
"""[Unreleased]: enforce "at least one" unless launcher.swap covers it.
|
|
2208
|
+
|
|
2209
|
+
``providers`` / ``profiles`` were relaxed from ``min_length=1`` to
|
|
2210
|
+
``default_factory=list`` (see the field comments above) so a
|
|
2211
|
+
swap-only deployment can omit them — every request in that shape
|
|
2212
|
+
is routed through a ``launcher-swap-<name>`` profile that
|
|
2213
|
+
:meth:`_inject_swap_profiles_and_auto_router_rules` synthesizes
|
|
2214
|
+
below, and its backing provider is registered at runtime on first
|
|
2215
|
+
spawn (``SwapManager.register_provider``), so there is genuinely
|
|
2216
|
+
nothing to declare statically.
|
|
2217
|
+
|
|
2218
|
+
Runs BEFORE the swap injection (which only ever *adds* profiles,
|
|
2219
|
+
never providers) so it observes the operator's raw, undecorated
|
|
2220
|
+
input rather than the post-injection state — the injected
|
|
2221
|
+
profiles are not a substitute for a real provider declaration in
|
|
2222
|
+
every other deployment shape, only in the swap-only one this
|
|
2223
|
+
validator carves out.
|
|
2224
|
+
|
|
2225
|
+
Outside that carve-out, an empty ``providers`` or ``profiles``
|
|
2226
|
+
list has always been a load-time error (there would be nothing to
|
|
2227
|
+
route to); this validator keeps that fail-fast guarantee instead
|
|
2228
|
+
of silently trading it away when the ``min_length=1`` field
|
|
2229
|
+
constraint was dropped.
|
|
2230
|
+
"""
|
|
2231
|
+
swap_cfg = self.launcher.swap if self.launcher is not None else None
|
|
2232
|
+
swap_covers_empty = (
|
|
2233
|
+
swap_cfg is not None and swap_cfg.enabled and bool(swap_cfg.models)
|
|
2234
|
+
)
|
|
2235
|
+
if swap_covers_empty:
|
|
2236
|
+
return self
|
|
2237
|
+
if not self.providers:
|
|
2238
|
+
raise ValueError(
|
|
2239
|
+
"providers: at least one entry is required (empty/omitted "
|
|
2240
|
+
"is only allowed when launcher.swap.enabled=true and "
|
|
2241
|
+
"launcher.swap.models has at least one entry)."
|
|
2242
|
+
)
|
|
2243
|
+
if not self.profiles:
|
|
2244
|
+
raise ValueError(
|
|
2245
|
+
"profiles: at least one entry is required (empty/omitted "
|
|
2246
|
+
"is only allowed when launcher.swap.enabled=true and "
|
|
2247
|
+
"launcher.swap.models has at least one entry)."
|
|
2248
|
+
)
|
|
2249
|
+
return self
|
|
2250
|
+
|
|
2162
2251
|
@model_validator(mode="after")
|
|
2163
2252
|
def _inject_swap_profiles_and_auto_router_rules(self) -> CodeRouterConfig:
|
|
2164
2253
|
"""§10 Q7 + design-deviation: bootstrap swap's dedicated profiles.
|
|
@@ -171,6 +171,13 @@ class ManagedProcess:
|
|
|
171
171
|
# under its per-model lock); a launcher auto-restart racing that
|
|
172
172
|
# re-spawn would fight over the same fixed port.
|
|
173
173
|
swap_managed: bool = False
|
|
174
|
+
# [Unreleased]: the swap catalog model name (SwapModelSpec.name) this
|
|
175
|
+
# process backs, set by SwapManager._spawn via spawn_process's
|
|
176
|
+
# swap_model kwarg. None for a manually-started process (swap_managed
|
|
177
|
+
# is False) or if a future swap-managed spawn path omits it. Purely
|
|
178
|
+
# informational — surfaced by GET /api/launcher/processes so the
|
|
179
|
+
# /launcher UI can label which catalog entry a running process is.
|
|
180
|
+
swap_model: str | None = None
|
|
174
181
|
# asyncio subprocess handle — not serialised
|
|
175
182
|
_proc: Any = field(default=None, repr=False, compare=False)
|
|
176
183
|
|
|
@@ -1027,7 +1034,13 @@ async def api_launcher_config_debug(request: Request) -> dict[str, Any]:
|
|
|
1027
1034
|
|
|
1028
1035
|
@router.get("/api/launcher/processes")
|
|
1029
1036
|
async def api_processes(request: Request) -> dict[str, Any]:
|
|
1030
|
-
"""List all managed processes.
|
|
1037
|
+
"""List all managed processes.
|
|
1038
|
+
|
|
1039
|
+
[Unreleased]: ``swap_managed`` / ``swap_model`` let the ``/launcher``
|
|
1040
|
+
UI (and other API clients) tell an on-demand SwapManager-spawned
|
|
1041
|
+
process apart from a manually-started one, and label which swap
|
|
1042
|
+
catalog model it backs.
|
|
1043
|
+
"""
|
|
1031
1044
|
reg = _registry(request)
|
|
1032
1045
|
return {
|
|
1033
1046
|
"processes": [
|
|
@@ -1040,6 +1053,8 @@ async def api_processes(request: Request) -> dict[str, Any]:
|
|
|
1040
1053
|
"status": p.status,
|
|
1041
1054
|
"pid": p.pid,
|
|
1042
1055
|
"returncode": p.returncode,
|
|
1056
|
+
"swap_managed": p.swap_managed,
|
|
1057
|
+
"swap_model": p.swap_model,
|
|
1043
1058
|
}
|
|
1044
1059
|
for p in reg.all()
|
|
1045
1060
|
]
|
|
@@ -1100,6 +1115,7 @@ async def spawn_process(
|
|
|
1100
1115
|
draft_model_path: str | None = None,
|
|
1101
1116
|
mtp_mode: str = "auto",
|
|
1102
1117
|
swap_managed: bool = False,
|
|
1118
|
+
swap_model: str | None = None,
|
|
1103
1119
|
) -> ManagedProcess:
|
|
1104
1120
|
"""Build argv, spawn the child process, and arm readiness/log tailing.
|
|
1105
1121
|
|
|
@@ -1116,6 +1132,12 @@ async def spawn_process(
|
|
|
1116
1132
|
exist. Any other exception from ``create_subprocess_exec`` propagates
|
|
1117
1133
|
as-is. Callers decide how to surface these (HTTP 400/500 for
|
|
1118
1134
|
``api_start``, a retryable ``AdapterError`` for SwapManager).
|
|
1135
|
+
|
|
1136
|
+
``swap_model`` ([Unreleased]) is purely informational — the swap
|
|
1137
|
+
catalog model name (``SwapModelSpec.name``), recorded on the
|
|
1138
|
+
resulting ``ManagedProcess`` for ``GET /api/launcher/processes`` /
|
|
1139
|
+
the ``/launcher`` UI. Only ``SwapManager._spawn`` passes it; the
|
|
1140
|
+
manual ``POST /api/launcher/start`` path leaves it ``None``.
|
|
1119
1141
|
"""
|
|
1120
1142
|
options = options if options is not None else {}
|
|
1121
1143
|
configured_binary: str | None = None
|
|
@@ -1178,6 +1200,7 @@ async def spawn_process(
|
|
|
1178
1200
|
spec_auto=spec_auto and fallback_cmd is not None,
|
|
1179
1201
|
fallback_cmd=fallback_cmd,
|
|
1180
1202
|
swap_managed=swap_managed,
|
|
1203
|
+
swap_model=swap_model,
|
|
1181
1204
|
)
|
|
1182
1205
|
proc.log_tail.append(f"[launcher] cmd: {' '.join(cmd)}")
|
|
1183
1206
|
for note in spec_notes:
|
|
@@ -1378,6 +1401,10 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1378
1401
|
<style>
|
|
1379
1402
|
.dot { width:.5rem;height:.5rem;border-radius:9999px;display:inline-block; }
|
|
1380
1403
|
.tabnum { font-variant-numeric:tabular-nums; }
|
|
1404
|
+
.badge-swap { font-size:10px; text-transform:uppercase; letter-spacing:.02em;
|
|
1405
|
+
background:rgba(99,102,241,.2); color:#a5b4fc; padding:1px 6px;
|
|
1406
|
+
border-radius:9999px; margin-left:6px; vertical-align:middle;
|
|
1407
|
+
white-space:nowrap; }
|
|
1381
1408
|
.log-box { font-family:monospace;font-size:.75rem;line-height:1.4;
|
|
1382
1409
|
overflow-y:auto;max-height:14rem;white-space:pre-wrap;word-break:break-all; }
|
|
1383
1410
|
.model-row:hover { background:rgba(255,255,255,.04);cursor:pointer; }
|
|
@@ -1851,7 +1878,17 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1851
1878
|
try {
|
|
1852
1879
|
const r = await fetch("/api/launcher/processes");
|
|
1853
1880
|
const d = await r.json();
|
|
1854
|
-
|
|
1881
|
+
const procs = d.processes || [];
|
|
1882
|
+
renderProcesses(procs);
|
|
1883
|
+
// [Unreleased] 404-loop fix: if the process whose logs we're
|
|
1884
|
+
// polling is no longer in the registry (deleted from another
|
|
1885
|
+
// client, swap TTL unload removed it, or the server restarted
|
|
1886
|
+
// under a still-open tab), stop the log polling BEFORE it even
|
|
1887
|
+
// issues a 404 — otherwise poll() would hit
|
|
1888
|
+
// /api/launcher/logs/<stale-id> every POLL_MS forever.
|
|
1889
|
+
if (selectedLogId && !procs.some(p => p.id === selectedLogId)) {
|
|
1890
|
+
markLogGone();
|
|
1891
|
+
}
|
|
1855
1892
|
} catch (_) {}
|
|
1856
1893
|
};
|
|
1857
1894
|
|
|
@@ -1871,8 +1908,14 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1871
1908
|
? `<button onclick="deleteProc('${p.id}')" class="btn-sm btn-slate ml-1">✕</button>`
|
|
1872
1909
|
: "";
|
|
1873
1910
|
const logBtn = `<button onclick="openLog('${p.id}','${esc(p.name)}')" class="btn-sm btn-indigo ml-1">📋 ログ</button>`;
|
|
1911
|
+
// [Unreleased]: swap badge — marks processes SwapManager spawned
|
|
1912
|
+
// on demand, distinct from manually-started ones. Title shows the
|
|
1913
|
+
// catalog model name when the backend supplied it.
|
|
1914
|
+
const swapBadge = p.swap_managed
|
|
1915
|
+
? `<span class="badge-swap" title="${esc(p.swap_model ? "swap model: " + p.swap_model : "swap-managed")}">swap</span>`
|
|
1916
|
+
: "";
|
|
1874
1917
|
return `<tr>
|
|
1875
|
-
<td class="py-2 pr-3 font-medium">${esc(p.name)}</td>
|
|
1918
|
+
<td class="py-2 pr-3 font-medium">${esc(p.name)}${swapBadge}</td>
|
|
1876
1919
|
<td class="py-2 pr-3 text-slate-400">${esc(p.backend)}</td>
|
|
1877
1920
|
<td class="py-2 pr-3 text-slate-400 truncate max-w-[10rem]" title="${esc(p.model_path)}">${esc(modelName)}</td>
|
|
1878
1921
|
<td class="py-2 pr-3 text-right">${p.port}</td>
|
|
@@ -1912,6 +1955,11 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1912
1955
|
if (!selectedLogId) return;
|
|
1913
1956
|
try {
|
|
1914
1957
|
const r = await fetch(`/api/launcher/logs/${selectedLogId}?n=200`);
|
|
1958
|
+
// [Unreleased] 404-loop fix: the id is gone from the registry —
|
|
1959
|
+
// stop polling instead of retrying (and 404-spamming the serve
|
|
1960
|
+
// log) every POLL_MS forever. The server keeps answering 404 for
|
|
1961
|
+
// unknown ids (correct); the client just has to take the hint.
|
|
1962
|
+
if (r.status === 404) { markLogGone(); return; }
|
|
1915
1963
|
const d = await r.json();
|
|
1916
1964
|
const box = document.getElementById("log-box");
|
|
1917
1965
|
box.textContent = d.logs.join("\n") || "(ログなし)";
|
|
@@ -1921,6 +1969,15 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1921
1969
|
}
|
|
1922
1970
|
};
|
|
1923
1971
|
|
|
1972
|
+
// [Unreleased] 404-loop fix: the process we were tailing no longer
|
|
1973
|
+
// exists server-side. Clear selectedLogId (which is what stops both
|
|
1974
|
+
// poll()'s refreshLogs call and any manual ↻) but keep the panel open
|
|
1975
|
+
// with a terminal message so the user sees WHY the logs stopped.
|
|
1976
|
+
const markLogGone = () => {
|
|
1977
|
+
selectedLogId = null;
|
|
1978
|
+
document.getElementById("log-box").textContent = "(process removed)";
|
|
1979
|
+
};
|
|
1980
|
+
|
|
1924
1981
|
window.closeLog = () => {
|
|
1925
1982
|
selectedLogId = null;
|
|
1926
1983
|
document.getElementById("log-panel").classList.add("hidden");
|
coderouter/launcher_swap.py
CHANGED
|
@@ -85,9 +85,18 @@ class _ModelState:
|
|
|
85
85
|
def _pick_ephemeral_port() -> int:
|
|
86
86
|
"""Ask the OS for a free port. Best-effort only (§6.6 known-trap #4):
|
|
87
87
|
|
|
88
|
-
there is an unavoidable TOCTOU window between this call
|
|
89
|
-
and the child actually binding
|
|
90
|
-
|
|
88
|
+
there is an unavoidable TOCTOU window between this call's bind+close
|
|
89
|
+
returning a "free" port and the child actually binding that same
|
|
90
|
+
port a moment later — nothing stops another process (or another
|
|
91
|
+
concurrent swap spawn) from grabbing it first. Retrying on a fresh
|
|
92
|
+
port when the child fails to come up
|
|
93
|
+
(``LauncherSwapConfig.port_retry_attempts``, default 2 additional
|
|
94
|
+
attempts) narrows the *impact* of losing that race but does NOT
|
|
95
|
+
close the window itself — a genuinely race-free allocation would
|
|
96
|
+
need the OS to hand the child an already-bound socket fd, which the
|
|
97
|
+
current spawn path (argv ``--port N``) does not support. Fixed
|
|
98
|
+
``port:`` in the catalog entry avoids the race entirely (§10 Q2,
|
|
99
|
+
recommended) and remains the only way to eliminate it outright.
|
|
91
100
|
"""
|
|
92
101
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
93
102
|
s.bind(("127.0.0.1", 0))
|
|
@@ -126,6 +135,13 @@ class SwapManager:
|
|
|
126
135
|
self._readiness_timeout_s = config.readiness_timeout_s
|
|
127
136
|
self._sweep_interval_s = config.sweep_interval_s
|
|
128
137
|
self._sweeper_task: asyncio.Task[None] | None = None
|
|
138
|
+
# [Unreleased] per-model TTL override: the sweeper must run
|
|
139
|
+
# whenever EITHER the global TTL is enabled OR at least one
|
|
140
|
+
# catalog entry sets its own ``ttl_seconds`` (even if the global
|
|
141
|
+
# value is None/disabled) — see _effective_ttl / start().
|
|
142
|
+
self._any_ttl_configured = self._ttl_seconds is not None or any(
|
|
143
|
+
m.ttl_seconds is not None for m in config.models
|
|
144
|
+
)
|
|
129
145
|
|
|
130
146
|
# ------------------------------------------------------------------
|
|
131
147
|
# Catalog matching
|
|
@@ -214,24 +230,40 @@ class SwapManager:
|
|
|
214
230
|
async with state.lock:
|
|
215
231
|
await self._unload_locked(state, spec, reason)
|
|
216
232
|
|
|
233
|
+
def _effective_ttl(self, spec: SwapModelSpec) -> float | None:
|
|
234
|
+
"""``spec.ttl_seconds`` if set, else the global ``self._ttl_seconds``.
|
|
235
|
+
|
|
236
|
+
[Unreleased] per-model TTL override (docs/backends/launcher.md
|
|
237
|
+
"launcher.swap fields" table). ``None`` at either level keeps
|
|
238
|
+
its usual meaning ("TTL disabled") — a spec-level override only
|
|
239
|
+
takes effect when it's not ``None``.
|
|
240
|
+
"""
|
|
241
|
+
return spec.ttl_seconds if spec.ttl_seconds is not None else self._ttl_seconds
|
|
242
|
+
|
|
217
243
|
async def sweep_once(self) -> None:
|
|
218
244
|
"""One TTL sweep pass: stop every idle, un-leased, expired model.
|
|
219
245
|
|
|
220
246
|
Takes each model's own lock independently (never more than one
|
|
221
247
|
per-model lock at a time — §6.6 known-trap #7) so a slow unload
|
|
222
|
-
for model A never delays the sweep of model B.
|
|
248
|
+
for model A never delays the sweep of model B. Each model's TTL
|
|
249
|
+
is resolved individually via :meth:`_effective_ttl` — a model
|
|
250
|
+
whose global TTL is disabled can still expire on its own
|
|
251
|
+
override, and vice versa.
|
|
223
252
|
"""
|
|
224
|
-
if self.
|
|
253
|
+
if not self._any_ttl_configured:
|
|
225
254
|
return
|
|
226
255
|
now = time.monotonic()
|
|
227
256
|
for spec in list(self._catalog.values()):
|
|
257
|
+
ttl = self._effective_ttl(spec)
|
|
258
|
+
if ttl is None:
|
|
259
|
+
continue
|
|
228
260
|
state = self._states.get(spec.name)
|
|
229
261
|
if state is None:
|
|
230
262
|
continue
|
|
231
263
|
async with state.lock:
|
|
232
264
|
if state.status != "ready" or state.in_flight > 0:
|
|
233
265
|
continue
|
|
234
|
-
if (now - state.last_used) <
|
|
266
|
+
if (now - state.last_used) < ttl:
|
|
235
267
|
continue
|
|
236
268
|
await self._unload_locked(state, spec, reason="ttl")
|
|
237
269
|
|
|
@@ -240,8 +272,14 @@ class SwapManager:
|
|
|
240
272
|
# ------------------------------------------------------------------
|
|
241
273
|
|
|
242
274
|
async def start(self) -> None:
|
|
243
|
-
"""Start the TTL sweeper background task.
|
|
244
|
-
|
|
275
|
+
"""Start the TTL sweeper background task.
|
|
276
|
+
|
|
277
|
+
No-op when TTL is disabled both globally AND on every catalog
|
|
278
|
+
entry (``self._any_ttl_configured`` — [Unreleased] per-model
|
|
279
|
+
override means a per-model ``ttl_seconds`` alone is enough to
|
|
280
|
+
need the sweeper, even with the global TTL off).
|
|
281
|
+
"""
|
|
282
|
+
if not self._any_ttl_configured or self._sweeper_task is not None:
|
|
245
283
|
return
|
|
246
284
|
self._sweeper_task = asyncio.create_task(self._sweeper_loop())
|
|
247
285
|
|
|
@@ -285,6 +323,35 @@ class SwapManager:
|
|
|
285
323
|
def _engine(self) -> Any | None:
|
|
286
324
|
return getattr(self._app.state, "engine", None)
|
|
287
325
|
|
|
326
|
+
def _remove_from_registry(self, proc_id: str) -> None:
|
|
327
|
+
"""Drop a swap-managed ``ManagedProcess`` from the launcher registry.
|
|
328
|
+
|
|
329
|
+
[Unreleased] registry-litter fix: ``stop_process`` only sets
|
|
330
|
+
``status="stopped"`` — it never removes the entry, because for
|
|
331
|
+
MANUALLY started processes the stopped row doubles as visible
|
|
332
|
+
history in the /launcher UI (with logs and an explicit ✕ delete
|
|
333
|
+
button). Swap-managed processes have no such contract: every
|
|
334
|
+
failed readiness attempt (times ``1 + port_retry_attempts``) and
|
|
335
|
+
every TTL unload would otherwise leave a permanent "stopped" row
|
|
336
|
+
accumulating unboundedly in ``GET /api/launcher/processes``. So
|
|
337
|
+
SwapManager removes ITS OWN processes right after it stops them,
|
|
338
|
+
guarded on ``swap_managed`` so a manual process can never be
|
|
339
|
+
swept up by mistake. Crash leftovers (a swap process that died
|
|
340
|
+
on its own, status "error"/"stopped" without SwapManager
|
|
341
|
+
stopping it) are deliberately NOT removed here — their log tail
|
|
342
|
+
is the only crash forensics an operator has; the ✕ button still
|
|
343
|
+
applies.
|
|
344
|
+
"""
|
|
345
|
+
reg = _registry_for_app(self._app)
|
|
346
|
+
try:
|
|
347
|
+
proc = reg.get(proc_id)
|
|
348
|
+
except KeyError:
|
|
349
|
+
return
|
|
350
|
+
if not proc.swap_managed:
|
|
351
|
+
return
|
|
352
|
+
with contextlib.suppress(KeyError):
|
|
353
|
+
reg.remove(proc_id)
|
|
354
|
+
|
|
288
355
|
def _resolve_options(self, spec: SwapModelSpec) -> dict[str, Any]:
|
|
289
356
|
"""Resolve ``spec.option_profile`` into the launcher options dict."""
|
|
290
357
|
if not spec.option_profile or self._launcher_cfg is None:
|
|
@@ -346,11 +413,20 @@ class SwapManager:
|
|
|
346
413
|
async def _spawn_with_retry(
|
|
347
414
|
self, spec: SwapModelSpec
|
|
348
415
|
) -> tuple[ManagedProcess | None, Exception | None]:
|
|
349
|
-
"""Spawn + wait for readiness.
|
|
350
|
-
|
|
351
|
-
|
|
416
|
+
"""Spawn + wait for readiness.
|
|
417
|
+
|
|
418
|
+
§10 Q2: when ``spec.port`` is unset, up to
|
|
419
|
+
``1 + LauncherSwapConfig.port_retry_attempts`` attempts total
|
|
420
|
+
(default: 1 initial + 2 retries = 3), each on a freshly picked
|
|
421
|
+
ephemeral port via :func:`_pick_ephemeral_port` — see that
|
|
422
|
+
function's docstring for the residual pick-then-bind TOCTOU
|
|
423
|
+
window this does NOT close, only bounds the impact of. Fixed
|
|
424
|
+
ports never retry — a second attempt on the same port would
|
|
425
|
+
just collide again.
|
|
352
426
|
"""
|
|
353
|
-
attempts =
|
|
427
|
+
attempts = (
|
|
428
|
+
1 if spec.port is not None else 1 + self._config.port_retry_attempts
|
|
429
|
+
)
|
|
354
430
|
last_exc: Exception | None = None
|
|
355
431
|
for _attempt in range(attempts):
|
|
356
432
|
port = spec.port if spec.port is not None else _pick_ephemeral_port()
|
|
@@ -368,6 +444,10 @@ class SwapManager:
|
|
|
368
444
|
)
|
|
369
445
|
with contextlib.suppress(Exception):
|
|
370
446
|
await stop_process(self._app, proc.id)
|
|
447
|
+
# [Unreleased] registry-litter fix: without this, every failed
|
|
448
|
+
# readiness attempt leaves one "stopped" row in the registry
|
|
449
|
+
# forever (1 + port_retry_attempts rows per failed load).
|
|
450
|
+
self._remove_from_registry(proc.id)
|
|
371
451
|
return None, last_exc
|
|
372
452
|
|
|
373
453
|
async def _spawn(self, spec: SwapModelSpec, port: int) -> ManagedProcess:
|
|
@@ -394,6 +474,10 @@ class SwapManager:
|
|
|
394
474
|
# provider). H-2: exempt from launcher auto-restart (crash
|
|
395
475
|
# recovery = next-request re-spawn under the per-model lock).
|
|
396
476
|
swap_managed=True,
|
|
477
|
+
# [Unreleased]: catalog model name, surfaced by
|
|
478
|
+
# GET /api/launcher/processes as "swap_model" so the /launcher
|
|
479
|
+
# UI can show which swap catalog entry a process backs.
|
|
480
|
+
swap_model=spec.name,
|
|
397
481
|
)
|
|
398
482
|
|
|
399
483
|
async def _await_ready(self, proc: ManagedProcess) -> bool:
|
|
@@ -457,6 +541,10 @@ class SwapManager:
|
|
|
457
541
|
with contextlib.suppress(Exception):
|
|
458
542
|
await stop_process(self._app, proc_id)
|
|
459
543
|
await self._deregister(spec)
|
|
544
|
+
# [Unreleased] registry-litter fix: a TTL-unloaded swap process
|
|
545
|
+
# is not "history" the way a manually stopped one is — leaving
|
|
546
|
+
# it would grow the registry by one row per load/unload cycle.
|
|
547
|
+
self._remove_from_registry(proc_id)
|
|
460
548
|
logger.info(
|
|
461
549
|
"swap-unload",
|
|
462
550
|
extra={"model": spec.name, "reason": reason, "proc_id": proc_id},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coderouter-cli
|
|
3
|
-
Version: 2.9.
|
|
3
|
+
Version: 2.9.3
|
|
4
4
|
Summary: Local-first, free-first, fallback-built-in LLM router. Claude Code / OpenAI compatible.
|
|
5
5
|
Project-URL: Homepage, https://github.com/zephel01/CodeRouter
|
|
6
6
|
Project-URL: Repository, https://github.com/zephel01/CodeRouter
|
|
@@ -11,7 +11,7 @@ coderouter/gguf_introspect.py,sha256=KeE00CfbYRa4gSTrzuwC6zVuHi20IzLC4nUQa98BEXI
|
|
|
11
11
|
coderouter/hardware.py,sha256=gn3_9qbVcGRR81yKMn1lJE_8-YDRau0LxIH_M-f7pxE,8356
|
|
12
12
|
coderouter/language_tax.py,sha256=LTbE3tIfoJuV2O3T0NixRKhzq_dEOTUuPEerJv2q9uk,9360
|
|
13
13
|
coderouter/launcher_speculative.py,sha256=kWaHNmhzBsbWuM_lRGDlbWkgJ1suuxBWQNTKhJOC-yg,12644
|
|
14
|
-
coderouter/launcher_swap.py,sha256=
|
|
14
|
+
coderouter/launcher_swap.py,sha256=W7qpdB-8Npr8yHGfx0N3SgZ2vDJ6BR9JuxW0I3TaZ-4,23783
|
|
15
15
|
coderouter/logging.py,sha256=5rfi4_R4aoeeUYDc642dgs61wA0aBX7wvubyB-k3Uyo,58074
|
|
16
16
|
coderouter/output_filters.py,sha256=0ry_rPiS_kC-FnHgaNVP6v7e6Al2djxzu9vBzZ8kEkE,25314
|
|
17
17
|
coderouter/token_estimation.py,sha256=iz22vZEEW2P7uKLB2pYvPNpIbZGbgXRO5MtfkS_-9Sk,7531
|
|
@@ -25,7 +25,7 @@ coderouter/config/__init__.py,sha256=FODEn74fN-qZnt4INPSHswqhOlEgpL6-_onxsitSx8g
|
|
|
25
25
|
coderouter/config/capability_registry.py,sha256=RZgzHF54dXy0CCDocRCz2ee21qXGbbgTSKTkRI9iLL4,16936
|
|
26
26
|
coderouter/config/env_file.py,sha256=CoMK27fuAXm-NtoLzXb8yN2E-wDFjHQuFwiIlmgTBQw,10356
|
|
27
27
|
coderouter/config/loader.py,sha256=FUEe8m4Tnmj_aul0vSctD8vKvNW-oLRoMRbTpSKqSmc,4077
|
|
28
|
-
coderouter/config/schemas.py,sha256=
|
|
28
|
+
coderouter/config/schemas.py,sha256=FJ0bc23tGbOYA2iQLKmyZN3A8R0vZzSfTY4PT8a5f1s,118933
|
|
29
29
|
coderouter/data/__init__.py,sha256=uNyfD9jaCvTWsBAWtaw1Fr25OSxzv3psGMfBjT1z0Cc,328
|
|
30
30
|
coderouter/data/model-capabilities.yaml,sha256=S9jt6SC6-3s2-icZ_n-a14iEMnc2yB1C2R6q-N_tZWQ,19309
|
|
31
31
|
coderouter/guards/__init__.py,sha256=5qliYBqygvVPneej7nx0uSjxDKsz7t8VzvrDgVBJlvU,1170
|
|
@@ -43,7 +43,7 @@ coderouter/ingress/__init__.py,sha256=WQsCH2CGJCAhy0mS6GSEdeYZRkkQu2OHDsP4CJWTLu
|
|
|
43
43
|
coderouter/ingress/anthropic_routes.py,sha256=1ThXzx8VgxRV7UCi3NF5ey7kbr0ADPKTXyQuYS5gK1w,27063
|
|
44
44
|
coderouter/ingress/app.py,sha256=6e_qNlYZIyjLKhY20t0ZNL-x5Fv8R8oaIH_HCdxa9Ic,20921
|
|
45
45
|
coderouter/ingress/dashboard_routes.py,sha256=1SqNNrVTfQiZcx5n2MvjAzWirittX0e61tCt-MKp4Ag,25617
|
|
46
|
-
coderouter/ingress/launcher_routes.py,sha256=
|
|
46
|
+
coderouter/ingress/launcher_routes.py,sha256=up9kNAjq1FHgv4_I298u7SkWLcv5T-RLhDcDYbxIp-s,85199
|
|
47
47
|
coderouter/ingress/metrics_routes.py,sha256=M22dwOGn24P05Ge4W3c7d7mYytSGWjIR-pPSPOAiHJY,3965
|
|
48
48
|
coderouter/ingress/openai_routes.py,sha256=TrugsQNJfNPgKKLzfT1aXzrs4emWYGE4XebEBuaZoWk,12505
|
|
49
49
|
coderouter/metrics/__init__.py,sha256=7Es351DPS7yLM0yVF_F0eesmiD83n7Zzhie44chht38,1465
|
|
@@ -69,8 +69,8 @@ coderouter/translation/__init__.py,sha256=PYXN7XVEwpG1uC8RLy6fvnGbzEZhhrEuUapH8I
|
|
|
69
69
|
coderouter/translation/anthropic.py,sha256=aZkcYH4x82b0x7efJgJb9RWn9Hbyc9pEOthXe4vjUdU,11113
|
|
70
70
|
coderouter/translation/convert.py,sha256=VV4kpu4dvLmGBmJfQnCLY5ryy_AnQcV2NuwjqRtaR8Q,52633
|
|
71
71
|
coderouter/translation/tool_repair.py,sha256=DGOKArM53yfVbIfiAveoul1vGq9NEKmjagf43Qh4IkM,57630
|
|
72
|
-
coderouter_cli-2.9.
|
|
73
|
-
coderouter_cli-2.9.
|
|
74
|
-
coderouter_cli-2.9.
|
|
75
|
-
coderouter_cli-2.9.
|
|
76
|
-
coderouter_cli-2.9.
|
|
72
|
+
coderouter_cli-2.9.3.dist-info/METADATA,sha256=ofXxvu6VaPHY-an5OdALTE8qZKIdJvfFK28heY_3ZOM,15994
|
|
73
|
+
coderouter_cli-2.9.3.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
74
|
+
coderouter_cli-2.9.3.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
|
|
75
|
+
coderouter_cli-2.9.3.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
|
|
76
|
+
coderouter_cli-2.9.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|