Coverage for src/local_deep_research/web/routers/api.py: 99%
208 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
1from fastapi import APIRouter, Depends, Request
2from fastapi.responses import JSONResponse
3from ..dependencies.auth import require_auth
4from ..dependencies.threadpool import run_db_sync
6import requests
8from loguru import logger
10from ...llm.providers.base import normalize_provider
11from ...constants import ResearchStatus
12from ...database.models import QueuedResearch, ResearchHistory
13from ...database.session_context import get_user_db_session
14from ...config.constants import DEFAULT_OLLAMA_URL
15from ...utilities.url_utils import normalize_url
17from .notes import _log_value_preview
18from .research import _research_not_found
19from ..services.research_service import (
20 cancel_research,
21)
22from ..services.resource_service import (
23 add_resource,
24 delete_resource,
25 get_resources_for_research,
26)
27from local_deep_research.settings import SettingsManager
28from ...security import redact_url_for_log, safe_get, strip_settings_snapshot
29from ..dependencies.json_body import (
30 is_json_request,
31 json_body_error,
32 read_json_dict,
33)
34from typing import Annotated
36# Create the router
37router = APIRouter(prefix="/research/api", tags=["api"])
39# NOTE: Routes use username (not .get()) intentionally.
40# Depends(require_auth) guarantees the key exists; direct access fails
41# fast if the dependency is ever removed.
44@router.get("/settings/current-config")
45def get_current_config(
46 request: Request, username: Annotated[str, Depends(require_auth)]
47):
48 """Get the current configuration from database settings."""
49 try:
50 with get_user_db_session(username) as session:
51 settings_manager = SettingsManager(session)
52 config = {
53 "provider": settings_manager.get_setting(
54 "llm.provider", "Not configured"
55 ),
56 "model": settings_manager.get_setting(
57 "llm.model", "Not configured"
58 ),
59 "search_tool": settings_manager.get_setting(
60 "search.tool", "searxng"
61 ),
62 "iterations": settings_manager.get_setting(
63 "search.iterations", 8
64 ),
65 "questions_per_iteration": settings_manager.get_setting(
66 "search.questions_per_iteration", 5
67 ),
68 "search_strategy": settings_manager.get_setting(
69 "search.search_strategy", "focused_iteration"
70 ),
71 }
73 return {"success": True, "config": config}
75 except Exception:
76 logger.exception("Error getting current config")
77 return JSONResponse(
78 {"success": False, "error": "An internal error occurred"},
79 status_code=500,
80 )
83# API Routes
84@router.post("/start")
85async def api_start_research(
86 request: Request, username: Annotated[str, Depends(require_auth)]
87):
88 """
89 Start a new research process.
91 Delegates to the full-featured start_research() in the research router,
92 which reads settings from the database, handles queueing, and starts
93 the research thread.
94 """
95 from .research import start_research
97 return await start_research(request=request, username=username)
100@router.get("/status/{research_id}")
101def api_research_status(
102 request: Request,
103 research_id,
104 username: Annotated[str, Depends(require_auth)],
105):
106 """
107 Get the status of a research process
108 """
109 try:
110 # Get a fresh session to avoid conflicts with the research process
112 with get_user_db_session(username) as db_session:
113 research = (
114 db_session.query(ResearchHistory)
115 .filter_by(id=research_id)
116 .first()
117 )
119 if research is None:
120 return _research_not_found(research_id)
122 # Extract attributes while session is active
123 # to avoid DetachedInstanceError after the with block exits
124 result = {
125 "status": research.status,
126 "progress": research.progress,
127 "completed_at": research.completed_at,
128 # report_path deliberately omitted: absolute server-side
129 # path, which leaks directory layout — same rule as
130 # /library/api/check-downloads and the history
131 # status/detail endpoints.
132 "metadata": strip_settings_snapshot(research.research_meta),
133 }
135 # Include queue position for queued research so the progress
136 # page can show "position N in queue" (#3283).
137 if research.status == ResearchStatus.QUEUED:
138 queued = (
139 db_session.query(QueuedResearch)
140 .filter_by(research_id=research_id)
141 .first()
142 )
143 if queued: 143 ↛ 146line 143 didn't jump to line 146 because the condition on line 143 was always true
144 result["queue_position"] = queued.position
146 return result
148 except Exception:
149 logger.exception("Error getting research status")
150 return JSONResponse(
151 {"status": "error", "message": "Failed to get research status"},
152 status_code=500,
153 )
156@router.post("/terminate/{research_id}")
157def api_terminate_research(
158 request: Request,
159 research_id,
160 username: Annotated[str, Depends(require_auth)],
161):
162 """
163 Terminate a research process
164 """
165 try:
166 result = cancel_research(research_id, username)
167 if result:
168 return {
169 "status": "success",
170 "message": "Research terminated",
171 "result": result,
172 }
174 return {
175 "status": "success",
176 "message": "Research not found or already completed",
177 "result": result,
178 }
180 except Exception:
181 logger.exception("Error terminating research")
182 return JSONResponse(
183 {"status": "error", "message": "Failed to stop research."},
184 status_code=500,
185 )
188@router.get("/resources/{research_id}")
189def api_get_resources(
190 request: Request,
191 research_id,
192 username: Annotated[str, Depends(require_auth)],
193):
194 """
195 Get resources for a specific research.
197 User-scoped: each user has their own encrypted database, so a
198 ``research_id`` belonging to another user simply yields an empty
199 list rather than leaking their data.
200 """
201 try:
202 resources = get_resources_for_research(research_id, username)
203 return {"status": "success", "resources": resources}
204 except Exception:
205 logger.exception("Error getting resources for research")
206 return JSONResponse(
207 {"status": "error", "message": "Failed to get resources"},
208 status_code=500,
209 )
212@router.post("/resources/{research_id}")
213async def api_add_resource(
214 request: Request,
215 research_id,
216 username: Annotated[str, Depends(require_auth)],
217):
218 """
219 Add a new resource to a research project
220 """
222 if not is_json_request(request):
223 return json_body_error("status", "Request body must be valid JSON")
225 data, error = await read_json_dict(request, "status")
226 if error is not None:
227 return error
229 def _impl():
230 try:
231 # Required fields
232 title = data.get("title")
233 url = data.get("url")
235 # Optional fields
236 content_preview = data.get("content_preview")
237 source_type = data.get("source_type", "web")
238 metadata = data.get("metadata", {})
240 # Validate required fields
241 if not title or not url:
242 return JSONResponse(
243 {
244 "status": "error",
245 "message": "Title and URL are required",
246 },
247 status_code=400,
248 )
250 # Security: Validate URL to prevent SSRF attacks
251 from ...security.ssrf_validator import validate_url
253 is_valid = validate_url(url)
254 if not is_valid:
255 if isinstance(url, str):
256 logger.warning(
257 "SSRF protection: Rejected URL {}",
258 _log_value_preview(redact_url_for_log(url)),
259 )
260 else:
261 logger.warning("SSRF protection: Rejected URL (non-string)")
262 return JSONResponse(
263 {"status": "error", "message": "Invalid URL"},
264 status_code=400,
265 )
267 # Check if the research exists
268 with get_user_db_session(username) as db_session:
269 research = (
270 db_session.query(ResearchHistory)
271 .filter_by(id=research_id)
272 .first()
273 )
275 if not research:
276 return _research_not_found(research_id)
278 # Add the resource
279 resource_id = add_resource(
280 research_id=research_id,
281 title=title,
282 url=url,
283 content_preview=content_preview,
284 source_type=source_type,
285 metadata=metadata,
286 username=username,
287 )
289 return {
290 "status": "success",
291 "message": "Resource added successfully",
292 "resource_id": resource_id,
293 }
295 except Exception:
296 logger.exception("Error adding resource")
297 return JSONResponse(
298 {"status": "error", "message": "Failed to add resource"},
299 status_code=500,
300 )
302 return await run_db_sync(_impl)
305@router.delete("/resources/{research_id}/delete/{resource_id}")
306def api_delete_resource(
307 request: Request,
308 research_id,
309 # Typed int, matching Flask's <int:resource_id> converter: without it a
310 # non-numeric segment is passed straight through to the service layer
311 # instead of being rejected as a 422 by the router.
312 resource_id: int,
313 username: Annotated[str, Depends(require_auth)],
314):
315 """
316 Delete a resource from a research project
317 """
318 try:
319 # delete_resource() always returns True or raises ValueError("...
320 # not found") — never False — so the not-found case is handled by
321 # the except ValueError below, not by checking the return value.
322 delete_resource(resource_id, username=username)
323 return {
324 "status": "success",
325 "message": "Resource deleted successfully",
326 }
327 except ValueError:
328 return JSONResponse(
329 {"status": "error", "message": "Resource not found"},
330 status_code=404,
331 )
332 except Exception:
333 logger.exception("Error deleting resource")
334 return JSONResponse(
335 {
336 "status": "error",
337 "message": "An internal error occurred while deleting the resource.",
338 },
339 status_code=500,
340 )
343def _ollama_base_url_from_config(raw_ollama_base_url):
344 """Resolve the Ollama base URL from a raw setting value (normalized, with
345 the default fallback). Single source so every Ollama probe targets the
346 same URL."""
347 return (
348 normalize_url(raw_ollama_base_url)
349 if raw_ollama_base_url
350 else DEFAULT_OLLAMA_URL
351 )
354def _probe_ollama_tags(base_url, timeout=5):
355 """Probe Ollama ``/api/tags`` once and classify the outcome.
357 Single source for the resolve→fetch→new/old-format-parse→error logic that
358 was previously copy-pasted across the status and model-availability checks
359 (so "is Ollama up?" can no longer answer differently per caller). Returns
360 ``(outcome, probe_result)`` where outcome is one of:
362 - ``"ok"`` → probe_result is the list of model dicts (both API formats handled)
363 - ``"bad_status"`` → probe_result is the non-200 status code
364 - ``"invalid_json"`` → probe_result is None (200 but unparseable body)
365 - ``"connection_error"`` / ``"timeout"`` → probe_result is None
367 Callers map the outcome onto their own response shape.
368 """
369 try:
370 response = safe_get(
371 f"{base_url}/api/tags",
372 timeout=timeout,
373 allow_localhost=True,
374 allow_private_ips=True,
375 )
376 except requests.exceptions.ConnectionError:
377 return "connection_error", None
378 except requests.exceptions.Timeout:
379 return "timeout", None
381 if response.status_code != 200:
382 return "bad_status", response.status_code
384 try:
385 data = response.json()
386 except ValueError:
387 return "invalid_json", None
389 # New Ollama API nests the list under "models"; the older format is a
390 # bare list. Mirror the previous inline check exactly (a bare ``"models"
391 # in data`` membership test, no isinstance guard) so behavior is identical
392 # to the pre-refactor endpoints — a malformed non-dict/non-list body
393 # raises here and is handled by each caller's outer except, as before.
394 if "models" in data:
395 models = data.get("models", [])
396 else:
397 models = data
398 return "ok", models
401@router.get("/check/ollama_status")
402def check_ollama_status(
403 request: Request, username: Annotated[str, Depends(require_auth)]
404):
405 """
406 Check if Ollama API is running
407 """
408 try:
409 # Get the Ollama provider/URL from the user's settings (the Flask
410 # version read these from app.config["LLM_CONFIG"]).
411 with get_user_db_session(username) as session:
412 settings_manager = SettingsManager(session)
413 provider = settings_manager.get_setting("llm.provider", "ollama")
414 raw_ollama_base_url = settings_manager.get_setting(
415 "llm.ollama.url", DEFAULT_OLLAMA_URL
416 )
418 if normalize_provider(provider) != "ollama":
419 return {
420 "running": True,
421 "message": f"Using provider: {provider}, not Ollama",
422 }
424 ollama_base_url = _ollama_base_url_from_config(raw_ollama_base_url)
425 logger.info(
426 f"Checking Ollama status at: {redact_url_for_log(ollama_base_url)}"
427 )
429 outcome, probe_result = _probe_ollama_tags(ollama_base_url)
431 if outcome == "ok":
432 model_count = len(probe_result)
433 logger.info(f"Ollama service is running with {model_count} models")
434 return {
435 "running": True,
436 "message": f"Ollama service is running with {model_count} models",
437 "model_count": model_count,
438 }
439 if outcome == "invalid_json":
440 logger.warning("Ollama returned invalid JSON")
441 return {
442 "running": True,
443 "message": "Ollama service is running but returned invalid data format",
444 "error_details": "Invalid response format from the service.",
445 }
446 if outcome == "bad_status":
447 logger.warning(
448 f"Ollama returned non-200 status code: {probe_result}"
449 )
450 return {
451 "running": False,
452 "message": f"Ollama service returned status code: {probe_result}",
453 "status_code": probe_result,
454 }
455 if outcome == "connection_error":
456 logger.warning("Ollama connection error")
457 return {
458 "running": False,
459 "message": "Ollama service is not running or not accessible",
460 "error_type": "connection_error",
461 "error_details": "Unable to connect to the service. Please check if the service is running.",
462 }
463 # outcome == "timeout"
464 logger.warning("Ollama request timed out")
465 return {
466 "running": False,
467 "message": "Ollama service request timed out after 5 seconds",
468 "error_type": "timeout",
469 "error_details": "Request timed out. The service may be overloaded.",
470 }
472 except Exception:
473 logger.exception("Error checking Ollama status")
474 return {
475 "running": False,
476 "message": "An internal error occurred while checking Ollama status.",
477 "error_type": "exception",
478 "error_details": "An internal error occurred.",
479 }
482@router.get("/check/ollama_model")
483def check_ollama_model(
484 request: Request, username: Annotated[str, Depends(require_auth)]
485):
486 """
487 Check if the configured Ollama model is available
488 """
489 try:
490 # Get the Ollama provider/model/URL from the user's settings (the
491 # Flask version read these from app.config["LLM_CONFIG"]).
492 with get_user_db_session(username) as session:
493 settings_manager = SettingsManager(session)
494 provider = settings_manager.get_setting("llm.provider", "ollama")
495 configured_model = settings_manager.get_setting("llm.model", "")
496 raw_ollama_base_url = settings_manager.get_setting(
497 "llm.ollama.url", DEFAULT_OLLAMA_URL
498 )
500 if normalize_provider(provider) != "ollama":
501 return {
502 "available": True,
503 "message": f"Using provider: {provider}, not Ollama",
504 "provider": provider,
505 }
507 # Get model name from request or fall back to the configured default
508 model_name = request.query_params.get("model")
509 if not model_name:
510 model_name = configured_model
512 if not model_name:
513 logger.warning(
514 "/api/check/ollama_model called with no model name and "
515 "llm.model is not configured"
516 )
517 return JSONResponse(
518 {
519 "available": False,
520 "model": "",
521 "message": (
522 "Model is required. Pass ?model=<name> in the "
523 "query string, or configure llm.model in Settings."
524 ),
525 "error_type": "model_not_configured",
526 },
527 status_code=400,
528 )
530 # Log which model we're checking for debugging
531 logger.info(
532 f"Checking availability of Ollama model: {_log_value_preview(model_name)}"
533 )
535 ollama_base_url = _ollama_base_url_from_config(raw_ollama_base_url)
537 outcome, probe_result = _probe_ollama_tags(ollama_base_url)
539 if outcome == "bad_status":
540 logger.warning(
541 f"Ollama API returned non-200 status: {probe_result}"
542 )
543 return {
544 "available": False,
545 "model": model_name,
546 "message": f"Could not access Ollama service - status code: {probe_result}",
547 "status_code": probe_result,
548 }
549 if outcome == "invalid_json":
550 logger.warning("Failed to parse Ollama API response")
551 return {
552 "available": False,
553 "model": model_name,
554 "message": "Invalid response from Ollama API",
555 "error_type": "json_parse_error",
556 }
557 if outcome == "connection_error":
558 logger.warning("Connection error to Ollama API")
559 return {
560 "available": False,
561 "model": model_name,
562 "message": "Could not connect to Ollama service",
563 "error_type": "connection_error",
564 "error_details": "Unable to connect to the service. Please check if the service is running.",
565 }
566 if outcome == "timeout":
567 logger.warning("Timeout connecting to Ollama API")
568 return {
569 "available": False,
570 "model": model_name,
571 "message": "Connection to Ollama service timed out",
572 "error_type": "timeout",
573 }
575 # outcome == "ok"
576 models = probe_result
577 model_names = [m.get("name", "") for m in models]
578 logger.debug(
579 f"Available Ollama models: {', '.join(model_names[:10])}"
580 + (
581 f" and {len(model_names) - 10} more"
582 if len(model_names) > 10
583 else ""
584 )
585 )
587 # Case-insensitive model name comparison
588 model_exists = any(
589 m.get("name", "").lower() == model_name.lower() for m in models
590 )
592 if model_exists:
593 logger.info(f"Ollama model {model_name} is available")
594 return {
595 "available": True,
596 "model": model_name,
597 "message": f"Model {model_name} is available",
598 "all_models": model_names,
599 }
600 # Check if models were found at all
601 if not models:
602 logger.warning("No models found in Ollama")
603 message = "No models found in Ollama. Please pull models first."
604 else:
605 logger.warning(
606 f"Model {_log_value_preview(model_name)} not found among {len(models)} available models"
607 )
608 # Don't expose available models for security reasons
609 message = f"Model {model_name} is not available"
611 return {
612 "available": False,
613 "model": model_name,
614 "message": message,
615 # Remove all_models to prevent information disclosure
616 }
618 except Exception:
619 # General exception
620 logger.exception("Error checking Ollama model")
622 return {
623 "available": False,
624 "model": model_name if "model_name" in locals() else "",
625 "message": "An internal error occurred while checking the model.",
626 "error_type": "exception",
627 "error_details": "An internal error occurred.",
628 }