coderouter-cli 2.8.1__py3-none-any.whl → 2.9.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coderouter/adapters/registry.py +31 -48
- coderouter/config/schemas.py +555 -8
- coderouter/doctor.py +58 -0
- 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.8.1.dist-info → coderouter_cli-2.9.1.dist-info}/METADATA +5 -3
- {coderouter_cli-2.8.1.dist-info → coderouter_cli-2.9.1.dist-info}/RECORD +12 -12
- coderouter/adapters/agent_cli.py +0 -1168
- {coderouter_cli-2.8.1.dist-info → coderouter_cli-2.9.1.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.8.1.dist-info → coderouter_cli-2.9.1.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.8.1.dist-info → coderouter_cli-2.9.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|