Files
learningcircuit--local-deep…/tests/metrics/test_token_counter_deep_coverage.py
wehub-resource-sync 7a0da7932b
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
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

510 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Deep coverage tests for token_counter.py targeting uncovered branches.
Focuses on:
- on_llm_start model/provider extraction paths
- on_llm_end token usage extraction (usage_metadata, response_metadata, llm_output)
- on_llm_error tracking
- _get_context_overflow_fields
- cost calculation helpers / tiktoken mocking
- TokenCounter.create_callback
- TokenCounter edge cases (no research_id, missing counts)
"""
import time
from unittest.mock import MagicMock, patch
from langchain_core.outputs import LLMResult
from local_deep_research.metrics.token_counter import (
TokenCounter,
TokenCountingCallback,
)
MODULE = "local_deep_research.metrics.token_counter"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_llm_result(llm_output=None, generations=None):
result = MagicMock(spec=LLMResult)
result.llm_output = llm_output
result.generations = generations or []
return result
def _make_generation(usage_metadata=None, response_metadata=None):
gen = MagicMock()
msg = MagicMock()
msg.usage_metadata = usage_metadata
msg.response_metadata = response_metadata or {}
gen.message = msg
return gen
def _make_callback(**overrides):
ctx = overrides.pop("research_context", {"research_query": "q"})
cb = TokenCountingCallback(
research_id=overrides.pop("research_id", "rid-1"),
research_context=ctx,
)
for k, v in overrides.items():
setattr(cb, k, v)
return cb
# ---------------------------------------------------------------------------
# on_llm_start: model name extraction
# ---------------------------------------------------------------------------
class TestOnLlmStartModelExtraction:
def test_preset_model_used_when_set(self):
cb = _make_callback()
cb.preset_model = "my-preset-model"
cb.preset_provider = "openai"
cb.on_llm_start({"_type": "ChatOpenAI"}, ["hello"])
assert cb.current_model == "my-preset-model"
assert cb.current_provider == "openai"
def test_model_from_invocation_params(self):
cb = _make_callback()
cb.on_llm_start(
{},
["hello"],
invocation_params={"model": "gpt-4-turbo"},
)
assert cb.current_model == "gpt-4-turbo"
def test_model_name_from_invocation_params(self):
cb = _make_callback()
cb.on_llm_start(
{},
["hello"],
invocation_params={"model_name": "claude-3"},
)
assert cb.current_model == "claude-3"
def test_model_from_serialized_kwargs(self):
cb = _make_callback()
cb.on_llm_start(
{"kwargs": {"model": "gemma3:12b"}},
["hello"],
)
assert cb.current_model == "gemma3:12b"
def test_model_name_from_serialized_kwargs(self):
cb = _make_callback()
cb.on_llm_start(
{"kwargs": {"model_name": "llama3"}},
["hello"],
)
assert cb.current_model == "llama3"
def test_model_from_serialized_name(self):
cb = _make_callback()
cb.on_llm_start({"name": "SerializedModelName"}, ["hello"])
assert cb.current_model == "SerializedModelName"
def test_ollama_fallback_to_kwargs_model(self):
cb = _make_callback()
cb.on_llm_start(
{"_type": "ChatOllama", "kwargs": {"model": "mistral"}},
["hello"],
)
assert cb.current_model == "mistral"
assert cb.current_provider == "ollama"
def test_ollama_fallback_to_type_string(self):
"""When Ollama _type present but no model in kwargs, falls back to 'ollama'."""
cb = _make_callback()
cb.on_llm_start(
{"_type": "ChatOllama", "kwargs": {}},
["hello"],
)
assert cb.current_model == "ollama"
def test_unknown_model_from_type(self):
cb = _make_callback()
cb.on_llm_start({"_type": "ChatSomething"}, ["hello"])
assert cb.current_model == "ChatSomething"
def test_unknown_model_fallback(self):
cb = _make_callback()
cb.on_llm_start({}, ["hello"])
assert cb.current_model == "unknown"
def test_provider_ollama_from_type(self):
cb = _make_callback()
cb.on_llm_start({"_type": "ChatOllama"}, ["hello"])
assert cb.current_provider == "ollama"
def test_provider_openai_from_type(self):
cb = _make_callback()
cb.on_llm_start({"_type": "ChatOpenAI"}, ["hello"])
assert cb.current_provider == "openai"
def test_provider_anthropic_from_type(self):
cb = _make_callback()
cb.on_llm_start({"_type": "ChatAnthropic"}, ["hello"])
assert cb.current_provider == "anthropic"
def test_provider_unknown_when_no_type(self):
cb = _make_callback()
cb.on_llm_start({}, ["hello"])
assert cb.current_provider == "unknown"
def test_call_count_incremented(self):
cb = _make_callback()
cb.on_llm_start({"_type": "ChatOpenAI"}, ["hello"])
cb.on_llm_start({"_type": "ChatOpenAI"}, ["hello again"])
model = cb.current_model
assert cb.counts["by_model"][model]["calls"] == 2
def test_start_time_recorded(self):
cb = _make_callback()
cb.on_llm_start({}, ["hello"])
assert cb.start_time is not None
assert cb.start_time <= time.time()
def test_prompt_estimate_computed(self):
cb = _make_callback()
cb.on_llm_start({}, ["a" * 400])
assert cb.original_prompt_estimate == 100 # 400 // 4
# ---------------------------------------------------------------------------
# on_llm_end: token usage paths
# ---------------------------------------------------------------------------
class TestOnLlmEndTokenUsage:
def _run_end(self, cb, llm_result):
with patch.object(cb, "_save_to_db") as mock_save:
cb.on_llm_end(llm_result)
return mock_save
def test_token_usage_from_llm_output(self):
cb = _make_callback()
cb.current_model = "gpt-4"
cb.counts["by_model"]["gpt-4"] = {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"calls": 1,
"provider": "openai",
}
result = _make_llm_result(
llm_output={
"token_usage": {
"prompt_tokens": 50,
"completion_tokens": 30,
"total_tokens": 80,
}
}
)
save_mock = self._run_end(cb, result)
assert cb.counts["total_tokens"] == 80
save_mock.assert_called_once_with(50, 30)
def test_token_usage_from_usage_metadata(self):
cb = _make_callback()
cb.current_model = "claude-3"
cb.counts["by_model"]["claude-3"] = {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"calls": 1,
"provider": "anthropic",
}
usage_meta = {
"input_tokens": 20,
"output_tokens": 10,
"total_tokens": 30,
}
gen = _make_generation(usage_metadata=usage_meta)
result = _make_llm_result(generations=[[gen]])
save_mock = self._run_end(cb, result)
assert cb.counts["total_tokens"] == 30
save_mock.assert_called_once_with(20, 10)
def test_token_usage_from_response_metadata_ollama(self):
cb = _make_callback()
cb.current_model = "mistral"
cb.counts["by_model"]["mistral"] = {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"calls": 1,
"provider": "ollama",
}
resp_meta = {"prompt_eval_count": 40, "eval_count": 20}
gen = _make_generation(usage_metadata=None, response_metadata=resp_meta)
result = _make_llm_result(generations=[[gen]])
save_mock = self._run_end(cb, result)
assert cb.counts["total_tokens"] == 60
save_mock.assert_called_once_with(40, 20)
def test_no_token_usage_saves_zero_counts(self):
"""No usage data from the provider still records the call (#4457)."""
cb = _make_callback()
result = _make_llm_result(llm_output=None, generations=[])
save_mock = self._run_end(cb, result)
save_mock.assert_called_once_with(0, 0)
def test_response_time_calculated(self):
cb = _make_callback()
cb.start_time = time.time() - 0.5 # 500ms ago
result = _make_llm_result(llm_output=None, generations=[])
cb.on_llm_end(result)
assert cb.response_time_ms is not None
assert cb.response_time_ms >= 400 # at least 400ms
def test_no_save_when_no_research_id(self):
cb = TokenCountingCallback(research_id=None)
cb.current_model = "gpt-4"
cb.counts["by_model"]["gpt-4"] = {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"calls": 1,
"provider": "openai",
}
result = _make_llm_result(
llm_output={
"token_usage": {"prompt_tokens": 10, "completion_tokens": 5}
}
)
with patch.object(cb, "_save_to_db") as mock_save:
cb.on_llm_end(result)
mock_save.assert_not_called()
def test_context_overflow_detection(self):
"""When prompt_eval_count >= 95% of context_limit, context_truncated is set."""
cb = _make_callback()
cb.context_limit = 1000
cb.original_prompt_estimate = 1200 # More than actual => truncated
cb.current_model = "llama"
cb.counts["by_model"]["llama"] = {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"calls": 1,
"provider": "ollama",
}
resp_meta = {
"prompt_eval_count": 960,
"eval_count": 40,
} # 960 >= 950 (95%)
gen = _make_generation(usage_metadata=None, response_metadata=resp_meta)
result = _make_llm_result(generations=[[gen]])
with patch.object(cb, "_save_to_db"):
cb.on_llm_end(result)
assert cb.context_truncated is True
assert cb.tokens_truncated > 0
# ---------------------------------------------------------------------------
# on_llm_error
# ---------------------------------------------------------------------------
class TestOnLlmError:
def test_sets_error_status(self):
cb = _make_callback()
with patch.object(cb, "_save_to_db") as mock_save:
cb.on_llm_error(ValueError("bad input"))
assert cb.success_status == "error"
assert cb.error_type == "ValueError"
mock_save.assert_called_once_with(0, 0)
def test_calculates_response_time_on_error(self):
cb = _make_callback()
cb.start_time = time.time() - 1.0
with patch.object(cb, "_save_to_db"):
cb.on_llm_error(RuntimeError("crash"))
assert cb.response_time_ms is not None
assert cb.response_time_ms >= 900
def test_no_save_when_no_research_id(self):
cb = TokenCountingCallback(research_id=None)
with patch.object(cb, "_save_to_db") as mock_save:
cb.on_llm_error(RuntimeError("boom"))
mock_save.assert_not_called()
# ---------------------------------------------------------------------------
# _get_context_overflow_fields
# ---------------------------------------------------------------------------
class TestGetContextOverflowFields:
def test_fields_when_not_truncated(self):
cb = _make_callback()
cb.context_limit = 4096
cb.context_truncated = False
cb.tokens_truncated = 0
cb.truncation_ratio = 0.0
fields = cb._get_context_overflow_fields()
assert fields["context_limit"] == 4096
assert fields["context_truncated"] is False
assert fields["tokens_truncated"] is None
assert fields["truncation_ratio"] is None
def test_fields_when_truncated(self):
cb = _make_callback()
cb.context_limit = 2048
cb.context_truncated = True
cb.tokens_truncated = 300
cb.truncation_ratio = 0.25
fields = cb._get_context_overflow_fields()
assert fields["tokens_truncated"] == 300
assert fields["truncation_ratio"] == 0.25
def test_ollama_metrics_included(self):
cb = _make_callback()
cb.ollama_metrics = {
"prompt_eval_count": 500,
"eval_count": 100,
"total_duration": 999,
}
fields = cb._get_context_overflow_fields()
assert fields["ollama_prompt_eval_count"] == 500
assert fields["ollama_eval_count"] == 100
def test_missing_ollama_metrics_returns_none(self):
cb = _make_callback()
cb.ollama_metrics = {}
fields = cb._get_context_overflow_fields()
assert fields["ollama_prompt_eval_count"] is None
assert fields["ollama_total_duration"] is None
# ---------------------------------------------------------------------------
# TokenCounter.create_callback
# ---------------------------------------------------------------------------
class TestTokenCounterCreateCallback:
def test_create_callback_returns_callback_instance(self):
counter = TokenCounter()
cb = counter.create_callback("res-99")
assert isinstance(cb, TokenCountingCallback)
assert cb.research_id == "res-99"
def test_create_callback_with_context(self):
counter = TokenCounter()
ctx = {"research_query": "AI safety", "username": "bob"}
cb = counter.create_callback("res-42", ctx)
assert cb.research_context == ctx
def test_create_callback_no_context(self):
counter = TokenCounter()
cb = counter.create_callback("res-1", None)
assert cb.research_context == {}
def test_multiple_callbacks_independent(self):
counter = TokenCounter()
cb1 = counter.create_callback("res-1")
cb2 = counter.create_callback("res-2")
cb1.current_model = "gpt-4"
assert cb2.current_model is None
# ---------------------------------------------------------------------------
# Tiktoken mocking cost calculation helpers
# ---------------------------------------------------------------------------
class TestTiktokenMocking:
"""Test that token counting works when tiktoken is mocked."""
def test_on_llm_start_no_tiktoken_needed(self):
"""on_llm_start should work without tiktoken."""
cb = _make_callback()
# No patch needed tiktoken is not used in on_llm_start
cb.on_llm_start({"_type": "ChatOpenAI"}, ["Hello world"])
assert cb.current_provider == "openai"
def test_token_count_aggregates_across_calls(self):
cb = _make_callback()
cb.on_llm_start({"_type": "ChatOpenAI"}, ["prompt"])
cb.current_model = "gpt-4"
cb.counts["by_model"].setdefault(
"gpt-4",
{
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"calls": 1,
"provider": "openai",
},
)
result1 = _make_llm_result(
llm_output={
"token_usage": {
"prompt_tokens": 100,
"completion_tokens": 50,
"total_tokens": 150,
}
}
)
result2 = _make_llm_result(
llm_output={
"token_usage": {
"prompt_tokens": 200,
"completion_tokens": 100,
"total_tokens": 300,
}
}
)
with patch.object(cb, "_save_to_db"):
cb.on_llm_end(result1)
cb.on_llm_end(result2)
assert cb.counts["total_tokens"] == 450
assert cb.counts["total_prompt_tokens"] == 300
assert cb.counts["total_completion_tokens"] == 150
# ---------------------------------------------------------------------------
# TokenCounter cost metrics and empty states
# ---------------------------------------------------------------------------
class TestTokenCounterMetrics:
def test_initial_counts_are_zero(self):
counter = TokenCounter()
cb = counter.create_callback("res-1")
assert cb.counts["total_tokens"] == 0
assert cb.counts["total_prompt_tokens"] == 0
assert cb.counts["total_completion_tokens"] == 0
assert cb.counts["by_model"] == {}
def test_llm_output_usage_key_fallback(self):
"""When token_usage absent, falls back to 'usage' key in llm_output."""
cb = _make_callback()
cb.current_model = "gpt-3.5"
cb.counts["by_model"]["gpt-3.5"] = {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"calls": 1,
"provider": "openai",
}
result = _make_llm_result(
llm_output={
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
}
}
)
with patch.object(cb, "_save_to_db"):
cb.on_llm_end(result)
assert cb.counts["total_tokens"] == 15