Coverage for src/local_deep_research/config/llm_config.py: 78%

560 statements  

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

1from typing import Any 

2 

3from langchain_core.callbacks import BaseCallbackManager, CallbackManager 

4from langchain_core.language_models import BaseChatModel, BaseLanguageModel 

5from langchain_core.messages import AIMessage 

6from langchain_core.runnables import RunnableBinding, ensure_config 

7from langchain_core.runnables.config import merge_configs 

8from loguru import logger 

9 

10from ..llm import get_llm_from_registry, is_llm_registered 

11from ..security.log_sanitizer import sanitize_for_log, scrub_error 

12from ..security.secure_logging import log_exception_type 

13from ..settings.exceptions import SettingsValueError 

14from ..utilities.search_utilities import remove_think_tags 

15 

16# Import providers module to trigger auto-discovery. get_llm() has no 

17# fallback construction path: if this import fails (e.g. a broken 

18# langchain install), the module must fail loudly here rather than start 

19# with an empty registry and confusing per-call errors. 

20from ..llm.providers import discover_providers # noqa: F401 

21from ..llm.providers.auto_discovery import is_builtin_provider_factory 

22from ..llm.providers.base import normalize_provider 

23from .thread_settings import get_setting_from_snapshot 

24 

25 

26class LLMNotConfiguredError(ValueError): 

27 """``get_llm`` could not build an LLM because none is CONFIGURED. 

28 

29 Raised for the "the user has not set this up yet" failures — no 

30 provider, an unknown provider, no ``llm.model``, a provider that 

31 auto-discovery did not register. It is deliberately a ``ValueError`` 

32 subclass so every existing ``except ValueError`` around ``get_llm`` 

33 keeps behaving exactly as before. 

34 

35 It exists so an HTTP boundary can tell a configuration failure apart 

36 from a request-validation failure *by type* — the alternative is 

37 matching on the exception text, which is what the fixed-message 

38 boundary rule forbids. Without it, a route that maps ``ValueError`` to 

39 "your request was invalid" tells a fresh install with no ``llm.model`` 

40 that its perfectly valid request was malformed. 

41 """ 

42 

43 

44# The fixed text a route may show for the above. A constant, never 

45# ``str(exc)``: the exception messages are setup guidance written for a log 

46# reader, not a boundary contract, and a future raise site could put an 

47# endpoint URL or a settings path in one. 

48LLM_NOT_CONFIGURED_CLIENT_MESSAGE = ( 

49 "LLM is not configured. Open Settings and choose a provider and model." 

50) 

51 

52 

53def get_selected_llm_provider(settings_snapshot=None): 

54 return normalize_provider( 

55 get_setting_from_snapshot( 

56 "llm.provider", "ollama", settings_snapshot=settings_snapshot 

57 ) 

58 ) 

59 

60 

61def _get_context_window_for_provider(provider_type, settings_snapshot=None): 

62 """Resolve the context window size for a provider. 

63 

64 Thin wrapper around the canonical 

65 ``llm/providers/_helpers.get_context_window_for_provider`` so the 

66 provider ``create_llm`` path and this path share a single source of 

67 truth (the two were previously identical copies). Kept as a named 

68 function because ``get_llm``/``wrap_llm_without_think_tags`` and tests 

69 reference it by name and signature. 

70 

71 NOTE: the helper reads settings through 

72 ``thread_settings.get_setting_from_snapshot`` (a function-local import), 

73 so tests exercising context-window resolution must patch 

74 ``...config.thread_settings.get_setting_from_snapshot`` rather than 

75 ``...config.llm_config.get_setting_from_snapshot``. 

76 

77 Returns: 

78 int or None: The context window size, or None for unrestricted cloud providers. 

79 """ 

80 from ..llm.providers._helpers import get_context_window_for_provider 

81 

82 return get_context_window_for_provider( 

83 provider_type, settings_snapshot=settings_snapshot 

84 ) 

85 

86 

87def get_llm( 

88 model_name=None, 

89 temperature=None, 

90 provider=None, 

91 openai_endpoint_url=None, 

92 research_id=None, 

93 research_context=None, 

94 settings_snapshot=None, 

95 username=None, 

96): 

97 """ 

98 Get LLM instance based on model name and provider. 

99 

100 Args: 

101 model_name: Name of the model to use (if None, uses database setting) 

102 temperature: Model temperature (if None, uses database setting) 

103 provider: Provider to use (if None, uses database setting) 

104 openai_endpoint_url: Per-call URL override for the 

105 ``openai_endpoint`` provider. When omitted, the provider uses 

106 the ``llm.openai_endpoint.url`` setting. 

107 research_id: Optional research ID for token tracking 

108 research_context: Optional research context for enhanced token tracking 

109 username: Requesting user, used to resolve per-user registered LLMs. 

110 When omitted, falls back to the ``_username`` in the settings 

111 snapshot; ``None`` resolves shared/built-in providers only. 

112 

113 Returns: 

114 A LangChain LLM instance with automatic think-tag removal 

115 """ 

116 

117 # Resolve the effective username for per-user registry lookups. An 

118 # explicit argument wins; otherwise fall back to the ``_username`` the 

119 # settings snapshot carries. None resolves the shared namespace (which 

120 # holds the auto-discovered built-in providers) only. 

121 if username is None: 

122 from ..search_system import username_from_snapshot 

123 

124 username = username_from_snapshot(settings_snapshot) 

125 

126 # Use database values for parameters if not provided 

127 if model_name is None: 

128 model_name = get_setting_from_snapshot( 

129 "llm.model", "", settings_snapshot=settings_snapshot 

130 ) 

131 if temperature is None: 

132 temperature = get_setting_from_snapshot( 

133 "llm.temperature", 0.7, settings_snapshot=settings_snapshot 

134 ) 

135 if provider is None: 

136 provider = get_setting_from_snapshot( 

137 "llm.provider", "ollama", settings_snapshot=settings_snapshot 

138 ) 

139 

140 # Clean model name: remove quotes and extra whitespace 

141 if model_name: 

142 model_name = model_name.strip().strip("\"'").strip() 

143 

144 # Clean provider: remove quotes and extra whitespace 

145 if provider: 

146 provider = provider.strip().strip("\"'").strip() 

147 

148 # Normalize provider: convert to lowercase canonical form 

149 provider = normalize_provider(provider) 

150 

151 had_settings_snapshot = settings_snapshot is not None 

152 

153 # The endpoint override must reach both the egress-policy gate and the 

154 # provider factory. Overlay it onto a per-call copy so those paths share 

155 # one effective value without mutating the caller's settings snapshot. 

156 if provider == "openai_endpoint" and openai_endpoint_url is not None: 

157 if ( 

158 not isinstance(openai_endpoint_url, str) 

159 or not openai_endpoint_url.strip() 

160 ): 

161 raise ValueError("openai_endpoint_url must be a non-empty string") 

162 settings_snapshot = dict(settings_snapshot or {}) 

163 settings_snapshot["llm.openai_endpoint.url"] = ( 

164 openai_endpoint_url.strip() 

165 ) 

166 

167 # Egress policy PEP for LLM endpoints. Fires here (before the registered- 

168 # LLM dispatch) because all built-in providers are auto-registered via 

169 # discover_providers(), so the registered branch handles every real LLM 

170 # call. 

171 # 

172 # Two paths: snapshot-present runs the full PEP; snapshot-absent runs 

173 # an allow-list check so background helpers / scaffolding paths cannot 

174 # silently instantiate a cloud LLM. The allow-list (known-local) is 

175 # deliberately tight — any provider not in it (including ambiguous 

176 # ones like ``openai_endpoint`` and future cloud providers) fails 

177 # closed instead of bypassing the PEP. 

178 if provider: 

179 from ..security.egress.policy import ( 

180 Decision, 

181 EgressContext, 

182 EgressScope, 

183 PolicyDeniedError, 

184 _LOCAL_DEFAULT_LLM_PROVIDERS, 

185 _is_user_registered_llm, 

186 context_from_snapshot, 

187 evaluate_llm_endpoint, 

188 resolve_run_primary_engine, 

189 ) 

190 

191 if not had_settings_snapshot: 

192 if provider == "openai_endpoint" and settings_snapshot is not None: 

193 # A per-call endpoint is enough to classify this otherwise 

194 # snapshot-less provider. Preserve the fail-closed contract: 

195 # only a local endpoint may proceed without a policy snapshot. 

196 ctx = EgressContext( 

197 scope=EgressScope.PRIVATE_ONLY, 

198 primary_engine="snapshotless-explicit-endpoint", 

199 require_local_llm=True, 

200 require_local_embeddings=True, 

201 ) 

202 decision = evaluate_llm_endpoint( 

203 provider, 

204 ctx, 

205 settings_snapshot=settings_snapshot, 

206 username=username, 

207 ) 

208 if not decision.allowed: 

209 logger.bind(policy_audit=True).warning( 

210 "Snapshot-less explicit LLM endpoint denied", 

211 provider=provider, 

212 reason=decision.reason, 

213 ) 

214 raise PolicyDeniedError(decision, target=provider) 

215 # User-registered in-process LLMs are exempt here for the same 

216 # reason evaluate_llm_endpoint allows them: no endpoint to 

217 # classify, operator-injected, audit-hook backstopped. 

218 elif provider not in _LOCAL_DEFAULT_LLM_PROVIDERS and not ( 

219 _is_user_registered_llm(provider, username=username) 

220 ): 

221 logger.bind(policy_audit=True).warning( 

222 "LLM constructed without policy snapshot; refusing " 

223 "non-local provider", 

224 provider=provider, 

225 ) 

226 raise PolicyDeniedError( 

227 Decision(False, "no_snapshot_for_provider"), 

228 target=provider, 

229 ) 

230 else: 

231 try: 

232 # Derive the run's primary the SAME way the search-engine 

233 # factory does (single source of truth), instead of the old 

234 # ``search.tool`` + searxng fallback. That fallback was a 

235 # fail-OPEN: a missing/blank primary defaulted to searxng -> 

236 # ADAPTIVE -> PUBLIC_ONLY -> require_local_llm stayed False -> 

237 # the endpoint check below was skipped, admitting a CLOUD LLM 

238 # for a run whose actual posture was private. resolve_run_ 

239 # primary_engine raises on a missing/invalid primary, which we 

240 # treat as a hard stop here. 

241 primary_engine = resolve_run_primary_engine(settings_snapshot) 

242 # Pass username so a per-user private retriever set as the 

243 # run's primary is classified against the caller's own 

244 # namespace. Without it ADAPTIVE can't see the retriever 

245 # (shared namespace only), resolves to the permissive BOTH, 

246 # leaves require_local_llm False, and admits a cloud LLM for a 

247 # run over the user's private corpus (fail-open). 

248 ctx = context_from_snapshot( 

249 settings_snapshot, primary_engine, username=username 

250 ) 

251 except ValueError as exc: 

252 # No configured primary, or an invalid policy config. Fail 

253 # closed: previously a missing primary silently fell back to 

254 # searxng/PUBLIC_ONLY and skipped the LLM endpoint check 

255 # entirely, opening a cloud-LLM bypass under the very 

256 # configuration the user asked to be strict about. 

257 logger.bind(policy_audit=True).warning( 

258 "no/invalid egress policy primary; refusing LLM", 

259 provider=provider, 

260 reason=str(exc), 

261 ) 

262 raise PolicyDeniedError( 

263 Decision(False, "invalid_policy_config"), 

264 target=provider, 

265 ) from exc 

266 

267 if ctx is not None and ctx.require_local_llm: 

268 decision = evaluate_llm_endpoint( 

269 provider, 

270 ctx, 

271 settings_snapshot=settings_snapshot, 

272 username=username, 

273 ) 

274 if not decision.allowed: 

275 logger.bind(policy_audit=True).warning( 

276 "LLM endpoint denied by egress policy", 

277 provider=provider, 

278 reason=decision.reason, 

279 ) 

280 raise PolicyDeniedError(decision, target=provider) 

281 

282 # Check if this is a registered custom LLM first. Resolve against the 

283 # caller's own namespace before the shared/built-in providers so one 

284 # user's registration can't shadow a built-in for anyone else. 

285 if provider and is_llm_registered(provider, username=username): 

286 logger.info(f"Using registered custom LLM: {provider}") 

287 custom_llm = get_llm_from_registry(provider, username=username) 

288 

289 # Check if it's a callable (factory function) or a BaseChatModel instance 

290 if callable(custom_llm) and not isinstance(custom_llm, BaseChatModel): 

291 # It's a callable (factory function), call it with parameters 

292 try: 

293 llm_instance = custom_llm( 

294 model_name=model_name, 

295 temperature=temperature, 

296 settings_snapshot=settings_snapshot, 

297 ) 

298 except TypeError as e: 

299 # Re-raise TypeError with better message 

300 raise TypeError( 

301 f"Registered LLM factory '{provider}' has invalid signature. " 

302 f"Factory functions must accept 'model_name', 'temperature', and 'settings_snapshot' parameters. " 

303 f"Error: {e}" 

304 ) 

305 

306 # Validate the result is a BaseChatModel 

307 if not isinstance(llm_instance, BaseChatModel): 

308 raise ValueError( 

309 f"Factory function for {provider} must return a BaseChatModel instance, " 

310 f"got {type(llm_instance).__name__}" 

311 ) 

312 elif isinstance(custom_llm, BaseChatModel): 

313 # It's already a proper LLM instance, use it directly 

314 llm_instance = custom_llm 

315 else: 

316 raise ValueError( 

317 f"Registered LLM {provider} must be either a BaseChatModel instance " 

318 f"or a callable factory function. Got: {type(custom_llm).__name__}" 

319 ) 

320 

321 return wrap_llm_without_think_tags( 

322 llm_instance, 

323 research_id=research_id, 

324 provider=provider, 

325 research_context=research_context, 

326 settings_snapshot=settings_snapshot, 

327 # LDR closes only a model it knows it built: a built-in 

328 # provider's create_llm builds a new one on every call. A 

329 # registered instance, or whatever a registered factory 

330 # returns, can be serving other researches (a factory can 

331 # return one cached or singleton model), so it stays with the 

332 # code that registered it. 

333 owns_llm=is_builtin_provider_factory(custom_llm), 

334 ) 

335 

336 # Validate the provider against the auto-discovered set — NOT a hardcoded 

337 # list. Auto-discovery (discover_providers, run at module import) registers 

338 # every llm/providers/implementations/*.py class, and the 

339 # is_llm_registered() check above already serves them, so the registry / 

340 # discovery IS the single source of truth for "valid provider". 

341 # 

342 # We deliberately do not keep a separate VALID_PROVIDERS constant: it was a 

343 # third copy of the provider list (besides the implementations directory 

344 # and the registry) and it silently drifted from auto-discovery (xai, 

345 # ionos and deepseek were valid+registered yet missing from it). Deriving 

346 # the set from discovery here means it can never drift. ('none' is the 

347 # explicit "unset" sentinel, handled by the guard further down.) 

348 from ..llm.providers import get_discovered_provider_options 

349 

350 valid_providers = { 

351 normalize_provider(option["value"]) 

352 for option in get_discovered_provider_options() 

353 } | {"none"} 

354 if provider not in valid_providers: 

355 # ``provider`` is a user-settable value (``llm.provider``) reaching a 

356 # log sink. The process-wide loguru patcher (``_sanitize_record`` in 

357 # utilities/log_utils.py, via ``redact_log_record``) already strips 

358 # control characters (\x00-\x1f, \x7f) from every record's message 

359 # and redacts credentials, so ``sanitize_for_log`` isn't newline/ANSI 

360 # defence here -- it adds the length cap plus the ``None`` guard 

361 # below. The exception text is a different audience (it never 

362 # reaches a client -- boundaries answer with 

363 # LLM_NOT_CONFIGURED_CLIENT_MESSAGE) and is left intact so 

364 # `match="Invalid provider: <name>"` assertions and the operator's 

365 # "which value did I actually set?" question survive. 

366 # ``str()`` first: ``normalize_provider`` returns None for an 

367 # empty/whitespace-only ``llm.provider``, and that None reaches here 

368 # (the ``if provider:`` gates above all skipped). ``sanitize_for_log`` 

369 # takes a str, so the bare call would turn this configuration error 

370 # into a TypeError. ``str(None)`` renders "None", exactly what the 

371 # previous f-string logged. 

372 logger.error( 

373 f"Invalid provider in settings: {sanitize_for_log(str(provider))}" 

374 ) 

375 raise LLMNotConfiguredError( 

376 f"Invalid provider: {provider}. " 

377 f"Must be one of: {sorted(valid_providers)}" 

378 ) 

379 

380 # Require an explicit model for built-in providers. Mirrors the 

381 # API-key-not-configured pattern in openai_base.py and the URL-not- 

382 # configured pattern in providers/implementations/ollama.py: no silent 

383 # substitution to a hardcoded default model. 

384 if not model_name or not model_name.strip(): 

385 logger.error("llm.model is not configured (empty/None after lookup)") 

386 raise LLMNotConfiguredError( 

387 "LLM model not configured. Please open Settings, choose an LLM " 

388 "provider, and select a model name (e.g. 'gpt-4o-mini' for " 

389 "OpenAI, 'claude-3-5-sonnet-20241022' for Anthropic, " 

390 "'llama3.1:8b' for Ollama). The 'llm.model' setting is required." 

391 ) 

392 logger.info( 

393 f"Getting LLM with model: {model_name}, temperature: {temperature}, provider: {provider}" 

394 ) 

395 

396 # Set context_limit on research_context for overflow detection. The 

397 # actual max_tokens calculation lives in each provider's create_llm 

398 # (via providers/_helpers.compute_max_tokens) so the cap is consistent 

399 # across the live registered-LLM path. 

400 context_window_size = _get_context_window_for_provider( 

401 provider, settings_snapshot 

402 ) 

403 if research_context and context_window_size: 403 ↛ 404line 403 didn't jump to line 404 because the condition on line 403 was never true

404 research_context["context_limit"] = context_window_size 

405 logger.info( 

406 f"Set context_limit={context_window_size} in research_context" 

407 ) 

408 else: 

409 logger.debug( 

410 f"Context limit not set: research_context={bool(research_context)}, " 

411 f"context_window_size={context_window_size}" 

412 ) 

413 

414 # Reaching here means the registered-LLM branch above did NOT fire, 

415 # which is unusual — auto-discovery normally registers all 6+ built-in 

416 # providers (anthropic, openai, openai_endpoint, ollama, lmstudio, 

417 # llamacpp + xai/ionos/openrouter via OpenAICompatibleProvider) at 

418 # import time. Two specific guards preserve the user-facing error 

419 # messages for known-bad cases. 

420 if provider == "none": 

421 raise LLMNotConfiguredError( 

422 "No LLM provider configured. Please set llm.provider in settings " 

423 "to a valid provider (e.g., 'ollama', 'openai', 'anthropic')." 

424 ) 

425 raise LLMNotConfiguredError( 

426 f"Provider '{provider}' was not registered by auto-discovery. " 

427 f"This usually indicates an import error during startup — check the " 

428 f"logs for 'Error loading provider from <module>' messages. " 

429 f"Note that clear_llm_registry()/unregister_llm() also remove " 

430 f"built-in providers; discover_providers(force_refresh=True) " 

431 f"restores them." 

432 ) 

433 

434 

435def _log_llm_error(error: Exception) -> None: 

436 """Log an LLM call failure with credential redaction.""" 

437 safe_msg = scrub_error(error) 

438 logger.warning(f"LLM Request - Failed with error: {safe_msg}") 

439 

440 

441def _merge_callbacks(existing, added, *, inherit: bool = True): 

442 """Return a callback manager holding *existing* plus *added*. 

443 

444 *existing* is never changed. It holds a call's callbacks: the ones its 

445 caller passed, or the ones it inherits from an enclosing run such as a 

446 LangGraph node. A caller can pass one handler list or 

447 ``BaseCallbackManager`` to many calls, and an enclosing run's manager 

448 serves all of its child runs, so adding to either in place could make 

449 those other calls fire this research's token callback too, writing 

450 their usage under this research's user and research id. 

451 

452 *existing* stays inheritable. With ``inherit=False`` the added handlers 

453 see only the run the result is passed to, the way a model's own 

454 ``callbacks`` field behaves, and child runs do not inherit them. 

455 

456 The result is a manager even when *existing* is a list. A 

457 ``RunnableBinding`` merges a call's callbacks with the ones it binds, 

458 which the call's callbacks can already hold (see 

459 ``_with_research_callbacks``). LangChain's ``merge_configs`` adds each 

460 handler once when either side is a manager, but joins two lists with 

461 every handler from both. 

462 """ 

463 added = list(added) 

464 if isinstance(existing, BaseCallbackManager): 

465 merged = existing.copy() 

466 if merged is existing: 466 ↛ 467line 466 didn't jump to line 467 because the condition on line 466 was never true

467 raise TypeError("A callback manager must support isolated copies") 

468 else: 

469 inherited = list(existing or []) 

470 merged = CallbackManager( 

471 handlers=inherited, inheritable_handlers=list(inherited) 

472 ) 

473 for handler in added: 

474 merged.add_handler(handler, inherit=inherit) 

475 return merged 

476 

477 

478def _config_receiver(runnable): 

479 """Return the runnable that receives the config passed to *runnable*. 

480 

481 LDR's rate-limiting wrapper hands each call's arguments, the config 

482 included, unchanged to the runnable it wraps. 

483 """ 

484 seen = set() 

485 while ( 

486 getattr(type(runnable), "_ldr_forwards_call_arguments", False) 

487 and id(runnable) not in seen 

488 ): 

489 seen.add(id(runnable)) 

490 runnable = runnable.base_llm 

491 return runnable 

492 

493 

494def _starts_the_model_run(runnable) -> bool: 

495 """Whether the config passed to *runnable* starts the model's own run. 

496 

497 A model starts its run with the config it is called with, and a 

498 ``RunnableBinding``, which ``bind``, ``bind_tools`` and ``with_config`` 

499 return for a standard model, passes its merged config on to the 

500 runnable it binds. Anything else is taken to start a run of its own 

501 and run the model as a child of that run, as the chain that 

502 ``with_structured_output`` returns does, or a chain like 

503 ``prompt | self`` that a custom ``bind_tools`` returns. 

504 """ 

505 while isinstance(runnable, RunnableBinding): 

506 runnable = runnable.bound 

507 return isinstance(runnable, BaseLanguageModel) 

508 

509 

510class ProcessingLLMWrapper: 

511 # Token callbacks of the research this wrapper serves, passed with each 

512 # call instead of stored on the model; see _with_research_callbacks. 

513 # The class default keeps lookups away from __getattr__, which would 

514 # otherwise ask the model. 

515 _ldr_research_callbacks: tuple = () 

516 

517 def __init__( 

518 self, 

519 base_llm, 

520 *, 

521 owns_llm: bool = True, 

522 research_id=None, 

523 provider=None, 

524 settings_snapshot=None, 

525 structured_output: bool = False, 

526 research_callbacks=(), 

527 ): 

528 self.base_llm = base_llm 

529 self._owns_llm = owns_llm 

530 self._ldr_research_callbacks = tuple(research_callbacks) 

531 # Cancellation scope: explicit id wins, otherwise the thread's 

532 # search context is consulted per call (see _effective_research_id). 

533 # Stored under both names — ``research_id`` for readability and 

534 # ``_ldr_research_id`` for the shared bind helper in 

535 # llm/cancellation.py (which propagates through nested wrappers). 

536 self.research_id = research_id 

537 self._ldr_research_id = research_id 

538 # Token-accounting gate: the stream-under-invoke path silently 

539 # converts every scoped invoke into a streaming request. For the 

540 # ``openai_endpoint`` provider that loses token/cost metrics unless 

541 # ``llm.openai_endpoint.stream_usage`` is enabled (custom gateways 

542 # omit usage on streams, or reject stream_options outright). 

543 # The provider + snapshot let _stream_cancel_allowed() make that 

544 # tradeoff a decision instead of an accident. 

545 self._provider = provider 

546 self._settings_snapshot = settings_snapshot 

547 # Structured-output contract: a wrapper created via 

548 # with_structured_output() wraps a Runnable whose invoke/​stream 

549 # yield dicts/Pydantic models, not AIMessageChunks. Reassembling 

550 # those through _combine_stream_chunks would corrupt them into an 

551 # AIMessage holding str(dict). Such wrappers must use the direct 

552 # invoke path (pre/post termination checks still apply). 

553 # A bound wrapper must not outlive its run: the id is resolved per 

554 # call so re-binding for the next run takes effect without 

555 # reconstructing the LLM, and a stale binding is only ever used 

556 # for a fail-fast/post-check, never to route into another run. 

557 self._ldr_structured_output = structured_output 

558 

559 @staticmethod 

560 def _normalize_response(response: Any) -> Any: 

561 """Strip <think> tags and normalize the response shape. 

562 

563 This is the SINGLE authorized place that strips <think> tags from a 

564 fresh LLM response. Every LLM from get_llm() is wrapped here, so 

565 downstream code can use ``response.content`` directly — do NOT add 

566 per-site ``remove_think_tags`` / ``get_llm_response_text`` calls on 

567 fresh ``invoke``/``ainvoke`` results; they are redundant and hide 

568 bugs. (Exceptions: ``.stream()``/``.astream()`` pass chunks on 

569 unstripped, Runnable transforms other than ``bind_tools``, 

570 ``with_structured_output``, ``bind`` and ``with_config`` still 

571 bypass this wrapper via ``__getattr__``, and injected/unwrapped LLMs 

572 are not wrapped at all — those may still need explicit handling. 

573 Those four are NOT exceptions: they re-wrap so the stripper survives 

574 — see the ``bind_tools`` override below and #4804.) 

575 

576 A message keeps its object identity (only ``.content`` is rewritten, 

577 so ``additional_kwargs``/``reasoning_content``/``tool_calls`` survive). 

578 A bare-string return (some providers/wrappers) is wrapped into an 

579 ``AIMessage`` so callers can always rely on ``.content``. Anything 

580 else is passed through unchanged. 

581 

582 Only *string* ``.content`` is stripped: ``remove_think_tags`` is 

583 text-only, and the ``<think>...</think>`` artifact is only ever 

584 emitted as plain text by some local models. Non-string content 

585 (e.g. provider content-block lists like Anthropic's, or ``None``) 

586 is passed through untouched — running the regex on it would raise 

587 ``TypeError`` or corrupt the structured content. 

588 """ 

589 if hasattr(response, "content"): 

590 if isinstance(response.content, str): 

591 response.content = remove_think_tags(response.content) 

592 elif isinstance(response, str): 

593 response = AIMessage(content=remove_think_tags(response)) 

594 return response 

595 

596 @staticmethod 

597 def _log_llm_error(error: Exception) -> None: 

598 _log_llm_error(error) 

599 

600 def _with_research_callbacks(self, args, kwargs): 

601 """Return the call arguments with this research's callbacks added. 

602 

603 The callbacks go into the call's ``RunnableConfig`` (positional 

604 argument two or ``config=``), so the model is invoked as it is and 

605 never stores them. Any model can serve other researches and users: 

606 a registered instance does, and so does a model that a registered 

607 factory returns more than once, such as a cached or singleton 

608 model. 

609 

610 Only the callbacks change: every other config key resolves as it 

611 does for the same call without this wrapper. The config passed on 

612 holds the caller's own keys rather than everything the call 

613 inherits, plus the callbacks the call would use anyway: the 

614 caller's, or those of an enclosing run such as a LangGraph node. 

615 A ``RunnableBinding``, such as the result of ``with_config``, lets 

616 its bound config win over an inherited one only when it is called 

617 without a config. For a binding, the config passed on is therefore 

618 its bound config already merged with the call's, as the binding 

619 merges them, so the binding merging its bound config in again gives 

620 the same values. 

621 

622 The handlers are not inheritable when that config starts the 

623 model's own run (see ``_starts_the_model_run``), which matches a 

624 model's own ``callbacks`` field: a run the model starts inside its 

625 own run is not recorded as well. When the config starts a chain 

626 instead, such as under ``with_structured_output`` or a custom 

627 ``bind_tools`` that returns ``prompt | self``, the model call is a 

628 child run, so the handlers are inheritable. If the model itself 

629 invokes another runnable during that call, the handlers see that 

630 inner run too and record its tokens as well. 

631 

632 Only the wrapper's own call methods add the callbacks: ``invoke``, 

633 ``ainvoke``, ``stream``, ``astream``, ``batch`` and ``abatch``. The 

634 wrappers that ``bind_tools``, ``with_structured_output``, ``bind`` 

635 and ``with_config`` return have the same methods and callbacks. A 

636 call that leaves the wrapper through ``__getattr__`` reaches the 

637 model without them, so its tokens are not recorded for the 

638 research. That covers the other Runnable transforms, such as 

639 ``with_retry`` and ``with_fallbacks``, and methods such as 

640 ``generate`` and ``astream_events``. 

641 """ 

642 if not self._ldr_research_callbacks: 

643 return args, kwargs 

644 positional = len(args) > 1 

645 explicit = args[1] if positional else kwargs.get("config") 

646 receiver = _config_receiver(self.base_llm) 

647 if isinstance(receiver, RunnableBinding): 

648 config = merge_configs(receiver.config, explicit) 

649 else: 

650 config = dict(explicit or {}) 

651 config["callbacks"] = _merge_callbacks( 

652 ensure_config(config).get("callbacks"), 

653 self._ldr_research_callbacks, 

654 inherit=not _starts_the_model_run(receiver), 

655 ) 

656 if positional: 

657 return (args[0], config, *args[2:]), kwargs 

658 return args, {**kwargs, "config": config} 

659 

660 def _effective_research_id(self): 

661 """Resolve the cancellation scope for this call. 

662 

663 Precedence: explicit ctor id > ``_ldr_research_id`` binding > 

664 current thread's search context. Consulted per call so a late 

665 ``bind_llm_to_research`` (or context propagation into pool 

666 threads) takes effect without reconstructing the LLM. 

667 """ 

668 from ..llm.cancellation import effective_research_id 

669 

670 return effective_research_id(self, self.research_id) 

671 

672 def _check_terminated(self, research_id=None): 

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

674 from ..llm.cancellation import raise_if_terminated 

675 

676 rid = ( 

677 research_id 

678 if research_id is not None 

679 else self._effective_research_id() 

680 ) 

681 if rid: 

682 raise_if_terminated(rid) 

683 

684 def _walk_base_chain(self): 

685 """Yield self.base_llm and each nested ``base_llm`` once. 

686 

687 Covers ``Processing -> RateLimited -> ChatModel`` without 

688 recursing (each level is a plain attribute read). Cycle-safe: 

689 a mis-wired test double that points at itself terminates. 

690 """ 

691 seen = set() 

692 cur = getattr(self, "base_llm", None) 

693 while cur is not None and id(cur) not in seen: 

694 seen.add(id(cur)) 

695 yield cur 

696 try: 

697 cur = getattr(cur, "base_llm", None) 

698 except Exception: 

699 break 

700 

701 def _underlying_stream_usage(self) -> bool: 

702 """True if any wrapped model opts into usage on streams. 

703 

704 Covers ``ChatOpenAI(stream_usage=True)`` and equivalents that 

705 stash ``stream_options.include_usage`` in ``model_kwargs``. 

706 """ 

707 for base in self._walk_base_chain(): 

708 try: 

709 if getattr(base, "stream_usage", None): 

710 return True 

711 mk = getattr(base, "model_kwargs", None) 

712 if isinstance(mk, dict): 712 ↛ 713line 712 didn't jump to line 713 because the condition on line 712 was never true

713 so = mk.get("stream_options", {}) 

714 if isinstance(so, dict) and so.get("include_usage"): 

715 return True 

716 except Exception: 

717 continue 

718 return False 

719 

720 def _underlying_disables_streaming(self) -> bool: 

721 """True if any wrapped model forbids streaming.""" 

722 for base in self._walk_base_chain(): 

723 try: 

724 if getattr(base, "disable_streaming", False): 

725 return True 

726 except Exception: 

727 continue 

728 return False 

729 

730 def _stream_cancel_allowed(self) -> bool: 

731 """Whether invoke may stream under the hood for cancellation. 

732 

733 Two gates, both conservative (False keeps the direct invoke path 

734 with pre/post termination checks, so Stop still aborts between 

735 calls — only mid-generation abort is lost): 

736 

737 1. Structured-output wrappers never stream: their chunks are 

738 dicts/Pydantic models, and reassembly would corrupt them into 

739 ``AIMessage(str(dict))``. 

740 2. ``openai_endpoint`` without usage-on-stream never streams: 

741 the reassembled message would carry no ``usage_metadata`` and 

742 token/cost metrics would silently go to zero. Opt in via the 

743 ``llm.openai_endpoint.stream_usage`` setting (or an underlying 

744 model built with ``stream_usage=True``). Other providers stream 

745 because their streamed chunks preserve usage via 

746 ``usage_metadata``/``response_metadata``. 

747 """ 

748 if getattr(self, "_ldr_structured_output", False): 

749 return False 

750 if self._underlying_disables_streaming(): 

751 return False 

752 provider = getattr(self, "_provider", None) 

753 try: 

754 from ..llm.providers.base import normalize_provider 

755 

756 provider = normalize_provider(provider) if provider else None 

757 except Exception: 

758 logger.debug( 

759 "Provider normalization failed; treating as unknown provider", 

760 exc_info=True, 

761 ) 

762 if provider == "openai_endpoint": 

763 try: 

764 from .thread_settings import get_setting_from_snapshot 

765 

766 snapshot = getattr(self, "_settings_snapshot", None) 

767 if get_setting_from_snapshot( 

768 "llm.openai_endpoint.stream_usage", 

769 False, 

770 settings_snapshot=snapshot, 

771 ): 

772 return True 

773 except SettingsValueError: 

774 raise 

775 except Exception: 

776 logger.debug( 

777 "stream_usage setting lookup failed; " 

778 "falling back to model capability", 

779 exc_info=True, 

780 ) 

781 # Model-level opt-in covers callers that built ChatOpenAI with 

782 # stream_usage=True but have no settings context on this thread. 

783 if self._underlying_stream_usage(): 

784 return True 

785 return False 

786 # Unknown provider but recognisably a ChatOpenAI-style model with 

787 # a custom base_url and no usage-on-stream: same zero-metrics trap 

788 # as openai_endpoint (e.g. thread-context fallback where the wrapper 

789 # was built without a provider label). Be conservative. 

790 for base in self._walk_base_chain(): 

791 try: 

792 if hasattr(base, "stream_usage") and hasattr( 792 ↛ 795line 792 didn't jump to line 795 because the condition on line 792 was never true

793 base, "openai_api_base" 

794 ): 

795 if getattr(base, "stream_usage", None): 

796 return True 

797 if getattr(base, "openai_api_base", None): 

798 return False 

799 # Default OpenAI endpoint without an explicit opt-in: 

800 # the validator enables stream_usage for the default 

801 # URL, so a falsy value here is unexpected — fall 

802 # through to the safe default below. 

803 return False 

804 except Exception: 

805 continue 

806 return True 

807 

808 @staticmethod 

809 def _is_structured_chunk(chunk: Any) -> bool: 

810 """True for structured-output chunks (dicts/Pydantic, no .content).""" 

811 if isinstance(chunk, dict): 

812 return True 

813 if isinstance(chunk, str): 813 ↛ 814line 813 didn't jump to line 814 because the condition on line 813 was never true

814 return False 

815 if hasattr(chunk, "content"): 815 ↛ 817line 815 didn't jump to line 817 because the condition on line 815 was always true

816 return False 

817 try: 

818 from pydantic import BaseModel as _PydanticBaseModel 

819 

820 if isinstance(chunk, _PydanticBaseModel): 

821 return True 

822 except Exception: 

823 logger.debug( 

824 "Pydantic structured-chunk check failed; treating as unstructured", 

825 exc_info=True, 

826 ) 

827 return False 

828 

829 @staticmethod 

830 def _combine_stream_chunks(chunks): 

831 """Combine streamed chunks into a single response object. 

832 

833 ``AIMessageChunk`` parts merge with ``+`` (preserves ``tool_calls``, 

834 ``additional_kwargs`` and content-block lists); the merged chunk is 

835 converted to a plain ``AIMessage`` so downstream code sees the same 

836 shape as ``invoke()``. Plain-string chunks (some test doubles) 

837 are joined into an ``AIMessage``. Returns ``None`` when *chunks* 

838 is empty (test doubles only — real models raise rather than yield 

839 zero chunks) so the caller can fall back to a direct ``invoke()``. 

840 

841 Structured-output chunks (dicts/Pydantic models without ``.content``) 

842 are never reassembled here: returning ``None`` lets the caller fall 

843 back to a direct ``invoke()`` that preserves the contract instead of 

844 corrupting the payload into ``AIMessage(str(dict))``. That fallback 

845 is a second upstream request for one logical call (token callbacks 

846 fire twice); callers log a warning when it happens after chunks 

847 were already consumed. 

848 """ 

849 if not chunks: 849 ↛ 850line 849 didn't jump to line 850 because the condition on line 849 was never true

850 return None 

851 for chunk in chunks: 

852 if isinstance(chunk, dict): 852 ↛ 853line 852 didn't jump to line 853 because the condition on line 852 was never true

853 return None 

854 if isinstance(chunk, str): 854 ↛ 855line 854 didn't jump to line 855 because the condition on line 854 was never true

855 continue 

856 if hasattr(chunk, "content"): 856 ↛ 860line 856 didn't jump to line 860 because the condition on line 856 was always true

857 continue 

858 # Pydantic models (and other parsed outputs) have no .content: 

859 # treat as structured, not as text to stringify. 

860 try: 

861 from pydantic import BaseModel as _PydanticBaseModel 

862 

863 if isinstance(chunk, _PydanticBaseModel): 

864 return None 

865 except Exception: 

866 logger.debug( 

867 "Pydantic structured-chunk check failed; continuing", 

868 exc_info=True, 

869 ) 

870 # Unknown non-message shape — only safe to stringify when it 

871 # is clearly a message-like object; otherwise fall back. 

872 if not hasattr(chunk, "tool_calls") and not hasattr( 

873 chunk, "additional_kwargs" 

874 ): 

875 return None 

876 try: 

877 # Fast path: message chunks support addition. 

878 combined = chunks[0] 

879 for chunk in chunks[1:]: 

880 try: 

881 combined = combined + chunk 

882 except Exception: 

883 # Mixed shapes — fall back to text join below. 

884 combined = None 

885 break 

886 if combined is not None and hasattr(combined, "content"): 886 ↛ 900line 886 didn't jump to line 900 because the condition on line 886 was always true

887 try: 

888 from langchain_core.messages.utils import ( 

889 message_chunk_to_message, 

890 ) 

891 

892 return message_chunk_to_message(combined) 

893 except Exception: 

894 return combined 

895 except Exception: 

896 logger.debug( 

897 "Stream-chunk combine via '+' failed; using text fallback" 

898 ) 

899 # Text fallback: join content/text of each chunk. 

900 from ..utilities.json_utils import _coerce_content_blocks 

901 

902 parts = [] 

903 for chunk in chunks: 

904 text = ( 

905 chunk 

906 if isinstance(chunk, str) 

907 else getattr(chunk, "content", None) 

908 ) 

909 if text is None: 

910 text = str(chunk) 

911 if isinstance(text, list): 

912 try: 

913 text = _coerce_content_blocks(text) 

914 except Exception: 

915 text = "".join(str(p) for p in text) 

916 if text: 

917 parts.append(str(text)) 

918 if not parts: 

919 return None 

920 return AIMessage(content="".join(parts)) 

921 

922 def _invoke_via_stream(self, *args: Any, **kwargs: Any): 

923 """Implement ``invoke`` as stream+reassemble with per-chunk cancel. 

924 

925 Returns the combined response, or ``None`` when streaming is 

926 unavailable (no ``stream`` attr), disallowed (structured-output or 

927 usage-unsafe endpoint — see :meth:`_stream_cancel_allowed`), or 

928 produced zero chunks (only empty test-double generators — real 

929 LangChain models raise inside the generator on failure rather 

930 than yielding zero chunks). The caller then falls back to a 

931 direct ``invoke()``. Raises ``ResearchTerminatedException`` 

932 promptly when the flag is set mid-generation; the generator is 

933 closed so the underlying HTTP response is released instead of 

934 leaking to GC. 

935 

936 Mid-stream structured fallback (a dict/Pydantic chunk after one 

937 or more message chunks were already pulled) also returns ``None``, 

938 so the caller issues a *second* upstream request for the same 

939 logical call — token callbacks fire for both attempts. No in-app 

940 path produces such mixed shapes today (structured-output wrappers 

941 are excluded up front via :meth:`_stream_cancel_allowed`), so this 

942 is defense-in-depth only; a warning is logged when it triggers so 

943 the double request is visible instead of silent. 

944 """ 

945 if getattr(self, "_ldr_structured_output", False): 945 ↛ 946line 945 didn't jump to line 946 because the condition on line 945 was never true

946 return None 

947 stream_fn = getattr(self.base_llm, "stream", None) 

948 if stream_fn is None: 948 ↛ 949line 948 didn't jump to line 949 because the condition on line 948 was never true

949 return None 

950 research_id = self._effective_research_id() 

951 try: 

952 gen = stream_fn(*args, **kwargs) 

953 except Exception: 

954 logger.debug( 

955 "Sync stream creation failed; falling back to direct invoke", 

956 exc_info=True, 

957 ) 

958 return None 

959 chunks = [] 

960 try: 

961 for chunk in gen: 

962 if research_id: 962 ↛ 967line 962 didn't jump to line 967 because the condition on line 962 was always true

963 self._check_terminated(research_id) 

964 # Structured-output runnables yield dicts/models: abort the 

965 # streaming attempt and fall back to direct invoke rather 

966 # than corrupting the contract. 

967 if self._is_structured_chunk(chunk): 

968 # NOTE: package logging is disabled by default 

969 # (local_deep_research/__init__.py); this surfaces 

970 # once the app enables it at startup. 

971 logger.warning( 

972 "Sync stream yielded structured chunk after " 

973 f"{len(chunks)} message chunk(s); falling back " 

974 "to direct invoke issues a second upstream " 

975 "request for one logical call (token callbacks " 

976 "fire twice)" 

977 ) 

978 try: 

979 close = getattr(gen, "close", None) 

980 if close is not None: 980 ↛ 987line 980 didn't jump to line 987 because the condition on line 980 was always true

981 close() 

982 except Exception: 

983 logger.debug( 

984 "Sync stream generator close failed " 

985 "(structured fallback, non-critical)" 

986 ) 

987 return None 

988 chunks.append(chunk) 

989 except BaseException: 

990 # On abort (or any mid-stream failure) release the HTTP 

991 # response promptly. ``GeneratorExit`` from close() surfaces 

992 # here for some providers — never mask the original raise. 

993 try: 

994 close = getattr(gen, "close", None) 

995 if close is not None: 995 ↛ 1001line 995 didn't jump to line 1001 because the condition on line 995 was always true

996 close() 

997 except Exception: 

998 logger.debug( 

999 "Sync stream generator close failed on abort (non-critical)" 

1000 ) 

1001 raise 

1002 else: 

1003 try: 

1004 close = getattr(gen, "close", None) 

1005 if close is not None: 1005 ↛ 1011line 1005 didn't jump to line 1011 because the condition on line 1005 was always true

1006 close() 

1007 except Exception: 

1008 logger.debug( 

1009 "Sync stream generator close failed (non-critical)" 

1010 ) 

1011 if not chunks: 

1012 # Only reachable with empty test-double generators; real 

1013 # LangChain models raise inside the generator on failure. 

1014 return None 

1015 combined = self._combine_stream_chunks(chunks) 

1016 if combined is None: 1016 ↛ 1020line 1016 didn't jump to line 1020 because the condition on line 1016 was never true

1017 # Chunks were consumed but not reassemblable (structured or 

1018 # unknown shapes): the caller's direct-invoke fallback is a 

1019 # second upstream request — see the docstring above. 

1020 logger.warning( 

1021 "Sync stream produced " 

1022 f"{len(chunks)} non-reassemblable chunk(s); falling back " 

1023 "to direct invoke issues a second upstream request for " 

1024 "one logical call (token callbacks fire twice)" 

1025 ) 

1026 return combined 

1027 

1028 async def _ainvoke_via_stream(self, *args: Any, **kwargs: Any): 

1029 """Async twin of :meth:`_invoke_via_stream` (via ``astream``). 

1030 

1031 Same fallback contract: zero chunks (test doubles only — real 

1032 models raise) and mid-stream structured shapes return ``None``; 

1033 a structured shape after pulled chunks means the direct-ainvoke 

1034 fallback is a second upstream request (warning logged). 

1035 """ 

1036 if getattr(self, "_ldr_structured_output", False): 1036 ↛ 1037line 1036 didn't jump to line 1037 because the condition on line 1036 was never true

1037 return None 

1038 astream_fn = getattr(self.base_llm, "astream", None) 

1039 if astream_fn is None: 1039 ↛ 1040line 1039 didn't jump to line 1040 because the condition on line 1039 was never true

1040 return None 

1041 research_id = self._effective_research_id() 

1042 chunks = [] 

1043 # ``astream`` is an async-generator function: calling it must not 

1044 # raise for a missing implementation (that is what the None check 

1045 # above covers); iteration errors propagate to the caller. 

1046 try: 

1047 agen = astream_fn(*args, **kwargs) 

1048 except Exception: 

1049 logger.debug( 

1050 "Async stream creation failed; falling back to direct ainvoke", 

1051 exc_info=True, 

1052 ) 

1053 return None 

1054 try: 

1055 async for chunk in agen: 

1056 if research_id: 1056 ↛ 1058line 1056 didn't jump to line 1058 because the condition on line 1056 was always true

1057 self._check_terminated(research_id) 

1058 if self._is_structured_chunk(chunk): 

1059 logger.warning( 

1060 "Async stream yielded structured chunk after " 

1061 f"{len(chunks)} message chunk(s); falling back " 

1062 "to direct ainvoke issues a second upstream " 

1063 "request for one logical call (token callbacks " 

1064 "fire twice)" 

1065 ) 

1066 try: 

1067 aclose = getattr(agen, "aclose", None) 

1068 if aclose is not None: 1068 ↛ 1075line 1068 didn't jump to line 1075 because the condition on line 1068 was always true

1069 await aclose() 

1070 except Exception: 

1071 logger.debug( 

1072 "Async stream generator close failed " 

1073 "(structured fallback, non-critical)" 

1074 ) 

1075 return None 

1076 chunks.append(chunk) 

1077 except BaseException: 

1078 try: 

1079 aclose = getattr(agen, "aclose", None) 

1080 if aclose is not None: 1080 ↛ 1083line 1080 didn't jump to line 1083 because the condition on line 1080 was always true

1081 await aclose() 

1082 else: 

1083 close = getattr(agen, "close", None) 

1084 if close is not None: 

1085 close() 

1086 except Exception: 

1087 logger.debug( 

1088 "Async stream generator close failed on abort (non-critical)" 

1089 ) 

1090 raise 

1091 else: 

1092 try: 

1093 aclose = getattr(agen, "aclose", None) 

1094 if aclose is not None: 1094 ↛ 1100line 1094 didn't jump to line 1100 because the condition on line 1094 was always true

1095 await aclose() 

1096 except Exception: 

1097 logger.debug( 

1098 "Async stream generator close failed (non-critical)" 

1099 ) 

1100 if not chunks: 1100 ↛ 1103line 1100 didn't jump to line 1103 because the condition on line 1100 was never true

1101 # Only reachable with empty test-double generators; real 

1102 # LangChain models raise inside the generator on failure. 

1103 return None 

1104 combined = self._combine_stream_chunks(chunks) 

1105 if combined is None: 1105 ↛ 1106line 1105 didn't jump to line 1106 because the condition on line 1105 was never true

1106 logger.warning( 

1107 "Async stream produced " 

1108 f"{len(chunks)} non-reassemblable chunk(s); falling back " 

1109 "to direct ainvoke issues a second upstream request for " 

1110 "one logical call (token callbacks fire twice)" 

1111 ) 

1112 return combined 

1113 

1114 def invoke(self, *args: Any, **kwargs: Any) -> Any: 

1115 # Fail fast if Stop arrived while queued (e.g. between 

1116 # check_termination checkpoints) — no new HTTP request. 

1117 self._check_terminated() 

1118 try: 

1119 args, kwargs = self._with_research_callbacks(args, kwargs) 

1120 # When a cancellation scope is known, stream under the hood 

1121 # so a Stop mid-generation aborts within one chunk instead 

1122 # of waiting for the full response (minutes on slow local 

1123 # models). Without a scope there is nothing to check 

1124 # between chunks, so keep the direct path. The streaming 

1125 # path is additionally gated by _stream_cancel_allowed(): 

1126 # structured-output wrappers and usage-unsafe endpoints 

1127 # (openai_endpoint without stream_usage) stay on direct 

1128 # invoke so token/cost metrics and the dict/Pydantic contract 

1129 # survive — pre/post checks still abort promptly between calls. 

1130 if ( 

1131 self._effective_research_id() is not None 

1132 and self._stream_cancel_allowed() 

1133 ): 

1134 streamed = self._invoke_via_stream(*args, **kwargs) 

1135 if streamed is not None: 

1136 self._check_terminated() 

1137 return self._normalize_response(streamed) 

1138 response = self.base_llm.invoke(*args, **kwargs) 

1139 except Exception as e: 

1140 self._log_llm_error(e) 

1141 raise 

1142 # Post-check: the flag may have been set while blocked. Raise 

1143 # so the worker aborts before the next pipeline step instead of 

1144 # continuing with a stale result. 

1145 self._check_terminated() 

1146 return self._normalize_response(response) 

1147 

1148 async def ainvoke(self, *args: Any, **kwargs: Any) -> Any: 

1149 # Async counterpart of invoke(); without this, ainvoke() would fall 

1150 # through __getattr__ to the base LLM and bypass think-tag stripping. 

1151 self._check_terminated() 

1152 try: 

1153 args, kwargs = self._with_research_callbacks(args, kwargs) 

1154 if ( 

1155 self._effective_research_id() is not None 

1156 and self._stream_cancel_allowed() 

1157 ): 

1158 streamed = await self._ainvoke_via_stream(*args, **kwargs) 

1159 if streamed is not None: 

1160 self._check_terminated() 

1161 return self._normalize_response(streamed) 

1162 response = await self.base_llm.ainvoke(*args, **kwargs) 

1163 except Exception as e: 

1164 self._log_llm_error(e) 

1165 raise 

1166 self._check_terminated() 

1167 return self._normalize_response(response) 

1168 

1169 def stream(self, *args: Any, **kwargs: Any): 

1170 """Yield chunks with per-chunk termination checks. 

1171 

1172 Previously ``stream`` fell through ``__getattr__`` to the raw 

1173 model, bypassing both think-tag normalization and cancellation. 

1174 Now the generator checks the flag before yielding each chunk and 

1175 raises ``ResearchTerminatedException`` promptly on Stop. Chunks 

1176 are forwarded untouched (no per-chunk scrubbing — credentials 

1177 can straddle boundaries). 

1178 """ 

1179 research_id = self._effective_research_id() 

1180 if research_id: 

1181 self._check_terminated(research_id) 

1182 args, kwargs = self._with_research_callbacks(args, kwargs) 

1183 gen = None 

1184 try: 

1185 gen = self.base_llm.stream(*args, **kwargs) 

1186 for chunk in gen: 

1187 if research_id: 

1188 self._check_terminated(research_id) 

1189 yield chunk 

1190 except Exception as error: 

1191 log_exception_type( 

1192 "LLM Request - Failed with error in stream()", error 

1193 ) 

1194 raise 

1195 finally: 

1196 try: 

1197 close = getattr(gen, "close", None) 

1198 if close is not None: 1198 ↛ exitline 1198 didn't return from function 'stream' because the condition on line 1198 was always true

1199 close() 

1200 except Exception: 

1201 logger.debug( 

1202 "Sync stream generator close failed in stream() (non-critical)" 

1203 ) 

1204 

1205 async def astream(self, *args: Any, **kwargs: Any): 

1206 """Async generator twin of :meth:`stream` with the same checks.""" 

1207 research_id = self._effective_research_id() 

1208 if research_id: 

1209 self._check_terminated(research_id) 

1210 args, kwargs = self._with_research_callbacks(args, kwargs) 

1211 agen = None 

1212 try: 

1213 agen = self.base_llm.astream(*args, **kwargs) 

1214 async for chunk in agen: 

1215 if research_id: 

1216 self._check_terminated(research_id) 

1217 yield chunk 

1218 except Exception as error: 

1219 log_exception_type( 

1220 "LLM Request - Failed with error in astream()", error 

1221 ) 

1222 raise 

1223 finally: 

1224 try: 

1225 aclose = getattr(agen, "aclose", None) 

1226 if aclose is not None: 1226 ↛ exitline 1226 didn't return from function 'astream' because the condition on line 1226 was always true

1227 await aclose() 

1228 except Exception: 

1229 logger.debug( 

1230 "Async stream generator close failed in astream() (non-critical)" 

1231 ) 

1232 

1233 # Pass through any other attributes to the base LLM 

1234 def __getattr__(self, name): 

1235 return getattr(self.base_llm, name) 

1236 

1237 def batch( 

1238 self, inputs, config=None, *, return_exceptions=False, **kwargs: Any 

1239 ): 

1240 """Batch via :meth:`invoke` so per-item cancellation applies.""" 

1241 results = [] 

1242 # Resolve shared vs per-input configs like LangChain does. 

1243 if isinstance(config, list): 1243 ↛ 1244line 1243 didn't jump to line 1244 because the condition on line 1243 was never true

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

1245 raise ValueError( 

1246 f"configs must match the number of inputs ({len(config)} != {len(inputs)})" 

1247 ) 

1248 configs = list(config) 

1249 else: 

1250 configs = [config] * len(inputs) 

1251 for single_input, single_config in zip(inputs, configs): 

1252 try: 

1253 if single_config is not None: 1253 ↛ 1254line 1253 didn't jump to line 1254 because the condition on line 1253 was never true

1254 results.append( 

1255 self.invoke(single_input, single_config, **kwargs) 

1256 ) 

1257 else: 

1258 results.append(self.invoke(single_input, **kwargs)) 

1259 except Exception as e: 

1260 if return_exceptions: 

1261 results.append(e) 

1262 else: 

1263 raise 

1264 # ResearchTerminatedException (BaseException) propagates even with 

1265 # return_exceptions=True — a Stop must never be swallowed into a 

1266 # result list. 

1267 return results 

1268 

1269 async def abatch( 

1270 self, inputs, config=None, *, return_exceptions=False, **kwargs: Any 

1271 ): 

1272 """Async twin of :meth:`batch`.""" 

1273 results = [] 

1274 if isinstance(config, list): 1274 ↛ 1275line 1274 didn't jump to line 1275 because the condition on line 1274 was never true

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

1276 raise ValueError( 

1277 f"configs must match the number of inputs ({len(config)} != {len(inputs)})" 

1278 ) 

1279 configs = list(config) 

1280 else: 

1281 configs = [config] * len(inputs) 

1282 for single_input, single_config in zip(inputs, configs): 1282 ↛ 1297line 1282 didn't jump to line 1297 because the loop on line 1282 didn't complete

1283 try: 

1284 if single_config is not None: 1284 ↛ 1285line 1284 didn't jump to line 1285 because the condition on line 1284 was never true

1285 results.append( 

1286 await self.ainvoke( 

1287 single_input, single_config, **kwargs 

1288 ) 

1289 ) 

1290 else: 

1291 results.append(await self.ainvoke(single_input, **kwargs)) 

1292 except Exception as e: 

1293 if return_exceptions: 

1294 results.append(e) 

1295 else: 

1296 raise 

1297 return results 

1298 

1299 def close(self): 

1300 """Close the model's clients if this wrapper owns the model. 

1301 

1302 ``get_llm()`` gives a wrapper ownership only of a model that a 

1303 built-in provider built for it. A registered instance, or a model 

1304 that a registered factory returns, stays with the code that 

1305 registered it, since other researches can be using it. 

1306 """ 

1307 if not self._owns_llm: 

1308 return 

1309 try: 

1310 from ..utilities.llm_utils import _close_base_llm 

1311 

1312 _close_base_llm(self.base_llm) 

1313 except Exception: 

1314 logger.debug( 

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

1316 exc_info=True, 

1317 ) 

1318 

1319 def bind_tools(self, tools, **kwargs: Any) -> Any: 

1320 """Re-wrap the tool-bound model so the <think>-stripper survives. 

1321 

1322 ``langchain.agents.create_agent()`` and similar helpers call 

1323 ``model.bind_tools(...)`` and then use the *return value* as 

1324 the model that actually runs inside the agent loop. Without 

1325 this override, Python falls through ``__getattr__`` to 

1326 ``self.base_llm.bind_tools(...)``, which returns an unwrapped 

1327 ``BaseChatModel``; the wrapper's ``_normalize_response`` (the 

1328 single authorized site that strips ``<think>…`` from fresh 

1329 responses) is then bypassed for every LLM call the agent 

1330 makes. 

1331 

1332 That bypass is the root cause of the regression tracked in 

1333 issue #4804: reasoning-mode models (Qwen 3.x, deepseek-r1, 

1334 etc.) emit ``<think>…</think>`` as plain text, the wrapper 

1335 fails to strip it, the raw artifact is appended to the 

1336 agent's ``AIMessage.content``, and on the next turn the 

1337 provider's strict parser rejects the request with 

1338 ``Failed to parse input at pos 0: <think>…``. 

1339 

1340 Re-wrapping the bound model keeps the stripper in the loop 

1341 for every LLM call the agent makes, not just the ones we 

1342 issue directly via ``self.model.invoke(...)``. 

1343 

1344 Args: 

1345 tools: Tool definitions (schemas, callables, or 

1346 ``BaseTool`` instances) to bind to the model. 

1347 **kwargs: Provider-specific binding options forwarded 

1348 verbatim to ``base_llm.bind_tools``. 

1349 

1350 Returns: 

1351 A ``ProcessingLLMWrapper`` wrapping the bound model, so 

1352 every subsequent ``invoke``/``ainvoke`` continues to 

1353 strip ``<think>`` tags. 

1354 """ 

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

1356 return self._rewrap(bound, structured_output=False) 

1357 

1358 def bind(self, **kwargs: Any) -> Any: 

1359 """Re-wrap like :meth:`bind_tools`. 

1360 

1361 ``langchain.agents.create_agent()`` calls ``model.bind(...)`` for an 

1362 agent without tools and runs the result. Without this override the 

1363 call would fall through ``__getattr__`` to the model, and the bound 

1364 model would skip the ``<think>`` stripping, the termination checks 

1365 and this research's token callbacks. Like this wrapper, the result 

1366 cannot be composed with ``|``. 

1367 """ 

1368 return self._rewrap(self.base_llm.bind(**kwargs)) 

1369 

1370 def with_config(self, config=None, **kwargs: Any) -> Any: 

1371 """Re-wrap like :meth:`bind`. 

1372 

1373 The bound config resolves with each call's config as it does in 

1374 LangChain: when the call passes no config, its keys win over those 

1375 inherited from an enclosing run. This research's token callbacks 

1376 run beside any callbacks it binds. 

1377 """ 

1378 return self._rewrap(self.base_llm.with_config(config, **kwargs)) 

1379 

1380 def _rewrap(self, runnable, *, structured_output=None): 

1381 """Wrap *runnable*, derived from this wrapper's model, the same way. 

1382 

1383 The new wrapper keeps this wrapper's ownership, cancellation scope, 

1384 provider, settings and research callbacks. It is a structured-output 

1385 wrapper if this one is, unless *structured_output* says otherwise. 

1386 """ 

1387 if structured_output is None: 

1388 structured_output = self._ldr_structured_output 

1389 return ProcessingLLMWrapper( 

1390 runnable, 

1391 owns_llm=self._owns_llm, 

1392 research_id=self._effective_research_id(), 

1393 provider=getattr(self, "_provider", None), 

1394 settings_snapshot=getattr(self, "_settings_snapshot", None), 

1395 structured_output=structured_output, 

1396 research_callbacks=self._ldr_research_callbacks, 

1397 ) 

1398 

1399 def with_structured_output(self, schema, **kwargs: Any) -> Any: 

1400 """Re-wrap like :meth:`bind_tools` so cancellation survives. 

1401 

1402 Structured-output runnables are used by several strategies for 

1403 JSON extraction; without re-wrapping, the bound runnable would 

1404 bypass the termination checks in ``invoke``. The re-wrapped 

1405 instance is marked ``structured_output`` so ``invoke``/``ainvoke`` 

1406 use the direct path (pre/post checks) instead of stream+reassemble, 

1407 preserving the dict/Pydantic contract. 

1408 """ 

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

1410 try: 

1411 from ..llm.cancellation import bind_llm_to_research 

1412 

1413 bind_llm_to_research(bound, self._effective_research_id()) 

1414 except Exception: 

1415 logger.debug( 

1416 "Structured-output scope propagation failed (non-critical)" 

1417 ) 

1418 return self._rewrap(bound, structured_output=True) 

1419 

1420 

1421def wrap_llm_without_think_tags( 

1422 llm, 

1423 research_id=None, 

1424 provider=None, 

1425 research_context=None, 

1426 settings_snapshot=None, 

1427 *, 

1428 owns_llm: bool = True, 

1429): 

1430 """Wrap response processing and metrics with explicit resource ownership. 

1431 

1432 Set ``owns_llm=False`` for a model the wrapper must not close. 

1433 ``get_llm()`` keeps ``True`` only for a model that a built-in provider 

1434 built for this call, and passes ``False`` for a registered instance and 

1435 for any model a registered factory returns: a factory can return the 

1436 same cached or singleton model on every call, and closing it when one 

1437 research ends would break the others. Whoever registered such a model 

1438 remains responsible for closing it after all consumers are done. 

1439 

1440 Ownership only decides who closes the model. Whether it owns the model 

1441 or not, this function writes nothing about the research onto the 

1442 model: the research's token callback is passed with each call, and the 

1443 cancellation scope is bound to LDR's wrappers only. 

1444 """ 

1445 

1446 # The model before any LDR wrapper. No research state lands on it or 

1447 # on anything it holds. 

1448 model = llm 

1449 

1450 # First apply rate limiting if enabled 

1451 from ..web_search_engines.rate_limiting.llm import ( 

1452 create_rate_limited_llm_wrapper, 

1453 ) 

1454 

1455 # Check if LLM rate limiting is enabled (independent of search rate limiting) 

1456 # Use the thread-safe get_db_setting defined in this module 

1457 if get_setting_from_snapshot( 

1458 "rate_limiting.llm_enabled", False, settings_snapshot=settings_snapshot 

1459 ): 

1460 llm = create_rate_limited_llm_wrapper(model, provider) 

1461 

1462 # Set context_limit in research_context for overflow detection. 

1463 # This is needed for providers that go through the registered provider path 

1464 # (which returns before the code in get_llm that sets context_limit). 

1465 if research_context is not None and provider is not None: 

1466 if "context_limit" not in research_context: 

1467 context_limit = _get_context_window_for_provider( 

1468 provider, settings_snapshot 

1469 ) 

1470 if context_limit is not None: 

1471 research_context["context_limit"] = context_limit 

1472 logger.info( 

1473 f"Set context_limit={context_limit} in wrap_llm for provider={provider}" 

1474 ) 

1475 

1476 # Import token counting functionality if research_id is provided 

1477 callbacks = [] 

1478 if research_id is not None: 

1479 from ..metrics import TokenCounter 

1480 

1481 token_counter = TokenCounter() 

1482 token_callback = token_counter.create_callback( 

1483 research_id, research_context 

1484 ) 

1485 # Set provider and model info on the callback 

1486 if provider: 

1487 token_callback.preset_provider = provider 

1488 # Try to extract model name from the LLM instance 

1489 if hasattr(model, "model_name"): 

1490 token_callback.preset_model = model.model_name 

1491 elif hasattr(model, "model"): 

1492 token_callback.preset_model = model.model 

1493 callbacks.append(token_callback) 

1494 

1495 wrapped = ProcessingLLMWrapper( 

1496 llm, 

1497 owns_llm=owns_llm, 

1498 research_id=research_id, 

1499 provider=provider, 

1500 settings_snapshot=settings_snapshot, 

1501 # The model can serve other researches and users, owned or not, so 

1502 # this research's callbacks travel in each call's RunnableConfig. 

1503 # The rate-limiting wrapper passes that config on to the model. 

1504 research_callbacks=callbacks, 

1505 ) 

1506 if research_id is not None: 

1507 # Propagate the scope to inner wrappers (rate-limiting layer) 

1508 # so direct uses of the inner instance also abort promptly. The 

1509 # walk stops at the model: it can be shared, and any model can 

1510 # hold shared objects under ``base_llm``. 

1511 try: 

1512 from ..llm.cancellation import bind_llm_to_research 

1513 

1514 bind_llm_to_research(wrapped, research_id, stop_at=model) 

1515 except Exception: 

1516 logger.debug( 

1517 "Inner-wrapper scope propagation failed (non-critical)" 

1518 ) 

1519 return wrapped