Coverage for src/local_deep_research/error_handling/openai_compat_errors.py: 95%
60 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""Friendly runtime-error rewriter for OpenAI-compatible LLM endpoints.
3When LM Studio, vLLM, llama.cpp server, OpenRouter, or any other OpenAI-compatible
4provider fails at request time, the underlying `openai.*` / `httpx.*` exception
5typically does not name the provider, configured base URL, or model in its
6message. This helper walks the cause chain to find the root SDK exception and
7produces a message that includes that context, while preserving the existing
8``Error type: <code>`` token convention used downstream in research_service.py
9and ErrorReporter.
11The helper deliberately does NOT introduce a new exception class -- the rest of
12the pipeline is string-based today and tokens are how Sites B and C
13communicate.
14"""
16from __future__ import annotations
18from types import ModuleType
20import httpx
21import openai
23from ..security.log_sanitizer import sanitize_error_message
24from ..security.url_redaction import redact_url_credentials
26# openai 3.x makes its requests through httpx2, so its transport errors are
27# httpx2 classes and are unrelated to the httpx ones imported above. openai 2.x
28# does not pull httpx2 in, so the import has to stay optional.
29_TRANSPORT_MODULES: tuple[ModuleType, ...] = (httpx,)
30try:
31 import httpx2
32except ImportError:
33 pass
34else:
35 _TRANSPORT_MODULES += (httpx2,)
36_CONNECT_ERRORS = tuple(m.ConnectError for m in _TRANSPORT_MODULES)
37_READ_TIMEOUTS = tuple(m.ReadTimeout for m in _TRANSPORT_MODULES)
40# Base-URL credential redaction lives in ``security.url_redaction`` so the
41# provider and router modules that log a base URL can share it without an
42# import cycle; the private alias keeps this module's historical name.
43_strip_credentials = redact_url_credentials
46def _is_dispatchable(exc: BaseException) -> bool:
47 """Return True when :func:`_dispatch` has a branch for ``exc``."""
48 return isinstance(exc, (openai.APIError, *_CONNECT_ERRORS, *_READ_TIMEOUTS))
51def _walk_cause(exc: BaseException) -> BaseException:
52 """Walk ``__cause__`` / ``__context__`` for the outermost exception
53 :func:`_dispatch` recognises, with a cycle guard.
55 LangChain often wraps the underlying ``openai.*`` exception in a generic
56 ``Exception`` or ``RuntimeError``; we need the original class to dispatch
57 on. The walk stops there rather than running to the end of the chain,
58 because the SDK re-raises from its transport library and the transport
59 re-raises from ``httpcore`` or a bare ``OSError``: a refused connection
60 ends in ``ConnectionRefusedError``, which names no provider and no failure
61 kind. If nothing in the chain is recognised the deepest exception is
62 returned, as before.
63 """
64 seen: set[int] = set()
65 cur: BaseException | None = exc
66 deepest: BaseException = exc
67 while cur is not None and id(cur) not in seen:
68 seen.add(id(cur))
69 if _is_dispatchable(cur):
70 return cur
71 deepest = cur
72 cur = cur.__cause__ or cur.__context__
73 return deepest
76# The setting name is the label the UI actually renders (see
77# ``defaults/default_settings.json``), not a Settings -> ... path, so a user
78# can search for it. The connect bound is stated separately because
79# ``_helpers.build_timeout`` caps connect at ``CONNECT_TIMEOUT_SECONDS``
80# (``min(5, seconds)``), so raising this setting above 5 cannot move it:
81# the openai SDK wraps a connect timeout in the same ``APITimeoutError``
82# as a read timeout, so without this sentence the hint would tell a user
83# with an unreachable endpoint to raise a knob that cannot help them.
84# "at most" rather than "not affected by" because below 5 seconds the
85# setting *is* the connect bound. The literal is pinned against the
86# constant by ``tests/error_handling/test_openai_compat_timeout_hint.py``.
87_TIMEOUT_HINT = (
88 ' If the server needs longer, raise the "LLM Request Inactivity Timeout '
89 '(seconds)" setting (`llm.request_timeout`, no upper limit), or set '
90 "`LDR_LLM_REQUEST_TIMEOUT`. It limits individual network operations, "
91 "not total response duration. A response that keeps sending data, SDK "
92 "retries, and retry delays can make the complete request last longer. "
93 "Connection attempts are bounded at most at 5 seconds, so raising it "
94 "above that will not help if the endpoint cannot be reached at all."
95)
98_DOCKER_HINT = (
99 " (from inside Docker, localhost is the container itself -- use "
100 "host.docker.internal, the host IP, or run with --network=host to share "
101 "the host network namespace)"
102)
105def _dispatch(
106 root: BaseException, provider: str, base_url: str, model: str
107) -> tuple[str, str]:
108 """Map a root exception to ``(error_code_token, friendly_message)``.
110 Returns ``("openai_unknown", <generic message>)`` for any exception we don't
111 recognise; callers should still suffix the original ``exc!s`` so no detail
112 is lost.
113 """
115 def _is(cls_name: str) -> bool:
116 cls = getattr(openai, cls_name, None)
117 return cls is not None and isinstance(root, cls)
119 # Timeout family -- must be checked BEFORE APIConnectionError because
120 # openai.APITimeoutError subclasses APIConnectionError in openai>=1.x.
121 if _is("APITimeoutError") or isinstance(root, _READ_TIMEOUTS):
122 return (
123 "openai_timeout",
124 f"{provider} at {base_url} did not respond in time. The server "
125 f"may be loading a model or overloaded.{_TIMEOUT_HINT}",
126 )
128 # Connection-refused / network-unreachable family
129 if _is("APIConnectionError") or isinstance(root, _CONNECT_ERRORS):
130 return (
131 "openai_connection_refused",
132 f"Cannot reach {provider} at {base_url}. Check that the server "
133 f"is running and the URL is correct.{_DOCKER_HINT}",
134 )
136 # Auth
137 if _is("AuthenticationError"):
138 return (
139 "openai_auth",
140 f"{provider} rejected the API key for {base_url}. Local servers "
141 "usually accept any non-empty key; remote providers need a valid "
142 "key.",
143 )
145 # Permission denied
146 if _is("PermissionDeniedError"):
147 return (
148 "openai_permission_denied",
149 f"{provider} denied access at {base_url} for model '{model}'.",
150 )
152 # Model not found (404 from OpenAI-compatible servers)
153 if _is("NotFoundError"):
154 return (
155 "openai_model_not_found",
156 f"{provider} at {base_url} does not have model '{model}'. Pick a "
157 f"model currently loaded in {provider}.",
158 )
160 # Rate limit (429) -- must be checked before the APIError catch-all
161 # because RateLimitError subclasses APIStatusError -> APIError.
162 if _is("RateLimitError"):
163 return (
164 "openai_rate_limit",
165 f"{provider} at {base_url} rate-limited the request for model "
166 f"'{model}'. Wait a moment and retry, or enable LLM rate "
167 "limiting in Settings.",
168 )
170 # Bad request (400)
171 if _is("BadRequestError"):
172 return (
173 "openai_bad_request",
174 f"{provider} rejected the request to {base_url} for model "
175 f"'{model}'.",
176 )
178 # Any other openai SDK error
179 if _is("APIError"): 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true
180 return (
181 "openai_unknown",
182 f"{provider} at {base_url} returned an error for model '{model}'.",
183 )
185 # Not an openai/httpx class we recognise -- caller should fall through.
186 return (
187 "openai_unknown",
188 f"{provider} at {base_url} returned an error for model '{model}'.",
189 )
192def is_openai_compat_runtime_error(exc: BaseException) -> bool:
193 """Return True iff ``exc`` (or any exception in its cause chain) is an
194 ``openai.*`` or transport runtime error we can rewrite.
196 Used at Site B in research_service.py to decide whether to call
197 :func:`friendly_openai_compatible_error` instead of the existing
198 string-keyword branches.
199 """
200 return _is_dispatchable(_walk_cause(exc))
203def friendly_openai_compatible_error(
204 exc: BaseException,
205 *,
206 provider: str,
207 base_url: str | None,
208 model: str | None,
209) -> str:
210 """Build a user-facing error message for an OpenAI-compatible failure.
212 Returns a string of the form::
214 <friendly message> (Error type: <code>) | Details: <original exc>
216 where ``<code>`` is one of the ``openai_*`` tokens that Site C and
217 :class:`~local_deep_research.error_handling.error_reporter.ErrorReporter`
218 recognise. The original exception text is always preserved in the
219 ``Details:`` suffix so the user (and our logs) never lose information.
220 """
221 redacted = _strip_credentials(base_url)
222 model_repr = model or "<unspecified>"
223 provider_repr = provider or "<unknown provider>"
224 root = _walk_cause(exc)
225 code, friendly = _dispatch(root, provider_repr, redacted, model_repr)
226 return f"{friendly} (Error type: {code}) | Details: {sanitize_error_message(str(exc))}"