rein-aharness/rein_aharness/llm_connect_client.py
tegwick f6930ad115 Rename package, CLI, and deploy artifacts to rein-aharness (HARNESS-WP-0002-T02)
agent_harness -> rein_aharness (package + all imports), CLI command
agent-harness -> rein-aharness, Docker image tag, k8s namespace/labels/
names, Makefile targets, deploy script env var/paths. In-repo identity
strings (hub event source, metrics harness field, default assignee,
argparse prog name, commit author identity) updated to match.

Historical documents left untouched on purpose: docs/adr/ADR-001-agent-harness-architecture.md,
docs/architecture.md (dated v0.1 snapshot), workplans/HARNESS-WP-0001
(completed under the old name), and the SSH host alias
"forgejo-agent-harness" (external ~/.ssh/config entry, not owned here).

Verified: 47/47 tests pass, CLI runs correctly from a fresh venv,
`make image` builds and the resulting container runs correctly.

deploy/README.md gained an explicit rename cutover checklist for what
this session cannot safely do unattended -- moving the host-side
secrets dir and checkout on railiance01, and not deleting the old k8s
namespace until the new one is confirmed working. The actual live
cutover (running that checklist against the real Railiance deployment)
is not attempted here -- real production surgery on binky-control's
live automation, needs the operator present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 14:22:18 +02:00

103 lines
3 KiB
Python

"""HTTP client for in-cluster / remote llm-connect (server mode).
Mirrors activity-core's llm_client pattern: provider keys and model routing
stay behind llm-connect; rein-aharness only sends prompts over HTTP.
"""
from __future__ import annotations
import os
from typing import Any
import httpx
_SAFE_RESPONSE_METADATA_KEYS = frozenset(
{
"finish_reason",
"usage",
"model",
"model_name",
"provider",
"request_id",
"response_id",
"trace_id",
"latency_ms",
"duration_ms",
"elapsed_ms",
"created",
"created_at",
}
)
class LLMConnectError(RuntimeError):
pass
class LLMConnectClient:
"""Synchronous client for llm-connect ``POST /execute``."""
def __init__(self, base_url: str, timeout_seconds: float = 300.0) -> None:
self.base_url = base_url.rstrip("/")
self.timeout_seconds = timeout_seconds
self.last_response_metadata: dict[str, Any] | None = None
def complete(
self,
prompt: str,
*,
model: str = "",
config: dict[str, Any] | None = None,
) -> str:
run_config = dict(config or {})
if model and "model_name" not in run_config:
run_config["model_name"] = model
run_config.setdefault("timeout_seconds", int(self.timeout_seconds))
payload: dict[str, Any] = {"prompt": prompt, "config": run_config}
try:
resp = httpx.post(
f"{self.base_url}/execute",
json=payload,
timeout=self.timeout_seconds,
)
resp.raise_for_status()
except httpx.HTTPError as exc:
raise LLMConnectError(f"llm-connect request failed: {exc}") from exc
try:
data = resp.json()
except ValueError as exc:
raise LLMConnectError("llm-connect returned non-JSON body") from exc
self.last_response_metadata = _extract_response_metadata(data)
content = data.get("content")
if not isinstance(content, str) or not content.strip():
raise LLMConnectError("llm-connect response missing string content")
return content
def get_llm_connect_client() -> LLMConnectClient:
base_url = os.environ.get("LLM_CONNECT_URL", "").strip()
if not base_url:
raise LLMConnectError(
"LLM_CONNECT_URL is not set "
"(e.g. http://llm-connect.activity-core.svc.cluster.local:8080)"
)
timeout = float(os.environ.get("LLM_CONNECT_TIMEOUT_SECONDS", "300"))
return LLMConnectClient(base_url, timeout)
def _extract_response_metadata(data: dict[str, Any]) -> dict[str, Any]:
out: dict[str, Any] = {}
for key, value in data.items():
if key in _SAFE_RESPONSE_METADATA_KEYS and _json_safe(value):
out[key] = value
return out
def _json_safe(value: Any) -> bool:
try:
import json
json.dumps(value)
except (TypeError, ValueError):
return False
return True