7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
482 lines
17 KiB
Python
482 lines
17 KiB
Python
"""Tests for openai_compat_errors helper and its integration with
|
|
``ErrorReporter`` (#3878).
|
|
|
|
These tests construct ``openai`` / ``httpx`` exceptions directly (no network
|
|
calls) and check that the rewritten messages and ``Error type: <code>`` tokens
|
|
match the spec in the issue.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
import pytest
|
|
from openai import (
|
|
APIConnectionError,
|
|
APITimeoutError,
|
|
AuthenticationError,
|
|
BadRequestError,
|
|
NotFoundError,
|
|
PermissionDeniedError,
|
|
RateLimitError,
|
|
)
|
|
|
|
from local_deep_research.error_handling.error_reporter import (
|
|
ErrorCategory,
|
|
ErrorReporter,
|
|
)
|
|
from local_deep_research.error_handling.openai_compat_errors import (
|
|
_strip_credentials,
|
|
_walk_cause,
|
|
friendly_openai_compatible_error,
|
|
is_openai_compat_runtime_error,
|
|
)
|
|
|
|
|
|
def _req(
|
|
url: str = "http://localhost:9999/v1/chat/completions",
|
|
) -> httpx.Request:
|
|
return httpx.Request("POST", url)
|
|
|
|
|
|
def _resp(status: int, body: dict | None = None) -> httpx.Response:
|
|
return httpx.Response(status, request=_req(), json=body or {})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Acceptance criterion 4: redaction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestStripCredentials:
|
|
def test_strips_userinfo(self):
|
|
assert (
|
|
_strip_credentials("https://user:secret@example.com/v1")
|
|
== "https://example.com/v1"
|
|
)
|
|
|
|
def test_strips_userinfo_keeps_port(self):
|
|
assert (
|
|
_strip_credentials("https://u:p@example.com:8443/v1")
|
|
== "https://example.com:8443/v1"
|
|
)
|
|
|
|
def test_no_userinfo_passes_through(self):
|
|
assert (
|
|
_strip_credentials("http://localhost:1234/v1")
|
|
== "http://localhost:1234/v1"
|
|
)
|
|
|
|
def test_empty_returns_unknown_marker(self):
|
|
assert _strip_credentials("") == "<unknown>"
|
|
assert _strip_credentials(None) == "<unknown>"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cause-chain walker
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWalkCause:
|
|
def test_returns_input_when_no_cause(self):
|
|
exc = RuntimeError("flat")
|
|
assert _walk_cause(exc) is exc
|
|
|
|
def test_walks_to_deepest(self):
|
|
root = APIConnectionError(message="conn", request=_req())
|
|
try:
|
|
try:
|
|
raise root
|
|
except Exception as e:
|
|
raise RuntimeError("middle") from e
|
|
except RuntimeError as e:
|
|
try:
|
|
raise ValueError("outer") from e
|
|
except ValueError as outer:
|
|
assert _walk_cause(outer) is root
|
|
|
|
def test_cycle_safe(self):
|
|
a = RuntimeError("a")
|
|
b = RuntimeError("b")
|
|
a.__cause__ = b
|
|
b.__cause__ = a
|
|
# Must terminate; the deepest reached before cycle is detected wins.
|
|
assert _walk_cause(a) in (a, b)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Acceptance criterion 1: connection-refused naming
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConnectionRefused:
|
|
def test_openai_api_connection_error(self):
|
|
exc = APIConnectionError(message="conn", request=_req())
|
|
msg = friendly_openai_compatible_error(
|
|
exc,
|
|
provider="lmstudio",
|
|
base_url="http://localhost:9999/v1",
|
|
model="qwen2.5-7b",
|
|
)
|
|
assert "Cannot reach lmstudio at http://localhost:9999/v1" in msg
|
|
assert "Error type: openai_connection_refused" in msg
|
|
assert "Details:" in msg
|
|
|
|
def test_httpx_connect_error_through_langchain_wrapper(self):
|
|
root = httpx.ConnectError("All connection attempts failed")
|
|
try:
|
|
try:
|
|
raise root
|
|
except Exception as e:
|
|
raise RuntimeError("LangChain wrapped") from e
|
|
except RuntimeError as wrapped:
|
|
msg = friendly_openai_compatible_error(
|
|
wrapped,
|
|
provider="openai_endpoint",
|
|
base_url="http://localhost:1234/v1",
|
|
model="any-model",
|
|
)
|
|
assert "Cannot reach openai_endpoint at http://localhost:1234/v1" in msg
|
|
assert "Error type: openai_connection_refused" in msg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Acceptance criterion 2: model-not-found naming
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestModelNotFound:
|
|
def test_notfound_names_provider_url_model(self):
|
|
exc = NotFoundError(
|
|
message="model 'typo-model' does not exist",
|
|
response=_resp(404),
|
|
body=None,
|
|
)
|
|
msg = friendly_openai_compatible_error(
|
|
exc,
|
|
provider="lmstudio",
|
|
base_url="http://localhost:1234/v1",
|
|
model="typo-model",
|
|
)
|
|
assert "lmstudio at http://localhost:1234/v1" in msg
|
|
assert "'typo-model'" in msg
|
|
assert "Error type: openai_model_not_found" in msg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Acceptance criterion 3: auth naming
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAuth:
|
|
def test_auth_names_provider_and_url(self):
|
|
exc = AuthenticationError(
|
|
message="invalid api key",
|
|
response=_resp(401),
|
|
body=None,
|
|
)
|
|
msg = friendly_openai_compatible_error(
|
|
exc,
|
|
provider="openai_endpoint",
|
|
base_url="https://api.openai.com/v1",
|
|
model="gpt-4o-mini",
|
|
)
|
|
assert "openai_endpoint rejected the API key" in msg
|
|
assert "https://api.openai.com/v1" in msg
|
|
assert "Error type: openai_auth" in msg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Acceptance criterion 4: userinfo never leaks into the surfaced message
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNoCredentialLeak:
|
|
def test_userinfo_stripped_from_friendly_text(self):
|
|
exc = APIConnectionError(message="conn", request=_req())
|
|
leaked_key = "supersecretkey1234567890"
|
|
msg = friendly_openai_compatible_error(
|
|
exc,
|
|
provider="openai_endpoint",
|
|
base_url=f"https://u:{leaked_key}@hosted.example.com/v1",
|
|
model="m",
|
|
)
|
|
# The userinfo segment must NOT survive into the friendly portion of
|
|
# the message. We split on the Details: suffix because the original
|
|
# exception text is preserved there verbatim (and in practice does not
|
|
# carry the URL, but if a future exception did, we'd still want this
|
|
# test to guard the rewritten half).
|
|
friendly_half = msg.split("| Details:")[0]
|
|
assert leaked_key not in friendly_half
|
|
assert "u:" not in friendly_half
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Acceptance criterion 5: ErrorReporter category mapping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestErrorReporterCategorisation:
|
|
@pytest.fixture
|
|
def reporter(self) -> ErrorReporter:
|
|
return ErrorReporter()
|
|
|
|
@pytest.mark.parametrize(
|
|
("token", "expected"),
|
|
[
|
|
("openai_connection_refused", ErrorCategory.CONNECTION_ERROR),
|
|
("openai_timeout", ErrorCategory.CONNECTION_ERROR),
|
|
("openai_auth", ErrorCategory.MODEL_ERROR),
|
|
("openai_permission_denied", ErrorCategory.MODEL_ERROR),
|
|
("openai_model_not_found", ErrorCategory.MODEL_ERROR),
|
|
("openai_bad_request", ErrorCategory.MODEL_ERROR),
|
|
("openai_unknown", ErrorCategory.MODEL_ERROR),
|
|
("openai_rate_limit", ErrorCategory.RATE_LIMIT_ERROR),
|
|
],
|
|
)
|
|
def test_token_to_category(
|
|
self, reporter: ErrorReporter, token: str, expected: ErrorCategory
|
|
):
|
|
message = f"Some friendly text. (Error type: {token}) | Details: boom"
|
|
assert reporter.categorize_error(message) == expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper-detector
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsOpenAICompatRuntimeError:
|
|
def test_yes_for_openai_class(self):
|
|
exc = APITimeoutError(request=_req())
|
|
assert is_openai_compat_runtime_error(exc) is True
|
|
|
|
def test_yes_for_wrapped_openai_class(self):
|
|
root = NotFoundError(message="missing", response=_resp(404), body=None)
|
|
try:
|
|
try:
|
|
raise root
|
|
except Exception as e:
|
|
raise RuntimeError("wrap") from e
|
|
except RuntimeError as wrapped:
|
|
assert is_openai_compat_runtime_error(wrapped) is True
|
|
|
|
def test_yes_for_httpx_connect_error(self):
|
|
assert (
|
|
is_openai_compat_runtime_error(httpx.ConnectError("nope")) is True
|
|
)
|
|
|
|
def test_no_for_unrelated_exception(self):
|
|
assert is_openai_compat_runtime_error(ValueError("unrelated")) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Additional class coverage (the four non-AC tokens still need to round-trip)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAdditionalDispatch:
|
|
def test_timeout(self):
|
|
exc = APITimeoutError(request=_req())
|
|
msg = friendly_openai_compatible_error(
|
|
exc,
|
|
provider="vllm",
|
|
base_url="http://localhost:8000/v1",
|
|
model="llama-3-8b",
|
|
)
|
|
assert "Error type: openai_timeout" in msg
|
|
assert "did not respond in time" in msg
|
|
|
|
def test_permission_denied(self):
|
|
exc = PermissionDeniedError(
|
|
message="forbidden", response=_resp(403), body=None
|
|
)
|
|
msg = friendly_openai_compatible_error(
|
|
exc,
|
|
provider="openai_endpoint",
|
|
base_url="https://api.example.com/v1",
|
|
model="gpt-4o",
|
|
)
|
|
assert "Error type: openai_permission_denied" in msg
|
|
assert "'gpt-4o'" in msg
|
|
|
|
def test_bad_request(self):
|
|
exc = BadRequestError(message="bad", response=_resp(400), body=None)
|
|
msg = friendly_openai_compatible_error(
|
|
exc,
|
|
provider="lmstudio",
|
|
base_url="http://localhost:1234/v1",
|
|
model="m",
|
|
)
|
|
assert "Error type: openai_bad_request" in msg
|
|
|
|
def test_falls_back_to_unknown_for_unrelated(self):
|
|
msg = friendly_openai_compatible_error(
|
|
ValueError("just a value error"),
|
|
provider="lmstudio",
|
|
base_url="http://localhost:1234/v1",
|
|
model="m",
|
|
)
|
|
assert "Error type: openai_unknown" in msg
|
|
assert "Details: just a value error" in msg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Edge cases pinned by this PR
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDispatchOrderingTimeoutBeforeConnection:
|
|
"""``openai.APITimeoutError`` subclasses ``APIConnectionError`` in
|
|
openai>=1.x, so the dispatch table at openai_compat_errors.py:87 must
|
|
check the timeout branch BEFORE the connection branch — otherwise
|
|
every timeout would be mislabelled as ``openai_connection_refused``.
|
|
|
|
The comment at lines 85-86 documents this constraint; this test pins it.
|
|
|
|
Mutation: swap the two ``if`` blocks (lines 87-92 and 95-100) so the
|
|
connection branch runs first, and ``APITimeoutError`` instances get the
|
|
wrong token.
|
|
"""
|
|
|
|
def test_timeout_subclass_dispatches_to_timeout_branch_not_connection(
|
|
self,
|
|
):
|
|
# Sanity: confirm the subclassing assumption that motivates the
|
|
# ordering. If openai breaks this hierarchy in a future major
|
|
# release, this assertion fails first and the test author can
|
|
# decide whether the ordering rule still applies.
|
|
assert issubclass(APITimeoutError, APIConnectionError)
|
|
|
|
exc = APITimeoutError(request=_req())
|
|
msg = friendly_openai_compatible_error(
|
|
exc,
|
|
provider="lmstudio",
|
|
base_url="http://localhost:1234/v1",
|
|
model="m",
|
|
)
|
|
assert "Error type: openai_timeout" in msg
|
|
assert "Error type: openai_connection_refused" not in msg
|
|
|
|
|
|
class TestWalkCauseChainPreference:
|
|
"""``_walk_cause`` traverses ``cur.__cause__ or cur.__context__`` —
|
|
i.e. explicit ``raise X from Y`` chains win over implicit
|
|
``__context__`` chains. Pins the preference.
|
|
|
|
Mutation: swap to ``cur.__context__ or cur.__cause__`` at
|
|
openai_compat_errors.py:60 — the test would point at the wrong root.
|
|
"""
|
|
|
|
def test_cause_preferred_over_context_when_both_set(self):
|
|
explicit_root = APIConnectionError(message="conn", request=_req())
|
|
implicit_root = RuntimeError("implicit context")
|
|
|
|
wrapper = RuntimeError("wrapper")
|
|
wrapper.__cause__ = explicit_root
|
|
wrapper.__context__ = implicit_root
|
|
|
|
assert _walk_cause(wrapper) is explicit_root
|
|
|
|
|
|
class TestStripCredentialsEdgeCases:
|
|
def test_ipv6_host_brackets_preserved(self):
|
|
# ``urlparse`` returns ``hostname`` without brackets; the
|
|
# implementation reassembles ``netloc`` and must re-add the
|
|
# brackets, or the resulting URL is unparseable downstream —
|
|
# ``http://::1:8080/`` is ambiguous about where the host ends
|
|
# and the port begins.
|
|
result = _strip_credentials("http://[::1]:8080/v1")
|
|
assert result == "http://[::1]:8080/v1"
|
|
|
|
def test_userinfo_stripped_with_ipv6_host(self):
|
|
# Combine userinfo and IPv6 host. The key must be removed AND
|
|
# the brackets must survive.
|
|
result = _strip_credentials(
|
|
"http://user:secret-key-12345@[::1]:8080/v1"
|
|
)
|
|
assert "secret-key-12345" not in result
|
|
assert "user:" not in result
|
|
assert result == "http://[::1]:8080/v1"
|
|
|
|
def test_url_with_no_netloc_passed_through(self):
|
|
# A bare path (no scheme/netloc) is returned as-is. The function
|
|
# short-circuits at ``if not parsed.netloc:``. Worth pinning so
|
|
# someone "fixing" the path-only case doesn't break it.
|
|
assert _strip_credentials("/v1/chat/completions") == (
|
|
"/v1/chat/completions"
|
|
)
|
|
|
|
|
|
class TestFriendlyErrorNoneArgs:
|
|
"""``friendly_openai_compatible_error`` uses ``provider or
|
|
"<unknown provider>"`` and ``model or "<unspecified>"`` to surface a
|
|
legible message even when the caller doesn't know the values. Pin
|
|
both placeholders so future refactors don't drop them.
|
|
"""
|
|
|
|
def test_none_provider_replaced_with_placeholder(self):
|
|
msg = friendly_openai_compatible_error(
|
|
APIConnectionError(message="conn", request=_req()),
|
|
provider=None,
|
|
base_url="http://localhost:9999/v1",
|
|
model="m",
|
|
)
|
|
assert "<unknown provider>" in msg
|
|
|
|
def test_none_model_replaced_with_placeholder(self):
|
|
msg = friendly_openai_compatible_error(
|
|
NotFoundError(
|
|
message="missing",
|
|
response=_resp(404),
|
|
body=None,
|
|
),
|
|
provider="lmstudio",
|
|
base_url="http://localhost:1234/v1",
|
|
model=None,
|
|
)
|
|
assert "<unspecified>" in msg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RateLimitError dispatch (follow-up to #3878)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRateLimitErrorDispatch:
|
|
"""``openai.RateLimitError`` subclasses ``APIError`` via
|
|
``APIStatusError``. It must dispatch to ``openai_rate_limit`` (mapped to
|
|
RATE_LIMIT_ERROR) instead of falling through to the ``openai_unknown``
|
|
catch-all (mapped to MODEL_ERROR).
|
|
|
|
Without the explicit branch, a 429 is mis-categorised as MODEL_ERROR,
|
|
producing the wrong suggestions and skipping the API_QUOTA_WARNING
|
|
notification.
|
|
"""
|
|
|
|
def test_rate_limit_dispatches_to_rate_limit_token(self):
|
|
exc = RateLimitError(
|
|
message="Rate limit exceeded",
|
|
response=_resp(429),
|
|
body=None,
|
|
)
|
|
msg = friendly_openai_compatible_error(
|
|
exc,
|
|
provider="openrouter",
|
|
base_url="https://openrouter.ai/api/v1",
|
|
model="gpt-4o",
|
|
)
|
|
assert "Error type: openai_rate_limit" in msg
|
|
assert "Error type: openai_unknown" not in msg
|
|
assert "rate-limited" in msg
|
|
|
|
def test_rate_limit_categorised_as_rate_limit_error(self):
|
|
reporter = ErrorReporter()
|
|
message = (
|
|
"openrouter at https://openrouter.ai/api/v1 rate-limited the "
|
|
"request for model 'gpt-4o'. (Error type: openai_rate_limit) "
|
|
"| Details: Error code: 429"
|
|
)
|
|
assert (
|
|
reporter.categorize_error(message) == ErrorCategory.RATE_LIMIT_ERROR
|
|
)
|