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

1"""Friendly runtime-error rewriter for OpenAI-compatible LLM endpoints. 

2 

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. 

10 

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""" 

15 

16from __future__ import annotations 

17 

18from types import ModuleType 

19 

20import httpx 

21import openai 

22 

23from ..security.log_sanitizer import sanitize_error_message 

24from ..security.url_redaction import redact_url_credentials 

25 

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) 

38 

39 

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 

44 

45 

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)) 

49 

50 

51def _walk_cause(exc: BaseException) -> BaseException: 

52 """Walk ``__cause__`` / ``__context__`` for the outermost exception 

53 :func:`_dispatch` recognises, with a cycle guard. 

54 

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 

74 

75 

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) 

96 

97 

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) 

103 

104 

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)``. 

109 

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 """ 

114 

115 def _is(cls_name: str) -> bool: 

116 cls = getattr(openai, cls_name, None) 

117 return cls is not None and isinstance(root, cls) 

118 

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 ) 

127 

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 ) 

135 

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 ) 

144 

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 ) 

151 

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 ) 

159 

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 ) 

169 

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 ) 

177 

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 ) 

184 

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 ) 

190 

191 

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. 

195 

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)) 

201 

202 

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. 

211 

212 Returns a string of the form:: 

213 

214 <friendly message> (Error type: <code>) | Details: <original exc> 

215 

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))}"