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

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 urllib.parse import urlparse, urlunparse 

19 

20import httpx 

21import openai 

22 

23from ..security.log_sanitizer import sanitize_error_message 

24 

25 

26def _strip_credentials(base_url: str | None) -> str: 

27 """Return ``base_url`` with any userinfo (``user:password@``) removed. 

28 

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

52 

53 

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

55 """Walk ``__cause__`` / ``__context__`` to find the deepest non-wrapper 

56 exception, with a cycle guard. 

57 

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 

71 

72 

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) 

78 

79 

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

84 

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

89 

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

91 cls = getattr(openai, cls_name, None) 

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

93 

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 ) 

102 

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 ) 

110 

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 ) 

119 

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 ) 

126 

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 ) 

134 

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 ) 

144 

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 ) 

152 

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 ) 

159 

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 ) 

165 

166 

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. 

170 

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 

181 

182 

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. 

191 

192 Returns a string of the form:: 

193 

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

195 

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