diff --git a/researchclaw/health.py b/researchclaw/health.py index b9289242..47805f53 100644 --- a/researchclaw/health.py +++ b/researchclaw/health.py @@ -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( @@ -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(): @@ -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: @@ -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 ( diff --git a/tests/test_rc_health.py b/tests/test_rc_health.py index 45c3a266..4d17edf8 100644 --- a/tests/test_rc_health.py +++ b/tests/test_rc_health.py @@ -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),