Files
wehub-resource-sync 4b6817381b
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
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

294 lines
11 KiB
Python

"""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),
)