pytest-fakellm 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ .DS_Store
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .pytest_cache/
8
+ .venv/
9
+ venv/
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-fakellm
3
+ Version: 0.1.0
4
+ Summary: Pytest fixtures for the fakellm mock OpenAI/Anthropic server — spin up, reset, and assert with zero boilerplate.
5
+ Project-URL: Homepage, https://github.com/1dg618/pytest-fakellm
6
+ Project-URL: Repository, https://github.com/1dg618/pytest-fakellm
7
+ Project-URL: Issues, https://github.com/1dg618/pytest-fakellm/issues
8
+ Author-email: Douglas Gregor <1dg618@gmail.com>
9
+ License: MIT
10
+ Keywords: anthropic,fakellm,fixtures,llm,mock,openai,pytest,testing
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: Pytest
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Testing
21
+ Classifier: Topic :: Software Development :: Testing :: Mocking
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: fakellm>=0.3.1
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: pytest>=7.0
26
+ Provides-Extra: anthropic
27
+ Requires-Dist: anthropic>=0.34; extra == 'anthropic'
28
+ Provides-Extra: dev
29
+ Requires-Dist: anthropic>=0.34; extra == 'dev'
30
+ Requires-Dist: openai>=1.40; extra == 'dev'
31
+ Provides-Extra: openai
32
+ Requires-Dist: openai>=1.40; extra == 'openai'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # pytest-fakellm
36
+
37
+ Pytest fixtures for [fakellm](https://github.com/dgregor/fakellm), the mock
38
+ OpenAI/Anthropic server. Spin up a server, get a clean state per test, and
39
+ assert on what your code sent — with zero boilerplate.
40
+
41
+ ```bash
42
+ pip install pytest-fakellm
43
+ ```
44
+
45
+ Once installed, the fixtures are available automatically — no imports, no
46
+ `conftest.py` setup.
47
+
48
+ ## The point
49
+
50
+ Without the plugin, using fakellm in a test means starting the server, wiring a
51
+ client to its URL, resetting state, and tearing it all down yourself, in every
52
+ test. With the plugin, that becomes:
53
+
54
+ ```python
55
+ def test_agent_handles_search(fakellm):
56
+ fakellm.set_config_text("""
57
+ version: 1
58
+ rules:
59
+ - name: summarize
60
+ when: { messages_contain: "research" }
61
+ respond: { content: "Based on the search, I found what you were looking for." }
62
+ """)
63
+ result = run_my_agent(fakellm.openai_client(), prompt="Please research fakellm")
64
+ assert "found what you were looking for" in result
65
+ assert fakellm.request_count >= 1
66
+ ```
67
+
68
+ The server starts once per session, state is reset before each test, and
69
+ everything is torn down at the end. You never touch a port number or a
70
+ subprocess.
71
+
72
+ ## Fixtures
73
+
74
+ | Fixture | What you get |
75
+ |---|---|
76
+ | `fakellm` | A `FakellmServer` handle with fresh conversation state for the test. |
77
+ | `fakellm_openai` | A ready `openai.OpenAI` client pointed at the (reset) server. |
78
+ | `fakellm_anthropic` | A ready `anthropic.Anthropic` client pointed at the (reset) server. |
79
+
80
+ ### `FakellmServer` handle
81
+
82
+ - `openai_client(**kwargs)` / `anthropic_client(**kwargs)` — clients pointed at the server.
83
+ - `openai_base_url` / `anthropic_base_url` — raw URLs if you build your own client.
84
+ - `set_config_text(yaml)` — write rules inline and reload.
85
+ - `load_rules(path)` — load rules from a file and reload.
86
+ - `reset()` — clear conversation state (done for you between tests).
87
+ - `reload()` — re-read the config from disk.
88
+ - `stats()` / `conversations()` — the admin JSON, for assertions.
89
+ - `request_count` — convenience count of requests seen.
90
+
91
+ ## Configuration
92
+
93
+ Set a starting config file via the command line:
94
+
95
+ ```bash
96
+ pytest --fakellm-config=tests/fixtures/rules.yaml
97
+ ```
98
+
99
+ or in `pyproject.toml` / `pytest.ini`:
100
+
101
+ ```toml
102
+ [tool.pytest.ini_options]
103
+ fakellm_config = "tests/fixtures/rules.yaml"
104
+ ```
105
+
106
+ If you don't set one, a temporary empty config is created so `set_config_text`
107
+ and `load_rules` work immediately.
108
+
109
+ `--fakellm-startup-timeout` (default `10.0`) controls how long the fixture waits
110
+ for the server to come up.
111
+
112
+ ## Client extras
113
+
114
+ `openai_client()` and `anthropic_client()` require the respective SDKs. Install
115
+ what you need:
116
+
117
+ ```bash
118
+ pip install "pytest-fakellm[openai]" # adds openai
119
+ pip install "pytest-fakellm[anthropic]" # adds anthropic
120
+ ```
121
+
122
+ ## License
123
+
124
+ MIT
@@ -0,0 +1,90 @@
1
+ # pytest-fakellm
2
+
3
+ Pytest fixtures for [fakellm](https://github.com/dgregor/fakellm), the mock
4
+ OpenAI/Anthropic server. Spin up a server, get a clean state per test, and
5
+ assert on what your code sent — with zero boilerplate.
6
+
7
+ ```bash
8
+ pip install pytest-fakellm
9
+ ```
10
+
11
+ Once installed, the fixtures are available automatically — no imports, no
12
+ `conftest.py` setup.
13
+
14
+ ## The point
15
+
16
+ Without the plugin, using fakellm in a test means starting the server, wiring a
17
+ client to its URL, resetting state, and tearing it all down yourself, in every
18
+ test. With the plugin, that becomes:
19
+
20
+ ```python
21
+ def test_agent_handles_search(fakellm):
22
+ fakellm.set_config_text("""
23
+ version: 1
24
+ rules:
25
+ - name: summarize
26
+ when: { messages_contain: "research" }
27
+ respond: { content: "Based on the search, I found what you were looking for." }
28
+ """)
29
+ result = run_my_agent(fakellm.openai_client(), prompt="Please research fakellm")
30
+ assert "found what you were looking for" in result
31
+ assert fakellm.request_count >= 1
32
+ ```
33
+
34
+ The server starts once per session, state is reset before each test, and
35
+ everything is torn down at the end. You never touch a port number or a
36
+ subprocess.
37
+
38
+ ## Fixtures
39
+
40
+ | Fixture | What you get |
41
+ |---|---|
42
+ | `fakellm` | A `FakellmServer` handle with fresh conversation state for the test. |
43
+ | `fakellm_openai` | A ready `openai.OpenAI` client pointed at the (reset) server. |
44
+ | `fakellm_anthropic` | A ready `anthropic.Anthropic` client pointed at the (reset) server. |
45
+
46
+ ### `FakellmServer` handle
47
+
48
+ - `openai_client(**kwargs)` / `anthropic_client(**kwargs)` — clients pointed at the server.
49
+ - `openai_base_url` / `anthropic_base_url` — raw URLs if you build your own client.
50
+ - `set_config_text(yaml)` — write rules inline and reload.
51
+ - `load_rules(path)` — load rules from a file and reload.
52
+ - `reset()` — clear conversation state (done for you between tests).
53
+ - `reload()` — re-read the config from disk.
54
+ - `stats()` / `conversations()` — the admin JSON, for assertions.
55
+ - `request_count` — convenience count of requests seen.
56
+
57
+ ## Configuration
58
+
59
+ Set a starting config file via the command line:
60
+
61
+ ```bash
62
+ pytest --fakellm-config=tests/fixtures/rules.yaml
63
+ ```
64
+
65
+ or in `pyproject.toml` / `pytest.ini`:
66
+
67
+ ```toml
68
+ [tool.pytest.ini_options]
69
+ fakellm_config = "tests/fixtures/rules.yaml"
70
+ ```
71
+
72
+ If you don't set one, a temporary empty config is created so `set_config_text`
73
+ and `load_rules` work immediately.
74
+
75
+ `--fakellm-startup-timeout` (default `10.0`) controls how long the fixture waits
76
+ for the server to come up.
77
+
78
+ ## Client extras
79
+
80
+ `openai_client()` and `anthropic_client()` require the respective SDKs. Install
81
+ what you need:
82
+
83
+ ```bash
84
+ pip install "pytest-fakellm[openai]" # adds openai
85
+ pip install "pytest-fakellm[anthropic]" # adds anthropic
86
+ ```
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "pytest-fakellm"
3
+ version = "0.1.0"
4
+ description = "Pytest fixtures for the fakellm mock OpenAI/Anthropic server — spin up, reset, and assert with zero boilerplate."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Douglas Gregor", email = "1dg618@gmail.com" }]
9
+ keywords = ["pytest", "fakellm", "llm", "testing", "mock", "openai", "anthropic", "fixtures"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Framework :: Pytest",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Software Development :: Testing",
21
+ "Topic :: Software Development :: Testing :: Mocking",
22
+ ]
23
+ dependencies = [
24
+ "pytest>=7.0",
25
+ "httpx>=0.27",
26
+ "fakellm>=0.3.1",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ openai = ["openai>=1.40"]
31
+ anthropic = ["anthropic>=0.34"]
32
+ dev = [
33
+ "openai>=1.40",
34
+ "anthropic>=0.34",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/1dg618/pytest-fakellm"
39
+ Repository = "https://github.com/1dg618/pytest-fakellm"
40
+ Issues = "https://github.com/1dg618/pytest-fakellm/issues"
41
+
42
+ # This is the line that makes the whole thing work: pytest discovers installed
43
+ # plugins via the "pytest11" entry-point group. Once this package is installed,
44
+ # the fixtures in pytest_fakellm.plugin are available with no imports.
45
+ [project.entry-points.pytest11]
46
+ fakellm = "pytest_fakellm.plugin"
47
+
48
+ [build-system]
49
+ requires = ["hatchling"]
50
+ build-backend = "hatchling.build"
51
+
52
+ [tool.hatch.build.targets.wheel]
53
+ packages = ["src/pytest_fakellm"]
54
+
55
+ [tool.pytest.ini_options]
56
+ testpaths = ["tests"]
@@ -0,0 +1,8 @@
1
+ """pytest-fakellm: pytest fixtures for the fakellm mock LLM server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .server import FakellmServer
6
+
7
+ __all__ = ["FakellmServer"]
8
+ __version__ = "0.1.0"
@@ -0,0 +1,130 @@
1
+ """pytest-fakellm — pytest fixtures for the fakellm mock LLM server.
2
+
3
+ Installing this package makes the fixtures below available in any test, with no
4
+ imports or registration required (pytest auto-discovers the plugin via the
5
+ ``pytest11`` entry point declared in pyproject.toml).
6
+
7
+ Typical use::
8
+
9
+ def test_agent(fakellm):
10
+ fakellm.set_config_text(MY_RULES_YAML)
11
+ result = run_my_agent(fakellm.openai_client(), prompt="research fakellm")
12
+ assert "found what you were looking for" in result
13
+
14
+ Fixtures
15
+ --------
16
+ fakellm
17
+ Session-scoped server, automatically reset before each test that uses it.
18
+ fakellm_openai
19
+ Convenience: a ready ``openai.OpenAI`` client pointed at the server.
20
+ fakellm_anthropic
21
+ Convenience: a ready ``anthropic.Anthropic`` client pointed at the server.
22
+
23
+ Configuration
24
+ -------------
25
+ The server's starting config file can be set with the ``--fakellm-config``
26
+ command-line option or the ``fakellm_config`` ini option. If neither is set, a
27
+ temporary empty config is created so that ``set_config_text`` / ``load_rules``
28
+ work out of the box.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from pathlib import Path
34
+ from typing import Any, Iterator
35
+
36
+ import pytest
37
+
38
+ from .server import FakellmServer
39
+
40
+ __all__ = ["FakellmServer", "fakellm", "fakellm_openai", "fakellm_anthropic"]
41
+
42
+
43
+ # -- options ---------------------------------------------------------------
44
+
45
+
46
+ def pytest_addoption(parser: pytest.Parser) -> None:
47
+ group = parser.getgroup("fakellm")
48
+ group.addoption(
49
+ "--fakellm-config",
50
+ action="store",
51
+ default=None,
52
+ help="Path to a fakellm YAML config to start the server with.",
53
+ )
54
+ group.addoption(
55
+ "--fakellm-startup-timeout",
56
+ action="store",
57
+ type=float,
58
+ default=10.0,
59
+ help="Seconds to wait for the fakellm server to become ready.",
60
+ )
61
+ parser.addini(
62
+ "fakellm_config",
63
+ help="Path to a fakellm YAML config to start the server with.",
64
+ default=None,
65
+ )
66
+
67
+
68
+ def _resolve_config(request: pytest.FixtureRequest) -> str | None:
69
+ cli = request.config.getoption("--fakellm-config")
70
+ if cli:
71
+ return cli
72
+ ini = request.config.getini("fakellm_config")
73
+ return ini or None
74
+
75
+
76
+ # -- fixtures --------------------------------------------------------------
77
+
78
+
79
+ @pytest.fixture(scope="session")
80
+ def fakellm_server(
81
+ request: pytest.FixtureRequest,
82
+ tmp_path_factory: pytest.TempPathFactory,
83
+ ) -> Iterator[FakellmServer]:
84
+ """Session-scoped, *unreset* server handle.
85
+
86
+ Starts one fakellm process for the whole test session and tears it down at
87
+ the end. Most tests should use the ``fakellm`` fixture instead, which layers
88
+ automatic per-test state reset on top of this.
89
+ """
90
+ config = _resolve_config(request)
91
+ if config is None:
92
+ # Give the server a writable config file so set_config_text/load_rules
93
+ # have somewhere to write, even when the user didn't supply one.
94
+ config_path = tmp_path_factory.mktemp("fakellm") / "fakellm.yaml"
95
+ config_path.write_text("version: 1\nrules: []\n")
96
+ config = str(config_path)
97
+
98
+ timeout = request.config.getoption("--fakellm-startup-timeout")
99
+ server = FakellmServer(config=config, startup_timeout=timeout)
100
+ server.start()
101
+ try:
102
+ yield server
103
+ finally:
104
+ server.stop()
105
+
106
+
107
+ @pytest.fixture
108
+ def fakellm(fakellm_server: FakellmServer) -> Iterator[FakellmServer]:
109
+ """The main fixture: a server handle with fresh state for each test.
110
+
111
+ The underlying process is shared across the session (fast), but conversation
112
+ state is cleared before the test body runs, so tests are isolated from one
113
+ another regardless of order.
114
+ """
115
+ fakellm_server.reset()
116
+ yield fakellm_server
117
+ # No teardown reset needed: the next test resets before it runs. Leaving
118
+ # state intact after a failure can also aid debugging.
119
+
120
+
121
+ @pytest.fixture
122
+ def fakellm_openai(fakellm: FakellmServer) -> Any:
123
+ """A ready-to-use ``openai.OpenAI`` client pointed at the reset server."""
124
+ return fakellm.openai_client()
125
+
126
+
127
+ @pytest.fixture
128
+ def fakellm_anthropic(fakellm: FakellmServer) -> Any:
129
+ """A ready-to-use ``anthropic.Anthropic`` client pointed at the reset server."""
130
+ return fakellm.anthropic_client()
@@ -0,0 +1,256 @@
1
+ """Server lifecycle management and the user-facing control handle.
2
+
3
+ This module knows how to launch a ``fakellm`` server as a subprocess, wait for
4
+ it to become healthy, and talk to its documented admin endpoints
5
+ (``/_fakellm/reset``, ``/_fakellm/reload``, ``/_fakellm/stats``). It deliberately
6
+ treats fakellm as a black box driven through its public CLI and HTTP surface,
7
+ so the plugin does not couple to fakellm's internals.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import shutil
14
+ import socket
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import httpx
22
+
23
+
24
+ def _free_port() -> int:
25
+ """Ask the OS for an unused TCP port, then release it for fakellm to claim.
26
+
27
+ There is a small race between releasing the port and fakellm binding it,
28
+ but in practice it is negligible for a test fixture and far simpler than
29
+ handing an open socket to a subprocess.
30
+ """
31
+ with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
32
+ s.bind(("127.0.0.1", 0))
33
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
34
+ return s.getsockname()[1]
35
+
36
+
37
+ class FakellmServer:
38
+ """A running fakellm instance plus helpers for using it from a test.
39
+
40
+ Instances are created by the plugin's fixtures; you normally interact with
41
+ one through the ``fakellm`` fixture rather than constructing it yourself.
42
+
43
+ Attributes:
44
+ host: Host the server is bound to.
45
+ port: Port the server is bound to.
46
+ base_url: Root URL of the server, e.g. ``http://127.0.0.1:9999``.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ *,
52
+ config: str | Path | None = None,
53
+ host: str = "127.0.0.1",
54
+ port: int | None = None,
55
+ startup_timeout: float = 10.0,
56
+ executable: str | None = None,
57
+ ) -> None:
58
+ self.host = host
59
+ self.port = port or _free_port()
60
+ self._config = Path(config) if config is not None else None
61
+ self._startup_timeout = startup_timeout
62
+ # Launch via the module entry point by default so the plugin works even
63
+ # when the ``fakellm`` console script is not on PATH (e.g. some CI venvs).
64
+ self._executable = executable
65
+ self._proc: subprocess.Popen[bytes] | None = None
66
+ self._client = httpx.Client(base_url=self.base_url, timeout=10.0)
67
+
68
+ # -- URLs ---------------------------------------------------------------
69
+
70
+ @property
71
+ def base_url(self) -> str:
72
+ return f"http://{self.host}:{self.port}"
73
+
74
+ @property
75
+ def openai_base_url(self) -> str:
76
+ """Base URL to hand to an OpenAI client (note the ``/v1`` suffix)."""
77
+ return f"{self.base_url}/v1"
78
+
79
+ @property
80
+ def anthropic_base_url(self) -> str:
81
+ """Base URL to hand to an Anthropic client."""
82
+ return self.base_url
83
+
84
+ # -- lifecycle ----------------------------------------------------------
85
+
86
+ def _command(self) -> list[str]:
87
+ if self._executable:
88
+ cmd = [self._executable, "serve"]
89
+ else:
90
+ script = shutil.which("fakellm")
91
+ if script:
92
+ cmd = [script, "serve"]
93
+ else:
94
+ cmd = [sys.executable, "-m", "fakellm", "serve"]
95
+ cmd += ["--port", str(self.port)]
96
+ if self._config is not None:
97
+ cmd += ["--config", str(self._config)]
98
+ return cmd
99
+
100
+ def start(self) -> None:
101
+ """Launch the server subprocess and block until it is accepting requests."""
102
+ if self._proc is not None:
103
+ return
104
+ self._proc = subprocess.Popen(
105
+ self._command(),
106
+ stdout=subprocess.PIPE,
107
+ stderr=subprocess.STDOUT,
108
+ )
109
+ self._wait_until_ready()
110
+
111
+ def _wait_until_ready(self) -> None:
112
+ deadline = time.monotonic() + self._startup_timeout
113
+ last_err: Exception | None = None
114
+ while time.monotonic() < deadline:
115
+ # If the process died during startup, surface its output immediately
116
+ # rather than waiting out the full timeout.
117
+ if self._proc is not None and self._proc.poll() is not None:
118
+ output = b""
119
+ if self._proc.stdout is not None:
120
+ output = self._proc.stdout.read()
121
+ raise RuntimeError(
122
+ "fakellm server exited during startup "
123
+ f"(code {self._proc.returncode}). Command: "
124
+ f"{' '.join(self._command())}\n"
125
+ f"Output:\n{output.decode(errors='replace')}"
126
+ )
127
+ try:
128
+ resp = self._client.get("/_fakellm/stats")
129
+ if resp.status_code < 500:
130
+ return
131
+ except httpx.HTTPError as exc: # not up yet
132
+ last_err = exc
133
+ time.sleep(0.05)
134
+ raise RuntimeError(
135
+ f"fakellm server did not become ready within {self._startup_timeout}s "
136
+ f"at {self.base_url}. Last error: {last_err!r}"
137
+ )
138
+
139
+ def stop(self) -> None:
140
+ """Terminate the server subprocess and close the admin client."""
141
+ self._client.close()
142
+ if self._proc is None:
143
+ return
144
+ self._proc.terminate()
145
+ try:
146
+ self._proc.wait(timeout=5.0)
147
+ except subprocess.TimeoutExpired:
148
+ self._proc.kill()
149
+ self._proc.wait(timeout=5.0)
150
+ self._proc = None
151
+
152
+ # -- admin operations ---------------------------------------------------
153
+
154
+ def reset(self) -> None:
155
+ """Clear all conversation state (POST ``/_fakellm/reset``).
156
+
157
+ Stats and request history are preserved, matching fakellm's documented
158
+ behavior. Called automatically between tests by the ``fakellm`` fixture.
159
+ """
160
+ self._client.post("/_fakellm/reset").raise_for_status()
161
+
162
+ def reload(self) -> None:
163
+ """Re-read the YAML config from disk (POST ``/_fakellm/reload``)."""
164
+ self._client.post("/_fakellm/reload").raise_for_status()
165
+
166
+ def load_rules(self, config: str | Path) -> None:
167
+ """Point the server at a new config file and reload it.
168
+
169
+ This rewrites the path the server was started with by writing the given
170
+ config into place is *not* done here; instead it relies on fakellm's
171
+ ``--config`` having been set to a path the test controls. For ad-hoc
172
+ rules use :meth:`set_config_text`.
173
+ """
174
+ path = Path(config)
175
+ if self._config is None:
176
+ raise RuntimeError(
177
+ "This server was not started with a config path, so load_rules "
178
+ "cannot point it at a file. Use the `fakellm_config` fixture to "
179
+ "set a config path, or call set_config_text()."
180
+ )
181
+ # Copy the provided file's contents into the active config path.
182
+ Path(self._config).write_text(path.read_text())
183
+ self.reload()
184
+
185
+ def set_config_text(self, yaml_text: str) -> None:
186
+ """Write raw YAML into the active config file and reload.
187
+
188
+ Requires the server to have been started with a config path (the
189
+ ``fakellm_config`` fixture does this for you).
190
+ """
191
+ if self._config is None:
192
+ raise RuntimeError(
193
+ "This server was not started with a config path. Use the "
194
+ "`fakellm_config` fixture (or pass config=...) so there is a "
195
+ "file to write to."
196
+ )
197
+ Path(self._config).write_text(yaml_text)
198
+ self.reload()
199
+
200
+ def stats(self) -> dict[str, Any]:
201
+ """Return the server's stats JSON (GET ``/_fakellm/stats``)."""
202
+ resp = self._client.get("/_fakellm/stats")
203
+ resp.raise_for_status()
204
+ return resp.json()
205
+
206
+ def conversations(self) -> dict[str, Any]:
207
+ """Return per-conversation turn/tool-result info (GET ``/_fakellm/conversations``)."""
208
+ resp = self._client.get("/_fakellm/conversations")
209
+ resp.raise_for_status()
210
+ return resp.json()
211
+
212
+ @property
213
+ def request_count(self) -> int:
214
+ """Convenience accessor: number of requests seen, from stats."""
215
+ data = self.stats()
216
+ # Be tolerant of the exact stats shape; fall back to len(recent).
217
+ for key in ("request_count", "requests", "total_requests"):
218
+ if isinstance(data.get(key), int):
219
+ return data[key]
220
+ recent = data.get("recent_requests") or data.get("recent") or []
221
+ return len(recent) if isinstance(recent, list) else 0
222
+
223
+ # -- client helpers -----------------------------------------------------
224
+
225
+ def openai_client(self, **kwargs: Any) -> Any:
226
+ """Return an ``openai.OpenAI`` client pointed at this server.
227
+
228
+ Requires the ``openai`` package to be installed. Extra kwargs are passed
229
+ through to the ``OpenAI(...)`` constructor.
230
+ """
231
+ try:
232
+ from openai import OpenAI
233
+ except ImportError as exc: # pragma: no cover - depends on user env
234
+ raise RuntimeError(
235
+ "openai is not installed. Install it (e.g. `pip install openai`) "
236
+ "to use fakellm.openai_client()."
237
+ ) from exc
238
+ kwargs.setdefault("base_url", self.openai_base_url)
239
+ kwargs.setdefault("api_key", "not-used")
240
+ return OpenAI(**kwargs)
241
+
242
+ def anthropic_client(self, **kwargs: Any) -> Any:
243
+ """Return an ``anthropic.Anthropic`` client pointed at this server.
244
+
245
+ Requires the ``anthropic`` package to be installed.
246
+ """
247
+ try:
248
+ from anthropic import Anthropic
249
+ except ImportError as exc: # pragma: no cover - depends on user env
250
+ raise RuntimeError(
251
+ "anthropic is not installed. Install it (e.g. `pip install "
252
+ "anthropic`) to use fakellm.anthropic_client()."
253
+ ) from exc
254
+ kwargs.setdefault("base_url", self.anthropic_base_url)
255
+ kwargs.setdefault("api_key", "not-used")
256
+ return Anthropic(**kwargs)
@@ -0,0 +1,74 @@
1
+ """Tests that use the plugin exactly as an end user would.
2
+
3
+ No imports from pytest_fakellm here on purpose — the `fakellm` fixture must be
4
+ auto-discovered via the entry point, proving the plugin is wired correctly.
5
+ We talk to the server with raw httpx (rather than the openai SDK) so the suite
6
+ needs no extra SDK installs.
7
+ """
8
+ import httpx
9
+
10
+
11
+ RULES = """
12
+ version: 1
13
+ rules:
14
+ - name: summarize
15
+ when: { messages_contain: "research" }
16
+ respond: { content: "I found what you were looking for." }
17
+ """
18
+
19
+
20
+ def _chat(server, text):
21
+ """Make an OpenAI-style chat call against the server using raw httpx."""
22
+ resp = httpx.post(
23
+ f"{server.openai_base_url}/chat/completions",
24
+ json={"model": "gpt-4", "messages": [{"role": "user", "content": text}]},
25
+ timeout=10.0,
26
+ )
27
+ resp.raise_for_status()
28
+ return resp.json()["choices"][0]["message"]["content"]
29
+
30
+
31
+ def test_fixture_is_autodiscovered(fakellm):
32
+ # If this argument resolves at all, the entry point worked.
33
+ assert fakellm.base_url.startswith("http://127.0.0.1:")
34
+
35
+
36
+ def test_inline_config_drives_response(fakellm):
37
+ fakellm.set_config_text(RULES)
38
+ content = _chat(fakellm, "please research fakellm")
39
+ assert content == "I found what you were looking for."
40
+
41
+
42
+ def test_request_count_increments(fakellm):
43
+ start = fakellm.request_count
44
+ _chat(fakellm, "hello")
45
+ _chat(fakellm, "hello again")
46
+ assert fakellm.request_count == start + 2
47
+
48
+
49
+ def test_state_is_reset_between_tests_part1(fakellm):
50
+ # Make some requests; the NEXT test should not see this conversation state.
51
+ _chat(fakellm, "first test traffic")
52
+ convs = fakellm.conversations()
53
+ # Stub tracks conversations dict; just assert the call works and is JSON.
54
+ assert isinstance(convs, dict)
55
+
56
+
57
+ def test_state_is_reset_between_tests_part2(fakellm):
58
+ # Because `fakellm` resets before each test, conversation state is empty
59
+ # even though the previous test made requests.
60
+ convs = fakellm.conversations()
61
+ assert convs == {}
62
+
63
+
64
+ def test_raw_base_urls_are_exposed(fakellm):
65
+ assert fakellm.openai_base_url.endswith("/v1")
66
+ assert fakellm.anthropic_base_url == fakellm.base_url
67
+
68
+
69
+ def test_default_config_returns_something(fakellm):
70
+ # With no rules set this run, the stub returns its default content.
71
+ fakellm.set_config_text("version: 1\nrules: []\n")
72
+ content = _chat(fakellm, "anything")
73
+ assert isinstance(content, str) and content # got some response
74
+ assert "mock response" in content # it's the fallback echo