## 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>
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user