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

579 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

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

2 

3import inspect 

4import json 

5import threading 

6import time 

7import uuid 

8from datetime import UTC, datetime 

9from pathlib import Path 

10from typing import Any, Dict, List, Optional 

11 

12from langchain_core.callbacks import BaseCallbackHandler 

13from langchain_core.outputs import LLMResult 

14from loguru import logger 

15from sqlalchemy import func, text 

16 

17from ..database.models import ModelUsage, TokenUsage 

18from ..utilities.metric_write_health import record_metric_write_drop 

19from .query_utils import ( 

20 get_period_cutoff, 

21 get_research_mode_condition, 

22 get_time_filter_condition, 

23) 

24from ..utilities.request_context import ( 

25 get_current_session_id, # noqa: F401 

26 get_current_username, 

27) 

28 

29# Maximum number of frames captured per LLM call when building the 

30# simplified call stack shown in the metrics UI. Five frames is enough to 

31# surface the LDR call site and the immediate LangChain/LangGraph frames 

32# above it without flooding the persisted column. 

33_CALL_STACK_FRAME_LIMIT = 5 

34 

35 

36class TokenCountingCallback(BaseCallbackHandler): 

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

38 

39 def __init__( 

40 self, 

41 research_id: Optional[str] = None, 

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

43 ): 

44 """Initialize the token counting callback. 

45 

46 Args: 

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

48 research_context: Additional research context for enhanced tracking 

49 """ 

50 super().__init__() 

51 self.research_id = research_id 

52 self.research_context = research_context or {} 

53 self.current_model = None 

54 self.current_provider = None 

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

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

57 

58 # Phase 1 Enhancement: Track timing and context 

59 self.start_time = None 

60 self.response_time_ms = None 

61 self.success_status = "success" 

62 self.error_type = None 

63 

64 # Per-run call metadata. The callback is shared across every LLM 

65 # call in a research session (see llm_config.py wrap_llm), and a 

66 # single session can drive overlapping chains (e.g. agent + judge 

67 # sub-strategies). Storing the captured calling_file / 

68 # calling_function / call_stack on instance attributes caused 

69 # overlapping or unmatched runs to inherit each other's stack 

70 # (or to keep a previous run's stack when the new run had no 

71 # matching LDR frame). Keying by langchain's run_id and removing 

72 # the entry on end/error bounds the metadata to its call. 

73 self._call_meta: Dict[str, Dict[str, Any]] = {} 

74 self._lock = threading.Lock() 

75 # Mirror the most recently captured values for code paths (and 

76 # legacy tests) that read these as plain attributes. They are not 

77 # authoritative; the per-run dict is. 

78 self.calling_file = None 

79 self.calling_function = None 

80 self.call_stack = None 

81 

82 # Context overflow tracking 

83 self.context_limit = None 

84 self.context_truncated = False 

85 self.tokens_truncated = 0 

86 self.truncation_ratio = 0.0 

87 self.original_prompt_estimate = 0 

88 

89 # Raw Ollama response metrics 

90 self.ollama_metrics = {} 

91 

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

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

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

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

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

97 # is deduplicated. 

98 self._warned_no_usage = False 

99 

100 # Track token counts in memory 

101 self.counts = { 

102 "total_tokens": 0, 

103 "total_prompt_tokens": 0, 

104 "total_completion_tokens": 0, 

105 "by_model": {}, 

106 } 

107 

108 def on_llm_start( 

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

110 ) -> None: 

111 """Called when LLM starts running.""" 

112 # Phase 1 Enhancement: Start timing 

113 self.start_time = time.time() 

114 

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

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

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

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

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

120 # after the first one that truncates. 

121 self.context_truncated = False 

122 self.tokens_truncated = 0 

123 self.truncation_ratio = 0.0 

124 # The same goes for the outcome: on_llm_error sets it, and without 

125 # this reset every call after the session's first failure was saved 

126 # with success_status "error" and that failure's error_type. 

127 self.success_status = "success" 

128 self.error_type = None 

129 

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

131 if prompts: 

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

133 self.original_prompt_estimate = total_chars // 4 

134 logger.debug( 

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

136 ) 

137 

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

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

140 

141 # Per-run call metadata, keyed by langchain's run_id so overlapping 

142 # runs (or a no-match run following a match) don't see each other's 

143 # stack. The run_id comes in via kwargs from langchain; fall back 

144 # to a synthetic key when invoked outside the normal chain so 

145 # tests / direct callers still work. 

146 run_id = kwargs.get("run_id") or f"_synthetic-{uuid.uuid4()}" 

147 

148 # Phase 1 Enhancement: Capture call stack information 

149 calling_file = None 

150 calling_function = None 

151 call_stack = None 

152 try: 

153 # The shared synchronous dispatcher is transparent to caller 

154 # attribution. Remove its frame before applying the frame budget 

155 # so it neither replaces an injected model's real caller nor 

156 # displaces an outer caller from the persisted diagnostic stack. 

157 stack = [ 

158 frame 

159 for frame in inspect.stack() 

160 if not ( 

161 frame.function == "invoke_llm_sync" 

162 and Path(frame.filename).parts[-3:] 

163 == ("local_deep_research", "utilities", "llm_utils.py") 

164 ) 

165 ] 

166 

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

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

169 # 

170 # Note: the project is installed as a wheel inside 

171 # .venv/.../site-packages in the Docker image, so the project 

172 # frames DO contain "site-packages" and "venv" as substrings. 

173 # The "local_deep_research" package-name check is sufficient to 

174 # identify project files — third-party packages with the same 

175 # name do not exist. 

176 for matched_index, frame_info in enumerate(stack[1:], start=1): 

177 file_path = frame_info.filename 

178 if "local_deep_research" in file_path: 

179 # Extract relative path from local_deep_research 

180 if "src/local_deep_research" in file_path: 

181 relative_path = file_path.split( 

182 "src/local_deep_research" 

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

184 elif "local_deep_research/src" in file_path: 

185 relative_path = file_path.split( 

186 "local_deep_research/src" 

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

188 elif "local_deep_research" in file_path: 188 ↛ 194line 188 didn't jump to line 194 because the condition on line 188 was always true

189 # Get everything after local_deep_research 

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

191 -1 

192 ].lstrip("/") 

193 else: 

194 relative_path = Path(file_path).name 

195 

196 calling_file = relative_path 

197 calling_function = frame_info.function 

198 

199 # Capture a simplified call stack starting from the 

200 # matched frame onward (limit to _CALL_STACK_FRAME_LIMIT 

201 # frames). Previously this sliced stack[1:6] 

202 # unconditionally, so when the matched LDR frame sat 

203 # deeper than index 5 (common with langgraph), 

204 # call_stack_frames only inspected langchain/langgraph 

205 # frames and stayed empty. 

206 call_stack_frames = [] 

207 for frame in stack[ 

208 matched_index : matched_index + _CALL_STACK_FRAME_LIMIT 

209 ]: 

210 if "local_deep_research" in frame.filename: 

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

212 call_stack_frames.append(frame_name) 

213 

214 call_stack = ( 

215 " -> ".join(call_stack_frames) 

216 if call_stack_frames 

217 else None 

218 ) 

219 break 

220 except Exception: 

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

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

223 

224 with self._lock: 

225 self._call_meta[run_id] = { 

226 "calling_file": calling_file, 

227 "calling_function": calling_function, 

228 "call_stack": call_stack, 

229 } 

230 # Mirror to the legacy attributes for callers that read them 

231 # directly. The per-run dict is authoritative; these are a 

232 # convenience that reflects the most recent on_llm_start. 

233 self.calling_file = calling_file 

234 self.calling_function = calling_function 

235 self.call_stack = call_stack 

236 

237 # First, use preset values if available 

238 if self.preset_model: 

239 self.current_model = self.preset_model 

240 else: 

241 # Try multiple locations for model name 

242 model_name = None 

243 

244 # First check invocation_params 

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

246 model_name = invocation_params.get( 

247 "model" 

248 ) or invocation_params.get("model_name") 

249 

250 # Check kwargs directly 

251 if not model_name: 

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

253 

254 # Check serialized data 

255 if not model_name and "kwargs" in serialized: 

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

257 "kwargs" 

258 ].get("model_name") 

259 

260 # Check for name in serialized data 

261 if not model_name and "name" in serialized: 

262 model_name = serialized["name"] 

263 

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

265 if ( 

266 not model_name 

267 and "_type" in serialized 

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

269 ): 

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

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

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

273 else: 

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

275 model_name = "ollama" 

276 

277 # Final fallback 

278 if not model_name: 

279 if "_type" in serialized: 

280 model_name = serialized["_type"] 

281 else: 

282 model_name = "unknown" 

283 

284 self.current_model = model_name 

285 

286 # Use preset provider if available 

287 if self.preset_provider: 

288 self.current_provider = self.preset_provider 

289 else: 

290 # Extract provider from serialized type or kwargs 

291 if "_type" in serialized: 

292 type_str = serialized["_type"] 

293 if "ChatOllama" in type_str: 

294 self.current_provider = "ollama" 

295 elif "ChatOpenAI" in type_str: 

296 self.current_provider = "openai" 

297 elif "ChatAnthropic" in type_str: 

298 self.current_provider = "anthropic" 

299 else: 

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

301 else: 

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

303 

304 # Initialize model tracking if needed 

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

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

307 "prompt_tokens": 0, 

308 "completion_tokens": 0, 

309 "total_tokens": 0, 

310 "calls": 0, 

311 "provider": self.current_provider, 

312 } 

313 

314 # Increment call count 

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

316 

317 def _check_context_overflow( 

318 self, 

319 prompt_eval_count: int, 

320 completion_tokens: int = 0, 

321 source: str = "", 

322 ) -> None: 

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

324 

325 Args: 

326 prompt_eval_count: Number of tokens the model actually processed. 

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

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

329 """ 

330 logger.debug( 

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

332 f"prompt_eval_count={prompt_eval_count}, " 

333 f"completion_tokens={completion_tokens}, " 

334 f"context_limit={self.context_limit}" 

335 ) 

336 

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

338 return 

339 

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

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

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

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

344 if prompt_eval_count >= self.context_limit * 0.80: 

345 self.context_truncated = True 

346 

347 if self.original_prompt_estimate > prompt_eval_count: 

348 self.tokens_truncated = max( 

349 0, 

350 self.original_prompt_estimate - prompt_eval_count, 

351 ) 

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

353 self.tokens_truncated > 0 

354 and self.original_prompt_estimate > 0 

355 ): 

356 self.truncation_ratio = ( 

357 self.tokens_truncated / self.original_prompt_estimate 

358 ) 

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

360 self.tokens_truncated = prompt_eval_count - self.context_limit 

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

362 self.truncation_ratio = ( 

363 self.tokens_truncated / prompt_eval_count 

364 ) 

365 

366 logger.warning( 

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

368 f"research_id={self.research_id} " 

369 f"model={self.current_model} " 

370 f"provider={self.current_provider} " 

371 f"source={source} " 

372 f"prompt_tokens={prompt_eval_count} " 

373 f"context_limit={self.context_limit} " 

374 f"tokens_truncated={self.tokens_truncated} " 

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

376 ) 

377 

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

379 elif ( 

380 completion_tokens > 0 

381 and prompt_eval_count + completion_tokens 

382 >= self.context_limit * 0.95 

383 ): 

384 total = prompt_eval_count + completion_tokens 

385 self.context_truncated = True 

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

387 self.truncation_ratio = ( 

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

389 ) 

390 logger.warning( 

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

392 f"research_id={self.research_id} " 

393 f"model={self.current_model} " 

394 f"provider={self.current_provider} " 

395 f"source={source} " 

396 f"prompt_tokens={prompt_eval_count} " 

397 f"completion_tokens={completion_tokens} " 

398 f"total_tokens={total} " 

399 f"context_limit={self.context_limit} " 

400 f"tokens_truncated={self.tokens_truncated} " 

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

402 ) 

403 else: 

404 logger.debug( 

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

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

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

408 ) 

409 

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

411 """Called when LLM ends running.""" 

412 # Phase 1 Enhancement: Calculate response time 

413 if self.start_time: 

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

415 

416 # Pop the per-run call metadata captured at on_llm_start so a 

417 # later unmatched run cannot persist a previous run's stack. 

418 # Missing-key case (e.g. direct test invocation that skipped 

419 # on_llm_start) yields a None-filled dict, which the API/UI 

420 # already treat as "no stack". 

421 run_id = kwargs.get("run_id") 

422 call_meta = ( 

423 self._pop_call_meta(run_id) if run_id else self._legacy_meta() 

424 ) 

425 

426 # Extract token usage from response 

427 token_usage = None 

428 ollama_metrics = {} 

429 

430 # Check multiple locations for token usage 

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

432 token_usage = response.llm_output.get( 

433 "token_usage" 

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

435 

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

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

438 for generation_list in response.generations: 

439 for generation in generation_list: 

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

441 generation.message, "usage_metadata" 

442 ): 

443 usage_meta = generation.message.usage_metadata 

444 if usage_meta: # Check if usage_metadata is not None 

445 token_usage = { 

446 "prompt_tokens": usage_meta.get( 

447 "input_tokens", 0 

448 ), 

449 "completion_tokens": usage_meta.get( 

450 "output_tokens", 0 

451 ), 

452 # Fall back to the sum: a provider that omits 

453 # the total otherwise contributes 0 here. 

454 # `or` rather than `is None` is deliberate: a 

455 # reported total of 0 alongside nonzero input 

456 # and output is bad data, and the sum recovers 

457 # it. The `is None` checks further down 

458 # (_save_to_db, thread_metrics) persist a real 

459 # 0, because the total has already been 

460 # resolved by the time they run. 

461 "total_tokens": usage_meta.get("total_tokens") 

462 or ( 

463 usage_meta.get("input_tokens", 0) 

464 + usage_meta.get("output_tokens", 0) 

465 ), 

466 } 

467 # Check context overflow before breaking 

468 # (input_tokens == prompt_eval_count for Ollama) 

469 self._check_context_overflow( 

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

471 completion_tokens=usage_meta.get( 

472 "output_tokens", 0 

473 ), 

474 source="usage_metadata", 

475 ) 

476 break 

477 # Also check response_metadata 

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

479 generation.message, "response_metadata" 

480 ): 

481 resp_meta = generation.message.response_metadata 

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

483 "eval_count" 

484 ): 

485 # Capture raw Ollama metrics 

486 ollama_metrics = { 

487 "prompt_eval_count": resp_meta.get( 

488 "prompt_eval_count" 

489 ), 

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

491 "total_duration": resp_meta.get( 

492 "total_duration" 

493 ), 

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

495 "prompt_eval_duration": resp_meta.get( 

496 "prompt_eval_duration" 

497 ), 

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

499 } 

500 

501 # Check for context overflow (input only) 

502 prompt_eval_count = resp_meta.get( 

503 "prompt_eval_count", 0 

504 ) 

505 self._check_context_overflow( 

506 prompt_eval_count, 

507 completion_tokens=resp_meta.get( 

508 "eval_count", 0 

509 ), 

510 source="response_metadata", 

511 ) 

512 

513 token_usage = { 

514 "prompt_tokens": resp_meta.get( 

515 "prompt_eval_count", 0 

516 ), 

517 "completion_tokens": resp_meta.get( 

518 "eval_count", 0 

519 ), 

520 "total_tokens": resp_meta.get( 

521 "prompt_eval_count", 0 

522 ) 

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

524 } 

525 break 

526 if token_usage: 

527 break 

528 

529 # Persist the metrics from this response, even when another call 

530 # finishes before its database write. Keep the attribute as a 

531 # compatibility mirror, not the source for this call's saved row. 

532 call_meta["ollama_metrics"] = ollama_metrics 

533 self.ollama_metrics = ollama_metrics 

534 

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

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

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

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

539 # providers the API typically rejects oversize prompts rather than 

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

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

542 # tag in the log message. 

543 if not self.context_truncated and self.context_limit: 

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

545 if self.original_prompt_estimate > self.context_limit: 

546 self.context_truncated = True 

547 self.tokens_truncated = max( 

548 0, self.original_prompt_estimate - self.context_limit 

549 ) 

550 self.truncation_ratio = ( 

551 self.tokens_truncated / self.original_prompt_estimate 

552 if self.original_prompt_estimate > 0 

553 else 0 

554 ) 

555 logger.warning( 

556 "Context overflow detected [estimated] " 

557 f"research_id={self.research_id} " 

558 f"model={self.current_model} " 

559 f"provider={self.current_provider} " 

560 f"estimated_prompt_tokens={self.original_prompt_estimate} " 

561 f"context_limit={self.context_limit} " 

562 f"tokens_truncated={self.tokens_truncated} " 

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

564 ) 

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

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

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

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

569 # the prompt_tokens / completion_tokens here come from the 

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

571 # counts, not character-based estimates. 

572 elif token_usage and isinstance(token_usage, dict): 

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

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

575 total = prompt_tokens + completion_tokens 

576 if total >= self.context_limit * 0.95: 

577 self.context_truncated = True 

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

579 self.truncation_ratio = ( 

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

581 ) 

582 logger.warning( 

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

584 f"research_id={self.research_id} " 

585 f"model={self.current_model} " 

586 f"provider={self.current_provider} " 

587 f"prompt_tokens={prompt_tokens} " 

588 f"completion_tokens={completion_tokens} " 

589 f"total_tokens={total} " 

590 f"context_limit={self.context_limit} " 

591 f"tokens_truncated={self.tokens_truncated} " 

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

593 ) 

594 

595 if token_usage and isinstance(token_usage, dict): 

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

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

598 total_tokens = token_usage.get( 

599 "total_tokens", prompt_tokens + completion_tokens 

600 ) 

601 

602 # Update in-memory counts 

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

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

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

606 

607 if self.current_model: 

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

609 "prompt_tokens" 

610 ] += prompt_tokens 

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

612 "completion_tokens" 

613 ] += completion_tokens 

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

615 total_tokens 

616 ) 

617 

618 # Save to database if we have a research_id 

619 if self.research_id: 

620 self._save_to_db( 

621 prompt_tokens, 

622 completion_tokens, 

623 call_meta, 

624 total_tokens=total_tokens, 

625 ) 

626 elif self.research_id: 

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

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

629 # proxies that strip it, Ollama omitting prompt_eval_count for 

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

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

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

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

634 # never been used (#4457). 

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

636 if not self._warned_no_usage: 

637 self._warned_no_usage = True 

638 hint = "" 

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

640 hint = ( 

641 " If your server supports stream_options.include_usage " 

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

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

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

645 ) 

646 logger.warning( 

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

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

649 f"provider={self.current_provider} " 

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

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

652 ) 

653 self._save_to_db(0, 0, call_meta) 

654 

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

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

657 # Phase 1 Enhancement: Track errors 

658 if self.start_time: 

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

660 

661 self.success_status = "error" 

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

663 

664 # Pop the per-run call metadata captured at on_llm_start (if any) 

665 # so a later unmatched run does not inherit a failed run's stack. 

666 run_id = kwargs.get("run_id") 

667 call_meta = ( 

668 self._pop_call_meta(run_id) if run_id else self._legacy_meta() 

669 ) 

670 

671 # Still save to database to track failed calls 

672 call_meta["ollama_metrics"] = {} 

673 self.ollama_metrics = {} 

674 if self.research_id: 

675 self._save_to_db(0, 0, call_meta) 

676 

677 def _pop_call_meta(self, run_id: str) -> Dict[str, Any]: 

678 """Remove and return the per-run call metadata for ``run_id``. 

679 

680 Falls back to ``_legacy_meta()`` (the most recently captured 

681 values) if the run was never registered — e.g. ``on_llm_error`` 

682 firing without a corresponding ``on_llm_start`` (provider raised 

683 during argument validation) or a test that drives the callbacks 

684 out of order. After popping, refresh the legacy mirror 

685 attributes from the next-newest remaining entry so direct 

686 ``cb.calling_file`` reads in overlapping-run scenarios do not 

687 show a deleted run's stack. 

688 """ 

689 with self._lock: 

690 meta = self._call_meta.pop( 

691 run_id, 

692 { 

693 "calling_file": None, 

694 "calling_function": None, 

695 "call_stack": None, 

696 }, 

697 ) 

698 if self._call_meta: 

699 latest_id = next(reversed(self._call_meta)) 

700 latest = self._call_meta[latest_id] 

701 else: 

702 latest = meta 

703 self.calling_file = latest.get("calling_file") 

704 self.calling_function = latest.get("calling_function") 

705 self.call_stack = latest.get("call_stack") 

706 return meta 

707 

708 def _legacy_meta(self) -> Dict[str, Any]: 

709 """Return the legacy mirror attributes as a meta dict. 

710 

711 Used by callers that did not register a run (no run_id kwarg), 

712 e.g. direct test invocations of ``on_llm_end`` / 

713 ``on_llm_error``. The values reflect the most recent 

714 ``on_llm_start`` (or whatever the test set explicitly). 

715 """ 

716 with self._lock: 

717 return { 

718 "calling_file": self.calling_file, 

719 "calling_function": self.calling_function, 

720 "call_stack": self.call_stack, 

721 } 

722 

723 def _get_context_overflow_fields( 

724 self, ollama_metrics: Optional[Dict[str, Any]] = None 

725 ) -> Dict[str, Any]: 

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

727 if ollama_metrics is None: 

728 ollama_metrics = self.ollama_metrics 

729 return { 

730 "context_limit": self.context_limit, 

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

732 "tokens_truncated": self.tokens_truncated 

733 if self.context_truncated 

734 else None, 

735 "truncation_ratio": self.truncation_ratio 

736 if self.context_truncated 

737 else None, 

738 # Raw Ollama metrics 

739 "ollama_prompt_eval_count": ollama_metrics.get("prompt_eval_count"), 

740 "ollama_eval_count": ollama_metrics.get("eval_count"), 

741 "ollama_total_duration": ollama_metrics.get("total_duration"), 

742 "ollama_load_duration": ollama_metrics.get("load_duration"), 

743 "ollama_prompt_eval_duration": ollama_metrics.get( 

744 "prompt_eval_duration" 

745 ), 

746 "ollama_eval_duration": ollama_metrics.get("eval_duration"), 

747 } 

748 

749 def _save_to_db( 

750 self, 

751 prompt_tokens: int, 

752 completion_tokens: int, 

753 call_meta: Optional[Dict[str, Any]] = None, 

754 total_tokens: Optional[int] = None, 

755 ): 

756 """Save token usage to the database. 

757 

758 Args: 

759 prompt_tokens: Prompt-side token count for this call. 

760 completion_tokens: Completion-side token count for this call. 

761 total_tokens: Total reported by the provider, as resolved by 

762 the caller. Defaults to the sum of the two counts above 

763 when the caller has no provider-reported total. 

764 call_meta: Per-run call metadata captured by on_llm_start 

765 (calling_file / calling_function / call_stack), plus the 

766 raw Ollama metrics from on_llm_end or on_llm_error. When the 

767 callback is invoked outside on_llm_end / on_llm_error 

768 (e.g. legacy tests that set the attributes directly), 

769 ``call_meta`` may be ``None`` and the instance attributes 

770 are used as a fallback. 

771 """ 

772 if total_tokens is None: 

773 total_tokens = prompt_tokens + completion_tokens 

774 if call_meta is None: 

775 with self._lock: 

776 call_meta = { 

777 "calling_file": self.calling_file, 

778 "calling_function": self.calling_function, 

779 "call_stack": self.call_stack, 

780 } 

781 import threading 

782 

783 from ..database.thread_local_session import thread_session_manager 

784 

785 overflow_fields = self._get_context_overflow_fields( 

786 call_meta.get("ollama_metrics") 

787 ) 

788 is_main_thread = threading.current_thread().name == "MainThread" 

789 in_scope = thread_session_manager.in_scope() 

790 if not is_main_thread or in_scope: 

791 # Active scopes own their transaction on every thread. Standalone 

792 # background writes use the separate thread-safe metrics writer. 

793 username = ( 

794 self.research_context.get("username") 

795 if self.research_context 

796 else None 

797 ) 

798 

799 if not username and is_main_thread: 

800 username = get_current_username() 

801 

802 if not username: 

803 # Render the context KEYS only, never the values: this dict 

804 # is the search context in production flows, which carries 

805 # user_password ("for metrics tracking", see 

806 # api/research_functions.py) and a settings_snapshot that can 

807 # hold engine credentials. The sink patcher is shape-based; 

808 # it cannot guarantee that an arbitrary setting value or an 

809 # unlabeled password inside this dict repr is removed. A value 

810 # must never be interpolated into a log message unless it 

811 # has already been passed through redact_secrets()/ 

812 # scrub_error() at the call site, so we log key names only. 

813 # Those names are fixed literals the code assigns (username, 

814 # user_password, settings_snapshot, research_query, ...; see 

815 # research_service.py's shared_research_context and 

816 # chat/context.py's build_research_context), never 

817 # caller-supplied data, which is why logging them here is 

818 # safe despite the "do not log context keys" caution at 

819 # research_service.py:1186 — that guards against logging 

820 # arbitrary values reachable under those keys, not the key 

821 # names themselves. 

822 context_keys = ( 

823 sorted(map(str, self.research_context)) 

824 if self.research_context 

825 else [] 

826 ) 

827 logger.warning( 

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

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

830 f"Research context keys: {context_keys}" 

831 ) 

832 return 

833 

834 # Import the thread-safe metrics database 

835 

836 # Prepare token data 

837 token_data = { 

838 "model_name": self.current_model, 

839 "provider": self.current_provider, 

840 "prompt_tokens": prompt_tokens, 

841 "completion_tokens": completion_tokens, 

842 "total_tokens": total_tokens, 

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

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

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

846 "search_iteration": self.research_context.get( 

847 "search_iteration" 

848 ), 

849 "response_time_ms": self.response_time_ms, 

850 "success_status": self.success_status, 

851 "error_type": self.error_type, 

852 "search_engines_planned": self.research_context.get( 

853 "search_engines_planned" 

854 ), 

855 "search_engine_selected": self.research_context.get( 

856 "search_engine_selected" 

857 ), 

858 "calling_file": call_meta.get("calling_file"), 

859 "calling_function": call_meta.get("calling_function"), 

860 "call_stack": call_meta.get("call_stack"), 

861 # Add context overflow fields using helper method 

862 **overflow_fields, 

863 } 

864 

865 # Convert list to JSON string if needed 

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

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

868 token_data["search_engines_planned"] 

869 ) 

870 

871 # Get password from research context 

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

873 if not password and not in_scope: 

874 record_metric_write_drop( 

875 username, "tokens", "missing_credentials" 

876 ) 

877 logger.warning( 

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

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

880 ) 

881 return 

882 

883 # Write metrics directly using thread-safe database 

884 try: 

885 from ..database.thread_metrics import metrics_writer 

886 

887 if in_scope: 

888 # Inside the caller's write transaction a second 

889 # connection would wait on this thread's own uncommitted 

890 # SQLite write lock. Borrow only the active research 

891 # owner's session; never replace or roll back another 

892 # user's enclosing transaction. telemetry_savepoint() 

893 # joins the caller's open write transaction, or commits 

894 # at once on its own when the scope has not written. 

895 session = thread_session_manager.get_scoped_session( 

896 username 

897 ) 

898 if session is None: 

899 record_metric_write_drop( 

900 username, "tokens", "database_unavailable" 

901 ) 

902 # Unlike rollback-coupled loss, an owner mismatch is 

903 # an anomaly worth surfacing (as rate telemetry does). 

904 logger.warning( 

905 "Skipping in-scope token metrics without a " 

906 "matching owner session" 

907 ) 

908 return 

909 if session.new or session.dirty or session.deleted: 

910 record_metric_write_drop( 

911 username, "tokens", "pending_transaction" 

912 ) 

913 # begin_nested() flushes pending ORM work BEFORE the 

914 # SAVEPOINT. A failed flush can invalidate the caller 

915 # transaction, so best-effort telemetry must skip. 

916 logger.debug( 

917 "Skipping in-scope token metrics with pending " 

918 "caller ORM state" 

919 ) 

920 return 

921 from ..database.session_context import ( 

922 telemetry_savepoint, 

923 ) 

924 

925 with telemetry_savepoint(session): 

926 metrics_writer.add_token_metrics_to_session( 

927 session, self.research_id, token_data 

928 ) 

929 return 

930 

931 from ..database.sqlcipher_utils import retry_on_db_lock 

932 

933 # Set password for this thread 

934 metrics_writer.set_user_password(username, password) 

935 

936 # ``write_token_metrics`` commits through a SQLAlchemy 

937 # session, so the lock error arrives wrapped as 

938 # ``sqlalchemy.exc.OperationalError``. ``retry_on_db_lock`` 

939 # catches that subclass (and the unwrapped DBAPI 

940 # variants) and retries with exponential backoff so a 

941 # short blip from the background document scheduler's 

942 # PDF-download loop doesn't surface as a permanent 

943 # "Failed to write metrics from thread" warning. 

944 def _write_metrics(): 

945 metrics_writer.write_token_metrics( 

946 username, self.research_id, token_data 

947 ) 

948 

949 retry_on_db_lock(_write_metrics) 

950 except Exception as e: 

951 record_metric_write_drop(username, "tokens", "write_error") 

952 # Deferred import to avoid potential circular imports during module initialization 

953 from ..security.log_sanitizer import scrub_error 

954 

955 safe_msg = scrub_error(e, password) 

956 logger.warning( 

957 f"Failed to write metrics from thread: {safe_msg}" 

958 ) 

959 return 

960 

961 # In MainThread, save directly 

962 username = None 

963 try: 

964 from ..database.session_context import get_user_db_session 

965 

966 # The query metadata belongs to the research, even if a different 

967 # account's request is active when its callback runs. 

968 research_username = self.research_context.get("username") 

969 username = research_username or get_current_username() 

970 if not username: 

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

972 return 

973 

974 password = ( 

975 self.research_context.get("user_password") 

976 if research_username 

977 else None 

978 ) 

979 with get_user_db_session(username, password=password) as session: 

980 # Phase 1 Enhancement: Prepare additional context 

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

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

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

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

985 search_engines_planned = self.research_context.get( 

986 "search_engines_planned" 

987 ) 

988 search_engine_selected = self.research_context.get( 

989 "search_engine_selected" 

990 ) 

991 

992 # Debug logging for search engine context 

993 if search_engines_planned or search_engine_selected: 

994 logger.info( 

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

996 ) 

997 else: 

998 logger.debug( 

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

1000 ) 

1001 

1002 # Convert list to JSON string if needed 

1003 if isinstance(search_engines_planned, list): 

1004 search_engines_planned = json.dumps(search_engines_planned) 

1005 

1006 # Log context overflow detection values before saving 

1007 logger.debug( 

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

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

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

1011 f"ollama_prompt_eval_count: {overflow_fields['ollama_prompt_eval_count']}, " 

1012 f"prompt_tokens: {prompt_tokens}, " 

1013 f"completion_tokens: {completion_tokens}" 

1014 ) 

1015 

1016 # Add token usage record with enhanced fields 

1017 token_usage = TokenUsage( 

1018 research_id=self.research_id, 

1019 model_name=self.current_model, 

1020 model_provider=self.current_provider, # Added provider 

1021 # for accurate cost tracking 

1022 prompt_tokens=prompt_tokens, 

1023 completion_tokens=completion_tokens, 

1024 total_tokens=total_tokens, 

1025 # Phase 1 Enhancement: Research context 

1026 research_query=research_query, 

1027 research_mode=research_mode, 

1028 research_phase=research_phase, 

1029 search_iteration=search_iteration, 

1030 # Phase 1 Enhancement: Performance metrics 

1031 response_time_ms=self.response_time_ms, 

1032 success_status=self.success_status, 

1033 error_type=self.error_type, 

1034 # Phase 1 Enhancement: Search engine context 

1035 search_engines_planned=search_engines_planned, 

1036 search_engine_selected=search_engine_selected, 

1037 # Phase 1 Enhancement: Call stack tracking 

1038 calling_file=call_meta.get("calling_file"), 

1039 calling_function=call_meta.get("calling_function"), 

1040 call_stack=call_meta.get("call_stack"), 

1041 # Add context overflow fields using helper method 

1042 **overflow_fields, 

1043 ) 

1044 session.add(token_usage) 

1045 

1046 # Update or create model usage statistics 

1047 model_usage = ( 

1048 session.query(ModelUsage) 

1049 .filter_by( 

1050 model_name=self.current_model, 

1051 model_provider=self.current_provider, 

1052 ) 

1053 .first() 

1054 ) 

1055 

1056 if model_usage: 

1057 model_usage.total_tokens += total_tokens 

1058 model_usage.total_calls += 1 

1059 else: 

1060 model_usage = ModelUsage( 

1061 model_name=self.current_model, 

1062 model_provider=self.current_provider, 

1063 total_tokens=total_tokens, 

1064 total_calls=1, 

1065 ) 

1066 session.add(model_usage) 

1067 

1068 # Commit the transaction 

1069 session.commit() 

1070 

1071 except Exception as e: 

1072 record_metric_write_drop(username, "tokens", "write_error") 

1073 # Deferred import to avoid potential circular imports during module initialization 

1074 from ..security.log_sanitizer import scrub_error 

1075 

1076 safe_msg = scrub_error(e) 

1077 logger.warning(f"Error saving token usage to database: {safe_msg}") 

1078 

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

1080 """Get the current token counts.""" 

1081 return self.counts 

1082 

1083 

1084class TokenCounter: 

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

1086 

1087 def __init__(self): 

1088 """Initialize the token counter.""" 

1089 

1090 def create_callback( 

1091 self, 

1092 research_id: Optional[str] = None, 

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

1094 ) -> TokenCountingCallback: 

1095 """Create a new token counting callback. 

1096 

1097 Args: 

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

1099 research_context: Additional research context for enhanced tracking 

1100 

1101 Returns: 

1102 A new TokenCountingCallback instance 

1103 """ 

1104 return TokenCountingCallback( 

1105 research_id=research_id, research_context=research_context 

1106 ) 

1107 

1108 def get_research_metrics( 

1109 self, research_id: str, username: Optional[str] = None 

1110 ) -> Dict[str, Any]: 

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

1112 

1113 Args: 

1114 research_id: The ID of the research 

1115 username: Authenticated user whose encrypted DB to open. If 

1116 omitted, falls back to the request's contextvar — but 

1117 passing it explicitly from the route handler is 

1118 preferred so the lookup is obviously user-scoped even 

1119 if the contextvar path ever regresses. 

1120 

1121 Returns: 

1122 Dictionary containing token usage metrics 

1123 """ 

1124 

1125 from ..database.session_context import get_user_db_session 

1126 

1127 if username is None: 

1128 username = get_current_username() 

1129 if not username: 

1130 return { 

1131 "research_id": research_id, 

1132 "total_tokens": 0, 

1133 "total_calls": 0, 

1134 "model_usage": [], 

1135 } 

1136 

1137 with get_user_db_session(username) as session: 

1138 # Get token usage for this research from TokenUsage table 

1139 from sqlalchemy import func 

1140 

1141 token_usages = ( 

1142 session.query( 

1143 TokenUsage.model_name, 

1144 TokenUsage.model_provider, 

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

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

1147 "completion_tokens" 

1148 ), 

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

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

1151 ) 

1152 .filter_by(research_id=research_id) 

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

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

1155 .all() 

1156 ) 

1157 

1158 model_usage = [] 

1159 total_tokens = 0 

1160 total_calls = 0 

1161 

1162 for usage in token_usages: 

1163 model_usage.append( 

1164 { 

1165 "model": usage.model_name, 

1166 "provider": usage.model_provider, 

1167 "tokens": usage.total_tokens or 0, 

1168 "calls": usage.calls or 0, 

1169 "prompt_tokens": usage.prompt_tokens or 0, 

1170 "completion_tokens": usage.completion_tokens or 0, 

1171 } 

1172 ) 

1173 total_tokens += usage.total_tokens or 0 

1174 total_calls += usage.calls or 0 

1175 

1176 return { 

1177 "research_id": research_id, 

1178 "total_tokens": total_tokens, 

1179 "total_calls": total_calls, 

1180 "model_usage": model_usage, 

1181 } 

1182 

1183 def get_overall_metrics( 

1184 self, 

1185 period: str = "30d", 

1186 research_mode: str = "all", 

1187 username: Optional[str] = None, 

1188 ) -> Dict[str, Any]: 

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

1190 

1191 Args: 

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

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

1194 username: Username to fetch metrics for. If omitted, falls back 

1195 to the Flask session (Flask-only path). 

1196 

1197 Returns: 

1198 Dictionary containing overall metrics 

1199 """ 

1200 return self._get_metrics_from_encrypted_db( 

1201 period, research_mode, username=username 

1202 ) 

1203 

1204 def _get_metrics_from_encrypted_db( 

1205 self, 

1206 period: str, 

1207 research_mode: str, 

1208 username: Optional[str] = None, 

1209 ) -> Dict[str, Any]: 

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

1211 from ..database.session_context import get_user_db_session 

1212 

1213 if not username: 

1214 username = get_current_username() 

1215 

1216 if not username: 

1217 return self._get_empty_metrics() 

1218 

1219 try: 

1220 with get_user_db_session(username) as session: 

1221 # Build base query with filters 

1222 query = session.query(TokenUsage) 

1223 

1224 # Apply time filter 

1225 time_condition = get_time_filter_condition( 

1226 period, TokenUsage.timestamp 

1227 ) 

1228 if time_condition is not None: 

1229 query = query.filter(time_condition) 

1230 

1231 # Apply research mode filter 

1232 mode_condition = get_research_mode_condition( 

1233 research_mode, TokenUsage.research_mode 

1234 ) 

1235 if mode_condition is not None: 

1236 query = query.filter(mode_condition) 

1237 

1238 # Total tokens from TokenUsage 

1239 total_tokens = ( 

1240 query.with_entities( 

1241 func.sum(TokenUsage.total_tokens) 

1242 ).scalar() 

1243 or 0 

1244 ) 

1245 

1246 # Import ResearchHistory model 

1247 from ..database.models.research import ResearchHistory 

1248 

1249 # Count researches from ResearchHistory table 

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

1251 

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

1253 all_research_count = ( 

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

1255 ) 

1256 logger.debug( 

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

1258 ) 

1259 

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

1261 sample_researches = ( 

1262 session.query( 

1263 ResearchHistory.id, 

1264 ResearchHistory.created_at, 

1265 ResearchHistory.mode, 

1266 ) 

1267 .limit(5) 

1268 .all() 

1269 ) 

1270 if sample_researches: 

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

1272 for r_id, r_created, r_mode in sample_researches: 

1273 logger.debug( 

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

1275 ) 

1276 else: 

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

1278 

1279 # Time filter for the ResearchHistory count. created_at is 

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

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

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

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

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

1285 start_time = get_period_cutoff(period) 

1286 if start_time is not None: 

1287 research_query = research_query.filter( 

1288 ResearchHistory.created_at >= start_time.isoformat() 

1289 ) 

1290 

1291 # Apply mode filter if specified 

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

1293 if mode_filter: 

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

1295 research_query = research_query.filter( 

1296 ResearchHistory.mode == mode_filter 

1297 ) 

1298 

1299 total_researches = research_query.scalar() or 0 

1300 logger.debug( 

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

1302 ) 

1303 

1304 # Also check distinct research_ids in TokenUsage for comparison 

1305 token_research_count = ( 

1306 session.query( 

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

1308 ).scalar() 

1309 or 0 

1310 ) 

1311 logger.debug( 

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

1313 ) 

1314 

1315 # Model statistics using ORM aggregation 

1316 model_stats_query = session.query( 

1317 TokenUsage.model_name, 

1318 TokenUsage.model_provider, 

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

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

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

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

1323 "completion_tokens" 

1324 ), 

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

1326 

1327 # Apply same filters to model stats 

1328 if time_condition is not None: 

1329 model_stats_query = model_stats_query.filter(time_condition) 

1330 if mode_condition is not None: 

1331 model_stats_query = model_stats_query.filter(mode_condition) 

1332 

1333 model_stats = ( 

1334 model_stats_query.group_by( 

1335 TokenUsage.model_name, TokenUsage.model_provider 

1336 ) 

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

1338 .all() 

1339 ) 

1340 

1341 # Provider identity comes from the same filtered usage rows 

1342 # as the totals. Historical ModelUsage rows may combine 

1343 # providers and cannot identify which one made these calls. 

1344 by_model = [] 

1345 for stat in model_stats: 

1346 by_model.append( 

1347 { 

1348 "model": stat.model_name, 

1349 "provider": stat.model_provider, 

1350 "tokens": stat.tokens, 

1351 "calls": stat.calls, 

1352 "prompt_tokens": stat.prompt_tokens, 

1353 "completion_tokens": stat.completion_tokens, 

1354 } 

1355 ) 

1356 

1357 # Get recent researches with token usage 

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

1359 recent_research_query = session.query( 

1360 TokenUsage.research_id, 

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

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

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

1364 

1365 if time_condition is not None: 

1366 recent_research_query = recent_research_query.filter( 

1367 time_condition 

1368 ) 

1369 if mode_condition is not None: 

1370 recent_research_query = recent_research_query.filter( 

1371 mode_condition 

1372 ) 

1373 

1374 recent_research_data = ( 

1375 recent_research_query.group_by(TokenUsage.research_id) 

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

1377 .limit(10) 

1378 .all() 

1379 ) 

1380 

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

1382 recent_research_ids = [ 

1383 r.research_id for r in recent_research_data 

1384 ] 

1385 research_query_map = {} 

1386 if recent_research_ids: 

1387 # Get first non-null research_query for each research_id 

1388 query_results = ( 

1389 session.query( 

1390 TokenUsage.research_id, TokenUsage.research_query 

1391 ) 

1392 .filter( 

1393 TokenUsage.research_id.in_(recent_research_ids), 

1394 TokenUsage.research_query.isnot(None), 

1395 ) 

1396 .order_by(TokenUsage.id) 

1397 .all() 

1398 ) 

1399 for research_id, research_query in query_results: 

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

1401 research_query_map[research_id] = research_query 

1402 

1403 recent_researches = [] 

1404 for research_data in recent_research_data: 

1405 query_text = research_query_map.get( 

1406 research_data.research_id, 

1407 f"Research {research_data.research_id}", 

1408 ) 

1409 

1410 recent_researches.append( 

1411 { 

1412 "id": research_data.research_id, 

1413 "query": query_text, 

1414 "tokens": research_data.token_count or 0, 

1415 "created_at": research_data.latest_timestamp, 

1416 } 

1417 ) 

1418 

1419 # Token breakdown statistics 

1420 breakdown_query = query.with_entities( 

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

1422 "total_input_tokens" 

1423 ), 

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

1425 "total_output_tokens" 

1426 ), 

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

1428 "avg_input_tokens" 

1429 ), 

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

1431 "avg_output_tokens" 

1432 ), 

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

1434 ) 

1435 token_breakdown = breakdown_query.first() 

1436 

1437 # Get rate limiting metrics 

1438 from ..database.models import ( 

1439 RateLimitAttempt, 

1440 RateLimitEstimate, 

1441 ) 

1442 

1443 # Get rate limit attempts 

1444 rate_limit_query = session.query(RateLimitAttempt) 

1445 

1446 # Apply time filter. The cutoff is read again below for the 

1447 # engine queries, so it needs a value when there is no time 

1448 # condition: 0, no cutoff, which is what period "all" means. 

1449 cutoff_time = 0 

1450 if time_condition is not None: 

1451 # RateLimitAttempt uses timestamp as float, not datetime. 

1452 # (#6942) Derive the window from the same single source 

1453 # of truth as time_condition above (PERIOD_DAYS_MAP via 

1454 # get_period_cutoff) instead of a local 7d/30d/3m/1y 

1455 # ladder: the ladder did not know the '90d'/'365d' 

1456 # aliases (or any unknown value, which time_condition 

1457 # treats as 30 days) and silently widened those 

1458 # rate-limit numbers to all-time under a bounded label. 

1459 cutoff_dt = get_period_cutoff(period) 

1460 if cutoff_dt is not None: 1460 ↛ 1463line 1460 didn't jump to line 1463 because the condition on line 1460 was always true

1461 cutoff_time = cutoff_dt.timestamp() 

1462 

1463 if cutoff_time > 0: 1463 ↛ 1469line 1463 didn't jump to line 1469 because the condition on line 1463 was always true

1464 rate_limit_query = rate_limit_query.filter( 

1465 RateLimitAttempt.timestamp >= cutoff_time 

1466 ) 

1467 

1468 # Get rate limit statistics 

1469 total_attempts = rate_limit_query.count() 

1470 successful_attempts = rate_limit_query.filter( 

1471 RateLimitAttempt.success 

1472 ).count() 

1473 failed_attempts = total_attempts - successful_attempts 

1474 

1475 # Count rate limiting events (failures with RateLimitError) 

1476 rate_limit_events = rate_limit_query.filter( 

1477 ~RateLimitAttempt.success, 

1478 RateLimitAttempt.error_type == "RateLimitError", 

1479 ).count() 

1480 

1481 logger.debug( 

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

1483 ) 

1484 

1485 # Get all attempts for detailed calculations 

1486 attempts = rate_limit_query.all() 

1487 

1488 # Calculate average wait times 

1489 if attempts: 

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

1491 attempts 

1492 ) 

1493 successful_wait_times = [ 

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

1495 ] 

1496 avg_successful_wait = ( 

1497 sum(successful_wait_times) / len(successful_wait_times) 

1498 if successful_wait_times 

1499 else 0 

1500 ) 

1501 else: 

1502 avg_wait_time = 0 

1503 avg_successful_wait = 0 

1504 

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

1506 tracked_engines_query = session.query( 

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

1508 ) 

1509 if cutoff_time > 0: 

1510 tracked_engines_query = tracked_engines_query.filter( 

1511 RateLimitAttempt.timestamp >= cutoff_time 

1512 ) 

1513 tracked_engines = tracked_engines_query.scalar() or 0 

1514 

1515 # Get engine-specific stats from attempts 

1516 engine_stats = [] 

1517 

1518 # Get distinct engine types from attempts 

1519 engine_types_query = session.query( 

1520 RateLimitAttempt.engine_type 

1521 ).distinct() 

1522 if cutoff_time > 0: 

1523 engine_types_query = engine_types_query.filter( 

1524 RateLimitAttempt.timestamp >= cutoff_time 

1525 ) 

1526 engine_types = [ 

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

1528 ] 

1529 

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

1531 estimates_map = {} 

1532 if engine_types: 

1533 all_estimates = ( 

1534 session.query(RateLimitEstimate) 

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

1536 .all() 

1537 ) 

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

1539 

1540 for engine_type in engine_types: 

1541 engine_attempts_list = [ 

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

1543 ] 

1544 engine_attempts = len(engine_attempts_list) 

1545 engine_success = len( 

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

1547 ) 

1548 

1549 # Get estimate if exists 

1550 estimate = estimates_map.get(engine_type) 

1551 

1552 # Calculate recent success rate 

1553 recent_success_rate = ( 

1554 (engine_success / engine_attempts * 100) 

1555 if engine_attempts > 0 

1556 else 0 

1557 ) 

1558 

1559 # Determine status based on success rate 

1560 if estimate: 

1561 status = ( 

1562 "healthy" 

1563 if estimate.success_rate > 0.8 

1564 else "degraded" 

1565 if estimate.success_rate > 0.5 

1566 else "poor" 

1567 ) 

1568 else: 

1569 status = ( 

1570 "healthy" 

1571 if recent_success_rate > 80 

1572 else "degraded" 

1573 if recent_success_rate > 50 

1574 else "poor" 

1575 ) 

1576 

1577 engine_stat = { 

1578 "engine": engine_type, 

1579 "base_wait": estimate.base_wait_seconds 

1580 if estimate 

1581 else 0.0, 

1582 "base_wait_seconds": round( 

1583 estimate.base_wait_seconds if estimate else 0.0, 2 

1584 ), 

1585 "min_wait_seconds": round( 

1586 estimate.min_wait_seconds if estimate else 0.0, 2 

1587 ), 

1588 "max_wait_seconds": round( 

1589 estimate.max_wait_seconds if estimate else 0.0, 2 

1590 ), 

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

1592 if estimate 

1593 else recent_success_rate, 

1594 "total_attempts": estimate.total_attempts 

1595 if estimate 

1596 else engine_attempts, 

1597 "recent_attempts": engine_attempts, 

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

1599 "attempts": engine_attempts, 

1600 "status": status, 

1601 } 

1602 

1603 if estimate: 

1604 # Pass UTC explicitly: web/routers/metrics.py renders 

1605 # this same RateLimitEstimate.last_updated field with 

1606 # ``fromtimestamp(..., UTC)`` (lines ~700 and ~967). 

1607 # Without the tz argument this is server-local, so the 

1608 # two surfaces showed the same timestamp shifted by the 

1609 # host's UTC offset. 

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

1611 estimate.last_updated, UTC 

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

1613 else: 

1614 engine_stat["last_updated"] = "Never" 

1615 

1616 engine_stats.append(engine_stat) 

1617 

1618 logger.debug( 

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

1620 ) 

1621 

1622 result = { 

1623 "total_tokens": total_tokens, 

1624 "total_researches": total_researches, 

1625 "by_model": by_model, 

1626 "recent_researches": recent_researches, 

1627 "token_breakdown": { 

1628 "total_input_tokens": int( 

1629 token_breakdown.total_input_tokens or 0 

1630 ), 

1631 "total_output_tokens": int( 

1632 token_breakdown.total_output_tokens or 0 

1633 ), 

1634 "avg_input_tokens": int( 

1635 token_breakdown.avg_input_tokens or 0 

1636 ), 

1637 "avg_output_tokens": int( 

1638 token_breakdown.avg_output_tokens or 0 

1639 ), 

1640 "avg_total_tokens": int( 

1641 token_breakdown.avg_total_tokens or 0 

1642 ), 

1643 }, 

1644 "rate_limiting": { 

1645 "total_attempts": total_attempts, 

1646 "successful_attempts": successful_attempts, 

1647 "failed_attempts": failed_attempts, 

1648 "success_rate": ( 

1649 successful_attempts / total_attempts * 100 

1650 ) 

1651 if total_attempts > 0 

1652 else 0, 

1653 "rate_limit_events": rate_limit_events, 

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

1655 "avg_successful_wait": round( 

1656 float(avg_successful_wait), 2 

1657 ), 

1658 "tracked_engines": tracked_engines, 

1659 "engine_stats": engine_stats, 

1660 "total_engines_tracked": tracked_engines, 

1661 "healthy_engines": len( 

1662 [ 

1663 s 

1664 for s in engine_stats 

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

1666 ] 

1667 ), 

1668 "degraded_engines": len( 

1669 [ 

1670 s 

1671 for s in engine_stats 

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

1673 ] 

1674 ), 

1675 "poor_engines": len( 

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

1677 ), 

1678 }, 

1679 } 

1680 

1681 logger.debug( 

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

1683 ) 

1684 return result 

1685 except Exception: 

1686 logger.exception( 

1687 "CRITICAL ERROR accessing encrypted database for metrics" 

1688 ) 

1689 return self._get_empty_metrics() 

1690 

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

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

1693 return { 

1694 "total_tokens": 0, 

1695 "total_researches": 0, 

1696 "by_model": [], 

1697 "recent_researches": [], 

1698 "token_breakdown": { 

1699 "total_input_tokens": 0, 

1700 "total_output_tokens": 0, 

1701 "avg_input_tokens": 0, 

1702 "avg_output_tokens": 0, 

1703 "avg_total_tokens": 0, 

1704 }, 

1705 "rate_limiting": { 

1706 "total_attempts": 0, 

1707 "successful_attempts": 0, 

1708 "failed_attempts": 0, 

1709 "success_rate": 0, 

1710 "rate_limit_events": 0, 

1711 "avg_wait_time": 0.0, 

1712 "avg_successful_wait": 0.0, 

1713 "tracked_engines": 0, 

1714 "engine_stats": [], 

1715 "total_engines_tracked": 0, 

1716 "healthy_engines": 0, 

1717 "degraded_engines": 0, 

1718 "poor_engines": 0, 

1719 }, 

1720 } 

1721 

1722 def get_enhanced_metrics( 

1723 self, 

1724 period: str = "30d", 

1725 research_mode: str = "all", 

1726 username: Optional[str] = None, 

1727 ) -> Dict[str, Any]: 

1728 """Get enhanced Phase 1 tracking metrics. 

1729 

1730 Args: 

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

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

1733 username: Authenticated user whose encrypted DB to open. If 

1734 omitted, falls back to the request's contextvar — route 

1735 handlers should pass it explicitly so the lookup is 

1736 obviously user-scoped. 

1737 

1738 Returns: 

1739 Dictionary containing enhanced metrics data including time series 

1740 """ 

1741 

1742 from ..database.session_context import get_user_db_session 

1743 

1744 if username is None: 

1745 username = get_current_username() 

1746 if not username: 

1747 # Return empty metrics structure when no user session 

1748 return { 

1749 "recent_enhanced_data": [], 

1750 "performance_stats": { 

1751 "avg_response_time": 0, 

1752 "min_response_time": 0, 

1753 "max_response_time": 0, 

1754 "success_rate": 0, 

1755 "error_rate": 0, 

1756 "total_enhanced_calls": 0, 

1757 }, 

1758 "mode_breakdown": [], 

1759 "search_engine_stats": [], 

1760 "phase_breakdown": [], 

1761 "time_series_data": [], 

1762 "call_stack_analysis": { 

1763 "by_file": [], 

1764 "by_function": [], 

1765 }, 

1766 } 

1767 

1768 try: 

1769 with get_user_db_session(username) as session: 

1770 # Build base query with filters 

1771 query = session.query(TokenUsage) 

1772 

1773 # Apply time filter 

1774 time_condition = get_time_filter_condition( 

1775 period, TokenUsage.timestamp 

1776 ) 

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

1778 query = query.filter(time_condition) 

1779 

1780 # Apply research mode filter 

1781 mode_condition = get_research_mode_condition( 

1782 research_mode, TokenUsage.research_mode 

1783 ) 

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

1785 query = query.filter(mode_condition) 

1786 

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

1788 time_series_query = query.filter( 

1789 TokenUsage.timestamp.isnot(None), 

1790 TokenUsage.total_tokens > 0, 

1791 ).order_by(TokenUsage.timestamp.asc()) 

1792 

1793 # Limit to recent data for performance 

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

1795 time_series_query = time_series_query.limit(200) 

1796 

1797 time_series_data = time_series_query.all() 

1798 

1799 # Format time series data with cumulative calculations 

1800 time_series = [] 

1801 cumulative_tokens = 0 

1802 cumulative_prompt_tokens = 0 

1803 cumulative_completion_tokens = 0 

1804 

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

1806 cumulative_tokens += usage.total_tokens or 0 

1807 cumulative_prompt_tokens += usage.prompt_tokens or 0 

1808 cumulative_completion_tokens += usage.completion_tokens or 0 

1809 

1810 time_series.append( 

1811 { 

1812 "timestamp": str(usage.timestamp) 

1813 if usage.timestamp 

1814 else None, 

1815 "tokens": usage.total_tokens or 0, 

1816 "prompt_tokens": usage.prompt_tokens or 0, 

1817 "completion_tokens": usage.completion_tokens or 0, 

1818 "cumulative_tokens": cumulative_tokens, 

1819 "cumulative_prompt_tokens": cumulative_prompt_tokens, 

1820 "cumulative_completion_tokens": cumulative_completion_tokens, 

1821 "research_id": usage.research_id, 

1822 } 

1823 ) 

1824 

1825 # Basic performance stats using ORM 

1826 performance_query = query.filter( 

1827 TokenUsage.response_time_ms.isnot(None) 

1828 ) 

1829 total_calls = performance_query.count() 

1830 

1831 if total_calls > 0: 

1832 avg_response_time = ( 

1833 performance_query.with_entities( 

1834 func.avg(TokenUsage.response_time_ms) 

1835 ).scalar() 

1836 or 0 

1837 ) 

1838 min_response_time = ( 

1839 performance_query.with_entities( 

1840 func.min(TokenUsage.response_time_ms) 

1841 ).scalar() 

1842 or 0 

1843 ) 

1844 max_response_time = ( 

1845 performance_query.with_entities( 

1846 func.max(TokenUsage.response_time_ms) 

1847 ).scalar() 

1848 or 0 

1849 ) 

1850 success_count = performance_query.filter( 

1851 TokenUsage.success_status == "success" 

1852 ).count() 

1853 error_count = performance_query.filter( 

1854 TokenUsage.success_status == "error" 

1855 ).count() 

1856 

1857 perf_stats = { 

1858 "avg_response_time": round(avg_response_time), 

1859 "min_response_time": min_response_time, 

1860 "max_response_time": max_response_time, 

1861 "success_rate": ( 

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

1863 if total_calls > 0 

1864 else 0 

1865 ), 

1866 "error_rate": ( 

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

1868 if total_calls > 0 

1869 else 0 

1870 ), 

1871 "total_enhanced_calls": total_calls, 

1872 } 

1873 else: 

1874 perf_stats = { 

1875 "avg_response_time": 0, 

1876 "min_response_time": 0, 

1877 "max_response_time": 0, 

1878 "success_rate": 0, 

1879 "error_rate": 0, 

1880 "total_enhanced_calls": 0, 

1881 } 

1882 

1883 # Research mode breakdown using ORM 

1884 mode_stats = ( 

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

1886 .with_entities( 

1887 TokenUsage.research_mode, 

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

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

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

1891 "avg_response_time" 

1892 ), 

1893 ) 

1894 .group_by(TokenUsage.research_mode) 

1895 .all() 

1896 ) 

1897 

1898 modes = [ 

1899 { 

1900 "mode": stat.research_mode, 

1901 "count": stat.count, 

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

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

1904 } 

1905 for stat in mode_stats 

1906 ] 

1907 

1908 # Recent enhanced data (simplified) 

1909 recent_enhanced_query = ( 

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

1911 .order_by(TokenUsage.timestamp.desc()) 

1912 .limit(50) 

1913 ) 

1914 

1915 recent_enhanced_data = recent_enhanced_query.all() 

1916 recent_enhanced = [ 

1917 { 

1918 "research_query": usage.research_query, 

1919 "research_mode": usage.research_mode, 

1920 "research_phase": usage.research_phase, 

1921 "search_iteration": usage.search_iteration, 

1922 "response_time_ms": usage.response_time_ms, 

1923 "success_status": usage.success_status, 

1924 "error_type": usage.error_type, 

1925 "search_engines_planned": usage.search_engines_planned, 

1926 "search_engine_selected": usage.search_engine_selected, 

1927 "total_tokens": usage.total_tokens, 

1928 "prompt_tokens": usage.prompt_tokens, 

1929 "completion_tokens": usage.completion_tokens, 

1930 "timestamp": str(usage.timestamp) 

1931 if usage.timestamp 

1932 else None, 

1933 "research_id": usage.research_id, 

1934 "calling_file": usage.calling_file, 

1935 "calling_function": usage.calling_function, 

1936 "call_stack": usage.call_stack, 

1937 } 

1938 for usage in recent_enhanced_data 

1939 ] 

1940 

1941 # Search engine breakdown using ORM 

1942 search_engine_stats = ( 

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

1944 .with_entities( 

1945 TokenUsage.search_engine_selected, 

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

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

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

1949 "avg_response_time" 

1950 ), 

1951 ) 

1952 .group_by(TokenUsage.search_engine_selected) 

1953 .all() 

1954 ) 

1955 

1956 search_engines = [ 

1957 { 

1958 "search_engine": stat.search_engine_selected, 

1959 "count": stat.count, 

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

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

1962 } 

1963 for stat in search_engine_stats 

1964 ] 

1965 

1966 # Research phase breakdown using ORM 

1967 phase_stats = ( 

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

1969 .with_entities( 

1970 TokenUsage.research_phase, 

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

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

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

1974 "avg_response_time" 

1975 ), 

1976 ) 

1977 .group_by(TokenUsage.research_phase) 

1978 .all() 

1979 ) 

1980 

1981 phases = [ 

1982 { 

1983 "phase": stat.research_phase, 

1984 "count": stat.count, 

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

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

1987 } 

1988 for stat in phase_stats 

1989 ] 

1990 

1991 # Call stack analysis using ORM 

1992 file_stats = ( 

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

1994 .with_entities( 

1995 TokenUsage.calling_file, 

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

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

1998 ) 

1999 .group_by(TokenUsage.calling_file) 

2000 .order_by(func.count().desc()) 

2001 .limit(10) 

2002 .all() 

2003 ) 

2004 

2005 files = [ 

2006 { 

2007 "file": stat.calling_file, 

2008 "count": stat.count, 

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

2010 } 

2011 for stat in file_stats 

2012 ] 

2013 

2014 function_stats = ( 

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

2016 .with_entities( 

2017 TokenUsage.calling_function, 

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

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

2020 ) 

2021 .group_by(TokenUsage.calling_function) 

2022 .order_by(func.count().desc()) 

2023 .limit(10) 

2024 .all() 

2025 ) 

2026 

2027 functions = [ 

2028 { 

2029 "function": stat.calling_function, 

2030 "count": stat.count, 

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

2032 } 

2033 for stat in function_stats 

2034 ] 

2035 

2036 return { 

2037 "recent_enhanced_data": recent_enhanced, 

2038 "performance_stats": perf_stats, 

2039 "mode_breakdown": modes, 

2040 "search_engine_stats": search_engines, 

2041 "phase_breakdown": phases, 

2042 "time_series_data": time_series, 

2043 "call_stack_analysis": { 

2044 "by_file": files, 

2045 "by_function": functions, 

2046 }, 

2047 } 

2048 except Exception: 

2049 logger.exception("Error in get_enhanced_metrics") 

2050 # Return simplified response without non-existent columns 

2051 return { 

2052 "recent_enhanced_data": [], 

2053 "performance_stats": { 

2054 "avg_response_time": 0, 

2055 "min_response_time": 0, 

2056 "max_response_time": 0, 

2057 "success_rate": 0, 

2058 "error_rate": 0, 

2059 "total_enhanced_calls": 0, 

2060 }, 

2061 "mode_breakdown": [], 

2062 "search_engine_stats": [], 

2063 "phase_breakdown": [], 

2064 "time_series_data": [], 

2065 "call_stack_analysis": { 

2066 "by_file": [], 

2067 "by_function": [], 

2068 }, 

2069 } 

2070 

2071 def get_research_timeline_metrics( 

2072 self, research_id: str, username: Optional[str] = None 

2073 ) -> Dict[str, Any]: 

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

2075 

2076 Args: 

2077 research_id: The ID of the research 

2078 username: Authenticated user whose encrypted DB to open. If 

2079 omitted, falls back to the request's contextvar. 

2080 

2081 Returns: 

2082 Dictionary containing timeline metrics for the research 

2083 """ 

2084 

2085 from ..database.session_context import get_user_db_session 

2086 

2087 if username is None: 

2088 username = get_current_username() 

2089 if not username: 

2090 return { 

2091 "research_id": research_id, 

2092 "research_details": {}, 

2093 "timeline": [], 

2094 "summary": { 

2095 "total_calls": 0, 

2096 "total_tokens": 0, 

2097 "total_prompt_tokens": 0, 

2098 "total_completion_tokens": 0, 

2099 "avg_response_time": 0, 

2100 "success_rate": 0, 

2101 }, 

2102 "phase_stats": {}, 

2103 } 

2104 

2105 with get_user_db_session(username) as session: 

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

2107 timeline_data = session.execute( 

2108 text( 

2109 """ 

2110 SELECT 

2111 timestamp, 

2112 total_tokens, 

2113 prompt_tokens, 

2114 completion_tokens, 

2115 response_time_ms, 

2116 success_status, 

2117 error_type, 

2118 research_phase, 

2119 search_iteration, 

2120 search_engine_selected, 

2121 model_name, 

2122 calling_file, 

2123 calling_function, 

2124 call_stack 

2125 FROM token_usage 

2126 WHERE research_id = :research_id 

2127 ORDER BY timestamp ASC 

2128 """ 

2129 ), 

2130 {"research_id": research_id}, 

2131 ).fetchall() 

2132 

2133 # Format timeline data with cumulative tokens 

2134 timeline = [] 

2135 cumulative_tokens = 0 

2136 cumulative_prompt_tokens = 0 

2137 cumulative_completion_tokens = 0 

2138 

2139 for row in timeline_data: 

2140 cumulative_tokens += row[1] or 0 

2141 cumulative_prompt_tokens += row[2] or 0 

2142 cumulative_completion_tokens += row[3] or 0 

2143 

2144 # call_stack is a JSON column. Depending on the 

2145 # SQLAlchemy/SQLite/driver combo the raw text() SELECT may 

2146 # either hand back the raw JSON-encoded TEXT or already 

2147 # the decoded Python value. Normalise to the API/UI 

2148 # contract (`string | null`): pass through None and 

2149 # strings; re-serialise any other stored JSON shape 

2150 # (number / bool / list / object) so a Python int/bool/ 

2151 # list never leaks to the browser. Legacy unparseable 

2152 # strings pass through as-is. 

2153 raw_call_stack = row[13] 

2154 if raw_call_stack is None or raw_call_stack == "": 

2155 decoded_call_stack = None 

2156 elif isinstance(raw_call_stack, str): 

2157 try: 

2158 decoded = json.loads(raw_call_stack) 

2159 except (TypeError, ValueError): 

2160 decoded_call_stack = raw_call_stack 

2161 else: 

2162 decoded_call_stack = ( 

2163 decoded 

2164 if decoded is None or isinstance(decoded, str) 

2165 else json.dumps(decoded) 

2166 ) 

2167 else: 

2168 decoded_call_stack = ( 

2169 raw_call_stack 

2170 if isinstance(raw_call_stack, str) 

2171 else json.dumps(raw_call_stack) 

2172 ) 

2173 

2174 timeline.append( 

2175 { 

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

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

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

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

2180 "cumulative_tokens": cumulative_tokens, 

2181 "cumulative_prompt_tokens": cumulative_prompt_tokens, 

2182 "cumulative_completion_tokens": cumulative_completion_tokens, 

2183 "response_time_ms": row[4], 

2184 "success_status": row[5], 

2185 "error_type": row[6], 

2186 "research_phase": row[7], 

2187 "search_iteration": row[8], 

2188 "search_engine_selected": row[9], 

2189 "model_name": row[10], 

2190 "calling_file": row[11], 

2191 "calling_function": row[12], 

2192 "call_stack": decoded_call_stack, 

2193 } 

2194 ) 

2195 

2196 # Get research basic info 

2197 research_info = session.execute( 

2198 text( 

2199 """ 

2200 SELECT query, mode, status, created_at, completed_at 

2201 FROM research_history 

2202 WHERE id = :research_id 

2203 """ 

2204 ), 

2205 {"research_id": research_id}, 

2206 ).fetchone() 

2207 

2208 research_details = {} 

2209 if research_info: 

2210 research_details = { 

2211 "query": research_info[0], 

2212 "mode": research_info[1], 

2213 "status": research_info[2], 

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

2215 if research_info[3] 

2216 else None, 

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

2218 if research_info[4] 

2219 else None, 

2220 } 

2221 

2222 # Calculate summary stats 

2223 total_calls = len(timeline_data) 

2224 total_tokens = cumulative_tokens 

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

2226 total_calls, 1 

2227 ) 

2228 success_rate = ( 

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

2230 / max(total_calls, 1) 

2231 * 100 

2232 ) 

2233 

2234 # Phase breakdown for this research 

2235 phase_stats = {} 

2236 for row in timeline_data: 

2237 phase = row[7] or "unknown" 

2238 if phase not in phase_stats: 

2239 phase_stats[phase] = { 

2240 "count": 0, 

2241 "tokens": 0, 

2242 "avg_response_time": 0, 

2243 } 

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

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

2246 if row[4]: 

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

2248 

2249 # Calculate averages for phases 

2250 for phase in phase_stats: 

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

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

2253 phase_stats[phase]["avg_response_time"] 

2254 / phase_stats[phase]["count"] 

2255 ) 

2256 

2257 return { 

2258 "research_id": research_id, 

2259 "research_details": research_details, 

2260 "timeline": timeline, 

2261 "summary": { 

2262 "total_calls": total_calls, 

2263 "total_tokens": total_tokens, 

2264 "total_prompt_tokens": cumulative_prompt_tokens, 

2265 "total_completion_tokens": cumulative_completion_tokens, 

2266 "avg_response_time": round(avg_response_time), 

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

2268 }, 

2269 "phase_stats": phase_stats, 

2270 }