7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
972 lines
33 KiB
Python
972 lines
33 KiB
Python
"""Comprehensive pytest tests for local_deep_research/metrics/token_counter.py.
|
|
|
|
Covers: TokenCountingCallback (init, on_llm_start, on_llm_end, on_llm_error,
|
|
_get_context_overflow_fields, _save_to_db, get_counts) and
|
|
TokenCounter (create_callback, _get_empty_metrics).
|
|
"""
|
|
|
|
import time
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.metrics.token_counter import (
|
|
TokenCounter,
|
|
TokenCountingCallback,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_callback(**kwargs):
|
|
"""Shorthand to build a callback without DB deps."""
|
|
return TokenCountingCallback(**kwargs)
|
|
|
|
|
|
def _llm_result_with_token_usage(prompt=10, completion=20, total=None):
|
|
"""Return a mock LLMResult whose llm_output contains token_usage."""
|
|
if total is None:
|
|
total = prompt + completion
|
|
response = Mock()
|
|
response.llm_output = {
|
|
"token_usage": {
|
|
"prompt_tokens": prompt,
|
|
"completion_tokens": completion,
|
|
"total_tokens": total,
|
|
}
|
|
}
|
|
response.generations = []
|
|
return response
|
|
|
|
|
|
def _llm_result_with_usage_metadata(
|
|
input_tokens=100, output_tokens=50, total_tokens=150
|
|
):
|
|
"""Return a mock LLMResult with usage_metadata on the message (Gemini/Google path)."""
|
|
response = Mock()
|
|
response.llm_output = None
|
|
|
|
message = Mock()
|
|
message.usage_metadata = {
|
|
"input_tokens": input_tokens,
|
|
"output_tokens": output_tokens,
|
|
"total_tokens": total_tokens,
|
|
}
|
|
message.response_metadata = {}
|
|
|
|
generation = Mock()
|
|
generation.message = message
|
|
response.generations = [[generation]]
|
|
return response
|
|
|
|
|
|
def _llm_result_with_ollama_response_metadata(
|
|
prompt_eval_count=200, eval_count=80, total_duration=None
|
|
):
|
|
"""Return a mock LLMResult with Ollama-style response_metadata."""
|
|
response = Mock()
|
|
response.llm_output = None
|
|
|
|
message = Mock()
|
|
message.usage_metadata = None # usage_metadata absent or None
|
|
message.response_metadata = {
|
|
"prompt_eval_count": prompt_eval_count,
|
|
"eval_count": eval_count,
|
|
"total_duration": total_duration or 5_000_000_000,
|
|
"load_duration": 100_000_000,
|
|
"prompt_eval_duration": 2_000_000_000,
|
|
"eval_duration": 1_500_000_000,
|
|
}
|
|
|
|
generation = Mock()
|
|
generation.message = message
|
|
response.generations = [[generation]]
|
|
return response
|
|
|
|
|
|
def _llm_result_empty():
|
|
"""Return a mock LLMResult with no token info at all."""
|
|
response = Mock()
|
|
response.llm_output = None
|
|
response.generations = []
|
|
return response
|
|
|
|
|
|
# ===========================================================================
|
|
# TokenCountingCallback — Initialization
|
|
# ===========================================================================
|
|
|
|
|
|
class TestTokenCountingCallbackInit:
|
|
def test_defaults(self):
|
|
cb = _make_callback()
|
|
assert cb.research_id is None
|
|
assert cb.research_context == {}
|
|
assert cb.current_model is None
|
|
assert cb.current_provider is None
|
|
assert cb.preset_model is None
|
|
assert cb.preset_provider is None
|
|
assert cb.start_time is None
|
|
assert cb.response_time_ms is None
|
|
assert cb.success_status == "success"
|
|
assert cb.error_type is None
|
|
assert cb.calling_file is None
|
|
assert cb.calling_function is None
|
|
assert cb.call_stack is None
|
|
assert cb.context_limit is None
|
|
assert cb.context_truncated is False
|
|
assert cb.tokens_truncated == 0
|
|
assert cb.truncation_ratio == 0.0
|
|
assert cb.original_prompt_estimate == 0
|
|
assert cb.ollama_metrics == {}
|
|
|
|
def test_counts_structure(self):
|
|
cb = _make_callback()
|
|
assert cb.counts == {
|
|
"total_tokens": 0,
|
|
"total_prompt_tokens": 0,
|
|
"total_completion_tokens": 0,
|
|
"by_model": {},
|
|
}
|
|
|
|
def test_research_id_stored(self):
|
|
cb = _make_callback(research_id="abc-123")
|
|
assert cb.research_id == "abc-123"
|
|
|
|
def test_research_context_stored(self):
|
|
ctx = {
|
|
"research_query": "quantum computing",
|
|
"research_mode": "detailed",
|
|
}
|
|
cb = _make_callback(research_context=ctx)
|
|
assert cb.research_context is ctx
|
|
|
|
def test_none_research_context_becomes_empty_dict(self):
|
|
cb = _make_callback(research_context=None)
|
|
assert cb.research_context == {}
|
|
|
|
|
|
# ===========================================================================
|
|
# on_llm_start — model/provider detection
|
|
# ===========================================================================
|
|
|
|
|
|
class TestOnLlmStart:
|
|
def test_preset_model_takes_priority(self):
|
|
cb = _make_callback()
|
|
cb.preset_model = "my-custom-model"
|
|
cb.preset_provider = "custom-provider"
|
|
|
|
cb.on_llm_start(serialized={}, prompts=["hello"])
|
|
|
|
assert cb.current_model == "my-custom-model"
|
|
assert cb.current_provider == "custom-provider"
|
|
|
|
def test_model_from_invocation_params(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
serialized={},
|
|
prompts=["hello"],
|
|
invocation_params={"model": "gpt-4"},
|
|
)
|
|
assert cb.current_model == "gpt-4"
|
|
|
|
def test_model_from_invocation_params_model_name(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
serialized={},
|
|
prompts=["hello"],
|
|
invocation_params={"model_name": "gpt-3.5-turbo"},
|
|
)
|
|
assert cb.current_model == "gpt-3.5-turbo"
|
|
|
|
def test_model_from_kwargs(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="claude-3")
|
|
assert cb.current_model == "claude-3"
|
|
|
|
def test_model_from_serialized_kwargs(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
serialized={"kwargs": {"model": "llama-3.1"}},
|
|
prompts=["hi"],
|
|
)
|
|
assert cb.current_model == "llama-3.1"
|
|
|
|
def test_model_from_serialized_name(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
serialized={"name": "ChatGPT"},
|
|
prompts=["hi"],
|
|
)
|
|
assert cb.current_model == "ChatGPT"
|
|
|
|
def test_model_from_ollama_type(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
serialized={"_type": "ChatOllama", "kwargs": {"model": "mistral"}},
|
|
prompts=["hi"],
|
|
)
|
|
assert cb.current_model == "mistral"
|
|
|
|
def test_model_ollama_type_fallback(self):
|
|
"""When _type is ChatOllama but no model in kwargs, falls back to 'ollama'."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
serialized={"_type": "ChatOllama"},
|
|
prompts=["hi"],
|
|
)
|
|
assert cb.current_model == "ollama"
|
|
|
|
def test_model_fallback_to_type(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
serialized={"_type": "SomeCustomLLM"},
|
|
prompts=["hi"],
|
|
)
|
|
assert cb.current_model == "SomeCustomLLM"
|
|
|
|
def test_model_fallback_to_unknown(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["hi"])
|
|
assert cb.current_model == "unknown"
|
|
|
|
# --- provider detection ---
|
|
|
|
def test_provider_ollama(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
serialized={"_type": "ChatOllama", "kwargs": {"model": "m"}},
|
|
prompts=["hi"],
|
|
)
|
|
assert cb.current_provider == "ollama"
|
|
|
|
def test_provider_openai(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
serialized={"_type": "ChatOpenAI", "kwargs": {"model": "gpt-4"}},
|
|
prompts=["hi"],
|
|
)
|
|
assert cb.current_provider == "openai"
|
|
|
|
def test_provider_anthropic(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
serialized={"_type": "ChatAnthropic", "kwargs": {"model": "c3"}},
|
|
prompts=["hi"],
|
|
)
|
|
assert cb.current_provider == "anthropic"
|
|
|
|
def test_provider_from_kwargs(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
serialized={"_type": "SomethingElse"},
|
|
prompts=["hi"],
|
|
provider="azure",
|
|
)
|
|
assert cb.current_provider == "azure"
|
|
|
|
def test_provider_unknown_fallback(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["hi"])
|
|
assert cb.current_provider == "unknown"
|
|
|
|
# --- call count / model tracking ---
|
|
|
|
def test_initializes_model_tracking(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="gpt-4")
|
|
assert "gpt-4" in cb.counts["by_model"]
|
|
assert cb.counts["by_model"]["gpt-4"]["calls"] == 1
|
|
|
|
def test_increments_call_count(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="gpt-4")
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="gpt-4")
|
|
assert cb.counts["by_model"]["gpt-4"]["calls"] == 2
|
|
|
|
# --- prompt estimation ---
|
|
|
|
def test_original_prompt_estimate(self):
|
|
cb = _make_callback()
|
|
# 400 chars -> ~100 estimated tokens
|
|
cb.on_llm_start(serialized={}, prompts=["a" * 400])
|
|
assert cb.original_prompt_estimate == 100
|
|
|
|
def test_original_prompt_estimate_empty_prompts(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=[])
|
|
assert cb.original_prompt_estimate == 0
|
|
|
|
def test_original_prompt_estimate_multiple_prompts(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["a" * 100, "b" * 300])
|
|
assert cb.original_prompt_estimate == 100 # 400 chars / 4
|
|
|
|
# --- timing ---
|
|
|
|
def test_start_time_is_set(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["hi"])
|
|
assert cb.start_time is not None
|
|
assert cb.start_time <= time.time()
|
|
|
|
# --- context_limit from research_context ---
|
|
|
|
def test_context_limit_from_research_context(self):
|
|
cb = _make_callback(research_context={"context_limit": 4096})
|
|
cb.on_llm_start(serialized={}, prompts=["hi"])
|
|
assert cb.context_limit == 4096
|
|
|
|
|
|
# ===========================================================================
|
|
# on_llm_end — token usage extraction
|
|
# ===========================================================================
|
|
|
|
|
|
class TestOnLlmEnd:
|
|
def _start_and_end(self, cb, response):
|
|
"""Helper: call on_llm_start then on_llm_end."""
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="test-model")
|
|
cb.on_llm_end(response)
|
|
|
|
def test_token_usage_from_llm_output(self):
|
|
cb = _make_callback()
|
|
response = _llm_result_with_token_usage(prompt=10, completion=20)
|
|
self._start_and_end(cb, response)
|
|
|
|
assert cb.counts["total_prompt_tokens"] == 10
|
|
assert cb.counts["total_completion_tokens"] == 20
|
|
assert cb.counts["total_tokens"] == 30
|
|
|
|
def test_token_usage_from_usage_key(self):
|
|
"""Token usage found under 'usage' key in llm_output."""
|
|
response = Mock()
|
|
response.llm_output = {
|
|
"usage": {
|
|
"prompt_tokens": 5,
|
|
"completion_tokens": 15,
|
|
"total_tokens": 20,
|
|
}
|
|
}
|
|
response.generations = []
|
|
|
|
cb = _make_callback()
|
|
self._start_and_end(cb, response)
|
|
assert cb.counts["total_tokens"] == 20
|
|
|
|
def test_token_usage_from_usage_metadata(self):
|
|
cb = _make_callback()
|
|
response = _llm_result_with_usage_metadata(100, 50, 150)
|
|
self._start_and_end(cb, response)
|
|
|
|
assert cb.counts["total_prompt_tokens"] == 100
|
|
assert cb.counts["total_completion_tokens"] == 50
|
|
assert cb.counts["total_tokens"] == 150
|
|
|
|
def test_token_usage_from_ollama_response_metadata(self):
|
|
cb = _make_callback()
|
|
response = _llm_result_with_ollama_response_metadata(200, 80)
|
|
self._start_and_end(cb, response)
|
|
|
|
assert cb.counts["total_prompt_tokens"] == 200
|
|
assert cb.counts["total_completion_tokens"] == 80
|
|
assert cb.counts["total_tokens"] == 280
|
|
|
|
def test_ollama_metrics_captured(self):
|
|
cb = _make_callback()
|
|
response = _llm_result_with_ollama_response_metadata(
|
|
200, 80, 5_000_000_000
|
|
)
|
|
self._start_and_end(cb, response)
|
|
|
|
assert cb.ollama_metrics["prompt_eval_count"] == 200
|
|
assert cb.ollama_metrics["eval_count"] == 80
|
|
assert cb.ollama_metrics["total_duration"] == 5_000_000_000
|
|
|
|
def test_no_token_usage_does_not_crash(self):
|
|
cb = _make_callback()
|
|
response = _llm_result_empty()
|
|
self._start_and_end(cb, response)
|
|
|
|
assert cb.counts["total_tokens"] == 0
|
|
assert cb.counts["total_prompt_tokens"] == 0
|
|
assert cb.counts["total_completion_tokens"] == 0
|
|
|
|
def test_by_model_counts_updated(self):
|
|
cb = _make_callback()
|
|
response = _llm_result_with_token_usage(prompt=10, completion=20)
|
|
self._start_and_end(cb, response)
|
|
|
|
model_counts = cb.counts["by_model"]["test-model"]
|
|
assert model_counts["prompt_tokens"] == 10
|
|
assert model_counts["completion_tokens"] == 20
|
|
assert model_counts["total_tokens"] == 30
|
|
|
|
def test_accumulation_over_multiple_calls(self):
|
|
cb = _make_callback()
|
|
r1 = _llm_result_with_token_usage(prompt=10, completion=20)
|
|
r2 = _llm_result_with_token_usage(prompt=5, completion=15)
|
|
|
|
self._start_and_end(cb, r1)
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="test-model")
|
|
cb.on_llm_end(r2)
|
|
|
|
assert cb.counts["total_prompt_tokens"] == 15
|
|
assert cb.counts["total_completion_tokens"] == 35
|
|
assert cb.counts["total_tokens"] == 50
|
|
|
|
def test_total_tokens_defaults_to_sum(self):
|
|
"""When total_tokens missing from dict, it's computed as prompt + completion."""
|
|
response = Mock()
|
|
response.llm_output = {
|
|
"token_usage": {
|
|
"prompt_tokens": 7,
|
|
"completion_tokens": 3,
|
|
}
|
|
}
|
|
response.generations = []
|
|
|
|
cb = _make_callback()
|
|
self._start_and_end(cb, response)
|
|
assert cb.counts["total_tokens"] == 10
|
|
|
|
def test_response_time_calculated(self):
|
|
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: keep but consider freezing).
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="m")
|
|
# Simulate elapsed time
|
|
cb.start_time = time.time() - 0.5 # 500ms ago
|
|
cb.on_llm_end(_llm_result_with_token_usage())
|
|
assert cb.response_time_ms is not None
|
|
assert cb.response_time_ms >= 400 # at least ~400ms
|
|
|
|
def test_save_to_db_called_when_research_id_present(self):
|
|
cb = _make_callback(research_id="r-123")
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="m")
|
|
|
|
with patch.object(cb, "_save_to_db") as mock_save:
|
|
cb.on_llm_end(_llm_result_with_token_usage(10, 20))
|
|
mock_save.assert_called_once_with(10, 20)
|
|
|
|
def test_save_to_db_not_called_without_research_id(self):
|
|
cb = _make_callback() # no research_id
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="m")
|
|
|
|
with patch.object(cb, "_save_to_db") as mock_save:
|
|
cb.on_llm_end(_llm_result_with_token_usage(10, 20))
|
|
mock_save.assert_not_called()
|
|
|
|
|
|
# ===========================================================================
|
|
# on_llm_end — context overflow detection (Ollama)
|
|
# ===========================================================================
|
|
|
|
|
|
class TestContextOverflowDetection:
|
|
def test_context_truncated_when_near_limit(self):
|
|
"""When prompt_eval_count >= 80% of context_limit, flag truncation."""
|
|
cb = _make_callback(research_context={"context_limit": 1000})
|
|
cb.on_llm_start(serialized={}, prompts=["a" * 4000], model="m")
|
|
# original_prompt_estimate = 4000/4 = 1000
|
|
|
|
response = _llm_result_with_ollama_response_metadata(
|
|
prompt_eval_count=960, eval_count=50
|
|
)
|
|
cb.on_llm_end(response)
|
|
|
|
assert cb.context_truncated is True
|
|
assert cb.tokens_truncated == 40 # 1000 - 960
|
|
assert cb.truncation_ratio == pytest.approx(0.04, abs=0.001) # 40/1000
|
|
|
|
def test_context_not_truncated_when_below_threshold(self):
|
|
cb = _make_callback(research_context={"context_limit": 1000})
|
|
cb.on_llm_start(serialized={}, prompts=["a" * 400], model="m")
|
|
# original_prompt_estimate = 100
|
|
|
|
response = _llm_result_with_ollama_response_metadata(
|
|
prompt_eval_count=100, eval_count=50
|
|
)
|
|
cb.on_llm_end(response)
|
|
|
|
assert cb.context_truncated is False
|
|
|
|
def test_context_no_limit_set(self):
|
|
"""No context_limit means no truncation detection."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["a" * 4000], model="m")
|
|
|
|
response = _llm_result_with_ollama_response_metadata(
|
|
prompt_eval_count=960, eval_count=50
|
|
)
|
|
cb.on_llm_end(response)
|
|
|
|
assert cb.context_truncated is False
|
|
|
|
|
|
class TestContextOverflowViaUsageMetadata:
|
|
"""Verify overflow detection via usage_metadata branch (langchain-ollama v1.0.1+)."""
|
|
|
|
def test_overflow_detected_via_usage_metadata_input_tokens(self):
|
|
"""input_tokens >= 80% of context_limit triggers truncation."""
|
|
cb = _make_callback(research_context={"context_limit": 1000})
|
|
cb.on_llm_start(serialized={}, prompts=["a" * 4000], model="m")
|
|
|
|
# Build a response where usage_metadata is present (langchain-ollama v1.0.1)
|
|
response = Mock()
|
|
response.llm_output = None
|
|
|
|
message = Mock()
|
|
message.usage_metadata = {
|
|
"input_tokens": 850, # >= 1000 * 0.80
|
|
"output_tokens": 50,
|
|
"total_tokens": 900,
|
|
}
|
|
message.response_metadata = {}
|
|
|
|
generation = Mock()
|
|
generation.message = message
|
|
response.generations = [[generation]]
|
|
|
|
cb.on_llm_end(response)
|
|
|
|
assert cb.context_truncated is True
|
|
|
|
def test_no_overflow_below_threshold_via_usage_metadata(self):
|
|
"""input_tokens below 80% does not trigger truncation."""
|
|
cb = _make_callback(research_context={"context_limit": 1000})
|
|
cb.on_llm_start(serialized={}, prompts=["a" * 100], model="m")
|
|
|
|
response = Mock()
|
|
response.llm_output = None
|
|
|
|
message = Mock()
|
|
message.usage_metadata = {
|
|
"input_tokens": 700, # < 1000 * 0.80 = 800
|
|
"output_tokens": 50,
|
|
"total_tokens": 750,
|
|
}
|
|
message.response_metadata = {}
|
|
|
|
generation = Mock()
|
|
generation.message = message
|
|
response.generations = [[generation]]
|
|
|
|
cb.on_llm_end(response)
|
|
|
|
assert cb.context_truncated is False
|
|
|
|
def test_usage_metadata_takes_priority_over_response_metadata(self):
|
|
"""When both metadata sources exist, usage_metadata branch fires first."""
|
|
cb = _make_callback(research_context={"context_limit": 1000})
|
|
cb.on_llm_start(serialized={}, prompts=["a" * 4000], model="m")
|
|
|
|
response = Mock()
|
|
response.llm_output = None
|
|
|
|
message = Mock()
|
|
# usage_metadata present — this branch should handle detection
|
|
message.usage_metadata = {
|
|
"input_tokens": 900,
|
|
"output_tokens": 50,
|
|
"total_tokens": 950,
|
|
}
|
|
# response_metadata also present but should NOT be reached
|
|
message.response_metadata = {
|
|
"prompt_eval_count": 900,
|
|
"eval_count": 50,
|
|
}
|
|
|
|
generation = Mock()
|
|
generation.message = message
|
|
response.generations = [[generation]]
|
|
|
|
cb.on_llm_end(response)
|
|
|
|
assert cb.context_truncated is True
|
|
|
|
|
|
# ===========================================================================
|
|
# on_llm_error
|
|
# ===========================================================================
|
|
|
|
|
|
class TestOnLlmError:
|
|
def test_error_status_set(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="m")
|
|
|
|
cb.on_llm_error(ValueError("bad input"))
|
|
|
|
assert cb.success_status == "error"
|
|
assert cb.error_type == "ValueError"
|
|
|
|
def test_response_time_calculated_on_error(self):
|
|
# audit: PUNCHLIST reviewed 2026-05 — KEEP (RACE_CONDITIONS).
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="m")
|
|
cb.start_time = time.time() - 1.0 # 1 second ago
|
|
|
|
cb.on_llm_error(RuntimeError("fail"))
|
|
|
|
assert cb.response_time_ms is not None
|
|
assert cb.response_time_ms >= 900
|
|
|
|
def test_save_to_db_called_on_error_with_research_id(self):
|
|
cb = _make_callback(research_id="r-err")
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="m")
|
|
|
|
with patch.object(cb, "_save_to_db") as mock_save:
|
|
cb.on_llm_error(RuntimeError("fail"))
|
|
mock_save.assert_called_once_with(0, 0)
|
|
|
|
def test_save_to_db_not_called_on_error_without_research_id(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="m")
|
|
|
|
with patch.object(cb, "_save_to_db") as mock_save:
|
|
cb.on_llm_error(RuntimeError("fail"))
|
|
mock_save.assert_not_called()
|
|
|
|
def test_error_type_captures_class_name(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="m")
|
|
|
|
class CustomAPIError(Exception):
|
|
pass
|
|
|
|
cb.on_llm_error(CustomAPIError("rate limited"))
|
|
assert cb.error_type == "CustomAPIError"
|
|
|
|
|
|
# ===========================================================================
|
|
# _get_context_overflow_fields
|
|
# ===========================================================================
|
|
|
|
|
|
class TestGetContextOverflowFields:
|
|
def test_no_overflow(self):
|
|
cb = _make_callback()
|
|
fields = cb._get_context_overflow_fields()
|
|
|
|
assert fields["context_limit"] is None
|
|
assert fields["context_truncated"] is False
|
|
assert fields["tokens_truncated"] is None
|
|
assert fields["truncation_ratio"] is None
|
|
|
|
def test_with_overflow(self):
|
|
cb = _make_callback()
|
|
cb.context_limit = 4096
|
|
cb.context_truncated = True
|
|
cb.tokens_truncated = 500
|
|
cb.truncation_ratio = 0.12
|
|
|
|
fields = cb._get_context_overflow_fields()
|
|
|
|
assert fields["context_limit"] == 4096
|
|
assert fields["context_truncated"] is True
|
|
assert fields["tokens_truncated"] == 500
|
|
assert fields["truncation_ratio"] == 0.12
|
|
|
|
def test_ollama_metrics_in_fields(self):
|
|
cb = _make_callback()
|
|
cb.ollama_metrics = {
|
|
"prompt_eval_count": 100,
|
|
"eval_count": 50,
|
|
"total_duration": 5_000_000_000,
|
|
"load_duration": 200_000_000,
|
|
"prompt_eval_duration": 1_000_000_000,
|
|
"eval_duration": 800_000_000,
|
|
}
|
|
|
|
fields = cb._get_context_overflow_fields()
|
|
assert fields["ollama_prompt_eval_count"] == 100
|
|
assert fields["ollama_eval_count"] == 50
|
|
assert fields["ollama_total_duration"] == 5_000_000_000
|
|
|
|
def test_ollama_metrics_empty(self):
|
|
cb = _make_callback()
|
|
fields = cb._get_context_overflow_fields()
|
|
assert fields["ollama_prompt_eval_count"] is None
|
|
assert fields["ollama_eval_count"] is None
|
|
|
|
|
|
# ===========================================================================
|
|
# get_counts
|
|
# ===========================================================================
|
|
|
|
|
|
class TestGetCounts:
|
|
def test_returns_counts_dict(self):
|
|
cb = _make_callback()
|
|
counts = cb.get_counts()
|
|
assert counts is cb.counts
|
|
|
|
def test_reflects_updates(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="m")
|
|
cb.on_llm_end(_llm_result_with_token_usage(prompt=7, completion=3))
|
|
|
|
counts = cb.get_counts()
|
|
assert counts["total_tokens"] == 10
|
|
assert counts["total_prompt_tokens"] == 7
|
|
assert counts["total_completion_tokens"] == 3
|
|
|
|
|
|
# ===========================================================================
|
|
# TokenCounter — factory class
|
|
# ===========================================================================
|
|
|
|
|
|
class TestTokenCounter:
|
|
def test_create_callback_returns_callback_instance(self):
|
|
tc = TokenCounter()
|
|
cb = tc.create_callback()
|
|
assert isinstance(cb, TokenCountingCallback)
|
|
|
|
def test_create_callback_passes_research_id(self):
|
|
tc = TokenCounter()
|
|
cb = tc.create_callback(research_id="r-1")
|
|
assert cb.research_id == "r-1"
|
|
|
|
def test_create_callback_passes_research_context(self):
|
|
tc = TokenCounter()
|
|
ctx = {"research_query": "test"}
|
|
cb = tc.create_callback(research_context=ctx)
|
|
assert cb.research_context is ctx
|
|
|
|
def test_get_empty_metrics_structure(self):
|
|
tc = TokenCounter()
|
|
m = tc._get_empty_metrics()
|
|
|
|
assert m["total_tokens"] == 0
|
|
assert m["total_researches"] == 0
|
|
assert m["by_model"] == []
|
|
assert m["recent_researches"] == []
|
|
assert "token_breakdown" in m
|
|
|
|
|
|
# ===========================================================================
|
|
# Edge cases
|
|
# ===========================================================================
|
|
|
|
|
|
class TestEdgeCases:
|
|
def test_on_llm_end_without_on_llm_start(self):
|
|
"""on_llm_end should not crash if on_llm_start was never called."""
|
|
cb = _make_callback()
|
|
# current_model is None
|
|
response = _llm_result_with_token_usage(prompt=5, completion=5)
|
|
# Should not raise
|
|
cb.on_llm_end(response)
|
|
# Totals updated but no by_model entry
|
|
assert cb.counts["total_tokens"] == 10
|
|
|
|
def test_on_llm_error_without_start_time(self):
|
|
"""on_llm_error should not crash if start_time was never set."""
|
|
cb = _make_callback()
|
|
cb.on_llm_error(RuntimeError("oops"))
|
|
assert cb.response_time_ms is None
|
|
assert cb.success_status == "error"
|
|
|
|
def test_llm_output_empty_dict(self):
|
|
response = Mock()
|
|
response.llm_output = {}
|
|
response.generations = []
|
|
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="m")
|
|
cb.on_llm_end(response)
|
|
assert cb.counts["total_tokens"] == 0
|
|
|
|
def test_llm_output_none(self):
|
|
response = Mock()
|
|
response.llm_output = None
|
|
response.generations = []
|
|
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="m")
|
|
cb.on_llm_end(response)
|
|
assert cb.counts["total_tokens"] == 0
|
|
|
|
def test_token_usage_with_zero_values(self):
|
|
response = _llm_result_with_token_usage(prompt=0, completion=0, total=0)
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="m")
|
|
cb.on_llm_end(response)
|
|
assert cb.counts["total_tokens"] == 0
|
|
|
|
def test_empty_string_prompt(self):
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=[""])
|
|
assert cb.original_prompt_estimate == 0
|
|
|
|
def test_very_long_prompt_estimate(self):
|
|
cb = _make_callback()
|
|
long_text = "x" * 1_000_000 # 1M chars
|
|
cb.on_llm_start(serialized={}, prompts=[long_text])
|
|
assert cb.original_prompt_estimate == 250_000
|
|
|
|
def test_multiple_models_tracked_separately(self):
|
|
cb = _make_callback()
|
|
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="model-a")
|
|
cb.on_llm_end(_llm_result_with_token_usage(prompt=10, completion=5))
|
|
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="model-b")
|
|
cb.on_llm_end(_llm_result_with_token_usage(prompt=20, completion=10))
|
|
|
|
assert cb.counts["by_model"]["model-a"]["total_tokens"] == 15
|
|
assert cb.counts["by_model"]["model-b"]["total_tokens"] == 30
|
|
assert cb.counts["total_tokens"] == 45
|
|
|
|
def test_usage_metadata_with_none_value(self):
|
|
"""usage_metadata exists but is None — should fall through gracefully."""
|
|
response = Mock()
|
|
response.llm_output = None
|
|
|
|
message = Mock()
|
|
message.usage_metadata = None
|
|
message.response_metadata = {}
|
|
|
|
generation = Mock()
|
|
generation.message = message
|
|
response.generations = [[generation]]
|
|
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="m")
|
|
cb.on_llm_end(response)
|
|
assert cb.counts["total_tokens"] == 0
|
|
|
|
def test_generations_with_no_message_attr(self):
|
|
"""Generations without .message should not crash."""
|
|
response = Mock()
|
|
response.llm_output = None
|
|
|
|
generation = Mock(spec=[]) # no attributes at all
|
|
response.generations = [[generation]]
|
|
|
|
cb = _make_callback()
|
|
cb.on_llm_start(serialized={}, prompts=["hi"], model="m")
|
|
cb.on_llm_end(response)
|
|
assert cb.counts["total_tokens"] == 0
|
|
|
|
def test_preset_model_and_provider(self):
|
|
"""preset_model/provider set before on_llm_start should be used."""
|
|
cb = _make_callback()
|
|
cb.preset_model = "preset-model"
|
|
cb.preset_provider = "preset-provider"
|
|
|
|
cb.on_llm_start(
|
|
serialized={"_type": "ChatOpenAI", "kwargs": {"model": "gpt-4"}},
|
|
prompts=["hi"],
|
|
)
|
|
|
|
assert cb.current_model == "preset-model"
|
|
assert cb.current_provider == "preset-provider"
|
|
|
|
def test_serialized_kwargs_model_name(self):
|
|
"""model_name (not model) in serialized kwargs."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
serialized={"kwargs": {"model_name": "my-model"}},
|
|
prompts=["hi"],
|
|
)
|
|
assert cb.current_model == "my-model"
|
|
|
|
|
|
# ===========================================================================
|
|
# _save_to_db — thread detection and error handling
|
|
# ===========================================================================
|
|
|
|
|
|
class TestSaveToDb:
|
|
@patch("threading.current_thread")
|
|
def test_background_thread_without_username_skips(self, mock_thread):
|
|
"""In a background thread without username, _save_to_db logs warning and returns."""
|
|
mock_thread.return_value.name = "WorkerThread"
|
|
|
|
cb = _make_callback(research_id="r-1", research_context={})
|
|
cb.current_model = "m"
|
|
cb.current_provider = "p"
|
|
|
|
# Should not raise
|
|
cb._save_to_db(10, 20)
|
|
|
|
@patch("threading.current_thread")
|
|
def test_background_thread_without_password_skips(self, mock_thread):
|
|
"""In a background thread with username but no password, skips save."""
|
|
mock_thread.return_value.name = "WorkerThread"
|
|
|
|
cb = _make_callback(
|
|
research_id="r-1",
|
|
research_context={"username": "alice"}, # no user_password
|
|
)
|
|
cb.current_model = "m"
|
|
cb.current_provider = "p"
|
|
|
|
# Should not raise
|
|
cb._save_to_db(10, 20)
|
|
|
|
@patch("threading.current_thread")
|
|
def test_background_thread_with_credentials_writes_metrics(
|
|
self, mock_thread
|
|
):
|
|
"""In a background thread with full credentials, calls metrics_writer."""
|
|
mock_thread.return_value.name = "WorkerThread"
|
|
|
|
mock_writer = MagicMock()
|
|
cb = _make_callback(
|
|
research_id="r-1",
|
|
research_context={
|
|
"username": "alice",
|
|
"user_password": "secret",
|
|
},
|
|
)
|
|
cb.current_model = "m"
|
|
cb.current_provider = "p"
|
|
|
|
with patch(
|
|
"local_deep_research.metrics.token_counter.TokenCountingCallback._save_to_db",
|
|
wraps=cb._save_to_db,
|
|
):
|
|
with patch(
|
|
"local_deep_research.database.thread_metrics.metrics_writer",
|
|
mock_writer,
|
|
):
|
|
cb._save_to_db(10, 20)
|
|
|
|
mock_writer.set_user_password.assert_called_once_with("alice", "secret")
|
|
mock_writer.write_token_metrics.assert_called_once()
|
|
|
|
@patch("threading.current_thread")
|
|
def test_main_thread_no_flask_session_skips(self, mock_thread):
|
|
"""In MainThread without flask session, save is skipped."""
|
|
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: delete or rewrite to actually invoke _save_to_db and assert no metrics write).
|
|
mock_thread.return_value.name = "MainThread"
|
|
|
|
cb = _make_callback(research_id="r-1")
|
|
cb.current_model = "m"
|
|
cb.current_provider = "p"
|
|
|
|
with patch(
|
|
"local_deep_research.metrics.token_counter.flask_session",
|
|
create=True,
|
|
):
|
|
# Patch at the import location used in the method
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{"flask": MagicMock()},
|
|
):
|
|
# The method imports flask.session internally, so we patch it there
|
|
mock_flask_mod = MagicMock()
|
|
mock_flask_mod.session.get.return_value = None
|
|
with patch(
|
|
"local_deep_research.metrics.token_counter.TokenCountingCallback._save_to_db",
|
|
) as _:
|
|
# Simply verify no exception is raised when there's no session
|
|
pass
|