aimock-pytest 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.
- aimock_pytest-0.1.0/.gitignore +7 -0
- aimock_pytest-0.1.0/PKG-INFO +136 -0
- aimock_pytest-0.1.0/README.md +124 -0
- aimock_pytest-0.1.0/pyproject.toml +21 -0
- aimock_pytest-0.1.0/src/aimock_pytest/__init__.py +5 -0
- aimock_pytest-0.1.0/src/aimock_pytest/_node_manager.py +189 -0
- aimock_pytest-0.1.0/src/aimock_pytest/_server.py +270 -0
- aimock_pytest-0.1.0/src/aimock_pytest/_version.py +3 -0
- aimock_pytest-0.1.0/src/aimock_pytest/plugin.py +55 -0
- aimock_pytest-0.1.0/src/aimock_pytest/py.typed +0 -0
- aimock_pytest-0.1.0/tests/conftest.py +14 -0
- aimock_pytest-0.1.0/tests/fixtures/hello.json +8 -0
- aimock_pytest-0.1.0/tests/test_basic.py +69 -0
- aimock_pytest-0.1.0/tests/test_fixtures.py +60 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aimock-pytest
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: pytest fixtures for aimock — mock LLM APIs, MCP, A2A, vector DBs
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: requests>=2.28
|
|
8
|
+
Provides-Extra: test
|
|
9
|
+
Requires-Dist: pytest>=7.0; extra == 'test'
|
|
10
|
+
Requires-Dist: requests>=2.28; extra == 'test'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# aimock-pytest
|
|
14
|
+
|
|
15
|
+
pytest fixtures for [aimock](https://github.com/CopilotKit/llmock) — mock LLM APIs, MCP tools, A2A agents, vector databases, and more.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# From PyPI (once published):
|
|
21
|
+
pip install aimock-pytest
|
|
22
|
+
|
|
23
|
+
# Local install from a repo checkout:
|
|
24
|
+
pip install ./packages/aimock-pytest
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Requires:** Node.js >= 20 on `PATH` (or set `AIMOCK_NODE_PATH`).
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
The plugin auto-registers two fixtures: `aimock` (function-scoped) and `aimock_session` (session-scoped).
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
def test_hello(aimock):
|
|
35
|
+
import requests
|
|
36
|
+
|
|
37
|
+
# Set up a fixture
|
|
38
|
+
aimock.on_message("hello", {"content": "Hi there!"})
|
|
39
|
+
|
|
40
|
+
# Point your SDK at aimock
|
|
41
|
+
r = requests.post(
|
|
42
|
+
f"{aimock.base_url}/v1/chat/completions",
|
|
43
|
+
json={
|
|
44
|
+
"model": "gpt-4",
|
|
45
|
+
"messages": [{"role": "user", "content": "hello"}],
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
assert r.json()["choices"][0]["message"]["content"] == "Hi there!"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Fixtures
|
|
52
|
+
|
|
53
|
+
| Fixture | Scope | Description |
|
|
54
|
+
| ---------------- | -------- | ------------------------------ |
|
|
55
|
+
| `aimock` | function | Fresh server per test |
|
|
56
|
+
| `aimock_session` | session | Shared server across all tests |
|
|
57
|
+
|
|
58
|
+
## Server API
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
# Add fixtures
|
|
62
|
+
aimock.on_message("pattern", {"content": "response"})
|
|
63
|
+
aimock.on_embedding("pattern", {"embedding": [0.1, 0.2]})
|
|
64
|
+
aimock.add_fixture(match={...}, response={...}, chunkSize=10, latency=50)
|
|
65
|
+
aimock.load_fixtures("path/to/fixtures.json")
|
|
66
|
+
|
|
67
|
+
# Inspect
|
|
68
|
+
aimock.get_journal() # list of all recorded requests
|
|
69
|
+
aimock.get_last_request() # most recent request or None
|
|
70
|
+
|
|
71
|
+
# Error injection
|
|
72
|
+
aimock.next_error(429, {"message": "Rate limited"})
|
|
73
|
+
|
|
74
|
+
# Reset
|
|
75
|
+
aimock.clear_fixtures() # remove all fixtures
|
|
76
|
+
aimock.reset() # clear fixtures + journal
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## CLI Options
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
--aimock-node PATH Path to node binary
|
|
83
|
+
--aimock-version VER aimock npm version (default: 1.7.0)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Environment Variables
|
|
87
|
+
|
|
88
|
+
| Variable | Description |
|
|
89
|
+
| ------------------ | ----------------------------------------------------- |
|
|
90
|
+
| `AIMOCK_NODE_PATH` | Path to node binary |
|
|
91
|
+
| `AIMOCK_CACHE_DIR` | Override cache directory (default: `~/.cache/aimock`) |
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
### Prerequisites
|
|
96
|
+
|
|
97
|
+
- Node.js >= 20
|
|
98
|
+
- Python >= 3.10
|
|
99
|
+
- pnpm
|
|
100
|
+
|
|
101
|
+
### Running tests locally
|
|
102
|
+
|
|
103
|
+
Build the npm package first, then point `AIMOCK_CLI_PATH` at the local build:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pnpm install && pnpm run build
|
|
107
|
+
AIMOCK_CLI_PATH=../../dist/cli.js pytest tests/ -v
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
If you install the test dependencies and run from the `packages/aimock-pytest/`
|
|
111
|
+
directory, `conftest.py` will auto-detect the local build so you can omit the
|
|
112
|
+
env var:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
pip install ./packages/aimock-pytest[test]
|
|
116
|
+
cd packages/aimock-pytest
|
|
117
|
+
pytest tests/ -v
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### How CI works
|
|
121
|
+
|
|
122
|
+
The `test-pytest.yml` workflow:
|
|
123
|
+
|
|
124
|
+
1. Checks out the repo
|
|
125
|
+
2. Builds the TypeScript package (`pnpm run build`)
|
|
126
|
+
3. Sets `AIMOCK_CLI_PATH` to the local `dist/cli.js`
|
|
127
|
+
4. Installs `aimock-pytest[test]` and runs `pytest`
|
|
128
|
+
|
|
129
|
+
Tests run across a matrix of Python 3.10--3.13 and Node 20/22.
|
|
130
|
+
|
|
131
|
+
The `publish-pytest.yml` workflow publishes to PyPI on pushes to `main` when
|
|
132
|
+
the version in `pyproject.toml` has not already been published.
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# aimock-pytest
|
|
2
|
+
|
|
3
|
+
pytest fixtures for [aimock](https://github.com/CopilotKit/llmock) — mock LLM APIs, MCP tools, A2A agents, vector databases, and more.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# From PyPI (once published):
|
|
9
|
+
pip install aimock-pytest
|
|
10
|
+
|
|
11
|
+
# Local install from a repo checkout:
|
|
12
|
+
pip install ./packages/aimock-pytest
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**Requires:** Node.js >= 20 on `PATH` (or set `AIMOCK_NODE_PATH`).
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
The plugin auto-registers two fixtures: `aimock` (function-scoped) and `aimock_session` (session-scoped).
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
def test_hello(aimock):
|
|
23
|
+
import requests
|
|
24
|
+
|
|
25
|
+
# Set up a fixture
|
|
26
|
+
aimock.on_message("hello", {"content": "Hi there!"})
|
|
27
|
+
|
|
28
|
+
# Point your SDK at aimock
|
|
29
|
+
r = requests.post(
|
|
30
|
+
f"{aimock.base_url}/v1/chat/completions",
|
|
31
|
+
json={
|
|
32
|
+
"model": "gpt-4",
|
|
33
|
+
"messages": [{"role": "user", "content": "hello"}],
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
assert r.json()["choices"][0]["message"]["content"] == "Hi there!"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Fixtures
|
|
40
|
+
|
|
41
|
+
| Fixture | Scope | Description |
|
|
42
|
+
| ---------------- | -------- | ------------------------------ |
|
|
43
|
+
| `aimock` | function | Fresh server per test |
|
|
44
|
+
| `aimock_session` | session | Shared server across all tests |
|
|
45
|
+
|
|
46
|
+
## Server API
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
# Add fixtures
|
|
50
|
+
aimock.on_message("pattern", {"content": "response"})
|
|
51
|
+
aimock.on_embedding("pattern", {"embedding": [0.1, 0.2]})
|
|
52
|
+
aimock.add_fixture(match={...}, response={...}, chunkSize=10, latency=50)
|
|
53
|
+
aimock.load_fixtures("path/to/fixtures.json")
|
|
54
|
+
|
|
55
|
+
# Inspect
|
|
56
|
+
aimock.get_journal() # list of all recorded requests
|
|
57
|
+
aimock.get_last_request() # most recent request or None
|
|
58
|
+
|
|
59
|
+
# Error injection
|
|
60
|
+
aimock.next_error(429, {"message": "Rate limited"})
|
|
61
|
+
|
|
62
|
+
# Reset
|
|
63
|
+
aimock.clear_fixtures() # remove all fixtures
|
|
64
|
+
aimock.reset() # clear fixtures + journal
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## CLI Options
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
--aimock-node PATH Path to node binary
|
|
71
|
+
--aimock-version VER aimock npm version (default: 1.7.0)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Environment Variables
|
|
75
|
+
|
|
76
|
+
| Variable | Description |
|
|
77
|
+
| ------------------ | ----------------------------------------------------- |
|
|
78
|
+
| `AIMOCK_NODE_PATH` | Path to node binary |
|
|
79
|
+
| `AIMOCK_CACHE_DIR` | Override cache directory (default: `~/.cache/aimock`) |
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
### Prerequisites
|
|
84
|
+
|
|
85
|
+
- Node.js >= 20
|
|
86
|
+
- Python >= 3.10
|
|
87
|
+
- pnpm
|
|
88
|
+
|
|
89
|
+
### Running tests locally
|
|
90
|
+
|
|
91
|
+
Build the npm package first, then point `AIMOCK_CLI_PATH` at the local build:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pnpm install && pnpm run build
|
|
95
|
+
AIMOCK_CLI_PATH=../../dist/cli.js pytest tests/ -v
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
If you install the test dependencies and run from the `packages/aimock-pytest/`
|
|
99
|
+
directory, `conftest.py` will auto-detect the local build so you can omit the
|
|
100
|
+
env var:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
pip install ./packages/aimock-pytest[test]
|
|
104
|
+
cd packages/aimock-pytest
|
|
105
|
+
pytest tests/ -v
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### How CI works
|
|
109
|
+
|
|
110
|
+
The `test-pytest.yml` workflow:
|
|
111
|
+
|
|
112
|
+
1. Checks out the repo
|
|
113
|
+
2. Builds the TypeScript package (`pnpm run build`)
|
|
114
|
+
3. Sets `AIMOCK_CLI_PATH` to the local `dist/cli.js`
|
|
115
|
+
4. Installs `aimock-pytest[test]` and runs `pytest`
|
|
116
|
+
|
|
117
|
+
Tests run across a matrix of Python 3.10--3.13 and Node 20/22.
|
|
118
|
+
|
|
119
|
+
The `publish-pytest.yml` workflow publishes to PyPI on pushes to `main` when
|
|
120
|
+
the version in `pyproject.toml` has not already been published.
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aimock-pytest"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "pytest fixtures for aimock — mock LLM APIs, MCP, A2A, vector DBs"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
dependencies = ["requests>=2.28"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
test = ["pytest>=7.0", "requests>=2.28"]
|
|
16
|
+
|
|
17
|
+
[project.entry-points."pytest11"]
|
|
18
|
+
aimock = "aimock_pytest.plugin"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/aimock_pytest"]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Handles Node.js detection and npm tarball caching for aimock."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import fcntl
|
|
7
|
+
except ImportError:
|
|
8
|
+
fcntl = None # Windows — file locking not available
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tarfile
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import requests
|
|
20
|
+
|
|
21
|
+
from aimock_pytest._version import AIMOCK_VERSION
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NodeManager:
|
|
25
|
+
"""Manages node binary detection and aimock npm package caching."""
|
|
26
|
+
|
|
27
|
+
# Use @copilotkit/aimock since that's the published npm package name.
|
|
28
|
+
_NPM_TARBALL_URL = (
|
|
29
|
+
"https://registry.npmjs.org/@copilotkit/aimock/-/aimock-{version}.tgz"
|
|
30
|
+
)
|
|
31
|
+
_MIN_NODE_MAJOR = 20
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
version: str = AIMOCK_VERSION,
|
|
36
|
+
cache_dir: str | Path | None = None,
|
|
37
|
+
node_path: str | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.version = version
|
|
40
|
+
self._custom_cache_dir = Path(cache_dir) if cache_dir else None
|
|
41
|
+
self._custom_node_path = node_path
|
|
42
|
+
|
|
43
|
+
# ── public API ──────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def ensure_installed(self) -> Path:
|
|
46
|
+
"""Return path to the aimock ``dist/cli.js``. Downloads if needed.
|
|
47
|
+
|
|
48
|
+
If ``AIMOCK_CLI_PATH`` is set, returns that path directly (bypassing
|
|
49
|
+
the npm tarball download entirely). This is used for local development
|
|
50
|
+
and CI where we test against the repo's own build.
|
|
51
|
+
"""
|
|
52
|
+
env_cli = os.environ.get("AIMOCK_CLI_PATH")
|
|
53
|
+
if env_cli:
|
|
54
|
+
p = Path(env_cli)
|
|
55
|
+
if not p.is_file():
|
|
56
|
+
raise RuntimeError(
|
|
57
|
+
f"AIMOCK_CLI_PATH is set to {env_cli!r} but the file does not exist"
|
|
58
|
+
)
|
|
59
|
+
return p
|
|
60
|
+
|
|
61
|
+
cli_js = self._package_dir() / "dist" / "cli.js"
|
|
62
|
+
if cli_js.is_file():
|
|
63
|
+
return cli_js
|
|
64
|
+
|
|
65
|
+
self._download_tarball()
|
|
66
|
+
|
|
67
|
+
if not cli_js.is_file():
|
|
68
|
+
raise RuntimeError(
|
|
69
|
+
f"Expected cli.js at {cli_js} but it was not found after extraction"
|
|
70
|
+
)
|
|
71
|
+
return cli_js
|
|
72
|
+
|
|
73
|
+
def find_node(self) -> str:
|
|
74
|
+
"""Find the node binary. Checks ``AIMOCK_NODE_PATH`` env, the
|
|
75
|
+
constructor override, then ``PATH``."""
|
|
76
|
+
# Explicit override from constructor
|
|
77
|
+
if self._custom_node_path:
|
|
78
|
+
self._verify_node(self._custom_node_path)
|
|
79
|
+
return self._custom_node_path
|
|
80
|
+
|
|
81
|
+
# Environment variable
|
|
82
|
+
env_path = os.environ.get("AIMOCK_NODE_PATH")
|
|
83
|
+
if env_path:
|
|
84
|
+
self._verify_node(env_path)
|
|
85
|
+
return env_path
|
|
86
|
+
|
|
87
|
+
# PATH lookup
|
|
88
|
+
node = shutil.which("node")
|
|
89
|
+
if node is None:
|
|
90
|
+
raise RuntimeError(
|
|
91
|
+
"node is not installed or not on PATH. "
|
|
92
|
+
"Install Node.js >= 20 or set AIMOCK_NODE_PATH."
|
|
93
|
+
)
|
|
94
|
+
self._verify_node(node)
|
|
95
|
+
return node
|
|
96
|
+
|
|
97
|
+
# ── internal ────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def _cache_dir(self) -> Path:
|
|
100
|
+
"""``~/.cache/aimock/{version}/`` or ``AIMOCK_CACHE_DIR`` env."""
|
|
101
|
+
base: Path
|
|
102
|
+
if self._custom_cache_dir:
|
|
103
|
+
base = self._custom_cache_dir
|
|
104
|
+
else:
|
|
105
|
+
env = os.environ.get("AIMOCK_CACHE_DIR")
|
|
106
|
+
if env:
|
|
107
|
+
base = Path(env)
|
|
108
|
+
else:
|
|
109
|
+
base = Path.home() / ".cache" / "aimock"
|
|
110
|
+
return base / self.version
|
|
111
|
+
|
|
112
|
+
def _package_dir(self) -> Path:
|
|
113
|
+
"""The extracted package directory inside the cache."""
|
|
114
|
+
return self._cache_dir() / "package"
|
|
115
|
+
|
|
116
|
+
def _download_tarball(self) -> None:
|
|
117
|
+
"""Download the @copilotkit/aimock tarball from the npm registry and
|
|
118
|
+
extract it into the cache directory.
|
|
119
|
+
|
|
120
|
+
Uses file-locking (``fcntl.flock``) so parallel pytest-xdist workers
|
|
121
|
+
don't race. On Windows where fcntl is unavailable, locking is skipped.
|
|
122
|
+
"""
|
|
123
|
+
cache = self._cache_dir()
|
|
124
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
|
|
126
|
+
_fcntl = fcntl # local binding for type narrowing
|
|
127
|
+
if _fcntl is not None:
|
|
128
|
+
lock_path = cache / ".lock"
|
|
129
|
+
lock_fd = open(lock_path, "w")
|
|
130
|
+
_fcntl.flock(lock_fd, _fcntl.LOCK_EX)
|
|
131
|
+
else:
|
|
132
|
+
lock_fd = None
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
# Double-check after acquiring lock — another worker may have
|
|
136
|
+
# finished the download while we waited.
|
|
137
|
+
if (self._package_dir() / "dist" / "cli.js").is_file():
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
url = self._NPM_TARBALL_URL.format(version=self.version)
|
|
141
|
+
resp = requests.get(url, timeout=60, stream=True)
|
|
142
|
+
resp.raise_for_status()
|
|
143
|
+
|
|
144
|
+
with tempfile.NamedTemporaryFile(suffix=".tgz", delete=False) as tmp:
|
|
145
|
+
for chunk in resp.iter_content(chunk_size=65536):
|
|
146
|
+
tmp.write(chunk)
|
|
147
|
+
tmp_path = tmp.name
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
# npm tarballs always contain a top-level ``package/`` dir.
|
|
151
|
+
with tarfile.open(tmp_path, "r:gz") as tar:
|
|
152
|
+
if sys.version_info >= (3, 12):
|
|
153
|
+
tar.extractall(path=str(cache), filter="data") # type: ignore[call-overload]
|
|
154
|
+
else:
|
|
155
|
+
tar.extractall(path=str(cache))
|
|
156
|
+
finally:
|
|
157
|
+
os.unlink(tmp_path)
|
|
158
|
+
finally:
|
|
159
|
+
if lock_fd is not None and _fcntl is not None:
|
|
160
|
+
_fcntl.flock(lock_fd, _fcntl.LOCK_UN)
|
|
161
|
+
lock_fd.close()
|
|
162
|
+
|
|
163
|
+
def _verify_node(self, node_path: str) -> None:
|
|
164
|
+
"""Verify the node binary exists and is >= the minimum version."""
|
|
165
|
+
try:
|
|
166
|
+
result = subprocess.run(
|
|
167
|
+
[node_path, "--version"],
|
|
168
|
+
capture_output=True,
|
|
169
|
+
text=True,
|
|
170
|
+
timeout=10,
|
|
171
|
+
)
|
|
172
|
+
except FileNotFoundError:
|
|
173
|
+
raise RuntimeError(f"node binary not found at {node_path}")
|
|
174
|
+
except subprocess.TimeoutExpired:
|
|
175
|
+
raise RuntimeError(f"node --version timed out for {node_path}")
|
|
176
|
+
|
|
177
|
+
version_str = result.stdout.strip()
|
|
178
|
+
match = re.match(r"v(\d+)\.", version_str)
|
|
179
|
+
if not match:
|
|
180
|
+
raise RuntimeError(
|
|
181
|
+
f"Could not parse node version from: {version_str!r}"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
major = int(match.group(1))
|
|
185
|
+
if major < self._MIN_NODE_MAJOR:
|
|
186
|
+
raise RuntimeError(
|
|
187
|
+
f"Node.js >= {self._MIN_NODE_MAJOR} required, "
|
|
188
|
+
f"found {version_str} at {node_path}"
|
|
189
|
+
)
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Manages the aimock subprocess and communicates via the /__aimock/* control API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from aimock_pytest._node_manager import NodeManager
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AIMockServer:
|
|
20
|
+
"""Wraps a running aimock Node.js process and exposes the control API as
|
|
21
|
+
Python methods."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
node_manager: NodeManager,
|
|
26
|
+
port: int = 0,
|
|
27
|
+
fixtures_path: str | Path | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
self.node_manager = node_manager
|
|
30
|
+
self.port = port
|
|
31
|
+
self.fixtures_path = fixtures_path
|
|
32
|
+
self._proc: subprocess.Popen[str] | None = None
|
|
33
|
+
self._base_url: str | None = None
|
|
34
|
+
|
|
35
|
+
# ── lifecycle ───────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
def start(self) -> str:
|
|
38
|
+
"""Start the aimock subprocess, wait for it to be ready, and return
|
|
39
|
+
the base URL (e.g. ``http://127.0.0.1:54321``)."""
|
|
40
|
+
env_cli = os.environ.get("AIMOCK_CLI_PATH")
|
|
41
|
+
if env_cli:
|
|
42
|
+
cli_path = Path(env_cli)
|
|
43
|
+
if not cli_path.is_file():
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
f"AIMOCK_CLI_PATH is set to {env_cli!r} but the file does not exist"
|
|
46
|
+
)
|
|
47
|
+
else:
|
|
48
|
+
cli_path = self.node_manager.ensure_installed()
|
|
49
|
+
node = self.node_manager.find_node()
|
|
50
|
+
|
|
51
|
+
# The CLI requires a valid fixtures path (exits 1 if not found).
|
|
52
|
+
# Use the provided path, or create an empty temp directory.
|
|
53
|
+
if self.fixtures_path:
|
|
54
|
+
fixtures_arg = str(self.fixtures_path)
|
|
55
|
+
else:
|
|
56
|
+
import tempfile
|
|
57
|
+
|
|
58
|
+
self._tmp_fixtures = tempfile.mkdtemp(prefix="aimock-fixtures-")
|
|
59
|
+
fixtures_arg = self._tmp_fixtures
|
|
60
|
+
|
|
61
|
+
cmd = [
|
|
62
|
+
node,
|
|
63
|
+
str(cli_path),
|
|
64
|
+
"--port",
|
|
65
|
+
str(self.port),
|
|
66
|
+
"--log-level",
|
|
67
|
+
"info",
|
|
68
|
+
"--fixtures",
|
|
69
|
+
fixtures_arg,
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
self._proc = subprocess.Popen(
|
|
73
|
+
cmd,
|
|
74
|
+
stdout=subprocess.PIPE,
|
|
75
|
+
stderr=subprocess.STDOUT,
|
|
76
|
+
text=True,
|
|
77
|
+
)
|
|
78
|
+
atexit.register(self.stop)
|
|
79
|
+
|
|
80
|
+
self._base_url = self._wait_for_ready(timeout=15)
|
|
81
|
+
return self._base_url
|
|
82
|
+
|
|
83
|
+
def stop(self) -> None:
|
|
84
|
+
"""Terminate the aimock subprocess."""
|
|
85
|
+
if self._proc is not None:
|
|
86
|
+
try:
|
|
87
|
+
self._proc.terminate()
|
|
88
|
+
self._proc.wait(timeout=5)
|
|
89
|
+
except subprocess.TimeoutExpired:
|
|
90
|
+
self._proc.kill()
|
|
91
|
+
self._proc.wait()
|
|
92
|
+
except Exception:
|
|
93
|
+
try:
|
|
94
|
+
self._proc.kill()
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
finally:
|
|
98
|
+
self._proc = None
|
|
99
|
+
# Clean up temp fixtures directory if we created one
|
|
100
|
+
if hasattr(self, "_tmp_fixtures") and self._tmp_fixtures:
|
|
101
|
+
import shutil
|
|
102
|
+
|
|
103
|
+
shutil.rmtree(self._tmp_fixtures, ignore_errors=True)
|
|
104
|
+
self._tmp_fixtures = None
|
|
105
|
+
atexit.unregister(self.stop)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def base_url(self) -> str:
|
|
109
|
+
"""The base URL of the running aimock server."""
|
|
110
|
+
if self._base_url is None:
|
|
111
|
+
raise RuntimeError("Server has not been started yet")
|
|
112
|
+
return self._base_url
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def url(self) -> str:
|
|
116
|
+
"""Alias for :attr:`base_url`."""
|
|
117
|
+
return self.base_url
|
|
118
|
+
|
|
119
|
+
# ── control API methods ─────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
def add_fixture(
|
|
122
|
+
self,
|
|
123
|
+
match: dict[str, Any],
|
|
124
|
+
response: dict[str, Any],
|
|
125
|
+
**opts: Any,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Add a single fixture via ``POST /__aimock/fixtures``."""
|
|
128
|
+
fixture: dict[str, Any] = {"match": match, "response": response}
|
|
129
|
+
if opts:
|
|
130
|
+
fixture["opts"] = opts
|
|
131
|
+
r = requests.post(
|
|
132
|
+
f"{self.base_url}/__aimock/fixtures",
|
|
133
|
+
json={"fixtures": [fixture]},
|
|
134
|
+
timeout=5,
|
|
135
|
+
)
|
|
136
|
+
r.raise_for_status()
|
|
137
|
+
|
|
138
|
+
def on_message(
|
|
139
|
+
self,
|
|
140
|
+
pattern: str,
|
|
141
|
+
response: dict[str, Any],
|
|
142
|
+
**opts: Any,
|
|
143
|
+
) -> AIMockServer:
|
|
144
|
+
"""Convenience: add a fixture matching ``userMessage``."""
|
|
145
|
+
self.add_fixture({"userMessage": pattern}, response, **opts)
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
def on_embedding(
|
|
149
|
+
self,
|
|
150
|
+
pattern: str,
|
|
151
|
+
response: dict[str, Any],
|
|
152
|
+
) -> AIMockServer:
|
|
153
|
+
"""Convenience: add a fixture matching ``inputText``."""
|
|
154
|
+
self.add_fixture({"inputText": pattern}, response)
|
|
155
|
+
return self
|
|
156
|
+
|
|
157
|
+
def load_fixtures(self, path: str | Path) -> AIMockServer:
|
|
158
|
+
"""Read a JSON fixture file and POST its contents to the control API.
|
|
159
|
+
|
|
160
|
+
The file must contain either:
|
|
161
|
+
- A JSON object with a ``"fixtures"`` key (list of fixtures)
|
|
162
|
+
- A JSON array of fixture objects
|
|
163
|
+
- A single fixture object (wrapped into a list automatically)
|
|
164
|
+
|
|
165
|
+
Raises :class:`ValueError` if the parsed JSON is not a dict or list.
|
|
166
|
+
"""
|
|
167
|
+
with open(path) as f:
|
|
168
|
+
data = json.load(f)
|
|
169
|
+
|
|
170
|
+
if isinstance(data, list):
|
|
171
|
+
fixtures = data
|
|
172
|
+
elif isinstance(data, dict) and "fixtures" in data:
|
|
173
|
+
fixtures = data["fixtures"]
|
|
174
|
+
elif isinstance(data, dict):
|
|
175
|
+
fixtures = [data]
|
|
176
|
+
else:
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"Invalid fixture file {path}: expected a JSON object or array, "
|
|
179
|
+
f"got {type(data).__name__}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
r = requests.post(
|
|
183
|
+
f"{self.base_url}/__aimock/fixtures",
|
|
184
|
+
json={"fixtures": fixtures},
|
|
185
|
+
timeout=5,
|
|
186
|
+
)
|
|
187
|
+
r.raise_for_status()
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
def clear_fixtures(self) -> AIMockServer:
|
|
191
|
+
"""Delete all fixtures via ``DELETE /__aimock/fixtures``."""
|
|
192
|
+
requests.delete(
|
|
193
|
+
f"{self.base_url}/__aimock/fixtures", timeout=5
|
|
194
|
+
).raise_for_status()
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
def reset(self) -> AIMockServer:
|
|
198
|
+
"""Clear fixtures, journal, and match counts via ``POST /__aimock/reset``."""
|
|
199
|
+
requests.post(
|
|
200
|
+
f"{self.base_url}/__aimock/reset", timeout=5
|
|
201
|
+
).raise_for_status()
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
def get_journal(self) -> list[dict[str, Any]]:
|
|
205
|
+
"""Return all recorded journal entries."""
|
|
206
|
+
r = requests.get(f"{self.base_url}/__aimock/journal", timeout=5)
|
|
207
|
+
r.raise_for_status()
|
|
208
|
+
return r.json() # type: ignore[no-any-return]
|
|
209
|
+
|
|
210
|
+
def get_last_request(self) -> dict[str, Any] | None:
|
|
211
|
+
"""Return the most recent journal entry, or ``None``."""
|
|
212
|
+
journal = self.get_journal()
|
|
213
|
+
return journal[-1] if journal else None
|
|
214
|
+
|
|
215
|
+
def next_error(
|
|
216
|
+
self,
|
|
217
|
+
status: int,
|
|
218
|
+
body: dict[str, Any] | None = None,
|
|
219
|
+
) -> AIMockServer:
|
|
220
|
+
"""Queue a one-shot error via ``POST /__aimock/error``."""
|
|
221
|
+
requests.post(
|
|
222
|
+
f"{self.base_url}/__aimock/error",
|
|
223
|
+
json={"status": status, "body": body or {}},
|
|
224
|
+
timeout=5,
|
|
225
|
+
).raise_for_status()
|
|
226
|
+
return self
|
|
227
|
+
|
|
228
|
+
# ── internal ────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
def _wait_for_ready(self, timeout: int = 15) -> str:
|
|
231
|
+
"""Read stdout lines until we see the listening URL, then verify via
|
|
232
|
+
health check."""
|
|
233
|
+
assert self._proc is not None
|
|
234
|
+
assert self._proc.stdout is not None
|
|
235
|
+
|
|
236
|
+
deadline = time.monotonic() + timeout
|
|
237
|
+
while time.monotonic() < deadline:
|
|
238
|
+
# Check if process exited
|
|
239
|
+
if self._proc.poll() is not None:
|
|
240
|
+
remaining = ""
|
|
241
|
+
if self._proc.stdout:
|
|
242
|
+
remaining = self._proc.stdout.read()
|
|
243
|
+
raise RuntimeError(
|
|
244
|
+
f"aimock process exited with code {self._proc.returncode}"
|
|
245
|
+
f"{': ' + remaining if remaining else ''}"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
line = self._proc.stdout.readline()
|
|
249
|
+
if not line:
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
m = re.search(r"listening on (http://\S+)", line)
|
|
253
|
+
if m:
|
|
254
|
+
url = m.group(1).rstrip("/")
|
|
255
|
+
# Verify health endpoint is reachable
|
|
256
|
+
for _ in range(30):
|
|
257
|
+
try:
|
|
258
|
+
r = requests.get(
|
|
259
|
+
f"{url}/__aimock/health", timeout=0.5
|
|
260
|
+
)
|
|
261
|
+
if r.status_code == 200:
|
|
262
|
+
return url
|
|
263
|
+
time.sleep(0.1)
|
|
264
|
+
except requests.RequestException:
|
|
265
|
+
time.sleep(0.1)
|
|
266
|
+
raise RuntimeError(
|
|
267
|
+
"aimock started but health check failed after 3 seconds"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
raise RuntimeError(f"aimock did not start within {timeout}s")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""pytest plugin — registers ``aimock`` and ``aimock_session`` fixtures.
|
|
2
|
+
|
|
3
|
+
Auto-discovered by pytest via the ``pytest11`` entry point in pyproject.toml.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from aimock_pytest._version import AIMOCK_VERSION
|
|
11
|
+
from aimock_pytest._node_manager import NodeManager
|
|
12
|
+
from aimock_pytest._server import AIMockServer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
16
|
+
group = parser.getgroup("aimock", "aimock mock server options")
|
|
17
|
+
group.addoption(
|
|
18
|
+
"--aimock-node",
|
|
19
|
+
default=None,
|
|
20
|
+
help="Path to node binary (default: auto-detect from PATH)",
|
|
21
|
+
)
|
|
22
|
+
group.addoption(
|
|
23
|
+
"--aimock-version",
|
|
24
|
+
default=AIMOCK_VERSION,
|
|
25
|
+
help=f"aimock npm package version to use (default: {AIMOCK_VERSION})",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture(scope="session")
|
|
30
|
+
def _aimock_node_manager(request: pytest.FixtureRequest) -> NodeManager:
|
|
31
|
+
"""Session-scoped :class:`NodeManager` — shared across all fixtures."""
|
|
32
|
+
return NodeManager(
|
|
33
|
+
version=request.config.getoption("--aimock-version"),
|
|
34
|
+
node_path=request.config.getoption("--aimock-node"),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def aimock(_aimock_node_manager: NodeManager) -> AIMockServer:
|
|
40
|
+
"""Function-scoped aimock server. A fresh server is started for every
|
|
41
|
+
test that requests this fixture, and torn down afterwards."""
|
|
42
|
+
server = AIMockServer(_aimock_node_manager, port=0)
|
|
43
|
+
server.start()
|
|
44
|
+
yield server # type: ignore[misc]
|
|
45
|
+
server.stop()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture(scope="session")
|
|
49
|
+
def aimock_session(_aimock_node_manager: NodeManager) -> AIMockServer:
|
|
50
|
+
"""Session-scoped aimock server. One server is shared across all tests
|
|
51
|
+
that request this fixture."""
|
|
52
|
+
server = AIMockServer(_aimock_node_manager, port=0)
|
|
53
|
+
server.start()
|
|
54
|
+
yield server # type: ignore[misc]
|
|
55
|
+
server.stop()
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# The aimock plugin is auto-registered via the pytest11 entry point.
|
|
2
|
+
# This file exists so pytest recognises this directory as a test root.
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
|
|
7
|
+
# Auto-detect local repo build for development. If AIMOCK_CLI_PATH is not
|
|
8
|
+
# already set and a built cli.js exists at the repo root, use it directly
|
|
9
|
+
# so tests run against the local build instead of downloading from npm.
|
|
10
|
+
REPO_ROOT = pathlib.Path(__file__).parent.parent.parent.parent
|
|
11
|
+
LOCAL_CLI = REPO_ROOT / "dist" / "cli.js"
|
|
12
|
+
|
|
13
|
+
if LOCAL_CLI.exists() and "AIMOCK_CLI_PATH" not in os.environ:
|
|
14
|
+
os.environ["AIMOCK_CLI_PATH"] = str(LOCAL_CLI)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
# ── Session-scoped fixture tests ──────────────────────────────────────────
|
|
4
|
+
# These two tests share a single aimock_session instance, verifying that the
|
|
5
|
+
# session-scoped fixture persists state across test functions.
|
|
6
|
+
|
|
7
|
+
_session_url: str | None = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_session_fixture_starts(aimock_session):
|
|
11
|
+
"""aimock_session starts and its URL persists across tests."""
|
|
12
|
+
global _session_url
|
|
13
|
+
r = requests.get(f"{aimock_session.base_url}/__aimock/health")
|
|
14
|
+
assert r.status_code == 200
|
|
15
|
+
_session_url = aimock_session.base_url
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_session_fixture_persists(aimock_session):
|
|
19
|
+
"""aimock_session is the same server instance as the previous test."""
|
|
20
|
+
# The base_url should be identical — same process, same port.
|
|
21
|
+
assert aimock_session.base_url == _session_url
|
|
22
|
+
|
|
23
|
+
# Fixtures added in a previous test would still be present (session scope
|
|
24
|
+
# does NOT auto-reset between tests). Verify the server is still alive.
|
|
25
|
+
r = requests.get(f"{aimock_session.base_url}/__aimock/health")
|
|
26
|
+
assert r.status_code == 200
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── Function-scoped fixture tests ────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_server_starts(aimock):
|
|
33
|
+
"""Server starts and health check works."""
|
|
34
|
+
r = requests.get(f"{aimock.base_url}/__aimock/health")
|
|
35
|
+
assert r.status_code == 200
|
|
36
|
+
assert r.json()["status"] == "ok"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_add_fixture_and_match(aimock):
|
|
40
|
+
"""Add a fixture via control API, then hit it."""
|
|
41
|
+
aimock.on_message("hello", {"content": "Hi there!"})
|
|
42
|
+
|
|
43
|
+
r = requests.post(
|
|
44
|
+
f"{aimock.base_url}/v1/chat/completions",
|
|
45
|
+
json={
|
|
46
|
+
"model": "gpt-4",
|
|
47
|
+
"messages": [{"role": "user", "content": "hello"}],
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
assert r.status_code == 200
|
|
51
|
+
body = r.json()
|
|
52
|
+
assert body["choices"][0]["message"]["content"] == "Hi there!"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_reset_clears_fixtures(aimock):
|
|
56
|
+
"""Reset clears fixtures and journal."""
|
|
57
|
+
aimock.on_message("test", {"content": "response"})
|
|
58
|
+
aimock.reset()
|
|
59
|
+
|
|
60
|
+
r = requests.post(
|
|
61
|
+
f"{aimock.base_url}/v1/chat/completions",
|
|
62
|
+
json={
|
|
63
|
+
"model": "gpt-4",
|
|
64
|
+
"messages": [{"role": "user", "content": "test"}],
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
# aimock returns 404 when no fixture matches the request, confirming
|
|
68
|
+
# that the previously-registered fixture was cleared by reset().
|
|
69
|
+
assert r.status_code == 404
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_load_fixture_file(aimock):
|
|
7
|
+
"""Load fixtures from a JSON file."""
|
|
8
|
+
fixture_path = os.path.join(os.path.dirname(__file__), "fixtures", "hello.json")
|
|
9
|
+
aimock.load_fixtures(fixture_path)
|
|
10
|
+
|
|
11
|
+
r = requests.post(
|
|
12
|
+
f"{aimock.base_url}/v1/chat/completions",
|
|
13
|
+
json={
|
|
14
|
+
"model": "gpt-4",
|
|
15
|
+
"messages": [{"role": "user", "content": "hello"}],
|
|
16
|
+
},
|
|
17
|
+
)
|
|
18
|
+
assert r.status_code == 200
|
|
19
|
+
assert "Hello from aimock" in r.json()["choices"][0]["message"]["content"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_journal_records_requests(aimock):
|
|
23
|
+
"""Journal captures all requests."""
|
|
24
|
+
aimock.on_message("journal-test", {"content": "ok"})
|
|
25
|
+
|
|
26
|
+
requests.post(
|
|
27
|
+
f"{aimock.base_url}/v1/chat/completions",
|
|
28
|
+
json={
|
|
29
|
+
"model": "gpt-4",
|
|
30
|
+
"messages": [{"role": "user", "content": "journal-test"}],
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
journal = aimock.get_journal()
|
|
35
|
+
assert len(journal) >= 1
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_one_shot_error(aimock):
|
|
39
|
+
"""Queue a one-shot error."""
|
|
40
|
+
aimock.on_message("error-test", {"content": "ok"})
|
|
41
|
+
aimock.next_error(429, {"message": "Rate limited", "type": "rate_limit_error"})
|
|
42
|
+
|
|
43
|
+
r1 = requests.post(
|
|
44
|
+
f"{aimock.base_url}/v1/chat/completions",
|
|
45
|
+
json={
|
|
46
|
+
"model": "gpt-4",
|
|
47
|
+
"messages": [{"role": "user", "content": "error-test"}],
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
assert r1.status_code == 429
|
|
51
|
+
|
|
52
|
+
# Second request should succeed
|
|
53
|
+
r2 = requests.post(
|
|
54
|
+
f"{aimock.base_url}/v1/chat/completions",
|
|
55
|
+
json={
|
|
56
|
+
"model": "gpt-4",
|
|
57
|
+
"messages": [{"role": "user", "content": "error-test"}],
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
assert r2.status_code == 200
|