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.
@@ -355,15 +355,134 @@ class AgentCliConfig(BaseModel):
355
355
  return self
356
356
 
357
357
 
358
- # [Unreleased]/planned v2.12.0: patterns used by ProviderConfig's
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
- # the planned ``shlex.split`` + ``shell=False`` dispatch (see that
362
- # validator's docstring and the field description below for the full
363
- # rationale). Compiled once at module scope since every ProviderConfig
364
- # validation reuses them.
365
- _RESTART_COMMAND_SHELL_META_RE = re.compile(r"(&&|\|\||\||;|>|<|\$\(|`)")
366
- _RESTART_COMMAND_ENV_PREFIX_RE = re.compile(r"^\w+=")
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
- "[Unreleased]/planned v2.12.0: currently run as "
524
- "``subprocess.run(command, shell=True)`` (coderouter/guards/"
525
- "self_healing.py), so shell syntax works today — pipelines "
526
- "(``pkill ollama && ollama serve``), ``~`` expansion, and "
527
- "leading env-var assignments (``OLLAMA_HOST=0.0.0.0 ollama "
528
- "serve``) are all honored. A future minor switches this to "
529
- "``shlex.split(command)`` + ``shell=False`` (argv dispatch, "
530
- "no shell in between), which does NOT support any of the "
531
- "above — ``&&`` becomes a literal argument to the first "
532
- "command instead of a separator, ``~/...`` paths are not "
533
- "expanded, and ``FOO=bar cmd`` fails to exec. If your "
534
- "command needs shell features, wrap it in a small script "
535
- "and point restart_command at that script instead, e.g. "
536
- "``restart_command: /path/to/restart-ollama.sh``."
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
- """[Unreleased]/planned v2.12.0 breaking-change heads-up (warning only).
543
-
544
- ``coderouter/guards/self_healing.py`` currently runs
545
- ``restart_command`` via ``subprocess.run(command, shell=True)``.
546
- The planned v2.12.0 change switches that to
547
- ``shlex.split(command)`` + ``shell=False`` — a shell-syntax
548
- ``restart_command`` that works fine today would then either
549
- crash (``FileNotFoundError`` for ``~/...`` paths or an
550
- ``ENV=val cmd`` prefix) or, worse, silently misbehave (``&&`` /
551
- ``||`` / ``|`` / ``;`` / redirects becoming literal argv tokens
552
- instead of shell operators — a provider "restart" that quietly
553
- does the wrong thing is worse than one that errors loudly).
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 ahead of the wire change so operators
557
- can migrate ``restart_command`` to a plain argv (or a wrapper
558
- script) before v2.12.0 lands, instead of discovering the break
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 _RESTART_COMMAND_SHELL_META_RE.search(cmd):
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 _RESTART_COMMAND_ENV_PREFIX_RE.match(stripped):
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 currently runs via "
581
- "subprocess.run(..., shell=True); a planned "
582
- "v2.12.0 change switches to shlex.split(...) + "
583
- "shell=False, which does not support this "
584
- "command's shell syntax. Rewrite it as plain "
585
- "argv, or wrap it in a script and point "
586
- "restart_command at that script."
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):