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
530 lines
18 KiB
Python
530 lines
18 KiB
Python
"""Tests for token_counter.py — model detection, context overflow, token extraction, and rate limiting metrics."""
|
|
|
|
import time
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from langchain_core.outputs import LLMResult
|
|
|
|
from local_deep_research.metrics.token_counter import TokenCountingCallback
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_callback(**kw):
|
|
"""Create a TokenCountingCallback with sensible defaults."""
|
|
return TokenCountingCallback(
|
|
research_id=kw.get("research_id"),
|
|
research_context=kw.get("research_context", {}),
|
|
)
|
|
|
|
|
|
def _make_llm_result(llm_output=None, generations=None):
|
|
"""Build a minimal LLMResult for on_llm_end tests."""
|
|
result = MagicMock(spec=LLMResult)
|
|
result.llm_output = llm_output
|
|
if generations is not None:
|
|
result.generations = generations
|
|
else:
|
|
result.generations = []
|
|
return result
|
|
|
|
|
|
# ===========================================================================
|
|
# 1. Model detection fallback chain
|
|
# ===========================================================================
|
|
|
|
|
|
class TestModelDetectionFallback:
|
|
"""Verify the on_llm_start model-name resolution chain."""
|
|
|
|
def test_preset_model_takes_priority(self):
|
|
"""Preset model should override anything in serialized/kwargs."""
|
|
cb = _make_callback()
|
|
cb.preset_model = "my-preset-model"
|
|
cb.preset_provider = "my-provider"
|
|
|
|
serialized = {"kwargs": {"model": "should-be-ignored"}}
|
|
cb.on_llm_start(
|
|
serialized, ["hello"], invocation_params={"model": "also-ignored"}
|
|
)
|
|
|
|
assert cb.current_model == "my-preset-model"
|
|
assert cb.current_provider == "my-provider"
|
|
|
|
def test_model_from_invocation_params(self):
|
|
"""Model extracted from invocation_params when preset is absent."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
{"_type": "ChatOpenAI"},
|
|
["hello"],
|
|
invocation_params={"model": "gpt-4"},
|
|
)
|
|
assert cb.current_model == "gpt-4"
|
|
|
|
def test_model_from_kwargs_directly(self):
|
|
"""Model extracted from kwargs when invocation_params lacks it."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
{"_type": "ChatOpenAI"},
|
|
["hello"],
|
|
model="gpt-3.5-turbo",
|
|
)
|
|
assert cb.current_model == "gpt-3.5-turbo"
|
|
|
|
def test_model_from_serialized_kwargs(self):
|
|
"""Model extracted from serialized['kwargs'] as next fallback."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
{"_type": "ChatOpenAI", "kwargs": {"model": "from-serialized"}},
|
|
["hello"],
|
|
)
|
|
assert cb.current_model == "from-serialized"
|
|
|
|
def test_model_from_serialized_name(self):
|
|
"""serialized['name'] used when kwargs has no model."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
{"_type": "ChatOpenAI", "name": "my-name", "kwargs": {}},
|
|
["hello"],
|
|
)
|
|
assert cb.current_model == "my-name"
|
|
|
|
def test_ollama_specific_extraction(self):
|
|
"""ChatOllama type triggers Ollama-specific model extraction."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
{"_type": "ChatOllama", "kwargs": {"model": "llama3"}},
|
|
["hello"],
|
|
)
|
|
assert cb.current_model == "llama3"
|
|
|
|
def test_ollama_fallback_to_ollama_string(self):
|
|
"""ChatOllama without 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_final_fallback_to_type(self):
|
|
"""When no model name found, _type string is used."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
{"_type": "SomeCustomLLM", "kwargs": {}},
|
|
["hello"],
|
|
)
|
|
assert cb.current_model == "SomeCustomLLM"
|
|
|
|
def test_final_fallback_to_unknown(self):
|
|
"""When nothing at all, model is 'unknown'."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start({}, ["hello"])
|
|
assert cb.current_model == "unknown"
|
|
|
|
|
|
# ===========================================================================
|
|
# 2. Provider detection
|
|
# ===========================================================================
|
|
|
|
|
|
class TestProviderDetection:
|
|
"""Verify provider extraction from serialized type strings."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"type_str, expected",
|
|
[
|
|
("ChatOllama", "ollama"),
|
|
("ChatOpenAI", "openai"),
|
|
("ChatAnthropic", "anthropic"),
|
|
],
|
|
)
|
|
def test_known_providers(self, type_str, expected):
|
|
"""Known provider types are mapped correctly."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
{"_type": type_str, "kwargs": {"model": "test"}},
|
|
["hello"],
|
|
)
|
|
assert cb.current_provider == expected
|
|
|
|
def test_unknown_provider_from_kwargs(self):
|
|
"""Unknown type falls back to provider kwarg."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
{"_type": "CustomLLM", "kwargs": {"model": "test"}},
|
|
["hello"],
|
|
provider="custom-prov",
|
|
)
|
|
assert cb.current_provider == "custom-prov"
|
|
|
|
def test_unknown_provider_no_kwarg(self):
|
|
"""No _type and no provider kwarg yields 'unknown'."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start({"kwargs": {"model": "test"}}, ["hello"])
|
|
assert cb.current_provider == "unknown"
|
|
|
|
|
|
# ===========================================================================
|
|
# 3. Token extraction from different response formats
|
|
# ===========================================================================
|
|
|
|
|
|
class TestTokenExtraction:
|
|
"""Verify on_llm_end token extraction from various LLMResult shapes."""
|
|
|
|
def test_tokens_from_llm_output_token_usage(self):
|
|
"""Standard token_usage dict in llm_output."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
{"_type": "ChatOpenAI", "kwargs": {"model": "gpt-4"}}, ["hi"]
|
|
)
|
|
|
|
result = _make_llm_result(
|
|
llm_output={
|
|
"token_usage": {
|
|
"prompt_tokens": 10,
|
|
"completion_tokens": 20,
|
|
"total_tokens": 30,
|
|
}
|
|
},
|
|
)
|
|
cb.on_llm_end(result)
|
|
|
|
assert cb.counts["total_prompt_tokens"] == 10
|
|
assert cb.counts["total_completion_tokens"] == 20
|
|
assert cb.counts["total_tokens"] == 30
|
|
|
|
def test_tokens_from_llm_output_usage_key(self):
|
|
"""Alternative 'usage' key in llm_output."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
{"_type": "ChatOpenAI", "kwargs": {"model": "gpt-4"}}, ["hi"]
|
|
)
|
|
|
|
result = _make_llm_result(
|
|
llm_output={
|
|
"usage": {
|
|
"prompt_tokens": 5,
|
|
"completion_tokens": 15,
|
|
"total_tokens": 20,
|
|
}
|
|
},
|
|
)
|
|
cb.on_llm_end(result)
|
|
|
|
assert cb.counts["total_tokens"] == 20
|
|
|
|
def test_tokens_from_usage_metadata_in_generations(self):
|
|
"""Ollama-style usage_metadata in generation messages."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
{"_type": "ChatOllama", "kwargs": {"model": "llama3"}}, ["hi"]
|
|
)
|
|
|
|
msg = MagicMock()
|
|
msg.usage_metadata = {
|
|
"input_tokens": 8,
|
|
"output_tokens": 12,
|
|
"total_tokens": 20,
|
|
}
|
|
msg.response_metadata = {}
|
|
gen = MagicMock()
|
|
gen.message = msg
|
|
result = _make_llm_result(generations=[[gen]])
|
|
|
|
cb.on_llm_end(result)
|
|
assert cb.counts["total_tokens"] == 20
|
|
|
|
def test_tokens_from_response_metadata_ollama(self):
|
|
"""Ollama response_metadata with prompt_eval_count / eval_count."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
{"_type": "ChatOllama", "kwargs": {"model": "llama3"}}, ["hi"]
|
|
)
|
|
|
|
msg = MagicMock()
|
|
msg.usage_metadata = None
|
|
msg.response_metadata = {
|
|
"prompt_eval_count": 100,
|
|
"eval_count": 50,
|
|
"total_duration": 1000,
|
|
}
|
|
gen = MagicMock()
|
|
gen.message = msg
|
|
result = _make_llm_result(generations=[[gen]])
|
|
|
|
cb.on_llm_end(result)
|
|
assert cb.counts["total_prompt_tokens"] == 100
|
|
assert cb.counts["total_completion_tokens"] == 50
|
|
assert cb.counts["total_tokens"] == 150
|
|
|
|
def test_missing_usage_entirely(self):
|
|
"""No usage info at all — counts stay zero."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
{"_type": "ChatOpenAI", "kwargs": {"model": "gpt-4"}}, ["hi"]
|
|
)
|
|
|
|
result = _make_llm_result(llm_output=None, generations=[])
|
|
cb.on_llm_end(result)
|
|
|
|
assert cb.counts["total_tokens"] == 0
|
|
|
|
def test_empty_generations_list(self):
|
|
"""generations present but empty list."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
{"_type": "ChatOllama", "kwargs": {"model": "llama3"}}, ["hi"]
|
|
)
|
|
|
|
result = _make_llm_result(llm_output=None, generations=[[]])
|
|
cb.on_llm_end(result)
|
|
|
|
assert cb.counts["total_tokens"] == 0
|
|
|
|
def test_usage_metadata_present_but_response_metadata_absent(self):
|
|
"""Generation has usage_metadata but no response_metadata attr."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
{"_type": "ChatOllama", "kwargs": {"model": "llama3"}}, ["hi"]
|
|
)
|
|
|
|
msg = MagicMock(spec=["usage_metadata"]) # no response_metadata
|
|
msg.usage_metadata = {
|
|
"input_tokens": 3,
|
|
"output_tokens": 7,
|
|
"total_tokens": 10,
|
|
}
|
|
gen = MagicMock()
|
|
gen.message = msg
|
|
result = _make_llm_result(generations=[[gen]])
|
|
|
|
cb.on_llm_end(result)
|
|
assert cb.counts["total_tokens"] == 10
|
|
|
|
def test_by_model_accumulation(self):
|
|
"""Multiple calls accumulate per-model stats."""
|
|
cb = _make_callback()
|
|
|
|
for _ in range(3):
|
|
cb.on_llm_start(
|
|
{"_type": "ChatOpenAI", "kwargs": {"model": "gpt-4"}}, ["hi"]
|
|
)
|
|
result = _make_llm_result(
|
|
llm_output={
|
|
"token_usage": {
|
|
"prompt_tokens": 10,
|
|
"completion_tokens": 5,
|
|
"total_tokens": 15,
|
|
}
|
|
},
|
|
)
|
|
cb.on_llm_end(result)
|
|
|
|
assert cb.counts["by_model"]["gpt-4"]["calls"] == 3
|
|
assert cb.counts["by_model"]["gpt-4"]["total_tokens"] == 45
|
|
|
|
|
|
# ===========================================================================
|
|
# 4. Context overflow detection
|
|
# ===========================================================================
|
|
|
|
|
|
class TestContextOverflowDetection:
|
|
"""Verify context overflow detection in on_llm_end via Ollama metrics."""
|
|
|
|
def _trigger_overflow(
|
|
self, context_limit, prompt_eval_count, original_prompt_estimate
|
|
):
|
|
"""Helper to set up and trigger context overflow path."""
|
|
cb = _make_callback(research_context={"context_limit": context_limit})
|
|
cb.on_llm_start(
|
|
{"_type": "ChatOllama", "kwargs": {"model": "llama3"}},
|
|
["x" * (original_prompt_estimate * 4)], # chars = tokens * 4
|
|
)
|
|
|
|
msg = MagicMock()
|
|
msg.usage_metadata = None
|
|
msg.response_metadata = {
|
|
"prompt_eval_count": prompt_eval_count,
|
|
"eval_count": 10,
|
|
}
|
|
gen = MagicMock()
|
|
gen.message = msg
|
|
result = _make_llm_result(generations=[[gen]])
|
|
cb.on_llm_end(result)
|
|
return cb
|
|
|
|
def test_overflow_detected_at_95_percent(self):
|
|
"""Context overflow flagged when prompt_eval_count >= 95% of limit."""
|
|
cb = self._trigger_overflow(
|
|
context_limit=1000,
|
|
prompt_eval_count=960, # 96% > 95%
|
|
original_prompt_estimate=1200,
|
|
)
|
|
assert cb.context_truncated is True
|
|
assert cb.tokens_truncated == 1200 - 960
|
|
|
|
def test_no_overflow_below_threshold(self):
|
|
"""No overflow when below 80% threshold AND estimate fits limit.
|
|
|
|
Both detection paths must NOT fire: prompt_eval_count is 70% of
|
|
the limit (below the 80% provider-confirmed threshold), and
|
|
original_prompt_estimate is below context_limit (so the
|
|
estimation-based path added in PR #3791 also stays quiet).
|
|
"""
|
|
cb = self._trigger_overflow(
|
|
context_limit=1000,
|
|
prompt_eval_count=700, # 70% < 80%
|
|
original_prompt_estimate=900, # below limit, no estimation path
|
|
)
|
|
assert cb.context_truncated is False
|
|
|
|
def test_exact_95_boundary(self):
|
|
"""Exact 95% threshold should trigger overflow."""
|
|
cb = self._trigger_overflow(
|
|
context_limit=1000,
|
|
prompt_eval_count=950, # exactly 95%
|
|
original_prompt_estimate=1200,
|
|
)
|
|
assert cb.context_truncated is True
|
|
|
|
def test_no_overflow_when_context_limit_none(self):
|
|
"""No overflow detection when context_limit is not set."""
|
|
cb = _make_callback(research_context={})
|
|
cb.on_llm_start(
|
|
{"_type": "ChatOllama", "kwargs": {"model": "llama3"}},
|
|
["hello"],
|
|
)
|
|
|
|
msg = MagicMock()
|
|
msg.usage_metadata = None
|
|
msg.response_metadata = {"prompt_eval_count": 9999, "eval_count": 10}
|
|
gen = MagicMock()
|
|
gen.message = msg
|
|
result = _make_llm_result(generations=[[gen]])
|
|
cb.on_llm_end(result)
|
|
|
|
assert cb.context_truncated is False
|
|
|
|
def test_truncation_ratio_zero_prompt_estimate(self):
|
|
"""When original_prompt_estimate <= prompt_eval_count, tokens_truncated is 0."""
|
|
cb = self._trigger_overflow(
|
|
context_limit=100,
|
|
prompt_eval_count=96, # 96% > 95%
|
|
original_prompt_estimate=90, # less than eval count
|
|
)
|
|
# context_truncated is set, but tokens_truncated stays 0
|
|
# because original_prompt_estimate <= prompt_eval_count
|
|
assert cb.context_truncated is True
|
|
assert cb.tokens_truncated == 0
|
|
|
|
def test_prompt_eval_count_zero_skips_overflow(self):
|
|
"""prompt_eval_count == 0 should not trigger overflow check."""
|
|
cb = _make_callback(research_context={"context_limit": 1000})
|
|
cb.on_llm_start(
|
|
{"_type": "ChatOllama", "kwargs": {"model": "llama3"}},
|
|
["hello"],
|
|
)
|
|
|
|
msg = MagicMock()
|
|
msg.usage_metadata = None
|
|
msg.response_metadata = {"prompt_eval_count": 0, "eval_count": 10}
|
|
gen = MagicMock()
|
|
gen.message = msg
|
|
result = _make_llm_result(generations=[[gen]])
|
|
cb.on_llm_end(result)
|
|
|
|
assert cb.context_truncated is False
|
|
|
|
|
|
# ===========================================================================
|
|
# 5. on_llm_error tracking
|
|
# ===========================================================================
|
|
|
|
|
|
class TestOnLLMError:
|
|
"""Verify error tracking in on_llm_error."""
|
|
|
|
def test_error_sets_status_and_type(self):
|
|
"""Error callback records status and error type."""
|
|
cb = _make_callback()
|
|
cb.start_time = time.time()
|
|
|
|
cb.on_llm_error(ValueError("boom"))
|
|
|
|
assert cb.success_status == "error"
|
|
assert cb.error_type == "ValueError"
|
|
assert cb.response_time_ms is not None
|
|
|
|
def test_error_saves_to_db_when_research_id_set(self):
|
|
"""Error with research_id triggers _save_to_db with zero tokens."""
|
|
cb = _make_callback(research_id="test-123")
|
|
cb.start_time = time.time()
|
|
|
|
with patch.object(cb, "_save_to_db") as mock_save:
|
|
cb.on_llm_error(RuntimeError("fail"))
|
|
mock_save.assert_called_once_with(0, 0)
|
|
|
|
|
|
# ===========================================================================
|
|
# 6. _get_context_overflow_fields
|
|
# ===========================================================================
|
|
|
|
|
|
class TestContextOverflowFields:
|
|
"""Verify _get_context_overflow_fields output."""
|
|
|
|
def test_fields_when_no_overflow(self):
|
|
"""Fields should indicate no truncation."""
|
|
cb = _make_callback()
|
|
fields = cb._get_context_overflow_fields()
|
|
|
|
assert fields["context_truncated"] is False
|
|
assert fields["tokens_truncated"] is None
|
|
assert fields["truncation_ratio"] is None
|
|
|
|
def test_fields_when_overflow_detected(self):
|
|
"""Fields should include truncation details."""
|
|
cb = _make_callback()
|
|
cb.context_limit = 1000
|
|
cb.context_truncated = True
|
|
cb.tokens_truncated = 200
|
|
cb.truncation_ratio = 0.2
|
|
cb.ollama_metrics = {"prompt_eval_count": 800, "eval_count": 50}
|
|
|
|
fields = cb._get_context_overflow_fields()
|
|
|
|
assert fields["context_truncated"] is True
|
|
assert fields["tokens_truncated"] == 200
|
|
assert fields["truncation_ratio"] == 0.2
|
|
assert fields["ollama_prompt_eval_count"] == 800
|
|
|
|
|
|
# ===========================================================================
|
|
# 7. Prompt estimate calculation
|
|
# ===========================================================================
|
|
|
|
|
|
class TestPromptEstimate:
|
|
"""Verify original_prompt_estimate is calculated from prompt chars."""
|
|
|
|
def test_estimate_from_multiple_prompts(self):
|
|
"""Estimate is sum of chars // 4."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start(
|
|
{}, ["aaaa", "bbbbbbbb"]
|
|
) # 4 + 8 = 12 chars => 3 tokens
|
|
assert cb.original_prompt_estimate == 3
|
|
|
|
def test_estimate_empty_prompts(self):
|
|
"""Empty prompts list yields 0 estimate."""
|
|
cb = _make_callback()
|
|
cb.on_llm_start({}, [])
|
|
assert cb.original_prompt_estimate == 0
|