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

1from fastapi import APIRouter, Depends, Request 

2from fastapi.responses import JSONResponse 

3from ..dependencies.auth import require_auth 

4from ..dependencies.threadpool import run_db_sync 

5 

6import requests 

7 

8from loguru import logger 

9 

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 

16 

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 

35 

36# Create the router 

37router = APIRouter(prefix="/research/api", tags=["api"]) 

38 

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. 

42 

43 

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 } 

72 

73 return {"success": True, "config": config} 

74 

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 ) 

81 

82 

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. 

90 

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 

96 

97 return await start_research(request=request, username=username) 

98 

99 

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 

111 

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 ) 

118 

119 if research is None: 

120 return _research_not_found(research_id) 

121 

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 } 

134 

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 

145 

146 return result 

147 

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 ) 

154 

155 

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 } 

173 

174 return { 

175 "status": "success", 

176 "message": "Research not found or already completed", 

177 "result": result, 

178 } 

179 

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 ) 

186 

187 

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. 

196 

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 ) 

210 

211 

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 """ 

221 

222 if not is_json_request(request): 

223 return json_body_error("status", "Request body must be valid JSON") 

224 

225 data, error = await read_json_dict(request, "status") 

226 if error is not None: 

227 return error 

228 

229 def _impl(): 

230 try: 

231 # Required fields 

232 title = data.get("title") 

233 url = data.get("url") 

234 

235 # Optional fields 

236 content_preview = data.get("content_preview") 

237 source_type = data.get("source_type", "web") 

238 metadata = data.get("metadata", {}) 

239 

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 ) 

249 

250 # Security: Validate URL to prevent SSRF attacks 

251 from ...security.ssrf_validator import validate_url 

252 

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 ) 

266 

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 ) 

274 

275 if not research: 

276 return _research_not_found(research_id) 

277 

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 ) 

288 

289 return { 

290 "status": "success", 

291 "message": "Resource added successfully", 

292 "resource_id": resource_id, 

293 } 

294 

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 ) 

301 

302 return await run_db_sync(_impl) 

303 

304 

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 ) 

341 

342 

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 ) 

352 

353 

354def _probe_ollama_tags(base_url, timeout=5): 

355 """Probe Ollama ``/api/tags`` once and classify the outcome. 

356 

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: 

361 

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 

366 

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 

380 

381 if response.status_code != 200: 

382 return "bad_status", response.status_code 

383 

384 try: 

385 data = response.json() 

386 except ValueError: 

387 return "invalid_json", None 

388 

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 

399 

400 

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 ) 

417 

418 if normalize_provider(provider) != "ollama": 

419 return { 

420 "running": True, 

421 "message": f"Using provider: {provider}, not Ollama", 

422 } 

423 

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 ) 

428 

429 outcome, probe_result = _probe_ollama_tags(ollama_base_url) 

430 

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 } 

471 

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 } 

480 

481 

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 ) 

499 

500 if normalize_provider(provider) != "ollama": 

501 return { 

502 "available": True, 

503 "message": f"Using provider: {provider}, not Ollama", 

504 "provider": provider, 

505 } 

506 

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 

511 

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 ) 

529 

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 ) 

534 

535 ollama_base_url = _ollama_base_url_from_config(raw_ollama_base_url) 

536 

537 outcome, probe_result = _probe_ollama_tags(ollama_base_url) 

538 

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 } 

574 

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 ) 

586 

587 # Case-insensitive model name comparison 

588 model_exists = any( 

589 m.get("name", "").lower() == model_name.lower() for m in models 

590 ) 

591 

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" 

610 

611 return { 

612 "available": False, 

613 "model": model_name, 

614 "message": message, 

615 # Remove all_models to prevent information disclosure 

616 } 

617 

618 except Exception: 

619 # General exception 

620 logger.exception("Error checking Ollama model") 

621 

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 }