Files
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

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