coderouter-cli 2.7.5__py3-none-any.whl → 2.7.6__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.
@@ -136,6 +136,17 @@ class GGUFInfo:
136
136
  n_kv_heads: int | None
137
137
  file_type: int | None
138
138
  file_size_bytes: int
139
+ n_nextn: int | None = None
140
+
141
+ @property
142
+ def supports_mtp(self) -> bool:
143
+ """True when the GGUF embeds Multi-Token-Prediction (nextn) layers.
144
+
145
+ Derived from ``{arch}.nextn_predict_layers`` — a positive count means
146
+ the main model carries MTP/nextn tensors and can drive llama.cpp's
147
+ ``--spec-type draft-mtp`` without a separate draft gguf.
148
+ """
149
+ return bool(self.n_nextn and self.n_nextn > 0)
139
150
 
140
151
  @property
141
152
  def quant_name(self) -> str | None:
@@ -224,6 +235,10 @@ _KEY_BLOCK_COUNT = ".block_count"
224
235
  _KEY_EMBED_LEN = ".embedding_length"
225
236
  _KEY_HEAD_COUNT = ".attention.head_count"
226
237
  _KEY_HEAD_COUNT_KV = ".attention.head_count_kv"
238
+ # Number of Multi-Token-Prediction (nextn) layers embedded in the main model
239
+ # (e.g. ``glm4moe.nextn_predict_layers``). A positive value means the GGUF can
240
+ # drive llama.cpp speculative decoding via ``--spec-type draft-mtp`` alone.
241
+ _KEY_NEXTN = ".nextn_predict_layers"
227
242
 
228
243
 
229
244
  def read_gguf_metadata(path: str | Path) -> GGUFInfo:
@@ -245,6 +260,7 @@ def read_gguf_metadata(path: str | Path) -> GGUFInfo:
245
260
  n_heads: int | None = None
246
261
  n_kv_heads: int | None = None
247
262
  file_type: int | None = None
263
+ n_nextn: int | None = None
248
264
 
249
265
  with p.open("rb") as fh:
250
266
  magic = fh.read(4)
@@ -275,6 +291,8 @@ def read_gguf_metadata(path: str | Path) -> GGUFInfo:
275
291
  n_kv_heads = value
276
292
  elif key.endswith(_KEY_HEAD_COUNT) and isinstance(value, int):
277
293
  n_heads = value
294
+ elif key.endswith(_KEY_NEXTN) and isinstance(value, int):
295
+ n_nextn = value
278
296
 
279
297
  return GGUFInfo(
280
298
  architecture=arch,
@@ -284,6 +302,7 @@ def read_gguf_metadata(path: str | Path) -> GGUFInfo:
284
302
  n_kv_heads=n_kv_heads,
285
303
  file_type=file_type,
286
304
  file_size_bytes=file_size,
305
+ n_nextn=n_nextn,
287
306
  )
288
307
 
289
308
 
@@ -30,6 +30,7 @@ import secrets
30
30
  import shlex
31
31
  import shutil
32
32
  import subprocess
33
+ import time
33
34
  import uuid
34
35
  from collections import deque
35
36
  from dataclasses import dataclass, field
@@ -40,6 +41,7 @@ from fastapi import APIRouter, HTTPException, Request
40
41
  from fastapi.responses import HTMLResponse
41
42
  from pydantic import BaseModel
42
43
 
44
+ from coderouter.launcher_speculative import resolve_speculative
43
45
  from coderouter.logging import get_logger
44
46
 
45
47
  logger = get_logger(__name__)
@@ -115,9 +117,23 @@ class ManagedProcess:
115
117
  options: dict[str, Any]
116
118
  extra_args: str
117
119
  status: str = "starting" # "starting" | "running" | "stopped" | "error"
120
+ # MTP / speculative-decoding controls (defaults keep existing call sites
121
+ # working). Recorded for introspection; the resolved flags live in the cmd.
122
+ draft_model_path: str | None = None
123
+ mtp_mode: str = "auto"
118
124
  pid: int | None = None
119
125
  returncode: int | None = None
120
126
  log_tail: deque = field(default_factory=lambda: deque(maxlen=200))
127
+ # MTP auto-fallback state. When the speculative flags were added by AUTO
128
+ # detection and the child dies during startup, the process is relaunched
129
+ # ONCE without them (some archs' draft-mtp support in llama.cpp is
130
+ # immature and crashes the context init). These fields make that possible
131
+ # and impossible to loop.
132
+ spec_tokens: list = field(default_factory=list)
133
+ spec_auto: bool = False
134
+ mtp_fallback_done: bool = False
135
+ fallback_cmd: list | None = None
136
+ started_at: float = 0.0
121
137
  # asyncio subprocess handle — not serialised
122
138
  _proc: Any = field(default=None, repr=False, compare=False)
123
139
 
@@ -208,8 +224,15 @@ _BACKEND_DEFAULTS: dict[str, str] = {
208
224
  # - llama.cpp: ``-m`` / ``--model`` select the GGUF file.
209
225
  # - vllm / mlx: ``--model`` selects the model; ``-m`` selects the python
210
226
  # module that ``_build_cmd`` pins, so it must not be re-specified either.
227
+ # - llama.cpp: the draft/MTP model is likewise set only via the dedicated
228
+ # ``draft_model_path`` field, so its aliases (``-md`` / ``--model-draft``
229
+ # / ``--spec-draft-model``) are rejected in options / extra_args too. The
230
+ # remaining spec knobs (``--spec-type``, ``--spec-draft-n-max`` …) stay
231
+ # free-form.
211
232
  _MODEL_FLAGS: dict[str, frozenset[str]] = {
212
- "llama.cpp": frozenset({"-m", "--model"}),
233
+ "llama.cpp": frozenset(
234
+ {"-m", "--model", "-md", "--model-draft", "--spec-draft-model"}
235
+ ),
213
236
  "vllm": frozenset({"-m", "--model"}),
214
237
  "mlx": frozenset({"-m", "--model"}),
215
238
  }
@@ -407,6 +430,24 @@ def _resolve_within_model_dirs(model_path: str, model_dirs: list[str]) -> Path:
407
430
  raise ValueError("model_path is not under any configured model_dirs.")
408
431
 
409
432
 
433
+ def _option_tokens(options: dict[str, Any]) -> list[str]:
434
+ """Flatten an ``options`` dict into CLI tokens (``{flag: val}`` semantics).
435
+
436
+ Boolean values become a bare flag when true and vanish when false;
437
+ everything else becomes ``[flag, str(val)]``. Shared by ``_build_cmd`` and
438
+ ``api_start`` so the tokens fed to ``resolve_speculative`` match exactly
439
+ what lands on the command line (no double-parsing drift).
440
+ """
441
+ tokens: list[str] = []
442
+ for flag, val in options.items():
443
+ if isinstance(val, bool):
444
+ if val:
445
+ tokens.append(flag)
446
+ else:
447
+ tokens.extend([flag, str(val)])
448
+ return tokens
449
+
450
+
410
451
  def _build_cmd(
411
452
  backend: str,
412
453
  model_path: str,
@@ -414,17 +455,27 @@ def _build_cmd(
414
455
  options: dict[str, Any],
415
456
  extra_args: str,
416
457
  binary: str | None = None,
458
+ spec_tokens: list[str] | None = None,
417
459
  ) -> list[str]:
418
460
  """Build the CLI command list for the given backend and options.
419
461
 
420
462
  ``binary`` overrides the default executable (``llama-server`` /
421
463
  ``python``). When None, the default is used and PATH resolution
422
464
  is left to the OS.
465
+
466
+ ``spec_tokens`` are pre-resolved speculative-decoding / MTP flags
467
+ (from :func:`coderouter.launcher_speculative.resolve_speculative`).
468
+ For llama.cpp they are appended right after the port args and BEFORE
469
+ the profile / extra args. They are trusted (the launcher, not the
470
+ caller, produced them) so they bypass the model-override guard even
471
+ though they may contain ``--model-draft``.
423
472
  """
424
473
  exe = _resolve_binary(backend, binary)
425
474
 
426
475
  if backend == "llama.cpp":
427
476
  cmd: list[str] = [exe, "-m", model_path, "--port", str(port)]
477
+ if spec_tokens:
478
+ cmd.extend(spec_tokens)
428
479
  elif backend == "vllm":
429
480
  cmd = [
430
481
  exe, "-m", "vllm.entrypoints.openai.api_server",
@@ -446,12 +497,7 @@ def _build_cmd(
446
497
  # H8: reject model-path re-specification via options keys.
447
498
  _assert_no_model_override(backend, list(options.keys()))
448
499
 
449
- for flag, val in options.items():
450
- if isinstance(val, bool):
451
- if val:
452
- cmd.append(flag)
453
- else:
454
- cmd.extend([flag, str(val)])
500
+ cmd.extend(_option_tokens(options))
455
501
 
456
502
  if extra_args.strip():
457
503
  extra_tokens = shlex.split(extra_args)
@@ -474,12 +520,37 @@ def _build_cmd(
474
520
  # recover by reading the buffered chunk and continuing so log tailing survives.
475
521
  _LOG_STREAM_LIMIT = 256 * 1024 # 256 KB
476
522
 
523
+ # Window (seconds) after spawn in which a non-zero exit is treated as a
524
+ # startup crash eligible for the MTP auto-fallback. A backend that ran fine
525
+ # for longer and then died is a normal crash, not a load-time failure, so it
526
+ # is never retried.
527
+ _MTP_FALLBACK_WINDOW_SECS = 180.0
477
528
 
478
- async def _tail_logs(proc: ManagedProcess) -> None:
479
- """Read stdout+stderr into proc.log_tail until the process exits."""
529
+
530
+ def _should_mtp_fallback(proc: ManagedProcess) -> bool:
531
+ """True when a just-crashed process qualifies for the one-shot MTP retry.
532
+
533
+ Only auto-detected speculative launches that die within the startup
534
+ window are eligible, and only once (guarded by ``mtp_fallback_done``).
535
+ """
480
536
  p = proc._proc
481
- if p is None:
482
- return
537
+ rc = p.returncode if p is not None else proc.returncode
538
+ return (
539
+ proc.spec_auto
540
+ and not proc.mtp_fallback_done
541
+ and bool(proc.fallback_cmd)
542
+ and rc not in (0, None)
543
+ and (time.monotonic() - proc.started_at) <= _MTP_FALLBACK_WINDOW_SECS
544
+ )
545
+
546
+
547
+ async def _tail_logs(proc: ManagedProcess) -> None:
548
+ """Read stdout+stderr into proc.log_tail until the process exits.
549
+
550
+ Loops so that a one-shot MTP fallback (relaunch without the auto-detected
551
+ speculative flags) can be tailed by the same task. The loop exits as soon
552
+ as no fallback applies.
553
+ """
483
554
 
484
555
  async def _drain(stream: asyncio.StreamReader | None) -> None:
485
556
  if stream is None:
@@ -505,14 +576,53 @@ async def _tail_logs(proc: ManagedProcess) -> None:
505
576
  break
506
577
  proc.log_tail.append(line.decode(errors="replace").rstrip())
507
578
 
508
- await asyncio.gather(_drain(p.stdout), _drain(p.stderr))
509
- await p.wait()
510
- proc.returncode = p.returncode
511
- proc.pid = None
512
- proc.status = "stopped" if (p.returncode or 0) == 0 else "error"
513
- proc.log_tail.append(
514
- f"[launcher] process exited with code {p.returncode}"
515
- )
579
+ while True:
580
+ p = proc._proc
581
+ if p is None:
582
+ return
583
+
584
+ await asyncio.gather(_drain(p.stdout), _drain(p.stderr))
585
+ await p.wait()
586
+ proc.returncode = p.returncode
587
+ proc.pid = None
588
+ proc.status = "stopped" if (p.returncode or 0) == 0 else "error"
589
+ proc.log_tail.append(
590
+ f"[launcher] process exited with code {p.returncode}"
591
+ )
592
+
593
+ if not _should_mtp_fallback(proc):
594
+ return
595
+
596
+ # Auto-detected MTP crashed during startup — retry ONCE without the
597
+ # speculative flags. The port is unchanged, so the existing provider
598
+ # registration still stands (do not re-register).
599
+ rc = proc.returncode
600
+ proc.mtp_fallback_done = True
601
+ proc.log_tail.append(
602
+ f"[launcher] MTP startup failure detected (exit code {rc}); "
603
+ "retrying without speculative decoding"
604
+ )
605
+ proc.log_tail.append(
606
+ f"[launcher] cmd: {' '.join(proc.fallback_cmd)}"
607
+ )
608
+ try:
609
+ p = await asyncio.create_subprocess_exec(
610
+ *proc.fallback_cmd,
611
+ stdout=asyncio.subprocess.PIPE,
612
+ stderr=asyncio.subprocess.PIPE,
613
+ limit=_LOG_STREAM_LIMIT,
614
+ )
615
+ except (FileNotFoundError, OSError) as exc:
616
+ proc.log_tail.append(f"[launcher] fallback relaunch failed: {exc}")
617
+ proc.status = "error"
618
+ return
619
+ proc._proc = p
620
+ proc.pid = p.pid
621
+ proc.status = "running"
622
+ proc.returncode = None
623
+ proc.started_at = time.monotonic()
624
+ proc.log_tail.append(f"[launcher] started PID {p.pid}")
625
+ # Loop to tail the relaunched process.
516
626
 
517
627
 
518
628
  async def shutdown_launcher(app: Any) -> None:
@@ -555,6 +665,11 @@ class StartRequest(BaseModel):
555
665
  port: int
556
666
  options: dict[str, Any] = {}
557
667
  extra_args: str = ""
668
+ # MTP / speculative-decoding controls (llama.cpp only). ``draft_model_path``
669
+ # is an explicit companion draft/MTP gguf; ``mtp_mode`` is "auto" (detect)
670
+ # or "off" (never emit speculative flags).
671
+ draft_model_path: str | None = None
672
+ mtp_mode: str = "auto"
558
673
 
559
674
 
560
675
  # ---------------------------------------------------------------------------
@@ -690,15 +805,69 @@ async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
690
805
  if bc and bc.binary:
691
806
  configured_binary = bc.binary
692
807
 
808
+ # Validate the MTP mode up front (only "auto" / "off" are accepted).
809
+ if req.mtp_mode not in ("auto", "off"):
810
+ raise HTTPException(
811
+ status_code=400,
812
+ detail=f"mtp_mode must be 'auto' or 'off', got {req.mtp_mode!r}.",
813
+ )
814
+
815
+ # Build the user token list once (options flags + extra_args) so
816
+ # resolve_speculative sees exactly what _build_cmd will emit. shlex.split
817
+ # can raise on unbalanced quotes → surface as 400 like other bad input.
818
+ try:
819
+ user_tokens = _option_tokens(req.options)
820
+ if req.extra_args.strip():
821
+ user_tokens += shlex.split(req.extra_args)
822
+ except ValueError as exc:
823
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
824
+
825
+ # Resolve MTP / speculative-decoding flags (llama.cpp only; no-op elsewhere).
826
+ try:
827
+ spec_tokens, spec_notes = resolve_speculative(
828
+ req.backend,
829
+ req.model_path,
830
+ req.draft_model_path,
831
+ req.mtp_mode,
832
+ user_tokens,
833
+ )
834
+ except ValueError as exc:
835
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
836
+
693
837
  try:
694
838
  cmd = _build_cmd(
695
839
  req.backend, req.model_path, req.port,
696
840
  req.options, req.extra_args,
697
841
  binary=configured_binary,
842
+ spec_tokens=spec_tokens,
698
843
  )
699
844
  except ValueError as exc:
700
845
  raise HTTPException(status_code=400, detail=str(exc)) from exc
701
846
 
847
+ # Speculative flags qualify for the one-shot startup-crash fallback only
848
+ # when they came from AUTO detection (mtp_mode="auto", no explicit draft
849
+ # model, and detection actually emitted flags). Explicit draft paths and
850
+ # operator-supplied --spec-type are never auto-retried. The fallback cmd
851
+ # is rebuilt from scratch with spec_tokens=None (exact — never spliced).
852
+ spec_auto = (
853
+ req.mtp_mode == "auto"
854
+ and req.draft_model_path is None
855
+ and bool(spec_tokens)
856
+ )
857
+ fallback_cmd: list[str] | None = None
858
+ if spec_auto:
859
+ try:
860
+ fallback_cmd = _build_cmd(
861
+ req.backend, req.model_path, req.port,
862
+ req.options, req.extra_args,
863
+ binary=configured_binary,
864
+ spec_tokens=None,
865
+ )
866
+ except ValueError:
867
+ # A failure to rebuild the non-spec command simply disables the
868
+ # fallback; it must never block the primary start.
869
+ fallback_cmd = None
870
+
702
871
  proc_id = uuid.uuid4().hex[:8]
703
872
  proc = ManagedProcess(
704
873
  id=proc_id,
@@ -708,9 +877,16 @@ async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
708
877
  port=req.port,
709
878
  options=req.options,
710
879
  extra_args=req.extra_args,
880
+ draft_model_path=req.draft_model_path,
881
+ mtp_mode=req.mtp_mode,
711
882
  status="starting",
883
+ spec_tokens=spec_tokens,
884
+ spec_auto=spec_auto and fallback_cmd is not None,
885
+ fallback_cmd=fallback_cmd,
712
886
  )
713
887
  proc.log_tail.append(f"[launcher] cmd: {' '.join(cmd)}")
888
+ for note in spec_notes:
889
+ proc.log_tail.append(f"[launcher] {note}")
714
890
 
715
891
  try:
716
892
  p = await asyncio.create_subprocess_exec(
@@ -732,6 +908,7 @@ async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
732
908
  proc._proc = p
733
909
  proc.pid = p.pid
734
910
  proc.status = "running"
911
+ proc.started_at = time.monotonic()
735
912
  proc.log_tail.append(f"[launcher] started PID {p.pid}")
736
913
 
737
914
  _registry(request).add(proc)
@@ -767,6 +944,7 @@ async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
767
944
  "pid": p.pid,
768
945
  "command": cmd,
769
946
  "provider_sync": provider_sync,
947
+ "speculative": spec_tokens,
770
948
  }
771
949
 
772
950
 
@@ -971,6 +1149,20 @@ _LAUNCHER_HTML = r"""<!doctype html>
971
1149
  <div id="profile-args" class="mt-1 text-xs font-mono text-slate-400 bg-slate-800/50 rounded p-2 hidden"></div>
972
1150
  </div>
973
1151
 
1152
+ <div class="grid grid-cols-2 gap-2">
1153
+ <div>
1154
+ <label class="block text-xs text-slate-400 mb-1">MTP/draft gguf (空欄で自動検出)</label>
1155
+ <input id="f-draft" type="text" placeholder="companion .gguf (任意)" />
1156
+ </div>
1157
+ <div>
1158
+ <label class="block text-xs text-slate-400 mb-1">MTP</label>
1159
+ <select id="f-mtp">
1160
+ <option value="auto">auto</option>
1161
+ <option value="off">off</option>
1162
+ </select>
1163
+ </div>
1164
+ </div>
1165
+
974
1166
  <div>
975
1167
  <div class="flex items-center justify-between mb-1">
976
1168
  <label class="block text-xs text-slate-400">追加オプション(自由入力)</label>
@@ -1292,6 +1484,8 @@ _LAUNCHER_HTML = r"""<!doctype html>
1292
1484
  const backend = document.getElementById("f-backend").value;
1293
1485
  const model = document.getElementById("f-model").value.trim();
1294
1486
  const extra = document.getElementById("f-extra").value.trim();
1487
+ const draft = document.getElementById("f-draft").value.trim();
1488
+ const mtp = document.getElementById("f-mtp").value;
1295
1489
 
1296
1490
  if (!name) { showLaunchErr("名前を入力してください"); return; }
1297
1491
  if (!model) { showLaunchErr("モデルパスを入力してください"); return; }
@@ -1306,7 +1500,8 @@ _LAUNCHER_HTML = r"""<!doctype html>
1306
1500
  method: "POST",
1307
1501
  headers: authHeaders({"Content-Type": "application/json"}),
1308
1502
  body: JSON.stringify({name, backend, model_path: model, port,
1309
- options: selectedProfileArgs(), extra_args: extra}),
1503
+ options: selectedProfileArgs(), extra_args: extra,
1504
+ draft_model_path: draft || null, mtp_mode: mtp}),
1310
1505
  });
1311
1506
  const d = await res.json();
1312
1507
  if (!res.ok) { showLaunchErr(d.detail || "起動失敗"); return; }
@@ -0,0 +1,333 @@
1
+ """Speculative-decoding / MTP flag resolution for the llama.cpp launcher.
2
+
3
+ Why this module exists
4
+ ======================
5
+
6
+ llama.cpp's ``llama-server`` (2026) folds Multi-Token Prediction (MTP) into
7
+ its speculative-decoding framework. Instead of asking the operator to hand-
8
+ assemble the right ``--spec-type`` / ``--model-draft`` incantation, the
9
+ launcher exposes two high-level knobs — ``draft_model_path`` and
10
+ ``mtp_mode`` — and derives the concrete CLI tokens here.
11
+
12
+ Both launchers (the FastAPI web UI in
13
+ :mod:`coderouter.ingress.launcher_routes` and the tkinter desktop app in
14
+ ``launcher_gui.py``) call :func:`resolve_speculative` so the decision logic
15
+ lives in exactly one place.
16
+
17
+ Decision summary (llama.cpp only)
18
+ ---------------------------------
19
+
20
+ * ``mtp_mode="off"`` — never emit speculative flags (reproduces the historical
21
+ command exactly). Combining it with ``draft_model_path`` is a conflict.
22
+ * Explicit ``draft_model_path`` — validate it, then pick ``draft-mtp`` when the
23
+ filename looks MTP-ish (``/mtp/i``) or ``draft-simple`` otherwise.
24
+ * ``mtp_mode="auto"`` (the default) — inspect the main GGUF: if it embeds nextn
25
+ layers (``{arch}.nextn_predict_layers > 0``) use ``--spec-type draft-mtp``
26
+ with no separate draft model; otherwise scan the *same directory* for a
27
+ small companion draft/MTP gguf and wire it up if one is found.
28
+
29
+ The functions here are pure stdlib + :mod:`coderouter.gguf_introspect`; they
30
+ perform filesystem reads but never spawn processes and hold no FastAPI
31
+ dependency, which keeps them trivially unit-testable.
32
+
33
+ Security
34
+ --------
35
+
36
+ Every resolved path is placed into an argv list by the caller (never a shell
37
+ string), and ``draft_model_path`` must resolve to an existing regular file —
38
+ so this module adds no new shell-interpolation or path-probing surface beyond
39
+ reading GGUF headers the operator already pointed us at.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import re
45
+ from pathlib import Path
46
+
47
+ from coderouter.gguf_introspect import try_read_gguf_metadata
48
+
49
+ __all__ = ["find_draft_companion", "resolve_speculative"]
50
+
51
+ _LLAMA_CPP = "llama.cpp"
52
+
53
+ # Companion candidates must be meaningfully smaller than the main model — a
54
+ # draft / MTP head is a fraction of the target's weights. Guards against
55
+ # mistaking a second full model in the same folder for a draft.
56
+ _COMPANION_MAX_SIZE_RATIO = 0.5
57
+
58
+ # Minimum stripped-prefix length before a shared-prefix match is trusted
59
+ # (avoids matching two unrelated models that merely share a short token).
60
+ _MIN_PREFIX_LEN = 3
61
+
62
+ # Trailing GGUF shard marker, e.g. "-00001-of-00003".
63
+ _SHARD_RE = re.compile(r"[-._]\d{5}-of-\d{5}$", re.IGNORECASE)
64
+
65
+ # Trailing quantisation token, e.g. "-Q4_K_M", ".IQ3_XXS", "-F16", "-BF16".
66
+ _QUANT_RE = re.compile(
67
+ r"[-._]?(i?q\d[_a-z0-9]*|bf16|fp?16|fp?32)$",
68
+ re.IGNORECASE,
69
+ )
70
+
71
+ # Filename hint that the companion carries MTP tensors (→ draft-mtp).
72
+ _MTP_NAME_RE = re.compile(r"mtp", re.IGNORECASE)
73
+
74
+
75
+ def _strip_quant_suffix(stem: str) -> str:
76
+ """Return ``stem`` with a trailing shard marker and quant token removed.
77
+
78
+ Used to derive a model's "family prefix" so a companion sitting next to
79
+ ``Foo-7B-Q4_K_M.gguf`` (prefix ``Foo-7B``) can be matched even though its
80
+ own quant/shape suffix differs.
81
+ """
82
+ s = _SHARD_RE.sub("", stem)
83
+ s = _QUANT_RE.sub("", s)
84
+ return s
85
+
86
+
87
+ def _has_spec_type(user_tokens: list[str]) -> bool:
88
+ """True if the operator already supplied ``--spec-type`` in extra args."""
89
+ return any(
90
+ tok == "--spec-type" or tok.startswith("--spec-type=")
91
+ for tok in user_tokens
92
+ )
93
+
94
+
95
+ def _has_split_mode_tensor(user_tokens: list[str]) -> bool:
96
+ """True if the tokens request ``--split-mode tensor`` (either form).
97
+
98
+ llama.cpp issue #24309: a nextn-embedded model combined with tensor split
99
+ crashes, so callers warn (but do not block) when this pairs with emitted
100
+ speculative flags.
101
+ """
102
+ for i, tok in enumerate(user_tokens):
103
+ if tok == "--split-mode=tensor":
104
+ return True
105
+ if (
106
+ tok == "--split-mode"
107
+ and i + 1 < len(user_tokens)
108
+ and user_tokens[i + 1] == "tensor"
109
+ ):
110
+ return True
111
+ return False
112
+
113
+
114
+ def _spec_type_for_name(path: Path) -> str:
115
+ """Pick ``draft-mtp`` for MTP-named files, ``draft-simple`` otherwise."""
116
+ return "draft-mtp" if _MTP_NAME_RE.search(path.name) else "draft-simple"
117
+
118
+
119
+ def find_draft_companion(main_path: str | Path) -> Path | None:
120
+ """Scan the directory of ``main_path`` for a companion draft/MTP gguf.
121
+
122
+ A candidate qualifies when it is a ``*.gguf`` other than the main file,
123
+ is smaller than :data:`_COMPANION_MAX_SIZE_RATIO` of the main file, and
124
+ either:
125
+
126
+ * has an ``mtp`` or ``draft`` hint in its filename, or
127
+ * shares the main file's family prefix (main filename with its shard /
128
+ quant suffix stripped).
129
+
130
+ If a candidate's GGUF ``architecture`` is readable and differs from the
131
+ main model's, it is dropped (tokenizer/vocab must match for speculation);
132
+ unreadable metadata is kept on a best-effort basis.
133
+
134
+ Candidates are ranked by name hint (``mtp`` > ``draft`` > none), then by
135
+ whether they share the prefix, then by smallest size. Returns the best
136
+ match, or ``None`` when the directory holds no suitable companion.
137
+ """
138
+ main = Path(main_path).expanduser()
139
+ directory = main.parent
140
+ try:
141
+ main_size = main.stat().st_size
142
+ except OSError:
143
+ return None
144
+ if main_size <= 0:
145
+ return None
146
+
147
+ main_prefix = _strip_quant_suffix(main.stem)
148
+ main_meta = try_read_gguf_metadata(main)
149
+ main_arch = main_meta.architecture if main_meta else None
150
+
151
+ try:
152
+ entries = list(directory.iterdir())
153
+ except OSError:
154
+ return None
155
+
156
+ # Each ranked candidate: (name_hint, shares_prefix, -size) sorted so that
157
+ # the strongest hint / prefix / smallest file sorts first.
158
+ ranked: list[tuple[int, int, int, Path]] = []
159
+ for cand in entries:
160
+ if cand == main:
161
+ continue
162
+ if cand.suffix.lower() != ".gguf":
163
+ continue
164
+ try:
165
+ if not cand.is_file():
166
+ continue
167
+ size = cand.stat().st_size
168
+ except OSError:
169
+ continue
170
+ if size <= 0 or size >= main_size * _COMPANION_MAX_SIZE_RATIO:
171
+ continue
172
+
173
+ name_lower = cand.name.lower()
174
+ has_mtp = "mtp" in name_lower
175
+ has_draft = "draft" in name_lower
176
+ shares_prefix = (
177
+ len(main_prefix) >= _MIN_PREFIX_LEN
178
+ and cand.stem.lower().startswith(main_prefix.lower())
179
+ )
180
+ if not (has_mtp or has_draft or shares_prefix):
181
+ continue
182
+
183
+ # Reject a candidate whose architecture is readable and mismatches the
184
+ # main model — a speculator must share the target's vocabulary.
185
+ cand_meta = try_read_gguf_metadata(cand)
186
+ if (
187
+ cand_meta is not None
188
+ and cand_meta.architecture is not None
189
+ and main_arch is not None
190
+ and cand_meta.architecture != main_arch
191
+ ):
192
+ continue
193
+
194
+ name_hint = 2 if has_mtp else (1 if has_draft else 0)
195
+ ranked.append((name_hint, int(shares_prefix), size, cand))
196
+
197
+ if not ranked:
198
+ return None
199
+ # Highest hint, then prefix match, then smallest size.
200
+ ranked.sort(key=lambda t: (-t[0], -t[1], t[2]))
201
+ return ranked[0][3]
202
+
203
+
204
+ def resolve_speculative(
205
+ backend: str,
206
+ model_path: str,
207
+ draft_model_path: str | None,
208
+ mtp_mode: str,
209
+ user_tokens: list[str],
210
+ ) -> tuple[list[str], list[str]]:
211
+ """Resolve speculative-decoding / MTP CLI tokens for a launch.
212
+
213
+ Parameters
214
+ ----------
215
+ backend:
216
+ Target backend id. Only ``"llama.cpp"`` supports speculation; other
217
+ backends must not receive ``draft_model_path`` / ``mtp_mode="off"``.
218
+ model_path:
219
+ The vetted main model path (already set via the launcher's dedicated
220
+ field). Inspected for embedded nextn layers / same-folder companions.
221
+ draft_model_path:
222
+ Optional explicit companion draft or MTP gguf. ``None`` means "not
223
+ supplied".
224
+ mtp_mode:
225
+ ``"auto"`` (default behaviour — detect) or ``"off"`` (never emit
226
+ speculative flags).
227
+ user_tokens:
228
+ The flat list of option/extra-args tokens already destined for the
229
+ command line. Used to defer to an operator-supplied ``--spec-type``
230
+ and to warn about the tensor-split crash.
231
+
232
+ Returns
233
+ -------
234
+ ``(spec_tokens, notes)`` where ``spec_tokens`` is the list of CLI tokens to
235
+ append (empty when none apply) and ``notes`` is a list of human-readable
236
+ strings describing what was decided (surfaced in the process log).
237
+
238
+ Raises
239
+ ------
240
+ ValueError
241
+ On misuse — the web API maps this to HTTP 400:
242
+
243
+ * ``draft_model_path`` / ``mtp_mode="off"`` on a non-llama.cpp backend,
244
+ * ``mtp_mode="off"`` combined with an explicit ``draft_model_path``,
245
+ * an explicit ``draft_model_path`` that does not resolve to a file.
246
+ """
247
+ notes: list[str] = []
248
+
249
+ # 1. Non-llama.cpp backends do not support speculation. Reject explicit
250
+ # use; otherwise (auto default, no draft) stay a silent no-op.
251
+ if backend != _LLAMA_CPP:
252
+ if draft_model_path or mtp_mode == "off":
253
+ raise ValueError(
254
+ "draft_model_path / mtp_mode are only supported for llama.cpp"
255
+ )
256
+ return [], notes
257
+
258
+ # 2. Explicit opt-out. Never emit flags; conflicting draft path is an error.
259
+ if mtp_mode == "off":
260
+ if draft_model_path:
261
+ raise ValueError(
262
+ "mtp_mode='off' conflicts with an explicit draft_model_path"
263
+ )
264
+ return [], notes
265
+
266
+ # 3. Operator already drives speculation via extra args → defer entirely.
267
+ if _has_spec_type(user_tokens):
268
+ notes.append(
269
+ "spec flags supplied via extra args; auto MTP detection skipped"
270
+ )
271
+ return [], notes
272
+
273
+ spec_tokens: list[str] = []
274
+
275
+ # 4. Explicit companion draft / MTP gguf.
276
+ if draft_model_path:
277
+ draft = Path(draft_model_path).expanduser()
278
+ if not draft.is_file():
279
+ raise ValueError(
280
+ f"draft_model_path does not exist or is not a file: {draft}"
281
+ )
282
+ spec_type = _spec_type_for_name(draft)
283
+ spec_tokens = ["--spec-type", spec_type, "--model-draft", str(draft)]
284
+ notes.append(
285
+ f"MTP: explicit draft model {draft.name} → --spec-type {spec_type}"
286
+ )
287
+ # 5. Auto detection (only when the main path is an existing .gguf).
288
+ else:
289
+ main = Path(model_path).expanduser()
290
+ if main.suffix.lower() != ".gguf" or not main.is_file():
291
+ notes.append(
292
+ "MTP auto: main model is not an existing .gguf; "
293
+ "skipping speculative detection"
294
+ )
295
+ else:
296
+ info = try_read_gguf_metadata(main)
297
+ if info is not None and info.n_nextn and info.n_nextn > 0:
298
+ # 5a. Main gguf embeds nextn/MTP tensors — no draft model needed.
299
+ spec_tokens = ["--spec-type", "draft-mtp"]
300
+ notes.append(
301
+ f"MTP: nextn layers ({info.n_nextn}) detected in main "
302
+ "gguf → --spec-type draft-mtp"
303
+ )
304
+ else:
305
+ # 5b. Look for a companion gguf next to the main model.
306
+ companion = find_draft_companion(main)
307
+ if companion is not None:
308
+ spec_type = _spec_type_for_name(companion)
309
+ spec_tokens = [
310
+ "--spec-type",
311
+ spec_type,
312
+ "--model-draft",
313
+ str(companion),
314
+ ]
315
+ notes.append(
316
+ f"MTP: companion gguf {companion.name} found next to "
317
+ f"main → --spec-type {spec_type}"
318
+ )
319
+ else:
320
+ # 5c. Nothing found — start without speculative decoding.
321
+ notes.append(
322
+ f"MTP/draft gguf not found next to {main.name}; "
323
+ "starting without speculative decoding"
324
+ )
325
+
326
+ # 6. Warn (do not block) about the nextn + tensor-split crash.
327
+ if spec_tokens and _has_split_mode_tensor(user_tokens):
328
+ notes.append(
329
+ "WARNING: --split-mode tensor with speculative/nextn is known to "
330
+ "crash llama.cpp (issue #24309); consider --split-mode layer"
331
+ )
332
+
333
+ return spec_tokens, notes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coderouter-cli
3
- Version: 2.7.5
3
+ Version: 2.7.6
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
@@ -7,9 +7,10 @@ coderouter/doctor.py,sha256=2luNk6BHSRvpQStJnHcqzNvNi-SKdOuKV0WZdorZhVk,82854
7
7
  coderouter/doctor_apply.py,sha256=r_J6xbu5-HivofPNriw4_vjNYs_VRs7GsGTS0oMEX10,24209
8
8
  coderouter/env_security.py,sha256=FEBZnXfJ0xE39kmMMn39zk0W_DRRnmcB_REmP9f4xWo,14796
9
9
  coderouter/errors.py,sha256=Xmq67lheyw8iv3Ox39jh2c4tvNI5RcUR4QkoxVDN6l4,1130
10
- coderouter/gguf_introspect.py,sha256=FZO14STLSp94Rfo5AInGwYUOpfjiXOW6CH5RiczTWDE,9514
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_speculative.py,sha256=kWaHNmhzBsbWuM_lRGDlbWkgJ1suuxBWQNTKhJOC-yg,12644
13
14
  coderouter/logging.py,sha256=91cveN8SCJEMNz9DGi6TrFSlc8cx0wdUkOdSLWLA-z8,56144
14
15
  coderouter/output_filters.py,sha256=0ry_rPiS_kC-FnHgaNVP6v7e6Al2djxzu9vBzZ8kEkE,25314
15
16
  coderouter/token_estimation.py,sha256=iz22vZEEW2P7uKLB2pYvPNpIbZGbgXRO5MtfkS_-9Sk,7531
@@ -41,7 +42,7 @@ coderouter/ingress/__init__.py,sha256=WQsCH2CGJCAhy0mS6GSEdeYZRkkQu2OHDsP4CJWTLu
41
42
  coderouter/ingress/anthropic_routes.py,sha256=1ThXzx8VgxRV7UCi3NF5ey7kbr0ADPKTXyQuYS5gK1w,27063
42
43
  coderouter/ingress/app.py,sha256=h6s-UWsbGP22rku4pPVlvRdrtbMj4KoEEUZoQQOrTTw,19370
43
44
  coderouter/ingress/dashboard_routes.py,sha256=1SqNNrVTfQiZcx5n2MvjAzWirittX0e61tCt-MKp4Ag,25617
44
- coderouter/ingress/launcher_routes.py,sha256=QzG0l0c2ZmAcQai3E-xs3cV5fUYf3ajjZauvI9pKDh4,57959
45
+ coderouter/ingress/launcher_routes.py,sha256=8EY3a6O_vbA45h2uhp_wc37bP_DJFqVVjsd68d_bwtk,66179
45
46
  coderouter/ingress/metrics_routes.py,sha256=M22dwOGn24P05Ge4W3c7d7mYytSGWjIR-pPSPOAiHJY,3965
46
47
  coderouter/ingress/openai_routes.py,sha256=TrugsQNJfNPgKKLzfT1aXzrs4emWYGE4XebEBuaZoWk,12505
47
48
  coderouter/metrics/__init__.py,sha256=7Es351DPS7yLM0yVF_F0eesmiD83n7Zzhie44chht38,1465
@@ -67,8 +68,8 @@ coderouter/translation/__init__.py,sha256=PYXN7XVEwpG1uC8RLy6fvnGbzEZhhrEuUapH8I
67
68
  coderouter/translation/anthropic.py,sha256=aZkcYH4x82b0x7efJgJb9RWn9Hbyc9pEOthXe4vjUdU,11113
68
69
  coderouter/translation/convert.py,sha256=VV4kpu4dvLmGBmJfQnCLY5ryy_AnQcV2NuwjqRtaR8Q,52633
69
70
  coderouter/translation/tool_repair.py,sha256=DGOKArM53yfVbIfiAveoul1vGq9NEKmjagf43Qh4IkM,57630
70
- coderouter_cli-2.7.5.dist-info/METADATA,sha256=VsPe8Z7VLlfIRAm5OHEP8VP-gE6CZmWcQFDqWlxiXIE,15546
71
- coderouter_cli-2.7.5.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
72
- coderouter_cli-2.7.5.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
73
- coderouter_cli-2.7.5.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
74
- coderouter_cli-2.7.5.dist-info/RECORD,,
71
+ coderouter_cli-2.7.6.dist-info/METADATA,sha256=VNl2YqpfRQDN5Ww8ukg21mjL5JVvgAOtPj6HKTZKP6Y,15546
72
+ coderouter_cli-2.7.6.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
73
+ coderouter_cli-2.7.6.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
74
+ coderouter_cli-2.7.6.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
75
+ coderouter_cli-2.7.6.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.30.1
2
+ Generator: hatchling 1.31.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any