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
« 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.
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 ...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)
43router = APIRouter(prefix="/api/v1", tags=["API v1"])
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
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).
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.
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 )
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)
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"})
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"})
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"})
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)
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)
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"})
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"})
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)
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.
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
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.
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
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}
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"})
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.
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 )
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 )
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 )
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_bool_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": "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 }
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 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.
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
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 )
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)
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 )
1107 if not data or "query" not in data:
1108 return JSONResponse(
1109 {"error": "Query parameter is required"}, status_code=400
1110 )
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 )
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
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
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)
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
1166 from ...security.egress.policy import PolicyDeniedError
1168 try:
1169 from ...api.research_functions import quick_summary
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)
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 }
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)
1189 if param_warnings:
1190 converted["warnings"] = param_warnings
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 )
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)
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 )
1250 if not data or "query" not in data:
1251 return JSONResponse(
1252 {"error": "Query parameter is required"}, status_code=400
1253 )
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 )
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
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
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)
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
1298 from ...security.egress.policy import PolicyDeniedError
1300 try:
1301 from ...api.research_functions import generate_report
1303 # See quick_summary: isolate long research from login DB checks.
1304 result = await run_research_sync(generate_report, query, **params)
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
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)
1324 if param_warnings and isinstance(result, dict):
1325 result["warnings"] = param_warnings
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 )
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)
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 )
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 )
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 }
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 )
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
1423 from ...security.egress.policy import PolicyDeniedError
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 )