coderouter-cli 2.9.2__py3-none-any.whl → 2.9.4__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.
@@ -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 returning
89
- and the child actually binding the port. Fixed ``port:`` in the
90
- catalog entry avoids the race entirely (§10 Q2, recommended).
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._ttl_seconds is None:
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) < self._ttl_seconds:
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. No-op when TTL is disabled."""
244
- if self._ttl_seconds is None or self._sweeper_task is not None:
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. §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).
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 = 1 if spec.port is not None else 2
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},
@@ -143,9 +143,18 @@ def normalize_message_roles(payload: dict[str, Any]) -> dict[str, Any]:
143
143
  (see anthropics/claude-code#63469, vllm-project/vllm#44000).
144
144
 
145
145
  Policy:
146
- - ``role: "system"`` → text content merged into the top-level
147
- ``system`` field (appended after any existing system prompt;
148
- same join rule as ``convert.to_anthropic_request``).
146
+ - leading ``role: "system"`` (before any user/assistant turn) →
147
+ text content merged into the top-level ``system`` field
148
+ (appended after any existing system prompt; same join rule as
149
+ ``convert.to_anthropic_request``). This is a genuine system
150
+ prompt from an OpenAI-style client, and it stays at position 0.
151
+ - mid-conversation ``role: "system"`` → coerced to ``user`` **in
152
+ place**, like any other non-spec role. Claude Code's
153
+ system-reminders are volatile per-turn text; hoisting them to
154
+ the top-level field moves content that changes every turn ahead
155
+ of the whole conversation, which invalidates the prefix cache of
156
+ local backends (llama.cpp / LM Studio) and forces a full prompt
157
+ reprocess on every request.
149
158
  - any other non-spec role (``ctx``, ``msg``, ...) → coerced to
150
159
  ``user`` so conversation position is preserved. Anthropic
151
160
  merges consecutive same-role turns, so this is safe.
@@ -175,9 +184,21 @@ def normalize_message_roles(payload: dict[str, Any]) -> dict[str, Any]:
175
184
  continue
176
185
  if role == "system":
177
186
  text = _content_as_text(msg.get("content"))
178
- if text:
179
- system_texts.append(text)
180
187
  coerced_roles.append("system")
188
+ if text:
189
+ if not messages_out:
190
+ # Leading system message — a genuine system prompt from an
191
+ # OpenAI-style client. Hoisting to the top-level field is
192
+ # correct and prefix-safe (it stays at position 0).
193
+ system_texts.append(text)
194
+ else:
195
+ # Mid-conversation system message — volatile per-turn text
196
+ # (Claude Code >= 2.1.154 system-reminders). Keep it where
197
+ # the client put it. Hoisting moves text that changes every
198
+ # turn ahead of the entire conversation, which invalidates
199
+ # the prefix cache of local backends (llama.cpp / LM Studio)
200
+ # and forces a full prompt reprocess on every request.
201
+ messages_out.append({"role": "user", "content": text})
181
202
  continue
182
203
  # Unknown role (ctx / msg / future surprises): keep its position
183
204
  # in the conversation as a user turn; drop if nothing salvageable.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coderouter-cli
3
- Version: 2.9.2
3
+ Version: 2.9.4
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
@@ -40,7 +40,7 @@ Requires-Dist: types-pyyaml>=6.0.12; extra == 'dev'
40
40
  Provides-Extra: doctor
41
41
  Requires-Dist: ruamel-yaml>=0.18.6; extra == 'doctor'
42
42
  Provides-Extra: repair
43
- Requires-Dist: json-repair>=0.30; extra == 'repair'
43
+ Requires-Dist: json-repair>=0.60.1; extra == 'repair'
44
44
  Description-Content-Type: text/markdown
45
45
 
46
46
  <h1 align="center">CodeRouter</h1>
@@ -10,8 +10,9 @@ coderouter/errors.py,sha256=Xmq67lheyw8iv3Ox39jh2c4tvNI5RcUR4QkoxVDN6l4,1130
10
10
  coderouter/gguf_introspect.py,sha256=KeE00CfbYRa4gSTrzuwC6zVuHi20IzLC4nUQa98BEXI,10387
11
11
  coderouter/hardware.py,sha256=gn3_9qbVcGRR81yKMn1lJE_8-YDRau0LxIH_M-f7pxE,8356
12
12
  coderouter/language_tax.py,sha256=LTbE3tIfoJuV2O3T0NixRKhzq_dEOTUuPEerJv2q9uk,9360
13
+ coderouter/launcher_devices.py,sha256=SoGpzknRqvnmvg_IeXy1TkeE7aLc_MOFqLzXdd3CjVs,19275
13
14
  coderouter/launcher_speculative.py,sha256=kWaHNmhzBsbWuM_lRGDlbWkgJ1suuxBWQNTKhJOC-yg,12644
14
- coderouter/launcher_swap.py,sha256=Oc9itCKzjSFbE_RWxqcjIJyiYax8o9zZo7czmEI2s-U,19150
15
+ coderouter/launcher_swap.py,sha256=W7qpdB-8Npr8yHGfx0N3SgZ2vDJ6BR9JuxW0I3TaZ-4,23783
15
16
  coderouter/logging.py,sha256=5rfi4_R4aoeeUYDc642dgs61wA0aBX7wvubyB-k3Uyo,58074
16
17
  coderouter/output_filters.py,sha256=0ry_rPiS_kC-FnHgaNVP6v7e6Al2djxzu9vBzZ8kEkE,25314
17
18
  coderouter/token_estimation.py,sha256=iz22vZEEW2P7uKLB2pYvPNpIbZGbgXRO5MtfkS_-9Sk,7531
@@ -25,7 +26,7 @@ coderouter/config/__init__.py,sha256=FODEn74fN-qZnt4INPSHswqhOlEgpL6-_onxsitSx8g
25
26
  coderouter/config/capability_registry.py,sha256=RZgzHF54dXy0CCDocRCz2ee21qXGbbgTSKTkRI9iLL4,16936
26
27
  coderouter/config/env_file.py,sha256=CoMK27fuAXm-NtoLzXb8yN2E-wDFjHQuFwiIlmgTBQw,10356
27
28
  coderouter/config/loader.py,sha256=FUEe8m4Tnmj_aul0vSctD8vKvNW-oLRoMRbTpSKqSmc,4077
28
- coderouter/config/schemas.py,sha256=zMv6UjL2ebcxc3dMIOa9lQjOGvvuHMWCPqoOVA35CEw,117181
29
+ coderouter/config/schemas.py,sha256=wHaOqbehppjtL-01eLh8DNmm0A3rZXdJ-cNbBcS2yM8,120984
29
30
  coderouter/data/__init__.py,sha256=uNyfD9jaCvTWsBAWtaw1Fr25OSxzv3psGMfBjT1z0Cc,328
30
31
  coderouter/data/model-capabilities.yaml,sha256=S9jt6SC6-3s2-icZ_n-a14iEMnc2yB1C2R6q-N_tZWQ,19309
31
32
  coderouter/guards/__init__.py,sha256=5qliYBqygvVPneej7nx0uSjxDKsz7t8VzvrDgVBJlvU,1170
@@ -43,7 +44,7 @@ coderouter/ingress/__init__.py,sha256=WQsCH2CGJCAhy0mS6GSEdeYZRkkQu2OHDsP4CJWTLu
43
44
  coderouter/ingress/anthropic_routes.py,sha256=1ThXzx8VgxRV7UCi3NF5ey7kbr0ADPKTXyQuYS5gK1w,27063
44
45
  coderouter/ingress/app.py,sha256=6e_qNlYZIyjLKhY20t0ZNL-x5Fv8R8oaIH_HCdxa9Ic,20921
45
46
  coderouter/ingress/dashboard_routes.py,sha256=1SqNNrVTfQiZcx5n2MvjAzWirittX0e61tCt-MKp4Ag,25617
46
- coderouter/ingress/launcher_routes.py,sha256=kVorBfnWL9Q0a6h-gad2YxgeoZjHFHbL8C4DcHtkigg,82031
47
+ coderouter/ingress/launcher_routes.py,sha256=lU2EILVAi-lvHBHIxyixsMTRZlp-Rv7-2sw-eW4ccYg,119687
47
48
  coderouter/ingress/metrics_routes.py,sha256=M22dwOGn24P05Ge4W3c7d7mYytSGWjIR-pPSPOAiHJY,3965
48
49
  coderouter/ingress/openai_routes.py,sha256=TrugsQNJfNPgKKLzfT1aXzrs4emWYGE4XebEBuaZoWk,12505
49
50
  coderouter/metrics/__init__.py,sha256=7Es351DPS7yLM0yVF_F0eesmiD83n7Zzhie44chht38,1465
@@ -66,11 +67,11 @@ coderouter/state/request_log.py,sha256=mNxQJ9IDGpUtT5rZbCo_-CTyhxmvig7C2Gfvdn9rc
66
67
  coderouter/state/store.py,sha256=h-rsMJq8GILsOfCP94nI40cuHaj4Vqycsm9UNN77REI,7445
67
68
  coderouter/state/suggest_rules.py,sha256=FvdhEvao5NvdKp9zs8AkcoFKHY4yqqXY2HekvSjpDFA,16670
68
69
  coderouter/translation/__init__.py,sha256=PYXN7XVEwpG1uC8RLy6fvnGbzEZhhrEuUapH8IYOtG8,1788
69
- coderouter/translation/anthropic.py,sha256=aZkcYH4x82b0x7efJgJb9RWn9Hbyc9pEOthXe4vjUdU,11113
70
+ coderouter/translation/anthropic.py,sha256=uEScq-icLn6ZXFWaxsKROv7feaq3zD0ZogjbTJ3Y7gQ,12578
70
71
  coderouter/translation/convert.py,sha256=VV4kpu4dvLmGBmJfQnCLY5ryy_AnQcV2NuwjqRtaR8Q,52633
71
72
  coderouter/translation/tool_repair.py,sha256=DGOKArM53yfVbIfiAveoul1vGq9NEKmjagf43Qh4IkM,57630
72
- coderouter_cli-2.9.2.dist-info/METADATA,sha256=dcDACrTxRsla8y8mcY05IC4KilzZD-co6xkEz9xC_H4,15994
73
- coderouter_cli-2.9.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
74
- coderouter_cli-2.9.2.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
75
- coderouter_cli-2.9.2.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
76
- coderouter_cli-2.9.2.dist-info/RECORD,,
73
+ coderouter_cli-2.9.4.dist-info/METADATA,sha256=E7EiSxxEYfkchQLvqlvdQzADFsnwbReGwi0Patjfk8I,15996
74
+ coderouter_cli-2.9.4.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
75
+ coderouter_cli-2.9.4.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
76
+ coderouter_cli-2.9.4.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
77
+ coderouter_cli-2.9.4.dist-info/RECORD,,