Coverage for src/local_deep_research/web/routers/api_v1.py: 97%

264 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1""" 

2REST API v1 router for Local Deep Research. 

3 

4Ports Flask's api.py blueprint to FastAPI APIRouter. 

5Provides health check and programmatic research endpoints. 

6""" 

7 

8import inspect 

9import os 

10import threading 

11import time 

12from typing import Any, Dict, Optional, Annotated 

13 

14try: 

15 import resource as _resource_mod 

16except ImportError: 

17 _resource_mod = None # Windows: no POSIX resource limits 

18 

19from fastapi import APIRouter, Depends, HTTPException, Request 

20from fastapi.responses import JSONResponse 

21from loguru import logger 

22 

23from ...api.research_functions import analyze_documents 

24from ...database.session_context import get_user_db_session 

25from ...security.log_sanitizer import sanitize_error_for_client 

26from ...utilities.db_utils import get_settings_manager 

27from ..dependencies.auth import get_session_username, require_auth 

28from ..dependencies.rate_limit import ( 

29 API_RATE_LIMIT_DEFAULT, 

30 api_rate_limit, 

31 set_request_api_rate_limit, 

32) 

33from ..dependencies.threadpool import run_db_sync 

34 

35router = APIRouter(prefix="/api/v1", tags=["API v1"]) 

36 

37# Match the largest strategy-layer cap (_TOOL_ERROR_MAX_LEN = 500 in 

38# langgraph_agent_strategy.py) at the HTTP boundary so a message already 

39# scrubbed at the strategy layer is never re-truncated here, and 

40# categorizable exception tokens (e.g. "Connection refused" sitting deep in a 

41# long error) survive to the API client. The 200-char default of 

42# sanitize_error_for_client would truncate that signal prematurely. 

43_ERROR_BOUNDARY_MAX_LEN = 500 

44 

45 

46def _scrub_error_fields(results: Dict[str, Any]) -> None: 

47 """In-place defense-in-depth scrub for exception-derived fields about to 

48 leave the API (CWE-209, CodeQL #8019). 

49 

50 Strategy-layer ``_scrub_tool_error``/``sanitize_error_for_client`` already 

51 wraps exception text at the source; this is the final HTTP boundary. Only 

52 fires on fields that start with the literal ``"Error:"`` marker so 

53 legitimate research prose is never touched, and truncation is from the tail 

54 so the marker survives the scrub. 

55 

56 Field names: strategies emit error text into ``current_knowledge``, but 

57 ``quick_summary()`` (research_functions.py) returns that value under the key 

58 ``summary`` — and ``analyze_documents()`` uses ``summary`` as well — so both 

59 spellings are scrubbed here. Ports the Flask fix from #5032. 

60 """ 

61 if not isinstance(results, dict): 

62 return 

63 for field in ("current_knowledge", "summary", "formatted_findings"): 

64 value = results.get(field) 

65 if isinstance(value, str) and value.startswith("Error:"): 

66 results[field] = sanitize_error_for_client( 

67 value, max_length=_ERROR_BOUNDARY_MAX_LEN 

68 ) 

69 for finding in results.get("findings", []): 

70 content = finding.get("content") 

71 if isinstance(content, str) and content.startswith("Error:"): 

72 finding["content"] = sanitize_error_for_client( 

73 content, max_length=_ERROR_BOUNDARY_MAX_LEN 

74 ) 

75 

76 

77# Body params /analyze_documents accepts beyond the positional 

78# query/collection_name. Derived from the real signature so the two can't 

79# drift: analyze_documents (unlike quick_summary/generate_report) has no 

80# **kwargs, so an unknown key would TypeError at call time — surfacing as 

81# an opaque 500. Validating up front turns that into a clear 400. 

82# username/settings_snapshot are excluded because they are server-set by 

83# _load_user_context_into_params (and overwritten if a body supplied 

84# them). 

85# 

86# ``programmatic_mode`` is ALSO subtracted here, even though it is a 

87# genuine declared parameter of analyze_documents (unlike 

88# username/settings_snapshot it would otherwise survive the allowlist 

89# derivation untouched). It is the same identity/audit-plumbing knob 

90# documented at length on ``_IDENTITY_PLUMBING_PARAMS`` below for 

91# quick_summary/generate_report: ``_load_user_context_into_params`` sets 

92# ``params.setdefault("programmatic_mode", False)`` specifically so 

93# authenticated REST calls default to DB-backed metrics/rate-limit 

94# persistence, and ``setdefault`` means an explicit body value is 

95# respected rather than overridden — so leaving it in this 

96# signature-derived allowlist would let a caller opt their own 

97# analyze_documents calls out of the audit trail and DB-backed 

98# rate-limit accounting, the identical gap closed for quick_summary/ 

99# generate_report via ``_REJECTED_BODY_PARAMS``. Rejecting it here (via 

100# subtraction, since analyze_documents' allowlist is derive-then-permit 

101# rather than the reject-list ``_reject_unsafe_body_params`` uses) closes 

102# it on this third endpoint too. 

103# 

104# ``output_file`` is ALSO subtracted here for the identical reason it is 

105# rejected on quick_summary/generate_report (see ``_SERVER_PATH_PARAMS`` 

106# below): analyze_documents (research_functions.py) reaches the same 

107# ``write_file_verified`` -> bare ``open(filepath, mode)`` sink — with no 

108# containment, traversal, or symlink check — after its own full 

109# search-and-LLM run, behind the same unset-by-default 

110# ``api.allow_file_output`` gate, and the same opaque catch-all 500 when 

111# that gate is (correctly) closed. A server-side filesystem path posted in 

112# a public JSON body is no more REST-shaped here than it is on the other 

113# two endpoints; there is no substantive reason for this third endpoint to 

114# disagree. Found and closed in the same post-merge review pass that added 

115# ``_SERVER_PATH_PARAMS`` — an earlier version of this module reported the 

116# gap here without closing it ("separately-scoped... out of scope"); that 

117# scoping call was wrong, since the fix is the same one-token subtraction 

118# already used for ``programmatic_mode`` right above. 

119# 

120# ``research_id``/``research_context`` are not declared parameters of 

121# analyze_documents at all (checked against the signature below), so 

122# there is nothing else in ``_IDENTITY_PLUMBING_PARAMS`` to subtract 

123# here today — but any future analyze_documents parameter matching one of 

124# those names should be audited the same way before being allowed 

125# through. 

126_ANALYZE_DOCUMENTS_PARAMS = ( 

127 frozenset(inspect.signature(analyze_documents).parameters) 

128 - {"query", "collection_name", "username", "settings_snapshot"} 

129 - { 

130 "research_id", 

131 "programmatic_mode", 

132 "research_context", 

133 "output_file", 

134 } 

135) 

136 

137 

138# Request-body keys that quick_summary/generate_report accept as declared 

139# Python parameters but must NEVER be forwarded from the HTTP path. 

140# ``retrievers``/``llms`` are registered into the retriever registry 

141# (web_search_engines/retriever_registry.py) and the LLM registry 

142# (llm/llm_registry.py) the moment they reach the research function. 

143# 

144# This is NOT a cross-user problem. Both registries are namespaced per user 

145# (#5539), and the ``username`` used at those registration sites is 

146# server-set from the authenticated session (``require_api_access`` / 

147# ``_load_user_context_into_params`` below) — never read from the request 

148# body. A value posted here can therefore only land in the poster's OWN 

149# namespace, and reads resolve own-namespace-first without ever surfacing 

150# another user's entries. 

151# 

152# What rejecting these keys prevents is a self-inflicted type confusion. A 

153# JSON body can only carry scalars, never a live BaseRetriever / 

154# BaseChatModel, so every value that can arrive here is the wrong type: a 

155# string registers successfully and then fails later, when something tries 

156# to use it as a retriever; a dict raises ValueError out of 

157# register_multiple, which the endpoint's catch-all turns into an opaque 

158# 500. Either way the bad entry persists in that user's namespace for the 

159# process lifetime, so the user's own later requests keep tripping over it. 

160# A 400 naming the offending key is the honest answer. 

161# 

162# It is also a regression guard: if the per-user namespacing were ever 

163# removed or bypassed, unvalidated body values would again reach a shared 

164# registry. Filtering at the HTTP boundary keeps that regression 

165# unreachable from the public API, and costs nothing, because no legitimate 

166# caller can express these objects in JSON to begin with. 

167# 

168# These must be rejected EXPLICITLY, not via the _ANALYZE_DOCUMENTS_PARAMS 

169# signature-derived-allowlist pattern: that pattern works there only 

170# because analyze_documents has no ``retrievers``/``llms`` parameter; 

171# reusing that derivation here would ADMIT them, since they ARE declared 

172# parameters of quick_summary/generate_report. 

173# 

174# Only the HTTP body path is filtered: in-process Python-SDK / internal 

175# callers still pass ``retrievers=``/``llms=`` directly (with real objects) 

176# and are unaffected. 

177_REGISTRY_PARAMS = frozenset({"retrievers", "llms"}) 

178 

179 

180# ``progress_callback`` is Callable-typed on ``generate_report``'s own 

181# signature, and reaches ``_init_search_system`` from ``quick_summary`` too 

182# via its **kwargs pass-through — quick_summary never declares the 

183# parameter itself, but nothing strips it before it lands in 

184# ``_init_search_system(progress_callback=...)``. A JSON body can only carry 

185# scalars/dicts/lists/null, never a live callable, so this is the same 

186# "wrong type reaches a registry" shape as retrievers/llms above, except it 

187# fails even more quietly: the value is simply assigned to 

188# ``system.progress_callback`` (search_system.py:set_progress_callback) and 

189# only blows up with "... object is not callable" deep inside the strategy 

190# loop, mid-research — the exact opaque 500 this module exists to prevent. 

191_CALLABLE_PARAMS = frozenset({"progress_callback"}) 

192 

193 

194# ``openai_endpoint_url`` is declared on ``_init_search_system`` (not on 

195# quick_summary/generate_report themselves — it reaches them via their 

196# **kwargs pass-through, same route as progress_callback above) and is 

197# forwarded UNCONDITIONALLY: ``_init_search_system`` passes it straight to 

198# ``get_llm(openai_endpoint_url=...)`` with no gate. This is NOT in the same 

199# bucket as ``provider``/``temperature``/``max_search_results`` below — 

200# those three are dead because they are only read inside quick_summary's/ 

201# generate_report's own ``if "settings_snapshot" not in kwargs:`` branch, 

202# which the REST path always skips (``_load_user_context_into_params`` 

203# unconditionally sets ``params["settings_snapshot"]`` before the research 

204# function runs). ``openai_endpoint_url`` never passes through that branch 

205# at all, so it is live on every REST call. 

206# 

207# Traced effect (config/llm_config.py ``get_llm``, ~lines 120-132): when the 

208# resolved ``provider == "openai_endpoint"``, a non-null 

209# ``openai_endpoint_url`` OVERLAYS ``settings_snapshot["llm.openai_endpoint. 

210# url"]`` for that call — the exact settings key the user's stored 

211# ``llm.openai_endpoint`` provider (URL *and* its API key) is read from. A 

212# caller whose stored ``llm.provider`` is ``openai_endpoint`` can therefore 

213# redirect that run's prompts, and the account's already-configured 

214# endpoint API key, to ANY host of the caller's choosing, gated only by the 

215# egress-policy PEP (which treats ``openai_endpoint`` as non-local and does 

216# not pin it to the stored URL). This is real credential/endpoint steering, 

217# not a self-inflicted type-confusion footgun like the classes above — it 

218# must be rejected outright, not merely documented as a no-op. 

219_CREDENTIAL_STEERING_PARAMS = frozenset({"openai_endpoint_url"}) 

220 

221 

222# ``research_id``, ``programmatic_mode``, and ``research_context`` are 

223# identity/audit plumbing that the REST path already manages itself; 

224# accepting a caller-supplied value only reopens control the server 

225# deliberately took away. 

226# 

227# - ``research_id``: a declared parameter of ``quick_summary`` (reached via 

228# plain kwargs on ``generate_report``, which never declares it). When 

229# absent, quick_summary generates a fresh UUID and returns it in the 

230# response body for correlation — a caller never needs to supply one on 

231# this path. Worse, the two research functions disagree on what a 

232# caller-supplied value affects: quick_summary threads it into BOTH 

233# ``search_context["research_id"]`` (what search_tracker.py's SearchCall 

234# rows key on) AND ``_init_search_system(research_id=...)`` (what 

235# token_counter.py's TokenUsage rows key on), but generate_report always 

236# mints its own fresh UUID for ``search_context`` while still forwarding 

237# any caller-supplied ``research_id`` from kwargs to ``_init_search_system`` 

238# alone. A caller-controlled value on generate_report therefore 

239# split-brains the two metrics tables for that run — SearchCall rows land 

240# under the server ID, TokenUsage rows land under the caller's ID — with 

241# no way for the caller to know that in advance. Per-user DB isolation 

242# (#5539) keeps this inside the poster's own account, but it is a 

243# needless, caller-invisible way to corrupt one's own metrics history for 

244# zero REST-path benefit. 

245# - ``programmatic_mode``: not declared on quick_summary/generate_report at 

246# all (kwargs-only, reaching ``_init_search_system`` and then 

247# ``AdvancedSearchSystem``). ``_load_user_context_into_params`` below 

248# uses ``setdefault("programmatic_mode", False)`` specifically so 

249# authenticated REST calls default to DB-backed persistence. Because 

250# ``setdefault`` only sets a value that is ABSENT, an explicit 

251# caller-supplied ``programmatic_mode`` reaching that line would be 

252# respected rather than overridden — which is exactly the gap this 

253# ``_REJECTED_BODY_PARAMS`` entry closes, by rejecting the key with a 400 

254# before ``_load_user_context_into_params`` ever runs, so no 

255# caller-supplied value can reach that ``setdefault`` at all. 

256# ``AdvancedSearchSystem.__init__`` logs 

257# "Running in programmatic mode - database operations and metrics 

258# tracking disabled. Rate limiting, search metrics, and persistence 

259# features will not be available." when true (search_system.py). A 

260# caller who sets this on the REST path can make their own calls opt out 

261# of the audit trail and DB-backed rate-limit accounting the authenticated 

262# path exists to provide — the request still counts against the slowapi 

263# per-route limiter, but leaves nothing else behind. The REST path should 

264# be the one deciding this, not the request body. 

265# - ``research_context``: a declared parameter of ``_init_search_system`` 

266# forwarded via **kwargs from both quick_summary and generate_report. 

267# quick_summary is safe: it unconditionally overwrites 

268# ``init_kwargs["research_context"] = search_context`` (its own 

269# server-built metrics dict) right before calling 

270# ``_init_search_system``, so a caller-supplied value in the body is 

271# always clobbered. generate_report has no equivalent overwrite — it 

272# forwards ``**kwargs`` straight into ``_init_search_system``, so a 

273# caller-supplied ``research_context`` reaches 

274# ``get_llm(research_context=...)`` (config/llm_config.py) untouched. 

275# That is a caller-controlled value of unconstrained JSON type reaching 

276# code that both mutates it (``research_context["context_limit"] = ...``) 

277# — a non-dict, e.g. a string or int, raises TypeError there and 

278# surfaces as an opaque 500 — and, if it is a dict, hands it to 

279# ``TokenCounter`` (metrics/token_counter.py), which reads 

280# ``username``/``user_password``/``research_query``/``research_mode`` 

281# etc. straight out of it for the metrics rows it writes. A caller can 

282# therefore inject arbitrary metadata (including a password-shaped 

283# value) into their own metrics history, or 500 the request outright, 

284# from a key the REST path never needed exposed. Same shape as 

285# ``research_id``/``programmatic_mode`` above: server-managed identity 

286# plumbing, not a lever the request body should control. 

287# - ``research_mode`` is read straight out of ``**kwargs`` by 

288# quick_summary (``kwargs.get("research_mode", "quick")``) and written 

289# into the metrics ``search_context`` as the row's mode label, while 

290# generate_report hardcodes its own value — so a body-supplied value 

291# only mislabels the caller's metrics history. Server-managed, same 

292# tier. 

293_IDENTITY_PLUMBING_PARAMS = frozenset( 

294 {"research_id", "programmatic_mode", "research_context", "research_mode"} 

295) 

296 

297 

298# Declared parameters of quick_summary/generate_report (or bare **kwargs 

299# keys they read) that have NO effect when posted over the public REST 

300# path, so accepting them only invites confusion or, if the invariant below 

301# is ever weakened, reopens the exact settings-injection class already 

302# excluded for username/settings_snapshot elsewhere in this module: 

303# 

304# - ``settings``/``settings_override``/``api_key``/``provider``/ 

305# ``max_search_results`` are only ever consumed inside 

306# quick_summary's/generate_report's own 

307# ``if "settings_snapshot" not in kwargs:`` branch, to build a snapshot 

308# from scratch. On the REST path that branch is always skipped, because 

309# ``_load_user_context_into_params`` below unconditionally overwrites 

310# ``params["settings_snapshot"]`` with the caller's own server-loaded 

311# snapshot BEFORE the research function ever runs — so these five are 

312# dead weight in the request body, not levers a caller can pull. 

313# 

314# ``max_search_results`` cannot be rescued by threading it into the 

315# settings snapshot instead (the way ``search_tool`` already works). 

316# NOT because of a ``get_search`` default shadowing it — that was traced 

317# to the wrong function. ``research_functions.py`` imports ``get_search`` 

318# from ``config/search_config.py`` (``from ..config.search_config import 

319# get_search`` at the top of the file), whose signature (``search_tool``, 

320# ``llm_instance``, ``username``, ``settings_snapshot``, 

321# ``programmatic_mode``) has NO ``max_results`` parameter at all, and 

322# which reads the effective value straight out of the snapshot 

323# (``get_setting_from_snapshot("search.max_results", 10, ...)``) — the 

324# 10-default there is a snapshot fallback, not a shadowing kwarg. 

325# ``_init_search_system`` calls this ``get_search`` without ever passing 

326# ``max_results``. (``search_engine_factory.get_search`` — a different 

327# function, same name, in the retriever branch of the web-search-engines 

328# package, imported into ``config/search_config.py`` as 

329# ``factory_get_search`` — also defaults ``max_results`` to 10, and IS 

330# one frame down this call path: ``config/search_config.get_search`` 

331# calls it with ``**params``, where ``params["max_results"]`` is the 

332# value already read from the snapshot above. So its own default is 

333# simply always overridden by that snapshot value before it can matter; 

334# a reader chasing "get_search" by name alone does still land on a 

335# second, differently-shaped function, but that function is reachable, 

336# not dead — the earlier version of this comment claiming it is "never 

337# on this call path" was wrong.) The real reason is simpler: even a caller-supplied 

338# ``max_search_results`` threaded into the snapshot would need to land 

339# under the key ``search.max_results`` — a different name — to have any 

340# effect, and nothing on the REST path performs that rename; only the 

341# user's own stored ``search.max_results`` setting (already in the 

342# snapshot ``_load_user_context_into_params`` loads) ever reaches 

343# ``get_search``. Renaming/threading it is a real internal-API gap 

344# shared by the programmatic SDK, the web app, and the MCP server, out 

345# of scope for a REST-boundary fix and not safe to patch here. 

346# ``provider`` is not blocked this way (``_init_search_system``'s own 

347# default is ``None``, so ``get_llm`` does fall back to the snapshot) 

348# but is rejected anyway for symmetry: neither has ever been a 

349# documented REST parameter (unlike ``temperature`` — see 

350# ``_ACCEPTED_BUT_INEFFECTIVE_PARAMS`` below), so there is no 

351# backward-compatibility reason to keep accepting either. 

352# - ``user_password``/``metadata`` are not declared parameters at all; they 

353# are read directly out of **kwargs. ``user_password`` gets stashed 

354# verbatim into the metrics ``search_context`` dict (a password-shaped 

355# value written to the metrics store); ``metadata`` is read nowhere and 

356# silently dropped. Neither does anything useful over HTTP either. 

357# 

358# No legitimate REST caller can rely on a no-op, so rejecting these costs 

359# nothing — same argument as for _REGISTRY_PARAMS. 

360_DEAD_OR_CONFUSING_PARAMS = frozenset( 

361 { 

362 "settings", 

363 "settings_override", 

364 "api_key", 

365 "user_password", 

366 "metadata", 

367 "provider", 

368 "max_search_results", 

369 } 

370) 

371 

372 

373# ``temperature`` is dead on quick_summary/generate_report for the exact 

374# same reason as ``_DEAD_OR_CONFUSING_PARAMS`` above: it is only read 

375# inside quick_summary's/generate_report's own 

376# ``if "settings_snapshot" not in kwargs:`` branch, which the REST path 

377# always skips, AND ``_init_search_system`` calls ``get_llm(temperature=`` 

378# with its OWN hardcoded ``temperature: float = 0.7`` default (never 

379# ``None``), so even threading a caller's value into the settings snapshot 

380# instead would not rescue it — ``get_llm``'s 

381# ``if temperature is None: <fall back to settings_snapshot>`` branch never 

382# fires. 

383# 

384# Unlike the rest of that set, though, ``temperature`` is a PUBLICLY 

385# DOCUMENTED REST parameter: it was listed in this module's own 

386# ``GET /api/v1`` ``parameters`` dict for both endpoints, and release notes 

387# 1.8.1 tells callers migrating off the removed ``quick_summary_test`` 

388# endpoint to "call /quick_summary with search_tool, iterations, and 

389# temperature set explicitly". Hard-400ing it (as the rest of 

390# ``_DEAD_OR_CONFUSING_PARAMS`` correctly does — no legitimate caller ever 

391# relied on those) would therefore break existing callers who followed our 

392# own documentation. So ``temperature`` gets the honest-and-non-breaking 

393# treatment instead: the request still succeeds, but the value is popped 

394# out of ``params`` before the research function is called (see 

395# ``_pop_ineffective_params``) — it truly cannot reach quick_summary/ 

396# generate_report — and the response carries a ``warnings`` entry naming 

397# it and pointing callers who actually need temperature control at 

398# ``POST /api/v1/analyze_documents``, where it IS honored 

399# (``analyze_documents`` reads it directly, outside the dead 

400# ``settings_snapshot``-from-scratch branch above). 

401_ACCEPTED_BUT_INEFFECTIVE_PARAMS = frozenset({"temperature"}) 

402 

403 

404# ``output_file`` is a declared parameter of ``generate_report`` — it was 

405# advertised in this module's own ``GET /api/v1`` documentation (the 

406# ``api_documentation`` route's ``parameters`` dict, below) prior to this 

407# fix; that entry has been removed since a rejected parameter should not 

408# also be advertised as accepted. It lets the caller name a server-side 

409# filesystem path to write the finished report to (``research_functions.py`` 

410# ``generate_report`` -> ``write_file_verified`` -> ``file_write_verifier.py``). 

411# 

412# It was NOT in the audit that produced the rest of this module's classes, 

413# because that audit was scoped to keys reaching ``_init_search_system`` 

414# (registry/callable/credential-steering/identity-plumbing/dead-settings — 

415# see each class above), and ``output_file`` never reaches 

416# ``_init_search_system`` at all; it is consumed directly inside 

417# ``generate_report`` after the report is built. Rejecting it here closes 

418# that gap rather than widening the earlier audit's own scope claim. 

419# 

420# Two failure modes, both real: 

421# - No ``defaults/`` entry sets ``api.allow_file_output``. Precisely: 

422# ``write_file_verified`` calls ``get_setting_from_snapshot`` with no 

423# ``default``, so a missing key raises ``NoSettingsContextError`` 

424# (``config/thread_settings.py``); it is ``file_write_verifier.py``'s own 

425# ``except Exception: actual_value = None`` that turns this into ``None``, 

426# which then does not equal the required ``True``. Either way 

427# ``write_file_verified`` raises ``FileWriteSecurityError`` — fail-closed, 

428# correctly — but only AFTER the 

429# full research-and-report run has already completed. The endpoint's 

430# catch-all ``except Exception`` has no special case for 

431# ``FileWriteSecurityError``, so the caller pays the entire run's cost for 

432# an opaque 500 that never mentions the real cause. 

433# - If an operator has explicitly turned ``api.allow_file_output`` on, 

434# ``write_file_verified``'s write path (``file_write_verifier.py``) is a 

435# bare ``open(filepath, mode)`` with no containment: no restriction to a 

436# configured output directory, no traversal or symlink check. A 

437# caller-chosen path is written verbatim wherever the server process has 

438# filesystem access. 

439# 

440# A server-side filesystem path posted in a public JSON body is not a 

441# REST-shaped parameter — same "caller cannot legitimately express this 

442# safely" rationale as ``_REGISTRY_PARAMS``/``_CALLABLE_PARAMS`` above, just 

443# for a path instead of a live object, so it is rejected outright rather 

444# than merely validated early. Only the HTTP body path is affected: the 

445# in-process Python API is unchanged, and a programmatic caller who trusts 

446# their own filesystem access can still pass ``output_file=`` directly to 

447# ``generate_report()``. 

448_SERVER_PATH_PARAMS = frozenset({"output_file"}) 

449 

450 

451# The full class of request-body keys that must never reach 

452# quick_summary/generate_report from the public REST path. See 

453# _REGISTRY_PARAMS, _CALLABLE_PARAMS, _CREDENTIAL_STEERING_PARAMS, 

454# _IDENTITY_PLUMBING_PARAMS, _DEAD_OR_CONFUSING_PARAMS, and 

455# _SERVER_PATH_PARAMS above for what each group protects against. 

456# 

457# This audit spans all THREE REST endpoints that forward a request body 

458# into a research function, not just these two: 

459# - quick_summary / generate_report (api_quick_summary / api_generate_report 

460# below) enforce this class via ``_reject_unsafe_body_params`` and this 

461# ``_REJECTED_BODY_PARAMS`` reject-list. 

462# - analyze_documents (api_analyze_documents below) uses a different 

463# mechanism — a signature-derived ALLOWlist (``_ANALYZE_DOCUMENTS_PARAMS``) 

464# rather than a reject-list, because it has no **kwargs to smuggle 

465# unlisted keys through. Members of ``_IDENTITY_PLUMBING_PARAMS`` that 

466# are also declared parameters of ``analyze_documents`` (currently just 

467# ``programmatic_mode`` — see the comment above 

468# ``_ANALYZE_DOCUMENTS_PARAMS``) are explicitly subtracted from that 

469# allowlist so the same identity/audit-plumbing protection applies there 

470# too, even though they never appear in this reject-list union. 

471# ``_REGISTRY_PARAMS``/``_CALLABLE_PARAMS``/``_CREDENTIAL_STEERING_PARAMS`` 

472# need no equivalent subtraction: none of retrievers/llms/ 

473# progress_callback/openai_endpoint_url is a declared parameter of 

474# analyze_documents, so the allowlist derivation already excludes them. 

475# ``output_file`` IS a declared parameter of ``analyze_documents`` too, 

476# and — like ``programmatic_mode`` above — is now explicitly subtracted 

477# from ``_ANALYZE_DOCUMENTS_PARAMS`` rather than admitted by the 

478# allowlist derivation: that endpoint's own write call reaches the 

479# identical ``write_file_verified`` sink as quick_summary/generate_report 

480# (see ``_SERVER_PATH_PARAMS`` above and the comment on 

481# ``_ANALYZE_DOCUMENTS_PARAMS`` itself), so there was no substantive 

482# reason to leave a third endpoint exposed once the other two were fixed. 

483# 

484# The scope of "traced" below is deliberately narrow: it covers every OTHER 

485# **kwargs-forwarded key that reaches ``_init_search_system`` from the REST 

486# body — NOT the full set of keys quick_summary/generate_report accept via 

487# **kwargs, which is what ``output_file`` above demonstrates. And "traced" 

488# here means privilege-safe (no host/credential steering, no cross-user 

489# reach, no audit-trail bypass), NOT type-safe. Three of these are 

490# confirmed footguns of the exact same *shape* as the now-rejected 

491# ``progress_callback``: a JSON body can send a value of the wrong scalar 

492# type, and the first consumer performs an unconditional, un-type-checked 

493# operation on it that AttributeErrors/TypeErrors — an opaque 500. 

494# ``model_name``/``search_strategy``/``search_tool`` are therefore 

495# additionally covered by ``_TYPE_VALIDATED_PARAMS``/ 

496# ``_validate_param_types`` below, so the wrong-type case now gets a clean 

497# 400 at the boundary instead of reaching that crash. They are not 

498# rejected outright (unlike ``progress_callback``) because, sent with the 

499# right type, they are ordinary, legitimate, already-documented knobs: 

500# 

501# - ``model_name`` — selects which model string to request from the 

502# caller's OWN already-configured provider/API key; no host or 

503# credential is chosen by this value. It is explicitly documented as a 

504# public parameter of POST /api/v1/generate_report in 

505# ``api_documentation`` above. 

506# - ``search_strategy`` — selects which strategy class runs 

507# (source_based/modular/etc.), all under the same user's settings 

508# snapshot and egress policy; no different privilege or destination. 

509# - ``search_tool`` — selects which search engine to use, same 

510# already-configured-account shape as the two above. It is explicitly 

511# documented as a public parameter of POST /api/v1/quick_summary in 

512# ``api_documentation`` above, and was NOT type-validated by the 

513# original #5533 audit despite having a confirmed footgun of the exact 

514# same shape: a truthy non-string value (e.g. a list) survives the 

515# ``search_tool or get_setting_from_snapshot(...)`` fallback in 

516# ``config/search_config.get_search``, then reaches 

517# ``retriever_registry.get(name, ...)`` 

518# (``web_search_engines/retriever_registry.py``) — a plain dict 

519# ``.get()`` call, which raises ``TypeError: unhashable type`` for a 

520# list, or for a dict that has no ``value`` key, instead of returning 

521# ``None``. Be precise about the dict case: ``config/search_config.py`` 

522# has an explicit ``isinstance(tool, dict)`` branch that unwraps 

523# ``tool["value"]``, so ``{"search_tool": {"value": "wikipedia"}}`` was a 

524# normal 200 before this change and is a 400 after it. Falsy non-strings 

525# (``0``, ``false``, ``[]``, ``{}``) were likewise omission-equivalent 

526# 200s via ``research_functions.py``'s ``if not search_tool`` and are 

527# 400s now. Those spellings are undocumented — the contract is 

528# ``"search_tool": "Search engine to use"``, a string, and the 

529# ``{"value": ...}`` form is a settings-snapshot debug shim, not an API 

530# spelling — so tightening them is intended here, but it IS a behaviour 

531# change and is called out rather than left for someone to discover. 

532# Found in post-merge review of this follow-up; added here rather than 

533# left as another silent gap in a set whose own comment claims 

534# completeness. 

535# 

536# ``iterations``/``questions_per_iteration`` are forwarded (see 

537# ``_init_search_system``'s ``system.max_iterations = iterations`` / 

538# ``system.questions_per_iteration = questions_per_iteration``) but are 

539# deliberately NOT in ``_TYPE_VALIDATED_PARAMS``, for the opposite reason 

540# from ``search_tool`` above: tracing their actual consumer shows there is 

541# nothing to protect. Both assignments are bare attribute writes with no 

542# operation performed on the value at that call site, so no JSON type 

543# whatsoever raises there. And unlike ``max_iterations``/ 

544# ``questions_per_iteration`` passed directly into ``AdvancedSearchSystem`` 

545# (which DO feed ``range(1, self.max_iterations + 1)`` in 

546# ``advanced_search_system/strategies/focused_iteration_strategy.py``), 

547# ``_init_search_system`` never passes either kwarg into the 

548# ``AdvancedSearchSystem(...)`` constructor call above — only the 

549# post-construction attribute-assignment lines do — and nothing anywhere 

550# in the tree reads ``system.max_iterations``/``system.questions_per_iteration`` 

551# back out afterward (``report_generator.py`` reads ``strategy.``, not 

552# ``system.``). ``AdvancedSearchSystem`` instead resolves its own 

553# ``self.max_iterations``/``self.questions_per_iteration`` from 

554# ``search.iterations``/``search.questions_per_iteration`` in the settings 

555# snapshot whenever the constructor argument is ``None`` — which it always 

556# is here, since ``_init_search_system`` never supplies one. So on the 

557# REST path (unlike a direct in-process ``AdvancedSearchSystem(...)`` call 

558# with ``max_iterations=`` set), both are pure no-ops regardless of what a 

559# caller posts for them — an earlier version of this comment claimed 

560# ``iterations`` reaches ``range(1, self.max_iterations + 1)`` and crashes 

561# mid-research on a bad type; that was traced to the wrong constructor 

562# call and is false. Given that, type-validating ``iterations`` would be 

563# cosmetic only — no crash exists to prevent, unlike ``model_name``/ 

564# ``search_strategy``/``search_tool`` above — so it is left out rather 

565# than added for symmetry alone. Both stay forwarded rather than moved 

566# into ``_ACCEPTED_BUT_INEFFECTIVE_PARAMS`` alongside ``temperature``: 

567# unlike ``temperature``, neither has a rescue endpoint to point callers 

568# at (``analyze_documents`` doesn't accept an ``iterations`` parameter at 

569# all), and popping a kwarg that already does nothing would only change 

570# whether the research function *sees* an inert value, not any observable 

571# behavior — so this pass limits itself to correcting the record (this 

572# comment, the docs strings, the changelog) rather than also changing the 

573# accepted/forwarded shape. ``search_tool`` is unaffected by any of this — 

574# it IS read (``config/search_config.get_search``), which is exactly why 

575# it gets the type check above. 

576_REJECTED_BODY_PARAMS = ( 

577 _REGISTRY_PARAMS 

578 | _CALLABLE_PARAMS 

579 | _CREDENTIAL_STEERING_PARAMS 

580 | _IDENTITY_PLUMBING_PARAMS 

581 | _DEAD_OR_CONFUSING_PARAMS 

582 | _SERVER_PATH_PARAMS 

583) 

584 

585 

586def _reject_unsafe_body_params(data: Dict[str, Any]) -> Optional[JSONResponse]: 

587 """Reject request-body keys that a JSON body cannot legitimately carry, 

588 that steer a live call to a caller-chosen endpoint/credential, that are 

589 identity/audit plumbing the REST path already manages itself, or that 

590 are silent no-ops (and only a source of confusion) over the public REST 

591 path. 

592 

593 Returns a 400 ``JSONResponse`` the endpoint must return when any 

594 ``_REJECTED_BODY_PARAMS`` key is present in the body, or ``None`` when 

595 the body is clean. See ``_REJECTED_BODY_PARAMS`` for the rationale. 

596 """ 

597 present = sorted(_REJECTED_BODY_PARAMS & set(data)) 

598 if present: 

599 return JSONResponse( 

600 { 

601 "error": ( 

602 "The following parameter(s) are not accepted via the " 

603 f"REST API: {', '.join(present)}. They either require " 

604 "a live Python object a JSON body cannot carry " 

605 "(retrievers/llms/progress_callback), steer the call's " 

606 "LLM endpoint/credentials to a caller-chosen host " 

607 "(openai_endpoint_url), are identity/audit plumbing the " 

608 "REST path already manages itself " 

609 "(research_id/programmatic_mode/research_context/" 

610 "research_mode), name a server-side filesystem path a " 

611 "public API body should not control (output_file), or " 

612 "have no effect " 

613 "when set from the REST path " 

614 "(settings/settings_override/api_key/user_password/" 

615 "metadata/provider/max_search_results); " 

616 "pass them via the in-process Python API instead." 

617 ) 

618 }, 

619 status_code=400, 

620 ) 

621 return None 

622 

623 

624def _pop_ineffective_params(params: Dict[str, Any]) -> list: 

625 """Strip ``_ACCEPTED_BUT_INEFFECTIVE_PARAMS`` keys out of ``params`` in 

626 place and return a caller-facing warning string per key removed. 

627 

628 Unlike ``_reject_unsafe_body_params``, this never blocks the request — 

629 these keys are accepted for backward compatibility (see 

630 ``_ACCEPTED_BUT_INEFFECTIVE_PARAMS``) but popped here so they truly 

631 cannot reach the research function, same end state as a rejected 

632 param, just without breaking existing callers who send them. 

633 """ 

634 warnings = [] 

635 for key in sorted(_ACCEPTED_BUT_INEFFECTIVE_PARAMS & set(params)): 

636 params.pop(key, None) 

637 warnings.append( 

638 f"'{key}' was accepted but has no effect on this endpoint and " 

639 "was ignored; it only affects POST /api/v1/analyze_documents." 

640 ) 

641 return warnings 

642 

643 

644# Forwarded **kwargs keys with a confirmed unconditional, un-type-checked 

645# operation performed on them by their first consumer (see the 

646# ``model_name``/``search_strategy``/``search_tool`` bullets in the 

647# ``_REJECTED_BODY_PARAMS`` comment above for the exact call sites and why 

648# ``iterations``/``questions_per_iteration`` are deliberately NOT here — 

649# their consumer is a bare attribute assignment, so no type ever crashes 

650# there). A JSON body sending the wrong scalar type for one of these three 

651# reaches that operation and 500s — the same opaque-500 shape 

652# ``_ANALYZE_DOCUMENTS_PARAMS`` exists to prevent for analyze_documents, 

653# applied here to the keys on this reject-list path with a confirmed 

654# footgun rather than every forwarded key speculatively. 

655_TYPE_VALIDATED_PARAMS: Dict[str, type] = { 

656 "model_name": str, 

657 "search_strategy": str, 

658 "search_tool": str, 

659} 

660 

661 

662# ``model_name``/``search_tool`` treat an explicit JSON ``null`` as 

663# indistinguishable from omitting the key entirely — both resolve from the 

664# settings snapshot exactly as if the caller had not sent the key. The two 

665# reach that outcome by different routes, and the distinction matters if 

666# either consumer is ever changed: 

667# - ``model_name``: ``config/llm_config.py``'s ``get_llm`` has an 

668# explicit ``if model_name is None`` branch. 

669# - ``search_tool``: control never reaches ``config/search_config.py``'s 

670# ``get_search`` at all for ``None``. ``research_functions.py``'s 

671# ``if not search_tool`` resolves ``search.tool`` from the snapshot 

672# first and skips ``get_search`` entirely when it is unset. (An earlier 

673# revision of this comment cited ``get_search``'s 

674# ``search_tool or <fallback>`` as the deciding line; that is the wrong 

675# frame — the outcome is the same, the mechanism is not.) Both were accepted (200) before this module's type 

676# validation existed; excluding them here from the type check keeps that 

677# true instead of turning a documented no-op spelling into a new 400. 

678# ``search_strategy`` gets no equivalent carve-out: its consumer default 

679# is the non-None string ``"source_based"`` (``_init_search_system``), so 

680# an EXPLICIT ``null`` overrides that default with a real ``None`` and 

681# reaches ``strategy_name.lower()`` (search_system.py) unconditionally — 

682# that was a genuine pre-existing 500 (not a no-op), and this module's 400 

683# for it is a real fix, not an over-rejection. 

684# 

685# NOTE: this is a type-level fix only. ``model_name: ""`` still passes 

686# ``isinstance(value, str)``, and an empty string is NOT ``None``, so it 

687# skips ``get_llm``'s null-sentinel branch entirely, falls through 

688# ``if model_name: model_name = model_name.strip()...`` unstripped, and 

689# still hits ``if not model_name or not model_name.strip(): raise 

690# ValueError(...)`` (config/llm_config.py) — a 500, not a 400. That is a 

691# separate, pre-existing value-level gap this type-only pass does not 

692# close. 

693_NULL_IS_SENTINEL_PARAMS = frozenset({"model_name", "search_tool"}) 

694 

695 

696def _validate_param_types(data: Dict[str, Any]) -> Optional[JSONResponse]: 

697 """Reject request-body values whose JSON type cannot survive the 

698 unconditional operation their first consumer performs on them. See 

699 ``_TYPE_VALIDATED_PARAMS`` for the covered keys and rationale, and 

700 ``_NULL_IS_SENTINEL_PARAMS`` for the keys where ``null`` is deliberately 

701 let through as equivalent to omission rather than treated as a type 

702 error. 

703 

704 Returns a 400 ``JSONResponse`` naming every offending key, or ``None`` 

705 when every present key already has an acceptable type. 

706 """ 

707 errors = [] 

708 for key, expected_type in _TYPE_VALIDATED_PARAMS.items(): 

709 if key not in data: 

710 continue 

711 value = data[key] 

712 if value is None and key in _NULL_IS_SENTINEL_PARAMS: 

713 continue 

714 if not isinstance(value, expected_type): 

715 errors.append( 

716 f"'{key}' must be a {expected_type.__name__}, got " 

717 f"{type(value).__name__} instead" 

718 ) 

719 if errors: 

720 return JSONResponse({"error": "; ".join(errors)}, status_code=400) 

721 return None 

722 

723 

724async def require_api_access( 

725 request: Request, 

726 username: Annotated[str, Depends(require_auth)], 

727) -> str: 

728 """Port of main's api_access_control decorator. 

729 

730 Enforces the per-user ``app.enable_api`` kill-switch (403 when the 

731 user disabled API access) and pre-caches ``app.api_rate_limit`` for 

732 the dynamic ``api_rate_limit`` shared limit so the limiter never does 

733 a second DB read. Async on purpose: the cached value lives in a 

734 ContextVar, and a sync dependency would run in the threadpool where 

735 the ContextVar write could not propagate back to the request task. 

736 """ 

737 

738 def _read_settings(): 

739 with get_user_db_session(username) as db_session: 

740 sm = get_settings_manager(db_session, username) 

741 return ( 

742 sm.get_setting("app.enable_api", True), 

743 sm.get_setting("app.api_rate_limit", API_RATE_LIMIT_DEFAULT), 

744 ) 

745 

746 api_enabled, rate_limit_value = await run_db_sync(_read_settings) 

747 

748 if not api_enabled: 

749 raise HTTPException(status_code=403, detail="API access is disabled") 

750 

751 set_request_api_rate_limit(rate_limit_value) 

752 return username 

753 

754 

755@router.get("/health") 

756def health_check( 

757 username: Annotated[str | None, Depends(get_session_username)], 

758): 

759 """Health check endpoint (no auth required). 

760 

761 Always returns "ok" if the process can serve a request — but exposes 

762 a `subsystems` dict so an orchestrator's deep-probe can additionally 

763 inspect specific components. The top-level "status" stays "ok" so 

764 existing liveness probes (and tests) keep passing; consumers that 

765 care about subsystem health should look at `subsystems`. 

766 

767 File-descriptor and thread diagnostics (`resources`) are only 

768 included for authenticated users, to avoid leaking process internals 

769 to anonymous callers. The basic ``status``/``message``/``timestamp`` 

770 fields stay public so the Docker healthcheck (which only inspects the 

771 HTTP status code) keeps working. 

772 """ 

773 subsystems: dict[str, str] = {} 

774 

775 # Research queue processor — the worker thread should be alive 

776 try: 

777 from ..queue.processor_v2 import queue_processor 

778 

779 thread = getattr(queue_processor, "thread", None) 

780 alive = bool( 

781 getattr(queue_processor, "running", False) 

782 and thread 

783 and thread.is_alive() 

784 ) 

785 subsystems["queue_processor"] = "ok" if alive else "not_started" 

786 except Exception: 

787 subsystems["queue_processor"] = "error" 

788 

789 # Database manager — the singleton should be importable and respond 

790 try: 

791 from ...database.encrypted_db import db_manager 

792 

793 _ = db_manager.has_encryption 

794 subsystems["db_manager"] = "ok" 

795 except Exception: 

796 subsystems["db_manager"] = "error" 

797 

798 diagnostics: Dict[str, Any] = { 

799 "status": "ok", 

800 "message": "API is running", 

801 "timestamp": time.time(), 

802 } 

803 

804 # Only expose subsystem + resource diagnostics to authenticated users. 

805 # Anonymous callers get exactly the status/message/timestamp triple main 

806 # exposed — enough for a container healthcheck, while not telling an 

807 # unauthenticated prober whether the queue worker or the DB is degraded. 

808 if username: 

809 diagnostics["subsystems"] = subsystems 

810 # File descriptor count (Linux only; /proc not available on macOS) 

811 try: 

812 fd_count = len(os.listdir("/proc/self/fd")) 

813 except OSError: 

814 fd_count = None 

815 

816 # FD soft/hard limits (POSIX) 

817 soft_limit = hard_limit = None 

818 if _resource_mod is not None: 

819 try: 

820 soft_limit, hard_limit = _resource_mod.getrlimit( 

821 _resource_mod.RLIMIT_NOFILE 

822 ) 

823 if soft_limit == _resource_mod.RLIM_INFINITY: 

824 soft_limit = None 

825 if hard_limit == _resource_mod.RLIM_INFINITY: 

826 hard_limit = None 

827 except (AttributeError, ValueError, OSError): 

828 pass 

829 

830 thread_count = threading.active_count() 

831 

832 fd_usage_percent = ( 

833 round(fd_count / soft_limit * 100, 1) 

834 if fd_count is not None 

835 and soft_limit is not None 

836 and soft_limit > 0 

837 else None 

838 ) 

839 

840 diagnostics["resources"] = { 

841 "fd_count": fd_count, 

842 "fd_soft_limit": soft_limit, 

843 "fd_hard_limit": hard_limit, 

844 "fd_usage_percent": fd_usage_percent, 

845 "thread_count": thread_count, 

846 } 

847 

848 if fd_usage_percent is not None and fd_usage_percent > 70: 

849 diagnostics["status"] = "warning" 

850 diagnostics["message"] = ( 

851 f"High FD usage: {fd_count}/{soft_limit} ({fd_usage_percent}%)" 

852 ) 

853 

854 return diagnostics 

855 

856 

857@router.get("/") 

858@api_rate_limit 

859def api_documentation( 

860 request: Request, username: Annotated[str, Depends(require_api_access)] 

861): 

862 """Provide documentation on available API endpoints. 

863 

864 Hand-written rather than derived from FastAPI's OpenAPI schema. Review 

865 asked why; the answer is three things that are each checkable today. 

866 

867 *The generated schema is currently thinner than this dict, not richer.* 

868 No route in this app declares a Pydantic request body or a 

869 ``response_model`` — every handler parses ``await request.json()`` by hand 

870 and returns a bare dict — so ``app.openapi()`` renders the three research 

871 POSTs with **no** ``requestBody`` member at all and a ``{}`` (any JSON) 200 

872 schema. It therefore never mentions ``query``, ``collection_name`` or 

873 ``allow_default_settings``, let alone that the first two are required, and 

874 it carries no securityScheme because access here is a session cookie 

875 checked by ``require_api_access`` rather than a declared scheme. Until 

876 these routes gain request/response models, this dict is the only place a 

877 caller learns the request shape. 

878 

879 *And the generated schema is off by default.* ``openapi_url`` is gated on 

880 ``LDR_EXPOSE_DOCS`` in ``fastapi_app.py`` so a multi-user deployment does 

881 not publish its entire surface unauthenticated: ``/openapi.json`` 404s on a 

882 default install, while this route is served and API-gated. 

883 

884 *It is also curated, not exhaustive.* It advertises the three research 

885 endpoints and deliberately omits ``/health`` and this route itself, and its 

886 parameter prose (the ``allow_default_settings`` egress-policy note in 

887 particular) states a security consequence no schema would infer from types. 

888 Callers already parse this shape. 

889 

890 Hand-written means it can drift, so it cannot: 

891 ``tests/web/routers/test_api_v1_documentation_is_current.py`` fails if a 

892 documented path is not served, or if a POST route is added here without 

893 being advertised. If the routes ever do gain request/response models, that 

894 test is the seam to converge on: the generated schema becomes the source of 

895 truth and this body can be derived from it. 

896 """ 

897 return { 

898 "api_version": "v1", 

899 "description": "REST API for Local Deep Research", 

900 "endpoints": [ 

901 { 

902 "path": "/api/v1/quick_summary", 

903 "method": "POST", 

904 "description": "Generate a quick research summary", 

905 "parameters": { 

906 "query": "Research query (required)", 

907 "search_tool": "Search engine to use (optional)", 

908 "iterations": "Accepted for backward compatibility, but currently has NO observable effect via the REST API: the value is only ever assigned to an attribute nothing reads back, so the research run always uses the account's stored search.iterations setting instead (optional)", 

909 "allow_default_settings": "Set to true to proceed with default settings (and NO egress policy) when your stored settings cannot be loaded; default is to refuse with 503 (optional)", 

910 "temperature": "Accepted for backward compatibility but has NO effect on this endpoint (dropped before the research call; a 'warnings' entry is returned when set) — use POST /api/v1/analyze_documents for temperature control (optional)", 

911 }, 

912 }, 

913 { 

914 "path": "/api/v1/generate_report", 

915 "method": "POST", 

916 "description": "Generate a comprehensive research report", 

917 "parameters": { 

918 "query": "Research query (required)", 

919 "searches_per_section": "Searches per report section (optional)", 

920 "model_name": "LLM model to use (optional)", 

921 "allow_default_settings": "Set to true to proceed with default settings (and NO egress policy) when your stored settings cannot be loaded; default is to refuse with 503 (optional)", 

922 "temperature": "Accepted for backward compatibility but has NO effect on this endpoint (dropped before the research call; a 'warnings' entry is returned when set) — use POST /api/v1/analyze_documents for temperature control (optional)", 

923 }, 

924 }, 

925 { 

926 "path": "/api/v1/analyze_documents", 

927 "method": "POST", 

928 "description": "Search and analyze documents in a local collection", 

929 "parameters": { 

930 "query": "Search query (required)", 

931 "collection_name": "Local collection name (required)", 

932 "max_results": "Maximum results to return (optional)", 

933 "temperature": "LLM temperature (optional)", 

934 "force_reindex": "Force collection reindexing (optional)", 

935 "allow_default_settings": "Set to true to proceed with default settings (and NO egress policy) when your stored settings cannot be loaded; default is to refuse with 503 (optional)", 

936 }, 

937 }, 

938 ], 

939 } 

940 

941 

942def _load_user_context_into_params( 

943 params: Dict[str, Any], 

944 username: str | None, 

945 allow_default_settings: bool = False, 

946) -> Optional[JSONResponse]: 

947 """Mutate ``params`` in place to thread the authenticated user's context 

948 down to the research-function call. 

949 

950 All authenticated REST endpoints share the same shape: the user has an 

951 encrypted DB whose settings snapshot must be loaded and passed through, 

952 so calls honor the user's stored API keys, model preference, search 

953 tool, and other config — not just the application defaults plus 

954 ``LDR_*`` env vars that the programmatic-API fallback would produce. 

955 

956 Sets ``username``, ``settings_snapshot``, and (for authenticated 

957 requests) ``programmatic_mode=False`` so DB-backed rate-limit 

958 estimates persist across requests. Still uses ``params.setdefault(...)`` 

959 (not a plain assignment) for ``programmatic_mode`` rather than an 

960 unconditional overwrite, but on every current caller that distinction 

961 is now moot: all three REST endpoints reject an explicit 

962 ``programmatic_mode`` in the request body before this function ever 

963 runs — quick_summary/generate_report via ``_REJECTED_BODY_PARAMS`` 

964 (400), analyze_documents via its allowlist subtraction in 

965 ``_ANALYZE_DOCUMENTS_PARAMS`` (400) — so ``params`` never contains the 

966 key by the time ``setdefault`` runs here, and the request body can no 

967 longer influence this value at all. ``setdefault`` (rather than a plain 

968 assignment) is kept anyway so a non-REST caller of this helper that 

969 pre-populates ``params["programmatic_mode"]`` itself is not silently 

970 overridden. 

971 

972 Returns ``None`` on success. If the settings snapshot cannot be loaded, 

973 fails CLOSED: returns a 503 ``JSONResponse`` the endpoint must return to 

974 the caller. Continuing with an empty snapshot would resolve to the 

975 permissive default egress scope, silently downgrading a configured 

976 PRIVATE_ONLY / require-local user — bypassing the very boundary they 

977 configured. ``allow_default_settings=True`` is the caller's CONSCIOUS 

978 opt-in to proceed with defaults (empty snapshot, no egress policy) 

979 instead; it is logged loudly so it is never silent. 

980 

981 Runs sync SQLAlchemy — call from a threadpool, not the event loop. 

982 """ 

983 if not username: 983 ↛ 984line 983 didn't jump to line 984 because the condition on line 983 was never true

984 logger.debug("No username in session, skipping settings snapshot") 

985 params["settings_snapshot"] = {} 

986 return None 

987 

988 params["username"] = username 

989 params.setdefault("programmatic_mode", False) 

990 try: 

991 with get_user_db_session(username) as db_session: 

992 if db_session is None: 992 ↛ 993line 992 didn't jump to line 993 because the condition on line 992 was never true

993 logger.warning(f"No database session for user: {username}") 

994 params["settings_snapshot"] = {} 

995 return None 

996 settings_manager = get_settings_manager(db_session, username) 

997 snapshot = settings_manager.get_settings_snapshot() 

998 params["settings_snapshot"] = snapshot 

999 logger.debug( 

1000 f"Loaded settings snapshot for user '{username}' " 

1001 f"with {len(snapshot)} settings" 

1002 ) 

1003 return None 

1004 except Exception: 

1005 # logger.exception captures the traceback so the root cause 

1006 # (e.g. SQLCipher decrypt failure, settings table corruption, 

1007 # missing column after a migration) is visible. Without this 

1008 # the downstream error misleads — looks like "no provider", 

1009 # really was "couldn't read user settings". 

1010 logger.exception("Failed to load user settings snapshot") 

1011 if allow_default_settings: 

1012 # Caller explicitly opted in to run without their settings. 

1013 # Proceed with defaults (empty snapshot → permissive scope). 

1014 # Logged loudly so it's never a silent downgrade. 

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

1016 "Settings snapshot failed to load; proceeding with " 

1017 "DEFAULT settings because allow_default_settings=true " 

1018 "— this run is NOT bound by the user's egress policy", 

1019 user=username, 

1020 ) 

1021 params["settings_snapshot"] = {} 

1022 return None 

1023 return JSONResponse( 

1024 { 

1025 "error": ( 

1026 "Your settings could not be loaded, so the " 

1027 "research was REFUSED to avoid silently " 

1028 "running without your privacy/egress policy " 

1029 "(which could send your data to the cloud " 

1030 "when you meant to keep it local)." 

1031 ), 

1032 "how_to_fix": ( 

1033 "This is usually transient — try again. If " 

1034 "it persists, your encrypted settings " 

1035 "database may be unavailable (e.g. a session " 

1036 "/ password issue), so re-authenticate. To " 

1037 "deliberately run with default settings and " 

1038 "NO egress policy, resend the request with " 

1039 '"allow_default_settings": true.' 

1040 ), 

1041 "reason": "settings_unavailable", 

1042 }, 

1043 status_code=503, 

1044 ) 

1045 

1046 

1047@router.post("/quick_summary") 

1048@api_rate_limit 

1049async def api_quick_summary( 

1050 request: Request, 

1051 username: Annotated[str, Depends(require_api_access)], 

1052): 

1053 """Generate a quick research summary via REST API.""" 

1054 try: 

1055 data = await request.json() 

1056 except Exception: 

1057 return JSONResponse({"error": "Invalid JSON body"}, status_code=400) 

1058 

1059 if not isinstance(data, dict): 

1060 # A JSON array/string/number passes the membership checks below but 

1061 # has no .get(), so it used to reach data.get() and 500. main's 

1062 # require_json_body enforced an object. 

1063 return JSONResponse( 

1064 {"error": "Request body must be a JSON object"}, status_code=400 

1065 ) 

1066 

1067 if not data or "query" not in data: 

1068 return JSONResponse( 

1069 {"error": "Query parameter is required"}, status_code=400 

1070 ) 

1071 

1072 query = data.get("query") 

1073 if not isinstance(query, str): 

1074 return JSONResponse( 

1075 {"error": "Query must be a string"}, status_code=400 

1076 ) 

1077 

1078 # Reject the whole unsafe-forwarding class (retrievers/llms/ 

1079 # progress_callback/openai_endpoint_url/research_id/programmatic_mode/ 

1080 # settings/settings_override/api_key/user_password/metadata/provider/ 

1081 # max_search_results/output_file) BEFORE building params. See 

1082 # _REJECTED_BODY_PARAMS for why these can't be forwarded from the HTTP 

1083 # path. ``temperature`` is handled separately below: it is a documented 

1084 # public parameter, so it is accepted (not 400ed) but stripped before 

1085 # it can reach quick_summary — see _ACCEPTED_BUT_INEFFECTIVE_PARAMS. 

1086 unsafe_param_error = _reject_unsafe_body_params(data) 

1087 if unsafe_param_error is not None: 

1088 return unsafe_param_error 

1089 

1090 # Cheap type check for the forwarded knobs with a confirmed 

1091 # unconditional operation on them (see _TYPE_VALIDATED_PARAMS): turns 

1092 # an opaque 500 into a clear 400. 

1093 type_error = _validate_param_types(data) 

1094 if type_error is not None: 

1095 return type_error 

1096 

1097 # Opt-in escape hatch for programmatic callers: when settings can't be 

1098 # loaded, proceed with defaults (empty snapshot → permissive scope) instead 

1099 # of failing closed (503). Default False (fail closed) so a configured 

1100 # PRIVATE_ONLY user is never silently downgraded; setting it true is a 

1101 # CONSCIOUS "I'm fine running without my settings/egress policy" choice. 

1102 # Excluded from ``params`` so it isn't forwarded to quick_summary(). 

1103 # Strict ``is True`` (not bool()): for a security-boundary flag we only opt 

1104 # in on a real JSON ``true`` — not on a truthy string like "false"/"0". 

1105 allow_default_settings = data.get("allow_default_settings") is True 

1106 params: Dict[str, Any] = { 

1107 k: v 

1108 for k, v in data.items() 

1109 if k not in ("query", "allow_default_settings") 

1110 } 

1111 # Set a reasonable default for API use. search_tool deliberately has 

1112 # no default here: when omitted, quick_summary reads the user's 

1113 # configured search.tool from the settings snapshot. ``temperature`` is 

1114 # popped out (not defaulted) here — quick_summary only reads its own 

1115 # temperature argument in the branch that builds a settings_snapshot 

1116 # from scratch, which the REST path never takes, so it can't be 

1117 # rescued by forwarding it; see _pop_ineffective_params. 

1118 params.setdefault("iterations", 1) 

1119 param_warnings = _pop_ineffective_params(params) 

1120 

1121 error = await run_db_sync( 

1122 _load_user_context_into_params, params, username, allow_default_settings 

1123 ) 

1124 if error is not None: 

1125 return error 

1126 

1127 try: 

1128 from ...api.research_functions import quick_summary 

1129 

1130 # run_db_sync (not raw to_thread): the research call opens per-user 

1131 # DB sessions (metrics writes, local-collection search) on the worker 

1132 # thread; to_thread reuses workers and would leak the session. 

1133 result = await run_db_sync(quick_summary, query, **params) 

1134 

1135 # Serialize Document objects 

1136 converted = result.copy() 

1137 for finding in converted.get("findings", []): 

1138 for i, doc in enumerate(finding.get("documents", [])): 

1139 finding["documents"][i] = { 

1140 "metadata": doc.metadata, 

1141 "content": doc.page_content, 

1142 } 

1143 

1144 # CWE-209 / CodeQL #8019: scrub exception-derived fields before the 

1145 # response leaves. See _scrub_error_fields for rationale. 

1146 _scrub_error_fields(converted) 

1147 

1148 if param_warnings: 

1149 converted["warnings"] = param_warnings 

1150 

1151 return converted 

1152 except TimeoutError: 

1153 logger.exception("Request timed out") 

1154 return JSONResponse( 

1155 { 

1156 "error": "Request timed out. Please try with a simpler query or fewer iterations." 

1157 }, 

1158 status_code=504, 

1159 ) 

1160 except Exception: 

1161 logger.exception("Error in quick_summary API") 

1162 return JSONResponse( 

1163 { 

1164 "error": "An internal error has occurred. Please try again later." 

1165 }, 

1166 status_code=500, 

1167 ) 

1168 

1169 

1170@router.post("/generate_report") 

1171@api_rate_limit 

1172async def api_generate_report( 

1173 request: Request, 

1174 username: Annotated[str, Depends(require_api_access)], 

1175): 

1176 """Generate a comprehensive research report via REST API.""" 

1177 try: 

1178 data = await request.json() 

1179 except Exception: 

1180 return JSONResponse({"error": "Invalid JSON body"}, status_code=400) 

1181 

1182 if not isinstance(data, dict): 

1183 # A JSON array/string/number passes the membership checks below but 

1184 # has no .get(), so it used to reach data.get() and 500. main's 

1185 # require_json_body enforced an object. 

1186 return JSONResponse( 

1187 {"error": "Request body must be a JSON object"}, status_code=400 

1188 ) 

1189 

1190 if not data or "query" not in data: 

1191 return JSONResponse( 

1192 {"error": "Query parameter is required"}, status_code=400 

1193 ) 

1194 

1195 query = data.get("query") 

1196 if not isinstance(query, str): 

1197 return JSONResponse( 

1198 {"error": "Query must be a string"}, status_code=400 

1199 ) 

1200 

1201 # Reject the whole unsafe-forwarding class (retrievers/llms/ 

1202 # progress_callback/openai_endpoint_url/research_id/programmatic_mode/ 

1203 # settings/settings_override/api_key/user_password/metadata/provider/ 

1204 # max_search_results/output_file) BEFORE building params. See 

1205 # _REJECTED_BODY_PARAMS for why these can't be forwarded from the HTTP 

1206 # path. ``temperature`` is handled separately below: see 

1207 # api_quick_summary and _ACCEPTED_BUT_INEFFECTIVE_PARAMS. 

1208 unsafe_param_error = _reject_unsafe_body_params(data) 

1209 if unsafe_param_error is not None: 

1210 return unsafe_param_error 

1211 

1212 # Cheap type check for the forwarded knobs with a confirmed 

1213 # unconditional operation on them — see api_quick_summary and 

1214 # _TYPE_VALIDATED_PARAMS. 

1215 type_error = _validate_param_types(data) 

1216 if type_error is not None: 

1217 return type_error 

1218 

1219 # See api_quick_summary for the allow_default_settings semantics 

1220 # (opt-in escape hatch, strict ``is True``, excluded from params). 

1221 allow_default_settings = data.get("allow_default_settings") is True 

1222 params = { 

1223 k: v 

1224 for k, v in data.items() 

1225 if k not in ("query", "allow_default_settings") 

1226 } 

1227 params.setdefault("searches_per_section", 1) 

1228 # ``temperature`` is popped out (not defaulted) here: see 

1229 # api_quick_summary for why it's dead on the REST path. 

1230 param_warnings = _pop_ineffective_params(params) 

1231 

1232 error = await run_db_sync( 

1233 _load_user_context_into_params, params, username, allow_default_settings 

1234 ) 

1235 if error is not None: 

1236 return error 

1237 

1238 try: 

1239 from ...api.research_functions import generate_report 

1240 

1241 # run_db_sync: see quick_summary — offload with thread-local session 

1242 # cleanup, not a bare to_thread. 

1243 result = await run_db_sync(generate_report, query, **params) 

1244 

1245 if ( 

1246 result 

1247 and "content" in result 

1248 and isinstance(result["content"], str) 

1249 and len(result["content"]) > 10000 

1250 ): 

1251 result["content"] = ( 

1252 result["content"][:2000] + "... [Content truncated]" 

1253 ) 

1254 result["content_truncated"] = True 

1255 

1256 # CWE-209 / CodeQL #8019: same boundary scrub as api_quick_summary. 

1257 # Today's payload ({content, metadata, file_path}) carries none of the 

1258 # scrubbed field names, so this is precautionary — it keeps the 

1259 # every-response-sink boundary policy intact if the payload ever grows 

1260 # error-carrying fields. 

1261 _scrub_error_fields(result) 

1262 

1263 if param_warnings and isinstance(result, dict): 

1264 result["warnings"] = param_warnings 

1265 

1266 return result 

1267 except TimeoutError: 

1268 return JSONResponse( 

1269 {"error": "Request timed out. Please try with a simpler query."}, 

1270 status_code=504, 

1271 ) 

1272 except Exception: 

1273 logger.exception("Error in generate_report API") 

1274 return JSONResponse( 

1275 { 

1276 "error": "An internal error has occurred. Please try again later." 

1277 }, 

1278 status_code=500, 

1279 ) 

1280 

1281 

1282@router.post("/analyze_documents") 

1283@api_rate_limit 

1284async def api_analyze_documents( 

1285 request: Request, 

1286 username: Annotated[str, Depends(require_api_access)], 

1287): 

1288 """Search and analyze documents in a local collection via REST API.""" 

1289 try: 

1290 data = await request.json() 

1291 except Exception: 

1292 return JSONResponse({"error": "Invalid JSON body"}, status_code=400) 

1293 

1294 if not isinstance(data, dict): 

1295 # A JSON array/string/number passes the membership checks below but 

1296 # has no .get(), so it used to reach data.get() and 500. main's 

1297 # require_json_body enforced an object. 

1298 return JSONResponse( 

1299 {"error": "Request body must be a JSON object"}, status_code=400 

1300 ) 

1301 

1302 if not data or "query" not in data or "collection_name" not in data: 

1303 return JSONResponse( 

1304 {"error": "Both query and collection_name parameters are required"}, 

1305 status_code=400, 

1306 ) 

1307 

1308 query = data.get("query") 

1309 collection_name = data.get("collection_name") 

1310 if not isinstance(query, str): 

1311 return JSONResponse( 

1312 {"error": "Query must be a string"}, status_code=400 

1313 ) 

1314 if not isinstance(collection_name, str): 

1315 return JSONResponse( 

1316 {"error": "Collection name must be a string"}, status_code=400 

1317 ) 

1318 # See api_quick_summary for the allow_default_settings semantics 

1319 # (opt-in escape hatch, strict ``is True``, excluded from params). 

1320 allow_default_settings = data.get("allow_default_settings") is True 

1321 params = { 

1322 k: v 

1323 for k, v in data.items() 

1324 if k not in ("query", "collection_name", "allow_default_settings") 

1325 } 

1326 

1327 unknown_params = sorted(set(params) - _ANALYZE_DOCUMENTS_PARAMS) 

1328 if unknown_params: 

1329 return JSONResponse( 

1330 { 

1331 "error": ( 

1332 f"Unknown parameter(s) for analyze_documents: " 

1333 f"{', '.join(unknown_params)}" 

1334 ), 

1335 "allowed_parameters": sorted(_ANALYZE_DOCUMENTS_PARAMS), 

1336 }, 

1337 status_code=400, 

1338 ) 

1339 

1340 error = await run_db_sync( 

1341 _load_user_context_into_params, params, username, allow_default_settings 

1342 ) 

1343 if error is not None: 

1344 return error 

1345 

1346 try: 

1347 # run_db_sync: analyze_documents runs local-collection search which 

1348 # opens the per-user DB session on the worker thread. 

1349 result = await run_db_sync( 

1350 analyze_documents, query, collection_name, **params 

1351 ) 

1352 # CWE-209 / CodeQL #8019: same boundary scrub as api_quick_summary. 

1353 # analyze_documents returns error text under the `summary` key. 

1354 _scrub_error_fields(result) 

1355 return result 

1356 except Exception: 

1357 logger.exception("Error in analyze_documents API") 

1358 return JSONResponse( 

1359 { 

1360 "error": "An internal error has occurred. Please try again later." 

1361 }, 

1362 status_code=500, 

1363 )