chore: import upstream snapshot with attribution
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:45 +08:00
commit 4b6817381b
3933 changed files with 525247 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
# LLM CLI providers (subprocess)
Use this package when adding a new **non-interactive** LLM that shells out to a vendor CLI (like OpenAI Codex), instead of HTTP APIs.
## Layout
| File | Role |
| -------------------- | ------------------------------------------------------------------------------------------- |
| `base.py` | `LLMCLIAdapter` protocol, `CLIProbe`, `CLIInvocation`. |
| `constants.py` | Shared runner/adapter constants (probe-cache TTL, tempfail retry knobs, common timeout defaults). |
| `registry.py` | `CLI_PROVIDER_REGISTRY`: maps `LLM_PROVIDER``adapter_factory` + optional `model_env_key`. `opensre doctor` uses `get_cli_provider_registration()` so any key in this registry is treated as CLI-backed—do not hardcode provider IDs in `doctor.py`. |
| `subprocess_env.py` | Filtered `env` passed to CLI subprocesses (`build_cli_subprocess_env`). Extend `_SAFE_SUBPROCESS_ENV_PREFIXES` here when a CLI needs vendor-specific env prefixes. |
| `env_overrides.py` | Optional explicit HTTP/API keys merged into `CLIInvocation.env` when `build_cli_subprocess_env` would drop them (shared tuples + `nonempty_env_values`). |
| `timeout_utils.py` | Shared timeout env parsing (`resolve_timeout_from_env` for default + clamp behavior). |
| `probe_utils.py` | Shared subprocess probe helpers (currently `run_version_probe` for `<binary> --version`). |
| `semver_utils.py` | Shared semver helpers (`parse_semver_three_part`, `semver_to_tuple`). |
| `binary_resolver.py` | Shared executable resolution helpers (`env -> PATH -> fallback paths`). |
| `runner.py` | `CLIBackedLLMClient`: guardrails, `detect()`, `subprocess.run`, ANSI strip, `LLMResponse`. |
| `text.py` | `flatten_messages_to_prompt` for stdin from chat-style payloads. |
| `codex.py` | Reference adapter: binary resolution, `codex exec`, `--version`, and opt-in-only `login status` probing. |
| `opencode.py` | Multi-provider CLI: `--version`, then `opencode auth list` (see `_parse_opencode_auth_list_output`). |
| `kimi.py` | `kimi --print` path: `--version`, `kimi login status`, then env/config.toml fallback (`KIMI_API_KEY`). |
| `copilot.py` | `copilot -p` path: `--version`, then env tokens, then `gh auth status` (and `--hostname` when `COPILOT_GH_HOST` / `GH_HOST` targets a non-default host); otherwise `logged_in=None`. Plaintext `$COPILOT_HOME/config.json` is not inspected. No OS keychain probes. |
| `pi_cli.py` | Pi CLI (pi.dev, BYOK multi-provider) `pi -p` print mode: `--version`, then state-based auth (provider API key in env, else `~/.pi/agent/auth.json` from `/login`) — Pi has no non-interactive auth-status command. Provider keys forwarded via `CLIInvocation.env` (`PI_PROVIDER_ENV_KEYS`), never the blanket prefix. |
| `grok_cli.py` | xAI Grok Build CLI (`grok -p --output-format plain`): `--version`, then `grok models` (~0.5 s, no LLM call) to classify auth — looks for "You are logged in" / "logged in with" in output. `XAI_API_KEY` env promotes to authenticated as a headless/CI fallback even when the probe result is unclear. `XAI_API_KEY` forwarded only via `CLIInvocation.env` (`XAI_CLI_ENV_KEYS`), never the blanket prefix. Never passes `--always-approve`. |
## Wiring a new provider
**Before merging**, read **[Subprocess environment allowlist](#subprocess-environment-allowlist)** below: if your CLI reads vendor-specific env vars, you must extend `_SAFE_SUBPROCESS_ENV_PREFIXES` in `subprocess_env.py` or the subprocess will not see them (auth and config will break silently).
1. **Adapter** — Implement `LLMCLIAdapter`: `detect()` must not raise; `build()` returns argv + optional stdin; `parse` / `explain_failure` for success and non-zero exits. Put prompt text on stdin and/or in argv as appropriate — the runner does not branch on a separate “delivery mode”; `CLIInvocation` carries what `build()` produced.
2. **Registry** — Add an entry to `CLI_PROVIDER_REGISTRY` in `registry.py` (`adapter_factory`, `model_env_key`). The dict key must match `LLM_PROVIDER` / `ProviderOption.value` / `LLMProvider` in `config/config.py`. `resolve_llm_route` in `core/llm/factory.py` resolves registered CLI providers into a `cli_provider_registration`, and the builders in `core/llm/client_builders.py` construct the CLI-backed client automatically (no new branch for normal cases).
3. **Config** — Add the provider literal to `LLMProvider` and validators in `config/config.py` (same string as the registry key).
4. **Wizard (optional)** — If onboarding should offer the CLI: add a `ProviderOption` in `surfaces/cli/wizard/config.py` with `credential_kind="cli"` and `adapter_factory`. `flow.py` already runs `_run_cli_llm_onboarding` for CLI providers and builds the saved-summary credential line from `provider.label` + `adapter.auth_hint`.
5. **Typing** — Prefer `adapter_factory: Callable[[], LLMCLIAdapter]` on `ProviderOption` so wizard and client stay aligned.
## Binary resolution (recommended pattern)
Use `binary_resolver.resolve_cli_binary(...)` so all adapters share the same behavior.
Resolution order:
1. Explicit binary env var (`<PROVIDER>_BIN`, e.g. Codex `CODEX_BIN`) **only if it points to a runnable file**.
2. `shutil.which(...)` lookups for platform-specific binary names.
3. Fallback install locations from `default_cli_fallback_paths(...)`.
Notes:
- Binary env vars are optional by default.
- Blank/invalid explicit paths are ignored; PATH/fallback resolution still runs.
- For Codex, keep this behavior: users can run with no `CODEX_BIN`.
## Conventions
- **No TTY**: invocation must be suitable for `subprocess.run` without an interactive session.
- **Probe vs run**: `detect()` is cheap; `CLIBackedLLMClient.invoke` probes again before exec so missing auth fails fast with a clear error.
- **Structured output**: `CLIBackedLLMClient.with_structured_output` delegates to `StructuredOutputClient` (JSON-in-prompt), same pattern as API clients.
- **Optional model envs**: use `<PROVIDER>_MODEL` (see [Per-provider env vars](#per-provider-env-vars-required-for-every-new-cli)); always optional—if unset, rely on vendor CLI defaults.
## Per-provider env vars (required for every new CLI)
**Codex is the reference.** Every subprocess LLM must expose the same *shape* of knobs:
| Env var | Role |
| ------- | ---- |
| `<PROVIDER>_BIN` | Optional explicit path to the vendor executable. Pass the same name as `explicit_env_key` to `resolve_cli_binary(...)`. Missing, blank, or invalid paths are ignored; PATH + fallbacks still run. |
| `<PROVIDER>_MODEL` | Optional model override. Register as `model_env_key` on `CLIProviderRegistration` in `registry.py`. Empty or unset → runner omits the flag and the CLI uses its default. |
**Naming:** derive `<PROVIDER>` from the registry / `LLM_PROVIDER` string: **uppercase**, then `_BIN` / `_MODEL`. Examples: `codex``CODEX_BIN`, `CODEX_MODEL`; a future `gemini``GEMINI_BIN`, `GEMINI_MODEL`.
Document both vars in the adapter module docstring or a one-line comment near `binary_env_key` / the registration entry so users and wizard copy stay aligned.
## Auth probe pattern
`detect()` must return a `CLIProbe` with `logged_in: bool | None` — three states:
| Value | Meaning | Wizard behaviour |
| ----- | ------- | ---------------- |
| `True` | Binary found **and** auth confirmed. | Proceeds immediately. |
| `False` | Binary found but definitely **not** authenticated. | Prompts user to run the login command (`auth_hint`). |
| `None` | Binary found but auth **status is unclear** (network error, unexpected output, etc.). | Asks user to retry or repick provider. |
Recommended probe sequence for CLIs with safe non-interactive auth-status commands:
1. Run `<binary> --version` — if it fails, return `installed=False` immediately.
2. Run `<binary> <auth-status-command>` — parse stdout/stderr to classify `logged_in`.
3. Write a `_classify_<name>_auth(returncode, stdout, stderr) -> tuple[bool | None, str]`
helper. Check **negative phrases first** (e.g. `"not logged in"` before `"logged in"`)
to avoid substring false-positives.
4. Map network/timeout errors to `None`, not `False` — the user may be on a flaky
connection and shouldn't be forced to re-authenticate.
See `_classify_codex_auth` in `codex.py` for a complete reference implementation.
**Codex exception:** do not run `codex login status` by default. Some Codex CLI
versions can open browser OAuth while checking a session, so the Codex adapter
only runs that command when `OPENSRE_CODEX_AUTH_STATUS_PROBE=1` is explicitly
set. The normal prompt-safe status path reports `logged_in=None` and lets
`codex exec` surface request-time auth failures.
**OpenCode** is multi-provider: users may rely on `auth.json`, environment API keys, or both.
Run `opencode auth list` after `--version` and parse the reported credential/environment
counts so detection matches the CLI (do not infer auth from the JSON file alone).
**Kimi** uses `kimi login status` after `--version`, then `_check_kimi_auth_fallback()` when the
CLI did not positively confirm auth (`logged_in` not `True`): check `KIMI_API_KEY`, then API keys in
``~/.kimi/config.toml`` (or `KIMI_SHARE_DIR`). API-key-only installs may omit “logged in” phrasing in
`login status`, so the fallback mirrors real usage. The same fallback runs when `login status` times
out or fails to spawn (`logged_in=None`), so a configured API key still counts as authenticated.
## Subprocess environment allowlist
`CLIBackedLLMClient` passes only a safe subset of env vars to the subprocess via
`build_cli_subprocess_env` in `subprocess_env.py` (`_SAFE_SUBPROCESS_ENV_KEYS` +
`_SAFE_SUBPROCESS_ENV_PREFIXES`).
Shared HTTP/API overrides live in `env_overrides.py`: use `nonempty_env_values(...)` with
`OPENAI_PLATFORM_ENV_KEYS` (Codex), `HTTP_LLM_PROVIDER_ENV_KEYS` (OpenCode),
`ANTHROPIC_CLI_ENV_KEYS` (Claude Code), `CURSOR_CLI_ENV_KEYS` (Cursor Agent headless API key),
or `COPILOT_CLI_ENV_KEYS` (Copilot CLI credential envs: `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`) plus `COPILOT_CLI_CONFIG_ENV_KEYS` (`COPILOT_HOME`, `COPILOT_MODEL`, `COPILOT_GH_HOST`, `GH_HOST`).
Extend those tuples when you add a matching API-key env to `LLMSettings`.
**Kimi** does not use those tuples today: OAuth/API material is covered by forwarding any `KIMI_*` keys via `_SAFE_SUBPROCESS_ENV_PREFIXES`; `KimiAdapter.build()` uses `CLIInvocation(env=None)` and relies on that allowlist.
The current prefix allowlist includes `CODEX_`, `CURSOR_`, `CLAUDE_`, `OPENCODE_`, `KIMI_`, and locale keys (`LC_`). `COPILOT_` is deliberately NOT a prefix entry — `COPILOT_GITHUB_TOKEN` is a GitHub PAT, and a blanket prefix would leak it into every other CLI subprocess; the Copilot adapter forwards every Copilot-scoped env via its own `CLIInvocation.env` instead (see `COPILOT_CLI_ENV_KEYS` / `COPILOT_CLI_CONFIG_ENV_KEYS` in `env_overrides.py`).
**If your CLI reads custom env vars** (e.g. `GEMINI_*`) you must add the
relevant prefix to `_SAFE_SUBPROCESS_ENV_PREFIXES` in `subprocess_env.py`, otherwise the
subprocess will not receive those vars and authentication or configuration will silently
fail. Add a test that asserts the required keys are forwarded.
## Codex binary resolution (reference)
Order in `CodexAdapter._resolve_binary` (now delegated to shared resolver helpers):
1. `CODEX_BIN` if set and path is runnable (explicit override).
2. `shutil.which("codex")` (and Windows `codex.cmd` / `codex.ps1`).
3. `_fallback_codex_paths()` — conventional install locations; invalid or blank `CODEX_BIN` is ignored so PATH/fallbacks still apply.
## Codex env quick reference (instance of the convention above)
All optional:
```bash
CODEX_MODEL=
CODEX_BIN=
```
- If `CODEX_MODEL` is unset, `codex exec` uses its default model behavior.
- If `CODEX_BIN` is unset, adapter resolution falls back to PATH + known install locations.
## Provider checklist (copy/paste)
- Add adapter in `integrations/llm_cli/`.
- Define `<PROVIDER>_BIN` + `<PROVIDER>_MODEL` per [Per-provider env vars](#per-provider-env-vars-required-for-every-new-cli); reuse `resolve_cli_binary(..., explicit_env_key=...)` for `_resolve_binary`.
- Implement `detect()` with `--version` + auth status checks; follow the three-state `logged_in` pattern above.
- Write `_classify_<name>_auth` — test against a real logged-in **and** logged-out session before merging.
- If the CLI reads custom env vars (e.g. `GEMINI_*`), add the prefix to `_SAFE_SUBPROCESS_ENV_PREFIXES` in `subprocess_env.py`.
- Register the provider in `registry.py` and add the same `LLM_PROVIDER` value in `config/config.py`.
- (Optional) Add wizard onboarding option in `surfaces/cli/wizard/config.py`.
- Add tests under `tests/integrations/llm_cli/` for detect/build/failure paths, including env forwarding.
## Tests
- `tests/integrations/llm_cli/` — adapter and runner unit tests; mock `subprocess` / `shutil.which` as needed.
- Platform-specific assertions must patch `integrations.llm_cli.binary_resolver.sys.platform` (not `codex.sys.platform`), because resolution lives in `binary_resolver.py`.
- `npm_prefix_bin_dirs` is `@lru_cache`d; tests that vary env or platform should call `npm_prefix_bin_dirs.cache_clear()` before each case (or use a shared autouse fixture) to avoid stale cache across tests.
+9
View File
@@ -0,0 +1,9 @@
"""Subprocess-backed LLM providers (Codex CLI, future Gemini/Claude CLIs)."""
from __future__ import annotations
from integrations.llm_cli.base import CLIInvocation, CLIProbe
from integrations.llm_cli.errors import CLIAuthenticationRequired
from integrations.llm_cli.runner import CLIBackedLLMClient
__all__ = ["CLIAuthenticationRequired", "CLIInvocation", "CLIProbe", "CLIBackedLLMClient"]
+296
View File
@@ -0,0 +1,296 @@
"""Google Antigravity CLI adapter (``agy -p``, non-interactive headless mode).
Antigravity CLI is the successor to Gemini CLI. Gemini CLI stops serving Pro/Ultra
and free users on 2026-06-18; paid Gemini Code Assist users keep Gemini CLI.
Env vars
--------
ANTIGRAVITY_CLI_BIN Optional explicit path to the ``agy`` binary.
ANTIGRAVITY_CLI_TIMEOUT_SECONDS Optional invocation timeout override (clamped 30600s).
``ANTIGRAVITY_CLI_MODEL`` is registered for forward-compat on the registry but is
**no-op** today: ``agy`` v1.0.2 does not expose ``--model`` in headless ``-p`` mode
(verified locally). Each invocation uses whatever model is persisted in agy's
local config; users change it interactively with ``/models`` inside the REPL.
Once Google ships ``--model`` in headless, ``build()`` can forward the env var
in a one-line change (see TODO near ``del model``).
Auth
----
Google Sign-In via browser OAuth on first interactive ``agy`` run; the token is
cached by the OS keyring. No documented ``ANTIGRAVITY_API_KEY``. As a best-effort
fallback, the probe treats explicit ``GEMINI_API_KEY`` / ``GOOGLE_API_KEY`` /
Vertex env credentials as authenticated, mirroring ``gemini_cli.py``.
Stateless invocation
--------------------
``build()`` never passes ``--continue`` / ``--conversation`` / ``--sandbox`` /
``--dangerously-skip-permissions``; each opensre call is ephemeral. ``--output-format``
was removed between Gemini CLI and Antigravity CLI — stdout is plain text now.
"""
from __future__ import annotations
import os
import subprocess
from integrations.llm_cli.base import CLIInvocation, CLIProbe
from integrations.llm_cli.binary_resolver import (
candidate_binary_names as _candidate_binary_names,
)
from integrations.llm_cli.binary_resolver import (
default_cli_fallback_paths as _default_cli_fallback_paths,
)
from integrations.llm_cli.binary_resolver import (
resolve_cli_binary,
)
from integrations.llm_cli.constants import (
DEFAULT_EXEC_TIMEOUT_SEC as _DEFAULT_EXEC_TIMEOUT_SEC,
)
from integrations.llm_cli.constants import (
MAX_EXEC_TIMEOUT_SEC as _MAX_EXEC_TIMEOUT_SEC,
)
from integrations.llm_cli.constants import (
MIN_EXEC_TIMEOUT_SEC as _MIN_EXEC_TIMEOUT_SEC,
)
from integrations.llm_cli.probe_utils import run_version_probe
from integrations.llm_cli.semver_utils import parse_semver_three_part, semver_to_tuple
from integrations.llm_cli.subprocess_env import build_cli_subprocess_env
from integrations.llm_cli.timeout_utils import resolve_timeout_from_env
_PROBE_TIMEOUT_SEC = 20.0
_AUTH_HINT = "Run: agy (interactive Google Sign-In) or set GEMINI_API_KEY for keyless fallback."
# Buffer so the Python-side subprocess timeout sits above ``agy --print-timeout``
# and lets the CLI emit its own clean timeout message instead of being SIGKILL'd.
_SUBPROCESS_TIMEOUT_BUFFER_SEC = 10.0
def _resolve_exec_timeout_seconds() -> float:
return resolve_timeout_from_env(
env_key="ANTIGRAVITY_CLI_TIMEOUT_SECONDS",
default=_DEFAULT_EXEC_TIMEOUT_SEC,
minimum=_MIN_EXEC_TIMEOUT_SEC,
maximum=_MAX_EXEC_TIMEOUT_SEC,
)
def _antigravity_auth_env_overrides() -> dict[str, str]:
"""Build agy subprocess auth/config overrides used by probe and invoke."""
env: dict[str, str] = {"NO_COLOR": "1"}
keys = (
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"GOOGLE_GENAI_USE_VERTEXAI",
"GOOGLE_APPLICATION_CREDENTIALS",
"GOOGLE_CLOUD_PROJECT",
"GOOGLE_CLOUD_LOCATION",
)
for key in keys:
val = os.environ.get(key, "").strip()
if val:
env[key] = val
return env
def _has_explicit_antigravity_auth_env() -> str | None:
env = _antigravity_auth_env_overrides()
for key in ("GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS"):
if env.get(key):
return key
if env.get("GOOGLE_GENAI_USE_VERTEXAI") and env.get("GOOGLE_CLOUD_PROJECT"):
return "GOOGLE_GENAI_USE_VERTEXAI"
return None
def _classify_antigravity_auth(
returncode: int, stdout: str, stderr: str
) -> tuple[bool | None, str]:
text = (stdout + "\n" + stderr).lower()
if "not authenticated" in text or ("authentication" in text and "required" in text):
return False, f"Not authenticated. {_AUTH_HINT}"
if "login required" in text or "please login" in text or "please sign in" in text:
return False, f"Not authenticated. {_AUTH_HINT}"
if "please set an auth method" in text:
return False, f"Not authenticated. {_AUTH_HINT}"
if "invalid api key" in text or ("api key" in text and "missing" in text):
return (
False,
"Antigravity API key missing or invalid. Set GEMINI_API_KEY or run `agy` to sign in.",
)
if returncode == 0:
return True, "Authenticated via Antigravity CLI."
if "network" in text or "timeout" in text or "unreachable" in text or "connection" in text:
return None, "Network error while checking auth; will retry at invocation."
tail = (stderr or stdout).strip()[:200]
if tail:
return None, f"Auth status unclear (exit {returncode}): {tail}"
return None, f"Auth status unclear (exit {returncode})."
def _fallback_antigravity_cli_paths() -> list[str]:
return _default_cli_fallback_paths("agy")
class AntigravityCLIAdapter:
"""Non-interactive Antigravity CLI (``agy -p`` headless mode)."""
name = "antigravity-cli"
binary_env_key = "ANTIGRAVITY_CLI_BIN"
install_hint = "curl -fsSL https://antigravity.google/cli/install.sh | bash"
auth_hint = _AUTH_HINT.removesuffix(".")
# 1.0.0 had OAuth-hang bugs fixed in 1.0.1; flag older installs at probe time.
min_version: str | None = "1.0.1"
default_exec_timeout_sec = _DEFAULT_EXEC_TIMEOUT_SEC
def _resolve_binary(self) -> str | None:
return resolve_cli_binary(
explicit_env_key="ANTIGRAVITY_CLI_BIN",
binary_names=_candidate_binary_names("agy"),
fallback_paths=_fallback_antigravity_cli_paths,
)
def _probe_binary(self, binary_path: str) -> CLIProbe:
version_output, version_error = run_version_probe(
binary_path,
timeout_sec=_PROBE_TIMEOUT_SEC,
)
if version_error:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=version_error,
)
version = parse_semver_three_part(version_output or "")
upgrade_note = ""
if (
self.min_version
and version
and semver_to_tuple(version) < semver_to_tuple(self.min_version)
):
upgrade_note = (
f" Antigravity CLI {version} is below tested minimum {self.min_version}; "
"upgrade: agy update"
)
probe_env = build_cli_subprocess_env(_antigravity_auth_env_overrides())
try:
auth_proc = subprocess.run(
[binary_path, "-p", "respond with: ok", "--print-timeout", "15s"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=_PROBE_TIMEOUT_SEC,
check=False,
env=probe_env,
)
except subprocess.TimeoutExpired:
logged_in: bool | None = None
auth_detail = (
f"Antigravity auth probe timed out after {_PROBE_TIMEOUT_SEC:.0f}s; "
"auth status unknown."
)
except OSError as exc:
logged_in = None
auth_detail = f"Could not spawn agy for auth probe: {exc}"
else:
logged_in, auth_detail = _classify_antigravity_auth(
auth_proc.returncode, auth_proc.stdout, auth_proc.stderr
)
auth_env_source = _has_explicit_antigravity_auth_env()
if logged_in is not True and auth_env_source:
logged_in = True
auth_detail = f"Authenticated via {auth_env_source} fallback."
return CLIProbe(
installed=True,
version=version,
logged_in=logged_in,
bin_path=binary_path,
detail=auth_detail + upgrade_note,
)
def detect(self) -> CLIProbe:
binary = self._resolve_binary()
if not binary:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=(
"Antigravity CLI (`agy`) not found on PATH or known install locations. "
f"Install with: {self.install_hint} or set ANTIGRAVITY_CLI_BIN."
),
)
return self._probe_binary(binary)
def build(
self,
*,
prompt: str,
model: str | None,
workspace: str,
reasoning_effort: str | None = None,
) -> CLIInvocation:
# ``model`` and ``reasoning_effort`` are accepted for protocol compatibility
# but ignored: agy 1.0.2 does not expose ``--model`` or reasoning knobs in
# headless ``-p`` mode (verified locally). Each invocation uses whatever
# model is persisted in agy's local config; users change it via ``/models``
# inside the REPL.
# TODO(antigravity-cli): once agy supports ``--model`` in headless, replace
# the ``del`` with a conditional ``argv.extend(["--model", model])`` block
# and lock the catalog into
# ``surfaces/cli/wizard/config.py:ANTIGRAVITY_CLI_MODELS``.
del model, reasoning_effort
binary = self._resolve_binary()
if not binary:
raise RuntimeError(
f"Antigravity CLI not found. {self.install_hint} "
"or set ANTIGRAVITY_CLI_BIN to the full binary path."
)
resolved_timeout = _resolve_exec_timeout_seconds()
argv: list[str] = [
binary,
"-p",
prompt,
"--print-timeout",
f"{int(resolved_timeout)}s",
]
ws = (workspace or "").strip()
cwd = ws or os.getcwd()
env = _antigravity_auth_env_overrides()
return CLIInvocation(
argv=tuple(argv),
stdin=None,
cwd=cwd,
env=env,
timeout_sec=resolved_timeout + _SUBPROCESS_TIMEOUT_BUFFER_SEC,
)
def parse(self, *, stdout: str, stderr: str, returncode: int) -> str:
result = (stdout or "").strip()
if not result:
raise RuntimeError(
self.explain_failure(stdout=stdout, stderr=stderr, returncode=returncode)
+ " (empty output)"
)
return result
def explain_failure(self, *, stdout: str, stderr: str, returncode: int) -> str:
from integrations.llm_cli.failure_explain import explain_cli_failure
return explain_cli_failure(
exit_label="agy -p",
stdout=stdout,
stderr=stderr,
returncode=returncode,
)
+64
View File
@@ -0,0 +1,64 @@
"""Shared types for LLM CLI adapters (non-interactive subprocess execution)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
@dataclass(frozen=True)
class CLIProbe:
"""Result of probing whether a CLI binary is usable (install + auth + version)."""
installed: bool
version: str | None
logged_in: bool | None
bin_path: str | None
detail: str
@dataclass(frozen=True)
class CLIInvocation:
"""A single non-interactive subprocess call (no TTY)."""
argv: tuple[str, ...]
stdin: str | None
cwd: str
env: dict[str, str] | None
timeout_sec: float
@runtime_checkable
class LLMCLIAdapter(Protocol):
"""Contract for one-shot, non-interactive LLM CLI execution."""
name: str
#: Env var for explicit binary path when not on PATH (e.g. ``CODEX_BIN``).
binary_env_key: str
install_hint: str
auth_hint: str
min_version: str | None
default_exec_timeout_sec: float
def detect(self) -> CLIProbe:
"""Resolve binary, version, and auth. Never raises; returns a structured probe."""
pass
def build(
self,
*,
prompt: str,
model: str | None,
workspace: str,
reasoning_effort: str | None = None,
) -> CLIInvocation:
"""Build argv for a non-interactive run (no approval prompts, no TTY)."""
pass
def parse(self, *, stdout: str, stderr: str, returncode: int) -> str:
"""Extract the model answer from a successful run."""
pass
def explain_failure(self, *, stdout: str, stderr: str, returncode: int) -> str:
"""Human-readable failure when returncode != 0 or output is unusable."""
pass
+252
View File
@@ -0,0 +1,252 @@
"""Shared binary resolution helpers for subprocess-backed CLI adapters.
Key public API
--------------
resolve_cli_binary(...)
Locate an executable using three-stage resolution:
1. Explicit ``*_BIN`` env override (e.g. ``CODEX_BIN``) — only used when the
path is runnable; logs a WARNING and falls through otherwise.
2. ``shutil.which`` PATH lookup for platform-specific binary names.
3. Conventional install-location fallbacks (npm, volta, pnpm, Homebrew, etc.).
diagnose_binary_path(path) -> str | None
Return a human-readable reason why *path* is not usable, or ``None`` when it
is fine. Distinguishes the following states so callers can surface actionable
messages to users:
+---------------------------------+----------------------------------------------------+
| Path state | Returned message (excerpt) |
+=================================+====================================================+
| Broken symlink | "'<path>' is a broken symlink (points to '<target>'). Remove or fix it." |
+---------------------------------+----------------------------------------------------+
| Does not exist | "'<path>' does not exist." |
+---------------------------------+----------------------------------------------------+
| Exists but is not a file | "'<path>' is not a file." |
+---------------------------------+----------------------------------------------------+
| File but not executable (Unix) | "'<path>' is not executable. Run: chmod +x <path>" |
+---------------------------------+----------------------------------------------------+
| File with wrong extension (Win) | "'<path>' is not a recognised executable (expected .cmd, .exe, .ps1, or .bat)." |
+---------------------------------+----------------------------------------------------+
| Valid runnable binary | ``None`` |
+---------------------------------+----------------------------------------------------+
On Windows the executable check uses file extension (``.cmd``, ``.exe``,
``.ps1``, ``.bat``) mirroring ``is_runnable_binary``, so both functions
accept and reject the same set of paths.
is_runnable_binary(path) -> bool
Low-level predicate used by ``resolve_cli_binary`` and the CLI wizard.
Prefer ``diagnose_binary_path`` when a user-facing message is needed.
Platform notes
--------------
* Windows binary names include ``.cmd``, ``.exe``, ``.ps1``, ``.bat`` suffixes;
``candidate_binary_names`` returns all four for a given base name.
* ``npm_prefix_bin_dirs`` is ``@lru_cache``-d — call ``.cache_clear()`` in tests
that vary ``NPM_CONFIG_PREFIX`` or ``sys.platform``.
* ``diagnose_binary_path`` reads the symlink target via ``Path.readlink()``
(Python ≥ 3.9) for a more actionable error message; falls back silently on
older hosts or permission errors.
"""
from __future__ import annotations
import contextlib
import logging
import os
import shutil
import subprocess
import sys
from collections.abc import Callable, Sequence
from functools import lru_cache
from pathlib import Path
logger = logging.getLogger(__name__)
def candidate_binary_names(binary_name: str) -> tuple[str, ...]:
"""Return platform-specific executable names for a CLI binary."""
if sys.platform == "win32":
return (
f"{binary_name}.cmd",
f"{binary_name}.exe",
f"{binary_name}.ps1",
f"{binary_name}.bat",
)
return (binary_name,)
def _append_candidate_paths(
candidates: list[str], directory: Path | str, names: tuple[str, ...]
) -> None:
base = str(directory).strip()
if not base:
return
root = Path(base).expanduser()
for name in names:
candidates.append(str(root / name))
@lru_cache(maxsize=1)
def npm_prefix_bin_dirs() -> tuple[str, ...]:
"""Resolve npm global bin directories from env and npm config."""
env_prefix = os.getenv("NPM_CONFIG_PREFIX", "").strip()
if not env_prefix:
# npm often exports lowercase `npm_config_prefix`; accept any casing.
for key, value in os.environ.items():
if key.lower() == "npm_config_prefix":
env_prefix = value.strip()
if env_prefix:
break
if env_prefix:
if sys.platform == "win32":
return (str(Path(env_prefix).expanduser()),)
return (str(Path(env_prefix).expanduser() / "bin"),)
try:
proc = subprocess.run(
["npm", "config", "get", "prefix"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=2.0,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
return ()
prefix = (proc.stdout or "").strip()
if proc.returncode != 0 or not prefix:
return ()
if sys.platform == "win32":
return (str(Path(prefix).expanduser()),)
return (str(Path(prefix).expanduser() / "bin"),)
def default_cli_fallback_paths(binary_name: str) -> list[str]:
"""Build common fallback install locations for a CLI binary."""
home = Path.home()
names = candidate_binary_names(binary_name)
candidates: list[str] = []
if sys.platform == "win32":
_append_candidate_paths(candidates, Path(os.getenv("APPDATA", "")) / "npm", names)
_append_candidate_paths(
candidates,
Path(os.getenv("LOCALAPPDATA", "")) / "Programs" / binary_name,
names,
)
# Match Unix branch: Volta / pnpm globals are common on Windows dev machines too.
localappdata = os.getenv("LOCALAPPDATA", "").strip()
volta_home = os.getenv("VOLTA_HOME", "").strip()
if volta_home:
_append_candidate_paths(candidates, Path(volta_home) / "bin", names)
elif localappdata:
_append_candidate_paths(candidates, Path(localappdata) / "Volta" / "bin", names)
_append_candidate_paths(candidates, os.getenv("PNPM_HOME", ""), names)
if localappdata:
_append_candidate_paths(candidates, Path(localappdata) / "pnpm", names)
else:
if sys.platform == "darwin":
_append_candidate_paths(candidates, "/opt/homebrew/bin", names)
_append_candidate_paths(candidates, "/usr/local/bin", names)
_append_candidate_paths(candidates, home / ".local/bin", names)
_append_candidate_paths(candidates, home / ".npm-global/bin", names)
_append_candidate_paths(candidates, home / ".volta/bin", names)
_append_candidate_paths(candidates, os.getenv("PNPM_HOME", ""), names)
xdg_data_home = os.getenv("XDG_DATA_HOME", "").strip()
if xdg_data_home:
_append_candidate_paths(candidates, Path(xdg_data_home) / "pnpm", names)
for npm_dir in npm_prefix_bin_dirs():
_append_candidate_paths(candidates, npm_dir, names)
deduped: list[str] = []
seen: set[str] = set()
for candidate in candidates:
normalized = str(Path(candidate).expanduser())
if normalized in seen:
continue
seen.add(normalized)
deduped.append(normalized)
return deduped
def is_runnable_binary(path: str) -> bool:
"""Return True when a path points to an executable binary/script."""
p = Path(path)
if not p.is_file():
return False
if sys.platform == "win32":
return p.suffix.lower() in {".cmd", ".exe", ".ps1", ".bat"} or os.access(p, os.X_OK)
return os.access(p, os.X_OK)
def diagnose_binary_path(path: str) -> str | None:
"""Return a human-readable reason why *path* is not runnable, or None if it is.
Distinguishes broken symlinks from missing files so callers can surface a
more actionable message than a generic "not found".
"""
p = Path(path)
if p.is_symlink() and not p.exists():
target = ""
with contextlib.suppress(OSError, AttributeError):
target = f" (points to '{p.readlink()}')"
return f"'{path}' is a broken symlink{target}. Remove or fix it."
if not p.exists():
return f"'{path}' does not exist."
if not p.is_file():
return f"'{path}' is not a file."
if sys.platform == "win32":
if p.suffix.lower() not in {".cmd", ".exe", ".ps1", ".bat"} and not os.access(p, os.X_OK):
return f"'{path}' is not a recognised executable (expected .cmd, .exe, .ps1, or .bat)."
elif not os.access(p, os.X_OK):
return f"'{path}' is not executable. Run: chmod +x {path}"
return None
def resolve_cli_binary(
*,
explicit_env_key: str,
binary_names: Sequence[str],
fallback_paths: Sequence[str] | Callable[[], Sequence[str]],
which_resolver: Callable[[str], str | None] | None = None,
runnable_check: Callable[[str], bool] | None = None,
) -> str | None:
"""Resolve an executable path from env override, PATH lookup, and fallbacks.
``which_resolver`` and ``runnable_check`` default to ``shutil.which`` and
``is_runnable_binary`` respectively. They are looked up at *call time* (not
bound as default parameter values) so that test patches on this module's
``shutil.which`` / ``is_runnable_binary`` take effect without callers having
to pass explicit overrides.
"""
_which = which_resolver if which_resolver is not None else shutil.which
_runnable = runnable_check if runnable_check is not None else is_runnable_binary
explicit = os.getenv(explicit_env_key, "").strip()
if explicit:
if _runnable(explicit):
return explicit
reason = diagnose_binary_path(explicit)
logger.warning(
"%s is set but unusable — falling back to PATH/defaults. %s",
explicit_env_key,
reason or "Not a runnable file.",
)
for name in binary_names:
found = _which(name)
if found:
return found
resolved_fallback_paths = fallback_paths() if callable(fallback_paths) else fallback_paths
for candidate in resolved_fallback_paths:
if _runnable(candidate):
return candidate
return None
+354
View File
@@ -0,0 +1,354 @@
"""Anthropic Claude Code CLI adapter (``claude -p``, non-interactive / print mode).
Env vars
--------
CLAUDE_CODE_BIN Optional explicit path to the ``claude`` binary.
Blank or non-runnable paths are ignored; PATH + fallbacks apply.
CLAUDE_CODE_MODEL Optional model override (e.g. ``claude-opus-4-7``).
Unset or empty → omit ``--model``; CLI default applies.
CLAUDE_CODE_TIMEOUT_SECONDS Optional invocation timeout override in seconds for long prompts
(default: 120, min: 30, max: 600).
Auth
----
When the ``claude`` binary is available, OpenSRE probes ``claude auth status``
and treats Claude subscription login as first-class auth. ``ANTHROPIC_API_KEY``
and ``~/.claude/.credentials.json`` (under ``Path.home()`` on all platforms)
are used as fallbacks when the binary is unavailable.
Platforms
---------
Binary resolution uses ``shutil.which`` with ``claude.cmd`` / ``claude.exe`` /
``.bat`` / ``.ps1`` on Windows, plus npm / Volta / pnpm style fallback dirs
(see ``default_cli_fallback_paths``). Without the CLI binary, macOS Keychain
may still hold OAuth credentials, so auth is reported as unclear until the
binary runs; Linux and Windows without env or creds file → not authenticated.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
from integrations.llm_cli.base import CLIInvocation, CLIProbe
from integrations.llm_cli.binary_resolver import (
candidate_binary_names as _candidate_binary_names,
)
from integrations.llm_cli.binary_resolver import (
default_cli_fallback_paths as _default_cli_fallback_paths,
)
from integrations.llm_cli.binary_resolver import (
resolve_cli_binary,
)
from integrations.llm_cli.constants import (
DEFAULT_EXEC_TIMEOUT_SEC as _DEFAULT_EXEC_TIMEOUT_SEC,
)
from integrations.llm_cli.constants import (
MAX_EXEC_TIMEOUT_SEC as _MAX_EXEC_TIMEOUT_SEC,
)
from integrations.llm_cli.constants import (
MIN_EXEC_TIMEOUT_SEC as _MIN_EXEC_TIMEOUT_SEC,
)
from integrations.llm_cli.env_overrides import (
ANTHROPIC_CLI_ENV_KEYS,
nonempty_env_values,
)
from integrations.llm_cli.probe_utils import run_version_probe
from integrations.llm_cli.semver_utils import parse_semver_three_part
from integrations.llm_cli.subprocess_env import build_cli_subprocess_env
from integrations.llm_cli.timeout_utils import resolve_timeout_from_env
# Claude Code's `--version` does config/cache init that can spike past Codex's 3s
# budget on cold starts or when another claude process holds shared state.
_PROBE_TIMEOUT_SEC = 8.0
_AUTH_HINT = "Run: claude auth login or set ANTHROPIC_API_KEY."
def _resolve_exec_timeout_seconds() -> float:
return resolve_timeout_from_env(
env_key="CLAUDE_CODE_TIMEOUT_SECONDS",
default=_DEFAULT_EXEC_TIMEOUT_SEC,
minimum=_MIN_EXEC_TIMEOUT_SEC,
maximum=_MAX_EXEC_TIMEOUT_SEC,
)
def _anthropic_env_overrides() -> dict[str, str]:
"""Build Claude subprocess auth/config overrides used by probe and invoke."""
env: dict[str, str] = {"NO_COLOR": "1"}
env.update(nonempty_env_values(ANTHROPIC_CLI_ENV_KEYS))
return env
def _anthropic_auth_env_source() -> str | None:
"""Return the active Anthropic auth env key, if present."""
env = _anthropic_env_overrides()
if env.get("ANTHROPIC_API_KEY"):
return "ANTHROPIC_API_KEY"
if env.get("ANTHROPIC_AUTH_TOKEN"):
return "ANTHROPIC_AUTH_TOKEN"
return None
def _auth_status_from_json_payload(data: dict) -> tuple[bool, str]:
"""Map `claude auth status` JSON object to (logged_in, user-facing detail).
The CLI emits ``apiKeySource`` whenever the env contributes an API key,
even when the active auth method is the subscription. Use ``authMethod``
(``claude.ai`` / ``api_key`` / ``none``) as the authoritative discriminator
for the detail string; ``apiKeySource`` and ``email`` are supporting detail
and are also used as a legacy fallback for older CLI versions that omit
``authMethod``.
"""
if not data.get("loggedIn"):
return False, f"Not authenticated. {_AUTH_HINT}"
auth_method = str(data.get("authMethod") or "").lower()
email = str(data.get("email") or "")
api_key_source = str(data.get("apiKeySource") or "")
if auth_method == "api_key":
source = api_key_source or "ANTHROPIC_API_KEY"
return True, f"Authenticated via {source}."
if auth_method == "claude.ai":
return True, f"Authenticated via Claude subscription{f' ({email})' if email else ''}."
if auth_method:
# Unrecognized but non-empty authMethod (e.g. a future "oauth" / "sso"):
# surface it verbatim instead of leaning on apiKeySource, which the CLI
# also populates for env-supplied API keys regardless of the active
# method and would mis-report the source.
return True, f"Authenticated via {auth_method}{f' ({email})' if email else ''}."
# Older CLI versions may omit authMethod — fall back to legacy heuristic.
if api_key_source:
return True, f"Authenticated via {api_key_source}."
if email:
return True, f"Authenticated via Claude subscription ({email})."
return True, "Authenticated via Claude CLI."
def _try_parse_auth_status_stdout(stdout: str) -> tuple[bool, str] | None:
"""If stdout is a JSON object from ``auth status``, return auth; else None."""
raw = (stdout or "").strip()
if not raw:
return None
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return None
if not isinstance(data, dict):
return None
return _auth_status_from_json_payload(data)
def _probe_cli_auth(binary_path: str) -> tuple[bool | None, str]:
"""Check Claude Code auth via `claude auth status` (local, no API call).
Returns ``(True, …)`` for any working auth (subscription or API key),
``(False, …)`` when the CLI definitively reports the user as not logged
in, and ``(None, …)`` only when the probe itself failed (timeout, spawn
error, or unparseable output from an older CLI).
The CLI exits **non-zero** when ``loggedIn`` is false (CLI ≥ 2.x) while
still printing valid JSON, so we parse stdout first and only fall back to
treating a non-zero exit as an opaque probe failure when the JSON is
absent or unparseable. The previous behaviour of returning ``None`` on
any non-zero exit hid the real "not logged in" state behind the wizard's
"could not verify" branch.
"""
try:
proc = subprocess.run(
[binary_path, "auth", "status"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=_PROBE_TIMEOUT_SEC,
check=False,
env=build_cli_subprocess_env(_anthropic_env_overrides()),
)
except subprocess.TimeoutExpired:
return (
None,
f"claude auth status timed out after {_PROBE_TIMEOUT_SEC:.0f} s — auth state unknown.",
)
except OSError as exc:
return None, f"Could not spawn claude for auth probe: {exc}"
parsed = _try_parse_auth_status_stdout(proc.stdout)
if parsed is not None:
return parsed
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip()[:500]
return None, f"claude auth status failed: {err or 'unknown error'}"
# Older CLI versions may not output JSON; classify explicit negative
# phrases first to avoid false positives like "Not logged in" (exit 0).
plain = (proc.stdout or proc.stderr or "").strip().lower()
negative_markers = (
"not logged in",
"not authenticated",
"login required",
"unauthenticated",
)
if any(marker in plain for marker in negative_markers):
return False, f"Not authenticated. {_AUTH_HINT}"
return True, "Authenticated via Claude CLI."
def _classify_claude_code_auth(binary_path: str | None = None) -> tuple[bool | None, str]:
"""Return (logged_in, detail) for Claude Code auth.
Resolution order:
1. Binary available → `claude auth status` is the source of truth for all
platforms; covers both subscription login and ANTHROPIC_API_KEY.
2. No binary, ANTHROPIC_API_KEY set → True (filesystem-independent fallback).
3. No binary, credentials file present → True (OAuth login).
4. No binary, macOS → None (Keychain may hold credentials; invocation will verify).
5. No binary, Linux/Windows → False.
"""
if binary_path:
return _probe_cli_auth(binary_path)
auth_env_source = _anthropic_auth_env_source()
if auth_env_source:
return True, f"Authenticated via {auth_env_source}."
creds_path = Path.home() / ".claude" / ".credentials.json"
try:
if creds_path.exists() and creds_path.stat().st_size > 2:
return True, "Authenticated via ~/.claude/.credentials.json (OAuth login)."
except OSError:
return None, "Could not read ~/.claude/.credentials.json; auth state unclear."
if sys.platform == "darwin":
return None, (f"Auth state unclear — binary unavailable for verification. {_AUTH_HINT}")
return (
False,
f"Not authenticated. {_AUTH_HINT}",
)
def _fallback_claude_code_paths() -> list[str]:
return _default_cli_fallback_paths("claude")
class ClaudeCodeAdapter:
"""Non-interactive Claude Code CLI (``claude -p``, print mode, no TTY)."""
name = "claude-code"
binary_env_key = "CLAUDE_CODE_BIN"
install_hint = "npm i -g @anthropic-ai/claude-code"
auth_hint = _AUTH_HINT.removesuffix(".")
min_version: str | None = None
default_exec_timeout_sec = _DEFAULT_EXEC_TIMEOUT_SEC
def _resolve_binary(self) -> str | None:
return resolve_cli_binary(
explicit_env_key="CLAUDE_CODE_BIN",
binary_names=_candidate_binary_names("claude"),
fallback_paths=_fallback_claude_code_paths,
)
def _probe_binary(self, binary_path: str) -> CLIProbe:
version_output, version_error = run_version_probe(
binary_path,
timeout_sec=_PROBE_TIMEOUT_SEC,
)
if version_error:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=version_error,
)
version = parse_semver_three_part(version_output or "")
logged_in, auth_detail = _classify_claude_code_auth(binary_path=binary_path)
auth_env_source = _anthropic_auth_env_source()
if logged_in is not True and auth_env_source:
logged_in = True
auth_detail = f"Authenticated via {auth_env_source} fallback."
return CLIProbe(
installed=True,
version=version,
logged_in=logged_in,
bin_path=binary_path,
detail=auth_detail,
)
def detect(self) -> CLIProbe:
binary = self._resolve_binary()
if not binary:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=(
"Claude Code CLI not found on PATH or known install locations. "
f"Install with: {self.install_hint} or set CLAUDE_CODE_BIN."
),
)
return self._probe_binary(binary)
def build(
self,
*,
prompt: str,
model: str | None,
workspace: str,
reasoning_effort: str | None = None,
) -> CLIInvocation:
_ = reasoning_effort
binary = self._resolve_binary()
if not binary:
raise RuntimeError(
f"Claude Code CLI not found. {self.install_hint}"
" or set CLAUDE_CODE_BIN to the full binary path."
)
ws = (workspace or "").strip()
cwd = str(Path(ws).expanduser()) if ws else os.getcwd()
argv: list[str] = [
binary,
"-p",
"--output-format",
"text",
]
resolved_model = (model or "").strip()
if resolved_model:
argv.extend(["--model", resolved_model])
# Forward Anthropic auth vars explicitly rather than relying on a blanket
# prefix allowlist, so they don't leak into other CLI adapters (e.g. Codex).
env = _anthropic_env_overrides()
return CLIInvocation(
argv=tuple(argv),
stdin=prompt,
cwd=cwd,
env=env,
timeout_sec=_resolve_exec_timeout_seconds(),
)
def parse(self, *, stdout: str, stderr: str, returncode: int) -> str:
result = (stdout or "").strip()
if not result:
raise RuntimeError(
self.explain_failure(stdout=stdout, stderr=stderr, returncode=returncode)
+ " (empty output)"
)
return result
def explain_failure(self, *, stdout: str, stderr: str, returncode: int) -> str:
from integrations.llm_cli.failure_explain import explain_cli_failure
return explain_cli_failure(
exit_label="claude -p",
stdout=stdout,
stderr=stderr,
returncode=returncode,
)
+252
View File
@@ -0,0 +1,252 @@
"""OpenAI Codex CLI adapter (`codex exec`, non-interactive).
OpenAI Platform env vars (``OPENAI_API_KEY``, ``OPENAI_ORG_ID``, ``OPENAI_PROJECT_ID``,
``OPENAI_BASE_URL``) are forwarded on invoke when set, so Codex runs work with
usage-based API key auth as well as ``codex login`` sessions.
"""
from __future__ import annotations
import os
import subprocess
from integrations.llm_cli.base import CLIInvocation, CLIProbe
from integrations.llm_cli.binary_resolver import (
candidate_binary_names as _candidate_binary_names,
)
from integrations.llm_cli.binary_resolver import (
default_cli_fallback_paths as _default_cli_fallback_paths,
)
from integrations.llm_cli.binary_resolver import (
resolve_cli_binary,
)
from integrations.llm_cli.constants import DEFAULT_EXEC_TIMEOUT_SEC
from integrations.llm_cli.env_overrides import (
OPENAI_PLATFORM_ENV_KEYS,
nonempty_env_values,
)
from integrations.llm_cli.probe_utils import run_version_probe
from integrations.llm_cli.semver_utils import parse_semver_three_part, semver_to_tuple
_PROBE_TIMEOUT_SEC = 3.0
_READ_ONLY_SANDBOX = "read-only"
_AUTH_STATUS_PROBE_ENV = "OPENSRE_CODEX_AUTH_STATUS_PROBE"
_TRUTHY_ENV_VALUES = {"1", "true", "yes", "on"}
def _classify_codex_auth(returncode: int, stdout: str, stderr: str) -> tuple[bool | None, str]:
text = (stdout + "\n" + stderr).lower()
# Negative phrases first: "logged in" is a substring of "not logged in".
if "not logged in" in text or "no credentials" in text:
return False, "Not logged in. Run: codex login"
if returncode == 0 and "logged in" in text:
return True, (stdout.strip() or stderr.strip() or "Logged in.").splitlines()[0]
if "expired" in text or ("invalid" in text and "token" in text):
return False, "Session expired. Re-authenticate: codex login"
if "rate limit" in text or "quota" in text:
return True, "Logged in but rate-limited; try again later."
if "network" in text or "unreachable" in text or "dns" in text or "connection refused" in text:
return None, "Network error while checking auth; will retry at invocation."
if returncode != 0:
tail = (stderr or stdout).strip()[:200]
return (
None,
f"Auth status unclear (exit {returncode}): {tail}"
if tail
else f"Auth status unclear (exit {returncode}).",
)
return None, "Auth status unknown."
def _codex_workspace_and_skip_git() -> tuple[str, bool]:
try:
proc = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=5.0,
check=False,
)
root = (proc.stdout or "").strip()
if proc.returncode == 0 and root:
return root, False
except (OSError, subprocess.TimeoutExpired):
# git missing, not a repo, or timed out — use cwd and let codex skip repo checks.
pass
return os.getcwd(), True
def _fallback_codex_paths() -> list[str]:
return _default_cli_fallback_paths("codex")
def _has_openai_api_key() -> bool:
return bool(os.environ.get("OPENAI_API_KEY", "").strip())
def _should_probe_codex_login_status() -> bool:
return os.environ.get(_AUTH_STATUS_PROBE_ENV, "").strip().lower() in _TRUTHY_ENV_VALUES
class CodexAdapter:
"""Non-interactive Codex CLI (`codex exec` with read-only sandbox)."""
name = "codex"
binary_env_key = "CODEX_BIN"
install_hint = "npm i -g @openai/codex"
auth_hint = "Run: codex login"
min_version: str | None = None
default_exec_timeout_sec = DEFAULT_EXEC_TIMEOUT_SEC
def _resolve_binary(self) -> str | None:
return resolve_cli_binary(
explicit_env_key="CODEX_BIN",
binary_names=_candidate_binary_names("codex"),
fallback_paths=_fallback_codex_paths,
)
def _probe_binary(self, binary_path: str) -> CLIProbe:
version_output, version_error = run_version_probe(
binary_path,
timeout_sec=_PROBE_TIMEOUT_SEC,
)
if version_error:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=version_error,
)
version = parse_semver_three_part(version_output or "")
upgrade_note = ""
if (
self.min_version
and version
and semver_to_tuple(version) < semver_to_tuple(self.min_version)
):
upgrade_note = (
f" Codex {version} is below tested minimum {self.min_version}; "
f"upgrade: {self.install_hint}@latest"
)
logged_in: bool | None
auth_detail: str
if not _should_probe_codex_login_status():
logged_in = None
auth_detail = "Codex CLI installed."
else:
try:
auth_proc = subprocess.run(
[binary_path, "login", "status"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=_PROBE_TIMEOUT_SEC,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
logged_in = None
auth_detail = "Could not verify login status (timeout or OS error)."
else:
logged_in, auth_detail = _classify_codex_auth(
auth_proc.returncode, auth_proc.stdout, auth_proc.stderr
)
if logged_in is not True and _has_openai_api_key():
# Allow API-key auth when ChatGPT/session login is absent or unclear.
logged_in = True
auth_detail = "Authenticated via OPENAI_API_KEY fallback."
detail = auth_detail + upgrade_note
return CLIProbe(
installed=True,
version=version,
logged_in=logged_in,
bin_path=binary_path,
detail=detail.strip(),
)
def detect(self) -> CLIProbe:
binary = self._resolve_binary()
if not binary:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail="Codex CLI not found on PATH or known install locations.",
)
return self._probe_binary(binary)
def build(
self,
*,
prompt: str,
model: str | None,
workspace: str,
reasoning_effort: str | None = None,
) -> CLIInvocation:
binary = self._resolve_binary()
if not binary:
raise RuntimeError(
"Codex CLI not found. Install with `npm i -g @openai/codex` or set CODEX_BIN."
)
ws, skip_git = _codex_workspace_and_skip_git()
if workspace:
ws = workspace
argv: list[str] = [
binary,
"exec",
"--ephemeral",
"-s",
_READ_ONLY_SANDBOX,
"--color",
"never",
"-C",
ws,
]
if skip_git:
argv.append("--skip-git-repo-check")
resolved_model = (model or "").strip()
if resolved_model:
argv.extend(["-m", resolved_model])
if reasoning_effort:
argv.extend(["-c", f'model_reasoning_effort="{reasoning_effort}"'])
argv.append("-")
oai = nonempty_env_values(OPENAI_PLATFORM_ENV_KEYS)
return CLIInvocation(
argv=tuple(argv),
stdin=prompt,
cwd=ws,
env=oai or None,
timeout_sec=self.default_exec_timeout_sec,
)
def parse(self, *, stdout: str, stderr: str, returncode: int) -> str:
result = (stdout or "").strip()
if not result:
raise RuntimeError(
self.explain_failure(stdout=stdout, stderr=stderr, returncode=returncode)
+ " (empty output)"
)
return result
def explain_failure(self, *, stdout: str, stderr: str, returncode: int) -> str:
from integrations.llm_cli.failure_explain import explain_cli_failure
return explain_cli_failure(
exit_label="codex exec",
stdout=stdout,
stderr=stderr,
returncode=returncode,
)
+514
View File
@@ -0,0 +1,514 @@
"""OpenSRE-managed OpenAI Codex OAuth browser login."""
from __future__ import annotations
import base64
import hashlib
import json
import os
import secrets
import tempfile
import threading
import webbrowser
from collections.abc import Callable, Mapping
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlencode, urlparse
import httpx
CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
CODEX_OAUTH_PORT = 1455
CODEX_OAUTH_CALLBACK_PATH = "/auth/callback"
CODEX_OAUTH_REDIRECT_URI = f"http://localhost:{CODEX_OAUTH_PORT}{CODEX_OAUTH_CALLBACK_PATH}"
CODEX_OAUTH_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
CODEX_OAUTH_SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke"
CODEX_OAUTH_AUTH_MODE = "chatgpt"
CODEX_OAUTH_TIMEOUT_SECONDS = 300.0
class CodexOAuthError(RuntimeError):
"""Raised when OpenSRE-managed Codex OAuth login fails."""
@dataclass(frozen=True)
class CodexOAuthResult:
"""Result of a completed Codex OAuth login."""
account_id: str
auth_path: Path
detail: str
@dataclass(frozen=True)
class _OAuthRequest:
state: str
code_verifier: str
authorize_url: str
@dataclass(frozen=True)
class _CallbackResult:
login: CodexOAuthResult | None = None
error: str = ""
error_description: str = ""
def codex_home() -> Path:
"""Return the Codex home directory used for auth persistence."""
override = os.getenv("CODEX_HOME", "").strip()
if override:
return Path(override).expanduser()
return Path.home() / ".codex"
def codex_auth_path() -> Path:
"""Return the Codex-compatible auth file path."""
return codex_home() / "auth.json"
def _base64_url_no_padding(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def _new_pkce_verifier() -> str:
return secrets.token_urlsafe(64)
def _pkce_challenge(verifier: str) -> str:
return _base64_url_no_padding(hashlib.sha256(verifier.encode("ascii")).digest())
def build_codex_oauth_request() -> _OAuthRequest:
"""Build the Codex OAuth authorize request with state and PKCE."""
state = secrets.token_urlsafe(32)
verifier = _new_pkce_verifier()
params = {
"response_type": "code",
"client_id": CODEX_OAUTH_CLIENT_ID,
"redirect_uri": CODEX_OAUTH_REDIRECT_URI,
"scope": CODEX_OAUTH_SCOPE,
"code_challenge": _pkce_challenge(verifier),
"code_challenge_method": "S256",
"id_token_add_organizations": "true",
"codex_cli_simplified_flow": "true",
"state": state,
"originator": "codex_cli_rs",
}
return _OAuthRequest(
state=state,
code_verifier=verifier,
authorize_url=f"{CODEX_OAUTH_AUTHORIZE_URL}?{urlencode(params)}",
)
class _CallbackHTTPServer(ThreadingHTTPServer):
expected_state: str
code_verifier: str
post: Callable[..., httpx.Response]
callback_result: _CallbackResult | None
callback_event: threading.Event
class _CallbackHandler(BaseHTTPRequestHandler):
server: _CallbackHTTPServer
def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path == "/success":
if self.server.callback_result is None:
self._handle_success_token_callback(parsed.query)
return
self._write_page(200, "OpenSRE OAuth login completed. You can close this tab.")
return
if parsed.path != CODEX_OAUTH_CALLBACK_PATH:
self.send_response(404)
self.end_headers()
return
query = parse_qs(parsed.query, keep_blank_values=True)
received_state = query.get("state", [""])[0]
if received_state != self.server.expected_state:
self.server.callback_result = _CallbackResult(
error="invalid_state",
error_description="OAuth callback state did not match the login request.",
)
self._write_page(
400,
"OpenSRE OAuth login failed. Return to the terminal and retry.",
)
self.server.callback_event.set()
return
provider_error = query.get("error", [""])[0]
if provider_error:
self.server.callback_result = _CallbackResult(
error=provider_error,
error_description=query.get("error_description", [""])[0],
)
self._write_page(
400,
"OpenSRE OAuth login was not completed. Return to the terminal.",
)
self.server.callback_event.set()
return
code = query.get("code", [""])[0].strip()
if not code:
self.server.callback_result = _CallbackResult(
error="missing_code",
error_description="OAuth callback did not include an authorization code.",
)
self._write_page(
400,
"OpenSRE OAuth login failed. Return to the terminal and retry.",
)
self.server.callback_event.set()
return
try:
token_response = exchange_codex_oauth_code(
code=code,
code_verifier=self.server.code_verifier,
post=self.server.post,
)
result = persist_codex_auth_tokens(token_response)
except CodexOAuthError as exc:
self.server.callback_result = _CallbackResult(
error="token_exchange_failed",
error_description=str(exc),
)
self._write_page(
400,
"OpenSRE OAuth login failed while saving credentials. Return to the terminal.",
)
self.server.callback_event.set()
return
self.server.callback_result = _CallbackResult(login=result)
self._redirect(_compose_success_url(token_response))
self.server.callback_event.set()
def log_message(self, _format: str, *_args: object) -> None:
"""Suppress callback URL logging so codes and state are not emitted."""
def _handle_success_token_callback(self, query_string: str) -> None:
query = parse_qs(query_string, keep_blank_values=True)
id_token = query.get("id_token", [""])[0].strip()
if not id_token:
self.server.callback_result = _CallbackResult(
error="missing_token",
error_description="OAuth success callback did not include an id_token.",
)
self._write_page(
400,
"OpenSRE OAuth login failed. Return to the terminal and retry.",
)
self.server.callback_event.set()
return
token_response = {
"access_token": query.get("access_token", [id_token])[0].strip() or id_token,
"refresh_token": query.get("refresh_token", [""])[0].strip(),
"id_token": id_token,
}
account_id = query.get("account_id", [""])[0].strip()
if account_id:
token_response["account_id"] = account_id
try:
result = persist_codex_auth_tokens(
token_response,
require_refresh_token=False,
detail_prefix="OpenAI OAuth success token stored",
)
except CodexOAuthError as exc:
self.server.callback_result = _CallbackResult(
error="token_persist_failed",
error_description=str(exc),
)
self._write_page(
400,
"OpenSRE OAuth login failed while saving credentials. Return to the terminal.",
)
self.server.callback_event.set()
return
self.server.callback_result = _CallbackResult(login=result)
self._write_page(200, "OpenSRE OAuth login completed. You can close this tab.")
self.server.callback_event.set()
def _redirect(self, location: str) -> None:
self.send_response(302)
self.send_header("Location", location)
self.send_header("Content-Length", "0")
self.end_headers()
def _write_page(self, status: int, message: str) -> None:
body = (
'<!doctype html><html><head><meta charset="utf-8">'
"<title>OpenSRE OAuth</title></head><body>"
f"<p>{message}</p></body></html>"
).encode()
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def wait_for_codex_oauth_callback(
*,
request: _OAuthRequest,
open_browser: Callable[[str], bool] = webbrowser.open,
post: Callable[..., httpx.Response] = httpx.post,
timeout_seconds: float = CODEX_OAUTH_TIMEOUT_SECONDS,
) -> CodexOAuthResult:
"""Open the browser and wait for OpenAI to redirect back with Codex auth."""
event = threading.Event()
try:
server = _CallbackHTTPServer(("localhost", CODEX_OAUTH_PORT), _CallbackHandler)
except OSError as exc:
raise CodexOAuthError(
f"Could not bind localhost:{CODEX_OAUTH_PORT}. "
f"Free port {CODEX_OAUTH_PORT}, then retry OpenAI OAuth login."
) from exc
server.expected_state = request.state
server.code_verifier = request.code_verifier
server.post = post
server.callback_result = None
server.callback_event = event
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
if not open_browser(request.authorize_url):
raise CodexOAuthError(
"Could not open the browser automatically. "
f"Open this URL manually to continue OAuth login: {request.authorize_url}"
)
if not event.wait(timeout_seconds):
raise CodexOAuthError("Timed out waiting for OpenAI OAuth callback.")
result = server.callback_result
if result is None:
raise CodexOAuthError("OAuth callback completed without a result.")
if result.error:
detail = f": {result.error_description}" if result.error_description else ""
raise CodexOAuthError(f"OpenAI OAuth callback failed ({result.error}){detail}.")
if result.login is None:
raise CodexOAuthError("OAuth callback completed without stored Codex auth.")
return result.login
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5.0)
def exchange_codex_oauth_code(
*,
code: str,
code_verifier: str,
post: Callable[..., httpx.Response] = httpx.post,
) -> dict[str, object]:
"""Exchange an authorization code for OpenAI OAuth tokens."""
try:
response = post(
CODEX_OAUTH_TOKEN_URL,
data={
"grant_type": "authorization_code",
"client_id": CODEX_OAUTH_CLIENT_ID,
"code": code,
"redirect_uri": CODEX_OAUTH_REDIRECT_URI,
"code_verifier": code_verifier,
},
headers={"Accept": "application/json"},
timeout=30.0,
)
except httpx.HTTPError as exc:
raise CodexOAuthError(f"OpenAI OAuth token exchange failed: {exc}") from exc
if response.status_code != 200:
raise CodexOAuthError(
f"OpenAI OAuth token exchange failed with HTTP {response.status_code}."
)
try:
data = response.json()
except ValueError as exc:
raise CodexOAuthError("OpenAI OAuth token exchange returned invalid JSON.") from exc
if not isinstance(data, dict):
raise CodexOAuthError("OpenAI OAuth token exchange returned an unexpected payload.")
return data
def _decode_jwt_payload(token: str) -> dict[str, object]:
parts = token.split(".")
if len(parts) < 2:
return {}
padded = parts[1] + "=" * (-len(parts[1]) % 4)
try:
decoded = base64.urlsafe_b64decode(padded.encode("ascii"))
data = json.loads(decoded)
except (ValueError, OSError):
return {}
return data if isinstance(data, dict) else {}
def _account_id_from_token_payload(payload: Mapping[str, object]) -> str:
auth_claim = payload.get("https://api.openai.com/auth")
if isinstance(auth_claim, Mapping):
chatgpt_account_id = auth_claim.get("chatgpt_account_id")
if isinstance(chatgpt_account_id, str) and chatgpt_account_id.strip():
return chatgpt_account_id.strip()
for key in ("account_id", "user_id", "sub"):
value = payload.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _required_token(data: Mapping[str, object], key: str) -> str:
value = data.get(key)
if not isinstance(value, str) or not value.strip():
raise CodexOAuthError(f"OpenAI OAuth token response did not include {key}.")
return value.strip()
def _utc_timestamp() -> str:
return datetime.now(UTC).replace(tzinfo=None).isoformat(timespec="microseconds") + "Z"
def codex_auth_payload(
token_response: Mapping[str, object],
*,
require_refresh_token: bool = True,
) -> dict[str, object]:
"""Convert OpenAI's token response to Codex's auth.json shape."""
access_token = _required_token(token_response, "access_token")
if require_refresh_token:
refresh_token = _required_token(token_response, "refresh_token")
else:
refresh_token = str(token_response.get("refresh_token") or "").strip()
id_token = _required_token(token_response, "id_token")
account_id = ""
raw_account_id = token_response.get("account_id")
if isinstance(raw_account_id, str):
account_id = raw_account_id.strip()
if not account_id:
account_id = _account_id_from_token_payload(_decode_jwt_payload(id_token))
if not account_id:
raise CodexOAuthError("Could not derive ChatGPT account id from OpenAI OAuth tokens.")
return {
"OPENAI_API_KEY": None,
"auth_mode": CODEX_OAUTH_AUTH_MODE,
"tokens": {
"access_token": access_token,
"refresh_token": refresh_token,
"id_token": id_token,
"account_id": account_id,
},
"last_refresh": _utc_timestamp(),
}
def persist_codex_auth_tokens(
token_response: Mapping[str, object],
*,
require_refresh_token: bool = True,
detail_prefix: str = "OpenAI OAuth tokens stored",
) -> CodexOAuthResult:
"""Persist an OpenAI token response in Codex-compatible auth.json format."""
payload = codex_auth_payload(
token_response,
require_refresh_token=require_refresh_token,
)
auth_path = write_codex_auth(payload)
account_id = str(payload["tokens"]["account_id"]) # type: ignore[index]
return CodexOAuthResult(
account_id=account_id,
auth_path=auth_path,
detail=f"{detail_prefix} for Codex at {auth_path}.",
)
def _compose_success_url(token_response: Mapping[str, object]) -> str:
id_token = _required_token(token_response, "id_token")
access_token = str(token_response.get("access_token") or "")
token_claims = _decode_jwt_payload(id_token)
access_claims = _decode_jwt_payload(access_token)
params = {
"id_token": id_token,
"needs_setup": str(
bool(token_claims.get("is_org_owner"))
and not bool(token_claims.get("completed_platform_onboarding"))
).lower(),
"org_id": str(token_claims.get("organization_id") or ""),
"project_id": str(token_claims.get("project_id") or ""),
"plan_type": str(access_claims.get("chatgpt_plan_type") or ""),
"platform_url": "https://platform.openai.com",
}
return f"http://localhost:{CODEX_OAUTH_PORT}/success?{urlencode(params)}"
def write_codex_auth(payload: Mapping[str, object], *, path: Path | None = None) -> Path:
"""Atomically write Codex auth.json with owner-only permissions."""
auth_path = path or codex_auth_path()
auth_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
with suppress(OSError):
auth_path.parent.chmod(0o700)
fd, tmp_name = tempfile.mkstemp(
prefix=f".{auth_path.name}.",
suffix=".tmp",
dir=str(auth_path.parent),
text=True,
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(dict(payload), handle, indent=2, sort_keys=True)
handle.write("\n")
os.chmod(tmp_name, 0o600)
os.replace(tmp_name, auth_path)
with suppress(OSError):
auth_path.chmod(0o600)
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
return auth_path
def run_codex_oauth_login(
*,
open_browser: Callable[[str], bool] = webbrowser.open,
post: Callable[..., httpx.Response] = httpx.post,
timeout_seconds: float = CODEX_OAUTH_TIMEOUT_SECONDS,
) -> CodexOAuthResult:
"""Complete the OpenSRE-managed Codex OAuth flow and persist auth.json."""
request = build_codex_oauth_request()
return wait_for_codex_oauth_callback(
request=request,
open_browser=open_browser,
post=post,
timeout_seconds=timeout_seconds,
)
__all__ = [
"CODEX_OAUTH_CALLBACK_PATH",
"CODEX_OAUTH_CLIENT_ID",
"CODEX_OAUTH_PORT",
"CODEX_OAUTH_REDIRECT_URI",
"CodexOAuthError",
"CodexOAuthResult",
"build_codex_oauth_request",
"codex_auth_path",
"codex_auth_payload",
"exchange_codex_oauth_code",
"persist_codex_auth_tokens",
"run_codex_oauth_login",
"wait_for_codex_oauth_callback",
"write_codex_auth",
]
+20
View File
@@ -0,0 +1,20 @@
"""Shared constants for subprocess-backed LLM CLI adapters.
Keep this module focused on values reused by multiple adapters/runner paths.
Provider-specific constants should remain in each adapter module.
"""
from __future__ import annotations
from typing import Final
# Runner cache/retry knobs.
PROBE_CACHE_TTL_SEC: Final[float] = 45.0
EX_TEMPFAIL: Final[int] = 75
TEMPFAIL_MAX_RETRIES: Final[int] = 2
TEMPFAIL_BACKOFF_SEC: Final[float] = 2.0
# Shared default for CLI subprocess invokes (investigation ReAct sends large prompts).
DEFAULT_EXEC_TIMEOUT_SEC: Final[float] = 300.0
MIN_EXEC_TIMEOUT_SEC: Final[float] = 30.0
MAX_EXEC_TIMEOUT_SEC: Final[float] = 600.0
+352
View File
@@ -0,0 +1,352 @@
"""GitHub Copilot CLI adapter (``copilot -p``, non-interactive / programmatic mode).
Env vars
--------
COPILOT_BIN Optional explicit path to the ``copilot`` binary.
Blank or non-runnable paths are ignored; PATH + fallbacks apply.
COPILOT_MODEL Optional model override. Unset or empty → omit ``--model``;
the CLI default applies.
COPILOT_HOME Optional config directory override. Defaults to ``~/.copilot``.
Auth probe
----------
Copilot CLI does **not** expose a non-interactive auth-status subcommand.
``copilot login`` opens an OAuth device flow; ``/login`` / ``/logout`` are
slash commands that only work inside an interactive session.
We classify auth in this order (cheap probes only — no Copilot network call):
1. ``COPILOT_GITHUB_TOKEN`` / ``GH_TOKEN`` / ``GITHUB_TOKEN`` env var set
→ ``True``. These are the documented headless/CI auth fallbacks that the
CLI checks before anything else.
2. ``gh auth status`` (when ``gh`` is on PATH) → parse stdout/stderr using
strings that match current ``gh`` output (for example ``✓ Logged in to
github.com account …``, ``- Active account: true``, or a ``- Token:`` line
whose prefix is a Copilot-supported token type per GitHub docs: ``gho_``,
``github_pat_``, ``ghu_`` — **not** ``ghp_``). If ``COPILOT_GH_HOST`` or
``GH_HOST`` targets a non-default host, we run ``gh auth status --hostname …``
as documented for GitHub Enterprise / data residency. Clearly logged-out
phrasing → ``False``; spawn error / timeout / ambiguous → ``None``.
Plaintext ``config.json`` under ``$COPILOT_HOME`` is **not** read: it is easy
to mis-classify and keychain-backed logins omit it anyway.
This matches Copilot's documented **GitHub CLI fallback**. **BYOK /
``COPILOT_OFFLINE``**: no GitHub token may be required; probe may still return
``None`` while ``copilot -p`` works — invoke-time failure is the real check.
3. Otherwise → ``None``. Auth state cannot be verified from env + ``gh``.
The runner appends the auth hint on a non-zero exit; the wizard offers retry
/ repick.
Note: OS-level credential stores (macOS Keychain, Windows Credential
Manager, Linux libsecret) are intentionally **not** probed. Service-name
lookups are fragile across CLI versions and ``gh auth status`` already covers
the main interactive-login path more reliably on every platform.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
from pathlib import Path
from integrations.llm_cli.base import CLIInvocation, CLIProbe
from integrations.llm_cli.binary_resolver import (
candidate_binary_names as _candidate_binary_names,
)
from integrations.llm_cli.binary_resolver import (
default_cli_fallback_paths as _default_cli_fallback_paths,
)
from integrations.llm_cli.binary_resolver import (
resolve_cli_binary,
)
from integrations.llm_cli.constants import DEFAULT_EXEC_TIMEOUT_SEC
from integrations.llm_cli.env_overrides import (
COPILOT_CLI_CONFIG_ENV_KEYS,
COPILOT_CLI_ENV_KEYS,
nonempty_env_values,
)
from integrations.llm_cli.probe_utils import run_version_probe
from integrations.llm_cli.semver_utils import parse_semver_three_part
_PROBE_TIMEOUT_SEC = 5.0
_GH_AUTH_TIMEOUT_SEC = 5.0
# ``gh auth status`` prints a line like `` ✓ Logged in to github.com account …``.
# Match with or without the leading checkmark (``gh`` versions differ).
_GH_LOGGED_IN_ACCOUNT_LINE = re.compile(
r"(?m)^\s*(?:✓\s*)?logged in to\s+\S+\s+account\b",
re.IGNORECASE,
)
# Copilot-supported token prefixes (classic ``ghp_`` is not supported by Copilot CLI).
_GH_TOKEN_LINE = re.compile(
r"(?m)^\s*-\s*token:\s*(gho_|github_pat_|ghu_)",
re.IGNORECASE,
)
# Hard negatives only — avoid ``gh auth login`` alone (could appear in unrelated text).
_GH_LOGGED_OUT_PHRASES = (
"not logged in",
"you are not logged into any github hosts",
"you are not logged into any hosts",
"no accounts",
)
_AUTH_HINT = (
"Run `copilot login` or `gh auth login`, or set COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN."
)
def _has_token_env() -> str | None:
"""Return the first set token env var name, if any."""
for key in COPILOT_CLI_ENV_KEYS:
if os.environ.get(key, "").strip():
return key
return None
def _gh_auth_status_argv(gh_bin: str) -> list[str]:
"""Build ``gh auth status`` argv; add ``--hostname`` for non-default GitHub hosts.
See GitHub Copilot docs (authenticate with GitHub CLI) and ``gh`` docs:
``gh auth status --hostname HOST`` for Enterprise / data residency when
``github.com`` is not the active host.
"""
argv: list[str] = [gh_bin, "auth", "status"]
host = os.environ.get("COPILOT_GH_HOST", "").strip() or os.environ.get("GH_HOST", "").strip()
if not host:
return argv
normalized = host.lower().rstrip("/").removeprefix("https://").removeprefix("http://")
if normalized in {"", "github.com", "api.github.com"}:
return argv
argv.extend(["--hostname", host])
return argv
def _gh_output_indicates_logged_in(stdout: str, stderr: str) -> bool:
"""Return True when ``gh auth status`` output clearly shows an authenticated host."""
text = f"{stdout}\n{stderr}"
lowered = text.lower()
return (
"active account: true" in lowered
or bool(_GH_LOGGED_IN_ACCOUNT_LINE.search(text))
or bool(_GH_TOKEN_LINE.search(text))
)
def _classify_gh_auth_status() -> tuple[bool | None, str]:
"""Run ``gh auth status`` and classify its output into the three-state contract.
Returns ``(logged_in, detail)`` where:
- ``True`` — ``gh`` clearly reports an active session.
- ``False`` — ``gh`` clearly reports no accounts / not logged in.
- ``None`` — ``gh`` not on PATH, spawn failed, timed out, or output is
ambiguous (auth then resolved as unknown if no token env).
Timeouts and errors map to ``None`` (not ``False``) per AGENTS.md: the
user may be on a flaky network and should not be forced to re-authenticate.
Negative phrases are checked **before** positive ones to avoid substring
false-positives (e.g. "not logged in" contains "logged in").
"""
gh_bin = shutil.which("gh")
if not gh_bin:
return None, ""
argv = _gh_auth_status_argv(gh_bin)
try:
proc = subprocess.run(
argv,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=_GH_AUTH_TIMEOUT_SEC,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
return None, ""
combined = f"{proc.stdout}\n{proc.stderr}".lower()
# Check negative phrases first to avoid substring false-positives.
if any(phrase in combined for phrase in _GH_LOGGED_OUT_PHRASES):
return False, "gh auth status: not logged in. Run `gh auth login` or set a token env var."
if _gh_output_indicates_logged_in(proc.stdout or "", proc.stderr or ""):
return True, "Authenticated via `gh` CLI session (gh auth status)."
return None, ""
def _classify_copilot_auth() -> tuple[bool | None, str]:
"""Resolve auth state without spawning the Copilot CLI itself.
Probe order (see module docstring for rationale):
1. Token env var.
2. ``gh auth status`` (covers interactive ``gh``-backed login on all platforms).
3. ``None`` — genuinely unknown.
"""
token_key = _has_token_env()
if token_key:
return True, f"Authenticated via {token_key}."
gh_logged_in, gh_detail = _classify_gh_auth_status()
if gh_logged_in is not None:
return gh_logged_in, gh_detail
return (
None,
f"Could not verify Copilot CLI auth (no token env, gh session not verified or gh not installed). "
f"{_AUTH_HINT}",
)
def _fallback_copilot_paths() -> list[str]:
return _default_cli_fallback_paths("copilot")
class CopilotAdapter:
"""Non-interactive GitHub Copilot CLI (``copilot -p``, programmatic mode)."""
name = "copilot"
binary_env_key = "COPILOT_BIN"
install_hint = "npm i -g @github/copilot"
auth_hint = _AUTH_HINT.removesuffix(".")
min_version: str | None = None
default_exec_timeout_sec = DEFAULT_EXEC_TIMEOUT_SEC
def _resolve_binary(self) -> str | None:
return resolve_cli_binary(
explicit_env_key="COPILOT_BIN",
binary_names=_candidate_binary_names("copilot"),
fallback_paths=_fallback_copilot_paths,
)
def _probe_binary(self, binary_path: str) -> CLIProbe:
version_output, version_error = run_version_probe(
binary_path,
timeout_sec=_PROBE_TIMEOUT_SEC,
)
if version_error:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=version_error,
)
version = parse_semver_three_part(version_output or "")
logged_in, auth_detail = _classify_copilot_auth()
return CLIProbe(
installed=True,
version=version,
logged_in=logged_in,
bin_path=binary_path,
detail=auth_detail,
)
def detect(self) -> CLIProbe:
binary = self._resolve_binary()
if not binary:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=(
"Copilot CLI not found on PATH or known install locations. "
f"Install with: {self.install_hint} or set COPILOT_BIN."
),
)
return self._probe_binary(binary)
def build(
self,
*,
prompt: str,
model: str | None,
workspace: str,
reasoning_effort: str | None = None,
) -> CLIInvocation:
# Copilot CLI does not expose a reasoning-effort knob; accept the param
# for protocol parity and discard it (same shape as ClaudeCodeAdapter).
del reasoning_effort
binary = self._resolve_binary()
if not binary:
raise RuntimeError(
f"Copilot CLI not found. {self.install_hint} "
"or set COPILOT_BIN to the full binary path."
)
ws = (workspace or "").strip()
cwd = str(Path(ws).expanduser()) if ws else os.getcwd()
# Each flag is required for a non-interactive run; do not drop these
# without checking `copilot --help`:
# -p PROMPT enters one-shot mode (without it, copilot opens a TUI).
# --no-color strips ANSI so stdout is parseable.
# --no-ask-user disables the agent's `ask_user` tool, otherwise the
# agent can pause waiting for input even with -p.
# --silent emits only the agent response, not stats / banner.
argv: list[str] = [
binary,
"-p",
prompt,
"--no-color",
"--no-ask-user",
"--silent",
]
resolved_model = (model or "").strip()
if resolved_model:
argv.extend(["--model", resolved_model])
# Forward Copilot's config + credential envs exclusively via this
# invocation env. The global subprocess prefix allowlist deliberately
# does NOT include ``COPILOT_`` (would leak ``COPILOT_GITHUB_TOKEN``,
# a GitHub PAT, into every other CLI subprocess).
env = {
**nonempty_env_values(COPILOT_CLI_CONFIG_ENV_KEYS),
**nonempty_env_values(COPILOT_CLI_ENV_KEYS),
}
return CLIInvocation(
argv=tuple(argv),
stdin=None,
cwd=cwd,
env=env or None,
timeout_sec=self.default_exec_timeout_sec,
)
def parse(self, *, stdout: str, stderr: str, returncode: int) -> str:
result = (stdout or "").strip()
if not result:
raise RuntimeError(
self.explain_failure(stdout=stdout, stderr=stderr, returncode=returncode)
+ " (empty output)"
)
return result
def explain_failure(self, *, stdout: str, stderr: str, returncode: int) -> str:
from integrations.llm_cli.failure_explain import explain_cli_failure
err = (stderr or "").strip()
out = (stdout or "").strip()
text = f"{err}\n{out}".lower()
auth_markers = (
"not logged in",
"not authenticated",
"no credentials",
"please /login",
"unauthorized",
"401",
)
extra = (_AUTH_HINT,) if any(marker in text for marker in auth_markers) else ()
return explain_cli_failure(
exit_label="copilot -p",
stdout=stdout,
stderr=stderr,
returncode=returncode,
extra_messages=extra,
always_include_output_snippet=True,
)
+236
View File
@@ -0,0 +1,236 @@
"""Cursor Agent CLI adapter (`agent --print`, non-interactive)."""
from __future__ import annotations
import os
import re
import subprocess
from integrations.llm_cli.base import CLIInvocation, CLIProbe
from integrations.llm_cli.binary_resolver import (
candidate_binary_names as _candidate_binary_names,
)
from integrations.llm_cli.binary_resolver import (
default_cli_fallback_paths as _default_cli_fallback_paths,
)
from integrations.llm_cli.binary_resolver import resolve_cli_binary
from integrations.llm_cli.constants import DEFAULT_EXEC_TIMEOUT_SEC
from integrations.llm_cli.env_overrides import CURSOR_CLI_ENV_KEYS, nonempty_env_values
from integrations.llm_cli.probe_utils import run_version_probe
_CURSOR_VERSION_RE = re.compile(r"(\d{4}\.\d{2}\.\d{2}-[a-zA-Z0-9]+|\d+\.\d+\.\d+)")
# `agent status` often hits the network and prints a short spinner; ~4s locally is common,
# so 3s probes spuriously timed out during wizard/doctor detection.
_PROBE_TIMEOUT_SEC = 15.0
def _parse_version(text: str) -> str | None:
match = _CURSOR_VERSION_RE.search(text or "")
return match.group(1) if match else None
def _has_cursor_api_key() -> bool:
return bool(os.environ.get("CURSOR_API_KEY", "").strip())
def _classify_cursor_auth(returncode: int, stdout: str, stderr: str) -> tuple[bool | None, str]:
"""Map ``agent status`` output to ``logged_in`` + detail (negative phrases first)."""
text = (stdout + "\n" + stderr).lower()
# Negative phrases first: "logged in" is a substring of "not logged in".
if "not logged in" in text or "authentication required" in text:
return False, "Not logged in. Run: agent login."
if returncode == 0 and "logged in as" in text:
line = (stdout.strip() or stderr.strip() or "Logged in.").splitlines()[0]
return True, line
if "network" in text or "unreachable" in text or "dns" in text or "connection refused" in text:
return None, "Network error while checking auth; try again or verify connectivity."
if returncode != 0:
tail = (stderr or stdout).strip()[:200]
return (
None,
f"Auth status unclear (exit {returncode}): {tail}"
if tail
else f"Auth status unclear (exit {returncode}).",
)
combined = stdout.strip() or stderr.strip()
return None, combined or "Could not determine Cursor Agent auth status."
class CursorAdapter:
"""Non-interactive Cursor Agent CLI adapter (`agent --print`).
Optional env (see registry ``CURSOR_MODEL``): ``CURSOR_BIN`` explicit binary path,
``CURSOR_MODEL`` model override. Headless auth uses ``CURSOR_API_KEY``, merged into
``CLIInvocation.env`` via ``env_overrides.CURSOR_CLI_ENV_KEYS`` (runner-safe prefixes still apply).
"""
name = "cursor"
binary_env_key = "CURSOR_BIN"
install_hint = "Install Cursor Agent with: curl https://cursor.com/install -fsS | bash"
auth_hint = "Run: agent login."
min_version: str | None = None
default_exec_timeout_sec = DEFAULT_EXEC_TIMEOUT_SEC
def _resolve_binary(self) -> str | None:
return resolve_cli_binary(
explicit_env_key="CURSOR_BIN",
binary_names=_candidate_binary_names("agent"),
fallback_paths=_default_cli_fallback_paths("agent"),
)
def detect(self) -> CLIProbe:
binary = self._resolve_binary()
if not binary:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=(
"Cursor Agent CLI not found. Install with "
"`curl https://cursor.com/install -fsS | bash` or set CURSOR_BIN."
),
)
version_output, version_error = run_version_probe(binary, timeout_sec=_PROBE_TIMEOUT_SEC)
if version_error:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=version_error,
)
version = _parse_version(version_output or "")
if not version:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail="Binary found but does not appear to be Cursor Agent CLI.",
)
try:
status_proc = subprocess.run(
[binary, "status"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=_PROBE_TIMEOUT_SEC,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
detail = f"Could not verify auth status with `{binary} status`: {exc}"
logged_in: bool | None = None
if _has_cursor_api_key():
logged_in = True
reason = (
"timed out" if isinstance(exc, subprocess.TimeoutExpired) else f"failed ({exc})"
)
detail = (
f"Cursor Agent auth probe {reason}; "
"headless auth via CURSOR_API_KEY is configured."
)
return CLIProbe(
installed=True,
version=version,
logged_in=logged_in,
bin_path=binary,
detail=detail,
)
logged_in, detail = _classify_cursor_auth(
status_proc.returncode, status_proc.stdout, status_proc.stderr
)
if logged_in is None and _has_cursor_api_key():
# Allow API-key auth only when session status is unclear — not when CLI says logged out.
logged_in = True
detail = "Auth status unclear, headless auth via environment is configured."
return CLIProbe(
installed=True,
version=version,
logged_in=logged_in,
bin_path=binary,
detail=detail,
)
def build(
self,
*,
prompt: str,
model: str | None,
workspace: str,
reasoning_effort: str | None = None,
) -> CLIInvocation:
_ = reasoning_effort
binary = self._resolve_binary()
if not binary:
raise RuntimeError(
"Cursor Agent CLI not found. Install with "
"`curl https://cursor.com/install -fsS | bash` or set CURSOR_BIN."
)
ws = workspace or os.getcwd()
argv: list[str] = [
binary,
"--print",
"--trust",
"--output-format",
"text",
"--workspace",
ws,
]
resolved_model = (model or "").strip()
if resolved_model:
argv.extend(["--model", resolved_model])
cursor_env = nonempty_env_values(CURSOR_CLI_ENV_KEYS)
return CLIInvocation(
argv=tuple(argv),
stdin=prompt,
cwd=ws,
env=cursor_env or None,
timeout_sec=self.default_exec_timeout_sec,
)
def parse(self, *, stdout: str, stderr: str, returncode: int) -> str:
result = (stdout or "").strip()
if not result:
if returncode == 0:
raise RuntimeError("Cursor Agent CLI returned empty output.")
raise RuntimeError(
self.explain_failure(stdout=stdout, stderr=stderr, returncode=returncode)
+ " (empty output)"
)
return result
def explain_failure(self, *, stdout: str, stderr: str, returncode: int) -> str:
from integrations.llm_cli.failure_explain import explain_cli_failure
err = (stderr or "").strip()
out = (stdout or "").strip()
text = f"{err}\n{out}"
extra: tuple[str, ...] = ()
if "Authentication required" in text or "Not logged in" in text:
extra = ("Not logged in. Run: agent login.",)
elif "Workspace Trust Required" in text:
extra = ("Workspace trust required. The adapter uses --trust for headless runs.",)
elif "Named models unavailable" in text:
extra = (
"Model unavailable for this account. Use CURSOR_MODEL=auto or omit the model override.",
)
return explain_cli_failure(
exit_label="cursor agent",
stdout=stdout,
stderr=stderr,
returncode=returncode,
extra_messages=extra,
)
+127
View File
@@ -0,0 +1,127 @@
"""Pick CLI subprocess ``env`` overrides from ``os.environ``.
``build_cli_subprocess_env`` only forwards a safe key/prefix subset from the parent process.
Vendor CLIs still need HTTP credentials sometimes; adapters merge ``nonempty_env_values(...)``
into ``CLIInvocation.env`` (same idea as Codex ``OPENAI_*``, Cursor ``CURSOR_API_KEY``, OpenCode HTTP keys).
Keep ``HTTP_LLM_PROVIDER_ENV_KEYS`` aligned with ``LLMSettings`` / ``config/config.py`` API-key env
names when adding HTTP LLM providers.
"""
from __future__ import annotations
import os
from typing import Final
OPENAI_PLATFORM_ENV_KEYS: Final[tuple[str, ...]] = (
"OPENAI_API_KEY",
"OPENAI_ORG_ID",
"OPENAI_PROJECT_ID",
"OPENAI_BASE_URL",
)
HTTP_LLM_PROVIDER_ENV_KEYS: Final[tuple[str, ...]] = (
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"OPENROUTER_API_KEY",
"DEEPSEEK_API_KEY",
"GEMINI_API_KEY",
"NVIDIA_API_KEY",
"MINIMAX_API_KEY",
"OPENAI_ORG_ID",
"OPENAI_PROJECT_ID",
"OPENAI_BASE_URL",
)
ANTHROPIC_CLI_ENV_KEYS: Final[tuple[str, ...]] = (
"ANTHROPIC_API_KEY",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
)
CURSOR_CLI_ENV_KEYS: Final[tuple[str, ...]] = ("CURSOR_API_KEY",)
# xAI Grok Build CLI credential envs. ``XAI_API_KEY`` is a secret and MUST NOT
# flow through the global ``_SAFE_SUBPROCESS_ENV_PREFIXES`` allowlist (a blanket
# ``XAI_`` prefix would forward the key into every other CLI subprocess). The
# Grok adapter forwards these *exclusively* via ``CLIInvocation.env`` so they
# only reach the Grok subprocess. ``XAI_BASE_URL`` supports enterprise / custom
# endpoints.
XAI_CLI_ENV_KEYS: Final[tuple[str, ...]] = (
"XAI_API_KEY",
"XAI_BASE_URL",
)
# Non-credential Copilot CLI config envs forwarded only via the Copilot
# adapter's ``CLIInvocation.env``. They are deliberately NOT in
# ``_SAFE_SUBPROCESS_ENV_PREFIXES``: scoping them to the Copilot subprocess
# avoids confusing other vendor CLIs with vars they do not consume.
# ``GH_HOST`` / ``COPILOT_GH_HOST`` are hostname routing for GitHub Enterprise /
# alternate GitHub endpoints (same semantics as ``gh auth status --hostname``);
# Copilot CLI must see them alongside the auth probe.
COPILOT_CLI_CONFIG_ENV_KEYS: Final[tuple[str, ...]] = (
"COPILOT_HOME",
"COPILOT_MODEL",
"COPILOT_GH_HOST",
"GH_HOST",
)
# Copilot CLI credential envs. ``COPILOT_GITHUB_TOKEN`` is a GitHub PAT and
# MUST NOT flow through the global ``_SAFE_SUBPROCESS_ENV_PREFIXES`` allowlist
# (a ``COPILOT_`` prefix entry would forward this PAT into every CLI
# subprocess — Codex, Kimi, Claude Code, etc. — which is a credential-leak
# regression). The Copilot adapter forwards these *exclusively* via
# ``CLIInvocation.env`` so they only reach the Copilot subprocess.
# ``GH_TOKEN`` / ``GITHUB_TOKEN`` are non-prefixed for the same reason.
COPILOT_CLI_ENV_KEYS: Final[tuple[str, ...]] = (
"COPILOT_GITHUB_TOKEN",
"GH_TOKEN",
"GITHUB_TOKEN",
)
# Pi CLI (pi.dev) is bring-your-own-key across ~30 providers; it reads a
# per-provider API-key env var (see https://pi.dev/docs/latest/providers).
# These are secrets, so — like the Grok/Copilot tuples above — they are
# forwarded *exclusively* via the Pi adapter's ``CLIInvocation.env`` and are
# NOT covered by the ``PI_`` entry in ``_SAFE_SUBPROCESS_ENV_PREFIXES`` (that
# prefix only carries Pi's own non-secret ``PI_*`` config vars). Keep this list
# aligned with Pi's provider catalog when it adds providers.
PI_PROVIDER_ENV_KEYS: Final[tuple[str, ...]] = (
"ANTHROPIC_API_KEY",
"ANT_LING_API_KEY",
"AZURE_OPENAI_API_KEY",
"OPENAI_API_KEY",
"DEEPSEEK_API_KEY",
"NVIDIA_API_KEY",
"GEMINI_API_KEY",
"MISTRAL_API_KEY",
"GROQ_API_KEY",
"CEREBRAS_API_KEY",
"CLOUDFLARE_API_KEY",
"XAI_API_KEY",
"OPENROUTER_API_KEY",
"AI_GATEWAY_API_KEY",
"ZAI_API_KEY",
"ZAI_CODING_CN_API_KEY",
"OPENCODE_API_KEY",
"HF_TOKEN",
"FIREWORKS_API_KEY",
"TOGETHER_API_KEY",
"KIMI_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_CN_API_KEY",
"XIAOMI_API_KEY",
"XIAOMI_TOKEN_PLAN_CN_API_KEY",
"XIAOMI_TOKEN_PLAN_AMS_API_KEY",
"XIAOMI_TOKEN_PLAN_SGP_API_KEY",
)
def nonempty_env_values(keys: tuple[str, ...]) -> dict[str, str]:
"""Return ``{name: value}`` for keys with non-empty stripped values in ``os.environ``."""
out: dict[str, str] = {}
for key in keys:
val = os.environ.get(key, "").strip()
if val:
out[key] = val
return out
+43
View File
@@ -0,0 +1,43 @@
"""Errors raised by subprocess-backed LLM CLI adapters."""
from __future__ import annotations
class CLITimeoutError(RuntimeError):
"""The CLI subprocess exceeded its configured timeout.
Treated as an expected operational failure (not a bug), so callers should
not forward it to error-tracking services like Sentry.
"""
class CLIAuthenticationRequired(RuntimeError):
"""CLI probe reported the user is definitely not authenticated (`logged_in=False`).
Investigation / streaming entrypoints map this to :class:`OpenSREError` so the
CLI prints a short message and suggestion instead of a traceback.
"""
def __init__(self, *, provider: str, auth_hint: str, detail: str) -> None:
self.provider = provider
self.auth_hint = auth_hint
self.detail = detail
super().__init__(f"{provider} is not authenticated. {auth_hint} ({detail})")
class CLITransientError(RuntimeError):
"""CLI subprocess exited with a transient failure code (e.g. EX_TEMPFAIL = 75).
Treated as an expected operational failure (not a bug), so callers should
not forward it to error-tracking services like Sentry.
"""
class CLIInterruptedError(RuntimeError):
"""CLI subprocess was terminated by SIGINT (exit code 130, Ctrl+C).
Inherits from :class:`RuntimeError` (not :class:`KeyboardInterrupt`) so that
callers wrapping ``invoke()`` in ``try/except Exception`` keep their existing
control flow contract. Sentry is configured to ignore this type via
``ignore_errors`` so user initiated cancellations do not surface as bugs.
"""
+82
View File
@@ -0,0 +1,82 @@
"""Shared failure explanation for all LLM CLI adapters.
Adapters call :func:`explain_cli_failure` from ``explain_failure`` so generic
quota/auth/context/network handling lives in one place. The runner must not
re-classify or override adapter messages.
"""
from __future__ import annotations
from collections.abc import Sequence
from core.llm.failure_classification import (
classify_cli_failure_category_hint,
classify_cli_failure_hint,
is_context_length_overflow,
)
__all__ = [
"classify_cli_failure_category_hint",
"classify_cli_failure_hint",
"explain_cli_failure",
"is_context_length_overflow",
]
def explain_cli_failure(
*,
exit_label: str,
stdout: str,
stderr: str,
returncode: int,
extra_messages: Sequence[str] = (),
always_include_output_snippet: bool = False,
) -> str:
"""Build a human-readable failure string for a non-zero CLI exit.
Args:
exit_label: Command label shown to users (e.g. ``codex exec``).
stdout: Process stdout (ANSI-stripped).
stderr: Process stderr (ANSI-stripped).
returncode: Subprocess exit code.
extra_messages: Provider-specific messages inserted before generic hints.
always_include_output_snippet: When True, append stderr/stdout after
``extra_messages`` (used by adapters that surface raw CLI output
alongside tailored guidance).
"""
err = (stderr or "").strip()
out = (stdout or "").strip()
bits: list[str] = [f"{exit_label} exited with code {returncode}"]
bits.extend(msg for msg in extra_messages if msg)
has_extra = len(bits) > 1
if has_extra:
if always_include_output_snippet:
if err:
bits.append(err[:2000])
elif out:
bits.append(out[:2000])
return ". ".join(bits)
if always_include_output_snippet:
if err:
bits.append(err[:2000])
elif out:
bits.append(out[:2000])
else:
hint = classify_cli_failure_hint(stdout, stderr, returncode)
if hint:
bits.append(hint)
return ". ".join(bits)
category = classify_cli_failure_category_hint(stdout, stderr, returncode)
if err:
bits.append(category if category else err[:2000])
elif out:
bits.append(category if category else out[:2000])
else:
hint = classify_cli_failure_hint(stdout, stderr, returncode)
if hint:
bits.append(hint)
return ". ".join(bits)
+291
View File
@@ -0,0 +1,291 @@
"""Google Gemini CLI adapter (``gemini -p``, non-interactive headless mode).
Env vars
--------
GEMINI_CLI_BIN Optional explicit path to the ``gemini`` binary.
GEMINI_CLI_MODEL Optional model override passed as ``--model``.
GEMINI_CLI_TIMEOUT_SECONDS Optional invocation timeout override for long prompts.
Auth
----
Gemini CLI supports multiple auth modes (cached login sessions, ``GEMINI_API_KEY``,
Vertex env credentials). Probe classification uses a short headless call and
maps outcomes to ``logged_in``: True / False / None.
"""
from __future__ import annotations
import json
import os
import subprocess
from integrations.llm_cli.base import CLIInvocation, CLIProbe
from integrations.llm_cli.binary_resolver import (
candidate_binary_names as _candidate_binary_names,
)
from integrations.llm_cli.binary_resolver import (
default_cli_fallback_paths as _default_cli_fallback_paths,
)
from integrations.llm_cli.binary_resolver import (
resolve_cli_binary,
)
from integrations.llm_cli.constants import (
DEFAULT_EXEC_TIMEOUT_SEC as _DEFAULT_EXEC_TIMEOUT_SEC,
)
from integrations.llm_cli.constants import (
MAX_EXEC_TIMEOUT_SEC as _MAX_EXEC_TIMEOUT_SEC,
)
from integrations.llm_cli.constants import (
MIN_EXEC_TIMEOUT_SEC as _MIN_EXEC_TIMEOUT_SEC,
)
from integrations.llm_cli.probe_utils import run_version_probe
from integrations.llm_cli.semver_utils import parse_semver_three_part
from integrations.llm_cli.subprocess_env import build_cli_subprocess_env
from integrations.llm_cli.timeout_utils import resolve_timeout_from_env
_PROBE_TIMEOUT_SEC = 20.0
_AUTH_HINT = "Run: gemini (interactive login) or set GEMINI_API_KEY."
# Source: https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/
_SUNSET_NOTE = (
" Note: Gemini CLI stops serving Pro/Ultra and free users on 2026-06-18; "
"paid Gemini Code Assist remains supported. Consider antigravity-cli (agy)."
)
def _resolve_exec_timeout_seconds() -> float:
return resolve_timeout_from_env(
env_key="GEMINI_CLI_TIMEOUT_SECONDS",
default=_DEFAULT_EXEC_TIMEOUT_SEC,
minimum=_MIN_EXEC_TIMEOUT_SEC,
maximum=_MAX_EXEC_TIMEOUT_SEC,
)
def _gemini_auth_env_overrides() -> dict[str, str]:
"""Build Gemini subprocess auth/config overrides used by probe and invoke."""
env: dict[str, str] = {"NO_COLOR": "1"}
keys = (
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"GOOGLE_GENAI_USE_VERTEXAI",
"GOOGLE_APPLICATION_CREDENTIALS",
"GOOGLE_CLOUD_PROJECT",
"GOOGLE_CLOUD_LOCATION",
)
for key in keys:
val = os.environ.get(key, "").strip()
if val:
env[key] = val
return env
def _has_explicit_gemini_auth_env() -> str | None:
env = _gemini_auth_env_overrides()
for key in ("GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS"):
if env.get(key):
return key
if env.get("GOOGLE_GENAI_USE_VERTEXAI") and env.get("GOOGLE_CLOUD_PROJECT"):
return "GOOGLE_GENAI_USE_VERTEXAI"
return None
def _classify_gemini_auth(returncode: int, stdout: str, stderr: str) -> tuple[bool | None, str]:
raw = (stdout or "").strip()
if raw.startswith("{"):
try:
payload = json.loads(raw)
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict):
err = payload.get("error")
if isinstance(err, dict):
message = str(err.get("message", "")).strip()
code = err.get("code")
msg_lower = message.lower()
if (
"please set an auth method" in msg_lower
or "gemini_api_key" in msg_lower
or "not authenticated" in msg_lower
or "login required" in msg_lower
or code == 41
):
return False, f"Not authenticated. {_AUTH_HINT}"
text = (stdout + "\n" + stderr).lower()
if "not authenticated" in text or ("authentication" in text and "required" in text):
return False, f"Not authenticated. {_AUTH_HINT}"
if "login required" in text or "please login" in text:
return False, f"Not authenticated. {_AUTH_HINT}"
if "please set an auth method" in text:
return False, f"Not authenticated. {_AUTH_HINT}"
if "invalid api key" in text or ("api key" in text and "missing" in text):
return False, "Gemini API key missing or invalid. Set GEMINI_API_KEY or login via `gemini`."
if returncode == 0:
return True, "Authenticated via Gemini CLI."
if "network" in text or "timeout" in text or "unreachable" in text or "connection" in text:
return None, "Network error while checking auth; will retry at invocation."
tail = (stderr or stdout).strip()[:200]
if tail:
return None, f"Auth status unclear (exit {returncode}): {tail}"
return None, f"Auth status unclear (exit {returncode})."
def _fallback_gemini_cli_paths() -> list[str]:
return _default_cli_fallback_paths("gemini")
class GeminiCLIAdapter:
"""Non-interactive Gemini CLI (``gemini -p`` headless mode)."""
name = "gemini-cli"
binary_env_key = "GEMINI_CLI_BIN"
install_hint = "npm i -g @google/gemini-cli"
auth_hint = _AUTH_HINT.removesuffix(".")
min_version: str | None = None
default_exec_timeout_sec = _DEFAULT_EXEC_TIMEOUT_SEC
def _resolve_binary(self) -> str | None:
return resolve_cli_binary(
explicit_env_key="GEMINI_CLI_BIN",
binary_names=_candidate_binary_names("gemini"),
fallback_paths=_fallback_gemini_cli_paths,
)
def _probe_binary(self, binary_path: str) -> CLIProbe:
version_output, version_error = run_version_probe(
binary_path,
timeout_sec=_PROBE_TIMEOUT_SEC,
)
if version_error:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=version_error,
)
version = parse_semver_three_part(version_output or "")
probe_env = build_cli_subprocess_env(_gemini_auth_env_overrides())
try:
auth_proc = subprocess.run(
[binary_path, "-p", "respond with: ok", "--output-format", "json"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=_PROBE_TIMEOUT_SEC,
check=False,
env=probe_env,
)
except subprocess.TimeoutExpired:
logged_in = None
auth_detail = (
f"Gemini auth probe timed out after {_PROBE_TIMEOUT_SEC:.0f}s; auth status unknown."
)
except OSError as exc:
logged_in = None
auth_detail = f"Could not spawn gemini for auth probe: {exc}"
else:
logged_in, auth_detail = _classify_gemini_auth(
auth_proc.returncode, auth_proc.stdout, auth_proc.stderr
)
auth_env_source = _has_explicit_gemini_auth_env()
if logged_in is not True and auth_env_source:
logged_in = True
auth_detail = f"Authenticated via {auth_env_source} fallback."
# Gate the sunset notice on ``logged_in is not False`` so a user mid
# auth-failure isn't shown the migration ad next to the actual error
# they need to read. Authenticated (True) and ambiguous (None) probes
# still surface it — those are the cohorts whose CLI is operational
# today and who need to plan for 2026-06-18.
detail = auth_detail + _SUNSET_NOTE if logged_in is not False else auth_detail
return CLIProbe(
installed=True,
version=version,
logged_in=logged_in,
bin_path=binary_path,
detail=detail,
)
def detect(self) -> CLIProbe:
binary = self._resolve_binary()
if not binary:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=(
"Gemini CLI not found on PATH or known install locations. "
f"Install with: {self.install_hint} or set GEMINI_CLI_BIN."
),
)
return self._probe_binary(binary)
def build(
self,
*,
prompt: str,
model: str | None,
workspace: str,
reasoning_effort: str | None = None,
) -> CLIInvocation:
_ = reasoning_effort
binary = self._resolve_binary()
if not binary:
raise RuntimeError(
f"Gemini CLI not found. {self.install_hint} "
"or set GEMINI_CLI_BIN to the full binary path."
)
argv: list[str] = [binary, "-p", prompt, "--output-format", "json"]
resolved_model = (model or "").strip()
if resolved_model:
argv.extend(["--model", resolved_model])
ws = (workspace or "").strip()
cwd = ws or os.getcwd()
env = _gemini_auth_env_overrides()
return CLIInvocation(
argv=tuple(argv),
stdin=None,
cwd=cwd,
env=env,
timeout_sec=_resolve_exec_timeout_seconds(),
)
def parse(self, *, stdout: str, stderr: str, returncode: int) -> str:
text = (stdout or "").strip()
if not text:
raise RuntimeError(
self.explain_failure(stdout=stdout, stderr=stderr, returncode=returncode)
+ " (empty output)"
)
try:
payload = json.loads(text)
except json.JSONDecodeError:
return text
if isinstance(payload, dict):
response = payload.get("response")
if isinstance(response, str):
return response.strip()
err = payload.get("error")
if isinstance(err, dict):
message = err.get("message")
if isinstance(message, str) and message.strip():
raise RuntimeError(f"Gemini CLI returned an error: {message.strip()}")
return text
def explain_failure(self, *, stdout: str, stderr: str, returncode: int) -> str:
from integrations.llm_cli.failure_explain import explain_cli_failure
return explain_cli_failure(
exit_label="gemini -p",
stdout=stdout,
stderr=stderr,
returncode=returncode,
)
+328
View File
@@ -0,0 +1,328 @@
"""xAI Grok Build CLI adapter (``grok -p``, non-interactive / headless mode).
Grok Build is xAI's terminal-native agentic coding tool (binary: ``grok``). OpenSRE
uses it purely as a one-shot text responder inside the ReAct loop, so invocations
run in headless ``-p`` mode with ``--output-format plain`` and never pass
``--always-approve`` (we do not want Grok autonomously editing files
or running shell commands; OpenSRE provides its own tools).
Env vars
--------
GROK_CLI_BIN Optional explicit path to the ``grok`` binary.
Blank or non-runnable paths are ignored; PATH + fallbacks apply.
GROK_CLI_MODEL Optional model override (e.g. ``grok-build-0.1``).
Unset or empty → omit ``-m``; the CLI's configured default applies.
GROK_CLI_TIMEOUT_SECONDS Optional invocation timeout override in seconds for long prompts
(default: 300, min: 30, max: 600).
XAI_API_KEY API-key auth for headless/CI runs. Forwarded explicitly to the
Grok subprocess via ``CLIInvocation.env`` (see Auth below).
Auth
----
Grok resolves credentials in the order ``model.api_key > model.env_key > active
session token > XAI_API_KEY`` (https://docs.x.ai/build/cli/headless-scripting).
``XAI_API_KEY`` is a secret, so it is forwarded **only** to the Grok subprocess via
``CLIInvocation.env`` rather than the blanket ``_SAFE_SUBPROCESS_ENV_PREFIXES``
allowlist (which would leak it into every other CLI subprocess — same rationale as
the Copilot/Claude Code adapters). There is no documented ``grok auth status``
command, so auth is detected via ``grok models`` — a fast (~0.5 s) subcommand that
prints "You are logged in" on success and doesn't incur an LLM call. Exit 0 with a
login confirmation string → authenticated; auth-error strings in output → not
authenticated; network/timeout → unclear. ``XAI_API_KEY`` in env is treated as
an authenticated fallback for headless/CI runs even when the probe result is unclear.
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
from integrations.llm_cli.base import CLIInvocation, CLIProbe
from integrations.llm_cli.binary_resolver import (
candidate_binary_names as _candidate_binary_names,
)
from integrations.llm_cli.binary_resolver import (
default_cli_fallback_paths as _default_cli_fallback_paths,
)
from integrations.llm_cli.binary_resolver import (
resolve_cli_binary,
)
from integrations.llm_cli.constants import (
DEFAULT_EXEC_TIMEOUT_SEC as _DEFAULT_EXEC_TIMEOUT_SEC,
)
from integrations.llm_cli.constants import (
MAX_EXEC_TIMEOUT_SEC as _MAX_EXEC_TIMEOUT_SEC,
)
from integrations.llm_cli.constants import (
MIN_EXEC_TIMEOUT_SEC as _MIN_EXEC_TIMEOUT_SEC,
)
from integrations.llm_cli.env_overrides import (
XAI_CLI_ENV_KEYS,
nonempty_env_values,
)
from integrations.llm_cli.probe_utils import run_version_probe
from integrations.llm_cli.semver_utils import parse_semver_three_part
from integrations.llm_cli.timeout_utils import resolve_timeout_from_env
_PROBE_TIMEOUT_SEC = 5.0
_AUTH_PROBE_TIMEOUT_SEC = 10.0
_AUTH_HINT = "Run: grok login or set XAI_API_KEY."
def _resolve_exec_timeout_seconds() -> float:
return resolve_timeout_from_env(
env_key="GROK_CLI_TIMEOUT_SECONDS",
default=_DEFAULT_EXEC_TIMEOUT_SEC,
minimum=_MIN_EXEC_TIMEOUT_SEC,
maximum=_MAX_EXEC_TIMEOUT_SEC,
)
def _grok_env_overrides() -> dict[str, str]:
"""Subprocess env overrides: disable color and forward xAI API credentials."""
env: dict[str, str] = {"NO_COLOR": "1"}
env.update(nonempty_env_values(XAI_CLI_ENV_KEYS))
return env
def _has_explicit_grok_auth_env() -> str | None:
"""Return the env var name if an explicit xAI API credential is set, else None."""
if os.environ.get("XAI_API_KEY", "").strip():
return "XAI_API_KEY"
return None
def _classify_grok_auth_from_probe(
returncode: int, stdout: str, stderr: str
) -> tuple[bool | None, str]:
"""Classify auth state from a ``grok models`` probe result.
``grok models`` is fast (~0.5 s) and prints "You are logged in" on success,
making it a reliable auth probe without incurring an LLM call.
Mirrors the pattern used by the Antigravity CLI adapter.
"""
text = (stdout + "\n" + stderr).lower()
if "you are logged in" in text or "logged in with" in text:
return True, "Authenticated via Grok Build CLI (grok models)."
if "unauthorized" in text or "not logged in" in text or "please log in" in text:
return False, f"Not authenticated. {_AUTH_HINT}"
if "401" in text or ("api key" in text and ("invalid" in text or "missing" in text)):
return False, f"Authentication failed. {_AUTH_HINT}"
if returncode == 0:
return True, "Authenticated via Grok Build CLI."
if "network" in text or "timeout" in text or "unreachable" in text or "connection" in text:
return None, "Network error during Grok auth probe; will retry at invocation."
tail = (stderr or stdout).strip()[:200]
if tail:
return None, f"Auth status unclear (exit {returncode}): {tail}"
return None, f"Auth status unclear (exit {returncode})."
def parse_grok_models_output(text: str) -> list[str]:
"""Parse ``grok models`` stdout into an ordered list of model IDs.
Example output::
You are logged in with grok.com.
Default model: grok-build
Available models:
- grok-composer-2.5-fast
* grok-build (default)
Returns model IDs in the order listed, default model last (it has ``*``).
"""
models: list[str] = []
in_section = False
for line in text.splitlines():
stripped = line.strip()
if stripped.lower().startswith("available models"):
in_section = True
continue
if in_section:
if stripped.startswith(("- ", "* ")):
model_id = stripped[2:].split("(")[0].strip()
if model_id:
models.append(model_id)
elif stripped and not stripped.startswith(("-", "*")):
break
return models
def _fallback_grok_paths() -> list[str]:
return _default_cli_fallback_paths("grok")
class GrokCLIAdapter:
"""Non-interactive xAI Grok Build CLI (``grok -p``, headless mode, no TTY)."""
name = "grok-cli"
binary_env_key = "GROK_CLI_BIN"
install_hint = "curl -fsSL https://x.ai/cli/install.sh | bash"
auth_hint = _AUTH_HINT.removesuffix(".")
min_version: str | None = None
default_exec_timeout_sec = _DEFAULT_EXEC_TIMEOUT_SEC
def _resolve_binary(self) -> str | None:
return resolve_cli_binary(
explicit_env_key="GROK_CLI_BIN",
binary_names=_candidate_binary_names("grok"),
fallback_paths=_fallback_grok_paths,
)
def _probe_binary(self, binary_path: str) -> CLIProbe:
version_output, version_error = run_version_probe(
binary_path,
timeout_sec=_PROBE_TIMEOUT_SEC,
)
if version_error:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=version_error,
)
version = parse_semver_three_part(version_output or "")
# Use the full parent-process env for the probe so grok can reach its
# keyring / session credentials (DBUS_SESSION_BUS_ADDRESS, DISPLAY, etc.
# are stripped by build_cli_subprocess_env and cause the process to hang).
# Merge overrides last so NO_COLOR and XAI_API_KEY always take effect.
probe_env = {**os.environ, **_grok_env_overrides()}
try:
auth_proc = subprocess.run(
[binary_path, "models"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=_AUTH_PROBE_TIMEOUT_SEC,
check=False,
env=probe_env,
)
except subprocess.TimeoutExpired:
logged_in: bool | None = None
auth_detail = (
f"Grok auth probe timed out after {_AUTH_PROBE_TIMEOUT_SEC:.0f}s; "
"auth status unknown."
)
except OSError as exc:
logged_in = None
auth_detail = f"Could not spawn grok for auth probe: {exc}"
else:
logged_in, auth_detail = _classify_grok_auth_from_probe(
auth_proc.returncode, auth_proc.stdout, auth_proc.stderr
)
# XAI_API_KEY is definitive for headless/CI auth when the probe result is
# *unclear* (network error, timeout). Do NOT promote when the probe
# explicitly returned False — XAI_API_KEY may itself be the rejected
# credential, and masking that defers the failure to invocation time.
auth_env_key = _has_explicit_grok_auth_env()
if logged_in is None and auth_env_key:
logged_in = True
auth_detail = f"Authenticated via {auth_env_key}."
return CLIProbe(
installed=True,
version=version,
logged_in=logged_in,
bin_path=binary_path,
detail=auth_detail,
)
def detect(self) -> CLIProbe:
binary = self._resolve_binary()
if not binary:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=(
"Grok Build CLI not found on PATH or known install locations. "
f"Install with: {self.install_hint} or set GROK_CLI_BIN."
),
)
return self._probe_binary(binary)
def build(
self,
*,
prompt: str,
model: str | None,
workspace: str,
reasoning_effort: str | None = None,
) -> CLIInvocation:
# Grok Build headless mode does not expose a reasoning-effort flag; the
# parameter is accepted for protocol compatibility and ignored.
_ = reasoning_effort
binary = self._resolve_binary()
if not binary:
raise RuntimeError(
f"Grok Build CLI not found. {self.install_hint}"
" or set GROK_CLI_BIN to the full binary path."
)
ws = (workspace or "").strip()
cwd = str(Path(ws).expanduser()) if ws else os.getcwd()
# `grok -p PROMPT` runs a single headless turn (no TTY). `--output-format
# plain` yields the model's text answer for parse(). We deliberately omit
# `--always-approve` so Grok never auto-executes its own tools — OpenSRE
# drives tool use itself.
argv: list[str] = [
binary,
"-p",
prompt,
"--output-format",
"plain",
]
resolved_model = (model or "").strip()
if resolved_model:
argv.extend(["-m", resolved_model])
# Forward xAI credentials explicitly rather than via the blanket prefix
# allowlist, so XAI_API_KEY does not leak into other CLI adapters.
env = _grok_env_overrides()
return CLIInvocation(
argv=tuple(argv),
stdin=None,
cwd=cwd,
env=env,
timeout_sec=_resolve_exec_timeout_seconds(),
)
def parse(self, *, stdout: str, stderr: str, returncode: int) -> str:
result = (stdout or "").strip()
if not result:
raise RuntimeError(
self.explain_failure(stdout=stdout, stderr=stderr, returncode=returncode)
+ " (empty output)"
)
return result
def explain_failure(self, *, stdout: str, stderr: str, returncode: int) -> str:
from integrations.llm_cli.failure_explain import explain_cli_failure
# Provider-specific auth message (more actionable than the generic shared
# hint); quota / context-length / network classification comes from the
# shared helper so Grok stays consistent with the other CLI adapters.
lowered = f"{stderr}\n{stdout}".lower()
extra: tuple[str, ...] = ()
if "unauthorized" in lowered or "401" in lowered or "not logged in" in lowered:
extra = (f"Authentication failed. {_AUTH_HINT}",)
return explain_cli_failure(
exit_label="grok -p",
stdout=stdout,
stderr=stderr,
returncode=returncode,
extra_messages=extra,
)
+298
View File
@@ -0,0 +1,298 @@
"""Kimi Code CLI adapter (`kimi -p`, non-interactive)."""
from __future__ import annotations
import logging
import os
import pathlib
import subprocess
import sys
import tomllib
from integrations._validation_helpers import report_validation_failure
from integrations.llm_cli.base import CLIInvocation, CLIProbe
from integrations.llm_cli.binary_resolver import (
candidate_binary_names as _candidate_binary_names,
)
from integrations.llm_cli.binary_resolver import (
default_cli_fallback_paths as _default_cli_fallback_paths,
)
from integrations.llm_cli.binary_resolver import resolve_cli_binary
from integrations.llm_cli.constants import DEFAULT_EXEC_TIMEOUT_SEC
from integrations.llm_cli.probe_utils import run_version_probe
from integrations.llm_cli.semver_utils import parse_semver_three_part, semver_to_tuple
logger = logging.getLogger(__name__)
_PROBE_TIMEOUT_SEC = 3.0
def _classify_kimi_login_status(
returncode: int, stdout: str, stderr: str
) -> tuple[bool | None, str]:
"""Classify Kimi login status output, following the Codex pattern."""
# Handle None values (defensive against subprocess edge cases)
stdout = stdout or ""
stderr = stderr or ""
text = (stdout + "\n" + stderr).lower()
# Negative phrases first to avoid substring false-positives
if "not logged in" in text or "no credentials" in text or "unauthorized" in text:
return False, "Not logged in. Run: kimi login"
if returncode == 0 and ("logged in" in text or "authenticated" in text):
return True, (stdout.strip() or stderr.strip() or "Authenticated.").splitlines()[0]
if "expired" in text or ("invalid" in text and "token" in text):
return False, "Session expired. Re-authenticate: kimi login"
if "rate limit" in text or "quota" in text:
return True, "Authenticated but rate-limited; try again later."
if "network" in text or "unreachable" in text or "dns" in text or "connection refused" in text:
return None, "Network error while checking auth; will retry at invocation."
if returncode != 0:
tail = (stderr or stdout).strip()[:200]
return (
None,
f"Auth status unclear (exit {returncode}): {tail}"
if tail
else f"Auth status unclear (exit {returncode}).",
)
return None, "Auth status unknown."
def _check_kimi_auth_fallback() -> tuple[bool | None, str]:
"""Fallback auth check: KIMI_API_KEY env var, then config.toml."""
# First try KIMI_API_KEY environment variable
if os.environ.get("KIMI_API_KEY", "").strip():
return True, "Authenticated via KIMI_API_KEY environment variable."
# Then try config.toml
share_dir = os.environ.get("KIMI_SHARE_DIR", "~/.kimi")
config_path = pathlib.Path(os.path.expanduser(share_dir)) / "config.toml"
if not config_path.exists():
return False, "Not logged in. Run: kimi login"
try:
content = config_path.read_text(encoding="utf-8")
config = tomllib.loads(content)
providers = config.get("providers", {})
if providers:
for prov in providers.values():
if str(prov.get("api_key", "")).strip():
return True, "Authenticated via config.toml."
return False, "No API key configured. Run: kimi login"
except Exception as e:
report_validation_failure(
e,
logger=logger,
integration="kimi",
method="_check_kimi_auth_fallback",
)
return None, f"Could not verify auth status: {e}"
def _fallback_kimi_paths() -> list[str]:
"""Build a list of common install locations for Kimi (uv, cargo, pipx, etc.)."""
# Kimi is installed via uv (Python tool) typically
paths = _default_cli_fallback_paths("kimi")
names = _candidate_binary_names("kimi")
# Add pipx/uv standard paths if not already covered
extra_dirs: list[str] = []
if sys.platform == "win32":
# Common locations for uv/cargo/pip on Windows
extra_dirs.extend(
[
os.path.expandvars(r"%USERPROFILE%\.cargo\bin"),
os.path.expandvars(r"%USERPROFILE%\.local\bin"),
os.path.expandvars(r"%APPDATA%\uv\bin"),
]
)
# Search Python Scripts directories for recent versions
for v in range(15, 11, -1): # 3.15 down to 3.12
extra_dirs.append(
os.path.expandvars(rf"%USERPROFILE%\AppData\Roaming\Python\Python3{v}\Scripts")
)
else:
# On Unix, ~/.local/bin is already in default_cli_fallback_paths.
# Cargo might not be.
extra_dirs.append(os.path.expanduser("~/.cargo/bin"))
for d in extra_dirs:
for name in names:
paths.append(str(pathlib.Path(d) / name))
return paths
class KimiAdapter:
"""Non-interactive Kimi Code CLI (`kimi -p` with --yolo)."""
name = "kimi"
binary_env_key = "KIMI_BIN"
install_hint = "uv tool install --python 3.13 kimi-cli"
auth_hint = "Run: kimi login"
min_version: str | None = "1.40.0"
default_exec_timeout_sec = DEFAULT_EXEC_TIMEOUT_SEC
def _resolve_binary(self) -> str | None:
return resolve_cli_binary(
explicit_env_key="KIMI_BIN",
binary_names=_candidate_binary_names("kimi"),
fallback_paths=_fallback_kimi_paths,
)
def _probe_binary(self, binary_path: str) -> CLIProbe:
version_output, version_error = run_version_probe(
binary_path,
timeout_sec=_PROBE_TIMEOUT_SEC,
)
if version_error:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=version_error,
)
version = parse_semver_three_part(version_output or "")
upgrade_note = ""
if (
self.min_version
and version
and semver_to_tuple(version) < semver_to_tuple(self.min_version)
):
upgrade_note = (
f" Kimi Code CLI {version} is below tested minimum {self.min_version}; "
f"upgrade: uv tool upgrade kimi-cli"
)
# First, try 'kimi login status' for native CLI auth probe
logged_in: bool | None = None
auth_detail = ""
try:
auth_proc = subprocess.run(
[binary_path, "login", "status"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=_PROBE_TIMEOUT_SEC,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
# login status unavailable; still fall back below (API key / config.toml).
auth_detail = "Could not verify login status (timeout or OS error)."
else:
logged_in, auth_detail = _classify_kimi_login_status(
auth_proc.returncode, auth_proc.stdout, auth_proc.stderr
)
# `kimi login status` misses some API-key-only setups; timeouts return None —
# both cases should merge env/config.toml auth when applicable.
if logged_in is not True:
logged_in_fb, auth_detail_fb = _check_kimi_auth_fallback()
if logged_in is None or logged_in_fb is True:
logged_in = logged_in_fb
auth_detail = auth_detail_fb
detail = auth_detail + upgrade_note
return CLIProbe(
installed=True,
version=version,
logged_in=logged_in,
bin_path=binary_path,
detail=detail.strip(),
)
def detect(self) -> CLIProbe:
binary = self._resolve_binary()
if not binary:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=(
"Kimi Code CLI not found. Install with "
"`uv tool install --python 3.13 kimi-cli`."
),
)
return self._probe_binary(binary)
def build(
self,
*,
prompt: str,
model: str | None,
workspace: str,
reasoning_effort: str | None = None,
) -> CLIInvocation:
_ = reasoning_effort
binary = self._resolve_binary()
if not binary:
raise RuntimeError(
"Kimi Code CLI not found. Install with "
"`uv tool install --python 3.13 kimi-cli` or set KIMI_BIN."
)
ws = workspace or os.getcwd()
# Every Kimi CLI invocation is forced into a one-shot, non-interactive mode.
# We use --print and --yolo to ensure no interactive prompts block the agent.
# Stdin is used via --input-format text to handle large prompts safely.
argv: list[str] = [
binary,
"--print",
"--input-format",
"text",
"--output-format",
"text",
"--final-message-only",
"--yolo",
"-w",
ws,
]
resolved_model = (model or "").strip()
if resolved_model:
argv.extend(["-m", resolved_model])
return CLIInvocation(
argv=tuple(argv),
stdin=prompt,
cwd=ws,
env=None,
timeout_sec=self.default_exec_timeout_sec,
)
def parse(self, *, stdout: str, stderr: str, returncode: int) -> str:
result = (stdout or "").strip()
if not result:
raise RuntimeError(
self.explain_failure(stdout=stdout, stderr=stderr, returncode=returncode)
+ " (empty output)"
)
return result
def explain_failure(self, *, stdout: str, stderr: str, returncode: int) -> str:
from integrations.llm_cli.failure_explain import explain_cli_failure
err = (stderr or "").strip()
out = (stdout or "").strip()
if returncode == 0 and not err and not out:
return "kimi returned no output"
extra: tuple[str, ...] = ()
if "LLM not set" in err or "LLM not set" in out:
extra = ("Not logged in or model unavailable. Run: kimi login",)
elif "Error code: 401" in err or "Error code: 401" in out:
extra = ("API key invalid or expired. Re-authenticate: kimi login",)
return explain_cli_failure(
exit_label="kimi",
stdout=stdout,
stderr=stderr,
returncode=returncode,
extra_messages=extra,
)
+253
View File
@@ -0,0 +1,253 @@
"""OpenCode CLI adapter (`opencode run`, non-interactive / one-shot mode).
OpenCode can authenticate via multiple mechanisms (credentials in ``auth.json`` and/or
provider API keys visible in the process environment). We probe auth the same way the
CLI summarizes it: ``opencode auth list`` (alias: ``opencode providers list``), after a
successful ``--version`` check. See ``_parse_opencode_auth_list_output`` for parsing rules.
"""
from __future__ import annotations
import os
import re
import subprocess
from integrations.llm_cli.base import CLIInvocation, CLIProbe
from integrations.llm_cli.binary_resolver import (
candidate_binary_names as _candidate_binary_names,
)
from integrations.llm_cli.binary_resolver import (
default_cli_fallback_paths as _default_cli_fallback_paths,
)
from integrations.llm_cli.binary_resolver import (
resolve_cli_binary,
)
from integrations.llm_cli.constants import DEFAULT_EXEC_TIMEOUT_SEC
from integrations.llm_cli.env_overrides import (
HTTP_LLM_PROVIDER_ENV_KEYS,
nonempty_env_values,
)
from integrations.llm_cli.probe_utils import run_version_probe
from integrations.llm_cli.semver_utils import parse_semver_three_part
_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m")
_PROBE_TIMEOUT_SEC = 8.0
_AUTH_LIST_TIMEOUT_SEC = 25.0
def _strip_ansi(text: str) -> str:
return _ANSI_ESCAPE.sub("", text)
def _parse_opencode_auth_list_output(raw_stdout: str, raw_stderr: str) -> tuple[bool | None, str]:
"""Map ``opencode auth list`` output to ``logged_in`` + human detail.
OpenCode prints a summary table (file-backed credentials under ``auth.json`` and
optional environment-backed provider keys). We require a ``<n> credentials`` line;
``<n> environment variable(s)`` is optional and defaults to 0 when absent.
"""
combined = _strip_ansi((raw_stdout or "") + "\n" + (raw_stderr or ""))
cred_m = re.search(r"(\d+)\s+credentials\b", combined)
if not cred_m:
tail = combined.strip()[-400:]
return (
None,
"Could not parse `opencode auth list` output (missing credentials summary)."
+ (f" Tail: {tail!r}" if tail else ""),
)
creds = int(cred_m.group(1))
env_m = re.search(r"(\d+)\s+environment variables?\b", combined)
envs = int(env_m.group(1)) if env_m else 0
if creds >= 1 or envs >= 1:
parts: list[str] = []
if creds >= 1:
parts.append(f"{creds} credential group(s) in auth store")
if envs >= 1:
parts.append(f"{envs} environment provider key(s)")
return True, "OpenCode: " + "; ".join(parts) + ". (See `opencode auth list`.)"
return (
False,
"OpenCode reports no file credentials and no provider keys in environment. "
"Run: opencode auth login — or export a supported provider API key.",
)
def _probe_opencode_auth_via_cli(binary_path: str) -> tuple[bool | None, str]:
"""Run ``opencode auth list`` with the same environment as the parent process.
Inherits the full environment so the CLI can detect API keys (e.g. ``ANTHROPIC_API_KEY``)
the same way it does interactively. Uses ``NO_COLOR=1`` to simplify parsing.
"""
env = os.environ.copy()
env["NO_COLOR"] = "1"
try:
list_proc = subprocess.run(
[binary_path, "auth", "list"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=_AUTH_LIST_TIMEOUT_SEC,
check=False,
env=env,
)
except subprocess.TimeoutExpired:
return (
None,
"`opencode auth list` timed out (first run can migrate local data). "
"Retry once OpenCode finishes initializing.",
)
except OSError as exc:
return None, f"Could not run `opencode auth list`: {exc}"
if list_proc.returncode != 0:
err = _strip_ansi((list_proc.stderr or list_proc.stdout or "").strip())
tail = (err or f"exit {list_proc.returncode}")[:400]
return None, f"`opencode auth list` failed (exit {list_proc.returncode}): {tail}"
return _parse_opencode_auth_list_output(list_proc.stdout, list_proc.stderr)
def _fallback_opencode_paths() -> list[str]:
return _default_cli_fallback_paths("opencode")
class OpenCodeAdapter:
"""Non-interactive OpenCode CLI (`opencode run`, one-shot execution)."""
name = "opencode"
binary_env_key = "OPENCODE_BIN"
install_hint = (
"brew install anomalyco/tap/opencode (macOS/Linux) | choco install opencode (Windows)"
)
auth_hint = "Run: opencode auth login (interactive) or configure provider API keys / auth.json"
min_version: str | None = None
default_exec_timeout_sec = DEFAULT_EXEC_TIMEOUT_SEC
def _resolve_binary(self) -> str | None:
return resolve_cli_binary(
explicit_env_key="OPENCODE_BIN",
binary_names=_candidate_binary_names("opencode"),
fallback_paths=_fallback_opencode_paths,
)
def _probe_binary(self, binary_path: str) -> CLIProbe:
version_output, version_error = run_version_probe(
binary_path,
timeout_sec=_PROBE_TIMEOUT_SEC,
)
if version_error:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=version_error,
)
version = parse_semver_three_part(version_output or "")
logged_in, auth_detail = _probe_opencode_auth_via_cli(binary_path)
return CLIProbe(
installed=True,
version=version,
logged_in=logged_in,
bin_path=binary_path,
detail=auth_detail,
)
def detect(self) -> CLIProbe:
binary = self._resolve_binary()
if not binary:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=(
"OpenCode CLI not found on PATH or known install locations. "
f"Install with: {self.install_hint} or set OPENCODE_BIN."
),
)
return self._probe_binary(binary)
def build(
self,
*,
prompt: str,
model: str | None,
workspace: str,
reasoning_effort: str | None = None,
) -> CLIInvocation:
_ = reasoning_effort
binary = self._resolve_binary()
if not binary:
raise RuntimeError(
f"OpenCode CLI not found. {self.install_hint}"
" or set OPENCODE_BIN to the full binary path."
)
cwd = workspace or os.getcwd()
argv: list[str] = [
binary,
"run",
]
resolved_model = (model or "").strip()
if resolved_model:
argv.extend(["-m", resolved_model])
env: dict[str, str] = {"NO_COLOR": "1"}
env.update(nonempty_env_values(HTTP_LLM_PROVIDER_ENV_KEYS))
for key in ("HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"):
val = os.environ.get(key, "").strip()
if val:
env[key] = val
return CLIInvocation(
argv=tuple(argv),
stdin=prompt,
cwd=cwd,
env=env,
timeout_sec=self.default_exec_timeout_sec,
)
def parse(self, *, stdout: str, stderr: str, returncode: int) -> str:
result = (stdout or "").strip()
if not result:
raise RuntimeError(
self.explain_failure(stdout=stdout, stderr=stderr, returncode=returncode)
+ " (empty output)"
)
return result
def explain_failure(self, *, stdout: str, stderr: str, returncode: int) -> str:
from integrations.llm_cli.failure_explain import explain_cli_failure
err = (stderr or "").strip()
out = (stdout or "").strip()
combined = (err + " " + out).lower()
extra: tuple[str, ...] = ()
if "not authenticated" in combined or ("auth" in combined and "failed" in combined):
extra = ("Authentication failed. Run: opencode auth login",)
elif "model" in combined and ("not found" in combined or "invalid" in combined):
extra = (
"Model not found. Check OPENCODE_MODEL format: "
"provider/model (e.g., openai/gpt-5.4-mini)",
)
elif "rate limit" in combined or "quota" in combined:
extra = ("Rate limited or quota exceeded. Try again later or check your provider plan",)
return explain_cli_failure(
exit_label="opencode run",
stdout=stdout,
stderr=stderr,
returncode=returncode,
extra_messages=extra,
always_include_output_snippet=bool(extra),
)
+293
View File
@@ -0,0 +1,293 @@
"""Pi CLI adapter (``pi -p``, non-interactive / print mode).
Pi (https://pi.dev, repo: earendil-works/pi) is an open-source, bring-your-own-key
coding-agent CLI that runs the same agent loop against ~30 providers (Anthropic,
OpenAI, Google Gemini, xAI, DeepSeek, …). OpenSRE uses it purely as a one-shot
text responder inside the ReAct loop, so invocations run in headless ``-p`` print
mode (no TTY, no approval prompts).
Env vars
--------
PI_BIN Optional explicit path to the ``pi`` binary. Blank or non-runnable
paths are ignored; PATH + fallbacks still apply.
PI_MODEL Optional model override in Pi's ``provider/model`` form
(e.g. ``google/gemini-2.5-flash-lite``, ``anthropic/claude-haiku``).
Unset or empty → omit ``--model`` and the CLI's configured default
applies. Registered as ``model_env_key`` in ``registry.py``.
Per-provider API keys (``ANTHROPIC_API_KEY``, ``OPENAI_API_KEY``,
``GEMINI_API_KEY``, …) are forwarded to the Pi subprocess via
``CLIInvocation.env`` (see ``PI_PROVIDER_ENV_KEYS`` in ``env_overrides.py``).
Auth probe
----------
Pi exposes **no** non-interactive auth-status command — ``/login`` / ``/logout``
are interactive TUI slash commands only. But Pi stores credentials in a readable
file, so (mirroring the Kimi / Claude Code adapters) we detect auth from state
rather than a probe subprocess. Resolution order:
1. A supported provider API key in the environment → ``True`` (BYOK / headless).
2. ``~/.pi/agent/auth.json`` present with credential content → ``True``. This is
the signal for users who authenticated via ``/login`` (OAuth subscriptions or
stored API keys) and have no provider key exported.
3. Neither present → ``False`` (run ``pi`` and ``/login``, or export a key).
4. Credential file present but unreadable / unparseable → ``None`` (unclear;
invocation will verify).
This matches Pi's own credential-resolution priority (``--api-key`` flag >
``auth.json`` > env var) and avoids depending on the undocumented auth behavior
of ``pi --list-models``.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from integrations.llm_cli.base import CLIInvocation, CLIProbe
from integrations.llm_cli.binary_resolver import (
candidate_binary_names as _candidate_binary_names,
)
from integrations.llm_cli.binary_resolver import (
default_cli_fallback_paths as _default_cli_fallback_paths,
)
from integrations.llm_cli.binary_resolver import (
resolve_cli_binary,
)
from integrations.llm_cli.constants import DEFAULT_EXEC_TIMEOUT_SEC
from integrations.llm_cli.env_overrides import (
PI_PROVIDER_ENV_KEYS,
nonempty_env_values,
)
from integrations.llm_cli.probe_utils import run_version_probe
from integrations.llm_cli.semver_utils import parse_semver_three_part
_PROBE_TIMEOUT_SEC = 8.0
_AUTH_HINT = "Run `pi` then `/login`, or export a provider API key (e.g. GEMINI_API_KEY)."
def _pi_agent_dir() -> Path:
"""Return Pi's agent config dir, honoring ``PI_AGENT_DIR`` / ``PI_CONFIG_DIR``.
Defaults to ``~/.pi/agent`` (where Pi stores ``auth.json``). The override env
var names are best-effort; whatever ``PI_*`` dir var Pi uses is also forwarded
to the subprocess via the ``PI_`` prefix allowlist.
"""
override = (
os.environ.get("PI_AGENT_DIR", "").strip() or os.environ.get("PI_CONFIG_DIR", "").strip()
)
if override:
return Path(override).expanduser()
return Path.home() / ".pi" / "agent"
def _has_provider_api_key() -> str | None:
"""Return the name of the first supported provider API key set in env, else None."""
for key in PI_PROVIDER_ENV_KEYS:
if os.environ.get(key, "").strip():
return key
return None
def _auth_json_has_credentials() -> tuple[bool | None, str]:
"""Inspect ``<agent-dir>/auth.json`` for stored credentials.
Returns ``(True, detail)`` when the file holds at least one credential entry,
``(False, detail)`` when it is absent, and ``(None, detail)`` when it exists
but cannot be read or parsed (auth state unclear).
"""
auth_path = _pi_agent_dir() / "auth.json"
try:
if not auth_path.exists():
return False, f"No credentials at {auth_path}. {_AUTH_HINT}"
raw = auth_path.read_text(encoding="utf-8").strip()
except OSError as exc:
return None, f"Could not read {auth_path}: {exc}"
if not raw:
return False, f"{auth_path} is empty. {_AUTH_HINT}"
try:
data = json.loads(raw)
except (json.JSONDecodeError, ValueError) as exc:
return None, f"Could not parse {auth_path}: {exc}"
# Pi stores a JSON object keyed by provider/credential; any non-empty entry
# means the user has logged in or stored a key. Be permissive about the exact
# schema (it may evolve) — presence of content is the signal.
if isinstance(data, dict) and any(data.values()):
return True, "Authenticated via ~/.pi/agent/auth.json (pi /login)."
if isinstance(data, list) and data:
return True, "Authenticated via ~/.pi/agent/auth.json (pi /login)."
return False, f"No credential entries in {auth_path}. {_AUTH_HINT}"
def _classify_pi_auth() -> tuple[bool | None, str]:
"""Resolve Pi auth state from env keys then the stored credential file."""
api_key = _has_provider_api_key()
if api_key:
return True, f"Authenticated via {api_key}."
return _auth_json_has_credentials()
def _pi_env_overrides() -> dict[str, str]:
"""Subprocess env overrides: disable color and forward provider API keys."""
env: dict[str, str] = {"NO_COLOR": "1"}
env.update(nonempty_env_values(PI_PROVIDER_ENV_KEYS))
for key in ("HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"):
val = os.environ.get(key, "").strip()
if val:
env[key] = val
return env
def _fallback_pi_paths() -> list[str]:
return _default_cli_fallback_paths("pi")
class PiAdapter:
"""Non-interactive Pi CLI (``pi -p``, print mode, no TTY)."""
name = "pi"
binary_env_key = "PI_BIN"
install_hint = "npm i -g @earendil-works/pi-coding-agent"
auth_hint = _AUTH_HINT.removesuffix(".")
min_version: str | None = None
default_exec_timeout_sec = DEFAULT_EXEC_TIMEOUT_SEC
def _resolve_binary(self) -> str | None:
return resolve_cli_binary(
explicit_env_key="PI_BIN",
binary_names=_candidate_binary_names("pi"),
fallback_paths=_fallback_pi_paths,
)
def _probe_binary(self, binary_path: str) -> CLIProbe:
version_output, version_error = run_version_probe(
binary_path,
timeout_sec=_PROBE_TIMEOUT_SEC,
)
if version_error:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=version_error,
)
version = parse_semver_three_part(version_output or "")
logged_in, auth_detail = _classify_pi_auth()
return CLIProbe(
installed=True,
version=version,
logged_in=logged_in,
bin_path=binary_path,
detail=auth_detail,
)
def detect(self) -> CLIProbe:
binary = self._resolve_binary()
if not binary:
return CLIProbe(
installed=False,
version=None,
logged_in=None,
bin_path=None,
detail=(
"Pi CLI not found on PATH or known install locations. "
f"Install with: {self.install_hint} or set PI_BIN."
),
)
return self._probe_binary(binary)
def build(
self,
*,
prompt: str,
model: str | None,
workspace: str,
reasoning_effort: str | None = None,
) -> CLIInvocation:
# Pi print mode has no reasoning-effort flag (thinking level is part of
# the model string, e.g. ``sonnet:high``); accept the param for protocol
# parity and discard it.
_ = reasoning_effort
binary = self._resolve_binary()
if not binary:
raise RuntimeError(
f"Pi CLI not found. {self.install_hint} or set PI_BIN to the full binary path."
)
ws = (workspace or "").strip()
cwd = str(Path(ws).expanduser()) if ws else os.getcwd()
# ``pi -p PROMPT`` runs a single non-interactive turn (no TTY) and prints
# the model's answer to stdout for parse(). Prompt is passed as an argv
# arg (the documented print-mode form). Pi keeps its default tools and
# context-file (AGENTS.md / CLAUDE.md) discovery enabled, matching the
# other default-agent CLI adapters (claude-code, opencode, gemini-cli).
argv: list[str] = [
binary,
"-p",
prompt,
]
resolved_model = (model or "").strip()
if resolved_model:
argv.extend(["--model", resolved_model])
env = _pi_env_overrides()
return CLIInvocation(
argv=tuple(argv),
stdin=None,
cwd=cwd,
env=env,
timeout_sec=self.default_exec_timeout_sec,
)
def parse(self, *, stdout: str, stderr: str, returncode: int) -> str:
result = (stdout or "").strip()
if not result:
raise RuntimeError(
self.explain_failure(stdout=stdout, stderr=stderr, returncode=returncode)
+ " (empty output)"
)
return result
def explain_failure(self, *, stdout: str, stderr: str, returncode: int) -> str:
from integrations.llm_cli.failure_explain import explain_cli_failure
err = (stderr or "").strip()
out = (stdout or "").strip()
combined = f"{err}\n{out}".lower()
extra: tuple[str, ...] = ()
if (
"not logged in" in combined
or "not authenticated" in combined
or "no credentials" in combined
or "unauthorized" in combined
or "401" in combined
or ("api key" in combined and ("invalid" in combined or "missing" in combined))
):
extra = (f"Authentication failed. {_AUTH_HINT}",)
elif "model" in combined and ("not found" in combined or "invalid" in combined):
extra = (
"Model not found. Check PI_MODEL format: provider/model "
"(e.g. google/gemini-2.5-flash-lite).",
)
elif "rate limit" in combined or "quota" in combined:
extra = (
"Rate limited or quota exceeded. Try again later or check your provider plan.",
)
return explain_cli_failure(
exit_label="pi -p",
stdout=stdout,
stderr=stderr,
returncode=returncode,
extra_messages=extra,
always_include_output_snippet=bool(extra),
)
+33
View File
@@ -0,0 +1,33 @@
"""Shared subprocess probe helpers for CLI adapters."""
from __future__ import annotations
import subprocess
def run_version_probe(binary_path: str, *, timeout_sec: float) -> tuple[str | None, str | None]:
"""Run ``<binary> --version`` and return ``(combined_output, error_detail)``.
``error_detail`` is suitable for ``CLIProbe.detail`` when the version probe
fails. On success, ``combined_output`` is ``stdout + stderr``.
"""
try:
proc = subprocess.run(
[binary_path, "--version"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout_sec,
check=False,
)
except FileNotFoundError as exc:
return None, f"CLI binary not found: `{binary_path}` ({exc})"
except (OSError, subprocess.TimeoutExpired) as exc:
return None, f"Could not run `{binary_path} --version`: {exc}"
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip()
return None, f"`{binary_path} --version` failed: {err or 'unknown error'}"
return (proc.stdout or "") + (proc.stderr or ""), None
+128
View File
@@ -0,0 +1,128 @@
"""Registration table for CLI-backed LLM providers (``LLM_PROVIDER`` subprocess path)."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from config.llm_auth.provider_catalog import require_provider_spec
from integrations.llm_cli.base import LLMCLIAdapter
@dataclass(frozen=True)
class CLIProviderRegistration:
"""Maps a configured ``LLM_PROVIDER`` value to adapter construction + model env."""
adapter_factory: Callable[[], LLMCLIAdapter]
#: Optional model override env var; unset or empty → ``None`` (CLI default / omit flag).
model_env_key: str
def _codex_factory() -> LLMCLIAdapter:
from integrations.llm_cli.codex import CodexAdapter
return CodexAdapter()
def _cursor_factory() -> LLMCLIAdapter:
from integrations.llm_cli.cursor import CursorAdapter
return CursorAdapter()
def _claude_code_factory() -> LLMCLIAdapter:
from integrations.llm_cli.claude_code import ClaudeCodeAdapter
return ClaudeCodeAdapter()
def _gemini_cli_factory() -> LLMCLIAdapter:
from integrations.llm_cli.gemini_cli import GeminiCLIAdapter
return GeminiCLIAdapter()
def _antigravity_cli_factory() -> LLMCLIAdapter:
from integrations.llm_cli.antigravity_cli import AntigravityCLIAdapter
return AntigravityCLIAdapter()
def _opencode_factory() -> LLMCLIAdapter:
from integrations.llm_cli.opencode import OpenCodeAdapter
return OpenCodeAdapter()
def _kimi_factory() -> LLMCLIAdapter:
from integrations.llm_cli.kimi import KimiAdapter
return KimiAdapter()
def _copilot_factory() -> LLMCLIAdapter:
from integrations.llm_cli.copilot import CopilotAdapter
return CopilotAdapter()
def _grok_cli_factory() -> LLMCLIAdapter:
from integrations.llm_cli.grok_cli import GrokCLIAdapter
return GrokCLIAdapter()
def _pi_factory() -> LLMCLIAdapter:
from integrations.llm_cli.pi_cli import PiAdapter
return PiAdapter()
CLI_PROVIDER_REGISTRY: dict[str, CLIProviderRegistration] = {
"codex": CLIProviderRegistration(
adapter_factory=_codex_factory,
model_env_key=require_provider_spec("codex").cli_model_env or "CODEX_MODEL",
),
"cursor": CLIProviderRegistration(
adapter_factory=_cursor_factory,
model_env_key=require_provider_spec("cursor").cli_model_env or "CURSOR_MODEL",
),
"claude-code": CLIProviderRegistration(
adapter_factory=_claude_code_factory,
model_env_key=require_provider_spec("claude-code").cli_model_env or "CLAUDE_CODE_MODEL",
),
"gemini-cli": CLIProviderRegistration(
adapter_factory=_gemini_cli_factory,
model_env_key=require_provider_spec("gemini-cli").cli_model_env or "GEMINI_CLI_MODEL",
),
"antigravity-cli": CLIProviderRegistration(
adapter_factory=_antigravity_cli_factory,
model_env_key=require_provider_spec("antigravity-cli").cli_model_env
or "ANTIGRAVITY_CLI_MODEL",
),
"opencode": CLIProviderRegistration(
adapter_factory=_opencode_factory,
model_env_key=require_provider_spec("opencode").cli_model_env or "OPENCODE_MODEL",
),
"kimi": CLIProviderRegistration(
adapter_factory=_kimi_factory,
model_env_key=require_provider_spec("kimi").cli_model_env or "KIMI_MODEL",
),
"copilot": CLIProviderRegistration(
adapter_factory=_copilot_factory,
model_env_key=require_provider_spec("copilot").cli_model_env or "COPILOT_MODEL",
),
"grok-cli": CLIProviderRegistration(
adapter_factory=_grok_cli_factory,
model_env_key=require_provider_spec("grok-cli").cli_model_env or "GROK_CLI_MODEL",
),
"pi": CLIProviderRegistration(
adapter_factory=_pi_factory,
model_env_key=require_provider_spec("pi").cli_model_env or "PI_MODEL",
),
}
def get_cli_provider_registration(provider: str) -> CLIProviderRegistration | None:
"""Return registration for *provider* if it is a registered CLI-backed LLM."""
return CLI_PROVIDER_REGISTRY.get(provider)
+273
View File
@@ -0,0 +1,273 @@
"""Shared subprocess executor for `LLMCLIAdapter` implementations."""
from __future__ import annotations
import logging
import re
import subprocess
import threading
import time
from collections.abc import Iterator
from typing import Any
from pydantic import BaseModel
from config.llm_reasoning_effort import get_active_reasoning_effort
from core.llm.shared.structured_output import StructuredOutputClient
from core.llm.types import LLMResponse
from integrations.llm_cli.base import CLIProbe, LLMCLIAdapter
from integrations.llm_cli.constants import (
EX_TEMPFAIL as _EX_TEMPFAIL,
)
from integrations.llm_cli.constants import (
PROBE_CACHE_TTL_SEC as _PROBE_CACHE_TTL_SEC,
)
from integrations.llm_cli.constants import (
TEMPFAIL_BACKOFF_SEC as _TEMPFAIL_BACKOFF_SEC,
)
from integrations.llm_cli.constants import (
TEMPFAIL_MAX_RETRIES as _TEMPFAIL_MAX_RETRIES,
)
from integrations.llm_cli.errors import (
CLIAuthenticationRequired,
CLIInterruptedError,
CLITimeoutError,
)
from integrations.llm_cli.subprocess_env import build_cli_subprocess_env
from integrations.llm_cli.text import flatten_messages_to_prompt
logger = logging.getLogger(__name__)
_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m")
_REDACTED_PROMPT_ARG = "<redacted-prompt>"
# Avoid re-running `detect()` (two subprocess probes) on every invoke during long
# investigations. Value is defined in shared constants.
# POSIX EX_TEMPFAIL (75): the subprocess hit a transient error and can be retried.
# kimi uses this when a session dies mid-flight ("To resume this session: kimi -r …").
# Back-compat name for tests and imports that expect this symbol on runner.
_build_subprocess_env = build_cli_subprocess_env
def _strip_ansi(text: str) -> str:
return _ANSI_ESCAPE.sub("", text)
def _sanitize_argv_for_debug(argv: tuple[str, ...], *, prompt: str) -> list[str]:
"""Redact prompt text from debug argv logs when passed as a CLI argument."""
if not prompt:
return list(argv)
redacted: list[str] = []
prompt_equals_form = f"--prompt={prompt}"
for arg in argv:
if arg == prompt:
redacted.append(_REDACTED_PROMPT_ARG)
continue
if arg == prompt_equals_form:
redacted.append(f"--prompt={_REDACTED_PROMPT_ARG}")
continue
redacted.append(arg)
return redacted
class CLIBackedLLMClient:
"""Drives any `LLMCLIAdapter` with a single non-interactive subprocess call per invoke."""
def __init__(
self,
adapter: LLMCLIAdapter,
*,
model: str | None = None,
max_tokens: int = 1024,
model_type: str = "reasoning",
) -> None:
self._adapter = adapter
self._model = model
self._max_tokens = max_tokens
self._model_type = model_type
self._cached_probe: CLIProbe | None = None
self._probe_cached_at: float = 0.0
self._probe_lock = threading.Lock()
def _probe(self) -> CLIProbe:
now = time.monotonic()
if self._cached_probe is not None and (now - self._probe_cached_at) < _PROBE_CACHE_TTL_SEC:
return self._cached_probe
with self._probe_lock:
locked_now = time.monotonic()
if (
self._cached_probe is not None
and (locked_now - self._probe_cached_at) < _PROBE_CACHE_TTL_SEC
):
return self._cached_probe
probe = self._adapter.detect()
self._cached_probe = probe
self._probe_cached_at = locked_now
return probe
def with_config(self, **_kwargs: Any) -> CLIBackedLLMClient:
return self
def with_structured_output(self, model: type[BaseModel]) -> Any:
"""JSON-schema prompt + parse; same contract as API `StructuredOutputClient`."""
return StructuredOutputClient(self, model)
def bind_tools(self, _tools: list[Any]) -> CLIBackedLLMClient:
return self
def invoke(self, prompt_or_messages: Any) -> LLMResponse:
# max_tokens / model_type are stored for API parity but ignored here:
# CLI adapters (e.g. codex exec) do not expose a scriptable token limit.
_ = self._max_tokens
_ = self._model_type
from platform.guardrails.apply import apply_guardrails_to_text
flat = flatten_messages_to_prompt(prompt_or_messages)
flat = apply_guardrails_to_text(flat)
probe = self._probe()
if not probe.installed or not probe.bin_path:
raise RuntimeError(
f"{self._adapter.name} CLI not found. {self._adapter.install_hint} "
f"or set {self._adapter.binary_env_key} to the full binary path. "
f"({probe.detail})"
)
if probe.logged_in is False:
raise CLIAuthenticationRequired(
provider=self._adapter.name,
auth_hint=self._adapter.auth_hint,
detail=probe.detail,
)
auth_probe_unclear = probe.logged_in is None
invocation = self._adapter.build(
prompt=flat,
model=self._model,
workspace="",
reasoning_effort=get_active_reasoning_effort(),
)
merged_env = _build_subprocess_env(invocation.env)
logger.debug(
"cli_llm_spawn",
extra={
"provider": self._adapter.name,
"argv": _sanitize_argv_for_debug(invocation.argv, prompt=flat),
},
)
backoff = _TEMPFAIL_BACKOFF_SEC
for attempt in range(_TEMPFAIL_MAX_RETRIES + 1):
try:
proc = subprocess.run(
list(invocation.argv),
input=invocation.stdin,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
cwd=invocation.cwd,
env=merged_env,
timeout=invocation.timeout_sec,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise CLITimeoutError(
f"{self._adapter.name} CLI timed out after {invocation.timeout_sec:.0f}s."
) from exc
except OSError as exc:
raise RuntimeError(f"Failed to spawn {self._adapter.name} CLI: {exc}") from exc
if proc.returncode == _EX_TEMPFAIL and attempt < _TEMPFAIL_MAX_RETRIES:
logger.warning(
"cli_llm_tempfail_retry",
extra={
"provider": self._adapter.name,
"attempt": attempt + 1,
"backoff_sec": backoff,
},
)
time.sleep(backoff)
backoff *= 2
continue
break
out = _strip_ansi(proc.stdout or "")
err = _strip_ansi(proc.stderr or "")
if proc.returncode != 0:
# Exit code 130 = subprocess terminated by SIGINT (Ctrl+C); raise
# CLIInterruptedError so callers using `try/except Exception` still
# observe the failure (KeyboardInterrupt inherits from BaseException
# and would bypass those handlers). Sentry's `ignore_errors` config
# filters this type so user-initiated cancellations are not reported
# as bugs.
if proc.returncode == 130:
raise CLIInterruptedError(f"{self._adapter.name} CLI subprocess interrupted.")
# Exit code 75 is EX_TEMPFAIL (sysexits.h) — a transient failure
# the caller should retry. Raise CLITimeoutError so it is treated as
# an expected operational failure and not forwarded to Sentry.
if proc.returncode == _EX_TEMPFAIL:
hint = (
f"{self._adapter.name} reported a temporary failure (exit 75). "
"Retry the request or check network connectivity."
)
if err:
hint = f"{hint} {err[:200]}"
raise CLITimeoutError(hint)
base = self._adapter.explain_failure(
stdout=out, stderr=err, returncode=proc.returncode
).strip()
# When the failure message signals an auth problem raise
# CLIAuthenticationRequired so callers (reraise_cli_runtime_error,
# server endpoints) get structured, actionable handling instead of
# a bare RuntimeError that lands in Sentry as a spurious bug.
# Patterns cover all current adapters:
# kimi → "not logged in", "api key invalid", "re-authenticate"
# cursor → "not logged in"
# opencode → "authentication failed", "not authenticated"
# claude/gemini/codex pass raw stderr which may contain these phrases too
_base_lower = base.lower()
if (
"not logged in" in _base_lower
or "api key invalid" in _base_lower
or "re-authenticate" in _base_lower
or "authentication failed" in _base_lower
or "not authenticated" in _base_lower
):
raise CLIAuthenticationRequired(
provider=self._adapter.name,
auth_hint=self._adapter.auth_hint,
detail=base,
)
if auth_probe_unclear:
message = (
f"{base}\n\n"
f"Auth status could not be verified before invocation. "
f"{self._adapter.auth_hint} ({probe.detail})"
)
else:
message = base
raise RuntimeError(message)
content = self._adapter.parse(stdout=out, stderr=err, returncode=proc.returncode)
content = _strip_ansi(content).strip()
if err:
logger.debug(
"cli_llm_stderr",
extra={"provider": self._adapter.name, "stderr": err[:500]},
)
logger.debug(
"cli_llm_invoke",
extra={"provider": self._adapter.name, "cli_cost_unknown": True},
)
return LLMResponse(content=content)
def invoke_stream(self, prompt_or_messages: Any) -> Iterator[str]:
"""Yield the full response as one chunk; real streaming is a follow-up.
Subprocess CLI adapters ``subprocess.run`` to completion, so this
satisfies the protocol contract without faking progressive output.
"""
yield self.invoke(prompt_or_messages).content
+21
View File
@@ -0,0 +1,21 @@
"""Shared semver parsing/comparison helpers for CLI adapters."""
from __future__ import annotations
import re
_SEMVER_THREE_PART_RE = re.compile(r"(\d+\.\d+\.\d+)")
def parse_semver_three_part(text: str) -> str | None:
"""Extract ``major.minor.patch`` from arbitrary version output text."""
match = _SEMVER_THREE_PART_RE.search(text or "")
return match.group(1) if match else None
def semver_to_tuple(version: str) -> tuple[int, int, int]:
"""Convert a semver-ish string into a comparable (major, minor, patch) tuple."""
parts = [int(m) for m in re.findall(r"\d+", version)][:3]
while len(parts) < 3:
parts.append(0)
return parts[0], parts[1], parts[2]
+93
View File
@@ -0,0 +1,93 @@
"""Filtered environment for spawning LLM CLI subprocesses.
Only keys needed for binary resolution, locale, proxies, TLS, and adapter-specific
prefixes are forwarded from ``os.environ``. Adapter implementations merge their
own overrides (for example explicit API keys for that CLI only).
"""
from __future__ import annotations
import os
_SAFE_SUBPROCESS_ENV_KEYS = frozenset(
{
"HOME",
# macOS Keychain item lookup (where `claude login` stores OAuth on darwin)
# requires USER. LOGNAME is the POSIX/Linux equivalent kept for parity.
"USER",
"LOGNAME",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"PATH",
"PATHEXT",
"SYSTEMROOT",
"WINDIR",
"COMSPEC",
"SHELL",
"TMP",
"TEMP",
"TMPDIR",
"LANG",
"TERM",
"TZ",
"NO_PROXY",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"REQUESTS_CA_BUNDLE",
"CURL_CA_BUNDLE",
"NO_COLOR",
"FORCE_COLOR",
"COLORTERM",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
}
)
_SAFE_SUBPROCESS_ENV_PREFIXES = (
"LC_",
"CODEX_",
"CURSOR_",
"CLAUDE_",
"GEMINI_",
"GOOGLE_",
# ``ANTIGRAVITY_*`` currently only carries non-secret config
# (``ANTIGRAVITY_CLI_BIN`` path, ``ANTIGRAVITY_CLI_TIMEOUT_SECONDS``
# numeric). If Google ever ships a secret-bearing env (e.g.
# ``ANTIGRAVITY_API_KEY``), revisit this — a blanket prefix would
# leak the secret into every other CLI subprocess. Mirror the
# COPILOT_ pattern below if that happens.
"ANTIGRAVITY_",
"OPENCODE_",
"KIMI_",
# Pi CLI (pi.dev) non-secret config vars (e.g. ``PI_AGENT_DIR`` /
# ``PI_CONFIG_DIR``, ``PI_BIN``, ``PI_MODEL``). Pi's per-provider API keys
# are NOT ``PI_``-prefixed (they are ``ANTHROPIC_API_KEY`` etc.) and are
# forwarded only via the Pi adapter's ``CLIInvocation.env`` — see
# ``PI_PROVIDER_ENV_KEYS`` in ``env_overrides.py`` — so this prefix carries
# no secrets.
"PI_",
# NOTE: deliberately NO ``COPILOT_`` entry. ``COPILOT_GITHUB_TOKEN`` is a
# GitHub PAT; if we forwarded it via this prefix allowlist it would leak
# into every other CLI subprocess (Codex, Kimi, Claude Code, etc.). All
# Copilot envs (config + credentials) flow through ``CLIInvocation.env``
# built by ``CopilotAdapter.build`` so they only reach the Copilot
# subprocess. See ``env_overrides.py`` for the COPILOT_*_ENV_KEYS tuples.
)
def build_cli_subprocess_env(overrides: dict[str, str] | None) -> dict[str, str]:
"""Return a subprocess ``env`` dict: safe inherited keys plus optional overrides."""
env: dict[str, str] = {}
for key, value in os.environ.items():
if key in _SAFE_SUBPROCESS_ENV_KEYS or any(
key.startswith(prefix) for prefix in _SAFE_SUBPROCESS_ENV_PREFIXES
):
env[key] = value
if overrides:
env.update(overrides)
return env
+36
View File
@@ -0,0 +1,36 @@
"""Flatten chat-style messages into a single prompt string for CLI stdin."""
from __future__ import annotations
from typing import Any
def flatten_messages_to_prompt(prompt_or_messages: Any) -> str:
"""Turn structured chat messages or a plain string into one text block."""
if isinstance(prompt_or_messages, str):
return prompt_or_messages
if not isinstance(prompt_or_messages, list):
return str(prompt_or_messages)
parts: list[str] = []
for msg in prompt_or_messages:
if not isinstance(msg, dict):
parts.append(str(msg))
continue
role = str(msg.get("role", "user")).strip().lower() or "user"
content = msg.get("content", "")
if isinstance(content, list):
text_bits: list[str] = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text_bits.append(str(block.get("text", "")))
else:
text_bits.append(str(block))
content = "\n".join(text_bits)
else:
content = str(content)
label = role.upper()
parts.append(f"=== {label} ===\n{content}")
return "\n\n".join(parts).strip()
+28
View File
@@ -0,0 +1,28 @@
"""Shared helpers for timeout environment parsing."""
from __future__ import annotations
import os
def resolve_timeout_from_env(
*,
env_key: str,
default: float,
minimum: float,
maximum: float,
) -> float:
"""Resolve timeout from an env var with defaulting + clamping.
Invalid, empty, and non-positive values fall back to ``default``.
"""
raw = os.environ.get(env_key, "").strip()
if not raw:
return default
try:
value = float(raw)
except ValueError:
return default
if value <= 0:
return default
return max(minimum, min(value, maximum))