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

106 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +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 sqlalchemy import func, desc 

8from loguru import logger 

9 

10from ...database.session_context import get_user_db_session 

11from ...database.models import TokenUsage 

12from ...metrics.query_utils import ( 

13 get_context_overflow_truncation_summary, 

14 get_period_cutoff, 

15) 

16from ...settings import SettingsManager 

17from typing import Annotated 

18 

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

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

21_MAX_PAGE = 10_000 

22 

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

24 

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

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

27# fast if the dependency is ever removed. 

28 

29 

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

31def get_context_overflow_metrics( 

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

33): 

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

35 try: 

36 # Get username from session 

37 

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

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

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

41 if period not in VALID_PERIODS: 

42 period = "30d" 

43 

44 # Pagination params for all_requests. 

45 # 

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

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

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

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

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

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

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

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

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

55 # missed. 

56 try: 

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

58 except (TypeError, ValueError): 

59 page = 1 

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

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

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

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

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

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

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

67 try: 

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

69 except (TypeError, ValueError): 

70 per_page = 50 

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

72 

73 # Calculate date filter (timezone-aware UTC). Derived from the same 

74 # single source of truth as the truncation summary below 

75 # (PERIOD_DAYS_MAP via get_period_cutoff) rather than a local 

76 # 7d/30d/3m/1y ladder, so the two windows in this response cannot 

77 # drift apart (#6942). None means 'all' (no time limit). 

78 start_date = get_period_cutoff(period) 

79 

80 with get_user_db_session(username) as session: 

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

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

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

84 # get_time_filter_condition, which derives its cutoff from the 

85 # same get_period_cutoff as the start_date above. 

86 summary = get_context_overflow_truncation_summary(session, period) 

87 total_requests = summary["total_requests"] 

88 requests_with_context = summary["requests_with_context"] 

89 truncated_requests = summary["truncated_requests"] 

90 truncation_rate = summary["truncation_rate"] 

91 avg_tokens_truncated = summary["avg_tokens_truncated"] 

92 

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

94 # aggregations that share the same time window. 

95 query = session.query(TokenUsage) 

96 if start_date: 

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

98 

99 token_summary = { 

100 "total_requests": total_requests, 

101 "total_tokens": summary["total_tokens"], 

102 "total_prompt_tokens": summary["total_prompt_tokens"], 

103 "total_completion_tokens": summary["total_completion_tokens"], 

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

105 "avg_completion_tokens": round( 

106 summary["avg_completion_tokens"], 0 

107 ), 

108 "max_prompt_tokens": summary["max_prompt_tokens"], 

109 } 

110 

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

112 model_token_query = ( 

113 query.with_entities( 

114 TokenUsage.model_name, 

115 TokenUsage.model_provider, 

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

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

118 "total_tokens" 

119 ), 

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

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

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

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

124 "avg_response_time_ms" 

125 ), 

126 ) 

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

128 .all() 

129 ) 

130 

131 model_token_stats = [ 

132 { 

133 "model": row.model_name, 

134 "provider": row.model_provider, 

135 "total_requests": row.total_requests, 

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

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

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

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

140 "avg_response_time_ms": round( 

141 row.avg_response_time_ms or 0, 0 

142 ), 

143 } 

144 for row in model_token_query 

145 ] 

146 

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

148 phase_query = ( 

149 query.with_entities( 

150 TokenUsage.research_phase, 

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

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

153 "total_tokens" 

154 ), 

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

156 ) 

157 .group_by(TokenUsage.research_phase) 

158 .all() 

159 ) 

160 

161 phase_breakdown = [ 

162 { 

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

164 "count": row.count, 

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

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

167 } 

168 for row in phase_query 

169 ] 

170 

171 # Get context limit distribution by model 

172 context_limits = session.query( 

173 TokenUsage.model_name, 

174 TokenUsage.context_limit, 

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

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

177 

178 if start_date: 

179 context_limits = context_limits.filter( 

180 TokenUsage.timestamp >= start_date 

181 ) 

182 

183 context_limits = context_limits.group_by( 

184 TokenUsage.model_name, TokenUsage.context_limit 

185 ).all() 

186 

187 # Get recent truncated requests 

188 recent_truncated = ( 

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

190 .order_by(desc(TokenUsage.timestamp)) 

191 .limit(20) 

192 .all() 

193 ) 

194 

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

196 # (even those without context_limit for OpenRouter models) 

197 time_series_query = query.order_by(TokenUsage.timestamp) 

198 

199 if start_date: 

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

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

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

203 else: 

204 # For longer periods, sample data 

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

206 else: 

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

208 

209 # Format time series for chart 

210 chart_data = [] 

211 for usage in time_series_data: 

212 # Calculate original tokens (before truncation) 

213 ollama_used = ( 

214 usage.ollama_prompt_eval_count 

215 ) # What Ollama actually processed 

216 actual_prompt = ollama_used or usage.prompt_tokens 

217 tokens_truncated = usage.tokens_truncated or 0 

218 original_tokens = ( 

219 actual_prompt + tokens_truncated 

220 if usage.context_truncated 

221 else actual_prompt 

222 ) 

223 

224 chart_data.append( 

225 { 

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

227 "research_id": usage.research_id, 

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

229 "completion_tokens": usage.completion_tokens, 

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

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

232 "context_limit": usage.context_limit, 

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

234 "tokens_truncated": tokens_truncated, 

235 "model": usage.model_name, 

236 "provider": usage.model_provider, 

237 "research_phase": usage.research_phase, 

238 "response_time_ms": usage.response_time_ms, 

239 } 

240 ) 

241 

242 # Get model-specific truncation stats 

243 model_stats = session.query( 

244 TokenUsage.model_name, 

245 TokenUsage.model_provider, 

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

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

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

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

250 

251 if start_date: 

252 model_stats = model_stats.filter( 

253 TokenUsage.timestamp >= start_date 

254 ) 

255 

256 model_stats = model_stats.group_by( 

257 TokenUsage.model_name, TokenUsage.model_provider 

258 ).all() 

259 

260 # --- Paginated all_requests --- 

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

262 all_requests_total = all_requests_query.count() 

263 all_requests_pages = ( 

264 (all_requests_total + per_page - 1) // per_page 

265 if all_requests_total > 0 

266 else 1 

267 ) 

268 all_requests_data = ( 

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

270 .limit(per_page) 

271 .all() 

272 ) 

273 

274 # Format response 

275 return { 

276 "status": "success", 

277 "overview": { 

278 "total_requests": total_requests, 

279 "requests_with_context_data": requests_with_context, 

280 "truncated_requests": truncated_requests, 

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

282 "avg_tokens_truncated": round(avg_tokens_truncated, 0) 

283 if avg_tokens_truncated 

284 else 0, 

285 }, 

286 "token_summary": token_summary, 

287 "model_token_stats": model_token_stats, 

288 "phase_breakdown": phase_breakdown, 

289 "context_limits": [ 

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

291 for model, limit, count in context_limits 

292 ], 

293 "model_stats": [ 

294 { 

295 "model": stat.model_name, 

296 "provider": stat.model_provider, 

297 "total_requests": stat.total_requests, 

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

299 "truncation_rate": round( 

300 (stat.truncated_count or 0) 

301 / stat.total_requests 

302 * 100, 

303 2, 

304 ) 

305 if stat.total_requests > 0 

306 else 0, 

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

308 if stat.avg_context_limit 

309 else None, 

310 } 

311 for stat in model_stats 

312 ], 

313 "recent_truncated": [ 

314 { 

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

316 "research_id": req.research_id, 

317 "model": req.model_name, 

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

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

320 "original_tokens": ( 

321 req.ollama_prompt_eval_count or req.prompt_tokens 

322 ) 

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

324 "context_limit": req.context_limit, 

325 "tokens_truncated": req.tokens_truncated, 

326 "truncation_ratio": req.truncation_ratio, 

327 "research_query": req.research_query, 

328 } 

329 for req in recent_truncated 

330 ], 

331 "chart_data": chart_data, 

332 "all_requests": [ 

333 { 

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

335 "research_id": req.research_id, 

336 "model": req.model_name, 

337 "provider": req.model_provider, 

338 "prompt_tokens": req.prompt_tokens, 

339 "completion_tokens": req.completion_tokens, 

340 "total_tokens": req.total_tokens, 

341 "context_limit": req.context_limit, 

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

343 "tokens_truncated": req.tokens_truncated or 0, 

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

345 if req.truncation_ratio 

346 else 0, 

347 "ollama_prompt_eval_count": req.ollama_prompt_eval_count, 

348 "research_query": req.research_query, 

349 "research_phase": req.research_phase, 

350 } 

351 for req in all_requests_data 

352 ], 

353 "pagination": { 

354 "page": page, 

355 "per_page": per_page, 

356 "total_count": all_requests_total, 

357 "total_pages": all_requests_pages, 

358 }, 

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

360 "llm.local_context_window_size" 

361 ), 

362 } 

363 

364 except Exception: 

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

366 return JSONResponse( 

367 { 

368 "status": "error", 

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

370 }, 

371 status_code=500, 

372 ) 

373 

374 

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

376def get_research_context_overflow( 

377 request: Request, 

378 research_id, 

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

380): 

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

382 try: 

383 with get_user_db_session(username) as session: 

384 # Get all token usage for this research 

385 token_usage = ( 

386 session.query(TokenUsage) 

387 .filter(TokenUsage.research_id == research_id) 

388 .order_by(TokenUsage.timestamp) 

389 .all() 

390 ) 

391 

392 if not token_usage: 

393 return { 

394 "status": "success", 

395 "data": { 

396 "overview": { 

397 "total_requests": 0, 

398 "total_tokens": 0, 

399 "context_limit": None, 

400 "max_tokens_used": 0, 

401 "truncation_occurred": False, 

402 }, 

403 "requests": [], 

404 }, 

405 } 

406 

407 # Calculate overview metrics 

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

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

410 total_completion = sum( 

411 req.completion_tokens or 0 for req in token_usage 

412 ) 

413 

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

415 context_limit = next( 

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

417 None, 

418 ) 

419 

420 # Check for truncation 

421 truncated_requests = [ 

422 req for req in token_usage if req.context_truncated 

423 ] 

424 max_tokens_used = max( 

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

426 ) 

427 

428 # Get token usage by phase 

429 phase_stats = {} 

430 for req in token_usage: 

431 phase = req.research_phase or "unknown" 

432 if phase not in phase_stats: 

433 phase_stats[phase] = { 

434 "count": 0, 

435 "prompt_tokens": 0, 

436 "completion_tokens": 0, 

437 "total_tokens": 0, 

438 "truncated_count": 0, 

439 } 

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

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

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

443 req.completion_tokens or 0 

444 ) 

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

446 if req.context_truncated: 

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

448 

449 # Format requests for response 

450 requests_data = [] 

451 for req in token_usage: 

452 requests_data.append( 

453 { 

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

455 "phase": req.research_phase, 

456 "prompt_tokens": req.prompt_tokens, 

457 "completion_tokens": req.completion_tokens, 

458 "total_tokens": req.total_tokens, 

459 "context_limit": req.context_limit, 

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

461 "tokens_truncated": req.tokens_truncated or 0, 

462 "ollama_prompt_eval_count": req.ollama_prompt_eval_count, 

463 "calling_function": req.calling_function, 

464 "response_time_ms": req.response_time_ms, 

465 } 

466 ) 

467 

468 return { 

469 "status": "success", 

470 "data": { 

471 "overview": { 

472 "total_requests": len(token_usage), 

473 "total_tokens": total_tokens, 

474 "total_prompt_tokens": total_prompt, 

475 "total_completion_tokens": total_completion, 

476 "context_limit": context_limit, 

477 "max_tokens_used": max_tokens_used, 

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

479 "truncated_count": len(truncated_requests), 

480 "tokens_lost": sum( 

481 req.tokens_truncated or 0 

482 for req in truncated_requests 

483 ), 

484 }, 

485 "phase_stats": phase_stats, 

486 "requests": requests_data, 

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

488 "provider": token_usage[0].model_provider 

489 if token_usage 

490 else None, 

491 }, 

492 } 

493 

494 except Exception: 

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

496 return JSONResponse( 

497 { 

498 "status": "error", 

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

500 }, 

501 status_code=500, 

502 )