coderouter-cli 2.10.0__py3-none-any.whl → 2.11.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/anthropic_native.py +23 -5
- coderouter/cli.py +49 -0
- coderouter/config/loader.py +65 -0
- coderouter/config/schemas.py +228 -12
- coderouter/doctor.py +98 -19
- coderouter/doctor_apply.py +72 -3
- coderouter/gguf_introspect.py +132 -25
- coderouter/guards/drift_detection.py +13 -1
- coderouter/ingress/launcher_routes.py +289 -22
- coderouter/launcher_devices.py +200 -4
- coderouter/launcher_speculative.py +5 -1
- coderouter/launcher_swap.py +11 -2
- coderouter/routing/fallback.py +14 -2
- coderouter/translation/anthropic.py +14 -1
- coderouter/translation/convert.py +7 -0
- coderouter/translation/tool_repair.py +193 -76
- coderouter/vscode_init.py +4 -2
- {coderouter_cli-2.10.0.dist-info → coderouter_cli-2.11.1.dist-info}/METADATA +2 -1
- {coderouter_cli-2.10.0.dist-info → coderouter_cli-2.11.1.dist-info}/RECORD +22 -22
- {coderouter_cli-2.10.0.dist-info → coderouter_cli-2.11.1.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.10.0.dist-info → coderouter_cli-2.11.1.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.10.0.dist-info → coderouter_cli-2.11.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -53,11 +53,16 @@ from coderouter.translation.anthropic import (
|
|
|
53
53
|
AnthropicResponse,
|
|
54
54
|
AnthropicStreamEvent,
|
|
55
55
|
)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
)
|
|
56
|
+
|
|
57
|
+
# NOTE: `coderouter.translation.convert` is intentionally NOT imported here
|
|
58
|
+
# at module scope (see H-7). The import chain is:
|
|
59
|
+
# translation/__init__.py -> convert.py -> adapters/__init__.py
|
|
60
|
+
# -> registry.py -> anthropic_native.py -> translation.convert (again)
|
|
61
|
+
# A top-level import here closes that cycle and makes standalone imports of
|
|
62
|
+
# `coderouter.translation.*` fail with a partial-initialization ImportError
|
|
63
|
+
# unless `coderouter.adapters` happened to be imported first. The three
|
|
64
|
+
# symbols used from `convert` are imported lazily inside `generate()` /
|
|
65
|
+
# `stream()` instead, which breaks the cycle without changing behavior.
|
|
61
66
|
|
|
62
67
|
logger = get_logger(__name__)
|
|
63
68
|
|
|
@@ -210,6 +215,12 @@ class AnthropicAdapter(BaseAdapter):
|
|
|
210
215
|
``to_chat_response``. The ``coderouter_provider`` tag is preserved
|
|
211
216
|
on both sides.
|
|
212
217
|
"""
|
|
218
|
+
# Local import to avoid a circular import at module load time (H-7):
|
|
219
|
+
# convert.py transitively imports coderouter.adapters, which imports
|
|
220
|
+
# this module. Importing convert.py here (call time, not load time)
|
|
221
|
+
# keeps `coderouter.translation.*` importable standalone.
|
|
222
|
+
from coderouter.translation.convert import to_anthropic_request, to_chat_response
|
|
223
|
+
|
|
213
224
|
anth_req = to_anthropic_request(request)
|
|
214
225
|
anth_resp = await self.generate_anthropic(anth_req, overrides=overrides)
|
|
215
226
|
chat_resp = to_chat_response(anth_resp)
|
|
@@ -241,6 +252,13 @@ class AnthropicAdapter(BaseAdapter):
|
|
|
241
252
|
- Anthropic ``event: error`` → translator raises
|
|
242
253
|
AdapterError(retryable=False), same treatment as above.
|
|
243
254
|
"""
|
|
255
|
+
# Local import to avoid a circular import at module load time (H-7);
|
|
256
|
+
# see the matching comment in generate() above.
|
|
257
|
+
from coderouter.translation.convert import (
|
|
258
|
+
stream_anthropic_to_chat_chunks,
|
|
259
|
+
to_anthropic_request,
|
|
260
|
+
)
|
|
261
|
+
|
|
244
262
|
anth_req = to_anthropic_request(request)
|
|
245
263
|
events = self.stream_anthropic(anth_req, overrides=overrides)
|
|
246
264
|
async for chunk in stream_anthropic_to_chat_chunks(events, provider_name=self.name):
|
coderouter/cli.py
CHANGED
|
@@ -624,6 +624,24 @@ def _resolve_config_path(explicit: str | None) -> Path:
|
|
|
624
624
|
return candidates[-1]
|
|
625
625
|
|
|
626
626
|
|
|
627
|
+
def _write_verdict_line(*, write: bool, files_written: int) -> None:
|
|
628
|
+
"""Print the one-line "did we touch the disk?" verdict.
|
|
629
|
+
|
|
630
|
+
Called on **every** exit path of :func:`_run_apply_or_dry_run`.
|
|
631
|
+
The v1.x regression this guards against: an ``--apply`` run that
|
|
632
|
+
silently rewrote providers.yaml while the summary said "already
|
|
633
|
+
applied" and printed neither a diff nor a ``Backup:`` line. Making
|
|
634
|
+
the statement unconditional means no future early-return can go
|
|
635
|
+
quiet about a write again.
|
|
636
|
+
"""
|
|
637
|
+
if not write:
|
|
638
|
+
print(" 0 files written (dry-run).")
|
|
639
|
+
elif files_written == 0:
|
|
640
|
+
print(" 0 files written — no file contents changed.")
|
|
641
|
+
else:
|
|
642
|
+
print(f" {files_written} file(s) written.")
|
|
643
|
+
|
|
644
|
+
|
|
627
645
|
def _run_apply_or_dry_run(
|
|
628
646
|
*,
|
|
629
647
|
report: object,
|
|
@@ -655,9 +673,13 @@ def _run_apply_or_dry_run(
|
|
|
655
673
|
)
|
|
656
674
|
except MissingDependencyError as exc:
|
|
657
675
|
print(f"doctor --apply: {exc}", file=sys.stderr)
|
|
676
|
+
_write_verdict_line(write=write, files_written=0)
|
|
658
677
|
return 1
|
|
659
678
|
except DoctorApplyError as exc:
|
|
679
|
+
# Every merge happens before the first byte is written, so an
|
|
680
|
+
# abort here always leaves the disk untouched.
|
|
660
681
|
print(f"doctor --apply: {exc}", file=sys.stderr)
|
|
682
|
+
_write_verdict_line(write=write, files_written=0)
|
|
661
683
|
return 1
|
|
662
684
|
|
|
663
685
|
label = "Apply" if write else "Dry-run"
|
|
@@ -680,6 +702,8 @@ def _run_apply_or_dry_run(
|
|
|
680
702
|
f" All {result.no_op_patches} patch(es) already applied "
|
|
681
703
|
f"— providers.yaml is up to date."
|
|
682
704
|
)
|
|
705
|
+
_write_verdict_line(write=write, files_written=len(result.written_paths))
|
|
706
|
+
_print_reformat_notice(result, write=write)
|
|
683
707
|
return 0
|
|
684
708
|
|
|
685
709
|
print(
|
|
@@ -694,16 +718,41 @@ def _run_apply_or_dry_run(
|
|
|
694
718
|
print()
|
|
695
719
|
print(diff, end="" if diff.endswith("\n") else "\n")
|
|
696
720
|
|
|
721
|
+
_write_verdict_line(write=write, files_written=len(result.written_paths))
|
|
697
722
|
if write:
|
|
698
723
|
for orig, bak in result.backups.items():
|
|
699
724
|
print(f" Backup: {orig} → {bak}")
|
|
700
725
|
else:
|
|
726
|
+
_print_reformat_notice(result, write=write)
|
|
701
727
|
print()
|
|
702
728
|
print(" (dry-run — no files were modified. Re-run with --apply to write.)")
|
|
703
729
|
|
|
704
730
|
return 0
|
|
705
731
|
|
|
706
732
|
|
|
733
|
+
def _print_reformat_notice(result: object, *, write: bool) -> None:
|
|
734
|
+
"""Dry-run-only heads-up about cosmetic re-serialization deltas.
|
|
735
|
+
|
|
736
|
+
``reformat_only`` holds targets whose re-dump differs from disk
|
|
737
|
+
without any patch having changed a value — today that is explicit
|
|
738
|
+
``key: null`` scalars, which ruamel re-emits as empty scalars.
|
|
739
|
+
These bytes are never written, so the note is purely so an operator
|
|
740
|
+
comparing "the diff I expected" with "the file I have" is not
|
|
741
|
+
surprised. Suppressed under ``--apply``, where nothing about them
|
|
742
|
+
is actionable.
|
|
743
|
+
"""
|
|
744
|
+
reformat = getattr(result, "reformat_only", None) or {}
|
|
745
|
+
if write or not reformat:
|
|
746
|
+
return
|
|
747
|
+
print(
|
|
748
|
+
f" note: {len(reformat)} file(s) would re-serialize with cosmetic "
|
|
749
|
+
"differences (e.g. explicit `null` → empty scalar). These are NOT "
|
|
750
|
+
"written — --apply only rewrites files whose values actually changed."
|
|
751
|
+
)
|
|
752
|
+
for path in sorted(reformat):
|
|
753
|
+
print(f" - {path}")
|
|
754
|
+
|
|
755
|
+
|
|
707
756
|
|
|
708
757
|
|
|
709
758
|
def _run_audit(args: argparse.Namespace) -> int:
|
coderouter/config/loader.py
CHANGED
|
@@ -7,6 +7,17 @@ Search order (first hit wins):
|
|
|
7
7
|
4. ~/.coderouter/providers.yaml
|
|
8
8
|
|
|
9
9
|
Secrets are resolved by reading the env var named by `api_key_env`.
|
|
10
|
+
|
|
11
|
+
.. note::
|
|
12
|
+
**[Unreleased] deprecation notice (planned v2.12.0):** step 3 above
|
|
13
|
+
(implicit CWD discovery) is slated to become opt-in, gated behind
|
|
14
|
+
``CODEROUTER_ALLOW_CWD_CONFIG``. Landing that change without warning
|
|
15
|
+
first would silently break anyone currently relying on "run from the
|
|
16
|
+
directory with providers.yaml in it" with no diagnostic. Until that
|
|
17
|
+
lands, :func:`load_config` emits a one-time ``cwd-config-loaded``
|
|
18
|
+
warning whenever step 3 is the one that actually resolved — never
|
|
19
|
+
when an explicit ``--config`` / ``CODEROUTER_CONFIG`` path was used,
|
|
20
|
+
even if it happens to point at the same file.
|
|
10
21
|
"""
|
|
11
22
|
|
|
12
23
|
from __future__ import annotations
|
|
@@ -17,6 +28,14 @@ from pathlib import Path
|
|
|
17
28
|
import yaml
|
|
18
29
|
|
|
19
30
|
from coderouter.config.schemas import CodeRouterConfig
|
|
31
|
+
from coderouter.logging import get_logger
|
|
32
|
+
|
|
33
|
+
logger = get_logger(__name__)
|
|
34
|
+
|
|
35
|
+
# Guards the one-time [Unreleased]/v2.12.0 CWD-config deprecation warning
|
|
36
|
+
# (see module docstring) so a process that calls load_config() repeatedly
|
|
37
|
+
# (tests, hot-reload, multiple create_app() calls) only logs it once.
|
|
38
|
+
_cwd_config_warning_emitted = False
|
|
20
39
|
|
|
21
40
|
|
|
22
41
|
def _candidate_paths(explicit: str | os.PathLike[str] | None) -> list[Path]:
|
|
@@ -30,6 +49,51 @@ def _candidate_paths(explicit: str | os.PathLike[str] | None) -> list[Path]:
|
|
|
30
49
|
return paths
|
|
31
50
|
|
|
32
51
|
|
|
52
|
+
def _warn_if_cwd_config(
|
|
53
|
+
chosen: Path, *, explicit: str | os.PathLike[str] | None
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Emit the one-time [Unreleased]/v2.12.0 CWD-config deprecation warning.
|
|
56
|
+
|
|
57
|
+
Only fires when ``chosen`` was resolved via the *implicit* CWD-search
|
|
58
|
+
step. Compares ``chosen`` against the *literal* explicit / env
|
|
59
|
+
candidate paths (not merely whether those inputs were set) so that:
|
|
60
|
+
|
|
61
|
+
* an explicit ``--config`` (or ``CODEROUTER_CONFIG``) path that
|
|
62
|
+
happens to coincide with ``./providers.yaml`` stays quiet — an
|
|
63
|
+
explicit choice, however coincidental, is not the thing being
|
|
64
|
+
deprecated; but
|
|
65
|
+
* an explicit path/env var that was set but did NOT resolve (file
|
|
66
|
+
missing, so the search fell through to CWD) still warns, because
|
|
67
|
+
the CWD step is what actually served the config in that case.
|
|
68
|
+
"""
|
|
69
|
+
global _cwd_config_warning_emitted
|
|
70
|
+
if _cwd_config_warning_emitted:
|
|
71
|
+
return
|
|
72
|
+
cwd_path = Path.cwd() / "providers.yaml"
|
|
73
|
+
if chosen != cwd_path:
|
|
74
|
+
return
|
|
75
|
+
if explicit and Path(explicit) == chosen:
|
|
76
|
+
return
|
|
77
|
+
if (env_path := os.environ.get("CODEROUTER_CONFIG")) and Path(env_path) == chosen:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
_cwd_config_warning_emitted = True
|
|
81
|
+
logger.warning(
|
|
82
|
+
"cwd-config-loaded",
|
|
83
|
+
extra={
|
|
84
|
+
"path": str(chosen),
|
|
85
|
+
"hint": (
|
|
86
|
+
f"loaded providers.yaml from the current working "
|
|
87
|
+
f"directory ({chosen}). Starting in v2.12.0, this "
|
|
88
|
+
"implicit CWD discovery becomes opt-in (gated behind "
|
|
89
|
+
"CODEROUTER_ALLOW_CWD_CONFIG) — pass --config or set "
|
|
90
|
+
"CODEROUTER_CONFIG to pin the file explicitly and avoid "
|
|
91
|
+
"a startup break when that lands."
|
|
92
|
+
),
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
33
97
|
def load_config(path: str | os.PathLike[str] | None = None) -> CodeRouterConfig:
|
|
34
98
|
"""Load providers.yaml + apply ALLOW_PAID env override."""
|
|
35
99
|
candidates = _candidate_paths(path)
|
|
@@ -40,6 +104,7 @@ def load_config(path: str | os.PathLike[str] | None = None) -> CodeRouterConfig:
|
|
|
40
104
|
f"providers.yaml not found. Searched:\n {searched}\n"
|
|
41
105
|
f"Hint: copy examples/providers.yaml to ~/.coderouter/providers.yaml"
|
|
42
106
|
)
|
|
107
|
+
_warn_if_cwd_config(chosen, explicit=path)
|
|
43
108
|
|
|
44
109
|
with chosen.open("r", encoding="utf-8") as f:
|
|
45
110
|
raw = yaml.safe_load(f) or {}
|
coderouter/config/schemas.py
CHANGED
|
@@ -17,7 +17,18 @@ import re
|
|
|
17
17
|
import warnings
|
|
18
18
|
from typing import Any, Literal, Self
|
|
19
19
|
|
|
20
|
-
from pydantic import
|
|
20
|
+
from pydantic import (
|
|
21
|
+
BaseModel,
|
|
22
|
+
ConfigDict,
|
|
23
|
+
Field,
|
|
24
|
+
HttpUrl,
|
|
25
|
+
field_validator,
|
|
26
|
+
model_validator,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from coderouter.logging import get_logger
|
|
30
|
+
|
|
31
|
+
logger = get_logger(__name__)
|
|
21
32
|
|
|
22
33
|
|
|
23
34
|
class Capabilities(BaseModel):
|
|
@@ -343,6 +354,17 @@ class AgentCliConfig(BaseModel):
|
|
|
343
354
|
return self
|
|
344
355
|
|
|
345
356
|
|
|
357
|
+
# [Unreleased]/planned v2.12.0: patterns used by ProviderConfig's
|
|
358
|
+
# ``_warn_restart_command_shell_syntax`` validator to flag
|
|
359
|
+
# ``restart_command`` values that rely on shell syntax not honored by
|
|
360
|
+
# the planned ``shlex.split`` + ``shell=False`` dispatch (see that
|
|
361
|
+
# validator's docstring and the field description below for the full
|
|
362
|
+
# rationale). Compiled once at module scope since every ProviderConfig
|
|
363
|
+
# validation reuses them.
|
|
364
|
+
_RESTART_COMMAND_SHELL_META_RE = re.compile(r"(&&|\|\||\||;|>|<|\$\(|`)")
|
|
365
|
+
_RESTART_COMMAND_ENV_PREFIX_RE = re.compile(r"^\w+=")
|
|
366
|
+
|
|
367
|
+
|
|
346
368
|
class ProviderConfig(BaseModel):
|
|
347
369
|
"""A single provider entry from providers.yaml.
|
|
348
370
|
|
|
@@ -490,15 +512,82 @@ class ProviderConfig(BaseModel):
|
|
|
490
512
|
restart_command: str | None = Field(
|
|
491
513
|
default=None,
|
|
492
514
|
description=(
|
|
493
|
-
"v2.0-J (Self-healing):
|
|
494
|
-
"
|
|
515
|
+
"v2.0-J (Self-healing): command to restart this provider's "
|
|
516
|
+
"backend process. Examples: 'ollama serve', "
|
|
495
517
|
"'open -a LM\\ Studio'. Only executed when the profile's "
|
|
496
518
|
"backend_health_action is 'exclude' and the provider "
|
|
497
519
|
"transitions to UNHEALTHY. Unset = no automatic restart "
|
|
498
|
-
"(recovery probe still runs, waiting for manual restart)."
|
|
520
|
+
"(recovery probe still runs, waiting for manual restart). "
|
|
521
|
+
"\n\n"
|
|
522
|
+
"[Unreleased]/planned v2.12.0: currently run as "
|
|
523
|
+
"``subprocess.run(command, shell=True)`` (coderouter/guards/"
|
|
524
|
+
"self_healing.py), so shell syntax works today — pipelines "
|
|
525
|
+
"(``pkill ollama && ollama serve``), ``~`` expansion, and "
|
|
526
|
+
"leading env-var assignments (``OLLAMA_HOST=0.0.0.0 ollama "
|
|
527
|
+
"serve``) are all honored. A future minor switches this to "
|
|
528
|
+
"``shlex.split(command)`` + ``shell=False`` (argv dispatch, "
|
|
529
|
+
"no shell in between), which does NOT support any of the "
|
|
530
|
+
"above — ``&&`` becomes a literal argument to the first "
|
|
531
|
+
"command instead of a separator, ``~/...`` paths are not "
|
|
532
|
+
"expanded, and ``FOO=bar cmd`` fails to exec. If your "
|
|
533
|
+
"command needs shell features, wrap it in a small script "
|
|
534
|
+
"and point restart_command at that script instead, e.g. "
|
|
535
|
+
"``restart_command: /path/to/restart-ollama.sh``."
|
|
499
536
|
),
|
|
500
537
|
)
|
|
501
538
|
|
|
539
|
+
@model_validator(mode="after")
|
|
540
|
+
def _warn_restart_command_shell_syntax(self) -> ProviderConfig:
|
|
541
|
+
"""[Unreleased]/planned v2.12.0 breaking-change heads-up (warning only).
|
|
542
|
+
|
|
543
|
+
``coderouter/guards/self_healing.py`` currently runs
|
|
544
|
+
``restart_command`` via ``subprocess.run(command, shell=True)``.
|
|
545
|
+
The planned v2.12.0 change switches that to
|
|
546
|
+
``shlex.split(command)`` + ``shell=False`` — a shell-syntax
|
|
547
|
+
``restart_command`` that works fine today would then either
|
|
548
|
+
crash (``FileNotFoundError`` for ``~/...`` paths or an
|
|
549
|
+
``ENV=val cmd`` prefix) or, worse, silently misbehave (``&&`` /
|
|
550
|
+
``||`` / ``|`` / ``;`` / redirects becoming literal argv tokens
|
|
551
|
+
instead of shell operators — a provider "restart" that quietly
|
|
552
|
+
does the wrong thing is worse than one that errors loudly).
|
|
553
|
+
|
|
554
|
+
Deliberately never raises: this validator does not enforce
|
|
555
|
+
anything, it only warns ahead of the wire change so operators
|
|
556
|
+
can migrate ``restart_command`` to a plain argv (or a wrapper
|
|
557
|
+
script) before v2.12.0 lands, instead of discovering the break
|
|
558
|
+
the next time a provider actually goes UNHEALTHY.
|
|
559
|
+
"""
|
|
560
|
+
cmd = self.restart_command
|
|
561
|
+
if not cmd:
|
|
562
|
+
return self
|
|
563
|
+
stripped = cmd.strip()
|
|
564
|
+
reasons: list[str] = []
|
|
565
|
+
if _RESTART_COMMAND_SHELL_META_RE.search(cmd):
|
|
566
|
+
reasons.append("shell metacharacter (&&, ||, |, ;, >, <, $(...), or `...`)")
|
|
567
|
+
if stripped.startswith("~/"):
|
|
568
|
+
reasons.append("leading '~/' path (no shell expansion under shell=False)")
|
|
569
|
+
if _RESTART_COMMAND_ENV_PREFIX_RE.match(stripped):
|
|
570
|
+
reasons.append("leading environment-variable assignment (FOO=bar ...)")
|
|
571
|
+
if reasons:
|
|
572
|
+
logger.warning(
|
|
573
|
+
"restart-command-shell-syntax",
|
|
574
|
+
extra={
|
|
575
|
+
"provider": self.name,
|
|
576
|
+
"restart_command": cmd,
|
|
577
|
+
"reasons": reasons,
|
|
578
|
+
"hint": (
|
|
579
|
+
"restart_command currently runs via "
|
|
580
|
+
"subprocess.run(..., shell=True); a planned "
|
|
581
|
+
"v2.12.0 change switches to shlex.split(...) + "
|
|
582
|
+
"shell=False, which does not support this "
|
|
583
|
+
"command's shell syntax. Rewrite it as plain "
|
|
584
|
+
"argv, or wrap it in a script and point "
|
|
585
|
+
"restart_command at that script."
|
|
586
|
+
),
|
|
587
|
+
},
|
|
588
|
+
)
|
|
589
|
+
return self
|
|
590
|
+
|
|
502
591
|
@model_validator(mode="after")
|
|
503
592
|
def _check_output_filters_known(self) -> ProviderConfig:
|
|
504
593
|
"""v1.0-A: fail at config-load on a typo'd filter name.
|
|
@@ -1414,6 +1503,29 @@ class LauncherBackendConfig(BaseModel):
|
|
|
1414
1503
|
binary: ~/llama.cpp/build/bin/llama-server
|
|
1415
1504
|
vllm:
|
|
1416
1505
|
binary: ~/.venv/bin/python
|
|
1506
|
+
|
|
1507
|
+
**Backend variants** (v2.11.0+, docs/designs/launcher-multi-build.md):
|
|
1508
|
+
a key may also be ``<base>-<variant>`` to register an additional build of
|
|
1509
|
+
the same backend — typically llama.cpp compiled for a specific GPU
|
|
1510
|
+
runtime. Each variant appears as its own entry in the Launcher's backend
|
|
1511
|
+
select, gets its own ``--list-devices`` probe (device IDs differ per
|
|
1512
|
+
build: ``CUDA0`` and ``Vulkan0`` are not the same GPU) and can carry its
|
|
1513
|
+
own ``option_profiles``. ``binary`` is **required** for a variant::
|
|
1514
|
+
|
|
1515
|
+
backends:
|
|
1516
|
+
llama.cpp: # 素のビルド (binary 省略可 = PATH)
|
|
1517
|
+
binary: ~/llm/apps/llama.cpp/build/bin/llama-server
|
|
1518
|
+
llama.cpp-cuda:
|
|
1519
|
+
binary: ~/llm/apps/llama.cpp/build-cuda/bin/llama-server
|
|
1520
|
+
llama.cpp-vulkan:
|
|
1521
|
+
binary: ~/llm/apps/llama.cpp/build-vulkan/bin/llama-server
|
|
1522
|
+
llama.cpp-rocm:
|
|
1523
|
+
binary: ~/llm/apps/llama.cpp/build-rocm/bin/llama-server
|
|
1524
|
+
|
|
1525
|
+
Variants are an advanced option: the matching GPU runtime (CUDA Toolkit /
|
|
1526
|
+
Vulkan runtime + ICD / ROCm) must already be installed by the operator.
|
|
1527
|
+
CodeRouter never selects a variant implicitly — omit them and both the UI
|
|
1528
|
+
and the resulting argv are identical to before.
|
|
1417
1529
|
"""
|
|
1418
1530
|
|
|
1419
1531
|
model_config = ConfigDict(extra="forbid")
|
|
@@ -1544,9 +1656,39 @@ class SwapModelSpec(BaseModel):
|
|
|
1544
1656
|
"only widens *catalog* matching for ``SwapManager.match``."
|
|
1545
1657
|
),
|
|
1546
1658
|
)
|
|
1547
|
-
backend:
|
|
1548
|
-
...,
|
|
1659
|
+
backend: str = Field(
|
|
1660
|
+
...,
|
|
1661
|
+
description=(
|
|
1662
|
+
"Same backend set as the manual launcher UI / _build_cmd: "
|
|
1663
|
+
"'llama.cpp' / 'vllm' / 'mlx', optionally with a '-<variant>' "
|
|
1664
|
+
"suffix naming a specific build declared in launcher.backends "
|
|
1665
|
+
"(e.g. 'llama.cpp-cuda' to pin this model to the CUDA build). "
|
|
1666
|
+
"Validated by the field validator below plus "
|
|
1667
|
+
"LauncherConfig._check_swap_backends_declared."
|
|
1668
|
+
),
|
|
1549
1669
|
)
|
|
1670
|
+
|
|
1671
|
+
@field_validator("backend")
|
|
1672
|
+
@classmethod
|
|
1673
|
+
def _check_backend_name(cls, v: str) -> str:
|
|
1674
|
+
"""基底名 or 既知基底名 + 妥当なバリアントのみ許す。
|
|
1675
|
+
|
|
1676
|
+
以前は ``Literal["llama.cpp","vllm","mlx"]`` だった。バリアント
|
|
1677
|
+
(``llama.cpp-cuda``) を通すため str に緩めたので、代わりに形式検証を
|
|
1678
|
+
ここで行う。既存の 3 値はそのまま通る (後方互換)。
|
|
1679
|
+
"""
|
|
1680
|
+
from coderouter.launcher_devices import (
|
|
1681
|
+
KNOWN_BASE_BACKENDS,
|
|
1682
|
+
is_valid_backend_name,
|
|
1683
|
+
)
|
|
1684
|
+
|
|
1685
|
+
if not is_valid_backend_name(v):
|
|
1686
|
+
raise ValueError(
|
|
1687
|
+
f"backend {v!r} is not valid. Expected one of "
|
|
1688
|
+
f"{list(KNOWN_BASE_BACKENDS)} or '<base>-<variant>' "
|
|
1689
|
+
"(e.g. 'llama.cpp-cuda')."
|
|
1690
|
+
)
|
|
1691
|
+
return v
|
|
1550
1692
|
model_path: str = Field(
|
|
1551
1693
|
...,
|
|
1552
1694
|
description=(
|
|
@@ -1802,10 +1944,13 @@ class LauncherConfig(BaseModel):
|
|
|
1802
1944
|
default_factory=dict,
|
|
1803
1945
|
description=(
|
|
1804
1946
|
"Per-backend binary path overrides. "
|
|
1805
|
-
"Keys are backend names ('llama.cpp', 'vllm')
|
|
1806
|
-
"
|
|
1807
|
-
"
|
|
1808
|
-
"
|
|
1947
|
+
"Keys are backend names ('llama.cpp', 'vllm', 'mlx') or backend "
|
|
1948
|
+
"variants ('llama.cpp-cuda', 'llama.cpp-vulkan', ...) naming an "
|
|
1949
|
+
"additional build of the same backend — see "
|
|
1950
|
+
"LauncherBackendConfig. When a base key is absent, the default "
|
|
1951
|
+
"executable is used ('llama-server' / 'python') and resolved via "
|
|
1952
|
+
"PATH. Variants must set 'binary' explicitly and additionally "
|
|
1953
|
+
"appear in the Launcher's backend select."
|
|
1809
1954
|
),
|
|
1810
1955
|
)
|
|
1811
1956
|
option_profiles: dict[str, list[LauncherOptionProfile]] = Field(
|
|
@@ -1813,6 +1958,10 @@ class LauncherConfig(BaseModel):
|
|
|
1813
1958
|
description=(
|
|
1814
1959
|
"Named option presets per backend. "
|
|
1815
1960
|
"Keys should be backend names: 'llama.cpp', 'vllm'. "
|
|
1961
|
+
"A backend-variant key ('llama.cpp-cuda') is also accepted: its "
|
|
1962
|
+
"presets are appended to the base backend's list, and a preset "
|
|
1963
|
+
"whose 'name' collides replaces the inherited one in place "
|
|
1964
|
+
"(see launcher_devices.resolve_option_profiles). "
|
|
1816
1965
|
"Each key maps to an ordered list of named presets. "
|
|
1817
1966
|
"A free-form 'extra args' field is always available in the UI "
|
|
1818
1967
|
"for one-off overrides without touching this config."
|
|
@@ -1929,15 +2078,82 @@ class LauncherConfig(BaseModel):
|
|
|
1929
2078
|
)
|
|
1930
2079
|
return self
|
|
1931
2080
|
|
|
2081
|
+
@model_validator(mode="after")
|
|
2082
|
+
def _check_backend_names_and_variant_binaries(self) -> LauncherConfig:
|
|
2083
|
+
"""``backends`` のキー形式と、バリアントの ``binary`` 必須を検証する。
|
|
2084
|
+
|
|
2085
|
+
設計: docs/designs/launcher-multi-build.md §5.2。
|
|
2086
|
+
|
|
2087
|
+
1. キーは既知基底名 (``llama.cpp`` / ``vllm`` / ``mlx``) そのもの、
|
|
2088
|
+
または ``<既知基底名>-<バリアント>`` (``llama.cpp-cuda`` 等) のみ。
|
|
2089
|
+
**破壊的変更**: これまで ``llamacpp:`` のような typo は黙って無視
|
|
2090
|
+
されていた (バックエンド一覧が固定 3 キーだったため)。今後は
|
|
2091
|
+
ロード時エラーになる。
|
|
2092
|
+
2. **バリアントは ``binary`` 必須**。``llama.cpp-cuda: {}`` を許すと
|
|
2093
|
+
既定名フォールバックで PATH の ``llama-server`` が使われ、CUDA
|
|
2094
|
+
ビルドを指定したつもりで素のビルドが静かに動く —— 本機能で最も
|
|
2095
|
+
気づきにくい事故なのでロード時に殺す。基底名は従来どおり
|
|
2096
|
+
``binary`` 省略可 (PATH 解決)。
|
|
2097
|
+
"""
|
|
2098
|
+
from coderouter.launcher_devices import (
|
|
2099
|
+
KNOWN_BASE_BACKENDS,
|
|
2100
|
+
is_valid_backend_name,
|
|
2101
|
+
is_variant,
|
|
2102
|
+
)
|
|
2103
|
+
|
|
2104
|
+
for name, bc in self.backends.items():
|
|
2105
|
+
if not is_valid_backend_name(name):
|
|
2106
|
+
raise ValueError(
|
|
2107
|
+
f"launcher.backends: invalid backend key {name!r}. "
|
|
2108
|
+
f"Expected one of {list(KNOWN_BASE_BACKENDS)} or "
|
|
2109
|
+
"'<base>-<variant>' where variant matches "
|
|
2110
|
+
"[a-z0-9][a-z0-9._-]* (e.g. 'llama.cpp-cuda')."
|
|
2111
|
+
)
|
|
2112
|
+
if is_variant(name) and not bc.binary:
|
|
2113
|
+
raise ValueError(
|
|
2114
|
+
f"launcher.backends[{name!r}]: 'binary' is required for a "
|
|
2115
|
+
"backend variant. Without it the launcher would fall back "
|
|
2116
|
+
"to the base default on PATH and silently run a different "
|
|
2117
|
+
"build than the one you selected."
|
|
2118
|
+
)
|
|
2119
|
+
return self
|
|
2120
|
+
|
|
2121
|
+
@model_validator(mode="after")
|
|
2122
|
+
def _check_swap_backends_declared(self) -> LauncherConfig:
|
|
2123
|
+
"""swap のモデルがバリアントを指すなら ``backends`` に実在すること。
|
|
2124
|
+
|
|
2125
|
+
設計 §5.2-3。基底名 (``llama.cpp`` 等) は ``backends`` に書かなくても
|
|
2126
|
+
PATH 解決で動くので従来どおり不要。バリアントは実行ファイルパスが
|
|
2127
|
+
``backends`` にしか無いので、宣言漏れをロード時に弾く。
|
|
2128
|
+
"""
|
|
2129
|
+
if self.swap is None:
|
|
2130
|
+
return self
|
|
2131
|
+
from coderouter.launcher_devices import is_variant
|
|
2132
|
+
|
|
2133
|
+
for spec in self.swap.models:
|
|
2134
|
+
if is_variant(spec.backend) and spec.backend not in self.backends:
|
|
2135
|
+
raise ValueError(
|
|
2136
|
+
f"launcher.swap.models[{spec.name!r}]: backend "
|
|
2137
|
+
f"{spec.backend!r} is a variant but is not declared in "
|
|
2138
|
+
"launcher.backends (its binary path is unknown)."
|
|
2139
|
+
)
|
|
2140
|
+
return self
|
|
2141
|
+
|
|
1932
2142
|
@model_validator(mode="after")
|
|
1933
2143
|
def _check_swap_option_profiles_exist(self) -> LauncherConfig:
|
|
1934
|
-
"""§5.4 #2: a swap model's ``option_profile`` must be a real preset.
|
|
2144
|
+
"""§5.4 #2: a swap model's ``option_profile`` must be a real preset.
|
|
2145
|
+
|
|
2146
|
+
バリアント backend の場合は基底名のプロファイルも継承されるので
|
|
2147
|
+
(:func:`resolve_option_profiles`)、マージ後の一覧で判定する。
|
|
2148
|
+
"""
|
|
1935
2149
|
if self.swap is None:
|
|
1936
2150
|
return self
|
|
2151
|
+
from coderouter.launcher_devices import resolve_option_profiles
|
|
2152
|
+
|
|
1937
2153
|
for spec in self.swap.models:
|
|
1938
2154
|
if spec.option_profile is None:
|
|
1939
2155
|
continue
|
|
1940
|
-
profiles = self.option_profiles
|
|
2156
|
+
profiles = resolve_option_profiles(self.option_profiles, spec.backend)
|
|
1941
2157
|
if not any(p.name == spec.option_profile for p in profiles):
|
|
1942
2158
|
raise ValueError(
|
|
1943
2159
|
f"launcher.swap.models[{spec.name!r}]: option_profile "
|