@nemesis-oss/ollama-sdk 1.13.0 → 1.14.0
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.
- package/CHANGELOG.md +24 -0
- package/README.md +110 -4
- package/dist/{anthropic-B9AFyzXI.d.ts → anthropic-DrDUfbfQ.d.ts} +1 -1
- package/dist/{anthropic-CYy_ECam.d.cts → anthropic-UeaVjLeP.d.cts} +1 -1
- package/dist/{compose-6UOPEknN.d.ts → compose-CeFbHJeO.d.ts} +1 -1
- package/dist/{compose-DhvoUrWa.d.cts → compose-DFkmtUOP.d.cts} +1 -1
- package/dist/generated-api.d.cts +5 -5
- package/dist/generated-api.d.ts +5 -5
- package/dist/generated-runtime.d.cts +2 -2
- package/dist/generated-runtime.d.ts +2 -2
- package/dist/index.cjs +34 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +40 -11
- package/dist/index.d.ts +40 -11
- package/dist/index.js +34 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp-generated.d.cts +2 -2
- package/dist/mcp-generated.d.ts +2 -2
- package/dist/{runtime-CIQYCRL3.d.ts → runtime-BIjr5ME4.d.ts} +1 -1
- package/dist/{runtime-DmI1cOEq.d.cts → runtime-C4RRHqOs.d.cts} +1 -1
- package/dist/skills.d.cts +3 -3
- package/dist/skills.d.ts +3 -3
- package/dist/testing.cjs +281 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +336 -0
- package/dist/testing.d.ts +336 -0
- package/dist/testing.js +275 -0
- package/dist/testing.js.map +1 -0
- package/dist/{types-B57ZZfp3.d.cts → types-DbPnfVh-.d.cts} +20 -3
- package/dist/{types-B57ZZfp3.d.ts → types-DbPnfVh-.d.ts} +20 -3
- package/package.json +14 -1
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory Ollama mock server for deterministic, zero-network CI testing.
|
|
3
|
+
*
|
|
4
|
+
* This is the SDK's own mock engine, promoted from the internal test fixture
|
|
5
|
+
* (`test/mocks/ollama-mock-server.ts`) to a public entry point
|
|
6
|
+
* (`@nemesis-oss/ollama-sdk/testing`) so consumers don't have to hand-roll
|
|
7
|
+
* ad-hoc HTTP stubs to test their Ollama integrations — see ADR 0029.
|
|
8
|
+
* The internal fixture re-exports this module, so the SDK's own test suite
|
|
9
|
+
* exercises the exact same code consumers get.
|
|
10
|
+
*
|
|
11
|
+
* The VCR cassette system (`test/vcr.ts` in the repo) replays recorded HTTP
|
|
12
|
+
* interactions, but cassettes are static. This mock server covers what
|
|
13
|
+
* cassettes can't:
|
|
14
|
+
*
|
|
15
|
+
* - programmable inter-chunk delays (backpressure handling)
|
|
16
|
+
* - mid-stream **in-band error injection** — an `{"error": "..."}` NDJSON
|
|
17
|
+
* frame emitted mid-response with HTTP 200 still, exactly how a real
|
|
18
|
+
* Ollama daemon reports GPU OOM / driver crashes mid-generation
|
|
19
|
+
* - connection drops mid-stream (`dropConnectionMidStream`)
|
|
20
|
+
* - 503/502/429 error status codes with custom bodies
|
|
21
|
+
* - arbitrary NDJSON chunk payloads (tool calls, thinking, partial frames)
|
|
22
|
+
*
|
|
23
|
+
* On top of the raw engine, the public surface adds the conveniences a
|
|
24
|
+
* consumer test suite wants on day one:
|
|
25
|
+
*
|
|
26
|
+
* - {@link createMockOllamaServer} factory (the recommended entry point)
|
|
27
|
+
* - built-in **default routes** — the official liveness probe (`HEAD /`
|
|
28
|
+
* and `GET /` → "Ollama is running") and `GET /api/version` — so
|
|
29
|
+
* `client.ping()` and friends work before any route is registered.
|
|
30
|
+
* Explicit `register()` calls always win.
|
|
31
|
+
* - **request recording** (`server.requests`, `clearRequests()`) for
|
|
32
|
+
* wire-level assertions without per-route callbacks
|
|
33
|
+
* - NDJSON **chunk builders** ({@link mockChatChunk}, {@link mockTextChunks},
|
|
34
|
+
* {@link mockToolCallChunks}) that produce correctly-shaped `/api/chat`
|
|
35
|
+
* frames — including parallel tool calls in one frame, the documented
|
|
36
|
+
* native behavior
|
|
37
|
+
*
|
|
38
|
+
* ## Usage
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* import { createMockOllamaServer, mockToolCallChunks } from '@nemesis-oss/ollama-sdk/testing';
|
|
42
|
+
* import { OllamaClient } from '@nemesis-oss/ollama-sdk';
|
|
43
|
+
*
|
|
44
|
+
* const server = createMockOllamaServer(); // ephemeral port, default routes on
|
|
45
|
+
* await server.start();
|
|
46
|
+
*
|
|
47
|
+
* server.register('/api/chat', {
|
|
48
|
+
* chunks: mockToolCallChunks([
|
|
49
|
+
* { name: 'get_weather', arguments: { city: 'Tokyo' } },
|
|
50
|
+
* { name: 'get_time', arguments: { tz: 'Asia/Tokyo' } },
|
|
51
|
+
* ]),
|
|
52
|
+
* });
|
|
53
|
+
*
|
|
54
|
+
* const client = new OllamaClient({ baseUrl: server.baseUrl });
|
|
55
|
+
* // ... exercise the client; assert on server.requests ...
|
|
56
|
+
*
|
|
57
|
+
* await server.stop();
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* ## Environment
|
|
61
|
+
*
|
|
62
|
+
* Node.js only — the module builds on `node:http`, which is why it lives in
|
|
63
|
+
* a dedicated subpath export: importing it from the browser/Edge-safe main
|
|
64
|
+
* entry would drag a Node built-in into every bundle. Never re-export this
|
|
65
|
+
* module from `src/index.ts`; the Edge Runtime verification
|
|
66
|
+
* (`npm run verify:edge-runtime`) exists to catch exactly that.
|
|
67
|
+
*
|
|
68
|
+
* Designed for Vitest/Jest: `start()` resolves once the server listens and
|
|
69
|
+
* `stop()` is idempotent, so `beforeEach`/`afterEach` hooks manage the
|
|
70
|
+
* lifecycle without tracking whether `start()` ran.
|
|
71
|
+
*/
|
|
72
|
+
/** One recorded incoming request, for wire-level test assertions. */
|
|
73
|
+
interface MockRequestRecord {
|
|
74
|
+
/** HTTP method (`GET`, `POST`, `HEAD`, ...). */
|
|
75
|
+
readonly method: string;
|
|
76
|
+
/** Request path with the query string stripped. */
|
|
77
|
+
readonly path: string;
|
|
78
|
+
/** Parsed request body (JSON-parsed when possible, raw string otherwise); `undefined` for body-less methods. */
|
|
79
|
+
readonly body?: unknown;
|
|
80
|
+
/** ISO-8601 timestamp of when the request arrived. */
|
|
81
|
+
readonly timestamp: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A single mock route handler. The server matches on the request path
|
|
85
|
+
* (query string stripped — Ollama's API doesn't use query params for the
|
|
86
|
+
* routes we care about).
|
|
87
|
+
*
|
|
88
|
+
* All fields optional except `chunks` (which defaults to `[]` — useful for
|
|
89
|
+
* testing the "stream closed without any data" case).
|
|
90
|
+
*/
|
|
91
|
+
interface MockEndpointOptions {
|
|
92
|
+
/** HTTP status code. Defaults to 200. */
|
|
93
|
+
readonly status?: number | undefined;
|
|
94
|
+
/**
|
|
95
|
+
* NDJSON chunks to emit, in order. Each chunk is emitted as a separate
|
|
96
|
+
* `res.write()` call followed by `\n`. Pass `[]` to emit no body (useful
|
|
97
|
+
* for testing the "stream closed without data" case).
|
|
98
|
+
*/
|
|
99
|
+
readonly chunks?: readonly string[] | undefined;
|
|
100
|
+
/**
|
|
101
|
+
* Delay (ms) before emitting each chunk. Defaults to 0 (no delay). Set to
|
|
102
|
+
* a small value (5-50ms) to test backpressure handling; set to a large
|
|
103
|
+
* value (10_000ms) to test timeout/cancellation.
|
|
104
|
+
*/
|
|
105
|
+
readonly chunkDelayMs?: number | undefined;
|
|
106
|
+
/**
|
|
107
|
+
* Custom response headers. Defaults to
|
|
108
|
+
* `{ 'content-type': 'application/x-ndjson' }` for streaming endpoints.
|
|
109
|
+
* Override for non-JSON responses (e.g. blob uploads).
|
|
110
|
+
*/
|
|
111
|
+
readonly headers?: Record<string, string> | undefined;
|
|
112
|
+
/**
|
|
113
|
+
* When `true`, the server closes the connection mid-stream after emitting
|
|
114
|
+
* the first chunk. Use this to test connection-drop recovery. Defaults to
|
|
115
|
+
* `false`.
|
|
116
|
+
*/
|
|
117
|
+
readonly dropConnectionMidStream?: boolean | undefined;
|
|
118
|
+
/**
|
|
119
|
+
* Emit this exact string as the whole response body — no NDJSON newline
|
|
120
|
+
* framing. Overrides `chunks` (use it for non-JSON responses such as the
|
|
121
|
+
* plain-text liveness body or raw blob bytes).
|
|
122
|
+
*/
|
|
123
|
+
readonly rawBody?: string | undefined;
|
|
124
|
+
/**
|
|
125
|
+
* Inject an in-band error frame — `{"error": "<message>"}` — into the
|
|
126
|
+
* NDJSON response while the HTTP status stays 200. This is how a real
|
|
127
|
+
* Ollama daemon reports mid-generation failures (GPU OOM, driver crash,
|
|
128
|
+
* context overflow): the status code never changes, the parser has to
|
|
129
|
+
* catch the frame. The SDK's stream readers raise `OllamaStreamError` on
|
|
130
|
+
* it.
|
|
131
|
+
*
|
|
132
|
+
* `after` is the number of chunks emitted before the error frame. The
|
|
133
|
+
* default is `chunks.length - 1` — every content chunk, then the error
|
|
134
|
+
* frame, with the final `done` frame never emitted, exactly what a
|
|
135
|
+
* mid-generation crash looks like on the wire (an error frame *after* the
|
|
136
|
+
* done frame would be unread: the stream has already completed cleanly).
|
|
137
|
+
* `after: 0` errors before any content; an explicit `after >=
|
|
138
|
+
* chunks.length` emits every chunk and then the error. Chunks at or after
|
|
139
|
+
* the injection point are never emitted: the server "crashed".
|
|
140
|
+
*/
|
|
141
|
+
readonly injectErrorFrame?: {
|
|
142
|
+
readonly after?: number | undefined;
|
|
143
|
+
readonly message: string;
|
|
144
|
+
} | undefined;
|
|
145
|
+
/**
|
|
146
|
+
* Optional callback fired when a request arrives at this route. Receives
|
|
147
|
+
* the parsed request body (or `undefined` if the body wasn't valid JSON).
|
|
148
|
+
* Useful for assertions like "the client sent the right `model` field".
|
|
149
|
+
* For global capture across routes see {@link OllamaMockServer.requests}.
|
|
150
|
+
*/
|
|
151
|
+
readonly onRequest?: ((body: unknown) => void) | undefined;
|
|
152
|
+
}
|
|
153
|
+
/** Options for {@link createMockOllamaServer} and the `OllamaMockServer` constructor. */
|
|
154
|
+
interface MockServerOptions {
|
|
155
|
+
/** TCP port to listen on. `0` (the default) picks an ephemeral port. */
|
|
156
|
+
readonly port?: number | undefined;
|
|
157
|
+
/**
|
|
158
|
+
* Serve the built-in default routes when no route is explicitly registered
|
|
159
|
+
* for a path: `HEAD /` and `GET /` → `200` (the official liveness probe —
|
|
160
|
+
* `GET /` answers the plain-text body "Ollama is running") and
|
|
161
|
+
* `GET /api/version` → `200 {"version": "<version>"}`. Defaults to `true`.
|
|
162
|
+
* Explicit `register()` calls always take precedence.
|
|
163
|
+
*/
|
|
164
|
+
readonly defaultRoutes?: boolean | undefined;
|
|
165
|
+
/** Version reported by the default `GET /api/version` route. Defaults to `'0.14.0'`. */
|
|
166
|
+
readonly version?: string | undefined;
|
|
167
|
+
}
|
|
168
|
+
/** Shape accepted by {@link mockToolCallChunks} for each tool call. */
|
|
169
|
+
interface MockToolCallSpec {
|
|
170
|
+
/** Tool-call id; auto-generated (`call-0`, `call-1`, …) when omitted. */
|
|
171
|
+
readonly id?: string | undefined;
|
|
172
|
+
/** Tool name, as the model would emit it. */
|
|
173
|
+
readonly name: string;
|
|
174
|
+
/** Tool arguments — an object (stringified by the builder) or a pre-encoded string. */
|
|
175
|
+
readonly arguments: Record<string, unknown> | string;
|
|
176
|
+
}
|
|
177
|
+
/** Options for {@link mockTextChunks}. */
|
|
178
|
+
interface MockTextChunksOptions {
|
|
179
|
+
/** Split the text into content frames of at most this many characters. Default: one frame with the whole text. */
|
|
180
|
+
readonly chunkSize?: number | undefined;
|
|
181
|
+
/** Model name stamped on each frame. Defaults to `'mock-model'`. */
|
|
182
|
+
readonly model?: string | undefined;
|
|
183
|
+
}
|
|
184
|
+
/** Options for {@link mockToolCallChunks}. */
|
|
185
|
+
interface MockToolCallChunksOptions {
|
|
186
|
+
/** Model name stamped on each frame. Defaults to `'mock-model'`. */
|
|
187
|
+
readonly model?: string | undefined;
|
|
188
|
+
/** Assistant text to carry on the tool-call frame (some models emit a preamble). Defaults to `''`. */
|
|
189
|
+
readonly content?: string | undefined;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* One `/api/chat` NDJSON frame as a ready-to-emit string. Sensible defaults:
|
|
193
|
+
* `model: 'mock-model'`, a fresh `created_at`, `done: false`.
|
|
194
|
+
*
|
|
195
|
+
* ```ts
|
|
196
|
+
* server.register('/api/chat', {
|
|
197
|
+
* chunks: [
|
|
198
|
+
* mockChatChunk({ content: 'Hel' }),
|
|
199
|
+
* mockChatChunk({ content: 'lo' }),
|
|
200
|
+
* mockChatChunk({ done: true, evalCount: 2, evalDurationNs: 20_000_000 }),
|
|
201
|
+
* ],
|
|
202
|
+
* });
|
|
203
|
+
* ```
|
|
204
|
+
*/
|
|
205
|
+
declare function mockChatChunk(frame: {
|
|
206
|
+
readonly content?: string | undefined;
|
|
207
|
+
readonly toolCalls?: readonly MockToolCallSpec[] | undefined;
|
|
208
|
+
readonly done?: boolean | undefined;
|
|
209
|
+
readonly model?: string | undefined;
|
|
210
|
+
readonly evalCount?: number | undefined;
|
|
211
|
+
readonly evalDurationNs?: number | undefined;
|
|
212
|
+
readonly promptEvalCount?: number | undefined;
|
|
213
|
+
readonly promptEvalDurationNs?: number | undefined;
|
|
214
|
+
readonly promptEvalCachedCount?: number | undefined;
|
|
215
|
+
readonly totalDurationNs?: number | undefined;
|
|
216
|
+
readonly loadDurationNs?: number | undefined;
|
|
217
|
+
}): string;
|
|
218
|
+
/**
|
|
219
|
+
* Content frames + a final `done` frame for a plain-text `/api/chat` reply.
|
|
220
|
+
* With `chunkSize`, the text is split into that many characters per frame —
|
|
221
|
+
* the shape streaming code needs to exercise incremental assembly.
|
|
222
|
+
*
|
|
223
|
+
* ```ts
|
|
224
|
+
* server.register('/api/chat', {
|
|
225
|
+
* chunks: mockTextChunks('Hello, world!', { chunkSize: 5 }),
|
|
226
|
+
* });
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
declare function mockTextChunks(text: string, opts?: MockTextChunksOptions): string[];
|
|
230
|
+
/**
|
|
231
|
+
* Tool-call frames for `/api/chat` — the documented native shape: tool calls
|
|
232
|
+
* arrive **complete in one frame** (parallel calls = one array with several
|
|
233
|
+
* entries), followed by a final `done` frame. Pair with an `Agent` or tool
|
|
234
|
+
* loop for deterministic tool-execution tests.
|
|
235
|
+
*
|
|
236
|
+
* ```ts
|
|
237
|
+
* // Parallel tool calls, one round trip:
|
|
238
|
+
* server.register('/api/chat', {
|
|
239
|
+
* chunks: mockToolCallChunks([
|
|
240
|
+
* { name: 'get_weather', arguments: { city: 'Tokyo' } },
|
|
241
|
+
* { name: 'get_time', arguments: { tz: 'Asia/Tokyo' } },
|
|
242
|
+
* ]),
|
|
243
|
+
* });
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
declare function mockToolCallChunks(toolCalls: readonly MockToolCallSpec[], opts?: MockToolCallChunksOptions): string[];
|
|
247
|
+
/**
|
|
248
|
+
* Lightweight in-memory Ollama mock server.
|
|
249
|
+
*
|
|
250
|
+
* Spawns a real `node:http` server on a free port (or a fixed port if you
|
|
251
|
+
* pass one). Tests register route handlers via `register()`, then point an
|
|
252
|
+
* `OllamaClient` at `server.baseUrl`. The server emits the registered chunks
|
|
253
|
+
* with the configured delay, status, and connection behavior; when no route
|
|
254
|
+
* is registered the built-in liveness + version defaults answer (see
|
|
255
|
+
* {@link MockServerOptions.defaultRoutes}).
|
|
256
|
+
*
|
|
257
|
+
* Prefer the {@link createMockOllamaServer} factory; the class is exported
|
|
258
|
+
* for typed references and back-compatibility with the SDK's own suite.
|
|
259
|
+
*
|
|
260
|
+
* The server is **single-process** — it doesn't fork or use workers. It's
|
|
261
|
+
* intended for unit/integration tests, not load testing.
|
|
262
|
+
*/
|
|
263
|
+
declare class OllamaMockServer {
|
|
264
|
+
private readonly server;
|
|
265
|
+
private readonly routes;
|
|
266
|
+
private readonly requestLog;
|
|
267
|
+
private readonly defaultsEnabled;
|
|
268
|
+
private readonly defaultVersion;
|
|
269
|
+
private port;
|
|
270
|
+
/**
|
|
271
|
+
* @param portOrOptions Either a TCP port (pass `0` for an ephemeral port —
|
|
272
|
+
* recommended for parallel test runs) or a {@link MockServerOptions}
|
|
273
|
+
* object. The actual port is exposed via {@link baseUrl} after
|
|
274
|
+
* {@link start}.
|
|
275
|
+
*/
|
|
276
|
+
constructor(portOrOptions?: number | MockServerOptions);
|
|
277
|
+
/**
|
|
278
|
+
* Register a route handler. Replaces any existing handler for the same
|
|
279
|
+
* path (including a built-in default). The path is matched against
|
|
280
|
+
* `req.url` exactly (query string stripped, no template matching — for
|
|
281
|
+
* `/api/blobs/{digest}`, register each specific digest value).
|
|
282
|
+
*/
|
|
283
|
+
register(path: string, options: MockEndpointOptions): void;
|
|
284
|
+
/** Clear all registered routes (built-in defaults remain). Useful in `beforeEach` hooks. */
|
|
285
|
+
clear(): void;
|
|
286
|
+
/**
|
|
287
|
+
* Every request this server has served, in arrival order — method, path,
|
|
288
|
+
* parsed body, timestamp — including 404s and default-route hits. Assert
|
|
289
|
+
* on what the client actually put on the wire; clear with
|
|
290
|
+
* {@link clearRequests} (e.g. in `beforeEach`).
|
|
291
|
+
*/
|
|
292
|
+
get requests(): readonly MockRequestRecord[];
|
|
293
|
+
/** Empty the request log ({@link requests}). Routes are unaffected. */
|
|
294
|
+
clearRequests(): void;
|
|
295
|
+
/**
|
|
296
|
+
* Base URL to point an `OllamaClient` at. Only meaningful after
|
|
297
|
+
* {@link start} has resolved.
|
|
298
|
+
*/
|
|
299
|
+
get baseUrl(): string;
|
|
300
|
+
/**
|
|
301
|
+
* Start listening. Resolves once the server is ready to accept
|
|
302
|
+
* connections.
|
|
303
|
+
*/
|
|
304
|
+
start(): Promise<void>;
|
|
305
|
+
/**
|
|
306
|
+
* Stop listening and close all active connections. Resolves once the
|
|
307
|
+
* server is fully closed (no lingering sockets). Idempotent — calling
|
|
308
|
+
* `stop()` on an already-stopped server resolves cleanly rather than
|
|
309
|
+
* throwing `ERR_SERVER_NOT_RUNNING`.
|
|
310
|
+
*/
|
|
311
|
+
stop(): Promise<void>;
|
|
312
|
+
/**
|
|
313
|
+
* Built-in default handler for a path, or `undefined` when defaults are
|
|
314
|
+
* disabled or the path isn't one of the official liveness surfaces.
|
|
315
|
+
* Registered routes are checked first and always win.
|
|
316
|
+
*/
|
|
317
|
+
private defaultRouteFor;
|
|
318
|
+
private handleRequest;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Create an in-memory Ollama mock server — the recommended entry point for
|
|
322
|
+
* consumer test suites. Ephemeral port, built-in liveness + version
|
|
323
|
+
* defaults on, empty request log. Call `await server.start()` before
|
|
324
|
+
* pointing an `OllamaClient` at `server.baseUrl`, and `await server.stop()`
|
|
325
|
+
* in teardown.
|
|
326
|
+
*
|
|
327
|
+
* ```ts
|
|
328
|
+
* const server = createMockOllamaServer();
|
|
329
|
+
* await server.start();
|
|
330
|
+
* // defaults already answer: HEAD / (ping) and GET /api/version
|
|
331
|
+
* server.register('/api/chat', { chunks: mockTextChunks('Hi!') });
|
|
332
|
+
* ```
|
|
333
|
+
*/
|
|
334
|
+
declare function createMockOllamaServer(options?: MockServerOptions): OllamaMockServer;
|
|
335
|
+
|
|
336
|
+
export { type MockEndpointOptions, type MockRequestRecord, type MockServerOptions, type MockTextChunksOptions, type MockToolCallChunksOptions, type MockToolCallSpec, OllamaMockServer, createMockOllamaServer, mockChatChunk, mockTextChunks, mockToolCallChunks };
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory Ollama mock server for deterministic, zero-network CI testing.
|
|
3
|
+
*
|
|
4
|
+
* This is the SDK's own mock engine, promoted from the internal test fixture
|
|
5
|
+
* (`test/mocks/ollama-mock-server.ts`) to a public entry point
|
|
6
|
+
* (`@nemesis-oss/ollama-sdk/testing`) so consumers don't have to hand-roll
|
|
7
|
+
* ad-hoc HTTP stubs to test their Ollama integrations — see ADR 0029.
|
|
8
|
+
* The internal fixture re-exports this module, so the SDK's own test suite
|
|
9
|
+
* exercises the exact same code consumers get.
|
|
10
|
+
*
|
|
11
|
+
* The VCR cassette system (`test/vcr.ts` in the repo) replays recorded HTTP
|
|
12
|
+
* interactions, but cassettes are static. This mock server covers what
|
|
13
|
+
* cassettes can't:
|
|
14
|
+
*
|
|
15
|
+
* - programmable inter-chunk delays (backpressure handling)
|
|
16
|
+
* - mid-stream **in-band error injection** — an `{"error": "..."}` NDJSON
|
|
17
|
+
* frame emitted mid-response with HTTP 200 still, exactly how a real
|
|
18
|
+
* Ollama daemon reports GPU OOM / driver crashes mid-generation
|
|
19
|
+
* - connection drops mid-stream (`dropConnectionMidStream`)
|
|
20
|
+
* - 503/502/429 error status codes with custom bodies
|
|
21
|
+
* - arbitrary NDJSON chunk payloads (tool calls, thinking, partial frames)
|
|
22
|
+
*
|
|
23
|
+
* On top of the raw engine, the public surface adds the conveniences a
|
|
24
|
+
* consumer test suite wants on day one:
|
|
25
|
+
*
|
|
26
|
+
* - {@link createMockOllamaServer} factory (the recommended entry point)
|
|
27
|
+
* - built-in **default routes** — the official liveness probe (`HEAD /`
|
|
28
|
+
* and `GET /` → "Ollama is running") and `GET /api/version` — so
|
|
29
|
+
* `client.ping()` and friends work before any route is registered.
|
|
30
|
+
* Explicit `register()` calls always win.
|
|
31
|
+
* - **request recording** (`server.requests`, `clearRequests()`) for
|
|
32
|
+
* wire-level assertions without per-route callbacks
|
|
33
|
+
* - NDJSON **chunk builders** ({@link mockChatChunk}, {@link mockTextChunks},
|
|
34
|
+
* {@link mockToolCallChunks}) that produce correctly-shaped `/api/chat`
|
|
35
|
+
* frames — including parallel tool calls in one frame, the documented
|
|
36
|
+
* native behavior
|
|
37
|
+
*
|
|
38
|
+
* ## Usage
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* import { createMockOllamaServer, mockToolCallChunks } from '@nemesis-oss/ollama-sdk/testing';
|
|
42
|
+
* import { OllamaClient } from '@nemesis-oss/ollama-sdk';
|
|
43
|
+
*
|
|
44
|
+
* const server = createMockOllamaServer(); // ephemeral port, default routes on
|
|
45
|
+
* await server.start();
|
|
46
|
+
*
|
|
47
|
+
* server.register('/api/chat', {
|
|
48
|
+
* chunks: mockToolCallChunks([
|
|
49
|
+
* { name: 'get_weather', arguments: { city: 'Tokyo' } },
|
|
50
|
+
* { name: 'get_time', arguments: { tz: 'Asia/Tokyo' } },
|
|
51
|
+
* ]),
|
|
52
|
+
* });
|
|
53
|
+
*
|
|
54
|
+
* const client = new OllamaClient({ baseUrl: server.baseUrl });
|
|
55
|
+
* // ... exercise the client; assert on server.requests ...
|
|
56
|
+
*
|
|
57
|
+
* await server.stop();
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* ## Environment
|
|
61
|
+
*
|
|
62
|
+
* Node.js only — the module builds on `node:http`, which is why it lives in
|
|
63
|
+
* a dedicated subpath export: importing it from the browser/Edge-safe main
|
|
64
|
+
* entry would drag a Node built-in into every bundle. Never re-export this
|
|
65
|
+
* module from `src/index.ts`; the Edge Runtime verification
|
|
66
|
+
* (`npm run verify:edge-runtime`) exists to catch exactly that.
|
|
67
|
+
*
|
|
68
|
+
* Designed for Vitest/Jest: `start()` resolves once the server listens and
|
|
69
|
+
* `stop()` is idempotent, so `beforeEach`/`afterEach` hooks manage the
|
|
70
|
+
* lifecycle without tracking whether `start()` ran.
|
|
71
|
+
*/
|
|
72
|
+
/** One recorded incoming request, for wire-level test assertions. */
|
|
73
|
+
interface MockRequestRecord {
|
|
74
|
+
/** HTTP method (`GET`, `POST`, `HEAD`, ...). */
|
|
75
|
+
readonly method: string;
|
|
76
|
+
/** Request path with the query string stripped. */
|
|
77
|
+
readonly path: string;
|
|
78
|
+
/** Parsed request body (JSON-parsed when possible, raw string otherwise); `undefined` for body-less methods. */
|
|
79
|
+
readonly body?: unknown;
|
|
80
|
+
/** ISO-8601 timestamp of when the request arrived. */
|
|
81
|
+
readonly timestamp: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A single mock route handler. The server matches on the request path
|
|
85
|
+
* (query string stripped — Ollama's API doesn't use query params for the
|
|
86
|
+
* routes we care about).
|
|
87
|
+
*
|
|
88
|
+
* All fields optional except `chunks` (which defaults to `[]` — useful for
|
|
89
|
+
* testing the "stream closed without any data" case).
|
|
90
|
+
*/
|
|
91
|
+
interface MockEndpointOptions {
|
|
92
|
+
/** HTTP status code. Defaults to 200. */
|
|
93
|
+
readonly status?: number | undefined;
|
|
94
|
+
/**
|
|
95
|
+
* NDJSON chunks to emit, in order. Each chunk is emitted as a separate
|
|
96
|
+
* `res.write()` call followed by `\n`. Pass `[]` to emit no body (useful
|
|
97
|
+
* for testing the "stream closed without data" case).
|
|
98
|
+
*/
|
|
99
|
+
readonly chunks?: readonly string[] | undefined;
|
|
100
|
+
/**
|
|
101
|
+
* Delay (ms) before emitting each chunk. Defaults to 0 (no delay). Set to
|
|
102
|
+
* a small value (5-50ms) to test backpressure handling; set to a large
|
|
103
|
+
* value (10_000ms) to test timeout/cancellation.
|
|
104
|
+
*/
|
|
105
|
+
readonly chunkDelayMs?: number | undefined;
|
|
106
|
+
/**
|
|
107
|
+
* Custom response headers. Defaults to
|
|
108
|
+
* `{ 'content-type': 'application/x-ndjson' }` for streaming endpoints.
|
|
109
|
+
* Override for non-JSON responses (e.g. blob uploads).
|
|
110
|
+
*/
|
|
111
|
+
readonly headers?: Record<string, string> | undefined;
|
|
112
|
+
/**
|
|
113
|
+
* When `true`, the server closes the connection mid-stream after emitting
|
|
114
|
+
* the first chunk. Use this to test connection-drop recovery. Defaults to
|
|
115
|
+
* `false`.
|
|
116
|
+
*/
|
|
117
|
+
readonly dropConnectionMidStream?: boolean | undefined;
|
|
118
|
+
/**
|
|
119
|
+
* Emit this exact string as the whole response body — no NDJSON newline
|
|
120
|
+
* framing. Overrides `chunks` (use it for non-JSON responses such as the
|
|
121
|
+
* plain-text liveness body or raw blob bytes).
|
|
122
|
+
*/
|
|
123
|
+
readonly rawBody?: string | undefined;
|
|
124
|
+
/**
|
|
125
|
+
* Inject an in-band error frame — `{"error": "<message>"}` — into the
|
|
126
|
+
* NDJSON response while the HTTP status stays 200. This is how a real
|
|
127
|
+
* Ollama daemon reports mid-generation failures (GPU OOM, driver crash,
|
|
128
|
+
* context overflow): the status code never changes, the parser has to
|
|
129
|
+
* catch the frame. The SDK's stream readers raise `OllamaStreamError` on
|
|
130
|
+
* it.
|
|
131
|
+
*
|
|
132
|
+
* `after` is the number of chunks emitted before the error frame. The
|
|
133
|
+
* default is `chunks.length - 1` — every content chunk, then the error
|
|
134
|
+
* frame, with the final `done` frame never emitted, exactly what a
|
|
135
|
+
* mid-generation crash looks like on the wire (an error frame *after* the
|
|
136
|
+
* done frame would be unread: the stream has already completed cleanly).
|
|
137
|
+
* `after: 0` errors before any content; an explicit `after >=
|
|
138
|
+
* chunks.length` emits every chunk and then the error. Chunks at or after
|
|
139
|
+
* the injection point are never emitted: the server "crashed".
|
|
140
|
+
*/
|
|
141
|
+
readonly injectErrorFrame?: {
|
|
142
|
+
readonly after?: number | undefined;
|
|
143
|
+
readonly message: string;
|
|
144
|
+
} | undefined;
|
|
145
|
+
/**
|
|
146
|
+
* Optional callback fired when a request arrives at this route. Receives
|
|
147
|
+
* the parsed request body (or `undefined` if the body wasn't valid JSON).
|
|
148
|
+
* Useful for assertions like "the client sent the right `model` field".
|
|
149
|
+
* For global capture across routes see {@link OllamaMockServer.requests}.
|
|
150
|
+
*/
|
|
151
|
+
readonly onRequest?: ((body: unknown) => void) | undefined;
|
|
152
|
+
}
|
|
153
|
+
/** Options for {@link createMockOllamaServer} and the `OllamaMockServer` constructor. */
|
|
154
|
+
interface MockServerOptions {
|
|
155
|
+
/** TCP port to listen on. `0` (the default) picks an ephemeral port. */
|
|
156
|
+
readonly port?: number | undefined;
|
|
157
|
+
/**
|
|
158
|
+
* Serve the built-in default routes when no route is explicitly registered
|
|
159
|
+
* for a path: `HEAD /` and `GET /` → `200` (the official liveness probe —
|
|
160
|
+
* `GET /` answers the plain-text body "Ollama is running") and
|
|
161
|
+
* `GET /api/version` → `200 {"version": "<version>"}`. Defaults to `true`.
|
|
162
|
+
* Explicit `register()` calls always take precedence.
|
|
163
|
+
*/
|
|
164
|
+
readonly defaultRoutes?: boolean | undefined;
|
|
165
|
+
/** Version reported by the default `GET /api/version` route. Defaults to `'0.14.0'`. */
|
|
166
|
+
readonly version?: string | undefined;
|
|
167
|
+
}
|
|
168
|
+
/** Shape accepted by {@link mockToolCallChunks} for each tool call. */
|
|
169
|
+
interface MockToolCallSpec {
|
|
170
|
+
/** Tool-call id; auto-generated (`call-0`, `call-1`, …) when omitted. */
|
|
171
|
+
readonly id?: string | undefined;
|
|
172
|
+
/** Tool name, as the model would emit it. */
|
|
173
|
+
readonly name: string;
|
|
174
|
+
/** Tool arguments — an object (stringified by the builder) or a pre-encoded string. */
|
|
175
|
+
readonly arguments: Record<string, unknown> | string;
|
|
176
|
+
}
|
|
177
|
+
/** Options for {@link mockTextChunks}. */
|
|
178
|
+
interface MockTextChunksOptions {
|
|
179
|
+
/** Split the text into content frames of at most this many characters. Default: one frame with the whole text. */
|
|
180
|
+
readonly chunkSize?: number | undefined;
|
|
181
|
+
/** Model name stamped on each frame. Defaults to `'mock-model'`. */
|
|
182
|
+
readonly model?: string | undefined;
|
|
183
|
+
}
|
|
184
|
+
/** Options for {@link mockToolCallChunks}. */
|
|
185
|
+
interface MockToolCallChunksOptions {
|
|
186
|
+
/** Model name stamped on each frame. Defaults to `'mock-model'`. */
|
|
187
|
+
readonly model?: string | undefined;
|
|
188
|
+
/** Assistant text to carry on the tool-call frame (some models emit a preamble). Defaults to `''`. */
|
|
189
|
+
readonly content?: string | undefined;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* One `/api/chat` NDJSON frame as a ready-to-emit string. Sensible defaults:
|
|
193
|
+
* `model: 'mock-model'`, a fresh `created_at`, `done: false`.
|
|
194
|
+
*
|
|
195
|
+
* ```ts
|
|
196
|
+
* server.register('/api/chat', {
|
|
197
|
+
* chunks: [
|
|
198
|
+
* mockChatChunk({ content: 'Hel' }),
|
|
199
|
+
* mockChatChunk({ content: 'lo' }),
|
|
200
|
+
* mockChatChunk({ done: true, evalCount: 2, evalDurationNs: 20_000_000 }),
|
|
201
|
+
* ],
|
|
202
|
+
* });
|
|
203
|
+
* ```
|
|
204
|
+
*/
|
|
205
|
+
declare function mockChatChunk(frame: {
|
|
206
|
+
readonly content?: string | undefined;
|
|
207
|
+
readonly toolCalls?: readonly MockToolCallSpec[] | undefined;
|
|
208
|
+
readonly done?: boolean | undefined;
|
|
209
|
+
readonly model?: string | undefined;
|
|
210
|
+
readonly evalCount?: number | undefined;
|
|
211
|
+
readonly evalDurationNs?: number | undefined;
|
|
212
|
+
readonly promptEvalCount?: number | undefined;
|
|
213
|
+
readonly promptEvalDurationNs?: number | undefined;
|
|
214
|
+
readonly promptEvalCachedCount?: number | undefined;
|
|
215
|
+
readonly totalDurationNs?: number | undefined;
|
|
216
|
+
readonly loadDurationNs?: number | undefined;
|
|
217
|
+
}): string;
|
|
218
|
+
/**
|
|
219
|
+
* Content frames + a final `done` frame for a plain-text `/api/chat` reply.
|
|
220
|
+
* With `chunkSize`, the text is split into that many characters per frame —
|
|
221
|
+
* the shape streaming code needs to exercise incremental assembly.
|
|
222
|
+
*
|
|
223
|
+
* ```ts
|
|
224
|
+
* server.register('/api/chat', {
|
|
225
|
+
* chunks: mockTextChunks('Hello, world!', { chunkSize: 5 }),
|
|
226
|
+
* });
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
declare function mockTextChunks(text: string, opts?: MockTextChunksOptions): string[];
|
|
230
|
+
/**
|
|
231
|
+
* Tool-call frames for `/api/chat` — the documented native shape: tool calls
|
|
232
|
+
* arrive **complete in one frame** (parallel calls = one array with several
|
|
233
|
+
* entries), followed by a final `done` frame. Pair with an `Agent` or tool
|
|
234
|
+
* loop for deterministic tool-execution tests.
|
|
235
|
+
*
|
|
236
|
+
* ```ts
|
|
237
|
+
* // Parallel tool calls, one round trip:
|
|
238
|
+
* server.register('/api/chat', {
|
|
239
|
+
* chunks: mockToolCallChunks([
|
|
240
|
+
* { name: 'get_weather', arguments: { city: 'Tokyo' } },
|
|
241
|
+
* { name: 'get_time', arguments: { tz: 'Asia/Tokyo' } },
|
|
242
|
+
* ]),
|
|
243
|
+
* });
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
declare function mockToolCallChunks(toolCalls: readonly MockToolCallSpec[], opts?: MockToolCallChunksOptions): string[];
|
|
247
|
+
/**
|
|
248
|
+
* Lightweight in-memory Ollama mock server.
|
|
249
|
+
*
|
|
250
|
+
* Spawns a real `node:http` server on a free port (or a fixed port if you
|
|
251
|
+
* pass one). Tests register route handlers via `register()`, then point an
|
|
252
|
+
* `OllamaClient` at `server.baseUrl`. The server emits the registered chunks
|
|
253
|
+
* with the configured delay, status, and connection behavior; when no route
|
|
254
|
+
* is registered the built-in liveness + version defaults answer (see
|
|
255
|
+
* {@link MockServerOptions.defaultRoutes}).
|
|
256
|
+
*
|
|
257
|
+
* Prefer the {@link createMockOllamaServer} factory; the class is exported
|
|
258
|
+
* for typed references and back-compatibility with the SDK's own suite.
|
|
259
|
+
*
|
|
260
|
+
* The server is **single-process** — it doesn't fork or use workers. It's
|
|
261
|
+
* intended for unit/integration tests, not load testing.
|
|
262
|
+
*/
|
|
263
|
+
declare class OllamaMockServer {
|
|
264
|
+
private readonly server;
|
|
265
|
+
private readonly routes;
|
|
266
|
+
private readonly requestLog;
|
|
267
|
+
private readonly defaultsEnabled;
|
|
268
|
+
private readonly defaultVersion;
|
|
269
|
+
private port;
|
|
270
|
+
/**
|
|
271
|
+
* @param portOrOptions Either a TCP port (pass `0` for an ephemeral port —
|
|
272
|
+
* recommended for parallel test runs) or a {@link MockServerOptions}
|
|
273
|
+
* object. The actual port is exposed via {@link baseUrl} after
|
|
274
|
+
* {@link start}.
|
|
275
|
+
*/
|
|
276
|
+
constructor(portOrOptions?: number | MockServerOptions);
|
|
277
|
+
/**
|
|
278
|
+
* Register a route handler. Replaces any existing handler for the same
|
|
279
|
+
* path (including a built-in default). The path is matched against
|
|
280
|
+
* `req.url` exactly (query string stripped, no template matching — for
|
|
281
|
+
* `/api/blobs/{digest}`, register each specific digest value).
|
|
282
|
+
*/
|
|
283
|
+
register(path: string, options: MockEndpointOptions): void;
|
|
284
|
+
/** Clear all registered routes (built-in defaults remain). Useful in `beforeEach` hooks. */
|
|
285
|
+
clear(): void;
|
|
286
|
+
/**
|
|
287
|
+
* Every request this server has served, in arrival order — method, path,
|
|
288
|
+
* parsed body, timestamp — including 404s and default-route hits. Assert
|
|
289
|
+
* on what the client actually put on the wire; clear with
|
|
290
|
+
* {@link clearRequests} (e.g. in `beforeEach`).
|
|
291
|
+
*/
|
|
292
|
+
get requests(): readonly MockRequestRecord[];
|
|
293
|
+
/** Empty the request log ({@link requests}). Routes are unaffected. */
|
|
294
|
+
clearRequests(): void;
|
|
295
|
+
/**
|
|
296
|
+
* Base URL to point an `OllamaClient` at. Only meaningful after
|
|
297
|
+
* {@link start} has resolved.
|
|
298
|
+
*/
|
|
299
|
+
get baseUrl(): string;
|
|
300
|
+
/**
|
|
301
|
+
* Start listening. Resolves once the server is ready to accept
|
|
302
|
+
* connections.
|
|
303
|
+
*/
|
|
304
|
+
start(): Promise<void>;
|
|
305
|
+
/**
|
|
306
|
+
* Stop listening and close all active connections. Resolves once the
|
|
307
|
+
* server is fully closed (no lingering sockets). Idempotent — calling
|
|
308
|
+
* `stop()` on an already-stopped server resolves cleanly rather than
|
|
309
|
+
* throwing `ERR_SERVER_NOT_RUNNING`.
|
|
310
|
+
*/
|
|
311
|
+
stop(): Promise<void>;
|
|
312
|
+
/**
|
|
313
|
+
* Built-in default handler for a path, or `undefined` when defaults are
|
|
314
|
+
* disabled or the path isn't one of the official liveness surfaces.
|
|
315
|
+
* Registered routes are checked first and always win.
|
|
316
|
+
*/
|
|
317
|
+
private defaultRouteFor;
|
|
318
|
+
private handleRequest;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Create an in-memory Ollama mock server — the recommended entry point for
|
|
322
|
+
* consumer test suites. Ephemeral port, built-in liveness + version
|
|
323
|
+
* defaults on, empty request log. Call `await server.start()` before
|
|
324
|
+
* pointing an `OllamaClient` at `server.baseUrl`, and `await server.stop()`
|
|
325
|
+
* in teardown.
|
|
326
|
+
*
|
|
327
|
+
* ```ts
|
|
328
|
+
* const server = createMockOllamaServer();
|
|
329
|
+
* await server.start();
|
|
330
|
+
* // defaults already answer: HEAD / (ping) and GET /api/version
|
|
331
|
+
* server.register('/api/chat', { chunks: mockTextChunks('Hi!') });
|
|
332
|
+
* ```
|
|
333
|
+
*/
|
|
334
|
+
declare function createMockOllamaServer(options?: MockServerOptions): OllamaMockServer;
|
|
335
|
+
|
|
336
|
+
export { type MockEndpointOptions, type MockRequestRecord, type MockServerOptions, type MockTextChunksOptions, type MockToolCallChunksOptions, type MockToolCallSpec, OllamaMockServer, createMockOllamaServer, mockChatChunk, mockTextChunks, mockToolCallChunks };
|