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
« 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.
4Ports Flask's api.py blueprint to FastAPI APIRouter.
5Provides health check and programmatic research endpoints.
6"""
8import inspect
9import os
10import threading
11import time
12from typing import Any, Dict, Optional, Annotated
14try:
15 import resource as _resource_mod
16except ImportError:
17 _resource_mod = None # Windows: no POSIX resource limits
19from fastapi import APIRouter, Depends, HTTPException, Request
20from fastapi.responses import JSONResponse
21from loguru import logger
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
35router = APIRouter(prefix="/api/v1", tags=["API v1"])
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
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).
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.
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 )
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)
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"})
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"})
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"})
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)
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)
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"})
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"})
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)
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.
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
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.
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
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}
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"})
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.
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
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.
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 """
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 )
746 api_enabled, rate_limit_value = await run_db_sync(_read_settings)
748 if not api_enabled:
749 raise HTTPException(status_code=403, detail="API access is disabled")
751 set_request_api_rate_limit(rate_limit_value)
752 return username
755@router.get("/health")
756def health_check(
757 username: Annotated[str | None, Depends(get_session_username)],
758):
759 """Health check endpoint (no auth required).
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`.
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] = {}
775 # Research queue processor — the worker thread should be alive
776 try:
777 from ..queue.processor_v2 import queue_processor
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"
789 # Database manager — the singleton should be importable and respond
790 try:
791 from ...database.encrypted_db import db_manager
793 _ = db_manager.has_encryption
794 subsystems["db_manager"] = "ok"
795 except Exception:
796 subsystems["db_manager"] = "error"
798 diagnostics: Dict[str, Any] = {
799 "status": "ok",
800 "message": "API is running",
801 "timestamp": time.time(),
802 }
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
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
830 thread_count = threading.active_count()
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 )
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 }
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 )
854 return diagnostics
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.
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.
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.
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.
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.
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 }
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.
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.
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.
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.
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
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 )
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)
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 )
1067 if not data or "query" not in data:
1068 return JSONResponse(
1069 {"error": "Query parameter is required"}, status_code=400
1070 )
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 )
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
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
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)
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
1127 try:
1128 from ...api.research_functions import quick_summary
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)
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 }
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)
1148 if param_warnings:
1149 converted["warnings"] = param_warnings
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 )
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)
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 )
1190 if not data or "query" not in data:
1191 return JSONResponse(
1192 {"error": "Query parameter is required"}, status_code=400
1193 )
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 )
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
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
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)
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
1238 try:
1239 from ...api.research_functions import generate_report
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)
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
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)
1263 if param_warnings and isinstance(result, dict):
1264 result["warnings"] = param_warnings
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 )
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)
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 )
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 )
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 }
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 )
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
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 )