Coverage for src/local_deep_research/web/routers/context_overflow_api.py: 99%

117 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1"""API endpoints for context overflow analytics.""" 

2 

3from fastapi import APIRouter, Depends, Request 

4from fastapi.responses import JSONResponse 

5from ..dependencies.auth import require_auth 

6 

7from datetime import datetime, timedelta, timezone 

8from sqlalchemy import func, desc 

9from loguru import logger 

10 

11from ...database.session_context import get_user_db_session 

12from ...database.models import TokenUsage 

13from ...metrics.query_utils import get_context_overflow_truncation_summary 

14from ...settings import SettingsManager 

15from typing import Annotated 

16 

17#: Upper bound on ``page``. Beyond this the OFFSET is meaningless and a 

18#: crafted value overflows SQLite's signed 64-bit integer. 

19_MAX_PAGE = 10_000 

20 

21router = APIRouter(tags=["context_overflow_api"]) 

22 

23# NOTE: Routes use username (not .get()) intentionally. 

24# Depends(require_auth) guarantees the key exists; direct access fails 

25# fast if the dependency is ever removed. 

26 

27 

28@router.get("/api/context-overflow") 

29def get_context_overflow_metrics( 

30 request: Request, username: Annotated[str, Depends(require_auth)] 

31): 

32 """Get context overflow metrics for the current user.""" 

33 try: 

34 # Get username from session 

35 

36 # Get time period from query params (whitelist valid values) 

37 VALID_PERIODS = {"7d", "30d", "3m", "1y", "all"} 

38 period = request.query_params.get("period", "30d") 

39 if period not in VALID_PERIODS: 

40 period = "30d" 

41 

42 # Pagination params for all_requests. 

43 # 

44 # Fall back to the default on an unparseable value rather than 

45 # letting int() raise. Main read these through Flask's 

46 # `request.args.get("page", 1, type=int)`, whose type= conversion 

47 # swallows the ValueError and returns the default — so `?page=abc` 

48 # rendered page 1. A bare int() here turns that same stale 

49 # bookmark into a 500 from the global handler, i.e. a client typo 

50 # reported as a backend outage on the analytics dashboard. Every 

51 # sibling paging route on this branch (history, library, rag, 

52 # research, benchmark) already guards this way; this one was 

53 # missed. 

54 try: 

55 page = int(request.query_params.get("page", "1")) 

56 except (TypeError, ValueError): 

57 page = 1 

58 # Upper-bound the page too, not just the lower bound. A numerically 

59 # valid but astronomical ?page=10**40 reaches .offset() and raises 

60 # OverflowError from SQLite's 64-bit integer conversion, which the 

61 # enclosing handler turns into a 500. (The existing guard only 

62 # rescues NON-numeric input; this is the numeric-but-huge case.) 

63 # Mirrors metrics.py's _MAX_PAGE, which already does this correctly. 

64 page = max(1, min(page, _MAX_PAGE)) 

65 try: 

66 per_page = int(request.query_params.get("per_page", "50")) 

67 except (TypeError, ValueError): 

68 per_page = 50 

69 per_page = max(1, min(per_page, 500)) 

70 

71 # Calculate date filter (use timezone-aware datetime) 

72 start_date = None 

73 if period != "all": 

74 now = datetime.now(timezone.utc) 

75 if period == "7d": 

76 start_date = now - timedelta(days=7) 

77 elif period == "30d": 

78 start_date = now - timedelta(days=30) 

79 elif period == "3m": 

80 start_date = now - timedelta(days=90) 

81 elif period == "1y": 81 ↛ 84line 81 didn't jump to line 84 because the condition on line 81 was always true

82 start_date = now - timedelta(days=365) 

83 

84 with get_user_db_session(username) as session: 

85 # Truncation summary — shared with /metrics/api/metrics so the 

86 # main dashboard's at-a-glance numbers cannot disagree with this 

87 # endpoint's deep-dive. Helper internally uses 

88 # get_time_filter_condition, equivalent to the start_date below. 

89 summary = get_context_overflow_truncation_summary(session, period) 

90 total_requests = summary["total_requests"] 

91 requests_with_context = summary["requests_with_context"] 

92 truncated_requests = summary["truncated_requests"] 

93 truncation_rate = summary["truncation_rate"] 

94 avg_tokens_truncated = summary["avg_tokens_truncated"] 

95 

96 # Base query — kept for downstream phase / chart_data / all_requests 

97 # aggregations that share the same time window. 

98 query = session.query(TokenUsage) 

99 if start_date: 

100 query = query.filter(TokenUsage.timestamp >= start_date) 

101 

102 token_summary = { 

103 "total_requests": total_requests, 

104 "total_tokens": summary["total_tokens"], 

105 "total_prompt_tokens": summary["total_prompt_tokens"], 

106 "total_completion_tokens": summary["total_completion_tokens"], 

107 "avg_prompt_tokens": round(summary["avg_prompt_tokens"], 0), 

108 "avg_completion_tokens": round( 

109 summary["avg_completion_tokens"], 0 

110 ), 

111 "max_prompt_tokens": summary["max_prompt_tokens"], 

112 } 

113 

114 # --- Model token stats (always populated, no context_limit filter) --- 

115 model_token_query = ( 

116 query.with_entities( 

117 TokenUsage.model_name, 

118 TokenUsage.model_provider, 

119 func.count(TokenUsage.id).label("total_requests"), 

120 func.coalesce(func.sum(TokenUsage.total_tokens), 0).label( 

121 "total_tokens" 

122 ), 

123 func.min(TokenUsage.prompt_tokens).label("min_prompt"), 

124 func.avg(TokenUsage.prompt_tokens).label("avg_prompt"), 

125 func.max(TokenUsage.prompt_tokens).label("max_prompt"), 

126 func.avg(TokenUsage.response_time_ms).label( 

127 "avg_response_time_ms" 

128 ), 

129 ) 

130 .group_by(TokenUsage.model_name, TokenUsage.model_provider) 

131 .all() 

132 ) 

133 

134 model_token_stats = [ 

135 { 

136 "model": row.model_name, 

137 "provider": row.model_provider, 

138 "total_requests": row.total_requests, 

139 "total_tokens": int(row.total_tokens or 0), 

140 "min_prompt": int(row.min_prompt or 0), 

141 "avg_prompt": round(row.avg_prompt or 0, 0), 

142 "max_prompt": int(row.max_prompt or 0), 

143 "avg_response_time_ms": round( 

144 row.avg_response_time_ms or 0, 0 

145 ), 

146 } 

147 for row in model_token_query 

148 ] 

149 

150 # --- Phase breakdown (always populated, no context_limit filter) --- 

151 phase_query = ( 

152 query.with_entities( 

153 TokenUsage.research_phase, 

154 func.count(TokenUsage.id).label("count"), 

155 func.coalesce(func.sum(TokenUsage.total_tokens), 0).label( 

156 "total_tokens" 

157 ), 

158 func.avg(TokenUsage.total_tokens).label("avg_tokens"), 

159 ) 

160 .group_by(TokenUsage.research_phase) 

161 .all() 

162 ) 

163 

164 phase_breakdown = [ 

165 { 

166 "phase": row.research_phase or "unknown", 

167 "count": row.count, 

168 "total_tokens": int(row.total_tokens or 0), 

169 "avg_tokens": round(row.avg_tokens or 0, 0), 

170 } 

171 for row in phase_query 

172 ] 

173 

174 # Get context limit distribution by model 

175 context_limits = session.query( 

176 TokenUsage.model_name, 

177 TokenUsage.context_limit, 

178 func.count(TokenUsage.id).label("count"), 

179 ).filter(TokenUsage.context_limit.isnot(None)) 

180 

181 if start_date: 

182 context_limits = context_limits.filter( 

183 TokenUsage.timestamp >= start_date 

184 ) 

185 

186 context_limits = context_limits.group_by( 

187 TokenUsage.model_name, TokenUsage.context_limit 

188 ).all() 

189 

190 # Get recent truncated requests 

191 recent_truncated = ( 

192 query.filter(TokenUsage.context_truncated.is_(True)) 

193 .order_by(desc(TokenUsage.timestamp)) 

194 .limit(20) 

195 .all() 

196 ) 

197 

198 # Get time series data for chart - include all records 

199 # (even those without context_limit for OpenRouter models) 

200 time_series_query = query.order_by(TokenUsage.timestamp) 

201 

202 if start_date: 

203 # For shorter periods, get all data points (capped at 1000) 

204 if period in ["7d", "30d"]: 

205 time_series_data = time_series_query.limit(1000).all() 

206 else: 

207 # For longer periods, sample data 

208 time_series_data = time_series_query.limit(500).all() 

209 else: 

210 time_series_data = time_series_query.limit(1000).all() 

211 

212 # Format time series for chart 

213 chart_data = [] 

214 for usage in time_series_data: 

215 # Calculate original tokens (before truncation) 

216 ollama_used = ( 

217 usage.ollama_prompt_eval_count 

218 ) # What Ollama actually processed 

219 actual_prompt = ollama_used or usage.prompt_tokens 

220 tokens_truncated = usage.tokens_truncated or 0 

221 original_tokens = ( 

222 actual_prompt + tokens_truncated 

223 if usage.context_truncated 

224 else actual_prompt 

225 ) 

226 

227 chart_data.append( 

228 { 

229 "timestamp": usage.timestamp.isoformat(), 

230 "research_id": usage.research_id, 

231 "prompt_tokens": usage.prompt_tokens, # From our standard token counting 

232 "completion_tokens": usage.completion_tokens, 

233 "ollama_prompt_tokens": ollama_used, # What Ollama actually used (may be capped) 

234 "original_prompt_tokens": original_tokens, # What was originally requested (before truncation) 

235 "context_limit": usage.context_limit, 

236 "truncated": bool(usage.context_truncated), 

237 "tokens_truncated": tokens_truncated, 

238 "model": usage.model_name, 

239 "provider": usage.model_provider, 

240 "research_phase": usage.research_phase, 

241 "response_time_ms": usage.response_time_ms, 

242 } 

243 ) 

244 

245 # Get model-specific truncation stats 

246 model_stats = session.query( 

247 TokenUsage.model_name, 

248 TokenUsage.model_provider, 

249 func.count(TokenUsage.id).label("total_requests"), 

250 func.sum(TokenUsage.context_truncated).label("truncated_count"), 

251 func.avg(TokenUsage.context_limit).label("avg_context_limit"), 

252 ).filter(TokenUsage.context_limit.isnot(None)) 

253 

254 if start_date: 

255 model_stats = model_stats.filter( 

256 TokenUsage.timestamp >= start_date 

257 ) 

258 

259 model_stats = model_stats.group_by( 

260 TokenUsage.model_name, TokenUsage.model_provider 

261 ).all() 

262 

263 # --- Paginated all_requests --- 

264 all_requests_query = query.order_by(desc(TokenUsage.timestamp)) 

265 all_requests_total = all_requests_query.count() 

266 all_requests_pages = ( 

267 (all_requests_total + per_page - 1) // per_page 

268 if all_requests_total > 0 

269 else 1 

270 ) 

271 all_requests_data = ( 

272 all_requests_query.offset((page - 1) * per_page) 

273 .limit(per_page) 

274 .all() 

275 ) 

276 

277 # Format response 

278 return { 

279 "status": "success", 

280 "overview": { 

281 "total_requests": total_requests, 

282 "requests_with_context_data": requests_with_context, 

283 "truncated_requests": truncated_requests, 

284 "truncation_rate": round(truncation_rate, 2), 

285 "avg_tokens_truncated": round(avg_tokens_truncated, 0) 

286 if avg_tokens_truncated 

287 else 0, 

288 }, 

289 "token_summary": token_summary, 

290 "model_token_stats": model_token_stats, 

291 "phase_breakdown": phase_breakdown, 

292 "context_limits": [ 

293 {"model": model, "limit": limit, "count": count} 

294 for model, limit, count in context_limits 

295 ], 

296 "model_stats": [ 

297 { 

298 "model": stat.model_name, 

299 "provider": stat.model_provider, 

300 "total_requests": stat.total_requests, 

301 "truncated_count": int(stat.truncated_count or 0), 

302 "truncation_rate": round( 

303 (stat.truncated_count or 0) 

304 / stat.total_requests 

305 * 100, 

306 2, 

307 ) 

308 if stat.total_requests > 0 

309 else 0, 

310 "avg_context_limit": round(stat.avg_context_limit, 0) 

311 if stat.avg_context_limit 

312 else None, 

313 } 

314 for stat in model_stats 

315 ], 

316 "recent_truncated": [ 

317 { 

318 "timestamp": req.timestamp.isoformat(), 

319 "research_id": req.research_id, 

320 "model": req.model_name, 

321 "prompt_tokens": req.prompt_tokens, # Standard token count 

322 "ollama_tokens": req.ollama_prompt_eval_count, # What Ollama actually used 

323 "original_tokens": ( 

324 req.ollama_prompt_eval_count or req.prompt_tokens 

325 ) 

326 + (req.tokens_truncated or 0), # What was requested 

327 "context_limit": req.context_limit, 

328 "tokens_truncated": req.tokens_truncated, 

329 "truncation_ratio": req.truncation_ratio, 

330 "research_query": req.research_query, 

331 } 

332 for req in recent_truncated 

333 ], 

334 "chart_data": chart_data, 

335 "all_requests": [ 

336 { 

337 "timestamp": req.timestamp.isoformat(), 

338 "research_id": req.research_id, 

339 "model": req.model_name, 

340 "provider": req.model_provider, 

341 "prompt_tokens": req.prompt_tokens, 

342 "completion_tokens": req.completion_tokens, 

343 "total_tokens": req.total_tokens, 

344 "context_limit": req.context_limit, 

345 "context_truncated": bool(req.context_truncated), 

346 "tokens_truncated": req.tokens_truncated or 0, 

347 "truncation_ratio": round(req.truncation_ratio * 100, 2) 

348 if req.truncation_ratio 

349 else 0, 

350 "ollama_prompt_eval_count": req.ollama_prompt_eval_count, 

351 "research_query": req.research_query, 

352 "research_phase": req.research_phase, 

353 } 

354 for req in all_requests_data 

355 ], 

356 "pagination": { 

357 "page": page, 

358 "per_page": per_page, 

359 "total_count": all_requests_total, 

360 "total_pages": all_requests_pages, 

361 }, 

362 "current_context_window": SettingsManager(session).get_setting( 

363 "llm.local_context_window_size" 

364 ), 

365 } 

366 

367 except Exception: 

368 logger.exception("Error getting context overflow metrics") 

369 return JSONResponse( 

370 { 

371 "status": "error", 

372 "message": "Failed to load context overflow metrics", 

373 }, 

374 status_code=500, 

375 ) 

376 

377 

378@router.get("/api/research/{research_id}/context-overflow") 

379def get_research_context_overflow( 

380 request: Request, 

381 research_id, 

382 username: Annotated[str, Depends(require_auth)], 

383): 

384 """Get context overflow metrics for a specific research.""" 

385 try: 

386 with get_user_db_session(username) as session: 

387 # Get all token usage for this research 

388 token_usage = ( 

389 session.query(TokenUsage) 

390 .filter(TokenUsage.research_id == research_id) 

391 .order_by(TokenUsage.timestamp) 

392 .all() 

393 ) 

394 

395 if not token_usage: 

396 return { 

397 "status": "success", 

398 "data": { 

399 "overview": { 

400 "total_requests": 0, 

401 "total_tokens": 0, 

402 "context_limit": None, 

403 "max_tokens_used": 0, 

404 "truncation_occurred": False, 

405 }, 

406 "requests": [], 

407 }, 

408 } 

409 

410 # Calculate overview metrics 

411 total_tokens = sum(req.total_tokens or 0 for req in token_usage) 

412 total_prompt = sum(req.prompt_tokens or 0 for req in token_usage) 

413 total_completion = sum( 

414 req.completion_tokens or 0 for req in token_usage 

415 ) 

416 

417 # Get context limit (should be same for all requests in a research) 

418 context_limit = next( 

419 (req.context_limit for req in token_usage if req.context_limit), 

420 None, 

421 ) 

422 

423 # Check for truncation 

424 truncated_requests = [ 

425 req for req in token_usage if req.context_truncated 

426 ] 

427 max_tokens_used = max( 

428 (req.prompt_tokens or 0) for req in token_usage 

429 ) 

430 

431 # Get token usage by phase 

432 phase_stats = {} 

433 for req in token_usage: 

434 phase = req.research_phase or "unknown" 

435 if phase not in phase_stats: 

436 phase_stats[phase] = { 

437 "count": 0, 

438 "prompt_tokens": 0, 

439 "completion_tokens": 0, 

440 "total_tokens": 0, 

441 "truncated_count": 0, 

442 } 

443 phase_stats[phase]["count"] += 1 

444 phase_stats[phase]["prompt_tokens"] += req.prompt_tokens or 0 

445 phase_stats[phase]["completion_tokens"] += ( 

446 req.completion_tokens or 0 

447 ) 

448 phase_stats[phase]["total_tokens"] += req.total_tokens or 0 

449 if req.context_truncated: 

450 phase_stats[phase]["truncated_count"] += 1 

451 

452 # Format requests for response 

453 requests_data = [] 

454 for req in token_usage: 

455 requests_data.append( 

456 { 

457 "timestamp": req.timestamp.isoformat(), 

458 "phase": req.research_phase, 

459 "prompt_tokens": req.prompt_tokens, 

460 "completion_tokens": req.completion_tokens, 

461 "total_tokens": req.total_tokens, 

462 "context_limit": req.context_limit, 

463 "context_truncated": bool(req.context_truncated), 

464 "tokens_truncated": req.tokens_truncated or 0, 

465 "ollama_prompt_eval_count": req.ollama_prompt_eval_count, 

466 "calling_function": req.calling_function, 

467 "response_time_ms": req.response_time_ms, 

468 } 

469 ) 

470 

471 return { 

472 "status": "success", 

473 "data": { 

474 "overview": { 

475 "total_requests": len(token_usage), 

476 "total_tokens": total_tokens, 

477 "total_prompt_tokens": total_prompt, 

478 "total_completion_tokens": total_completion, 

479 "context_limit": context_limit, 

480 "max_tokens_used": max_tokens_used, 

481 "truncation_occurred": len(truncated_requests) > 0, 

482 "truncated_count": len(truncated_requests), 

483 "tokens_lost": sum( 

484 req.tokens_truncated or 0 

485 for req in truncated_requests 

486 ), 

487 }, 

488 "phase_stats": phase_stats, 

489 "requests": requests_data, 

490 "model": token_usage[0].model_name if token_usage else None, 

491 "provider": token_usage[0].model_provider 

492 if token_usage 

493 else None, 

494 }, 

495 } 

496 

497 except Exception: 

498 logger.exception("Error getting research context overflow") 

499 return JSONResponse( 

500 { 

501 "status": "error", 

502 "message": "Failed to load context overflow data", 

503 }, 

504 status_code=500, 

505 )