|
| 1 | +# @copilotkit/mock-openai |
| 2 | + |
| 3 | +Deterministic mock OpenAI server for testing. Streams SSE responses in real OpenAI chat completion format, driven entirely by fixtures. Zero runtime dependencies — built on Node.js builtins only. |
| 4 | + |
| 5 | +## Install |
| 6 | + |
| 7 | +```bash |
| 8 | +npm install @copilotkit/mock-openai |
| 9 | +``` |
| 10 | + |
| 11 | +## Quick Start |
| 12 | + |
| 13 | +```typescript |
| 14 | +import { MockOpenAI } from "@copilotkit/mock-openai"; |
| 15 | + |
| 16 | +const mock = new MockOpenAI(); |
| 17 | + |
| 18 | +mock.onMessage("hello", { content: "Hi there!" }); |
| 19 | + |
| 20 | +const url = await mock.start(); |
| 21 | +// Point your OpenAI client at `url` instead of https://api.openai.com |
| 22 | + |
| 23 | +// ... run your tests ... |
| 24 | + |
| 25 | +await mock.stop(); |
| 26 | +``` |
| 27 | + |
| 28 | +## Programmatic API |
| 29 | + |
| 30 | +### `new MockOpenAI(options?)` |
| 31 | + |
| 32 | +Create a new mock server instance. |
| 33 | + |
| 34 | +| Option | Type | Default | Description | |
| 35 | +|---|---|---|---| |
| 36 | +| `port` | `number` | `0` (random) | Port to listen on | |
| 37 | +| `host` | `string` | `"127.0.0.1"` | Host to bind to | |
| 38 | +| `latency` | `number` | `0` | Default ms delay between SSE chunks | |
| 39 | +| `chunkSize` | `number` | `20` | Default characters per SSE chunk | |
| 40 | + |
| 41 | +### `MockOpenAI.create(options?)` |
| 42 | + |
| 43 | +Static factory — creates an instance and starts it in one call. Returns `Promise<MockOpenAI>`. |
| 44 | + |
| 45 | +### Server Lifecycle |
| 46 | + |
| 47 | +| Method | Returns | Description | |
| 48 | +|---|---|---| |
| 49 | +| `start()` | `Promise<string>` | Start the server, returns the base URL | |
| 50 | +| `stop()` | `Promise<void>` | Stop the server | |
| 51 | +| `url` | `string` | Base URL (throws if not started) | |
| 52 | +| `baseUrl` | `string` | Alias for `url` | |
| 53 | +| `port` | `number` | Listening port (throws if not started) | |
| 54 | + |
| 55 | +### Fixture Registration |
| 56 | + |
| 57 | +All registration methods return `this` for chaining. |
| 58 | + |
| 59 | +#### `on(match, response, opts?)` |
| 60 | + |
| 61 | +Register a fixture with full control over match criteria. |
| 62 | + |
| 63 | +```typescript |
| 64 | +mock.on( |
| 65 | + { userMessage: /weather/i, model: "gpt-4" }, |
| 66 | + { content: "It's sunny!" }, |
| 67 | + { latency: 50 }, |
| 68 | +); |
| 69 | +``` |
| 70 | + |
| 71 | +#### `onMessage(pattern, response, opts?)` |
| 72 | + |
| 73 | +Shorthand — matches on the last user message. |
| 74 | + |
| 75 | +```typescript |
| 76 | +mock.onMessage("hello", { content: "Hi!" }); |
| 77 | +mock.onMessage(/greet/i, { content: "Hey there!" }); |
| 78 | +``` |
| 79 | + |
| 80 | +#### `onToolCall(name, response, opts?)` |
| 81 | + |
| 82 | +Shorthand — matches when the request contains a tool call with the given name. |
| 83 | + |
| 84 | +```typescript |
| 85 | +mock.onToolCall("get_weather", { |
| 86 | + toolCalls: [{ name: "get_weather", arguments: '{"location":"SF"}' }], |
| 87 | +}); |
| 88 | +``` |
| 89 | + |
| 90 | +#### `onToolResult(id, response, opts?)` |
| 91 | + |
| 92 | +Shorthand — matches when a tool result message has the given `tool_call_id`. |
| 93 | + |
| 94 | +```typescript |
| 95 | +mock.onToolResult("call_abc123", { content: "Temperature is 72F" }); |
| 96 | +``` |
| 97 | + |
| 98 | +#### `addFixture(fixture)` / `addFixtures(fixtures)` |
| 99 | + |
| 100 | +Add raw `Fixture` objects directly. |
| 101 | + |
| 102 | +#### `loadFixtureFile(path)` / `loadFixtureDir(path)` |
| 103 | + |
| 104 | +Load fixtures from JSON files on disk. See [Fixture Files](#fixture-files) below. |
| 105 | + |
| 106 | +#### `clearFixtures()` |
| 107 | + |
| 108 | +Remove all registered fixtures. |
| 109 | + |
| 110 | +### Error Injection |
| 111 | + |
| 112 | +#### `nextRequestError(status, errorBody?)` |
| 113 | + |
| 114 | +Queue a one-shot error for the very next request. The error fires once, then auto-removes itself. |
| 115 | + |
| 116 | +```typescript |
| 117 | +mock.nextRequestError(429, { |
| 118 | + message: "Rate limited", |
| 119 | + type: "rate_limit_error", |
| 120 | +}); |
| 121 | + |
| 122 | +// Next request → 429 error |
| 123 | +// Subsequent requests → normal fixture matching |
| 124 | +``` |
| 125 | + |
| 126 | +### Request Journal |
| 127 | + |
| 128 | +Every request to `/v1/chat/completions` is recorded in a journal. |
| 129 | + |
| 130 | +#### Programmatic Access |
| 131 | + |
| 132 | +| Method | Returns | Description | |
| 133 | +|---|---|---| |
| 134 | +| `getRequests()` | `JournalEntry[]` | All recorded requests | |
| 135 | +| `getLastRequest()` | `JournalEntry \| null` | Most recent request | |
| 136 | +| `clearRequests()` | `void` | Clear the journal | |
| 137 | +| `journal` | `Journal` | Direct access to the journal instance | |
| 138 | + |
| 139 | +```typescript |
| 140 | +await fetch(mock.url + "/v1/chat/completions", { ... }); |
| 141 | + |
| 142 | +const last = mock.getLastRequest(); |
| 143 | +expect(last?.body.messages).toContainEqual({ |
| 144 | + role: "user", |
| 145 | + content: "hello", |
| 146 | +}); |
| 147 | +``` |
| 148 | + |
| 149 | +#### HTTP Endpoints |
| 150 | + |
| 151 | +The server also exposes journal data over HTTP (useful in CLI mode): |
| 152 | + |
| 153 | +- `GET /v1/_requests` — returns all journal entries as JSON. Supports `?limit=N`. |
| 154 | +- `DELETE /v1/_requests` — clears the journal. Returns 204. |
| 155 | + |
| 156 | +### Reset |
| 157 | + |
| 158 | +#### `reset()` |
| 159 | + |
| 160 | +Clear all fixtures **and** the journal in one call. Works before or after the server is started. |
| 161 | + |
| 162 | +```typescript |
| 163 | +afterEach(() => { |
| 164 | + mock.reset(); |
| 165 | +}); |
| 166 | +``` |
| 167 | + |
| 168 | +## Fixture Matching |
| 169 | + |
| 170 | +Fixtures are evaluated in registration order (first match wins). A fixture matches when **all** specified fields match the incoming request (AND logic). |
| 171 | + |
| 172 | +| Field | Type | Matches on | |
| 173 | +|---|---|---| |
| 174 | +| `userMessage` | `string \| RegExp` | Content of the last `role: "user"` message | |
| 175 | +| `toolName` | `string` | Name of a tool in the request's `tools` array | |
| 176 | +| `toolCallId` | `string` | `tool_call_id` on a `role: "tool"` message | |
| 177 | +| `model` | `string \| RegExp` | The `model` field in the request | |
| 178 | +| `predicate` | `(req) => boolean` | Arbitrary matching function | |
| 179 | + |
| 180 | +## Fixture Responses |
| 181 | + |
| 182 | +### Text |
| 183 | + |
| 184 | +```typescript |
| 185 | +{ content: "Hello world" } |
| 186 | +``` |
| 187 | + |
| 188 | +Streams as SSE chunks, splitting `content` by `chunkSize`. |
| 189 | + |
| 190 | +### Tool Calls |
| 191 | + |
| 192 | +```typescript |
| 193 | +{ |
| 194 | + toolCalls: [ |
| 195 | + { name: "get_weather", arguments: '{"location":"SF"}' } |
| 196 | + ] |
| 197 | +} |
| 198 | +``` |
| 199 | + |
| 200 | +### Errors |
| 201 | + |
| 202 | +```typescript |
| 203 | +{ |
| 204 | + error: { message: "Rate limited", type: "rate_limit_error" }, |
| 205 | + status: 429 |
| 206 | +} |
| 207 | +``` |
| 208 | + |
| 209 | +## Fixture Files |
| 210 | + |
| 211 | +Fixtures can be defined in JSON files for use with the CLI or `loadFixtureFile`/`loadFixtureDir`. |
| 212 | + |
| 213 | +```json |
| 214 | +{ |
| 215 | + "fixtures": [ |
| 216 | + { |
| 217 | + "match": { "userMessage": "hello" }, |
| 218 | + "response": { "content": "Hello! How can I help you today?" } |
| 219 | + }, |
| 220 | + { |
| 221 | + "match": { "toolName": "get_weather" }, |
| 222 | + "response": { |
| 223 | + "toolCalls": [ |
| 224 | + { |
| 225 | + "name": "get_weather", |
| 226 | + "arguments": "{\"location\":\"San Francisco\"}" |
| 227 | + } |
| 228 | + ] |
| 229 | + } |
| 230 | + } |
| 231 | + ] |
| 232 | +} |
| 233 | +``` |
| 234 | + |
| 235 | +Each entry can also include `latency` and `chunkSize` overrides. |
| 236 | + |
| 237 | +## CLI |
| 238 | + |
| 239 | +The package includes a standalone server binary: |
| 240 | + |
| 241 | +```bash |
| 242 | +mock-openai [options] |
| 243 | +``` |
| 244 | + |
| 245 | +| Option | Short | Default | Description | |
| 246 | +|---|---|---|---| |
| 247 | +| `--port` | `-p` | `4010` | Port to listen on | |
| 248 | +| `--host` | `-h` | `127.0.0.1` | Host to bind to | |
| 249 | +| `--fixtures` | `-f` | `./fixtures` | Path to fixtures directory or file | |
| 250 | +| `--latency` | `-l` | `0` | Latency between SSE chunks (ms) | |
| 251 | +| `--chunk-size` | `-c` | `20` | Characters per SSE chunk | |
| 252 | +| `--help` | | | Show help | |
| 253 | + |
| 254 | +```bash |
| 255 | +# Start with bundled example fixtures |
| 256 | +mock-openai |
| 257 | + |
| 258 | +# Custom fixtures on a specific port |
| 259 | +mock-openai -p 8080 -f ./my-fixtures |
| 260 | + |
| 261 | +# Simulate slow responses |
| 262 | +mock-openai --latency 100 --chunk-size 5 |
| 263 | +``` |
| 264 | + |
| 265 | +## Advanced Usage |
| 266 | + |
| 267 | +### Low-level Server |
| 268 | + |
| 269 | +If you need the raw HTTP server without the `MockOpenAI` wrapper: |
| 270 | + |
| 271 | +```typescript |
| 272 | +import { createServer } from "@copilotkit/mock-openai"; |
| 273 | + |
| 274 | +const fixtures = [ |
| 275 | + { match: { userMessage: "hi" }, response: { content: "Hello!" } }, |
| 276 | +]; |
| 277 | + |
| 278 | +const { server, journal, url } = await createServer(fixtures, { port: 0 }); |
| 279 | +// ... use it ... |
| 280 | +server.close(); |
| 281 | +``` |
| 282 | + |
| 283 | +### Custom Matching with Predicates |
| 284 | + |
| 285 | +```typescript |
| 286 | +mock.on( |
| 287 | + { |
| 288 | + predicate: (req) => |
| 289 | + req.messages.length > 5 && req.model.startsWith("gpt-4"), |
| 290 | + }, |
| 291 | + { content: "You've been chatting a while!" }, |
| 292 | +); |
| 293 | +``` |
| 294 | + |
| 295 | +### Per-Fixture Timing |
| 296 | + |
| 297 | +```typescript |
| 298 | +mock.on( |
| 299 | + { userMessage: "slow" }, |
| 300 | + { content: "Finally..." }, |
| 301 | + { latency: 200, chunkSize: 5 }, |
| 302 | +); |
| 303 | +``` |
| 304 | + |
| 305 | +## License |
| 306 | + |
| 307 | +MIT |
0 commit comments