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.
@@ -0,0 +1,466 @@
1
+ """Phase 1 on-demand model swap (llama-swap-equivalent, self-hosted).
2
+
3
+ See docs/designs/launcher-model-swap.md for the full design. This module
4
+ implements :class:`SwapManager` — the coordination layer that turns a
5
+ request's ``model`` name into "a launcher-managed backend is running and
6
+ registered" before dispatch, with:
7
+
8
+ - **Thundering-herd collapse**: N concurrent requests for the same
9
+ not-yet-loaded model spawn exactly one process (U1).
10
+ - **Readiness hand-off**: waits on the launcher's own
11
+ ``ManagedProcess.ready`` event (set by
12
+ ``coderouter.ingress.launcher_routes._wait_ready_and_register`` once
13
+ it reaches a terminal outcome) rather than re-implementing a health
14
+ check (§10 Q5 — "SwapManager holds no readiness *judgment*, only
15
+ waiting and coordination").
16
+ - **Idle TTL unload**: a background sweeper stops processes that have
17
+ had no in-flight lease for ``ttl_seconds``, going through the exact
18
+ same intentional-stop path as the manual Stop button (§10 Q4) so
19
+ launcher auto-restart never fights it.
20
+ - **Lease-protected in-flight**: a model with an active request is
21
+ never TTL-evicted, no matter how idle it looks a moment later.
22
+
23
+ Security (§7): the only thing this module can ever spawn is a model
24
+ listed in the static ``launcher.swap.models`` catalog
25
+ (:class:`~coderouter.config.schemas.SwapModelSpec`) — a request's
26
+ ``model`` field is exclusively a *catalog lookup key*, never a path or
27
+ command. Spawning always goes through
28
+ :func:`coderouter.ingress.launcher_routes.spawn_process`, which in turn
29
+ resolves ``model_path`` against ``launcher.model_dirs`` via
30
+ ``_resolve_within_model_dirs`` — the same traversal guard the manual
31
+ Launcher UI uses.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import contextlib
38
+ import re
39
+ import socket
40
+ import time
41
+ from dataclasses import dataclass, field
42
+ from typing import Any
43
+
44
+ from coderouter.adapters.base import AdapterError
45
+ from coderouter.config.schemas import LauncherSwapConfig, ProviderConfig, SwapModelSpec
46
+ from coderouter.ingress.launcher_routes import (
47
+ ManagedProcess,
48
+ _registry_for_app,
49
+ _resolve_within_model_dirs,
50
+ spawn_process,
51
+ stop_process,
52
+ )
53
+ from coderouter.logging import get_logger
54
+
55
+ logger = get_logger(__name__)
56
+
57
+
58
+ @dataclass
59
+ class Lease:
60
+ """A held claim on a loaded swap model.
61
+
62
+ Returned by :meth:`SwapManager.ensure_loaded`; the caller MUST pass
63
+ it to :meth:`SwapManager.release_lease` exactly once — in a
64
+ ``finally`` so it fires even on error / client-disconnect / stream
65
+ cancellation (§6.6 known-trap #1). ``released`` guards against a
66
+ double-release turning into a double-decrement of ``in_flight``.
67
+ """
68
+
69
+ model: str
70
+ released: bool = field(default=False)
71
+
72
+
73
+ @dataclass
74
+ class _ModelState:
75
+ """Per-model coordination state (§6.1). One instance per catalog entry."""
76
+
77
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
78
+ error: BaseException | None = None
79
+ proc_id: str | None = None
80
+ last_used: float = field(default_factory=time.monotonic)
81
+ in_flight: int = 0
82
+ status: str = "idle" # idle | loading | ready | stopping
83
+
84
+
85
+ def _pick_ephemeral_port() -> int:
86
+ """Ask the OS for a free port. Best-effort only (§6.6 known-trap #4):
87
+
88
+ there is an unavoidable TOCTOU window between this call returning
89
+ and the child actually binding the port. Fixed ``port:`` in the
90
+ catalog entry avoids the race entirely (§10 Q2, recommended).
91
+ """
92
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
93
+ s.bind(("127.0.0.1", 0))
94
+ return s.getsockname()[1]
95
+
96
+
97
+ class SwapManager:
98
+ """Coordinates on-demand spawn / readiness wait / idle TTL unload.
99
+
100
+ One instance lives at ``app.state.swap`` and is wired into
101
+ :class:`~coderouter.routing.fallback.FallbackEngine` via
102
+ ``attach_swap_manager`` so the dispatch entry points
103
+ (``generate`` / ``stream`` / ``generate_anthropic`` /
104
+ ``stream_anthropic``) can call :meth:`ensure_loaded` before chain
105
+ resolution.
106
+ """
107
+
108
+ def __init__(
109
+ self, app: Any, config: LauncherSwapConfig, launcher_cfg: Any
110
+ ) -> None:
111
+ self._app = app
112
+ self._config = config
113
+ self._launcher_cfg = launcher_cfg
114
+ self._catalog: dict[str, SwapModelSpec] = {m.name: m for m in config.models}
115
+ # Review fix M-2/M-3: the dispatch hook keys on the RESOLVED
116
+ # profile (and chain membership), not on the request's model
117
+ # name — these indexes serve those lookups.
118
+ self._by_profile: dict[str, SwapModelSpec] = {
119
+ m.profile_name: m for m in config.models
120
+ }
121
+ self._by_provider: dict[str, SwapModelSpec] = {
122
+ m.provider_name: m for m in config.models
123
+ }
124
+ self._states: dict[str, _ModelState] = {}
125
+ self._ttl_seconds = config.ttl_seconds
126
+ self._readiness_timeout_s = config.readiness_timeout_s
127
+ self._sweep_interval_s = config.sweep_interval_s
128
+ self._sweeper_task: asyncio.Task[None] | None = None
129
+
130
+ # ------------------------------------------------------------------
131
+ # Catalog matching
132
+ # ------------------------------------------------------------------
133
+
134
+ def match(self, model: str) -> SwapModelSpec | None:
135
+ """Return the catalog entry ``model`` resolves to, or ``None``.
136
+
137
+ Exact ``name`` match first, then each entry's optional
138
+ ``model_pattern`` (``re.fullmatch``) — mirrors
139
+ ``SwapModelSpec.model_pattern``'s docstring.
140
+ """
141
+ spec = self._catalog.get(model)
142
+ if spec is not None:
143
+ return spec
144
+ for spec in self._catalog.values():
145
+ if spec.model_pattern and re.fullmatch(spec.model_pattern, model):
146
+ return spec
147
+ return None
148
+
149
+ def spec_for_profile(self, profile_name: str | None) -> SwapModelSpec | None:
150
+ """Catalog entry whose DEDICATED profile is ``profile_name``, or None.
151
+
152
+ Review fix M-2/M-3: the dispatch hook keys on the resolved
153
+ profile — a request routed to ``launcher-swap-<name>`` gets a
154
+ lease no matter what its ``model`` field says (M-3), and a
155
+ request routed anywhere else never spawns just because its
156
+ model name happens to match the catalog (M-2).
157
+ """
158
+ if profile_name is None:
159
+ return None
160
+ return self._by_profile.get(profile_name)
161
+
162
+ def spec_for_provider(self, provider_name: str) -> SwapModelSpec | None:
163
+ """Catalog entry registered under ``provider_name``, or None.
164
+
165
+ Lets the dispatch hook also serve chains that explicitly list a
166
+ swap provider (``launcher-swap-<name>``) among other providers.
167
+ """
168
+ return self._by_provider.get(provider_name)
169
+
170
+ # ------------------------------------------------------------------
171
+ # Public coordination API (§4.2)
172
+ # ------------------------------------------------------------------
173
+
174
+ async def ensure_loaded(self, model: str) -> Lease:
175
+ """Ensure ``model``'s backend is running + registered; return a lease.
176
+
177
+ Raises ``KeyError`` if ``model`` isn't in the catalog (callers
178
+ should check :meth:`match` first — the dispatch hook does) and
179
+ a retryable :class:`~coderouter.adapters.base.AdapterError` if
180
+ spawn or readiness fails (never a permanent "poison" — the next
181
+ call retries from scratch, §6.4).
182
+ """
183
+ spec = self._catalog.get(model)
184
+ if spec is None:
185
+ spec = self.match(model)
186
+ if spec is None:
187
+ raise KeyError(f"{model!r} is not in the swap catalog")
188
+ return await self._ensure_loaded_spec(spec)
189
+
190
+ async def release_lease(self, lease: Lease) -> None:
191
+ """Release a lease acquired by :meth:`ensure_loaded`. Idempotent."""
192
+ if lease.released:
193
+ return
194
+ lease.released = True
195
+ state = self._states.get(lease.model)
196
+ if state is None:
197
+ return
198
+ async with state.lock:
199
+ state.in_flight = max(0, state.in_flight - 1)
200
+ state.last_used = time.monotonic()
201
+
202
+ def touch(self, model: str) -> None:
203
+ """Update ``last_used`` without touching the lease count."""
204
+ state = self._states.get(model)
205
+ if state is not None:
206
+ state.last_used = time.monotonic()
207
+
208
+ async def unload(self, model: str, *, reason: str) -> None:
209
+ """Stop ``model``'s backend if loaded. Used by :meth:`sweep_once`."""
210
+ spec = self._catalog.get(model)
211
+ state = self._states.get(model)
212
+ if spec is None or state is None:
213
+ return
214
+ async with state.lock:
215
+ await self._unload_locked(state, spec, reason)
216
+
217
+ async def sweep_once(self) -> None:
218
+ """One TTL sweep pass: stop every idle, un-leased, expired model.
219
+
220
+ Takes each model's own lock independently (never more than one
221
+ 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.
223
+ """
224
+ if self._ttl_seconds is None:
225
+ return
226
+ now = time.monotonic()
227
+ for spec in list(self._catalog.values()):
228
+ state = self._states.get(spec.name)
229
+ if state is None:
230
+ continue
231
+ async with state.lock:
232
+ if state.status != "ready" or state.in_flight > 0:
233
+ continue
234
+ if (now - state.last_used) < self._ttl_seconds:
235
+ continue
236
+ await self._unload_locked(state, spec, reason="ttl")
237
+
238
+ # ------------------------------------------------------------------
239
+ # Background sweeper lifecycle (wired from ingress/app.py's lifespan)
240
+ # ------------------------------------------------------------------
241
+
242
+ async def start(self) -> None:
243
+ """Start the TTL sweeper background task. No-op when TTL is disabled."""
244
+ if self._ttl_seconds is None or self._sweeper_task is not None:
245
+ return
246
+ self._sweeper_task = asyncio.create_task(self._sweeper_loop())
247
+
248
+ async def stop(self) -> None:
249
+ """Cancel the sweeper task. Does NOT stop already-loaded processes —
250
+
251
+ ``shutdown_launcher`` (ingress/launcher_routes.py) already tears
252
+ down every ``ManagedProcess``, swap-spawned ones included
253
+ (§6.6 known-trap #9).
254
+ """
255
+ task = self._sweeper_task
256
+ self._sweeper_task = None
257
+ if task is not None:
258
+ task.cancel()
259
+ with contextlib.suppress(asyncio.CancelledError):
260
+ await task
261
+
262
+ async def _sweeper_loop(self) -> None:
263
+ while True:
264
+ await asyncio.sleep(self._sweep_interval_s)
265
+ with contextlib.suppress(Exception):
266
+ await self.sweep_once()
267
+
268
+ # ------------------------------------------------------------------
269
+ # Internals
270
+ # ------------------------------------------------------------------
271
+
272
+ def _state_for(self, name: str) -> _ModelState:
273
+ state = self._states.get(name)
274
+ if state is None:
275
+ state = _ModelState()
276
+ self._states[name] = state
277
+ return state
278
+
279
+ def _get_proc(self, proc_id: str) -> ManagedProcess | None:
280
+ try:
281
+ return _registry_for_app(self._app).get(proc_id)
282
+ except KeyError:
283
+ return None
284
+
285
+ def _engine(self) -> Any | None:
286
+ return getattr(self._app.state, "engine", None)
287
+
288
+ def _resolve_options(self, spec: SwapModelSpec) -> dict[str, Any]:
289
+ """Resolve ``spec.option_profile`` into the launcher options dict."""
290
+ if not spec.option_profile or self._launcher_cfg is None:
291
+ return {}
292
+ profiles = self._launcher_cfg.option_profiles.get(spec.backend, [])
293
+ for p in profiles:
294
+ if p.name == spec.option_profile:
295
+ return dict(p.args)
296
+ return {}
297
+
298
+ def _to_adapter_error(self, model: str, exc: BaseException | None) -> AdapterError:
299
+ detail = str(exc) if exc is not None else "unknown error"
300
+ return AdapterError(
301
+ f"swap: model {model!r} failed to load: {detail}",
302
+ provider=f"launcher-swap-{model}",
303
+ retryable=True,
304
+ )
305
+
306
+ async def _ensure_loaded_spec(self, spec: SwapModelSpec) -> Lease:
307
+ state = self._state_for(spec.name)
308
+ async with state.lock:
309
+ if state.status == "ready" and self._proc_running(state.proc_id):
310
+ state.last_used = time.monotonic()
311
+ state.in_flight += 1
312
+ return Lease(spec.name)
313
+
314
+ # idle (or a stale "ready" whose process died under us) ->
315
+ # (re)spawn. Concurrent callers for the SAME model queue on
316
+ # this very asyncio.Lock; once we release it below they
317
+ # re-enter this method and take the fast "ready" path above,
318
+ # so at most one spawn happens no matter how many requests
319
+ # arrive at once (U1). This holds the lock for the *entire*
320
+ # spawn + readiness wait — a deliberate simplification versus
321
+ # the design's fork-only-then-Event-release sketch (§6.2);
322
+ # see the implementation report for the rationale. It never
323
+ # blocks any OTHER model's dispatch or sweep, since every
324
+ # lock here is strictly per-model.
325
+ state.status = "loading"
326
+ state.error = None
327
+ proc, exc = await self._spawn_with_retry(spec)
328
+ if proc is None:
329
+ state.status = "idle"
330
+ state.proc_id = None
331
+ state.error = exc
332
+ raise self._to_adapter_error(spec.name, exc)
333
+ state.status = "ready"
334
+ state.proc_id = proc.id
335
+ state.error = None
336
+ state.last_used = time.monotonic()
337
+ state.in_flight += 1
338
+ return Lease(spec.name)
339
+
340
+ def _proc_running(self, proc_id: str | None) -> bool:
341
+ if proc_id is None:
342
+ return False
343
+ proc = self._get_proc(proc_id)
344
+ return proc is not None and proc.status == "running"
345
+
346
+ async def _spawn_with_retry(
347
+ self, spec: SwapModelSpec
348
+ ) -> tuple[ManagedProcess | None, Exception | None]:
349
+ """Spawn + wait for readiness. §10 Q2: one retry on a fresh ephemeral
350
+ port when ``spec.port`` is unset and the first attempt doesn't come
351
+ up (fixed ports never retry — a second attempt would collide again).
352
+ """
353
+ attempts = 1 if spec.port is not None else 2
354
+ last_exc: Exception | None = None
355
+ for _attempt in range(attempts):
356
+ port = spec.port if spec.port is not None else _pick_ephemeral_port()
357
+ try:
358
+ proc = await self._spawn(spec, port)
359
+ except (ValueError, FileNotFoundError, OSError) as exc:
360
+ last_exc = exc
361
+ continue
362
+ if await self._await_ready(proc):
363
+ self._register(spec, port)
364
+ return proc, None
365
+ last_exc = RuntimeError(
366
+ f"swap model {spec.name!r} did not become ready "
367
+ f"(status={proc.status!r}, port={port})"
368
+ )
369
+ with contextlib.suppress(Exception):
370
+ await stop_process(self._app, proc.id)
371
+ return None, last_exc
372
+
373
+ async def _spawn(self, spec: SwapModelSpec, port: int) -> ManagedProcess:
374
+ model_dirs = (
375
+ self._launcher_cfg.model_dirs if self._launcher_cfg is not None else []
376
+ )
377
+ # M14-style traversal guard (§7): model_path is static catalog
378
+ # config, but re-validated at spawn time exactly like the manual
379
+ # /api/launcher/start UI.
380
+ resolved = _resolve_within_model_dirs(spec.model_path, model_dirs)
381
+ return await spawn_process(
382
+ self._app,
383
+ self._launcher_cfg,
384
+ name=spec.provider_name,
385
+ backend=spec.backend,
386
+ model_path=str(resolved),
387
+ port=port,
388
+ options=self._resolve_options(spec),
389
+ extra_args=spec.extra_args,
390
+ draft_model_path=spec.draft_model_path,
391
+ mtp_mode=spec.mtp_mode,
392
+ # H-1: suppress the generic 'launcher-<backend>-<port>'
393
+ # registration (SwapManager registers/deregisters its own
394
+ # provider). H-2: exempt from launcher auto-restart (crash
395
+ # recovery = next-request re-spawn under the per-model lock).
396
+ swap_managed=True,
397
+ )
398
+
399
+ async def _await_ready(self, proc: ManagedProcess) -> bool:
400
+ """Wait on the launcher's own readiness signal (§10 Q5) — no polling,
401
+ no re-implemented health check."""
402
+ try:
403
+ await asyncio.wait_for(
404
+ proc.ready.wait(), timeout=self._readiness_timeout_s
405
+ )
406
+ except TimeoutError:
407
+ return False
408
+ return proc.status == "running"
409
+
410
+ def _register(self, spec: SwapModelSpec, port: int) -> None:
411
+ """Register the dedicated single-model profile (§4.4 "provider同期").
412
+
413
+ Distinct from (and additional to) the generic auto-registration
414
+ ``_wait_ready_and_register`` already performed into the shared
415
+ "launcher" profile under a port-based name — this is
416
+ SwapManager's OWN registration under ``spec.provider_name`` /
417
+ ``spec.profile_name`` so the auto-injected auto_router rule
418
+ (§10 Q7) has somewhere correct to route.
419
+ """
420
+ engine = self._engine()
421
+ if engine is None:
422
+ return
423
+ provider_cfg = ProviderConfig(
424
+ name=spec.provider_name,
425
+ base_url=f"http://localhost:{port}/v1",
426
+ model="",
427
+ timeout_s=120.0,
428
+ )
429
+ with contextlib.suppress(Exception):
430
+ engine.register_provider(provider_cfg, profile_name=spec.profile_name)
431
+
432
+ async def _deregister(self, spec: SwapModelSpec) -> None:
433
+ engine = self._engine()
434
+ if engine is None:
435
+ return
436
+ with contextlib.suppress(Exception):
437
+ await engine.deregister_provider(
438
+ spec.provider_name, profile_name=spec.profile_name
439
+ )
440
+
441
+ async def _unload_locked(
442
+ self, state: _ModelState, spec: SwapModelSpec, reason: str
443
+ ) -> None:
444
+ """Stop + deregister. MUST be called with ``state.lock`` held.
445
+
446
+ §10 Q4: goes through :func:`stop_process` — the same intentional-
447
+ stop path as the manual Stop button (sets
448
+ ``ManagedProcess.stopping = True`` before signalling) — so
449
+ launcher auto-restart (when the operator has it on) never treats
450
+ a TTL unload as a crash to heal.
451
+ """
452
+ if state.status not in ("ready", "loading"):
453
+ return
454
+ proc_id = state.proc_id
455
+ state.status = "stopping"
456
+ if proc_id is not None:
457
+ with contextlib.suppress(Exception):
458
+ await stop_process(self._app, proc_id)
459
+ await self._deregister(spec)
460
+ logger.info(
461
+ "swap-unload",
462
+ extra={"model": spec.name, "reason": reason, "proc_id": proc_id},
463
+ )
464
+ state.status = "idle"
465
+ state.proc_id = None
466
+ state.error = None