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

301 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +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 ...settings.exceptions import SettingsValueError 

27from ...utilities.db_utils import get_settings_manager 

28from ..dependencies.auth import get_session_username, require_auth 

29from ..dependencies.json_body import is_json_request 

30from ..dependencies.rate_limit import ( 

31 API_RATE_LIMIT_DEFAULT, 

32 api_rate_limit, 

33 set_request_api_rate_limit, 

34) 

35from ..dependencies.threadpool import run_db_sync, run_research_sync 

36from ..routes.research_validation import ( 

37 ITERATIONS_MAX, 

38 ITERATIONS_MIN, 

39 QUESTIONS_PER_ITERATION_MAX, 

40 QUESTIONS_PER_ITERATION_MIN, 

41) 

42 

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

44 

45# Match the largest strategy-layer cap (_AGENT_ERROR_MAX_LEN = 500 in 

46# security/log_sanitizer.py) at the HTTP boundary so a message already 

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

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

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

50# sanitize_error_for_client would truncate that signal prematurely. 

51_ERROR_BOUNDARY_MAX_LEN = 500 

52 

53 

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

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

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

57 

58 Strategy-layer ``sanitize_error_for_agent``/``sanitize_error_for_client`` already 

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

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

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

62 so the marker survives the scrub. 

63 

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

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

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

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

68 """ 

69 if not isinstance(results, dict): 

70 return 

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

72 value = results.get(field) 

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

74 results[field] = sanitize_error_for_client( 

75 value, max_length=_ERROR_BOUNDARY_MAX_LEN 

76 ) 

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

78 content = finding.get("content") 

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

80 finding["content"] = sanitize_error_for_client( 

81 content, max_length=_ERROR_BOUNDARY_MAX_LEN 

82 ) 

83 

84 

85# Body params /analyze_documents accepts beyond the positional 

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

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

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

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

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

91# _load_user_context_into_params (and overwritten if a body supplied 

92# them). 

93# 

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

95# genuine declared parameter of analyze_documents (unlike 

96# username/settings_snapshot it would otherwise survive the allowlist 

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

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

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

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

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

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

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

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

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

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

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

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

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

110# it on this third endpoint too. 

111# 

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

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

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

115# ``write_file_verified`` sink — with no containment or traversal check, 

116# and a leaf-symlink refusal (O_NOFOLLOW) that does not cover 

117# parent-directory symlinks or hardlinks — after its own full 

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

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

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

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

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

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

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

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

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

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

128# 

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

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

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

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

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

134# through. 

135_ANALYZE_DOCUMENTS_PARAMS = ( 

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

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

138 - { 

139 "research_id", 

140 "programmatic_mode", 

141 "research_context", 

142 "output_file", 

143 } 

144) 

145 

146 

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

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

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

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

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

152# 

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

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

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

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

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

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

159# another user's entries. 

160# 

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

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

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

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

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

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

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

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

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

170# 

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

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

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

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

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

176# 

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

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

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

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

181# parameters of quick_summary/generate_report. 

182# 

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

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

185# and are unaffected. 

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

187 

188 

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

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

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

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

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

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

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

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

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

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

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

200_CALLABLE_PARAMS = frozenset({"progress_callback"}) 

201 

202 

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

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

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

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

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

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

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

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

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

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

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

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

215# 

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

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

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

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

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

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

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

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

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

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

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

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

228_CREDENTIAL_STEERING_PARAMS = frozenset({"openai_endpoint_url"}) 

229 

230 

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

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

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

234# deliberately took away. 

235# 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

253# zero REST-path benefit. 

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

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

256# ``AdvancedSearchSystem``). ``_load_user_context_into_params`` below 

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

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

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

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

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

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

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

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

265# ``AdvancedSearchSystem.__init__`` logs 

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

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

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

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

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

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

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

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

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

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

276# quick_summary is safe: it unconditionally overwrites 

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

278# server-built metrics dict) right before calling 

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

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

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

282# caller-supplied ``research_context`` reaches 

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

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

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

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

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

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

289# ``username``/``user_password``/``research_query``/``research_mode`` 

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

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

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

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

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

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

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

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

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

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

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

301# tier. 

302_IDENTITY_PLUMBING_PARAMS = frozenset( 

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

304) 

305 

306 

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

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

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

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

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

312# 

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

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

315# quick_summary's/generate_report's own 

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

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

318# ``_load_user_context_into_params`` below unconditionally overwrites 

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

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

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

322# 

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

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

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

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

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

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

329# ``llm_instance``, ``username``, ``settings_snapshot``, 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

360# backward-compatibility reason to keep accepting either. 

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

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

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

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

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

366# 

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

368# nothing — same argument as for _REGISTRY_PARAMS. 

369_DEAD_OR_CONFUSING_PARAMS = frozenset( 

370 { 

371 "settings", 

372 "settings_override", 

373 "api_key", 

374 "user_password", 

375 "metadata", 

376 "provider", 

377 "max_search_results", 

378 } 

379) 

380 

381 

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

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

384# inside quick_summary's/generate_report's own 

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

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

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

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

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

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

391# fires. 

392# 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

410_ACCEPTED_BUT_INEFFECTIVE_PARAMS = frozenset({"temperature"}) 

411 

412 

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

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

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

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

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

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

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

420# 

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

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

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

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

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

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

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

428# 

429# Two failure modes, both real: 

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

431# ``write_file_verified`` calls ``get_setting_from_snapshot`` with no 

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

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

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

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

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

437# correctly — but only AFTER the 

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

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

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

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

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

443# ``write_file_verified``'s write path (``file_write_verifier.py``) has 

444# no containment: no restriction to a configured output directory, no 

445# traversal check. It refuses a symlink at the destination leaf (via 

446# O_NOFOLLOW) but that refusal does not cover a symlinked parent 

447# directory or a hardlinked leaf. A caller-chosen path is otherwise 

448# written verbatim wherever the server process has filesystem access. 

449# 

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

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

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

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

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

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

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

457# ``generate_report()``. 

458_SERVER_PATH_PARAMS = frozenset({"output_file"}) 

459 

460 

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

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

463# _REGISTRY_PARAMS, _CALLABLE_PARAMS, _CREDENTIAL_STEERING_PARAMS, 

464# _IDENTITY_PLUMBING_PARAMS, _DEAD_OR_CONFUSING_PARAMS, and 

465# _SERVER_PATH_PARAMS above for what each group protects against. 

466# 

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

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

469# - quick_summary / generate_report (api_quick_summary / api_generate_report 

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

471# ``_REJECTED_BODY_PARAMS`` reject-list. 

472# - analyze_documents (api_analyze_documents below) uses a different 

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

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

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

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

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

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

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

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

481# ``_REGISTRY_PARAMS``/``_CALLABLE_PARAMS``/``_CREDENTIAL_STEERING_PARAMS`` 

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

483# progress_callback/openai_endpoint_url is a declared parameter of 

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

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

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

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

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

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

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

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

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

493# 

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

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

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

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

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

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

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

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

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

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

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

505# additionally covered by ``_TYPE_VALIDATED_PARAMS``/ 

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

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

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

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

510# 

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

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

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

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

515# ``api_documentation`` above. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

544# completeness. 

545# 

546# ``iterations``/``questions_per_iteration`` are forwarded into 

547# ``_init_search_system`` when present. Omitted keys stay omitted so the 

548# snapshot's ``search.iterations`` / ``search.questions_per_iteration`` 

549# are used. When present, they are boundary-validated in 

550# ``_validate_param_types`` (finite ints, 1..100 for iterations, 1..20 for 

551# questions_per_iteration) before reaching downstream consumers. 

552_REJECTED_BODY_PARAMS = ( 

553 _REGISTRY_PARAMS 

554 | _CALLABLE_PARAMS 

555 | _CREDENTIAL_STEERING_PARAMS 

556 | _IDENTITY_PLUMBING_PARAMS 

557 | _DEAD_OR_CONFUSING_PARAMS 

558 | _SERVER_PATH_PARAMS 

559) 

560 

561 

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

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

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

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

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

567 path. 

568 

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

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

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

572 """ 

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

574 if present: 

575 return JSONResponse( 

576 { 

577 "error": ( 

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

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

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

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

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

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

584 "REST path already manages itself " 

585 "(research_id/programmatic_mode/research_context/" 

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

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

588 "have no effect " 

589 "when set from the REST path " 

590 "(settings/settings_override/api_key/user_password/" 

591 "metadata/provider/max_search_results); " 

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

593 ) 

594 }, 

595 status_code=400, 

596 ) 

597 return None 

598 

599 

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

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

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

603 

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

605 these keys are accepted for backward compatibility (see 

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

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

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

609 """ 

610 warnings = [] 

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

612 params.pop(key, None) 

613 warnings.append( 

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

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

616 ) 

617 return warnings 

618 

619 

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

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

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

623# ``_REJECTED_BODY_PARAMS`` comment above for the exact call sites; 

624# ``iterations``/``questions_per_iteration`` are validated separately 

625# below in ``_validate_param_types`` for integer type and range). A JSON 

626# body sending the wrong scalar type for one of these three 

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

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

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

630# footgun rather than every forwarded key speculatively. 

631_TYPE_VALIDATED_PARAMS: Dict[str, type] = { 

632 "model_name": str, 

633 "search_strategy": str, 

634 "search_tool": str, 

635} 

636 

637 

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

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

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

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

642# either consumer is ever changed: 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

660# 

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

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

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

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

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

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

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

668# close. 

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

670 

671 

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

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

674 unconditional operation their first consumer performs on them. See 

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

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

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

678 error. 

679 

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

681 when every present key already has an acceptable type. 

682 """ 

683 errors = [] 

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

685 if key not in data: 

686 continue 

687 value = data[key] 

688 if value is None and key in _NULL_IS_SENTINEL_PARAMS: 

689 continue 

690 if not isinstance(value, expected_type): 

691 errors.append( 

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

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

694 ) 

695 

696 if "iterations" in data: 

697 val = data["iterations"] 

698 if val is not None: 

699 if type(val) is not int or not ( 

700 ITERATIONS_MIN <= val <= ITERATIONS_MAX 

701 ): 

702 errors.append( 

703 f"'iterations' must be an integer between {ITERATIONS_MIN} and {ITERATIONS_MAX}" 

704 ) 

705 

706 if "questions_per_iteration" in data: 

707 val = data["questions_per_iteration"] 

708 if val is not None: 

709 if type(val) is not int or not ( 

710 QUESTIONS_PER_ITERATION_MIN 

711 <= val 

712 <= QUESTIONS_PER_ITERATION_MAX 

713 ): 

714 errors.append( 

715 f"'questions_per_iteration' must be an integer between " 

716 f"{QUESTIONS_PER_ITERATION_MIN} and {QUESTIONS_PER_ITERATION_MAX}" 

717 ) 

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_bool_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": "Number of research cycles. If omitted, uses the account's stored search.iterations setting (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 An invalid configured setting (``SettingsValueError``) also returns a 

982 503, with ``reason`` ``invalid_setting`` and an ``error`` naming the 

983 key, its source and the reason but not the value. The opt-in does not 

984 apply to it. 

985 

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

987 """ 

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

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

990 params["settings_snapshot"] = {} 

991 return None 

992 

993 params["username"] = username 

994 params.setdefault("programmatic_mode", False) 

995 try: 

996 with get_user_db_session(username) as db_session: 

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

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

999 params["settings_snapshot"] = {} 

1000 return None 

1001 settings_manager = get_settings_manager(db_session, username) 

1002 snapshot = settings_manager.get_settings_snapshot() 

1003 params["settings_snapshot"] = snapshot 

1004 logger.debug( 

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

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

1007 ) 

1008 return None 

1009 except SettingsValueError as error: 

1010 # Handled before the generic branch so the unavailable-database 

1011 # opt-in below cannot waive invalid configuration. The diagnostic 

1012 # names the key, its source and the reason, never the value or the 

1013 # parser's own exception, so it is the one part of this exception 

1014 # that may reach the client. It is read into a string here, and the 

1015 # body is built from that string: the response-leakage guard 

1016 # (tests/web/routers/test_error_response_leakage.py) rejects any use 

1017 # of a bound exception inside a response body. 

1018 diagnostic = error.diagnostic 

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

1020 "Research refused because of an invalid setting: {}", 

1021 diagnostic, 

1022 user=username, 

1023 ) 

1024 return JSONResponse( 

1025 { 

1026 "error": ( 

1027 "The research was REFUSED because a configured " 

1028 f"setting is invalid. {diagnostic}" 

1029 ), 

1030 "how_to_fix": ( 

1031 "Correct or remove the named setting in the " 

1032 "deployment environment or the saved settings, then " 

1033 "retry. allow_default_settings does not bypass an " 

1034 "invalid setting." 

1035 ), 

1036 "reason": "invalid_setting", 

1037 }, 

1038 status_code=503, 

1039 ) 

1040 except Exception: 

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

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

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

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

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

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

1047 if allow_default_settings: 

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

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

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

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

1052 "Settings snapshot failed to load; proceeding with " 

1053 "DEFAULT settings because allow_default_settings=true " 

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

1055 user=username, 

1056 ) 

1057 params["settings_snapshot"] = {} 

1058 return None 

1059 return JSONResponse( 

1060 { 

1061 "error": ( 

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

1063 "research was REFUSED to avoid silently " 

1064 "running without your privacy/egress policy " 

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

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

1067 ), 

1068 "how_to_fix": ( 

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

1070 "it persists, your encrypted settings " 

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

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

1073 "deliberately run with default settings and " 

1074 "NO egress policy, resend the request with " 

1075 '"allow_default_settings": true.' 

1076 ), 

1077 "reason": "settings_unavailable", 

1078 }, 

1079 status_code=503, 

1080 ) 

1081 

1082 

1083@router.post("/quick_summary") 

1084@api_rate_limit 

1085async def api_quick_summary( 

1086 request: Request, 

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

1088): 

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

1090 if not is_json_request(request): 

1091 return JSONResponse( 

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

1093 ) 

1094 try: 

1095 data = await request.json() 

1096 except Exception: 

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

1098 

1099 if not isinstance(data, dict): 

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

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

1102 # require_json_body enforced an object. 

1103 return JSONResponse( 

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

1105 ) 

1106 

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

1108 return JSONResponse( 

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

1110 ) 

1111 

1112 query = data.get("query") 

1113 if not isinstance(query, str): 

1114 return JSONResponse( 

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

1116 ) 

1117 

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

1119 # progress_callback/openai_endpoint_url/research_id/programmatic_mode/ 

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

1121 # max_search_results/output_file) BEFORE building params. See 

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

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

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

1125 # it can reach quick_summary — see _ACCEPTED_BUT_INEFFECTIVE_PARAMS. 

1126 unsafe_param_error = _reject_unsafe_body_params(data) 

1127 if unsafe_param_error is not None: 

1128 return unsafe_param_error 

1129 

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

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

1132 # an opaque 500 into a clear 400. 

1133 type_error = _validate_param_types(data) 

1134 if type_error is not None: 

1135 return type_error 

1136 

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

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

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

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

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

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

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

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

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

1146 params: Dict[str, Any] = { 

1147 k: v 

1148 for k, v in data.items() 

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

1150 } 

1151 # search_tool and iterations deliberately have no default here: when 

1152 # omitted, _init_search_system reads the user's configured values from 

1153 # the settings snapshot. Defaulting iterations to 1 would clobber 

1154 # stored search.iterations. ``temperature`` is popped out (not 

1155 # defaulted) here — quick_summary only reads its own temperature 

1156 # argument in the branch that builds a settings_snapshot from scratch, 

1157 # which the REST path never takes; see _pop_ineffective_params. 

1158 param_warnings = _pop_ineffective_params(params) 

1159 

1160 error = await run_db_sync( 

1161 _load_user_context_into_params, params, username, allow_default_settings 

1162 ) 

1163 if error is not None: 

1164 return error 

1165 

1166 from ...security.egress.policy import PolicyDeniedError 

1167 

1168 try: 

1169 from ...api.research_functions import quick_summary 

1170 

1171 # Keep long research calls off the default executor used by every 

1172 # authenticated request's database check. The dedicated dispatch 

1173 # still cleans up worker-owned sessions and thread context. 

1174 result = await run_research_sync(quick_summary, query, **params) 

1175 

1176 # Serialize Document objects 

1177 converted = result.copy() 

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

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

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

1181 "metadata": doc.metadata, 

1182 "content": doc.page_content, 

1183 } 

1184 

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

1186 # response leaves. See _scrub_error_fields for rationale. 

1187 _scrub_error_fields(converted) 

1188 

1189 if param_warnings: 

1190 converted["warnings"] = param_warnings 

1191 

1192 return converted 

1193 except TimeoutError: 

1194 logger.exception("Request timed out") 

1195 return JSONResponse( 

1196 { 

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

1198 }, 

1199 status_code=504, 

1200 ) 

1201 except PolicyDeniedError as exc: 

1202 # A STRICT blank-primary (or other policy) denial from 

1203 # _init_search_system / the factory PEP must surface as a curated 

1204 # 400, not the generic 500 below (which would swallow it before 

1205 # the global PolicyDeniedError handler can render it). Keeps the 

1206 # error contract consistent with POST /api/start_research. 

1207 reason = getattr(getattr(exc, "decision", None), "reason", "denied") 

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

1209 "POST /api/v1/quick_summary policy denied", 

1210 reason=reason, 

1211 ) 

1212 return JSONResponse( 

1213 {"error": f"Egress policy refused this request: {reason}"}, 

1214 status_code=400, 

1215 ) 

1216 except Exception: 

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

1218 return JSONResponse( 

1219 { 

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

1221 }, 

1222 status_code=500, 

1223 ) 

1224 

1225 

1226@router.post("/generate_report") 

1227@api_rate_limit 

1228async def api_generate_report( 

1229 request: Request, 

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

1231): 

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

1233 if not is_json_request(request): 

1234 return JSONResponse( 

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

1236 ) 

1237 try: 

1238 data = await request.json() 

1239 except Exception: 

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

1241 

1242 if not isinstance(data, dict): 

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

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

1245 # require_json_body enforced an object. 

1246 return JSONResponse( 

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

1248 ) 

1249 

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

1251 return JSONResponse( 

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

1253 ) 

1254 

1255 query = data.get("query") 

1256 if not isinstance(query, str): 

1257 return JSONResponse( 

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

1259 ) 

1260 

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

1262 # progress_callback/openai_endpoint_url/research_id/programmatic_mode/ 

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

1264 # max_search_results/output_file) BEFORE building params. See 

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

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

1267 # api_quick_summary and _ACCEPTED_BUT_INEFFECTIVE_PARAMS. 

1268 unsafe_param_error = _reject_unsafe_body_params(data) 

1269 if unsafe_param_error is not None: 

1270 return unsafe_param_error 

1271 

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

1273 # unconditional operation on them — see api_quick_summary and 

1274 # _TYPE_VALIDATED_PARAMS. 

1275 type_error = _validate_param_types(data) 

1276 if type_error is not None: 

1277 return type_error 

1278 

1279 # See api_quick_summary for the allow_default_settings semantics 

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

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

1282 params = { 

1283 k: v 

1284 for k, v in data.items() 

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

1286 } 

1287 params.setdefault("searches_per_section", 1) 

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

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

1290 param_warnings = _pop_ineffective_params(params) 

1291 

1292 error = await run_db_sync( 

1293 _load_user_context_into_params, params, username, allow_default_settings 

1294 ) 

1295 if error is not None: 

1296 return error 

1297 

1298 from ...security.egress.policy import PolicyDeniedError 

1299 

1300 try: 

1301 from ...api.research_functions import generate_report 

1302 

1303 # See quick_summary: isolate long research from login DB checks. 

1304 result = await run_research_sync(generate_report, query, **params) 

1305 

1306 if ( 

1307 result 

1308 and "content" in result 

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

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

1311 ): 

1312 result["content"] = ( 

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

1314 ) 

1315 result["content_truncated"] = True 

1316 

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

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

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

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

1321 # error-carrying fields. 

1322 _scrub_error_fields(result) 

1323 

1324 if param_warnings and isinstance(result, dict): 

1325 result["warnings"] = param_warnings 

1326 

1327 return result 

1328 except TimeoutError: 

1329 return JSONResponse( 

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

1331 status_code=504, 

1332 ) 

1333 except PolicyDeniedError as exc: 

1334 # Same 500-vs-400 contract as api_quick_summary above. 

1335 reason = getattr(getattr(exc, "decision", None), "reason", "denied") 

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

1337 "POST /api/v1/generate_report policy denied", 

1338 reason=reason, 

1339 ) 

1340 return JSONResponse( 

1341 {"error": f"Egress policy refused this request: {reason}"}, 

1342 status_code=400, 

1343 ) 

1344 except Exception: 

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

1346 return JSONResponse( 

1347 { 

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

1349 }, 

1350 status_code=500, 

1351 ) 

1352 

1353 

1354@router.post("/analyze_documents") 

1355@api_rate_limit 

1356async def api_analyze_documents( 

1357 request: Request, 

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

1359): 

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

1361 if not is_json_request(request): 

1362 return JSONResponse( 

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

1364 status_code=400, 

1365 ) 

1366 try: 

1367 data = await request.json() 

1368 except Exception: 

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

1370 

1371 if not isinstance(data, dict): 

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

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

1374 # require_json_body enforced an object. 

1375 return JSONResponse( 

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

1377 ) 

1378 

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

1380 return JSONResponse( 

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

1382 status_code=400, 

1383 ) 

1384 

1385 query = data.get("query") 

1386 collection_name = data.get("collection_name") 

1387 if not isinstance(query, str): 

1388 return JSONResponse( 

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

1390 ) 

1391 if not isinstance(collection_name, str): 

1392 return JSONResponse( 

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

1394 ) 

1395 # See api_quick_summary for the allow_default_settings semantics 

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

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

1398 params = { 

1399 k: v 

1400 for k, v in data.items() 

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

1402 } 

1403 

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

1405 if unknown_params: 

1406 return JSONResponse( 

1407 { 

1408 "error": ( 

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

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

1411 ), 

1412 "allowed_parameters": sorted(_ANALYZE_DOCUMENTS_PARAMS), 

1413 }, 

1414 status_code=400, 

1415 ) 

1416 

1417 error = await run_db_sync( 

1418 _load_user_context_into_params, params, username, allow_default_settings 

1419 ) 

1420 if error is not None: 

1421 return error 

1422 

1423 from ...security.egress.policy import PolicyDeniedError 

1424 

1425 try: 

1426 # Local-collection search can also be long-running. 

1427 result = await run_research_sync( 

1428 analyze_documents, query, collection_name, **params 

1429 ) 

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

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

1432 _scrub_error_fields(result) 

1433 return result 

1434 except PolicyDeniedError as exc: 

1435 # Same 500-vs-400 contract as api_quick_summary/generate_report: 

1436 # a STRICT blank-primary (or other policy) denial from get_llm / 

1437 # get_search must surface as a curated 400, not the generic 500. 

1438 reason = getattr(getattr(exc, "decision", None), "reason", "denied") 

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

1440 "POST /api/v1/analyze_documents policy denied", 

1441 reason=reason, 

1442 ) 

1443 return JSONResponse( 

1444 {"error": f"Egress policy refused this request: {reason}"}, 

1445 status_code=400, 

1446 ) 

1447 except Exception: 

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

1449 return JSONResponse( 

1450 { 

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

1452 }, 

1453 status_code=500, 

1454 )