diff --git a/src/research_handler.py b/src/research_handler.py index 3484c46..381530b 100644 --- a/src/research_handler.py +++ b/src/research_handler.py @@ -30,6 +30,24 @@ def _bounded_int(value, *, default: int, minimum: int, maximum: int) -> int: return max(minimum, min(maximum, n)) +def _format_probe_failure(model: str, exc: Exception) -> str: + """Turn a failed research model probe into a user-facing message.""" + detail = getattr(exc, "detail", None) + status = getattr(exc, "status_code", None) + err = str(detail if detail is not None else exc).strip() + + if status in {401, 403} or "401" in err or "API key" in err or "Unauthorized" in err: + return f"Model '{model}' requires an API key. Check your endpoint configuration." + + if status and err: + return f"Model '{model}' probe failed: {err}" + + if err: + return f"Cannot reach model '{model}' — {err}" + + return f"Cannot reach model '{model}' — check that the endpoint is running and accessible." + + class ResearchHandler: """Handles research service operations with iterative deep research.""" @@ -634,14 +652,7 @@ class ResearchHandler: logger.info(f"Endpoint probe OK: {model}") except Exception as e: logger.error(f"Probe failed for {model}: {e}") - err = str(e) - if "401" in err or "API key" in err or "Unauthorized" in err: - raise RuntimeError( - f"Model '{model}' requires an API key. Check your endpoint configuration." - ) from e - raise RuntimeError( - f"Cannot reach model '{model}' — check that the endpoint is running and accessible." - ) from e + raise RuntimeError(_format_probe_failure(model, e)) from e async def call_research_service( self, diff --git a/tests/test_research_probe_errors.py b/tests/test_research_probe_errors.py new file mode 100644 index 0000000..8418090 --- /dev/null +++ b/tests/test_research_probe_errors.py @@ -0,0 +1,61 @@ +"""Regression tests for Deep Research model probe error messages. + +Deep Research probes the selected model before starting a long run. When the +upstream returned a concrete model/API error, the probe used to collapse it into +"Cannot reach model", hiding the real issue from the UI. +""" +import pytest +from fastapi import HTTPException + +from src.research_handler import ResearchHandler, _format_probe_failure + + +def test_probe_failure_preserves_upstream_model_errors(): + exc = HTTPException( + status_code=400, + detail="OpenAI returned HTTP 400: Unsupported parameter: temperature", + ) + + msg = _format_probe_failure("o3-mini", exc) + + assert msg == ( + "Model 'o3-mini' probe failed: " + "OpenAI returned HTTP 400: Unsupported parameter: temperature" + ) + + +def test_probe_failure_keeps_api_key_guidance(): + exc = HTTPException(status_code=401, detail="OpenAI authentication failed") + + assert _format_probe_failure("gpt-4o", exc) == ( + "Model 'gpt-4o' requires an API key. Check your endpoint configuration." + ) + + +def test_probe_failure_keeps_reachability_guidance_for_plain_errors(): + msg = _format_probe_failure("local-model", RuntimeError("connection refused")) + + assert msg == "Cannot reach model 'local-model' — connection refused" + + +@pytest.mark.asyncio +async def test_probe_endpoint_surfaces_http_exception_detail(monkeypatch): + async def _raise(*args, **kwargs): + raise HTTPException( + status_code=400, + detail="OpenAI returned HTTP 400: max_tokens is not supported", + ) + + monkeypatch.setattr("src.llm_core.llm_call_async", _raise) + + with pytest.raises(RuntimeError) as excinfo: + await ResearchHandler._probe_endpoint( + "https://api.openai.com/v1/chat/completions", + "o3-mini", + {"Authorization": "Bearer test"}, + ) + + msg = str(excinfo.value) + assert "Model 'o3-mini' probe failed" in msg + assert "max_tokens is not supported" in msg + assert "Cannot reach model" not in msg