coderouter-cli 2.7.5__py3-none-any.whl → 2.7.7__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,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.7
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,23 +7,25 @@ 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
16
17
  coderouter/token_estimation_accurate.py,sha256=GTfzrBVnvAGjeVzmzAeUdOYZvWZKLAxcxPpFiJGlzjk,4609
17
18
  coderouter/adapters/__init__.py,sha256=7dIDSZ-FE_0iSqLSDc_lK1idRdLTKcM2hP9tCJipgPI,463
19
+ coderouter/adapters/agent_cli.py,sha256=jy8u5aNacQmKMH698P_4aZYNIhjGA6xFgynGquNOzOM,24335
18
20
  coderouter/adapters/anthropic_native.py,sha256=CT9Hitun-c3z83YHT2zZXgwZpY3_t6eRnOfAmNud2aw,23610
19
21
  coderouter/adapters/base.py,sha256=ykKMaiIVVGr23oFFXilK2iP_YhI8-CtsEiJyFXaC9bE,10396
20
22
  coderouter/adapters/openai_compat.py,sha256=wcrd_UpV7N6ISpUyKvdFPGU-YjCrX5oMqGpjAFEeWfg,20356
21
- coderouter/adapters/registry.py,sha256=Syt3eDljWZAK5mfiJGvUMKaZYAfCRScp7PvV6pYt7mc,683
23
+ coderouter/adapters/registry.py,sha256=A2LNhp70LOSNodM_iKO6EL-MBadB3kli-YPWRe1U7pQ,979
22
24
  coderouter/config/__init__.py,sha256=FODEn74fN-qZnt4INPSHswqhOlEgpL6-_onxsitSx8g,274
23
25
  coderouter/config/capability_registry.py,sha256=RZgzHF54dXy0CCDocRCz2ee21qXGbbgTSKTkRI9iLL4,16936
24
26
  coderouter/config/env_file.py,sha256=CoMK27fuAXm-NtoLzXb8yN2E-wDFjHQuFwiIlmgTBQw,10356
25
27
  coderouter/config/loader.py,sha256=FUEe8m4Tnmj_aul0vSctD8vKvNW-oLRoMRbTpSKqSmc,4077
26
- coderouter/config/schemas.py,sha256=f46bpmSzZ_zJRhHbo9-inp-mAW86WZAg6hfSxgPaEcc,77741
28
+ coderouter/config/schemas.py,sha256=-HrlEN1BkfO_Euo0m-mszQIaLupg3en6vHkqpPKTvAw,84538
27
29
  coderouter/data/__init__.py,sha256=uNyfD9jaCvTWsBAWtaw1Fr25OSxzv3psGMfBjT1z0Cc,328
28
30
  coderouter/data/model-capabilities.yaml,sha256=S9jt6SC6-3s2-icZ_n-a14iEMnc2yB1C2R6q-N_tZWQ,19309
29
31
  coderouter/guards/__init__.py,sha256=5qliYBqygvVPneej7nx0uSjxDKsz7t8VzvrDgVBJlvU,1170
@@ -41,7 +43,7 @@ coderouter/ingress/__init__.py,sha256=WQsCH2CGJCAhy0mS6GSEdeYZRkkQu2OHDsP4CJWTLu
41
43
  coderouter/ingress/anthropic_routes.py,sha256=1ThXzx8VgxRV7UCi3NF5ey7kbr0ADPKTXyQuYS5gK1w,27063
42
44
  coderouter/ingress/app.py,sha256=h6s-UWsbGP22rku4pPVlvRdrtbMj4KoEEUZoQQOrTTw,19370
43
45
  coderouter/ingress/dashboard_routes.py,sha256=1SqNNrVTfQiZcx5n2MvjAzWirittX0e61tCt-MKp4Ag,25617
44
- coderouter/ingress/launcher_routes.py,sha256=QzG0l0c2ZmAcQai3E-xs3cV5fUYf3ajjZauvI9pKDh4,57959
46
+ coderouter/ingress/launcher_routes.py,sha256=8EY3a6O_vbA45h2uhp_wc37bP_DJFqVVjsd68d_bwtk,66179
45
47
  coderouter/ingress/metrics_routes.py,sha256=M22dwOGn24P05Ge4W3c7d7mYytSGWjIR-pPSPOAiHJY,3965
46
48
  coderouter/ingress/openai_routes.py,sha256=TrugsQNJfNPgKKLzfT1aXzrs4emWYGE4XebEBuaZoWk,12505
47
49
  coderouter/metrics/__init__.py,sha256=7Es351DPS7yLM0yVF_F0eesmiD83n7Zzhie44chht38,1465
@@ -67,8 +69,8 @@ coderouter/translation/__init__.py,sha256=PYXN7XVEwpG1uC8RLy6fvnGbzEZhhrEuUapH8I
67
69
  coderouter/translation/anthropic.py,sha256=aZkcYH4x82b0x7efJgJb9RWn9Hbyc9pEOthXe4vjUdU,11113
68
70
  coderouter/translation/convert.py,sha256=VV4kpu4dvLmGBmJfQnCLY5ryy_AnQcV2NuwjqRtaR8Q,52633
69
71
  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,,
72
+ coderouter_cli-2.7.7.dist-info/METADATA,sha256=BfuchYkkpXZtvCOcok0FISgazhd7eSFKD9f8qID58dI,15546
73
+ coderouter_cli-2.7.7.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
74
+ coderouter_cli-2.7.7.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
75
+ coderouter_cli-2.7.7.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
76
+ coderouter_cli-2.7.7.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