activity-core/src/activity_core/llm_client.py
tegwick 459a272974
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 30s
fix(llm): surface llm-connect's error body instead of a bare 502
llm-connect maps every provider API error onto HTTP 502 and puts the real
cause in the body (llm_connect/server.py::_error_response: error,
provider_status). LLMConnectClient.complete called raise_for_status() and threw
that body away, so a revoked OpenRouter key was indistinguishable from a downed
gateway — four days of production evidence read as "llm-connect is down".

Live check confirms one fault, not two: the llm-connect pod is Running 1/1 with
healthy endpoints, and today's FI/Binky/triage runs still 502 after yesterday's
rollout, matching the sanitized upstream 401 railiance-platform reported.

The client now raises with error, provider_status, provider, model and a
bounded copy of llm-connect's already-sanitized message, under a field
allowlist so no provider blob or key material reaches the run artefact.

Refs ACTIVITY-WP-0031-T01, T03.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:53:30 +02:00

145 lines
4.4 KiB
Python

"""llm-connect adapter for instruction execution.
activity-core deliberately talks to llm-connect over its small HTTP surface
instead of importing provider-specific SDKs. This keeps the activity worker on
owned infrastructure while leaving provider selection, API keys, and model
routing behind the existing llm-connect boundary.
"""
from __future__ import annotations
import os
from typing import Any
import httpx
class DisabledLLMClient:
"""LLM client used when no llm-connect endpoint is configured."""
last_response_metadata: dict[str, Any] | None = None
def complete(
self,
prompt: str,
model: str = "",
config: dict[str, Any] | None = None,
) -> str: # noqa: ARG002
raise RuntimeError("LLM_CONNECT_URL is not configured")
class LLMConnectClient:
"""Small synchronous client for llm-connect server mode."""
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,
}
resp = httpx.post(
f"{self.base_url}/execute",
json=payload,
timeout=self.timeout_seconds,
)
if resp.status_code >= 400:
raise RuntimeError(_llm_connect_error_text(resp))
data = resp.json()
self.last_response_metadata = _extract_response_metadata(data)
content = data.get("content")
if not isinstance(content, str):
raise ValueError("llm-connect response missing string content")
return content
_SAFE_ERROR_KEYS = ("error", "provider_status", "provider", "model")
_MAX_ERROR_MESSAGE_CHARS = 400
def _llm_connect_error_text(resp: httpx.Response) -> str:
"""Describe an llm-connect error using its own diagnostic body.
llm-connect maps every provider API error onto HTTP 502 and puts the real
cause in the body (``error``, ``provider_status``) — see
``llm_connect/server.py::_error_response``. Reporting only the transport
status makes a rejected provider key look identical to a dead gateway,
which is exactly the ambiguity ACTIVITY-WP-0031-T03 evidence must not have.
llm-connect sanitizes its own ``message`` before returning it; this keeps a
bounded copy of that text plus an allowlist of non-secret fields.
"""
base = f"llm-connect returned HTTP {resp.status_code}"
try:
body = resp.json()
except ValueError:
return base
if not isinstance(body, dict):
return base
parts = [
f"{key}={body[key]}"
for key in _SAFE_ERROR_KEYS
if body.get(key) not in (None, "")
]
message = body.get("message")
if isinstance(message, str) and message.strip():
parts.append(f"message={message.strip()[:_MAX_ERROR_MESSAGE_CHARS]}")
if not parts:
return base
return f"{base}: " + "; ".join(parts)
_SAFE_RESPONSE_METADATA_KEYS = {
"finish_reason",
"usage",
"model",
"model_name",
"provider",
"request_id",
"response_id",
"trace_id",
"latency_ms",
"duration_ms",
"elapsed_ms",
"created",
"created_at",
}
def _extract_response_metadata(data: dict[str, Any]) -> dict[str, Any]:
"""Keep non-secret llm-connect diagnostics alongside the returned content."""
return {
key: value for key, value in data.items()
if key in _SAFE_RESPONSE_METADATA_KEYS and _json_safe(value)
}
def _json_safe(value: Any) -> bool:
try:
import json
json.dumps(value)
except (TypeError, ValueError):
return False
return True
def get_llm_client() -> DisabledLLMClient | LLMConnectClient:
base_url = os.environ.get("LLM_CONNECT_URL", "").strip()
if not base_url:
return DisabledLLMClient()
timeout = float(os.environ.get("LLM_CONNECT_TIMEOUT_SECONDS", "300"))
return LLMConnectClient(base_url, timeout)