coderouter-cli 2.12.0__py3-none-any.whl → 2.13.0__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/cli.py +20 -21
- coderouter/config/loader.py +133 -34
- coderouter/config/schemas.py +93 -47
- coderouter/guards/self_healing.py +58 -3
- coderouter/ingress/app.py +72 -29
- coderouter/ingress/launcher_routes.py +237 -61
- coderouter/launcher_devices.py +11 -4
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.13.0.dist-info}/METADATA +1 -1
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.13.0.dist-info}/RECORD +12 -12
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.13.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.13.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.13.0.dist-info}/licenses/LICENSE +0 -0
coderouter/cli.py
CHANGED
|
@@ -600,28 +600,27 @@ def _run_check_model(args: argparse.Namespace) -> int:
|
|
|
600
600
|
|
|
601
601
|
|
|
602
602
|
def _resolve_config_path(explicit: str | None) -> Path:
|
|
603
|
-
"""
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
603
|
+
"""Return the file the loader actually read, for ``--apply`` write-back.
|
|
604
|
+
|
|
605
|
+
Delegates the search order to
|
|
606
|
+
:func:`coderouter.config.loader.resolve_config_path` rather than
|
|
607
|
+
re-implementing it. Re-implementing it here is exactly how the
|
|
608
|
+
v2.13.0 CWD opt-in change would introduce a silent bug: if the loader
|
|
609
|
+
gates ``./providers.yaml`` behind ``CODEROUTER_ALLOW_CWD_CONFIG`` but
|
|
610
|
+
this copy still searched CWD unconditionally, ``doctor --apply`` would
|
|
611
|
+
write patches back into a file that ``load_config`` never read. Keeping
|
|
612
|
+
one source of truth makes that impossible.
|
|
613
|
+
|
|
614
|
+
When nothing exists, falls back to ``~/.coderouter/providers.yaml``
|
|
615
|
+
(the loader's last candidate) — the apply step surfaces a clearer
|
|
616
|
+
error against that path than this resolver would.
|
|
609
617
|
"""
|
|
610
|
-
import
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
if
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
candidates.append(Path(env_path))
|
|
617
|
-
candidates.append(Path.cwd() / "providers.yaml")
|
|
618
|
-
candidates.append(Path.home() / ".coderouter" / "providers.yaml")
|
|
619
|
-
for p in candidates:
|
|
620
|
-
if p.is_file():
|
|
621
|
-
return p
|
|
622
|
-
# Fall back to the last candidate even if absent — the apply step
|
|
623
|
-
# will surface a clearer error than this resolver would.
|
|
624
|
-
return candidates[-1]
|
|
618
|
+
from coderouter.config.loader import resolve_config_path
|
|
619
|
+
|
|
620
|
+
resolved = resolve_config_path(explicit)
|
|
621
|
+
if resolved is not None:
|
|
622
|
+
return resolved
|
|
623
|
+
return Path.home() / ".coderouter" / "providers.yaml"
|
|
625
624
|
|
|
626
625
|
|
|
627
626
|
def _write_verdict_line(*, write: bool, files_written: int) -> None:
|
coderouter/config/loader.py
CHANGED
|
@@ -3,21 +3,26 @@
|
|
|
3
3
|
Search order (first hit wins):
|
|
4
4
|
1. Path passed explicitly (CLI --config flag)
|
|
5
5
|
2. $CODEROUTER_CONFIG env var
|
|
6
|
-
3. ./providers.yaml (current working dir)
|
|
6
|
+
3. ./providers.yaml (current working dir) — opt-in since v2.13.0,
|
|
7
|
+
gated behind ``CODEROUTER_ALLOW_CWD_CONFIG`` (truthy: 1/true/yes/on)
|
|
7
8
|
4. ~/.coderouter/providers.yaml
|
|
8
9
|
|
|
9
10
|
Secrets are resolved by reading the env var named by `api_key_env`.
|
|
10
11
|
|
|
11
12
|
.. note::
|
|
12
|
-
**
|
|
13
|
-
|
|
14
|
-
``
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
**v2.13.0 (security):** step 3 above (implicit CWD discovery) is now
|
|
14
|
+
opt-in, gated behind ``CODEROUTER_ALLOW_CWD_CONFIG``. It was a code
|
|
15
|
+
execution vector: a hostile ``providers.yaml`` dropped into a repo
|
|
16
|
+
could steer ``restart_command`` / ``launcher.backends[*].binary`` /
|
|
17
|
+
``launcher.bench.command_template`` — all of which name executables —
|
|
18
|
+
simply because CodeRouter happened to be started from that directory.
|
|
19
|
+
When the opt-in is enabled and step 3 resolves the config, a one-time
|
|
20
|
+
``cwd-config-loaded`` warning fires (never when an explicit
|
|
21
|
+
``--config`` / ``CODEROUTER_CONFIG`` path was used, even if it happens
|
|
22
|
+
to point at the same file). When the opt-in is NOT set but a
|
|
23
|
+
``./providers.yaml`` exists and was not explicitly named, a one-time
|
|
24
|
+
``cwd-config-skipped`` warning fires so the operator understands why
|
|
25
|
+
that file was ignored.
|
|
21
26
|
"""
|
|
22
27
|
|
|
23
28
|
from __future__ import annotations
|
|
@@ -32,10 +37,25 @@ from coderouter.logging import get_logger
|
|
|
32
37
|
|
|
33
38
|
logger = get_logger(__name__)
|
|
34
39
|
|
|
35
|
-
#
|
|
36
|
-
# (see module docstring)
|
|
37
|
-
|
|
40
|
+
# Name of the env var that opts CWD ``providers.yaml`` discovery back in
|
|
41
|
+
# (see module docstring). v2.13.0 gates step 3 of the search behind it.
|
|
42
|
+
CWD_CONFIG_ENV = "CODEROUTER_ALLOW_CWD_CONFIG"
|
|
43
|
+
# Truthy vocabulary shared by every CWD-opt-in / ALLOW_PAID style toggle.
|
|
44
|
+
_TRUTHY = {"1", "true", "yes", "on"}
|
|
45
|
+
|
|
46
|
+
# Guards the one-time ``cwd-config-loaded`` warning (opt-in enabled and the
|
|
47
|
+
# CWD step actually resolved) so a process that calls load_config()
|
|
48
|
+
# repeatedly (tests, hot-reload, multiple create_app() calls) only logs it
|
|
49
|
+
# once.
|
|
38
50
|
_cwd_config_warning_emitted = False
|
|
51
|
+
# Guards the one-time ``cwd-config-skipped`` warning (opt-in NOT set but a
|
|
52
|
+
# ./providers.yaml exists and was ignored) — same once-per-process rationale.
|
|
53
|
+
_cwd_config_skip_warning_emitted = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cwd_config_allowed() -> bool:
|
|
57
|
+
"""True iff the CWD ``providers.yaml`` opt-in is enabled (v2.13.0)."""
|
|
58
|
+
return os.environ.get(CWD_CONFIG_ENV, "").strip().lower() in _TRUTHY
|
|
39
59
|
|
|
40
60
|
|
|
41
61
|
def _candidate_paths(explicit: str | os.PathLike[str] | None) -> list[Path]:
|
|
@@ -44,27 +64,58 @@ def _candidate_paths(explicit: str | os.PathLike[str] | None) -> list[Path]:
|
|
|
44
64
|
paths.append(Path(explicit))
|
|
45
65
|
if env_path := os.environ.get("CODEROUTER_CONFIG"):
|
|
46
66
|
paths.append(Path(env_path))
|
|
47
|
-
|
|
67
|
+
# v2.13.0: implicit CWD discovery is opt-in — a hostile providers.yaml
|
|
68
|
+
# dropped into a repo could otherwise steer restart_command / launcher
|
|
69
|
+
# binaries simply because CodeRouter was started from that directory.
|
|
70
|
+
if cwd_config_allowed():
|
|
71
|
+
paths.append(Path.cwd() / "providers.yaml")
|
|
48
72
|
paths.append(Path.home() / ".coderouter" / "providers.yaml")
|
|
49
73
|
return paths
|
|
50
74
|
|
|
51
75
|
|
|
76
|
+
def resolve_config_path(
|
|
77
|
+
explicit: str | os.PathLike[str] | None = None,
|
|
78
|
+
) -> Path | None:
|
|
79
|
+
"""Return the config file the loader would pick, or None if none exists.
|
|
80
|
+
|
|
81
|
+
Single source of truth for the search order so callers (e.g.
|
|
82
|
+
``coderouter.cli._resolve_config_path`` driving ``doctor --apply``)
|
|
83
|
+
never re-implement it. Re-implementing it risks writing back to a
|
|
84
|
+
different file than the one :func:`load_config` actually read — the
|
|
85
|
+
exact regression the v2.13.0 CWD opt-in would otherwise introduce.
|
|
86
|
+
"""
|
|
87
|
+
return next((p for p in _candidate_paths(explicit) if p.is_file()), None)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _explicitly_named(
|
|
91
|
+
path: Path, explicit: str | os.PathLike[str] | None
|
|
92
|
+
) -> bool:
|
|
93
|
+
"""True iff ``path`` matches the literal --config or CODEROUTER_CONFIG value.
|
|
94
|
+
|
|
95
|
+
Used to keep the CWD warnings quiet when ``./providers.yaml`` was
|
|
96
|
+
chosen on purpose (an explicit choice, however coincidental, is not
|
|
97
|
+
the implicit-discovery behaviour being gated).
|
|
98
|
+
"""
|
|
99
|
+
if explicit and Path(explicit) == path:
|
|
100
|
+
return True
|
|
101
|
+
env_path = os.environ.get("CODEROUTER_CONFIG")
|
|
102
|
+
return env_path is not None and Path(env_path) == path
|
|
103
|
+
|
|
104
|
+
|
|
52
105
|
def _warn_if_cwd_config(
|
|
53
106
|
chosen: Path, *, explicit: str | os.PathLike[str] | None
|
|
54
107
|
) -> None:
|
|
55
|
-
"""Emit the one-time
|
|
108
|
+
"""Emit the one-time ``cwd-config-loaded`` warning (opt-in is enabled).
|
|
56
109
|
|
|
57
110
|
Only fires when ``chosen`` was resolved via the *implicit* CWD-search
|
|
58
|
-
step
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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.
|
|
111
|
+
step — which, since v2.13.0, only runs when
|
|
112
|
+
``CODEROUTER_ALLOW_CWD_CONFIG`` is set. Compares ``chosen`` against the
|
|
113
|
+
*literal* explicit / env candidate paths (not merely whether those
|
|
114
|
+
inputs were set) so that an explicit ``--config`` (or
|
|
115
|
+
``CODEROUTER_CONFIG``) path that happens to coincide with
|
|
116
|
+
``./providers.yaml`` stays quiet, while an explicit path/env var that
|
|
117
|
+
was set but did NOT resolve (file missing, search fell through to CWD)
|
|
118
|
+
still warns — the CWD step is what actually served the config there.
|
|
68
119
|
"""
|
|
69
120
|
global _cwd_config_warning_emitted
|
|
70
121
|
if _cwd_config_warning_emitted:
|
|
@@ -72,9 +123,7 @@ def _warn_if_cwd_config(
|
|
|
72
123
|
cwd_path = Path.cwd() / "providers.yaml"
|
|
73
124
|
if chosen != cwd_path:
|
|
74
125
|
return
|
|
75
|
-
if
|
|
76
|
-
return
|
|
77
|
-
if (env_path := os.environ.get("CODEROUTER_CONFIG")) and Path(env_path) == chosen:
|
|
126
|
+
if _explicitly_named(chosen, explicit):
|
|
78
127
|
return
|
|
79
128
|
|
|
80
129
|
_cwd_config_warning_emitted = True
|
|
@@ -84,26 +133,76 @@ def _warn_if_cwd_config(
|
|
|
84
133
|
"path": str(chosen),
|
|
85
134
|
"hint": (
|
|
86
135
|
f"loaded providers.yaml from the current working "
|
|
87
|
-
f"directory ({chosen})
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
"
|
|
136
|
+
f"directory ({chosen}) because CODEROUTER_ALLOW_CWD_CONFIG "
|
|
137
|
+
"opt-in is enabled. This file's restart_command / "
|
|
138
|
+
"launcher.backends[*].binary / "
|
|
139
|
+
"launcher.bench.command_template decide which executables "
|
|
140
|
+
"run — only enable the opt-in in directories you trust."
|
|
92
141
|
),
|
|
93
142
|
},
|
|
94
143
|
)
|
|
95
144
|
|
|
96
145
|
|
|
146
|
+
def _warn_if_cwd_config_skipped(
|
|
147
|
+
explicit: str | os.PathLike[str] | None,
|
|
148
|
+
) -> Path | None:
|
|
149
|
+
"""Warn once when a ./providers.yaml exists but was skipped (no opt-in).
|
|
150
|
+
|
|
151
|
+
Returns the skipped CWD path (so :func:`load_config` can name it in a
|
|
152
|
+
later ``FileNotFoundError``), or None when there is nothing to skip:
|
|
153
|
+
the opt-in is enabled, no ``./providers.yaml`` exists, or the file was
|
|
154
|
+
explicitly named (``--config`` / ``CODEROUTER_CONFIG``) — in which case
|
|
155
|
+
it is not being skipped at all.
|
|
156
|
+
"""
|
|
157
|
+
global _cwd_config_skip_warning_emitted
|
|
158
|
+
if cwd_config_allowed():
|
|
159
|
+
return None
|
|
160
|
+
cwd_path = Path.cwd() / "providers.yaml"
|
|
161
|
+
if not cwd_path.is_file():
|
|
162
|
+
return None
|
|
163
|
+
if _explicitly_named(cwd_path, explicit):
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
if not _cwd_config_skip_warning_emitted:
|
|
167
|
+
_cwd_config_skip_warning_emitted = True
|
|
168
|
+
logger.warning(
|
|
169
|
+
"cwd-config-skipped",
|
|
170
|
+
extra={
|
|
171
|
+
"path": str(cwd_path),
|
|
172
|
+
"hint": (
|
|
173
|
+
f"found providers.yaml in the current working "
|
|
174
|
+
f"directory ({cwd_path}) but did NOT load it: implicit "
|
|
175
|
+
"CWD discovery is opt-in since v2.13.0 (a hostile "
|
|
176
|
+
"providers.yaml can steer restart_command / launcher "
|
|
177
|
+
"binaries). To use it, set CODEROUTER_ALLOW_CWD_CONFIG=1 "
|
|
178
|
+
"(only in directories you trust) or pass --config "
|
|
179
|
+
f"{cwd_path} to name it explicitly."
|
|
180
|
+
),
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
return cwd_path
|
|
184
|
+
|
|
185
|
+
|
|
97
186
|
def load_config(path: str | os.PathLike[str] | None = None) -> CodeRouterConfig:
|
|
98
187
|
"""Load providers.yaml + apply ALLOW_PAID env override."""
|
|
188
|
+
skipped_cwd = _warn_if_cwd_config_skipped(path)
|
|
99
189
|
candidates = _candidate_paths(path)
|
|
100
190
|
chosen: Path | None = next((p for p in candidates if p.is_file()), None)
|
|
101
191
|
if chosen is None:
|
|
102
192
|
searched = "\n ".join(str(p) for p in candidates)
|
|
103
|
-
|
|
193
|
+
message = (
|
|
104
194
|
f"providers.yaml not found. Searched:\n {searched}\n"
|
|
105
195
|
f"Hint: copy examples/providers.yaml to ~/.coderouter/providers.yaml"
|
|
106
196
|
)
|
|
197
|
+
if skipped_cwd is not None:
|
|
198
|
+
message += (
|
|
199
|
+
f"\nNote: {skipped_cwd} exists but was NOT read — implicit "
|
|
200
|
+
"CWD discovery is opt-in since v2.13.0. Three ways to use "
|
|
201
|
+
f"it: pass --config {skipped_cwd}, set "
|
|
202
|
+
f"CODEROUTER_CONFIG={skipped_cwd}, or set "
|
|
203
|
+
"CODEROUTER_ALLOW_CWD_CONFIG=1 (only in directories you trust)."
|
|
204
|
+
)
|
|
205
|
+
raise FileNotFoundError(message)
|
|
107
206
|
_warn_if_cwd_config(chosen, explicit=path)
|
|
108
207
|
|
|
109
208
|
with chosen.open("r", encoding="utf-8") as f:
|
coderouter/config/schemas.py
CHANGED
|
@@ -355,15 +355,20 @@ class AgentCliConfig(BaseModel):
|
|
|
355
355
|
return self
|
|
356
356
|
|
|
357
357
|
|
|
358
|
-
#
|
|
358
|
+
# v2.13.0: patterns used by ProviderConfig's
|
|
359
359
|
# ``_warn_restart_command_shell_syntax`` validator to flag
|
|
360
|
-
# ``restart_command`` values that rely on shell syntax not honored by
|
|
361
|
-
#
|
|
362
|
-
#
|
|
363
|
-
#
|
|
364
|
-
#
|
|
365
|
-
|
|
366
|
-
|
|
360
|
+
# ``restart_command`` values that rely on shell syntax not honored by the
|
|
361
|
+
# ``shlex.split`` + ``shell=False`` dispatch (see that validator's
|
|
362
|
+
# docstring and the field description below for the full rationale).
|
|
363
|
+
# Public (no leading underscore) because ``coderouter.guards.self_healing``
|
|
364
|
+
# imports ``RESTART_COMMAND_SHELL_META_RE`` to *refuse* to run any
|
|
365
|
+
# restart_command containing shell metacharacters — under shell=False a
|
|
366
|
+
# value like ``touch a; b`` would create a file literally named ``a;`` and
|
|
367
|
+
# exit 0, i.e. report a "successful" restart while only half-executing.
|
|
368
|
+
# Compiled once at module scope since every ProviderConfig validation
|
|
369
|
+
# reuses them.
|
|
370
|
+
RESTART_COMMAND_SHELL_META_RE = re.compile(r"(&&|\|\||\||;|>|<|\$\(|`)")
|
|
371
|
+
RESTART_COMMAND_ENV_PREFIX_RE = re.compile(r"^\w+=")
|
|
367
372
|
|
|
368
373
|
|
|
369
374
|
class ProviderConfig(BaseModel):
|
|
@@ -520,54 +525,56 @@ class ProviderConfig(BaseModel):
|
|
|
520
525
|
"transitions to UNHEALTHY. Unset = no automatic restart "
|
|
521
526
|
"(recovery probe still runs, waiting for manual restart). "
|
|
522
527
|
"\n\n"
|
|
523
|
-
"
|
|
524
|
-
"
|
|
525
|
-
"
|
|
526
|
-
"(``pkill ollama && ollama serve``),
|
|
527
|
-
"leading env-var assignments
|
|
528
|
-
"serve``) are
|
|
529
|
-
"
|
|
530
|
-
"
|
|
531
|
-
"
|
|
532
|
-
"
|
|
533
|
-
"
|
|
534
|
-
"
|
|
535
|
-
"
|
|
536
|
-
"
|
|
528
|
+
"v2.13.0: run as ``subprocess.run(shlex.split(command), "
|
|
529
|
+
"shell=False)`` (coderouter/guards/self_healing.py) — argv "
|
|
530
|
+
"dispatch, no shell in between. Pipelines "
|
|
531
|
+
"(``pkill ollama && ollama serve``), redirects, ``~`` "
|
|
532
|
+
"expansion, and leading env-var assignments "
|
|
533
|
+
"(``OLLAMA_HOST=0.0.0.0 ollama serve``) are NOT supported: "
|
|
534
|
+
"``~/...`` paths are not expanded and ``FOO=bar cmd`` fails to "
|
|
535
|
+
"exec. A value containing shell metacharacters (``&&``, ``||``, "
|
|
536
|
+
"``|``, ``;``, ``>``, ``<``, ``$(...)``, ```...```) is refused "
|
|
537
|
+
"outright (not run) rather than half-executed. If your command "
|
|
538
|
+
"needs shell features, wrap it in a small script and point "
|
|
539
|
+
"restart_command at that script (e.g. "
|
|
540
|
+
"``restart_command: /path/to/restart-ollama.sh``), or write it "
|
|
541
|
+
"as ``/bin/sh -c '...'`` explicitly."
|
|
537
542
|
),
|
|
538
543
|
)
|
|
539
544
|
|
|
540
545
|
@model_validator(mode="after")
|
|
541
546
|
def _warn_restart_command_shell_syntax(self) -> ProviderConfig:
|
|
542
|
-
"""
|
|
543
|
-
|
|
544
|
-
``coderouter/guards/self_healing.py``
|
|
545
|
-
``
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
547
|
+
"""v2.13.0: warn that this ``restart_command`` will not run as written.
|
|
548
|
+
|
|
549
|
+
``coderouter/guards/self_healing.py`` runs ``restart_command`` via
|
|
550
|
+
``subprocess.run(shlex.split(command), shell=False)`` — argv
|
|
551
|
+
dispatch, no shell. A shell-syntax ``restart_command`` therefore
|
|
552
|
+
does NOT do what the shell would do:
|
|
553
|
+
|
|
554
|
+
* shell metacharacters (``&&`` / ``||`` / ``|`` / ``;`` /
|
|
555
|
+
redirects / ``$(...)`` / backticks) → the value is *refused*
|
|
556
|
+
(not run) by self_healing, because under shell=False they would
|
|
557
|
+
otherwise become literal argv tokens and half-execute a
|
|
558
|
+
provider "restart" that quietly does the wrong thing;
|
|
559
|
+
* a leading ``~/...`` path or an ``ENV=val cmd`` prefix →
|
|
560
|
+
``exec`` fails (``FileNotFoundError``) because there is no shell
|
|
561
|
+
to expand ``~`` or apply the assignment.
|
|
554
562
|
|
|
555
563
|
Deliberately never raises: this validator does not enforce
|
|
556
|
-
anything, it only warns
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
the next time a provider actually goes UNHEALTHY.
|
|
564
|
+
anything, it only warns so an existing providers.yaml still loads
|
|
565
|
+
(rather than becoming un-startable). Migrate ``restart_command`` to
|
|
566
|
+
a plain argv, a wrapper script, or an explicit ``/bin/sh -c '...'``.
|
|
560
567
|
"""
|
|
561
568
|
cmd = self.restart_command
|
|
562
569
|
if not cmd:
|
|
563
570
|
return self
|
|
564
571
|
stripped = cmd.strip()
|
|
565
572
|
reasons: list[str] = []
|
|
566
|
-
if
|
|
573
|
+
if RESTART_COMMAND_SHELL_META_RE.search(cmd):
|
|
567
574
|
reasons.append("shell metacharacter (&&, ||, |, ;, >, <, $(...), or `...`)")
|
|
568
575
|
if stripped.startswith("~/"):
|
|
569
576
|
reasons.append("leading '~/' path (no shell expansion under shell=False)")
|
|
570
|
-
if
|
|
577
|
+
if RESTART_COMMAND_ENV_PREFIX_RE.match(stripped):
|
|
571
578
|
reasons.append("leading environment-variable assignment (FOO=bar ...)")
|
|
572
579
|
if reasons:
|
|
573
580
|
logger.warning(
|
|
@@ -577,13 +584,13 @@ class ProviderConfig(BaseModel):
|
|
|
577
584
|
"restart_command": cmd,
|
|
578
585
|
"reasons": reasons,
|
|
579
586
|
"hint": (
|
|
580
|
-
"restart_command
|
|
581
|
-
"subprocess.run(
|
|
582
|
-
"v2.
|
|
583
|
-
"shell
|
|
584
|
-
"
|
|
585
|
-
"
|
|
586
|
-
"
|
|
587
|
+
"restart_command runs via "
|
|
588
|
+
"subprocess.run(shlex.split(...), shell=False) as of "
|
|
589
|
+
"v2.13.0, which does not support this command's "
|
|
590
|
+
"shell syntax: values with shell metacharacters are "
|
|
591
|
+
"refused (not run), and '~/' / 'FOO=bar' prefixes "
|
|
592
|
+
"fail to exec. Rewrite it as plain argv, wrap it in "
|
|
593
|
+
"a script, or use /bin/sh -c '...'."
|
|
587
594
|
),
|
|
588
595
|
},
|
|
589
596
|
)
|
|
@@ -1573,6 +1580,28 @@ class LauncherOptionProfile(BaseModel):
|
|
|
1573
1580
|
)
|
|
1574
1581
|
|
|
1575
1582
|
|
|
1583
|
+
class LauncherBenchPreset(BaseModel):
|
|
1584
|
+
"""launcher.bench.presets[<key>] の1エントリ。H-2: スイープAPIが受け付けるのはキー名だけ。"""
|
|
1585
|
+
|
|
1586
|
+
model_config = ConfigDict(extra="forbid")
|
|
1587
|
+
name: str = Field(..., min_length=1, description="Launcher UI に出す表示名。")
|
|
1588
|
+
command_template: str = Field(
|
|
1589
|
+
...,
|
|
1590
|
+
min_length=1,
|
|
1591
|
+
description="外部ベンチコマンドのテンプレ。{port} {config} {base_url} {results_dir} {runs} を単純置換で展開。",
|
|
1592
|
+
)
|
|
1593
|
+
runs: int | None = Field(
|
|
1594
|
+
default=None,
|
|
1595
|
+
ge=1,
|
|
1596
|
+
le=1000,
|
|
1597
|
+
description="このプリセット固有の実行回数。None なら launcher.bench.runs。",
|
|
1598
|
+
)
|
|
1599
|
+
results_dir: str | None = Field(
|
|
1600
|
+
default=None,
|
|
1601
|
+
description="このプリセット固有の results ディレクトリ。None なら launcher.bench.results_dir。リクエストからは指定できない。",
|
|
1602
|
+
)
|
|
1603
|
+
|
|
1604
|
+
|
|
1576
1605
|
class LauncherBenchConfig(BaseModel):
|
|
1577
1606
|
"""The ``launcher.bench:`` block — defaults for the bench sweep feature.
|
|
1578
1607
|
|
|
@@ -1614,6 +1643,23 @@ class LauncherBenchConfig(BaseModel):
|
|
|
1614
1643
|
"大きな GGUF ロードを考慮した既定 5 分。"
|
|
1615
1644
|
),
|
|
1616
1645
|
)
|
|
1646
|
+
presets: dict[str, LauncherBenchPreset] = Field(
|
|
1647
|
+
default_factory=dict,
|
|
1648
|
+
description="名前付きベンチコマンド。キーが POST /api/launcher/sweep/start の bench_preset が取りうる唯一の値。",
|
|
1649
|
+
)
|
|
1650
|
+
default_preset: str | None = Field(
|
|
1651
|
+
default=None,
|
|
1652
|
+
description="bench_preset 未指定時に使うプリセットキー。None なら command_template 由来の暗黙 default。",
|
|
1653
|
+
)
|
|
1654
|
+
|
|
1655
|
+
@model_validator(mode="after")
|
|
1656
|
+
def _check_default_preset(self) -> Self:
|
|
1657
|
+
if self.default_preset is not None and self.default_preset not in self.presets:
|
|
1658
|
+
raise ValueError(
|
|
1659
|
+
f"launcher.bench.default_preset={self.default_preset!r} is not a key of "
|
|
1660
|
+
f"launcher.bench.presets (known: {sorted(self.presets) or 'none'})"
|
|
1661
|
+
)
|
|
1662
|
+
return self
|
|
1617
1663
|
|
|
1618
1664
|
|
|
1619
1665
|
class SwapModelSpec(BaseModel):
|
|
@@ -50,6 +50,14 @@ Design choices
|
|
|
50
50
|
are guarded independently.
|
|
51
51
|
- **No new dependency** — subprocess (stdlib) for restart commands,
|
|
52
52
|
asyncio for recovery probe scheduling.
|
|
53
|
+
- **v2.13.0: argv dispatch (shell=False)** — ``restart_command`` is run
|
|
54
|
+
as ``subprocess.run(shlex.split(command), shell=False)``, never through
|
|
55
|
+
a shell. A value containing shell metacharacters is refused outright
|
|
56
|
+
(not run), because under shell=False those tokens would half-execute
|
|
57
|
+
the command (e.g. ``touch a; b`` would create a file named ``a;`` and
|
|
58
|
+
exit 0, falsely reporting a successful restart). This closes the H-1
|
|
59
|
+
code-execution vector where a hostile CWD providers.yaml could drive
|
|
60
|
+
arbitrary shell through self-healing.
|
|
53
61
|
- **Restart is opt-in** — only providers with ``restart_command``
|
|
54
62
|
set get automatic restart. Others rely solely on recovery probes
|
|
55
63
|
(waiting for manual restart by the operator).
|
|
@@ -62,12 +70,14 @@ from __future__ import annotations
|
|
|
62
70
|
|
|
63
71
|
import asyncio
|
|
64
72
|
import contextlib
|
|
73
|
+
import os
|
|
74
|
+
import shlex
|
|
65
75
|
import subprocess
|
|
66
76
|
import threading
|
|
67
77
|
import time
|
|
68
78
|
from dataclasses import dataclass
|
|
69
79
|
|
|
70
|
-
from coderouter.config.schemas import ProviderConfig
|
|
80
|
+
from coderouter.config.schemas import RESTART_COMMAND_SHELL_META_RE, ProviderConfig
|
|
71
81
|
from coderouter.logging import (
|
|
72
82
|
get_logger,
|
|
73
83
|
log_self_healing_exclude,
|
|
@@ -268,6 +278,51 @@ class SelfHealingOrchestrator:
|
|
|
268
278
|
|
|
269
279
|
provider = provider_config.name
|
|
270
280
|
|
|
281
|
+
# v2.13.0: argv dispatch (shell=False). We validate/parse the command
|
|
282
|
+
# BEFORE taking the per-provider restart lock so a malformed value
|
|
283
|
+
# short-circuits without ever touching the lock (and a later valid
|
|
284
|
+
# attempt is unaffected).
|
|
285
|
+
#
|
|
286
|
+
# A-5: refuse anything with shell metacharacters. Under shell=False,
|
|
287
|
+
# ``touch a; b`` would create a file literally named ``a;`` and exit
|
|
288
|
+
# 0 — reporting a "successful" restart while only half-executing the
|
|
289
|
+
# intended pipeline. Refusing loudly beats half-running silently.
|
|
290
|
+
if RESTART_COMMAND_SHELL_META_RE.search(command):
|
|
291
|
+
log_self_healing_restart(
|
|
292
|
+
logger,
|
|
293
|
+
provider=provider,
|
|
294
|
+
command=command,
|
|
295
|
+
success=False,
|
|
296
|
+
error=(
|
|
297
|
+
"restart_command contains shell syntax but is no longer "
|
|
298
|
+
"run through a shell (v2.13.0). Wrap it in a script and "
|
|
299
|
+
"point restart_command at that script, or write it as "
|
|
300
|
+
"/bin/sh -c '...'."
|
|
301
|
+
),
|
|
302
|
+
)
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
argv = shlex.split(command, posix=(os.name != "nt"))
|
|
307
|
+
except ValueError as exc:
|
|
308
|
+
log_self_healing_restart(
|
|
309
|
+
logger,
|
|
310
|
+
provider=provider,
|
|
311
|
+
command=command,
|
|
312
|
+
success=False,
|
|
313
|
+
error=f"unparsable restart_command: {exc}",
|
|
314
|
+
)
|
|
315
|
+
return False
|
|
316
|
+
if not argv:
|
|
317
|
+
log_self_healing_restart(
|
|
318
|
+
logger,
|
|
319
|
+
provider=provider,
|
|
320
|
+
command=command,
|
|
321
|
+
success=False,
|
|
322
|
+
error="restart_command is empty after parsing",
|
|
323
|
+
)
|
|
324
|
+
return False
|
|
325
|
+
|
|
271
326
|
# Get or create a per-provider lock.
|
|
272
327
|
with self._lock:
|
|
273
328
|
if provider not in self._restart_locks:
|
|
@@ -281,8 +336,8 @@ class SelfHealingOrchestrator:
|
|
|
281
336
|
|
|
282
337
|
try:
|
|
283
338
|
result = subprocess.run(
|
|
284
|
-
|
|
285
|
-
shell=
|
|
339
|
+
argv,
|
|
340
|
+
shell=False,
|
|
286
341
|
capture_output=True,
|
|
287
342
|
timeout=timeout_s,
|
|
288
343
|
text=True,
|
coderouter/ingress/app.py
CHANGED
|
@@ -10,7 +10,7 @@ from contextlib import asynccontextmanager
|
|
|
10
10
|
from fastapi import FastAPI, Request
|
|
11
11
|
from fastapi.responses import JSONResponse
|
|
12
12
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
13
|
-
from starlette.types import ASGIApp
|
|
13
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
14
14
|
|
|
15
15
|
from coderouter import __version__
|
|
16
16
|
from coderouter.config import load_config
|
|
@@ -101,37 +101,78 @@ def _parse_max_body_bytes(raw: str | None) -> int:
|
|
|
101
101
|
return value if value > 0 else _DEFAULT_MAX_BODY_BYTES
|
|
102
102
|
|
|
103
103
|
|
|
104
|
-
class
|
|
105
|
-
|
|
104
|
+
class _BodyTooLarge(Exception):
|
|
105
|
+
pass
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
|
|
108
|
+
class BodySizeLimitMiddleware:
|
|
109
|
+
"""Reject requests whose body exceeds the configured cap.
|
|
110
|
+
|
|
111
|
+
M-3: this is a **pure ASGI** middleware, not a ``BaseHTTPMiddleware``.
|
|
112
|
+
The old dispatch only checked the ``Content-Length`` header, so a chunked
|
|
113
|
+
request (no Content-Length) bypassed the cap entirely. We now also count
|
|
114
|
+
the bytes actually received and fail closed with 413 the moment the total
|
|
115
|
+
crosses the limit. It must be pure ASGI because a ``BaseHTTPMiddleware``
|
|
116
|
+
that consumes ``request.stream()`` to measure the body would hand a
|
|
117
|
+
drained (empty) body to the downstream route.
|
|
118
|
+
|
|
119
|
+
Streaming *responses* (SSE) are unaffected — this only inspects the
|
|
120
|
+
request side.
|
|
111
121
|
"""
|
|
112
122
|
|
|
113
123
|
def __init__(self, app: ASGIApp, max_bytes: int) -> None:
|
|
114
|
-
|
|
124
|
+
self.app = app
|
|
115
125
|
self._max_bytes = max_bytes
|
|
116
126
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if declared > self._max_bytes:
|
|
125
|
-
return JSONResponse(
|
|
126
|
-
status_code=413,
|
|
127
|
-
content={
|
|
128
|
-
"detail": (
|
|
129
|
-
f"Request body too large: {declared} bytes exceeds "
|
|
130
|
-
f"the {self._max_bytes}-byte limit."
|
|
131
|
-
)
|
|
132
|
-
},
|
|
127
|
+
def _too_large(self, observed: int) -> JSONResponse:
|
|
128
|
+
return JSONResponse(
|
|
129
|
+
status_code=413,
|
|
130
|
+
content={
|
|
131
|
+
"detail": (
|
|
132
|
+
f"Request body too large: {observed} bytes exceeds "
|
|
133
|
+
f"the {self._max_bytes}-byte limit."
|
|
133
134
|
)
|
|
134
|
-
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
139
|
+
if scope["type"] != "http":
|
|
140
|
+
await self.app(scope, receive, send)
|
|
141
|
+
return
|
|
142
|
+
for key, value in scope.get("headers", []):
|
|
143
|
+
if key == b"content-length":
|
|
144
|
+
try:
|
|
145
|
+
declared = int(value)
|
|
146
|
+
except ValueError:
|
|
147
|
+
break
|
|
148
|
+
if declared > self._max_bytes:
|
|
149
|
+
await self._too_large(declared)(scope, receive, send)
|
|
150
|
+
return
|
|
151
|
+
break
|
|
152
|
+
received = 0
|
|
153
|
+
response_started = False
|
|
154
|
+
|
|
155
|
+
async def limited_receive() -> Message:
|
|
156
|
+
nonlocal received
|
|
157
|
+
message = await receive()
|
|
158
|
+
if message["type"] == "http.request":
|
|
159
|
+
received += len(message.get("body", b""))
|
|
160
|
+
if received > self._max_bytes:
|
|
161
|
+
raise _BodyTooLarge(received)
|
|
162
|
+
return message
|
|
163
|
+
|
|
164
|
+
async def guarded_send(message: Message) -> None:
|
|
165
|
+
nonlocal response_started
|
|
166
|
+
if message["type"] == "http.response.start":
|
|
167
|
+
response_started = True
|
|
168
|
+
await send(message)
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
await self.app(scope, limited_receive, guarded_send)
|
|
172
|
+
except _BodyTooLarge as exc:
|
|
173
|
+
if response_started:
|
|
174
|
+
raise
|
|
175
|
+
await self._too_large(int(exc.args[0]))(scope, receive, send)
|
|
135
176
|
|
|
136
177
|
|
|
137
178
|
class HostValidationMiddleware(BaseHTTPMiddleware):
|
|
@@ -409,10 +450,12 @@ def create_app(config_path: str | None = None) -> FastAPI:
|
|
|
409
450
|
)
|
|
410
451
|
app.add_middleware(HostValidationMiddleware, allowed_hosts=allowed_hosts)
|
|
411
452
|
|
|
412
|
-
# M14: request body size limit (DoS protection).
|
|
413
|
-
#
|
|
414
|
-
#
|
|
415
|
-
#
|
|
453
|
+
# M14: request body size limit (DoS protection). ``add_middleware`` wraps
|
|
454
|
+
# outermost-last, so the middleware added *after* HostValidation ends up
|
|
455
|
+
# the outermost layer — i.e. the size guard runs FIRST, before Host
|
|
456
|
+
# validation. That ordering is harmless: an oversized/chunked body is
|
|
457
|
+
# rejected as early as possible. SSE streaming responses are unaffected;
|
|
458
|
+
# this only inspects the request side.
|
|
416
459
|
max_body_bytes = _parse_max_body_bytes(
|
|
417
460
|
os.environ.get(_MAX_BODY_BYTES_ENV)
|
|
418
461
|
)
|
|
@@ -552,6 +552,47 @@ def _resolve_within_model_dirs(model_path: str, model_dirs: list[str]) -> Path:
|
|
|
552
552
|
raise ValueError("model_path is not under any configured model_dirs.")
|
|
553
553
|
|
|
554
554
|
|
|
555
|
+
_model_dirs_warning_emitted = False
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _assert_model_path_allowed(
|
|
559
|
+
launcher_cfg: Any, model_path: str | None, *, field: str
|
|
560
|
+
) -> None:
|
|
561
|
+
"""model_path が launcher.model_dirs 配下か検査(パスは書き換えない)。
|
|
562
|
+
|
|
563
|
+
M-1: ``/api/launcher/start`` と ``/sweep/start`` はこれまで任意の
|
|
564
|
+
ファイルシステムパスを spawn に渡していた。ここで ``model_dirs`` 配下に
|
|
565
|
+
限定する。ただし **検証のみ**で resolve 済みパスを spawn に渡さない
|
|
566
|
+
(macOS の /var→/private/var symlink で argv が変わり既存テストが落ちる)。
|
|
567
|
+
``model_dirs`` 未設定なら従来どおり通し、一度だけ警告する。
|
|
568
|
+
"""
|
|
569
|
+
global _model_dirs_warning_emitted
|
|
570
|
+
if not model_path:
|
|
571
|
+
return
|
|
572
|
+
model_dirs: list[str] = (
|
|
573
|
+
list(getattr(launcher_cfg, "model_dirs", []) or []) if launcher_cfg else []
|
|
574
|
+
)
|
|
575
|
+
if not model_dirs:
|
|
576
|
+
if not _model_dirs_warning_emitted:
|
|
577
|
+
logger.warning(
|
|
578
|
+
"launcher-model-dirs-unconfigured",
|
|
579
|
+
extra={
|
|
580
|
+
"hint": (
|
|
581
|
+
"launcher.model_dirs is empty; POST /api/launcher/start and "
|
|
582
|
+
"/sweep/start accept any filesystem path. Configure "
|
|
583
|
+
"launcher.model_dirs when the launcher is reachable from "
|
|
584
|
+
"anything but loopback."
|
|
585
|
+
)
|
|
586
|
+
},
|
|
587
|
+
)
|
|
588
|
+
_model_dirs_warning_emitted = True
|
|
589
|
+
return
|
|
590
|
+
try:
|
|
591
|
+
_resolve_within_model_dirs(model_path, model_dirs)
|
|
592
|
+
except ValueError as exc:
|
|
593
|
+
raise HTTPException(status_code=400, detail=f"{field}: {exc}") from exc
|
|
594
|
+
|
|
595
|
+
|
|
555
596
|
def _option_tokens(options: dict[str, Any]) -> list[str]:
|
|
556
597
|
"""Flatten an ``options`` dict into CLI tokens (``{flag: val}`` semantics).
|
|
557
598
|
|
|
@@ -1410,6 +1451,8 @@ async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
|
|
|
1410
1451
|
cfg = request.app.state.config
|
|
1411
1452
|
launcher_cfg = getattr(cfg, "launcher", None)
|
|
1412
1453
|
_assert_backend_declared(launcher_cfg, req.backend)
|
|
1454
|
+
_assert_model_path_allowed(launcher_cfg, req.model_path, field="model_path")
|
|
1455
|
+
_assert_model_path_allowed(launcher_cfg, req.draft_model_path, field="draft_model_path")
|
|
1413
1456
|
# デバイス選択 → CLI 断片(llama.cpp のみ・選択された場合のみ)。未指定なら
|
|
1414
1457
|
# None を渡し、既存の argv と完全一致(後方互換)。
|
|
1415
1458
|
device_args: list[str] | None = None
|
|
@@ -1665,8 +1708,10 @@ class SweepRequest(BaseModel):
|
|
|
1665
1708
|
options: dict[str, Any] = Field(default_factory=dict)
|
|
1666
1709
|
extra_args: str = ""
|
|
1667
1710
|
configs: list[SweepConfigItem] = Field(default_factory=list)
|
|
1668
|
-
|
|
1669
|
-
runs: int | None = None
|
|
1711
|
+
bench_preset: str | None = None # None なら launcher.bench.default_preset / 暗黙 default
|
|
1712
|
+
runs: int | None = Field(default=None, ge=1, le=1000)
|
|
1713
|
+
# 廃止フィールド。黙って捨てると事故になるので api_sweep_start で 400 にする。
|
|
1714
|
+
bench_command: str | None = None
|
|
1670
1715
|
results_dir: str | None = None
|
|
1671
1716
|
|
|
1672
1717
|
|
|
@@ -1872,25 +1917,73 @@ class _SweepRunner:
|
|
|
1872
1917
|
await self._safe_stop(mp.id)
|
|
1873
1918
|
|
|
1874
1919
|
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1920
|
+
_HARDCODED_BENCH_TEMPLATE = "llmbench run --model local-openai --runs {runs}"
|
|
1921
|
+
_HARDCODED_BENCH_RUNS = 5
|
|
1922
|
+
|
|
1923
|
+
|
|
1924
|
+
@dataclass(frozen=True)
|
|
1925
|
+
class _ResolvedBench:
|
|
1926
|
+
key: str
|
|
1927
|
+
name: str
|
|
1928
|
+
command_template: str
|
|
1929
|
+
runs: int
|
|
1930
|
+
results_dir: str | None
|
|
1931
|
+
readiness_timeout_s: float
|
|
1932
|
+
|
|
1933
|
+
|
|
1934
|
+
def _bench_preset_map(launcher_cfg: Any) -> dict[str, Any]:
|
|
1881
1935
|
bench_cfg = getattr(launcher_cfg, "bench", None) if launcher_cfg else None
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1936
|
+
return dict(getattr(bench_cfg, "presets", {}) or {})
|
|
1937
|
+
|
|
1938
|
+
|
|
1939
|
+
def _resolve_bench(launcher_cfg: Any, preset_key: str | None) -> _ResolvedBench:
|
|
1940
|
+
"""H-2: リクエストの bench_preset を config 上の実体へ解決。無いキーは ValueError(→400)。"""
|
|
1941
|
+
bench_cfg = getattr(launcher_cfg, "bench", None) if launcher_cfg else None
|
|
1942
|
+
base_template = getattr(bench_cfg, "command_template", None) or _HARDCODED_BENCH_TEMPLATE
|
|
1943
|
+
base_runs = getattr(bench_cfg, "runs", None) or _HARDCODED_BENCH_RUNS
|
|
1944
|
+
base_results = getattr(bench_cfg, "results_dir", None)
|
|
1945
|
+
readiness = getattr(bench_cfg, "readiness_timeout_s", None) or _DEFAULT_READINESS_TIMEOUT_S
|
|
1946
|
+
presets = _bench_preset_map(launcher_cfg)
|
|
1947
|
+
key = preset_key or getattr(bench_cfg, "default_preset", None)
|
|
1948
|
+
if key is None or (key == "default" and "default" not in presets):
|
|
1949
|
+
return _ResolvedBench(
|
|
1950
|
+
key="default",
|
|
1951
|
+
name="default (launcher.bench.command_template)",
|
|
1952
|
+
command_template=base_template,
|
|
1953
|
+
runs=base_runs,
|
|
1954
|
+
results_dir=base_results,
|
|
1955
|
+
readiness_timeout_s=readiness,
|
|
1956
|
+
)
|
|
1957
|
+
preset = presets.get(key)
|
|
1958
|
+
if preset is None:
|
|
1959
|
+
known = sorted(presets) or ["default"]
|
|
1960
|
+
raise ValueError(
|
|
1961
|
+
f"Unknown bench preset {key!r}. Declare it under launcher.bench.presets "
|
|
1962
|
+
f"in providers.yaml (known: {known})."
|
|
1963
|
+
)
|
|
1964
|
+
return _ResolvedBench(
|
|
1965
|
+
key=key,
|
|
1966
|
+
name=preset.name,
|
|
1967
|
+
command_template=preset.command_template,
|
|
1968
|
+
runs=preset.runs if preset.runs is not None else base_runs,
|
|
1969
|
+
results_dir=preset.results_dir if preset.results_dir is not None else base_results,
|
|
1970
|
+
readiness_timeout_s=readiness,
|
|
1971
|
+
)
|
|
1888
1972
|
|
|
1889
1973
|
|
|
1890
1974
|
@router.post("/api/launcher/sweep/start")
|
|
1891
1975
|
async def api_sweep_start(req: SweepRequest, request: Request) -> dict[str, Any]:
|
|
1892
1976
|
"""ベンチスイープを開始する(設計 §4.4 / §4.5)。書き込み系 → token 必須。"""
|
|
1893
1977
|
_require_launcher_token(request)
|
|
1978
|
+
if req.bench_command is not None or req.results_dir is not None:
|
|
1979
|
+
raise HTTPException(
|
|
1980
|
+
status_code=400,
|
|
1981
|
+
detail=(
|
|
1982
|
+
"bench_command / results_dir are no longer accepted from the request. "
|
|
1983
|
+
"Declare the command under launcher.bench.presets in providers.yaml and "
|
|
1984
|
+
"pass its key as 'bench_preset'."
|
|
1985
|
+
),
|
|
1986
|
+
)
|
|
1894
1987
|
app = request.app
|
|
1895
1988
|
|
|
1896
1989
|
existing = getattr(app.state, "launcher_sweep", None)
|
|
@@ -1901,12 +1994,12 @@ async def api_sweep_start(req: SweepRequest, request: Request) -> dict[str, Any]
|
|
|
1901
1994
|
|
|
1902
1995
|
cfg = app.state.config
|
|
1903
1996
|
launcher_cfg = getattr(cfg, "launcher", None)
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
runs = req.runs if req.runs is not None else
|
|
1909
|
-
|
|
1997
|
+
try:
|
|
1998
|
+
bench = _resolve_bench(launcher_cfg, req.bench_preset)
|
|
1999
|
+
except ValueError as exc:
|
|
2000
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
2001
|
+
runs = req.runs if req.runs is not None else bench.runs
|
|
2002
|
+
_assert_model_path_allowed(launcher_cfg, req.model_path, field="model_path")
|
|
1910
2003
|
|
|
1911
2004
|
# ポート競合: registry の使用中ポート照合 + best-effort な空きチェック。
|
|
1912
2005
|
reg = _registry_for_app(app)
|
|
@@ -1945,17 +2038,60 @@ async def api_sweep_start(req: SweepRequest, request: Request) -> dict[str, Any]
|
|
|
1945
2038
|
model_path=req.model_path,
|
|
1946
2039
|
backend=req.backend,
|
|
1947
2040
|
port=req.port,
|
|
1948
|
-
bench_cmd_template=
|
|
1949
|
-
results_dir=results_dir,
|
|
2041
|
+
bench_cmd_template=bench.command_template,
|
|
2042
|
+
results_dir=bench.results_dir,
|
|
1950
2043
|
options=req.options,
|
|
1951
2044
|
extra_args=req.extra_args,
|
|
1952
2045
|
)
|
|
1953
2046
|
runner = _SweepRunner(
|
|
1954
|
-
app, launcher_cfg, plan, runs=runs, readiness_timeout_s=
|
|
2047
|
+
app, launcher_cfg, plan, runs=runs, readiness_timeout_s=bench.readiness_timeout_s
|
|
1955
2048
|
)
|
|
1956
2049
|
app.state.launcher_sweep = runner
|
|
1957
2050
|
runner.start()
|
|
1958
|
-
return {
|
|
2051
|
+
return {
|
|
2052
|
+
"sweep_id": runner.sweep_id,
|
|
2053
|
+
"steps": [s.as_dict() for s in steps],
|
|
2054
|
+
"bench_preset": bench.key,
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
|
|
2058
|
+
@router.get("/api/launcher/bench-presets")
|
|
2059
|
+
async def api_bench_presets(request: Request) -> dict[str, Any]:
|
|
2060
|
+
"""スイープ UI が選べるベンチプリセットのキー一覧。読み取り系 → 認証なし。
|
|
2061
|
+
|
|
2062
|
+
H-2: command_template はローカルパスが混ざりうるので **返さない**。
|
|
2063
|
+
key/name/runs/has_results_dir だけ返す。presets に "default" が無ければ
|
|
2064
|
+
暗黙 default(launcher.bench.command_template 由来)を先頭に挿入する。
|
|
2065
|
+
"""
|
|
2066
|
+
cfg = request.app.state.config
|
|
2067
|
+
launcher_cfg = getattr(cfg, "launcher", None)
|
|
2068
|
+
bench_cfg = getattr(launcher_cfg, "bench", None) if launcher_cfg else None
|
|
2069
|
+
presets = _bench_preset_map(launcher_cfg)
|
|
2070
|
+
default_preset = getattr(bench_cfg, "default_preset", None)
|
|
2071
|
+
base_results = getattr(bench_cfg, "results_dir", None)
|
|
2072
|
+
base_runs = getattr(bench_cfg, "runs", None) or _HARDCODED_BENCH_RUNS
|
|
2073
|
+
items: list[dict[str, Any]] = []
|
|
2074
|
+
if "default" not in presets:
|
|
2075
|
+
items.append(
|
|
2076
|
+
{
|
|
2077
|
+
"key": "default",
|
|
2078
|
+
"name": "default (launcher.bench.command_template)",
|
|
2079
|
+
"runs": base_runs,
|
|
2080
|
+
"has_results_dir": base_results is not None,
|
|
2081
|
+
}
|
|
2082
|
+
)
|
|
2083
|
+
for key, preset in presets.items():
|
|
2084
|
+
items.append(
|
|
2085
|
+
{
|
|
2086
|
+
"key": key,
|
|
2087
|
+
"name": preset.name,
|
|
2088
|
+
"runs": preset.runs if preset.runs is not None else base_runs,
|
|
2089
|
+
"has_results_dir": (
|
|
2090
|
+
preset.results_dir is not None or base_results is not None
|
|
2091
|
+
),
|
|
2092
|
+
}
|
|
2093
|
+
)
|
|
2094
|
+
return {"presets": items, "default": default_preset}
|
|
1959
2095
|
|
|
1960
2096
|
|
|
1961
2097
|
@router.get("/api/launcher/sweep/status")
|
|
@@ -2048,6 +2184,7 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
2048
2184
|
<a href="/dashboard" class="text-slate-400 hover:text-slate-200 transition-colors">Dashboard</a>
|
|
2049
2185
|
<span class="text-slate-100 font-medium border-b border-indigo-400 pb-0.5">Launcher</span>
|
|
2050
2186
|
<span id="status-msg" class="ml-auto text-xs text-slate-500"></span>
|
|
2187
|
+
<button id="token-badge" onclick="promptToken()" class="btn-sm btn-red hidden">🔑 トークン未設定</button>
|
|
2051
2188
|
</div>
|
|
2052
2189
|
</header>
|
|
2053
2190
|
|
|
@@ -2213,18 +2350,12 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
2213
2350
|
<input id="sweep-port" type="number" value="8090" min="1024" max="65535" />
|
|
2214
2351
|
</div>
|
|
2215
2352
|
<div>
|
|
2216
|
-
<label class="block text-xs text-slate-400 mb-1"
|
|
2217
|
-
<
|
|
2353
|
+
<label class="block text-xs text-slate-400 mb-1">ベンチプリセット (providers.yaml の launcher.bench.presets)</label>
|
|
2354
|
+
<select id="sweep-preset"></select>
|
|
2218
2355
|
</div>
|
|
2219
|
-
<div
|
|
2220
|
-
<
|
|
2221
|
-
|
|
2222
|
-
<input id="sweep-runs" type="number" value="5" min="1" max="1000" />
|
|
2223
|
-
</div>
|
|
2224
|
-
<div>
|
|
2225
|
-
<label class="block text-xs text-slate-400 mb-1">results_dir (任意)</label>
|
|
2226
|
-
<input id="sweep-results" type="text" placeholder="results/" />
|
|
2227
|
-
</div>
|
|
2356
|
+
<div>
|
|
2357
|
+
<label class="block text-xs text-slate-400 mb-1">runs</label>
|
|
2358
|
+
<input id="sweep-runs" type="number" value="5" min="1" max="1000" />
|
|
2228
2359
|
</div>
|
|
2229
2360
|
</div>
|
|
2230
2361
|
</div>
|
|
@@ -2269,15 +2400,31 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
2269
2400
|
"use strict";
|
|
2270
2401
|
|
|
2271
2402
|
const POLL_MS = 3000;
|
|
2272
|
-
//
|
|
2273
|
-
//
|
|
2274
|
-
|
|
2275
|
-
|
|
2403
|
+
// H-3: the token is NEVER embedded in the HTML. The server only tells us
|
|
2404
|
+
// whether auth is required; the token itself is entered by the operator and
|
|
2405
|
+
// kept in sessionStorage (cleared when the tab closes).
|
|
2406
|
+
const AUTH_REQUIRED = __LAUNCHER_AUTH_REQUIRED__;
|
|
2407
|
+
const TOKEN_KEY = "coderouter-launcher-token";
|
|
2408
|
+
let launcherToken = sessionStorage.getItem(TOKEN_KEY) || "";
|
|
2276
2409
|
const authHeaders = (base) => {
|
|
2277
2410
|
const h = Object.assign({}, base || {});
|
|
2278
|
-
if (
|
|
2411
|
+
if (launcherToken) h["X-CodeRouter-Token"] = launcherToken;
|
|
2279
2412
|
return h;
|
|
2280
2413
|
};
|
|
2414
|
+
const updateTokenBadge = () => {
|
|
2415
|
+
const el = document.getElementById("token-badge");
|
|
2416
|
+
if (!el) return;
|
|
2417
|
+
el.textContent = launcherToken ? "🔑 トークン設定済" : "🔑 トークン未設定";
|
|
2418
|
+
el.className = "btn-sm " + (launcherToken ? "btn-slate" : "btn-red") + (AUTH_REQUIRED ? "" : " hidden");
|
|
2419
|
+
};
|
|
2420
|
+
window.promptToken = () => {
|
|
2421
|
+
const v = window.prompt("CODEROUTER_LAUNCHER_TOKEN を入力してください", "");
|
|
2422
|
+
if (v === null) return;
|
|
2423
|
+
launcherToken = v.trim();
|
|
2424
|
+
if (launcherToken) sessionStorage.setItem(TOKEN_KEY, launcherToken);
|
|
2425
|
+
else sessionStorage.removeItem(TOKEN_KEY);
|
|
2426
|
+
updateTokenBadge();
|
|
2427
|
+
};
|
|
2281
2428
|
let allProfiles = {}; // backend → [{name, args}]
|
|
2282
2429
|
const _modelCache = {}; // index → {path, name, dir, size_gb}
|
|
2283
2430
|
let selectedLogId = null;
|
|
@@ -2808,14 +2955,12 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
2808
2955
|
tensor_split: c.tensor_split, backend: c.backend || null}));
|
|
2809
2956
|
if (!configs.length) { showSweepErr("構成を 1 つ以上選択してください"); return; }
|
|
2810
2957
|
const port = parseInt(document.getElementById("sweep-port").value);
|
|
2811
|
-
const
|
|
2958
|
+
const preset = document.getElementById("sweep-preset").value || null;
|
|
2812
2959
|
const runs = parseInt(document.getElementById("sweep-runs").value);
|
|
2813
|
-
const results = document.getElementById("sweep-results").value.trim();
|
|
2814
2960
|
const body = {
|
|
2815
2961
|
backend: "llama.cpp", model_path: model, port, configs,
|
|
2816
|
-
|
|
2962
|
+
bench_preset: preset,
|
|
2817
2963
|
runs: isNaN(runs) ? null : runs,
|
|
2818
|
-
results_dir: results || null,
|
|
2819
2964
|
};
|
|
2820
2965
|
try {
|
|
2821
2966
|
const r = await fetch("/api/launcher/sweep/start", {
|
|
@@ -2970,12 +3115,12 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
2970
3115
|
const modelName = p.model_path.split("/").pop();
|
|
2971
3116
|
const isActive = p.status === "running" || p.status === "loading" || p.status === "starting";
|
|
2972
3117
|
const stopBtn = isActive
|
|
2973
|
-
? `<button
|
|
3118
|
+
? `<button data-act="stop" data-id="${esc(p.id)}" class="btn-sm btn-red">■ 停止</button>`
|
|
2974
3119
|
: "";
|
|
2975
3120
|
const delBtn = !isActive
|
|
2976
|
-
? `<button
|
|
3121
|
+
? `<button data-act="del" data-id="${esc(p.id)}" class="btn-sm btn-slate ml-1">✕</button>`
|
|
2977
3122
|
: "";
|
|
2978
|
-
const logBtn = `<button
|
|
3123
|
+
const logBtn = `<button data-act="log" data-id="${esc(p.id)}" data-name="${esc(p.name)}" class="btn-sm btn-indigo ml-1">📋 ログ</button>`;
|
|
2979
3124
|
// [Unreleased]: swap badge — marks processes SwapManager spawned
|
|
2980
3125
|
// on demand, distinct from manually-started ones. Title shows the
|
|
2981
3126
|
// catalog model name when the backend supplied it.
|
|
@@ -2994,6 +3139,18 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
2994
3139
|
}).join("");
|
|
2995
3140
|
};
|
|
2996
3141
|
|
|
3142
|
+
// M-2: no inline onclick with interpolated process fields (XSS). The row
|
|
3143
|
+
// buttons carry data-* attributes; a single delegated listener dispatches.
|
|
3144
|
+
document.getElementById("proc-table").addEventListener("click", (ev) => {
|
|
3145
|
+
const btn = ev.target.closest("button[data-act]");
|
|
3146
|
+
if (!btn) return;
|
|
3147
|
+
const id = btn.getAttribute("data-id");
|
|
3148
|
+
const act = btn.getAttribute("data-act");
|
|
3149
|
+
if (act === "stop") stopProc(id);
|
|
3150
|
+
else if (act === "del") deleteProc(id);
|
|
3151
|
+
else if (act === "log") openLog(id, btn.getAttribute("data-name") || "");
|
|
3152
|
+
});
|
|
3153
|
+
|
|
2997
3154
|
window.stopProc = async (id) => {
|
|
2998
3155
|
if (!confirm("プロセスを停止しますか?")) return;
|
|
2999
3156
|
const r = await fetch(`/api/launcher/stop/${id}`, {method:"POST", headers: authHeaders()});
|
|
@@ -3051,11 +3208,35 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
3051
3208
|
document.getElementById("log-panel").classList.add("hidden");
|
|
3052
3209
|
};
|
|
3053
3210
|
|
|
3211
|
+
// H-2: fill the sweep preset <select> from config-declared presets. The
|
|
3212
|
+
// command_template is never sent to the browser; the operator only picks a key.
|
|
3213
|
+
const fetchBenchPresets = async () => {
|
|
3214
|
+
try {
|
|
3215
|
+
const r = await fetch("/api/launcher/bench-presets");
|
|
3216
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
3217
|
+
const d = await r.json();
|
|
3218
|
+
const sel = document.getElementById("sweep-preset");
|
|
3219
|
+
if (!sel) return;
|
|
3220
|
+
const presets = d.presets || [];
|
|
3221
|
+
sel.innerHTML = presets.map((p) => {
|
|
3222
|
+
const runs = (p.runs != null) ? ` — runs ${p.runs}` : "";
|
|
3223
|
+
return `<option value="${esc(p.key)}">${esc(p.name)}${runs}</option>`;
|
|
3224
|
+
}).join("");
|
|
3225
|
+
const chosen = d.default || "default";
|
|
3226
|
+
const keys = presets.map((p) => p.key);
|
|
3227
|
+
sel.value = keys.includes(chosen) ? chosen : (keys[0] || "");
|
|
3228
|
+
} catch (e) {
|
|
3229
|
+
console.error("[Launcher] fetchBenchPresets failed:", e);
|
|
3230
|
+
}
|
|
3231
|
+
};
|
|
3232
|
+
|
|
3054
3233
|
// ── Init + polling ───────────────────────────────────────────────────────
|
|
3055
3234
|
|
|
3056
3235
|
const init = async () => {
|
|
3057
3236
|
updateDeviceBlock();
|
|
3058
|
-
|
|
3237
|
+
updateTokenBadge();
|
|
3238
|
+
if (AUTH_REQUIRED && !launcherToken) promptToken();
|
|
3239
|
+
await Promise.all([fetchModels(), fetchProfiles(), fetchBackends(), fetchProcesses(), fetchBenchPresets()]);
|
|
3059
3240
|
pollSweep(); // 既存スイープがあれば表示 (running なら以後のポーリングを継続)
|
|
3060
3241
|
};
|
|
3061
3242
|
|
|
@@ -3078,18 +3259,13 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
3078
3259
|
async def launcher_page() -> HTMLResponse:
|
|
3079
3260
|
"""Serve the launcher single-page UI.
|
|
3080
3261
|
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3262
|
+
H-3: the launcher token is NEVER embedded in the served HTML (it used to be
|
|
3263
|
+
substituted into a JS string literal, so anyone who could read the page —
|
|
3264
|
+
including a browser cache or a shoulder-surfer — recovered the shared
|
|
3265
|
+
secret). We now only substitute a boolean ``__LAUNCHER_AUTH_REQUIRED__``
|
|
3266
|
+
telling the client whether auth is on; the operator enters the token in the
|
|
3267
|
+
browser and it lives in ``sessionStorage`` only.
|
|
3086
3268
|
"""
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
token.replace("\\", "\\\\")
|
|
3090
|
-
.replace('"', '\\"')
|
|
3091
|
-
.replace("<", "\\u003c")
|
|
3092
|
-
.replace(">", "\\u003e")
|
|
3093
|
-
)
|
|
3094
|
-
html = _LAUNCHER_HTML.replace("__LAUNCHER_TOKEN__", safe_token)
|
|
3269
|
+
auth_required = "true" if os.environ.get(_LAUNCHER_TOKEN_ENV, "") else "false"
|
|
3270
|
+
html = _LAUNCHER_HTML.replace("__LAUNCHER_AUTH_REQUIRED__", auth_required)
|
|
3095
3271
|
return HTMLResponse(content=html)
|
coderouter/launcher_devices.py
CHANGED
|
@@ -629,6 +629,10 @@ def render_bench_command(
|
|
|
629
629
|
argv 化。
|
|
630
630
|
|
|
631
631
|
``str.format`` は JSON 波括弧等で誤爆するため単純置換を使う。
|
|
632
|
+
|
|
633
|
+
H-2: 置換より **先に** ``shlex.split`` でトークン化し、各トークン内でのみ
|
|
634
|
+
置換する。こうしないと ``{config}`` にスペースを含む値が入ったとき argv が
|
|
635
|
+
増殖し(引数注入)、想定外のフラグを外部ベンチに渡せてしまう。
|
|
632
636
|
"""
|
|
633
637
|
mapping = {
|
|
634
638
|
"port": str(port),
|
|
@@ -637,13 +641,16 @@ def render_bench_command(
|
|
|
637
641
|
"results_dir": results_dir or "",
|
|
638
642
|
"runs": str(runs) if runs is not None else "",
|
|
639
643
|
}
|
|
640
|
-
rendered = template
|
|
641
|
-
for k, v in mapping.items():
|
|
642
|
-
rendered = rendered.replace("{" + k + "}", v)
|
|
643
644
|
# ★ Windows: shlex の POSIX モードはバックスラッシュをエスケープ文字
|
|
644
645
|
# として食い潰し `C:\tools\llmbench.exe` を壊す。os.name で分岐して
|
|
645
646
|
# Windows では posix=False(バックスラッシュを素通し)にする。
|
|
646
|
-
|
|
647
|
+
argv: list[str] = []
|
|
648
|
+
for tok in shlex.split(template, posix=(os.name != "nt")):
|
|
649
|
+
for k, v in mapping.items():
|
|
650
|
+
tok = tok.replace("{" + k + "}", v)
|
|
651
|
+
if tok:
|
|
652
|
+
argv.append(tok)
|
|
653
|
+
return argv
|
|
647
654
|
|
|
648
655
|
|
|
649
656
|
# ---------------------------------------------------------------------------
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coderouter-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.13.0
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
coderouter/__init__.py,sha256=ghdjPrLtnRzY8fyQ4CJZI1UJKADyNTLtA3G7se8H7Ns,696
|
|
2
2
|
coderouter/__main__.py,sha256=-LCgxJnvgUV240HjQKv7ly-mn2NuKHpC4nCpvTHjeSU,130
|
|
3
|
-
coderouter/cli.py,sha256=
|
|
3
|
+
coderouter/cli.py,sha256=TyI29d8-Zc8SClcKn92Oj8DGyQRxMpBSWTxLiFTH3Qo,37043
|
|
4
4
|
coderouter/cli_stats.py,sha256=CCjzc1G4hTRHZ2gG1XhxhDpUkJnnl3NXbcbp1T18jpg,29894
|
|
5
5
|
coderouter/cost.py,sha256=32h6uzb4nxh2eA5d2Hn3kD9yJbtis6CFDAbeIy5KRkM,7431
|
|
6
6
|
coderouter/doctor.py,sha256=wmPunlvqm2s7x3VGLjexqHPLD2oejwE3Mu04V8y-xn4,89869
|
|
@@ -10,7 +10,7 @@ coderouter/errors.py,sha256=Xmq67lheyw8iv3Ox39jh2c4tvNI5RcUR4QkoxVDN6l4,1130
|
|
|
10
10
|
coderouter/gguf_introspect.py,sha256=Scu0JjUfLHj7T7TL0HQIm1AZcGYrXsjkaMMSN9HkTro,16121
|
|
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=
|
|
13
|
+
coderouter/launcher_devices.py,sha256=DAV34Q1crGptmj1zOAk21YQzlxQNAt5Ud7qcldTD95w,29406
|
|
14
14
|
coderouter/launcher_speculative.py,sha256=h0RTGsD1Y56sJVywDJwybrT75_Ta3GF1Jz_B1d42aPg,12931
|
|
15
15
|
coderouter/launcher_swap.py,sha256=tMW5ZElDdf9T1VQGsUkvfLiSCdj_oXN_UtkV7DE8ySY,24175
|
|
16
16
|
coderouter/logging.py,sha256=5rfi4_R4aoeeUYDc642dgs61wA0aBX7wvubyB-k3Uyo,58074
|
|
@@ -26,8 +26,8 @@ coderouter/adapters/registry.py,sha256=XNc1wmFv_oZlqKS0rA693TRDdbq1sZ-OIcofXOVOt
|
|
|
26
26
|
coderouter/config/__init__.py,sha256=FODEn74fN-qZnt4INPSHswqhOlEgpL6-_onxsitSx8g,274
|
|
27
27
|
coderouter/config/capability_registry.py,sha256=RZgzHF54dXy0CCDocRCz2ee21qXGbbgTSKTkRI9iLL4,16936
|
|
28
28
|
coderouter/config/env_file.py,sha256=CoMK27fuAXm-NtoLzXb8yN2E-wDFjHQuFwiIlmgTBQw,10356
|
|
29
|
-
coderouter/config/loader.py,sha256=
|
|
30
|
-
coderouter/config/schemas.py,sha256=
|
|
29
|
+
coderouter/config/loader.py,sha256=vk1LFm-OCiyq2GB5G53g7kTnrJgmQjkZPqgr9X13wpc,11294
|
|
30
|
+
coderouter/config/schemas.py,sha256=J05km7-YXgNxy6guKUDcNsuMyJ21SnDIurjx8Rz_hSg,136672
|
|
31
31
|
coderouter/data/__init__.py,sha256=uNyfD9jaCvTWsBAWtaw1Fr25OSxzv3psGMfBjT1z0Cc,328
|
|
32
32
|
coderouter/data/model-capabilities.yaml,sha256=S9jt6SC6-3s2-icZ_n-a14iEMnc2yB1C2R6q-N_tZWQ,19309
|
|
33
33
|
coderouter/guards/__init__.py,sha256=5qliYBqygvVPneej7nx0uSjxDKsz7t8VzvrDgVBJlvU,1170
|
|
@@ -39,13 +39,13 @@ coderouter/guards/drift_actions.py,sha256=A6pY5CR480Ct5rCVyjlBvjPFVc93eu_r5qcUpK
|
|
|
39
39
|
coderouter/guards/drift_detection.py,sha256=lW45EZDGRleDUTstw_CHlfUiwLUrlDHX8g7ncuOYNEw,13962
|
|
40
40
|
coderouter/guards/memory_budget.py,sha256=_bRtusk4AwrU781wVXW32OFU7zD2FXxOwTb7yGqPJqA,8278
|
|
41
41
|
coderouter/guards/memory_pressure.py,sha256=9Xm-b7rv9Td5kPNmAFoL3sNmaOR5zT8NLdn6erWcuyY,8719
|
|
42
|
-
coderouter/guards/self_healing.py,sha256=
|
|
42
|
+
coderouter/guards/self_healing.py,sha256=XeUlgFWGqMQ5E9YvvzR2CsHRJOAJEyDChqRN0xmZ0vo,16841
|
|
43
43
|
coderouter/guards/tool_loop.py,sha256=EzeMcmU7BLeTW2jsRVevU81l5rhWcn1oUr7EpzgXjVM,15209
|
|
44
44
|
coderouter/ingress/__init__.py,sha256=WQsCH2CGJCAhy0mS6GSEdeYZRkkQu2OHDsP4CJWTLug,155
|
|
45
45
|
coderouter/ingress/anthropic_routes.py,sha256=1ThXzx8VgxRV7UCi3NF5ey7kbr0ADPKTXyQuYS5gK1w,27063
|
|
46
|
-
coderouter/ingress/app.py,sha256=
|
|
46
|
+
coderouter/ingress/app.py,sha256=ZOx7dSvMxaWS4CzXOOEwgzFuycAVgAeWKCN6lFNF-F0,22433
|
|
47
47
|
coderouter/ingress/dashboard_routes.py,sha256=1SqNNrVTfQiZcx5n2MvjAzWirittX0e61tCt-MKp4Ag,25617
|
|
48
|
-
coderouter/ingress/launcher_routes.py,sha256=
|
|
48
|
+
coderouter/ingress/launcher_routes.py,sha256=uP_Otp9JE0fydUt1YP-wYcnS8Kj5RPLNIFf88YpfkPM,141535
|
|
49
49
|
coderouter/ingress/metrics_routes.py,sha256=M22dwOGn24P05Ge4W3c7d7mYytSGWjIR-pPSPOAiHJY,3965
|
|
50
50
|
coderouter/ingress/openai_routes.py,sha256=TrugsQNJfNPgKKLzfT1aXzrs4emWYGE4XebEBuaZoWk,12505
|
|
51
51
|
coderouter/metrics/__init__.py,sha256=7Es351DPS7yLM0yVF_F0eesmiD83n7Zzhie44chht38,1465
|
|
@@ -72,8 +72,8 @@ coderouter/translation/anthropic.py,sha256=ZQQGS9itN_3o7IKD68lKr5gR_Kem32Jsd7OcG
|
|
|
72
72
|
coderouter/translation/convert.py,sha256=VrstDAMsUpcQ1VgMTn_-ETp1OjGX8v0ZGhCkcd4icyU,53037
|
|
73
73
|
coderouter/translation/tool_repair.py,sha256=1p8rmdyyscGX2g0DDOYitMnLv-nKfR1SLuLaRB4z7GM,64244
|
|
74
74
|
coderouter/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
75
|
-
coderouter_cli-2.
|
|
76
|
-
coderouter_cli-2.
|
|
77
|
-
coderouter_cli-2.
|
|
78
|
-
coderouter_cli-2.
|
|
79
|
-
coderouter_cli-2.
|
|
75
|
+
coderouter_cli-2.13.0.dist-info/METADATA,sha256=J6h8klUNsTIhWoMjrax70AR9nCFXYK_rFP2tbpChn8I,16897
|
|
76
|
+
coderouter_cli-2.13.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
77
|
+
coderouter_cli-2.13.0.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
|
|
78
|
+
coderouter_cli-2.13.0.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
|
|
79
|
+
coderouter_cli-2.13.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|