coderouter-cli 2.12.0__py3-none-any.whl → 2.14.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/adapters/anthropic_native.py +4 -2
- coderouter/adapters/openai_compat.py +5 -2
- coderouter/cli.py +159 -22
- coderouter/config/loader.py +157 -35
- coderouter/config/schemas.py +237 -47
- coderouter/credentials.py +332 -0
- coderouter/doctor_apply.py +54 -9
- coderouter/guards/self_healing.py +58 -3
- coderouter/ingress/app.py +72 -29
- coderouter/ingress/dashboard_routes.py +24 -4
- coderouter/ingress/launcher_routes.py +237 -61
- coderouter/ingress/metrics_routes.py +13 -2
- coderouter/ingress/ui_auth.py +94 -0
- coderouter/launcher_devices.py +11 -4
- coderouter/logging.py +7 -0
- coderouter/rollback.py +217 -0
- coderouter/secret_redaction.py +606 -0
- coderouter/state/audit_log.py +6 -0
- coderouter/state/request_log.py +6 -0
- coderouter/vscode_init.py +156 -20
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.14.0.dist-info}/METADATA +1 -1
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.14.0.dist-info}/RECORD +25 -21
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.14.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.14.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.14.0.dist-info}/licenses/LICENSE +0 -0
coderouter/config/schemas.py
CHANGED
|
@@ -355,15 +355,134 @@ 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+=")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class CredentialRefresh(BaseModel):
|
|
375
|
+
"""How to make a vendor CLI rotate the token it wrote to disk.
|
|
376
|
+
|
|
377
|
+
Deliberately not an OAuth client. Every vendor has its own endpoint,
|
|
378
|
+
client id, rotation policy and error shape, and all of them change —
|
|
379
|
+
delegating to the CLI that owns the file is the version that survives
|
|
380
|
+
contact with a moving vendor.
|
|
381
|
+
|
|
382
|
+
``command`` is an argv **list**, dispatched with ``shell=False``. Same
|
|
383
|
+
trust decision as v2.13.0's ``restart_command``: a string that goes
|
|
384
|
+
through a shell turns a config file into arbitrary code execution.
|
|
385
|
+
There is no string form here to have to refuse.
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
model_config = ConfigDict(extra="forbid")
|
|
389
|
+
|
|
390
|
+
command: list[str] = Field(
|
|
391
|
+
...,
|
|
392
|
+
min_length=1,
|
|
393
|
+
description=(
|
|
394
|
+
"argv for the vendor CLI, e.g. ['grok', 'models']. Run with "
|
|
395
|
+
"shell=False; no shell metacharacters are interpreted."
|
|
396
|
+
),
|
|
397
|
+
)
|
|
398
|
+
early_ratio: float = Field(
|
|
399
|
+
default=0.5,
|
|
400
|
+
ge=0.0,
|
|
401
|
+
le=1.0,
|
|
402
|
+
description=(
|
|
403
|
+
"Refresh when the remaining lifetime drops below this fraction "
|
|
404
|
+
"of itself. Early refresh avoids a token dying mid-request."
|
|
405
|
+
),
|
|
406
|
+
)
|
|
407
|
+
min_lead_s: float = Field(
|
|
408
|
+
default=300.0,
|
|
409
|
+
ge=0.0,
|
|
410
|
+
description="Always refresh at least this many seconds before expiry.",
|
|
411
|
+
)
|
|
412
|
+
timeout_s: float = Field(
|
|
413
|
+
default=30.0,
|
|
414
|
+
gt=0.0,
|
|
415
|
+
le=600.0,
|
|
416
|
+
description="Hard timeout for the refresh command.",
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class ProviderCredential(BaseModel):
|
|
421
|
+
"""Where this provider's credential comes from (v2.14.0).
|
|
422
|
+
|
|
423
|
+
``source: env`` is the historical behaviour, spelled out. ``source:
|
|
424
|
+
cli_session`` borrows the token a vendor CLI already wrote to disk, so
|
|
425
|
+
a subscription-authenticated provider can be an ordinary
|
|
426
|
+
``openai_compat`` entry instead of a ``kind: agent_cli`` island — and
|
|
427
|
+
therefore takes part in fallback chains, auto-routing, the budget
|
|
428
|
+
tracker and every other routing feature.
|
|
429
|
+
"""
|
|
430
|
+
|
|
431
|
+
model_config = ConfigDict(extra="forbid")
|
|
432
|
+
|
|
433
|
+
source: Literal["env", "cli_session"] = Field(
|
|
434
|
+
default="env",
|
|
435
|
+
description="'env' reads an environment variable; 'cli_session' reads a JSON file.",
|
|
436
|
+
)
|
|
437
|
+
env: str | None = Field(
|
|
438
|
+
default=None, description="source=env: the environment variable name."
|
|
439
|
+
)
|
|
440
|
+
path: str | None = Field(
|
|
441
|
+
default=None,
|
|
442
|
+
description=(
|
|
443
|
+
"source=cli_session: the JSON file the vendor CLI writes, e.g. "
|
|
444
|
+
"~/.kimi-code/credentials/kimi-code.json. Must live under $HOME."
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
field: str = Field(
|
|
448
|
+
default="access_token",
|
|
449
|
+
description="Dotted path to the token inside that JSON (e.g. 'tokens.access').",
|
|
450
|
+
)
|
|
451
|
+
expiry_field: str = Field(
|
|
452
|
+
default="expires_at",
|
|
453
|
+
description=(
|
|
454
|
+
"Dotted path to the expiry. Epoch seconds or milliseconds; both "
|
|
455
|
+
"are accepted. Absent means 'no expiry info' — the upstream 401 "
|
|
456
|
+
"becomes the signal instead."
|
|
457
|
+
),
|
|
458
|
+
)
|
|
459
|
+
refresh: CredentialRefresh | None = Field(
|
|
460
|
+
default=None,
|
|
461
|
+
description="Omit to never refresh (a long-lived token, or one you rotate yourself).",
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
@model_validator(mode="after")
|
|
465
|
+
def _check_source_requirements(self) -> Self:
|
|
466
|
+
"""Each source needs its own field, and only its own field."""
|
|
467
|
+
if self.source == "env":
|
|
468
|
+
if not self.env:
|
|
469
|
+
raise ValueError("credential.source='env' requires credential.env")
|
|
470
|
+
if self.path:
|
|
471
|
+
raise ValueError("credential.path is meaningless for source='env'")
|
|
472
|
+
if self.source == "cli_session":
|
|
473
|
+
if not self.path:
|
|
474
|
+
raise ValueError(
|
|
475
|
+
"credential.source='cli_session' requires credential.path"
|
|
476
|
+
)
|
|
477
|
+
if self.env:
|
|
478
|
+
raise ValueError("credential.env is meaningless for source='cli_session'")
|
|
479
|
+
from coderouter.credentials import session_path_is_sane
|
|
480
|
+
|
|
481
|
+
if not session_path_is_sane(self.path):
|
|
482
|
+
raise ValueError(
|
|
483
|
+
f"credential.path must live under your home directory: {self.path}"
|
|
484
|
+
)
|
|
485
|
+
return self
|
|
367
486
|
|
|
368
487
|
|
|
369
488
|
class ProviderConfig(BaseModel):
|
|
@@ -411,6 +530,19 @@ class ProviderConfig(BaseModel):
|
|
|
411
530
|
default=None,
|
|
412
531
|
description="Env var name holding the API key. None = no auth (e.g. local).",
|
|
413
532
|
)
|
|
533
|
+
# v2.14.0: the general form of the above. Mutually exclusive with
|
|
534
|
+
# api_key_env — a resolver that silently fell back between sources would
|
|
535
|
+
# make "which credential is this request actually using?" unanswerable,
|
|
536
|
+
# which is the question an operator asks at exactly the wrong moment.
|
|
537
|
+
credential: ProviderCredential | None = Field(
|
|
538
|
+
default=None,
|
|
539
|
+
description=(
|
|
540
|
+
"Where the credential comes from. Omit to use api_key_env. "
|
|
541
|
+
"source='cli_session' reads the token a vendor CLI already wrote "
|
|
542
|
+
"to disk, so a subscription provider can be a plain "
|
|
543
|
+
"openai_compat entry that takes part in fallback chains."
|
|
544
|
+
),
|
|
545
|
+
)
|
|
414
546
|
|
|
415
547
|
# Routing-relevant flags
|
|
416
548
|
paid: bool = Field(
|
|
@@ -520,54 +652,56 @@ class ProviderConfig(BaseModel):
|
|
|
520
652
|
"transitions to UNHEALTHY. Unset = no automatic restart "
|
|
521
653
|
"(recovery probe still runs, waiting for manual restart). "
|
|
522
654
|
"\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
|
-
"
|
|
655
|
+
"v2.13.0: run as ``subprocess.run(shlex.split(command), "
|
|
656
|
+
"shell=False)`` (coderouter/guards/self_healing.py) — argv "
|
|
657
|
+
"dispatch, no shell in between. Pipelines "
|
|
658
|
+
"(``pkill ollama && ollama serve``), redirects, ``~`` "
|
|
659
|
+
"expansion, and leading env-var assignments "
|
|
660
|
+
"(``OLLAMA_HOST=0.0.0.0 ollama serve``) are NOT supported: "
|
|
661
|
+
"``~/...`` paths are not expanded and ``FOO=bar cmd`` fails to "
|
|
662
|
+
"exec. A value containing shell metacharacters (``&&``, ``||``, "
|
|
663
|
+
"``|``, ``;``, ``>``, ``<``, ``$(...)``, ```...```) is refused "
|
|
664
|
+
"outright (not run) rather than half-executed. If your command "
|
|
665
|
+
"needs shell features, wrap it in a small script and point "
|
|
666
|
+
"restart_command at that script (e.g. "
|
|
667
|
+
"``restart_command: /path/to/restart-ollama.sh``), or write it "
|
|
668
|
+
"as ``/bin/sh -c '...'`` explicitly."
|
|
537
669
|
),
|
|
538
670
|
)
|
|
539
671
|
|
|
540
672
|
@model_validator(mode="after")
|
|
541
673
|
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
|
-
|
|
674
|
+
"""v2.13.0: warn that this ``restart_command`` will not run as written.
|
|
675
|
+
|
|
676
|
+
``coderouter/guards/self_healing.py`` runs ``restart_command`` via
|
|
677
|
+
``subprocess.run(shlex.split(command), shell=False)`` — argv
|
|
678
|
+
dispatch, no shell. A shell-syntax ``restart_command`` therefore
|
|
679
|
+
does NOT do what the shell would do:
|
|
680
|
+
|
|
681
|
+
* shell metacharacters (``&&`` / ``||`` / ``|`` / ``;`` /
|
|
682
|
+
redirects / ``$(...)`` / backticks) → the value is *refused*
|
|
683
|
+
(not run) by self_healing, because under shell=False they would
|
|
684
|
+
otherwise become literal argv tokens and half-execute a
|
|
685
|
+
provider "restart" that quietly does the wrong thing;
|
|
686
|
+
* a leading ``~/...`` path or an ``ENV=val cmd`` prefix →
|
|
687
|
+
``exec`` fails (``FileNotFoundError``) because there is no shell
|
|
688
|
+
to expand ``~`` or apply the assignment.
|
|
554
689
|
|
|
555
690
|
Deliberately never raises: this validator does not enforce
|
|
556
|
-
anything, it only warns
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
the next time a provider actually goes UNHEALTHY.
|
|
691
|
+
anything, it only warns so an existing providers.yaml still loads
|
|
692
|
+
(rather than becoming un-startable). Migrate ``restart_command`` to
|
|
693
|
+
a plain argv, a wrapper script, or an explicit ``/bin/sh -c '...'``.
|
|
560
694
|
"""
|
|
561
695
|
cmd = self.restart_command
|
|
562
696
|
if not cmd:
|
|
563
697
|
return self
|
|
564
698
|
stripped = cmd.strip()
|
|
565
699
|
reasons: list[str] = []
|
|
566
|
-
if
|
|
700
|
+
if RESTART_COMMAND_SHELL_META_RE.search(cmd):
|
|
567
701
|
reasons.append("shell metacharacter (&&, ||, |, ;, >, <, $(...), or `...`)")
|
|
568
702
|
if stripped.startswith("~/"):
|
|
569
703
|
reasons.append("leading '~/' path (no shell expansion under shell=False)")
|
|
570
|
-
if
|
|
704
|
+
if RESTART_COMMAND_ENV_PREFIX_RE.match(stripped):
|
|
571
705
|
reasons.append("leading environment-variable assignment (FOO=bar ...)")
|
|
572
706
|
if reasons:
|
|
573
707
|
logger.warning(
|
|
@@ -577,13 +711,13 @@ class ProviderConfig(BaseModel):
|
|
|
577
711
|
"restart_command": cmd,
|
|
578
712
|
"reasons": reasons,
|
|
579
713
|
"hint": (
|
|
580
|
-
"restart_command
|
|
581
|
-
"subprocess.run(
|
|
582
|
-
"v2.
|
|
583
|
-
"shell
|
|
584
|
-
"
|
|
585
|
-
"
|
|
586
|
-
"
|
|
714
|
+
"restart_command runs via "
|
|
715
|
+
"subprocess.run(shlex.split(...), shell=False) as of "
|
|
716
|
+
"v2.13.0, which does not support this command's "
|
|
717
|
+
"shell syntax: values with shell metacharacters are "
|
|
718
|
+
"refused (not run), and '~/' / 'FOO=bar' prefixes "
|
|
719
|
+
"fail to exec. Rewrite it as plain argv, wrap it in "
|
|
720
|
+
"a script, or use /bin/sh -c '...'."
|
|
587
721
|
),
|
|
588
722
|
},
|
|
589
723
|
)
|
|
@@ -604,6 +738,23 @@ class ProviderConfig(BaseModel):
|
|
|
604
738
|
validate_output_filters(self.output_filters)
|
|
605
739
|
return self
|
|
606
740
|
|
|
741
|
+
@model_validator(mode="after")
|
|
742
|
+
def _check_credential_exclusive(self) -> Self:
|
|
743
|
+
"""``api_key_env`` and ``credential`` cannot both be set.
|
|
744
|
+
|
|
745
|
+
Failing at load time is the whole point: two credential sources on
|
|
746
|
+
one provider is a question ("which one won?") that should never
|
|
747
|
+
reach a request, let alone a log line an operator has to reverse
|
|
748
|
+
engineer at 2am.
|
|
749
|
+
"""
|
|
750
|
+
if self.credential is not None and self.api_key_env:
|
|
751
|
+
raise ValueError(
|
|
752
|
+
f"provider {self.name!r}: set either api_key_env or credential, "
|
|
753
|
+
"not both (credential.source='env' with credential.env=... is "
|
|
754
|
+
"the spelled-out form of api_key_env)"
|
|
755
|
+
)
|
|
756
|
+
return self
|
|
757
|
+
|
|
607
758
|
@model_validator(mode="after")
|
|
608
759
|
def _check_kind_requirements(self) -> ProviderConfig:
|
|
609
760
|
"""Enforce per-``kind`` field requirements (external-agents design §5.2.2).
|
|
@@ -1573,6 +1724,28 @@ class LauncherOptionProfile(BaseModel):
|
|
|
1573
1724
|
)
|
|
1574
1725
|
|
|
1575
1726
|
|
|
1727
|
+
class LauncherBenchPreset(BaseModel):
|
|
1728
|
+
"""launcher.bench.presets[<key>] の1エントリ。H-2: スイープAPIが受け付けるのはキー名だけ。"""
|
|
1729
|
+
|
|
1730
|
+
model_config = ConfigDict(extra="forbid")
|
|
1731
|
+
name: str = Field(..., min_length=1, description="Launcher UI に出す表示名。")
|
|
1732
|
+
command_template: str = Field(
|
|
1733
|
+
...,
|
|
1734
|
+
min_length=1,
|
|
1735
|
+
description="外部ベンチコマンドのテンプレ。{port} {config} {base_url} {results_dir} {runs} を単純置換で展開。",
|
|
1736
|
+
)
|
|
1737
|
+
runs: int | None = Field(
|
|
1738
|
+
default=None,
|
|
1739
|
+
ge=1,
|
|
1740
|
+
le=1000,
|
|
1741
|
+
description="このプリセット固有の実行回数。None なら launcher.bench.runs。",
|
|
1742
|
+
)
|
|
1743
|
+
results_dir: str | None = Field(
|
|
1744
|
+
default=None,
|
|
1745
|
+
description="このプリセット固有の results ディレクトリ。None なら launcher.bench.results_dir。リクエストからは指定できない。",
|
|
1746
|
+
)
|
|
1747
|
+
|
|
1748
|
+
|
|
1576
1749
|
class LauncherBenchConfig(BaseModel):
|
|
1577
1750
|
"""The ``launcher.bench:`` block — defaults for the bench sweep feature.
|
|
1578
1751
|
|
|
@@ -1614,6 +1787,23 @@ class LauncherBenchConfig(BaseModel):
|
|
|
1614
1787
|
"大きな GGUF ロードを考慮した既定 5 分。"
|
|
1615
1788
|
),
|
|
1616
1789
|
)
|
|
1790
|
+
presets: dict[str, LauncherBenchPreset] = Field(
|
|
1791
|
+
default_factory=dict,
|
|
1792
|
+
description="名前付きベンチコマンド。キーが POST /api/launcher/sweep/start の bench_preset が取りうる唯一の値。",
|
|
1793
|
+
)
|
|
1794
|
+
default_preset: str | None = Field(
|
|
1795
|
+
default=None,
|
|
1796
|
+
description="bench_preset 未指定時に使うプリセットキー。None なら command_template 由来の暗黙 default。",
|
|
1797
|
+
)
|
|
1798
|
+
|
|
1799
|
+
@model_validator(mode="after")
|
|
1800
|
+
def _check_default_preset(self) -> Self:
|
|
1801
|
+
if self.default_preset is not None and self.default_preset not in self.presets:
|
|
1802
|
+
raise ValueError(
|
|
1803
|
+
f"launcher.bench.default_preset={self.default_preset!r} is not a key of "
|
|
1804
|
+
f"launcher.bench.presets (known: {sorted(self.presets) or 'none'})"
|
|
1805
|
+
)
|
|
1806
|
+
return self
|
|
1617
1807
|
|
|
1618
1808
|
|
|
1619
1809
|
class SwapModelSpec(BaseModel):
|