114 lines
4.3 KiB
Python
114 lines
4.3 KiB
Python
"""Fake LLM Provider for offline unit, contract, and characterization testing.
|
|
|
|
Provides deterministic responses, stream simulation, tool-call dispatching,
|
|
and fault injection without requiring any external network access or API keys.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
from providers.base import CancelFn, Provider, ProviderError, TextCallback, ToolSpec
|
|
|
|
|
|
class FakeProvider(Provider):
|
|
"""Deterministic test double mimicking real LLM Providers (OpenAI, Anthropic, Ollama)."""
|
|
|
|
name = "fake"
|
|
supports_vision = True
|
|
|
|
def __init__(self, conf: Optional[Dict[str, Any]] = None) -> None:
|
|
# Initialize base provider with default configuration if none provided
|
|
super().__init__(conf or {"model": "fake-model-v1"})
|
|
# History of all message batches sent across all chat calls
|
|
self.call_history: List[List[Dict[str, Any]]] = []
|
|
# Queue of programmed assistant responses to return sequentially
|
|
self.response_queue: List[Dict[str, Any]] = []
|
|
# Queue of exceptions to raise on corresponding calls
|
|
self.error_queue: List[Exception] = []
|
|
# Default text returned when response queue is empty
|
|
self.default_text: str = "Fake model response."
|
|
# Total number of chat invocations
|
|
self.call_count: int = 0
|
|
# Recorded tool specs passed into each turn
|
|
self.last_tools: Optional[List[ToolSpec]] = None
|
|
|
|
def queue_response(
|
|
self,
|
|
content: str = "",
|
|
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
|
reasoning: Optional[str] = None,
|
|
chunks: Optional[List[str]] = None,
|
|
) -> FakeProvider:
|
|
"""Enqueue a pre-configured response structure for upcoming chat turns."""
|
|
self.response_queue.append({
|
|
"content": content,
|
|
"tool_calls": tool_calls or [],
|
|
"reasoning": reasoning,
|
|
"chunks": chunks or ([content] if content else []),
|
|
})
|
|
return self
|
|
|
|
def queue_error(self, exc: Exception) -> FakeProvider:
|
|
"""Enqueue an exception to simulate network/API errors on the next turn."""
|
|
self.error_queue.append(exc)
|
|
return self
|
|
|
|
def chat(
|
|
self,
|
|
messages: List[Dict[str, Any]],
|
|
tools: Optional[List[ToolSpec]] = None,
|
|
on_text: Optional[TextCallback] = None,
|
|
cancel: Optional[CancelFn] = None,
|
|
on_reasoning: Optional[TextCallback] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Simulate single LLM turn with full streaming and tool-call support."""
|
|
self.call_count += 1
|
|
self.call_history.append([dict(m) for m in messages])
|
|
self.last_tools = tools
|
|
|
|
# 1. Check for injected errors
|
|
if self.error_queue:
|
|
raise self.error_queue.pop(0)
|
|
|
|
# 2. Check early cancellation before processing
|
|
if cancel and cancel():
|
|
raise ProviderError("Execution aborted by user cancel signal before response generation.")
|
|
|
|
# 3. Retrieve queued response or construct default response
|
|
if self.response_queue:
|
|
resp_spec = self.response_queue.pop(0)
|
|
content = resp_spec.get("content", "")
|
|
tool_calls = resp_spec.get("tool_calls", [])
|
|
reasoning = resp_spec.get("reasoning")
|
|
chunks = resp_spec.get("chunks", [content] if content else [])
|
|
else:
|
|
content = self.default_text
|
|
tool_calls = []
|
|
reasoning = None
|
|
chunks = [content]
|
|
|
|
# 4. Stream reasoning chunks if provided
|
|
if reasoning and on_reasoning:
|
|
on_reasoning(reasoning)
|
|
|
|
# 5. Stream text chunks, checking cancellation between fragments
|
|
for chunk in chunks:
|
|
if cancel and cancel():
|
|
raise ProviderError("Execution cancelled during text chunk streaming.")
|
|
if on_text and chunk:
|
|
on_text(chunk)
|
|
|
|
# 6. Return canonical assistant message payload
|
|
assistant_msg: Dict[str, Any] = {
|
|
"role": "assistant",
|
|
"content": content,
|
|
}
|
|
if tool_calls:
|
|
assistant_msg["tool_calls"] = tool_calls
|
|
|
|
return assistant_msg
|
|
|
|
def list_models(self) -> List[str]:
|
|
"""Return available mock models for settings and validation tests."""
|
|
return ["fake-model-v1", "fake-reasoner-pro", "fake-vision-plus"]
|