Coverage for src/local_deep_research/llm/providers/_helpers.py: 100%

73 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""Shared utilities for LLM provider construction. 

2 

3These helpers were previously inlined in ``config/llm_config.get_llm``'s 

4procedural ``if/elif`` chain (which is now removed). They live here so 

5provider-class ``create_llm`` methods are the single source of truth for 

6LLM construction logic — no per-provider drift. 

7""" 

8 

9import math 

10from typing import Any, Final 

11 

12from ...settings.exceptions import SettingsValueError 

13 

14 

15DEFAULT_LOCAL_CONTEXT_WINDOW_SIZE = 8192 

16DEFAULT_CLOUD_CONTEXT_WINDOW_SIZE = 128000 

17LOCAL_PROVIDERS = ("ollama", "llamacpp", "lmstudio") 

18# compute_max_tokens caps llm.max_tokens at this share of a known context 

19# window, which leaves the rest of the window for the prompt. 

20MAX_TOKENS_CONTEXT_SHARE = 0.8 

21 

22# ``llm.request_timeout`` is a *per-network-operation* bound, not a cap on 

23# the total wall-clock duration of a response. httpx (which every provider 

24# SDK here sits on) has no total-deadline knob: a scalar expands to 

25# ``Timeout(connect=X, read=X, write=X, pool=X)`` where ``read`` is the 

26# time allowed *between* received bytes. A provider that keeps dripping 

27# data therefore keeps the request alive past X. See 

28# ``docs/CONFIGURATION.md`` — the setting is documented as an inactivity 

29# bound for exactly this reason. 

30DEFAULT_REQUEST_TIMEOUT: Final = 1800 

31MIN_REQUEST_TIMEOUT: Final = 1 

32# Deliberately no ceiling: a long-running local model on modest hardware 

33# can legitimately think for hours, and a cap that is generous enough for 

34# those is not a meaningful guard against a typo anyway. Non-finite values 

35# are still rejected below, which is what actually prevents an unbounded 

36# wait; MAX_MAX_RETRIES stays, because retries MULTIPLY this bound. 

37 

38# Connecting is not inference: a blackholed IP should fail fast instead of 

39# holding a worker for the full inference budget. Matches the OpenAI and 

40# Anthropic SDK defaults (``Timeout(connect=5.0, ...)``), which a scalar 

41# timeout would otherwise overwrite. 

42CONNECT_TIMEOUT_SECONDS: Final = 5.0 

43 

44# Limit the number of SDK retries. Each attempt has its own per-operation 

45# timeouts, with SDK backoff or server-directed delays between attempts. 

46DEFAULT_MAX_RETRIES: Final = 2 

47MIN_MAX_RETRIES: Final = 0 

48MAX_MAX_RETRIES: Final = 5 

49 

50MODEL_DISCOVERY_TIMEOUT_SECONDS: Final = 30 

51 

52 

53def get_local_context_window(settings_snapshot=None): 

54 """Resolve ``llm.local_context_window_size``, the local providers' window.""" 

55 from ...config.thread_settings import get_setting_from_snapshot 

56 

57 window_size = get_setting_from_snapshot( 

58 "llm.local_context_window_size", 

59 DEFAULT_LOCAL_CONTEXT_WINDOW_SIZE, 

60 settings_snapshot=settings_snapshot, 

61 ) 

62 return ( 

63 int(window_size) 

64 if window_size is not None 

65 else DEFAULT_LOCAL_CONTEXT_WINDOW_SIZE 

66 ) 

67 

68 

69def get_context_window_for_provider(provider_type, settings_snapshot=None): 

70 """Resolve effective context window size for a provider. 

71 

72 Local providers (ollama, llamacpp, lmstudio) use a smaller default to 

73 prevent memory issues. Cloud providers respect 

74 ``llm.context_window_unrestricted`` and return ``None`` when 

75 unrestricted (the provider auto-handles its own context window). 

76 """ 

77 from ...config.thread_settings import get_setting_from_snapshot 

78 

79 if provider_type in LOCAL_PROVIDERS: 

80 return get_local_context_window(settings_snapshot) 

81 use_unrestricted = get_setting_from_snapshot( 

82 "llm.context_window_unrestricted", 

83 True, 

84 settings_snapshot=settings_snapshot, 

85 ) 

86 if use_unrestricted: 

87 return None 

88 window_size = get_setting_from_snapshot( 

89 "llm.context_window_size", 

90 DEFAULT_CLOUD_CONTEXT_WINDOW_SIZE, 

91 settings_snapshot=settings_snapshot, 

92 ) 

93 return ( 

94 int(window_size) 

95 if window_size is not None 

96 else DEFAULT_CLOUD_CONTEXT_WINDOW_SIZE 

97 ) 

98 

99 

100def compute_max_tokens( 

101 settings_snapshot=None, context_window_size=None 

102) -> int | None: 

103 """Resolve effective max_tokens for ``ChatXxx(max_tokens=...)``. 

104 

105 Caps at 80% of ``context_window_size`` (when provided) to leave room 

106 for the prompt. Returns ``None`` (caller should omit the kwarg) when: 

107 

108 - ``llm.supports_max_tokens`` is False, OR 

109 - ``llm.max_tokens`` is unset / explicitly None in the snapshot. 

110 

111 Omitting the kwarg when the setting is absent matches the pre-refactor 

112 live-class behavior (the provider SDK's own default applies); a 

113 hardcoded fallback like the dead chain's 100000 exceeds the output 

114 limit of most cloud models. Production users have ``llm.max_tokens`` 

115 populated from ``default_settings.json`` (currently 30000), so the 

116 unset branch only fires for partial-snapshot programmatic callers. 

117 

118 ``llm.max_tokens`` is read with an explicit ``None`` default (#5984), 

119 so an absent key resolves to ``None`` (kwarg omitted) instead of 

120 raising ``NoSettingsContextError``. Provider-level ``except 

121 NoSettingsContextError`` wrappers around this helper are defensive 

122 only. 

123 """ 

124 from ...config.thread_settings import get_setting_from_snapshot 

125 

126 if not get_setting_from_snapshot( 

127 "llm.supports_max_tokens", 

128 True, 

129 settings_snapshot=settings_snapshot, 

130 ): 

131 return None 

132 raw = get_setting_from_snapshot( 

133 "llm.max_tokens", 

134 default=None, 

135 settings_snapshot=settings_snapshot, 

136 ) 

137 if raw is None: 

138 return None 

139 max_tokens = int(raw) 

140 if context_window_size is not None: 

141 max_tokens = min( 

142 max_tokens, int(context_window_size * MAX_TOKENS_CONTEXT_SHARE) 

143 ) 

144 return max_tokens 

145 

146 

147def _get_setting_value(settings_snapshot, key: str, default: Any) -> Any: 

148 """Read an optional setting; invalid configured values must propagate.""" 

149 from ...config.thread_settings import ( 

150 get_setting_from_snapshot, 

151 NoSettingsContextError, 

152 ) 

153 

154 try: 

155 return get_setting_from_snapshot( 

156 key, default, settings_snapshot=settings_snapshot 

157 ) 

158 except NoSettingsContextError: 

159 return default 

160 

161 

162def _validate_number( 

163 key: str, 

164 value: Any, 

165 *, 

166 default: float, 

167 minimum: float, 

168 maximum: float | None, 

169) -> float: 

170 """Validate ``value`` as a finite float inside ``[minimum, maximum]``. 

171 

172 ``maximum=None`` means no upper bound; the value is still required to be 

173 finite, so ``inf`` cannot pass as 'no timeout'. 

174 

175 Only an unset value uses ``default``. Invalid configured values raise 

176 without including the value or a parser exception in the error. 

177 """ 

178 if value is None: 

179 return float(default) 

180 if isinstance(value, bool): 

181 raise SettingsValueError( 

182 key, source="snapshot", reason="expected a finite number" 

183 ) 

184 number = None 

185 try: 

186 number = float(value) 

187 except (TypeError, ValueError, OverflowError): 

188 pass 

189 # Raise outside the parser handler so its exception cannot expose a 

190 # configured value through __context__. 

191 if number is None or not math.isfinite(number): 

192 raise SettingsValueError( 

193 key, source="snapshot", reason="expected a finite number" 

194 ) 

195 if number < minimum: 

196 raise SettingsValueError( 

197 key, source="snapshot", reason=f"must be at least {minimum}" 

198 ) 

199 if maximum is not None and number > maximum: 

200 raise SettingsValueError( 

201 key, source="snapshot", reason=f"must be at most {maximum}" 

202 ) 

203 return number 

204 

205 

206def _resolve_bounded_number( 

207 settings_snapshot, 

208 key: str, 

209 *, 

210 default: float, 

211 minimum: float, 

212 maximum: float | None, 

213) -> float: 

214 """Read a numeric setting and validate its supported bounds. 

215 

216 ``maximum=None`` means no upper bound; the value is still required to 

217 be a finite number, so ``inf`` cannot slip through as 'no timeout'. 

218 """ 

219 return _validate_number( 

220 key, 

221 _get_setting_value(settings_snapshot, key, default), 

222 default=default, 

223 minimum=minimum, 

224 maximum=maximum, 

225 ) 

226 

227 

228def resolve_request_timeout(settings_snapshot=None) -> float: 

229 """Resolve the per-operation request timeout shared by all clients. 

230 

231 The returned value bounds each individual network operation (connect, 

232 write, and the wait for the *next* response byte) — not the total 

233 duration of a response. Always returns a finite number of at least 

234 ``MIN_REQUEST_TIMEOUT``; there is no upper bound. 

235 """ 

236 return _resolve_bounded_number( 

237 settings_snapshot, 

238 "llm.request_timeout", 

239 default=DEFAULT_REQUEST_TIMEOUT, 

240 minimum=MIN_REQUEST_TIMEOUT, 

241 maximum=None, 

242 ) 

243 

244 

245def resolve_max_retries(settings_snapshot=None) -> int: 

246 """Resolve the bounded SDK retry count shared by all clients. 

247 

248 Retries multiply the number of attempts, so the configured value must 

249 not exceed ``MAX_MAX_RETRIES``. Each attempt has per-operation timeouts; 

250 SDK backoff and server-directed delays also affect total elapsed time. 

251 """ 

252 return int( 

253 _resolve_bounded_number( 

254 settings_snapshot, 

255 "llm.max_retries", 

256 default=DEFAULT_MAX_RETRIES, 

257 minimum=MIN_MAX_RETRIES, 

258 maximum=MAX_MAX_RETRIES, 

259 ) 

260 ) 

261 

262 

263def build_timeout(seconds: float) -> tuple[float, float, float, float]: 

264 """Expand a scalar bound into a hashable per-operation timeout tuple. 

265 

266 Returns ``(connect, read, write, pool)``, the 4-tuple form httpx 

267 accepts wherever it accepts a ``Timeout`` object 

268 (``httpx._config.Timeout.__init__`` unpacks a tuple in exactly this 

269 order). The effective client timeout is identical either way: 

270 ``Timeout(connect=min(5, X), read=X, write=X, pool=X)``. The connect 

271 slot is bounded *at most* at 5 seconds, not pinned to it — below a 

272 5-second ``seconds`` the ``min`` yields ``seconds``, so raising the 

273 setting from 2 to 4 does raise the connect bound as well. 

274 

275 **For client construction only.** ``openai._models 

276 .FinalRequestOptions.timeout`` is pydantic-validated and rejects a 

277 tuple, so a per-request ``timeout=`` keyword (e.g. 

278 ``client.models.list(timeout=...)``) must use ``build_httpx_timeout`` 

279 instead. Only the client-level ``timeout=`` is untyped. 

280 

281 The openai SDK also stringifies whatever it is given into the 

282 informational ``x-stainless-read-timeout`` request header, so the 

283 tuple's repr rides along on the clients that take it. The effective 

284 bounds are unaffected; the header is not read by this codebase. 

285 

286 **The tuple shape is load-bearing, not cosmetic.** ``langchain_openai`` 

287 builds ``ChatOpenAI``'s httpx clients through an ``@lru_cache``'d 

288 factory and silently falls back to a per-instance client when the 

289 timeout is unhashable — ``_client_utils._get_default_httpx_client``: 

290 *"Uses cached client unless timeout is ``httpx.Timeout``, which is not 

291 hashable"*. ``httpx.Timeout`` defines ``__eq__`` without ``__hash__``, 

292 so handing a ``Timeout`` object to ``ChatOpenAI(request_timeout=...)`` 

293 would give every LLM its own connection pool. That would invert the 

294 invariants recorded in ``utilities/llm_utils.py`` (``_close_base_llm`` 

295 deliberately does *not* close ChatOpenAI/ChatAnthropic clients because 

296 they are shared) and in ``docs/developing/resource-cleanup.md``. With 

297 the tuple those stay true, so neither needs changing. 

298 

299 Sharing is also why sync entry points must keep calling the sync 

300 ``invoke``: the cached *async* client is bound to the event loop that 

301 created it, so a second ``asyncio.run()`` in the same process reuses a 

302 client whose loop is closed (issue #6293). That is a property of 

303 langchain's cache, not of this bound — an unhashable timeout only 

304 masked it by accident. 

305 

306 Passing a bare scalar instead would set ``connect`` to the same large 

307 value, so a TCP connect to a blackholed address would hold a worker 

308 for the whole inference budget. A 2-tuple is not an option either: it 

309 leaves ``write`` and ``pool`` at ``None`` (unbounded), so all four 

310 slots are always filled. 

311 """ 

312 bounded = float(seconds) 

313 return (min(CONNECT_TIMEOUT_SECONDS, bounded), bounded, bounded, bounded) 

314 

315 

316def build_httpx_timeout(seconds: float, timeout_cls): 

317 """``build_timeout`` as the SDK's own ``Timeout`` object. 

318 

319 For every client whose httpx client is per-instance regardless of 

320 hashability, which is all of them except ``ChatOpenAI``: 

321 ``ollama.Client`` is constructed once per ``ChatOllama`` 

322 /``OllamaEmbeddings``; ``OpenAIEmbeddings`` never consults 

323 ``_get_default_httpx_client`` at all; and the model-discovery clients 

324 are fresh SDK instances built per call. Only 

325 ``ChatOpenAI`` routes through the ``@lru_cache``'d factory, so only 

326 there does the tuple of ``build_timeout`` buy anything. 

327 

328 Use the target SDK's exported type — ``anthropic.Timeout``, 

329 ``openai.Timeout``, or ``httpx.Timeout`` for Ollama — to match its 

330 native transport. This is not interchangeable: Anthropic and Ollama 

331 ship ``httpx`` while openai ships ``httpx2``, and the two ``Timeout`` 

332 classes do not recognise each other. Handing an ``httpx.Timeout`` to 

333 ``openai.OpenAI(timeout=...)`` is silently treated as a scalar, which 

334 nests it into all four slots of an ``httpx2.Timeout``. 

335 """ 

336 connect, read, write, pool = build_timeout(seconds) 

337 return timeout_cls(connect=connect, read=read, write=write, pool=pool)