Coverage for src/local_deep_research/error_handling/openai_compat_errors.py: 96%
65 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +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 urllib.parse import urlparse, urlunparse
20import httpx
21import openai
23from ..security.log_sanitizer import sanitize_error_message
26def _strip_credentials(base_url: str | None) -> str:
27 """Return ``base_url`` with any userinfo (``user:password@``) removed.
29 Users sometimes embed an API key directly in the base URL (e.g.
30 ``https://user:key@host/v1``). We must never echo that back to the UI or
31 logs. Falsy / unparseable inputs are returned as ``"<unknown>"``.
32 """
33 if not base_url:
34 return "<unknown>"
35 try:
36 parsed = urlparse(base_url)
37 except Exception:
38 return "<unknown>"
39 if not parsed.netloc:
40 return base_url
41 host = parsed.hostname or ""
42 # urlparse exposes IPv6 hostnames without their surrounding brackets;
43 # re-add them when reassembling the netloc, or the rebuilt URL is
44 # not parseable by downstream HTTP libraries (e.g. ``http://::1:8080/``
45 # is ambiguous: is the host ``::`` and the port ``1:8080``?). IPv4
46 # never contains ``:`` so this heuristic is safe.
47 if ":" in host:
48 host = f"[{host}]"
49 if parsed.port:
50 host = f"{host}:{parsed.port}"
51 return urlunparse(parsed._replace(netloc=host)) or "<unknown>"
54def _walk_cause(exc: BaseException) -> BaseException:
55 """Walk ``__cause__`` / ``__context__`` to find the deepest non-wrapper
56 exception, with a cycle guard.
58 LangChain often wraps the underlying ``openai.*`` exception in a generic
59 ``Exception`` or ``RuntimeError``; we need the original class to dispatch
60 on. If the walk doesn't find anything more specific, the original is
61 returned.
62 """
63 seen: set[int] = set()
64 cur: BaseException | None = exc
65 deepest: BaseException = exc
66 while cur is not None and id(cur) not in seen:
67 seen.add(id(cur))
68 deepest = cur
69 cur = cur.__cause__ or cur.__context__
70 return deepest
73_DOCKER_HINT = (
74 " (from inside Docker, localhost is the container itself -- use "
75 "host.docker.internal, the host IP, or run with --network=host to share "
76 "the host network namespace)"
77)
80def _dispatch(
81 root: BaseException, provider: str, base_url: str, model: str
82) -> tuple[str, str]:
83 """Map a root exception to ``(error_code_token, friendly_message)``.
85 Returns ``("openai_unknown", <generic message>)`` for any exception we don't
86 recognise; callers should still suffix the original ``exc!s`` so no detail
87 is lost.
88 """
90 def _is(cls_name: str) -> bool:
91 cls = getattr(openai, cls_name, None)
92 return cls is not None and isinstance(root, cls)
94 # Timeout family -- must be checked BEFORE APIConnectionError because
95 # openai.APITimeoutError subclasses APIConnectionError in openai>=1.x.
96 if _is("APITimeoutError") or isinstance(root, httpx.ReadTimeout):
97 return (
98 "openai_timeout",
99 f"{provider} at {base_url} did not respond in time. The server "
100 "may be loading a model or overloaded.",
101 )
103 # Connection-refused / network-unreachable family
104 if _is("APIConnectionError") or isinstance(root, httpx.ConnectError):
105 return (
106 "openai_connection_refused",
107 f"Cannot reach {provider} at {base_url}. Check that the server "
108 f"is running and the URL is correct.{_DOCKER_HINT}",
109 )
111 # Auth
112 if _is("AuthenticationError"):
113 return (
114 "openai_auth",
115 f"{provider} rejected the API key for {base_url}. Local servers "
116 "usually accept any non-empty key; remote providers need a valid "
117 "key.",
118 )
120 # Permission denied
121 if _is("PermissionDeniedError"):
122 return (
123 "openai_permission_denied",
124 f"{provider} denied access at {base_url} for model '{model}'.",
125 )
127 # Model not found (404 from OpenAI-compatible servers)
128 if _is("NotFoundError"):
129 return (
130 "openai_model_not_found",
131 f"{provider} at {base_url} does not have model '{model}'. Pick a "
132 f"model currently loaded in {provider}.",
133 )
135 # Rate limit (429) -- must be checked before the APIError catch-all
136 # because RateLimitError subclasses APIStatusError -> APIError.
137 if _is("RateLimitError"):
138 return (
139 "openai_rate_limit",
140 f"{provider} at {base_url} rate-limited the request for model "
141 f"'{model}'. Wait a moment and retry, or enable LLM rate "
142 "limiting in Settings.",
143 )
145 # Bad request (400)
146 if _is("BadRequestError"):
147 return (
148 "openai_bad_request",
149 f"{provider} rejected the request to {base_url} for model "
150 f"'{model}'.",
151 )
153 # Any other openai SDK error
154 if _is("APIError"): 154 ↛ 155line 154 didn't jump to line 155 because the condition on line 154 was never true
155 return (
156 "openai_unknown",
157 f"{provider} at {base_url} returned an error for model '{model}'.",
158 )
160 # Not an openai/httpx class we recognise -- caller should fall through.
161 return (
162 "openai_unknown",
163 f"{provider} at {base_url} returned an error for model '{model}'.",
164 )
167def is_openai_compat_runtime_error(exc: BaseException) -> bool:
168 """Return True iff ``exc`` (or any exception in its cause chain) is an
169 ``openai.*`` / ``httpx.*`` runtime error we can rewrite.
171 Used at Site B in research_service.py to decide whether to call
172 :func:`friendly_openai_compatible_error` instead of the existing
173 string-keyword branches.
174 """
175 root = _walk_cause(exc)
176 if isinstance(root, openai.APIError):
177 return True
178 if isinstance(root, (httpx.ConnectError, httpx.ReadTimeout)):
179 return True
180 return False
183def friendly_openai_compatible_error(
184 exc: BaseException,
185 *,
186 provider: str,
187 base_url: str | None,
188 model: str | None,
189) -> str:
190 """Build a user-facing error message for an OpenAI-compatible failure.
192 Returns a string of the form::
194 <friendly message> (Error type: <code>) | Details: <original exc>
196 where ``<code>`` is one of the ``openai_*`` tokens that Site C and
197 :class:`~local_deep_research.error_handling.error_reporter.ErrorReporter`
198 recognise. The original exception text is always preserved in the
199 ``Details:`` suffix so the user (and our logs) never lose information.
200 """
201 redacted = _strip_credentials(base_url)
202 model_repr = model or "<unspecified>"
203 provider_repr = provider or "<unknown provider>"
204 root = _walk_cause(exc)
205 code, friendly = _dispatch(root, provider_repr, redacted, model_repr)
206 return f"{friendly} (Error type: {code}) | Details: {sanitize_error_message(str(exc))}"