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
299 lines
10 KiB
Python
299 lines
10 KiB
Python
"""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,
|
|
)
|