activity-core/tests/test_llm_client.py

162 lines
4.8 KiB
Python
Raw Normal View History

from __future__ import annotations
import httpx
import pytest
from activity_core.llm_client import LLMConnectClient
def test_llm_connect_client_forwards_run_config(monkeypatch) -> None:
captured: dict = {}
class Response:
status_code = 200
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"},
}
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},
}
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")