Coverage for src/local_deep_research/metrics/token_counter.py: 96%

500 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

1"""Token counting functionality for LLM usage tracking.""" 

2 

3import inspect 

4import json 

5import time 

6from datetime import datetime 

7from pathlib import Path 

8from typing import Any, Dict, List, Optional 

9 

10from langchain_core.callbacks import BaseCallbackHandler 

11from langchain_core.outputs import LLMResult 

12from loguru import logger 

13from sqlalchemy import func, text 

14 

15from ..database.models import ModelUsage, TokenUsage 

16from .query_utils import ( 

17 get_period_cutoff, 

18 get_research_mode_condition, 

19 get_time_filter_condition, 

20) 

21 

22 

23class TokenCountingCallback(BaseCallbackHandler): 

24 """Callback handler for counting tokens across different models.""" 

25 

26 def __init__( 

27 self, 

28 research_id: Optional[str] = None, 

29 research_context: Optional[Dict[str, Any]] = None, 

30 ): 

31 """Initialize the token counting callback. 

32 

33 Args: 

34 research_id: The ID of the research to track tokens for 

35 research_context: Additional research context for enhanced tracking 

36 """ 

37 super().__init__() 

38 self.research_id = research_id 

39 self.research_context = research_context or {} 

40 self.current_model = None 

41 self.current_provider = None 

42 self.preset_model = None # Model name set during callback creation 

43 self.preset_provider = None # Provider set during callback creation 

44 

45 # Phase 1 Enhancement: Track timing and context 

46 self.start_time = None 

47 self.response_time_ms = None 

48 self.success_status = "success" 

49 self.error_type = None 

50 

51 # Call stack tracking 

52 self.calling_file = None 

53 self.calling_function = None 

54 self.call_stack = None 

55 

56 # Context overflow tracking 

57 self.context_limit = None 

58 self.context_truncated = False 

59 self.tokens_truncated = 0 

60 self.truncation_ratio = 0.0 

61 self.original_prompt_estimate = 0 

62 

63 # Raw Ollama response metrics 

64 self.ollama_metrics = {} 

65 

66 # Whether we've already logged the "provider reports no usage" 

67 # warning. The callback is shared across every LLM call in a 

68 # research session (see llm_config.py wrap_llm), so without this a 

69 # 50-call run against a non-reporting provider logs 50 identical 

70 # warnings. Calls are still recorded every time — only the warning 

71 # is deduplicated. 

72 self._warned_no_usage = False 

73 

74 # Track token counts in memory 

75 self.counts = { 

76 "total_tokens": 0, 

77 "total_prompt_tokens": 0, 

78 "total_completion_tokens": 0, 

79 "by_model": {}, 

80 } 

81 

82 def on_llm_start( 

83 self, serialized: Dict[str, Any], prompts: List[str], **kwargs: Any 

84 ) -> None: 

85 """Called when LLM starts running.""" 

86 # Phase 1 Enhancement: Start timing 

87 self.start_time = time.time() 

88 

89 # Reset per-call truncation state. The callback instance is shared 

90 # across every LLM call in a research session (see llm_config.py 

91 # wrap_llm), so without this reset the post-loop estimation block's 

92 # `if not self.context_truncated` guard would silently disable 

93 # [estimated] / [estimated-total-context] detection on every call 

94 # after the first one that truncates. 

95 self.context_truncated = False 

96 self.tokens_truncated = 0 

97 self.truncation_ratio = 0.0 

98 

99 # Estimate original prompt size (rough estimate: ~4 chars per token) 

100 if prompts: 

101 total_chars = sum(len(prompt) for prompt in prompts) 

102 self.original_prompt_estimate = total_chars // 4 

103 logger.debug( 

104 f"Estimated prompt tokens: {self.original_prompt_estimate} (from {total_chars} chars)" 

105 ) 

106 

107 # Get context limit from research context (will be set from settings) 

108 self.context_limit = self.research_context.get("context_limit") 

109 

110 # Phase 1 Enhancement: Capture call stack information 

111 try: 

112 stack = inspect.stack() 

113 

114 # Skip the first few frames (this method, langchain internals) 

115 # Look for the first frame that's in our project directory 

116 for frame_info in stack[1:]: 

117 file_path = frame_info.filename 

118 # Look for any frame containing local_deep_research project 

119 if ( 

120 "local_deep_research" in file_path 

121 and "site-packages" not in file_path 

122 and "venv" not in file_path 

123 ): 

124 # Extract relative path from local_deep_research 

125 if "src/local_deep_research" in file_path: 

126 relative_path = file_path.split( 

127 "src/local_deep_research" 

128 )[-1].lstrip("/") 

129 elif "local_deep_research/src" in file_path: 129 ↛ 133line 129 didn't jump to line 133 because the condition on line 129 was always true

130 relative_path = file_path.split( 

131 "local_deep_research/src" 

132 )[-1].lstrip("/") 

133 elif "local_deep_research" in file_path: 

134 # Get everything after local_deep_research 

135 relative_path = file_path.split("local_deep_research")[ 

136 -1 

137 ].lstrip("/") 

138 else: 

139 relative_path = Path(file_path).name 

140 

141 self.calling_file = relative_path 

142 self.calling_function = frame_info.function 

143 

144 # Capture a simplified call stack (just the relevant frames) 

145 call_stack_frames = [] 

146 for frame in stack[1:6]: # Limit to 5 frames 

147 if ( 

148 "local_deep_research" in frame.filename 

149 and "site-packages" not in frame.filename 

150 and "venv" not in frame.filename 

151 ): 

152 frame_name = f"{Path(frame.filename).name}:{frame.function}:{frame.lineno}" 

153 call_stack_frames.append(frame_name) 

154 

155 self.call_stack = ( 

156 " -> ".join(call_stack_frames) 

157 if call_stack_frames 

158 else None 

159 ) 

160 break 

161 except Exception: 

162 logger.warning("Error capturing call stack") 

163 # Continue without call stack info if there's an error 

164 

165 # First, use preset values if available 

166 if self.preset_model: 

167 self.current_model = self.preset_model 

168 else: 

169 # Try multiple locations for model name 

170 model_name = None 

171 

172 # First check invocation_params 

173 invocation_params = kwargs.get("invocation_params", {}) 

174 model_name = invocation_params.get( 

175 "model" 

176 ) or invocation_params.get("model_name") 

177 

178 # Check kwargs directly 

179 if not model_name: 

180 model_name = kwargs.get("model") or kwargs.get("model_name") 

181 

182 # Check serialized data 

183 if not model_name and "kwargs" in serialized: 

184 model_name = serialized["kwargs"].get("model") or serialized[ 

185 "kwargs" 

186 ].get("model_name") 

187 

188 # Check for name in serialized data 

189 if not model_name and "name" in serialized: 

190 model_name = serialized["name"] 

191 

192 # If still not found and we have Ollama, try to extract from the instance 

193 if ( 

194 not model_name 

195 and "_type" in serialized 

196 and "ChatOllama" in serialized["_type"] 

197 ): 

198 # For Ollama, the model name might be in the serialized kwargs 

199 if "kwargs" in serialized and "model" in serialized["kwargs"]: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true

200 model_name = serialized["kwargs"]["model"] 

201 else: 

202 # Default to the type if we can't find the actual model 

203 model_name = "ollama" 

204 

205 # Final fallback 

206 if not model_name: 

207 if "_type" in serialized: 

208 model_name = serialized["_type"] 

209 else: 

210 model_name = "unknown" 

211 

212 self.current_model = model_name 

213 

214 # Use preset provider if available 

215 if self.preset_provider: 

216 self.current_provider = self.preset_provider 

217 else: 

218 # Extract provider from serialized type or kwargs 

219 if "_type" in serialized: 

220 type_str = serialized["_type"] 

221 if "ChatOllama" in type_str: 

222 self.current_provider = "ollama" 

223 elif "ChatOpenAI" in type_str: 

224 self.current_provider = "openai" 

225 elif "ChatAnthropic" in type_str: 

226 self.current_provider = "anthropic" 

227 else: 

228 self.current_provider = kwargs.get("provider", "unknown") 

229 else: 

230 self.current_provider = kwargs.get("provider", "unknown") 

231 

232 # Initialize model tracking if needed 

233 if self.current_model not in self.counts["by_model"]: 

234 self.counts["by_model"][self.current_model] = { 

235 "prompt_tokens": 0, 

236 "completion_tokens": 0, 

237 "total_tokens": 0, 

238 "calls": 0, 

239 "provider": self.current_provider, 

240 } 

241 

242 # Increment call count 

243 self.counts["by_model"][self.current_model]["calls"] += 1 

244 

245 def _check_context_overflow( 

246 self, 

247 prompt_eval_count: int, 

248 completion_tokens: int = 0, 

249 source: str = "", 

250 ) -> None: 

251 """Check for context overflow based on prompt and total token usage. 

252 

253 Args: 

254 prompt_eval_count: Number of tokens the model actually processed. 

255 completion_tokens: Number of tokens generated (for total-context check). 

256 source: Which branch provided the data (for logging). 

257 """ 

258 logger.debug( 

259 f"Context overflow check [{source}]: " 

260 f"prompt_eval_count={prompt_eval_count}, " 

261 f"completion_tokens={completion_tokens}, " 

262 f"context_limit={self.context_limit}" 

263 ) 

264 

265 if not self.context_limit or prompt_eval_count <= 0: 

266 return 

267 

268 # Input-only overflow: prompt at >= 80% of context limit. Matches the 

269 # chart-warning threshold and PR #3840's deliberate choice (PR #3792 

270 # lowered from 95% → 80%). The total-context branch below uses 95% 

271 # because it's a stricter condition (input+output combined). 

272 if prompt_eval_count >= self.context_limit * 0.80: 

273 self.context_truncated = True 

274 

275 if self.original_prompt_estimate > prompt_eval_count: 

276 self.tokens_truncated = max( 

277 0, 

278 self.original_prompt_estimate - prompt_eval_count, 

279 ) 

280 if ( 280 ↛ 294line 280 didn't jump to line 294 because the condition on line 280 was always true

281 self.tokens_truncated > 0 

282 and self.original_prompt_estimate > 0 

283 ): 

284 self.truncation_ratio = ( 

285 self.tokens_truncated / self.original_prompt_estimate 

286 ) 

287 elif prompt_eval_count > self.context_limit: 287 ↛ 288line 287 didn't jump to line 288 because the condition on line 287 was never true

288 self.tokens_truncated = prompt_eval_count - self.context_limit 

289 if self.tokens_truncated > 0 and prompt_eval_count > 0: 

290 self.truncation_ratio = ( 

291 self.tokens_truncated / prompt_eval_count 

292 ) 

293 

294 logger.warning( 

295 f"Context overflow detected [provider-confirmed] " 

296 f"research_id={self.research_id} " 

297 f"model={self.current_model} " 

298 f"provider={self.current_provider} " 

299 f"source={source} " 

300 f"prompt_tokens={prompt_eval_count} " 

301 f"context_limit={self.context_limit} " 

302 f"tokens_truncated={self.tokens_truncated} " 

303 f"truncation_ratio={self.truncation_ratio:.1%}" 

304 ) 

305 

306 # Total-context overflow: input + output exceeds 95% of context limit 

307 elif ( 

308 completion_tokens > 0 

309 and prompt_eval_count + completion_tokens 

310 >= self.context_limit * 0.95 

311 ): 

312 total = prompt_eval_count + completion_tokens 

313 self.context_truncated = True 

314 self.tokens_truncated = max(0, total - self.context_limit) 

315 self.truncation_ratio = ( 

316 self.tokens_truncated / total if total > 0 else 0 

317 ) 

318 logger.warning( 

319 f"Context overflow detected [total-context] " 

320 f"research_id={self.research_id} " 

321 f"model={self.current_model} " 

322 f"provider={self.current_provider} " 

323 f"source={source} " 

324 f"prompt_tokens={prompt_eval_count} " 

325 f"completion_tokens={completion_tokens} " 

326 f"total_tokens={total} " 

327 f"context_limit={self.context_limit} " 

328 f"tokens_truncated={self.tokens_truncated} " 

329 f"truncation_ratio={self.truncation_ratio:.1%}" 

330 ) 

331 else: 

332 logger.debug( 

333 f"Context OK [{source}]: " 

334 f"{prompt_eval_count}/{self.context_limit} " 

335 f"({prompt_eval_count / self.context_limit:.1%})" 

336 ) 

337 

338 def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None: 

339 """Called when LLM ends running.""" 

340 # Phase 1 Enhancement: Calculate response time 

341 if self.start_time: 

342 self.response_time_ms = int((time.time() - self.start_time) * 1000) 

343 

344 # Extract token usage from response 

345 token_usage = None 

346 

347 # Check multiple locations for token usage 

348 if hasattr(response, "llm_output") and response.llm_output: 

349 token_usage = response.llm_output.get( 

350 "token_usage" 

351 ) or response.llm_output.get("usage", {}) 

352 

353 # Check for usage metadata in generations (Ollama specific) 

354 if not token_usage and hasattr(response, "generations"): 

355 for generation_list in response.generations: 

356 for generation in generation_list: 

357 if hasattr(generation, "message") and hasattr( 

358 generation.message, "usage_metadata" 

359 ): 

360 usage_meta = generation.message.usage_metadata 

361 if usage_meta: # Check if usage_metadata is not None 

362 token_usage = { 

363 "prompt_tokens": usage_meta.get( 

364 "input_tokens", 0 

365 ), 

366 "completion_tokens": usage_meta.get( 

367 "output_tokens", 0 

368 ), 

369 "total_tokens": usage_meta.get( 

370 "total_tokens", 0 

371 ), 

372 } 

373 # Check context overflow before breaking 

374 # (input_tokens == prompt_eval_count for Ollama) 

375 self._check_context_overflow( 

376 usage_meta.get("input_tokens", 0), 

377 completion_tokens=usage_meta.get( 

378 "output_tokens", 0 

379 ), 

380 source="usage_metadata", 

381 ) 

382 break 

383 # Also check response_metadata 

384 if hasattr(generation, "message") and hasattr( 

385 generation.message, "response_metadata" 

386 ): 

387 resp_meta = generation.message.response_metadata 

388 if resp_meta.get("prompt_eval_count") or resp_meta.get( 

389 "eval_count" 

390 ): 

391 # Capture raw Ollama metrics 

392 self.ollama_metrics = { 

393 "prompt_eval_count": resp_meta.get( 

394 "prompt_eval_count" 

395 ), 

396 "eval_count": resp_meta.get("eval_count"), 

397 "total_duration": resp_meta.get( 

398 "total_duration" 

399 ), 

400 "load_duration": resp_meta.get("load_duration"), 

401 "prompt_eval_duration": resp_meta.get( 

402 "prompt_eval_duration" 

403 ), 

404 "eval_duration": resp_meta.get("eval_duration"), 

405 } 

406 

407 # Check for context overflow (input only) 

408 prompt_eval_count = resp_meta.get( 

409 "prompt_eval_count", 0 

410 ) 

411 self._check_context_overflow( 

412 prompt_eval_count, 

413 completion_tokens=resp_meta.get( 

414 "eval_count", 0 

415 ), 

416 source="response_metadata", 

417 ) 

418 

419 token_usage = { 

420 "prompt_tokens": resp_meta.get( 

421 "prompt_eval_count", 0 

422 ), 

423 "completion_tokens": resp_meta.get( 

424 "eval_count", 0 

425 ), 

426 "total_tokens": resp_meta.get( 

427 "prompt_eval_count", 0 

428 ) 

429 + resp_meta.get("eval_count", 0), 

430 } 

431 break 

432 if token_usage: 

433 break 

434 

435 # Estimation-based overflow detection for providers that don't echo 

436 # prompt_eval_count (OpenAI, Anthropic, OpenRouter, etc.). The Ollama 

437 # path above sets context_truncated=True if it detected provider- 

438 # confirmed truncation; we only fire here if it didn't. For hosted 

439 # providers the API typically rejects oversize prompts rather than 

440 # silently truncating, so this signal flags "would-overflow per our 

441 # estimate" — not the same as actual truncation, hence the [estimated] 

442 # tag in the log message. 

443 if not self.context_truncated and self.context_limit: 

444 # Input-only overflow: prompt estimate exceeds context limit 

445 if self.original_prompt_estimate > self.context_limit: 

446 self.context_truncated = True 

447 self.tokens_truncated = max( 

448 0, self.original_prompt_estimate - self.context_limit 

449 ) 

450 self.truncation_ratio = ( 

451 self.tokens_truncated / self.original_prompt_estimate 

452 if self.original_prompt_estimate > 0 

453 else 0 

454 ) 

455 logger.warning( 

456 "Context overflow detected [estimated] " 

457 f"research_id={self.research_id} " 

458 f"model={self.current_model} " 

459 f"provider={self.current_provider} " 

460 f"estimated_prompt_tokens={self.original_prompt_estimate} " 

461 f"context_limit={self.context_limit} " 

462 f"tokens_truncated={self.tokens_truncated} " 

463 f"truncation_ratio={self.truncation_ratio:.1%}" 

464 ) 

465 # Total-context overflow: input + output exceeds context limit. 

466 # The "[estimated-total-context]" tag below refers to the 

467 # *detection method* (post-loop fallback path that runs when 

468 # _check_context_overflow couldn't fire), not the data source — 

469 # the prompt_tokens / completion_tokens here come from the 

470 # provider via response.llm_output.token_usage and are actual 

471 # counts, not character-based estimates. 

472 elif token_usage and isinstance(token_usage, dict): 472 ↛ 495line 472 didn't jump to line 495 because the condition on line 472 was always true

473 prompt_tokens = token_usage.get("prompt_tokens", 0) 

474 completion_tokens = token_usage.get("completion_tokens", 0) 

475 total = prompt_tokens + completion_tokens 

476 if total >= self.context_limit * 0.95: 

477 self.context_truncated = True 

478 self.tokens_truncated = max(0, total - self.context_limit) 

479 self.truncation_ratio = ( 

480 self.tokens_truncated / total if total > 0 else 0 

481 ) 

482 logger.warning( 

483 "Context overflow detected [estimated-total-context] " 

484 f"research_id={self.research_id} " 

485 f"model={self.current_model} " 

486 f"provider={self.current_provider} " 

487 f"prompt_tokens={prompt_tokens} " 

488 f"completion_tokens={completion_tokens} " 

489 f"total_tokens={total} " 

490 f"context_limit={self.context_limit} " 

491 f"tokens_truncated={self.tokens_truncated} " 

492 f"truncation_ratio={self.truncation_ratio:.1%}" 

493 ) 

494 

495 if token_usage and isinstance(token_usage, dict): 

496 prompt_tokens = token_usage.get("prompt_tokens", 0) 

497 completion_tokens = token_usage.get("completion_tokens", 0) 

498 total_tokens = token_usage.get( 

499 "total_tokens", prompt_tokens + completion_tokens 

500 ) 

501 

502 # Update in-memory counts 

503 self.counts["total_prompt_tokens"] += prompt_tokens 

504 self.counts["total_completion_tokens"] += completion_tokens 

505 self.counts["total_tokens"] += total_tokens 

506 

507 if self.current_model: 

508 self.counts["by_model"][self.current_model][ 

509 "prompt_tokens" 

510 ] += prompt_tokens 

511 self.counts["by_model"][self.current_model][ 

512 "completion_tokens" 

513 ] += completion_tokens 

514 self.counts["by_model"][self.current_model]["total_tokens"] += ( 

515 total_tokens 

516 ) 

517 

518 # Save to database if we have a research_id 

519 if self.research_id: 

520 self._save_to_db(prompt_tokens, completion_tokens) 

521 elif self.research_id: 

522 # The provider returned no usage data at all (e.g. OpenAI- 

523 # compatible servers that omit `usage` on streamed responses, 

524 # proxies that strip it, Ollama omitting prompt_eval_count for 

525 # fully-cached prompts). Record the call anyway with zero token 

526 # counts — model, provider, phase, response time and context- 

527 # overflow estimates are still real data. Skipping the row 

528 # entirely is what made every metrics page render as if LDR had 

529 # never been used (#4457). 

530 # Warn once per research session, not once per call. 

531 if not self._warned_no_usage: 

532 self._warned_no_usage = True 

533 hint = "" 

534 if self.current_provider == "openai_endpoint": 534 ↛ 535line 534 didn't jump to line 535 because the condition on line 534 was never true

535 hint = ( 

536 " If your server supports stream_options.include_usage " 

537 "(LM Studio 0.3.18+, llama.cpp, vLLM, OpenRouter), " 

538 "enable the 'llm.openai_endpoint.stream_usage' setting " 

539 "to get real token counts on streamed calls." 

540 ) 

541 logger.warning( 

542 f"LLM provider returned no token usage data - recording " 

543 f"calls with zero counts. model={self.current_model} " 

544 f"provider={self.current_provider} " 

545 f"research_id={self.research_id}.{hint} " 

546 f"(further occurrences this session are suppressed)" 

547 ) 

548 self._save_to_db(0, 0) 

549 

550 def on_llm_error(self, error, **kwargs: Any) -> None: 

551 """Called when LLM encounters an error.""" 

552 # Phase 1 Enhancement: Track errors 

553 if self.start_time: 

554 self.response_time_ms = int((time.time() - self.start_time) * 1000) 

555 

556 self.success_status = "error" 

557 self.error_type = str(type(error).__name__) 

558 

559 # Still save to database to track failed calls 

560 if self.research_id: 

561 self._save_to_db(0, 0) 

562 

563 def _get_context_overflow_fields(self) -> Dict[str, Any]: 

564 """Get context overflow detection fields for database saving.""" 

565 return { 

566 "context_limit": self.context_limit, 

567 "context_truncated": self.context_truncated, # Now Boolean 

568 "tokens_truncated": self.tokens_truncated 

569 if self.context_truncated 

570 else None, 

571 "truncation_ratio": self.truncation_ratio 

572 if self.context_truncated 

573 else None, 

574 # Raw Ollama metrics 

575 "ollama_prompt_eval_count": self.ollama_metrics.get( 

576 "prompt_eval_count" 

577 ), 

578 "ollama_eval_count": self.ollama_metrics.get("eval_count"), 

579 "ollama_total_duration": self.ollama_metrics.get("total_duration"), 

580 "ollama_load_duration": self.ollama_metrics.get("load_duration"), 

581 "ollama_prompt_eval_duration": self.ollama_metrics.get( 

582 "prompt_eval_duration" 

583 ), 

584 "ollama_eval_duration": self.ollama_metrics.get("eval_duration"), 

585 } 

586 

587 def _save_to_db(self, prompt_tokens: int, completion_tokens: int): 

588 """Save token usage to the database.""" 

589 # Check if we're in a thread - if so, queue the save for later 

590 import threading 

591 

592 if threading.current_thread().name != "MainThread": 

593 # Use thread-safe metrics database for background threads 

594 username = ( 

595 self.research_context.get("username") 

596 if self.research_context 

597 else None 

598 ) 

599 

600 if not username: 

601 logger.warning( 

602 f"Cannot save token metrics - no username in research context. " 

603 f"Token usage: prompt={prompt_tokens}, completion={completion_tokens}, " 

604 f"Research context: {self.research_context}" 

605 ) 

606 return 

607 

608 # Import the thread-safe metrics database 

609 

610 # Prepare token data 

611 token_data = { 

612 "model_name": self.current_model, 

613 "provider": self.current_provider, 

614 "prompt_tokens": prompt_tokens, 

615 "completion_tokens": completion_tokens, 

616 "research_query": self.research_context.get("research_query"), 

617 "research_mode": self.research_context.get("research_mode"), 

618 "research_phase": self.research_context.get("research_phase"), 

619 "search_iteration": self.research_context.get( 

620 "search_iteration" 

621 ), 

622 "response_time_ms": self.response_time_ms, 

623 "success_status": self.success_status, 

624 "error_type": self.error_type, 

625 "search_engines_planned": self.research_context.get( 

626 "search_engines_planned" 

627 ), 

628 "search_engine_selected": self.research_context.get( 

629 "search_engine_selected" 

630 ), 

631 "calling_file": self.calling_file, 

632 "calling_function": self.calling_function, 

633 "call_stack": self.call_stack, 

634 # Add context overflow fields using helper method 

635 **self._get_context_overflow_fields(), 

636 } 

637 

638 # Convert list to JSON string if needed 

639 if isinstance(token_data.get("search_engines_planned"), list): 

640 token_data["search_engines_planned"] = json.dumps( 

641 token_data["search_engines_planned"] 

642 ) 

643 

644 # Get password from research context 

645 password = self.research_context.get("user_password") 

646 if not password: 

647 logger.warning( 

648 f"Cannot save token metrics - no password in research context. " 

649 f"Username: {username}, Token usage: prompt={prompt_tokens}, completion={completion_tokens}" 

650 ) 

651 return 

652 

653 # Write metrics directly using thread-safe database 

654 try: 

655 from ..database.thread_metrics import metrics_writer 

656 

657 # Set password for this thread 

658 metrics_writer.set_user_password(username, password) 

659 

660 # Write metrics to encrypted database 

661 metrics_writer.write_token_metrics( 

662 username, self.research_id, token_data 

663 ) 

664 except Exception: 

665 logger.warning("Failed to write metrics from thread") 

666 return 

667 

668 # In MainThread, save directly 

669 try: 

670 from flask import session as flask_session 

671 from ..database.session_context import get_user_db_session 

672 

673 username = flask_session.get("username") 

674 if not username: 

675 logger.debug("No user session, skipping token metrics save") 

676 return 

677 

678 with get_user_db_session(username) as session: 

679 # Phase 1 Enhancement: Prepare additional context 

680 research_query = self.research_context.get("research_query") 

681 research_mode = self.research_context.get("research_mode") 

682 research_phase = self.research_context.get("research_phase") 

683 search_iteration = self.research_context.get("search_iteration") 

684 search_engines_planned = self.research_context.get( 

685 "search_engines_planned" 

686 ) 

687 search_engine_selected = self.research_context.get( 

688 "search_engine_selected" 

689 ) 

690 

691 # Debug logging for search engine context 

692 if search_engines_planned or search_engine_selected: 

693 logger.info( 

694 f"Token tracking - Search context: planned={search_engines_planned}, selected={search_engine_selected}, phase={research_phase}" 

695 ) 

696 else: 

697 logger.debug( 

698 f"Token tracking - No search engine context yet, phase={research_phase}" 

699 ) 

700 

701 # Convert list to JSON string if needed 

702 if isinstance(search_engines_planned, list): 

703 search_engines_planned = json.dumps(search_engines_planned) 

704 

705 # Log context overflow detection values before saving 

706 logger.debug( 

707 f"Saving TokenUsage - context_limit: {self.context_limit}, " 

708 f"context_truncated: {self.context_truncated}, " 

709 f"tokens_truncated: {self.tokens_truncated}, " 

710 f"ollama_prompt_eval_count: {self.ollama_metrics.get('prompt_eval_count')}, " 

711 f"prompt_tokens: {prompt_tokens}, " 

712 f"completion_tokens: {completion_tokens}" 

713 ) 

714 

715 # Add token usage record with enhanced fields 

716 token_usage = TokenUsage( 

717 research_id=self.research_id, 

718 model_name=self.current_model, 

719 model_provider=self.current_provider, # Added provider 

720 # for accurate cost tracking 

721 prompt_tokens=prompt_tokens, 

722 completion_tokens=completion_tokens, 

723 total_tokens=prompt_tokens + completion_tokens, 

724 # Phase 1 Enhancement: Research context 

725 research_query=research_query, 

726 research_mode=research_mode, 

727 research_phase=research_phase, 

728 search_iteration=search_iteration, 

729 # Phase 1 Enhancement: Performance metrics 

730 response_time_ms=self.response_time_ms, 

731 success_status=self.success_status, 

732 error_type=self.error_type, 

733 # Phase 1 Enhancement: Search engine context 

734 search_engines_planned=search_engines_planned, 

735 search_engine_selected=search_engine_selected, 

736 # Phase 1 Enhancement: Call stack tracking 

737 calling_file=self.calling_file, 

738 calling_function=self.calling_function, 

739 call_stack=self.call_stack, 

740 # Add context overflow fields using helper method 

741 **self._get_context_overflow_fields(), 

742 ) 

743 session.add(token_usage) 

744 

745 # Update or create model usage statistics 

746 model_usage = ( 

747 session.query(ModelUsage) 

748 .filter_by( 

749 model_name=self.current_model, 

750 ) 

751 .first() 

752 ) 

753 

754 if model_usage: 

755 model_usage.total_tokens += ( 

756 prompt_tokens + completion_tokens 

757 ) 

758 model_usage.total_calls += 1 

759 else: 

760 model_usage = ModelUsage( 

761 model_name=self.current_model, 

762 model_provider=self.current_provider, 

763 total_tokens=prompt_tokens + completion_tokens, 

764 total_calls=1, 

765 ) 

766 session.add(model_usage) 

767 

768 # Commit the transaction 

769 session.commit() 

770 

771 except Exception: 

772 logger.warning("Error saving token usage to database") 

773 

774 def get_counts(self) -> Dict[str, Any]: 

775 """Get the current token counts.""" 

776 return self.counts 

777 

778 

779class TokenCounter: 

780 """Manager class for token counting across the application.""" 

781 

782 def __init__(self): 

783 """Initialize the token counter.""" 

784 

785 def create_callback( 

786 self, 

787 research_id: Optional[str] = None, 

788 research_context: Optional[Dict[str, Any]] = None, 

789 ) -> TokenCountingCallback: 

790 """Create a new token counting callback. 

791 

792 Args: 

793 research_id: The ID of the research to track tokens for 

794 research_context: Additional research context for enhanced tracking 

795 

796 Returns: 

797 A new TokenCountingCallback instance 

798 """ 

799 return TokenCountingCallback( 

800 research_id=research_id, research_context=research_context 

801 ) 

802 

803 def get_research_metrics(self, research_id: str) -> Dict[str, Any]: 

804 """Get token metrics for a specific research. 

805 

806 Args: 

807 research_id: The ID of the research 

808 

809 Returns: 

810 Dictionary containing token usage metrics 

811 """ 

812 from flask import session as flask_session 

813 

814 from ..database.session_context import get_user_db_session 

815 

816 username = flask_session.get("username") 

817 if not username: 

818 return { 

819 "research_id": research_id, 

820 "total_tokens": 0, 

821 "total_calls": 0, 

822 "model_usage": [], 

823 } 

824 

825 with get_user_db_session(username) as session: 

826 # Get token usage for this research from TokenUsage table 

827 from sqlalchemy import func 

828 

829 token_usages = ( 

830 session.query( 

831 TokenUsage.model_name, 

832 TokenUsage.model_provider, 

833 func.sum(TokenUsage.prompt_tokens).label("prompt_tokens"), 

834 func.sum(TokenUsage.completion_tokens).label( 

835 "completion_tokens" 

836 ), 

837 func.sum(TokenUsage.total_tokens).label("total_tokens"), 

838 func.count().label("calls"), 

839 ) 

840 .filter_by(research_id=research_id) 

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

842 .order_by(func.sum(TokenUsage.total_tokens).desc()) 

843 .all() 

844 ) 

845 

846 model_usage = [] 

847 total_tokens = 0 

848 total_calls = 0 

849 

850 for usage in token_usages: 

851 model_usage.append( 

852 { 

853 "model": usage.model_name, 

854 "provider": usage.model_provider, 

855 "tokens": usage.total_tokens or 0, 

856 "calls": usage.calls or 0, 

857 "prompt_tokens": usage.prompt_tokens or 0, 

858 "completion_tokens": usage.completion_tokens or 0, 

859 } 

860 ) 

861 total_tokens += usage.total_tokens or 0 

862 total_calls += usage.calls or 0 

863 

864 return { 

865 "research_id": research_id, 

866 "total_tokens": total_tokens, 

867 "total_calls": total_calls, 

868 "model_usage": model_usage, 

869 } 

870 

871 def get_overall_metrics( 

872 self, period: str = "30d", research_mode: str = "all" 

873 ) -> Dict[str, Any]: 

874 """Get overall token metrics across all researches. 

875 

876 Args: 

877 period: Time period to filter by ('7d', '30d', '3m', '1y', 'all') 

878 research_mode: Research mode to filter by ('quick', 'detailed', 'all') 

879 

880 Returns: 

881 Dictionary containing overall metrics 

882 """ 

883 return self._get_metrics_from_encrypted_db(period, research_mode) 

884 

885 def _get_metrics_from_encrypted_db( 

886 self, period: str, research_mode: str 

887 ) -> Dict[str, Any]: 

888 """Get metrics from user's encrypted database.""" 

889 from flask import session as flask_session 

890 

891 from ..database.session_context import get_user_db_session 

892 

893 username = flask_session.get("username") 

894 if not username: 

895 return self._get_empty_metrics() 

896 

897 try: 

898 with get_user_db_session(username) as session: 

899 # Build base query with filters 

900 query = session.query(TokenUsage) 

901 

902 # Apply time filter 

903 time_condition = get_time_filter_condition( 

904 period, TokenUsage.timestamp 

905 ) 

906 if time_condition is not None: 

907 query = query.filter(time_condition) 

908 

909 # Apply research mode filter 

910 mode_condition = get_research_mode_condition( 

911 research_mode, TokenUsage.research_mode 

912 ) 

913 if mode_condition is not None: 

914 query = query.filter(mode_condition) 

915 

916 # Total tokens from TokenUsage 

917 total_tokens = ( 

918 query.with_entities( 

919 func.sum(TokenUsage.total_tokens) 

920 ).scalar() 

921 or 0 

922 ) 

923 

924 # Import ResearchHistory model 

925 from ..database.models.research import ResearchHistory 

926 

927 # Count researches from ResearchHistory table 

928 research_query = session.query(func.count(ResearchHistory.id)) 

929 

930 # Debug: Check if any research history records exist at all 

931 all_research_count = ( 

932 session.query(func.count(ResearchHistory.id)).scalar() or 0 

933 ) 

934 logger.debug( 

935 f"Total ResearchHistory records in database: {all_research_count}" 

936 ) 

937 

938 # Debug: List first few research IDs and their timestamps 

939 sample_researches = ( 

940 session.query( 

941 ResearchHistory.id, 

942 ResearchHistory.created_at, 

943 ResearchHistory.mode, 

944 ) 

945 .limit(5) 

946 .all() 

947 ) 

948 if sample_researches: 

949 logger.debug("Sample ResearchHistory records:") 

950 for r_id, r_created, r_mode in sample_researches: 

951 logger.debug( 

952 f" - ID: {r_id}, Created: {r_created}, Mode: {r_mode}" 

953 ) 

954 else: 

955 logger.debug("No ResearchHistory records found in database") 

956 

957 # Time filter for the ResearchHistory count. created_at is 

958 # an ISO-8601 TEXT column, so compare isoformat strings. 

959 # Must use the API's period vocabulary ('7d'/'30d'/'3m'/ 

960 # '1y'/'all') — the previous 'today'/'week'/'month' branches 

961 # never matched it, so the research count silently ignored 

962 # the selected period and always showed all-time. 

963 start_time = get_period_cutoff(period) 

964 if start_time is not None: 

965 research_query = research_query.filter( 

966 ResearchHistory.created_at >= start_time.isoformat() 

967 ) 

968 

969 # Apply mode filter if specified 

970 mode_filter = research_mode if research_mode != "all" else None 

971 if mode_filter: 

972 logger.debug(f"Applying mode filter: {mode_filter}") 

973 research_query = research_query.filter( 

974 ResearchHistory.mode == mode_filter 

975 ) 

976 

977 total_researches = research_query.scalar() or 0 

978 logger.debug( 

979 f"Final filtered research count: {total_researches}" 

980 ) 

981 

982 # Also check distinct research_ids in TokenUsage for comparison 

983 token_research_count = ( 

984 session.query( 

985 func.count(func.distinct(TokenUsage.research_id)) 

986 ).scalar() 

987 or 0 

988 ) 

989 logger.debug( 

990 f"Distinct research_ids in TokenUsage: {token_research_count}" 

991 ) 

992 

993 # Model statistics using ORM aggregation 

994 model_stats_query = session.query( 

995 TokenUsage.model_name, 

996 func.sum(TokenUsage.total_tokens).label("tokens"), 

997 func.count().label("calls"), 

998 func.sum(TokenUsage.prompt_tokens).label("prompt_tokens"), 

999 func.sum(TokenUsage.completion_tokens).label( 

1000 "completion_tokens" 

1001 ), 

1002 ).filter(TokenUsage.model_name.isnot(None)) 

1003 

1004 # Apply same filters to model stats 

1005 if time_condition is not None: 

1006 model_stats_query = model_stats_query.filter(time_condition) 

1007 if mode_condition is not None: 

1008 model_stats_query = model_stats_query.filter(mode_condition) 

1009 

1010 model_stats = ( 

1011 model_stats_query.group_by(TokenUsage.model_name) 

1012 .order_by(func.sum(TokenUsage.total_tokens).desc()) 

1013 .all() 

1014 ) 

1015 

1016 # Batch load provider info from ModelUsage table (fix N+1) 

1017 model_names = [stat.model_name for stat in model_stats] 

1018 provider_map = {} 

1019 if model_names: 

1020 provider_results = ( 

1021 session.query( 

1022 ModelUsage.model_name, ModelUsage.model_provider 

1023 ) 

1024 .filter(ModelUsage.model_name.in_(model_names)) 

1025 .order_by(ModelUsage.id) 

1026 .all() 

1027 ) 

1028 for model_name, model_provider in provider_results: 

1029 provider_map.setdefault(model_name, model_provider) 

1030 

1031 by_model = [] 

1032 for stat in model_stats: 

1033 provider = provider_map.get(stat.model_name, "unknown") 

1034 

1035 by_model.append( 

1036 { 

1037 "model": stat.model_name, 

1038 "provider": provider, 

1039 "tokens": stat.tokens, 

1040 "calls": stat.calls, 

1041 "prompt_tokens": stat.prompt_tokens, 

1042 "completion_tokens": stat.completion_tokens, 

1043 } 

1044 ) 

1045 

1046 # Get recent researches with token usage 

1047 # Note: This requires research_history table - for now we'll use available data 

1048 recent_research_query = session.query( 

1049 TokenUsage.research_id, 

1050 func.sum(TokenUsage.total_tokens).label("token_count"), 

1051 func.max(TokenUsage.timestamp).label("latest_timestamp"), 

1052 ).filter(TokenUsage.research_id.isnot(None)) 

1053 

1054 if time_condition is not None: 

1055 recent_research_query = recent_research_query.filter( 

1056 time_condition 

1057 ) 

1058 if mode_condition is not None: 

1059 recent_research_query = recent_research_query.filter( 

1060 mode_condition 

1061 ) 

1062 

1063 recent_research_data = ( 

1064 recent_research_query.group_by(TokenUsage.research_id) 

1065 .order_by(func.max(TokenUsage.timestamp).desc()) 

1066 .limit(10) 

1067 .all() 

1068 ) 

1069 

1070 # Batch load research queries for recent researches (fix N+1) 

1071 recent_research_ids = [ 

1072 r.research_id for r in recent_research_data 

1073 ] 

1074 research_query_map = {} 

1075 if recent_research_ids: 

1076 # Get first non-null research_query for each research_id 

1077 query_results = ( 

1078 session.query( 

1079 TokenUsage.research_id, TokenUsage.research_query 

1080 ) 

1081 .filter( 

1082 TokenUsage.research_id.in_(recent_research_ids), 

1083 TokenUsage.research_query.isnot(None), 

1084 ) 

1085 .order_by(TokenUsage.id) 

1086 .all() 

1087 ) 

1088 for research_id, research_query in query_results: 

1089 if research_id not in research_query_map: 1089 ↛ 1088line 1089 didn't jump to line 1088 because the condition on line 1089 was always true

1090 research_query_map[research_id] = research_query 

1091 

1092 recent_researches = [] 

1093 for research_data in recent_research_data: 

1094 query_text = research_query_map.get( 

1095 research_data.research_id, 

1096 f"Research {research_data.research_id}", 

1097 ) 

1098 

1099 recent_researches.append( 

1100 { 

1101 "id": research_data.research_id, 

1102 "query": query_text, 

1103 "tokens": research_data.token_count or 0, 

1104 "created_at": research_data.latest_timestamp, 

1105 } 

1106 ) 

1107 

1108 # Token breakdown statistics 

1109 breakdown_query = query.with_entities( 

1110 func.sum(TokenUsage.prompt_tokens).label( 

1111 "total_input_tokens" 

1112 ), 

1113 func.sum(TokenUsage.completion_tokens).label( 

1114 "total_output_tokens" 

1115 ), 

1116 func.avg(TokenUsage.prompt_tokens).label( 

1117 "avg_input_tokens" 

1118 ), 

1119 func.avg(TokenUsage.completion_tokens).label( 

1120 "avg_output_tokens" 

1121 ), 

1122 func.avg(TokenUsage.total_tokens).label("avg_total_tokens"), 

1123 ) 

1124 token_breakdown = breakdown_query.first() 

1125 

1126 # Get rate limiting metrics 

1127 from ..database.models import ( 

1128 RateLimitAttempt, 

1129 RateLimitEstimate, 

1130 ) 

1131 

1132 # Get rate limit attempts 

1133 rate_limit_query = session.query(RateLimitAttempt) 

1134 

1135 # Apply time filter 

1136 if time_condition is not None: 

1137 # RateLimitAttempt uses timestamp as float, not datetime 

1138 if period == "7d": 

1139 cutoff_time = time.time() - (7 * 24 * 3600) 

1140 elif period == "30d": 

1141 cutoff_time = time.time() - (30 * 24 * 3600) 

1142 elif period == "3m": 

1143 cutoff_time = time.time() - (90 * 24 * 3600) 

1144 elif period == "1y": 

1145 cutoff_time = time.time() - (365 * 24 * 3600) 

1146 else: # all 

1147 cutoff_time = 0 

1148 

1149 if cutoff_time > 0: 

1150 rate_limit_query = rate_limit_query.filter( 

1151 RateLimitAttempt.timestamp >= cutoff_time 

1152 ) 

1153 

1154 # Get rate limit statistics 

1155 total_attempts = rate_limit_query.count() 

1156 successful_attempts = rate_limit_query.filter( 

1157 RateLimitAttempt.success 

1158 ).count() 

1159 failed_attempts = total_attempts - successful_attempts 

1160 

1161 # Count rate limiting events (failures with RateLimitError) 

1162 rate_limit_events = rate_limit_query.filter( 

1163 ~RateLimitAttempt.success, 

1164 RateLimitAttempt.error_type == "RateLimitError", 

1165 ).count() 

1166 

1167 logger.debug( 

1168 f"Rate limit attempts in database: total={total_attempts}, successful={successful_attempts}" 

1169 ) 

1170 

1171 # Get all attempts for detailed calculations 

1172 attempts = rate_limit_query.all() 

1173 

1174 # Calculate average wait times 

1175 if attempts: 

1176 avg_wait_time = sum(a.wait_time for a in attempts) / len( 

1177 attempts 

1178 ) 

1179 successful_wait_times = [ 

1180 a.wait_time for a in attempts if a.success 

1181 ] 

1182 avg_successful_wait = ( 

1183 sum(successful_wait_times) / len(successful_wait_times) 

1184 if successful_wait_times 

1185 else 0 

1186 ) 

1187 else: 

1188 avg_wait_time = 0 

1189 avg_successful_wait = 0 

1190 

1191 # Get tracked engines - count distinct engine types from attempts 

1192 tracked_engines_query = session.query( 

1193 func.count(func.distinct(RateLimitAttempt.engine_type)) 

1194 ) 

1195 if cutoff_time > 0: 

1196 tracked_engines_query = tracked_engines_query.filter( 

1197 RateLimitAttempt.timestamp >= cutoff_time 

1198 ) 

1199 tracked_engines = tracked_engines_query.scalar() or 0 

1200 

1201 # Get engine-specific stats from attempts 

1202 engine_stats = [] 

1203 

1204 # Get distinct engine types from attempts 

1205 engine_types_query = session.query( 

1206 RateLimitAttempt.engine_type 

1207 ).distinct() 

1208 if cutoff_time > 0: 

1209 engine_types_query = engine_types_query.filter( 

1210 RateLimitAttempt.timestamp >= cutoff_time 

1211 ) 

1212 engine_types = [ 

1213 row.engine_type for row in engine_types_query.all() 

1214 ] 

1215 

1216 # Batch-load all estimates (fix N+1 query) 

1217 estimates_map = {} 

1218 if engine_types: 

1219 all_estimates = ( 

1220 session.query(RateLimitEstimate) 

1221 .filter(RateLimitEstimate.engine_type.in_(engine_types)) 

1222 .all() 

1223 ) 

1224 estimates_map = {e.engine_type: e for e in all_estimates} 

1225 

1226 for engine_type in engine_types: 

1227 engine_attempts_list = [ 

1228 a for a in attempts if a.engine_type == engine_type 

1229 ] 

1230 engine_attempts = len(engine_attempts_list) 

1231 engine_success = len( 

1232 [a for a in engine_attempts_list if a.success] 

1233 ) 

1234 

1235 # Get estimate if exists 

1236 estimate = estimates_map.get(engine_type) 

1237 

1238 # Calculate recent success rate 

1239 recent_success_rate = ( 

1240 (engine_success / engine_attempts * 100) 

1241 if engine_attempts > 0 

1242 else 0 

1243 ) 

1244 

1245 # Determine status based on success rate 

1246 if estimate: 

1247 status = ( 

1248 "healthy" 

1249 if estimate.success_rate > 0.8 

1250 else "degraded" 

1251 if estimate.success_rate > 0.5 

1252 else "poor" 

1253 ) 

1254 else: 

1255 status = ( 

1256 "healthy" 

1257 if recent_success_rate > 80 

1258 else "degraded" 

1259 if recent_success_rate > 50 

1260 else "poor" 

1261 ) 

1262 

1263 engine_stat = { 

1264 "engine": engine_type, 

1265 "base_wait": estimate.base_wait_seconds 

1266 if estimate 

1267 else 0.0, 

1268 "base_wait_seconds": round( 

1269 estimate.base_wait_seconds if estimate else 0.0, 2 

1270 ), 

1271 "min_wait_seconds": round( 

1272 estimate.min_wait_seconds if estimate else 0.0, 2 

1273 ), 

1274 "max_wait_seconds": round( 

1275 estimate.max_wait_seconds if estimate else 0.0, 2 

1276 ), 

1277 "success_rate": round(estimate.success_rate * 100, 1) 

1278 if estimate 

1279 else recent_success_rate, 

1280 "total_attempts": estimate.total_attempts 

1281 if estimate 

1282 else engine_attempts, 

1283 "recent_attempts": engine_attempts, 

1284 "recent_success_rate": round(recent_success_rate, 1), 

1285 "attempts": engine_attempts, 

1286 "status": status, 

1287 } 

1288 

1289 if estimate: 

1290 engine_stat["last_updated"] = datetime.fromtimestamp( 

1291 estimate.last_updated 

1292 ).strftime("%Y-%m-%d %H:%M:%S") 

1293 else: 

1294 engine_stat["last_updated"] = "Never" 

1295 

1296 engine_stats.append(engine_stat) 

1297 

1298 logger.debug( 

1299 f"Tracked engines: {tracked_engines}, engine_stats: {engine_stats}" 

1300 ) 

1301 

1302 result = { 

1303 "total_tokens": total_tokens, 

1304 "total_researches": total_researches, 

1305 "by_model": by_model, 

1306 "recent_researches": recent_researches, 

1307 "token_breakdown": { 

1308 "total_input_tokens": int( 

1309 token_breakdown.total_input_tokens or 0 

1310 ), 

1311 "total_output_tokens": int( 

1312 token_breakdown.total_output_tokens or 0 

1313 ), 

1314 "avg_input_tokens": int( 

1315 token_breakdown.avg_input_tokens or 0 

1316 ), 

1317 "avg_output_tokens": int( 

1318 token_breakdown.avg_output_tokens or 0 

1319 ), 

1320 "avg_total_tokens": int( 

1321 token_breakdown.avg_total_tokens or 0 

1322 ), 

1323 }, 

1324 "rate_limiting": { 

1325 "total_attempts": total_attempts, 

1326 "successful_attempts": successful_attempts, 

1327 "failed_attempts": failed_attempts, 

1328 "success_rate": ( 

1329 successful_attempts / total_attempts * 100 

1330 ) 

1331 if total_attempts > 0 

1332 else 0, 

1333 "rate_limit_events": rate_limit_events, 

1334 "avg_wait_time": round(float(avg_wait_time), 2), 

1335 "avg_successful_wait": round( 

1336 float(avg_successful_wait), 2 

1337 ), 

1338 "tracked_engines": tracked_engines, 

1339 "engine_stats": engine_stats, 

1340 "total_engines_tracked": tracked_engines, 

1341 "healthy_engines": len( 

1342 [ 

1343 s 

1344 for s in engine_stats 

1345 if s["status"] == "healthy" 

1346 ] 

1347 ), 

1348 "degraded_engines": len( 

1349 [ 

1350 s 

1351 for s in engine_stats 

1352 if s["status"] == "degraded" 

1353 ] 

1354 ), 

1355 "poor_engines": len( 

1356 [s for s in engine_stats if s["status"] == "poor"] 

1357 ), 

1358 }, 

1359 } 

1360 

1361 logger.debug( 

1362 f"Returning from _get_metrics_from_encrypted_db - total_researches: {result['total_researches']}" 

1363 ) 

1364 return result 

1365 except Exception: 

1366 logger.exception( 

1367 "CRITICAL ERROR accessing encrypted database for metrics" 

1368 ) 

1369 return self._get_empty_metrics() 

1370 

1371 def _get_empty_metrics(self) -> Dict[str, Any]: 

1372 """Return empty metrics structure when no data is available.""" 

1373 return { 

1374 "total_tokens": 0, 

1375 "total_researches": 0, 

1376 "by_model": [], 

1377 "recent_researches": [], 

1378 "token_breakdown": { 

1379 "prompt_tokens": 0, 

1380 "completion_tokens": 0, 

1381 "avg_prompt_tokens": 0, 

1382 "avg_completion_tokens": 0, 

1383 "avg_total_tokens": 0, 

1384 }, 

1385 } 

1386 

1387 def get_enhanced_metrics( 

1388 self, period: str = "30d", research_mode: str = "all" 

1389 ) -> Dict[str, Any]: 

1390 """Get enhanced Phase 1 tracking metrics. 

1391 

1392 Args: 

1393 period: Time period to filter by ('7d', '30d', '3m', '1y', 'all') 

1394 research_mode: Research mode to filter by ('quick', 'detailed', 'all') 

1395 

1396 Returns: 

1397 Dictionary containing enhanced metrics data including time series 

1398 """ 

1399 from flask import session as flask_session 

1400 

1401 from ..database.session_context import get_user_db_session 

1402 

1403 username = flask_session.get("username") 

1404 if not username: 

1405 # Return empty metrics structure when no user session 

1406 return { 

1407 "recent_enhanced_data": [], 

1408 "performance_stats": { 

1409 "avg_response_time": 0, 

1410 "min_response_time": 0, 

1411 "max_response_time": 0, 

1412 "success_rate": 0, 

1413 "error_rate": 0, 

1414 "total_enhanced_calls": 0, 

1415 }, 

1416 "mode_breakdown": [], 

1417 "search_engine_stats": [], 

1418 "phase_breakdown": [], 

1419 "time_series_data": [], 

1420 "call_stack_analysis": { 

1421 "by_file": [], 

1422 "by_function": [], 

1423 }, 

1424 } 

1425 

1426 try: 

1427 with get_user_db_session(username) as session: 

1428 # Build base query with filters 

1429 query = session.query(TokenUsage) 

1430 

1431 # Apply time filter 

1432 time_condition = get_time_filter_condition( 

1433 period, TokenUsage.timestamp 

1434 ) 

1435 if time_condition is not None: 1435 ↛ 1439line 1435 didn't jump to line 1439 because the condition on line 1435 was always true

1436 query = query.filter(time_condition) 

1437 

1438 # Apply research mode filter 

1439 mode_condition = get_research_mode_condition( 

1440 research_mode, TokenUsage.research_mode 

1441 ) 

1442 if mode_condition is not None: 1442 ↛ 1443line 1442 didn't jump to line 1443 because the condition on line 1442 was never true

1443 query = query.filter(mode_condition) 

1444 

1445 # Get time series data for the chart - most important for "Token Consumption Over Time" 

1446 time_series_query = query.filter( 

1447 TokenUsage.timestamp.isnot(None), 

1448 TokenUsage.total_tokens > 0, 

1449 ).order_by(TokenUsage.timestamp.asc()) 

1450 

1451 # Limit to recent data for performance 

1452 if period != "all": 1452 ↛ 1455line 1452 didn't jump to line 1455 because the condition on line 1452 was always true

1453 time_series_query = time_series_query.limit(200) 

1454 

1455 time_series_data = time_series_query.all() 

1456 

1457 # Format time series data with cumulative calculations 

1458 time_series = [] 

1459 cumulative_tokens = 0 

1460 cumulative_prompt_tokens = 0 

1461 cumulative_completion_tokens = 0 

1462 

1463 for usage in time_series_data: 1463 ↛ 1464line 1463 didn't jump to line 1464 because the loop on line 1463 never started

1464 cumulative_tokens += usage.total_tokens or 0 

1465 cumulative_prompt_tokens += usage.prompt_tokens or 0 

1466 cumulative_completion_tokens += usage.completion_tokens or 0 

1467 

1468 time_series.append( 

1469 { 

1470 "timestamp": str(usage.timestamp) 

1471 if usage.timestamp 

1472 else None, 

1473 "tokens": usage.total_tokens or 0, 

1474 "prompt_tokens": usage.prompt_tokens or 0, 

1475 "completion_tokens": usage.completion_tokens or 0, 

1476 "cumulative_tokens": cumulative_tokens, 

1477 "cumulative_prompt_tokens": cumulative_prompt_tokens, 

1478 "cumulative_completion_tokens": cumulative_completion_tokens, 

1479 "research_id": usage.research_id, 

1480 } 

1481 ) 

1482 

1483 # Basic performance stats using ORM 

1484 performance_query = query.filter( 

1485 TokenUsage.response_time_ms.isnot(None) 

1486 ) 

1487 total_calls = performance_query.count() 

1488 

1489 if total_calls > 0: 

1490 avg_response_time = ( 

1491 performance_query.with_entities( 

1492 func.avg(TokenUsage.response_time_ms) 

1493 ).scalar() 

1494 or 0 

1495 ) 

1496 min_response_time = ( 

1497 performance_query.with_entities( 

1498 func.min(TokenUsage.response_time_ms) 

1499 ).scalar() 

1500 or 0 

1501 ) 

1502 max_response_time = ( 

1503 performance_query.with_entities( 

1504 func.max(TokenUsage.response_time_ms) 

1505 ).scalar() 

1506 or 0 

1507 ) 

1508 success_count = performance_query.filter( 

1509 TokenUsage.success_status == "success" 

1510 ).count() 

1511 error_count = performance_query.filter( 

1512 TokenUsage.success_status == "error" 

1513 ).count() 

1514 

1515 perf_stats = { 

1516 "avg_response_time": round(avg_response_time), 

1517 "min_response_time": min_response_time, 

1518 "max_response_time": max_response_time, 

1519 "success_rate": ( 

1520 round((success_count / total_calls * 100), 1) 

1521 if total_calls > 0 

1522 else 0 

1523 ), 

1524 "error_rate": ( 

1525 round((error_count / total_calls * 100), 1) 

1526 if total_calls > 0 

1527 else 0 

1528 ), 

1529 "total_enhanced_calls": total_calls, 

1530 } 

1531 else: 

1532 perf_stats = { 

1533 "avg_response_time": 0, 

1534 "min_response_time": 0, 

1535 "max_response_time": 0, 

1536 "success_rate": 0, 

1537 "error_rate": 0, 

1538 "total_enhanced_calls": 0, 

1539 } 

1540 

1541 # Research mode breakdown using ORM 

1542 mode_stats = ( 

1543 query.filter(TokenUsage.research_mode.isnot(None)) 

1544 .with_entities( 

1545 TokenUsage.research_mode, 

1546 func.count().label("count"), 

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

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

1549 "avg_response_time" 

1550 ), 

1551 ) 

1552 .group_by(TokenUsage.research_mode) 

1553 .all() 

1554 ) 

1555 

1556 modes = [ 

1557 { 

1558 "mode": stat.research_mode, 

1559 "count": stat.count, 

1560 "avg_tokens": round(stat.avg_tokens or 0), 

1561 "avg_response_time": round(stat.avg_response_time or 0), 

1562 } 

1563 for stat in mode_stats 

1564 ] 

1565 

1566 # Recent enhanced data (simplified) 

1567 recent_enhanced_query = ( 

1568 query.filter(TokenUsage.research_query.isnot(None)) 

1569 .order_by(TokenUsage.timestamp.desc()) 

1570 .limit(50) 

1571 ) 

1572 

1573 recent_enhanced_data = recent_enhanced_query.all() 

1574 recent_enhanced = [ 

1575 { 

1576 "research_query": usage.research_query, 

1577 "research_mode": usage.research_mode, 

1578 "research_phase": usage.research_phase, 

1579 "search_iteration": usage.search_iteration, 

1580 "response_time_ms": usage.response_time_ms, 

1581 "success_status": usage.success_status, 

1582 "error_type": usage.error_type, 

1583 "search_engines_planned": usage.search_engines_planned, 

1584 "search_engine_selected": usage.search_engine_selected, 

1585 "total_tokens": usage.total_tokens, 

1586 "prompt_tokens": usage.prompt_tokens, 

1587 "completion_tokens": usage.completion_tokens, 

1588 "timestamp": str(usage.timestamp) 

1589 if usage.timestamp 

1590 else None, 

1591 "research_id": usage.research_id, 

1592 "calling_file": usage.calling_file, 

1593 "calling_function": usage.calling_function, 

1594 "call_stack": usage.call_stack, 

1595 } 

1596 for usage in recent_enhanced_data 

1597 ] 

1598 

1599 # Search engine breakdown using ORM 

1600 search_engine_stats = ( 

1601 query.filter(TokenUsage.search_engine_selected.isnot(None)) 

1602 .with_entities( 

1603 TokenUsage.search_engine_selected, 

1604 func.count().label("count"), 

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

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

1607 "avg_response_time" 

1608 ), 

1609 ) 

1610 .group_by(TokenUsage.search_engine_selected) 

1611 .all() 

1612 ) 

1613 

1614 search_engines = [ 

1615 { 

1616 "search_engine": stat.search_engine_selected, 

1617 "count": stat.count, 

1618 "avg_tokens": round(stat.avg_tokens or 0), 

1619 "avg_response_time": round(stat.avg_response_time or 0), 

1620 } 

1621 for stat in search_engine_stats 

1622 ] 

1623 

1624 # Research phase breakdown using ORM 

1625 phase_stats = ( 

1626 query.filter(TokenUsage.research_phase.isnot(None)) 

1627 .with_entities( 

1628 TokenUsage.research_phase, 

1629 func.count().label("count"), 

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

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

1632 "avg_response_time" 

1633 ), 

1634 ) 

1635 .group_by(TokenUsage.research_phase) 

1636 .all() 

1637 ) 

1638 

1639 phases = [ 

1640 { 

1641 "phase": stat.research_phase, 

1642 "count": stat.count, 

1643 "avg_tokens": round(stat.avg_tokens or 0), 

1644 "avg_response_time": round(stat.avg_response_time or 0), 

1645 } 

1646 for stat in phase_stats 

1647 ] 

1648 

1649 # Call stack analysis using ORM 

1650 file_stats = ( 

1651 query.filter(TokenUsage.calling_file.isnot(None)) 

1652 .with_entities( 

1653 TokenUsage.calling_file, 

1654 func.count().label("count"), 

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

1656 ) 

1657 .group_by(TokenUsage.calling_file) 

1658 .order_by(func.count().desc()) 

1659 .limit(10) 

1660 .all() 

1661 ) 

1662 

1663 files = [ 

1664 { 

1665 "file": stat.calling_file, 

1666 "count": stat.count, 

1667 "avg_tokens": round(stat.avg_tokens or 0), 

1668 } 

1669 for stat in file_stats 

1670 ] 

1671 

1672 function_stats = ( 

1673 query.filter(TokenUsage.calling_function.isnot(None)) 

1674 .with_entities( 

1675 TokenUsage.calling_function, 

1676 func.count().label("count"), 

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

1678 ) 

1679 .group_by(TokenUsage.calling_function) 

1680 .order_by(func.count().desc()) 

1681 .limit(10) 

1682 .all() 

1683 ) 

1684 

1685 functions = [ 

1686 { 

1687 "function": stat.calling_function, 

1688 "count": stat.count, 

1689 "avg_tokens": round(stat.avg_tokens or 0), 

1690 } 

1691 for stat in function_stats 

1692 ] 

1693 

1694 return { 

1695 "recent_enhanced_data": recent_enhanced, 

1696 "performance_stats": perf_stats, 

1697 "mode_breakdown": modes, 

1698 "search_engine_stats": search_engines, 

1699 "phase_breakdown": phases, 

1700 "time_series_data": time_series, 

1701 "call_stack_analysis": { 

1702 "by_file": files, 

1703 "by_function": functions, 

1704 }, 

1705 } 

1706 except Exception: 

1707 logger.exception("Error in get_enhanced_metrics") 

1708 # Return simplified response without non-existent columns 

1709 return { 

1710 "recent_enhanced_data": [], 

1711 "performance_stats": { 

1712 "avg_response_time": 0, 

1713 "min_response_time": 0, 

1714 "max_response_time": 0, 

1715 "success_rate": 0, 

1716 "error_rate": 0, 

1717 "total_enhanced_calls": 0, 

1718 }, 

1719 "mode_breakdown": [], 

1720 "search_engine_stats": [], 

1721 "phase_breakdown": [], 

1722 "time_series_data": [], 

1723 "call_stack_analysis": { 

1724 "by_file": [], 

1725 "by_function": [], 

1726 }, 

1727 } 

1728 

1729 def get_research_timeline_metrics(self, research_id: str) -> Dict[str, Any]: 

1730 """Get timeline metrics for a specific research. 

1731 

1732 Args: 

1733 research_id: The ID of the research 

1734 

1735 Returns: 

1736 Dictionary containing timeline metrics for the research 

1737 """ 

1738 from flask import session as flask_session 

1739 

1740 from ..database.session_context import get_user_db_session 

1741 

1742 username = flask_session.get("username") 

1743 if not username: 

1744 return { 

1745 "research_id": research_id, 

1746 "research_details": {}, 

1747 "timeline": [], 

1748 "summary": { 

1749 "total_calls": 0, 

1750 "total_tokens": 0, 

1751 "total_prompt_tokens": 0, 

1752 "total_completion_tokens": 0, 

1753 "avg_response_time": 0, 

1754 "success_rate": 0, 

1755 }, 

1756 "phase_stats": {}, 

1757 } 

1758 

1759 with get_user_db_session(username) as session: 

1760 # Get all token usage for this research ordered by time including call stack 

1761 timeline_data = session.execute( 

1762 text( 

1763 """ 

1764 SELECT 

1765 timestamp, 

1766 total_tokens, 

1767 prompt_tokens, 

1768 completion_tokens, 

1769 response_time_ms, 

1770 success_status, 

1771 error_type, 

1772 research_phase, 

1773 search_iteration, 

1774 search_engine_selected, 

1775 model_name, 

1776 calling_file, 

1777 calling_function, 

1778 call_stack 

1779 FROM token_usage 

1780 WHERE research_id = :research_id 

1781 ORDER BY timestamp ASC 

1782 """ 

1783 ), 

1784 {"research_id": research_id}, 

1785 ).fetchall() 

1786 

1787 # Format timeline data with cumulative tokens 

1788 timeline = [] 

1789 cumulative_tokens = 0 

1790 cumulative_prompt_tokens = 0 

1791 cumulative_completion_tokens = 0 

1792 

1793 for row in timeline_data: 

1794 cumulative_tokens += row[1] or 0 

1795 cumulative_prompt_tokens += row[2] or 0 

1796 cumulative_completion_tokens += row[3] or 0 

1797 

1798 timeline.append( 

1799 { 

1800 "timestamp": str(row[0]) if row[0] else None, 

1801 "tokens": row[1] or 0, 

1802 "prompt_tokens": row[2] or 0, 

1803 "completion_tokens": row[3] or 0, 

1804 "cumulative_tokens": cumulative_tokens, 

1805 "cumulative_prompt_tokens": cumulative_prompt_tokens, 

1806 "cumulative_completion_tokens": cumulative_completion_tokens, 

1807 "response_time_ms": row[4], 

1808 "success_status": row[5], 

1809 "error_type": row[6], 

1810 "research_phase": row[7], 

1811 "search_iteration": row[8], 

1812 "search_engine_selected": row[9], 

1813 "model_name": row[10], 

1814 "calling_file": row[11], 

1815 "calling_function": row[12], 

1816 "call_stack": row[13], 

1817 } 

1818 ) 

1819 

1820 # Get research basic info 

1821 research_info = session.execute( 

1822 text( 

1823 """ 

1824 SELECT query, mode, status, created_at, completed_at 

1825 FROM research_history 

1826 WHERE id = :research_id 

1827 """ 

1828 ), 

1829 {"research_id": research_id}, 

1830 ).fetchone() 

1831 

1832 research_details = {} 

1833 if research_info: 

1834 research_details = { 

1835 "query": research_info[0], 

1836 "mode": research_info[1], 

1837 "status": research_info[2], 

1838 "created_at": str(research_info[3]) 

1839 if research_info[3] 

1840 else None, 

1841 "completed_at": str(research_info[4]) 

1842 if research_info[4] 

1843 else None, 

1844 } 

1845 

1846 # Calculate summary stats 

1847 total_calls = len(timeline_data) 

1848 total_tokens = cumulative_tokens 

1849 avg_response_time = sum(row[4] or 0 for row in timeline_data) / max( 

1850 total_calls, 1 

1851 ) 

1852 success_rate = ( 

1853 sum(1 for row in timeline_data if row[5] == "success") 

1854 / max(total_calls, 1) 

1855 * 100 

1856 ) 

1857 

1858 # Phase breakdown for this research 

1859 phase_stats = {} 

1860 for row in timeline_data: 

1861 phase = row[7] or "unknown" 

1862 if phase not in phase_stats: 

1863 phase_stats[phase] = { 

1864 "count": 0, 

1865 "tokens": 0, 

1866 "avg_response_time": 0, 

1867 } 

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

1869 phase_stats[phase]["tokens"] += row[1] or 0 

1870 if row[4]: 1870 ↛ 1860line 1870 didn't jump to line 1860 because the condition on line 1870 was always true

1871 phase_stats[phase]["avg_response_time"] += row[4] 

1872 

1873 # Calculate averages for phases 

1874 for phase in phase_stats: 

1875 if phase_stats[phase]["count"] > 0: 1875 ↛ 1874line 1875 didn't jump to line 1874 because the condition on line 1875 was always true

1876 phase_stats[phase]["avg_response_time"] = round( 

1877 phase_stats[phase]["avg_response_time"] 

1878 / phase_stats[phase]["count"] 

1879 ) 

1880 

1881 return { 

1882 "research_id": research_id, 

1883 "research_details": research_details, 

1884 "timeline": timeline, 

1885 "summary": { 

1886 "total_calls": total_calls, 

1887 "total_tokens": total_tokens, 

1888 "total_prompt_tokens": cumulative_prompt_tokens, 

1889 "total_completion_tokens": cumulative_completion_tokens, 

1890 "avg_response_time": round(avg_response_time), 

1891 "success_rate": round(success_rate, 1), 

1892 }, 

1893 "phase_stats": phase_stats, 

1894 }