"""
XKB Unified LLM Helper
======================
Single entry point for all LLM calls in xkb scripts.
Model is configured in config/llm.json — change "model" there to switch all
scripts at once. OpenClaw handles auth/token-refresh automatically.
Usage:
from _llm import call
text = call(system="You are helpful.", user="Summarize this: ...")
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
import xkb_failures
from runtime_config import runtime_env, llm_service_env
_SKILL_DIR = Path(__file__).resolve().parent.parent
_CONFIG_FILE = _SKILL_DIR / "config" / "llm.json"
# Non-secret default only; endpoint and credentials come from the isolated
# generation service route, never from the judge route.
FALLBACK_MODEL = "gpt-6-astra"
def _runtime_settings() -> dict[str, str]:
"""Read credentials from process env or explicit XKB_ENV_FILE only."""
return llm_service_env("generation")
def _load_model() -> str:
env_model = _runtime_settings().get("LLM_MODEL", "")
if env_model:
return env_model
try:
cfg = json.loads(_CONFIG_FILE.read_text(encoding="utf-8"))
return cfg.get("model", FALLBACK_MODEL)
except Exception as err:
# 設定檔存在卻讀不動,跟沒有設定檔是兩件事。退回的這個預設值目前在
# 代理上會回 server_error,所以一個打錯字的設定會表現成「每次呼叫模型
# 都失敗」,而不是「你的設定檔壞了」。
if _CONFIG_FILE.exists():
xkb_failures.note("LLM config", err, detail=str(_CONFIG_FILE))
return FALLBACK_MODEL
def _direct_api_call(system: str, user: str, *, timeout: int = 120,
max_tokens: int = 4096, model: str | None = None,
attempts: int | None = None) -> str:
"""
Fallback when openclaw CLI is not available.
Uses LLM_API_URL + LLM_API_KEY env vars for direct HTTP calls.
Supports both OpenAI chat-completions format and Anthropic messages format.
"""
settings = _runtime_settings()
direct_api_url = settings.get("LLM_API_URL", "")
direct_api_key = settings.get("LLM_API_KEY", "")
if not direct_api_url or not direct_api_key:
raise RuntimeError(
"openclaw is not installed and LLM_API_URL / LLM_API_KEY are not set.\n"
"Set them to use a direct OpenAI-compatible API fallback:\n"
" export LLM_API_URL=https://your-openai-compatible-endpoint/v1\n"
" export LLM_API_KEY=your-key\n"
" export LLM_MODEL=gpt-6-luna"
)
model = model or _load_model()
attempts = MAX_ATTEMPTS if attempts is None else attempts
if type(attempts) is not int or attempts < 1:
raise ValueError("attempts must be a positive integer")
url = direct_api_url.rstrip("/")
# Use Anthropic format only when URL explicitly contains "/anthropic"
is_anthropic = "/anthropic" in url
is_gemini = "generativelanguage.googleapis.com" in url
if is_gemini:
# Gemini native generateContent format
if ":generateContent" not in url:
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
# model var not used for URL when generateContent already in URL
url = f"{url}?key={direct_api_key}"
parts_text = (f"{system.strip()}\n\n" if system else "") + user.strip()
payload = {"contents": [{"parts": [{"text": parts_text}]}],
"generationConfig": {"maxOutputTokens": max_tokens}}
headers = {"Content-Type": "application/json"}
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
import time as _time
_last_err = None
for _attempt in range(attempts):
if _attempt:
_time.sleep(8 * _attempt)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
return data["candidates"][0]["content"]["parts"][0]["text"].strip()
except urllib.error.HTTPError as _he:
_body = _he.read().decode()[:200]
_last_err = RuntimeError(f"Direct API call failed: {_he!r} body={_body}")
if _he.code not in (429, 503):
raise _last_err
except Exception as _e:
raise RuntimeError(f"Direct API call failed: {_e!r}")
raise _last_err # type: ignore
elif is_anthropic:
# Anthropic messages format
if not url.endswith("/messages"):
url = url + "/messages"
messages = [{"role": "user", "content": user}]
payload = {
"model": model,
"max_tokens": max_tokens,
"messages": messages,
}
if system:
payload["system"] = system
headers = {
"Content-Type": "application/json",
"x-api-key": direct_api_key,
"anthropic-version": "2023-06-01",
"Authorization": f"Bearer {direct_api_key}",
}
else:
# OpenAI chat completions format
if not url.endswith("/chat/completions"):
url = url + "/chat/completions"
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": user})
payload = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {direct_api_key}",
}
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
data = _post_with_retry(req, timeout, attempts=attempts)
if is_anthropic:
# Handle thinking blocks: find first text block
content = data.get("content", [])
if isinstance(content, list):
text_block = next((b for b in content if b.get("type") == "text"), None)
if text_block:
return text_block["text"].strip()
raise RuntimeError(f"Unexpected Anthropic response format: {data}")
else:
# OpenAI format
choices = data.get("choices", [])
if choices:
text = choices[0].get("message", {}).get("content", "") or ""
# Strip ... blocks from reasoning-style providers
import re as _re
text = _re.sub(r"[\s\S]*?", "", text).strip()
return text
raise RuntimeError(f"Unexpected OpenAI response format: {data}")
# Rate limiting and a briefly unavailable upstream are worth waiting out; a
# rejected key or an unknown model will fail the same way five times in a row.
TRANSIENT_STATUS = (429, 500, 502, 503, 504)
MAX_ATTEMPTS = 5
def _post_with_retry(req, timeout: int, *, attempts: int | None = None) -> dict:
"""POST, retrying only the failures that another attempt could fix.
A long batch is only as reliable as its flakiest call: a run of a hundred
digestion calls used to end on whichever one happened to catch a 503.
"""
import time as _time
last: Exception | None = None
for attempt in range(MAX_ATTEMPTS if attempts is None else attempts):
if attempt:
_time.sleep(4 * attempt)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as err:
detail = err.read().decode("utf-8", "replace")[:200]
last = RuntimeError(f"Direct API call failed: {err!r} body={detail}")
if err.code not in TRANSIENT_STATUS:
raise last from err
except (urllib.error.URLError, TimeoutError, ConnectionError) as err:
last = RuntimeError(f"Direct API call failed: {err!r}")
except Exception as err:
raise RuntimeError(f"Direct API call failed: {err!r}") from err
raise last if last else RuntimeError("Direct API call failed with no error recorded")
def call(system: str, user: str, *, model: str | None = None, timeout: int = 120,
max_tokens: int = 4096) -> str:
"""
Call the configured LLM via `openclaw capability model run`.
OpenClaw handles provider auth (OAuth, API keys) automatically.
Configure the default model in config/llm.json.
Args:
system: System prompt text.
user: User message / task prompt.
model: Override model for this call only.
timeout: Seconds to wait for a response (default 120).
Returns:
LLM response text, stripped.
Raises:
RuntimeError: If the call fails or returns no output.
"""
m = model or _load_model()
# Hermes supplies an explicit OpenAI-compatible runtime endpoint. Prefer it
# over the legacy OpenClaw CLI so Cron and non-OpenClaw sessions use the
# same provider contract.
settings = _runtime_settings()
if settings.get("LLM_API_URL") and settings.get("LLM_API_KEY"):
return _direct_api_call(system, user, timeout=timeout, max_tokens=max_tokens)
# The OpenClaw CLI is a legacy fallback, and it does not share the
# direct API's model contract: it requires while the
# API takes a bare name. config/llm.json holds one value, so a bare
# name means this call cannot succeed here. Say which credential is
# missing instead of letting the CLI reject the model downstream,
# where it surfaces as "produced no output" and reads like a crash.
if shutil.which("openclaw") is None or "/" not in m:
raise RuntimeError(
"No LLM backend is configured. Set LLM_API_URL and LLM_API_KEY in "
"the process environment, or in the file named by XKB_ENV_FILE.\n"
f"(The legacy openclaw CLI needs a name; the "
f"configured model is {m!r}.)"
)
# openclaw capability model run takes a single --prompt.
# We inject the system context as a prefix.
if system:
combined = f"[SYSTEM]\n{system.strip()}\n\n[USER]\n{user.strip()}"
else:
combined = user.strip()
cmd = ["openclaw", "capability", "model", "run", "--prompt", combined, "--json"]
if m:
cmd += ["--model", m]
try:
# The process may hang after outputting JSON (streaming artefact).
# We read stdout until we have a complete JSON object, then kill.
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
# Collect output until JSON is complete or timeout fires
import signal, threading
chunks: list[str] = []
done = threading.Event()
def _reader():
depth = 0
in_json = False
for ch in iter(lambda: proc.stdout.read(1), ""):
chunks.append(ch)
if ch == "{":
depth += 1
in_json = True
elif ch == "}":
depth -= 1
if in_json and depth == 0:
break
done.set()
t = threading.Thread(target=_reader, daemon=True)
t.start()
t.join(timeout=timeout)
proc.kill()
proc.wait()
raw_out = "".join(chunks).strip()
if not raw_out:
stderr = proc.stderr.read() if proc.stderr else ""
raise RuntimeError(f"openclaw model run produced no output. stderr: {stderr[:300]}")
data = json.loads(raw_out)
if not data.get("ok"):
raise RuntimeError(f"openclaw model run failed: {data}")
outputs = data.get("outputs", [])
if not outputs:
raise RuntimeError(f"openclaw model run returned empty outputs: {data}")
return outputs[0].get("text", "").strip()
except json.JSONDecodeError as e:
raise RuntimeError(f"Failed to parse openclaw JSON output: {e!r}\nraw={raw_out[:300]}")
except FileNotFoundError:
# openclaw not installed — fall back to direct API call
return _direct_api_call(system, user, timeout=timeout, max_tokens=max_tokens)
def call_raw(prompt: str, *, model: str | None = None, timeout: int = 120) -> str:
"""Single-prompt variant (no system/user split)."""
return call("", prompt, model=model, timeout=timeout)
import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py
if __name__ == "__main__":
xkb_usage.record(__file__)
# Quick smoke test: python3 scripts/_llm.py "Hello, reply in 5 words"
query = " ".join(sys.argv[1:]) or "Reply with: OK"
print(f"Model: {_load_model()}", file=sys.stderr)
print(call("You are a helpful assistant.", query))