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
442 lines
13 KiB
Python
442 lines
13 KiB
Python
"""Tests for edge cases and advanced scenarios in custom LLM integration."""
|
|
|
|
import asyncio
|
|
from typing import Any, Iterator, List, Optional, Dict
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from langchain_core.callbacks import (
|
|
AsyncCallbackManagerForLLMRun,
|
|
CallbackManagerForLLMRun,
|
|
)
|
|
from langchain_core.language_models import BaseChatModel
|
|
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage
|
|
from langchain_core.outputs import (
|
|
ChatGeneration,
|
|
ChatGenerationChunk,
|
|
ChatResult,
|
|
)
|
|
from pydantic import Field
|
|
|
|
from local_deep_research.config.llm_config import get_llm
|
|
from local_deep_research.llm import (
|
|
clear_llm_registry,
|
|
get_llm_from_registry,
|
|
register_llm,
|
|
)
|
|
|
|
|
|
class StreamingLLM(BaseChatModel):
|
|
"""LLM that supports streaming."""
|
|
|
|
chunks: List[str] = Field(
|
|
default_factory=lambda: [
|
|
"Hello",
|
|
" world",
|
|
" from",
|
|
" streaming",
|
|
" LLM",
|
|
]
|
|
)
|
|
|
|
def _generate(self, messages: List[BaseMessage], **kwargs) -> ChatResult:
|
|
"""Generate non-streaming response."""
|
|
full_response = "".join(self.chunks)
|
|
message = AIMessage(content=full_response)
|
|
generation = ChatGeneration(message=message)
|
|
return ChatResult(generations=[generation])
|
|
|
|
def _stream(
|
|
self,
|
|
messages: List[BaseMessage],
|
|
stop: Optional[List[str]] = None,
|
|
run_manager: Optional[CallbackManagerForLLMRun] = None,
|
|
**kwargs: Any,
|
|
) -> Iterator[ChatGenerationChunk]:
|
|
"""Stream response chunks."""
|
|
for chunk in self.chunks:
|
|
yield ChatGenerationChunk(message=AIMessageChunk(content=chunk))
|
|
|
|
@property
|
|
def _llm_type(self) -> str:
|
|
return "streaming"
|
|
|
|
|
|
class AsyncLLM(BaseChatModel):
|
|
"""LLM that supports async operations."""
|
|
|
|
response: str = Field(default="Async response")
|
|
delay: float = Field(default=0.1)
|
|
|
|
def _generate(self, messages: List[BaseMessage], **kwargs) -> ChatResult:
|
|
"""Sync generation (fallback)."""
|
|
message = AIMessage(content=self.response)
|
|
generation = ChatGeneration(message=message)
|
|
return ChatResult(generations=[generation])
|
|
|
|
async def _agenerate(
|
|
self,
|
|
messages: List[BaseMessage],
|
|
stop: Optional[List[str]] = None,
|
|
run_manager: Optional[AsyncCallbackManagerForLLMRun] = None,
|
|
**kwargs: Any,
|
|
) -> ChatResult:
|
|
"""Async generation."""
|
|
await asyncio.sleep(self.delay) # Simulate async work
|
|
message = AIMessage(content=f"Async: {self.response}")
|
|
generation = ChatGeneration(message=message)
|
|
return ChatResult(generations=[generation])
|
|
|
|
@property
|
|
def _llm_type(self) -> str:
|
|
return "async"
|
|
|
|
|
|
class BrokenLLM(BaseChatModel):
|
|
"""LLM that raises errors."""
|
|
|
|
error_message: str = Field(default="LLM is broken")
|
|
|
|
def _generate(self, messages: List[BaseMessage], **kwargs) -> ChatResult:
|
|
"""Always raises an error."""
|
|
raise RuntimeError(self.error_message)
|
|
|
|
@property
|
|
def _llm_type(self) -> str:
|
|
return "broken"
|
|
|
|
|
|
class SlowLLM(BaseChatModel):
|
|
"""LLM that responds slowly."""
|
|
|
|
response: str = Field(default="Slow response")
|
|
delay: float = Field(default=2.0)
|
|
|
|
def _generate(self, messages: List[BaseMessage], **kwargs) -> ChatResult:
|
|
"""Generate response with delay."""
|
|
import time
|
|
|
|
time.sleep(self.delay)
|
|
message = AIMessage(content=self.response)
|
|
generation = ChatGeneration(message=message)
|
|
return ChatResult(generations=[generation])
|
|
|
|
@property
|
|
def _llm_type(self) -> str:
|
|
return "slow"
|
|
|
|
|
|
class MalformedResponseLLM(BaseChatModel):
|
|
"""LLM that returns malformed responses."""
|
|
|
|
def _generate(self, messages: List[BaseMessage], **kwargs) -> ChatResult:
|
|
"""Return invalid response structure."""
|
|
# Return a ChatResult with no generations
|
|
return ChatResult(generations=[])
|
|
|
|
@property
|
|
def _llm_type(self) -> str:
|
|
return "malformed"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_registry():
|
|
"""Clear the registry before and after each test."""
|
|
clear_llm_registry()
|
|
yield
|
|
clear_llm_registry()
|
|
|
|
|
|
@pytest.fixture
|
|
def full_settings_snapshot():
|
|
"""Provide a complete settings snapshot for tests."""
|
|
|
|
def _text_param(value: str | None) -> Dict[str, Any]:
|
|
return {"value": value, "ui_element": "text"}
|
|
|
|
def _number_param(value: int | float | None) -> Dict[str, Any]:
|
|
return {"value": value, "ui_element": "number"}
|
|
|
|
def _bool_param(value: bool) -> Dict[str, Any]:
|
|
return {"value": value, "ui_element": "checkbox"}
|
|
|
|
return {
|
|
"search.tool": _text_param("searxng"),
|
|
"llm.model": _text_param("test-model"),
|
|
"llm.temperature": _number_param(0.7),
|
|
"llm.provider": _text_param("test"),
|
|
"llm.supports_max_tokens": _bool_param(True),
|
|
"llm.max_tokens": _number_param(100000),
|
|
"llm.local_context_window_size": _number_param(4096),
|
|
"llm.context_window_unrestricted": _bool_param(True),
|
|
"llm.context_window_size": _number_param(128000),
|
|
"llm.ollama.url": _text_param("http://localhost:11434"),
|
|
"llm.openai.api_key": _text_param(None),
|
|
"llm.anthropic.api_key": _text_param(None),
|
|
"llm.openai_endpoint.api_key": _text_param(None),
|
|
"llm.openai_endpoint.url": _text_param("https://openrouter.ai/api/v1"),
|
|
}
|
|
|
|
|
|
def test_streaming_llm_registration(full_settings_snapshot):
|
|
"""Test registering an LLM with streaming support."""
|
|
streaming_llm = StreamingLLM()
|
|
register_llm("streaming", streaming_llm)
|
|
|
|
# Get the LLM through the system
|
|
with patch(
|
|
"local_deep_research.config.llm_config.wrap_llm_without_think_tags"
|
|
) as mock_wrap:
|
|
mock_wrap.side_effect = lambda llm, **kwargs: llm
|
|
|
|
# Provide settings_snapshot to avoid settings context error
|
|
llm = get_llm(
|
|
provider="streaming", settings_snapshot=full_settings_snapshot
|
|
)
|
|
assert isinstance(llm, StreamingLLM)
|
|
|
|
# Test that streaming works
|
|
chunks = list(llm._stream([]))
|
|
assert len(chunks) == 5
|
|
assert all(hasattr(chunk, "message") for chunk in chunks)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_llm_operations():
|
|
"""Test async LLM operations."""
|
|
async_llm = AsyncLLM(delay=0.05)
|
|
register_llm("async", async_llm)
|
|
|
|
# Test async generation
|
|
result = await async_llm._agenerate([])
|
|
assert result.generations[0].message.content == "Async: Async response"
|
|
|
|
|
|
def test_broken_llm_error_handling(full_settings_snapshot):
|
|
"""Test handling of LLMs that raise errors."""
|
|
broken_llm = BrokenLLM(error_message="Test error")
|
|
register_llm("broken", broken_llm)
|
|
|
|
with patch(
|
|
"local_deep_research.config.llm_config.wrap_llm_without_think_tags"
|
|
) as mock_wrap:
|
|
mock_wrap.side_effect = lambda llm, **kwargs: llm
|
|
|
|
# Provide settings_snapshot to avoid settings context error
|
|
llm = get_llm(
|
|
provider="broken", settings_snapshot=full_settings_snapshot
|
|
)
|
|
|
|
# Should raise the error when trying to generate
|
|
with pytest.raises(RuntimeError, match="Test error"):
|
|
llm._generate([])
|
|
|
|
|
|
def test_malformed_response_handling(full_settings_snapshot):
|
|
"""Test handling of LLMs that return malformed responses."""
|
|
malformed_llm = MalformedResponseLLM()
|
|
register_llm("malformed", malformed_llm)
|
|
|
|
with patch(
|
|
"local_deep_research.config.llm_config.wrap_llm_without_think_tags"
|
|
) as mock_wrap:
|
|
mock_wrap.side_effect = lambda llm, **kwargs: llm
|
|
|
|
# Provide settings_snapshot to avoid settings context error
|
|
llm = get_llm(
|
|
provider="malformed", settings_snapshot=full_settings_snapshot
|
|
)
|
|
result = llm._generate([])
|
|
|
|
# Should return empty generations
|
|
assert len(result.generations) == 0
|
|
|
|
|
|
def test_concurrent_llm_registration():
|
|
"""Test concurrent registration and usage of LLMs."""
|
|
import threading
|
|
|
|
errors = []
|
|
|
|
def register_and_use(name: str, response: str):
|
|
try:
|
|
llm = SlowLLM(response=response, delay=0.01)
|
|
register_llm(name, llm)
|
|
|
|
# Try to use it
|
|
retrieved = get_llm_from_registry(name)
|
|
assert retrieved is not None
|
|
except Exception as e:
|
|
errors.append(e)
|
|
|
|
# Start multiple threads
|
|
threads = []
|
|
for i in range(10):
|
|
t = threading.Thread(
|
|
target=register_and_use, args=(f"llm_{i}", f"Response {i}")
|
|
)
|
|
threads.append(t)
|
|
t.start()
|
|
|
|
# Wait for all to complete
|
|
for t in threads:
|
|
t.join()
|
|
|
|
assert len(errors) == 0
|
|
|
|
# Verify all were registered
|
|
for i in range(10):
|
|
assert get_llm_from_registry(f"llm_{i}") is not None
|
|
|
|
|
|
def test_provider_name_normalization(full_settings_snapshot):
|
|
"""Test that provider names are normalized correctly."""
|
|
llm = SlowLLM()
|
|
|
|
# Register with mixed case - the registry stores as-is
|
|
register_llm("MixedCase", llm)
|
|
|
|
# The registry is case-sensitive, so we need to register with lowercase too
|
|
register_llm("mixedcase", llm)
|
|
|
|
# Should be retrievable with lowercase
|
|
with patch(
|
|
"local_deep_research.config.llm_config.wrap_llm_without_think_tags"
|
|
) as mock_wrap:
|
|
mock_wrap.side_effect = lambda llm, **kwargs: llm
|
|
|
|
# The provider name should be normalized to lowercase
|
|
result = get_llm(
|
|
provider="mixedcase", settings_snapshot=full_settings_snapshot
|
|
)
|
|
assert isinstance(result, SlowLLM)
|
|
|
|
|
|
def test_factory_with_invalid_signature(full_settings_snapshot):
|
|
"""Test factory functions with invalid signatures."""
|
|
|
|
def bad_factory():
|
|
# Missing required parameters
|
|
return SlowLLM()
|
|
|
|
register_llm("bad_factory", bad_factory)
|
|
|
|
# Should raise error when trying to use with parameters. A settings
|
|
# snapshot is required so the egress-policy PEP (which fails closed for
|
|
# snapshot-less non-local providers) lets the call reach the factory
|
|
# dispatch, where the signature mismatch is what we are exercising.
|
|
with pytest.raises(TypeError):
|
|
get_llm(
|
|
provider="bad_factory",
|
|
model_name="test",
|
|
temperature=0.5,
|
|
settings_snapshot=full_settings_snapshot,
|
|
)
|
|
|
|
|
|
def test_llm_memory_cleanup():
|
|
"""Test that LLMs are properly cleaned up."""
|
|
import gc
|
|
import weakref
|
|
|
|
# Create an LLM and register it
|
|
llm = SlowLLM()
|
|
weak_ref = weakref.ref(llm)
|
|
register_llm("memory_test", llm)
|
|
|
|
# Delete the original reference
|
|
del llm
|
|
|
|
# Force garbage collection
|
|
gc.collect()
|
|
|
|
# The LLM should still exist because registry holds a reference
|
|
assert weak_ref() is not None
|
|
|
|
# Clear the registry
|
|
clear_llm_registry()
|
|
gc.collect()
|
|
|
|
# Now it should be garbage collected
|
|
assert weak_ref() is None
|
|
|
|
|
|
def test_llm_with_custom_attributes():
|
|
"""Test LLMs with custom attributes and methods."""
|
|
|
|
class CustomLLM(BaseChatModel):
|
|
custom_attr: str = Field(default="custom")
|
|
|
|
def custom_method(self):
|
|
return "custom_result"
|
|
|
|
def _generate(
|
|
self, messages: List[BaseMessage], **kwargs
|
|
) -> ChatResult:
|
|
message = AIMessage(content=f"Custom: {self.custom_attr}")
|
|
generation = ChatGeneration(message=message)
|
|
return ChatResult(generations=[generation])
|
|
|
|
@property
|
|
def _llm_type(self) -> str:
|
|
return "custom_attrs"
|
|
|
|
custom_llm = CustomLLM(custom_attr="test_value")
|
|
register_llm("custom_attrs", custom_llm)
|
|
|
|
retrieved = get_llm_from_registry("custom_attrs")
|
|
assert retrieved.custom_attr == "test_value"
|
|
assert retrieved.custom_method() == "custom_result"
|
|
|
|
|
|
def test_llm_state_persistence(full_settings_snapshot):
|
|
"""Test that LLM state persists across calls."""
|
|
|
|
class StatefulLLM(BaseChatModel):
|
|
call_count: int = Field(default=0)
|
|
history: List[str] = Field(default_factory=list)
|
|
|
|
def _generate(
|
|
self, messages: List[BaseMessage], **kwargs
|
|
) -> ChatResult:
|
|
self.call_count += 1
|
|
query = messages[-1].content if messages else "empty"
|
|
self.history.append(query)
|
|
|
|
message = AIMessage(content=f"Call {self.call_count}: {query}")
|
|
generation = ChatGeneration(message=message)
|
|
return ChatResult(generations=[generation])
|
|
|
|
@property
|
|
def _llm_type(self) -> str:
|
|
return "stateful"
|
|
|
|
stateful_llm = StatefulLLM()
|
|
register_llm("stateful", stateful_llm)
|
|
|
|
# Use it multiple times
|
|
with patch(
|
|
"local_deep_research.config.llm_config.wrap_llm_without_think_tags"
|
|
) as mock_wrap:
|
|
mock_wrap.side_effect = lambda llm, **kwargs: llm
|
|
|
|
# Provide settings_snapshot to avoid settings context error
|
|
llm1 = get_llm(
|
|
provider="stateful", settings_snapshot=full_settings_snapshot
|
|
)
|
|
llm2 = get_llm(
|
|
provider="stateful", settings_snapshot=full_settings_snapshot
|
|
)
|
|
|
|
# Should be the same instance
|
|
assert llm1 is llm2
|
|
|
|
# State should persist
|
|
from langchain_core.messages import HumanMessage
|
|
|
|
llm1._generate([HumanMessage(content="First")])
|
|
llm2._generate([HumanMessage(content="Second")])
|
|
|
|
assert stateful_llm.call_count == 2
|
|
assert stateful_llm.history == ["First", "Second"]
|