Coverage for src/local_deep_research/web_search_engines/rate_limiting/llm/wrapper.py: 92%

286 statements  

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

1""" 

2Rate-limited wrapper for LLM calls. 

3""" 

4 

5import inspect 

6from typing import Optional 

7from urllib.parse import urlparse 

8 

9from ....security import ( 

10 redact_url_for_log, 

11 scrub_error, 

12 url_authority_without_userinfo, 

13) 

14from ....security.secure_logging import logger 

15from tenacity import ( 

16 retry, 

17 stop_after_attempt, 

18 retry_if_exception, 

19) 

20from tenacity.wait import wait_base 

21 

22from ....llm.providers.base import normalize_provider 

23 

24from ..tracker import get_tracker 

25from ..exceptions import RateLimitError 

26from .detection import is_llm_rate_limit_error, extract_retry_after 

27 

28 

29class AdaptiveLLMWait(wait_base): 

30 """Adaptive wait strategy for LLM rate limiting.""" 

31 

32 def __init__(self, tracker, engine_type: str): 

33 self.tracker = tracker 

34 self.engine_type = engine_type 

35 self.last_error = None 

36 

37 def __call__(self, retry_state) -> float: 

38 # Store the error for potential retry-after extraction 

39 if retry_state.outcome and retry_state.outcome.failed: 

40 self.last_error = retry_state.outcome.exception() 

41 

42 # Get adaptive wait time from tracker 

43 wait_time: float = self.tracker.get_wait_time(self.engine_type) 

44 

45 # If we have a retry-after from the error, use it 

46 if self.last_error: 

47 retry_after = extract_retry_after(self.last_error) 

48 if retry_after > 0: 

49 wait_time = max(wait_time, float(retry_after)) 

50 

51 logger.info( 

52 f"LLM rate limit wait for {self.engine_type}: {wait_time:.2f}s" 

53 ) 

54 return wait_time 

55 

56 

57def create_rate_limited_llm_wrapper(base_llm, provider: Optional[str] = None): 

58 """ 

59 Create a rate-limited wrapper around an LLM instance. 

60 

61 Args: 

62 base_llm: The base LLM instance to wrap 

63 provider: Optional provider name (e.g., 'openai', 'anthropic') 

64 

65 Returns: 

66 A wrapped LLM instance with rate limiting capabilities 

67 """ 

68 

69 class RateLimitedLLMWrapper: 

70 """Wrapper that adds rate limiting to LLM calls.""" 

71 

72 # invoke, ainvoke, stream, astream, batch and abatch pass their 

73 # arguments, the call's RunnableConfig included, to base_llm 

74 # unchanged. ProcessingLLMWrapper relies on this to see which 

75 # runnable receives that config (config/llm_config.py). 

76 _ldr_forwards_call_arguments = True 

77 

78 def __init__(self, llm, provider_name: Optional[str] = None): 

79 self.base_llm = llm 

80 self.provider = provider_name 

81 self.rate_limiter = None 

82 # Cancellation scope (see llm/cancellation.py). Bound via 

83 # ``bind_llm_to_research`` by the outer ProcessingLLMWrapper; 

84 # falls back to the thread's search context per call. 

85 self._ldr_research_id = getattr(llm, "_ldr_research_id", None) 

86 

87 # Only setup rate limiting if enabled 

88 if self._should_rate_limit(): 88 ↛ 89line 88 didn't jump to line 89 because the condition on line 88 was never true

89 self.rate_limiter = get_tracker() 

90 logger.info( 

91 f"Rate limiting enabled for LLM provider: {self._get_rate_limit_key()}" 

92 ) 

93 

94 def _effective_research_id(self): 

95 """Resolve cancellation scope: binding > thread context.""" 

96 try: 

97 from ....llm.cancellation import effective_research_id 

98 

99 return effective_research_id(self, self._ldr_research_id) 

100 except Exception: 

101 return getattr(self, "_ldr_research_id", None) 

102 

103 def _check_terminated(self, research_id=None): 

104 """Raise ``ResearchTerminatedException`` if Stop was requested.""" 

105 from ....llm.cancellation import raise_if_terminated 

106 

107 rid = ( 

108 research_id 

109 if research_id is not None 

110 else self._effective_research_id() 

111 ) 

112 if rid: 

113 raise_if_terminated(rid) 

114 

115 def _should_rate_limit(self) -> bool: 

116 """Check if rate limiting should be applied to this LLM.""" 

117 # Rate limiting for LLMs is currently disabled by default 

118 # TODO: Pass settings_snapshot to enable proper configuration 

119 # 

120 # Blocker for flipping this on, now that ainvoke() is a real 

121 # async path: tenacity's AsyncRetrying._run_wait awaits the wait 

122 # strategy through wrap_to_async_func, which for a SYNC callable 

123 # just calls it inline — so AdaptiveLLMWait.__call__ runs ON the 

124 # event loop, and it reaches tracker.get_wait_time -> 

125 # _ensure_estimates_loaded, which opens the user's encrypted 

126 # (SQLCipher) DB. Only the sleep between attempts is non-blocking. 

127 # Do not enable this without first making AdaptiveLLMWait 

128 # loop-safe (async wait strategy, or offload the tracker read). 

129 return False 

130 

131 def _check_if_local_model(self) -> bool: 

132 """Check if the LLM is a local model that shouldn't be rate limited.""" 

133 # Don't rate limit local models 

134 local_providers = [ 

135 "ollama", 

136 "lmstudio", 

137 "llamacpp", 

138 "local", 

139 "none", 

140 ] 

141 if normalize_provider(self.provider) in local_providers: 

142 logger.debug( 

143 f"Skipping rate limiting for local provider: {self.provider}" 

144 ) 

145 return True 

146 

147 # Check if base URL indicates local model 

148 if hasattr(self.base_llm, "base_url"): 

149 base_url = str(self.base_llm.base_url) 

150 if any( 

151 local in base_url 

152 # Classify a configured local service URL; no listening socket is opened. 

153 for local in ["localhost", "127.0.0.1", "0.0.0.0"] # nosec B104 

154 ): 

155 logger.debug( 

156 "Skipping rate limiting for local URL: " 

157 f"{redact_url_for_log(base_url)}" 

158 ) 

159 return True 

160 

161 return False 

162 

163 def _get_rate_limit_key(self) -> str: 

164 """Build composite key: provider-url-model""" 

165 provider = self.provider or "unknown" 

166 

167 # Extract URL 

168 url = "unknown" 

169 if hasattr(self.base_llm, "base_url"): 

170 url = str(self.base_llm.base_url) 

171 elif hasattr(self.base_llm, "_client") and hasattr( 

172 self.base_llm._client, "base_url" 

173 ): 

174 url = str(self.base_llm._client.base_url) 

175 

176 # Clean URL: remove protocol and trailing slashes 

177 if url != "unknown": 

178 parsed = urlparse(url) 

179 # Authority minus userinfo: the key is logged. A 

180 # scheme-less value keeps the old path fallback unless 

181 # that path could hold userinfo. 

182 if parsed.netloc: 

183 url = url_authority_without_userinfo(url) or "unknown" 

184 elif "@" in parsed.path: 

185 url = "unknown" 

186 else: 

187 url = parsed.path 

188 url = url.rstrip("/") 

189 

190 # Extract model 

191 model = "unknown" 

192 if hasattr(self.base_llm, "model_name"): 

193 model = str(self.base_llm.model_name) 

194 elif hasattr(self.base_llm, "model"): 

195 model = str(self.base_llm.model) 

196 

197 # Clean model name 

198 model = model.replace("/", "-").replace(":", "-") 

199 

200 return f"{provider}-{url}-{model}" 

201 

202 def invoke(self, *args, **kwargs): 

203 """Invoke the LLM with rate limiting if enabled.""" 

204 self._check_terminated() 

205 result = self._call_rate_limited( 

206 lambda: self.base_llm.invoke(*args, **kwargs) 

207 ) 

208 self._check_terminated() 

209 return result 

210 

211 async def ainvoke(self, *args, **kwargs): 

212 """Async invoke with the same rate limiting and scrub as invoke(). 

213 

214 Without this, ``ainvoke`` falls through ``__getattr__`` to the 

215 base LLM and silently skips the adaptive wait/retry loop and 

216 the RateLimitError wrapping — a behavioural hole for any caller 

217 that reaches this async twin, including the fan-out path daemon 

218 code submits to the process's one shared event loop (ADR-0013, 

219 docs/decisions/0013-research-runs-keep-daemon-thread.md); the 

220 sync ``invoke`` stays the canonical production path on the 

221 research thread. The ``@retry`` decorator detects the coroutine 

222 and sleeps between attempts 

223 with ``asyncio.sleep`` (tenacity AsyncRetrying), so the sleep 

224 itself does not block the loop. The wait STRATEGY that 

225 computes that sleep still runs synchronously on the loop — 

226 see the note on ``_should_rate_limit``. ``_acall_rate_limited`` 

227 also calls ``tracker.record_outcome`` (and from it 

228 ``_update_estimate``) synchronously on the loop after each 

229 successful call and each rate-limit failure; those can read 

230 the settings context and the search context and persist 

231 through the metrics database. Both are moot today because 

232 ``self.rate_limiter`` is always ``None``. 

233 See #6232/#6246/#6249/#6268/#6347 for the history. 

234 """ 

235 self._check_terminated() 

236 result = await self._acall_rate_limited( 

237 lambda: self.base_llm.ainvoke(*args, **kwargs) 

238 ) 

239 self._check_terminated() 

240 return result 

241 

242 def _scrub_or_raise(self, error): 

243 """Re-raise with scrubbing: provider 429 bodies can echo the 

244 Authorization header, so the body must not reach logs or 

245 exception chains unscrubbed. Non-rate-limit errors pass 

246 through unchanged.""" 

247 if is_llm_rate_limit_error(error): 

248 logger.warning("LLM rate limit error detected") 

249 safe_msg = scrub_error(error) 

250 raise RateLimitError(f"LLM rate limit: {safe_msg}") from None 

251 raise error 

252 

253 def _call_rate_limited(self, call): 

254 """Run one base-LLM call with retries and tracker bookkeeping 

255 when rate limiting is enabled; scrub rate-limit errors either 

256 way.""" 

257 if not self.rate_limiter: 

258 try: 

259 return call() 

260 except Exception as e: 

261 self._scrub_or_raise(e) 

262 

263 rate_limit_key = self._get_rate_limit_key() 

264 tracker = self.rate_limiter 

265 if tracker is None: # defensive: narrowed above in this branch 265 ↛ 266line 265 didn't jump to line 266 because the condition on line 265 was never true

266 return call() 

267 

268 @retry( 

269 wait=AdaptiveLLMWait(tracker, rate_limit_key), 

270 stop=stop_after_attempt(3), 

271 retry=retry_if_exception(is_llm_rate_limit_error), 

272 ) 

273 def _with_retry(): 

274 try: 

275 return call() 

276 except Exception as e: 

277 self._scrub_or_raise(e) 

278 

279 try: 

280 result = _with_retry() 

281 tracker.record_outcome( 

282 engine_type=rate_limit_key, 

283 wait_time=0, 

284 success=True, 

285 retry_count=0, 

286 ) 

287 return result 

288 except Exception as e: 

289 if is_llm_rate_limit_error(e): 

290 tracker.record_outcome( 

291 engine_type=rate_limit_key, 

292 wait_time=0, 

293 success=False, 

294 retry_count=0, 

295 ) 

296 raise 

297 

298 def stream(self, *args, **kwargs): 

299 """Stream through the base LLM, scrubbing failures wherever they 

300 surface. 

301 

302 `BaseChatModel.stream` is a generator function, so calling it 

303 returns a generator without making the request: a 429 is raised on 

304 the first `next()`, after the call has already returned. Wrapping 

305 only the call therefore protects nothing, which is why this 

306 iterates the source here and catches during iteration. 

307 

308 Chunks are forwarded untouched: a credential echoed in a 429 body 

309 can straddle chunk boundaries, so scrubbing individual chunks is 

310 unsound. Only exceptions go through the scrub. 

311 """ 

312 

313 def _source(): 

314 try: 

315 return self.base_llm.stream(*args, **kwargs) 

316 except Exception as e: 

317 self._scrub_or_raise(e) 

318 

319 research_id = self._effective_research_id() 

320 if research_id: 

321 self._check_terminated(research_id) 

322 chunks = iter(self._call_rate_limited(_source)) 

323 try: 

324 while True: 

325 # Only the advance is guarded, and `yield` stays outside the 

326 # Only the advance is guarded, and `yield` stays outside the 

327 # inner `try`: a handler that encloses the yield also catches 

328 # whatever the CONSUMER throws back in, which would scrub an 

329 # unrelated caller-side failure as if it were a provider 429. 

330 # The outer handler only closes the upstream and 

331 # re-raises the same object it caught, so a consumer 

332 # `throw()` still propagates untouched to the caller. 

333 # The termination check sits after next() and before yield: 

334 # a Stop aborts without yielding the just-pulled chunk and 

335 # without draining the rest of the stream. 

336 try: 

337 chunk = next(chunks) 

338 except StopIteration: 

339 break 

340 except Exception as e: 

341 self._scrub_or_raise(e) 

342 if research_id: 

343 self._check_terminated(research_id) 

344 yield chunk 

345 except BaseException as exc: 

346 # Close the upstream generator on ANY exit — early consumer 

347 # close (GeneratorExit lands at the yield above) or an 

348 # exception — so its connection does not linger until GC 

349 # (#6607). 

350 self._close_upstream(chunks, exc) 

351 raise 

352 self._close_upstream(chunks, None) 

353 

354 def _close_upstream(self, chunks, body_exc): 

355 """Close a sync upstream iterator after stream() stops. 

356 

357 The upstream's own cleanup can raise (e.g. a LangChain callback 

358 handler failing in on_llm_error), and that text may carry 

359 provider or credential material, so any close failure is 

360 handled by ``_handle_close_error`` rather than left to 

361 propagate raw. Only an explicit consumer close (GeneratorExit) 

362 still surfaces the (scrubbed) close failure; a normal 

363 exhaustion or an unrelated body exception both keep the close 

364 failure out of the caller's way. The attribute lookup itself 

365 is inside the guarded try: a proxy whose ``__getattr__`` raises 

366 must not replace whatever is already propagating. Tolerant of 

367 non-generator iterables (mocks, list-backed sources) that have 

368 no close(). 

369 """ 

370 try: 

371 closer = getattr(chunks, "close", None) 

372 if closer is None: 

373 return 

374 closer() 

375 except Exception as close_exc: 

376 self._handle_close_error(close_exc, body_exc) 

377 

378 async def _aclose_upstream(self, chunks, body_exc): 

379 """Async twin of _close_upstream() for astream().""" 

380 # Skip an upstream that is already finished or mid-close: at 

381 # loop shutdown / cyclic GC the event loop's asyncgen finalizer 

382 # may already be running its aclose(), and a second one raises 

383 # "aclose(): asynchronous generator is already running". 

384 # Only the order where the loop's aclose starts first can be 

385 # guarded here. When this aclose starts first and the 

386 # upstream's cleanup suspends (e.g. an async callback handler 

387 # awaiting in on_llm_error), the loop's own later aclose hits 

388 # that RuntimeError inside asyncio and is logged by the loop's 

389 # exception handler. That is benign: the upstream is still 

390 # closed exactly once, and the log carries no provider text. 

391 # It needs a consumer that abandons astream() without aclose(). 

392 # 

393 # All the lookups below (ag_running, ag_frame, aclose) live 

394 # inside this same guarded try as the close call itself: a 

395 # proxy whose __getattr__ raises must not replace whatever 

396 # exception is already propagating. 

397 try: 

398 if getattr(chunks, "ag_running", False) is True: 

399 return 

400 if inspect.isasyncgen(chunks) and chunks.ag_frame is None: 

401 return 

402 acloser = getattr(chunks, "aclose", None) 

403 if acloser is None: 403 ↛ 404line 403 didn't jump to line 404 because the condition on line 403 was never true

404 return 

405 result = acloser() 

406 # Plain callables (e.g. a Mock source) return a 

407 # non-awaitable; only real coroutines are awaited. 

408 if inspect.isawaitable(result): 

409 await result 

410 except Exception as close_exc: 

411 self._handle_close_error(close_exc, body_exc) 

412 

413 def _handle_close_error(self, close_exc, body_exc): 

414 """Decide whether a close() failure should surface. 

415 

416 Only an explicit consumer close (``body_exc`` is 

417 ``GeneratorExit``) keeps the current scrub-and-raise 

418 behaviour. A normal exhaustion (``body_exc`` is ``None``) or 

419 any other in-flight exception must not be replaced by the 

420 close failure: it is logged at debug with the close 

421 exception's type name only and dropped. 

422 """ 

423 if isinstance(body_exc, GeneratorExit): 

424 self._scrub_or_raise(close_exc) 

425 return 

426 logger.debug( 

427 "Suppressed {} while closing the upstream stream", 

428 type(close_exc).__name__, 

429 ) 

430 

431 async def astream(self, *args, **kwargs): 

432 """Async generator implementing LangChain's astream contract: 

433 ``async for chunk in model.astream(...)``. 

434 

435 Named `astream`, not `_astream`: `__getattr__` forwards any name 

436 this class does not define straight to the raw model, so an 

437 underscore-prefixed twin is never reached and the surface stays 

438 unscrubbed while looking covered. 

439 

440 `BaseChatModel.astream` is an async generator function, so the 

441 provider error arrives on the first `__anext__()` rather than at 

442 call time — each advance is guarded for that reason. 

443 Chunks are forwarded untouched: a credential echoed in a 429 body 

444 can straddle chunk boundaries, so per-chunk scrubbing is unsound. 

445 """ 

446 research_id = self._effective_research_id() 

447 if research_id: 

448 self._check_terminated(research_id) 

449 try: 

450 chunks = self.base_llm.astream(*args, **kwargs).__aiter__() 

451 except Exception as e: 

452 self._scrub_or_raise(e) 

453 

454 try: 

455 while True: 

456 # `yield` deliberately outside the inner `try`; see 

457 # stream(). The outer handler re-raises the same object, 

458 # so a consumer `throw()` still propagates untouched. 

459 # Termination is checked after each advance so a Stop 

460 # aborts without yielding the just-pulled chunk. 

461 try: 

462 chunk = await chunks.__anext__() 

463 except StopAsyncIteration: 

464 break 

465 except Exception as e: 

466 self._scrub_or_raise(e) 

467 if research_id: 

468 self._check_terminated(research_id) 

469 yield chunk 

470 except BaseException as exc: 

471 # Async twin of stream()'s cleanup: close the upstream 

472 # async generator on any exit instead of leaking the 

473 # connection until GC or loop shutdown (#6607). 

474 await self._aclose_upstream(chunks, exc) 

475 raise 

476 await self._aclose_upstream(chunks, None) 

477 

478 async def _acall_rate_limited(self, call): 

479 """Async variant of _call_rate_limited.""" 

480 if not self.rate_limiter: 

481 try: 

482 return await call() 

483 except Exception as e: 

484 self._scrub_or_raise(e) 

485 

486 rate_limit_key = self._get_rate_limit_key() 

487 tracker = self.rate_limiter 

488 if tracker is None: # defensive: narrowed above in this branch 488 ↛ 489line 488 didn't jump to line 489 because the condition on line 488 was never true

489 return await call() 

490 

491 @retry( 

492 wait=AdaptiveLLMWait(tracker, rate_limit_key), 

493 stop=stop_after_attempt(3), 

494 retry=retry_if_exception(is_llm_rate_limit_error), 

495 ) 

496 async def _with_retry(): 

497 try: 

498 return await call() 

499 except Exception as e: 

500 self._scrub_or_raise(e) 

501 

502 try: 

503 result = await _with_retry() 

504 tracker.record_outcome( 

505 engine_type=rate_limit_key, 

506 wait_time=0, 

507 success=True, 

508 retry_count=0, 

509 ) 

510 return result 

511 except Exception as e: 

512 if is_llm_rate_limit_error(e): 

513 tracker.record_outcome( 

514 engine_type=rate_limit_key, 

515 wait_time=0, 

516 success=False, 

517 retry_count=0, 

518 ) 

519 raise 

520 

521 @staticmethod 

522 def _configs_for_inputs(config, inputs): 

523 """Resolve per-item configs from a shared config or a list of 

524 per-input configs (LangChain Runnable.batch takes either).""" 

525 if isinstance(config, list): 

526 if len(config) != len(inputs): 

527 raise ValueError( 

528 "configs must match the number of inputs " 

529 f"({len(config)} != {len(inputs)})" 

530 ) 

531 return list(config) 

532 return [config] * len(inputs) 

533 

534 def batch( 

535 self, inputs, config=None, *, return_exceptions=False, **kwargs 

536 ): 

537 """Batch through invoke(), forwarding config (shared or 

538 per-input) so caller callbacks/tags/metadata survive. 

539 

540 Only ``Exception`` is captured for ``return_exceptions=True`` — 

541 ``ResearchTerminatedException`` (a ``BaseException``) always 

542 propagates so a Stop is never swallowed into a result list. 

543 """ 

544 configs = self._configs_for_inputs(config, inputs) 

545 results = [] 

546 for msg, conf in zip(inputs, configs): 

547 try: 

548 results.append(self.invoke(msg, conf, **kwargs)) 

549 except Exception as e: 

550 if return_exceptions: 

551 results.append(e) 

552 else: 

553 raise 

554 return results 

555 

556 async def abatch( 

557 self, inputs, config=None, *, return_exceptions=False, **kwargs 

558 ): 

559 """Async counterpart of batch() (same BaseException guarantee).""" 

560 configs = self._configs_for_inputs(config, inputs) 

561 results = [] 

562 for msg, conf in zip(inputs, configs): 

563 try: 

564 results.append(await self.ainvoke(msg, conf, **kwargs)) 

565 except Exception as e: 

566 if return_exceptions: 

567 results.append(e) 

568 else: 

569 raise 

570 return results 

571 

572 def bind_tools(self, tools, **kwargs): 

573 """Bind tools and re-wrap so the bound runnable keeps rate 

574 limiting and scrubbing (same shape as 

575 ProcessingLLMWrapper.bind_tools, #4804).""" 

576 bound = self.base_llm.bind_tools(tools, **kwargs) 

577 wrapped = create_rate_limited_llm_wrapper(bound, self.provider) 

578 try: 

579 wrapped._ldr_research_id = self._effective_research_id() 

580 inner = getattr(wrapped, "base_llm", None) 

581 if inner is not None: 581 ↛ 596line 581 didn't jump to line 596 because the condition on line 581 was always true

582 try: 

583 setattr( 

584 inner, 

585 "_ldr_research_id", 

586 wrapped._ldr_research_id, 

587 ) 

588 except Exception: 

589 logger.debug( 

590 "bind_tools inner scope propagation failed (non-critical)" 

591 ) 

592 except Exception: 

593 logger.debug( 

594 "bind_tools scope propagation failed (non-critical)" 

595 ) 

596 return wrapped 

597 

598 def with_structured_output(self, schema, **kwargs): 

599 """Wrap the structured-output runnable the same way as 

600 bind_tools.""" 

601 bound = self.base_llm.with_structured_output(schema, **kwargs) 

602 wrapped = create_rate_limited_llm_wrapper(bound, self.provider) 

603 try: 

604 wrapped._ldr_research_id = self._effective_research_id() 

605 except Exception: 

606 logger.debug( 

607 "with_structured_output scope propagation failed (non-critical)" 

608 ) 

609 return wrapped 

610 

611 # Pass through any other attributes to the base LLM 

612 def __getattr__(self, name): 

613 return getattr(self.base_llm, name) 

614 

615 def close(self): 

616 """Close underlying HTTP clients held by this LLM. Idempotent.""" 

617 try: 

618 from ....utilities.llm_utils import _close_base_llm 

619 

620 _close_base_llm(self.base_llm) 

621 except Exception: 

622 logger.debug( 

623 "best-effort cleanup of HTTP clients on shutdown", 

624 exc_info=True, 

625 ) 

626 

627 def __str__(self): 

628 return f"RateLimited({str(self.base_llm)})" 

629 

630 def __repr__(self): 

631 return f"RateLimitedLLMWrapper({repr(self.base_llm)})" 

632 

633 return RateLimitedLLMWrapper(base_llm, provider)