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
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1"""API endpoints for context overflow analytics."""
3from fastapi import APIRouter, Depends, Request
4from fastapi.responses import JSONResponse
5from ..dependencies.auth import require_auth
7from datetime import datetime, timedelta, timezone
8from sqlalchemy import func, desc
9from loguru import logger
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
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
21router = APIRouter(tags=["context_overflow_api"])
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.
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
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"
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))
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)
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"]
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)
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 }
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 )
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 ]
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 )
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 ]
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))
181 if start_date:
182 context_limits = context_limits.filter(
183 TokenUsage.timestamp >= start_date
184 )
186 context_limits = context_limits.group_by(
187 TokenUsage.model_name, TokenUsage.context_limit
188 ).all()
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 )
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)
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()
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 )
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 )
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))
254 if start_date:
255 model_stats = model_stats.filter(
256 TokenUsage.timestamp >= start_date
257 )
259 model_stats = model_stats.group_by(
260 TokenUsage.model_name, TokenUsage.model_provider
261 ).all()
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 )
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 }
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 )
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 )
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 }
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 )
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 )
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 )
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
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 )
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 }
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 )