项目文件夹

文件
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 行
17 KiB
Python

"""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