Files
cowork-local/tests/fakes/fake_provider.py
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## 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>
2026-08-31 05:15:13 +00:00

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