CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
213 lines
6.6 KiB
Python
213 lines
6.6 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
|
|
|
|
import itertools
|
|
from dataclasses import dataclass
|
|
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
|
|
try:
|
|
from providers.base import (
|
|
CancelFn,
|
|
Provider,
|
|
ProviderError,
|
|
TextCallback,
|
|
ToolSpec,
|
|
)
|
|
except ImportError:
|
|
from cowork_local.providers.base import (
|
|
CancelFn,
|
|
Provider,
|
|
ProviderError,
|
|
TextCallback,
|
|
ToolSpec,
|
|
)
|
|
|
|
ToolCallScript = Tuple[str, Dict[str, Any]]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ScriptedTurn:
|
|
"""What :class:`FakeProvider` should do for ONE ``chat()`` call."""
|
|
|
|
text: str = ""
|
|
reasoning: str = ""
|
|
tool_calls: Sequence[Any] = ()
|
|
error: Optional[str] = None
|
|
chunk_size: int = 0
|
|
|
|
|
|
@dataclass
|
|
class RecordedCall:
|
|
"""A snapshot of one ``chat()`` invocation, for assertions after the fact."""
|
|
|
|
messages: List[Dict[str, Any]]
|
|
tool_names: List[str]
|
|
cancelled: bool = False
|
|
|
|
|
|
class FakeProvider(Provider):
|
|
"""Deterministic test double mimicking real LLM Providers."""
|
|
|
|
name = "fake"
|
|
supports_vision = True
|
|
|
|
def __init__(
|
|
self,
|
|
turns: Optional[Sequence[ScriptedTurn]] = None,
|
|
*,
|
|
model: str = "fake-model",
|
|
models: Optional[Sequence[str]] = None,
|
|
strict: bool = False,
|
|
conf: Optional[Dict[str, Any]] = None,
|
|
) -> None:
|
|
super().__init__(dict(conf or {}, model=model))
|
|
self._turns: List[ScriptedTurn] = list(turns or [])
|
|
self._models = list(models or [model])
|
|
self._strict = strict
|
|
self._ids = itertools.count(1)
|
|
self.calls: List[RecordedCall] = []
|
|
self.call_history: List[List[Dict[str, Any]]] = []
|
|
self.response_queue: List[Dict[str, Any]] = []
|
|
self.error_queue: List[Exception] = []
|
|
self.default_text: str = "Fake model response."
|
|
self.last_tools: Optional[List[ToolSpec]] = None
|
|
|
|
@property
|
|
def call_count(self) -> int:
|
|
return len(self.calls)
|
|
|
|
@property
|
|
def remaining_turns(self) -> int:
|
|
return len(self._turns) + len(self.response_queue)
|
|
|
|
def last_messages(self) -> List[Dict[str, Any]]:
|
|
return self.calls[-1].messages if self.calls else []
|
|
|
|
def queue_response(
|
|
self,
|
|
content: str = "",
|
|
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
|
reasoning: Optional[str] = None,
|
|
chunks: Optional[List[str]] = None,
|
|
) -> "FakeProvider":
|
|
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":
|
|
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]:
|
|
self.last_tools = tools
|
|
self.call_history.append([dict(m) for m in messages])
|
|
record = RecordedCall(
|
|
messages=[dict(m) for m in messages],
|
|
tool_names=[t.name for t in (tools or [])],
|
|
)
|
|
self.calls.append(record)
|
|
|
|
if cancel is not None and cancel():
|
|
record.cancelled = True
|
|
raise ProviderError("Execution aborted by user cancel")
|
|
|
|
if self.error_queue:
|
|
err = self.error_queue.pop(0)
|
|
raise err
|
|
|
|
if self.response_queue:
|
|
resp = self.response_queue.pop(0)
|
|
if resp.get("reasoning") and on_reasoning:
|
|
on_reasoning(resp["reasoning"])
|
|
for chunk in resp.get("chunks", []):
|
|
if cancel is not None and cancel():
|
|
record.cancelled = True
|
|
raise ProviderError("Execution aborted by user cancel")
|
|
if on_text:
|
|
on_text(chunk)
|
|
out = {
|
|
"role": "assistant",
|
|
"content": resp.get("content", ""),
|
|
}
|
|
if resp.get("tool_calls"):
|
|
out["tool_calls"] = resp["tool_calls"]
|
|
return out
|
|
|
|
if self._turns:
|
|
turn = self._turns.pop(0)
|
|
if self._is_cancelled(cancel):
|
|
record.cancelled = True
|
|
return {"role": "assistant", "content": ""}
|
|
if turn.error:
|
|
raise ProviderError(turn.error)
|
|
if turn.reasoning and on_reasoning:
|
|
on_reasoning(turn.reasoning)
|
|
for piece in self._stream_pieces(turn):
|
|
if self._is_cancelled(cancel):
|
|
record.cancelled = True
|
|
break
|
|
if on_text:
|
|
on_text(piece)
|
|
|
|
formatted_tool_calls = []
|
|
for tc in turn.tool_calls:
|
|
if isinstance(tc, dict):
|
|
formatted_tool_calls.append(tc)
|
|
elif isinstance(tc, (tuple, list)) and len(tc) == 2:
|
|
formatted_tool_calls.append({
|
|
"id": f"call_{next(self._ids)}",
|
|
"name": tc[0],
|
|
"arguments": dict(tc[1]),
|
|
})
|
|
out = {
|
|
"role": "assistant",
|
|
"content": turn.text,
|
|
}
|
|
if formatted_tool_calls:
|
|
out["tool_calls"] = formatted_tool_calls
|
|
return out
|
|
|
|
if self._strict:
|
|
raise AssertionError(
|
|
f"FakeProvider script exhausted: chat() was called {len(self.calls)} "
|
|
"time(s) but fewer turns were scripted."
|
|
)
|
|
|
|
if on_text:
|
|
on_text(self.default_text)
|
|
return {
|
|
"role": "assistant",
|
|
"content": self.default_text,
|
|
}
|
|
|
|
def list_models(self) -> List[str]:
|
|
self.last_error = ""
|
|
return list(self._models)
|
|
|
|
@staticmethod
|
|
def _stream_pieces(turn: ScriptedTurn) -> List[str]:
|
|
if not turn.text:
|
|
return []
|
|
if turn.chunk_size <= 0:
|
|
return [turn.text]
|
|
size = turn.chunk_size
|
|
return [turn.text[i:i + size] for i in range(0, len(turn.text), size)]
|
|
|
|
|
|
__all__ = ["FakeProvider", "ScriptedTurn", "RecordedCall"]
|