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
933 lines
34 KiB
Python
933 lines
34 KiB
Python
"""
|
|
Behavioral tests for the rate limiting subsystem.
|
|
|
|
Covers untested logic in:
|
|
- cli.py: format_stats_table() pure formatting function
|
|
- detection.py: provider-specific detection paths, extract_retry_after edge cases
|
|
- wrapper.py: _check_if_local_model URL detection, _get_rate_limit_key edge cases,
|
|
_do_invoke rate limit wrapping, string representations
|
|
- exceptions.py: exception hierarchy
|
|
"""
|
|
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cli.py — format_stats_table()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFormatStatsTableEmptyInput:
|
|
"""Tests for format_stats_table with empty/no data."""
|
|
|
|
def test_empty_list_returns_no_data_message(self):
|
|
"""Empty stats list → specific 'no data' message."""
|
|
from local_deep_research.web_search_engines.rate_limiting.cli import (
|
|
format_stats_table,
|
|
)
|
|
|
|
assert format_stats_table([]) == "No rate limit data available."
|
|
|
|
def test_none_like_empty_returns_no_data_message(self):
|
|
"""Any falsy input returns the no-data message."""
|
|
from local_deep_research.web_search_engines.rate_limiting.cli import (
|
|
format_stats_table,
|
|
)
|
|
|
|
assert format_stats_table([]) == "No rate limit data available."
|
|
|
|
|
|
class TestFormatStatsTableStructure:
|
|
"""Tests for format_stats_table output structure."""
|
|
|
|
@pytest.fixture
|
|
def single_row(self):
|
|
"""A single stats row tuple: (engine, base_wait, min, max, timestamp, attempts, rate)."""
|
|
return [("DuckDuckGo", 2.50, 1.0, 5.0, 1700000000.0, 42, 0.95)]
|
|
|
|
def test_header_line_present(self, single_row):
|
|
from local_deep_research.web_search_engines.rate_limiting.cli import (
|
|
format_stats_table,
|
|
)
|
|
|
|
output = format_stats_table(single_row)
|
|
assert "Rate Limit Statistics:" in output
|
|
|
|
def test_separator_lines_present(self, single_row):
|
|
from local_deep_research.web_search_engines.rate_limiting.cli import (
|
|
format_stats_table,
|
|
)
|
|
|
|
output = format_stats_table(single_row)
|
|
assert "-" * 80 in output
|
|
|
|
def test_column_headers_present(self, single_row):
|
|
from local_deep_research.web_search_engines.rate_limiting.cli import (
|
|
format_stats_table,
|
|
)
|
|
|
|
output = format_stats_table(single_row)
|
|
for header in [
|
|
"Engine",
|
|
"Base Wait",
|
|
"Range",
|
|
"Success",
|
|
"Attempts",
|
|
"Updated",
|
|
]:
|
|
assert header in output
|
|
|
|
def test_engine_name_appears_in_output(self, single_row):
|
|
from local_deep_research.web_search_engines.rate_limiting.cli import (
|
|
format_stats_table,
|
|
)
|
|
|
|
output = format_stats_table(single_row)
|
|
assert "DuckDuckGo" in output
|
|
|
|
|
|
class TestFormatStatsTableFormatting:
|
|
"""Tests for format_stats_table value formatting rules."""
|
|
|
|
def test_base_wait_two_decimal_places(self):
|
|
"""base_wait uses .2f format: 2.5 → '2.50'."""
|
|
from local_deep_research.web_search_engines.rate_limiting.cli import (
|
|
format_stats_table,
|
|
)
|
|
|
|
row = [("Engine", 2.5, 1.0, 5.0, 1700000000.0, 10, 0.9)]
|
|
output = format_stats_table(row)
|
|
assert "2.50" in output
|
|
|
|
def test_range_formatted_with_units(self):
|
|
"""Range shows 'min - max' with one decimal and 's' suffix."""
|
|
from local_deep_research.web_search_engines.rate_limiting.cli import (
|
|
format_stats_table,
|
|
)
|
|
|
|
row = [("Engine", 2.0, 1.0, 5.0, 1700000000.0, 10, 0.9)]
|
|
output = format_stats_table(row)
|
|
assert "1.0s - 5.0s" in output
|
|
|
|
def test_success_rate_formatted_as_percentage(self):
|
|
"""Success rate 0.95 → '95.0%'."""
|
|
from local_deep_research.web_search_engines.rate_limiting.cli import (
|
|
format_stats_table,
|
|
)
|
|
|
|
row = [("Engine", 2.0, 1.0, 5.0, 1700000000.0, 10, 0.95)]
|
|
output = format_stats_table(row)
|
|
assert "95.0%" in output
|
|
|
|
def test_zero_success_rate_formatted(self):
|
|
"""Success rate 0.0 → '0.0%'."""
|
|
from local_deep_research.web_search_engines.rate_limiting.cli import (
|
|
format_stats_table,
|
|
)
|
|
|
|
row = [("Engine", 2.0, 1.0, 5.0, 1700000000.0, 10, 0.0)]
|
|
output = format_stats_table(row)
|
|
assert "0.0%" in output
|
|
|
|
def test_full_success_rate_formatted(self):
|
|
"""Success rate 1.0 → '100.0%'."""
|
|
from local_deep_research.web_search_engines.rate_limiting.cli import (
|
|
format_stats_table,
|
|
)
|
|
|
|
row = [("Engine", 2.0, 1.0, 5.0, 1700000000.0, 10, 1.0)]
|
|
output = format_stats_table(row)
|
|
assert "100.0%" in output
|
|
|
|
def test_timestamp_formatted_as_date_time(self):
|
|
"""Timestamp uses MM-DD HH:MM format."""
|
|
from datetime import datetime
|
|
|
|
from local_deep_research.web_search_engines.rate_limiting.cli import (
|
|
format_stats_table,
|
|
)
|
|
|
|
ts = 1700000000.0
|
|
expected = datetime.fromtimestamp(ts).strftime("%m-%d %H:%M")
|
|
row = [("Engine", 2.0, 1.0, 5.0, ts, 10, 0.9)]
|
|
output = format_stats_table(row)
|
|
assert expected in output
|
|
|
|
def test_attempts_count_appears(self):
|
|
"""Attempts count appears in output as-is."""
|
|
from local_deep_research.web_search_engines.rate_limiting.cli import (
|
|
format_stats_table,
|
|
)
|
|
|
|
row = [("Engine", 2.0, 1.0, 5.0, 1700000000.0, 12345, 0.9)]
|
|
output = format_stats_table(row)
|
|
assert "12345" in output
|
|
|
|
def test_multiple_rows_all_appear(self):
|
|
"""Multiple data rows all appear in output."""
|
|
from local_deep_research.web_search_engines.rate_limiting.cli import (
|
|
format_stats_table,
|
|
)
|
|
|
|
rows = [
|
|
("EngineA", 1.0, 0.5, 2.0, 1700000000.0, 10, 0.8),
|
|
("EngineB", 3.0, 1.5, 6.0, 1700000000.0, 20, 0.9),
|
|
("EngineC", 5.0, 2.5, 10.0, 1700000000.0, 30, 0.7),
|
|
]
|
|
output = format_stats_table(rows)
|
|
assert "EngineA" in output
|
|
assert "EngineB" in output
|
|
assert "EngineC" in output
|
|
|
|
def test_output_is_newline_joined(self):
|
|
"""Output is lines joined by newlines (not a list)."""
|
|
from local_deep_research.web_search_engines.rate_limiting.cli import (
|
|
format_stats_table,
|
|
)
|
|
|
|
row = [("Engine", 2.0, 1.0, 5.0, 1700000000.0, 10, 0.9)]
|
|
output = format_stats_table(row)
|
|
lines = output.split("\n")
|
|
# Header + separator + column headers + separator + data row = 5 lines
|
|
assert len(lines) == 5
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# detection.py — provider-specific detection paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDetectionProviderSpecificPaths:
|
|
"""Tests for provider-specific error detection paths in is_llm_rate_limit_error.
|
|
|
|
The function checks (in order): HTTP 429, message patterns, error type name,
|
|
OpenAI module, Anthropic module. The Anthropic path is reachable when the
|
|
message contains 'too many' without 'requests'.
|
|
"""
|
|
|
|
def test_error_type_ratelimiterror_detected(self):
|
|
"""Error class named 'RateLimitError' detected via type-name check."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
|
|
is_llm_rate_limit_error,
|
|
)
|
|
|
|
class RateLimitError(Exception):
|
|
pass
|
|
|
|
error = RateLimitError("Some message without keywords")
|
|
# type name check: "ratelimiterror" in error_type
|
|
assert is_llm_rate_limit_error(error) is True
|
|
|
|
def test_error_type_quotaexceeded_detected(self):
|
|
"""Error class named 'QuotaExceeded' detected via type-name check."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
|
|
is_llm_rate_limit_error,
|
|
)
|
|
|
|
class QuotaExceeded(Exception):
|
|
pass
|
|
|
|
error = QuotaExceeded("Generic error")
|
|
assert is_llm_rate_limit_error(error) is True
|
|
|
|
def test_anthropic_too_many_without_requests_detected(self):
|
|
"""Anthropic module error with 'too many' (no 'requests') uses Anthropic-specific path.
|
|
|
|
The generic check looks for 'too many requests' but Anthropic check
|
|
looks for just 'too many' — so this is only caught by the Anthropic path.
|
|
"""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
|
|
is_llm_rate_limit_error,
|
|
)
|
|
|
|
# Create error class whose module contains "anthropic"
|
|
AnthropicError = type(
|
|
"SomeError", (Exception,), {"__module__": "anthropic.errors"}
|
|
)
|
|
error = AnthropicError("too many connections")
|
|
assert is_llm_rate_limit_error(error) is True
|
|
|
|
def test_anthropic_module_clean_message_not_detected(self):
|
|
"""Anthropic module error without any rate-limit indicator → False."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
|
|
is_llm_rate_limit_error,
|
|
)
|
|
|
|
AnthropicAuthError = type(
|
|
"AuthError", (Exception,), {"__module__": "anthropic.errors"}
|
|
)
|
|
error = AnthropicAuthError("Invalid API key provided")
|
|
assert is_llm_rate_limit_error(error) is False
|
|
|
|
def test_error_without_response_attribute_uses_message(self):
|
|
"""Plain Exception without .response → falls through to message check."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
|
|
is_llm_rate_limit_error,
|
|
)
|
|
|
|
error = Exception("rate limit exceeded")
|
|
assert not hasattr(error, "response")
|
|
assert is_llm_rate_limit_error(error) is True
|
|
|
|
def test_error_with_non_429_status_code_uses_message(self):
|
|
"""Error with response.status_code != 429 falls through to message check."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
|
|
is_llm_rate_limit_error,
|
|
)
|
|
|
|
error = Mock()
|
|
error.response = Mock()
|
|
error.response.status_code = 500
|
|
error.__str__ = lambda self: "Internal server error"
|
|
error.__class__.__name__ = "HTTPError"
|
|
error.__class__.__module__ = "requests"
|
|
assert is_llm_rate_limit_error(error) is False
|
|
|
|
def test_completely_clean_error_returns_false(self):
|
|
"""Error with no rate-limit signals at all → False."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
|
|
is_llm_rate_limit_error,
|
|
)
|
|
|
|
error = Exception("Connection timed out after 30 seconds")
|
|
assert is_llm_rate_limit_error(error) is False
|
|
|
|
def test_slow_down_message_detected(self):
|
|
"""'slow down' message pattern is detected."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
|
|
is_llm_rate_limit_error,
|
|
)
|
|
|
|
error = Exception("Please slow down your requests")
|
|
assert is_llm_rate_limit_error(error) is True
|
|
|
|
def test_threshold_message_detected(self):
|
|
"""'threshold' message pattern is detected."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
|
|
is_llm_rate_limit_error,
|
|
)
|
|
|
|
error = Exception("Request threshold reached")
|
|
assert is_llm_rate_limit_error(error) is True
|
|
|
|
def test_case_insensitive_detection(self):
|
|
"""Detection is case-insensitive (message lowercased before check)."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
|
|
is_llm_rate_limit_error,
|
|
)
|
|
|
|
error = Exception("RATE LIMIT EXCEEDED")
|
|
assert is_llm_rate_limit_error(error) is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# detection.py — extract_retry_after edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestExtractRetryAfterEdgeCases:
|
|
"""Edge case tests for extract_retry_after."""
|
|
|
|
def test_decimal_retry_after_header(self):
|
|
"""Retry-After header with decimal value: '30.5' → 30.5."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
|
|
extract_retry_after,
|
|
)
|
|
|
|
error = Mock()
|
|
error.response = Mock()
|
|
error.response.headers = {"Retry-After": "30.5"}
|
|
error.__str__ = lambda self: "rate limit"
|
|
assert extract_retry_after(error) == 30.5
|
|
|
|
def test_non_numeric_retry_after_header_falls_through(self):
|
|
"""Non-numeric Retry-After header → falls through to message parsing."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
|
|
extract_retry_after,
|
|
)
|
|
|
|
error = Mock()
|
|
error.response = Mock()
|
|
error.response.headers = {
|
|
"Retry-After": "Wed, 21 Oct 2023 07:28:00 GMT"
|
|
}
|
|
error.__str__ = lambda self: "Please try again in 60 seconds"
|
|
assert extract_retry_after(error) == 60.0
|
|
|
|
def test_no_response_attribute_uses_message(self):
|
|
"""Error without .response → falls through to message regex."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
|
|
extract_retry_after,
|
|
)
|
|
|
|
error = Exception("Retry after 15 seconds")
|
|
assert extract_retry_after(error) == 15.0
|
|
|
|
def test_retry_after_pattern_in_message(self):
|
|
"""'retry after N seconds' pattern matched (case insensitive)."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
|
|
extract_retry_after,
|
|
)
|
|
|
|
error = Exception("Retry after 25 seconds please")
|
|
assert extract_retry_after(error) == 25.0
|
|
|
|
def test_wait_pattern_in_message(self):
|
|
"""'wait N seconds' pattern matched."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
|
|
extract_retry_after,
|
|
)
|
|
|
|
error = Exception("Please wait 10 seconds before retrying")
|
|
assert extract_retry_after(error) == 10.0
|
|
|
|
def test_decimal_seconds_in_message(self):
|
|
"""Decimal seconds in message: 'try again in 1.5 seconds'."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
|
|
extract_retry_after,
|
|
)
|
|
|
|
error = Exception("Please try again in 1.5 seconds")
|
|
assert extract_retry_after(error) == 1.5
|
|
|
|
def test_no_time_info_returns_zero(self):
|
|
"""Error message with no time information → 0."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
|
|
extract_retry_after,
|
|
)
|
|
|
|
error = Exception("Rate limit exceeded, come back later")
|
|
assert extract_retry_after(error) == 0
|
|
|
|
def test_response_without_headers_attribute(self):
|
|
"""Error with response but no headers attribute → 0 if no message match."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.detection import (
|
|
extract_retry_after,
|
|
)
|
|
|
|
error = Mock()
|
|
error.response = Mock(spec=["status_code"]) # No .headers
|
|
error.__str__ = lambda self: "Some error"
|
|
assert extract_retry_after(error) == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# wrapper.py — _check_if_local_model
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckIfLocalModel:
|
|
"""Tests for _check_if_local_model URL-based and provider-based detection."""
|
|
|
|
def _make_wrapper(self, provider=None, base_url=None):
|
|
"""Create a wrapper with given provider and base_url."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
|
|
create_rate_limited_llm_wrapper,
|
|
)
|
|
|
|
mock_llm = Mock()
|
|
if base_url is not None:
|
|
mock_llm.base_url = base_url
|
|
else:
|
|
# Remove base_url attribute
|
|
del mock_llm.base_url
|
|
mock_llm.model_name = "test-model"
|
|
|
|
return create_rate_limited_llm_wrapper(mock_llm, provider=provider)
|
|
|
|
def test_localhost_url_detected_as_local(self):
|
|
"""base_url containing 'localhost' → local model."""
|
|
wrapper = self._make_wrapper(
|
|
provider="custom", base_url="http://localhost:11434"
|
|
)
|
|
assert wrapper._check_if_local_model() is True
|
|
|
|
def test_127_0_0_1_url_detected_as_local(self):
|
|
"""base_url containing '127.0.0.1' → local model."""
|
|
wrapper = self._make_wrapper(
|
|
provider="custom", base_url="http://127.0.0.1:8080"
|
|
)
|
|
assert wrapper._check_if_local_model() is True
|
|
|
|
def test_0_0_0_0_url_detected_as_local(self):
|
|
"""base_url containing '0.0.0.0' → local model."""
|
|
wrapper = self._make_wrapper(
|
|
provider="custom", base_url="http://0.0.0.0:5000"
|
|
)
|
|
assert wrapper._check_if_local_model() is True
|
|
|
|
def test_remote_url_not_local(self):
|
|
"""Remote URL like api.openai.com → not local."""
|
|
wrapper = self._make_wrapper(
|
|
provider="custom", base_url="https://api.openai.com/v1"
|
|
)
|
|
assert wrapper._check_if_local_model() is False
|
|
|
|
def test_no_base_url_not_local(self):
|
|
"""LLM without base_url attribute → not local (by URL check)."""
|
|
wrapper = self._make_wrapper(provider="custom", base_url=None)
|
|
assert wrapper._check_if_local_model() is False
|
|
|
|
def test_ollama_provider_is_local(self):
|
|
"""'ollama' provider → local model."""
|
|
wrapper = self._make_wrapper(
|
|
provider="ollama", base_url="https://api.example.com"
|
|
)
|
|
assert wrapper._check_if_local_model() is True
|
|
|
|
def test_lmstudio_provider_is_local(self):
|
|
"""'lmstudio' provider → local model."""
|
|
wrapper = self._make_wrapper(provider="lmstudio")
|
|
assert wrapper._check_if_local_model() is True
|
|
|
|
def test_llamacpp_provider_is_local(self):
|
|
"""'llamacpp' provider → local model."""
|
|
wrapper = self._make_wrapper(provider="llamacpp")
|
|
assert wrapper._check_if_local_model() is True
|
|
|
|
def test_local_provider_is_local(self):
|
|
"""'local' provider → local model."""
|
|
wrapper = self._make_wrapper(provider="local")
|
|
assert wrapper._check_if_local_model() is True
|
|
|
|
def test_none_provider_string_is_local(self):
|
|
"""'none' provider → local model (in local_providers list)."""
|
|
wrapper = self._make_wrapper(provider="none")
|
|
assert wrapper._check_if_local_model() is True
|
|
|
|
def test_provider_case_insensitive(self):
|
|
"""Provider check is case-insensitive: 'OLLAMA' → local."""
|
|
wrapper = self._make_wrapper(provider="OLLAMA")
|
|
assert wrapper._check_if_local_model() is True
|
|
|
|
def test_none_provider_value_not_local(self):
|
|
"""None (Python None) provider → not local by provider check."""
|
|
wrapper = self._make_wrapper(provider=None)
|
|
assert wrapper._check_if_local_model() is False
|
|
|
|
def test_openai_provider_not_local(self):
|
|
"""'openai' provider with remote URL → not local."""
|
|
wrapper = self._make_wrapper(
|
|
provider="openai", base_url="https://api.openai.com"
|
|
)
|
|
assert wrapper._check_if_local_model() is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# wrapper.py — _get_rate_limit_key edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetRateLimitKey:
|
|
"""Tests for _get_rate_limit_key composite key building."""
|
|
|
|
def _make_wrapper(
|
|
self, provider=None, base_url=None, model_name=None, model=None
|
|
):
|
|
"""Create a wrapper with specified attributes on the mock LLM."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
|
|
create_rate_limited_llm_wrapper,
|
|
)
|
|
|
|
mock_llm = Mock(spec=[]) # Empty spec so we control attributes
|
|
if base_url is not None:
|
|
mock_llm.base_url = base_url
|
|
if model_name is not None:
|
|
mock_llm.model_name = model_name
|
|
if model is not None:
|
|
mock_llm.model = model
|
|
|
|
return create_rate_limited_llm_wrapper(mock_llm, provider=provider)
|
|
|
|
def test_basic_key_structure(self):
|
|
"""Key format: provider-url-model."""
|
|
wrapper = self._make_wrapper(
|
|
provider="openai",
|
|
base_url="https://api.openai.com/v1",
|
|
model_name="gpt-4",
|
|
)
|
|
key = wrapper._get_rate_limit_key()
|
|
assert key == "openai-api.openai.com-gpt-4"
|
|
|
|
def test_no_provider_uses_unknown(self):
|
|
"""None provider → 'unknown' in key."""
|
|
wrapper = self._make_wrapper(
|
|
provider=None,
|
|
base_url="https://api.example.com",
|
|
model_name="test",
|
|
)
|
|
key = wrapper._get_rate_limit_key()
|
|
assert key.startswith("unknown-")
|
|
|
|
def test_no_base_url_uses_unknown(self):
|
|
"""LLM without base_url or _client → 'unknown' for URL part."""
|
|
wrapper = self._make_wrapper(
|
|
provider="openai",
|
|
model_name="gpt-4",
|
|
)
|
|
key = wrapper._get_rate_limit_key()
|
|
assert "unknown" in key
|
|
|
|
def test_fallback_to_client_base_url(self):
|
|
"""Falls back to _client.base_url when base_url not present."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
|
|
create_rate_limited_llm_wrapper,
|
|
)
|
|
|
|
mock_llm = Mock(spec=[])
|
|
mock_llm._client = Mock()
|
|
mock_llm._client.base_url = "https://api.fallback.com/v1"
|
|
mock_llm.model_name = "test-model"
|
|
|
|
wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
|
|
key = wrapper._get_rate_limit_key()
|
|
assert "api.fallback.com" in key
|
|
|
|
def test_model_name_slash_replaced(self):
|
|
"""'/' in model name replaced with '-'."""
|
|
wrapper = self._make_wrapper(
|
|
provider="openai",
|
|
base_url="https://api.example.com",
|
|
model_name="org/model-name",
|
|
)
|
|
key = wrapper._get_rate_limit_key()
|
|
assert "org-model-name" in key
|
|
assert "/" not in key
|
|
|
|
def test_model_name_colon_replaced(self):
|
|
"""':' in model name replaced with '-': 'llama3:8b' → 'llama3-8b'."""
|
|
wrapper = self._make_wrapper(
|
|
provider="ollama",
|
|
base_url="http://localhost:11434",
|
|
model_name="llama3:8b",
|
|
)
|
|
key = wrapper._get_rate_limit_key()
|
|
assert "llama3-8b" in key
|
|
assert "llama3:8b" not in key # Original colon form should not appear
|
|
|
|
def test_model_attribute_fallback(self):
|
|
"""Falls back to .model when .model_name not present."""
|
|
wrapper = self._make_wrapper(
|
|
provider="anthropic",
|
|
base_url="https://api.anthropic.com",
|
|
model="claude-3-opus",
|
|
)
|
|
key = wrapper._get_rate_limit_key()
|
|
assert "claude-3-opus" in key
|
|
|
|
def test_no_model_attribute_uses_unknown(self):
|
|
"""LLM without model_name or model → 'unknown' for model part."""
|
|
wrapper = self._make_wrapper(
|
|
provider="openai",
|
|
base_url="https://api.example.com",
|
|
)
|
|
key = wrapper._get_rate_limit_key()
|
|
assert key.endswith("-unknown")
|
|
|
|
def test_url_trailing_slashes_stripped(self):
|
|
"""Trailing slashes in URL are stripped."""
|
|
wrapper = self._make_wrapper(
|
|
provider="openai",
|
|
base_url="https://api.example.com/",
|
|
model_name="gpt-4",
|
|
)
|
|
key = wrapper._get_rate_limit_key()
|
|
assert not key.endswith("/")
|
|
assert "api.example.com-gpt-4" in key
|
|
|
|
def test_url_protocol_stripped(self):
|
|
"""URL protocol (https://) is stripped — only netloc kept."""
|
|
wrapper = self._make_wrapper(
|
|
provider="openai",
|
|
base_url="https://api.example.com/v1",
|
|
model_name="gpt-4",
|
|
)
|
|
key = wrapper._get_rate_limit_key()
|
|
assert "https://" not in key
|
|
assert "api.example.com" in key
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# wrapper.py — string representations
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWrapperStringRepresentations:
|
|
"""Tests for __str__ and __repr__ of RateLimitedLLMWrapper."""
|
|
|
|
def test_str_format(self):
|
|
"""__str__ wraps base LLM string: 'RateLimited(...)'."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
|
|
create_rate_limited_llm_wrapper,
|
|
)
|
|
|
|
mock_llm = Mock()
|
|
mock_llm.__str__ = lambda self: "MockLLM(gpt-4)"
|
|
mock_llm.model_name = "gpt-4"
|
|
|
|
wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
|
|
assert str(wrapper) == "RateLimited(MockLLM(gpt-4))"
|
|
|
|
def test_repr_format(self):
|
|
"""__repr__ wraps base LLM repr: 'RateLimitedLLMWrapper(...)'."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
|
|
create_rate_limited_llm_wrapper,
|
|
)
|
|
|
|
mock_llm = Mock()
|
|
mock_llm.__repr__ = lambda self: "MockLLM(model='gpt-4')"
|
|
mock_llm.model_name = "gpt-4"
|
|
|
|
wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
|
|
assert repr(wrapper) == "RateLimitedLLMWrapper(MockLLM(model='gpt-4'))"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# wrapper.py — _do_invoke rate limit error wrapping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDoInvokeRateLimitWrapping:
|
|
"""Tests for _do_invoke wrapping rate limit errors as RateLimitError."""
|
|
|
|
def test_rate_limit_error_wrapped(self):
|
|
"""Rate limit error from base LLM → wrapped as RateLimitError."""
|
|
from local_deep_research.web_search_engines.rate_limiting.exceptions import (
|
|
RateLimitError,
|
|
)
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
|
|
create_rate_limited_llm_wrapper,
|
|
)
|
|
|
|
mock_llm = Mock()
|
|
mock_llm.model_name = "gpt-4"
|
|
mock_llm.invoke.side_effect = Exception("429 Too many requests")
|
|
|
|
wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
|
|
with pytest.raises(RateLimitError, match="LLM rate limit"):
|
|
wrapper._do_invoke("test prompt")
|
|
|
|
def test_non_rate_limit_error_not_wrapped(self):
|
|
"""Non-rate-limit error from base LLM → re-raised as-is."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
|
|
create_rate_limited_llm_wrapper,
|
|
)
|
|
|
|
mock_llm = Mock()
|
|
mock_llm.model_name = "gpt-4"
|
|
mock_llm.invoke.side_effect = ValueError("Invalid input format")
|
|
|
|
wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
|
|
with pytest.raises(ValueError, match="Invalid input format"):
|
|
wrapper._do_invoke("test prompt")
|
|
|
|
def test_successful_invoke_returns_result(self):
|
|
"""Successful invoke returns the base LLM result."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
|
|
create_rate_limited_llm_wrapper,
|
|
)
|
|
|
|
mock_llm = Mock()
|
|
mock_llm.model_name = "gpt-4"
|
|
mock_llm.invoke.return_value = "response text"
|
|
|
|
wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
|
|
result = wrapper._do_invoke("test prompt")
|
|
assert result == "response text"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# wrapper.py — attribute passthrough via __getattr__
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWrapperAttributePassthrough:
|
|
"""Tests for __getattr__ passing attributes through to base LLM."""
|
|
|
|
def test_passes_through_custom_attributes(self):
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
|
|
create_rate_limited_llm_wrapper,
|
|
)
|
|
|
|
mock_llm = Mock()
|
|
mock_llm.model_name = "gpt-4"
|
|
mock_llm.temperature = 0.7
|
|
mock_llm.custom_setting = "value"
|
|
|
|
wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
|
|
assert wrapper.temperature == 0.7
|
|
assert wrapper.custom_setting == "value"
|
|
|
|
def test_nonexistent_attribute_raises(self):
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
|
|
create_rate_limited_llm_wrapper,
|
|
)
|
|
|
|
mock_llm = Mock(spec=["invoke", "model_name"])
|
|
mock_llm.model_name = "gpt-4"
|
|
|
|
wrapper = create_rate_limited_llm_wrapper(mock_llm, provider="openai")
|
|
with pytest.raises(AttributeError):
|
|
_ = wrapper.nonexistent_attribute
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# exceptions.py — exception hierarchy
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRateLimitExceptions:
|
|
"""Tests for rate limiting exception classes."""
|
|
|
|
def test_rate_limit_error_is_exception(self):
|
|
"""RateLimitError is a subclass of Exception."""
|
|
from local_deep_research.web_search_engines.rate_limiting.exceptions import (
|
|
RateLimitError,
|
|
)
|
|
|
|
assert issubclass(RateLimitError, Exception)
|
|
|
|
def test_adaptive_retry_error_is_exception(self):
|
|
"""AdaptiveRetryError is a subclass of Exception."""
|
|
from local_deep_research.web_search_engines.rate_limiting.exceptions import (
|
|
AdaptiveRetryError,
|
|
)
|
|
|
|
assert issubclass(AdaptiveRetryError, Exception)
|
|
|
|
def test_rate_limit_config_error_is_exception(self):
|
|
"""RateLimitConfigError is a subclass of Exception."""
|
|
from local_deep_research.web_search_engines.rate_limiting.exceptions import (
|
|
RateLimitConfigError,
|
|
)
|
|
|
|
assert issubclass(RateLimitConfigError, Exception)
|
|
|
|
def test_rate_limit_error_carries_message(self):
|
|
"""RateLimitError preserves its message."""
|
|
from local_deep_research.web_search_engines.rate_limiting.exceptions import (
|
|
RateLimitError,
|
|
)
|
|
|
|
error = RateLimitError("too many requests to API")
|
|
assert str(error) == "too many requests to API"
|
|
|
|
def test_exceptions_are_catchable_separately(self):
|
|
"""Each exception type is distinct and catchable independently."""
|
|
from local_deep_research.web_search_engines.rate_limiting.exceptions import (
|
|
AdaptiveRetryError,
|
|
RateLimitConfigError,
|
|
RateLimitError,
|
|
)
|
|
|
|
with pytest.raises(RateLimitError):
|
|
raise RateLimitError("test")
|
|
|
|
with pytest.raises(AdaptiveRetryError):
|
|
raise AdaptiveRetryError("test")
|
|
|
|
with pytest.raises(RateLimitConfigError):
|
|
raise RateLimitConfigError("test")
|
|
|
|
def test_rate_limit_error_not_caught_as_config_error(self):
|
|
"""RateLimitError is not caught by RateLimitConfigError handler."""
|
|
from local_deep_research.web_search_engines.rate_limiting.exceptions import (
|
|
RateLimitConfigError,
|
|
RateLimitError,
|
|
)
|
|
|
|
with pytest.raises(RateLimitError):
|
|
try:
|
|
raise RateLimitError("rate limited")
|
|
except RateLimitConfigError:
|
|
pytest.fail(
|
|
"RateLimitError incorrectly caught as RateLimitConfigError"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# wrapper.py — AdaptiveLLMWait strategy
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAdaptiveLLMWait:
|
|
"""Tests for AdaptiveLLMWait tenacity wait strategy."""
|
|
|
|
def test_uses_tracker_wait_time(self):
|
|
"""Wait time comes from tracker.get_wait_time."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
|
|
AdaptiveLLMWait,
|
|
)
|
|
|
|
mock_tracker = Mock()
|
|
mock_tracker.get_wait_time.return_value = 5.0
|
|
|
|
wait = AdaptiveLLMWait(mock_tracker, "test-engine")
|
|
retry_state = Mock()
|
|
retry_state.outcome = None
|
|
|
|
result = wait(retry_state)
|
|
assert result == 5.0
|
|
mock_tracker.get_wait_time.assert_called_once_with("test-engine")
|
|
|
|
def test_retry_after_overrides_tracker_when_larger(self):
|
|
"""retry-after from error overrides tracker wait when it's larger."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
|
|
AdaptiveLLMWait,
|
|
)
|
|
|
|
mock_tracker = Mock()
|
|
mock_tracker.get_wait_time.return_value = 2.0
|
|
|
|
wait = AdaptiveLLMWait(mock_tracker, "test-engine")
|
|
|
|
# Simulate a failed attempt with retry-after info
|
|
error = Exception("Please try again in 30 seconds")
|
|
retry_state = Mock()
|
|
retry_state.outcome = Mock()
|
|
retry_state.outcome.failed = True
|
|
retry_state.outcome.exception.return_value = error
|
|
|
|
result = wait(retry_state)
|
|
assert result == 30.0 # retry-after (30) > tracker (2)
|
|
|
|
def test_tracker_time_used_when_larger_than_retry_after(self):
|
|
"""Tracker wait time used when it's larger than retry-after."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
|
|
AdaptiveLLMWait,
|
|
)
|
|
|
|
mock_tracker = Mock()
|
|
mock_tracker.get_wait_time.return_value = 60.0
|
|
|
|
wait = AdaptiveLLMWait(mock_tracker, "test-engine")
|
|
|
|
error = Exception("Please try again in 5 seconds")
|
|
retry_state = Mock()
|
|
retry_state.outcome = Mock()
|
|
retry_state.outcome.failed = True
|
|
retry_state.outcome.exception.return_value = error
|
|
|
|
result = wait(retry_state)
|
|
assert result == 60.0 # tracker (60) > retry-after (5)
|
|
|
|
def test_no_failure_uses_tracker_time(self):
|
|
"""When outcome is not failed, only tracker time is used."""
|
|
from local_deep_research.web_search_engines.rate_limiting.llm.wrapper import (
|
|
AdaptiveLLMWait,
|
|
)
|
|
|
|
mock_tracker = Mock()
|
|
mock_tracker.get_wait_time.return_value = 3.0
|
|
|
|
wait = AdaptiveLLMWait(mock_tracker, "test-engine")
|
|
|
|
retry_state = Mock()
|
|
retry_state.outcome = Mock()
|
|
retry_state.outcome.failed = False
|
|
|
|
result = wait(retry_state)
|
|
assert result == 3.0
|