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