2026-05-19 20:55:35 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import httpx
|
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
|
|
|
import pytest
|
2026-05-19 20:55:35 +02:00
|
|
|
|
|
|
|
|
from activity_core.llm_client import LLMConnectClient
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_llm_connect_client_forwards_run_config(monkeypatch) -> None:
|
|
|
|
|
captured: dict = {}
|
|
|
|
|
|
|
|
|
|
class Response:
|
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
|
|
|
status_code = 200
|
2026-05-19 20:55:35 +02:00
|
|
|
|
|
|
|
|
def json(self) -> dict:
|
2026-07-01 20:12:04 +02:00
|
|
|
return {
|
|
|
|
|
"content": '{"summary":"ok","recommendations":[]}',
|
|
|
|
|
"finish_reason": "stop",
|
|
|
|
|
"usage": {"input_tokens": 10, "output_tokens": 20},
|
|
|
|
|
"raw_response": {"provider_blob": "not persisted"},
|
|
|
|
|
}
|
2026-05-19 20:55:35 +02:00
|
|
|
|
|
|
|
|
def fake_post(url: str, json: dict, timeout: float) -> Response:
|
|
|
|
|
captured["url"] = url
|
|
|
|
|
captured["json"] = json
|
|
|
|
|
captured["timeout"] = timeout
|
|
|
|
|
return Response()
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(httpx, "post", fake_post)
|
|
|
|
|
|
|
|
|
|
client = LLMConnectClient("http://llm-connect.local/", timeout_seconds=42)
|
|
|
|
|
result = client.complete(
|
|
|
|
|
"Prompt",
|
|
|
|
|
model="fallback-model",
|
|
|
|
|
config={
|
|
|
|
|
"model_name": "custodian-triage-balanced",
|
|
|
|
|
"temperature": 0.2,
|
|
|
|
|
"max_tokens": 1200,
|
|
|
|
|
"max_depth": 2,
|
|
|
|
|
"model_params": {"reasoning_effort": "medium"},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result == '{"summary":"ok","recommendations":[]}'
|
|
|
|
|
assert captured["url"] == "http://llm-connect.local/execute"
|
|
|
|
|
assert captured["timeout"] == 42
|
|
|
|
|
assert captured["json"] == {
|
|
|
|
|
"prompt": "Prompt",
|
|
|
|
|
"config": {
|
|
|
|
|
"model_name": "custodian-triage-balanced",
|
|
|
|
|
"temperature": 0.2,
|
|
|
|
|
"max_tokens": 1200,
|
|
|
|
|
"max_depth": 2,
|
|
|
|
|
"model_params": {"reasoning_effort": "medium"},
|
|
|
|
|
"timeout_seconds": 42,
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-07-01 20:12:04 +02:00
|
|
|
assert client.last_response_metadata == {
|
|
|
|
|
"finish_reason": "stop",
|
|
|
|
|
"usage": {"input_tokens": 10, "output_tokens": 20},
|
|
|
|
|
}
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
class ErrorResponse:
|
|
|
|
|
"""Models an llm-connect error reply, which carries the real cause."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, status_code: int, body) -> None:
|
|
|
|
|
self.status_code = status_code
|
|
|
|
|
self._body = body
|
|
|
|
|
|
|
|
|
|
def json(self):
|
|
|
|
|
if self._body is _NON_JSON:
|
|
|
|
|
raise ValueError("not json")
|
|
|
|
|
return self._body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_NON_JSON = object()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _client(monkeypatch, response) -> LLMConnectClient:
|
|
|
|
|
monkeypatch.setattr(httpx, "post", lambda url, json, timeout: response)
|
|
|
|
|
return LLMConnectClient("http://llm-connect.local")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rejected_provider_key_is_distinguishable_from_a_dead_gateway(monkeypatch) -> None:
|
|
|
|
|
"""llm-connect maps every provider API error onto 502 (server.py:230).
|
|
|
|
|
|
|
|
|
|
ACTIVITY-WP-0031: reporting only "502 Bad Gateway" made a revoked
|
|
|
|
|
OpenRouter key look like a downed llm-connect for four days.
|
|
|
|
|
"""
|
|
|
|
|
client = _client(
|
|
|
|
|
monkeypatch,
|
|
|
|
|
ErrorResponse(
|
|
|
|
|
502,
|
|
|
|
|
{
|
|
|
|
|
"error": "provider_api_error",
|
|
|
|
|
"provider_status": 401,
|
|
|
|
|
"provider": "openrouter",
|
|
|
|
|
"message": "No auth credentials found",
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(RuntimeError) as excinfo:
|
|
|
|
|
client.complete("Prompt")
|
|
|
|
|
|
|
|
|
|
text = str(excinfo.value)
|
|
|
|
|
assert "HTTP 502" in text
|
|
|
|
|
assert "error=provider_api_error" in text
|
|
|
|
|
assert "provider_status=401" in text
|
|
|
|
|
assert "provider=openrouter" in text
|
|
|
|
|
assert "message=No auth credentials found" in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_error_text_keeps_only_allowlisted_fields(monkeypatch) -> None:
|
|
|
|
|
client = _client(
|
|
|
|
|
monkeypatch,
|
|
|
|
|
ErrorResponse(
|
|
|
|
|
502,
|
|
|
|
|
{
|
|
|
|
|
"error": "provider_api_error",
|
|
|
|
|
"provider_status": 401,
|
|
|
|
|
"api_key": "sk-should-never-appear",
|
|
|
|
|
"raw_response": {"authorization": "Bearer secret"},
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(RuntimeError) as excinfo:
|
|
|
|
|
client.complete("Prompt")
|
|
|
|
|
|
|
|
|
|
text = str(excinfo.value)
|
|
|
|
|
assert "sk-should-never-appear" not in text
|
|
|
|
|
assert "authorization" not in text
|
|
|
|
|
assert "provider_status=401" in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_error_message_is_bounded(monkeypatch) -> None:
|
|
|
|
|
client = _client(
|
|
|
|
|
monkeypatch,
|
|
|
|
|
ErrorResponse(502, {"error": "provider_api_error", "message": "x" * 5000}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(RuntimeError) as excinfo:
|
|
|
|
|
client.complete("Prompt")
|
|
|
|
|
|
|
|
|
|
assert len(str(excinfo.value)) < 600
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_error_without_a_usable_body_still_reports_the_status(monkeypatch) -> None:
|
|
|
|
|
client = _client(monkeypatch, ErrorResponse(504, _NON_JSON))
|
|
|
|
|
|
|
|
|
|
with pytest.raises(RuntimeError, match="llm-connect returned HTTP 504"):
|
|
|
|
|
client.complete("Prompt")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_error_with_a_non_dict_body_still_reports_the_status(monkeypatch) -> None:
|
|
|
|
|
client = _client(monkeypatch, ErrorResponse(500, ["unexpected"]))
|
|
|
|
|
|
|
|
|
|
with pytest.raises(RuntimeError, match="llm-connect returned HTTP 500"):
|
|
|
|
|
client.complete("Prompt")
|