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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +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 sqlalchemy import func, desc
8from loguru import logger
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
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
23router = APIRouter(tags=["context_overflow_api"])
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.
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
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"
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))
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)
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"]
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)
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 }
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 )
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 ]
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 )
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 ]
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))
178 if start_date:
179 context_limits = context_limits.filter(
180 TokenUsage.timestamp >= start_date
181 )
183 context_limits = context_limits.group_by(
184 TokenUsage.model_name, TokenUsage.context_limit
185 ).all()
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 )
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)
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()
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 )
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 )
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))
251 if start_date:
252 model_stats = model_stats.filter(
253 TokenUsage.timestamp >= start_date
254 )
256 model_stats = model_stats.group_by(
257 TokenUsage.model_name, TokenUsage.model_provider
258 ).all()
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 )
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 }
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 )
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 )
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 }
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 )
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 )
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 )
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
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 )
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 }
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 )