Skip to content

Commit e29efc9

Browse files
committed
refactor(ai): rename ModelBuilder to Ai, rebuild fake()/record() around model-level fakes
Ai (formerly ModelBuilder) drops the per-instance agent binding: fake_agent_models/ fake_agent_responses are class-level registries keyed by agent class name, and get_model_for()/build() take the agent as an argument instead of storing it in the constructor. Agent.fake() now registers a fixed, ordered list of replies as a deterministic chat model (via Ai.fake()) instead of binding a whole FakeAgent stand-in into the container. Replies flow through the real message-building/pipeline/tool- execution path, so faked tool calls actually execute — only the model at the bottom is swapped. Agent.record()/RecordingAgent/AgentBinding are unchanged. Drops the dead _match_fake()/self._fakes machinery on Agent (never populated by any code path) along with the now-unreachable FakeAgent/NoFakeResponse testing helpers.
1 parent 6e357c5 commit e29efc9

11 files changed

Lines changed: 199 additions & 216 deletions

File tree

example/agents/tests/features/test_chat_controller.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44

55

66
class TestChatController(TestCase):
7-
@RouterAgent.fake({"*hello*": "Hello there, This is no stream chat, Hope you are doing well."})
7+
@RouterAgent.fake(["Hello there, This is no stream chat, Hope you are doing well."])
88
async def test_it_responds_without_stream(self):
99
response = await self.post("/chat", json={"message": "hello"})
1010

1111
response.assert_ok()
1212
response.assert_contents("Hello there, This is no stream chat, Hope you are doing well.")
1313

14-
@RouterAgent.fake({"*hello*": "Hello there, This is no stream chat, Hope you are doing well."})
14+
@RouterAgent.fake(["Hello there, This is no stream chat, Hope you are doing well."])
1515
async def test_stream_assertions_are_rejected_on_a_buffered_response(self):
1616
response = await self.post("/chat", json={"message": "hello"})
1717

@@ -21,7 +21,7 @@ async def test_stream_assertions_are_rejected_on_a_buffered_response(self):
2121
with self.assertRaises(AssertionError):
2222
response.assert_stream("Hello there, This is no stream chat, Hope you are doing well.")
2323

24-
@RouterAgent.fake({"*hello*": "Hello there, This is stream chat, Hope you are doing well."})
24+
@RouterAgent.fake(["Hello there, This is stream chat, Hope you are doing well."])
2525
async def test_it_responds_with_stream(self):
2626
response = await self.post("/chat/stream", json={"message": "hello"})
2727

fastapi_startkit/src/fastapi_startkit/ai/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,22 @@
1010
from .fakes import fake_chat_model
1111
from .image import Image, ImageResponse
1212
from .image_factory import ImageFactory
13+
from .model_builder import Ai
1314
from .providers.ai_provider import AIProvider
1415
from .response import AgentResponse, AgentSnapshot
15-
from .testing import AgentBinding, FakeAgent, NoFakeResponse, RecordingAgent
16+
from .testing import AgentBinding, AgentModelFake, RecordingAgent
1617

1718
__all__ = [
1819
"Agent",
20+
"Ai",
1921
"Middleware",
2022
"AgentBinding",
23+
"AgentModelFake",
2124
"AgentResponse",
2225
"AgentSnapshot",
2326
"AIConfig",
2427
"AIProvider",
2528
"AnthropicConfig",
26-
"FakeAgent",
27-
"NoFakeResponse",
2829
"RecordingAgent",
2930
"Audio",
3031
"AudioResponse",

fastapi_startkit/src/fastapi_startkit/ai/agent.py

Lines changed: 8 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
from __future__ import annotations
22

3-
import fnmatch
43
from typing import TYPE_CHECKING, Any, AsyncIterator, Callable, Optional, Type
54

65
from .document import Document
7-
from .response import AgentResponse, AgentSnapshot
6+
from .response import AgentResponse
87
from .testing import AgentBinding
98

109
if TYPE_CHECKING:
1110
from langchain_core.tools import BaseTool
1211

12+
from .testing import AgentModelFake
13+
1314

1415
class Agent:
1516
provider: str | None = None
@@ -20,7 +21,6 @@ class Agent:
2021
top_p: float = 1.0
2122

2223
def __init__(self):
23-
self._fakes: dict[str, AgentResponse | AgentSnapshot] = {}
2424
self._call_log: list[dict] = []
2525

2626
def messages(self) -> list[dict]:
@@ -55,21 +55,6 @@ async def prompt(
5555
self._log_call("prompt", message)
5656
return self._apply_schema(response)
5757

58-
_run_kwargs = dict(
59-
model=model,
60-
attachments=attachments,
61-
provider_options=provider_options,
62-
)
63-
64-
match = self._match_fake(message)
65-
if match is not None:
66-
if isinstance(match, AgentSnapshot):
67-
response = await match.resolve(self, message, **_run_kwargs)
68-
else:
69-
response = match
70-
self._log_call("prompt", message)
71-
return self._apply_schema(response)
72-
7358
messages = self._build_messages(message, attachments)
7459
chat_model = self._build_model(model, provider_options)
7560

@@ -96,22 +81,14 @@ async def stream(
9681
yield response.content
9782
return
9883

99-
fake = self._match_fake(message)
100-
if fake is not None:
101-
if isinstance(fake, AgentSnapshot):
102-
response = await fake.resolve(self, message)
103-
else:
104-
response = fake
105-
yield response.content
106-
return
10784
async for chunk in self._stream(message, model=model, provider_options=provider_options):
10885
yield chunk
10986

11087
@classmethod
111-
def fake(cls, responses: dict | None = None) -> "AgentBinding":
112-
from .testing import AgentBinding, FakeAgent
88+
def fake(cls, responses: list) -> "AgentModelFake":
89+
from .testing import AgentModelFake
11390

114-
return AgentBinding(cls, FakeAgent(responses))
91+
return AgentModelFake(cls, responses)
11592

11693
@classmethod
11794
def record(cls, cassette: str | None = None) -> "AgentBinding":
@@ -146,16 +123,9 @@ def assert_not_prompted(self) -> None:
146123
self.assert_prompted(times=0)
147124

148125
def reset(self) -> "Agent":
149-
self._fakes.clear()
150126
self._call_log.clear()
151127
return self
152128

153-
def _match_fake(self, message: str) -> Optional[AgentResponse | AgentSnapshot]:
154-
for pattern, value in self._fakes.items():
155-
if fnmatch.fnmatch(message.lower(), pattern.lower()):
156-
return value
157-
return None
158-
159129
def _log_call(self, method: str, message: str) -> None:
160130
self._call_log.append({"method": method, "message": message})
161131

@@ -223,9 +193,9 @@ def _build_messages(
223193
return messages
224194

225195
def _build_model(self, model: str | None = None, provider_options: dict | None = None) -> Any:
226-
from .model_builder import ModelBuilder # noqa: PLC0415
196+
from .model_builder import Ai # noqa: PLC0415
227197

228-
return ModelBuilder(agent=self).get_model_for(model, provider_options)
198+
return Ai().get_model_for(self, model, provider_options)
229199

230200
def _to_agent_response(self, result: Any) -> AgentResponse:
231201
messages = result.get("messages", []) if isinstance(result, dict) else []

fastapi_startkit/src/fastapi_startkit/ai/model_builder.py

Lines changed: 36 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,19 @@
88
from .agent import Agent
99

1010

11-
class ModelBuilder:
11+
class Ai:
1212
# Keyed by agent class name (see _key()) so a fake can be registered
1313
# before any instance of that agent exists. get_model_for() consults
1414
# this registry, so a faked agent runs through the same message-building
1515
# / pipeline / tool-execution path as a real one — only the model at the
1616
# bottom is swapped for a deterministic stand-in.
17-
fake_models: dict[str, Any] = {}
18-
# Reserved for response-level fakes (mirroring fake_models, but for
17+
fake_agent_models: dict[str, Any] = {}
18+
# Reserved for response-level fakes (mirroring fake_agent_models, but for
1919
# cached final replies rather than whole chat models). Not yet wired up.
20-
fake_responses: dict[str, Any] = {}
20+
fake_agent_responses: dict[str, Any] = {}
2121

22-
def __init__(self, agent: "Agent") -> None:
23-
self._agent = agent
22+
def __init__(self) -> None:
23+
pass
2424

2525
@staticmethod
2626
def _key(agent: "Agent | str") -> str:
@@ -38,59 +38,63 @@ def fake(cls, agent: "Agent | str", messages: list) -> Any:
3838

3939
turns = [message if hasattr(message, "content") else AIMessage(content=str(message)) for message in messages]
4040
model = GenericFakeChatModel(messages=iter(turns))
41-
cls.fake_models[cls._key(agent)] = model
41+
cls.fake_agent_models[cls._key(agent)] = model
4242
return model
4343

4444
@classmethod
4545
def has_fake_model_for(cls, agent: "Agent | str") -> bool:
46-
return cls._key(agent) in cls.fake_models
46+
return cls._key(agent) in cls.fake_agent_models
4747

4848
@classmethod
4949
def get_fake_model_for(cls, agent: "Agent | str") -> Any:
50-
return cls.fake_models[cls._key(agent)]
50+
return cls.fake_agent_models[cls._key(agent)]
51+
52+
@classmethod
53+
def forget(cls, agent: "Agent | str") -> None:
54+
cls.fake_agent_models.pop(cls._key(agent), None)
5155

5256
@classmethod
5357
def reset_fakes(cls) -> None:
54-
cls.fake_models.clear()
55-
cls.fake_responses.clear()
58+
cls.fake_agent_models.clear()
59+
cls.fake_agent_responses.clear()
5660

57-
def get_model_for(self, model: str | None = None, provider_options: dict | None = None) -> Any:
61+
def get_model_for(self, agent: "Agent", model: str | None = None, provider_options: dict | None = None) -> Any:
5862
"""Resolve the model to run: a registered fake if one exists for
59-
this builder's agent, otherwise a freshly-built provider model."""
60-
if self.has_fake_model_for(self._agent):
61-
return self.get_fake_model_for(self._agent)
62-
return self.build(model, provider_options)
63+
``agent``, otherwise a freshly-built provider model."""
64+
if self.has_fake_model_for(agent):
65+
return self.get_fake_model_for(agent)
66+
return self.build(agent, model, provider_options)
6367

64-
def build(self, model: str | None = None, provider_options: dict | None = None) -> Any:
68+
def build(self, agent: "Agent", model: str | None = None, provider_options: dict | None = None) -> Any:
6569
from langchain.chat_models import init_chat_model # noqa: PLC0415
6670

67-
lab = Lab.get_provider(self._agent.provider)
71+
lab = Lab.get_provider(agent.provider)
6872
kwargs: dict[str, Any] = {"model_provider": lab.get_provider_key()}
6973

7074
api_key = lab.get_api_key()
7175
if api_key:
7276
kwargs["api_key"] = api_key
73-
if self._agent.max_tokens:
74-
kwargs["max_tokens"] = self._agent.max_tokens
75-
if self._agent.top_p != 1.0:
76-
kwargs["top_p"] = self._agent.top_p
77-
if self._agent.timeout:
78-
kwargs["timeout"] = self._agent.timeout
77+
if agent.max_tokens:
78+
kwargs["max_tokens"] = agent.max_tokens
79+
if agent.top_p != 1.0:
80+
kwargs["top_p"] = agent.top_p
81+
if agent.timeout:
82+
kwargs["timeout"] = agent.timeout
7983

80-
kwargs.update(self._resolve_provider_options(provider_options))
84+
kwargs.update(self._resolve_provider_options(agent, provider_options))
8185

82-
chat_model = init_chat_model(self._resolve_model(model), **kwargs)
86+
chat_model = init_chat_model(self._resolve_model(agent, model), **kwargs)
8387

84-
tools = list(self._agent.tools())
88+
tools = list(agent.tools())
8589
return chat_model.bind_tools(tools) if tools else chat_model
8690

87-
def _resolve_model(self, override: str | None = None) -> str:
88-
return Lab.get_provider(self._agent.provider).get_model(override or self._agent.model or None)
91+
def _resolve_model(self, agent: "Agent", override: str | None = None) -> str:
92+
return Lab.get_provider(agent.provider).get_model(override or agent.model or None)
8993

90-
def _resolve_provider_options(self, override: dict | None = None) -> dict:
91-
options = dict(self._agent.provider_options().get(self._agent.provider, {}))
94+
def _resolve_provider_options(self, agent: "Agent", override: dict | None = None) -> dict:
95+
options = dict(agent.provider_options().get(agent.provider, {}))
9296
if override:
93-
provider_specific = override.get(self._agent.provider, override)
97+
provider_specific = override.get(agent.provider, override)
9498
if isinstance(provider_specific, dict):
9599
options.update(provider_specific)
96100
return options

fastapi_startkit/src/fastapi_startkit/ai/testing.py

Lines changed: 35 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import hashlib
66
import inspect
77
import json
8-
import re
98
import sys
109
from collections.abc import AsyncIterator
1110
from pathlib import Path
@@ -18,23 +17,13 @@
1817
from .document import Document
1918

2019

21-
class NoFakeResponse(LookupError):
22-
pass
23-
24-
2520
def _matches(pattern: str, message: str) -> bool:
2621
pattern, message = pattern.lower(), message.lower()
2722
if any(ch in pattern for ch in "*?["):
2823
return fnmatch.fnmatch(message, pattern)
2924
return pattern in message
3025

3126

32-
def _reply_text(reply: Any) -> str:
33-
if isinstance(reply, AgentResponse):
34-
return reply.content
35-
return getattr(reply, "content", None) or str(reply)
36-
37-
3827
class _Recorder:
3928
def __init__(self) -> None:
4029
self.calls: list[str] = []
@@ -65,33 +54,46 @@ def _joined(value: Any) -> str:
6554
return "".join(value) if isinstance(value, list) else value
6655

6756

68-
def _word_chunks(text: str) -> list[str]:
69-
"""Split text into word chunks (word + trailing whitespace) so a fake can
70-
mimic a token stream. Loss-less: ``"".join(_word_chunks(t)) == t``."""
71-
return re.findall(r"\S+\s*", text) or [text]
57+
class AgentModelFake:
58+
"""Registers a fixed, ordered list of replies as ``agent_cls``'s chat
59+
model for the duration of a ``with`` block (or a decorated function).
7260
61+
Unlike the old pattern-matching stand-in, this swaps only the model —
62+
``prompt()``/``stream()`` still run the real message-building, pipeline,
63+
and tool-execution path; see ``Ai.fake()``.
64+
"""
7365

74-
class FakeAgent(_Recorder):
75-
def __init__(self, responses: dict[str, Any] | None = None) -> None:
76-
super().__init__()
77-
self.responses = responses or {}
66+
def __init__(self, agent_cls: type[Agent], responses: list) -> None:
67+
self._agent_cls = agent_cls
68+
self._responses = responses
7869

79-
def _resolve(self, message: str) -> str:
80-
if not self.responses:
81-
return ""
82-
for pattern, reply in self.responses.items():
83-
if _matches(pattern, message):
84-
return _reply_text(reply)
85-
raise NoFakeResponse(f"No fake response matched message: {message!r}")
70+
def __enter__(self) -> None:
71+
from .model_builder import Ai
8672

87-
async def prompt(self, message: str, attachments: list[Document] | None = None) -> AgentResponse:
88-
self._record_call(message, attachments)
89-
return AgentResponse(content=self._resolve(message))
73+
Ai.fake(self._agent_cls.__name__, self._responses)
9074

91-
async def stream(self, message: str) -> AsyncIterator[str]:
92-
self._record_call(message, None)
93-
for chunk in _word_chunks(self._resolve(message)):
94-
yield chunk
75+
def __exit__(self, *_exc: Any) -> bool:
76+
from .model_builder import Ai
77+
78+
Ai.forget(self._agent_cls.__name__)
79+
return False
80+
81+
def __call__(self, func: Callable) -> Callable:
82+
if inspect.iscoroutinefunction(func):
83+
84+
@functools.wraps(func)
85+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
86+
with self:
87+
return await func(*args, **kwargs)
88+
89+
return async_wrapper
90+
91+
@functools.wraps(func)
92+
def wrapper(*args: Any, **kwargs: Any) -> Any:
93+
with self:
94+
return func(*args, **kwargs)
95+
96+
return wrapper
9597

9698

9799
class RecordingAgent(_Recorder):

0 commit comments

Comments
 (0)