Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions researchclaw/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,146 @@ def _is_timeout(exc: BaseException) -> bool:
return isinstance(reason, (TimeoutError, socket.timeout))


def _is_anthropic(base_url: str) -> bool:
return "anthropic.com" in base_url.lower()


def _anthropic_messages_url(base_url: str) -> str:
root = base_url.rstrip("/")
if root.endswith("/v1"):
return f"{root}/messages"
return f"{root}/v1/messages"


def _anthropic_probe(base_url: str, api_key: str, model: str = "_probe") -> int:
"""POST a minimal /v1/messages probe and return the HTTP status."""
url = _anthropic_messages_url(base_url)
payload = json.dumps(
{
"model": model,
"max_tokens": 1,
"messages": [{"role": "user", "content": "."}],
}
).encode("utf-8")
headers = {
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
}
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=5):
return 200
except urllib.error.HTTPError as exc:
return exc.code


def _check_anthropic_connectivity(base_url: str, api_key: str) -> CheckResult:
"""llm_connectivity for Anthropic — any HTTP response proves reachability."""
url = _anthropic_messages_url(base_url)
try:
status = _anthropic_probe(base_url, api_key)
except urllib.error.URLError as exc:
if _is_timeout(exc):
return CheckResult(
name="llm_connectivity",
status="fail",
detail="LLM endpoint unreachable",
fix="Verify endpoint URL and network connectivity",
)
return CheckResult(
name="llm_connectivity",
status="fail",
detail=f"LLM connectivity error: {exc.reason}",
fix="Verify endpoint URL and network connectivity",
)
except TimeoutError:
return CheckResult(
name="llm_connectivity",
status="fail",
detail="LLM endpoint unreachable",
fix="Verify endpoint URL and network connectivity",
)

# 400/404 = bad probe model, 401 = bad key, 429 = rate limit — all still reachable.
if status in (200, 400, 401, 403, 404, 429):
return CheckResult(
name="llm_connectivity",
status="pass",
detail=f"Reachable (HTTP {status}): {url}",
)
return CheckResult(
name="llm_connectivity",
status="fail",
detail=f"LLM endpoint HTTP {status}",
fix="Check llm.base_url and provider status",
)


def _check_anthropic_api_key(base_url: str, api_key: str) -> CheckResult:
"""api_key_valid for Anthropic — 401 means rejected, others mean accepted."""
try:
status = _anthropic_probe(base_url, api_key)
except urllib.error.URLError as exc:
return CheckResult(
name="api_key_valid",
status="warn",
detail=f"Could not verify API key: {exc.reason}",
fix="Retry when endpoint/network is available",
)
except TimeoutError:
return CheckResult(
name="api_key_valid",
status="warn",
detail="Could not verify API key (timeout)",
fix="Retry when endpoint/network is available",
)

if status == 401:
return CheckResult(
name="api_key_valid",
status="fail",
detail="Invalid API key",
fix="Set a valid API key for the configured endpoint",
)
# 404 here means "model not found" — the key was accepted before the model lookup.
if status in (200, 400, 404, 429):
return CheckResult(
name="api_key_valid",
status="pass",
detail="API key accepted",
)
return CheckResult(
name="api_key_valid",
status="warn",
detail=f"API key check returned HTTP {status}",
fix="Verify endpoint health and API key permissions",
)


def _check_anthropic_models(
base_url: str, api_key: str, models: list[str]
) -> tuple[set[str], set[str]] | None:
"""Return (available, missing) sets, or None if endpoint unreachable."""
available: set[str] = set()
missing: set[str] = set()
for model in models:
try:
status = _anthropic_probe(base_url, api_key, model=model)
except (urllib.error.URLError, TimeoutError):
return None
if status == 200:
available.add(model)
elif status == 404:
# Anthropic returns 404 with not_found_error for unknown models.
missing.add(model)
elif status in (401, 403, 429) or status >= 500:
return None
else:
missing.add(model)
return available, missing


def check_llm_connectivity(base_url: str, api_key: str = "") -> CheckResult:
if not base_url.strip():
return CheckResult(
Expand All @@ -170,6 +310,9 @@ def check_llm_connectivity(base_url: str, api_key: str = "") -> CheckResult:
fix="Set llm.base_url in config",
)

if _is_anthropic(base_url):
return _check_anthropic_connectivity(base_url, api_key)

url = _models_url(base_url)
headers: dict[str, str] = {}
if api_key.strip():
Expand Down Expand Up @@ -296,6 +439,9 @@ def check_api_key_valid(base_url: str, api_key: str) -> CheckResult:
fix="Set llm.api_key or environment variable defined by llm.api_key_env",
)

if _is_anthropic(base_url):
return _check_anthropic_api_key(base_url, api_key)

try:
status, _ = _fetch_models(base_url, api_key)
if status == 200:
Expand Down Expand Up @@ -427,6 +573,9 @@ def _check_models_against_endpoint(
if not models:
return set(), set()

if _is_anthropic(base_url):
return _check_anthropic_models(base_url, api_key, models)

try:
_, payload = _fetch_models(base_url, api_key)
except (
Expand Down
72 changes: 72 additions & 0 deletions tests/test_rc_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,78 @@ def test_check_model_chain_no_models() -> None:
assert "No models configured" in result.detail


def test_is_anthropic_url() -> None:
assert health._is_anthropic("https://api.anthropic.com")
assert health._is_anthropic("https://api.anthropic.com/v1")
assert not health._is_anthropic("https://api.openai.com/v1")


def test_anthropic_messages_url() -> None:
assert (
health._anthropic_messages_url("https://api.anthropic.com")
== "https://api.anthropic.com/v1/messages"
)
assert (
health._anthropic_messages_url("https://api.anthropic.com/v1")
== "https://api.anthropic.com/v1/messages"
)


def test_check_llm_connectivity_anthropic_pass() -> None:
with patch("urllib.request.urlopen", return_value=_DummyHTTPResponse(status=200)):
result = health.check_llm_connectivity(
"https://api.anthropic.com", "sk-ant-test"
)
assert result.status == "pass"


def test_check_llm_connectivity_anthropic_404_still_pass() -> None:
# Anthropic returns 404 with not_found_error for unknown models — still proves reachability.
with patch(
"urllib.request.urlopen",
side_effect=urllib.error.HTTPError(
"https://api.anthropic.com/v1/messages", 404, "not found", {}, None
),
):
result = health.check_llm_connectivity(
"https://api.anthropic.com", "sk-ant-test"
)
assert result.status == "pass"


def test_check_api_key_valid_anthropic_invalid_401() -> None:
with patch(
"urllib.request.urlopen",
side_effect=urllib.error.HTTPError(
"https://api.anthropic.com/v1/messages", 401, "unauthorized", {}, None
),
):
result = health.check_api_key_valid("https://api.anthropic.com", "bad")
assert result.status == "fail"
assert "Invalid API key" in result.detail


def test_check_model_chain_anthropic_pass() -> None:
with patch("urllib.request.urlopen", return_value=_DummyHTTPResponse(status=200)):
result = health.check_model_chain(
"https://api.anthropic.com", "sk-ant-test", "claude-sonnet-4-6"
)
assert result.status == "pass"


def test_check_model_chain_anthropic_unknown_model() -> None:
with patch(
"urllib.request.urlopen",
side_effect=urllib.error.HTTPError(
"https://api.anthropic.com/v1/messages", 404, "not found", {}, None
),
):
result = health.check_model_chain(
"https://api.anthropic.com", "sk-ant-test", "claude-fake-9"
)
assert result.status == "fail"


def test_check_sandbox_python_exists() -> None:
with (
patch.object(Path, "exists", return_value=True),
Expand Down