chore: import upstream snapshot with attribution
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
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
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
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,481 @@
|
||||
"""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
|
||||
)
|
||||
Reference in New Issue
Block a user