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
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:
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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 30–600s).
|
||||
|
||||
``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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user